import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import axios from 'axios'; import { Activity, MessageSquareText, X } from 'lucide-react'; import { APP_ENDPOINTS } from '../../config/env'; import { useAppStore } from '../../store/appStore'; import { normalizeAssistantMessageText } from './messageParser'; import './BotDashboardModule.css'; import { channelsZhCn } from '../../i18n/channels.zh-cn'; import { channelsEn } from '../../i18n/channels.en'; import { pickLocale } from '../../i18n'; import { dashboardZhCn } from '../../i18n/dashboard.zh-cn'; import { dashboardEn } from '../../i18n/dashboard.en'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { LucentIconButton } from '../../components/lucent/LucentIconButton'; import { TopicFeedPanel } from './topic/TopicFeedPanel'; import { ChatDisabledMask, ChatModeRail, ConversationViewport, } from './components/ChatPanelParts'; import { ChatComposerDock } from './components/ChatComposerDock'; import { WorkspaceHoverTooltip, WorkspacePreviewModal } from './components/WorkspacePanelParts'; import { ResourceMonitorModal } from './components/RuntimePanelParts'; import { BotDashboardBotListPanel } from './components/BotDashboardBotListPanel'; import { BotDashboardConfigDrawers } from './components/BotDashboardConfigDrawers'; import { BotDashboardConversationNodes } from './components/BotDashboardConversationNodes'; import { BotDashboardRuntimePanel } from './components/BotDashboardRuntimePanel'; import { isChannelConfigured, renderBotChannelFields } from './components/BotChannelFieldEditor'; import { DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, RUNTIME_STALE_MS, clampTemperature, isPreviewableWorkspacePath, mergeConversation, normalizeRuntimeState, parseBotTimestamp, topicDraftUiKey, resolveWorkspaceDocumentPath, workspaceFileAction, } from './botDashboardShared'; import type { BotChannel, CompactPanelTab, NodeBotGroup, RuntimeViewMode, WorkspaceTreeResponse, } from './botDashboardShared'; import { useDashboardChat } from './hooks/useDashboardChat'; import { useBotDashboardConfigState } from './hooks/useBotDashboardConfigState'; import { useDashboardChannelConfig } from './hooks/useDashboardChannelConfig'; import { useDashboardMcpCronConfig } from './hooks/useDashboardMcpCronConfig'; import { useDashboardRuntimeControl } from './hooks/useDashboardRuntimeControl'; import { useDashboardSkillsEnvConfig } from './hooks/useDashboardSkillsEnvConfig'; import { useDashboardTopicConfig } from './hooks/useDashboardTopicConfig'; import { useDashboardWorkspace } from './hooks/useDashboardWorkspace'; import { readCachedPlatformPageSize, } from '../../utils/platformPageSize'; interface BotDashboardModuleProps { onOpenCreateWizard?: () => void; onOpenImageFactory?: () => void; forcedBotId?: string; forcedNodeId?: string; compactMode?: boolean; initialCompactPanelTab?: 'chat' | 'runtime'; hideCompactFab?: boolean; } export function BotDashboardModule({ onOpenCreateWizard, onOpenImageFactory, forcedBotId, forcedNodeId, compactMode = false, initialCompactPanelTab = 'chat', hideCompactFab = false, }: BotDashboardModuleProps) { const { activeBots, setBots, mergeBot, updateBotStatus, locale, addBotMessage, setBotMessages, setBotMessageFeedback, } = useAppStore(); const { notify, confirm } = useLucentPrompt(); const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable'; const [selectedBotId, setSelectedBotId] = useState(''); const [speechEnabled, setSpeechEnabled] = useState(true); const [showChannelModal, setShowChannelModal] = useState(false); const [showTopicModal, setShowTopicModal] = useState(false); const [showSkillsModal, setShowSkillsModal] = useState(false); const [showMcpModal, setShowMcpModal] = useState(false); const [showEnvParamsModal, setShowEnvParamsModal] = useState(false); const [showCronModal, setShowCronModal] = useState(false); const channelCreateMenuRef = useRef(null); const topicPresetMenuRef = useRef(null); const skillZipPickerRef = useRef(null); const skillAddMenuRef = useRef(null); const [uploadMaxMb, setUploadMaxMb] = useState(100); const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState([]); const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10)); const [botListPageSizeReady, setBotListPageSizeReady] = useState( () => readCachedPlatformPageSize(0) > 0, ); const [chatPullPageSize, setChatPullPageSize] = useState(60); const [commandAutoUnlockSeconds, setCommandAutoUnlockSeconds] = useState(10); const [workspaceDownloadExtensions, setWorkspaceDownloadExtensions] = useState( DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, ); const [runtimeViewMode, setRuntimeViewMode] = useState('visual'); const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); const [botListMenuOpen, setBotListMenuOpen] = useState(false); const [topicDetailOpen, setTopicDetailOpen] = useState(false); const [compactPanelTab, setCompactPanelTab] = useState(initialCompactPanelTab); const [isCompactMobile, setIsCompactMobile] = useState(false); const [botListQuery, setBotListQuery] = useState(''); const [botListPage, setBotListPage] = useState(1); const [expandedProgressByKey, setExpandedProgressByKey] = useState>({}); const [expandedUserByKey, setExpandedUserByKey] = useState>({}); const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false); const botSearchInputName = useMemo( () => `nbot-search-${Math.random().toString(36).slice(2, 10)}`, [], ); const workspaceSearchInputName = useMemo( () => `nbot-workspace-search-${Math.random().toString(36).slice(2, 10)}`, [], ); const runtimeMenuRef = useRef(null); const botListMenuRef = useRef(null); const botOrderRef = useRef>({}); const nextBotOrderRef = useRef(1); const copyWorkspacePreviewUrl = async (filePath: string) => { const normalized = String(filePath || '').trim(); if (!selectedBotId || !normalized) return; const hrefRaw = buildWorkspacePreviewHref(normalized); const href = (() => { try { return new URL(hrefRaw, window.location.origin).href; } catch { return hrefRaw; } })(); try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(href); } else { const ta = document.createElement('textarea'); ta.value = href; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } notify(t.urlCopied, { tone: 'success' }); } catch { notify(t.urlCopyFail, { tone: 'error' }); } }; const copyWorkspacePreviewPath = async (filePath: string) => { const normalized = String(filePath || '').trim(); if (!normalized) return; await copyTextToClipboard( normalized, isZh ? '文件路径已复制' : 'File path copied', isZh ? '文件路径复制失败' : 'Failed to copy file path', ); }; const copyTextToClipboard = async (textRaw: string, successMsg: string, failMsg: string) => { const text = String(textRaw || ''); if (!text.trim()) return; try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); } else { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } notify(successMsg, { tone: 'success' }); } catch { notify(failMsg, { tone: 'error' }); } }; const openWorkspacePathFromChat = async (path: string) => { const normalized = String(path || '').trim(); if (!normalized) return; const action = workspaceFileAction(normalized, workspaceDownloadExtensionSet); if (action === 'download') { triggerWorkspaceFileDownload(normalized); return; } if (action === 'preview') { void openWorkspaceFilePreview(normalized); return; } try { await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/tree`, { params: { path: normalized }, }); await loadWorkspaceTree(selectedBotId, normalized); return; } catch { if (!isPreviewableWorkspacePath(normalized, workspaceDownloadExtensionSet) || action === 'unsupported') { notify(fileNotPreviewableLabel, { tone: 'warning' }); return; } } }; const resolveWorkspaceMediaSrc = useCallback((srcRaw: string, baseFilePath?: string): string => { const src = String(srcRaw || '').trim(); if (!src || !selectedBotId) return src; const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath); if (resolvedWorkspacePath) { return buildWorkspacePreviewHref(resolvedWorkspacePath); } const lower = src.toLowerCase(); if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) { return src; } return src; }, [selectedBotId]); useEffect(() => { const ordered = Object.values(activeBots).sort((a, b) => { const aCreated = parseBotTimestamp(a.created_at); const bCreated = parseBotTimestamp(b.created_at); if (aCreated !== bCreated) return aCreated - bCreated; return String(a.id || '').localeCompare(String(b.id || '')); }); ordered.forEach((bot) => { const id = String(bot.id || '').trim(); if (!id) return; if (botOrderRef.current[id] !== undefined) return; botOrderRef.current[id] = nextBotOrderRef.current; nextBotOrderRef.current += 1; }); const alive = new Set(ordered.map((bot) => String(bot.id || '').trim()).filter(Boolean)); Object.keys(botOrderRef.current).forEach((id) => { if (!alive.has(id)) delete botOrderRef.current[id]; }); }, [activeBots]); const bots = useMemo( () => Object.values(activeBots) .filter((bot) => { const expectedNodeId = String(forcedNodeId || '').trim().toLowerCase(); if (!expectedNodeId) return true; return String(bot.node_id || 'local').trim().toLowerCase() === expectedNodeId; }) .sort((a, b) => { const aId = String(a.id || '').trim(); const bId = String(b.id || '').trim(); const aOrder = botOrderRef.current[aId] ?? Number.MAX_SAFE_INTEGER; const bOrder = botOrderRef.current[bId] ?? Number.MAX_SAFE_INTEGER; if (aOrder !== bOrder) return aOrder - bOrder; return aId.localeCompare(bId); }), [activeBots, forcedNodeId], ); const hasForcedBot = Boolean(String(forcedBotId || '').trim()); const compactListFirstMode = compactMode && !hasForcedBot; const isCompactListPage = compactListFirstMode && !selectedBotId; const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId); const showBotListPanel = !hasForcedBot && (!compactMode || isCompactListPage); const normalizedBotListQuery = botListQuery.trim().toLowerCase(); const filteredBots = useMemo(() => { if (!normalizedBotListQuery) return bots; return bots.filter((bot) => { const id = String(bot.id || '').toLowerCase(); const name = String(bot.name || '').toLowerCase(); const nodeId = String(bot.node_id || '').toLowerCase(); const nodeName = String(bot.node_display_name || '').toLowerCase(); return ( id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery) || nodeId.includes(normalizedBotListQuery) || nodeName.includes(normalizedBotListQuery) ); }); }, [bots, normalizedBotListQuery]); const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / botListPageSize)); const pagedBots = useMemo(() => { const page = Math.min(Math.max(1, botListPage), botListTotalPages); const start = (page - 1) * botListPageSize; return filteredBots.slice(start, start + botListPageSize); }, [filteredBots, botListPage, botListTotalPages, botListPageSize]); const pagedBotGroups = useMemo(() => { const unknownNodeLabel = locale === 'zh' ? '未命名节点' : 'Unnamed node'; const groups = new Map(); pagedBots.forEach((bot) => { const nodeId = String(bot.node_id || 'local').trim() || 'local'; const label = String(bot.node_display_name || '').trim() || nodeId || unknownNodeLabel; const key = nodeId.toLowerCase(); const existing = groups.get(key); if (existing) { existing.bots.push(bot); return; } groups.set(key, { key, label, nodeId, bots: [bot], }); }); return Array.from(groups.values()).sort((a, b) => { if (a.key === b.key) return 0; if (a.key === 'local') return -1; if (b.key === 'local') return 1; return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); }); }, [locale, pagedBots]); const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined; const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]); const messages = selectedBot?.messages || []; const events = selectedBot?.events || []; const isZh = locale === 'zh'; const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const }); const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn }); const refresh = async () => { const forced = String(forcedBotId || '').trim(); if (forced) { const targetId = String(selectedBotId || forced).trim() || forced; const botRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}`); setBots(botRes.data ? [botRes.data] : []); return; } const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`); setBots(botsRes.data); }; const { addChannel, addableChannelTypes, beginChannelCreate, channelCreateMenuOpen, channelDraftUiKey, channels, expandedChannelByKey, globalDelivery, isDashboardChannel, isSavingChannel, isSavingGlobalDelivery, newChannelDraft, newChannelPanelOpen, openChannelModal, removeChannel, resetChannelCollection, resetNewChannelDraft, saveChannel, saveGlobalDelivery, setChannelCreateMenuOpen, setExpandedChannelByKey, setGlobalDelivery, setNewChannelDraft, setNewChannelPanelOpen, updateChannelLocal, updateGlobalDeliveryFlag, } = useDashboardChannelConfig({ confirm, notify, refresh, selectedBot, setShowChannelModal, t, }); const { botSkills, envDraftKey, envDraftValue, envParams, installMarketSkill, isMarketSkillsLoading, isSkillUploading, loadBotEnvParams, loadBotSkills, loadMarketSkills, marketSkillInstallingId, marketSkills, onPickSkillZip, removeBotSkill, removeEnvParam, resetSkillsEnvState, saveBotEnvParams, setEnvDraftKey, setEnvDraftValue, setShowSkillMarketInstallModal, setSkillAddMenuOpen, showSkillMarketInstallModal, skillAddMenuOpen, triggerSkillZipUpload, upsertEnvParam, } = useDashboardSkillsEnvConfig({ confirm, isZh, notify, selectedBot, setShowEnvParamsModal, skillZipPickerRef, t, }); const { beginMcpCreate, canRemoveMcpServer, cronActionJobId, cronJobs, cronLoading, deleteCronJob, expandedMcpByKey, isSavingMcp, loadCronJobs, mcpDraftUiKey, mcpServers, newMcpDraft, newMcpPanelOpen, openMcpModal, removeMcpServer, resetCronState, resetMcpConfigState, resetNewMcpDraft, saveNewMcpServer, saveSingleMcpServer, setExpandedMcpByKey, setNewMcpDraft, setNewMcpPanelOpen, stopCronJob, updateMcpServer, } = useDashboardMcpCronConfig({ confirm, isZh, notify, selectedBot, setShowMcpModal, t, }); const { batchStartBots, batchStopBots, controlStateByBot, isBatchOperating, loadResourceSnapshot, openResourceMonitor, operatingBotId, resourceBot, resourceBotId, resourceError, resourceLoading, resourceSnapshot, restartBot, setBotEnabled, setShowResourceModal, showResourceModal, startBot, stopBot, } = useDashboardRuntimeControl({ bots, confirm, isZh, notify, refresh, t, updateBotStatus, }); const { buildWorkspaceDownloadHref, buildWorkspacePreviewHref, buildWorkspaceRawHref, closeWorkspacePreview, filteredWorkspaceEntries, hideWorkspaceHoverCard, loadWorkspaceTree, openWorkspaceFilePreview, resetWorkspaceBrowserState, saveWorkspacePreviewMarkdown, setWorkspaceAutoRefresh, setWorkspaceHoverCard, setWorkspacePreviewDraft, setWorkspacePreviewFullscreen, setWorkspacePreviewMode, setWorkspaceQuery, showWorkspaceHoverCard, triggerWorkspaceFileDownload, workspaceAutoRefresh, workspaceCurrentPath, workspaceDownloadExtensionSet, workspaceError, workspaceFileLoading, workspaceFiles, workspaceHoverCard, workspaceLoading, workspaceParentPath, workspacePathDisplay, workspacePreview, workspacePreviewCanEdit, workspacePreviewDraft, workspacePreviewEditorEnabled, workspacePreviewFullscreen, workspacePreviewSaving, workspaceSearchLoading, workspaceSearchQuery: workspaceQuery, } = useDashboardWorkspace({ notify, selectedBotDockerStatus: selectedBot?.docker_status, selectedBotId, t, workspaceDownloadExtensions, }); const { activeTopicOptions, addTopic, beginTopicCreate, deleteTopicFeedItem, effectiveTopicPresetTemplates, expandedTopicByKey, hasTopicUnread, isSavingTopic, loadTopicFeed, loadTopicFeedStats, loadTopics, markTopicFeedItemRead, newTopicAdvancedOpen, newTopicDescription, newTopicExamplesNegative, newTopicExamplesPositive, newTopicExcludeWhen, newTopicIncludeWhen, newTopicKey, newTopicName, newTopicPanelOpen, newTopicPriority, newTopicPurpose, newTopicSourceLabel, normalizeTopicKeyInput, openTopicModal, removeTopic, resetNewTopicDraft, resetTopicConfigState, resetTopicFeedState, saveTopic, setExpandedTopicByKey, setNewTopicAdvancedOpen, setNewTopicDescription, setNewTopicExamplesNegative, setNewTopicExamplesPositive, setNewTopicExcludeWhen, setNewTopicIncludeWhen, setNewTopicKey, setNewTopicName, setNewTopicPanelOpen, setNewTopicPriority, setNewTopicPurpose, setTopicFeedTopicKey, setTopicPresetMenuOpen, setTopicPresetTemplates, topicFeedDeleteSavingById, topicFeedError, topicFeedItems, topicFeedLoading, topicFeedLoadingMore, topicFeedNextCursor, topicFeedReadSavingById, topicFeedTopicKey, topicPanelState, topicPresetMenuOpen, topics, updateTopicLocal, } = useDashboardTopicConfig({ confirm, isZh, notify, selectedBot, setShowTopicModal, t, }); const passwordToggleLabels = isZh ? { show: '显示密码', hide: '隐藏密码' } : { show: 'Show password', hide: 'Hide password' }; const lc = isZh ? channelsZhCn : channelsEn; const runtimeMoreLabel = isZh ? '更多' : 'More'; const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined; const selectedBotEnabled = Boolean(selectedBot && selectedBot.enabled !== false); const canChat = Boolean(selectedBotEnabled && selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState); const conversation = useMemo(() => mergeConversation(messages), [messages]); const latestEvent = useMemo(() => [...events].reverse()[0], [events]); const envEntries = useMemo( () => Object.entries(envParams || {}) .filter(([k]) => String(k || '').trim().length > 0) .sort(([a], [b]) => a.localeCompare(b)), [envParams], ); const lastUserTs = useMemo(() => [...conversation].reverse().find((m) => m.role === 'user')?.ts || 0, [conversation]); const lastAssistantFinalTs = useMemo( () => [...conversation].reverse().find((m) => m.role === 'assistant' && (m.kind || 'final') !== 'progress')?.ts || 0, [conversation], ); const botUpdatedAtTs = useMemo(() => parseBotTimestamp(selectedBot?.updated_at), [selectedBot?.updated_at]); const latestRuntimeSignalTs = useMemo(() => { const latestEventTs = latestEvent?.ts || 0; return Math.max(latestEventTs, botUpdatedAtTs, lastUserTs); }, [latestEvent?.ts, botUpdatedAtTs, lastUserTs]); const hasFreshRuntimeSignal = useMemo( () => latestRuntimeSignalTs > 0 && Date.now() - latestRuntimeSignalTs < RUNTIME_STALE_MS, [latestRuntimeSignalTs], ); const isThinking = useMemo(() => { if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false; if (lastUserTs <= 0) return false; if (lastAssistantFinalTs >= lastUserTs) return false; return hasFreshRuntimeSignal; }, [selectedBot, lastUserTs, lastAssistantFinalTs, hasFreshRuntimeSignal]); const { activeControlCommand, attachmentUploadPercent, canSendControlCommand, chatBottomRef, chatDateJumping, chatDatePanelPosition, chatDatePickerOpen, chatDateTriggerRef, chatDateValue, chatScrollRef, command, composerTextareaRef, controlCommandPanelOpen, controlCommandPanelRef, copyAssistantReply, copyUserPrompt, editUserPrompt, feedbackSavingByMessageId, filePickerRef, interruptExecution, isInterrupting, isSendingBlocked, isUploadingAttachments, isVoiceRecording, isVoiceTranscribing, jumpConversationToDate, loadInitialChatMessages, onChatScroll, onComposerKeyDown, onPickAttachments, onVoiceInput, pendingAttachments, quoteAssistantReply, quotedReply, send, sendControlCommand, setChatDatePickerOpen, setChatDateValue, setCommand, setControlCommandPanelOpen, setPendingAttachments, setQuotedReply, setVoiceCountdown, setVoiceMaxSeconds, showInterruptSubmitAction, submitAssistantFeedback, syncChatScrollToBottom, toggleChatDatePicker, triggerPickAttachments, voiceCountdown, } = useDashboardChat({ activeBots, addBotMessage, allowedAttachmentExtensions, canChat, chatPullPageSize, commandAutoUnlockSeconds, isThinking, isZh, loadWorkspaceTree, messages, notify, selectedBot, selectedBotId, setAllowedAttachmentExtensions, setBotMessageFeedback, setBotMessages, setUploadMaxMb, speechEnabled, t, uploadMaxMb, workspaceCurrentPath, }); const isChatEnabled = Boolean(canChat && !isSendingBlocked); const displayState = useMemo(() => { if (!selectedBot) return 'IDLE'; const backendState = normalizeRuntimeState(selectedBot.current_state); if (selectedBot.docker_status !== 'RUNNING') return backendState; if (hasFreshRuntimeSignal && (backendState === 'TOOL_CALL' || backendState === 'THINKING' || backendState === 'ERROR')) { return backendState; } if (isThinking) { if (latestEvent?.state === 'TOOL_CALL') return 'TOOL_CALL'; return 'THINKING'; } if ( latestEvent && ['THINKING', 'TOOL_CALL', 'ERROR'].includes(latestEvent.state) && Date.now() - latestEvent.ts < 15000 ) { return latestEvent.state; } if (latestEvent?.state === 'ERROR') return 'ERROR'; return 'IDLE'; }, [selectedBot, isThinking, latestEvent, hasFreshRuntimeSignal]); const runtimeAction = useMemo(() => { const action = normalizeAssistantMessageText(selectedBot?.last_action || '').trim(); if (action) return action; const eventText = normalizeAssistantMessageText(latestEvent?.text || '').trim(); if (eventText) return eventText; return '-'; }, [selectedBot, latestEvent]); const conversationNodes = ( { void openWorkspacePathFromChat(path); }} onQuoteAssistantReply={quoteAssistantReply} onResolveMediaSrc={resolveWorkspaceMediaSrc} onSubmitAssistantFeedback={submitAssistantFeedback} onToggleProgressExpansion={(key) => { setExpandedProgressByKey((prev) => ({ ...prev, [key]: !prev[key], })); }} onToggleUserExpansion={(key) => { setExpandedUserByKey((prev) => ({ ...prev, [key]: !prev[key], })); }} t={t} workspaceDownloadExtensionSet={workspaceDownloadExtensionSet} /> ); useEffect(() => { setBotListPage(1); }, [normalizedBotListQuery]); 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; } if (compactListFirstMode) { if (selectedBotId && !activeBots[selectedBotId]) { setSelectedBotId(''); } return; } if (!selectedBotId && bots.length > 0) setSelectedBotId(bots[0].id); if (selectedBotId && !activeBots[selectedBotId] && bots.length > 0) setSelectedBotId(bots[0].id); }, [bots, selectedBotId, activeBots, forcedBotId, compactListFirstMode]); useEffect(() => { const onPointerDown = (event: MouseEvent) => { if (runtimeMenuRef.current && !runtimeMenuRef.current.contains(event.target as Node)) { setRuntimeMenuOpen(false); } if (botListMenuRef.current && !botListMenuRef.current.contains(event.target as Node)) { setBotListMenuOpen(false); } if (controlCommandPanelRef.current && !controlCommandPanelRef.current.contains(event.target as Node)) { setChatDatePickerOpen(false); } if (channelCreateMenuRef.current && !channelCreateMenuRef.current.contains(event.target as Node)) { setChannelCreateMenuOpen(false); } if (topicPresetMenuRef.current && !topicPresetMenuRef.current.contains(event.target as Node)) { setTopicPresetMenuOpen(false); } if (skillAddMenuRef.current && !skillAddMenuRef.current.contains(event.target as Node)) { setSkillAddMenuOpen(false); } }; const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key !== 'Escape') return; setChatDatePickerOpen(false); setChannelCreateMenuOpen(false); setTopicPresetMenuOpen(false); setSkillAddMenuOpen(false); }; document.addEventListener('mousedown', onPointerDown); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('keydown', onKeyDown); }; }, []); useEffect(() => { setRuntimeMenuOpen(false); setBotListMenuOpen(false); }, [selectedBotId]); useEffect(() => { setExpandedProgressByKey({}); setExpandedUserByKey({}); setShowRuntimeActionModal(false); setWorkspaceHoverCard(null); }, [selectedBotId]); useEffect(() => { if (!selectedBotId) return; let alive = true; const loadBotDetail = async () => { try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`); if (alive) mergeBot(res.data); } catch (error) { console.error(`Failed to fetch bot detail for ${selectedBotId}`, error); } }; void loadBotDetail(); return () => { alive = false; }; }, [selectedBotId, mergeBot]); useEffect(() => { if (!compactMode) { setIsCompactMobile(false); setCompactPanelTab('chat'); return; } const media = window.matchMedia('(max-width: 980px)'); const apply = () => setIsCompactMobile(media.matches); apply(); media.addEventListener('change', apply); return () => media.removeEventListener('change', apply); }, [compactMode]); useEffect(() => { if (compactMode) { setCompactPanelTab(initialCompactPanelTab); } }, [compactMode, initialCompactPanelTab]); useEffect(() => { if (!selectedBotId || !selectedBot) { setGlobalDelivery({ sendProgress: false, sendToolHints: false }); return; } setGlobalDelivery({ sendProgress: Boolean(selectedBot.send_progress), sendToolHints: Boolean(selectedBot.send_tool_hints), }); }, [selectedBotId, selectedBot?.send_progress, selectedBot?.send_tool_hints]); const renderChannelFields = (channel: BotChannel, onPatch: (patch: Partial) => void) => renderBotChannelFields({ channel, lc, onPatch, passwordToggleLabels, }); useEffect(() => { if (!selectedBotId) { resetWorkspaceBrowserState(); resetChannelCollection(); resetTopicConfigState('full'); setPendingAttachments([]); resetCronState(); resetSkillsEnvState(); resetMcpConfigState('full'); resetTopicFeedState(true); return; } resetTopicConfigState('bot-switch'); resetMcpConfigState('bot-switch'); resetTopicFeedState(); let cancelled = false; const loadAll = async () => { try { if (cancelled) return; await loadInitialChatMessages(selectedBotId); if (cancelled) return; await Promise.all([ loadWorkspaceTree(selectedBotId, ''), loadCronJobs(selectedBotId), loadBotSkills(selectedBotId), loadBotEnvParams(selectedBotId), loadTopics(selectedBotId), loadTopicFeedStats(selectedBotId), ]); requestAnimationFrame(() => syncChatScrollToBottom('auto')); } catch (error: any) { const detail = String(error?.response?.data?.detail || '').trim(); if (!cancelled && detail) { notify(detail, { tone: 'error' }); } } }; void loadAll(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedBotId, loadInitialChatMessages, syncChatScrollToBottom]); useEffect(() => { if (!topicFeedTopicKey || topicFeedTopicKey === '__all__') return; const exists = activeTopicOptions.some((row) => row.key === topicFeedTopicKey); if (!exists) { setTopicFeedTopicKey('__all__'); } }, [activeTopicOptions, topicFeedTopicKey]); useEffect(() => { if (!selectedBotId || runtimeViewMode !== 'topic') return; if (topics.length === 0) { void loadTopics(selectedBotId); } }, [runtimeViewMode, selectedBotId, topics.length]); useEffect(() => { if (!selectedBot || runtimeViewMode !== 'topic') return; void loadTopicFeed({ append: false, topicKey: topicFeedTopicKey }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedBot?.id, runtimeViewMode, topicFeedTopicKey]); useEffect(() => { if (!selectedBot || runtimeViewMode !== 'topic') return; if (topicDetailOpen) return; const timer = window.setInterval(() => { void loadTopicFeed({ append: false, topicKey: topicFeedTopicKey }); }, 15000); return () => window.clearInterval(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedBot?.id, runtimeViewMode, topicFeedTopicKey, topicDetailOpen]); useEffect(() => { if (!selectedBotId) return; void loadTopicFeedStats(selectedBotId); const timer = window.setInterval(() => { void loadTopicFeedStats(selectedBotId); }, 15000); return () => window.clearInterval(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedBotId]); const { agentTab, applyEditFormFromBot, defaultSystemTimezone, editForm, ensureSelectedBotDetail, isSaving, isTestingProvider, onBaseProviderChange, paramDraft, providerTestResult, saveBot, setAgentTab, setEditForm, setParamDraft, setProviderTestResult, setShowAgentModal, setShowBaseModal, setShowParamModal, showAgentModal, showBaseModal, showParamModal, systemTimezoneOptions, testProviderConnection, } = useBotDashboardConfigState({ isZh, mergeBot, notify, refresh, selectedBot, selectedBotId, setAllowedAttachmentExtensions, setBotListPageSize, setBotListPageSizeReady, setChatPullPageSize, setCommandAutoUnlockSeconds, setSpeechEnabled, setTopicPresetTemplates, setUploadMaxMb, setVoiceCountdown, setVoiceMaxSeconds, setWorkspaceDownloadExtensions, t, }); const removeBot = async (botId?: string) => { const targetId = botId || selectedBot?.id; if (!targetId) return; const ok = await confirm({ title: t.delete, message: t.deleteBotConfirm(targetId), tone: 'warning', }); if (!ok) return; try { await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${targetId}`, { params: { delete_workspace: true } }); await refresh(); if (selectedBotId === targetId) setSelectedBotId(''); notify(t.deleteBotDone, { tone: 'success' }); } catch { notify(t.deleteFail, { tone: 'error' }); } }; const clearConversationHistory = async () => { if (!selectedBot) return; const target = selectedBot.name || selectedBot.id; const ok = await confirm({ title: t.clearHistory, message: t.clearHistoryConfirm(target), tone: 'warning', }); if (!ok) return; try { await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/messages`); setBotMessages(selectedBot.id, []); notify(t.clearHistoryDone, { tone: 'success' }); } catch (error: any) { const msg = error?.response?.data?.detail || t.clearHistoryFail; notify(msg, { tone: 'error' }); } }; const exportConversationJson = () => { if (!selectedBot) return; try { const payload = { bot_id: selectedBot.id, bot_name: selectedBot.name || selectedBot.id, exported_at: new Date().toISOString(), message_count: conversation.length, messages: conversation.map((m) => ({ id: m.id || null, role: m.role, text: m.text, attachments: m.attachments || [], kind: m.kind || 'final', feedback: m.feedback || null, ts: m.ts, datetime: new Date(m.ts).toISOString(), })), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `${selectedBot.id}-conversation-${stamp}.json`; const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch { notify(t.exportHistoryFail, { tone: 'error' }); } }; return ( <>
{showBotListPanel ? ( { setBotListMenuOpen(false); void batchStartBots(); }} onBatchStopBots={() => { setBotListMenuOpen(false); void batchStopBots(); }} onNextPage={() => setBotListPage((page) => Math.min(botListTotalPages, page + 1))} onOpenCreateWizard={onOpenCreateWizard} onOpenImageFactory={onOpenImageFactory} onOpenResourceMonitor={openResourceMonitor} onPrevPage={() => setBotListPage((page) => Math.max(1, page - 1))} onQueryChange={(value) => { setBotListQuery(value); setBotListPage(1); }} onRemoveBot={(botId) => { void removeBot(botId); }} onSelectBot={(botId) => { setSelectedBotId(botId); if (compactMode) setCompactPanelTab('chat'); }} onSetBotEnabled={(botId, enabled) => { void setBotEnabled(botId, enabled); }} onToggleBotListMenu={() => setBotListMenuOpen((prev) => !prev)} onToggleBotRunning={(botId, dockerStatus) => { void (String(dockerStatus || '').toUpperCase() === 'RUNNING' ? stopBot(botId, dockerStatus) : startBot(botId, dockerStatus)); }} operatingBotId={operatingBotId} pagedBotGroups={pagedBotGroups} selectedBotId={selectedBotId} t={t} /> ) : null}
{selectedBot ? (
{runtimeViewMode === 'topic' ? ( void loadTopicFeed({ append: false, topicKey: topicFeedTopicKey })} onMarkRead={(itemId) => void markTopicFeedItemRead(itemId)} onDeleteItem={(item) => void deleteTopicFeedItem(item)} onLoadMore={() => void loadTopicFeed({ append: true, cursor: topicFeedNextCursor, topicKey: topicFeedTopicKey })} onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)} onOpenTopicSettings={() => { if (selectedBot) openTopicModal(selectedBot.id); }} onDetailOpenChange={setTopicDetailOpen} layout="panel" /> ) : ( <> 0} isChatEnabled={isChatEnabled} isThinking={isThinking} onScroll={onChatScroll} thinkingLabel={t.thinking} /> setQuotedReply(null)} onCommandChange={setCommand} onCloseChatDatePicker={() => setChatDatePickerOpen(false)} onComposerKeyDown={onComposerKeyDown} onDateValueChange={setChatDateValue} onInterruptExecution={() => void interruptExecution()} onJumpConversationToDate={() => void jumpConversationToDate()} onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)} onPickAttachments={onPickAttachments} onRemovePendingAttachment={(path) => setPendingAttachments((prev) => prev.filter((v) => v !== path))} onSend={() => void (showInterruptSubmitAction ? interruptExecution() : send())} onSendControlCommand={(value) => void sendControlCommand(value)} onToggleChatDatePicker={toggleChatDatePicker} onToggleControlPanel={() => { setChatDatePickerOpen(false); setControlCommandPanelOpen((prev) => !prev); }} onTriggerPickAttachments={triggerPickAttachments} onVoiceInput={onVoiceInput} pendingAttachments={pendingAttachments} quotedReply={quotedReply} selectedBotId={selectedBotId} showInterruptSubmitAction={showInterruptSubmitAction} t={t} voiceCountdown={voiceCountdown} workspaceDownloadExtensionSet={workspaceDownloadExtensionSet} /> )}
) : (
{forcedBotMissing ? `${t.selectBot}: ${String(forcedBotId).trim()}` : t.selectBot}
)}
{ void clearConversationHistory(); }} onEnsureSelectedBotDetail={async () => { const detail = await ensureSelectedBotDetail(); applyEditFormFromBot(detail); return detail; }} onExportConversationJson={exportConversationJson} onOpenChannelModal={openChannelModal} onOpenMcpModal={(botId) => { void openMcpModal(botId); }} onOpenTopicModal={openTopicModal} onOpenWorkspaceFilePreview={(path) => { void openWorkspaceFilePreview(path); }} onRestartBot={(botId, dockerStatus) => { void restartBot(botId, dockerStatus); }} onSetRuntimeMenuOpen={setRuntimeMenuOpen} onShowWorkspaceHoverCard={showWorkspaceHoverCard} operatingBotId={operatingBotId} runtimeMenuOpen={runtimeMenuOpen} runtimeMenuRef={runtimeMenuRef} runtimeMoreLabel={runtimeMoreLabel} selectedBot={selectedBot} selectedBotEnabled={selectedBotEnabled} selectedBotId={selectedBotId} setProviderTestResult={setProviderTestResult} setShowAgentModal={setShowAgentModal} setShowBaseModal={setShowBaseModal} setShowCronModal={setShowCronModal} setShowEnvParamsModal={setShowEnvParamsModal} setShowParamModal={setShowParamModal} setShowSkillsModal={setShowSkillsModal} setWorkspaceAutoRefresh={setWorkspaceAutoRefresh} setWorkspaceQuery={setWorkspaceQuery} showCompactBotPageClose={showCompactBotPageClose} t={t} workspaceAutoRefresh={workspaceAutoRefresh} workspaceCurrentPath={workspaceCurrentPath} workspaceDownloadExtensionSet={workspaceDownloadExtensionSet} workspaceError={workspaceError} workspaceFileLoading={workspaceFileLoading} workspaceFiles={workspaceFiles} workspaceLoading={workspaceLoading} workspaceParentPath={workspaceParentPath} workspacePathDisplay={workspacePathDisplay} workspaceQuery={workspaceQuery} workspaceSearchInputName={workspaceSearchInputName} workspaceSearchLoading={workspaceSearchLoading} />
{showCompactBotPageClose ? ( { setSelectedBotId(''); setCompactPanelTab('chat'); }} tooltip={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'} aria-label={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'} > ) : null} {compactMode && !isCompactListPage && !hideCompactFab ? (
setCompactPanelTab((v) => (v === 'runtime' ? 'chat' : 'runtime'))} tooltip={compactPanelTab === 'runtime' ? (isZh ? '切换到对话面板' : 'Switch to chat') : (isZh ? '切换到运行面板' : 'Switch to runtime')} aria-label={compactPanelTab === 'runtime' ? (isZh ? '切换到对话面板' : 'Switch to chat') : (isZh ? '切换到运行面板' : 'Switch to runtime')} > {compactPanelTab === 'runtime' ? : }
) : null} setShowResourceModal(false)} onRefresh={() => void loadResourceSnapshot(resourceBotId)} snapshot={resourceSnapshot} /> setEditForm((prev) => ({ ...prev, temperature: clampTemperature(value) }))} showChannelModal={showChannelModal} setShowChannelModal={setShowChannelModal} channelCreateMenuOpen={channelCreateMenuOpen} setChannelCreateMenuOpen={setChannelCreateMenuOpen} channelCreateMenuRef={channelCreateMenuRef} addableChannelTypes={addableChannelTypes} channels={channels} expandedChannelByKey={expandedChannelByKey} setExpandedChannelByKey={setExpandedChannelByKey} globalDelivery={globalDelivery} isSavingChannel={isSavingChannel} isSavingGlobalDelivery={isSavingGlobalDelivery} newChannelDraft={newChannelDraft} setNewChannelDraft={setNewChannelDraft} newChannelPanelOpen={newChannelPanelOpen} setNewChannelPanelOpen={setNewChannelPanelOpen} resetNewChannelDraft={resetNewChannelDraft} addChannel={addChannel} saveChannel={saveChannel} saveGlobalDelivery={saveGlobalDelivery} updateChannelLocal={updateChannelLocal} updateGlobalDeliveryFlag={updateGlobalDeliveryFlag} removeChannel={removeChannel} renderChannelFields={renderChannelFields} isChannelConfigured={isChannelConfigured} channelDraftUiKey={channelDraftUiKey} isDashboardChannel={isDashboardChannel} beginChannelCreate={beginChannelCreate} showTopicModal={showTopicModal} setShowTopicModal={setShowTopicModal} effectiveTopicPresetTemplates={effectiveTopicPresetTemplates} topicPresetMenuOpen={topicPresetMenuOpen} setTopicPresetMenuOpen={setTopicPresetMenuOpen} topicPresetMenuRef={topicPresetMenuRef} beginTopicCreate={beginTopicCreate} expandedTopicByKey={expandedTopicByKey} setExpandedTopicByKey={setExpandedTopicByKey} isSavingTopic={isSavingTopic} topics={topics} newTopicAdvancedOpen={newTopicAdvancedOpen} setNewTopicAdvancedOpen={setNewTopicAdvancedOpen} newTopicDescription={newTopicDescription} setNewTopicDescription={setNewTopicDescription} newTopicExamplesNegative={newTopicExamplesNegative} setNewTopicExamplesNegative={setNewTopicExamplesNegative} newTopicExamplesPositive={newTopicExamplesPositive} setNewTopicExamplesPositive={setNewTopicExamplesPositive} newTopicExcludeWhen={newTopicExcludeWhen} setNewTopicExcludeWhen={setNewTopicExcludeWhen} newTopicIncludeWhen={newTopicIncludeWhen} setNewTopicIncludeWhen={setNewTopicIncludeWhen} newTopicKey={newTopicKey} setNewTopicKey={setNewTopicKey} newTopicName={newTopicName} setNewTopicName={setNewTopicName} newTopicPanelOpen={newTopicPanelOpen} setNewTopicPanelOpen={setNewTopicPanelOpen} newTopicPriority={newTopicPriority} setNewTopicPriority={setNewTopicPriority} newTopicPurpose={newTopicPurpose} setNewTopicPurpose={setNewTopicPurpose} newTopicSourceLabel={newTopicSourceLabel} resetNewTopicDraft={resetNewTopicDraft} addTopic={addTopic} normalizeTopicKeyInput={normalizeTopicKeyInput} removeTopic={removeTopic} saveTopic={saveTopic} updateTopicLocal={updateTopicLocal} topicDraftUiKey={topicDraftUiKey} showSkillsModal={showSkillsModal} setShowSkillsModal={setShowSkillsModal} skillZipPickerRef={skillZipPickerRef} skillAddMenuOpen={skillAddMenuOpen} setSkillAddMenuOpen={setSkillAddMenuOpen} isSkillUploading={isSkillUploading} skillAddMenuRef={skillAddMenuRef} onPickSkillZip={onPickSkillZip} triggerSkillZipUpload={triggerSkillZipUpload} botSkills={botSkills} removeBotSkill={removeBotSkill} showSkillMarketInstallModal={showSkillMarketInstallModal} setShowSkillMarketInstallModal={setShowSkillMarketInstallModal} marketSkills={marketSkills} isMarketSkillsLoading={isMarketSkillsLoading} marketSkillInstallingId={marketSkillInstallingId} loadMarketSkills={loadMarketSkills} loadBotSkills={loadBotSkills} installMarketSkill={installMarketSkill} showMcpModal={showMcpModal} setShowMcpModal={setShowMcpModal} newMcpPanelOpen={newMcpPanelOpen} setNewMcpPanelOpen={setNewMcpPanelOpen} newMcpDraft={newMcpDraft} setNewMcpDraft={setNewMcpDraft} resetNewMcpDraft={resetNewMcpDraft} beginMcpCreate={beginMcpCreate} canRemoveMcpServer={canRemoveMcpServer} expandedMcpByKey={expandedMcpByKey} setExpandedMcpByKey={setExpandedMcpByKey} isSavingMcp={isSavingMcp} removeMcpServer={removeMcpServer} saveSingleMcpServer={saveSingleMcpServer} updateMcpServer={updateMcpServer} mcpDraftUiKey={mcpDraftUiKey} mcpServers={mcpServers} saveNewMcpServer={saveNewMcpServer} showEnvParamsModal={showEnvParamsModal} setShowEnvParamsModal={setShowEnvParamsModal} envDraftKey={envDraftKey} setEnvDraftKey={setEnvDraftKey} envDraftValue={envDraftValue} setEnvDraftValue={setEnvDraftValue} envEntries={envEntries} upsertEnvParam={upsertEnvParam} removeEnvParam={removeEnvParam} saveBotEnvParams={saveBotEnvParams} showCronModal={showCronModal} setShowCronModal={setShowCronModal} cronActionJobId={cronActionJobId} cronJobs={cronJobs} cronLoading={cronLoading} loadCronJobs={loadCronJobs} deleteCronJob={deleteCronJob} stopCronJob={stopCronJob} showAgentModal={showAgentModal} setShowAgentModal={setShowAgentModal} /> {showRuntimeActionModal && (
setShowRuntimeActionModal(false)}>
e.stopPropagation()}>

{t.lastAction}

setShowRuntimeActionModal(false)} tooltip={t.close} aria-label={t.close}>
{runtimeAction}
)} {workspacePreview ? ( void copyWorkspacePreviewPath(path)} onCopyPreviewUrl={(path) => void copyWorkspacePreviewUrl(path)} onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)} onSaveShortcut={() => { void saveWorkspacePreviewMarkdown(); }} preview={workspacePreview} previewCanEdit={workspacePreviewCanEdit} previewDraft={workspacePreviewDraft} previewEditorEnabled={workspacePreviewEditorEnabled} previewFullscreen={workspacePreviewFullscreen} previewSaving={workspacePreviewSaving} resolveMediaSrc={resolveWorkspaceMediaSrc} saveLabel={t.save} setPreviewDraft={setWorkspacePreviewDraft} setPreviewFullscreen={setWorkspacePreviewFullscreen} setPreviewMode={setWorkspacePreviewMode} /> ) : null} {workspaceHoverCard ? : null} ); }