dashboard-nanobot/frontend/src/modules/dashboard/BotDashboardModule.tsx

1639 lines
60 KiB
TypeScript

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<HTMLDivElement | null>(null);
const topicPresetMenuRef = useRef<HTMLDivElement | null>(null);
const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
const skillAddMenuRef = useRef<HTMLDivElement | null>(null);
const [uploadMaxMb, setUploadMaxMb] = useState(100);
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState<string[]>([]);
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<string[]>(
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
);
const [runtimeViewMode, setRuntimeViewMode] = useState<RuntimeViewMode>('visual');
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
const [botListMenuOpen, setBotListMenuOpen] = useState(false);
const [topicDetailOpen, setTopicDetailOpen] = useState(false);
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>(initialCompactPanelTab);
const [isCompactMobile, setIsCompactMobile] = useState(false);
const [botListQuery, setBotListQuery] = useState('');
const [botListPage, setBotListPage] = useState(1);
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({});
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<HTMLDivElement | null>(null);
const botListMenuRef = useRef<HTMLDivElement | null>(null);
const botOrderRef = useRef<Record<string, number>>({});
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<WorkspaceTreeResponse>(`${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<NodeBotGroup[]>(() => {
const unknownNodeLabel = locale === 'zh' ? '未命名节点' : 'Unnamed node';
const groups = new Map<string, NodeBotGroup>();
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 = (
<BotDashboardConversationNodes
conversation={conversation}
expandedProgressByKey={expandedProgressByKey}
expandedUserByKey={expandedUserByKey}
feedbackSavingByMessageId={feedbackSavingByMessageId}
isZh={isZh}
onCopyAssistantReply={copyAssistantReply}
onCopyUserPrompt={copyUserPrompt}
onEditUserPrompt={editUserPrompt}
onOpenWorkspacePath={(path) => {
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<BotChannel>) => 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 (
<>
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}>
{showBotListPanel ? (
<BotDashboardBotListPanel
botCount={bots.length}
botListMenuOpen={botListMenuOpen}
botListMenuRef={botListMenuRef}
botListPage={botListPage}
botListPageSizeReady={botListPageSizeReady}
botListQuery={botListQuery}
botListTotalPages={botListTotalPages}
botSearchInputName={botSearchInputName}
controlStateByBot={controlStateByBot}
filteredBotCount={filteredBots.length}
isBatchOperating={isBatchOperating}
isZh={isZh}
normalizedBotListQuery={normalizedBotListQuery}
onBatchStartBots={() => {
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}
<section className={`panel ops-chat-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'chat') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
{selectedBot ? (
<div className="ops-chat-shell">
<div className="ops-main-content-shell">
<div className="ops-main-content-frame">
<div className="ops-main-content-head">
<ChatModeRail
hasTopicUnread={hasTopicUnread}
isZh={isZh}
runtimeViewMode={runtimeViewMode}
onChange={setRuntimeViewMode}
/>
</div>
<div className="ops-main-content-body">
{runtimeViewMode === 'topic' ? (
<TopicFeedPanel
isZh={isZh}
topicKey={topicFeedTopicKey}
topicOptions={activeTopicOptions}
topicState={topicPanelState}
items={topicFeedItems}
loading={topicFeedLoading}
loadingMore={topicFeedLoadingMore}
nextCursor={topicFeedNextCursor}
error={topicFeedError}
readSavingById={topicFeedReadSavingById}
deleteSavingById={topicFeedDeleteSavingById}
onTopicChange={setTopicFeedTopicKey}
onRefresh={() => 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"
/>
) : (
<>
<ConversationViewport
bottomRef={chatBottomRef}
chatScrollRef={chatScrollRef}
conversationNodes={conversationNodes}
emptyLabel={t.noConversation}
hasConversation={conversation.length > 0}
isChatEnabled={isChatEnabled}
isThinking={isThinking}
onScroll={onChatScroll}
thinkingLabel={t.thinking}
/>
<ChatComposerDock
activeControlCommand={activeControlCommand}
allowedAttachmentExtensions={allowedAttachmentExtensions}
attachmentUploadPercent={attachmentUploadPercent}
canChat={canChat}
canSendControlCommand={canSendControlCommand}
chatDateJumping={chatDateJumping}
chatDatePanelPosition={chatDatePanelPosition}
chatDatePickerOpen={chatDatePickerOpen}
chatDateTriggerRef={chatDateTriggerRef}
chatDateValue={chatDateValue}
command={command}
composerTextareaRef={composerTextareaRef}
controlCommandPanelOpen={controlCommandPanelOpen}
controlCommandPanelRef={controlCommandPanelRef}
disabledSend={
showInterruptSubmitAction
? isInterrupting
: (
!isChatEnabled
|| isVoiceRecording
|| isVoiceTranscribing
|| (!command.trim() && pendingAttachments.length === 0 && !quotedReply)
)
}
filePickerRef={filePickerRef}
isCompactMobile={isCompactMobile}
isInterrupting={isInterrupting}
isUploadingAttachments={isUploadingAttachments}
isVoiceRecording={isVoiceRecording}
isVoiceTranscribing={isVoiceTranscribing}
isZh={isZh}
onClearQuotedReply={() => 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}
/>
<ChatDisabledMask
botDisabledHint={t.botDisabledHint}
botStarting={t.botStarting}
botStopping={t.botStopping}
canChat={canChat}
chatDisabled={t.chatDisabled}
selectedBotControlState={selectedBotControlState}
selectedBotEnabled={selectedBotEnabled}
/>
</>
)}
</div>
</div>
</div>
</div>
) : (
<div style={{ color: 'var(--muted)' }}>
{forcedBotMissing
? `${t.selectBot}: ${String(forcedBotId).trim()}`
: t.selectBot}
</div>
)}
</section>
<BotDashboardRuntimePanel
compactMode={compactMode}
compactPanelTab={compactPanelTab}
displayState={displayState}
filteredWorkspaceEntries={filteredWorkspaceEntries}
forcedBotId={forcedBotId}
forcedBotMissing={forcedBotMissing}
hideWorkspaceHoverCard={hideWorkspaceHoverCard}
isCompactListPage={isCompactListPage}
lc={lc}
loadBotEnvParams={loadBotEnvParams}
loadBotSkills={loadBotSkills}
loadCronJobs={loadCronJobs}
loadWorkspaceTree={loadWorkspaceTree}
onClearConversationHistory={() => {
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}
/>
</div>
{showCompactBotPageClose ? (
<LucentIconButton
className="ops-compact-close-btn"
onClick={() => {
setSelectedBotId('');
setCompactPanelTab('chat');
}}
tooltip={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
aria-label={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
>
<X size={16} />
</LucentIconButton>
) : null}
{compactMode && !isCompactListPage && !hideCompactFab ? (
<div className="ops-compact-fab-stack">
<LucentIconButton
className={`ops-compact-fab-switch ${compactPanelTab === 'chat' ? 'is-chat' : 'is-runtime'}`}
onClick={() => 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' ? <MessageSquareText size={18} /> : <Activity size={18} />}
</LucentIconButton>
</div>
) : null}
<ResourceMonitorModal
botId={resourceBotId}
botImageTag={resourceBot?.image_tag || '-'}
botModel={resourceBot?.llm_model || '-'}
botName={resourceBot?.name || ''}
botProvider={resourceBot?.llm_provider || '-'}
error={resourceError}
isOpen={showResourceModal}
labels={{
baseImage: isZh ? '基础镜像' : 'Base Image',
blockIo: isZh ? '磁盘 I/O' : 'Block I/O',
close: t.close,
collected: isZh ? '采样时间' : 'Collected',
configuredLimits: isZh ? '配置配额' : 'Configured Limits',
container: isZh ? '容器状态' : 'Container',
containerName: isZh ? '容器名' : 'Container Name',
cpu: 'CPU',
dockerRuntimeLimits: isZh ? 'Docker 实际限制' : 'Docker Runtime Limits',
liveUsage: isZh ? '实时使用' : 'Live Usage',
loading: isZh ? '读取中...' : 'Loading...',
memory: isZh ? '内存' : 'Memory',
memoryPercent: isZh ? '内存占比' : 'Memory %',
networkIo: isZh ? '网络 I/O' : 'Network I/O',
noMetrics: isZh ? '暂无监控数据' : 'No metrics',
policy: isZh ? '策略说明' : 'Policy',
providerModel: 'Provider/Model',
refreshNow: isZh ? '立即刷新' : 'Refresh now',
resourceMonitor: isZh ? '资源监测' : 'Resource Monitor',
sizeUnlimited: isZh ? '不限' : 'Unlimited',
storage: isZh ? '存储' : 'Storage',
uiRuleHint: isZh ? '(界面规则:资源配置填写 0 表示不限制)' : ' (UI rule: value 0 means unlimited)',
workspacePercent: isZh ? '工作区占比' : 'Workspace %',
workspaceUsage: isZh ? '工作区占用' : 'Workspace Usage',
}}
loading={resourceLoading}
onClose={() => setShowResourceModal(false)}
onRefresh={() => void loadResourceSnapshot(resourceBotId)}
snapshot={resourceSnapshot}
/>
<BotDashboardConfigDrawers
isZh={isZh}
t={t}
lc={lc}
noteLocale={noteLocale}
passwordToggleLabels={passwordToggleLabels}
defaultSystemTimezone={defaultSystemTimezone}
systemTimezoneOptions={systemTimezoneOptions}
selectedBotId={selectedBot?.id || ''}
selectedBotExists={Boolean(selectedBot)}
editForm={editForm}
setEditForm={setEditForm}
paramDraft={paramDraft}
setParamDraft={setParamDraft}
agentTab={agentTab}
setAgentTab={setAgentTab}
isSaving={isSaving}
saveBot={saveBot}
showBaseModal={showBaseModal}
setShowBaseModal={setShowBaseModal}
showParamModal={showParamModal}
setShowParamModal={setShowParamModal}
isTestingProvider={isTestingProvider}
providerTestResult={providerTestResult}
onBaseProviderChange={onBaseProviderChange}
onTestProviderConnection={testProviderConnection}
onTemperatureChange={(value) => 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 && (
<div className="modal-mask" onClick={() => setShowRuntimeActionModal(false)}>
<div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.lastAction}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowRuntimeActionModal(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="workspace-preview-body">
<pre>{runtimeAction}</pre>
</div>
</div>
</div>
)}
{workspacePreview ? (
<WorkspacePreviewModal
buildWorkspaceDownloadHref={buildWorkspaceDownloadHref}
buildWorkspaceRawHref={buildWorkspaceRawHref}
closeLabel={t.close}
closePreview={closeWorkspacePreview}
copyAddressLabel={t.copyAddress}
copyPathLabel={isZh ? '复制路径' : 'Copy path'}
downloadLabel={t.download}
editFileLabel={t.editFile}
filePreviewLabel={t.filePreview}
fileTruncatedLabel={t.fileTruncated}
fullscreenEditLabel={isZh ? '全屏编辑' : 'Full screen editor'}
fullscreenExitLabel={isZh ? '退出全屏' : 'Exit full screen'}
fullscreenPreviewLabel={isZh ? '全屏预览' : 'Full screen'}
isZh={isZh}
onCopyPreviewPath={(path) => 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 ? <WorkspaceHoverTooltip hoverCard={workspaceHoverCard} isZh={isZh} /> : null}
</>
);
}