import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type ImgHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'; import axios from 'axios'; import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Command, Copy, Download, EllipsisVertical, ExternalLink, Eye, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, 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, { defaultSchema } 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'; import { LucentSelect } from '../../components/lucent/LucentSelect'; import { PasswordInput } from '../../components/PasswordInput'; import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel'; import type { BotSkillMarketItem } from '../platform/types'; import { SkillMarketInstallModal } from './components/SkillMarketInstallModal'; import { normalizePlatformPageSize, readCachedPlatformPageSize, writeCachedPlatformPageSize, } from '../../utils/platformPageSize'; 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' | 'email'; type RuntimeViewMode = 'visual' | 'topic'; type CompactPanelTab = 'chat' | 'runtime'; type QuotedReply = { id?: number; text: string; ts: number }; 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 BotMessagesByDateResponse { items?: any[]; anchor_id?: number | null; resolved_ts?: number | null; matched_exact_date?: boolean; has_more_before?: boolean; has_more_after?: boolean; } 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 MCPServerConfig { type?: 'streamableHttp' | 'sse' | string; url?: string; headers?: Record; toolTimeout?: number; locked?: boolean; } interface MCPConfigResponse { bot_id: string; mcp_servers?: Record; locked_servers?: string[]; restart_required?: boolean; status?: string; } interface MCPServerDraft { name: string; type: 'streamableHttp' | 'sse'; url: string; botId: string; botSecret: string; toolTimeout: string; headers: Record; locked: boolean; originName?: string; } 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 BotTopic { id: string | number; bot_id: string; topic_key: string; name: string; description: string; is_active: boolean; routing?: Record; view_schema?: Record; routing_purpose?: string; routing_include_when?: string; routing_exclude_when?: string; routing_examples_positive?: string; routing_examples_negative?: string; routing_priority?: string; created_at?: string; updated_at?: string; } interface TopicFeedListResponse { bot_id: string; topic_key?: string | null; items: TopicFeedItem[]; next_cursor?: number | null; unread_count?: number; total_unread_count?: number; } interface TopicFeedStatsResponse { bot_id: string; total_count: number; unread_count: number; latest_item_id?: number | null; } interface NanobotImage { tag: string; status: string; } interface BaseImageOption { tag: string; label: string; disabled: 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 MarketSkillInstallResponse { status: string; bot_id: string; skill_market_item_id: number; installed: string[]; skills: WorkspaceSkillOption[]; } interface SystemDefaultsResponse { limits?: { upload_max_mb?: number; }; workspace?: { allowed_attachment_extensions?: unknown; download_extensions?: unknown; }; chat?: { page_size?: number; pull_page_size?: number; }; topic_presets?: unknown; speech?: { enabled?: boolean; model?: string; device?: string; max_audio_seconds?: number; }; } interface TopicPresetTemplate { id: string; topic_key: string; name?: unknown; description?: unknown; routing_purpose?: unknown; routing_include_when?: unknown; routing_exclude_when?: unknown; routing_examples_positive?: unknown; routing_examples_negative?: unknown; routing_priority?: number; } type BotEnvParams = Record; const DEFAULT_TOPIC_PRESET_TEMPLATES: TopicPresetTemplate[] = [ { id: 'politics', topic_key: 'politics_news', name: { 'zh-cn': '时政新闻', en: 'Politics News' }, description: { 'zh-cn': '沉淀国内外时政动态、政策发布与重大公共治理事件,便于集中查看。', en: 'Track politics, policy releases, and major public governance events.', }, routing_purpose: { 'zh-cn': '收录与政府决策、政策法规、外交事务及公共治理相关的关键信息。', en: 'Capture key information related to government decisions, policy, diplomacy, and governance.', }, routing_include_when: ['时政', '政策', '法规', '国务院', '政府', '部委', '人大', '政协', '外交', '国际关系', '白宫', '总统', '议会', 'election', 'policy'], routing_exclude_when: ['娱乐', '明星', '综艺', '体育', '游戏', '购物', '种草', '广告'], routing_examples_positive: ['国务院发布新一轮宏观政策措施。', '外交部就国际热点事件发布声明。', '某国总统宣布新的对外政策方向。'], routing_examples_negative: ['某明星新剧开播引发热议。', '某球队转会新闻与赛果分析。', '数码产品促销与购物推荐汇总。'], routing_priority: 85, }, { id: 'finance', topic_key: 'finance_market', name: { 'zh-cn': '财经信息', en: 'Finance & Market' }, description: { 'zh-cn': '聚合宏观经济、市场波动、公司财报与监管政策等财经信息。', en: 'Aggregate macroeconomics, market moves, company earnings, and regulatory updates.', }, routing_purpose: { 'zh-cn': '沉淀与资本市场、行业景气、资产价格相关的关键结论与风险提示。', en: 'Capture key insights and risk alerts related to capital markets and asset prices.', }, routing_include_when: ['财经', '金融', '股市', 'A股', '港股', '美股', '债券', '汇率', '利率', '通胀', 'GDP', '财报', '央行', 'market', 'earnings'], routing_exclude_when: ['娱乐', '体育', '游戏', '影视', '八卦', '生活方式', '旅行攻略'], routing_examples_positive: ['央行公布最新利率决议并释放政策信号。', '上市公司发布季度财报并上调全年指引。', '美元指数走强导致主要货币普遍承压。'], routing_examples_negative: ['某综艺节目收视排名变化。', '某球员转会传闻引发讨论。', '新游上线玩法测评。'], routing_priority: 80, }, { id: 'tech', topic_key: 'tech_updates', name: { 'zh-cn': '技术资讯', en: 'Tech Updates' }, description: { 'zh-cn': '追踪 AI、云计算、开源社区与开发工具链的最新技术资讯。', en: 'Track updates across AI, cloud, open-source ecosystems, and developer tooling.', }, routing_purpose: { 'zh-cn': '沉淀技术发布、版本升级、兼容性变更与工程实践建议。', en: 'Capture releases, version upgrades, compatibility changes, and engineering guidance.', }, routing_include_when: ['技术', '开源', 'AI', '模型', '大语言模型', 'MCP', 'API', 'SDK', '发布', '版本', '升级', 'breaking change', 'security advisory'], routing_exclude_when: ['娱乐', '体育', '美食', '旅游', '情感', '八卦'], routing_examples_positive: ['某主流框架发布新版本并调整默认配置。', '开源项目披露高危安全漏洞并给出修复方案。', 'AI 模型服务更新 API,返回结构发生变化。'], routing_examples_negative: ['某艺人参加活动造型盘点。', '旅游目的地打卡攻略合集。', '比赛结果预测与竞猜。'], routing_priority: 75, }, ]; 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', }, }, xunfei: { model: 'astron-code-latest', apiBase: 'https://spark-api-open.xf-yun.com/v1', note: { 'zh-cn': '讯飞星火(OpenAI 兼容)接口,模型示例 astron-code-latest', en: 'Xunfei Spark (OpenAI-compatible), model example: astron-code-latest', }, }, }; const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack', 'email']; const RUNTIME_STALE_MS = 45000; const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']); 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 formatDateInputValue(ts: number): string { const d = new Date(ts); if (Number.isNaN(d.getTime())) return ''; const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } function mapBotMessageResponseRow(row: any): ChatMessage { 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; } function stateLabel(s?: string) { return (s || 'IDLE').toUpperCase(); } function resolvePresetText(raw: unknown, locale: 'zh-cn' | 'en'): string { if (typeof raw === 'string') return raw.trim(); if (!raw || typeof raw !== 'object') return ''; const bag = raw as Record; const byLocale = String(bag[locale] || '').trim(); if (byLocale) return byLocale; return String(bag['zh-cn'] || bag.en || '').trim(); } function normalizePresetTextList(raw: unknown): string[] { if (!Array.isArray(raw)) return []; const rows: string[] = []; raw.forEach((item) => { const text = String(item || '').trim(); if (text) rows.push(text); }); return rows; } function parseTopicPresets(raw: unknown): TopicPresetTemplate[] { if (!Array.isArray(raw)) return []; const rows: TopicPresetTemplate[] = []; raw.forEach((item) => { if (!item || typeof item !== 'object') return; const record = item as Record; const id = String(record.id || '').trim().toLowerCase(); const topicKey = String(record.topic_key || '').trim().toLowerCase(); if (!id || !topicKey) return; const priority = Number(record.routing_priority); rows.push({ id, topic_key: topicKey, name: record.name, description: record.description, routing_purpose: record.routing_purpose, routing_include_when: record.routing_include_when, routing_exclude_when: record.routing_exclude_when, routing_examples_positive: record.routing_examples_positive, routing_examples_negative: record.routing_examples_negative, routing_priority: Number.isFinite(priority) ? Math.max(0, Math.min(100, Math.round(priority))) : undefined, }); }); return rows; } function isSystemFallbackTopic(topic: Pick): boolean { const key = String(topic.topic_key || '').trim().toLowerCase(); if (!SYSTEM_FALLBACK_TOPIC_KEYS.has(key)) return false; const routing = topic.routing && typeof topic.routing === 'object' ? topic.routing : {}; const purpose = String((routing as Record).purpose || '').trim().toLowerCase(); const desc = String(topic.description || '').trim().toLowerCase(); const name = String(topic.name || '').trim().toLowerCase(); const priority = Number((routing as Record).priority); if (purpose.includes('fallback')) return true; if (desc.includes('default topic')) return true; if (name === 'inbox') return true; if (Number.isFinite(priority) && priority <= 1) return true; return false; } 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; } const TEXT_PREVIEW_EXTENSIONS = new Set(['.md', '.json', '.log', '.txt', '.csv']); const HTML_PREVIEW_EXTENSIONS = new Set(['.html', '.htm']); const IMAGE_PREVIEW_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']); const AUDIO_PREVIEW_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma']); const VIDEO_PREVIEW_EXTENSIONS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts']); const DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS = [ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps', '.glb', ]; const DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET = new Set(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS); function normalizeWorkspaceExtension(raw: unknown): string { const value = String(raw ?? '').trim().toLowerCase(); if (!value) return ''; const stripped = value.replace(/^\*\./, ''); const normalized = stripped.startsWith('.') ? stripped : `.${stripped}`; return /^\.[a-z0-9][a-z0-9._+-]{0,31}$/.test(normalized) ? normalized : ''; } function parseWorkspaceDownloadExtensions( raw: unknown, fallback: readonly string[] = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, ): string[] { if (raw === null || raw === undefined) return [...fallback]; if (Array.isArray(raw) && raw.length === 0) return []; if (typeof raw === 'string' && raw.trim() === '') return []; const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/); const rows: string[] = []; source.forEach((item) => { const ext = normalizeWorkspaceExtension(item); if (ext && !rows.includes(ext)) rows.push(ext); }); return rows; } function parseAllowedAttachmentExtensions(raw: unknown): string[] { if (raw === null || raw === undefined) return []; if (Array.isArray(raw) && raw.length === 0) return []; if (typeof raw === 'string' && raw.trim() === '') return []; const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/); const rows: string[] = []; source.forEach((item) => { const ext = normalizeWorkspaceExtension(item); if (ext && !rows.includes(ext)) rows.push(ext); }); return rows; } function pathHasExtension(path: string, extensions: ReadonlySet): boolean { const normalized = String(path || '').trim().toLowerCase(); if (!normalized) return false; for (const ext of extensions) { if (normalized.endsWith(ext)) return true; } return false; } function isDownloadOnlyPath(path: string, downloadExtensions: ReadonlySet = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET) { return pathHasExtension(path, downloadExtensions); } function isPreviewableWorkspaceFile( node: WorkspaceNode, downloadExtensions: ReadonlySet = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET, ) { if (node.type !== 'file') return false; return isPreviewableWorkspacePath(node.path, downloadExtensions); } function isImagePath(path: string) { return pathHasExtension(path, IMAGE_PREVIEW_EXTENSIONS); } function isVideoPath(path: string) { return pathHasExtension(path, VIDEO_PREVIEW_EXTENSIONS); } function isAudioPath(path: string) { return pathHasExtension(path, AUDIO_PREVIEW_EXTENSIONS); } 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) { return pathHasExtension(path, HTML_PREVIEW_EXTENSIONS); } function isPreviewableWorkspacePath( path: string, downloadExtensions: ReadonlySet = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET, ) { if (isDownloadOnlyPath(path, downloadExtensions)) return true; return ( pathHasExtension(path, TEXT_PREVIEW_EXTENSIONS) || isHtmlPath(path) || isImagePath(path) || isAudioPath(path) || isVideoPath(path) ); } function workspaceFileAction( path: string, downloadExtensions: ReadonlySet = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET, ): 'preview' | 'download' | 'unsupported' { const normalized = String(path || '').trim(); if (!normalized) return 'unsupported'; if (isDownloadOnlyPath(normalized, downloadExtensions)) return 'download'; if (isImagePath(normalized) || isHtmlPath(normalized) || isVideoPath(normalized) || isAudioPath(normalized)) return 'preview'; if (pathHasExtension(normalized, TEXT_PREVIEW_EXTENSIONS)) return 'preview'; return 'unsupported'; } const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/'; const WORKSPACE_ABS_PATH_PATTERN = /\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.[a-z0-9][a-z0-9._+-]{0,31}\b/gi; const WORKSPACE_RELATIVE_PATH_PATTERN = /(^|[\s(\[])(\/[^\n\r<>"'`)\]]+?\.[a-z0-9][a-z0-9._+-]{0,31})(?![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 renderWorkspacePathSegments(pathRaw: string, keyPrefix: string): ReactNode[] { const path = String(pathRaw || ''); if (!path) return ['-']; const normalized = path.replace(/\\/g, '/'); const hasLeadingSlash = normalized.startsWith('/'); const parts = normalized.split('/').filter((part) => part.length > 0); const nodes: ReactNode[] = []; if (hasLeadingSlash) { nodes.push(/); } parts.forEach((part, index) => { if (index > 0) { nodes.push(/); } nodes.push({part}); }); return nodes.length > 0 ? nodes : ['-']; } const MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, tagNames: [...new Set([...(defaultSchema.tagNames || []), 'audio', 'source', 'video'])], attributes: { ...defaultSchema.attributes, audio: [...((defaultSchema.attributes?.audio as string[] | undefined) || []), 'autoplay', 'controls', 'loop', 'muted', 'preload', 'src'], source: [...((defaultSchema.attributes?.source as string[] | undefined) || []), 'media', 'src', 'type'], video: [...((defaultSchema.attributes?.video as string[] | undefined) || []), 'autoplay', 'controls', 'height', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'src', 'width'], }, }; function resolveWorkspaceDocumentPath(targetRaw: string, baseFilePath?: string): string | null { const target = String(targetRaw || '').trim(); if (!target || target.startsWith('#')) return null; const linkedPath = parseWorkspaceLink(target); if (linkedPath) return linkedPath; if (target.startsWith('/root/.nanobot/workspace/')) { return normalizeDashboardAttachmentPath(target); } const lower = target.toLowerCase(); if ( lower.startsWith('blob:') || lower.startsWith('data:') || lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('javascript:') || lower.startsWith('mailto:') || lower.startsWith('tel:') || target.startsWith('//') ) { return null; } const normalizedBase = normalizeDashboardAttachmentPath(baseFilePath || ''); if (!normalizedBase) { return null; } try { const baseUrl = new URL(`https://workspace.local/${normalizedBase}`); const resolvedUrl = new URL(target, baseUrl); if (resolvedUrl.origin !== 'https://workspace.local') return null; try { return normalizeDashboardAttachmentPath(decodeURIComponent(resolvedUrl.pathname)); } catch { return normalizeDashboardAttachmentPath(resolvedUrl.pathname); } } catch { return null; } } function decorateWorkspacePathsInPlainChunk(source: string): string { if (!source) return source; const protectedLinks: string[] = []; const withProtectedAbsoluteLinks = source.replace(WORKSPACE_ABS_PATH_PATTERN, (fullPath) => { const normalized = normalizeDashboardAttachmentPath(fullPath); if (!normalized) return fullPath; const token = `@@WS_PATH_LINK_${protectedLinks.length}@@`; protectedLinks.push(`[${fullPath}](${buildWorkspaceLink(normalized)})`); return token; }); const withRelativeLinks = withProtectedAbsoluteLinks.replace( WORKSPACE_RELATIVE_PATH_PATTERN, (full, prefix: string, rawPath: string) => { const normalized = normalizeDashboardAttachmentPath(rawPath); if (!normalized) return full; return `${prefix}[${rawPath}](${buildWorkspaceLink(normalized)})`; }, ); return withRelativeLinks.replace(/@@WS_PATH_LINK_(\d+)@@/g, (_full, idxRaw: string) => { const idx = Number(idxRaw); if (!Number.isFinite(idx) || idx < 0 || idx >= protectedLinks.length) return String(_full || ''); return protectedLinks[idx]; }); } function decorateWorkspacePathsForMarkdown(text: string) { const source = String(text || ''); if (!source) return source; // Keep existing Markdown links unchanged; only decorate plain text segments. const markdownLinkPattern = /\[[^\]]*?\]\((?:[^)(]|\([^)(]*\))*\)/g; let result = ''; let last = 0; let match = markdownLinkPattern.exec(source); while (match) { const idx = Number(match.index || 0); if (idx > last) { result += decorateWorkspacePathsInPlainChunk(source.slice(last, idx)); } result += match[0]; last = idx + match[0].length; match = markdownLinkPattern.exec(source); } if (last < source.length) { result += decorateWorkspacePathsInPlainChunk(source.slice(last)); } return result; } 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, '/') .replace(/^['"`([<{]+/, '') .replace(/['"`)\]>}.,,。!?;:]+$/, ''); if (!v) return ''; const prefix = '/root/.nanobot/workspace/'; if (v.startsWith(prefix)) return v.slice(prefix.length); return v.replace(/^\/+/, ''); } const COMPOSER_DRAFT_STORAGE_PREFIX = 'nanobot-dashboard-composer-draft:v1:'; interface ComposerDraftStorage { command: string; attachments: string[]; updated_at_ms: number; } function getComposerDraftStorageKey(botId: string): string { return `${COMPOSER_DRAFT_STORAGE_PREFIX}${String(botId || '').trim()}`; } function loadComposerDraft(botId: string): ComposerDraftStorage | null { const id = String(botId || '').trim(); if (!id || typeof window === 'undefined') return null; try { const raw = window.localStorage.getItem(getComposerDraftStorageKey(id)); if (!raw) return null; const parsed = JSON.parse(raw) as Partial | null; const command = String(parsed?.command || ''); const attachments = normalizeAttachmentPaths(parsed?.attachments) .map(normalizeDashboardAttachmentPath) .filter(Boolean); return { command, attachments, updated_at_ms: Number(parsed?.updated_at_ms || Date.now()), }; } catch { return null; } } function persistComposerDraft(botId: string, commandRaw: string, attachmentsRaw: string[]): void { const id = String(botId || '').trim(); if (!id || typeof window === 'undefined') return; const command = String(commandRaw || ''); const attachments = normalizeAttachmentPaths(attachmentsRaw) .map(normalizeDashboardAttachmentPath) .filter(Boolean); const key = getComposerDraftStorageKey(id); try { if (!command.trim() && attachments.length === 0) { window.localStorage.removeItem(key); return; } const payload: ComposerDraftStorage = { command, attachments, updated_at_ms: Date.now(), }; window.localStorage.setItem(key, JSON.stringify(payload)); } catch { // ignore localStorage write failures } } 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 [speechEnabled, setSpeechEnabled] = useState(true); const [voiceMaxSeconds, setVoiceMaxSeconds] = useState(20); const [isVoiceRecording, setIsVoiceRecording] = useState(false); const [isVoiceTranscribing, setIsVoiceTranscribing] = useState(false); const [voiceCountdown, setVoiceCountdown] = useState(20); const [isSaving, setIsSaving] = useState(false); const [showBaseModal, setShowBaseModal] = useState(false); const [showParamModal, setShowParamModal] = useState(false); const [showChannelModal, setShowChannelModal] = useState(false); const [showTopicModal, setShowTopicModal] = useState(false); const [showSkillsModal, setShowSkillsModal] = useState(false); const [showSkillMarketInstallModal, setShowSkillMarketInstallModal] = useState(false); const [skillAddMenuOpen, setSkillAddMenuOpen] = useState(false); const [showMcpModal, setShowMcpModal] = 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 chatScrollRef = useRef(null); const chatAutoFollowRef = useRef(true); 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 [workspacePreviewEditing, setWorkspacePreviewEditing] = useState(false); const [workspacePreviewSaving, setWorkspacePreviewSaving] = useState(false); const [workspacePreviewDraft, setWorkspacePreviewDraft] = useState(''); const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false); const [workspaceQuery, setWorkspaceQuery] = useState(''); const [pendingAttachments, setPendingAttachments] = useState([]); const [composerDraftHydrated, setComposerDraftHydrated] = useState(false); 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 [expandedChannelByKey, setExpandedChannelByKey] = useState>({}); const [newChannelPanelOpen, setNewChannelPanelOpen] = useState(false); const [channelCreateMenuOpen, setChannelCreateMenuOpen] = useState(false); const channelCreateMenuRef = useRef(null); const [newChannelDraft, setNewChannelDraft] = useState({ id: 'draft-channel', bot_id: '', channel_type: 'feishu', external_app_id: '', app_secret: '', internal_port: 8080, is_active: true, extra_config: {}, }); const [topics, setTopics] = useState([]); const [expandedTopicByKey, setExpandedTopicByKey] = useState>({}); const [newTopicPanelOpen, setNewTopicPanelOpen] = useState(false); const [topicPresetTemplates, setTopicPresetTemplates] = useState([]); const [newTopicSource, setNewTopicSource] = useState(''); const [topicPresetMenuOpen, setTopicPresetMenuOpen] = useState(false); const topicPresetMenuRef = useRef(null); const [newTopicAdvancedOpen, setNewTopicAdvancedOpen] = useState(false); const [newTopicKey, setNewTopicKey] = useState(''); const [newTopicName, setNewTopicName] = useState(''); const [newTopicDescription, setNewTopicDescription] = useState(''); const [newTopicPurpose, setNewTopicPurpose] = useState(''); const [newTopicIncludeWhen, setNewTopicIncludeWhen] = useState(''); const [newTopicExcludeWhen, setNewTopicExcludeWhen] = useState(''); const [newTopicExamplesPositive, setNewTopicExamplesPositive] = useState(''); const [newTopicExamplesNegative, setNewTopicExamplesNegative] = useState(''); const [newTopicPriority, setNewTopicPriority] = useState('50'); const [botSkills, setBotSkills] = useState([]); const [marketSkills, setMarketSkills] = useState([]); const [isSkillUploading, setIsSkillUploading] = useState(false); const [isMarketSkillsLoading, setIsMarketSkillsLoading] = useState(false); const [marketSkillInstallingId, setMarketSkillInstallingId] = useState(null); const skillZipPickerRef = useRef(null); const skillAddMenuRef = useRef(null); const [envParams, setEnvParams] = useState({}); const [mcpServers, setMcpServers] = useState([]); const [persistedMcpServers, setPersistedMcpServers] = useState([]); const [newMcpPanelOpen, setNewMcpPanelOpen] = useState(false); const [newMcpDraft, setNewMcpDraft] = useState({ name: '', type: 'streamableHttp', url: '', botId: '', botSecret: '', toolTimeout: '60', headers: {}, locked: false, originName: '', }); const [expandedMcpByKey, setExpandedMcpByKey] = useState>({}); const [envDraftKey, setEnvDraftKey] = useState(''); const [envDraftValue, setEnvDraftValue] = useState(''); const [isSavingChannel, setIsSavingChannel] = useState(false); const [isSavingTopic, setIsSavingTopic] = useState(false); const [isSavingMcp, setIsSavingMcp] = useState(false); const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false); const [isBatchOperating, setIsBatchOperating] = useState(false); const [availableImages, setAvailableImages] = useState([]); const [controlCommandByBot, setControlCommandByBot] = useState>({}); const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({ sendProgress: false, sendToolHints: false, }); const [uploadMaxMb, setUploadMaxMb] = useState(100); const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState([]); const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10)); const [botListPageSizeReady, setBotListPageSizeReady] = useState( () => readCachedPlatformPageSize(0) > 0, ); const [chatPullPageSize, setChatPullPageSize] = useState(60); const [chatHasMore, setChatHasMore] = useState(false); const [chatLoadingMore, setChatLoadingMore] = useState(false); const [chatDatePickerOpen, setChatDatePickerOpen] = useState(false); const [chatDateValue, setChatDateValue] = useState(''); const [chatDateJumping, setChatDateJumping] = useState(false); const [chatJumpAnchorId, setChatJumpAnchorId] = useState(null); const [chatDatePanelPosition, setChatDatePanelPosition] = useState<{ bottom: number; right: number } | null>(null); const [workspaceDownloadExtensions, setWorkspaceDownloadExtensions] = useState( DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, ); const [runtimeViewMode, setRuntimeViewMode] = useState('visual'); const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); const [botListMenuOpen, setBotListMenuOpen] = useState(false); const [topicFeedTopicKey, setTopicFeedTopicKey] = useState('__all__'); const [topicFeedItems, setTopicFeedItems] = useState([]); const [topicFeedNextCursor, setTopicFeedNextCursor] = useState(null); const [topicFeedLoading, setTopicFeedLoading] = useState(false); const [topicFeedLoadingMore, setTopicFeedLoadingMore] = useState(false); const [topicFeedError, setTopicFeedError] = useState(''); const [topicFeedReadSavingById, setTopicFeedReadSavingById] = useState>({}); const [topicFeedDeleteSavingById, setTopicFeedDeleteSavingById] = useState>({}); const [topicFeedUnreadCount, setTopicFeedUnreadCount] = useState(0); const [topicDetailOpen, setTopicDetailOpen] = 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 [expandedUserByKey, setExpandedUserByKey] = useState>({}); const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState>({}); const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false); const [showTemplateModal, setShowTemplateModal] = useState(false); const [templateTab, setTemplateTab] = useState<'agent' | 'topic'>('agent'); const [isLoadingTemplates, setIsLoadingTemplates] = useState(false); const [isSavingTemplates, setIsSavingTemplates] = useState(false); const [templateAgentText, setTemplateAgentText] = useState(''); const [templateTopicText, setTemplateTopicText] = useState(''); const [controlCommandPanelOpen, setControlCommandPanelOpen] = useState(false); const [workspaceHoverCard, setWorkspaceHoverCard] = useState(null); const botSearchInputName = useMemo( () => `nbot-search-${Math.random().toString(36).slice(2, 10)}`, [], ); const workspaceSearchInputName = useMemo( () => `nbot-workspace-search-${Math.random().toString(36).slice(2, 10)}`, [], ); const voiceRecorderRef = useRef(null); const voiceStreamRef = useRef(null); const voiceChunksRef = useRef([]); const voiceTimerRef = useRef(null); const runtimeMenuRef = useRef(null); const botListMenuRef = useRef(null); const controlCommandPanelRef = useRef(null); const chatDateTriggerRef = 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)), }); }, []); 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 buildWorkspaceRawHref = (filePath: string, forceDownload: boolean = false) => { const normalized = String(filePath || '') .trim() .split('/') .filter(Boolean) .map((part) => encodeURIComponent(part)) .join('/'); if (!normalized) return ''; const base = `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/raw/${normalized}`; return forceDownload ? `${base}?download=1` : base; }; const buildWorkspacePreviewHref = (filePath: string) => { const normalized = String(filePath || '').trim(); if (!normalized) return ''; return isHtmlPath(normalized) ? buildWorkspaceRawHref(normalized, false) : buildWorkspaceDownloadHref(normalized, false); }; const closeWorkspacePreview = () => { setWorkspacePreview(null); setWorkspacePreviewFullscreen(false); setWorkspacePreviewEditing(false); setWorkspacePreviewSaving(false); setWorkspacePreviewDraft(''); }; useEffect(() => { if (!workspacePreview) { setWorkspacePreviewEditing(false); setWorkspacePreviewSaving(false); setWorkspacePreviewDraft(''); return; } setWorkspacePreviewEditing(false); setWorkspacePreviewSaving(false); setWorkspacePreviewDraft(workspacePreview.content || ''); }, [workspacePreview?.path, workspacePreview?.content]); 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 = buildWorkspacePreviewHref(normalized); const href = (() => { try { return new URL(hrefRaw, window.location.origin).href; } catch { return hrefRaw; } })(); try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(href); } else { const ta = document.createElement('textarea'); ta.value = href; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } notify(t.urlCopied, { tone: 'success' }); } catch { notify(t.urlCopyFail, { tone: 'error' }); } }; const copyWorkspacePreviewPath = async (filePath: string) => { const normalized = String(filePath || '').trim(); if (!normalized) return; await copyTextToClipboard( normalized, isZh ? '文件路径已复制' : 'File path copied', isZh ? '文件路径复制失败' : 'Failed to copy file path', ); }; const copyTextToClipboard = async (textRaw: string, successMsg: string, failMsg: string) => { const text = String(textRaw || ''); if (!text.trim()) return; try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); } else { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } notify(successMsg, { tone: 'success' }); } catch { notify(failMsg, { tone: 'error' }); } }; const openWorkspacePathFromChat = async (path: string) => { const normalized = String(path || '').trim(); if (!normalized) return; const action = workspaceFileAction(normalized, workspaceDownloadExtensionSet); if (action === 'download') { triggerWorkspaceFileDownload(normalized); return; } if (action === 'preview') { void openWorkspaceFilePreview(normalized); return; } try { await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/tree`, { params: { path: normalized }, }); await loadWorkspaceTree(selectedBotId, normalized); return; } catch { if (!isPreviewableWorkspacePath(normalized, workspaceDownloadExtensionSet) || action === 'unsupported') { notify(fileNotPreviewableLabel, { tone: 'warning' }); return; } } }; const resolveWorkspaceMediaSrc = useCallback((srcRaw: string, baseFilePath?: string): string => { const src = String(srcRaw || '').trim(); if (!src || !selectedBotId) return src; const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath); if (resolvedWorkspacePath) { return buildWorkspacePreviewHref(resolvedWorkspacePath); } const lower = src.toLowerCase(); if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) { return src; } return src; }, [selectedBotId]); const transformWorkspacePreviewMarkdownUrl = (url: string, key: string): string => { if (!workspacePreview?.isMarkdown || !selectedBotId) return url; const resolvedWorkspacePath = resolveWorkspaceDocumentPath(url, workspacePreview.path); if (!resolvedWorkspacePath) return url; if (key === 'href') { return buildWorkspaceLink(resolvedWorkspacePath); } return buildWorkspacePreviewHref(resolvedWorkspacePath); }; 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\/[^\n\r<>"'`]+?\.[a-z0-9][a-z0-9._+-]{0,31}\b|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} ); }, img: ({ src, alt, ...props }: ImgHTMLAttributes) => { const resolvedSrc = resolveWorkspaceMediaSrc(String(src || '')); return ( {String(alt ); }, 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, resolveWorkspaceMediaSrc, 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 hasForcedBot = Boolean(String(forcedBotId || '').trim()); const compactListFirstMode = compactMode && !hasForcedBot; const isCompactListPage = compactListFirstMode && !selectedBotId; const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId); const showBotListPanel = !hasForcedBot && (!compactMode || isCompactListPage); const normalizedBotListQuery = botListQuery.trim().toLowerCase(); const filteredBots = useMemo(() => { if (!normalizedBotListQuery) return bots; return bots.filter((bot) => { const id = String(bot.id || '').toLowerCase(); const name = String(bot.name || '').toLowerCase(); return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery); }); }, [bots, normalizedBotListQuery]); const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / botListPageSize)); const pagedBots = useMemo(() => { const page = Math.min(Math.max(1, botListPage), botListTotalPages); const start = (page - 1) * botListPageSize; return filteredBots.slice(start, start + botListPageSize); }, [filteredBots, botListPage, botListTotalPages, botListPageSize]); const 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 passwordToggleLabels = isZh ? { show: '显示密码', hide: '隐藏密码' } : { show: 'Show password', hide: 'Hide password' }; const activeTopicOptions = useMemo( () => topics .filter((topic) => Boolean(topic.is_active)) .map((topic) => ({ key: String(topic.topic_key || '').trim().toLowerCase(), label: String(topic.name || topic.topic_key || '').trim(), })) .filter((row) => Boolean(row.key)) .sort((a, b) => a.key.localeCompare(b.key)), [topics], ); const topicPanelState = useMemo<'none' | 'inactive' | 'ready'>(() => { if (topics.length === 0) return 'none'; if (activeTopicOptions.length === 0) return 'inactive'; return 'ready'; }, [activeTopicOptions, topics]); const lc = isZh ? channelsZhCn : channelsEn; const baseImageOptions = useMemo(() => { const imagesByTag = new Map(); availableImages.forEach((img) => { const tag = String(img.tag || '').trim(); if (!tag || imagesByTag.has(tag)) return; imagesByTag.set(tag, img); }); const options = Array.from(imagesByTag.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([tag, img]) => { const status = String(img.status || '').toUpperCase() || 'UNKNOWN'; return { tag, label: `${tag} · ${status}`, disabled: status !== 'READY', }; }); const currentTag = String(editForm.image_tag || '').trim(); if (currentTag && !options.some((opt) => opt.tag === currentTag)) { options.unshift({ tag: currentTag, label: isZh ? `${currentTag} · 未登记(只读)` : `${currentTag} · unregistered (read-only)`, disabled: true, }); } return options; }, [availableImages, editForm.image_tag, isZh]); const runtimeMoreLabel = isZh ? '更多' : 'More'; const effectiveTopicPresetTemplates = useMemo( () => (topicPresetTemplates.length > 0 ? topicPresetTemplates : DEFAULT_TOPIC_PRESET_TEMPLATES), [topicPresetTemplates], ); const newTopicSourceLabel = useMemo(() => { if (newTopicSource === 'blank') return t.topicPresetBlank; const source = effectiveTopicPresetTemplates.find((row) => row.id === newTopicSource); if (!source) return t.topicPresetBlank; return resolvePresetText(source.name, isZh ? 'zh-cn' : 'en') || source.topic_key || source.id; }, [effectiveTopicPresetTemplates, isZh, newTopicSource, t.topicPresetBlank]); const templateAgentCount = useMemo(() => { try { const parsed = JSON.parse(templateAgentText || "{}"); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 5; const row = parsed as Record; return ["agents_md", "soul_md", "user_md", "tools_md", "identity_md"].filter((k) => Object.prototype.hasOwnProperty.call(row, k), ).length || 5; } catch { return 5; } }, [templateAgentText]); const templateTopicCount = useMemo(() => { try { const parsed = JSON.parse(templateTopicText || '{"presets":[]}') as Record; const rows = parsed?.presets; if (Array.isArray(rows)) return rows.length; return effectiveTopicPresetTemplates.length; } catch { return effectiveTopicPresetTemplates.length; } }, [templateTopicText, effectiveTopicPresetTemplates.length]); const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined; const selectedBotEnabled = Boolean(selectedBot && selectedBot.enabled !== false); const isSending = selectedBot ? Boolean(sendingByBot[selectedBot.id]) : false; const canChat = Boolean(selectedBotEnabled && selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState); const isChatEnabled = Boolean(canChat && !isSending); const activeControlCommand = selectedBot ? controlCommandByBot[selectedBot.id] || '' : ''; const canSendControlCommand = Boolean(selectedBot && canChat && !isVoiceRecording && !isVoiceTranscribing); const conversation = useMemo(() => mergeConversation(messages), [messages]); const latestEvent = useMemo(() => [...events].reverse()[0], [events]); const workspaceDownloadExtensionSet = useMemo( () => new Set(parseWorkspaceDownloadExtensions(workspaceDownloadExtensions)), [workspaceDownloadExtensions], ); const workspaceFiles = useMemo( () => workspaceEntries.filter((v) => v.type === 'file' && isPreviewableWorkspaceFile(v, workspaceDownloadExtensionSet)), [workspaceEntries, workspaceDownloadExtensionSet], ); 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 resourceBot = useMemo(() => bots.find((b) => b.id === resourceBotId), [bots, resourceBotId]); const hasTopicUnread = topicFeedUnreadCount > 0; 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 isUserBubble = item.role === 'user'; const fullText = String(item.text || ''); const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText; const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim(); const progressCollapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText)); const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : ''; const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0; const userCollapsible = isUserBubble && userLineCount > 5; const collapsible = isProgressBubble ? progressCollapsible : userCollapsible; const expanded = isProgressBubble ? Boolean(expandedProgressByKey[itemKey]) : Boolean(expandedUserByKey[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 ? ( { if (isProgressBubble) { setExpandedProgressByKey((prev) => ({ ...prev, [itemKey]: !prev[itemKey], })); return; } setExpandedUserByKey((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(displayText)}
    ) : ( {decorateWorkspacePathsForMarkdown(displayText)} ) ) : null} {(item.attachments || []).length > 0 ? (
    {(item.attachments || []).map((rawPath) => { const filePath = normalizeDashboardAttachmentPath(rawPath); const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet); 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, expandedUserByKey, 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 (compactListFirstMode) { if (selectedBotId && !activeBots[selectedBotId]) { setSelectedBotId(''); } return; } if (!selectedBotId && bots.length > 0) setSelectedBotId(bots[0].id); if (selectedBotId && !activeBots[selectedBotId] && bots.length > 0) setSelectedBotId(bots[0].id); }, [bots, selectedBotId, activeBots, forcedBotId, compactListFirstMode]); useEffect(() => { setComposerDraftHydrated(false); if (!selectedBotId) { setCommand(''); setPendingAttachments([]); setComposerDraftHydrated(true); return; } const draft = loadComposerDraft(selectedBotId); setCommand(draft?.command || ''); setPendingAttachments(draft?.attachments || []); setComposerDraftHydrated(true); }, [selectedBotId]); useEffect(() => { if (!selectedBotId || !composerDraftHydrated) return; persistComposerDraft(selectedBotId, command, pendingAttachments); }, [selectedBotId, composerDraftHydrated, command, pendingAttachments]); useEffect(() => { setControlCommandPanelOpen(false); }, [selectedBotId]); useEffect(() => { return () => { clearVoiceTimer(); try { if (voiceRecorderRef.current && voiceRecorderRef.current.state !== 'inactive') { voiceRecorderRef.current.stop(); } } catch { // ignore } releaseVoiceStream(); }; }, []); useEffect(() => { if (!isVoiceRecording && !isVoiceTranscribing) { setVoiceCountdown(voiceMaxSeconds); } }, [voiceMaxSeconds, isVoiceRecording, isVoiceTranscribing]); useEffect(() => { const hasDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply); if (!hasDraft && !isUploadingAttachments && !isVoiceRecording && !isVoiceTranscribing) return; const onBeforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ''; }; window.addEventListener('beforeunload', onBeforeUnload); return () => window.removeEventListener('beforeunload', onBeforeUnload); }, [command, pendingAttachments.length, quotedReply, isUploadingAttachments, isVoiceRecording, isVoiceTranscribing]); const syncChatScrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => { const box = chatScrollRef.current; if (!box) return; box.scrollTo({ top: box.scrollHeight, behavior }); }, []); useEffect(() => { chatAutoFollowRef.current = true; requestAnimationFrame(() => syncChatScrollToBottom('auto')); }, [selectedBotId, syncChatScrollToBottom]); useEffect(() => { if (!chatAutoFollowRef.current) return; requestAnimationFrame(() => syncChatScrollToBottom('auto')); }, [conversation.length, syncChatScrollToBottom]); useEffect(() => { setQuotedReply(null); if (isVoiceRecording) { stopVoiceRecording(); } }, [selectedBotId]); useEffect(() => { const onPointerDown = (event: MouseEvent) => { if (runtimeMenuRef.current && !runtimeMenuRef.current.contains(event.target as Node)) { setRuntimeMenuOpen(false); } if (botListMenuRef.current && !botListMenuRef.current.contains(event.target as Node)) { setBotListMenuOpen(false); } if (controlCommandPanelRef.current && !controlCommandPanelRef.current.contains(event.target as Node)) { setChatDatePickerOpen(false); } if (channelCreateMenuRef.current && !channelCreateMenuRef.current.contains(event.target as Node)) { setChannelCreateMenuOpen(false); } if (topicPresetMenuRef.current && !topicPresetMenuRef.current.contains(event.target as Node)) { setTopicPresetMenuOpen(false); } if (skillAddMenuRef.current && !skillAddMenuRef.current.contains(event.target as Node)) { setSkillAddMenuOpen(false); } }; const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key !== 'Escape') return; setChatDatePickerOpen(false); setChannelCreateMenuOpen(false); setTopicPresetMenuOpen(false); setSkillAddMenuOpen(false); }; document.addEventListener('mousedown', onPointerDown); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('keydown', onKeyDown); }; }, []); useEffect(() => { setRuntimeMenuOpen(false); setBotListMenuOpen(false); }, [selectedBotId]); useEffect(() => { setExpandedProgressByKey({}); setExpandedUserByKey({}); setShowRuntimeActionModal(false); setWorkspaceHoverCard(null); }, [selectedBotId]); useEffect(() => { if (!selectedBotId) return; let alive = true; const loadBotDetail = async () => { try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`); if (alive) mergeBot(res.data); } catch (error) { console.error(`Failed to fetch bot detail for ${selectedBotId}`, error); } }; void loadBotDetail(); return () => { alive = false; }; }, [selectedBotId, mergeBot]); useEffect(() => { if (!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`); if (!alive) return; const configured = Number(res.data?.limits?.upload_max_mb); if (Number.isFinite(configured) && configured > 0) { setUploadMaxMb(Math.max(1, Math.floor(configured))); } const configuredPageSize = normalizePlatformPageSize( res.data?.chat?.page_size, readCachedPlatformPageSize(10), ); writeCachedPlatformPageSize(configuredPageSize); setBotListPageSize(configuredPageSize); setAllowedAttachmentExtensions( parseAllowedAttachmentExtensions(res.data?.workspace?.allowed_attachment_extensions), ); setWorkspaceDownloadExtensions( parseWorkspaceDownloadExtensions( res.data?.workspace?.download_extensions, DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, ), ); setTopicPresetTemplates(parseTopicPresets(res.data?.topic_presets)); const speechEnabledRaw = res.data?.speech?.enabled; if (typeof speechEnabledRaw === 'boolean') { setSpeechEnabled(speechEnabledRaw); } const speechSeconds = Number(res.data?.speech?.max_audio_seconds); if (Number.isFinite(speechSeconds) && speechSeconds > 0) { const normalized = Math.max(5, Math.floor(speechSeconds)); setVoiceMaxSeconds(normalized); setVoiceCountdown(normalized); } const pullPageSize = Number(res.data?.chat?.pull_page_size); if (Number.isFinite(pullPageSize) && pullPageSize > 0) { setChatPullPageSize(Math.max(10, Math.min(500, Math.floor(pullPageSize)))); } } catch { // keep default limit } finally { if (alive) { setBotListPageSizeReady(true); } } }; 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] = await Promise.allSettled([axios.get(`${APP_ENDPOINTS.apiBase}/images`)]); if (imagesRes.status === 'fulfilled') { setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []); } else { setAvailableImages([]); } }; const refresh = async () => { const forced = String(forcedBotId || '').trim(); if (forced) { const targetId = String(selectedBotId || forced).trim() || forced; const botRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}`); setBots(botRes.data ? [botRes.data] : []); await loadImageOptions(); return; } 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 (workspaceFileAction(normalizedPath, workspaceDownloadExtensionSet) === 'download') { 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 saveWorkspacePreviewMarkdown = async () => { if (!selectedBotId || !workspacePreview?.isMarkdown) return; if (workspacePreview.truncated) { notify(t.fileEditDisabled, { tone: 'warning' }); return; } setWorkspacePreviewSaving(true); try { const res = await axios.put( `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, { content: workspacePreviewDraft }, { params: { path: workspacePreview.path } }, ); const filePath = res.data.path || workspacePreview.path; const textExt = (filePath.split('.').pop() || '').toLowerCase(); const content = res.data.content || workspacePreviewDraft; setWorkspacePreview({ ...workspacePreview, path: filePath, content, truncated: false, ext: textExt ? `.${textExt}` : '', isMarkdown: textExt === 'md' || textExt === 'markdown' || Boolean(res.data.is_markdown), }); setWorkspacePreviewEditing(false); notify(t.fileSaved, { tone: 'success' }); void loadWorkspaceTree(selectedBotId, workspaceCurrentPath); } catch (error: any) { notify(error?.response?.data?.detail || t.fileSaveFail, { tone: 'error' }); } finally { setWorkspacePreviewSaving(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 normalizeTopicKeyInput = (raw: string) => String(raw || '') .trim() .toLowerCase() .replace(/\s+/g, '_') .replace(/[^a-z0-9_.-]/g, ''); const createEmptyChannelDraft = (channelType: ChannelType = 'feishu'): BotChannel => ({ id: 'draft-channel', bot_id: selectedBot?.id || '', channel_type: channelType, external_app_id: '', app_secret: '', internal_port: 8080, is_active: true, extra_config: {}, }); const channelDraftUiKey = (channel: Pick, fallbackIndex: number) => { const id = String(channel.id || '').trim(); if (id) return id; const type = String(channel.channel_type || '').trim().toLowerCase(); return type || `channel-${fallbackIndex}`; }; const resetNewChannelDraft = (channelType: ChannelType = 'feishu') => { setNewChannelDraft(createEmptyChannelDraft(channelType)); }; const resetNewTopicDraft = () => { setNewTopicKey(''); setNewTopicName(''); setNewTopicDescription(''); setNewTopicPurpose(''); setNewTopicIncludeWhen(''); setNewTopicExcludeWhen(''); setNewTopicExamplesPositive(''); setNewTopicExamplesNegative(''); setNewTopicPriority('50'); setNewTopicAdvancedOpen(false); setNewTopicSource(''); }; const resetNewMcpDraft = () => { setNewMcpDraft({ name: '', type: 'streamableHttp', url: '', botId: '', botSecret: '', toolTimeout: '60', headers: {}, locked: false, originName: '', }); }; const mapMcpResponseToDrafts = (payload?: MCPConfigResponse | null): MCPServerDraft[] => { const rows = payload?.mcp_servers && typeof payload.mcp_servers === 'object' ? payload.mcp_servers : {}; return Object.entries(rows).map(([name, cfg]) => { const rawHeaders = cfg?.headers && typeof cfg.headers === 'object' ? cfg.headers : {}; const headers: Record = {}; Object.entries(rawHeaders).forEach(([k, v]) => { const key = String(k || '').trim(); if (!key) return; headers[key] = String(v ?? '').trim(); }); const headerEntries = Object.entries(headers); const botIdHeader = headerEntries.find(([k]) => String(k || '').trim().toLowerCase() === 'x-bot-id'); const botSecretHeader = headerEntries.find(([k]) => String(k || '').trim().toLowerCase() === 'x-bot-secret'); return { name: String(name || '').trim(), type: String(cfg?.type || 'streamableHttp') === 'sse' ? 'sse' : 'streamableHttp', url: String(cfg?.url || '').trim(), botId: String(botIdHeader?.[1] || '').trim(), botSecret: String(botSecretHeader?.[1] || '').trim(), toolTimeout: String(Number(cfg?.toolTimeout || 60) || 60), headers, locked: Boolean(cfg?.locked), originName: String(name || '').trim(), }; }); }; const applyMcpDrafts = (drafts: MCPServerDraft[]) => { setMcpServers(drafts); setPersistedMcpServers(drafts); setExpandedMcpByKey((prev) => { const next: Record = {}; drafts.forEach((row, idx) => { const key = mcpDraftUiKey(row, idx); next[key] = typeof prev[key] === 'boolean' ? prev[key] : idx === 0; }); return next; }); return drafts; }; const openChannelModal = (botId: string) => { if (!botId) return; setExpandedChannelByKey({}); setChannelCreateMenuOpen(false); setNewChannelPanelOpen(false); resetNewChannelDraft(); void loadChannels(botId); setShowChannelModal(true); }; const openTopicModal = (botId: string) => { if (!botId) return; setExpandedTopicByKey({}); setTopicPresetMenuOpen(false); setNewTopicPanelOpen(false); resetNewTopicDraft(); void loadTopics(botId); setShowTopicModal(true); }; const openMcpModal = async (botId: string) => { if (!botId) return; setExpandedMcpByKey({}); setNewMcpPanelOpen(false); resetNewMcpDraft(); await loadBotMcpConfig(botId); setShowMcpModal(true); }; const beginTopicCreate = (presetId: string) => { setExpandedTopicByKey({}); setTopicPresetMenuOpen(false); setNewTopicPanelOpen(true); setNewTopicSource(presetId); if (presetId === 'blank') { resetNewTopicDraft(); setNewTopicPanelOpen(true); setNewTopicSource('blank'); return; } applyTopicPreset(presetId, true); }; const beginChannelCreate = (channelType: ChannelType) => { setExpandedChannelByKey({}); setChannelCreateMenuOpen(false); setNewChannelPanelOpen(true); resetNewChannelDraft(channelType); }; const beginMcpCreate = () => { setExpandedMcpByKey({}); setNewMcpPanelOpen(true); resetNewMcpDraft(); }; const applyTopicPreset = (presetId: string, silent: boolean = false) => { const preset = effectiveTopicPresetTemplates.find((row) => row.id === presetId); if (!preset) return; const localeKey: 'zh-cn' | 'en' = isZh ? 'zh-cn' : 'en'; setNewTopicKey(String(preset.topic_key || '').trim()); setNewTopicName(resolvePresetText(preset.name, localeKey)); setNewTopicDescription(resolvePresetText(preset.description, localeKey)); setNewTopicPurpose(resolvePresetText(preset.routing_purpose, localeKey)); setNewTopicIncludeWhen(normalizePresetTextList(preset.routing_include_when).join('\n')); setNewTopicExcludeWhen(normalizePresetTextList(preset.routing_exclude_when).join('\n')); setNewTopicExamplesPositive(normalizePresetTextList(preset.routing_examples_positive).join('\n')); setNewTopicExamplesNegative(normalizePresetTextList(preset.routing_examples_negative).join('\n')); setNewTopicPriority(String(Number.isFinite(Number(preset.routing_priority)) ? Number(preset.routing_priority) : 50)); setNewTopicAdvancedOpen(true); if (!silent) { notify(isZh ? '主题预设已填充。' : 'Topic preset applied.', { tone: 'success' }); } }; const topicDraftUiKey = (topic: Pick, fallbackIndex: number) => { const key = String(topic.topic_key || topic.id || '').trim(); return key || `topic-${fallbackIndex}`; }; const mcpDraftUiKey = (row: Pick, fallbackIndex: number) => { void row; return `mcp-${fallbackIndex}`; }; const normalizeRoutingTextList = (raw: string): string[] => String(raw || '') .split('\n') .map((v) => String(v || '').trim()) .filter(Boolean); const normalizeRoutingPriority = (raw: string): number => { const n = Number(raw); if (!Number.isFinite(n)) return 50; return Math.max(0, Math.min(100, Math.round(n))); }; const topicRoutingFromRaw = (routing?: Record) => { const row = routing && typeof routing === 'object' ? routing : {}; const examplesRaw = row.examples && typeof row.examples === 'object' ? row.examples as Record : {}; const includeWhen = Array.isArray(row.include_when) ? row.include_when : []; const excludeWhen = Array.isArray(row.exclude_when) ? row.exclude_when : []; const positive = Array.isArray(examplesRaw.positive) ? examplesRaw.positive : []; const negative = Array.isArray(examplesRaw.negative) ? examplesRaw.negative : []; const priority = Number(row.priority); return { routing_purpose: String(row.purpose || ''), routing_include_when: includeWhen.map((v) => String(v || '').trim()).filter(Boolean).join('\n'), routing_exclude_when: excludeWhen.map((v) => String(v || '').trim()).filter(Boolean).join('\n'), routing_examples_positive: positive.map((v) => String(v || '').trim()).filter(Boolean).join('\n'), routing_examples_negative: negative.map((v) => String(v || '').trim()).filter(Boolean).join('\n'), routing_priority: String(Number.isFinite(priority) ? Math.max(0, Math.min(100, Math.round(priority))) : 50), }; }; const buildTopicRoutingPayload = (topic: { routing?: Record; routing_purpose?: string; routing_include_when?: string; routing_exclude_when?: string; routing_examples_positive?: string; routing_examples_negative?: string; routing_priority?: string; }): Record => { const base = topic.routing && typeof topic.routing === 'object' ? { ...topic.routing } : {}; return { ...base, purpose: String(topic.routing_purpose || '').trim(), include_when: normalizeRoutingTextList(String(topic.routing_include_when || '')), exclude_when: normalizeRoutingTextList(String(topic.routing_exclude_when || '')), examples: { positive: normalizeRoutingTextList(String(topic.routing_examples_positive || '')), negative: normalizeRoutingTextList(String(topic.routing_examples_negative || '')), }, priority: normalizeRoutingPriority(String(topic.routing_priority || '50')), system_filters: { progress: true, tool_hint: true, }, }; }; const loadTopics = async (botId: string) => { if (!botId) return; try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topics`); const rows = Array.isArray(res.data) ? res.data : []; const mapped = rows .map((row) => ({ ...row, topic_key: String(row.topic_key || '').trim().toLowerCase(), name: String(row.name || ''), description: String(row.description || ''), is_active: Boolean(row.is_active), routing: row.routing && typeof row.routing === 'object' ? row.routing : {}, ...topicRoutingFromRaw(row.routing && typeof row.routing === 'object' ? row.routing : {}), })) .filter((row) => !isSystemFallbackTopic(row)); setTopics(mapped); setExpandedTopicByKey((prev) => { const next: Record = {}; mapped.forEach((topic, idx) => { const key = topicDraftUiKey(topic, idx); next[key] = typeof prev[key] === 'boolean' ? prev[key] : idx === 0; }); return next; }); } catch { setTopics([]); setExpandedTopicByKey({}); } }; const updateTopicLocal = (index: number, patch: Partial) => { setTopics((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row))); }; const saveTopic = async (topic: BotTopic) => { if (!selectedBot) return; const topicKey = String(topic.topic_key || '').trim().toLowerCase(); if (!topicKey) return; setIsSavingTopic(true); try { await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/topics/${encodeURIComponent(topicKey)}`, { name: String(topic.name || '').trim(), description: String(topic.description || '').trim(), is_active: Boolean(topic.is_active), routing: buildTopicRoutingPayload(topic), }); await loadTopics(selectedBot.id); notify(t.topicSaved, { tone: 'success' }); } catch (error: any) { notify(error?.response?.data?.detail || t.topicSaveFail, { tone: 'error' }); } finally { setIsSavingTopic(false); } }; const addTopic = async () => { if (!selectedBot) return; const topic_key = normalizeTopicKeyInput(newTopicKey); if (!topic_key) { notify(t.topicKeyRequired, { tone: 'warning' }); return; } setIsSavingTopic(true); try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/topics`, { topic_key, name: String(newTopicName || '').trim() || topic_key, description: String(newTopicDescription || '').trim(), is_active: true, routing: buildTopicRoutingPayload({ routing_purpose: newTopicPurpose, routing_include_when: newTopicIncludeWhen, routing_exclude_when: newTopicExcludeWhen, routing_examples_positive: newTopicExamplesPositive, routing_examples_negative: newTopicExamplesNegative, routing_priority: newTopicPriority, }), }); await loadTopics(selectedBot.id); resetNewTopicDraft(); setNewTopicPanelOpen(false); notify(t.topicSaved, { tone: 'success' }); } catch (error: any) { notify(error?.response?.data?.detail || t.topicSaveFail, { tone: 'error' }); } finally { setIsSavingTopic(false); } }; const removeTopic = async (topic: BotTopic) => { if (!selectedBot) return; const topicKey = String(topic.topic_key || '').trim().toLowerCase(); if (!topicKey) return; const ok = await confirm({ title: t.topic, message: t.topicDeleteConfirm(topicKey), tone: 'warning', }); if (!ok) return; setIsSavingTopic(true); try { await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/topics/${encodeURIComponent(topicKey)}`); await loadTopics(selectedBot.id); notify(t.topicDeleted, { tone: 'success' }); } catch (error: any) { notify(error?.response?.data?.detail || t.topicDeleteFail, { tone: 'error' }); } finally { setIsSavingTopic(false); } }; const loadTopicFeed = async (args?: { append?: boolean; cursor?: number | null; topicKey?: string }) => { if (!selectedBot) return; const append = Boolean(args?.append); const cursor = append ? Number(args?.cursor || 0) : 0; const rawTopicKey = String(args?.topicKey ?? topicFeedTopicKey ?? '__all__').trim().toLowerCase(); const topicKey = rawTopicKey && rawTopicKey !== '__all__' ? rawTopicKey : ''; if (append) { setTopicFeedLoadingMore(true); } else { setTopicFeedLoading(true); setTopicFeedError(''); } try { const params: Record = { limit: 40 }; if (topicKey) params.topic_key = topicKey; if (append && Number.isFinite(cursor) && cursor > 0) { params.cursor = cursor; } const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/topic-items`, { params }); const rows = Array.isArray(res.data?.items) ? res.data.items : []; const nextCursorRaw = Number(res.data?.next_cursor); const nextCursor = Number.isFinite(nextCursorRaw) && nextCursorRaw > 0 ? nextCursorRaw : null; const totalUnreadRaw = Number(res.data?.total_unread_count); setTopicFeedNextCursor(nextCursor); setTopicFeedUnreadCount(Number.isFinite(totalUnreadRaw) && totalUnreadRaw > 0 ? totalUnreadRaw : 0); setTopicFeedItems((prev) => { if (!append) return rows; const merged = [...prev]; const seen = new Set(prev.map((item) => Number(item.id))); rows.forEach((item) => { const id = Number(item?.id); if (Number.isFinite(id) && seen.has(id)) return; merged.push(item); }); return merged; }); if (!append) setTopicFeedError(''); } catch (error: any) { if (!append) { setTopicFeedItems([]); setTopicFeedNextCursor(null); } setTopicFeedError(error?.response?.data?.detail || (isZh ? '读取主题消息失败。' : 'Failed to load topic feed.')); } finally { if (append) { setTopicFeedLoadingMore(false); } else { setTopicFeedLoading(false); } } }; const loadTopicFeedStats = async (botId?: string) => { const targetBotId = String(botId || selectedBot?.id || '').trim(); if (!targetBotId) return; try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${targetBotId}/topic-items/stats`); const unread = Number(res.data?.unread_count); setTopicFeedUnreadCount(Number.isFinite(unread) && unread > 0 ? unread : 0); } catch { setTopicFeedUnreadCount(0); } }; const markTopicFeedItemRead = async (itemId: number) => { if (!selectedBot) return; const targetId = Number(itemId); if (!Number.isFinite(targetId) || targetId <= 0) return; setTopicFeedReadSavingById((prev) => ({ ...prev, [targetId]: true })); try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/topic-items/${targetId}/read`); setTopicFeedItems((prev) => prev.map((item) => (Number(item.id) === targetId ? { ...item, is_read: true } : item))); setTopicFeedUnreadCount((prev) => Math.max(0, prev - 1)); } catch { // ignore individual read failures; user can retry } finally { setTopicFeedReadSavingById((prev) => { const next = { ...prev }; delete next[targetId]; return next; }); } }; const deleteTopicFeedItem = async (item: TopicFeedItem) => { if (!selectedBot) return; const targetId = Number(item?.id); if (!Number.isFinite(targetId) || targetId <= 0) return; const displayName = String(item?.title || item?.topic_key || targetId).trim() || String(targetId); const ok = await confirm({ title: t.delete, message: isZh ? `确认删除这条主题消息?\n${displayName}` : `Delete this Topic item?\n${displayName}`, tone: 'warning', }); if (!ok) return; setTopicFeedDeleteSavingById((prev) => ({ ...prev, [targetId]: true })); try { await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/topic-items/${targetId}`); setTopicFeedItems((prev) => prev.filter((row) => Number(row.id) !== targetId)); if (!Boolean(item?.is_read)) { setTopicFeedUnreadCount((prev) => Math.max(0, prev - 1)); } notify(isZh ? '主题消息已删除。' : 'Topic item deleted.', { tone: 'success' }); } catch (error: any) { notify(error?.response?.data?.detail || (isZh ? '删除主题消息失败。' : 'Failed to delete topic item.'), { tone: 'error' }); } finally { setTopicFeedDeleteSavingById((prev) => { const next = { ...prev }; delete next[targetId]; return next; }); } }; const loadChannels = async (botId: string) => { if (!botId) return; const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`); const rows = Array.isArray(res.data) ? res.data : []; setChannels(rows); setExpandedChannelByKey((prev) => { const next: Record = {}; rows .filter((channel) => !isDashboardChannel(channel)) .forEach((channel, idx) => { const key = channelDraftUiKey(channel, idx); next[key] = typeof prev[key] === 'boolean' ? prev[key] : idx === 0; }); return next; }); }; 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 loadMarketSkills = async (botId: string) => { if (!botId) return; setIsMarketSkillsLoading(true); try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skill-market`); setMarketSkills(Array.isArray(res.data) ? res.data : []); } catch (error: any) { setMarketSkills([]); notify(error?.response?.data?.detail || t.toolsLoadFail, { tone: 'error' }); } finally { setIsMarketSkillsLoading(false); } }; 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 loadBotMcpConfig = async (botId: string): Promise => { if (!botId) return []; try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/mcp-config`); const drafts = mapMcpResponseToDrafts(res.data); return applyMcpDrafts(drafts); } catch { applyMcpDrafts([]); return []; } }; const saveNewMcpServer = async () => { const name = String(newMcpDraft.name || '').trim(); const url = String(newMcpDraft.url || '').trim(); if (!name || !url) { notify(t.mcpDraftRequired, { tone: 'warning' }); return; } const nextRow: MCPServerDraft = { ...newMcpDraft, name, url, botId: String(newMcpDraft.botId || '').trim(), botSecret: String(newMcpDraft.botSecret || '').trim(), toolTimeout: String(newMcpDraft.toolTimeout || '60').trim() || '60', headers: { ...(newMcpDraft.headers || {}) }, locked: false, originName: name, }; const nextRows = [...persistedMcpServers, nextRow]; const expandedKey = mcpDraftUiKey(nextRow, nextRows.length - 1); await saveBotMcpConfig(nextRows, { closeDraft: true, expandedKey }); }; const saveSingleMcpServer = async (index: number) => { const row = mcpServers[index]; if (!row || row.locked) return; const originName = String(row.originName || row.name || '').trim(); const nextRows = [...persistedMcpServers]; const targetIndex = nextRows.findIndex((candidate) => { const candidateOrigin = String(candidate.originName || candidate.name || '').trim(); return candidateOrigin && candidateOrigin === originName; }); if (targetIndex >= 0) { nextRows[targetIndex] = { ...row }; } else { nextRows.push({ ...row, originName: originName || String(row.name || '').trim() }); } const expandedKey = mcpDraftUiKey(row, targetIndex >= 0 ? targetIndex : nextRows.length - 1); await saveBotMcpConfig(nextRows, { expandedKey }); }; const updateMcpServer = (index: number, patch: Partial) => { setMcpServers((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row))); }; const canRemoveMcpServer = (row?: MCPServerDraft | null) => { return Boolean(row && !row.locked); }; const removeMcpServer = async (index: number) => { const row = mcpServers[index]; if (!canRemoveMcpServer(row)) { notify(isZh ? '当前 MCP 服务不可删除。' : 'This MCP server cannot be removed.', { tone: 'warning' }); return; } const nextRows = mcpServers.filter((_, i) => i !== index); setMcpServers(nextRows); setPersistedMcpServers(nextRows); setExpandedMcpByKey((prev) => { const next: Record = {}; nextRows.forEach((server, idx) => { const key = mcpDraftUiKey(server, idx); next[key] = typeof prev[key] === 'boolean' ? prev[key] : idx === 0; }); return next; }); await saveBotMcpConfig(nextRows); }; const buildMcpHeaders = (row: MCPServerDraft): Record => { const headers: Record = {}; Object.entries(row.headers || {}).forEach(([k, v]) => { const key = String(k || '').trim(); if (!key) return; const lowered = key.toLowerCase(); if (!row.locked && (lowered === 'x-bot-id' || lowered === 'x-bot-secret')) { return; } headers[key] = String(v ?? '').trim(); }); if (!row.locked) { const botId = String(row.botId || '').trim(); const botSecret = String(row.botSecret || '').trim(); if (botId) headers['X-Bot-Id'] = botId; if (botSecret) headers['X-Bot-Secret'] = botSecret; } return headers; }; const saveBotMcpConfig = async ( rows: MCPServerDraft[] = mcpServers, options?: { closeDraft?: boolean; expandedKey?: string }, ) => { if (!selectedBot) return; const mcp_servers: Record = {}; for (const row of rows) { const name = String(row.name || '').trim(); const url = String(row.url || '').trim(); if (!name || !url) continue; const timeout = Math.max(1, Math.min(600, Number(row.toolTimeout || 60) || 60)); mcp_servers[name] = { type: row.type === 'sse' ? 'sse' : 'streamableHttp', url, headers: buildMcpHeaders(row), toolTimeout: timeout, }; } setIsSavingMcp(true); try { await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/mcp-config`, { mcp_servers }); if (options?.expandedKey) { setExpandedMcpByKey({ [options.expandedKey]: true }); } await loadBotMcpConfig(selectedBot.id); if (options?.closeDraft) { setNewMcpPanelOpen(false); resetNewMcpDraft(); } notify(t.mcpSaved, { tone: 'success' }); } catch (error: any) { notify(error?.response?.data?.detail || t.mcpSaveFail, { tone: 'error' }); } finally { setIsSavingMcp(false); } }; 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); await loadMarketSkills(selectedBot.id); } catch (error: any) { notify(error?.response?.data?.detail || t.toolsRemoveFail, { tone: 'error' }); } }; const installMarketSkill = async (marketSkill: BotSkillMarketItem) => { if (!selectedBot) return; const skillId = Number(marketSkill.id); if (!Number.isFinite(skillId) || skillId <= 0) return; setMarketSkillInstallingId(skillId); try { const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skill-market/${skillId}/install`, ); setBotSkills(Array.isArray(res.data?.skills) ? res.data.skills : []); await loadMarketSkills(selectedBot.id); notify( isZh ? `已安装技能:${(res.data?.installed || []).join(', ') || marketSkill.display_name || marketSkill.skill_key}` : `Installed: ${(res.data?.installed || []).join(', ') || marketSkill.display_name || marketSkill.skill_key}`, { tone: 'success' }, ); } catch (error: any) { notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' }); if (selectedBot) { await loadMarketSkills(selectedBot.id); } } finally { setMarketSkillInstallingId(null); } }; 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); await loadMarketSkills(selectedBot.id); } 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) => { if (i !== index || c.locked) return c; return { ...c, ...patch }; }), ); }; const saveChannel = async (channel: BotChannel) => { if (!selectedBot || channel.locked || isDashboardChannel(channel)) 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) return; const channelType = String(newChannelDraft.channel_type || '').trim().toLowerCase() as ChannelType; if (!channelType || !addableChannelTypes.includes(channelType)) return; setIsSavingChannel(true); try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels`, { channel_type: channelType, is_active: Boolean(newChannelDraft.is_active), external_app_id: String(newChannelDraft.external_app_id || ''), app_secret: String(newChannelDraft.app_secret || ''), internal_port: Number(newChannelDraft.internal_port) || 8080, extra_config: sanitizeChannelExtra(channelType, newChannelDraft.extra_config || {}), }); await loadChannels(selectedBot.id); setNewChannelPanelOpen(false); resetNewChannelDraft(); } 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.locked || 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 parseChannelListValue = (raw: unknown): string => { if (!Array.isArray(raw)) return ''; return raw .map((item) => String(item || '').trim()) .filter(Boolean) .join('\n'); }; const parseChannelListInput = (raw: string): string[] => { const rows: string[] = []; String(raw || '') .split(/[\n,]/) .forEach((item) => { const text = String(item || '').trim(); if (text && !rows.includes(text)) rows.push(text); }); return rows; }; const isChannelConfigured = (channel: BotChannel): boolean => { const ctype = String(channel.channel_type || '').trim().toLowerCase(); if (ctype === 'email') { const extra = channel.extra_config || {}; return Boolean( String(extra.imapHost || '').trim() && String(extra.imapUsername || '').trim() && String(extra.imapPassword || '').trim() && String(extra.smtpHost || '').trim() && String(extra.smtpUsername || '').trim() && String(extra.smtpPassword || '').trim(), ); } return Boolean(String(channel.external_app_id || '').trim() || String(channel.app_secret || '').trim()); }; 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, onPatch: (patch: Partial) => void) => { const ctype = String(channel.channel_type).toLowerCase(); if (ctype === 'telegram') { return ( <> onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} /> onPatch({ extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })} autoComplete="off" /> ); } if (ctype === 'feishu') { return ( <> onPatch({ external_app_id: e.target.value })} autoComplete="off" /> onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} /> onPatch({ extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} autoComplete="off" /> onPatch({ extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} autoComplete="off" /> ); } if (ctype === 'dingtalk') { return ( <> onPatch({ external_app_id: e.target.value })} autoComplete="off" /> onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} /> ); } if (ctype === 'slack') { return ( <> onPatch({ external_app_id: e.target.value })} autoComplete="off" /> onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} /> ); } if (ctype === 'qq') { return ( <> onPatch({ external_app_id: e.target.value })} autoComplete="off" /> onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} /> ); } if (ctype === 'email') { const extra = channel.extra_config || {}; return ( <>
    onPatch({ extra_config: { ...extra, fromAddress: e.target.value } })} autoComplete="off" />