From 9f98d3f68db4132bbbdeaf58b78e8112cd5027e7 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Mon, 13 Apr 2026 20:39:35 +0800 Subject: [PATCH] fix git bugs. --- backend/requirements.txt | 2 +- .../src/components/lucent/LucentTooltip.tsx | 36 ++--- .../components/SkillMarketInstallModal.tsx | 41 +++-- .../config-managers/channelManager.ts | 56 +++++-- .../dashboard/hooks/useBotDashboardModule.ts | 2 + .../hooks/useDashboardChannelConfig.ts | 59 +++++--- .../hooks/useDashboardChatCommandDispatch.ts | 2 +- .../hooks/useDashboardChatComposer.ts | 68 +++++---- .../hooks/useDashboardChatStaging.ts | 7 +- .../dashboard/hooks/useDashboardShellState.ts | 143 ++++++++++-------- .../components/PlatformBotRuntimeSection.tsx | 7 +- .../src/shared/workspace/useBotWorkspace.ts | 32 +++- .../workspace/useWorkspaceAttachments.ts | 67 +++++++- 13 files changed, 345 insertions(+), 177 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index e89a512..9ecf5cb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/components/lucent/LucentTooltip.tsx b/frontend/src/components/lucent/LucentTooltip.tsx index eea9d93..32f8145 100644 --- a/frontend/src/components/lucent/LucentTooltip.tsx +++ b/frontend/src/components/lucent/LucentTooltip.tsx @@ -36,7 +36,7 @@ export function LucentTooltip({ content, children, side = 'top' }: LucentTooltip const tooltipId = useId(); const wrapRef = useRef(null); const bubbleRef = useRef(null); - const [visible, setVisible] = useState(false); + const [requestedVisible, setRequestedVisible] = useState(false); const [layout, setLayout] = useState(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 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); } }} > diff --git a/frontend/src/modules/dashboard/components/SkillMarketInstallModal.tsx b/frontend/src/modules/dashboard/components/SkillMarketInstallModal.tsx index cccc53a..d91bc84 100644 --- a/frontend/src/modules/dashboard/components/SkillMarketInstallModal.tsx +++ b/frontend/src/modules/dashboard/components/SkillMarketInstallModal.tsx @@ -21,8 +21,15 @@ interface SkillMarketInstallModalProps { } export function SkillMarketInstallModal({ - isZh, open, + ...props +}: SkillMarketInstallModalProps) { + if (!open) return null; + return ; +} + +function SkillMarketInstallModalContent({ + isZh, items, loading, installingId, @@ -30,24 +37,24 @@ export function SkillMarketInstallModal({ onRefresh, onInstall, formatBytes, -}: SkillMarketInstallModalProps) { +}: Omit) { 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 ( setSearch('')} + onChange={(value) => { + setSearch(value); + setPage(1); + }} + onClear={() => { + setSearch(''); + setPage(1); + }} autoFocus debounceMs={120} placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'} diff --git a/frontend/src/modules/dashboard/config-managers/channelManager.ts b/frontend/src/modules/dashboard/config-managers/channelManager.ts index 34eba32..80a84aa 100644 --- a/frontend/src/modules/dashboard/config-managers/channelManager.ts +++ b/frontend/src/modules/dashboard/config-managers/channelManager.ts @@ -24,11 +24,43 @@ interface PromptApi { confirm: (options: ConfirmOptions) => Promise; } +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(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; @@ -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); diff --git a/frontend/src/modules/dashboard/hooks/useBotDashboardModule.ts b/frontend/src/modules/dashboard/hooks/useBotDashboardModule.ts index 6d4611f..5ef2d1d 100644 --- a/frontend/src/modules/dashboard/hooks/useBotDashboardModule.ts +++ b/frontend/src/modules/dashboard/hooks/useBotDashboardModule.ts @@ -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, diff --git a/frontend/src/modules/dashboard/hooks/useDashboardChannelConfig.ts b/frontend/src/modules/dashboard/hooks/useDashboardChannelConfig.ts index 9a532b1..e9f6c11 100644 --- a/frontend/src/modules/dashboard/hooks/useDashboardChannelConfig.ts +++ b/frontend/src/modules/dashboard/hooks/useDashboardChannelConfig.ts @@ -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; reloginWeixin: () => Promise; - selectedBot?: any; + selectedBot?: Pick | 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 | 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>({}); 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) => { + 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 = { diff --git a/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts b/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts index 48d20c7..0cc0f12 100644 --- a/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts +++ b/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts @@ -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; diff --git a/frontend/src/modules/dashboard/hooks/useDashboardChatComposer.ts b/frontend/src/modules/dashboard/hooks/useDashboardChatComposer.ts index 6e9b651..e8b529e 100644 --- a/frontend/src/modules/dashboard/hooks/useDashboardChatComposer.ts +++ b/frontend/src/modules/dashboard/hooks/useDashboardChatComposer.ts @@ -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 & Pick) => 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(null); + const [commandByBot, setCommandByBot] = useState>({}); + const [quotedReplyByBot, setQuotedReplyByBot] = useState>({}); const filePickerRef = useRef(null); const composerTextareaRef = useRef(null); const stagedAutoSubmitAttemptByBotRef = useRef>({}); + 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) => { + 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) => { + 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; diff --git a/frontend/src/modules/dashboard/hooks/useDashboardChatStaging.ts b/frontend/src/modules/dashboard/hooks/useDashboardChatStaging.ts index 4e1b1fe..9ece041 100644 --- a/frontend/src/modules/dashboard/hooks/useDashboardChatStaging.ts +++ b/frontend/src/modules/dashboard/hooks/useDashboardChatStaging.ts @@ -13,7 +13,12 @@ interface UseDashboardChatStagingOptions { setQuotedReply: Dispatch>; composerTextareaRef: RefObject; notify: (message: string, options?: DashboardChatNotifyOptions) => void; - t: any; + t: StagedSubmissionLabels; +} + +export interface StagedSubmissionLabels { + stagedSubmissionQueued: string; + stagedSubmissionRestored: string; } export function useDashboardChatStaging({ diff --git a/frontend/src/modules/dashboard/hooks/useDashboardShellState.ts b/frontend/src/modules/dashboard/hooks/useDashboardShellState.ts index 419e446..516fa6c 100644 --- a/frontend/src/modules/dashboard/hooks/useDashboardShellState.ts +++ b/frontend/src/modules/dashboard/hooks/useDashboardShellState.ts @@ -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(next: SetStateAction, 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('visual'); - const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); - const [botListMenuOpen, setBotListMenuOpen] = useState(false); + const [runtimeMenuBotId, setRuntimeMenuBotId] = useState(null); + const [botListMenuOpen, setBotListMenuOpenState] = useState(false); const [topicDetailOpen, setTopicDetailOpen] = useState(false); const [compactPanelTabState, setCompactPanelTabState] = useState('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(null); const runtimeMenuRef = useRef(null); const botListMenuRef = useRef(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) => { + setBotListQueryState((prev) => resolveStateAction(next, prev)); + setBotListPageState(1); + }, []); + + const setBotListPage = useCallback((next: SetStateAction) => { + setBotListPageState((prev) => { + const resolved = Number(resolveStateAction(next, prev)); + return Number.isFinite(resolved) ? Math.max(1, Math.trunc(resolved)) : 1; + }); + }, []); + + const setSelectedBotId = useCallback((next: SetStateAction) => { + 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) => { + setRuntimeMenuBotId((prev) => { + const prevOpen = Boolean(selectedBotIdResolved && prev === selectedBotIdResolved); + const resolved = resolveStateAction(next, prevOpen); + return resolved && selectedBotIdResolved ? selectedBotIdResolved : null; + }); + }, [selectedBotIdResolved]); + + const setControlCommandPanelOpen = useCallback((next: SetStateAction) => { + setControlCommandPanelBotId((prev) => { + const prevOpen = Boolean(selectedBotIdResolved && prev === selectedBotIdResolved); + const resolved = resolveStateAction(next, prevOpen); + return resolved && selectedBotIdResolved ? selectedBotIdResolved : null; + }); + }, [selectedBotIdResolved]); + + const setBotListMenuOpen = useCallback((next: SetStateAction) => { + 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, diff --git a/frontend/src/modules/platform/components/PlatformBotRuntimeSection.tsx b/frontend/src/modules/platform/components/PlatformBotRuntimeSection.tsx index 7dd1731..a2ff1d5 100644 --- a/frontend/src/modules/platform/components/PlatformBotRuntimeSection.tsx +++ b/frontend/src/modules/platform/components/PlatformBotRuntimeSection.tsx @@ -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(null); const [workspaceCardHeightPx, setWorkspaceCardHeightPx] = useState(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( diff --git a/frontend/src/shared/workspace/useBotWorkspace.ts b/frontend/src/shared/workspace/useBotWorkspace.ts index 7af23c7..a7b06b0 100644 --- a/frontend/src/shared/workspace/useBotWorkspace.ts +++ b/frontend/src/shared/workspace/useBotWorkspace.ts @@ -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(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; + restorePendingAttachments?: (botId: string) => string[]; notify: (message: string, options?: WorkspaceNotifyOptions) => void; - t: any; + t: WorkspaceTreeLabels & WorkspaceAttachmentLabels & Record; 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, }); diff --git a/frontend/src/shared/workspace/useWorkspaceAttachments.ts b/frontend/src/shared/workspace/useWorkspaceAttachments.ts index 2baffdf..ab99e9c 100644 --- a/frontend/src/shared/workspace/useWorkspaceAttachments.ts +++ b/frontend/src/shared/workspace/useWorkspaceAttachments.ts @@ -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(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; refreshAttachmentPolicy: () => Promise; + 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([]); + const [pendingAttachmentsByBot, setPendingAttachmentsByBot] = useState>({}); const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); const [attachmentUploadPercent, setAttachmentUploadPercent] = useState(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) => { + 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) => { 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,