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

7169 lines
301 KiB
TypeScript
Raw Normal View History

2026-03-09 09:52:42 +00:00
import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
2026-03-01 16:26:03 +00:00
import axios from 'axios';
2026-03-13 06:40:54 +00:00
import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, 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';
2026-03-01 16:26:03 +00:00
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
2026-03-02 02:54:40 +00:00
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
2026-03-01 16:26:03 +00:00
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';
2026-03-03 08:12:27 +00:00
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
2026-03-11 12:55:42 +00:00
import { LucentSelect } from '../../components/lucent/LucentSelect';
2026-03-13 06:40:54 +00:00
import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel';
2026-03-01 16:26:03 +00:00
interface BotDashboardModuleProps {
onOpenCreateWizard?: () => void;
onOpenImageFactory?: () => void;
forcedBotId?: string;
compactMode?: boolean;
}
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
type WorkspaceNodeType = 'dir' | 'file';
2026-03-14 07:44:11 +00:00
type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack' | 'email';
2026-03-13 06:40:54 +00:00
type RuntimeViewMode = 'visual' | 'topic';
2026-03-01 16:26:03 +00:00
type CompactPanelTab = 'chat' | 'runtime';
2026-03-09 04:53:15 +00:00
type QuotedReply = { id?: number; text: string; ts: number };
const BOT_LIST_PAGE_SIZE = 8;
2026-03-01 16:26:03 +00:00
interface WorkspaceNode {
name: string;
path: string;
type: WorkspaceNodeType;
size?: number;
ext?: string;
2026-03-05 02:42:39 +00:00
ctime?: string;
2026-03-01 16:26:03 +00:00
mtime?: string;
children?: WorkspaceNode[];
}
2026-03-05 02:42:39 +00:00
interface WorkspaceHoverCardState {
node: WorkspaceNode;
top: number;
left: number;
above: boolean;
}
2026-03-01 16:26:03 +00:00
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;
2026-03-01 19:44:06 +00:00
isImage: boolean;
2026-03-02 07:51:47 +00:00
isHtml: boolean;
2026-03-10 18:28:39 +00:00
isVideo: boolean;
isAudio: boolean;
2026-03-01 16:26:03 +00:00
}
interface WorkspaceUploadResponse {
bot_id: string;
files: Array<{ name: string; path: string; size: number }>;
}
interface CronJob {
id: string;
name: string;
enabled?: boolean;
schedule?: {
kind?: 'at' | 'every' | 'cron' | string;
atMs?: number;
everyMs?: number;
expr?: string;
tz?: string;
};
payload?: {
message?: string;
channel?: string;
to?: string;
};
state?: {
nextRunAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
};
}
interface CronJobsResponse {
bot_id: string;
version: number;
jobs: CronJob[];
}
2026-03-11 12:55:42 +00:00
interface MCPServerConfig {
type?: 'streamableHttp' | 'sse' | string;
url?: string;
headers?: Record<string, string>;
toolTimeout?: number;
2026-03-13 06:40:54 +00:00
locked?: boolean;
2026-03-11 12:55:42 +00:00
}
interface MCPConfigResponse {
bot_id: string;
mcp_servers?: Record<string, MCPServerConfig>;
2026-03-13 11:23:06 +00:00
locked_servers?: string[];
2026-03-11 12:55:42 +00:00
restart_required?: boolean;
2026-03-13 11:23:06 +00:00
status?: string;
2026-03-11 12:55:42 +00:00
}
interface MCPTestResponse {
ok: boolean;
transport?: string;
status_code?: number | null;
message?: string;
probe_from?: string;
}
interface MCPTestState {
status: 'idle' | 'testing' | 'pass' | 'fail';
message: string;
}
interface MCPServerDraft {
name: string;
type: 'streamableHttp' | 'sse';
url: string;
botId: string;
botSecret: string;
toolTimeout: string;
2026-03-13 06:40:54 +00:00
headers: Record<string, string>;
locked: boolean;
originName?: string;
2026-03-11 12:55:42 +00:00
}
2026-03-01 16:26:03 +00:00
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<string, unknown>;
locked?: boolean;
}
2026-03-13 06:40:54 +00:00
interface BotTopic {
id: string | number;
bot_id: string;
topic_key: string;
name: string;
description: string;
is_active: boolean;
routing?: Record<string, unknown>;
view_schema?: Record<string, unknown>;
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;
}
2026-03-03 06:09:11 +00:00
interface NanobotImage {
tag: string;
status: string;
}
interface DockerImage {
tag: string;
version?: string;
image_id?: string;
}
interface BaseImageOption {
tag: string;
label: string;
disabled: boolean;
needsRegister: boolean;
}
2026-03-01 16:26:03 +00:00
interface WorkspaceSkillOption {
id: string;
name: string;
type: 'dir' | 'file' | string;
path: string;
size?: number | null;
mtime?: string;
description?: string;
}
2026-03-03 06:09:11 +00:00
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;
}
2026-03-01 16:26:03 +00:00
interface SkillUploadResponse {
status: string;
bot_id: string;
installed: string[];
skills: WorkspaceSkillOption[];
}
2026-03-03 06:09:11 +00:00
interface SystemDefaultsResponse {
limits?: {
upload_max_mb?: number;
};
2026-03-13 06:40:54 +00:00
workspace?: {
download_extensions?: unknown;
};
2026-03-14 07:44:11 +00:00
chat?: {
pull_page_size?: number;
};
2026-03-13 06:40:54 +00:00
topic_presets?: unknown;
2026-03-11 17:20:57 +00:00
speech?: {
enabled?: boolean;
model?: string;
device?: string;
max_audio_seconds?: number;
};
2026-03-03 06:09:11 +00:00
}
2026-03-13 06:40:54 +00:00
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;
}
2026-03-01 16:26:03 +00:00
type BotEnvParams = Record<string, string>;
2026-03-13 06:40:54 +00:00
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,
},
];
2026-03-01 16:26:03 +00:00
const providerPresets: Record<string, { model: string; apiBase?: string; note: { 'zh-cn': string; en: string } }> = {
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': 'KimiMoonshot接口模型示例 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',
},
},
2026-03-15 07:14:01 +00:00
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',
},
},
2026-03-01 16:26:03 +00:00
};
2026-03-14 07:44:11 +00:00
const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack', 'email'];
2026-03-09 04:53:15 +00:00
const RUNTIME_STALE_MS = 45000;
2026-03-13 06:40:54 +00:00
const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']);
2026-03-01 16:26:03 +00:00
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}`;
}
2026-03-09 04:53:15 +00:00
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}`;
}
}
2026-03-01 16:26:03 +00:00
function stateLabel(s?: string) {
return (s || 'IDLE').toUpperCase();
}
2026-03-13 06:40:54 +00:00
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<string, unknown>;
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<string, unknown>;
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<BotTopic, 'topic_key' | 'name' | 'description' | 'routing'>): 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<string, unknown>).purpose || '').trim().toLowerCase();
const desc = String(topic.description || '').trim().toLowerCase();
const name = String(topic.name || '').trim().toLowerCase();
const priority = Number((routing as Record<string, unknown>).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;
}
2026-03-01 16:26:03 +00:00
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;
}
2026-03-09 04:53:15 +00:00
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;
}
2026-03-13 06:40:54 +00:00
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 pathHasExtension(path: string, extensions: ReadonlySet<string>): boolean {
const normalized = String(path || '').trim().toLowerCase();
if (!normalized) return false;
for (const ext of extensions) {
if (normalized.endsWith(ext)) return true;
2026-03-10 18:28:39 +00:00
}
2026-03-13 06:40:54 +00:00
return false;
}
function isDownloadOnlyPath(path: string, downloadExtensions: ReadonlySet<string> = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET) {
return pathHasExtension(path, downloadExtensions);
2026-03-01 16:26:03 +00:00
}
2026-03-13 06:40:54 +00:00
function isPreviewableWorkspaceFile(
node: WorkspaceNode,
downloadExtensions: ReadonlySet<string> = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET,
) {
if (node.type !== 'file') return false;
return isPreviewableWorkspacePath(node.path, downloadExtensions);
2026-03-01 16:26:03 +00:00
}
2026-03-01 19:44:06 +00:00
function isImagePath(path: string) {
2026-03-13 06:40:54 +00:00
return pathHasExtension(path, IMAGE_PREVIEW_EXTENSIONS);
2026-03-01 19:44:06 +00:00
}
2026-03-10 18:28:39 +00:00
function isVideoPath(path: string) {
2026-03-13 06:40:54 +00:00
return pathHasExtension(path, VIDEO_PREVIEW_EXTENSIONS);
2026-03-10 18:28:39 +00:00
}
function isAudioPath(path: string) {
2026-03-13 06:40:54 +00:00
return pathHasExtension(path, AUDIO_PREVIEW_EXTENSIONS);
2026-03-10 18:28:39 +00:00
}
2026-03-05 04:52:52 +00:00
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));
}
2026-03-02 07:51:47 +00:00
function isHtmlPath(path: string) {
2026-03-13 06:40:54 +00:00
return pathHasExtension(path, HTML_PREVIEW_EXTENSIONS);
2026-03-02 04:38:01 +00:00
}
2026-03-13 06:40:54 +00:00
function isPreviewableWorkspacePath(
path: string,
downloadExtensions: ReadonlySet<string> = 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)
2026-03-01 19:44:06 +00:00
);
}
2026-03-13 06:40:54 +00:00
function workspaceFileAction(
path: string,
downloadExtensions: ReadonlySet<string> = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET,
): 'preview' | 'download' | 'unsupported' {
2026-03-02 05:55:35 +00:00
const normalized = String(path || '').trim();
if (!normalized) return 'unsupported';
2026-03-13 06:40:54 +00:00
if (isDownloadOnlyPath(normalized, downloadExtensions)) return 'download';
2026-03-10 18:28:39 +00:00
if (isImagePath(normalized) || isHtmlPath(normalized) || isVideoPath(normalized) || isAudioPath(normalized)) return 'preview';
2026-03-13 06:40:54 +00:00
if (pathHasExtension(normalized, TEXT_PREVIEW_EXTENSIONS)) return 'preview';
2026-03-02 05:55:35 +00:00
return 'unsupported';
}
2026-03-01 19:44:06 +00:00
const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/';
2026-03-10 19:09:41 +00:00
const WORKSPACE_ABS_PATH_PATTERN =
2026-03-13 06:40:54 +00:00
/\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.[a-z0-9][a-z0-9._+-]{0,31}\b/gi;
2026-03-10 11:04:49 +00:00
const WORKSPACE_RELATIVE_PATH_PATTERN =
2026-03-13 06:40:54 +00:00
/(^|[\s(\[])(\/[^\n\r<>"'`)\]]+?\.[a-z0-9][a-z0-9._+-]{0,31})(?![A-Za-z0-9_./-])/gim;
2026-03-01 19:44:06 +00:00
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;
}
}
2026-03-10 19:09:41 +00:00
function decorateWorkspacePathsInPlainChunk(source: string): string {
if (!source) return source;
const protectedLinks: string[] = [];
const withProtectedAbsoluteLinks = source.replace(WORKSPACE_ABS_PATH_PATTERN, (fullPath) => {
2026-03-01 19:44:06 +00:00
const normalized = normalizeDashboardAttachmentPath(fullPath);
if (!normalized) return fullPath;
2026-03-10 19:09:41 +00:00
const token = `@@WS_PATH_LINK_${protectedLinks.length}@@`;
protectedLinks.push(`[${fullPath}](${buildWorkspaceLink(normalized)})`);
return token;
2026-03-01 19:44:06 +00:00
});
2026-03-10 19:09:41 +00:00
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];
2026-03-10 11:04:49 +00:00
});
2026-03-01 19:44:06 +00:00
}
2026-03-10 19:09:41 +00:00
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;
}
2026-03-01 16:26:03 +00:00
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 {
2026-03-13 06:40:54 +00:00
const v = String(path || '')
.trim()
.replace(/\\/g, '/')
.replace(/^['"`([<{]+/, '')
.replace(/['"`)\]>}.,,。!?;:]+$/, '');
2026-03-01 16:26:03 +00:00
if (!v) return '';
const prefix = '/root/.nanobot/workspace/';
if (v.startsWith(prefix)) return v.slice(prefix.length);
return v.replace(/^\/+/, '');
}
2026-03-10 18:40:59 +00:00
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<ComposerDraftStorage> | 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
}
}
2026-03-01 16:26:03 +00:00
function isExternalHttpLink(href: string): boolean {
return /^https?:\/\//i.test(String(href || '').trim());
}
2026-03-09 04:53:15 +00:00
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 };
}
2026-03-01 16:26:03 +00:00
function mergeConversation(messages: ChatMessage[]) {
const merged: ChatMessage[] = [];
messages
.filter((msg) => msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0))
.forEach((msg) => {
2026-03-09 04:53:15 +00:00
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);
2026-03-01 16:26:03 +00:00
const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean);
2026-03-09 04:53:15 +00:00
if (!cleanText && attachments.length === 0 && !userQuoted) return;
2026-03-01 16:26:03 +00:00
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);
2026-03-09 04:53:15 +00:00
const sameQuoted = normalizeAssistantMessageText(last.quoted_reply || '') === normalizeAssistantMessageText(userQuoted);
if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && sameQuoted && Math.abs(msg.ts - last.ts) < 15000) {
2026-03-01 16:26:03 +00:00
last.ts = msg.ts;
2026-03-03 16:45:51 +00:00
last.id = msg.id || last.id;
if (typeof msg.feedback !== 'undefined') {
last.feedback = msg.feedback;
}
2026-03-01 16:26:03 +00:00
return;
}
}
2026-03-09 04:53:15 +00:00
merged.push({ ...msg, text: cleanText, quoted_reply: userQuoted || undefined, attachments });
2026-03-01 16:26:03 +00:00
});
return merged.slice(-120);
}
function clampTemperature(value: number) {
if (Number.isNaN(value)) return 0.2;
return Math.min(1, Math.max(0, value));
}
2026-03-03 06:09:11 +00:00
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)}%`;
}
2026-03-05 02:42:39 +00:00
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();
}
}
2026-03-01 16:26:03 +00:00
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,
2026-03-09 04:53:15 +00:00
mergeBot,
2026-03-01 16:26:03 +00:00
updateBotStatus,
locale,
addBotMessage,
setBotMessages,
2026-03-03 16:45:51 +00:00
setBotMessageFeedback,
2026-03-01 16:26:03 +00:00
} = useAppStore();
const { notify, confirm } = useLucentPrompt();
2026-03-01 19:44:06 +00:00
const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable';
2026-03-01 16:26:03 +00:00
const [selectedBotId, setSelectedBotId] = useState('');
const [command, setCommand] = useState('');
2026-03-11 17:20:57 +00:00
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);
2026-03-01 16:26:03 +00:00
const [isSaving, setIsSaving] = useState(false);
const [showBaseModal, setShowBaseModal] = useState(false);
const [showParamModal, setShowParamModal] = useState(false);
const [showChannelModal, setShowChannelModal] = useState(false);
2026-03-13 06:40:54 +00:00
const [showTopicModal, setShowTopicModal] = useState(false);
2026-03-01 16:26:03 +00:00
const [showSkillsModal, setShowSkillsModal] = useState(false);
2026-03-11 12:55:42 +00:00
const [showMcpModal, setShowMcpModal] = useState(false);
2026-03-01 16:26:03 +00:00
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
const [showCronModal, setShowCronModal] = useState(false);
const [showAgentModal, setShowAgentModal] = useState(false);
2026-03-03 06:09:11 +00:00
const [showResourceModal, setShowResourceModal] = useState(false);
const [resourceBotId, setResourceBotId] = useState('');
const [resourceSnapshot, setResourceSnapshot] = useState<BotResourceSnapshot | null>(null);
const [resourceLoading, setResourceLoading] = useState(false);
const [resourceError, setResourceError] = useState('');
2026-03-01 16:26:03 +00:00
const [agentTab, setAgentTab] = useState<AgentTab>('AGENTS');
const [isTestingProvider, setIsTestingProvider] = useState(false);
const [providerTestResult, setProviderTestResult] = useState('');
const [operatingBotId, setOperatingBotId] = useState<string | null>(null);
const [sendingByBot, setSendingByBot] = useState<Record<string, boolean>>({});
2026-03-09 04:53:15 +00:00
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
2026-03-01 16:26:03 +00:00
const [controlStateByBot, setControlStateByBot] = useState<Record<string, 'starting' | 'stopping'>>({});
const chatBottomRef = useRef<HTMLDivElement | null>(null);
2026-03-11 12:55:42 +00:00
const chatScrollRef = useRef<HTMLDivElement | null>(null);
const chatAutoFollowRef = useRef(true);
2026-03-01 16:26:03 +00:00
const [workspaceEntries, setWorkspaceEntries] = useState<WorkspaceNode[]>([]);
2026-03-09 09:52:42 +00:00
const [workspaceSearchEntries, setWorkspaceSearchEntries] = useState<WorkspaceNode[]>([]);
const [workspaceSearchLoading, setWorkspaceSearchLoading] = useState(false);
2026-03-01 16:26:03 +00:00
const [workspaceLoading, setWorkspaceLoading] = useState(false);
const [workspaceError, setWorkspaceError] = useState('');
const [workspaceCurrentPath, setWorkspaceCurrentPath] = useState('');
const [workspaceParentPath, setWorkspaceParentPath] = useState<string | null>(null);
const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false);
const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null);
2026-03-02 04:38:01 +00:00
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
2026-03-05 02:42:39 +00:00
const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false);
2026-03-09 09:52:42 +00:00
const [workspaceQuery, setWorkspaceQuery] = useState('');
2026-03-01 16:26:03 +00:00
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
2026-03-10 18:40:59 +00:00
const [composerDraftHydrated, setComposerDraftHydrated] = useState(false);
2026-03-09 04:53:15 +00:00
const [quotedReply, setQuotedReply] = useState<QuotedReply | null>(null);
2026-03-01 16:26:03 +00:00
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
2026-03-05 04:52:52 +00:00
const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null);
2026-03-01 16:26:03 +00:00
const filePickerRef = useRef<HTMLInputElement | null>(null);
2026-03-10 07:04:33 +00:00
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
2026-03-01 16:26:03 +00:00
const [cronJobs, setCronJobs] = useState<CronJob[]>([]);
const [cronLoading, setCronLoading] = useState(false);
const [cronActionJobId, setCronActionJobId] = useState<string>('');
const [channels, setChannels] = useState<BotChannel[]>([]);
2026-03-13 06:40:54 +00:00
const [expandedChannelByKey, setExpandedChannelByKey] = useState<Record<string, boolean>>({});
const [newChannelPanelOpen, setNewChannelPanelOpen] = useState(false);
const [channelCreateMenuOpen, setChannelCreateMenuOpen] = useState(false);
const [newChannelDraft, setNewChannelDraft] = useState<BotChannel>({
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<BotTopic[]>([]);
const [expandedTopicByKey, setExpandedTopicByKey] = useState<Record<string, boolean>>({});
const [newTopicPanelOpen, setNewTopicPanelOpen] = useState(false);
const [topicPresetTemplates, setTopicPresetTemplates] = useState<TopicPresetTemplate[]>([]);
const [newTopicSource, setNewTopicSource] = useState<string>('');
const [topicPresetMenuOpen, setTopicPresetMenuOpen] = useState(false);
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');
2026-03-01 16:26:03 +00:00
const [botSkills, setBotSkills] = useState<WorkspaceSkillOption[]>([]);
const [isSkillUploading, setIsSkillUploading] = useState(false);
const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
const [envParams, setEnvParams] = useState<BotEnvParams>({});
2026-03-11 12:55:42 +00:00
const [mcpServers, setMcpServers] = useState<MCPServerDraft[]>([]);
2026-03-13 06:40:54 +00:00
const [persistedMcpServers, setPersistedMcpServers] = useState<MCPServerDraft[]>([]);
const [newMcpPanelOpen, setNewMcpPanelOpen] = useState(false);
const [newMcpDraft, setNewMcpDraft] = useState<MCPServerDraft>({
name: '',
type: 'streamableHttp',
url: '',
botId: '',
botSecret: '',
toolTimeout: '60',
headers: {},
locked: false,
originName: '',
});
const [expandedMcpByKey, setExpandedMcpByKey] = useState<Record<string, boolean>>({});
2026-03-11 12:55:42 +00:00
const [mcpTestByIndex, setMcpTestByIndex] = useState<Record<number, MCPTestState>>({});
2026-03-01 16:26:03 +00:00
const [envDraftKey, setEnvDraftKey] = useState('');
const [envDraftValue, setEnvDraftValue] = useState('');
const [envDraftVisible, setEnvDraftVisible] = useState(false);
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
const [isSavingChannel, setIsSavingChannel] = useState(false);
2026-03-13 06:40:54 +00:00
const [isSavingTopic, setIsSavingTopic] = useState(false);
2026-03-11 12:55:42 +00:00
const [isSavingMcp, setIsSavingMcp] = useState(false);
2026-03-01 16:26:03 +00:00
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
2026-03-13 06:40:54 +00:00
const [isBatchOperating, setIsBatchOperating] = useState(false);
2026-03-03 06:09:11 +00:00
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
const [localDockerImages, setLocalDockerImages] = useState<DockerImage[]>([]);
2026-03-01 16:26:03 +00:00
const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
sendProgress: false,
sendToolHints: false,
});
2026-03-03 06:09:11 +00:00
const [uploadMaxMb, setUploadMaxMb] = useState(100);
2026-03-14 07:44:11 +00:00
const [chatPullPageSize, setChatPullPageSize] = useState(60);
const [chatHasMore, setChatHasMore] = useState(false);
const [chatLoadingMore, setChatLoadingMore] = useState(false);
2026-03-13 06:40:54 +00:00
const [workspaceDownloadExtensions, setWorkspaceDownloadExtensions] = useState<string[]>(
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
);
2026-03-01 16:26:03 +00:00
const [runtimeViewMode, setRuntimeViewMode] = useState<RuntimeViewMode>('visual');
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
2026-03-13 06:40:54 +00:00
const [botListMenuOpen, setBotListMenuOpen] = useState(false);
const [topicFeedTopicKey, setTopicFeedTopicKey] = useState('__all__');
const [topicFeedItems, setTopicFeedItems] = useState<TopicFeedItem[]>([]);
const [topicFeedNextCursor, setTopicFeedNextCursor] = useState<number | null>(null);
const [topicFeedLoading, setTopicFeedLoading] = useState(false);
const [topicFeedLoadingMore, setTopicFeedLoadingMore] = useState(false);
const [topicFeedError, setTopicFeedError] = useState('');
const [topicFeedReadSavingById, setTopicFeedReadSavingById] = useState<Record<number, boolean>>({});
2026-03-15 07:14:01 +00:00
const [topicFeedDeleteSavingById, setTopicFeedDeleteSavingById] = useState<Record<number, boolean>>({});
2026-03-13 06:40:54 +00:00
const [topicFeedUnreadCount, setTopicFeedUnreadCount] = useState(0);
const [topicDetailOpen, setTopicDetailOpen] = useState(false);
2026-03-01 16:26:03 +00:00
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat');
const [isCompactMobile, setIsCompactMobile] = useState(false);
2026-03-09 04:53:15 +00:00
const [botListQuery, setBotListQuery] = useState('');
const [botListPage, setBotListPage] = useState(1);
2026-03-01 19:44:06 +00:00
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
2026-03-11 14:25:31 +00:00
const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({});
2026-03-03 16:45:51 +00:00
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
2026-03-01 20:27:58 +00:00
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
2026-03-13 06:40:54 +00:00
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('');
2026-03-05 02:42:39 +00:00
const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | null>(null);
2026-03-11 17:20:57 +00:00
const voiceRecorderRef = useRef<MediaRecorder | null>(null);
const voiceStreamRef = useRef<MediaStream | null>(null);
const voiceChunksRef = useRef<BlobPart[]>([]);
const voiceTimerRef = useRef<number | null>(null);
2026-03-01 16:26:03 +00:00
const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
2026-03-13 06:40:54 +00:00
const botListMenuRef = useRef<HTMLDivElement | null>(null);
2026-03-10 16:53:54 +00:00
const botOrderRef = useRef<Record<string, number>>({});
const nextBotOrderRef = useRef(1);
2026-03-09 09:52:42 +00:00
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)),
});
}, []);
2026-03-05 02:42:39 +00:00
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => {
const query = [`path=${encodeURIComponent(filePath)}`];
if (forceDownload) query.push('download=1');
2026-03-10 07:04:33 +00:00
return `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/download?${query.join('&')}`;
2026-03-05 02:42:39 +00:00
};
2026-03-02 04:38:01 +00:00
const closeWorkspacePreview = () => {
setWorkspacePreview(null);
setWorkspacePreviewFullscreen(false);
};
const triggerWorkspaceFileDownload = (filePath: string) => {
if (!selectedBotId) return;
const normalized = String(filePath || '').trim();
if (!normalized) return;
const filename = normalized.split('/').pop() || 'workspace-file';
const link = document.createElement('a');
link.href = buildWorkspaceDownloadHref(normalized, true);
link.download = filename;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
};
2026-03-02 07:51:47 +00:00
const copyWorkspacePreviewUrl = async (filePath: string) => {
const normalized = String(filePath || '').trim();
if (!selectedBotId || !normalized) return;
const hrefRaw = buildWorkspaceDownloadHref(normalized, false);
const href = (() => {
try {
return new URL(hrefRaw, window.location.origin).href;
} catch {
return hrefRaw;
}
})();
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(href);
} else {
const ta = document.createElement('textarea');
ta.value = href;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
notify(t.urlCopied, { tone: 'success' });
} catch {
notify(t.urlCopyFail, { tone: 'error' });
}
};
2026-03-10 18:28:39 +00:00
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',
);
};
2026-03-03 16:45:51 +00:00
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' });
}
};
2026-03-09 09:52:42 +00:00
const openWorkspacePathFromChat = async (path: string) => {
2026-03-01 19:44:06 +00:00
const normalized = String(path || '').trim();
if (!normalized) return;
2026-03-13 06:40:54 +00:00
const action = workspaceFileAction(normalized, workspaceDownloadExtensionSet);
2026-03-02 05:55:35 +00:00
if (action === 'download') {
2026-03-02 04:38:01 +00:00
triggerWorkspaceFileDownload(normalized);
return;
}
2026-03-02 05:55:35 +00:00
if (action === 'preview') {
void openWorkspaceFilePreview(normalized);
return;
}
2026-03-09 09:52:42 +00:00
try {
await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/tree`, {
params: { path: normalized },
});
await loadWorkspaceTree(selectedBotId, normalized);
2026-03-01 19:44:06 +00:00
return;
2026-03-09 09:52:42 +00:00
} catch {
2026-03-13 06:40:54 +00:00
if (!isPreviewableWorkspacePath(normalized, workspaceDownloadExtensionSet) || action === 'unsupported') {
2026-03-09 09:52:42 +00:00
notify(fileNotPreviewableLabel, { tone: 'warning' });
return;
}
2026-03-01 19:44:06 +00:00
}
};
const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => {
const source = String(text || '');
if (!source) return [source];
const pattern =
2026-03-13 06:40:54 +00:00
/\[(\/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;
2026-03-01 19:44:06 +00:00
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(
<a
key={`${keyPrefix}-ws-${matchIndex}`}
href="#"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
2026-03-09 09:52:42 +00:00
void openWorkspacePathFromChat(normalizedPath);
2026-03-01 19:44:06 +00:00
}}
>
{displayText}
</a>,
);
} 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;
};
2026-03-01 16:26:03 +00:00
const markdownComponents = useMemo(
() => ({
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
const link = String(href || '').trim();
2026-03-01 19:44:06 +00:00
const workspacePath = parseWorkspaceLink(link);
if (workspacePath) {
return (
<a
href="#"
onClick={(event) => {
event.preventDefault();
2026-03-09 09:52:42 +00:00
void openWorkspacePathFromChat(workspacePath);
2026-03-01 19:44:06 +00:00
}}
{...props}
>
{children}
</a>
);
}
2026-03-01 16:26:03 +00:00
if (isExternalHttpLink(link)) {
return (
<a href={link} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
);
}
return (
<a href={link || '#'} {...props}>
{children}
</a>
);
},
2026-03-01 19:44:06 +00:00
p: ({ children, ...props }: { children?: ReactNode }) => (
<p {...props}>{renderWorkspaceAwareChildren(children, 'md-p')}</p>
),
li: ({ children, ...props }: { children?: ReactNode }) => (
<li {...props}>{renderWorkspaceAwareChildren(children, 'md-li')}</li>
),
code: ({ children, ...props }: { children?: ReactNode }) => (
<code {...props}>{renderWorkspaceAwareChildren(children, 'md-code')}</code>
),
2026-03-01 16:26:03 +00:00
}),
2026-03-01 19:44:06 +00:00
[fileNotPreviewableLabel, notify, selectedBotId],
2026-03-01 16:26:03 +00:00
);
const [editForm, setEditForm] = useState({
name: '',
2026-03-05 02:42:39 +00:00
access_password: '',
2026-03-01 16:26:03 +00:00
llm_provider: '',
llm_model: '',
image_tag: '',
api_key: '',
api_base: '',
temperature: 0.2,
top_p: 1,
max_tokens: 8192,
2026-03-03 06:09:11 +00:00
cpu_cores: 1,
memory_mb: 1024,
storage_gb: 10,
2026-03-01 16:26:03 +00:00
agents_md: '',
soul_md: '',
user_md: '',
tools_md: '',
identity_md: '',
});
2026-03-03 06:09:11 +00:00
const [paramDraft, setParamDraft] = useState({
max_tokens: '8192',
cpu_cores: '1',
memory_mb: '1024',
storage_gb: '10',
});
2026-03-01 16:26:03 +00:00
2026-03-10 16:53:54 +00:00
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]);
2026-03-10 07:04:33 +00:00
const bots = useMemo(
() =>
Object.values(activeBots).sort((a, b) => {
2026-03-10 16:53:54 +00:00
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);
2026-03-10 07:04:33 +00:00
}),
[activeBots],
);
2026-03-14 05:03:22 +00:00
const hasForcedBot = Boolean(String(forcedBotId || '').trim());
const compactListFirstMode = compactMode && !hasForcedBot;
const isCompactListPage = compactListFirstMode && !selectedBotId;
const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId);
2026-03-14 08:07:34 +00:00
const showBotListPanel = !hasForcedBot && (!compactMode || isCompactListPage);
2026-03-09 04:53:15 +00:00
const normalizedBotListQuery = botListQuery.trim().toLowerCase();
const filteredBots = useMemo(() => {
if (!normalizedBotListQuery) return bots;
return bots.filter((bot) => {
const id = String(bot.id || '').toLowerCase();
const name = String(bot.name || '').toLowerCase();
return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery);
});
}, [bots, normalizedBotListQuery]);
const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / BOT_LIST_PAGE_SIZE));
const pagedBots = useMemo(() => {
const page = Math.min(Math.max(1, botListPage), botListTotalPages);
const start = (page - 1) * BOT_LIST_PAGE_SIZE;
return filteredBots.slice(start, start + BOT_LIST_PAGE_SIZE);
}, [filteredBots, botListPage, botListTotalPages]);
2026-03-01 16:26:03 +00:00
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 });
2026-03-13 06:40:54 +00:00
const activeTopicOptions = useMemo<TopicFeedOption[]>(
() =>
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]);
2026-03-01 16:26:03 +00:00
const lc = isZh ? channelsZhCn : channelsEn;
2026-03-03 06:09:11 +00:00
const baseImageOptions = useMemo<BaseImageOption[]>(() => {
const readyTags = new Set(
availableImages
.filter((img) => String(img.status || '').toUpperCase() === 'READY')
.map((img) => String(img.tag || '').trim())
.filter(Boolean),
);
const allTags = new Set<string>();
localDockerImages.forEach((img) => {
const tag = String(img.tag || '').trim();
if (tag) allTags.add(tag);
});
availableImages.forEach((img) => {
const tag = String(img.tag || '').trim();
if (tag) allTags.add(tag);
});
if (editForm.image_tag) {
allTags.add(editForm.image_tag);
}
return Array.from(allTags)
.sort((a, b) => a.localeCompare(b))
.map((tag) => {
const isReady = readyTags.has(tag);
if (isReady) {
return { tag, label: `${tag} · READY`, disabled: false, needsRegister: false };
}
const hasInDocker = localDockerImages.some((row) => String(row.tag || '').trim() === tag);
if (hasInDocker) {
return {
tag,
label: isZh ? `${tag} · 本地镜像(未登记)` : `${tag} · local image (unregistered)`,
disabled: false,
needsRegister: true,
};
}
return {
tag,
label: isZh ? `${tag} · 不可用` : `${tag} · unavailable`,
disabled: true,
needsRegister: false,
};
});
}, [availableImages, localDockerImages, editForm.image_tag, isZh]);
2026-03-01 16:26:03 +00:00
const runtimeMoreLabel = isZh ? '更多' : 'More';
2026-03-13 06:40:54 +00:00
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<string, unknown>;
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<string, unknown>;
const rows = parsed?.presets;
if (Array.isArray(rows)) return rows.length;
return effectiveTopicPresetTemplates.length;
} catch {
return effectiveTopicPresetTemplates.length;
}
}, [templateTopicText, effectiveTopicPresetTemplates.length]);
2026-03-01 16:26:03 +00:00
const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined;
const isSending = selectedBot ? Boolean(sendingByBot[selectedBot.id]) : false;
const canChat = Boolean(selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState);
const isChatEnabled = Boolean(canChat && !isSending);
const conversation = useMemo(() => mergeConversation(messages), [messages]);
const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
2026-03-13 06:40:54 +00:00
const workspaceDownloadExtensionSet = useMemo(
() => new Set(parseWorkspaceDownloadExtensions(workspaceDownloadExtensions)),
[workspaceDownloadExtensions],
);
2026-03-01 16:26:03 +00:00
const workspaceFiles = useMemo(
2026-03-13 06:40:54 +00:00
() => workspaceEntries.filter((v) => v.type === 'file' && isPreviewableWorkspaceFile(v, workspaceDownloadExtensionSet)),
[workspaceEntries, workspaceDownloadExtensionSet],
2026-03-01 16:26:03 +00:00
);
2026-03-09 09:52:42 +00:00
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]);
2026-03-01 16:26:03 +00:00
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],
);
2026-03-09 04:53:15 +00:00
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],
);
2026-03-01 16:26:03 +00:00
const isThinking = useMemo(() => {
if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false;
if (lastUserTs <= 0) return false;
if (lastAssistantFinalTs >= lastUserTs) return false;
2026-03-09 04:53:15 +00:00
return hasFreshRuntimeSignal;
}, [selectedBot, lastUserTs, lastAssistantFinalTs, hasFreshRuntimeSignal]);
2026-03-01 16:26:03 +00:00
const displayState = useMemo(() => {
if (!selectedBot) return 'IDLE';
const backendState = normalizeRuntimeState(selectedBot.current_state);
if (selectedBot.docker_status !== 'RUNNING') return backendState;
2026-03-09 04:53:15 +00:00
if (hasFreshRuntimeSignal && (backendState === 'TOOL_CALL' || backendState === 'THINKING' || backendState === 'ERROR')) {
2026-03-01 16:26:03 +00:00
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';
2026-03-09 04:53:15 +00:00
}, [selectedBot, isThinking, latestEvent, hasFreshRuntimeSignal]);
2026-03-01 16:26:03 +00:00
const runtimeAction = useMemo(() => {
2026-03-01 20:04:42 +00:00
const action = normalizeAssistantMessageText(selectedBot?.last_action || '').trim();
if (action) return action;
const eventText = normalizeAssistantMessageText(latestEvent?.text || '').trim();
if (eventText) return eventText;
2026-03-01 16:26:03 +00:00
return '-';
2026-03-01 20:04:42 +00:00
}, [selectedBot, latestEvent]);
2026-03-03 06:09:11 +00:00
const resourceBot = useMemo(() => bots.find((b) => b.id === resourceBotId), [bots, resourceBotId]);
2026-03-13 06:40:54 +00:00
const hasTopicUnread = topicFeedUnreadCount > 0;
2026-03-01 19:44:06 +00:00
2026-03-05 02:42:39 +00:00
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 });
};
2026-03-01 19:44:06 +00:00
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;
};
2026-03-01 16:26:03 +00:00
const conversationNodes = useMemo(
() =>
2026-03-01 19:44:06 +00:00
conversation.map((item, idx) => {
2026-03-03 16:45:51 +00:00
const itemKey = `${item.id || item.ts}-${idx}`;
2026-03-01 19:44:06 +00:00
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
2026-03-11 14:25:31 +00:00
const isUserBubble = item.role === 'user';
2026-03-01 20:04:42 +00:00
const fullText = String(item.text || '');
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
2026-03-11 14:25:31 +00:00
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]);
2026-03-01 20:04:42 +00:00
const displayText = isProgressBubble && !expanded ? summaryText : fullText;
2026-03-09 04:53:15 +00:00
const currentDayKey = new Date(item.ts).toDateString();
const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : '';
const showDateDivider = idx === 0 || currentDayKey !== prevDayKey;
2026-03-01 19:44:06 +00:00
return (
2026-03-09 04:53:15 +00:00
<div key={itemKey}>
{showDateDivider ? (
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
<span>{formatConversationDate(item.ts, isZh)}</span>
2026-03-03 16:45:51 +00:00
</div>
) : null}
2026-03-09 04:53:15 +00:00
<div className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
<div className={`ops-chat-item ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
{item.role !== 'user' && (
<div className="ops-avatar bot" title="Nanobot">
<img src={nanobotLogo} alt="Nanobot" />
2026-03-01 16:26:03 +00:00
</div>
2026-03-09 04:53:15 +00:00
)}
{item.role === 'user' ? (
<div className="ops-chat-hover-actions ops-chat-hover-actions-user">
2026-03-10 07:04:33 +00:00
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => editUserPrompt(item.text)}
tooltip={t.editPrompt}
aria-label={t.editPrompt}
>
<Pencil size={13} />
</LucentIconButton>
2026-03-03 16:45:51 +00:00
<LucentIconButton
className="ops-chat-inline-action"
2026-03-09 04:53:15 +00:00
onClick={() => void copyUserPrompt(item.text)}
tooltip={t.copyPrompt}
aria-label={t.copyPrompt}
2026-03-03 16:45:51 +00:00
>
<Copy size={13} />
</LucentIconButton>
</div>
) : null}
2026-03-01 16:26:03 +00:00
2026-03-09 04:53:15 +00:00
<div className={`ops-chat-bubble ${item.role === 'user' ? 'user' : 'assistant'} ${(item.kind || 'final') === 'progress' ? 'progress' : ''}`}>
<div className="ops-chat-meta">
<span>{item.role === 'user' ? t.you : 'Nanobot'}</span>
<div className="ops-chat-meta-right">
<span className="mono">{formatClock(item.ts)}</span>
{collapsible ? (
<LucentIconButton
className="ops-chat-expand-icon-btn"
2026-03-11 14:25:31 +00:00
onClick={() => {
if (isProgressBubble) {
setExpandedProgressByKey((prev) => ({
...prev,
[itemKey]: !prev[itemKey],
}));
return;
}
setExpandedUserByKey((prev) => ({
2026-03-09 04:53:15 +00:00
...prev,
[itemKey]: !prev[itemKey],
2026-03-11 14:25:31 +00:00
}));
}}
2026-03-09 04:53:15 +00:00
tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
>
2026-03-11 14:25:31 +00:00
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
2026-03-09 04:53:15 +00:00
</LucentIconButton>
) : null}
</div>
</div>
2026-03-11 14:25:31 +00:00
<div className={`ops-chat-text ${collapsible && !expanded ? (isUserBubble ? 'is-collapsed-user' : 'is-collapsed') : ''}`}>
2026-03-09 04:53:15 +00:00
{item.text ? (
item.role === 'user' ? (
<>
{item.quoted_reply ? (
<div className="ops-user-quoted-reply">
<div className="ops-user-quoted-label">{t.quotedReplyLabel}</div>
<div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div>
</div>
) : null}
2026-03-11 14:25:31 +00:00
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
2026-03-09 04:53:15 +00:00
</>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={markdownComponents}
>
{decorateWorkspacePathsForMarkdown(displayText)}
</ReactMarkdown>
)
) : null}
{(item.attachments || []).length > 0 ? (
<div className="ops-chat-attachments">
{(item.attachments || []).map((rawPath) => {
const filePath = normalizeDashboardAttachmentPath(rawPath);
2026-03-13 06:40:54 +00:00
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
2026-03-09 04:53:15 +00:00
const filename = filePath.split('/').pop() || filePath;
return (
<a
key={`${item.ts}-${filePath}`}
className="ops-attach-link mono"
2026-03-09 09:52:42 +00:00
href="#"
onClick={(event) => {
event.preventDefault();
void openWorkspacePathFromChat(filePath);
}}
2026-03-09 04:53:15 +00:00
title={fileAction === 'download' ? t.download : fileAction === 'preview' ? t.previewTitle : t.fileNotPreviewable}
>
{fileAction === 'download' ? (
<Download size={12} className="ops-attach-link-icon" />
) : fileAction === 'preview' ? (
<Eye size={12} className="ops-attach-link-icon" />
) : (
<FileText size={12} className="ops-attach-link-icon" />
)}
<span className="ops-attach-link-name">{filename}</span>
</a>
);
})}
</div>
) : null}
{item.role === 'assistant' && !isProgressBubble ? (
<div className="ops-chat-reply-actions">
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'up' ? 'active-up' : ''}`}
onClick={() => void submitAssistantFeedback(item, 'up')}
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
tooltip={t.goodReply}
aria-label={t.goodReply}
>
<ThumbsUp size={13} />
</LucentIconButton>
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'down' ? 'active-down' : ''}`}
onClick={() => void submitAssistantFeedback(item, 'down')}
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
tooltip={t.badReply}
aria-label={t.badReply}
>
<ThumbsDown size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => quoteAssistantReply(item)}
tooltip={t.quoteReply}
aria-label={t.quoteReply}
>
<Reply size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void copyAssistantReply(item.text)}
tooltip={t.copyReply}
aria-label={t.copyReply}
>
<Copy size={13} />
</LucentIconButton>
</div>
) : null}
</div>
</div>
2026-03-01 16:26:03 +00:00
</div>
2026-03-09 04:53:15 +00:00
{item.role === 'user' && (
<div className="ops-avatar user" title={t.user}>
<UserRound size={18} />
</div>
)}
</div>
2026-03-01 16:26:03 +00:00
</div>
2026-03-09 04:53:15 +00:00
);
}),
2026-03-03 16:45:51 +00:00
[
conversation,
expandedProgressByKey,
2026-03-11 14:25:31 +00:00
expandedUserByKey,
2026-03-03 16:45:51 +00:00
feedbackSavingByMessageId,
isZh,
selectedBotId,
t.badReply,
t.copyPrompt,
t.copyReply,
2026-03-09 04:53:15 +00:00
t.quoteReply,
t.quotedReplyLabel,
2026-03-03 16:45:51 +00:00
t.goodReply,
t.user,
t.you,
],
2026-03-01 16:26:03 +00:00
);
2026-03-09 04:53:15 +00:00
useEffect(() => {
setBotListPage(1);
}, [normalizedBotListQuery]);
useEffect(() => {
setBotListPage((prev) => Math.min(Math.max(prev, 1), botListTotalPages));
}, [botListTotalPages]);
2026-03-01 16:26:03 +00:00
useEffect(() => {
const forced = String(forcedBotId || '').trim();
if (forced) {
if (activeBots[forced]) {
if (selectedBotId !== forced) setSelectedBotId(forced);
} else if (selectedBotId) {
setSelectedBotId('');
}
return;
}
2026-03-14 05:03:22 +00:00
if (compactListFirstMode) {
if (selectedBotId && !activeBots[selectedBotId]) {
setSelectedBotId('');
}
return;
}
2026-03-01 16:26:03 +00:00
if (!selectedBotId && bots.length > 0) setSelectedBotId(bots[0].id);
if (selectedBotId && !activeBots[selectedBotId] && bots.length > 0) setSelectedBotId(bots[0].id);
2026-03-14 05:03:22 +00:00
}, [bots, selectedBotId, activeBots, forcedBotId, compactListFirstMode]);
2026-03-01 16:26:03 +00:00
2026-03-10 18:40:59 +00:00
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]);
2026-03-11 17:20:57 +00:00
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]);
2026-03-10 18:40:59 +00:00
useEffect(() => {
const hasDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
2026-03-11 17:20:57 +00:00
if (!hasDraft && !isUploadingAttachments && !isVoiceRecording && !isVoiceTranscribing) return;
2026-03-10 18:40:59 +00:00
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
2026-03-11 17:20:57 +00:00
}, [command, pendingAttachments.length, quotedReply, isUploadingAttachments, isVoiceRecording, isVoiceTranscribing]);
2026-03-10 18:40:59 +00:00
2026-03-11 12:55:42 +00:00
const syncChatScrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
const box = chatScrollRef.current;
if (!box) return;
box.scrollTo({ top: box.scrollHeight, behavior });
}, []);
2026-03-01 16:26:03 +00:00
useEffect(() => {
2026-03-11 12:55:42 +00:00
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
}, [selectedBotId, syncChatScrollToBottom]);
useEffect(() => {
if (!chatAutoFollowRef.current) return;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
}, [conversation.length, syncChatScrollToBottom]);
2026-03-01 16:26:03 +00:00
2026-03-09 04:53:15 +00:00
useEffect(() => {
setQuotedReply(null);
2026-03-11 17:20:57 +00:00
if (isVoiceRecording) {
stopVoiceRecording();
}
2026-03-09 04:53:15 +00:00
}, [selectedBotId]);
2026-03-01 16:26:03 +00:00
useEffect(() => {
const onPointerDown = (event: MouseEvent) => {
2026-03-13 06:40:54 +00:00
if (runtimeMenuRef.current && !runtimeMenuRef.current.contains(event.target as Node)) {
2026-03-01 16:26:03 +00:00
setRuntimeMenuOpen(false);
}
2026-03-13 06:40:54 +00:00
if (botListMenuRef.current && !botListMenuRef.current.contains(event.target as Node)) {
setBotListMenuOpen(false);
}
2026-03-01 16:26:03 +00:00
};
document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown);
}, []);
useEffect(() => {
setRuntimeMenuOpen(false);
2026-03-13 06:40:54 +00:00
setBotListMenuOpen(false);
2026-03-01 16:26:03 +00:00
}, [selectedBotId]);
2026-03-01 19:44:06 +00:00
useEffect(() => {
setExpandedProgressByKey({});
2026-03-11 14:25:31 +00:00
setExpandedUserByKey({});
2026-03-01 20:27:58 +00:00
setShowRuntimeActionModal(false);
2026-03-05 02:42:39 +00:00
setWorkspaceHoverCard(null);
2026-03-01 19:44:06 +00:00
}, [selectedBotId]);
2026-03-09 04:53:15 +00:00
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]);
2026-03-05 02:42:39 +00:00
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]);
2026-03-03 06:09:11 +00:00
useEffect(() => {
let alive = true;
const loadSystemDefaults = async () => {
try {
const res = await axios.get<SystemDefaultsResponse>(`${APP_ENDPOINTS.apiBase}/system/defaults`);
2026-03-11 17:20:57 +00:00
if (!alive) return;
2026-03-03 06:09:11 +00:00
const configured = Number(res.data?.limits?.upload_max_mb);
2026-03-11 17:20:57 +00:00
if (Number.isFinite(configured) && configured > 0) {
setUploadMaxMb(Math.max(1, Math.floor(configured)));
}
2026-03-13 06:40:54 +00:00
setWorkspaceDownloadExtensions(
parseWorkspaceDownloadExtensions(
res.data?.workspace?.download_extensions,
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
),
);
setTopicPresetTemplates(parseTopicPresets(res.data?.topic_presets));
2026-03-11 17:20:57 +00:00
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);
}
2026-03-14 07:44:11 +00:00
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))));
}
2026-03-03 06:09:11 +00:00
} catch {
// keep default limit
}
};
void loadSystemDefaults();
return () => {
alive = false;
};
}, []);
2026-03-01 16:26:03 +00:00
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;
2026-03-09 09:52:42 +00:00
if (showBaseModal || showParamModal || showAgentModal) return;
applyEditFormFromBot(selectedBot);
}, [
selectedBotId,
selectedBot?.id,
selectedBot?.updated_at,
showBaseModal,
showParamModal,
showAgentModal,
applyEditFormFromBot,
]);
2026-03-01 16:26:03 +00:00
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]);
2026-03-03 06:09:11 +00:00
const loadImageOptions = async () => {
const [imagesRes, dockerImagesRes] = await Promise.allSettled([
axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`),
axios.get<DockerImage[]>(`${APP_ENDPOINTS.apiBase}/docker-images`),
]);
if (imagesRes.status === 'fulfilled') {
setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []);
} else {
setAvailableImages([]);
}
if (dockerImagesRes.status === 'fulfilled') {
setLocalDockerImages(Array.isArray(dockerImagesRes.value.data) ? dockerImagesRes.value.data : []);
} else {
setLocalDockerImages([]);
}
};
2026-03-01 16:26:03 +00:00
const refresh = async () => {
2026-03-03 06:09:11 +00:00
const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`);
setBots(botsRes.data);
await loadImageOptions();
2026-03-01 16:26:03 +00:00
};
2026-03-09 09:52:42 +00:00
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]);
2026-03-03 06:09:11 +00:00
const loadResourceSnapshot = async (botId: string) => {
if (!botId) return;
setResourceLoading(true);
setResourceError('');
try {
const res = await axios.get<BotResourceSnapshot>(`${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]);
2026-03-01 16:26:03 +00:00
const openWorkspaceFilePreview = async (path: string) => {
if (!selectedBotId || !path) return;
const normalizedPath = String(path || '').trim();
2026-03-02 04:38:01 +00:00
setWorkspacePreviewFullscreen(false);
2026-03-13 06:40:54 +00:00
if (workspaceFileAction(normalizedPath, workspaceDownloadExtensionSet) === 'download') {
2026-03-02 04:38:01 +00:00
triggerWorkspaceFileDownload(normalizedPath);
2026-03-01 16:26:03 +00:00
return;
}
2026-03-01 19:44:06 +00:00
if (isImagePath(normalizedPath)) {
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
setWorkspacePreview({
path: normalizedPath,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: true,
2026-03-02 07:51:47 +00:00
isHtml: false,
2026-03-10 18:28:39 +00:00
isVideo: false,
isAudio: false,
2026-03-02 07:51:47 +00:00
});
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,
2026-03-10 18:28:39 +00:00
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,
2026-03-01 19:44:06 +00:00
});
return;
}
2026-03-01 16:26:03 +00:00
setWorkspaceFileLoading(true);
try {
const res = await axios.get<WorkspaceFileResponse>(`${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),
2026-03-01 19:44:06 +00:00
isImage: false,
2026-03-02 07:51:47 +00:00
isHtml: false,
2026-03-10 18:28:39 +00:00
isVideo: false,
isAudio: false,
2026-03-01 16:26:03 +00:00
});
} catch (error: any) {
const msg = error?.response?.data?.detail || t.fileReadFail;
notify(msg, { tone: 'error' });
} finally {
setWorkspaceFileLoading(false);
}
};
const loadWorkspaceTree = async (botId: string, path: string = '') => {
if (!botId) return;
setWorkspaceLoading(true);
setWorkspaceError('');
try {
const res = await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, {
params: { path },
});
const entries = Array.isArray(res.data?.entries) ? res.data.entries : [];
setWorkspaceEntries(entries);
2026-03-09 09:52:42 +00:00
setWorkspaceSearchEntries([]);
2026-03-01 16:26:03 +00:00
setWorkspaceCurrentPath(res.data?.cwd || '');
setWorkspaceParentPath(res.data?.parent ?? null);
} catch (error: any) {
setWorkspaceEntries([]);
2026-03-09 09:52:42 +00:00
setWorkspaceSearchEntries([]);
2026-03-01 16:26:03 +00:00
setWorkspaceCurrentPath('');
setWorkspaceParentPath(null);
setWorkspaceError(error?.response?.data?.detail || t.workspaceLoadFail);
} finally {
setWorkspaceLoading(false);
}
};
2026-03-09 09:52:42 +00:00
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<WorkspaceTreeResponse>(`${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);
}
};
2026-03-13 06:40:54 +00:00
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<BotChannel, 'id' | 'channel_type'>, 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: '',
});
};
2026-03-13 11:23:06 +00:00
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<string, string> = {};
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<string, boolean> = {};
drafts.forEach((row, idx) => {
const key = mcpDraftUiKey(row, idx);
next[key] = typeof prev[key] === 'boolean' ? prev[key] : idx === 0;
});
return next;
});
setMcpTestByIndex({});
return drafts;
};
2026-03-13 06:40:54 +00:00
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);
};
2026-03-13 11:23:06 +00:00
const openMcpModal = async (botId: string) => {
2026-03-13 06:40:54 +00:00
if (!botId) return;
setExpandedMcpByKey({});
setNewMcpPanelOpen(false);
resetNewMcpDraft();
2026-03-14 07:44:11 +00:00
await loadBotMcpConfig(botId);
2026-03-13 06:40:54 +00:00
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<BotTopic, 'topic_key' | 'id'>, fallbackIndex: number) => {
const key = String(topic.topic_key || topic.id || '').trim();
return key || `topic-${fallbackIndex}`;
};
const mcpDraftUiKey = (row: Pick<MCPServerDraft, 'name' | 'url'>, 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<string, unknown>) => {
const row = routing && typeof routing === 'object' ? routing : {};
const examplesRaw = row.examples && typeof row.examples === 'object' ? row.examples as Record<string, unknown> : {};
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<string, unknown>;
routing_purpose?: string;
routing_include_when?: string;
routing_exclude_when?: string;
routing_examples_positive?: string;
routing_examples_negative?: string;
routing_priority?: string;
}): Record<string, unknown> => {
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<BotTopic[]>(`${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<string, boolean> = {};
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<BotTopic>) => {
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<string, string | number> = { limit: 40 };
if (topicKey) params.topic_key = topicKey;
if (append && Number.isFinite(cursor) && cursor > 0) {
params.cursor = cursor;
}
const res = await axios.get<TopicFeedListResponse>(`${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<number>(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<TopicFeedStatsResponse>(`${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;
});
}
};
2026-03-15 07:14:01 +00:00
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;
});
}
};
2026-03-01 16:26:03 +00:00
const loadChannels = async (botId: string) => {
if (!botId) return;
const res = await axios.get<BotChannel[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`);
2026-03-13 06:40:54 +00:00
const rows = Array.isArray(res.data) ? res.data : [];
setChannels(rows);
setExpandedChannelByKey((prev) => {
const next: Record<string, boolean> = {};
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;
});
2026-03-01 16:26:03 +00:00
};
const loadBotSkills = async (botId: string) => {
if (!botId) return;
const res = await axios.get<WorkspaceSkillOption[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skills`);
setBotSkills(Array.isArray(res.data) ? res.data : []);
};
const loadBotEnvParams = async (botId: string) => {
if (!botId) return;
try {
const res = await axios.get<{ env_params?: Record<string, string> }>(`${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;
});
};
2026-03-13 11:23:06 +00:00
const loadBotMcpConfig = async (botId: string): Promise<MCPServerDraft[]> => {
if (!botId) return [];
2026-03-11 12:55:42 +00:00
try {
const res = await axios.get<MCPConfigResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/mcp-config`);
2026-03-13 11:23:06 +00:00
const drafts = mapMcpResponseToDrafts(res.data);
return applyMcpDrafts(drafts);
2026-03-11 12:55:42 +00:00
} catch {
2026-03-13 11:23:06 +00:00
applyMcpDrafts([]);
return [];
}
};
2026-03-13 06:40:54 +00:00
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 });
2026-03-11 12:55:42 +00:00
};
const updateMcpServer = (index: number, patch: Partial<MCPServerDraft>) => {
setMcpServers((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row)));
setMcpTestByIndex((prev) => ({ ...prev, [index]: { status: 'idle', message: '' } }));
};
2026-03-13 07:40:30 +00:00
const canRemoveMcpServer = (row?: MCPServerDraft | null) => {
2026-03-14 07:44:11 +00:00
return Boolean(row && !row.locked);
2026-03-13 07:40:30 +00:00
};
2026-03-13 11:23:06 +00:00
const removeMcpServer = async (index: number) => {
2026-03-13 06:40:54 +00:00
const row = mcpServers[index];
2026-03-13 07:40:30 +00:00
if (!canRemoveMcpServer(row)) {
2026-03-14 07:44:11 +00:00
notify(isZh ? '当前 MCP 服务不可删除。' : 'This MCP server cannot be removed.', { tone: 'warning' });
2026-03-13 06:40:54 +00:00
return;
}
2026-03-13 11:23:06 +00:00
const nextRows = mcpServers.filter((_, i) => i !== index);
setMcpServers(nextRows);
setPersistedMcpServers(nextRows);
2026-03-13 06:40:54 +00:00
setExpandedMcpByKey((prev) => {
const next: Record<string, boolean> = {};
2026-03-13 11:23:06 +00:00
nextRows.forEach((server, idx) => {
const key = mcpDraftUiKey(server, idx);
next[key] = typeof prev[key] === 'boolean' ? prev[key] : idx === 0;
});
2026-03-13 06:40:54 +00:00
return next;
});
2026-03-11 12:55:42 +00:00
setMcpTestByIndex((prev) => {
const next: Record<number, MCPTestState> = {};
Object.entries(prev).forEach(([key, val]) => {
const idx = Number(key);
if (idx < index) next[idx] = val;
if (idx > index) next[idx - 1] = val;
});
return next;
});
2026-03-13 12:23:21 +00:00
await saveBotMcpConfig(nextRows, { skipConnectivityTest: true });
2026-03-11 12:55:42 +00:00
};
2026-03-13 06:40:54 +00:00
const buildMcpHeaders = (row: MCPServerDraft): Record<string, string> => {
const headers: Record<string, string> = {};
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;
};
2026-03-11 12:55:42 +00:00
const testSingleMcpServer = async (row: MCPServerDraft, index: number): Promise<boolean> => {
if (!selectedBot) return false;
const url = String(row.url || '').trim();
if (!url) {
setMcpTestByIndex((prev) => ({
...prev,
[index]: { status: 'fail', message: t.mcpTestNeedUrl },
}));
return false;
}
const timeout = Math.max(1, Math.min(600, Number(row.toolTimeout || 60) || 60));
setMcpTestByIndex((prev) => ({
...prev,
[index]: { status: 'testing', message: t.mcpTesting },
}));
try {
const res = await axios.post<MCPTestResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/mcp-config/test`, {
type: row.type === 'sse' ? 'sse' : 'streamableHttp',
url,
2026-03-13 06:40:54 +00:00
headers: buildMcpHeaders(row),
2026-03-11 12:55:42 +00:00
tool_timeout: timeout,
});
const ok = Boolean(res.data?.ok);
const baseMsg = String(res.data?.message || '').trim() || (ok ? t.mcpTestPass : t.mcpTestFail);
const probeFrom = String(res.data?.probe_from || '').trim();
const msg = probeFrom ? `${baseMsg} (${probeFrom})` : baseMsg;
setMcpTestByIndex((prev) => ({
...prev,
[index]: { status: ok ? 'pass' : 'fail', message: msg },
}));
return ok;
} catch (error: any) {
const msg = error?.response?.data?.detail || t.mcpTestFail;
setMcpTestByIndex((prev) => ({
...prev,
[index]: { status: 'fail', message: String(msg) },
}));
return false;
}
};
2026-03-13 06:40:54 +00:00
const saveBotMcpConfig = async (
rows: MCPServerDraft[] = mcpServers,
2026-03-13 12:23:21 +00:00
options?: { closeDraft?: boolean; expandedKey?: string; skipConnectivityTest?: boolean },
2026-03-13 06:40:54 +00:00
) => {
2026-03-11 12:55:42 +00:00
if (!selectedBot) return;
const mcp_servers: Record<string, MCPServerConfig> = {};
const testQueue: Array<{ index: number; row: MCPServerDraft }> = [];
2026-03-13 06:40:54 +00:00
for (const [index, row] of rows.entries()) {
2026-03-11 12:55:42 +00:00
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));
2026-03-13 06:40:54 +00:00
if (!row.locked) {
testQueue.push({ index, row });
}
2026-03-11 12:55:42 +00:00
mcp_servers[name] = {
type: row.type === 'sse' ? 'sse' : 'streamableHttp',
url,
2026-03-13 06:40:54 +00:00
headers: buildMcpHeaders(row),
2026-03-11 12:55:42 +00:00
toolTimeout: timeout,
};
}
setIsSavingMcp(true);
try {
2026-03-13 12:23:21 +00:00
if (!options?.skipConnectivityTest) {
for (const item of testQueue) {
const ok = await testSingleMcpServer(item.row, item.index);
if (!ok) {
notify(t.mcpTestBlockSave, { tone: 'error' });
setIsSavingMcp(false);
return;
}
2026-03-11 12:55:42 +00:00
}
}
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/mcp-config`, { mcp_servers });
2026-03-13 06:40:54 +00:00
if (options?.expandedKey) {
setExpandedMcpByKey({ [options.expandedKey]: true });
}
await loadBotMcpConfig(selectedBot.id);
if (options?.closeDraft) {
setNewMcpPanelOpen(false);
resetNewMcpDraft();
}
2026-03-11 12:55:42 +00:00
notify(t.mcpSaved, { tone: 'success' });
} catch (error: any) {
notify(error?.response?.data?.detail || t.mcpSaveFail, { tone: 'error' });
} finally {
setIsSavingMcp(false);
}
};
2026-03-01 16:26:03 +00:00
const removeBotSkill = async (skill: WorkspaceSkillOption) => {
if (!selectedBot) return;
const ok = await confirm({
title: t.removeSkill,
message: t.toolsRemoveConfirm(skill.name || skill.id),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/${encodeURIComponent(skill.id)}`);
await loadBotSkills(selectedBot.id);
} catch (error: any) {
notify(error?.response?.data?.detail || t.toolsRemoveFail, { tone: 'error' });
}
};
const triggerSkillZipUpload = () => {
if (!selectedBot || isSkillUploading) return;
skillZipPickerRef.current?.click();
};
const onPickSkillZip = async (event: ChangeEvent<HTMLInputElement>) => {
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<SkillUploadResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/upload`,
formData,
);
const nextSkills = Array.isArray(res.data?.skills) ? res.data.skills : [];
setBotSkills(nextSkills);
} catch (error: any) {
notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' });
} finally {
setIsSkillUploading(false);
event.target.value = '';
}
};
const loadCronJobs = async (botId: string) => {
if (!botId) return;
setCronLoading(true);
try {
const res = await axios.get<CronJobsResponse>(`${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<BotChannel>) => {
setChannels((prev) => prev.map((c, i) => (i === index ? { ...c, ...patch } : c)));
};
const saveChannel = async (channel: BotChannel) => {
if (!selectedBot) return;
setIsSavingChannel(true);
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`, {
channel_type: channel.channel_type,
external_app_id: channel.external_app_id,
app_secret: channel.app_secret,
internal_port: Number(channel.internal_port),
is_active: channel.is_active,
extra_config: sanitizeChannelExtra(String(channel.channel_type), channel.extra_config || {}),
});
await loadChannels(selectedBot.id);
notify(t.channelSaved, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || t.channelSaveFail;
notify(msg, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const addChannel = async () => {
2026-03-13 06:40:54 +00:00
if (!selectedBot) return;
const channelType = String(newChannelDraft.channel_type || '').trim().toLowerCase() as ChannelType;
if (!channelType || !addableChannelTypes.includes(channelType)) return;
2026-03-01 16:26:03 +00:00
setIsSavingChannel(true);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels`, {
2026-03-13 06:40:54 +00:00
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 || {}),
2026-03-01 16:26:03 +00:00
});
await loadChannels(selectedBot.id);
2026-03-13 06:40:54 +00:00
setNewChannelPanelOpen(false);
resetNewChannelDraft();
2026-03-01 16:26:03 +00:00
} catch (error: any) {
const msg = error?.response?.data?.detail || t.channelAddFail;
notify(msg, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const removeChannel = async (channel: BotChannel) => {
if (!selectedBot || channel.channel_type === 'dashboard') return;
const ok = await confirm({
title: t.channels,
message: t.channelDeleteConfirm(channel.channel_type),
tone: 'warning',
});
if (!ok) return;
setIsSavingChannel(true);
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`);
await loadChannels(selectedBot.id);
} catch (error: any) {
const msg = error?.response?.data?.detail || t.channelDeleteFail;
notify(msg, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const isDashboardChannel = (channel: BotChannel) => String(channel.channel_type).toLowerCase() === 'dashboard';
2026-03-14 07:44:11 +00:00
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());
};
2026-03-01 16:26:03 +00:00
const sanitizeChannelExtra = (channelType: string, extra: Record<string, unknown>) => {
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),
});
2026-03-01 19:44:06 +00:00
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`);
}
2026-03-01 16:26:03 +00:00
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);
}
};
2026-03-13 06:40:54 +00:00
const renderChannelFields = (channel: BotChannel, onPatch: (patch: Partial<BotChannel>) => void) => {
2026-03-01 16:26:03 +00:00
const ctype = String(channel.channel_type).toLowerCase();
if (ctype === 'telegram') {
return (
<>
2026-03-13 06:40:54 +00:00
<input className="input" type="password" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
2026-03-01 16:26:03 +00:00
<input
className="input"
placeholder={lc.proxy}
value={String((channel.extra_config || {}).proxy || '')}
2026-03-13 06:40:54 +00:00
onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })}
2026-03-11 12:55:42 +00:00
autoComplete="off"
2026-03-01 16:26:03 +00:00
/>
<label className="field-label">
<input
type="checkbox"
checked={Boolean((channel.extra_config || {}).replyToMessage)}
onChange={(e) =>
2026-03-13 06:40:54 +00:00
onPatch({ extra_config: { ...(channel.extra_config || {}), replyToMessage: e.target.checked } })
2026-03-01 16:26:03 +00:00
}
style={{ marginRight: 6 }}
/>
{lc.replyToMessage}
</label>
</>
);
}
if (ctype === 'feishu') {
return (
<>
2026-03-13 06:40:54 +00:00
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
<input className="input" placeholder={lc.encryptKey} value={String((channel.extra_config || {}).encryptKey || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} autoComplete="off" />
<input className="input" placeholder={lc.verificationToken} value={String((channel.extra_config || {}).verificationToken || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} autoComplete="off" />
2026-03-01 16:26:03 +00:00
</>
);
}
if (ctype === 'dingtalk') {
return (
<>
2026-03-13 06:40:54 +00:00
<input className="input" placeholder={lc.clientId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
2026-03-01 16:26:03 +00:00
</>
);
}
if (ctype === 'slack') {
return (
<>
2026-03-13 06:40:54 +00:00
<input className="input" placeholder={lc.botToken} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
2026-03-01 16:26:03 +00:00
</>
);
}
if (ctype === 'qq') {
return (
<>
2026-03-13 06:40:54 +00:00
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
2026-03-01 16:26:03 +00:00
</>
);
}
2026-03-14 07:44:11 +00:00
if (ctype === 'email') {
const extra = channel.extra_config || {};
return (
<>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.consentGranted)}
onChange={(e) => onPatch({ extra_config: { ...extra, consentGranted: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailConsentGranted}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailFromAddress}</label>
<input
className="input"
value={String(extra.fromAddress || '')}
onChange={(e) => onPatch({ extra_config: { ...extra, fromAddress: e.target.value } })}
autoComplete="off"
/>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{lc.emailAllowFrom}</label>
<textarea
className="textarea"
rows={3}
value={parseChannelListValue(extra.allowFrom)}
onChange={(e) => onPatch({ extra_config: { ...extra, allowFrom: parseChannelListInput(e.target.value) } })}
placeholder={lc.emailAllowFromPlaceholder}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapHost}</label>
<input className="input" value={String(extra.imapHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapHost: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapPort}</label>
<input className="input mono" type="number" min="1" max="65535" value={String(extra.imapPort ?? 993)} onChange={(e) => onPatch({ extra_config: { ...extra, imapPort: Number(e.target.value || 993) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapUsername}</label>
<input className="input" value={String(extra.imapUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapUsername: e.target.value } })} autoComplete="username" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapPassword}</label>
<input className="input" type="password" value={String(extra.imapPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapMailbox}</label>
<input className="input" value={String(extra.imapMailbox || 'INBOX')} onChange={(e) => onPatch({ extra_config: { ...extra, imapMailbox: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.imapUseSsl ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, imapUseSsl: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailImapUseSsl}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpHost}</label>
<input className="input" value={String(extra.smtpHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpHost: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpPort}</label>
<input className="input mono" type="number" min="1" max="65535" value={String(extra.smtpPort ?? 587)} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPort: Number(e.target.value || 587) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpUsername}</label>
<input className="input" value={String(extra.smtpUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpUsername: e.target.value } })} autoComplete="username" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpPassword}</label>
<input className="input" type="password" value={String(extra.smtpPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.smtpUseTls ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseTls: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailSmtpUseTls}
</label>
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.smtpUseSsl)}
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseSsl: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailSmtpUseSsl}
</label>
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.autoReplyEnabled ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, autoReplyEnabled: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailAutoReplyEnabled}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailPollIntervalSeconds}</label>
<input className="input mono" type="number" min="5" max="3600" value={String(extra.pollIntervalSeconds ?? 30)} onChange={(e) => onPatch({ extra_config: { ...extra, pollIntervalSeconds: Number(e.target.value || 30) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailMaxBodyChars}</label>
<input className="input mono" type="number" min="1" max="50000" value={String(extra.maxBodyChars ?? 12000)} onChange={(e) => onPatch({ extra_config: { ...extra, maxBodyChars: Number(e.target.value || 12000) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSubjectPrefix}</label>
<input className="input" value={String(extra.subjectPrefix || 'Re: ')} onChange={(e) => onPatch({ extra_config: { ...extra, subjectPrefix: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.markSeen ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, markSeen: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailMarkSeen}
</label>
</div>
</>
);
}
2026-03-01 16:26:03 +00:00
return null;
};
2026-03-13 06:40:54 +00:00
const openTemplateManager = async () => {
setBotListMenuOpen(false);
setIsLoadingTemplates(true);
2026-03-01 16:26:03 +00:00
try {
2026-03-13 06:40:54 +00:00
const res = await axios.get(`${APP_ENDPOINTS.apiBase}/system/templates`);
const agentRaw = res.data?.agent_md_templates;
const topicRaw = res.data?.topic_presets;
setTemplateAgentText(JSON.stringify(agentRaw && typeof agentRaw === 'object' ? agentRaw : {}, null, 2));
setTemplateTopicText(JSON.stringify(topicRaw && typeof topicRaw === 'object' ? topicRaw : { presets: [] }, null, 2));
setTemplateTab('agent');
setShowTemplateModal(true);
2026-03-01 16:26:03 +00:00
} catch {
2026-03-13 06:40:54 +00:00
notify(t.templateLoadFail, { tone: 'error' });
2026-03-01 16:26:03 +00:00
} finally {
2026-03-13 06:40:54 +00:00
setIsLoadingTemplates(false);
2026-03-01 16:26:03 +00:00
}
};
2026-03-13 06:40:54 +00:00
const saveTemplateManager = async (scope: 'agent' | 'topic') => {
let payload: Record<string, unknown>;
2026-03-01 16:26:03 +00:00
try {
2026-03-13 06:40:54 +00:00
if (scope === 'agent') {
const parsedAgent = JSON.parse(templateAgentText || '{}');
if (!parsedAgent || typeof parsedAgent !== 'object' || Array.isArray(parsedAgent)) {
throw new Error(t.templateAgentInvalid);
}
const agentObject = parsedAgent as Record<string, unknown>;
payload = {
agent_md_templates: {
agents_md: String(agentObject.agents_md || ''),
soul_md: String(agentObject.soul_md || ''),
user_md: String(agentObject.user_md || ''),
tools_md: String(agentObject.tools_md || ''),
identity_md: String(agentObject.identity_md || ''),
},
};
} else {
const parsedTopic = JSON.parse(templateTopicText || '{"presets":[]}');
if (!parsedTopic || typeof parsedTopic !== 'object' || Array.isArray(parsedTopic)) {
throw new Error(t.templateTopicInvalid);
}
payload = {
topic_presets: parsedTopic,
};
}
} catch (error: any) {
notify(error?.message || t.templateParseFail, { tone: 'error' });
return;
}
setIsSavingTemplates(true);
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/system/templates`, payload);
notify(t.templateSaved, { tone: 'success' });
if (scope === 'topic') {
const defaults = await axios.get<SystemDefaultsResponse>(`${APP_ENDPOINTS.apiBase}/system/defaults`);
setTopicPresetTemplates(parseTopicPresets(defaults.data?.topic_presets));
}
} catch {
notify(t.templateSaveFail, { tone: 'error' });
} finally {
setIsSavingTemplates(false);
}
};
const batchStartBots = async () => {
if (isBatchOperating) return;
const candidates = bots.filter((bot) => String(bot.docker_status || '').toUpperCase() !== 'RUNNING');
if (candidates.length === 0) {
notify(t.batchStartNone, { tone: 'warning' });
return;
}
const ok = await confirm({
title: t.batchStart,
message: t.batchStartConfirm(candidates.length),
tone: 'warning',
});
if (!ok) return;
setBotListMenuOpen(false);
setIsBatchOperating(true);
let success = 0;
let failed = 0;
try {
for (const bot of candidates) {
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${bot.id}/start`);
updateBotStatus(bot.id, 'RUNNING');
success += 1;
} catch {
failed += 1;
}
}
await refresh();
notify(t.batchStartDone(success, failed), { tone: failed > 0 ? 'warning' : 'success' });
} finally {
setIsBatchOperating(false);
}
};
const batchStopBots = async () => {
if (isBatchOperating) return;
const candidates = bots.filter((bot) => String(bot.docker_status || '').toUpperCase() === 'RUNNING');
if (candidates.length === 0) {
notify(t.batchStopNone, { tone: 'warning' });
return;
}
const ok = await confirm({
title: t.batchStop,
message: t.batchStopConfirm(candidates.length),
tone: 'warning',
});
if (!ok) return;
setBotListMenuOpen(false);
setIsBatchOperating(true);
let success = 0;
let failed = 0;
try {
for (const bot of candidates) {
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${bot.id}/stop`);
updateBotStatus(bot.id, 'STOPPED');
success += 1;
} catch {
failed += 1;
}
}
await refresh();
notify(t.batchStopDone(success, failed), { tone: failed > 0 ? 'warning' : 'success' });
} finally {
setIsBatchOperating(false);
}
};
const stopBot = async (id: string, status: string) => {
if (status !== 'RUNNING') return;
setOperatingBotId(id);
setControlStateByBot((prev) => ({ ...prev, [id]: 'stopping' }));
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`);
updateBotStatus(id, 'STOPPED');
await refresh();
} catch {
notify(t.stopFail, { tone: 'error' });
} finally {
setOperatingBotId(null);
setControlStateByBot((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
};
const startBot = async (id: string, status: string) => {
if (status === 'RUNNING') return;
setOperatingBotId(id);
setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' }));
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`);
updateBotStatus(id, 'RUNNING');
await refresh();
2026-03-15 07:14:01 +00:00
} catch (error: any) {
notify(error?.response?.data?.detail || t.startFail, { tone: 'error' });
2026-03-13 06:40:54 +00:00
} finally {
2026-03-01 16:26:03 +00:00
setOperatingBotId(null);
setControlStateByBot((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
};
2026-03-09 04:53:15 +00:00
const restartBot = async (id: string, status: string) => {
const normalized = String(status || '').toUpperCase();
2026-03-10 05:47:28 +00:00
const ok = await confirm({
title: t.restart,
message: t.restartConfirm(id),
tone: 'warning',
});
if (!ok) return;
2026-03-09 04:53:15 +00:00
setOperatingBotId(id);
try {
if (normalized === 'RUNNING') {
setControlStateByBot((prev) => ({ ...prev, [id]: 'stopping' }));
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`);
updateBotStatus(id, 'STOPPED');
}
setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' }));
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`);
updateBotStatus(id, 'RUNNING');
await refresh();
2026-03-15 07:14:01 +00:00
} catch (error: any) {
notify(error?.response?.data?.detail || t.restartFail, { tone: 'error' });
2026-03-09 04:53:15 +00:00
} finally {
setOperatingBotId(null);
setControlStateByBot((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
};
2026-03-01 16:26:03 +00:00
const send = async () => {
if (!selectedBot || !canChat || isSending) return;
2026-03-09 04:53:15 +00:00
if (!command.trim() && pendingAttachments.length === 0 && !quotedReply) return;
2026-03-01 16:26:03 +00:00
const text = normalizeUserMessageText(command);
2026-03-09 04:53:15 +00:00
const quoteText = normalizeAssistantMessageText(quotedReply?.text || '');
const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : '';
const payloadCore = text || (pendingAttachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : '');
const payloadText = `${quoteBlock}${payloadCore}`.trim();
2026-03-01 16:26:03 +00:00
if (!payloadText && pendingAttachments.length === 0) return;
try {
2026-03-14 07:44:11 +00:00
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
2026-03-01 16:26:03 +00:00
setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: payloadText, attachments: pendingAttachments },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
2026-03-13 06:40:54 +00:00
addBotMessage(selectedBot.id, {
role: 'user',
text: payloadText,
attachments: [...pendingAttachments],
ts: Date.now(),
kind: 'final',
});
2026-03-14 07:44:11 +00:00
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
2026-03-01 16:26:03 +00:00
setCommand('');
setPendingAttachments([]);
2026-03-09 04:53:15 +00:00
setQuotedReply(null);
2026-03-01 16:26:03 +00:00
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
addBotMessage(selectedBot.id, {
role: 'assistant',
text: t.sendFailMsg(msg),
ts: Date.now(),
});
2026-03-14 07:44:11 +00:00
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
2026-03-01 16:26:03 +00:00
notify(msg, { tone: 'error' });
} finally {
setSendingByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
};
2026-03-09 04:53:15 +00:00
const interruptExecution = async () => {
if (!selectedBot || !canChat) return;
if (interruptingByBot[selectedBot.id]) return;
try {
setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: '/stop' },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
notify(t.interruptSent, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
notify(msg, { tone: 'error' });
} finally {
setInterruptingByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
};
2026-03-03 16:45:51 +00:00
const copyUserPrompt = async (text: string) => {
await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail);
};
2026-03-10 07:04:33 +00:00
const editUserPrompt = (text: string) => {
const normalized = normalizeUserMessageText(text);
if (!normalized) return;
setCommand(normalized);
composerTextareaRef.current?.focus();
if (composerTextareaRef.current) {
const caret = normalized.length;
window.requestAnimationFrame(() => {
composerTextareaRef.current?.setSelectionRange(caret, caret);
});
}
notify(t.editPromptDone, { tone: 'success' });
};
2026-03-03 16:45:51 +00:00
const copyAssistantReply = async (text: string) => {
await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail);
};
2026-03-09 04:53:15 +00:00
const quoteAssistantReply = (message: ChatMessage) => {
const content = normalizeAssistantMessageText(message.text);
if (!content) return;
setQuotedReply((prev) => {
if (prev && prev.ts === message.ts && normalizeAssistantMessageText(prev.text) === content) {
return null;
}
return { id: message.id, ts: message.ts, text: content };
});
};
2026-03-14 07:44:11 +00:00
const fetchBotMessages = useCallback(async (botId: string): Promise<ChatMessage[]> => {
const safeLimit = Math.max(100, Math.min(500, chatPullPageSize * 3));
2026-03-03 16:45:51 +00:00
const res = await axios.get<any[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages`, {
2026-03-14 07:44:11 +00:00
params: { limit: safeLimit },
2026-03-03 16:45:51 +00:00
});
const rows = Array.isArray(res.data) ? res.data : [];
return rows
.map((row) => {
const roleRaw = String(row?.role || '').toLowerCase();
const role: ChatMessage['role'] =
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
return {
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
role,
text: String(row?.text || ''),
attachments: normalizeAttachmentPaths(row?.media),
ts: Number(row?.ts || Date.now()),
feedback,
kind: 'final',
} as ChatMessage;
})
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0)
2026-03-14 07:44:11 +00:00
.slice(-safeLimit);
}, [chatPullPageSize]);
const fetchBotMessagesPage = useCallback(async (
botId: string,
options?: { beforeId?: number | null; limit?: number },
): Promise<{ items: ChatMessage[]; hasMore: boolean; nextBeforeId: number | null }> => {
const requested = Number(options?.limit);
const safeLimit = Number.isFinite(requested)
? Math.max(10, Math.min(500, Math.floor(requested)))
: Math.max(10, Math.min(500, chatPullPageSize));
const beforeIdRaw = Number(options?.beforeId);
const beforeId = Number.isFinite(beforeIdRaw) && beforeIdRaw > 0 ? Math.floor(beforeIdRaw) : undefined;
const res = await axios.get<{ items?: any[]; has_more?: boolean; next_before_id?: number | null }>(
`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages/page`,
{
params: {
limit: safeLimit,
...(beforeId ? { before_id: beforeId } : {}),
},
},
);
const rows = Array.isArray(res.data?.items) ? res.data.items : [];
const items = rows
.map((row) => {
const roleRaw = String(row?.role || '').toLowerCase();
const role: ChatMessage['role'] =
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
return {
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
role,
text: String(row?.text || ''),
attachments: normalizeAttachmentPaths(row?.media),
ts: Number(row?.ts || Date.now()),
feedback,
kind: 'final',
} as ChatMessage;
})
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0);
const nextBeforeRaw = Number(res.data?.next_before_id);
const nextBeforeId = Number.isFinite(nextBeforeRaw) && nextBeforeRaw > 0 ? Math.floor(nextBeforeRaw) : null;
return {
items,
hasMore: Boolean(res.data?.has_more),
nextBeforeId,
};
}, [chatPullPageSize]);
const loadMoreChatMessages = useCallback(async () => {
if (!selectedBotId || chatLoadingMore || !chatHasMore) return;
const current = (activeBots[selectedBotId]?.messages || []).filter((msg) => (msg.kind || 'final') !== 'progress');
const oldestMessageId = current
.map((msg) => Number(msg.id))
.filter((id) => Number.isFinite(id) && id > 0)
.reduce<number | null>((acc, id) => (acc === null ? id : Math.min(acc, id)), null);
if (!oldestMessageId) {
setChatHasMore(false);
return;
}
const scrollBox = chatScrollRef.current;
const prevHeight = scrollBox?.scrollHeight || 0;
const prevTop = scrollBox?.scrollTop || 0;
setChatLoadingMore(true);
try {
const page = await fetchBotMessagesPage(selectedBotId, { beforeId: oldestMessageId, limit: chatPullPageSize });
if (page.items.length <= 0) {
setChatHasMore(false);
return;
}
const mergedMap = new Map<string, ChatMessage>();
[...page.items, ...current].forEach((msg) => {
const key = msg.id ? `id:${msg.id}` : `k:${msg.role}:${msg.ts}:${msg.text}`;
if (!mergedMap.has(key)) mergedMap.set(key, msg);
});
const merged = Array.from(mergedMap.values()).sort((a, b) => {
if (a.ts !== b.ts) return a.ts - b.ts;
return Number(a.id || 0) - Number(b.id || 0);
});
setBotMessages(selectedBotId, merged);
setChatHasMore(Boolean(page.hasMore));
requestAnimationFrame(() => {
const box = chatScrollRef.current;
if (!box) return;
const delta = box.scrollHeight - prevHeight;
box.scrollTop = prevTop + Math.max(0, delta);
});
} catch {
// ignore
} finally {
setChatLoadingMore(false);
}
}, [selectedBotId, chatLoadingMore, chatHasMore, activeBots, fetchBotMessagesPage, chatPullPageSize, setBotMessages]);
const onChatScroll = useCallback(() => {
const box = chatScrollRef.current;
if (!box) return;
const distanceToBottom = box.scrollHeight - box.scrollTop - box.clientHeight;
chatAutoFollowRef.current = distanceToBottom <= 64;
if (box.scrollTop <= 28 && chatHasMore && !chatLoadingMore) {
void loadMoreChatMessages();
}
}, [chatHasMore, chatLoadingMore, loadMoreChatMessages]);
2026-03-03 16:45:51 +00:00
const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => {
if (!selectedBotId) {
notify(t.feedbackMessagePending, { tone: 'warning' });
return;
}
let targetMessageId = message.id;
if (!targetMessageId) {
try {
const latest = await fetchBotMessages(selectedBotId);
setBotMessages(selectedBotId, latest);
const normalizedTarget = normalizeAssistantMessageText(message.text);
const matched = latest
.filter((m) => m.role === 'assistant' && m.id)
.map((m) => ({ m, diff: Math.abs((m.ts || 0) - (message.ts || 0)) }))
.filter(({ m, diff }) => normalizeAssistantMessageText(m.text) === normalizedTarget && diff <= 10 * 60 * 1000)
.sort((a, b) => a.diff - b.diff)[0]?.m;
if (matched?.id) {
targetMessageId = matched.id;
}
} catch {
// ignore and fallback to warning below
}
}
if (!targetMessageId) {
notify(t.feedbackMessagePending, { tone: 'warning' });
return;
}
if (feedbackSavingByMessageId[targetMessageId]) return;
2026-03-09 04:53:15 +00:00
const nextFeedback: 'up' | 'down' | null = message.feedback === feedback ? null : feedback;
2026-03-03 16:45:51 +00:00
setFeedbackSavingByMessageId((prev) => ({ ...prev, [targetMessageId]: true }));
try {
2026-03-09 04:53:15 +00:00
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/feedback`, { feedback: nextFeedback });
setBotMessageFeedback(selectedBotId, targetMessageId, nextFeedback);
if (nextFeedback === null) {
notify(t.feedbackCleared, { tone: 'success' });
} else {
notify(nextFeedback === 'up' ? t.feedbackUpSaved : t.feedbackDownSaved, { tone: 'success' });
}
2026-03-03 16:45:51 +00:00
} catch (error: any) {
const msg = error?.response?.data?.detail || t.feedbackSaveFail;
notify(msg, { tone: 'error' });
} finally {
setFeedbackSavingByMessageId((prev) => {
const next = { ...prev };
delete next[targetMessageId];
return next;
});
}
};
2026-03-01 16:26:03 +00:00
const onComposerKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
const native = e.nativeEvent as unknown as { isComposing?: boolean; keyCode?: number };
if (native.isComposing || native.keyCode === 229) return;
const isEnter = e.key === 'Enter' || e.key === 'NumpadEnter';
if (!isEnter || e.shiftKey) return;
e.preventDefault();
void send();
};
const triggerPickAttachments = () => {
if (!selectedBot || !canChat || isUploadingAttachments) return;
filePickerRef.current?.click();
};
2026-03-11 17:20:57 +00:00
const clearVoiceTimer = () => {
if (voiceTimerRef.current) {
window.clearInterval(voiceTimerRef.current);
voiceTimerRef.current = null;
}
};
const releaseVoiceStream = () => {
if (voiceStreamRef.current) {
voiceStreamRef.current.getTracks().forEach((track) => {
try {
track.stop();
} catch {
// ignore
}
});
voiceStreamRef.current = null;
}
};
const transcribeVoiceBlob = async (blob: Blob) => {
if (!selectedBot || blob.size <= 0) return;
setIsVoiceTranscribing(true);
try {
const mime = String(blob.type || '').toLowerCase();
const ext = mime.includes('ogg') ? 'ogg' : mime.includes('mp4') ? 'mp4' : 'webm';
const file = new File([blob], `voice-input-${Date.now()}.${ext}`, { type: blob.type || 'audio/webm' });
const formData = new FormData();
formData.append('file', file);
formData.append('language', 'zh');
const res = await axios.post<{ text?: string }>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/speech/transcribe`,
formData,
{ timeout: 120000 },
);
const text = normalizeUserMessageText(String(res.data?.text || ''));
if (!text) {
notify(t.voiceTranscribeEmpty, { tone: 'warning' });
return;
}
setCommand((prev) => {
const base = String(prev || '').trim();
if (!base) return text;
return `${base}\n${text}`;
});
window.requestAnimationFrame(() => composerTextareaRef.current?.focus());
notify(t.voiceTranscribeDone, { tone: 'success' });
} catch (error: any) {
const msg = String(error?.response?.data?.detail || '').trim();
console.error('Speech transcription failed', {
botId: selectedBot.id,
message: msg || t.voiceTranscribeFail,
status: error?.response?.status,
response: error?.response?.data,
error,
});
notify(msg || t.voiceTranscribeFail, { tone: 'error' });
} finally {
setIsVoiceTranscribing(false);
}
};
const stopVoiceRecording = () => {
const recorder = voiceRecorderRef.current;
if (!recorder || recorder.state === 'inactive') return;
try {
recorder.stop();
} catch {
// ignore
}
};
const startVoiceRecording = async () => {
if (!selectedBot || !canChat || isVoiceTranscribing) return;
if (!speechEnabled) {
notify(t.voiceUnavailable, { tone: 'warning' });
return;
}
if (typeof window === 'undefined' || typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
notify(t.voiceUnsupported, { tone: 'error' });
return;
}
if (typeof MediaRecorder === 'undefined') {
notify(t.voiceUnsupported, { tone: 'error' });
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeCandidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4'];
const supportedMime = mimeCandidates.find((candidate) => MediaRecorder.isTypeSupported(candidate));
const recorder = supportedMime
? new MediaRecorder(stream, { mimeType: supportedMime })
: new MediaRecorder(stream);
voiceStreamRef.current = stream;
voiceRecorderRef.current = recorder;
voiceChunksRef.current = [];
setVoiceCountdown(voiceMaxSeconds);
setIsVoiceRecording(true);
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data && event.data.size > 0) {
voiceChunksRef.current.push(event.data);
}
};
recorder.onerror = () => {
setIsVoiceRecording(false);
clearVoiceTimer();
releaseVoiceStream();
notify(t.voiceRecordFail, { tone: 'error' });
};
recorder.onstop = () => {
const blob = new Blob(voiceChunksRef.current, { type: supportedMime || recorder.mimeType || 'audio/webm' });
voiceRecorderRef.current = null;
voiceChunksRef.current = [];
clearVoiceTimer();
releaseVoiceStream();
setIsVoiceRecording(false);
setVoiceCountdown(voiceMaxSeconds);
if (blob.size > 0) {
void transcribeVoiceBlob(blob);
}
};
recorder.start(200);
clearVoiceTimer();
voiceTimerRef.current = window.setInterval(() => {
setVoiceCountdown((prev) => {
if (prev <= 1) {
stopVoiceRecording();
return 0;
}
return prev - 1;
});
}, 1000);
} catch {
releaseVoiceStream();
setIsVoiceRecording(false);
clearVoiceTimer();
notify(t.voicePermissionDenied, { tone: 'error' });
}
};
2026-03-10 07:04:33 +00:00
const onVoiceInput = () => {
2026-03-11 17:20:57 +00:00
if (isVoiceTranscribing) return;
if (isVoiceRecording) {
stopVoiceRecording();
return;
}
void startVoiceRecording();
2026-03-10 07:04:33 +00:00
};
2026-03-01 16:26:03 +00:00
const onPickAttachments = async (event: ChangeEvent<HTMLInputElement>) => {
if (!selectedBot || !event.target.files || event.target.files.length === 0) return;
const files = Array.from(event.target.files);
2026-03-03 06:09:11 +00:00
const maxBytes = uploadMaxMb * 1024 * 1024;
const tooLarge = files.filter((f) => Number(f.size) > maxBytes);
if (tooLarge.length > 0) {
const names = tooLarge.map((f) => String(f.name || '').trim() || 'unknown').slice(0, 3).join(', ');
notify(t.uploadTooLarge(names, uploadMaxMb), { tone: 'warning' });
event.target.value = '';
return;
}
2026-03-05 04:52:52 +00:00
const mediaFiles: File[] = [];
const normalFiles: File[] = [];
files.forEach((file) => {
if (isMediaUploadFile(file)) {
mediaFiles.push(file);
} else {
normalFiles.push(file);
}
});
2026-03-01 16:26:03 +00:00
2026-03-05 04:52:52 +00:00
const totalBytes = files.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
let uploadedBytes = 0;
const uploadedPaths: string[] = [];
const uploadBatch = async (batchFiles: File[], path: 'media' | 'uploads') => {
if (batchFiles.length === 0) return;
const batchBytes = batchFiles.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
const formData = new FormData();
batchFiles.forEach((file) => formData.append('files', file));
2026-03-01 16:26:03 +00:00
const res = await axios.post<WorkspaceUploadResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/workspace/upload`,
formData,
2026-03-05 04:52:52 +00:00
{
params: { path },
onUploadProgress: (progressEvent) => {
const loaded = Number(progressEvent.loaded || 0);
if (!Number.isFinite(loaded) || loaded < 0) {
setAttachmentUploadPercent(null);
return;
}
if (totalBytes <= 0) {
setAttachmentUploadPercent(null);
return;
}
const cappedLoaded = Math.max(0, Math.min(batchBytes, loaded));
const pct = Math.max(0, Math.min(100, Math.round(((uploadedBytes + cappedLoaded) / totalBytes) * 100)));
setAttachmentUploadPercent(pct);
},
},
2026-03-01 16:26:03 +00:00
);
const uploaded = normalizeAttachmentPaths((res.data?.files || []).map((v) => v.path));
2026-03-05 04:52:52 +00:00
uploadedPaths.push(...uploaded);
uploadedBytes += batchBytes;
if (totalBytes > 0) {
const pct = Math.max(0, Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)));
setAttachmentUploadPercent(pct);
}
};
setIsUploadingAttachments(true);
setAttachmentUploadPercent(0);
try {
await uploadBatch(mediaFiles, 'media');
await uploadBatch(normalFiles, 'uploads');
if (uploadedPaths.length > 0) {
setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploadedPaths])));
2026-03-01 16:26:03 +00:00
await loadWorkspaceTree(selectedBot.id, workspaceCurrentPath);
}
} catch (error: any) {
const msg = error?.response?.data?.detail || t.uploadFail;
notify(msg, { tone: 'error' });
} finally {
setIsUploadingAttachments(false);
2026-03-05 04:52:52 +00:00
setAttachmentUploadPercent(null);
2026-03-01 16:26:03 +00:00
event.target.value = '';
}
};
const onBaseProviderChange = (provider: string) => {
const preset = providerPresets[provider];
setEditForm((p) => ({
...p,
llm_provider: provider,
llm_model: preset?.model || p.llm_model,
api_base: preset?.apiBase ?? p.api_base,
}));
setProviderTestResult('');
};
const testProviderConnection = async () => {
if (!editForm.llm_provider || !editForm.llm_model || !editForm.api_key.trim()) {
notify(t.providerRequired, { tone: 'warning' });
return;
}
setIsTestingProvider(true);
setProviderTestResult('');
try {
const res = await axios.post(`${APP_ENDPOINTS.apiBase}/providers/test`, {
provider: editForm.llm_provider,
model: editForm.llm_model,
api_key: editForm.api_key.trim(),
api_base: editForm.api_base || undefined,
});
if (res.data?.ok) {
const preview = (res.data.models_preview || []).slice(0, 3).join(', ');
setProviderTestResult(t.connOk(preview));
} else {
setProviderTestResult(t.connFail(res.data?.detail || 'unknown error'));
}
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || 'request failed';
setProviderTestResult(t.connFail(msg));
} finally {
setIsTestingProvider(false);
}
};
useEffect(() => {
if (!selectedBotId) {
2026-03-14 07:44:11 +00:00
setChatHasMore(false);
setChatLoadingMore(false);
2026-03-01 16:26:03 +00:00
setWorkspaceEntries([]);
setWorkspaceCurrentPath('');
setWorkspaceParentPath(null);
setWorkspaceError('');
setChannels([]);
2026-03-13 06:40:54 +00:00
setTopics([]);
setExpandedTopicByKey({});
setNewTopicPanelOpen(false);
setTopicPresetMenuOpen(false);
setNewTopicAdvancedOpen(false);
setNewTopicKey('');
setNewTopicName('');
setNewTopicDescription('');
setNewTopicPurpose('');
setNewTopicIncludeWhen('');
setNewTopicExcludeWhen('');
setNewTopicExamplesPositive('');
setNewTopicExamplesNegative('');
setNewTopicPriority('50');
setNewTopicSource('');
setNewTopicAdvancedOpen(false);
2026-03-01 16:26:03 +00:00
setPendingAttachments([]);
setCronJobs([]);
setBotSkills([]);
setEnvParams({});
2026-03-13 06:40:54 +00:00
setExpandedMcpByKey({});
setNewMcpPanelOpen(false);
resetNewMcpDraft();
setTopicFeedTopicKey('__all__');
setTopicFeedItems([]);
setTopicFeedNextCursor(null);
setTopicFeedError('');
setTopicFeedReadSavingById({});
2026-03-15 07:14:01 +00:00
setTopicFeedDeleteSavingById({});
2026-03-13 06:40:54 +00:00
setTopicFeedUnreadCount(0);
2026-03-01 16:26:03 +00:00
return;
}
2026-03-14 07:44:11 +00:00
setChatHasMore(false);
setChatLoadingMore(false);
2026-03-13 06:40:54 +00:00
setTopics([]);
setExpandedTopicByKey({});
setNewTopicPanelOpen(false);
setTopicPresetMenuOpen(false);
setNewTopicSource('');
setNewMcpPanelOpen(false);
resetNewMcpDraft();
setTopicFeedTopicKey('__all__');
setTopicFeedItems([]);
setTopicFeedNextCursor(null);
setTopicFeedError('');
setTopicFeedReadSavingById({});
2026-03-15 07:14:01 +00:00
setTopicFeedDeleteSavingById({});
2026-03-05 02:42:39 +00:00
let cancelled = false;
const loadAll = async () => {
try {
2026-03-10 16:53:54 +00:00
if (cancelled) return;
2026-03-14 07:44:11 +00:00
const page = await fetchBotMessagesPage(selectedBotId, { limit: chatPullPageSize });
if (cancelled) return;
setBotMessages(selectedBotId, page.items);
setChatHasMore(Boolean(page.hasMore));
2026-03-05 02:42:39 +00:00
await Promise.all([
loadWorkspaceTree(selectedBotId, ''),
loadCronJobs(selectedBotId),
loadBotSkills(selectedBotId),
loadBotEnvParams(selectedBotId),
2026-03-13 06:40:54 +00:00
loadTopics(selectedBotId),
loadTopicFeedStats(selectedBotId),
2026-03-05 02:42:39 +00:00
]);
2026-03-14 07:44:11 +00:00
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
2026-03-05 02:42:39 +00:00
} catch (error: any) {
const detail = String(error?.response?.data?.detail || '').trim();
if (!cancelled && detail) {
notify(detail, { tone: 'error' });
}
}
};
void loadAll();
return () => {
cancelled = true;
};
2026-03-01 16:26:03 +00:00
// eslint-disable-next-line react-hooks/exhaustive-deps
2026-03-14 07:44:11 +00:00
}, [selectedBotId, chatPullPageSize, fetchBotMessagesPage, setBotMessages, syncChatScrollToBottom]);
2026-03-01 16:26:03 +00:00
useEffect(() => {
if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return;
let stopped = false;
const tick = async () => {
if (stopped) return;
await loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
};
void tick();
const timer = window.setInterval(() => {
void tick();
}, 2000);
return () => {
stopped = true;
window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceAutoRefresh, selectedBotId, selectedBot?.docker_status, workspaceCurrentPath]);
2026-03-13 06:40:54 +00:00
useEffect(() => {
if (!topicFeedTopicKey || topicFeedTopicKey === '__all__') return;
const exists = activeTopicOptions.some((row) => row.key === topicFeedTopicKey);
if (!exists) {
setTopicFeedTopicKey('__all__');
}
}, [activeTopicOptions, topicFeedTopicKey]);
useEffect(() => {
if (!selectedBotId || runtimeViewMode !== 'topic') return;
if (topics.length === 0) {
void loadTopics(selectedBotId);
}
}, [runtimeViewMode, selectedBotId, topics.length]);
useEffect(() => {
if (!selectedBot || runtimeViewMode !== 'topic') return;
void loadTopicFeed({ append: false, topicKey: topicFeedTopicKey });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBot?.id, runtimeViewMode, topicFeedTopicKey]);
useEffect(() => {
if (!selectedBot || runtimeViewMode !== 'topic') return;
if (topicDetailOpen) return;
const timer = window.setInterval(() => {
void loadTopicFeed({ append: false, topicKey: topicFeedTopicKey });
}, 15000);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBot?.id, runtimeViewMode, topicFeedTopicKey, topicDetailOpen]);
useEffect(() => {
if (!selectedBotId) return;
void loadTopicFeedStats(selectedBotId);
const timer = window.setInterval(() => {
void loadTopicFeedStats(selectedBotId);
}, 15000);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBotId]);
2026-03-09 09:52:42 +00:00
useEffect(() => {
setWorkspaceQuery('');
}, [selectedBotId, workspaceCurrentPath]);
useEffect(() => {
if (!selectedBotId) {
setWorkspaceSearchEntries([]);
setWorkspaceSearchLoading(false);
return;
}
if (!workspaceQuery.trim()) {
setWorkspaceSearchEntries([]);
setWorkspaceSearchLoading(false);
return;
}
void loadWorkspaceSearchEntries(selectedBotId, workspaceCurrentPath);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBotId, workspaceCurrentPath, workspaceQuery]);
2026-03-01 16:26:03 +00:00
const saveBot = async (mode: 'params' | 'agent' | 'base') => {
2026-03-03 07:44:39 +00:00
const targetBotId = String(selectedBot?.id || selectedBotId || '').trim();
if (!targetBotId) {
notify(isZh ? '未选中 Bot无法保存。' : 'No bot selected.', { tone: 'warning' });
return;
}
2026-03-01 16:26:03 +00:00
setIsSaving(true);
try {
const payload: Record<string, string | number> = {};
if (mode === 'base') {
payload.name = editForm.name;
2026-03-05 02:42:39 +00:00
payload.access_password = editForm.access_password;
2026-03-03 06:09:11 +00:00
payload.image_tag = editForm.image_tag;
const selectedImageOption = baseImageOptions.find((opt) => opt.tag === editForm.image_tag);
if (selectedImageOption?.disabled) {
throw new Error(isZh ? '当前镜像不可用,请选择可用镜像。' : 'Selected image is unavailable.');
}
if (selectedImageOption?.needsRegister) {
await axios.post(`${APP_ENDPOINTS.apiBase}/images/register`, {
tag: editForm.image_tag,
source_dir: 'manual',
});
}
const normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores));
const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb));
const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb));
payload.cpu_cores = normalizedCpuCores;
payload.memory_mb = normalizedMemoryMb;
payload.storage_gb = normalizedStorageGb;
setEditForm((p) => ({
...p,
cpu_cores: normalizedCpuCores,
memory_mb: normalizedMemoryMb,
storage_gb: normalizedStorageGb,
}));
2026-03-05 02:42:39 +00:00
setParamDraft((p) => ({
...p,
2026-03-03 06:09:11 +00:00
cpu_cores: String(normalizedCpuCores),
memory_mb: String(normalizedMemoryMb),
storage_gb: String(normalizedStorageGb),
2026-03-05 02:42:39 +00:00
}));
}
if (mode === 'params') {
payload.llm_provider = editForm.llm_provider;
payload.llm_model = editForm.llm_model;
payload.api_base = editForm.api_base;
if (editForm.api_key.trim()) payload.api_key = editForm.api_key.trim();
payload.temperature = clampTemperature(Number(editForm.temperature));
payload.top_p = Number(editForm.top_p);
const normalizedMaxTokens = clampMaxTokens(Number(paramDraft.max_tokens));
payload.max_tokens = normalizedMaxTokens;
setEditForm((p) => ({
...p,
max_tokens: normalizedMaxTokens,
}));
setParamDraft((p) => ({ ...p, max_tokens: String(normalizedMaxTokens) }));
2026-03-01 16:26:03 +00:00
}
if (mode === 'agent') {
payload.agents_md = editForm.agents_md;
payload.soul_md = editForm.soul_md;
payload.user_md = editForm.user_md;
payload.tools_md = editForm.tools_md;
payload.identity_md = editForm.identity_md;
}
2026-03-03 07:44:39 +00:00
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${targetBotId}`, payload);
2026-03-01 16:26:03 +00:00
await refresh();
setShowBaseModal(false);
setShowParamModal(false);
setShowAgentModal(false);
notify(t.configUpdated, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || t.saveFail;
notify(msg, { tone: 'error' });
} finally {
setIsSaving(false);
}
};
const removeBot = async (botId?: string) => {
const targetId = botId || selectedBot?.id;
if (!targetId) return;
const ok = await confirm({
title: t.delete,
message: t.deleteBotConfirm(targetId),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${targetId}`, { params: { delete_workspace: true } });
await refresh();
if (selectedBotId === targetId) setSelectedBotId('');
notify(t.deleteBotDone, { tone: 'success' });
} catch {
notify(t.deleteFail, { tone: 'error' });
}
};
const clearConversationHistory = async () => {
if (!selectedBot) return;
const target = selectedBot.name || selectedBot.id;
const ok = await confirm({
title: t.clearHistory,
message: t.clearHistoryConfirm(target),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/messages`);
setBotMessages(selectedBot.id, []);
notify(t.clearHistoryDone, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || t.clearHistoryFail;
notify(msg, { tone: 'error' });
}
};
const exportConversationJson = () => {
if (!selectedBot) return;
try {
const payload = {
bot_id: selectedBot.id,
bot_name: selectedBot.name || selectedBot.id,
exported_at: new Date().toISOString(),
message_count: conversation.length,
messages: conversation.map((m) => ({
2026-03-03 16:45:51 +00:00
id: m.id || null,
2026-03-01 16:26:03 +00:00
role: m.role,
text: m.text,
attachments: m.attachments || [],
kind: m.kind || 'final',
2026-03-03 16:45:51 +00:00
feedback: m.feedback || null,
2026-03-01 16:26:03 +00:00
ts: m.ts,
datetime: new Date(m.ts).toISOString(),
})),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${selectedBot.id}-conversation-${stamp}.json`;
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
notify(t.exportHistoryFail, { tone: 'error' });
}
};
const tabMap: Record<AgentTab, keyof typeof editForm> = {
AGENTS: 'agents_md',
SOUL: 'soul_md',
USER: 'user_md',
TOOLS: 'tools_md',
IDENTITY: 'identity_md',
};
const renderWorkspaceNodes = (nodes: WorkspaceNode[]): ReactNode[] => {
const rendered: ReactNode[] = [];
if (workspaceParentPath !== null) {
rendered.push(
<button
key="dir:.."
className="workspace-entry dir nav-up"
onClick={() => void loadWorkspaceTree(selectedBotId, workspaceParentPath || '')}
title={t.goUpTitle}
>
<FolderOpen size={14} />
<span className="workspace-entry-name">..</span>
<span className="workspace-entry-meta">{t.goUp}</span>
</button>,
);
}
nodes.forEach((node) => {
const key = `${node.type}:${node.path}`;
if (node.type === 'dir') {
rendered.push(
<button
key={key}
className="workspace-entry dir"
onClick={() => void loadWorkspaceTree(selectedBotId, node.path)}
title={t.openFolderTitle}
>
<FolderOpen size={14} />
2026-03-05 02:42:39 +00:00
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
2026-03-01 16:26:03 +00:00
<span className="workspace-entry-meta">{t.folder}</span>
</button>,
);
return;
}
2026-03-13 06:40:54 +00:00
const previewable = isPreviewableWorkspaceFile(node, workspaceDownloadExtensionSet);
const downloadOnlyFile = workspaceFileAction(node.path, workspaceDownloadExtensionSet) === 'download';
2026-03-01 16:26:03 +00:00
rendered.push(
<button
key={key}
className={`workspace-entry file ${previewable ? '' : 'disabled'}`}
2026-03-09 04:53:15 +00:00
disabled={workspaceFileLoading}
aria-disabled={!previewable || workspaceFileLoading}
onClick={() => {
if (workspaceFileLoading) return;
if (!previewable) return;
void openWorkspaceFilePreview(node.path);
}}
2026-03-05 02:42:39 +00:00
onMouseEnter={(event) => showWorkspaceHoverCard(node, event.currentTarget)}
onMouseLeave={hideWorkspaceHoverCard}
onFocus={(event) => showWorkspaceHoverCard(node, event.currentTarget)}
onBlur={hideWorkspaceHoverCard}
2026-03-02 04:38:01 +00:00
title={previewable ? (downloadOnlyFile ? t.download : t.previewTitle) : t.fileNotPreviewable}
2026-03-01 16:26:03 +00:00
>
<FileText size={14} />
2026-03-05 02:42:39 +00:00
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
2026-03-01 16:26:03 +00:00
<span className="workspace-entry-meta mono">{node.ext || '-'}</span>
</button>,
);
});
return rendered;
};
return (
<>
2026-03-14 08:07:34 +00:00
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}>
{showBotListPanel ? (
2026-03-01 16:26:03 +00:00
<section className="panel stack ops-bot-list">
<div className="row-between">
2026-03-09 04:53:15 +00:00
<h2 style={{ fontSize: 18 }}>
{normalizedBotListQuery
? `${t.titleBots} (${filteredBots.length}/${bots.length})`
: `${t.titleBots} (${bots.length})`}
</h2>
2026-03-13 06:40:54 +00:00
<div className="ops-list-actions" ref={botListMenuRef}>
2026-03-03 08:12:27 +00:00
<LucentIconButton
2026-03-01 16:26:03 +00:00
className="btn btn-primary btn-sm icon-btn"
onClick={onOpenCreateWizard}
2026-03-03 08:12:27 +00:00
tooltip={t.newBot}
2026-03-01 16:26:03 +00:00
aria-label={t.newBot}
>
<Plus size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-13 06:40:54 +00:00
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setBotListMenuOpen((prev) => !prev)}
tooltip={t.extensions}
aria-label={t.extensions}
aria-haspopup="menu"
aria-expanded={botListMenuOpen}
>
<EllipsisVertical size={14} />
</LucentIconButton>
{botListMenuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={t.extensions}>
<button
className="ops-more-item"
role="menuitem"
disabled={!onOpenImageFactory}
onClick={() => {
setBotListMenuOpen(false);
onOpenImageFactory?.();
}}
>
<Boxes size={14} />
<span>{t.manageImages}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
disabled={isLoadingTemplates}
onClick={() => {
void openTemplateManager();
}}
>
<FileText size={14} />
<span>{t.templateManager}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
disabled={isBatchOperating}
onClick={() => {
void batchStartBots();
}}
>
<Power size={14} />
<span>{t.batchStart}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
disabled={isBatchOperating}
onClick={() => {
void batchStopBots();
}}
>
<Square size={14} />
<span>{t.batchStop}</span>
</button>
</div>
) : null}
2026-03-01 16:26:03 +00:00
</div>
</div>
2026-03-09 04:53:15 +00:00
<div className="ops-bot-list-toolbar">
2026-03-09 09:52:42 +00:00
<div className="ops-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
value={botListQuery}
onChange={(e) => setBotListQuery(e.target.value)}
placeholder={t.botSearchPlaceholder}
aria-label={t.botSearchPlaceholder}
2026-03-10 07:04:33 +00:00
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
name="bot-search"
2026-03-09 09:52:42 +00:00
/>
<button
type="button"
className="ops-search-inline-btn"
onClick={() => {
if (botListQuery.trim()) {
setBotListQuery('');
setBotListPage(1);
return;
}
setBotListPage(1);
}}
title={botListQuery.trim() ? t.clearSearch : t.searchAction}
aria-label={botListQuery.trim() ? t.clearSearch : t.searchAction}
>
{botListQuery.trim() ? <X size={14} /> : <Search size={14} />}
</button>
</div>
2026-03-09 04:53:15 +00:00
</div>
2026-03-09 09:52:42 +00:00
<div className="list-scroll">
2026-03-09 04:53:15 +00:00
{pagedBots.map((bot) => {
2026-03-01 16:26:03 +00:00
const selected = selectedBotId === bot.id;
const controlState = controlStateByBot[bot.id];
const isOperating = operatingBotId === bot.id;
const isStarting = controlState === 'starting';
const isStopping = controlState === 'stopping';
return (
2026-03-14 05:03:22 +00:00
<div
key={bot.id}
className={`ops-bot-card ${selected ? 'is-active' : ''}`}
onClick={() => {
setSelectedBotId(bot.id);
if (compactMode) setCompactPanelTab('chat');
}}
>
2026-03-01 16:26:03 +00:00
<span className={`ops-bot-strip ${bot.docker_status === 'RUNNING' ? 'is-running' : 'is-stopped'}`} aria-hidden="true" />
<div className="row-between ops-bot-top">
2026-03-10 18:28:39 +00:00
<div className="ops-bot-name-wrap">
<div className="ops-bot-name-row">
{bot.has_access_password ? (
<span className="ops-bot-lock" title={isZh ? '已设置访问密码' : 'Access password enabled'} aria-label={isZh ? '已设置访问密码' : 'Access password enabled'}>
<Lock size={12} />
</span>
) : null}
<div className="ops-bot-name">{bot.name}</div>
<LucentIconButton
className="ops-bot-open-inline"
2026-03-09 04:53:15 +00:00
onClick={(e) => {
e.stopPropagation();
const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`;
window.open(target, '_blank', 'noopener,noreferrer');
}}
tooltip={isZh ? '新页面打开' : 'Open in new page'}
aria-label={isZh ? '新页面打开' : 'Open in new page'}
>
<ExternalLink size={11} />
</LucentIconButton>
</div>
2026-03-01 16:26:03 +00:00
<div className="mono ops-bot-id">{bot.id}</div>
</div>
2026-03-09 04:53:15 +00:00
<div className="ops-bot-top-actions">
<span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
</div>
2026-03-01 16:26:03 +00:00
</div>
<div className="ops-bot-meta">{t.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
<div className="ops-bot-actions">
2026-03-03 08:12:27 +00:00
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
onClick={(e) => {
e.stopPropagation();
openResourceMonitor(bot.id);
}}
tooltip={isZh ? '资源监测' : 'Resource Monitor'}
aria-label={isZh ? '资源监测' : 'Resource Monitor'}
>
<Gauge size={14} />
</LucentIconButton>
{bot.docker_status === 'RUNNING' ? (
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-stop"
disabled={isOperating}
2026-03-01 16:26:03 +00:00
onClick={(e) => {
e.stopPropagation();
2026-03-03 08:12:27 +00:00
void stopBot(bot.id, bot.docker_status);
2026-03-01 16:26:03 +00:00
}}
2026-03-03 08:12:27 +00:00
tooltip={t.stop}
aria-label={t.stop}
2026-03-01 16:26:03 +00:00
>
2026-03-03 08:12:27 +00:00
{isStopping ? (
<span className="ops-control-pending">
<span className="ops-control-dots" aria-hidden="true">
<i />
<i />
<i />
2026-03-03 07:44:39 +00:00
</span>
2026-03-03 08:12:27 +00:00
</span>
) : <Square size={14} />}
</LucentIconButton>
2026-03-01 16:26:03 +00:00
) : (
2026-03-03 08:12:27 +00:00
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-start"
disabled={isOperating}
2026-03-01 16:26:03 +00:00
onClick={(e) => {
e.stopPropagation();
2026-03-03 08:12:27 +00:00
void startBot(bot.id, bot.docker_status);
2026-03-01 16:26:03 +00:00
}}
2026-03-03 08:12:27 +00:00
tooltip={t.start}
aria-label={t.start}
2026-03-01 16:26:03 +00:00
>
2026-03-03 08:12:27 +00:00
{isStarting ? (
<span className="ops-control-pending">
<span className="ops-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : <Power size={14} />}
</LucentIconButton>
)}
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
onClick={(e) => {
e.stopPropagation();
void removeBot(bot.id);
}}
tooltip={t.delete}
aria-label={t.delete}
>
<Trash2 size={14} />
</LucentIconButton>
2026-03-01 16:26:03 +00:00
</div>
</div>
);
})}
2026-03-09 04:53:15 +00:00
{filteredBots.length === 0 ? (
<div className="ops-bot-list-empty">{t.botSearchNoResult}</div>
) : null}
</div>
<div className="ops-bot-list-pagination">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setBotListPage((p) => Math.max(1, p - 1))}
disabled={botListPage <= 1}
tooltip={t.paginationPrev}
aria-label={t.paginationPrev}
>
<ChevronLeft size={14} />
</LucentIconButton>
<div className="ops-bot-list-page-indicator">{t.paginationPage(botListPage, botListTotalPages)}</div>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setBotListPage((p) => Math.min(botListTotalPages, p + 1))}
disabled={botListPage >= botListTotalPages}
tooltip={t.paginationNext}
aria-label={t.paginationNext}
>
<ChevronRight size={14} />
</LucentIconButton>
2026-03-01 16:26:03 +00:00
</div>
</section>
) : null}
2026-03-14 05:03:22 +00:00
<section className={`panel ops-chat-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'chat') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
2026-03-01 16:26:03 +00:00
{selectedBot ? (
<div className="ops-chat-shell">
2026-03-13 06:40:54 +00:00
<div className="ops-main-content-shell">
<div className="ops-main-content-frame">
<div className="ops-main-content-head">
<div className="ops-main-mode-rail" role="tablist" aria-label={isZh ? '主面板视图切换' : 'Main panel view switch'}>
<button
className={`ops-main-mode-tab ${runtimeViewMode === 'visual' ? 'is-active' : ''}`}
onClick={() => setRuntimeViewMode('visual')}
aria-label={isZh ? '对话视图' : 'Conversation view'}
role="tab"
aria-selected={runtimeViewMode === 'visual'}
>
<MessageCircle size={14} />
<span className="ops-main-mode-label">{isZh ? '对话' : 'Chat'}</span>
</button>
<button
className={`ops-main-mode-tab has-dot ${runtimeViewMode === 'topic' ? 'is-active' : ''}`}
onClick={() => setRuntimeViewMode('topic')}
aria-label={isZh ? '主题视图' : 'Topic view'}
role="tab"
aria-selected={runtimeViewMode === 'topic'}
>
<MessageSquareText size={14} />
<span className="ops-main-mode-label-wrap">
<span className="ops-main-mode-label">{isZh ? '主题' : 'Topic'}</span>
{hasTopicUnread ? <span className="ops-switch-dot" aria-hidden="true" /> : null}
</span>
</button>
2026-03-01 16:26:03 +00:00
</div>
2026-03-13 06:40:54 +00:00
</div>
<div className="ops-main-content-body">
{runtimeViewMode === 'topic' ? (
<TopicFeedPanel
isZh={isZh}
topicKey={topicFeedTopicKey}
topicOptions={activeTopicOptions}
topicState={topicPanelState}
items={topicFeedItems}
loading={topicFeedLoading}
loadingMore={topicFeedLoadingMore}
nextCursor={topicFeedNextCursor}
error={topicFeedError}
readSavingById={topicFeedReadSavingById}
2026-03-15 07:14:01 +00:00
deleteSavingById={topicFeedDeleteSavingById}
2026-03-13 06:40:54 +00:00
onTopicChange={setTopicFeedTopicKey}
onRefresh={() => void loadTopicFeed({ append: false, topicKey: topicFeedTopicKey })}
onMarkRead={(itemId) => void markTopicFeedItemRead(itemId)}
2026-03-15 07:14:01 +00:00
onDeleteItem={(item) => void deleteTopicFeedItem(item)}
2026-03-13 06:40:54 +00:00
onLoadMore={() => void loadTopicFeed({ append: true, cursor: topicFeedNextCursor, topicKey: topicFeedTopicKey })}
onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)}
onOpenTopicSettings={() => {
if (selectedBot) openTopicModal(selectedBot.id);
}}
onDetailOpenChange={setTopicDetailOpen}
layout="panel"
/>
) : (
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
<div className="ops-chat-scroll" ref={chatScrollRef} onScroll={onChatScroll}>
{conversation.length === 0 ? (
<div className="ops-chat-empty">
{t.noConversation}
2026-03-01 16:26:03 +00:00
</div>
2026-03-13 06:40:54 +00:00
) : (
conversationNodes
)}
2026-03-01 16:26:03 +00:00
2026-03-13 06:40:54 +00:00
{isThinking ? (
<div className="ops-chat-row is-assistant">
<div className="ops-chat-item is-assistant">
<div className="ops-avatar bot" title="Nanobot">
<img src={nanobotLogo} alt="Nanobot" />
</div>
<div className="ops-thinking-bubble">
<div className="ops-thinking-cloud">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
<div className="ops-thinking-text">{t.thinking}</div>
</div>
</div>
2026-03-09 04:53:15 +00:00
</div>
2026-03-13 06:40:54 +00:00
) : null}
<div ref={chatBottomRef} />
</div>
<div className="ops-chat-dock">
{(quotedReply || pendingAttachments.length > 0) ? (
<div className="ops-chat-top-context">
{quotedReply ? (
<div className="ops-composer-quote" aria-live="polite">
<div className="ops-composer-quote-head">
<span>{t.quotedReplyLabel}</span>
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={() => setQuotedReply(null)}
2026-03-09 04:53:15 +00:00
>
2026-03-13 06:40:54 +00:00
<X size={12} />
</button>
</div>
<div className="ops-composer-quote-text">{normalizeAssistantMessageText(quotedReply.text)}</div>
</div>
) : null}
{pendingAttachments.length > 0 ? (
<div className="ops-pending-files">
{pendingAttachments.map((p) => (
<span key={p} className="ops-pending-chip mono">
{(() => {
const filePath = normalizeDashboardAttachmentPath(p);
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
const filename = filePath.split('/').pop() || filePath;
return (
<a
className="ops-attach-link mono ops-pending-open"
href="#"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void openWorkspacePathFromChat(filePath);
}}
>
{fileAction === 'download' ? (
<Download size={12} className="ops-attach-link-icon" />
) : fileAction === 'preview' ? (
<Eye size={12} className="ops-attach-link-icon" />
) : (
<FileText size={12} className="ops-attach-link-icon" />
)}
<span className="ops-attach-link-name">{filename}</span>
</a>
);
})()}
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setPendingAttachments((prev) => prev.filter((v) => v !== p));
}}
>
<X size={12} />
</button>
</span>
))}
</div>
) : null}
</div>
) : null}
{isUploadingAttachments ? (
<div className="ops-upload-progress" aria-live="polite">
<div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
<div
className="ops-upload-progress-fill"
style={{ width: `${Math.max(3, Number(attachmentUploadPercent ?? 24))}%` }}
/>
</div>
<span className="ops-upload-progress-text mono">
{attachmentUploadPercent === null
? t.uploadingFile
: `${t.uploadingFile} ${attachmentUploadPercent}%`}
</span>
</div>
) : null}
<div className="ops-composer">
<input
ref={filePickerRef}
type="file"
multiple
onChange={onPickAttachments}
style={{ display: 'none' }}
/>
<div className="ops-composer-shell">
<textarea
ref={composerTextareaRef}
className="input ops-composer-input"
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={onComposerKeyDown}
disabled={!canChat || isVoiceRecording || isVoiceTranscribing}
placeholder={
canChat
? t.inputPlaceholder
: t.disabledPlaceholder
}
/>
<div className="ops-composer-tools-right">
{(isVoiceRecording || isVoiceTranscribing) ? (
<div className="ops-voice-inline" aria-live="polite">
<div className={`ops-voice-wave ${isVoiceRecording ? 'is-live' : ''} ${isCompactMobile ? 'is-mobile' : 'is-desktop'}`}>
{Array.from({ length: isCompactMobile ? 1 : 5 }).map((_, segmentIdx) => (
<div key={`vw-segment-${segmentIdx}`} className="ops-voice-wave-segment">
{Array.from({ length: isCompactMobile ? 28 : 18 }).map((_, idx) => {
const delayIndex = isCompactMobile
? idx
: (segmentIdx * 18) + idx;
return (
<i
key={`vw-inline-${segmentIdx}-${idx}`}
style={{ animationDelay: `${(delayIndex % 14) * 0.06}s` }}
/>
);
})}
</div>
))}
</div>
<div className="ops-voice-countdown mono">
{isVoiceRecording ? `${voiceCountdown}s` : t.voiceTranscribing}
</div>
</div>
) : null}
2026-03-09 04:53:15 +00:00
<button
2026-03-13 06:40:54 +00:00
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
disabled={!canChat || isVoiceTranscribing || (!speechEnabled && !isVoiceRecording)}
onClick={onVoiceInput}
aria-label={isVoiceRecording ? t.voiceStop : t.voiceStart}
title={
isVoiceTranscribing
? t.voiceTranscribing
: isVoiceRecording
? t.voiceStop
: t.voiceStart
}
2026-03-09 04:53:15 +00:00
>
2026-03-13 06:40:54 +00:00
{isVoiceTranscribing ? (
<RefreshCw size={16} className="animate-spin" />
) : isVoiceRecording ? (
<Square size={16} />
) : (
<Mic size={16} />
)}
2026-03-09 04:53:15 +00:00
</button>
2026-03-13 06:40:54 +00:00
<LucentIconButton
className="ops-composer-inline-btn"
disabled={!canChat || isUploadingAttachments || isVoiceRecording || isVoiceTranscribing}
onClick={triggerPickAttachments}
tooltip={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
aria-label={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
>
<Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
</LucentIconButton>
<button
className={`ops-composer-submit-btn ${isChatEnabled && (isThinking || isSending) ? 'is-interrupt' : ''}`}
disabled={
isChatEnabled && (isThinking || isSending)
? Boolean(interruptingByBot[selectedBot.id])
: (
!isChatEnabled
|| isVoiceRecording
|| isVoiceTranscribing
|| (!command.trim() && pendingAttachments.length === 0 && !quotedReply)
)
}
onClick={() => void (isChatEnabled && (isThinking || isSending) ? interruptExecution() : send())}
aria-label={isChatEnabled && (isThinking || isSending) ? t.interrupt : t.send}
title={isChatEnabled && (isThinking || isSending) ? t.interrupt : t.send}
>
{isChatEnabled && (isThinking || isSending) ? (
<Square size={15} />
) : (
<ArrowUp size={18} />
)}
</button>
</div>
</div>
2026-03-09 04:53:15 +00:00
</div>
2026-03-10 19:09:41 +00:00
</div>
2026-03-13 06:40:54 +00:00
{!canChat ? (
<div className="ops-chat-disabled-mask">
<div className="ops-chat-disabled-card">
{selectedBotControlState === 'starting'
? t.botStarting
: selectedBotControlState === 'stopping'
? t.botStopping
: t.chatDisabled}
2026-03-11 17:20:57 +00:00
</div>
</div>
) : null}
2026-03-13 06:40:54 +00:00
</div>
)}
2026-03-10 07:04:33 +00:00
</div>
2026-03-01 16:26:03 +00:00
</div>
</div>
</div>
) : (
<div style={{ color: 'var(--muted)' }}>
{forcedBotMissing
? `${t.selectBot}: ${String(forcedBotId).trim()}`
: t.selectBot}
</div>
)}
</section>
2026-03-14 05:03:22 +00:00
<section className={`panel stack ops-runtime-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'runtime') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
2026-03-01 16:26:03 +00:00
{selectedBot ? (
<div className="ops-runtime-shell">
<div className="row-between ops-runtime-head">
<h2 style={{ fontSize: 18 }}>{t.runtime}</h2>
<div className="ops-panel-tools" ref={runtimeMenuRef}>
2026-03-09 04:53:15 +00:00
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void restartBot(selectedBot.id, selectedBot.docker_status)}
disabled={operatingBotId === selectedBot.id}
tooltip={t.restart}
aria-label={t.restart}
>
2026-03-13 06:40:54 +00:00
<RotateCcw size={14} className={operatingBotId === selectedBot.id ? 'animate-spin' : ''} />
2026-03-09 04:53:15 +00:00
</LucentIconButton>
2026-03-03 08:12:27 +00:00
<LucentIconButton
2026-03-01 16:26:03 +00:00
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setRuntimeMenuOpen((v) => !v)}
2026-03-03 08:12:27 +00:00
tooltip={runtimeMoreLabel}
2026-03-01 16:26:03 +00:00
aria-label={runtimeMoreLabel}
aria-haspopup="menu"
aria-expanded={runtimeMenuOpen}
>
<EllipsisVertical size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-01 16:26:03 +00:00
{runtimeMenuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={runtimeMoreLabel}>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
2026-03-09 09:52:42 +00:00
void (async () => {
const detail = await ensureSelectedBotDetail();
applyEditFormFromBot(detail);
setProviderTestResult('');
setShowBaseModal(true);
})();
2026-03-01 16:26:03 +00:00
}}
>
<Settings2 size={14} />
<span>{t.base}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
2026-03-09 09:52:42 +00:00
void (async () => {
const detail = await ensureSelectedBotDetail();
applyEditFormFromBot(detail);
setShowParamModal(true);
})();
2026-03-01 16:26:03 +00:00
}}
>
<SlidersHorizontal size={14} />
<span>{t.params}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
2026-03-13 06:40:54 +00:00
if (!selectedBot) return;
openChannelModal(selectedBot.id);
2026-03-01 16:26:03 +00:00
}}
>
<Waypoints size={14} />
<span>{t.channels}</span>
</button>
2026-03-13 06:40:54 +00:00
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
if (!selectedBot) return;
openTopicModal(selectedBot.id);
}}
>
<MessageSquareText size={14} />
<span>{t.topic}</span>
</button>
2026-03-01 16:26:03 +00:00
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
if (!selectedBot) return;
void loadBotEnvParams(selectedBot.id);
setShowEnvParamsModal(true);
}}
>
<Settings2 size={14} />
<span>{t.envParams}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
if (!selectedBot) return;
void loadBotSkills(selectedBot.id);
setShowSkillsModal(true);
}}
>
<Hammer size={14} />
<span>{t.skills}</span>
</button>
2026-03-11 12:55:42 +00:00
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
if (!selectedBot) return;
2026-03-13 06:40:54 +00:00
openMcpModal(selectedBot.id);
2026-03-11 12:55:42 +00:00
}}
>
<Boxes size={14} />
<span>{t.mcp}</span>
</button>
2026-03-01 16:26:03 +00:00
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
if (selectedBot) void loadCronJobs(selectedBot.id);
setShowCronModal(true);
}}
>
<Clock3 size={14} />
<span>{t.cronViewer}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
2026-03-09 09:52:42 +00:00
void (async () => {
const detail = await ensureSelectedBotDetail();
applyEditFormFromBot(detail);
setShowAgentModal(true);
})();
2026-03-01 16:26:03 +00:00
}}
>
<FileText size={14} />
<span>{t.agent}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
exportConversationJson();
}}
>
<Save size={14} />
<span>{t.exportHistory}</span>
</button>
<button
className="ops-more-item danger"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
void clearConversationHistory();
}}
>
<Trash2 size={14} />
<span>{t.clearHistory}</span>
</button>
</div>
) : null}
</div>
</div>
<div className="ops-runtime-scroll">
<div
2026-03-13 06:40:54 +00:00
className="card ops-runtime-card ops-runtime-state-card is-visual"
2026-03-01 16:26:03 +00:00
>
2026-03-13 06:40:54 +00:00
<div className={`ops-state-stage ops-state-${String(displayState).toLowerCase()}`}>
<div className="ops-state-model mono">{selectedBot.llm_model || '-'}</div>
<div className="ops-state-face" aria-hidden="true">
<span className="ops-state-eye left" />
<span className="ops-state-eye right" />
2026-03-01 16:26:03 +00:00
</div>
2026-03-13 06:40:54 +00:00
<div className="ops-state-caption mono">{String(displayState || 'IDLE').toUpperCase()}</div>
{displayState === 'TOOL_CALL' ? <Hammer size={18} className="ops-state-float state-tool" /> : null}
{displayState === 'SUCCESS' ? <Check size={18} className="ops-state-float state-success" /> : null}
{displayState === 'ERROR' ? <TriangleAlert size={18} className="ops-state-float state-error" /> : null}
</div>
2026-03-01 16:26:03 +00:00
</div>
<div className="card ops-runtime-card">
<div className="section-mini-title">{t.workspaceOutputs}</div>
{workspaceError ? <div className="ops-empty-inline">{workspaceError}</div> : null}
<div className="workspace-toolbar">
2026-03-09 09:52:42 +00:00
<div className="workspace-path-wrap">
<div className="workspace-path mono" title={workspacePathDisplay}>
{workspacePathDisplay}
</div>
</div>
2026-03-05 02:42:39 +00:00
<div className="workspace-toolbar-actions">
<LucentIconButton
className="workspace-refresh-icon-btn"
disabled={workspaceLoading || !selectedBotId}
onClick={() => void loadWorkspaceTree(selectedBot.id, workspaceCurrentPath)}
tooltip={lc.refreshHint}
aria-label={lc.refreshHint}
>
<RefreshCw size={14} className={workspaceLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<label className="workspace-auto-switch" title={lc.autoRefresh}>
<span className="workspace-auto-switch-label">{lc.autoRefresh}</span>
<input
type="checkbox"
checked={workspaceAutoRefresh}
onChange={() => setWorkspaceAutoRefresh((v) => !v)}
aria-label={t.autoRefresh}
/>
<span className="workspace-auto-switch-track" />
</label>
</div>
2026-03-01 16:26:03 +00:00
</div>
2026-03-09 09:52:42 +00:00
<div className="workspace-search-toolbar">
<div className="ops-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
value={workspaceQuery}
onChange={(e) => setWorkspaceQuery(e.target.value)}
placeholder={t.workspaceSearchPlaceholder}
aria-label={t.workspaceSearchPlaceholder}
/>
<button
type="button"
className="ops-search-inline-btn"
onClick={() => {
if (workspaceQuery.trim()) {
setWorkspaceQuery('');
return;
}
setWorkspaceQuery((v) => v.trim());
}}
title={workspaceQuery.trim() ? t.clearSearch : t.searchAction}
aria-label={workspaceQuery.trim() ? t.clearSearch : t.searchAction}
>
{workspaceQuery.trim() ? <X size={14} /> : <Search size={14} />}
</button>
</div>
</div>
2026-03-01 16:26:03 +00:00
<div className="workspace-panel">
<div className="workspace-list">
2026-03-09 09:52:42 +00:00
{workspaceLoading || workspaceSearchLoading ? (
2026-03-01 16:26:03 +00:00
<div className="ops-empty-inline">{t.loadingDir}</div>
2026-03-09 09:52:42 +00:00
) : renderWorkspaceNodes(filteredWorkspaceEntries).length === 0 ? (
<div className="ops-empty-inline">{normalizedWorkspaceQuery ? t.workspaceSearchNoResult : t.emptyDir}</div>
2026-03-01 16:26:03 +00:00
) : (
2026-03-09 09:52:42 +00:00
renderWorkspaceNodes(filteredWorkspaceEntries)
2026-03-01 16:26:03 +00:00
)}
</div>
<div className="workspace-hint">
{workspaceFileLoading
? t.openingPreview
: t.workspaceHint}
</div>
</div>
{workspaceFiles.length === 0 ? (
<div className="ops-empty-inline">{t.noPreviewFile}</div>
) : null}
</div>
</div>
</div>
) : (
<div style={{ color: 'var(--muted)' }}>
{forcedBotMissing
? `${t.noTelemetry}: ${String(forcedBotId).trim()}`
: t.noTelemetry}
</div>
)}
</section>
</div>
2026-03-14 05:03:22 +00:00
{showCompactBotPageClose ? (
2026-03-03 08:12:27 +00:00
<LucentIconButton
2026-03-14 05:03:22 +00:00
className="ops-compact-close-btn"
onClick={() => {
setSelectedBotId('');
setCompactPanelTab('chat');
}}
tooltip={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
aria-label={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
2026-03-01 16:26:03 +00:00
>
2026-03-14 05:03:22 +00:00
<X size={16} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-01 16:26:03 +00:00
) : null}
2026-03-14 05:03:22 +00:00
{compactMode && !isCompactListPage ? (
<div className="ops-compact-fab-stack">
<LucentIconButton
className={`ops-compact-fab-switch ${compactPanelTab === 'chat' ? 'is-chat' : 'is-runtime'}`}
onClick={() => setCompactPanelTab((v) => (v === 'runtime' ? 'chat' : 'runtime'))}
tooltip={compactPanelTab === 'runtime' ? (isZh ? '切换到对话面板' : 'Switch to chat') : (isZh ? '切换到运行面板' : 'Switch to runtime')}
aria-label={compactPanelTab === 'runtime' ? (isZh ? '切换到对话面板' : 'Switch to chat') : (isZh ? '切换到运行面板' : 'Switch to runtime')}
>
{compactPanelTab === 'runtime' ? <MessageSquareText size={18} /> : <Activity size={18} />}
</LucentIconButton>
</div>
) : null}
2026-03-01 16:26:03 +00:00
2026-03-03 06:09:11 +00:00
{showResourceModal && (
<div className="modal-mask" onClick={() => setShowResourceModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{isZh ? '资源监测' : 'Resource Monitor'}</h3>
<span className="modal-sub mono">{resourceBot?.name || resourceBotId}</span>
</div>
<div className="modal-title-actions">
2026-03-03 08:12:27 +00:00
<LucentIconButton
2026-03-03 06:09:11 +00:00
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void loadResourceSnapshot(resourceBotId)}
2026-03-03 08:12:27 +00:00
tooltip={isZh ? '立即刷新' : 'Refresh now'}
2026-03-03 06:09:11 +00:00
aria-label={isZh ? '立即刷新' : 'Refresh now'}
>
<RefreshCw size={14} className={resourceLoading ? 'animate-spin' : ''} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
<LucentIconButton
2026-03-03 06:09:11 +00:00
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setShowResourceModal(false)}
2026-03-03 08:12:27 +00:00
tooltip={t.close}
2026-03-03 06:09:11 +00:00
aria-label={t.close}
>
<X size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-03 06:09:11 +00:00
</div>
</div>
{resourceError ? <div className="card">{resourceError}</div> : null}
{resourceSnapshot ? (
<div className="stack">
<div className="card summary-grid">
<div>{isZh ? '容器状态' : 'Container'}: <strong className="mono">{resourceSnapshot.docker_status}</strong></div>
2026-03-03 07:44:39 +00:00
<div>{isZh ? '容器名' : 'Container Name'}: <span className="mono">{resourceSnapshot.bot_id ? `worker_${resourceSnapshot.bot_id}` : '-'}</span></div>
<div>{isZh ? '基础镜像' : 'Base Image'}: <span className="mono">{resourceBot?.image_tag || '-'}</span></div>
<div>Provider/Model: <span className="mono">{resourceBot?.llm_provider || '-'} / {resourceBot?.llm_model || '-'}</span></div>
2026-03-03 06:09:11 +00:00
<div>{isZh ? '采样时间' : 'Collected'}: <span className="mono">{resourceSnapshot.collected_at}</span></div>
2026-03-03 07:44:39 +00:00
<div>{isZh ? '策略说明' : 'Policy'}: <strong>{isZh ? '资源值 0 = 不限制' : 'Value 0 = Unlimited'}</strong></div>
2026-03-03 06:09:11 +00:00
</div>
<div className="grid-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
<div className="card stack">
<div className="section-mini-title">{isZh ? '配置配额' : 'Configured Limits'}</div>
<div className="ops-runtime-row"><span>CPU</span><strong>{Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : resourceSnapshot.configured.cpu_cores}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '内存' : 'Memory'}</span><strong>{Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${resourceSnapshot.configured.memory_mb} MB`}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '存储' : 'Storage'}</span><strong>{Number(resourceSnapshot.configured.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${resourceSnapshot.configured.storage_gb} GB`}</strong></div>
</div>
<div className="card stack">
<div className="section-mini-title">{isZh ? 'Docker 实际限制' : 'Docker Runtime Limits'}</div>
<div className="ops-runtime-row"><span>CPU</span><strong>{resourceSnapshot.runtime.limits.cpu_cores ? resourceSnapshot.runtime.limits.cpu_cores.toFixed(2) : (Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '内存' : 'Memory'}</span><strong>{resourceSnapshot.runtime.limits.memory_bytes ? formatBytes(resourceSnapshot.runtime.limits.memory_bytes) : (Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '存储' : 'Storage'}</span><strong>{resourceSnapshot.runtime.limits.storage_bytes ? formatBytes(resourceSnapshot.runtime.limits.storage_bytes) : (resourceSnapshot.runtime.limits.storage_opt_raw || (Number(resourceSnapshot.configured.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-'))}</strong></div>
</div>
</div>
<div className="card stack">
<div className="section-mini-title">{isZh ? '实时使用' : 'Live Usage'}</div>
<div className="ops-runtime-row"><span>CPU</span><strong>{formatPercent(resourceSnapshot.runtime.usage.cpu_percent)}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '内存' : 'Memory'}</span><strong>{formatBytes(resourceSnapshot.runtime.usage.memory_bytes)} / {resourceSnapshot.runtime.usage.memory_limit_bytes > 0 ? formatBytes(resourceSnapshot.runtime.usage.memory_limit_bytes) : '-'}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '内存占比' : 'Memory %'}</span><strong>{formatPercent(resourceSnapshot.runtime.usage.memory_percent)}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '工作区占用' : 'Workspace Usage'}</span><strong>{formatBytes(resourceSnapshot.workspace.usage_bytes)} / {resourceSnapshot.workspace.configured_limit_bytes ? formatBytes(resourceSnapshot.workspace.configured_limit_bytes) : '-'}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '工作区占比' : 'Workspace %'}</span><strong>{formatPercent(resourceSnapshot.workspace.usage_percent)}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '网络 I/O' : 'Network I/O'}</span><strong>RX {formatBytes(resourceSnapshot.runtime.usage.network_rx_bytes)} · TX {formatBytes(resourceSnapshot.runtime.usage.network_tx_bytes)}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '磁盘 I/O' : 'Block I/O'}</span><strong>R {formatBytes(resourceSnapshot.runtime.usage.blk_read_bytes)} · W {formatBytes(resourceSnapshot.runtime.usage.blk_write_bytes)}</strong></div>
<div className="ops-runtime-row"><span>PIDs</span><strong>{resourceSnapshot.runtime.usage.pids || 0}</strong></div>
</div>
<div className="field-label">
{resourceSnapshot.note}
{isZh ? '(界面规则:资源配置填写 0 表示不限制)' : ' (UI rule: value 0 means unlimited)'}
</div>
</div>
) : (
<div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div>
)}
</div>
</div>
)}
2026-03-01 16:26:03 +00:00
{showBaseModal && (
<div className="modal-mask" onClick={() => setShowBaseModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
2026-03-02 07:51:47 +00:00
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.baseConfig}</h3>
<span className="modal-sub">{t.baseConfigSub}</span>
</div>
<div className="modal-title-actions">
2026-03-03 08:12:27 +00:00
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowBaseModal(false)} tooltip={t.close} aria-label={t.close}>
2026-03-02 07:51:47 +00:00
<X size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-02 07:51:47 +00:00
</div>
2026-03-01 16:26:03 +00:00
</div>
<label className="field-label">{t.botIdReadonly}</label>
<input className="input" value={selectedBot?.id || ''} disabled />
<label className="field-label">{t.botName}</label>
<input className="input" value={editForm.name} onChange={(e) => setEditForm((p) => ({ ...p, name: e.target.value }))} placeholder={t.botNamePlaceholder} />
2026-03-05 02:42:39 +00:00
<label className="field-label">{t.accessPassword}</label>
<input
className="input"
type="password"
value={editForm.access_password}
onChange={(e) => setEditForm((p) => ({ ...p, access_password: e.target.value }))}
placeholder={t.accessPasswordPlaceholder}
/>
2026-03-01 16:26:03 +00:00
<label className="field-label">{t.baseImageReadonly}</label>
2026-03-11 12:55:42 +00:00
<LucentSelect
2026-03-03 06:09:11 +00:00
value={editForm.image_tag}
onChange={(e) => setEditForm((p) => ({ ...p, image_tag: e.target.value }))}
>
{baseImageOptions.map((img) => (
<option key={img.tag} value={img.tag} disabled={img.disabled}>
{img.label}
</option>
))}
2026-03-11 12:55:42 +00:00
</LucentSelect>
2026-03-03 06:09:11 +00:00
{baseImageOptions.find((opt) => opt.tag === editForm.image_tag)?.needsRegister ? (
<div className="field-label" style={{ color: 'var(--warning)' }}>
{isZh ? '该镜像尚未登记,保存时会自动加入镜像注册表。' : 'This image is not registered yet. It will be auto-registered on save.'}
</div>
) : null}
2026-03-01 16:26:03 +00:00
2026-03-05 02:42:39 +00:00
<label className="field-label">{isZh ? 'CPU 核心数' : 'CPU Cores'}</label>
<input
className="input"
type="number"
min="0"
max="16"
step="0.1"
value={paramDraft.cpu_cores}
onChange={(e) => setParamDraft((p) => ({ ...p, cpu_cores: e.target.value }))}
/>
<label className="field-label">{isZh ? '内存 (MB)' : 'Memory (MB)'}</label>
<input
className="input"
type="number"
min="0"
max="65536"
step="128"
value={paramDraft.memory_mb}
onChange={(e) => setParamDraft((p) => ({ ...p, memory_mb: e.target.value }))}
/>
<label className="field-label">{isZh ? '存储 (GB)' : 'Storage (GB)'}</label>
<input
className="input"
type="number"
min="0"
max="1024"
step="1"
value={paramDraft.storage_gb}
onChange={(e) => setParamDraft((p) => ({ ...p, storage_gb: e.target.value }))}
/>
<div className="field-label">{isZh ? '提示:填写 0 表示不限制(保存后需手动重启 Bot 生效)。' : 'Tip: value 0 means unlimited (takes effect after manual bot restart).'}</div>
<div className="row-between">
<button className="btn btn-secondary" onClick={() => setShowBaseModal(false)}>{t.cancel}</button>
<button className="btn btn-primary" disabled={isSaving} onClick={() => void saveBot('base')}>{t.save}</button>
</div>
</div>
</div>
)}
{showParamModal && (
<div className="modal-mask" onClick={() => setShowParamModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.modelParams}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowParamModal(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
2026-03-01 16:26:03 +00:00
<label className="field-label">Provider</label>
2026-03-11 12:55:42 +00:00
<LucentSelect value={editForm.llm_provider} onChange={(e) => onBaseProviderChange(e.target.value)}>
2026-03-01 16:26:03 +00:00
<option value="openrouter">openrouter</option>
<option value="dashscope">dashscope (aliyun qwen)</option>
<option value="openai">openai</option>
<option value="deepseek">deepseek</option>
<option value="kimi">kimi (moonshot)</option>
<option value="minimax">minimax</option>
2026-03-15 07:14:01 +00:00
<option value="xunfei">xunfei (spark)</option>
2026-03-11 12:55:42 +00:00
</LucentSelect>
2026-03-01 16:26:03 +00:00
<label className="field-label">{t.modelName}</label>
<input className="input" value={editForm.llm_model} onChange={(e) => setEditForm((p) => ({ ...p, llm_model: e.target.value }))} placeholder={t.modelNamePlaceholder} />
<label className="field-label">{t.newApiKey}</label>
<input className="input" type="password" value={editForm.api_key} onChange={(e) => setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} />
<label className="field-label">API Base</label>
<input className="input" value={editForm.api_base} onChange={(e) => setEditForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="API Base URL" />
<div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{providerPresets[editForm.llm_provider]?.note[noteLocale]}
</div>
<button className="btn btn-secondary" onClick={() => void testProviderConnection()} disabled={isTestingProvider}>
{isTestingProvider ? t.testing : t.testModelConnection}
</button>
{providerTestResult && <div className="card">{providerTestResult}</div>}
<div className="slider-row">
<label className="field-label">Temperature: {Number(editForm.temperature).toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={editForm.temperature} onChange={(e) => setEditForm((p) => ({ ...p, temperature: clampTemperature(Number(e.target.value)) }))} />
</div>
<div className="slider-row">
<label className="field-label">Top P: {Number(editForm.top_p).toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={editForm.top_p} onChange={(e) => setEditForm((p) => ({ ...p, top_p: Number(e.target.value) }))} />
</div>
<label className="field-label">Max Tokens</label>
2026-03-03 06:09:11 +00:00
<input
className="input"
type="number"
step="1"
min="256"
max="32768"
value={paramDraft.max_tokens}
onChange={(e) => setParamDraft((p) => ({ ...p, max_tokens: e.target.value }))}
/>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{[4096, 8192, 16384, 32768].map((value) => (
<button
key={value}
className="btn btn-secondary btn-sm"
type="button"
onClick={() => setParamDraft((p) => ({ ...p, max_tokens: String(value) }))}
>
{value}
</button>
))}
</div>
2026-03-01 16:26:03 +00:00
<div className="row-between">
<button className="btn btn-secondary" onClick={() => setShowParamModal(false)}>{t.cancel}</button>
<button className="btn btn-primary" disabled={isSaving} onClick={() => void saveBot('params')}>{t.saveParams}</button>
</div>
</div>
</div>
)}
{showChannelModal && (
2026-03-13 06:40:54 +00:00
<div
className="modal-mask"
onClick={() => {
setShowChannelModal(false);
setChannelCreateMenuOpen(false);
setNewChannelPanelOpen(false);
resetNewChannelDraft();
}}
>
<div className="modal-card modal-wide ops-modal-scrollable ops-config-modal" onClick={(e) => e.stopPropagation()}>
2026-03-02 07:51:47 +00:00
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{lc.wizardSectionTitle}</h3>
</div>
<div className="modal-title-actions">
2026-03-13 06:40:54 +00:00
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
setShowChannelModal(false);
setChannelCreateMenuOpen(false);
setNewChannelPanelOpen(false);
resetNewChannelDraft();
}}
tooltip={t.close}
aria-label={t.close}
>
2026-03-02 07:51:47 +00:00
<X size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-02 07:51:47 +00:00
</div>
</div>
2026-03-01 16:26:03 +00:00
<div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{lc.wizardSectionDesc}
</div>
<div className="card">
<div className="section-mini-title">{lc.globalDeliveryTitle}</div>
<div className="field-label">{lc.globalDeliveryDesc}</div>
<div className="wizard-dashboard-switches" style={{ marginTop: 8 }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(globalDelivery.sendProgress)}
onChange={(e) => updateGlobalDeliveryFlag('sendProgress', e.target.checked)}
style={{ marginRight: 6 }}
/>
{lc.sendProgress}
</label>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(globalDelivery.sendToolHints)}
onChange={(e) => updateGlobalDeliveryFlag('sendToolHints', e.target.checked)}
style={{ marginRight: 6 }}
/>
{lc.sendToolHints}
</label>
2026-03-03 08:12:27 +00:00
<LucentIconButton
2026-03-01 16:26:03 +00:00
className="btn btn-primary btn-sm icon-btn"
disabled={isSavingGlobalDelivery || !selectedBot}
onClick={() => void saveGlobalDelivery()}
2026-03-03 08:12:27 +00:00
tooltip={lc.saveChannel}
2026-03-01 16:26:03 +00:00
aria-label={lc.saveChannel}
>
<Save size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-01 16:26:03 +00:00
</div>
</div>
2026-03-13 06:40:54 +00:00
<div className="wizard-channel-list ops-config-list-scroll">
{channels.filter((channel) => !isDashboardChannel(channel)).length === 0 ? (
<div className="ops-empty-inline">{lc.channelEmpty}</div>
) : (
channels.map((channel, idx) => {
if (isDashboardChannel(channel)) return null;
const uiKey = channelDraftUiKey(channel, idx);
const expanded = expandedChannelByKey[uiKey] ?? idx === 0;
2026-03-14 07:44:11 +00:00
const hasCredential = isChannelConfigured(channel);
2026-03-13 06:40:54 +00:00
const summary = [
String(channel.channel_type || '').toUpperCase(),
channel.is_active ? lc.enabled : lc.disabled,
hasCredential ? lc.channelConfigured : lc.channelPending,
].join(' · ');
return (
<div key={`${channel.id}-${channel.channel_type}`} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{String(channel.channel_type || '').toUpperCase()}</strong>
<div className="ops-config-collapsed-meta">{summary}</div>
</div>
<div className="ops-config-card-actions">
<label className="field-label">
<input
type="checkbox"
checked={channel.is_active}
onChange={(e) => updateChannelLocal(idx, { is_active: e.target.checked })}
style={{ marginRight: 6 }}
/>
{lc.enabled}
</label>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingChannel}
onClick={() => void removeChannel(channel)}
tooltip={lc.remove}
aria-label={lc.remove}
>
<Trash2 size={14} />
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => setExpandedChannelByKey((prev) => ({ ...prev, [uiKey]: !expanded }))}
tooltip={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
aria-label={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
</div>
</div>
{expanded ? (
<>
<div className="ops-topic-grid">
{renderChannelFields(channel, (patch) => updateChannelLocal(idx, patch))}
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{lc.customChannel}</span>
<button
className="btn btn-primary btn-sm"
disabled={isSavingChannel}
onClick={() => void saveChannel(channel)}
>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{lc.saveChannel}</span>
</button>
</div>
</>
) : null}
</div>
);
})
)}
</div>
{newChannelPanelOpen ? (
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{lc.addChannel}</strong>
<div className="ops-config-collapsed-meta">{String(newChannelDraft.channel_type || '').toUpperCase()}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => {
setNewChannelPanelOpen(false);
resetNewChannelDraft();
}}
tooltip={t.cancel}
aria-label={t.cancel}
>
<X size={15} />
</LucentIconButton>
</div>
</div>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{lc.channelType}</label>
<input className="input mono" value={String(newChannelDraft.channel_type || '').toUpperCase()} readOnly />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label" style={{ visibility: 'hidden' }}>{lc.enabled}</label>
<label className="field-label">
<input
type="checkbox"
checked={newChannelDraft.is_active}
onChange={(e) => setNewChannelDraft((prev) => ({ ...prev, is_active: e.target.checked }))}
style={{ marginRight: 6 }}
/>
{lc.enabled}
</label>
</div>
{renderChannelFields(newChannelDraft, (patch) => setNewChannelDraft((prev) => ({ ...prev, ...patch })))}
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{lc.channelAddHint}</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => {
setNewChannelPanelOpen(false);
resetNewChannelDraft();
}}
>
{t.cancel}
</button>
<button className="btn btn-primary btn-sm" disabled={isSavingChannel} onClick={() => void addChannel()}>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{lc.saveChannel}</span>
</button>
</div>
</div>
</div>
) : null}
{!newChannelPanelOpen ? (
<div className="row-between ops-config-footer">
<span className="field-label">{lc.channelAddHint}</span>
<div className="ops-topic-create-menu-wrap">
<button
className="btn btn-secondary btn-sm"
disabled={addableChannelTypes.length === 0 || isSavingChannel}
onClick={() => setChannelCreateMenuOpen((prev) => !prev)}
>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{lc.addChannel}</span>
</button>
{channelCreateMenuOpen ? (
<div className="ops-topic-create-menu">
{addableChannelTypes.map((channelType) => (
<button
key={channelType}
className="ops-topic-create-menu-item"
onClick={() => beginChannelCreate(channelType)}
>
{String(channelType || '').toUpperCase()}
</button>
))}
</div>
) : null}
</div>
</div>
) : null}
</div>
</div>
)}
{showTopicModal && (
<div
className="modal-mask"
onClick={() => {
setShowTopicModal(false);
setTopicPresetMenuOpen(false);
setNewTopicPanelOpen(false);
resetNewTopicDraft();
}}
>
<div className="modal-card modal-wide ops-modal-scrollable ops-config-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.topicPanel}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
setShowTopicModal(false);
setTopicPresetMenuOpen(false);
setNewTopicPanelOpen(false);
resetNewTopicDraft();
}}
tooltip={t.close}
aria-label={t.close}
>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{t.topicPanelDesc}
</div>
<div className="wizard-channel-list ops-config-list-scroll">
{topics.length === 0 ? (
<div className="ops-empty-inline">{t.topicEmpty}</div>
) : (
topics.map((topic, idx) => {
const uiKey = topicDraftUiKey(topic, idx);
const expanded = expandedTopicByKey[uiKey] ?? idx === 0;
const includeCount = normalizeRoutingTextList(String(topic.routing_include_when || '')).length;
const excludeCount = normalizeRoutingTextList(String(topic.routing_exclude_when || '')).length;
return (
<div key={`${topic.id}-${topic.topic_key}`} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong className="mono">{topic.topic_key}</strong>
<div className="field-label">{topic.name || topic.topic_key}</div>
{!expanded ? (
<div className="ops-config-collapsed-meta">
{`${t.topicPriority}: ${topic.routing_priority || '50'} · ${isZh ? '命中' : 'include'} ${includeCount} · ${isZh ? '排除' : 'exclude'} ${excludeCount}`}
</div>
) : null}
</div>
<div className="ops-config-card-actions">
<label className="field-label">
<input
type="checkbox"
checked={Boolean(topic.is_active)}
onChange={(e) => updateTopicLocal(idx, { is_active: e.target.checked })}
style={{ marginRight: 6 }}
/>
{t.topicActive}
</label>
<LucentIconButton
className="btn btn-primary btn-sm icon-btn"
disabled={isSavingTopic}
onClick={() => void saveTopic(topic)}
tooltip={t.save}
aria-label={t.save}
>
<Save size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingTopic}
onClick={() => void removeTopic(topic)}
tooltip={t.delete}
aria-label={t.delete}
>
<Trash2 size={14} />
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => setExpandedTopicByKey((prev) => ({ ...prev, [uiKey]: !expanded }))}
tooltip={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
aria-label={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
</div>
</div>
{expanded ? (
<>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{t.topicName}</label>
<input
className="input"
value={topic.name || ''}
onChange={(e) => updateTopicLocal(idx, { name: e.target.value })}
placeholder={t.topicName}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.topicPriority}</label>
<input
className="input mono"
type="number"
min={0}
max={100}
step={1}
value={topic.routing_priority || '50'}
onChange={(e) => updateTopicLocal(idx, { routing_priority: e.target.value })}
/>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{t.topicDescription}</label>
<textarea
className="input"
rows={3}
value={topic.description || ''}
onChange={(e) => updateTopicLocal(idx, { description: e.target.value })}
placeholder={t.topicDescription}
/>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{t.topicPurpose}</label>
<textarea
className="input"
rows={3}
value={topic.routing_purpose || ''}
onChange={(e) => updateTopicLocal(idx, { routing_purpose: e.target.value })}
placeholder={t.topicPurpose}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.topicIncludeWhen}</label>
<textarea
className="input mono"
rows={4}
value={topic.routing_include_when || ''}
onChange={(e) => updateTopicLocal(idx, { routing_include_when: e.target.value })}
placeholder={t.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.topicExcludeWhen}</label>
<textarea
className="input mono"
rows={4}
value={topic.routing_exclude_when || ''}
onChange={(e) => updateTopicLocal(idx, { routing_exclude_when: e.target.value })}
placeholder={t.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.topicExamplesPositive}</label>
<textarea
className="input mono"
rows={4}
value={topic.routing_examples_positive || ''}
onChange={(e) => updateTopicLocal(idx, { routing_examples_positive: e.target.value })}
placeholder={t.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.topicExamplesNegative}</label>
<textarea
className="input mono"
rows={4}
value={topic.routing_examples_negative || ''}
onChange={(e) => updateTopicLocal(idx, { routing_examples_negative: e.target.value })}
placeholder={t.topicListHint}
/>
</div>
</div>
</>
) : null}
</div>
);
})
)}
2026-03-01 16:26:03 +00:00
</div>
2026-03-13 06:40:54 +00:00
{newTopicPanelOpen ? (
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{t.topicAdd}</strong>
<div className="ops-config-collapsed-meta">
{newTopicSourceLabel}
</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => setNewTopicAdvancedOpen((prev) => !prev)}
tooltip={newTopicAdvancedOpen ? (isZh ? '收起高级路由' : 'Hide Advanced') : (isZh ? '展开高级路由' : 'Show Advanced')}
aria-label={newTopicAdvancedOpen ? (isZh ? '收起高级路由' : 'Hide Advanced') : (isZh ? '展开高级路由' : 'Show Advanced')}
>
{newTopicAdvancedOpen ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => {
setNewTopicPanelOpen(false);
setTopicPresetMenuOpen(false);
resetNewTopicDraft();
}}
tooltip={t.cancel}
aria-label={t.cancel}
>
<X size={15} />
</LucentIconButton>
</div>
</div>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{t.topicKey}</label>
<input
className="input mono"
value={newTopicKey}
onChange={(e) => setNewTopicKey(normalizeTopicKeyInput(e.target.value))}
placeholder={t.topicKeyPlaceholder}
autoComplete="off"
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.topicName}</label>
<input
className="input"
value={newTopicName}
onChange={(e) => setNewTopicName(e.target.value)}
placeholder={t.topicName}
autoComplete="off"
/>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{t.topicDescription}</label>
<textarea
className="input"
rows={3}
value={newTopicDescription}
onChange={(e) => setNewTopicDescription(e.target.value)}
placeholder={t.topicDescription}
/>
</div>
{newTopicAdvancedOpen ? (
<>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{t.topicPurpose}</label>
<textarea
className="input"
rows={3}
value={newTopicPurpose}
onChange={(e) => setNewTopicPurpose(e.target.value)}
placeholder={t.topicPurpose}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.topicIncludeWhen}</label>
<textarea
className="input mono"
rows={4}
value={newTopicIncludeWhen}
onChange={(e) => setNewTopicIncludeWhen(e.target.value)}
placeholder={t.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.topicExcludeWhen}</label>
<textarea
className="input mono"
rows={4}
value={newTopicExcludeWhen}
onChange={(e) => setNewTopicExcludeWhen(e.target.value)}
placeholder={t.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.topicExamplesPositive}</label>
<textarea
className="input mono"
rows={4}
value={newTopicExamplesPositive}
onChange={(e) => setNewTopicExamplesPositive(e.target.value)}
placeholder={t.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.topicExamplesNegative}</label>
<textarea
className="input mono"
rows={4}
value={newTopicExamplesNegative}
onChange={(e) => setNewTopicExamplesNegative(e.target.value)}
placeholder={t.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.topicPriority}</label>
<input
className="input mono"
type="number"
min={0}
max={100}
step={1}
value={newTopicPriority}
onChange={(e) => setNewTopicPriority(e.target.value)}
/>
</div>
</>
) : null}
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{t.topicAddHint}</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<button
className="btn btn-secondary btn-sm"
disabled={isSavingTopic}
onClick={() => {
setNewTopicPanelOpen(false);
setTopicPresetMenuOpen(false);
resetNewTopicDraft();
}}
>
{t.cancel}
</button>
<button
className="btn btn-primary btn-sm"
disabled={isSavingTopic || !selectedBot}
onClick={() => void addTopic()}
>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{t.save}</span>
</button>
</div>
</div>
</div>
) : null}
{!newTopicPanelOpen ? (
<div className="row-between ops-config-footer">
<span className="field-label">{t.topicAddHint}</span>
<div className="ops-topic-create-menu-wrap">
<button
className="btn btn-secondary btn-sm"
disabled={isSavingTopic || !selectedBot}
onClick={() => setTopicPresetMenuOpen((prev) => !prev)}
>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{t.topicAdd}</span>
</button>
{topicPresetMenuOpen ? (
<div className="ops-topic-create-menu">
{effectiveTopicPresetTemplates.map((preset) => (
<button key={preset.id} className="ops-topic-create-menu-item" onClick={() => beginTopicCreate(preset.id)}>
{resolvePresetText(preset.name, isZh ? 'zh-cn' : 'en') || preset.topic_key || preset.id}
</button>
))}
<button className="ops-topic-create-menu-item" onClick={() => beginTopicCreate('blank')}>{t.topicPresetBlank}</button>
</div>
) : null}
</div>
</div>
) : null}
2026-03-01 16:26:03 +00:00
</div>
</div>
)}
{showSkillsModal && (
<div className="modal-mask" onClick={() => setShowSkillsModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
2026-03-02 07:51:47 +00:00
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.skillsPanel}</h3>
</div>
<div className="modal-title-actions">
2026-03-03 08:12:27 +00:00
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowSkillsModal(false)} tooltip={t.close} aria-label={t.close}>
2026-03-02 07:51:47 +00:00
<X size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-02 07:51:47 +00:00
</div>
</div>
2026-03-05 02:42:39 +00:00
<div className="wizard-channel-list ops-skills-list-scroll">
2026-03-01 16:26:03 +00:00
{botSkills.length === 0 ? (
<div className="ops-empty-inline">{t.skillsEmpty}</div>
) : (
botSkills.map((skill) => (
<div key={skill.id} className="card wizard-channel-card wizard-channel-compact">
<div className="row-between">
<div>
<strong>{skill.name || skill.id}</strong>
<div className="field-label mono">{skill.path}</div>
<div className="field-label mono">{String(skill.type || '').toUpperCase()}</div>
<div className="field-label">{skill.description || '-'}</div>
</div>
2026-03-03 08:12:27 +00:00
<LucentIconButton
2026-03-01 16:26:03 +00:00
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => void removeBotSkill(skill)}
2026-03-03 08:12:27 +00:00
tooltip={t.removeSkill}
aria-label={t.removeSkill}
2026-03-01 16:26:03 +00:00
>
<Trash2 size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-01 16:26:03 +00:00
</div>
</div>
))
)}
</div>
<div className="row-between">
<input
ref={skillZipPickerRef}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
onChange={onPickSkillZip}
style={{ display: 'none' }}
/>
<button
className="btn btn-secondary btn-sm"
disabled={isSkillUploading}
onClick={triggerSkillZipUpload}
title={isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
aria-label={isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
>
{isSkillUploading ? <RefreshCw size={14} className="animate-spin" /> : null}
<span style={{ marginLeft: isSkillUploading ? 6 : 0 }}>
{isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
</span>
</button>
<span className="field-label">{t.zipOnlyHint}</span>
</div>
</div>
</div>
)}
2026-03-11 12:55:42 +00:00
{showMcpModal && (
2026-03-13 06:40:54 +00:00
<div
className="modal-mask"
onClick={() => {
setShowMcpModal(false);
setNewMcpPanelOpen(false);
resetNewMcpDraft();
}}
>
<div className="modal-card modal-wide ops-modal-scrollable ops-config-modal" onClick={(e) => e.stopPropagation()}>
2026-03-11 12:55:42 +00:00
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.mcpPanel}</h3>
</div>
<div className="modal-title-actions">
2026-03-13 06:40:54 +00:00
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
setShowMcpModal(false);
setNewMcpPanelOpen(false);
resetNewMcpDraft();
}}
tooltip={t.close}
aria-label={t.close}
>
2026-03-11 12:55:42 +00:00
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="field-label" style={{ marginBottom: 8 }}>{t.mcpPanelDesc}</div>
2026-03-13 06:40:54 +00:00
<div className="wizard-channel-list ops-config-list-scroll">
2026-03-11 12:55:42 +00:00
{mcpServers.length === 0 ? (
<div className="ops-empty-inline">{t.mcpEmpty}</div>
) : (
2026-03-13 06:40:54 +00:00
mcpServers.map((row, idx) => {
const uiKey = mcpDraftUiKey(row, idx);
const expanded = expandedMcpByKey[uiKey] ?? idx === 0;
const summary = `${row.type || 'streamableHttp'} · ${row.url || '-'}`;
return (
<div key={`mcp-${idx}`} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{row.name || `${t.mcpServer} #${idx + 1}`}</strong>
<div className="ops-config-collapsed-meta">{summary}</div>
</div>
<div className="ops-config-card-actions">
<button
className="btn btn-secondary btn-sm"
onClick={() => void testSingleMcpServer(row, idx)}
disabled={mcpTestByIndex[idx]?.status === 'testing' || isSavingMcp}
>
{mcpTestByIndex[idx]?.status === 'testing' ? <RefreshCw size={14} className="animate-spin" /> : <Check size={14} />}
<span style={{ marginLeft: 6 }}>{t.mcpTest}</span>
</button>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
2026-03-13 07:40:30 +00:00
disabled={isSavingMcp || !canRemoveMcpServer(row)}
2026-03-13 06:40:54 +00:00
onClick={() => removeMcpServer(idx)}
tooltip={t.removeSkill}
aria-label={t.removeSkill}
>
<Trash2 size={14} />
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => setExpandedMcpByKey((prev) => ({ ...prev, [uiKey]: !expanded }))}
tooltip={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
aria-label={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
</div>
2026-03-11 12:55:42 +00:00
</div>
2026-03-13 06:40:54 +00:00
{expanded ? (
<>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{t.mcpName}</label>
<input
className="input mono"
value={row.name}
placeholder={t.mcpNamePlaceholder}
onChange={(e) => updateMcpServer(idx, { name: e.target.value })}
autoComplete="off"
disabled={row.locked}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.mcpType}</label>
<LucentSelect value={row.type} onChange={(e) => updateMcpServer(idx, { type: e.target.value as 'streamableHttp' | 'sse' })} disabled={row.locked}>
<option value="streamableHttp">streamableHttp</option>
<option value="sse">sse</option>
</LucentSelect>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">URL</label>
<input
className="input mono"
value={row.url}
placeholder={t.mcpUrlPlaceholder}
onChange={(e) => updateMcpServer(idx, { url: e.target.value })}
autoComplete="off"
disabled={row.locked}
/>
</div>
<div className="ops-config-field">
<label className="field-label">X-Bot-Id</label>
<input
className="input mono"
value={row.botId}
placeholder={t.mcpBotIdPlaceholder}
onChange={(e) => updateMcpServer(idx, { botId: e.target.value })}
autoComplete="off"
disabled={row.locked}
/>
</div>
<div className="ops-config-field">
<label className="field-label">X-Bot-Secret</label>
<input
className="input"
type="password"
value={row.botSecret}
placeholder={t.mcpBotSecretPlaceholder}
onChange={(e) => updateMcpServer(idx, { botSecret: e.target.value })}
autoComplete="new-password"
disabled={row.locked}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.mcpToolTimeout}</label>
<input
className="input mono"
type="number"
min="1"
max="600"
value={row.toolTimeout}
onChange={(e) => updateMcpServer(idx, { toolTimeout: e.target.value })}
disabled={row.locked}
/>
</div>
</div>
{!row.locked ? (
<div className="row-between ops-config-footer">
<span className="field-label">{t.mcpHint}</span>
<button className="btn btn-primary btn-sm" onClick={() => void saveSingleMcpServer(idx)} disabled={isSavingMcp}>
{isSavingMcp ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
<span style={{ marginLeft: 6 }}>{t.save}</span>
</button>
</div>
) : null}
</>
) : null}
{mcpTestByIndex[idx]?.status !== 'idle' ? (
<div className="field-label" style={{ color: mcpTestByIndex[idx]?.status === 'pass' ? 'var(--ok)' : mcpTestByIndex[idx]?.status === 'fail' ? 'var(--err)' : 'var(--muted)' }}>
{mcpTestByIndex[idx]?.message}
</div>
) : null}
2026-03-11 12:55:42 +00:00
</div>
2026-03-13 06:40:54 +00:00
);
})
)}
</div>
{newMcpPanelOpen ? (
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{t.addMcpServer}</strong>
<div className="ops-config-collapsed-meta">{newMcpDraft.type || 'streamableHttp'}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => {
setNewMcpPanelOpen(false);
resetNewMcpDraft();
}}
tooltip={t.cancel}
aria-label={t.cancel}
>
<X size={15} />
</LucentIconButton>
</div>
</div>
<div className="ops-topic-grid">
<div className="ops-config-field">
2026-03-11 12:55:42 +00:00
<label className="field-label">{t.mcpName}</label>
2026-03-13 06:40:54 +00:00
<input
className="input mono"
value={newMcpDraft.name}
placeholder={t.mcpNamePlaceholder}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, name: e.target.value }))}
autoComplete="off"
/>
</div>
<div className="ops-config-field">
2026-03-11 12:55:42 +00:00
<label className="field-label">{t.mcpType}</label>
2026-03-13 06:40:54 +00:00
<LucentSelect
value={newMcpDraft.type}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, type: e.target.value as 'streamableHttp' | 'sse' }))}
>
2026-03-11 12:55:42 +00:00
<option value="streamableHttp">streamableHttp</option>
<option value="sse">sse</option>
</LucentSelect>
2026-03-13 06:40:54 +00:00
</div>
<div className="ops-config-field ops-config-field-full">
2026-03-11 12:55:42 +00:00
<label className="field-label">URL</label>
2026-03-13 06:40:54 +00:00
<input
className="input mono"
value={newMcpDraft.url}
placeholder={t.mcpUrlPlaceholder}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, url: e.target.value }))}
autoComplete="off"
/>
</div>
<div className="ops-config-field">
2026-03-11 12:55:42 +00:00
<label className="field-label">X-Bot-Id</label>
2026-03-13 06:40:54 +00:00
<input
className="input mono"
value={newMcpDraft.botId}
placeholder={t.mcpBotIdPlaceholder}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, botId: e.target.value }))}
autoComplete="off"
/>
</div>
<div className="ops-config-field">
2026-03-11 12:55:42 +00:00
<label className="field-label">X-Bot-Secret</label>
2026-03-13 06:40:54 +00:00
<input
className="input"
type="password"
value={newMcpDraft.botSecret}
placeholder={t.mcpBotSecretPlaceholder}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, botSecret: e.target.value }))}
autoComplete="new-password"
/>
</div>
<div className="ops-config-field">
2026-03-11 12:55:42 +00:00
<label className="field-label">{t.mcpToolTimeout}</label>
2026-03-13 06:40:54 +00:00
<input
className="input mono"
type="number"
min="1"
max="600"
value={newMcpDraft.toolTimeout}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, toolTimeout: e.target.value }))}
/>
2026-03-11 12:55:42 +00:00
</div>
2026-03-13 06:40:54 +00:00
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{t.mcpHint}</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => {
setNewMcpPanelOpen(false);
resetNewMcpDraft();
}}
>
{t.cancel}
</button>
<button className="btn btn-primary btn-sm" disabled={isSavingMcp} onClick={() => void saveNewMcpServer()}>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{t.save}</span>
</button>
</div>
</div>
</div>
) : null}
{!newMcpPanelOpen ? (
<div className="row-between ops-config-footer">
<span className="field-label">{t.mcpHint}</span>
<button className="btn btn-secondary btn-sm" onClick={beginMcpCreate}>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{t.addMcpServer}</span>
</button>
</div>
) : null}
2026-03-11 12:55:42 +00:00
</div>
</div>
)}
2026-03-01 16:26:03 +00:00
{showEnvParamsModal && (
<div className="modal-mask" onClick={() => setShowEnvParamsModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
2026-03-02 07:51:47 +00:00
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.envParams}</h3>
</div>
<div className="modal-title-actions">
2026-03-03 08:12:27 +00:00
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowEnvParamsModal(false)} tooltip={t.close} aria-label={t.close}>
2026-03-02 07:51:47 +00:00
<X size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-02 07:51:47 +00:00
</div>
</div>
2026-03-01 16:26:03 +00:00
<div className="field-label" style={{ marginBottom: 8 }}>{t.envParamsDesc}</div>
<div className="wizard-channel-list">
{envEntries.length === 0 ? (
<div className="ops-empty-inline">{t.noEnvParams}</div>
) : (
envEntries.map(([key, value]) => (
<div key={key} className="card wizard-channel-card wizard-channel-compact">
<div className="row-between" style={{ alignItems: 'center', gap: 8 }}>
<input className="input mono" value={key} readOnly style={{ maxWidth: 280 }} />
<input
className="input"
type={envVisibleByKey[key] ? 'text' : 'password'}
value={value}
onChange={(e) => upsertEnvParam(key, e.target.value)}
placeholder={t.envValue}
2026-03-11 12:55:42 +00:00
autoComplete="off"
2026-03-01 16:26:03 +00:00
/>
2026-03-03 08:12:27 +00:00
<LucentIconButton
2026-03-01 16:26:03 +00:00
className="btn btn-secondary btn-sm wizard-icon-btn"
onClick={() => setEnvVisibleByKey((prev) => ({ ...prev, [key]: !prev[key] }))}
2026-03-03 08:12:27 +00:00
tooltip={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
2026-03-01 16:26:03 +00:00
aria-label={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
>
{envVisibleByKey[key] ? <EyeOff size={14} /> : <Eye size={14} />}
2026-03-03 08:12:27 +00:00
</LucentIconButton>
<LucentIconButton
2026-03-01 16:26:03 +00:00
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => removeEnvParam(key)}
2026-03-03 08:12:27 +00:00
tooltip={t.removeEnvParam}
aria-label={t.removeEnvParam}
2026-03-01 16:26:03 +00:00
>
<Trash2 size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-01 16:26:03 +00:00
</div>
</div>
))
)}
</div>
<div className="row-between">
<input
className="input mono"
value={envDraftKey}
onChange={(e) => setEnvDraftKey(e.target.value.toUpperCase())}
placeholder={t.envKey}
2026-03-11 12:55:42 +00:00
autoComplete="off"
2026-03-01 16:26:03 +00:00
/>
<input
className="input"
type={envDraftVisible ? 'text' : 'password'}
value={envDraftValue}
onChange={(e) => setEnvDraftValue(e.target.value)}
placeholder={t.envValue}
2026-03-11 12:55:42 +00:00
autoComplete="off"
2026-03-01 16:26:03 +00:00
/>
2026-03-03 08:12:27 +00:00
<LucentIconButton
2026-03-01 16:26:03 +00:00
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setEnvDraftVisible((v) => !v)}
2026-03-03 08:12:27 +00:00
tooltip={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
2026-03-01 16:26:03 +00:00
aria-label={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
>
{envDraftVisible ? <EyeOff size={14} /> : <Eye size={14} />}
2026-03-03 08:12:27 +00:00
</LucentIconButton>
<LucentIconButton
2026-03-01 16:26:03 +00:00
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
const key = String(envDraftKey || '').trim().toUpperCase();
if (!key) return;
upsertEnvParam(key, envDraftValue);
2026-03-01 19:44:06 +00:00
setEnvDraftKey('');
2026-03-01 16:26:03 +00:00
setEnvDraftValue('');
}}
2026-03-03 08:12:27 +00:00
tooltip={t.addEnvParam}
2026-03-01 16:26:03 +00:00
aria-label={t.addEnvParam}
>
<Plus size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-01 16:26:03 +00:00
</div>
<div className="row-between">
<span className="field-label">{t.envParamsHint}</span>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-secondary" onClick={() => setShowEnvParamsModal(false)}>{t.cancel}</button>
<button className="btn btn-primary" onClick={() => void saveBotEnvParams()}>{t.save}</button>
</div>
</div>
</div>
</div>
)}
{showCronModal && (
<div className="modal-mask" onClick={() => setShowCronModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
2026-03-02 07:51:47 +00:00
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.cronViewer}</h3>
</div>
<div className="modal-title-actions">
2026-03-03 08:12:27 +00:00
<LucentIconButton
2026-03-02 07:51:47 +00:00
className="btn btn-secondary btn-sm icon-btn"
onClick={() => selectedBot && void loadCronJobs(selectedBot.id)}
2026-03-03 08:12:27 +00:00
tooltip={t.cronReload}
2026-03-02 07:51:47 +00:00
aria-label={t.cronReload}
disabled={cronLoading}
>
<RefreshCw size={14} className={cronLoading ? 'animate-spin' : ''} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowCronModal(false)} tooltip={t.close} aria-label={t.close}>
2026-03-02 07:51:47 +00:00
<X size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-02 07:51:47 +00:00
</div>
2026-03-01 16:26:03 +00:00
</div>
{cronLoading ? (
<div className="ops-empty-inline">{t.cronLoading}</div>
) : cronJobs.length === 0 ? (
<div className="ops-empty-inline">{t.cronEmpty}</div>
) : (
2026-03-05 02:42:39 +00:00
<div className="ops-cron-list ops-cron-list-scroll">
2026-03-01 16:26:03 +00:00
{cronJobs.map((job) => {
const stopping = cronActionJobId === job.id;
const channel = String(job.payload?.channel || '').trim();
const to = String(job.payload?.to || '').trim();
const target = channel && to ? `${channel}:${to}` : channel || to || '-';
return (
<div key={job.id} className="ops-cron-item">
<div className="ops-cron-main">
<div className="ops-cron-name">
<Clock3 size={13} />
<span>{job.name || job.id}</span>
</div>
<div className="ops-cron-meta mono">{formatCronSchedule(job, isZh)}</div>
<div className="ops-cron-meta mono">
{job.state?.nextRunAtMs ? new Date(job.state.nextRunAtMs).toLocaleString() : '-'}
</div>
<div className="ops-cron-meta mono">
{target}
</div>
<div className="ops-cron-meta">{job.enabled === false ? t.cronDisabled : t.cronEnabled}</div>
</div>
<div className="ops-cron-actions">
2026-03-03 08:12:27 +00:00
<LucentIconButton
2026-03-01 16:26:03 +00:00
className="btn btn-danger btn-sm icon-btn"
onClick={() => void stopCronJob(job.id)}
2026-03-03 08:12:27 +00:00
tooltip={t.cronStop}
2026-03-01 16:26:03 +00:00
aria-label={t.cronStop}
disabled={stopping || job.enabled === false}
>
<PowerOff size={13} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
<LucentIconButton
2026-03-01 16:26:03 +00:00
className="btn btn-danger btn-sm icon-btn"
onClick={() => void deleteCronJob(job.id)}
2026-03-03 08:12:27 +00:00
tooltip={t.cronDelete}
2026-03-01 16:26:03 +00:00
aria-label={t.cronDelete}
disabled={stopping}
>
<Trash2 size={13} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-01 16:26:03 +00:00
</div>
</div>
);
})}
</div>
)}
</div>
</div>
)}
2026-03-13 06:40:54 +00:00
{showTemplateModal && (
<div className="modal-mask" onClick={() => setShowTemplateModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.templateManagerTitle}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowTemplateModal(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="ops-template-tabs" role="tablist" aria-label={t.templateManagerTitle}>
<button
className={`ops-template-tab ${templateTab === 'agent' ? 'is-active' : ''}`}
onClick={() => setTemplateTab('agent')}
role="tab"
aria-selected={templateTab === 'agent'}
>
<span className="ops-template-tab-label">{`${t.templateTabAgent} (${templateAgentCount})`}</span>
</button>
<button
className={`ops-template-tab ${templateTab === 'topic' ? 'is-active' : ''}`}
onClick={() => setTemplateTab('topic')}
role="tab"
aria-selected={templateTab === 'topic'}
>
<span className="ops-template-tab-label">{`${t.templateTabTopic} (${templateTopicCount})`}</span>
</button>
</div>
<div className="ops-config-grid" style={{ gridTemplateColumns: '1fr' }}>
{templateTab === 'agent' ? (
<div className="ops-config-field">
<textarea
className="textarea md-area mono"
rows={16}
value={templateAgentText}
onChange={(e) => setTemplateAgentText(e.target.value)}
placeholder='{"agents_md":"..."}'
/>
</div>
) : (
<div className="ops-config-field">
<textarea
className="textarea md-area mono"
rows={16}
value={templateTopicText}
onChange={(e) => setTemplateTopicText(e.target.value)}
placeholder='{"presets":[...]}'
/>
</div>
)}
</div>
<div className="row-between">
<button className="btn btn-secondary" onClick={() => setShowTemplateModal(false)}>{t.cancel}</button>
<button className="btn btn-primary" disabled={isSavingTemplates} onClick={() => void saveTemplateManager(templateTab)}>
{isSavingTemplates ? t.processing : t.save}
</button>
</div>
</div>
</div>
)}
2026-03-01 16:26:03 +00:00
{showAgentModal && (
<div className="modal-mask" onClick={() => setShowAgentModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
2026-03-02 07:51:47 +00:00
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.agentFiles}</h3>
</div>
<div className="modal-title-actions">
2026-03-03 08:12:27 +00:00
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowAgentModal(false)} tooltip={t.close} aria-label={t.close}>
2026-03-02 07:51:47 +00:00
<X size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-02 07:51:47 +00:00
</div>
</div>
2026-03-01 16:26:03 +00:00
<div className="wizard-agent-layout">
<div className="agent-tabs-vertical">
{(['AGENTS', 'SOUL', 'USER', 'TOOLS', 'IDENTITY'] as AgentTab[]).map((tab) => (
<button key={tab} className={`agent-tab ${agentTab === tab ? 'active' : ''}`} onClick={() => setAgentTab(tab)}>{tab}.md</button>
))}
</div>
<textarea className="textarea md-area" value={String(editForm[tabMap[agentTab]])} onChange={(e) => setEditForm((p) => ({ ...p, [tabMap[agentTab]]: e.target.value }))} />
</div>
<div className="row-between">
<button className="btn btn-secondary" onClick={() => setShowAgentModal(false)}>{t.cancel}</button>
<button className="btn btn-primary" disabled={isSaving} onClick={() => void saveBot('agent')}>{t.saveFiles}</button>
</div>
</div>
</div>
)}
2026-03-01 20:27:58 +00:00
{showRuntimeActionModal && (
<div className="modal-mask" onClick={() => setShowRuntimeActionModal(false)}>
<div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}>
2026-03-02 07:51:47 +00:00
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.lastAction}</h3>
</div>
<div className="modal-title-actions">
2026-03-03 08:12:27 +00:00
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowRuntimeActionModal(false)} tooltip={t.close} aria-label={t.close}>
2026-03-02 07:51:47 +00:00
<X size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-02 07:51:47 +00:00
</div>
2026-03-01 20:27:58 +00:00
</div>
<div className="workspace-preview-body">
<pre>{runtimeAction}</pre>
</div>
</div>
</div>
)}
2026-03-01 16:26:03 +00:00
{workspacePreview && (
2026-03-02 04:38:01 +00:00
<div className="modal-mask" onClick={closeWorkspacePreview}>
<div className={`modal-card modal-preview ${workspacePreviewFullscreen ? 'modal-preview-fullscreen' : ''}`} onClick={(e) => e.stopPropagation()}>
2026-03-02 05:47:45 +00:00
<div className="modal-title-row workspace-preview-header">
<div className="workspace-preview-header-text">
<h3>{t.filePreview}</h3>
2026-03-10 18:28:39 +00:00
<span className="modal-sub mono workspace-preview-path-row">
<span>{workspacePreview.path}</span>
<LucentIconButton
className="workspace-preview-copy-name"
onClick={() => void copyWorkspacePreviewPath(workspacePreview.path)}
tooltip={isZh ? '复制路径' : 'Copy path'}
aria-label={isZh ? '复制路径' : 'Copy path'}
>
<Copy size={12} />
</LucentIconButton>
</span>
2026-03-02 05:47:45 +00:00
</div>
<div className="workspace-preview-header-actions">
2026-03-03 08:12:27 +00:00
<LucentIconButton
2026-03-02 05:47:45 +00:00
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setWorkspacePreviewFullscreen((v) => !v)}
2026-03-03 08:12:27 +00:00
tooltip={workspacePreviewFullscreen ? (isZh ? '退出全屏' : 'Exit full screen') : (isZh ? '全屏预览' : 'Full screen')}
2026-03-02 05:47:45 +00:00
aria-label={workspacePreviewFullscreen ? (isZh ? '退出全屏' : 'Exit full screen') : (isZh ? '全屏预览' : 'Full screen')}
>
{workspacePreviewFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
2026-03-03 08:12:27 +00:00
</LucentIconButton>
<LucentIconButton
2026-03-02 05:47:45 +00:00
className="btn btn-secondary btn-sm icon-btn"
onClick={closeWorkspacePreview}
2026-03-03 08:12:27 +00:00
tooltip={t.close}
2026-03-02 05:47:45 +00:00
aria-label={t.close}
>
<X size={14} />
2026-03-03 08:12:27 +00:00
</LucentIconButton>
2026-03-02 05:47:45 +00:00
</div>
2026-03-01 16:26:03 +00:00
</div>
2026-03-10 18:28:39 +00:00
<div className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''} ${workspacePreview.isImage || workspacePreview.isVideo || workspacePreview.isAudio ? 'media' : ''}`}>
2026-03-01 19:44:06 +00:00
{workspacePreview.isImage ? (
<img
className="workspace-preview-image"
2026-03-05 02:42:39 +00:00
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
2026-03-01 19:44:06 +00:00
alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
/>
2026-03-10 18:28:39 +00:00
) : workspacePreview.isVideo ? (
<video
className="workspace-preview-media"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
controls
preload="metadata"
/>
) : workspacePreview.isAudio ? (
<audio
className="workspace-preview-audio"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
controls
preload="metadata"
/>
2026-03-02 07:51:47 +00:00
) : workspacePreview.isHtml ? (
<iframe
className="workspace-preview-embed"
2026-03-05 02:42:39 +00:00
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
2026-03-02 07:51:47 +00:00
title={workspacePreview.path}
/>
2026-03-01 19:44:06 +00:00
) : workspacePreview.isMarkdown ? (
2026-03-01 16:26:03 +00:00
<div className="workspace-markdown">
2026-03-02 02:54:40 +00:00
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={markdownComponents}
>
2026-03-01 16:26:03 +00:00
{workspacePreview.content}
</ReactMarkdown>
</div>
) : (
<pre>{workspacePreview.content}</pre>
)}
</div>
{workspacePreview.truncated ? (
<div className="ops-empty-inline">{t.fileTruncated}</div>
) : null}
<div className="row-between">
<span className="workspace-preview-meta mono">{workspacePreview.ext || '-'}</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
2026-03-02 07:51:47 +00:00
{workspacePreview.isHtml ? (
<button
className="btn btn-secondary"
onClick={() => void copyWorkspacePreviewUrl(workspacePreview.path)}
>
{t.copyAddress}
</button>
) : (
<a
className="btn btn-secondary"
2026-03-05 02:42:39 +00:00
href={buildWorkspaceDownloadHref(workspacePreview.path, true)}
2026-03-02 07:51:47 +00:00
target="_blank"
rel="noopener noreferrer"
download={workspacePreview.path.split('/').pop() || 'workspace-file'}
>
{t.download}
</a>
)}
2026-03-01 16:26:03 +00:00
</div>
</div>
</div>
</div>
)}
2026-03-05 02:42:39 +00:00
{workspaceHoverCard ? (
<div
className={`workspace-hover-panel ${workspaceHoverCard.above ? 'is-above' : ''}`}
style={{ top: workspaceHoverCard.top, left: workspaceHoverCard.left }}
role="tooltip"
>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '全称' : 'Name'}</span>
<span className="workspace-entry-info-value mono">{workspaceHoverCard.node.name || '-'}</span>
</div>
2026-03-09 09:52:42 +00:00
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '完整路径' : 'Full Path'}</span>
<span className="workspace-entry-info-value mono">
{`/root/.nanobot/workspace/${String(workspaceHoverCard.node.path || '').replace(/^\/+/, '')}`}
</span>
</div>
2026-03-05 02:42:39 +00:00
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '创建时间' : 'Created'}</span>
<span className="workspace-entry-info-value">{formatWorkspaceTime(workspaceHoverCard.node.ctime, isZh)}</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '修改时间' : 'Modified'}</span>
<span className="workspace-entry-info-value">{formatWorkspaceTime(workspaceHoverCard.node.mtime, isZh)}</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '文件大小' : 'Size'}</span>
<span className="workspace-entry-info-value mono">{Number.isFinite(Number(workspaceHoverCard.node.size)) ? formatBytes(Number(workspaceHoverCard.node.size)) : '-'}</span>
</div>
</div>
) : null}
2026-03-01 16:26:03 +00:00
</>
);
}