fix git bugs.

main
mula.liu 2026-04-13 20:39:35 +08:00
parent a774c398e8
commit 9f98d3f68d
13 changed files with 345 additions and 177 deletions

View File

@ -1,5 +1,5 @@
fastapi==0.110.0
uvicorn==0.27.1
uvicorn[standard]==0.27.1
docker==7.0.0
sqlmodel==0.0.16
pydantic==2.6.3

View File

@ -36,7 +36,7 @@ export function LucentTooltip({ content, children, side = 'top' }: LucentTooltip
const tooltipId = useId();
const wrapRef = useRef<HTMLSpanElement | null>(null);
const bubbleRef = useRef<HTMLSpanElement | null>(null);
const [visible, setVisible] = useState(false);
const [requestedVisible, setRequestedVisible] = useState(false);
const [layout, setLayout] = useState<TooltipLayout | null>(null);
const child = useMemo(() => {
@ -44,6 +44,7 @@ export function LucentTooltip({ content, children, side = 'top' }: LucentTooltip
return isValidElement(first) ? (first as ReactElement<{ 'aria-describedby'?: string; disabled?: boolean }>) : null;
}, [children]);
const childDisabled = Boolean(child?.props.disabled);
const visible = requestedVisible && !childDisabled;
const updatePosition = useCallback(() => {
const wrap = wrapRef.current;
@ -95,20 +96,17 @@ export function LucentTooltip({ content, children, side = 'top' }: LucentTooltip
}, [side]);
useLayoutEffect(() => {
if (!visible) {
setLayout(null);
return;
}
if (!visible) return;
updatePosition();
}, [updatePosition, visible, text]);
useEffect(() => {
if (!visible) return;
const handleWindowChange = () => updatePosition();
const handleDismiss = () => setVisible(false);
const handleDismiss = () => setRequestedVisible(false);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setVisible(false);
setRequestedVisible(false);
}
};
window.addEventListener('scroll', handleWindowChange, true);
@ -125,12 +123,6 @@ export function LucentTooltip({ content, children, side = 'top' }: LucentTooltip
};
}, [updatePosition, visible]);
useEffect(() => {
if (childDisabled) {
setVisible(false);
}
}, [childDisabled]);
if (!text) return <>{children}</>;
const enhancedChild = child
@ -144,19 +136,23 @@ export function LucentTooltip({ content, children, side = 'top' }: LucentTooltip
<span
ref={wrapRef}
className="lucent-tooltip-wrap"
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
onPointerDownCapture={() => setVisible(false)}
onClickCapture={() => setVisible(false)}
onFocusCapture={() => setVisible(true)}
onMouseEnter={() => {
if (!childDisabled) setRequestedVisible(true);
}}
onMouseLeave={() => setRequestedVisible(false)}
onPointerDownCapture={() => setRequestedVisible(false)}
onClickCapture={() => setRequestedVisible(false)}
onFocusCapture={() => {
if (!childDisabled) setRequestedVisible(true);
}}
onKeyDownCapture={(event) => {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'Escape') {
setVisible(false);
setRequestedVisible(false);
}
}}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setVisible(false);
setRequestedVisible(false);
}
}}
>

View File

@ -21,8 +21,15 @@ interface SkillMarketInstallModalProps {
}
export function SkillMarketInstallModal({
isZh,
open,
...props
}: SkillMarketInstallModalProps) {
if (!open) return null;
return <SkillMarketInstallModalContent {...props} />;
}
function SkillMarketInstallModalContent({
isZh,
items,
loading,
installingId,
@ -30,24 +37,24 @@ export function SkillMarketInstallModal({
onRefresh,
onInstall,
formatBytes,
}: SkillMarketInstallModalProps) {
}: Omit<SkillMarketInstallModalProps, 'open'>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() => readCachedPlatformPageSize(10));
useEffect(() => {
if (!open) return;
setSearch('');
setPage(1);
let cancelled = false;
void onRefresh();
void (async () => {
setPageSize(await fetchPreferredPlatformPageSize(10));
const nextPageSize = await fetchPreferredPlatformPageSize(10);
if (!cancelled) {
setPageSize(nextPageSize);
}
})();
}, [open]);
useEffect(() => {
setPage(1);
}, [search, pageSize]);
return () => {
cancelled = true;
};
}, [onRefresh]);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase();
@ -66,8 +73,6 @@ export function SkillMarketInstallModal({
[currentPage, filteredItems, pageSize],
);
if (!open) return null;
return (
<ModalCardShell
cardClassName="modal-wide platform-modal skill-market-browser-shell"
@ -89,8 +94,14 @@ export function SkillMarketInstallModal({
<ProtectedSearchInput
className="platform-searchbar skill-market-search"
value={search}
onChange={setSearch}
onClear={() => setSearch('')}
onChange={(value) => {
setSearch(value);
setPage(1);
}}
onClear={() => {
setSearch('');
setPage(1);
}}
autoFocus
debounceMs={120}
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}

View File

@ -24,11 +24,43 @@ interface PromptApi {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
export interface ChannelManagerLabels {
channelSaved: string;
channelSaveFail: string;
channelAddFail: string;
channelDeleted: string;
channelDeleteConfirm: (channelType: string) => string;
channelDeleteFail: string;
channels: string;
}
export interface GlobalDeliveryState {
sendProgress: boolean;
sendToolHints: boolean;
}
interface ApiErrorDetail {
detail?: string;
}
function resolveApiErrorMessage(error: unknown, fallback: string): string {
if (axios.isAxiosError<ApiErrorDetail>(error)) {
const detail = String(error.response?.data?.detail || '').trim();
if (detail) return detail;
const message = String(error.message || '').trim();
if (message) return message;
} else if (error instanceof Error) {
const message = String(error.message || '').trim();
if (message) return message;
}
return fallback;
}
interface ChannelManagerDeps extends PromptApi {
selectedBotId: string;
selectedBotDockerStatus: string;
t: any;
currentGlobalDelivery: { sendProgress: boolean; sendToolHints: boolean };
t: ChannelManagerLabels;
currentGlobalDelivery: GlobalDeliveryState;
addableChannelTypes: ChannelType[];
currentNewChannelDraft: BotChannel;
refresh: () => Promise<void>;
@ -42,9 +74,7 @@ interface ChannelManagerDeps extends PromptApi {
setNewChannelDraft: (value: BotChannel | ((prev: BotChannel) => BotChannel)) => void;
setIsSavingChannel: (value: boolean) => void;
setGlobalDelivery: (
value:
| { sendProgress: boolean; sendToolHints: boolean }
| ((prev: { sendProgress: boolean; sendToolHints: boolean }) => { sendProgress: boolean; sendToolHints: boolean })
value: GlobalDeliveryState | ((prev: GlobalDeliveryState) => GlobalDeliveryState)
) => void;
setIsSavingGlobalDelivery: (value: boolean) => void;
}
@ -163,8 +193,8 @@ export function createChannelManager({
});
await loadChannels(selectedBotId);
notify(t.channelSaved, { tone: 'success' });
} catch (error: any) {
const message = error?.response?.data?.detail || t.channelSaveFail;
} catch (error: unknown) {
const message = resolveApiErrorMessage(error, t.channelSaveFail);
notify(message, { tone: 'error' });
} finally {
setIsSavingChannel(false);
@ -188,8 +218,8 @@ export function createChannelManager({
await loadChannels(selectedBotId);
setNewChannelPanelOpen(false);
resetNewChannelDraft();
} catch (error: any) {
const message = error?.response?.data?.detail || t.channelAddFail;
} catch (error: unknown) {
const message = resolveApiErrorMessage(error, t.channelAddFail);
notify(message, { tone: 'error' });
} finally {
setIsSavingChannel(false);
@ -209,8 +239,8 @@ export function createChannelManager({
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/channels/${channel.id}`);
await loadChannels(selectedBotId);
notify(t.channelDeleted, { tone: 'success' });
} catch (error: any) {
const message = error?.response?.data?.detail || t.channelDeleteFail;
} catch (error: unknown) {
const message = resolveApiErrorMessage(error, t.channelDeleteFail);
notify(message, { tone: 'error' });
} finally {
setIsSavingChannel(false);
@ -235,8 +265,8 @@ export function createChannelManager({
}
await refresh();
notify(t.channelSaved, { tone: 'success' });
} catch (error: any) {
const message = error?.response?.data?.detail || t.channelSaveFail;
} catch (error: unknown) {
const message = resolveApiErrorMessage(error, t.channelSaveFail);
notify(message, { tone: 'error' });
} finally {
setIsSavingGlobalDelivery(false);

View File

@ -21,6 +21,7 @@ import { useDashboardSupportData } from './useDashboardSupportData';
import { useDashboardSystemDefaults } from './useDashboardSystemDefaults';
import { useDashboardTemplateManager } from './useDashboardTemplateManager';
import { useDashboardVoiceInput } from './useDashboardVoiceInput';
import { loadComposerDraft } from '../utils';
export function useBotDashboardModule({
forcedBotId,
@ -380,6 +381,7 @@ export function useBotDashboardModule({
selectedBotDockerStatus: selectedBot?.docker_status || '',
workspaceDownloadExtensions,
refreshAttachmentPolicy,
restorePendingAttachments: (botId) => loadComposerDraft(botId)?.attachments || [],
notify,
t,
isZh,

View File

@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { channelsEn } from '../../../i18n/channels.en';
import type { BotState } from '../../../types/bot';
import { optionalChannelTypes } from '../constants';
import { createChannelManager } from '../config-managers/channelManager';
import { createChannelManager, type ChannelManagerLabels, type GlobalDeliveryState } from '../config-managers/channelManager';
import type { BotChannel, WeixinLoginStatus } from '../types';
type PromptTone = 'info' | 'success' | 'warning' | 'error';
@ -29,13 +31,28 @@ interface UseDashboardChannelConfigOptions {
passwordToggleLabels: { show: string; hide: string };
refresh: () => Promise<void>;
reloginWeixin: () => Promise<void>;
selectedBot?: any;
selectedBot?: Pick<BotState, 'id' | 'docker_status' | 'send_progress' | 'send_tool_hints'> | null;
selectedBotId: string;
t: any;
lc: any;
t: ChannelManagerLabels & { cancel: string; close: string };
lc: typeof channelsEn;
weixinLoginStatus: WeixinLoginStatus | null;
}
const EMPTY_GLOBAL_DELIVERY: GlobalDeliveryState = {
sendProgress: false,
sendToolHints: false,
};
function readBotGlobalDelivery(
bot?: Pick<BotState, 'send_progress' | 'send_tool_hints'> | null,
): GlobalDeliveryState {
if (!bot) return EMPTY_GLOBAL_DELIVERY;
return {
sendProgress: Boolean(bot.send_progress),
sendToolHints: Boolean(bot.send_tool_hints),
};
}
export function useDashboardChannelConfig({
closeRuntimeMenu,
confirm,
@ -69,16 +86,27 @@ export function useDashboardChannelConfig({
});
const [isSavingChannel, setIsSavingChannel] = useState(false);
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
sendProgress: false,
sendToolHints: false,
});
const [globalDeliveryDraftByBot, setGlobalDeliveryDraftByBot] = useState<Record<string, GlobalDeliveryState>>({});
const addableChannelTypes = useMemo(() => {
const exists = new Set(channels.map((channel) => String(channel.channel_type).toLowerCase()));
return optionalChannelTypes.filter((type) => !exists.has(type));
}, [channels]);
const globalDelivery = useMemo(() => {
if (!selectedBotId || !selectedBot) return EMPTY_GLOBAL_DELIVERY;
return globalDeliveryDraftByBot[selectedBotId] ?? readBotGlobalDelivery(selectedBot);
}, [globalDeliveryDraftByBot, selectedBot, selectedBotId]);
const setGlobalDelivery = useCallback((value: SetStateAction<GlobalDeliveryState>) => {
if (!selectedBotId) return;
setGlobalDeliveryDraftByBot((prev) => {
const currentValue = prev[selectedBotId] ?? readBotGlobalDelivery(selectedBot);
const nextValue = typeof value === 'function' ? value(currentValue) : value;
return { ...prev, [selectedBotId]: nextValue };
});
}, [selectedBot, selectedBotId]);
const {
resetNewChannelDraft,
channelDraftUiKey,
@ -112,17 +140,6 @@ export function useDashboardChannelConfig({
setIsSavingGlobalDelivery,
});
useEffect(() => {
if (!selectedBotId || !selectedBot) {
setGlobalDelivery({ sendProgress: false, sendToolHints: false });
return;
}
setGlobalDelivery({
sendProgress: Boolean(selectedBot.send_progress),
sendToolHints: Boolean(selectedBot.send_tool_hints),
});
}, [selectedBot, selectedBotId]);
useEffect(() => {
const onPointerDown = (event: MouseEvent) => {
if (channelCreateMenuRef.current && !channelCreateMenuRef.current.contains(event.target as Node)) {
@ -155,7 +172,7 @@ export function useDashboardChannelConfig({
setNewChannelPanelOpen(false);
setChannelCreateMenuOpen(false);
resetNewChannelDraft();
setGlobalDelivery({ sendProgress: false, sendToolHints: false });
setGlobalDeliveryDraftByBot({});
}, [resetNewChannelDraft]);
const channelConfigModalProps = {

View File

@ -7,7 +7,7 @@ import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../.
import type { QuotedReply, StagedSubmissionDraft } from '../types';
import type { DashboardChatNotifyOptions } from './dashboardChatShared';
interface ChatCommandDispatchLabels {
export interface ChatCommandDispatchLabels {
attachmentMessage: string;
quoteOnlyMessage: string;
backendDeliverFail: string;

View File

@ -1,12 +1,12 @@
import { useEffect, useRef, useState, type Dispatch, type KeyboardEvent, type SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type KeyboardEvent, type SetStateAction } from 'react';
import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../../shared/text/messageText';
import type { QuotedReply } from '../types';
import { loadComposerDraft, persistComposerDraft } from '../utils';
import type { DashboardChatNotifyOptions } from './dashboardChatShared';
import { useDashboardChatCommandDispatch } from './useDashboardChatCommandDispatch';
import { useDashboardChatStaging } from './useDashboardChatStaging';
import { useDashboardChatCommandDispatch, type ChatCommandDispatchLabels } from './useDashboardChatCommandDispatch';
import { useDashboardChatStaging, type StagedSubmissionLabels } from './useDashboardChatStaging';
const COMPOSER_MIN_ROWS = 3;
const COMPOSER_MAX_HEIGHT_PX = 220;
@ -25,7 +25,15 @@ interface UseDashboardChatComposerOptions {
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void;
scrollConversationToBottom: (behavior?: ScrollBehavior) => void;
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
t: any;
t: DashboardChatComposerLabels;
}
interface DashboardChatComposerLabels extends ChatCommandDispatchLabels, StagedSubmissionLabels {
copyPromptDone: string;
copyPromptFail: string;
editPromptDone: string;
copyReplyDone: string;
copyReplyFail: string;
}
export function useDashboardChatComposer({
@ -44,16 +52,40 @@ export function useDashboardChatComposer({
notify,
t,
}: UseDashboardChatComposerOptions) {
const [command, setCommand] = useState('');
const [composerDraftHydrated, setComposerDraftHydrated] = useState(false);
const [quotedReply, setQuotedReply] = useState<QuotedReply | null>(null);
const [commandByBot, setCommandByBot] = useState<Record<string, string>>({});
const [quotedReplyByBot, setQuotedReplyByBot] = useState<Record<string, QuotedReply | null>>({});
const filePickerRef = useRef<HTMLInputElement | null>(null);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const stagedAutoSubmitAttemptByBotRef = useRef<Record<string, string>>({});
const persistedComposerDraft = useMemo(
() => (selectedBotId ? loadComposerDraft(selectedBotId) : null),
[selectedBotId],
);
const command = selectedBotId ? (commandByBot[selectedBotId] ?? persistedComposerDraft?.command ?? '') : '';
const quotedReply = selectedBotId ? (quotedReplyByBot[selectedBotId] ?? null) : null;
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
const setCommand = useCallback((value: SetStateAction<string>) => {
if (!selectedBotId) return;
setCommandByBot((prev) => {
const currentValue = prev[selectedBotId] ?? persistedComposerDraft?.command ?? '';
const nextValue = typeof value === 'function' ? value(currentValue) : value;
return { ...prev, [selectedBotId]: nextValue };
});
}, [persistedComposerDraft?.command, selectedBotId]);
const setQuotedReply = useCallback((value: SetStateAction<QuotedReply | null>) => {
if (!selectedBotId) return;
setQuotedReplyByBot((prev) => {
const currentValue = prev[selectedBotId] ?? null;
const nextValue = typeof value === 'function' ? value(currentValue) : value;
return { ...prev, [selectedBotId]: nextValue };
});
}, [selectedBotId]);
const {
completeLeadingStagedSubmission,
nextQueuedSubmission,
@ -109,27 +141,9 @@ export function useDashboardChatComposer({
: 'send';
useEffect(() => {
setComposerDraftHydrated(false);
if (!selectedBotId) {
setCommand('');
setPendingAttachments([]);
setComposerDraftHydrated(true);
return;
}
const draft = loadComposerDraft(selectedBotId);
setCommand(draft?.command || '');
setPendingAttachments(draft?.attachments || []);
setComposerDraftHydrated(true);
}, [selectedBotId, setPendingAttachments]);
useEffect(() => {
if (!selectedBotId || !composerDraftHydrated) return;
if (!selectedBotId) return;
persistComposerDraft(selectedBotId, command, pendingAttachments);
}, [selectedBotId, composerDraftHydrated, command, pendingAttachments]);
useEffect(() => {
setQuotedReply(null);
}, [selectedBotId]);
}, [selectedBotId, command, pendingAttachments]);
useEffect(() => {
const textarea = composerTextareaRef.current;

View File

@ -13,7 +13,12 @@ interface UseDashboardChatStagingOptions {
setQuotedReply: Dispatch<SetStateAction<QuotedReply | null>>;
composerTextareaRef: RefObject<HTMLTextAreaElement | null>;
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
t: any;
t: StagedSubmissionLabels;
}
export interface StagedSubmissionLabels {
stagedSubmissionQueued: string;
stagedSubmissionRestored: string;
}
export function useDashboardChatStaging({

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { sortBotsByCreatedAtDesc } from '../../../shared/bot/sortBots';
import type { BotState } from '../../../types/bot';
@ -13,6 +13,10 @@ interface UseDashboardShellStateOptions {
onCompactPanelTabChange?: (tab: CompactPanelTab) => void;
}
function resolveStateAction<T>(next: SetStateAction<T>, prev: T): T {
return typeof next === 'function' ? (next as (prevState: T) => T)(prev) : next;
}
export function useDashboardShellState({
activeBots,
botListPageSize,
@ -21,17 +25,17 @@ export function useDashboardShellState({
forcedBotId,
onCompactPanelTabChange,
}: UseDashboardShellStateOptions) {
const [selectedBotId, setSelectedBotId] = useState('');
const [selectedBotId, setSelectedBotIdState] = useState('');
const [runtimeViewMode, setRuntimeViewMode] = useState<RuntimeViewMode>('visual');
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
const [botListMenuOpen, setBotListMenuOpen] = useState(false);
const [runtimeMenuBotId, setRuntimeMenuBotId] = useState<string | null>(null);
const [botListMenuOpen, setBotListMenuOpenState] = useState(false);
const [topicDetailOpen, setTopicDetailOpen] = useState(false);
const [compactPanelTabState, setCompactPanelTabState] = useState<CompactPanelTab>('chat');
const [isCompactMobile, setIsCompactMobile] = useState(false);
const [botListQuery, setBotListQuery] = useState('');
const [botListPage, setBotListPage] = useState(1);
const [isCompactMobileState, setIsCompactMobileState] = useState(false);
const [botListQuery, setBotListQueryState] = useState('');
const [botListPageState, setBotListPageState] = useState(1);
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
const [controlCommandPanelOpen, setControlCommandPanelOpen] = useState(false);
const [controlCommandPanelBotId, setControlCommandPanelBotId] = useState<string | null>(null);
const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
const botListMenuRef = useRef<HTMLDivElement | null>(null);
@ -44,20 +48,37 @@ export function useDashboardShellState({
const hasForcedBot = Boolean(String(forcedBotId || '').trim());
const compactListFirstMode = compactMode && !hasForcedBot;
const isCompactListPage = compactListFirstMode && !selectedBotId;
const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId);
const compactPanelTab = compactPanelTabProp ?? compactPanelTabState;
const compactPanelTab = compactMode ? (compactPanelTabProp ?? compactPanelTabState) : 'chat';
const setCompactPanelTab = useCallback(
(next: CompactPanelTab | ((prev: CompactPanelTab) => CompactPanelTab)) => {
const resolved = typeof next === 'function' ? next(compactPanelTab) : next;
const resolved = resolveStateAction(next, compactPanelTabProp ?? compactPanelTabState);
if (compactPanelTabProp === undefined) {
setCompactPanelTabState(resolved);
}
onCompactPanelTabChange?.(resolved);
},
[compactPanelTab, compactPanelTabProp, onCompactPanelTabChange],
[compactPanelTabProp, compactPanelTabState, onCompactPanelTabChange],
);
const showBotListPanel = !hasForcedBot && (!compactMode || isCompactListPage);
const setBotListQuery = useCallback((next: SetStateAction<string>) => {
setBotListQueryState((prev) => resolveStateAction(next, prev));
setBotListPageState(1);
}, []);
const setBotListPage = useCallback((next: SetStateAction<number>) => {
setBotListPageState((prev) => {
const resolved = Number(resolveStateAction(next, prev));
return Number.isFinite(resolved) ? Math.max(1, Math.trunc(resolved)) : 1;
});
}, []);
const setSelectedBotId = useCallback((next: SetStateAction<string>) => {
setSelectedBotIdState((prev) => String(resolveStateAction(next, prev) || '').trim());
setControlCommandPanelBotId(null);
setRuntimeMenuBotId(null);
setBotListMenuOpenState(false);
}, []);
const normalizedForcedBotId = String(forcedBotId || '').trim();
const normalizedBotListQuery = botListQuery.trim().toLowerCase();
const filteredBots = useMemo(() => {
if (!normalizedBotListQuery) return bots;
@ -67,66 +88,66 @@ export function useDashboardShellState({
return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery);
});
}, [bots, normalizedBotListQuery]);
const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]);
useEffect(() => {
setBotListPage(1);
}, [normalizedBotListQuery]);
const forcedBotMissing = Boolean(normalizedForcedBotId && bots.length > 0 && !activeBots[normalizedForcedBotId]);
const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / botListPageSize));
const botListPage = Math.min(Math.max(1, botListPageState), botListTotalPages);
const pagedBots = useMemo(() => {
const page = Math.min(Math.max(1, botListPage), botListTotalPages);
const start = (page - 1) * botListPageSize;
return filteredBots.slice(start, start + botListPageSize);
}, [botListPage, botListPageSize, botListTotalPages, filteredBots]);
const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined;
useEffect(() => {
setBotListPage((prev) => Math.min(Math.max(prev, 1), botListTotalPages));
}, [botListTotalPages]);
useEffect(() => {
const forced = String(forcedBotId || '').trim();
if (forced) {
if (activeBots[forced]) {
if (selectedBotId !== forced) setSelectedBotId(forced);
} else if (selectedBotId) {
setSelectedBotId('');
}
return;
const selectedBotIdResolved = useMemo(() => {
if (normalizedForcedBotId) {
return activeBots[normalizedForcedBotId] ? normalizedForcedBotId : '';
}
if (compactListFirstMode) {
if (selectedBotId && !activeBots[selectedBotId]) {
setSelectedBotId('');
}
return;
return selectedBotId && activeBots[selectedBotId] ? selectedBotId : '';
}
if (!selectedBotId && bots.length > 0) setSelectedBotId(bots[0].id);
if (selectedBotId && !activeBots[selectedBotId] && bots.length > 0) setSelectedBotId(bots[0].id);
}, [activeBots, bots, compactListFirstMode, forcedBotId, selectedBotId]);
useEffect(() => {
setControlCommandPanelOpen(false);
}, [selectedBotId]);
useEffect(() => {
setRuntimeMenuOpen(false);
setBotListMenuOpen(false);
}, [selectedBotId]);
useEffect(() => {
if (!compactMode) {
setIsCompactMobile(false);
setCompactPanelTab('chat');
return;
if (selectedBotId && activeBots[selectedBotId]) {
return selectedBotId;
}
return bots[0]?.id || '';
}, [activeBots, bots, compactListFirstMode, normalizedForcedBotId, selectedBotId]);
const isCompactListPage = compactListFirstMode && !selectedBotIdResolved;
const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotIdResolved);
const showBotListPanel = !hasForcedBot && (!compactMode || isCompactListPage);
const selectedBot = selectedBotIdResolved ? activeBots[selectedBotIdResolved] : undefined;
const runtimeMenuOpen = Boolean(selectedBotIdResolved && runtimeMenuBotId === selectedBotIdResolved);
const controlCommandPanelOpen = Boolean(
selectedBotIdResolved && controlCommandPanelBotId === selectedBotIdResolved,
);
const setRuntimeMenuOpen = useCallback((next: SetStateAction<boolean>) => {
setRuntimeMenuBotId((prev) => {
const prevOpen = Boolean(selectedBotIdResolved && prev === selectedBotIdResolved);
const resolved = resolveStateAction(next, prevOpen);
return resolved && selectedBotIdResolved ? selectedBotIdResolved : null;
});
}, [selectedBotIdResolved]);
const setControlCommandPanelOpen = useCallback((next: SetStateAction<boolean>) => {
setControlCommandPanelBotId((prev) => {
const prevOpen = Boolean(selectedBotIdResolved && prev === selectedBotIdResolved);
const resolved = resolveStateAction(next, prevOpen);
return resolved && selectedBotIdResolved ? selectedBotIdResolved : null;
});
}, [selectedBotIdResolved]);
const setBotListMenuOpen = useCallback((next: SetStateAction<boolean>) => {
setBotListMenuOpenState((prev) => resolveStateAction(next, prev));
}, []);
useEffect(() => {
if (!compactMode) return;
const media = window.matchMedia('(max-width: 980px)');
const apply = () => setIsCompactMobile(media.matches);
const apply = () => setIsCompactMobileState(media.matches);
apply();
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}, [compactMode, setCompactPanelTab]);
}, [compactMode]);
return {
botListMenuOpen,
@ -143,20 +164,20 @@ export function useDashboardShellState({
forcedBotMissing,
hasForcedBot,
isCompactListPage,
isCompactMobile,
isCompactMobile: compactMode && isCompactMobileState,
normalizedBotListQuery,
pagedBots,
runtimeMenuOpen,
runtimeMenuRef,
runtimeViewMode,
selectedBot,
selectedBotId,
selectedBotId: selectedBotIdResolved,
setBotListMenuOpen,
setBotListPage,
setBotListQuery,
setCompactPanelTab,
setControlCommandPanelOpen,
setIsCompactMobile,
setIsCompactMobile: setIsCompactMobileState,
setRuntimeMenuOpen,
setRuntimeViewMode,
setSelectedBotId,

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw, Terminal } from 'lucide-react';
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
@ -42,10 +42,7 @@ export function PlatformBotRuntimeSection({
const dashboardT = pickLocale(isZh ? 'zh' : 'en', { 'zh-cn': dashboardZhCn, en: dashboardEn });
const dockerLogsCardRef = useRef<HTMLDivElement | null>(null);
const [workspaceCardHeightPx, setWorkspaceCardHeightPx] = useState<number | null>(null);
const workspaceSearchInputName = useMemo(
() => `platform-workspace-search-${Math.random().toString(36).slice(2, 10)}`,
[],
);
const workspaceSearchInputName = `platform-workspace-search-${useId().replace(/:/g, '-')}`;
const effectivePageSize = Math.max(1, Math.trunc(pageSize || 10));
const dockerLogsTableHeightPx = DOCKER_LOG_TABLE_HEADER_HEIGHT + effectivePageSize * DOCKER_LOG_TABLE_ROW_HEIGHT;
const workspaceCardStyle = useMemo(

View File

@ -14,16 +14,38 @@ import {
workspaceFileAction,
} from './utils';
import type { WorkspaceAttachmentPolicySnapshot, WorkspaceNotifyOptions } from './workspaceShared';
import { useWorkspaceAttachments } from './useWorkspaceAttachments';
import { useWorkspaceAttachments, type WorkspaceAttachmentLabels } from './useWorkspaceAttachments';
import { useWorkspacePreview } from './useWorkspacePreview';
interface WorkspaceTreeLabels {
workspaceLoadFail: string;
}
interface ApiErrorDetail {
detail?: string;
}
function resolveApiErrorMessage(error: unknown, fallback: string): string {
if (axios.isAxiosError<ApiErrorDetail>(error)) {
const detail = String(error.response?.data?.detail || '').trim();
if (detail) return detail;
const message = String(error.message || '').trim();
if (message) return message;
} else if (error instanceof Error) {
const message = String(error.message || '').trim();
if (message) return message;
}
return fallback;
}
interface UseBotWorkspaceOptions {
selectedBotId: string;
selectedBotDockerStatus?: string;
workspaceDownloadExtensions: string[];
refreshAttachmentPolicy: () => Promise<WorkspaceAttachmentPolicySnapshot>;
restorePendingAttachments?: (botId: string) => string[];
notify: (message: string, options?: WorkspaceNotifyOptions) => void;
t: any;
t: WorkspaceTreeLabels & WorkspaceAttachmentLabels & Record<string, unknown>;
isZh: boolean;
fileNotPreviewableLabel: string;
}
@ -33,6 +55,7 @@ export function useBotWorkspace({
selectedBotDockerStatus,
workspaceDownloadExtensions,
refreshAttachmentPolicy,
restorePendingAttachments,
notify,
t,
isZh,
@ -98,12 +121,12 @@ export function useBotWorkspace({
setWorkspaceSearchEntries([]);
setWorkspaceCurrentPath(res.data?.cwd || '');
setWorkspaceParentPath(res.data?.parent ?? null);
} catch (error: any) {
} catch (error: unknown) {
setWorkspaceEntries([]);
setWorkspaceSearchEntries([]);
setWorkspaceCurrentPath('');
setWorkspaceParentPath(null);
setWorkspaceError(error?.response?.data?.detail || t.workspaceLoadFail);
setWorkspaceError(resolveApiErrorMessage(error, t.workspaceLoadFail));
} finally {
setWorkspaceLoading(false);
}
@ -174,6 +197,7 @@ export function useBotWorkspace({
workspaceCurrentPath,
loadWorkspaceTree,
refreshAttachmentPolicy,
restorePendingAttachments,
notify,
t,
});

View File

@ -1,4 +1,4 @@
import { useCallback, useState, type ChangeEvent } from 'react';
import { useCallback, useMemo, useState, type ChangeEvent, type SetStateAction } from 'react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../config/env';
@ -6,13 +6,37 @@ import type { WorkspaceUploadResponse } from './types';
import { isMediaUploadFile, normalizeAttachmentPaths } from './utils';
import type { WorkspaceAttachmentPolicySnapshot, WorkspaceNotifyOptions } from './workspaceShared';
export interface WorkspaceAttachmentLabels {
uploadTypeNotAllowed: (files: string, allowed: string) => string;
uploadTooLarge: (files: string, limitMb: number) => string;
uploadFail: string;
}
interface ApiErrorDetail {
detail?: string;
}
function resolveApiErrorMessage(error: unknown, fallback: string): string {
if (axios.isAxiosError<ApiErrorDetail>(error)) {
const detail = String(error.response?.data?.detail || '').trim();
if (detail) return detail;
const message = String(error.message || '').trim();
if (message) return message;
} else if (error instanceof Error) {
const message = String(error.message || '').trim();
if (message) return message;
}
return fallback;
}
interface UseWorkspaceAttachmentsOptions {
selectedBotId: string;
workspaceCurrentPath: string;
loadWorkspaceTree: (botId: string, path?: string) => Promise<void>;
refreshAttachmentPolicy: () => Promise<WorkspaceAttachmentPolicySnapshot>;
restorePendingAttachments?: (botId: string) => string[];
notify: (message: string, options?: WorkspaceNotifyOptions) => void;
t: any;
t: WorkspaceAttachmentLabels;
}
export function useWorkspaceAttachments({
@ -20,18 +44,45 @@ export function useWorkspaceAttachments({
workspaceCurrentPath,
loadWorkspaceTree,
refreshAttachmentPolicy,
restorePendingAttachments,
notify,
t,
}: UseWorkspaceAttachmentsOptions) {
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
const [pendingAttachmentsByBot, setPendingAttachmentsByBot] = useState<Record<string, string[]>>({});
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null);
const restoredPendingAttachments = useMemo(
() => (selectedBotId ? normalizeAttachmentPaths(restorePendingAttachments?.(selectedBotId) ?? []) : []),
[restorePendingAttachments, selectedBotId],
);
const pendingAttachments = useMemo(
() => (selectedBotId ? pendingAttachmentsByBot[selectedBotId] ?? restoredPendingAttachments : []),
[pendingAttachmentsByBot, restoredPendingAttachments, selectedBotId],
);
const setPendingAttachments = useCallback((value: SetStateAction<string[]>) => {
if (!selectedBotId) return;
setPendingAttachmentsByBot((prev) => {
const currentValue = prev[selectedBotId] ?? restoredPendingAttachments;
const nextValue = typeof value === 'function' ? value(currentValue) : value;
return {
...prev,
[selectedBotId]: normalizeAttachmentPaths(nextValue),
};
});
}, [restoredPendingAttachments, selectedBotId]);
const resetPendingAttachments = useCallback(() => {
setPendingAttachments([]);
if (!selectedBotId) {
setPendingAttachmentsByBot({});
} else {
setPendingAttachmentsByBot((prev) => ({ ...prev, [selectedBotId]: [] }));
}
setIsUploadingAttachments(false);
setAttachmentUploadPercent(null);
}, []);
}, [selectedBotId]);
const onPickAttachments = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
if (!selectedBotId || !event.target.files || event.target.files.length === 0) return;
@ -121,15 +172,15 @@ export function useWorkspaceAttachments({
setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploadedPaths])));
await loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
}
} catch (error: any) {
const msg = error?.response?.data?.detail || t.uploadFail;
} catch (error: unknown) {
const msg = resolveApiErrorMessage(error, t.uploadFail);
notify(msg, { tone: 'error' });
} finally {
setIsUploadingAttachments(false);
setAttachmentUploadPercent(null);
event.target.value = '';
}
}, [loadWorkspaceTree, notify, refreshAttachmentPolicy, selectedBotId, t, workspaceCurrentPath]);
}, [loadWorkspaceTree, notify, refreshAttachmentPolicy, selectedBotId, setPendingAttachments, t, workspaceCurrentPath]);
return {
attachmentUploadPercent,