import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; import axios from 'axios'; import { Activity, ArrowUp, Boxes, Check, ChevronLeft, ChevronRight, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Repeat2, Reply, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, 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'; import { LucentIconButton } from '../../components/lucent/LucentIconButton'; 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'; type QuotedReply = { id?: number; text: string; ts: number }; const BOT_LIST_PAGE_SIZE = 8; interface WorkspaceNode { name: string; path: string; type: WorkspaceNodeType; size?: number; ext?: string; ctime?: string; mtime?: string; children?: WorkspaceNode[]; } interface WorkspaceHoverCardState { node: WorkspaceNode; top: number; left: number; above: boolean; } 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; isHtml: boolean; isVideo: boolean; isAudio: 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 NanobotImage { tag: string; status: string; } interface DockerImage { tag: string; version?: string; image_id?: string; } interface BaseImageOption { tag: string; label: string; disabled: boolean; needsRegister: boolean; } interface WorkspaceSkillOption { id: string; name: string; type: 'dir' | 'file' | string; path: string; size?: number | null; mtime?: string; description?: string; } interface BotResourceSnapshot { bot_id: string; docker_status: string; configured: { cpu_cores: number; memory_mb: number; storage_gb: number; }; runtime: { docker_status: string; limits: { cpu_cores?: number | null; memory_bytes?: number | null; storage_bytes?: number | null; nano_cpus?: number; storage_opt_raw?: string; }; usage: { cpu_percent: number; memory_bytes: number; memory_limit_bytes: number; memory_percent: number; network_rx_bytes: number; network_tx_bytes: number; blk_read_bytes: number; blk_write_bytes: number; pids: number; container_rw_bytes: number; }; }; workspace: { path: string; usage_bytes: number; configured_limit_bytes?: number | null; usage_percent: number; }; enforcement: { cpu_limited: boolean; memory_limited: boolean; storage_limited: boolean; }; note: string; collected_at: string; } interface SkillUploadResponse { status: string; bot_id: string; installed: string[]; skills: WorkspaceSkillOption[]; } interface SystemDefaultsResponse { limits?: { upload_max_mb?: number; }; } 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']; const RUNTIME_STALE_MS = 45000; 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 formatConversationDate(ts: number, isZh: boolean) { const d = new Date(ts); try { return d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', { year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', }); } catch { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } } 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 parseBotTimestamp(raw?: string | number) { if (typeof raw === 'number' && Number.isFinite(raw)) return raw; const text = String(raw || '').trim(); if (!text) return 0; const ms = Date.parse(text); return Number.isFinite(ms) ? ms : 0; } function isPreviewableWorkspaceFile(node: WorkspaceNode) { if (node.type !== 'file') return false; const ext = (node.ext || '').trim().toLowerCase(); if (ext) { return [ '.md', '.json', '.log', '.txt', '.csv', '.html', '.htm', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps', ].includes(ext); } return isPreviewableWorkspacePath(node.path); } 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 isVideoPath(path: string) { const normalized = String(path || '').trim().toLowerCase(); return ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts'].some((ext) => normalized.endsWith(ext)); } function isAudioPath(path: string) { const normalized = String(path || '').trim().toLowerCase(); return ['.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma'].some((ext) => normalized.endsWith(ext)); } const MEDIA_UPLOAD_EXTENSIONS = new Set([ '.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.heic', '.heif', '.tif', '.tiff', '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts', ]); function isMediaUploadFile(file: File): boolean { const mime = String(file.type || '').toLowerCase(); if (mime.startsWith('image/') || mime.startsWith('audio/') || mime.startsWith('video/')) { return true; } const name = String(file.name || '').trim().toLowerCase(); const dot = name.lastIndexOf('.'); if (dot < 0) return false; return MEDIA_UPLOAD_EXTENSIONS.has(name.slice(dot)); } function isHtmlPath(path: string) { const normalized = String(path || '').trim().toLowerCase(); return normalized.endsWith('.html') || normalized.endsWith('.htm'); } 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', '.html', '.htm', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) => normalized.endsWith(ext), ); } function workspaceFileAction(path: string): 'preview' | 'download' | 'unsupported' { const normalized = String(path || '').trim(); if (!normalized) return 'unsupported'; if (isPdfPath(normalized) || isOfficePath(normalized)) return 'download'; if (isImagePath(normalized) || isHtmlPath(normalized) || isVideoPath(normalized) || isAudioPath(normalized)) return 'preview'; const lower = normalized.toLowerCase(); if (['.md', '.json', '.log', '.txt', '.csv'].some((ext) => lower.endsWith(ext))) return 'preview'; return 'unsupported'; } const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/'; const WORKSPACE_ABS_PATH_PATTERN = /\/root\/\.nanobot\/workspace\/[^\s<>"'`)\],,。!?;:]+/gi; const WORKSPACE_RELATIVE_PATH_PATTERN = /(^|[\s(\[])(\/[^\s<>"'`)\]]+\.(?:md|markdown|json|txt|log|csv|tsv|yaml|yml|toml|html|htm|pdf|png|jpg|jpeg|gif|webp|svg))(?![A-Za-z0-9_./-])/gim; 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\/[^)\r\n]*)\)/gi, (_full, markdownPath: string) => { const normalized = normalizeDashboardAttachmentPath(markdownPath); if (!normalized) return String(_full || ''); return `[${markdownPath}](${buildWorkspaceLink(normalized)})`; }, ); const withAbsoluteLinks = normalizedExistingLinks.replace(WORKSPACE_ABS_PATH_PATTERN, (fullPath) => { const normalized = normalizeDashboardAttachmentPath(fullPath); if (!normalized) return fullPath; return `[${fullPath}](${buildWorkspaceLink(normalized)})`; }); return withAbsoluteLinks.replace(WORKSPACE_RELATIVE_PATH_PATTERN, (full, prefix: string, rawPath: string) => { const normalized = normalizeDashboardAttachmentPath(rawPath); if (!normalized) return full; return `${prefix}[${rawPath}](${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 parseQuotedReplyBlock(input: string): { quoted: string; body: string } { const source = String(input || ''); const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i); const quoted = normalizeAssistantMessageText(match?.[1] || ''); const body = source.replace(/\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]\s*/gi, '').trim(); return { quoted, body }; } 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 parsedUser = msg.role === 'user' ? parseQuotedReplyBlock(msg.text) : { quoted: '', body: msg.text }; const userQuoted = parsedUser.quoted; const userBody = parsedUser.body; const cleanText = msg.role === 'user' ? normalizeUserMessageText(userBody) : normalizeAssistantMessageText(msg.text); const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean); if (!cleanText && attachments.length === 0 && !userQuoted) 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); const sameQuoted = normalizeAssistantMessageText(last.quoted_reply || '') === normalizeAssistantMessageText(userQuoted); if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && sameQuoted && Math.abs(msg.ts - last.ts) < 15000) { last.ts = msg.ts; last.id = msg.id || last.id; if (typeof msg.feedback !== 'undefined') { last.feedback = msg.feedback; } return; } } merged.push({ ...msg, text: cleanText, quoted_reply: userQuoted || undefined, 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 clampMaxTokens(value: number) { if (Number.isNaN(value)) return 8192; return Math.min(32768, Math.max(256, Math.round(value))); } function clampCpuCores(value: number) { if (Number.isNaN(value)) return 1; if (value === 0) return 0; return Math.min(16, Math.max(0.1, Math.round(value * 10) / 10)); } function clampMemoryMb(value: number) { if (Number.isNaN(value)) return 1024; if (value === 0) return 0; return Math.min(65536, Math.max(256, Math.round(value))); } function clampStorageGb(value: number) { if (Number.isNaN(value)) return 10; if (value === 0) return 0; return Math.min(1024, Math.max(1, Math.round(value))); } function formatBytes(bytes: number): string { const value = Number(bytes || 0); if (!Number.isFinite(value) || value <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const idx = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024))); const scaled = value / Math.pow(1024, idx); return `${scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)} ${units[idx]}`; } function formatPercent(value: number): string { const n = Number(value || 0); if (!Number.isFinite(n)) return '0.00%'; return `${Math.max(0, n).toFixed(2)}%`; } function formatWorkspaceTime(raw: string | undefined, isZh: boolean): string { const text = String(raw || '').trim(); if (!text) return '-'; const dt = new Date(text); if (Number.isNaN(dt.getTime())) return '-'; try { return dt.toLocaleString(isZh ? 'zh-CN' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long', hour: '2-digit', minute: '2-digit', hour12: false, }); } catch { return dt.toLocaleString(); } } 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, 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 [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 [showResourceModal, setShowResourceModal] = useState(false); const [resourceBotId, setResourceBotId] = useState(''); const [resourceSnapshot, setResourceSnapshot] = useState(null); const [resourceLoading, setResourceLoading] = useState(false); const [resourceError, setResourceError] = useState(''); const [agentTab, setAgentTab] = useState('AGENTS'); const [isTestingProvider, setIsTestingProvider] = useState(false); const [providerTestResult, setProviderTestResult] = useState(''); const [operatingBotId, setOperatingBotId] = useState(null); const [sendingByBot, setSendingByBot] = useState>({}); const [interruptingByBot, setInterruptingByBot] = useState>({}); const [controlStateByBot, setControlStateByBot] = useState>({}); const chatBottomRef = useRef(null); const [workspaceEntries, setWorkspaceEntries] = useState([]); const [workspaceSearchEntries, setWorkspaceSearchEntries] = useState([]); const [workspaceSearchLoading, setWorkspaceSearchLoading] = useState(false); 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(false); const [workspaceQuery, setWorkspaceQuery] = useState(''); const [pendingAttachments, setPendingAttachments] = useState([]); const [quotedReply, setQuotedReply] = useState(null); const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); const [attachmentUploadPercent, setAttachmentUploadPercent] = useState(null); const filePickerRef = useRef(null); const composerTextareaRef = 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 [availableImages, setAvailableImages] = useState([]); const [localDockerImages, setLocalDockerImages] = useState([]); const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({ sendProgress: false, sendToolHints: false, }); const [uploadMaxMb, setUploadMaxMb] = useState(100); 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 [botListQuery, setBotListQuery] = useState(''); const [botListPage, setBotListPage] = useState(1); const [expandedProgressByKey, setExpandedProgressByKey] = useState>({}); const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState>({}); const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false); const [workspaceHoverCard, setWorkspaceHoverCard] = useState(null); const runtimeMenuRef = useRef(null); const botOrderRef = useRef>({}); const nextBotOrderRef = useRef(1); const applyEditFormFromBot = useCallback((bot?: any) => { if (!bot) return; setProviderTestResult(''); setEditForm({ name: bot.name || '', access_password: bot.access_password || '', 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: clampMaxTokens(bot.max_tokens ?? 8192), cpu_cores: clampCpuCores(bot.cpu_cores ?? 1), memory_mb: clampMemoryMb(bot.memory_mb ?? 1024), storage_gb: clampStorageGb(bot.storage_gb ?? 10), 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 || '', }); setParamDraft({ max_tokens: String(clampMaxTokens(bot.max_tokens ?? 8192)), cpu_cores: String(clampCpuCores(bot.cpu_cores ?? 1)), memory_mb: String(clampMemoryMb(bot.memory_mb ?? 1024)), storage_gb: String(clampStorageGb(bot.storage_gb ?? 10)), }); setPendingAttachments([]); }, []); const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => { const query = [`path=${encodeURIComponent(filePath)}`]; if (forceDownload) query.push('download=1'); return `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/download?${query.join('&')}`; }; 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 copyWorkspacePreviewUrl = async (filePath: string) => { const normalized = String(filePath || '').trim(); if (!selectedBotId || !normalized) return; const hrefRaw = buildWorkspaceDownloadHref(normalized, false); 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); if (action === 'download') { triggerWorkspaceFileDownload(normalized); return; } if (action === 'preview') { void openWorkspaceFilePreview(normalized); return; } try { await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/tree`, { params: { path: normalized }, }); await loadWorkspaceTree(selectedBotId, normalized); return; } catch { if (!isPreviewableWorkspacePath(normalized) || action === 'unsupported') { notify(fileNotPreviewableLabel, { tone: 'warning' }); return; } } }; const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => { const source = String(text || ''); if (!source) return [source]; const pattern = /\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)|\/root\/\.nanobot\/workspace\/[^\s<>"'`)\],,。!?;:]+|https:\/\/workspace\.local\/open\/[^)\r\n]+/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(); void 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(); void 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: '', access_password: '', llm_provider: '', llm_model: '', image_tag: '', api_key: '', api_base: '', temperature: 0.2, top_p: 1, max_tokens: 8192, cpu_cores: 1, memory_mb: 1024, storage_gb: 10, agents_md: '', soul_md: '', user_md: '', tools_md: '', identity_md: '', }); const [paramDraft, setParamDraft] = useState({ max_tokens: '8192', cpu_cores: '1', memory_mb: '1024', storage_gb: '10', }); 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).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], ); 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(); return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery); }); }, [bots, normalizedBotListQuery]); const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / BOT_LIST_PAGE_SIZE)); const pagedBots = useMemo(() => { const page = Math.min(Math.max(1, botListPage), botListTotalPages); const start = (page - 1) * BOT_LIST_PAGE_SIZE; return filteredBots.slice(start, start + BOT_LIST_PAGE_SIZE); }, [filteredBots, botListPage, botListTotalPages]); 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 baseImageOptions = useMemo(() => { const readyTags = new Set( availableImages .filter((img) => String(img.status || '').toUpperCase() === 'READY') .map((img) => String(img.tag || '').trim()) .filter(Boolean), ); const allTags = new Set(); localDockerImages.forEach((img) => { const tag = String(img.tag || '').trim(); if (tag) allTags.add(tag); }); availableImages.forEach((img) => { const tag = String(img.tag || '').trim(); if (tag) allTags.add(tag); }); if (editForm.image_tag) { allTags.add(editForm.image_tag); } return Array.from(allTags) .sort((a, b) => a.localeCompare(b)) .map((tag) => { const isReady = readyTags.has(tag); if (isReady) { return { tag, label: `${tag} · READY`, disabled: false, needsRegister: false }; } const hasInDocker = localDockerImages.some((row) => String(row.tag || '').trim() === tag); if (hasInDocker) { return { tag, label: isZh ? `${tag} · 本地镜像(未登记)` : `${tag} · local image (unregistered)`, disabled: false, needsRegister: true, }; } return { tag, label: isZh ? `${tag} · 不可用` : `${tag} · unavailable`, disabled: true, needsRegister: false, }; }); }, [availableImages, localDockerImages, editForm.image_tag, isZh]); 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 workspacePathDisplay = workspaceCurrentPath ? `/${String(workspaceCurrentPath || '').replace(/^\/+/, '')}` : '/'; const normalizedWorkspaceQuery = workspaceQuery.trim().toLowerCase(); const filteredWorkspaceEntries = useMemo(() => { const sourceEntries = normalizedWorkspaceQuery ? workspaceSearchEntries : workspaceEntries; if (!normalizedWorkspaceQuery) return sourceEntries; return sourceEntries.filter((entry) => { const name = String(entry.name || '').toLowerCase(); const path = String(entry.path || '').toLowerCase(); return name.includes(normalizedWorkspaceQuery) || path.includes(normalizedWorkspaceQuery); }); }, [workspaceEntries, workspaceSearchEntries, normalizedWorkspaceQuery]); 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 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 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 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 resourceBot = useMemo(() => bots.find((b) => b.id === resourceBotId), [bots, resourceBotId]); const hideWorkspaceHoverCard = () => setWorkspaceHoverCard(null); const showWorkspaceHoverCard = (node: WorkspaceNode, anchor: HTMLElement) => { const rect = anchor.getBoundingClientRect(); const panelHeight = 160; const panelWidth = 420; const gap = 8; const viewportPadding = 8; const belowSpace = window.innerHeight - rect.bottom; const aboveSpace = rect.top; const above = belowSpace < panelHeight && aboveSpace > panelHeight; const leftRaw = rect.left + 8; const left = Math.max(viewportPadding, Math.min(leftRaw, window.innerWidth - panelWidth - viewportPadding)); const top = above ? rect.top - gap : rect.bottom + gap; setWorkspaceHoverCard({ node, top, left, above }); }; 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.id || 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; const currentDayKey = new Date(item.ts).toDateString(); const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : ''; const showDateDivider = idx === 0 || currentDayKey !== prevDayKey; return (
    {showDateDivider ? (
    {formatConversationDate(item.ts, isZh)}
    ) : null}
    {item.role !== 'user' && (
    Nanobot
    )} {item.role === 'user' ? (
    editUserPrompt(item.text)} tooltip={t.editPrompt} aria-label={t.editPrompt} > void copyUserPrompt(item.text)} tooltip={t.copyPrompt} aria-label={t.copyPrompt} >
    ) : null}
    {item.role === 'user' ? t.you : 'Nanobot'}
    {formatClock(item.ts)} {collapsible ? ( setExpandedProgressByKey((prev) => ({ ...prev, [itemKey]: !prev[itemKey], })) } tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')} aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')} > {expanded ? '×' : '…'} ) : null}
    {item.text ? ( item.role === 'user' ? ( <> {item.quoted_reply ? (
    {t.quotedReplyLabel}
    {normalizeAssistantMessageText(item.quoted_reply)}
    ) : null}
    {normalizeUserMessageText(item.text)}
    ) : ( {decorateWorkspacePathsForMarkdown(displayText)} ) ) : null} {(item.attachments || []).length > 0 ? (
    {(item.attachments || []).map((rawPath) => { const filePath = normalizeDashboardAttachmentPath(rawPath); const fileAction = workspaceFileAction(filePath); const filename = filePath.split('/').pop() || filePath; return ( { event.preventDefault(); void openWorkspacePathFromChat(filePath); }} title={fileAction === 'download' ? t.download : fileAction === 'preview' ? t.previewTitle : t.fileNotPreviewable} > {fileAction === 'download' ? ( ) : fileAction === 'preview' ? ( ) : ( )} {filename} ); })}
    ) : null} {item.role === 'assistant' && !isProgressBubble ? (
    void submitAssistantFeedback(item, 'up')} disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])} tooltip={t.goodReply} aria-label={t.goodReply} > void submitAssistantFeedback(item, 'down')} disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])} tooltip={t.badReply} aria-label={t.badReply} > quoteAssistantReply(item)} tooltip={t.quoteReply} aria-label={t.quoteReply} > void copyAssistantReply(item.text)} tooltip={t.copyReply} aria-label={t.copyReply} >
    ) : null}
    {item.role === 'user' && (
    )}
    ); }), [ conversation, expandedProgressByKey, feedbackSavingByMessageId, isZh, selectedBotId, t.badReply, t.copyPrompt, t.copyReply, t.quoteReply, t.quotedReplyLabel, t.goodReply, t.user, t.you, ], ); 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 (!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(() => { setQuotedReply(null); }, [selectedBotId]); 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); 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 (!workspaceHoverCard) return; const close = () => setWorkspaceHoverCard(null); window.addEventListener('scroll', close, true); window.addEventListener('resize', close); return () => { window.removeEventListener('scroll', close, true); window.removeEventListener('resize', close); }; }, [workspaceHoverCard]); useEffect(() => { let alive = true; const loadSystemDefaults = async () => { try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/system/defaults`); const configured = Number(res.data?.limits?.upload_max_mb); if (!Number.isFinite(configured) || configured <= 0 || !alive) return; setUploadMaxMb(Math.max(1, Math.floor(configured))); } catch { // keep default limit } }; void loadSystemDefaults(); return () => { alive = false; }; }, []); 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; if (showBaseModal || showParamModal || showAgentModal) return; applyEditFormFromBot(selectedBot); }, [ selectedBotId, selectedBot?.id, selectedBot?.updated_at, showBaseModal, showParamModal, showAgentModal, applyEditFormFromBot, ]); 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 loadImageOptions = async () => { const [imagesRes, dockerImagesRes] = await Promise.allSettled([ axios.get(`${APP_ENDPOINTS.apiBase}/images`), axios.get(`${APP_ENDPOINTS.apiBase}/docker-images`), ]); if (imagesRes.status === 'fulfilled') { setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []); } else { setAvailableImages([]); } if (dockerImagesRes.status === 'fulfilled') { setLocalDockerImages(Array.isArray(dockerImagesRes.value.data) ? dockerImagesRes.value.data : []); } else { setLocalDockerImages([]); } }; const refresh = async () => { const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`); setBots(botsRes.data); await loadImageOptions(); }; const ensureSelectedBotDetail = useCallback(async () => { if (!selectedBotId) return selectedBot; try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`); mergeBot(res.data); return res.data; } catch { return selectedBot; } }, [selectedBotId, selectedBot, mergeBot]); const loadResourceSnapshot = async (botId: string) => { if (!botId) return; setResourceLoading(true); setResourceError(''); try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/resources`); setResourceSnapshot(res.data); } catch (error: any) { const msg = error?.response?.data?.detail || (isZh ? '读取资源监控失败。' : 'Failed to load resource metrics.'); setResourceError(String(msg)); } finally { setResourceLoading(false); } }; const openResourceMonitor = (botId: string) => { setResourceBotId(botId); setShowResourceModal(true); void loadResourceSnapshot(botId); }; useEffect(() => { void loadImageOptions(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!showBaseModal) return; void loadImageOptions(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [showBaseModal]); useEffect(() => { if (!showResourceModal || !resourceBotId) return; let stopped = false; const tick = async () => { if (stopped) return; await loadResourceSnapshot(resourceBotId); }; const timer = window.setInterval(() => { void tick(); }, 2000); return () => { stopped = true; window.clearInterval(timer); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [showResourceModal, resourceBotId]); 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, isHtml: false, isVideo: false, isAudio: false, }); return; } if (isHtmlPath(normalizedPath)) { const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase(); setWorkspacePreview({ path: normalizedPath, content: '', truncated: false, ext: fileExt ? `.${fileExt}` : '', isMarkdown: false, isImage: false, isHtml: true, isVideo: false, isAudio: false, }); return; } if (isVideoPath(normalizedPath)) { const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase(); setWorkspacePreview({ path: normalizedPath, content: '', truncated: false, ext: fileExt ? `.${fileExt}` : '', isMarkdown: false, isImage: false, isHtml: false, isVideo: true, isAudio: false, }); return; } if (isAudioPath(normalizedPath)) { const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase(); setWorkspacePreview({ path: normalizedPath, content: '', truncated: false, ext: fileExt ? `.${fileExt}` : '', isMarkdown: false, isImage: false, isHtml: false, isVideo: false, isAudio: 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, isHtml: false, isVideo: false, isAudio: 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); setWorkspaceSearchEntries([]); setWorkspaceCurrentPath(res.data?.cwd || ''); setWorkspaceParentPath(res.data?.parent ?? null); } catch (error: any) { setWorkspaceEntries([]); setWorkspaceSearchEntries([]); setWorkspaceCurrentPath(''); setWorkspaceParentPath(null); setWorkspaceError(error?.response?.data?.detail || t.workspaceLoadFail); } finally { setWorkspaceLoading(false); } }; const loadWorkspaceSearchEntries = async (botId: string, path: string = '') => { if (!botId) return; const q = String(workspaceQuery || '').trim(); if (!q) { setWorkspaceSearchEntries([]); setWorkspaceSearchLoading(false); return; } setWorkspaceSearchLoading(true); try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, { params: { path, recursive: true }, }); const entries = Array.isArray(res.data?.entries) ? res.data.entries : []; setWorkspaceSearchEntries(entries); } catch { setWorkspaceSearchEntries([]); } finally { setWorkspaceSearchLoading(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 restartBot = async (id: string, status: string) => { const normalized = String(status || '').toUpperCase(); const ok = await confirm({ title: t.restart, message: t.restartConfirm(id), tone: 'warning', }); if (!ok) return; setOperatingBotId(id); try { if (normalized === 'RUNNING') { setControlStateByBot((prev) => ({ ...prev, [id]: 'stopping' })); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`); updateBotStatus(id, 'STOPPED'); } setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' })); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`); updateBotStatus(id, 'RUNNING'); await refresh(); } catch { notify(t.restartFail, { 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 && !quotedReply) return; const text = normalizeUserMessageText(command); const quoteText = normalizeAssistantMessageText(quotedReply?.text || ''); const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : ''; const payloadCore = text || (pendingAttachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : ''); const payloadText = `${quoteBlock}${payloadCore}`.trim(); 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([]); setQuotedReply(null); } 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 interruptExecution = async () => { if (!selectedBot || !canChat) return; if (interruptingByBot[selectedBot.id]) return; try { setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true })); const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, { command: '/stop' }, { timeout: 12000 }, ); if (!res.data?.success) { throw new Error(t.backendDeliverFail); } notify(t.interruptSent, { tone: 'success' }); } catch (error: any) { const msg = error?.response?.data?.detail || error?.message || t.sendFail; notify(msg, { tone: 'error' }); } finally { setInterruptingByBot((prev) => { const next = { ...prev }; delete next[selectedBot.id]; return next; }); } }; const copyUserPrompt = async (text: string) => { await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail); }; const editUserPrompt = (text: string) => { const normalized = normalizeUserMessageText(text); if (!normalized) return; setCommand(normalized); composerTextareaRef.current?.focus(); if (composerTextareaRef.current) { const caret = normalized.length; window.requestAnimationFrame(() => { composerTextareaRef.current?.setSelectionRange(caret, caret); }); } notify(t.editPromptDone, { tone: 'success' }); }; const copyAssistantReply = async (text: string) => { await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail); }; const quoteAssistantReply = (message: ChatMessage) => { const content = normalizeAssistantMessageText(message.text); if (!content) return; setQuotedReply((prev) => { if (prev && prev.ts === message.ts && normalizeAssistantMessageText(prev.text) === content) { return null; } return { id: message.id, ts: message.ts, text: content }; }); }; const fetchBotMessages = async (botId: string): Promise => { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages`, { params: { limit: 300 }, }); const rows = Array.isArray(res.data) ? res.data : []; return rows .map((row) => { const roleRaw = String(row?.role || '').toLowerCase(); const role: ChatMessage['role'] = roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant'; const feedbackRaw = String(row?.feedback || '').trim().toLowerCase(); const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null; return { id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined, role, text: String(row?.text || ''), attachments: normalizeAttachmentPaths(row?.media), ts: Number(row?.ts || Date.now()), feedback, kind: 'final', } as ChatMessage; }) .filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0) .slice(-300); }; const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => { if (!selectedBotId) { notify(t.feedbackMessagePending, { tone: 'warning' }); return; } let targetMessageId = message.id; if (!targetMessageId) { try { const latest = await fetchBotMessages(selectedBotId); setBotMessages(selectedBotId, latest); const normalizedTarget = normalizeAssistantMessageText(message.text); const matched = latest .filter((m) => m.role === 'assistant' && m.id) .map((m) => ({ m, diff: Math.abs((m.ts || 0) - (message.ts || 0)) })) .filter(({ m, diff }) => normalizeAssistantMessageText(m.text) === normalizedTarget && diff <= 10 * 60 * 1000) .sort((a, b) => a.diff - b.diff)[0]?.m; if (matched?.id) { targetMessageId = matched.id; } } catch { // ignore and fallback to warning below } } if (!targetMessageId) { notify(t.feedbackMessagePending, { tone: 'warning' }); return; } if (feedbackSavingByMessageId[targetMessageId]) return; const nextFeedback: 'up' | 'down' | null = message.feedback === feedback ? null : feedback; setFeedbackSavingByMessageId((prev) => ({ ...prev, [targetMessageId]: true })); try { await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/feedback`, { feedback: nextFeedback }); setBotMessageFeedback(selectedBotId, targetMessageId, nextFeedback); if (nextFeedback === null) { notify(t.feedbackCleared, { tone: 'success' }); } else { notify(nextFeedback === 'up' ? t.feedbackUpSaved : t.feedbackDownSaved, { tone: 'success' }); } } catch (error: any) { const msg = error?.response?.data?.detail || t.feedbackSaveFail; notify(msg, { tone: 'error' }); } finally { setFeedbackSavingByMessageId((prev) => { const next = { ...prev }; delete next[targetMessageId]; 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 onVoiceInput = () => { notify(t.voiceUnavailable, { tone: 'warning' }); }; const onPickAttachments = async (event: ChangeEvent) => { if (!selectedBot || !event.target.files || event.target.files.length === 0) return; const files = Array.from(event.target.files); const maxBytes = uploadMaxMb * 1024 * 1024; const tooLarge = files.filter((f) => Number(f.size) > maxBytes); if (tooLarge.length > 0) { const names = tooLarge.map((f) => String(f.name || '').trim() || 'unknown').slice(0, 3).join(', '); notify(t.uploadTooLarge(names, uploadMaxMb), { tone: 'warning' }); event.target.value = ''; return; } const mediaFiles: File[] = []; const normalFiles: File[] = []; files.forEach((file) => { if (isMediaUploadFile(file)) { mediaFiles.push(file); } else { normalFiles.push(file); } }); const totalBytes = files.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0); let uploadedBytes = 0; const uploadedPaths: string[] = []; const uploadBatch = async (batchFiles: File[], path: 'media' | 'uploads') => { if (batchFiles.length === 0) return; const batchBytes = batchFiles.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0); const formData = new FormData(); batchFiles.forEach((file) => formData.append('files', file)); const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/workspace/upload`, formData, { params: { path }, onUploadProgress: (progressEvent) => { const loaded = Number(progressEvent.loaded || 0); if (!Number.isFinite(loaded) || loaded < 0) { setAttachmentUploadPercent(null); return; } if (totalBytes <= 0) { setAttachmentUploadPercent(null); return; } const cappedLoaded = Math.max(0, Math.min(batchBytes, loaded)); const pct = Math.max(0, Math.min(100, Math.round(((uploadedBytes + cappedLoaded) / totalBytes) * 100))); setAttachmentUploadPercent(pct); }, }, ); const uploaded = normalizeAttachmentPaths((res.data?.files || []).map((v) => v.path)); uploadedPaths.push(...uploaded); uploadedBytes += batchBytes; if (totalBytes > 0) { const pct = Math.max(0, Math.min(100, Math.round((uploadedBytes / totalBytes) * 100))); setAttachmentUploadPercent(pct); } }; setIsUploadingAttachments(true); setAttachmentUploadPercent(0); try { await uploadBatch(mediaFiles, 'media'); await uploadBatch(normalFiles, 'uploads'); if (uploadedPaths.length > 0) { setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploadedPaths]))); await loadWorkspaceTree(selectedBot.id, workspaceCurrentPath); } } catch (error: any) { const msg = error?.response?.data?.detail || t.uploadFail; notify(msg, { tone: 'error' }); } finally { setIsUploadingAttachments(false); setAttachmentUploadPercent(null); 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; } let cancelled = false; const loadAll = async () => { try { if (cancelled) return; await Promise.all([ loadWorkspaceTree(selectedBotId, ''), loadCronJobs(selectedBotId), loadBotSkills(selectedBotId), loadBotEnvParams(selectedBotId), ]); } 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]); 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]); useEffect(() => { setWorkspaceQuery(''); }, [selectedBotId, workspaceCurrentPath]); useEffect(() => { if (!selectedBotId) { setWorkspaceSearchEntries([]); setWorkspaceSearchLoading(false); return; } if (!workspaceQuery.trim()) { setWorkspaceSearchEntries([]); setWorkspaceSearchLoading(false); return; } void loadWorkspaceSearchEntries(selectedBotId, workspaceCurrentPath); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedBotId, workspaceCurrentPath, workspaceQuery]); const saveBot = async (mode: 'params' | 'agent' | 'base') => { const targetBotId = String(selectedBot?.id || selectedBotId || '').trim(); if (!targetBotId) { notify(isZh ? '未选中 Bot,无法保存。' : 'No bot selected.', { tone: 'warning' }); return; } setIsSaving(true); try { const payload: Record = {}; if (mode === 'base') { payload.name = editForm.name; payload.access_password = editForm.access_password; payload.image_tag = editForm.image_tag; const selectedImageOption = baseImageOptions.find((opt) => opt.tag === editForm.image_tag); if (selectedImageOption?.disabled) { throw new Error(isZh ? '当前镜像不可用,请选择可用镜像。' : 'Selected image is unavailable.'); } if (selectedImageOption?.needsRegister) { await axios.post(`${APP_ENDPOINTS.apiBase}/images/register`, { tag: editForm.image_tag, source_dir: 'manual', }); } const normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores)); const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb)); const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb)); payload.cpu_cores = normalizedCpuCores; payload.memory_mb = normalizedMemoryMb; payload.storage_gb = normalizedStorageGb; setEditForm((p) => ({ ...p, cpu_cores: normalizedCpuCores, memory_mb: normalizedMemoryMb, storage_gb: normalizedStorageGb, })); setParamDraft((p) => ({ ...p, cpu_cores: String(normalizedCpuCores), memory_mb: String(normalizedMemoryMb), storage_gb: String(normalizedStorageGb), })); } if (mode === 'params') { 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(); payload.temperature = clampTemperature(Number(editForm.temperature)); payload.top_p = Number(editForm.top_p); const normalizedMaxTokens = clampMaxTokens(Number(paramDraft.max_tokens)); payload.max_tokens = normalizedMaxTokens; setEditForm((p) => ({ ...p, max_tokens: normalizedMaxTokens, })); setParamDraft((p) => ({ ...p, max_tokens: String(normalizedMaxTokens) })); } 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/${targetBotId}`, 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) => ({ 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' }); } }; 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 ? (

    {normalizedBotListQuery ? `${t.titleBots} (${filteredBots.length}/${bots.length})` : `${t.titleBots} (${bots.length})`}

    setBotListQuery(e.target.value)} placeholder={t.botSearchPlaceholder} aria-label={t.botSearchPlaceholder} autoComplete="off" autoCorrect="off" autoCapitalize="none" spellCheck={false} name="bot-search" />
    {pagedBots.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)}>
    ); })} {filteredBots.length === 0 ? (
    {t.botSearchNoResult}
    ) : null}
    setBotListPage((p) => Math.max(1, p - 1))} disabled={botListPage <= 1} tooltip={t.paginationPrev} aria-label={t.paginationPrev} >
    {t.paginationPage(botListPage, botListTotalPages)}
    setBotListPage((p) => Math.min(botListTotalPages, p + 1))} disabled={botListPage >= botListTotalPages} tooltip={t.paginationNext} aria-label={t.paginationNext} >
    ) : null}
    {selectedBot ? (
    {conversation.length === 0 ? (
    {t.noConversation}
    ) : ( conversationNodes )} {isThinking ? (
    Nanobot
    {t.thinking}
    ) : null}
    {(quotedReply || pendingAttachments.length > 0) ? (
    {quotedReply ? (
    {t.quotedReplyLabel}
    {normalizeAssistantMessageText(quotedReply.text)}
    ) : null} {pendingAttachments.length > 0 ? (
    {pendingAttachments.map((p) => ( {(() => { const filePath = normalizeDashboardAttachmentPath(p); const fileAction = workspaceFileAction(filePath); const filename = filePath.split('/').pop() || filePath; return ( { event.preventDefault(); event.stopPropagation(); void openWorkspacePathFromChat(filePath); }} > {fileAction === 'download' ? ( ) : fileAction === 'preview' ? ( ) : ( )} {filename} ); })()} ))}
    ) : null}
    ) : null}