import { useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; import axios from 'axios'; import { Activity, Boxes, Check, Clock3, EllipsisVertical, Eye, EyeOff, FileText, FolderOpen, Hammer, Maximize2, MessageSquareText, Minimize2, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Save, Settings2, SlidersHorizontal, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; import { APP_ENDPOINTS } from '../../config/env'; import { useAppStore } from '../../store/appStore'; import type { ChatMessage } from '../../types/bot'; import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from './messageParser'; import nanobotLogo from '../../assets/nanobot-logo.png'; 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'; interface BotDashboardModuleProps { onOpenCreateWizard?: () => void; onOpenImageFactory?: () => void; forcedBotId?: string; compactMode?: boolean; } type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; type WorkspaceNodeType = 'dir' | 'file'; type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack'; type RuntimeViewMode = 'visual' | 'text'; type CompactPanelTab = 'chat' | 'runtime'; interface WorkspaceNode { name: string; path: string; type: WorkspaceNodeType; size?: number; ext?: string; mtime?: string; children?: WorkspaceNode[]; } interface WorkspaceTreeResponse { bot_id: string; root: string; cwd: string; parent: string | null; entries: WorkspaceNode[]; } interface WorkspaceFileResponse { bot_id: string; path: string; size: number; is_markdown: boolean; truncated: boolean; content: string; } interface WorkspacePreviewState { path: string; content: string; truncated: boolean; ext: string; isMarkdown: boolean; isImage: boolean; } interface WorkspaceUploadResponse { bot_id: string; files: Array<{ name: string; path: string; size: number }>; } interface CronJob { id: string; name: string; enabled?: boolean; schedule?: { kind?: 'at' | 'every' | 'cron' | string; atMs?: number; everyMs?: number; expr?: string; tz?: string; }; payload?: { message?: string; channel?: string; to?: string; }; state?: { nextRunAtMs?: number; lastRunAtMs?: number; lastStatus?: string; lastError?: string; }; } interface CronJobsResponse { bot_id: string; version: number; jobs: CronJob[]; } interface BotChannel { id: string | number; bot_id: string; channel_type: ChannelType | string; external_app_id: string; app_secret: string; internal_port: number; is_active: boolean; extra_config: Record; locked?: boolean; } interface WorkspaceSkillOption { id: string; name: string; type: 'dir' | 'file' | string; path: string; size?: number | null; mtime?: string; description?: string; } interface SkillUploadResponse { status: string; bot_id: string; installed: string[]; skills: WorkspaceSkillOption[]; } type BotEnvParams = Record; const providerPresets: Record = { openrouter: { model: 'openai/gpt-4o-mini', apiBase: 'https://openrouter.ai/api/v1', note: { 'zh-cn': 'OpenRouter 网关,模型示例 openai/gpt-4o-mini', en: 'OpenRouter gateway, model example: openai/gpt-4o-mini', }, }, dashscope: { model: 'qwen-plus', apiBase: 'https://dashscope.aliyuncs.com/compatible-mode/v1', note: { 'zh-cn': '阿里云 DashScope(千问),模型示例 qwen-plus', en: 'Alibaba DashScope (Qwen), model example: qwen-plus', }, }, openai: { model: 'gpt-4o-mini', note: { 'zh-cn': 'OpenAI 原生接口', en: 'OpenAI native endpoint', }, }, deepseek: { model: 'deepseek-chat', note: { 'zh-cn': 'DeepSeek 原生接口', en: 'DeepSeek native endpoint', }, }, kimi: { model: 'moonshot-v1-8k', apiBase: 'https://api.moonshot.cn/v1', note: { 'zh-cn': 'Kimi(Moonshot)接口,模型示例 moonshot-v1-8k', en: 'Kimi (Moonshot) endpoint, model example: moonshot-v1-8k', }, }, minimax: { model: 'MiniMax-Text-01', apiBase: 'https://api.minimax.chat/v1', note: { 'zh-cn': 'MiniMax 接口,模型示例 MiniMax-Text-01', en: 'MiniMax endpoint, model example: MiniMax-Text-01', }, }, }; const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack']; function formatClock(ts: number) { const d = new Date(ts); const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); const ss = String(d.getSeconds()).padStart(2, '0'); return `${hh}:${mm}:${ss}`; } function stateLabel(s?: string) { return (s || 'IDLE').toUpperCase(); } function normalizeRuntimeState(s?: string) { const raw = stateLabel(s); if (raw.includes('ERROR') || raw.includes('FAIL')) return 'ERROR'; if (raw.includes('TOOL') || raw.includes('EXEC') || raw.includes('ACTION')) return 'TOOL_CALL'; if (raw.includes('THINK') || raw.includes('PLAN') || raw.includes('REASON') || raw === 'RUNNING') return 'THINKING'; if (raw.includes('SUCCESS') || raw.includes('DONE') || raw.includes('COMPLETE')) return 'SUCCESS'; if (raw.includes('IDLE') || raw.includes('STOP')) return 'IDLE'; return raw; } function isPreviewableWorkspaceFile(node: WorkspaceNode) { if (node.type !== 'file') return false; const ext = (node.ext || '').toLowerCase(); return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].includes(ext); } function isPdfPath(path: string) { return String(path || '').trim().toLowerCase().endsWith('.pdf'); } function isImagePath(path: string) { const normalized = String(path || '').trim().toLowerCase(); return normalized.endsWith('.png') || normalized.endsWith('.jpg') || normalized.endsWith('.jpeg') || normalized.endsWith('.webp'); } function isOfficePath(path: string) { const normalized = String(path || '').trim().toLowerCase(); return ['.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) => normalized.endsWith(ext), ); } function isPreviewableWorkspacePath(path: string) { const normalized = String(path || '').trim().toLowerCase(); return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) => normalized.endsWith(ext), ); } const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/'; function buildWorkspaceLink(path: string) { return `${WORKSPACE_LINK_PREFIX}${encodeURIComponent(path)}`; } function parseWorkspaceLink(href: string): string | null { const link = String(href || '').trim(); if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null; const encoded = link.slice(WORKSPACE_LINK_PREFIX.length); try { const decoded = decodeURIComponent(encoded || '').trim(); return decoded || null; } catch { return null; } } function decorateWorkspacePathsForMarkdown(text: string) { const source = String(text || ''); const normalizedExistingLinks = source.replace( /\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\s*\n?\s*\((https:\/\/workspace\.local\/open\/[^)\s]+)\)/g, '[$1]($2)', ); const workspacePathPattern = /\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b/gi; return normalizedExistingLinks.replace(workspacePathPattern, (fullPath) => { const normalized = normalizeDashboardAttachmentPath(fullPath); if (!normalized) return fullPath; return `[${fullPath}](${buildWorkspaceLink(normalized)})`; }); } function normalizeAttachmentPaths(raw: unknown): string[] { if (!Array.isArray(raw)) return []; return raw .map((v) => String(v || '').trim().replace(/\\/g, '/')) .filter((v) => v.length > 0); } function normalizeDashboardAttachmentPath(path: string): string { const v = String(path || '').trim().replace(/\\/g, '/'); if (!v) return ''; const prefix = '/root/.nanobot/workspace/'; if (v.startsWith(prefix)) return v.slice(prefix.length); return v.replace(/^\/+/, ''); } function isExternalHttpLink(href: string): boolean { return /^https?:\/\//i.test(String(href || '').trim()); } function mergeConversation(messages: ChatMessage[]) { const merged: ChatMessage[] = []; messages .filter((msg) => msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0)) .forEach((msg) => { const cleanText = msg.role === 'user' ? normalizeUserMessageText(msg.text) : normalizeAssistantMessageText(msg.text); const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean); if (!cleanText && attachments.length === 0) return; const last = merged[merged.length - 1]; if (last && last.role === msg.role) { const normalizedLast = last.role === 'user' ? normalizeUserMessageText(last.text) : normalizeAssistantMessageText(last.text); const normalizedCurrent = msg.role === 'user' ? normalizeUserMessageText(cleanText) : normalizeAssistantMessageText(cleanText); const lastKind = last.kind || 'final'; const currentKind = msg.kind || 'final'; const sameAttachmentSet = JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments); if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && Math.abs(msg.ts - last.ts) < 15000) { last.ts = msg.ts; return; } } merged.push({ ...msg, text: cleanText, attachments }); }); return merged.slice(-120); } function clampTemperature(value: number) { if (Number.isNaN(value)) return 0.2; return Math.min(1, Math.max(0, value)); } function formatCronSchedule(job: CronJob, isZh: boolean) { const s = job.schedule || {}; if (s.kind === 'every' && Number(s.everyMs) > 0) { const sec = Math.round(Number(s.everyMs) / 1000); return isZh ? `每 ${sec}s` : `every ${sec}s`; } if (s.kind === 'cron') { if (s.tz) return `${s.expr || '-'} (${s.tz})`; return s.expr || '-'; } if (s.kind === 'at' && Number(s.atMs) > 0) { return new Date(Number(s.atMs)).toLocaleString(); } return '-'; } export function BotDashboardModule({ onOpenCreateWizard, onOpenImageFactory, forcedBotId, compactMode = false, }: BotDashboardModuleProps) { const { activeBots, setBots, updateBotStatus, locale, addBotMessage, setBotMessages, } = useAppStore(); const { notify, confirm } = useLucentPrompt(); const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable'; const [selectedBotId, setSelectedBotId] = useState(''); const [command, setCommand] = useState(''); const [isSaving, setIsSaving] = useState(false); const [showBaseModal, setShowBaseModal] = useState(false); const [showParamModal, setShowParamModal] = useState(false); const [showChannelModal, setShowChannelModal] = useState(false); const [showSkillsModal, setShowSkillsModal] = useState(false); const [showEnvParamsModal, setShowEnvParamsModal] = useState(false); const [showCronModal, setShowCronModal] = useState(false); const [showAgentModal, setShowAgentModal] = useState(false); const [agentTab, setAgentTab] = useState('AGENTS'); const [isTestingProvider, setIsTestingProvider] = useState(false); const [providerTestResult, setProviderTestResult] = useState(''); const [operatingBotId, setOperatingBotId] = useState(null); const [sendingByBot, setSendingByBot] = useState>({}); const [controlStateByBot, setControlStateByBot] = useState>({}); const chatBottomRef = useRef(null); const [workspaceEntries, setWorkspaceEntries] = useState([]); const [workspaceLoading, setWorkspaceLoading] = useState(false); const [workspaceError, setWorkspaceError] = useState(''); const [workspaceCurrentPath, setWorkspaceCurrentPath] = useState(''); const [workspaceParentPath, setWorkspaceParentPath] = useState(null); const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false); const [workspacePreview, setWorkspacePreview] = useState(null); const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false); const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(true); const [pendingAttachments, setPendingAttachments] = useState([]); const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); const filePickerRef = useRef(null); const [cronJobs, setCronJobs] = useState([]); const [cronLoading, setCronLoading] = useState(false); const [cronActionJobId, setCronActionJobId] = useState(''); const [channels, setChannels] = useState([]); const [botSkills, setBotSkills] = useState([]); const [isSkillUploading, setIsSkillUploading] = useState(false); const skillZipPickerRef = useRef(null); const [envParams, setEnvParams] = useState({}); const [envDraftKey, setEnvDraftKey] = useState(''); const [envDraftValue, setEnvDraftValue] = useState(''); const [envDraftVisible, setEnvDraftVisible] = useState(false); const [envVisibleByKey, setEnvVisibleByKey] = useState>({}); const [isSavingChannel, setIsSavingChannel] = useState(false); const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false); const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({ sendProgress: false, sendToolHints: false, }); const [newChannelType, setNewChannelType] = useState('feishu'); const [runtimeViewMode, setRuntimeViewMode] = useState('visual'); const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); const [compactPanelTab, setCompactPanelTab] = useState('chat'); const [isCompactMobile, setIsCompactMobile] = useState(false); const [expandedProgressByKey, setExpandedProgressByKey] = useState>({}); const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false); const runtimeMenuRef = useRef(null); const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(filePath)}${forceDownload ? '&download=1' : ''}`; const closeWorkspacePreview = () => { setWorkspacePreview(null); setWorkspacePreviewFullscreen(false); }; const triggerWorkspaceFileDownload = (filePath: string) => { if (!selectedBotId) return; const normalized = String(filePath || '').trim(); if (!normalized) return; const filename = normalized.split('/').pop() || 'workspace-file'; const link = document.createElement('a'); link.href = buildWorkspaceDownloadHref(normalized, true); link.download = filename; link.rel = 'noopener noreferrer'; document.body.appendChild(link); link.click(); link.remove(); }; const openWorkspacePathFromChat = (path: string) => { const normalized = String(path || '').trim(); if (!normalized) return; if (isPdfPath(normalized) || isOfficePath(normalized)) { triggerWorkspaceFileDownload(normalized); return; } if (!isPreviewableWorkspacePath(normalized)) { notify(fileNotPreviewableLabel, { tone: 'warning' }); return; } void openWorkspaceFilePreview(normalized); }; const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => { const source = String(text || ''); if (!source) return [source]; const pattern = /\[(\/root\/\.nanobot\/workspace\/[^\]]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps))\]\((https:\/\/workspace\.local\/open\/[^)\s]+)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b|https:\/\/workspace\.local\/open\/[^\s)]+/gi; const nodes: ReactNode[] = []; let lastIndex = 0; let matchIndex = 0; let match = pattern.exec(source); while (match) { if (match.index > lastIndex) { nodes.push(source.slice(lastIndex, match.index)); } const raw = match[0]; const markdownPath = match[1] ? String(match[1]) : ''; const markdownHref = match[2] ? String(match[2]) : ''; let normalizedPath = ''; let displayText = raw; if (markdownPath && markdownHref) { normalizedPath = normalizeDashboardAttachmentPath(markdownPath); displayText = markdownPath; } else if (raw.startsWith(WORKSPACE_LINK_PREFIX)) { normalizedPath = String(parseWorkspaceLink(raw) || '').trim(); displayText = normalizedPath ? `/root/.nanobot/workspace/${normalizedPath}` : raw; } else if (raw.startsWith('/root/.nanobot/workspace/')) { normalizedPath = normalizeDashboardAttachmentPath(raw); displayText = raw; } if (normalizedPath) { nodes.push( { event.preventDefault(); event.stopPropagation(); openWorkspacePathFromChat(normalizedPath); }} > {displayText} , ); } else { nodes.push(raw); } lastIndex = match.index + raw.length; matchIndex += 1; match = pattern.exec(source); } if (lastIndex < source.length) { nodes.push(source.slice(lastIndex)); } return nodes; }; const renderWorkspaceAwareChildren = (children: ReactNode, keyPrefix: string): ReactNode => { const list = Array.isArray(children) ? children : [children]; const mapped = list.flatMap((child, idx) => { if (typeof child === 'string') { return renderWorkspaceAwareText(child, `${keyPrefix}-${idx}`); } return [child]; }); return mapped; }; const markdownComponents = useMemo( () => ({ a: ({ href, children, ...props }: AnchorHTMLAttributes) => { const link = String(href || '').trim(); const workspacePath = parseWorkspaceLink(link); if (workspacePath) { return ( { event.preventDefault(); openWorkspacePathFromChat(workspacePath); }} {...props} > {children} ); } if (isExternalHttpLink(link)) { return ( {children} ); } return ( {children} ); }, p: ({ children, ...props }: { children?: ReactNode }) => (

{renderWorkspaceAwareChildren(children, 'md-p')}

), li: ({ children, ...props }: { children?: ReactNode }) => (
  • {renderWorkspaceAwareChildren(children, 'md-li')}
  • ), code: ({ children, ...props }: { children?: ReactNode }) => ( {renderWorkspaceAwareChildren(children, 'md-code')} ), }), [fileNotPreviewableLabel, notify, selectedBotId], ); const [editForm, setEditForm] = useState({ name: '', llm_provider: '', llm_model: '', image_tag: '', api_key: '', api_base: '', temperature: 0.2, top_p: 1, max_tokens: 8192, agents_md: '', soul_md: '', user_md: '', tools_md: '', identity_md: '', }); const bots = useMemo(() => Object.values(activeBots), [activeBots]); 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 lc = isZh ? channelsZhCn : channelsEn; const runtimeMoreLabel = isZh ? '更多' : 'More'; const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined; const isSending = selectedBot ? Boolean(sendingByBot[selectedBot.id]) : false; const canChat = Boolean(selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState); const isChatEnabled = Boolean(canChat && !isSending); const conversation = useMemo(() => mergeConversation(messages), [messages]); const latestEvent = useMemo(() => [...events].reverse()[0], [events]); const workspaceFiles = useMemo( () => workspaceEntries.filter((v) => v.type === 'file' && isPreviewableWorkspaceFile(v)), [workspaceEntries], ); const addableChannelTypes = useMemo(() => { const exists = new Set(channels.map((c) => String(c.channel_type).toLowerCase())); return optionalChannelTypes.filter((t) => !exists.has(t)); }, [channels]); 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 isThinking = useMemo(() => { if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false; if (lastUserTs <= 0) return false; if (lastAssistantFinalTs >= lastUserTs) return false; // Keep showing running/thinking state until a final assistant reply arrives. return true; }, [selectedBot, lastUserTs, lastAssistantFinalTs]); const displayState = useMemo(() => { if (!selectedBot) return 'IDLE'; const backendState = normalizeRuntimeState(selectedBot.current_state); if (selectedBot.docker_status !== 'RUNNING') return backendState; if (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]); 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 runtimeActionSummary = useMemo(() => { const full = String(runtimeAction || '').trim(); if (!full || full === '-') return '-'; return summarizeProgressText(full, isZh); }, [runtimeAction, isZh]); const runtimeActionHasMore = useMemo(() => { const full = String(runtimeAction || '').trim(); const summary = String(runtimeActionSummary || '').trim(); return Boolean(full && full !== '-' && summary && full !== summary); }, [runtimeAction, runtimeActionSummary]); const runtimeActionDisplay = runtimeActionHasMore ? runtimeActionSummary : runtimeAction; const shouldCollapseProgress = (text: string) => { const normalized = String(text || '').trim(); if (!normalized) return false; const lines = normalized.split('\n').length; return lines > 6 || normalized.length > 520; }; const conversationNodes = useMemo( () => conversation.map((item, idx) => { const itemKey = `${item.ts}-${idx}`; const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress'; const fullText = String(item.text || ''); const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText; const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim(); const collapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText)); const expanded = Boolean(expandedProgressByKey[itemKey]); const displayText = isProgressBubble && !expanded ? summaryText : fullText; return (
    {item.role !== 'user' && (
    Nanobot
    )}
    {item.role === 'user' ? t.you : 'Nanobot'}
    {formatClock(item.ts)} {collapsible ? ( ) : null}
    {item.text ? ( item.role === 'user' ? (
    {normalizeUserMessageText(item.text)}
    ) : ( {decorateWorkspacePathsForMarkdown(displayText)} ) ) : null} {(item.attachments || []).length > 0 ? (
    {(item.attachments || []).map((rawPath) => { const filePath = normalizeDashboardAttachmentPath(rawPath); const filename = filePath.split('/').pop() || filePath; return ( { event.preventDefault(); if (isPdfPath(filePath) || isOfficePath(filePath)) { triggerWorkspaceFileDownload(filePath); return; } openWorkspacePathFromChat(filePath); }} > {filename} ); })}
    ) : null}
    {item.role === 'user' && (
    )}
    )}), [conversation, expandedProgressByKey, isZh, selectedBotId, t.user, t.you], ); useEffect(() => { const forced = String(forcedBotId || '').trim(); if (forced) { if (activeBots[forced]) { if (selectedBotId !== forced) setSelectedBotId(forced); } else if (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]); useEffect(() => { chatBottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); }, [selectedBotId, conversation.length]); useEffect(() => { const onPointerDown = (event: MouseEvent) => { if (!runtimeMenuRef.current) return; if (!runtimeMenuRef.current.contains(event.target as Node)) { setRuntimeMenuOpen(false); } }; document.addEventListener('mousedown', onPointerDown); return () => document.removeEventListener('mousedown', onPointerDown); }, []); useEffect(() => { setRuntimeMenuOpen(false); }, [selectedBotId]); useEffect(() => { setExpandedProgressByKey({}); setShowRuntimeActionModal(false); }, [selectedBotId]); 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 (!selectedBotId) return; const bot = selectedBot; if (!bot) return; setProviderTestResult(''); setEditForm({ name: bot.name || '', llm_provider: bot.llm_provider || 'dashscope', llm_model: bot.llm_model || '', image_tag: bot.image_tag || '', api_key: '', api_base: bot.api_base || '', temperature: clampTemperature(bot.temperature ?? 0.2), top_p: bot.top_p ?? 1, max_tokens: bot.max_tokens ?? 8192, agents_md: bot.agents_md || '', soul_md: bot.soul_md || bot.system_prompt || '', user_md: bot.user_md || '', tools_md: bot.tools_md || '', identity_md: bot.identity_md || '', }); setPendingAttachments([]); }, [selectedBotId, selectedBot?.id]); 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 refresh = async () => { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`); setBots(res.data); }; const openWorkspaceFilePreview = async (path: string) => { if (!selectedBotId || !path) return; const normalizedPath = String(path || '').trim(); setWorkspacePreviewFullscreen(false); if (isPdfPath(normalizedPath) || isOfficePath(normalizedPath)) { triggerWorkspaceFileDownload(normalizedPath); return; } if (isImagePath(normalizedPath)) { const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase(); setWorkspacePreview({ path: normalizedPath, content: '', truncated: false, ext: fileExt ? `.${fileExt}` : '', isMarkdown: false, isImage: true, }); return; } setWorkspaceFileLoading(true); try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, { params: { path, max_bytes: 400000 }, }); const filePath = res.data.path || path; const textExt = (filePath.split('.').pop() || '').toLowerCase(); let content = res.data.content || ''; if (textExt === 'json') { try { content = JSON.stringify(JSON.parse(content), null, 2); } catch { // Keep original content when JSON is not strictly parseable. } } setWorkspacePreview({ path: filePath, content, truncated: Boolean(res.data.truncated), ext: textExt ? `.${textExt}` : '', isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown), isImage: false, }); } catch (error: any) { const msg = error?.response?.data?.detail || t.fileReadFail; notify(msg, { tone: 'error' }); } finally { setWorkspaceFileLoading(false); } }; const loadWorkspaceTree = async (botId: string, path: string = '') => { if (!botId) return; setWorkspaceLoading(true); setWorkspaceError(''); try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, { params: { path }, }); const entries = Array.isArray(res.data?.entries) ? res.data.entries : []; setWorkspaceEntries(entries); setWorkspaceCurrentPath(res.data?.cwd || ''); setWorkspaceParentPath(res.data?.parent ?? null); } catch (error: any) { setWorkspaceEntries([]); setWorkspaceCurrentPath(''); setWorkspaceParentPath(null); setWorkspaceError(error?.response?.data?.detail || t.workspaceLoadFail); } finally { setWorkspaceLoading(false); } }; const loadChannels = async (botId: string) => { if (!botId) return; const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`); setChannels(Array.isArray(res.data) ? res.data : []); }; const loadBotSkills = async (botId: string) => { if (!botId) return; const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skills`); setBotSkills(Array.isArray(res.data) ? res.data : []); }; const loadBotEnvParams = async (botId: string) => { if (!botId) return; try { const res = await axios.get<{ env_params?: Record }>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/env-params`); const rows = res.data?.env_params && typeof res.data.env_params === 'object' ? res.data.env_params : {}; const next: BotEnvParams = {}; Object.entries(rows).forEach(([k, v]) => { const key = String(k || '').trim().toUpperCase(); if (!key) return; next[key] = String(v ?? ''); }); setEnvParams(next); } catch { setEnvParams({}); } }; const saveBotEnvParams = async () => { if (!selectedBot) return; try { await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/env-params`, { env_params: envParams }); setShowEnvParamsModal(false); notify(t.envParamsSaved, { tone: 'success' }); } catch (error: any) { notify(error?.response?.data?.detail || t.envParamsSaveFail, { tone: 'error' }); } }; const upsertEnvParam = (key: string, value: string) => { const normalized = String(key || '').trim().toUpperCase(); if (!normalized) return; setEnvParams((prev) => ({ ...(prev || {}), [normalized]: String(value ?? '') })); }; const removeEnvParam = (key: string) => { const normalized = String(key || '').trim().toUpperCase(); if (!normalized) return; setEnvParams((prev) => { const next = { ...(prev || {}) }; delete next[normalized]; return next; }); }; const removeBotSkill = async (skill: WorkspaceSkillOption) => { if (!selectedBot) return; const ok = await confirm({ title: t.removeSkill, message: t.toolsRemoveConfirm(skill.name || skill.id), tone: 'warning', }); if (!ok) return; try { await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/${encodeURIComponent(skill.id)}`); await loadBotSkills(selectedBot.id); } catch (error: any) { notify(error?.response?.data?.detail || t.toolsRemoveFail, { tone: 'error' }); } }; const triggerSkillZipUpload = () => { if (!selectedBot || isSkillUploading) return; skillZipPickerRef.current?.click(); }; const onPickSkillZip = async (event: ChangeEvent) => { if (!selectedBot || !event.target.files || event.target.files.length === 0) return; const file = event.target.files[0]; const filename = String(file?.name || '').toLowerCase(); if (!filename.endsWith('.zip')) { notify(t.invalidZipFile, { tone: 'warning' }); event.target.value = ''; return; } const formData = new FormData(); formData.append('file', file); setIsSkillUploading(true); try { const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/upload`, formData, ); const nextSkills = Array.isArray(res.data?.skills) ? res.data.skills : []; setBotSkills(nextSkills); } catch (error: any) { notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' }); } finally { setIsSkillUploading(false); event.target.value = ''; } }; const loadCronJobs = async (botId: string) => { if (!botId) return; setCronLoading(true); try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/cron/jobs`, { params: { include_disabled: true }, }); setCronJobs(Array.isArray(res.data?.jobs) ? res.data.jobs : []); } catch { setCronJobs([]); } finally { setCronLoading(false); } }; const stopCronJob = async (jobId: string) => { if (!selectedBot || !jobId) return; setCronActionJobId(jobId); try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/cron/jobs/${jobId}/stop`); await loadCronJobs(selectedBot.id); } catch (error: any) { notify(error?.response?.data?.detail || t.cronStopFail, { tone: 'error' }); } finally { setCronActionJobId(''); } }; const deleteCronJob = async (jobId: string) => { if (!selectedBot || !jobId) return; const ok = await confirm({ title: t.cronDelete, message: t.cronDeleteConfirm(jobId), tone: 'warning', }); if (!ok) return; setCronActionJobId(jobId); try { await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/cron/jobs/${jobId}`); await loadCronJobs(selectedBot.id); } catch (error: any) { notify(error?.response?.data?.detail || t.cronDeleteFail, { tone: 'error' }); } finally { setCronActionJobId(''); } }; const updateChannelLocal = (index: number, patch: Partial) => { setChannels((prev) => prev.map((c, i) => (i === index ? { ...c, ...patch } : c))); }; const saveChannel = async (channel: BotChannel) => { if (!selectedBot) return; setIsSavingChannel(true); try { await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`, { channel_type: channel.channel_type, external_app_id: channel.external_app_id, app_secret: channel.app_secret, internal_port: Number(channel.internal_port), is_active: channel.is_active, extra_config: sanitizeChannelExtra(String(channel.channel_type), channel.extra_config || {}), }); await loadChannels(selectedBot.id); notify(t.channelSaved, { tone: 'success' }); } catch (error: any) { const msg = error?.response?.data?.detail || t.channelSaveFail; notify(msg, { tone: 'error' }); } finally { setIsSavingChannel(false); } }; const addChannel = async () => { if (!selectedBot || !addableChannelTypes.includes(newChannelType)) return; setIsSavingChannel(true); try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels`, { channel_type: newChannelType, is_active: true, external_app_id: '', app_secret: '', internal_port: 8080, extra_config: {}, }); await loadChannels(selectedBot.id); const rest = addableChannelTypes.filter((t) => t !== newChannelType); if (rest.length > 0) setNewChannelType(rest[0]); } catch (error: any) { const msg = error?.response?.data?.detail || t.channelAddFail; notify(msg, { tone: 'error' }); } finally { setIsSavingChannel(false); } }; const removeChannel = async (channel: BotChannel) => { if (!selectedBot || channel.channel_type === 'dashboard') return; const ok = await confirm({ title: t.channels, message: t.channelDeleteConfirm(channel.channel_type), tone: 'warning', }); if (!ok) return; setIsSavingChannel(true); try { await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`); await loadChannels(selectedBot.id); } catch (error: any) { const msg = error?.response?.data?.detail || t.channelDeleteFail; notify(msg, { tone: 'error' }); } finally { setIsSavingChannel(false); } }; const isDashboardChannel = (channel: BotChannel) => String(channel.channel_type).toLowerCase() === 'dashboard'; const sanitizeChannelExtra = (channelType: string, extra: Record) => { const type = String(channelType || '').toLowerCase(); if (type === 'dashboard') return extra || {}; const next = { ...(extra || {}) }; delete next.sendProgress; delete next.sendToolHints; return next; }; const updateGlobalDeliveryFlag = (key: 'sendProgress' | 'sendToolHints', value: boolean) => { setGlobalDelivery((prev) => ({ ...prev, [key]: value })); }; const saveGlobalDelivery = async () => { if (!selectedBot) return; setIsSavingGlobalDelivery(true); try { await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}`, { send_progress: Boolean(globalDelivery.sendProgress), send_tool_hints: Boolean(globalDelivery.sendToolHints), }); if (selectedBot.docker_status === 'RUNNING') { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/stop`); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/start`); } await refresh(); notify(t.channelSaved, { tone: 'success' }); } catch (error: any) { const msg = error?.response?.data?.detail || t.channelSaveFail; notify(msg, { tone: 'error' }); } finally { setIsSavingGlobalDelivery(false); } }; const renderChannelFields = (channel: BotChannel, idx: number) => { const ctype = String(channel.channel_type).toLowerCase(); if (ctype === 'telegram') { return ( <> updateChannelLocal(idx, { app_secret: e.target.value })} /> updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })} /> ); } if (ctype === 'feishu') { return ( <> updateChannelLocal(idx, { external_app_id: e.target.value })} /> updateChannelLocal(idx, { app_secret: e.target.value })} /> updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} /> updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} /> ); } if (ctype === 'dingtalk') { return ( <> updateChannelLocal(idx, { external_app_id: e.target.value })} /> updateChannelLocal(idx, { app_secret: e.target.value })} /> ); } if (ctype === 'slack') { return ( <> updateChannelLocal(idx, { external_app_id: e.target.value })} /> updateChannelLocal(idx, { app_secret: e.target.value })} /> ); } if (ctype === 'qq') { return ( <> updateChannelLocal(idx, { external_app_id: e.target.value })} /> updateChannelLocal(idx, { app_secret: e.target.value })} /> ); } return null; }; const stopBot = async (id: string, status: string) => { if (status !== 'RUNNING') return; setOperatingBotId(id); setControlStateByBot((prev) => ({ ...prev, [id]: 'stopping' })); try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`); updateBotStatus(id, 'STOPPED'); await refresh(); } catch { notify(t.stopFail, { tone: 'error' }); } finally { setOperatingBotId(null); setControlStateByBot((prev) => { const next = { ...prev }; delete next[id]; return next; }); } }; const startBot = async (id: string, status: string) => { if (status === 'RUNNING') return; setOperatingBotId(id); setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' })); try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`); updateBotStatus(id, 'RUNNING'); await refresh(); } catch { notify(t.startFail, { tone: 'error' }); } finally { setOperatingBotId(null); setControlStateByBot((prev) => { const next = { ...prev }; delete next[id]; return next; }); } }; const send = async () => { if (!selectedBot || !canChat || isSending) return; if (!command.trim() && pendingAttachments.length === 0) return; const text = normalizeUserMessageText(command); const payloadText = text || (pendingAttachments.length > 0 ? t.attachmentMessage : ''); if (!payloadText && pendingAttachments.length === 0) return; try { setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: true })); const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, { command: payloadText, attachments: pendingAttachments }, { timeout: 12000 }, ); if (!res.data?.success) { throw new Error(t.backendDeliverFail); } setCommand(''); setPendingAttachments([]); } catch (error: any) { const msg = error?.response?.data?.detail || error?.message || t.sendFail; addBotMessage(selectedBot.id, { role: 'assistant', text: t.sendFailMsg(msg), ts: Date.now(), }); notify(msg, { tone: 'error' }); } finally { setSendingByBot((prev) => { const next = { ...prev }; delete next[selectedBot.id]; return next; }); } }; const onComposerKeyDown = (e: KeyboardEvent) => { const native = e.nativeEvent as unknown as { isComposing?: boolean; keyCode?: number }; if (native.isComposing || native.keyCode === 229) return; const isEnter = e.key === 'Enter' || e.key === 'NumpadEnter'; if (!isEnter || e.shiftKey) return; e.preventDefault(); void send(); }; const triggerPickAttachments = () => { if (!selectedBot || !canChat || isUploadingAttachments) return; filePickerRef.current?.click(); }; const onPickAttachments = async (event: ChangeEvent) => { if (!selectedBot || !event.target.files || event.target.files.length === 0) return; const files = Array.from(event.target.files); const formData = new FormData(); files.forEach((file) => formData.append('files', file)); formData.append('path', 'uploads'); setIsUploadingAttachments(true); try { const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/workspace/upload`, formData, ); const uploaded = normalizeAttachmentPaths((res.data?.files || []).map((v) => v.path)); if (uploaded.length > 0) { setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploaded]))); await loadWorkspaceTree(selectedBot.id, workspaceCurrentPath); } } catch (error: any) { const msg = error?.response?.data?.detail || t.uploadFail; notify(msg, { tone: 'error' }); } finally { setIsUploadingAttachments(false); event.target.value = ''; } }; const onBaseProviderChange = (provider: string) => { const preset = providerPresets[provider]; setEditForm((p) => ({ ...p, llm_provider: provider, llm_model: preset?.model || p.llm_model, api_base: preset?.apiBase ?? p.api_base, })); setProviderTestResult(''); }; const testProviderConnection = async () => { if (!editForm.llm_provider || !editForm.llm_model || !editForm.api_key.trim()) { notify(t.providerRequired, { tone: 'warning' }); return; } setIsTestingProvider(true); setProviderTestResult(''); try { const res = await axios.post(`${APP_ENDPOINTS.apiBase}/providers/test`, { provider: editForm.llm_provider, model: editForm.llm_model, api_key: editForm.api_key.trim(), api_base: editForm.api_base || undefined, }); if (res.data?.ok) { const preview = (res.data.models_preview || []).slice(0, 3).join(', '); setProviderTestResult(t.connOk(preview)); } else { setProviderTestResult(t.connFail(res.data?.detail || 'unknown error')); } } catch (error: any) { const msg = error?.response?.data?.detail || error?.message || 'request failed'; setProviderTestResult(t.connFail(msg)); } finally { setIsTestingProvider(false); } }; useEffect(() => { if (!selectedBotId) { setWorkspaceEntries([]); setWorkspaceCurrentPath(''); setWorkspaceParentPath(null); setWorkspaceError(''); setChannels([]); setPendingAttachments([]); setCronJobs([]); setBotSkills([]); setEnvParams({}); return; } void loadWorkspaceTree(selectedBotId, ''); void loadCronJobs(selectedBotId); void loadBotSkills(selectedBotId); void loadBotEnvParams(selectedBotId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedBotId]); useEffect(() => { if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return; let stopped = false; const tick = async () => { if (stopped) return; await loadWorkspaceTree(selectedBotId, workspaceCurrentPath); }; void tick(); const timer = window.setInterval(() => { void tick(); }, 2000); return () => { stopped = true; window.clearInterval(timer); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [workspaceAutoRefresh, selectedBotId, selectedBot?.docker_status, workspaceCurrentPath]); const saveBot = async (mode: 'params' | 'agent' | 'base') => { if (!selectedBot) return; setIsSaving(true); try { const payload: Record = {}; if (mode === 'base') { payload.name = editForm.name; payload.llm_provider = editForm.llm_provider; payload.llm_model = editForm.llm_model; payload.api_base = editForm.api_base; if (editForm.api_key.trim()) payload.api_key = editForm.api_key.trim(); } if (mode === 'params') { payload.temperature = clampTemperature(Number(editForm.temperature)); payload.top_p = Number(editForm.top_p); payload.max_tokens = Number(editForm.max_tokens); } if (mode === 'agent') { payload.agents_md = editForm.agents_md; payload.soul_md = editForm.soul_md; payload.user_md = editForm.user_md; payload.tools_md = editForm.tools_md; payload.identity_md = editForm.identity_md; } await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}`, payload); await refresh(); setShowBaseModal(false); setShowParamModal(false); setShowAgentModal(false); notify(t.configUpdated, { tone: 'success' }); } catch (error: any) { const msg = error?.response?.data?.detail || t.saveFail; notify(msg, { tone: 'error' }); } finally { setIsSaving(false); } }; 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) => ({ role: m.role, text: m.text, attachments: m.attachments || [], kind: m.kind || 'final', 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' }); } }; const tabMap: Record = { AGENTS: 'agents_md', SOUL: 'soul_md', USER: 'user_md', TOOLS: 'tools_md', IDENTITY: 'identity_md', }; const renderWorkspaceNodes = (nodes: WorkspaceNode[]): ReactNode[] => { const rendered: ReactNode[] = []; if (workspaceParentPath !== null) { rendered.push( , ); } nodes.forEach((node) => { const key = `${node.type}:${node.path}`; if (node.type === 'dir') { rendered.push( , ); return; } const previewable = isPreviewableWorkspaceFile(node); const downloadOnlyFile = isPdfPath(node.path) || isOfficePath(node.path); rendered.push( , ); }); return rendered; }; return ( <>
    {!compactMode ? (

    {t.titleBots}

    {bots.map((bot) => { const selected = selectedBotId === bot.id; const controlState = controlStateByBot[bot.id]; const isOperating = operatingBotId === bot.id; const isStarting = controlState === 'starting'; const isStopping = controlState === 'stopping'; return (
    setSelectedBotId(bot.id)}>
    ); })}
    ) : null}
    {selectedBot ? (
    {conversation.length === 0 ? (
    {t.noConversation}
    ) : ( conversationNodes )} {isThinking ? (
    Nanobot
    {t.thinking}
    ) : null}