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

7900 lines
334 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type ImgHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react';
import axios from 'axios';
import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Command, Copy, Download, EllipsisVertical, ExternalLink, Eye, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { APP_ENDPOINTS } from '../../config/env';
import { useAppStore } from '../../store/appStore';
import type { ChatMessage } from '../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from './messageParser';
import nanobotLogo from '../../assets/nanobot-logo.png';
import './BotDashboardModule.css';
import { channelsZhCn } from '../../i18n/channels.zh-cn';
import { channelsEn } from '../../i18n/channels.en';
import { pickLocale } from '../../i18n';
import { dashboardZhCn } from '../../i18n/dashboard.zh-cn';
import { dashboardEn } from '../../i18n/dashboard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../components/lucent/LucentSelect';
import { PasswordInput } from '../../components/PasswordInput';
import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel';
import type { BotSkillMarketItem } from '../platform/types';
import { SkillMarketInstallModal } from './components/SkillMarketInstallModal';
import {
normalizePlatformPageSize,
readCachedPlatformPageSize,
writeCachedPlatformPageSize,
} from '../../utils/platformPageSize';
interface BotDashboardModuleProps {
onOpenCreateWizard?: () => void;
onOpenImageFactory?: () => void;
forcedBotId?: string;
compactMode?: boolean;
}
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
type WorkspaceNodeType = 'dir' | 'file';
type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack' | 'email';
type RuntimeViewMode = 'visual' | 'topic';
type CompactPanelTab = 'chat' | 'runtime';
type QuotedReply = { id?: number; text: string; ts: number };
interface WorkspaceNode {
name: string;
path: string;
type: WorkspaceNodeType;
size?: number;
ext?: string;
ctime?: string;
mtime?: string;
children?: WorkspaceNode[];
}
interface WorkspaceHoverCardState {
node: WorkspaceNode;
top: number;
left: number;
above: boolean;
}
interface WorkspaceTreeResponse {
bot_id: string;
root: string;
cwd: string;
parent: string | null;
entries: WorkspaceNode[];
}
interface WorkspaceFileResponse {
bot_id: string;
path: string;
size: number;
is_markdown: boolean;
truncated: boolean;
content: string;
}
interface WorkspacePreviewState {
path: string;
content: string;
truncated: boolean;
ext: string;
isMarkdown: boolean;
isImage: boolean;
isHtml: boolean;
isVideo: boolean;
isAudio: boolean;
}
interface WorkspaceUploadResponse {
bot_id: string;
files: Array<{ name: string; path: string; size: number }>;
}
interface BotMessagesByDateResponse {
items?: any[];
anchor_id?: number | null;
resolved_ts?: number | null;
matched_exact_date?: boolean;
has_more_before?: boolean;
has_more_after?: boolean;
}
interface CronJob {
id: string;
name: string;
enabled?: boolean;
schedule?: {
kind?: 'at' | 'every' | 'cron' | string;
atMs?: number;
everyMs?: number;
expr?: string;
tz?: string;
};
payload?: {
message?: string;
channel?: string;
to?: string;
};
state?: {
nextRunAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
};
}
interface CronJobsResponse {
bot_id: string;
version: number;
jobs: CronJob[];
}
interface MCPServerConfig {
type?: 'streamableHttp' | 'sse' | string;
url?: string;
headers?: Record<string, string>;
toolTimeout?: number;
locked?: boolean;
}
interface MCPConfigResponse {
bot_id: string;
mcp_servers?: Record<string, MCPServerConfig>;
locked_servers?: string[];
restart_required?: boolean;
status?: string;
}
interface MCPServerDraft {
name: string;
type: 'streamableHttp' | 'sse';
url: string;
botId: string;
botSecret: string;
toolTimeout: string;
headers: Record<string, string>;
locked: boolean;
originName?: string;
}
interface BotChannel {
id: string | number;
bot_id: string;
channel_type: ChannelType | string;
external_app_id: string;
app_secret: string;
internal_port: number;
is_active: boolean;
extra_config: Record<string, unknown>;
locked?: boolean;
}
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;
}
interface NanobotImage {
tag: string;
status: string;
}
interface BaseImageOption {
tag: string;
label: string;
disabled: boolean;
}
interface WorkspaceSkillOption {
id: string;
name: string;
type: 'dir' | 'file' | string;
path: string;
size?: number | null;
mtime?: string;
description?: string;
}
interface BotResourceSnapshot {
bot_id: string;
docker_status: string;
configured: {
cpu_cores: number;
memory_mb: number;
storage_gb: number;
};
runtime: {
docker_status: string;
limits: {
cpu_cores?: number | null;
memory_bytes?: number | null;
storage_bytes?: number | null;
nano_cpus?: number;
storage_opt_raw?: string;
};
usage: {
cpu_percent: number;
memory_bytes: number;
memory_limit_bytes: number;
memory_percent: number;
network_rx_bytes: number;
network_tx_bytes: number;
blk_read_bytes: number;
blk_write_bytes: number;
pids: number;
container_rw_bytes: number;
};
};
workspace: {
path: string;
usage_bytes: number;
configured_limit_bytes?: number | null;
usage_percent: number;
};
enforcement: {
cpu_limited: boolean;
memory_limited: boolean;
storage_limited: boolean;
};
note: string;
collected_at: string;
}
interface SkillUploadResponse {
status: string;
bot_id: string;
installed: string[];
skills: WorkspaceSkillOption[];
}
interface MarketSkillInstallResponse {
status: string;
bot_id: string;
skill_market_item_id: number;
installed: string[];
skills: WorkspaceSkillOption[];
}
interface SystemDefaultsResponse {
limits?: {
upload_max_mb?: number;
};
workspace?: {
allowed_attachment_extensions?: unknown;
download_extensions?: unknown;
};
chat?: {
page_size?: number;
pull_page_size?: number;
};
topic_presets?: unknown;
speech?: {
enabled?: boolean;
model?: string;
device?: string;
max_audio_seconds?: number;
};
}
interface TopicPresetTemplate {
id: string;
topic_key: string;
name?: unknown;
description?: unknown;
routing_purpose?: unknown;
routing_include_when?: unknown;
routing_exclude_when?: unknown;
routing_examples_positive?: unknown;
routing_examples_negative?: unknown;
routing_priority?: number;
}
type BotEnvParams = Record<string, string>;
const DEFAULT_TOPIC_PRESET_TEMPLATES: TopicPresetTemplate[] = [
{
id: 'politics',
topic_key: 'politics_news',
name: { 'zh-cn': '时政新闻', en: 'Politics News' },
description: {
'zh-cn': '沉淀国内外时政动态、政策发布与重大公共治理事件,便于集中查看。',
en: 'Track politics, policy releases, and major public governance events.',
},
routing_purpose: {
'zh-cn': '收录与政府决策、政策法规、外交事务及公共治理相关的关键信息。',
en: 'Capture key information related to government decisions, policy, diplomacy, and governance.',
},
routing_include_when: ['时政', '政策', '法规', '国务院', '政府', '部委', '人大', '政协', '外交', '国际关系', '白宫', '总统', '议会', 'election', 'policy'],
routing_exclude_when: ['娱乐', '明星', '综艺', '体育', '游戏', '购物', '种草', '广告'],
routing_examples_positive: ['国务院发布新一轮宏观政策措施。', '外交部就国际热点事件发布声明。', '某国总统宣布新的对外政策方向。'],
routing_examples_negative: ['某明星新剧开播引发热议。', '某球队转会新闻与赛果分析。', '数码产品促销与购物推荐汇总。'],
routing_priority: 85,
},
{
id: 'finance',
topic_key: 'finance_market',
name: { 'zh-cn': '财经信息', en: 'Finance & Market' },
description: {
'zh-cn': '聚合宏观经济、市场波动、公司财报与监管政策等财经信息。',
en: 'Aggregate macroeconomics, market moves, company earnings, and regulatory updates.',
},
routing_purpose: {
'zh-cn': '沉淀与资本市场、行业景气、资产价格相关的关键结论与风险提示。',
en: 'Capture key insights and risk alerts related to capital markets and asset prices.',
},
routing_include_when: ['财经', '金融', '股市', 'A股', '港股', '美股', '债券', '汇率', '利率', '通胀', 'GDP', '财报', '央行', 'market', 'earnings'],
routing_exclude_when: ['娱乐', '体育', '游戏', '影视', '八卦', '生活方式', '旅行攻略'],
routing_examples_positive: ['央行公布最新利率决议并释放政策信号。', '上市公司发布季度财报并上调全年指引。', '美元指数走强导致主要货币普遍承压。'],
routing_examples_negative: ['某综艺节目收视排名变化。', '某球员转会传闻引发讨论。', '新游上线玩法测评。'],
routing_priority: 80,
},
{
id: 'tech',
topic_key: 'tech_updates',
name: { 'zh-cn': '技术资讯', en: 'Tech Updates' },
description: {
'zh-cn': '追踪 AI、云计算、开源社区与开发工具链的最新技术资讯。',
en: 'Track updates across AI, cloud, open-source ecosystems, and developer tooling.',
},
routing_purpose: {
'zh-cn': '沉淀技术发布、版本升级、兼容性变更与工程实践建议。',
en: 'Capture releases, version upgrades, compatibility changes, and engineering guidance.',
},
routing_include_when: ['技术', '开源', 'AI', '模型', '大语言模型', 'MCP', 'API', 'SDK', '发布', '版本', '升级', 'breaking change', 'security advisory'],
routing_exclude_when: ['娱乐', '体育', '美食', '旅游', '情感', '八卦'],
routing_examples_positive: ['某主流框架发布新版本并调整默认配置。', '开源项目披露高危安全漏洞并给出修复方案。', 'AI 模型服务更新 API返回结构发生变化。'],
routing_examples_negative: ['某艺人参加活动造型盘点。', '旅游目的地打卡攻略合集。', '比赛结果预测与竞猜。'],
routing_priority: 75,
},
];
const providerPresets: Record<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',
},
},
xunfei: {
model: 'astron-code-latest',
apiBase: 'https://spark-api-open.xf-yun.com/v1',
note: {
'zh-cn': '讯飞星火OpenAI 兼容)接口,模型示例 astron-code-latest',
en: 'Xunfei Spark (OpenAI-compatible), model example: astron-code-latest',
},
},
};
const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack', 'email'];
const RUNTIME_STALE_MS = 45000;
const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']);
function formatClock(ts: number) {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function formatConversationDate(ts: number, isZh: boolean) {
const d = new Date(ts);
try {
return d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
});
} catch {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
}
function formatDateInputValue(ts: number): string {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function mapBotMessageResponseRow(row: any): ChatMessage {
const roleRaw = String(row?.role || '').toLowerCase();
const role: ChatMessage['role'] =
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
return {
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
role,
text: String(row?.text || ''),
attachments: normalizeAttachmentPaths(row?.media),
ts: Number(row?.ts || Date.now()),
feedback,
kind: 'final',
} as ChatMessage;
}
function stateLabel(s?: string) {
return (s || 'IDLE').toUpperCase();
}
function resolvePresetText(raw: unknown, locale: 'zh-cn' | 'en'): string {
if (typeof raw === 'string') return raw.trim();
if (!raw || typeof raw !== 'object') return '';
const bag = raw as Record<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;
}
function normalizeRuntimeState(s?: string) {
const raw = stateLabel(s);
if (raw.includes('ERROR') || raw.includes('FAIL')) return 'ERROR';
if (raw.includes('TOOL') || raw.includes('EXEC') || raw.includes('ACTION')) return 'TOOL_CALL';
if (raw.includes('THINK') || raw.includes('PLAN') || raw.includes('REASON') || raw === 'RUNNING') return 'THINKING';
if (raw.includes('SUCCESS') || raw.includes('DONE') || raw.includes('COMPLETE')) return 'SUCCESS';
if (raw.includes('IDLE') || raw.includes('STOP')) return 'IDLE';
return raw;
}
function parseBotTimestamp(raw?: string | number) {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
const text = String(raw || '').trim();
if (!text) return 0;
const ms = Date.parse(text);
return Number.isFinite(ms) ? ms : 0;
}
const TEXT_PREVIEW_EXTENSIONS = new Set(['.md', '.json', '.log', '.txt', '.csv']);
const HTML_PREVIEW_EXTENSIONS = new Set(['.html', '.htm']);
const IMAGE_PREVIEW_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
const AUDIO_PREVIEW_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma']);
const VIDEO_PREVIEW_EXTENSIONS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts']);
const DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS = [
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps', '.glb',
];
const DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET = new Set(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS);
function normalizeWorkspaceExtension(raw: unknown): string {
const value = String(raw ?? '').trim().toLowerCase();
if (!value) return '';
const stripped = value.replace(/^\*\./, '');
const normalized = stripped.startsWith('.') ? stripped : `.${stripped}`;
return /^\.[a-z0-9][a-z0-9._+-]{0,31}$/.test(normalized) ? normalized : '';
}
function parseWorkspaceDownloadExtensions(
raw: unknown,
fallback: readonly string[] = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
): string[] {
if (raw === null || raw === undefined) return [...fallback];
if (Array.isArray(raw) && raw.length === 0) return [];
if (typeof raw === 'string' && raw.trim() === '') return [];
const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/);
const rows: string[] = [];
source.forEach((item) => {
const ext = normalizeWorkspaceExtension(item);
if (ext && !rows.includes(ext)) rows.push(ext);
});
return rows;
}
function parseAllowedAttachmentExtensions(raw: unknown): string[] {
if (raw === null || raw === undefined) return [];
if (Array.isArray(raw) && raw.length === 0) return [];
if (typeof raw === 'string' && raw.trim() === '') return [];
const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/);
const rows: string[] = [];
source.forEach((item) => {
const ext = normalizeWorkspaceExtension(item);
if (ext && !rows.includes(ext)) rows.push(ext);
});
return rows;
}
function pathHasExtension(path: string, extensions: ReadonlySet<string>): boolean {
const normalized = String(path || '').trim().toLowerCase();
if (!normalized) return false;
for (const ext of extensions) {
if (normalized.endsWith(ext)) return true;
}
return false;
}
function isDownloadOnlyPath(path: string, downloadExtensions: ReadonlySet<string> = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET) {
return pathHasExtension(path, downloadExtensions);
}
function isPreviewableWorkspaceFile(
node: WorkspaceNode,
downloadExtensions: ReadonlySet<string> = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET,
) {
if (node.type !== 'file') return false;
return isPreviewableWorkspacePath(node.path, downloadExtensions);
}
function isImagePath(path: string) {
return pathHasExtension(path, IMAGE_PREVIEW_EXTENSIONS);
}
function isVideoPath(path: string) {
return pathHasExtension(path, VIDEO_PREVIEW_EXTENSIONS);
}
function isAudioPath(path: string) {
return pathHasExtension(path, AUDIO_PREVIEW_EXTENSIONS);
}
const MEDIA_UPLOAD_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.heic', '.heif', '.tif', '.tiff',
'.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma',
'.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts',
]);
function isMediaUploadFile(file: File): boolean {
const mime = String(file.type || '').toLowerCase();
if (mime.startsWith('image/') || mime.startsWith('audio/') || mime.startsWith('video/')) {
return true;
}
const name = String(file.name || '').trim().toLowerCase();
const dot = name.lastIndexOf('.');
if (dot < 0) return false;
return MEDIA_UPLOAD_EXTENSIONS.has(name.slice(dot));
}
function isHtmlPath(path: string) {
return pathHasExtension(path, HTML_PREVIEW_EXTENSIONS);
}
function isPreviewableWorkspacePath(
path: string,
downloadExtensions: ReadonlySet<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)
);
}
function workspaceFileAction(
path: string,
downloadExtensions: ReadonlySet<string> = DEFAULT_WORKSPACE_DOWNLOAD_EXTENSION_SET,
): 'preview' | 'download' | 'unsupported' {
const normalized = String(path || '').trim();
if (!normalized) return 'unsupported';
if (isDownloadOnlyPath(normalized, downloadExtensions)) return 'download';
if (isImagePath(normalized) || isHtmlPath(normalized) || isVideoPath(normalized) || isAudioPath(normalized)) return 'preview';
if (pathHasExtension(normalized, TEXT_PREVIEW_EXTENSIONS)) return 'preview';
return 'unsupported';
}
const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/';
const WORKSPACE_ABS_PATH_PATTERN =
/\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.[a-z0-9][a-z0-9._+-]{0,31}\b/gi;
const WORKSPACE_RELATIVE_PATH_PATTERN =
/(^|[\s(\[])(\/[^\n\r<>"'`)\]]+?\.[a-z0-9][a-z0-9._+-]{0,31})(?![A-Za-z0-9_./-])/gim;
function buildWorkspaceLink(path: string) {
return `${WORKSPACE_LINK_PREFIX}${encodeURIComponent(path)}`;
}
function parseWorkspaceLink(href: string): string | null {
const link = String(href || '').trim();
if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null;
const encoded = link.slice(WORKSPACE_LINK_PREFIX.length);
try {
const decoded = decodeURIComponent(encoded || '').trim();
return decoded || null;
} catch {
return null;
}
}
function renderWorkspacePathSegments(pathRaw: string, keyPrefix: string): ReactNode[] {
const path = String(pathRaw || '');
if (!path) return ['-'];
const normalized = path.replace(/\\/g, '/');
const hasLeadingSlash = normalized.startsWith('/');
const parts = normalized.split('/').filter((part) => part.length > 0);
const nodes: ReactNode[] = [];
if (hasLeadingSlash) {
nodes.push(<span key={`${keyPrefix}-root`} className="workspace-path-separator">/</span>);
}
parts.forEach((part, index) => {
if (index > 0) {
nodes.push(<span key={`${keyPrefix}-sep-${index}`} className="workspace-path-separator">/</span>);
}
nodes.push(<span key={`${keyPrefix}-part-${index}`} className="workspace-path-segment">{part}</span>);
});
return nodes.length > 0 ? nodes : ['-'];
}
const MARKDOWN_SANITIZE_SCHEMA = {
...defaultSchema,
tagNames: [...new Set([...(defaultSchema.tagNames || []), 'audio', 'source', 'video'])],
attributes: {
...defaultSchema.attributes,
audio: [...((defaultSchema.attributes?.audio as string[] | undefined) || []), 'autoplay', 'controls', 'loop', 'muted', 'preload', 'src'],
source: [...((defaultSchema.attributes?.source as string[] | undefined) || []), 'media', 'src', 'type'],
video: [...((defaultSchema.attributes?.video as string[] | undefined) || []), 'autoplay', 'controls', 'height', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'src', 'width'],
},
};
function resolveWorkspaceDocumentPath(targetRaw: string, baseFilePath?: string): string | null {
const target = String(targetRaw || '').trim();
if (!target || target.startsWith('#')) return null;
const linkedPath = parseWorkspaceLink(target);
if (linkedPath) return linkedPath;
if (target.startsWith('/root/.nanobot/workspace/')) {
return normalizeDashboardAttachmentPath(target);
}
const lower = target.toLowerCase();
if (
lower.startsWith('blob:') ||
lower.startsWith('data:') ||
lower.startsWith('http://') ||
lower.startsWith('https://') ||
lower.startsWith('javascript:') ||
lower.startsWith('mailto:') ||
lower.startsWith('tel:') ||
target.startsWith('//')
) {
return null;
}
const normalizedBase = normalizeDashboardAttachmentPath(baseFilePath || '');
if (!normalizedBase) {
return null;
}
try {
const baseUrl = new URL(`https://workspace.local/${normalizedBase}`);
const resolvedUrl = new URL(target, baseUrl);
if (resolvedUrl.origin !== 'https://workspace.local') return null;
try {
return normalizeDashboardAttachmentPath(decodeURIComponent(resolvedUrl.pathname));
} catch {
return normalizeDashboardAttachmentPath(resolvedUrl.pathname);
}
} catch {
return null;
}
}
function decorateWorkspacePathsInPlainChunk(source: string): string {
if (!source) return source;
const protectedLinks: string[] = [];
const withProtectedAbsoluteLinks = source.replace(WORKSPACE_ABS_PATH_PATTERN, (fullPath) => {
const normalized = normalizeDashboardAttachmentPath(fullPath);
if (!normalized) return fullPath;
const token = `@@WS_PATH_LINK_${protectedLinks.length}@@`;
protectedLinks.push(`[${fullPath}](${buildWorkspaceLink(normalized)})`);
return token;
});
const withRelativeLinks = withProtectedAbsoluteLinks.replace(
WORKSPACE_RELATIVE_PATH_PATTERN,
(full, prefix: string, rawPath: string) => {
const normalized = normalizeDashboardAttachmentPath(rawPath);
if (!normalized) return full;
return `${prefix}[${rawPath}](${buildWorkspaceLink(normalized)})`;
},
);
return withRelativeLinks.replace(/@@WS_PATH_LINK_(\d+)@@/g, (_full, idxRaw: string) => {
const idx = Number(idxRaw);
if (!Number.isFinite(idx) || idx < 0 || idx >= protectedLinks.length) return String(_full || '');
return protectedLinks[idx];
});
}
function decorateWorkspacePathsForMarkdown(text: string) {
const source = String(text || '');
if (!source) return source;
// Keep existing Markdown links unchanged; only decorate plain text segments.
const markdownLinkPattern = /\[[^\]]*?\]\((?:[^)(]|\([^)(]*\))*\)/g;
let result = '';
let last = 0;
let match = markdownLinkPattern.exec(source);
while (match) {
const idx = Number(match.index || 0);
if (idx > last) {
result += decorateWorkspacePathsInPlainChunk(source.slice(last, idx));
}
result += match[0];
last = idx + match[0].length;
match = markdownLinkPattern.exec(source);
}
if (last < source.length) {
result += decorateWorkspacePathsInPlainChunk(source.slice(last));
}
return result;
}
function normalizeAttachmentPaths(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((v) => String(v || '').trim().replace(/\\/g, '/'))
.filter((v) => v.length > 0);
}
function normalizeDashboardAttachmentPath(path: string): string {
const v = String(path || '')
.trim()
.replace(/\\/g, '/')
.replace(/^['"`([<{]+/, '')
.replace(/['"`)\]>}.,,。!?;:]+$/, '');
if (!v) return '';
const prefix = '/root/.nanobot/workspace/';
if (v.startsWith(prefix)) return v.slice(prefix.length);
return v.replace(/^\/+/, '');
}
const COMPOSER_DRAFT_STORAGE_PREFIX = 'nanobot-dashboard-composer-draft:v1:';
interface ComposerDraftStorage {
command: string;
attachments: string[];
updated_at_ms: number;
}
function getComposerDraftStorageKey(botId: string): string {
return `${COMPOSER_DRAFT_STORAGE_PREFIX}${String(botId || '').trim()}`;
}
function loadComposerDraft(botId: string): ComposerDraftStorage | null {
const id = String(botId || '').trim();
if (!id || typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(getComposerDraftStorageKey(id));
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<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
}
}
function isExternalHttpLink(href: string): boolean {
return /^https?:\/\//i.test(String(href || '').trim());
}
function parseQuotedReplyBlock(input: string): { quoted: string; body: string } {
const source = String(input || '');
const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i);
const quoted = normalizeAssistantMessageText(match?.[1] || '');
const body = source.replace(/\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]\s*/gi, '').trim();
return { quoted, body };
}
function mergeConversation(messages: ChatMessage[]) {
const merged: ChatMessage[] = [];
messages
.filter((msg) => msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0))
.forEach((msg) => {
const parsedUser = msg.role === 'user' ? parseQuotedReplyBlock(msg.text) : { quoted: '', body: msg.text };
const userQuoted = parsedUser.quoted;
const userBody = parsedUser.body;
const cleanText = msg.role === 'user' ? normalizeUserMessageText(userBody) : normalizeAssistantMessageText(msg.text);
const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean);
if (!cleanText && attachments.length === 0 && !userQuoted) return;
const last = merged[merged.length - 1];
if (last && last.role === msg.role) {
const normalizedLast = last.role === 'user' ? normalizeUserMessageText(last.text) : normalizeAssistantMessageText(last.text);
const normalizedCurrent = msg.role === 'user' ? normalizeUserMessageText(cleanText) : normalizeAssistantMessageText(cleanText);
const lastKind = last.kind || 'final';
const currentKind = msg.kind || 'final';
const sameAttachmentSet =
JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments);
const sameQuoted = normalizeAssistantMessageText(last.quoted_reply || '') === normalizeAssistantMessageText(userQuoted);
if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && sameQuoted && Math.abs(msg.ts - last.ts) < 15000) {
last.ts = msg.ts;
last.id = msg.id || last.id;
if (typeof msg.feedback !== 'undefined') {
last.feedback = msg.feedback;
}
return;
}
}
merged.push({ ...msg, text: cleanText, quoted_reply: userQuoted || undefined, attachments });
});
return merged.slice(-120);
}
function clampTemperature(value: number) {
if (Number.isNaN(value)) return 0.2;
return Math.min(1, Math.max(0, value));
}
function clampMaxTokens(value: number) {
if (Number.isNaN(value)) return 8192;
return Math.min(32768, Math.max(256, Math.round(value)));
}
function clampCpuCores(value: number) {
if (Number.isNaN(value)) return 1;
if (value === 0) return 0;
return Math.min(16, Math.max(0.1, Math.round(value * 10) / 10));
}
function clampMemoryMb(value: number) {
if (Number.isNaN(value)) return 1024;
if (value === 0) return 0;
return Math.min(65536, Math.max(256, Math.round(value)));
}
function clampStorageGb(value: number) {
if (Number.isNaN(value)) return 10;
if (value === 0) return 0;
return Math.min(1024, Math.max(1, Math.round(value)));
}
function formatBytes(bytes: number): string {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const idx = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024)));
const scaled = value / Math.pow(1024, idx);
return `${scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)} ${units[idx]}`;
}
function formatPercent(value: number): string {
const n = Number(value || 0);
if (!Number.isFinite(n)) return '0.00%';
return `${Math.max(0, n).toFixed(2)}%`;
}
function formatWorkspaceTime(raw: string | undefined, isZh: boolean): string {
const text = String(raw || '').trim();
if (!text) return '-';
const dt = new Date(text);
if (Number.isNaN(dt.getTime())) return '-';
try {
return dt.toLocaleString(isZh ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} catch {
return dt.toLocaleString();
}
}
function formatCronSchedule(job: CronJob, isZh: boolean) {
const s = job.schedule || {};
if (s.kind === 'every' && Number(s.everyMs) > 0) {
const sec = Math.round(Number(s.everyMs) / 1000);
return isZh ? `${sec}s` : `every ${sec}s`;
}
if (s.kind === 'cron') {
if (s.tz) return `${s.expr || '-'} (${s.tz})`;
return s.expr || '-';
}
if (s.kind === 'at' && Number(s.atMs) > 0) {
return new Date(Number(s.atMs)).toLocaleString();
}
return '-';
}
export function BotDashboardModule({
onOpenCreateWizard,
onOpenImageFactory,
forcedBotId,
compactMode = false,
}: BotDashboardModuleProps) {
const {
activeBots,
setBots,
mergeBot,
updateBotStatus,
locale,
addBotMessage,
setBotMessages,
setBotMessageFeedback,
} = useAppStore();
const { notify, confirm } = useLucentPrompt();
const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable';
const [selectedBotId, setSelectedBotId] = useState('');
const [command, setCommand] = useState('');
const [speechEnabled, setSpeechEnabled] = useState(true);
const [voiceMaxSeconds, setVoiceMaxSeconds] = useState(20);
const [isVoiceRecording, setIsVoiceRecording] = useState(false);
const [isVoiceTranscribing, setIsVoiceTranscribing] = useState(false);
const [voiceCountdown, setVoiceCountdown] = useState(20);
const [isSaving, setIsSaving] = useState(false);
const [showBaseModal, setShowBaseModal] = useState(false);
const [showParamModal, setShowParamModal] = useState(false);
const [showChannelModal, setShowChannelModal] = useState(false);
const [showTopicModal, setShowTopicModal] = useState(false);
const [showSkillsModal, setShowSkillsModal] = useState(false);
const [showSkillMarketInstallModal, setShowSkillMarketInstallModal] = useState(false);
const [skillAddMenuOpen, setSkillAddMenuOpen] = useState(false);
const [showMcpModal, setShowMcpModal] = useState(false);
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
const [showCronModal, setShowCronModal] = useState(false);
const [showAgentModal, setShowAgentModal] = useState(false);
const [showResourceModal, setShowResourceModal] = useState(false);
const [resourceBotId, setResourceBotId] = useState('');
const [resourceSnapshot, setResourceSnapshot] = useState<BotResourceSnapshot | null>(null);
const [resourceLoading, setResourceLoading] = useState(false);
const [resourceError, setResourceError] = useState('');
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>>({});
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
const [controlStateByBot, setControlStateByBot] = useState<Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>>({});
const chatBottomRef = useRef<HTMLDivElement | null>(null);
const chatScrollRef = useRef<HTMLDivElement | null>(null);
const chatAutoFollowRef = useRef(true);
const [workspaceEntries, setWorkspaceEntries] = useState<WorkspaceNode[]>([]);
const [workspaceSearchEntries, setWorkspaceSearchEntries] = useState<WorkspaceNode[]>([]);
const [workspaceSearchLoading, setWorkspaceSearchLoading] = useState(false);
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);
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
const [workspacePreviewEditing, setWorkspacePreviewEditing] = useState(false);
const [workspacePreviewSaving, setWorkspacePreviewSaving] = useState(false);
const [workspacePreviewDraft, setWorkspacePreviewDraft] = useState('');
const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false);
const [workspaceQuery, setWorkspaceQuery] = useState('');
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
const [composerDraftHydrated, setComposerDraftHydrated] = useState(false);
const [quotedReply, setQuotedReply] = useState<QuotedReply | null>(null);
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null);
const filePickerRef = useRef<HTMLInputElement | null>(null);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const [cronJobs, setCronJobs] = useState<CronJob[]>([]);
const [cronLoading, setCronLoading] = useState(false);
const [cronActionJobId, setCronActionJobId] = useState<string>('');
const [channels, setChannels] = useState<BotChannel[]>([]);
const [expandedChannelByKey, setExpandedChannelByKey] = useState<Record<string, boolean>>({});
const [newChannelPanelOpen, setNewChannelPanelOpen] = useState(false);
const [channelCreateMenuOpen, setChannelCreateMenuOpen] = useState(false);
const channelCreateMenuRef = useRef<HTMLDivElement | null>(null);
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 topicPresetMenuRef = useRef<HTMLDivElement | null>(null);
const [newTopicAdvancedOpen, setNewTopicAdvancedOpen] = useState(false);
const [newTopicKey, setNewTopicKey] = useState('');
const [newTopicName, setNewTopicName] = useState('');
const [newTopicDescription, setNewTopicDescription] = useState('');
const [newTopicPurpose, setNewTopicPurpose] = useState('');
const [newTopicIncludeWhen, setNewTopicIncludeWhen] = useState('');
const [newTopicExcludeWhen, setNewTopicExcludeWhen] = useState('');
const [newTopicExamplesPositive, setNewTopicExamplesPositive] = useState('');
const [newTopicExamplesNegative, setNewTopicExamplesNegative] = useState('');
const [newTopicPriority, setNewTopicPriority] = useState('50');
const [botSkills, setBotSkills] = useState<WorkspaceSkillOption[]>([]);
const [marketSkills, setMarketSkills] = useState<BotSkillMarketItem[]>([]);
const [isSkillUploading, setIsSkillUploading] = useState(false);
const [isMarketSkillsLoading, setIsMarketSkillsLoading] = useState(false);
const [marketSkillInstallingId, setMarketSkillInstallingId] = useState<number | null>(null);
const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
const skillAddMenuRef = useRef<HTMLDivElement | null>(null);
const [envParams, setEnvParams] = useState<BotEnvParams>({});
const [mcpServers, setMcpServers] = useState<MCPServerDraft[]>([]);
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>>({});
const [envDraftKey, setEnvDraftKey] = useState('');
const [envDraftValue, setEnvDraftValue] = useState('');
const [isSavingChannel, setIsSavingChannel] = useState(false);
const [isSavingTopic, setIsSavingTopic] = useState(false);
const [isSavingMcp, setIsSavingMcp] = useState(false);
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
const [isBatchOperating, setIsBatchOperating] = useState(false);
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
const [controlCommandByBot, setControlCommandByBot] = useState<Record<string, string>>({});
const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
sendProgress: false,
sendToolHints: false,
});
const [uploadMaxMb, setUploadMaxMb] = useState(100);
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState<string[]>([]);
const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10));
const [botListPageSizeReady, setBotListPageSizeReady] = useState(
() => readCachedPlatformPageSize(0) > 0,
);
const [chatPullPageSize, setChatPullPageSize] = useState(60);
const [chatHasMore, setChatHasMore] = useState(false);
const [chatLoadingMore, setChatLoadingMore] = useState(false);
const [chatDatePickerOpen, setChatDatePickerOpen] = useState(false);
const [chatDateValue, setChatDateValue] = useState('');
const [chatDateJumping, setChatDateJumping] = useState(false);
const [chatJumpAnchorId, setChatJumpAnchorId] = useState<number | null>(null);
const [chatDatePanelPosition, setChatDatePanelPosition] = useState<{ bottom: number; right: number } | null>(null);
const [workspaceDownloadExtensions, setWorkspaceDownloadExtensions] = useState<string[]>(
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
);
const [runtimeViewMode, setRuntimeViewMode] = useState<RuntimeViewMode>('visual');
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
const [botListMenuOpen, setBotListMenuOpen] = useState(false);
const [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>>({});
const [topicFeedDeleteSavingById, setTopicFeedDeleteSavingById] = useState<Record<number, boolean>>({});
const [topicFeedUnreadCount, setTopicFeedUnreadCount] = useState(0);
const [topicDetailOpen, setTopicDetailOpen] = useState(false);
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat');
const [isCompactMobile, setIsCompactMobile] = useState(false);
const [botListQuery, setBotListQuery] = useState('');
const [botListPage, setBotListPage] = useState(1);
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({});
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [templateTab, setTemplateTab] = useState<'agent' | 'topic'>('agent');
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
const [isSavingTemplates, setIsSavingTemplates] = useState(false);
const [templateAgentText, setTemplateAgentText] = useState('');
const [templateTopicText, setTemplateTopicText] = useState('');
const [controlCommandPanelOpen, setControlCommandPanelOpen] = useState(false);
const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | null>(null);
const botSearchInputName = useMemo(
() => `nbot-search-${Math.random().toString(36).slice(2, 10)}`,
[],
);
const workspaceSearchInputName = useMemo(
() => `nbot-workspace-search-${Math.random().toString(36).slice(2, 10)}`,
[],
);
const voiceRecorderRef = useRef<MediaRecorder | null>(null);
const voiceStreamRef = useRef<MediaStream | null>(null);
const voiceChunksRef = useRef<BlobPart[]>([]);
const voiceTimerRef = useRef<number | null>(null);
const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
const botListMenuRef = useRef<HTMLDivElement | null>(null);
const controlCommandPanelRef = useRef<HTMLDivElement | null>(null);
const chatDateTriggerRef = useRef<HTMLButtonElement | null>(null);
const botOrderRef = useRef<Record<string, number>>({});
const nextBotOrderRef = useRef(1);
const applyEditFormFromBot = useCallback((bot?: any) => {
if (!bot) return;
setProviderTestResult('');
setEditForm({
name: bot.name || '',
access_password: bot.access_password || '',
llm_provider: bot.llm_provider || 'dashscope',
llm_model: bot.llm_model || '',
image_tag: bot.image_tag || '',
api_key: '',
api_base: bot.api_base || '',
temperature: clampTemperature(bot.temperature ?? 0.2),
top_p: bot.top_p ?? 1,
max_tokens: clampMaxTokens(bot.max_tokens ?? 8192),
cpu_cores: clampCpuCores(bot.cpu_cores ?? 1),
memory_mb: clampMemoryMb(bot.memory_mb ?? 1024),
storage_gb: clampStorageGb(bot.storage_gb ?? 10),
agents_md: bot.agents_md || '',
soul_md: bot.soul_md || bot.system_prompt || '',
user_md: bot.user_md || '',
tools_md: bot.tools_md || '',
identity_md: bot.identity_md || '',
});
setParamDraft({
max_tokens: String(clampMaxTokens(bot.max_tokens ?? 8192)),
cpu_cores: String(clampCpuCores(bot.cpu_cores ?? 1)),
memory_mb: String(clampMemoryMb(bot.memory_mb ?? 1024)),
storage_gb: String(clampStorageGb(bot.storage_gb ?? 10)),
});
}, []);
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => {
const query = [`path=${encodeURIComponent(filePath)}`];
if (forceDownload) query.push('download=1');
return `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/download?${query.join('&')}`;
};
const buildWorkspaceRawHref = (filePath: string, forceDownload: boolean = false) => {
const normalized = String(filePath || '')
.trim()
.split('/')
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.join('/');
if (!normalized) return '';
const base = `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/raw/${normalized}`;
return forceDownload ? `${base}?download=1` : base;
};
const buildWorkspacePreviewHref = (filePath: string) => {
const normalized = String(filePath || '').trim();
if (!normalized) return '';
return isHtmlPath(normalized)
? buildWorkspaceRawHref(normalized, false)
: buildWorkspaceDownloadHref(normalized, false);
};
const closeWorkspacePreview = () => {
setWorkspacePreview(null);
setWorkspacePreviewFullscreen(false);
setWorkspacePreviewEditing(false);
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft('');
};
useEffect(() => {
if (!workspacePreview) {
setWorkspacePreviewEditing(false);
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft('');
return;
}
setWorkspacePreviewEditing(false);
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft(workspacePreview.content || '');
}, [workspacePreview?.path, workspacePreview?.content]);
const triggerWorkspaceFileDownload = (filePath: string) => {
if (!selectedBotId) return;
const normalized = String(filePath || '').trim();
if (!normalized) return;
const filename = normalized.split('/').pop() || 'workspace-file';
const link = document.createElement('a');
link.href = buildWorkspaceDownloadHref(normalized, true);
link.download = filename;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
};
const copyWorkspacePreviewUrl = async (filePath: string) => {
const normalized = String(filePath || '').trim();
if (!selectedBotId || !normalized) return;
const hrefRaw = buildWorkspacePreviewHref(normalized);
const href = (() => {
try {
return new URL(hrefRaw, window.location.origin).href;
} catch {
return hrefRaw;
}
})();
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(href);
} else {
const ta = document.createElement('textarea');
ta.value = href;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
notify(t.urlCopied, { tone: 'success' });
} catch {
notify(t.urlCopyFail, { tone: 'error' });
}
};
const copyWorkspacePreviewPath = async (filePath: string) => {
const normalized = String(filePath || '').trim();
if (!normalized) return;
await copyTextToClipboard(
normalized,
isZh ? '文件路径已复制' : 'File path copied',
isZh ? '文件路径复制失败' : 'Failed to copy file path',
);
};
const copyTextToClipboard = async (textRaw: string, successMsg: string, failMsg: string) => {
const text = String(textRaw || '');
if (!text.trim()) return;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
notify(successMsg, { tone: 'success' });
} catch {
notify(failMsg, { tone: 'error' });
}
};
const openWorkspacePathFromChat = async (path: string) => {
const normalized = String(path || '').trim();
if (!normalized) return;
const action = workspaceFileAction(normalized, workspaceDownloadExtensionSet);
if (action === 'download') {
triggerWorkspaceFileDownload(normalized);
return;
}
if (action === 'preview') {
void openWorkspaceFilePreview(normalized);
return;
}
try {
await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/tree`, {
params: { path: normalized },
});
await loadWorkspaceTree(selectedBotId, normalized);
return;
} catch {
if (!isPreviewableWorkspacePath(normalized, workspaceDownloadExtensionSet) || action === 'unsupported') {
notify(fileNotPreviewableLabel, { tone: 'warning' });
return;
}
}
};
const resolveWorkspaceMediaSrc = useCallback((srcRaw: string, baseFilePath?: string): string => {
const src = String(srcRaw || '').trim();
if (!src || !selectedBotId) return src;
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath);
if (resolvedWorkspacePath) {
return buildWorkspacePreviewHref(resolvedWorkspacePath);
}
const lower = src.toLowerCase();
if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) {
return src;
}
return src;
}, [selectedBotId]);
const transformWorkspacePreviewMarkdownUrl = (url: string, key: string): string => {
if (!workspacePreview?.isMarkdown || !selectedBotId) return url;
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(url, workspacePreview.path);
if (!resolvedWorkspacePath) return url;
if (key === 'href') {
return buildWorkspaceLink(resolvedWorkspacePath);
}
return buildWorkspacePreviewHref(resolvedWorkspacePath);
};
const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => {
const source = String(text || '');
if (!source) return [source];
const pattern =
/\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.[a-z0-9][a-z0-9._+-]{0,31}\b|https:\/\/workspace\.local\/open\/[^)\r\n]+/gi;
const nodes: ReactNode[] = [];
let lastIndex = 0;
let matchIndex = 0;
let match = pattern.exec(source);
while (match) {
if (match.index > lastIndex) {
nodes.push(source.slice(lastIndex, match.index));
}
const raw = match[0];
const markdownPath = match[1] ? String(match[1]) : '';
const markdownHref = match[2] ? String(match[2]) : '';
let normalizedPath = '';
let displayText = raw;
if (markdownPath && markdownHref) {
normalizedPath = normalizeDashboardAttachmentPath(markdownPath);
displayText = markdownPath;
} else if (raw.startsWith(WORKSPACE_LINK_PREFIX)) {
normalizedPath = String(parseWorkspaceLink(raw) || '').trim();
displayText = normalizedPath ? `/root/.nanobot/workspace/${normalizedPath}` : raw;
} else if (raw.startsWith('/root/.nanobot/workspace/')) {
normalizedPath = normalizeDashboardAttachmentPath(raw);
displayText = raw;
}
if (normalizedPath) {
nodes.push(
<a
key={`${keyPrefix}-ws-${matchIndex}`}
href="#"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void openWorkspacePathFromChat(normalizedPath);
}}
>
{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;
};
const markdownComponents = useMemo(
() => ({
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
const link = String(href || '').trim();
const workspacePath = parseWorkspaceLink(link);
if (workspacePath) {
return (
<a
href="#"
onClick={(event) => {
event.preventDefault();
void openWorkspacePathFromChat(workspacePath);
}}
{...props}
>
{children}
</a>
);
}
if (isExternalHttpLink(link)) {
return (
<a href={link} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
);
}
return (
<a href={link || '#'} {...props}>
{children}
</a>
);
},
img: ({ src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>) => {
const resolvedSrc = resolveWorkspaceMediaSrc(String(src || ''));
return (
<img
src={resolvedSrc}
alt={String(alt || '')}
loading="lazy"
{...props}
/>
);
},
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>
),
}),
[fileNotPreviewableLabel, notify, resolveWorkspaceMediaSrc, selectedBotId],
);
const [editForm, setEditForm] = useState({
name: '',
access_password: '',
llm_provider: '',
llm_model: '',
image_tag: '',
api_key: '',
api_base: '',
temperature: 0.2,
top_p: 1,
max_tokens: 8192,
cpu_cores: 1,
memory_mb: 1024,
storage_gb: 10,
agents_md: '',
soul_md: '',
user_md: '',
tools_md: '',
identity_md: '',
});
const [paramDraft, setParamDraft] = useState({
max_tokens: '8192',
cpu_cores: '1',
memory_mb: '1024',
storage_gb: '10',
});
useEffect(() => {
const ordered = Object.values(activeBots).sort((a, b) => {
const aCreated = parseBotTimestamp(a.created_at);
const bCreated = parseBotTimestamp(b.created_at);
if (aCreated !== bCreated) return aCreated - bCreated;
return String(a.id || '').localeCompare(String(b.id || ''));
});
ordered.forEach((bot) => {
const id = String(bot.id || '').trim();
if (!id) return;
if (botOrderRef.current[id] !== undefined) return;
botOrderRef.current[id] = nextBotOrderRef.current;
nextBotOrderRef.current += 1;
});
const alive = new Set(ordered.map((bot) => String(bot.id || '').trim()).filter(Boolean));
Object.keys(botOrderRef.current).forEach((id) => {
if (!alive.has(id)) delete botOrderRef.current[id];
});
}, [activeBots]);
const bots = useMemo(
() =>
Object.values(activeBots).sort((a, b) => {
const aId = String(a.id || '').trim();
const bId = String(b.id || '').trim();
const aOrder = botOrderRef.current[aId] ?? Number.MAX_SAFE_INTEGER;
const bOrder = botOrderRef.current[bId] ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) return aOrder - bOrder;
return aId.localeCompare(bId);
}),
[activeBots],
);
const hasForcedBot = Boolean(String(forcedBotId || '').trim());
const compactListFirstMode = compactMode && !hasForcedBot;
const isCompactListPage = compactListFirstMode && !selectedBotId;
const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId);
const showBotListPanel = !hasForcedBot && (!compactMode || isCompactListPage);
const normalizedBotListQuery = botListQuery.trim().toLowerCase();
const filteredBots = useMemo(() => {
if (!normalizedBotListQuery) return bots;
return bots.filter((bot) => {
const id = String(bot.id || '').toLowerCase();
const name = String(bot.name || '').toLowerCase();
return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery);
});
}, [bots, normalizedBotListQuery]);
const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / botListPageSize));
const pagedBots = useMemo(() => {
const page = Math.min(Math.max(1, botListPage), botListTotalPages);
const start = (page - 1) * botListPageSize;
return filteredBots.slice(start, start + botListPageSize);
}, [filteredBots, botListPage, botListTotalPages, botListPageSize]);
const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined;
const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]);
const messages = selectedBot?.messages || [];
const events = selectedBot?.events || [];
const isZh = locale === 'zh';
const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn });
const passwordToggleLabels = isZh
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const activeTopicOptions = useMemo<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]);
const lc = isZh ? channelsZhCn : channelsEn;
const baseImageOptions = useMemo<BaseImageOption[]>(() => {
const imagesByTag = new Map<string, NanobotImage>();
availableImages.forEach((img) => {
const tag = String(img.tag || '').trim();
if (!tag || imagesByTag.has(tag)) return;
imagesByTag.set(tag, img);
});
const options = Array.from(imagesByTag.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([tag, img]) => {
const status = String(img.status || '').toUpperCase() || 'UNKNOWN';
return {
tag,
label: `${tag} · ${status}`,
disabled: status !== 'READY',
};
});
const currentTag = String(editForm.image_tag || '').trim();
if (currentTag && !options.some((opt) => opt.tag === currentTag)) {
options.unshift({
tag: currentTag,
label: isZh ? `${currentTag} · 未登记(只读)` : `${currentTag} · unregistered (read-only)`,
disabled: true,
});
}
return options;
}, [availableImages, editForm.image_tag, isZh]);
const runtimeMoreLabel = isZh ? '更多' : 'More';
const effectiveTopicPresetTemplates = useMemo(
() => (topicPresetTemplates.length > 0 ? topicPresetTemplates : DEFAULT_TOPIC_PRESET_TEMPLATES),
[topicPresetTemplates],
);
const newTopicSourceLabel = useMemo(() => {
if (newTopicSource === 'blank') return t.topicPresetBlank;
const source = effectiveTopicPresetTemplates.find((row) => row.id === newTopicSource);
if (!source) return t.topicPresetBlank;
return resolvePresetText(source.name, isZh ? 'zh-cn' : 'en') || source.topic_key || source.id;
}, [effectiveTopicPresetTemplates, isZh, newTopicSource, t.topicPresetBlank]);
const templateAgentCount = useMemo(() => {
try {
const parsed = JSON.parse(templateAgentText || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 5;
const row = parsed as Record<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]);
const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined;
const selectedBotEnabled = Boolean(selectedBot && selectedBot.enabled !== false);
const isSending = selectedBot ? Boolean(sendingByBot[selectedBot.id]) : false;
const canChat = Boolean(selectedBotEnabled && selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState);
const isChatEnabled = Boolean(canChat && !isSending);
const activeControlCommand = selectedBot ? controlCommandByBot[selectedBot.id] || '' : '';
const canSendControlCommand = Boolean(selectedBot && canChat && !isVoiceRecording && !isVoiceTranscribing);
const conversation = useMemo(() => mergeConversation(messages), [messages]);
const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
const workspaceDownloadExtensionSet = useMemo(
() => new Set(parseWorkspaceDownloadExtensions(workspaceDownloadExtensions)),
[workspaceDownloadExtensions],
);
const workspaceFiles = useMemo(
() => workspaceEntries.filter((v) => v.type === 'file' && isPreviewableWorkspaceFile(v, workspaceDownloadExtensionSet)),
[workspaceEntries, workspaceDownloadExtensionSet],
);
const workspacePathDisplay = workspaceCurrentPath
? `/${String(workspaceCurrentPath || '').replace(/^\/+/, '')}`
: '/';
const normalizedWorkspaceQuery = workspaceQuery.trim().toLowerCase();
const filteredWorkspaceEntries = useMemo(() => {
const sourceEntries = normalizedWorkspaceQuery ? workspaceSearchEntries : workspaceEntries;
if (!normalizedWorkspaceQuery) return sourceEntries;
return sourceEntries.filter((entry) => {
const name = String(entry.name || '').toLowerCase();
const path = String(entry.path || '').toLowerCase();
return name.includes(normalizedWorkspaceQuery) || path.includes(normalizedWorkspaceQuery);
});
}, [workspaceEntries, workspaceSearchEntries, normalizedWorkspaceQuery]);
const addableChannelTypes = useMemo(() => {
const exists = new Set(channels.map((c) => String(c.channel_type).toLowerCase()));
return optionalChannelTypes.filter((t) => !exists.has(t));
}, [channels]);
const envEntries = useMemo(
() =>
Object.entries(envParams || {})
.filter(([k]) => String(k || '').trim().length > 0)
.sort(([a], [b]) => a.localeCompare(b)),
[envParams],
);
const lastUserTs = useMemo(() => [...conversation].reverse().find((m) => m.role === 'user')?.ts || 0, [conversation]);
const lastAssistantFinalTs = useMemo(
() =>
[...conversation].reverse().find((m) => m.role === 'assistant' && (m.kind || 'final') !== 'progress')?.ts || 0,
[conversation],
);
const botUpdatedAtTs = useMemo(() => parseBotTimestamp(selectedBot?.updated_at), [selectedBot?.updated_at]);
const latestRuntimeSignalTs = useMemo(() => {
const latestEventTs = latestEvent?.ts || 0;
return Math.max(latestEventTs, botUpdatedAtTs, lastUserTs);
}, [latestEvent?.ts, botUpdatedAtTs, lastUserTs]);
const hasFreshRuntimeSignal = useMemo(
() => latestRuntimeSignalTs > 0 && Date.now() - latestRuntimeSignalTs < RUNTIME_STALE_MS,
[latestRuntimeSignalTs],
);
const isThinking = useMemo(() => {
if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false;
if (lastUserTs <= 0) return false;
if (lastAssistantFinalTs >= lastUserTs) return false;
return hasFreshRuntimeSignal;
}, [selectedBot, lastUserTs, lastAssistantFinalTs, hasFreshRuntimeSignal]);
const displayState = useMemo(() => {
if (!selectedBot) return 'IDLE';
const backendState = normalizeRuntimeState(selectedBot.current_state);
if (selectedBot.docker_status !== 'RUNNING') return backendState;
if (hasFreshRuntimeSignal && (backendState === 'TOOL_CALL' || backendState === 'THINKING' || backendState === 'ERROR')) {
return backendState;
}
if (isThinking) {
if (latestEvent?.state === 'TOOL_CALL') return 'TOOL_CALL';
return 'THINKING';
}
if (
latestEvent &&
['THINKING', 'TOOL_CALL', 'ERROR'].includes(latestEvent.state) &&
Date.now() - latestEvent.ts < 15000
) {
return latestEvent.state;
}
if (latestEvent?.state === 'ERROR') return 'ERROR';
return 'IDLE';
}, [selectedBot, isThinking, latestEvent, hasFreshRuntimeSignal]);
const runtimeAction = useMemo(() => {
const action = normalizeAssistantMessageText(selectedBot?.last_action || '').trim();
if (action) return action;
const eventText = normalizeAssistantMessageText(latestEvent?.text || '').trim();
if (eventText) return eventText;
return '-';
}, [selectedBot, latestEvent]);
const resourceBot = useMemo(() => bots.find((b) => b.id === resourceBotId), [bots, resourceBotId]);
const hasTopicUnread = topicFeedUnreadCount > 0;
const hideWorkspaceHoverCard = () => setWorkspaceHoverCard(null);
const showWorkspaceHoverCard = (node: WorkspaceNode, anchor: HTMLElement) => {
const rect = anchor.getBoundingClientRect();
const panelHeight = 160;
const panelWidth = 420;
const gap = 8;
const viewportPadding = 8;
const belowSpace = window.innerHeight - rect.bottom;
const aboveSpace = rect.top;
const above = belowSpace < panelHeight && aboveSpace > panelHeight;
const leftRaw = rect.left + 8;
const left = Math.max(viewportPadding, Math.min(leftRaw, window.innerWidth - panelWidth - viewportPadding));
const top = above ? rect.top - gap : rect.bottom + gap;
setWorkspaceHoverCard({ node, top, left, above });
};
const shouldCollapseProgress = (text: string) => {
const normalized = String(text || '').trim();
if (!normalized) return false;
const lines = normalized.split('\n').length;
return lines > 6 || normalized.length > 520;
};
const conversationNodes = useMemo(
() =>
conversation.map((item, idx) => {
const itemKey = `${item.id || item.ts}-${idx}`;
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
const isUserBubble = item.role === 'user';
const fullText = String(item.text || '');
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
const progressCollapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText));
const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : '';
const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0;
const userCollapsible = isUserBubble && userLineCount > 5;
const collapsible = isProgressBubble ? progressCollapsible : userCollapsible;
const expanded = isProgressBubble ? Boolean(expandedProgressByKey[itemKey]) : Boolean(expandedUserByKey[itemKey]);
const displayText = isProgressBubble && !expanded ? summaryText : fullText;
const currentDayKey = new Date(item.ts).toDateString();
const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : '';
const showDateDivider = idx === 0 || currentDayKey !== prevDayKey;
return (
<div
key={itemKey}
data-chat-message-id={item.id ? String(item.id) : undefined}
>
{showDateDivider ? (
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
<span>{formatConversationDate(item.ts, isZh)}</span>
</div>
) : null}
<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" />
</div>
)}
{item.role === 'user' ? (
<div className="ops-chat-hover-actions ops-chat-hover-actions-user">
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => editUserPrompt(item.text)}
tooltip={t.editPrompt}
aria-label={t.editPrompt}
>
<Pencil size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void copyUserPrompt(item.text)}
tooltip={t.copyPrompt}
aria-label={t.copyPrompt}
>
<Copy size={13} />
</LucentIconButton>
</div>
) : null}
<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"
onClick={() => {
if (isProgressBubble) {
setExpandedProgressByKey((prev) => ({
...prev,
[itemKey]: !prev[itemKey],
}));
return;
}
setExpandedUserByKey((prev) => ({
...prev,
[itemKey]: !prev[itemKey],
}));
}}
tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
) : null}
</div>
</div>
<div className={`ops-chat-text ${collapsible && !expanded ? (isUserBubble ? 'is-collapsed-user' : 'is-collapsed') : ''}`}>
{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}
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
</>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents}
>
{decorateWorkspacePathsForMarkdown(displayText)}
</ReactMarkdown>
)
) : null}
{(item.attachments || []).length > 0 ? (
<div className="ops-chat-attachments">
{(item.attachments || []).map((rawPath) => {
const filePath = normalizeDashboardAttachmentPath(rawPath);
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
const filename = filePath.split('/').pop() || filePath;
return (
<a
key={`${item.ts}-${filePath}`}
className="ops-attach-link mono"
href="#"
onClick={(event) => {
event.preventDefault();
void openWorkspacePathFromChat(filePath);
}}
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>
</div>
{item.role === 'user' && (
<div className="ops-avatar user" title={t.user}>
<UserRound size={18} />
</div>
)}
</div>
</div>
);
}),
[
conversation,
expandedProgressByKey,
expandedUserByKey,
feedbackSavingByMessageId,
isZh,
selectedBotId,
t.badReply,
t.copyPrompt,
t.copyReply,
t.quoteReply,
t.quotedReplyLabel,
t.goodReply,
t.user,
t.you,
],
);
useEffect(() => {
setBotListPage(1);
}, [normalizedBotListQuery]);
useEffect(() => {
setBotListPage((prev) => Math.min(Math.max(prev, 1), botListTotalPages));
}, [botListTotalPages]);
useEffect(() => {
const forced = String(forcedBotId || '').trim();
if (forced) {
if (activeBots[forced]) {
if (selectedBotId !== forced) setSelectedBotId(forced);
} else if (selectedBotId) {
setSelectedBotId('');
}
return;
}
if (compactListFirstMode) {
if (selectedBotId && !activeBots[selectedBotId]) {
setSelectedBotId('');
}
return;
}
if (!selectedBotId && bots.length > 0) setSelectedBotId(bots[0].id);
if (selectedBotId && !activeBots[selectedBotId] && bots.length > 0) setSelectedBotId(bots[0].id);
}, [bots, selectedBotId, activeBots, forcedBotId, compactListFirstMode]);
useEffect(() => {
setComposerDraftHydrated(false);
if (!selectedBotId) {
setCommand('');
setPendingAttachments([]);
setComposerDraftHydrated(true);
return;
}
const draft = loadComposerDraft(selectedBotId);
setCommand(draft?.command || '');
setPendingAttachments(draft?.attachments || []);
setComposerDraftHydrated(true);
}, [selectedBotId]);
useEffect(() => {
if (!selectedBotId || !composerDraftHydrated) return;
persistComposerDraft(selectedBotId, command, pendingAttachments);
}, [selectedBotId, composerDraftHydrated, command, pendingAttachments]);
useEffect(() => {
setControlCommandPanelOpen(false);
}, [selectedBotId]);
useEffect(() => {
return () => {
clearVoiceTimer();
try {
if (voiceRecorderRef.current && voiceRecorderRef.current.state !== 'inactive') {
voiceRecorderRef.current.stop();
}
} catch {
// ignore
}
releaseVoiceStream();
};
}, []);
useEffect(() => {
if (!isVoiceRecording && !isVoiceTranscribing) {
setVoiceCountdown(voiceMaxSeconds);
}
}, [voiceMaxSeconds, isVoiceRecording, isVoiceTranscribing]);
useEffect(() => {
const hasDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
if (!hasDraft && !isUploadingAttachments && !isVoiceRecording && !isVoiceTranscribing) return;
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [command, pendingAttachments.length, quotedReply, isUploadingAttachments, isVoiceRecording, isVoiceTranscribing]);
const syncChatScrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
const box = chatScrollRef.current;
if (!box) return;
box.scrollTo({ top: box.scrollHeight, behavior });
}, []);
useEffect(() => {
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
}, [selectedBotId, syncChatScrollToBottom]);
useEffect(() => {
if (!chatAutoFollowRef.current) return;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
}, [conversation.length, syncChatScrollToBottom]);
useEffect(() => {
setQuotedReply(null);
if (isVoiceRecording) {
stopVoiceRecording();
}
}, [selectedBotId]);
useEffect(() => {
const onPointerDown = (event: MouseEvent) => {
if (runtimeMenuRef.current && !runtimeMenuRef.current.contains(event.target as Node)) {
setRuntimeMenuOpen(false);
}
if (botListMenuRef.current && !botListMenuRef.current.contains(event.target as Node)) {
setBotListMenuOpen(false);
}
if (controlCommandPanelRef.current && !controlCommandPanelRef.current.contains(event.target as Node)) {
setChatDatePickerOpen(false);
}
if (channelCreateMenuRef.current && !channelCreateMenuRef.current.contains(event.target as Node)) {
setChannelCreateMenuOpen(false);
}
if (topicPresetMenuRef.current && !topicPresetMenuRef.current.contains(event.target as Node)) {
setTopicPresetMenuOpen(false);
}
if (skillAddMenuRef.current && !skillAddMenuRef.current.contains(event.target as Node)) {
setSkillAddMenuOpen(false);
}
};
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Escape') return;
setChatDatePickerOpen(false);
setChannelCreateMenuOpen(false);
setTopicPresetMenuOpen(false);
setSkillAddMenuOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, []);
useEffect(() => {
setRuntimeMenuOpen(false);
setBotListMenuOpen(false);
}, [selectedBotId]);
useEffect(() => {
setExpandedProgressByKey({});
setExpandedUserByKey({});
setShowRuntimeActionModal(false);
setWorkspaceHoverCard(null);
}, [selectedBotId]);
useEffect(() => {
if (!selectedBotId) return;
let alive = true;
const loadBotDetail = async () => {
try {
const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`);
if (alive) mergeBot(res.data);
} catch (error) {
console.error(`Failed to fetch bot detail for ${selectedBotId}`, error);
}
};
void loadBotDetail();
return () => {
alive = false;
};
}, [selectedBotId, mergeBot]);
useEffect(() => {
if (!workspaceHoverCard) return;
const close = () => setWorkspaceHoverCard(null);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [workspaceHoverCard]);
useEffect(() => {
let alive = true;
const loadSystemDefaults = async () => {
try {
const res = await axios.get<SystemDefaultsResponse>(`${APP_ENDPOINTS.apiBase}/system/defaults`);
if (!alive) return;
const configured = Number(res.data?.limits?.upload_max_mb);
if (Number.isFinite(configured) && configured > 0) {
setUploadMaxMb(Math.max(1, Math.floor(configured)));
}
const configuredPageSize = normalizePlatformPageSize(
res.data?.chat?.page_size,
readCachedPlatformPageSize(10),
);
writeCachedPlatformPageSize(configuredPageSize);
setBotListPageSize(configuredPageSize);
setAllowedAttachmentExtensions(
parseAllowedAttachmentExtensions(res.data?.workspace?.allowed_attachment_extensions),
);
setWorkspaceDownloadExtensions(
parseWorkspaceDownloadExtensions(
res.data?.workspace?.download_extensions,
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
),
);
setTopicPresetTemplates(parseTopicPresets(res.data?.topic_presets));
const speechEnabledRaw = res.data?.speech?.enabled;
if (typeof speechEnabledRaw === 'boolean') {
setSpeechEnabled(speechEnabledRaw);
}
const speechSeconds = Number(res.data?.speech?.max_audio_seconds);
if (Number.isFinite(speechSeconds) && speechSeconds > 0) {
const normalized = Math.max(5, Math.floor(speechSeconds));
setVoiceMaxSeconds(normalized);
setVoiceCountdown(normalized);
}
const pullPageSize = Number(res.data?.chat?.pull_page_size);
if (Number.isFinite(pullPageSize) && pullPageSize > 0) {
setChatPullPageSize(Math.max(10, Math.min(500, Math.floor(pullPageSize))));
}
} catch {
// keep default limit
} finally {
if (alive) {
setBotListPageSizeReady(true);
}
}
};
void loadSystemDefaults();
return () => {
alive = false;
};
}, []);
useEffect(() => {
if (!compactMode) {
setIsCompactMobile(false);
setCompactPanelTab('chat');
return;
}
const media = window.matchMedia('(max-width: 980px)');
const apply = () => setIsCompactMobile(media.matches);
apply();
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}, [compactMode]);
useEffect(() => {
if (!selectedBotId) return;
if (showBaseModal || showParamModal || showAgentModal) return;
applyEditFormFromBot(selectedBot);
}, [
selectedBotId,
selectedBot?.id,
selectedBot?.updated_at,
showBaseModal,
showParamModal,
showAgentModal,
applyEditFormFromBot,
]);
useEffect(() => {
if (!selectedBotId || !selectedBot) {
setGlobalDelivery({ sendProgress: false, sendToolHints: false });
return;
}
setGlobalDelivery({
sendProgress: Boolean(selectedBot.send_progress),
sendToolHints: Boolean(selectedBot.send_tool_hints),
});
}, [selectedBotId, selectedBot?.send_progress, selectedBot?.send_tool_hints]);
const loadImageOptions = async () => {
const [imagesRes] = await Promise.allSettled([axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`)]);
if (imagesRes.status === 'fulfilled') {
setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []);
} else {
setAvailableImages([]);
}
};
const refresh = async () => {
const forced = String(forcedBotId || '').trim();
if (forced) {
const targetId = String(selectedBotId || forced).trim() || forced;
const botRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}`);
setBots(botRes.data ? [botRes.data] : []);
await loadImageOptions();
return;
}
const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`);
setBots(botsRes.data);
await loadImageOptions();
};
const ensureSelectedBotDetail = useCallback(async () => {
if (!selectedBotId) return selectedBot;
try {
const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`);
mergeBot(res.data);
return res.data;
} catch {
return selectedBot;
}
}, [selectedBotId, selectedBot, mergeBot]);
const loadResourceSnapshot = async (botId: string) => {
if (!botId) return;
setResourceLoading(true);
setResourceError('');
try {
const res = await axios.get<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]);
const openWorkspaceFilePreview = async (path: string) => {
if (!selectedBotId || !path) return;
const normalizedPath = String(path || '').trim();
setWorkspacePreviewFullscreen(false);
if (workspaceFileAction(normalizedPath, workspaceDownloadExtensionSet) === 'download') {
triggerWorkspaceFileDownload(normalizedPath);
return;
}
if (isImagePath(normalizedPath)) {
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
setWorkspacePreview({
path: normalizedPath,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: true,
isHtml: false,
isVideo: false,
isAudio: false,
});
return;
}
if (isHtmlPath(normalizedPath)) {
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
setWorkspacePreview({
path: normalizedPath,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: false,
isHtml: true,
isVideo: false,
isAudio: false,
});
return;
}
if (isVideoPath(normalizedPath)) {
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
setWorkspacePreview({
path: normalizedPath,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: false,
isHtml: false,
isVideo: true,
isAudio: false,
});
return;
}
if (isAudioPath(normalizedPath)) {
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
setWorkspacePreview({
path: normalizedPath,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: false,
isHtml: false,
isVideo: false,
isAudio: true,
});
return;
}
setWorkspaceFileLoading(true);
try {
const res = await axios.get<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),
isImage: false,
isHtml: false,
isVideo: false,
isAudio: false,
});
} catch (error: any) {
const msg = error?.response?.data?.detail || t.fileReadFail;
notify(msg, { tone: 'error' });
} finally {
setWorkspaceFileLoading(false);
}
};
const saveWorkspacePreviewMarkdown = async () => {
if (!selectedBotId || !workspacePreview?.isMarkdown) return;
if (workspacePreview.truncated) {
notify(t.fileEditDisabled, { tone: 'warning' });
return;
}
setWorkspacePreviewSaving(true);
try {
const res = await axios.put<WorkspaceFileResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`,
{ content: workspacePreviewDraft },
{ params: { path: workspacePreview.path } },
);
const filePath = res.data.path || workspacePreview.path;
const textExt = (filePath.split('.').pop() || '').toLowerCase();
const content = res.data.content || workspacePreviewDraft;
setWorkspacePreview({
...workspacePreview,
path: filePath,
content,
truncated: false,
ext: textExt ? `.${textExt}` : '',
isMarkdown: textExt === 'md' || textExt === 'markdown' || Boolean(res.data.is_markdown),
});
setWorkspacePreviewEditing(false);
notify(t.fileSaved, { tone: 'success' });
void loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
} catch (error: any) {
notify(error?.response?.data?.detail || t.fileSaveFail, { tone: 'error' });
} finally {
setWorkspacePreviewSaving(false);
}
};
const loadWorkspaceTree = async (botId: string, path: string = '') => {
if (!botId) return;
setWorkspaceLoading(true);
setWorkspaceError('');
try {
const res = await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, {
params: { path },
});
const entries = Array.isArray(res.data?.entries) ? res.data.entries : [];
setWorkspaceEntries(entries);
setWorkspaceSearchEntries([]);
setWorkspaceCurrentPath(res.data?.cwd || '');
setWorkspaceParentPath(res.data?.parent ?? null);
} catch (error: any) {
setWorkspaceEntries([]);
setWorkspaceSearchEntries([]);
setWorkspaceCurrentPath('');
setWorkspaceParentPath(null);
setWorkspaceError(error?.response?.data?.detail || t.workspaceLoadFail);
} finally {
setWorkspaceLoading(false);
}
};
const loadWorkspaceSearchEntries = async (botId: string, path: string = '') => {
if (!botId) return;
const q = String(workspaceQuery || '').trim();
if (!q) {
setWorkspaceSearchEntries([]);
setWorkspaceSearchLoading(false);
return;
}
setWorkspaceSearchLoading(true);
try {
const res = await axios.get<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);
}
};
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: '',
});
};
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;
});
return drafts;
};
const openChannelModal = (botId: string) => {
if (!botId) return;
setExpandedChannelByKey({});
setChannelCreateMenuOpen(false);
setNewChannelPanelOpen(false);
resetNewChannelDraft();
void loadChannels(botId);
setShowChannelModal(true);
};
const openTopicModal = (botId: string) => {
if (!botId) return;
setExpandedTopicByKey({});
setTopicPresetMenuOpen(false);
setNewTopicPanelOpen(false);
resetNewTopicDraft();
void loadTopics(botId);
setShowTopicModal(true);
};
const openMcpModal = async (botId: string) => {
if (!botId) return;
setExpandedMcpByKey({});
setNewMcpPanelOpen(false);
resetNewMcpDraft();
await loadBotMcpConfig(botId);
setShowMcpModal(true);
};
const beginTopicCreate = (presetId: string) => {
setExpandedTopicByKey({});
setTopicPresetMenuOpen(false);
setNewTopicPanelOpen(true);
setNewTopicSource(presetId);
if (presetId === 'blank') {
resetNewTopicDraft();
setNewTopicPanelOpen(true);
setNewTopicSource('blank');
return;
}
applyTopicPreset(presetId, true);
};
const beginChannelCreate = (channelType: ChannelType) => {
setExpandedChannelByKey({});
setChannelCreateMenuOpen(false);
setNewChannelPanelOpen(true);
resetNewChannelDraft(channelType);
};
const beginMcpCreate = () => {
setExpandedMcpByKey({});
setNewMcpPanelOpen(true);
resetNewMcpDraft();
};
const applyTopicPreset = (presetId: string, silent: boolean = false) => {
const preset = effectiveTopicPresetTemplates.find((row) => row.id === presetId);
if (!preset) return;
const localeKey: 'zh-cn' | 'en' = isZh ? 'zh-cn' : 'en';
setNewTopicKey(String(preset.topic_key || '').trim());
setNewTopicName(resolvePresetText(preset.name, localeKey));
setNewTopicDescription(resolvePresetText(preset.description, localeKey));
setNewTopicPurpose(resolvePresetText(preset.routing_purpose, localeKey));
setNewTopicIncludeWhen(normalizePresetTextList(preset.routing_include_when).join('\n'));
setNewTopicExcludeWhen(normalizePresetTextList(preset.routing_exclude_when).join('\n'));
setNewTopicExamplesPositive(normalizePresetTextList(preset.routing_examples_positive).join('\n'));
setNewTopicExamplesNegative(normalizePresetTextList(preset.routing_examples_negative).join('\n'));
setNewTopicPriority(String(Number.isFinite(Number(preset.routing_priority)) ? Number(preset.routing_priority) : 50));
setNewTopicAdvancedOpen(true);
if (!silent) {
notify(isZh ? '主题预设已填充。' : 'Topic preset applied.', { tone: 'success' });
}
};
const topicDraftUiKey = (topic: Pick<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;
});
}
};
const deleteTopicFeedItem = async (item: TopicFeedItem) => {
if (!selectedBot) return;
const targetId = Number(item?.id);
if (!Number.isFinite(targetId) || targetId <= 0) return;
const displayName = String(item?.title || item?.topic_key || targetId).trim() || String(targetId);
const ok = await confirm({
title: t.delete,
message: isZh ? `确认删除这条主题消息?\n${displayName}` : `Delete this Topic item?\n${displayName}`,
tone: 'warning',
});
if (!ok) return;
setTopicFeedDeleteSavingById((prev) => ({ ...prev, [targetId]: true }));
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/topic-items/${targetId}`);
setTopicFeedItems((prev) => prev.filter((row) => Number(row.id) !== targetId));
if (!Boolean(item?.is_read)) {
setTopicFeedUnreadCount((prev) => Math.max(0, prev - 1));
}
notify(isZh ? '主题消息已删除。' : 'Topic item deleted.', { tone: 'success' });
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '删除主题消息失败。' : 'Failed to delete topic item.'), { tone: 'error' });
} finally {
setTopicFeedDeleteSavingById((prev) => {
const next = { ...prev };
delete next[targetId];
return next;
});
}
};
const loadChannels = async (botId: string) => {
if (!botId) return;
const res = await axios.get<BotChannel[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`);
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;
});
};
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 loadMarketSkills = async (botId: string) => {
if (!botId) return;
setIsMarketSkillsLoading(true);
try {
const res = await axios.get<BotSkillMarketItem[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skill-market`);
setMarketSkills(Array.isArray(res.data) ? res.data : []);
} catch (error: any) {
setMarketSkills([]);
notify(error?.response?.data?.detail || t.toolsLoadFail, { tone: 'error' });
} finally {
setIsMarketSkillsLoading(false);
}
};
const loadBotEnvParams = async (botId: string) => {
if (!botId) return;
try {
const res = await axios.get<{ env_params?: Record<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;
});
};
const loadBotMcpConfig = async (botId: string): Promise<MCPServerDraft[]> => {
if (!botId) return [];
try {
const res = await axios.get<MCPConfigResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/mcp-config`);
const drafts = mapMcpResponseToDrafts(res.data);
return applyMcpDrafts(drafts);
} catch {
applyMcpDrafts([]);
return [];
}
};
const saveNewMcpServer = async () => {
const name = String(newMcpDraft.name || '').trim();
const url = String(newMcpDraft.url || '').trim();
if (!name || !url) {
notify(t.mcpDraftRequired, { tone: 'warning' });
return;
}
const nextRow: MCPServerDraft = {
...newMcpDraft,
name,
url,
botId: String(newMcpDraft.botId || '').trim(),
botSecret: String(newMcpDraft.botSecret || '').trim(),
toolTimeout: String(newMcpDraft.toolTimeout || '60').trim() || '60',
headers: { ...(newMcpDraft.headers || {}) },
locked: false,
originName: name,
};
const nextRows = [...persistedMcpServers, nextRow];
const expandedKey = mcpDraftUiKey(nextRow, nextRows.length - 1);
await saveBotMcpConfig(nextRows, { closeDraft: true, expandedKey });
};
const saveSingleMcpServer = async (index: number) => {
const row = mcpServers[index];
if (!row || row.locked) return;
const originName = String(row.originName || row.name || '').trim();
const nextRows = [...persistedMcpServers];
const targetIndex = nextRows.findIndex((candidate) => {
const candidateOrigin = String(candidate.originName || candidate.name || '').trim();
return candidateOrigin && candidateOrigin === originName;
});
if (targetIndex >= 0) {
nextRows[targetIndex] = { ...row };
} else {
nextRows.push({ ...row, originName: originName || String(row.name || '').trim() });
}
const expandedKey = mcpDraftUiKey(row, targetIndex >= 0 ? targetIndex : nextRows.length - 1);
await saveBotMcpConfig(nextRows, { expandedKey });
};
const updateMcpServer = (index: number, patch: Partial<MCPServerDraft>) => {
setMcpServers((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row)));
};
const canRemoveMcpServer = (row?: MCPServerDraft | null) => {
return Boolean(row && !row.locked);
};
const removeMcpServer = async (index: number) => {
const row = mcpServers[index];
if (!canRemoveMcpServer(row)) {
notify(isZh ? '当前 MCP 服务不可删除。' : 'This MCP server cannot be removed.', { tone: 'warning' });
return;
}
const nextRows = mcpServers.filter((_, i) => i !== index);
setMcpServers(nextRows);
setPersistedMcpServers(nextRows);
setExpandedMcpByKey((prev) => {
const next: Record<string, boolean> = {};
nextRows.forEach((server, idx) => {
const key = mcpDraftUiKey(server, idx);
next[key] = typeof prev[key] === 'boolean' ? prev[key] : idx === 0;
});
return next;
});
await saveBotMcpConfig(nextRows);
};
const buildMcpHeaders = (row: MCPServerDraft): Record<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;
};
const saveBotMcpConfig = async (
rows: MCPServerDraft[] = mcpServers,
options?: { closeDraft?: boolean; expandedKey?: string },
) => {
if (!selectedBot) return;
const mcp_servers: Record<string, MCPServerConfig> = {};
for (const row of rows) {
const name = String(row.name || '').trim();
const url = String(row.url || '').trim();
if (!name || !url) continue;
const timeout = Math.max(1, Math.min(600, Number(row.toolTimeout || 60) || 60));
mcp_servers[name] = {
type: row.type === 'sse' ? 'sse' : 'streamableHttp',
url,
headers: buildMcpHeaders(row),
toolTimeout: timeout,
};
}
setIsSavingMcp(true);
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/mcp-config`, { mcp_servers });
if (options?.expandedKey) {
setExpandedMcpByKey({ [options.expandedKey]: true });
}
await loadBotMcpConfig(selectedBot.id);
if (options?.closeDraft) {
setNewMcpPanelOpen(false);
resetNewMcpDraft();
}
notify(t.mcpSaved, { tone: 'success' });
} catch (error: any) {
notify(error?.response?.data?.detail || t.mcpSaveFail, { tone: 'error' });
} finally {
setIsSavingMcp(false);
}
};
const removeBotSkill = async (skill: WorkspaceSkillOption) => {
if (!selectedBot) return;
const ok = await confirm({
title: t.removeSkill,
message: t.toolsRemoveConfirm(skill.name || skill.id),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/${encodeURIComponent(skill.id)}`);
await loadBotSkills(selectedBot.id);
await loadMarketSkills(selectedBot.id);
} catch (error: any) {
notify(error?.response?.data?.detail || t.toolsRemoveFail, { tone: 'error' });
}
};
const installMarketSkill = async (marketSkill: BotSkillMarketItem) => {
if (!selectedBot) return;
const skillId = Number(marketSkill.id);
if (!Number.isFinite(skillId) || skillId <= 0) return;
setMarketSkillInstallingId(skillId);
try {
const res = await axios.post<MarketSkillInstallResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skill-market/${skillId}/install`,
);
setBotSkills(Array.isArray(res.data?.skills) ? res.data.skills : []);
await loadMarketSkills(selectedBot.id);
notify(
isZh
? `已安装技能:${(res.data?.installed || []).join(', ') || marketSkill.display_name || marketSkill.skill_key}`
: `Installed: ${(res.data?.installed || []).join(', ') || marketSkill.display_name || marketSkill.skill_key}`,
{ tone: 'success' },
);
} catch (error: any) {
notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' });
if (selectedBot) {
await loadMarketSkills(selectedBot.id);
}
} finally {
setMarketSkillInstallingId(null);
}
};
const triggerSkillZipUpload = () => {
if (!selectedBot || isSkillUploading) return;
skillZipPickerRef.current?.click();
};
const onPickSkillZip = async (event: ChangeEvent<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);
await loadMarketSkills(selectedBot.id);
} catch (error: any) {
notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' });
} finally {
setIsSkillUploading(false);
event.target.value = '';
}
};
const loadCronJobs = async (botId: string) => {
if (!botId) return;
setCronLoading(true);
try {
const res = await axios.get<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) => {
if (i !== index || c.locked) return c;
return { ...c, ...patch };
}),
);
};
const saveChannel = async (channel: BotChannel) => {
if (!selectedBot || channel.locked || isDashboardChannel(channel)) return;
setIsSavingChannel(true);
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`, {
channel_type: channel.channel_type,
external_app_id: channel.external_app_id,
app_secret: channel.app_secret,
internal_port: Number(channel.internal_port),
is_active: channel.is_active,
extra_config: sanitizeChannelExtra(String(channel.channel_type), channel.extra_config || {}),
});
await loadChannels(selectedBot.id);
notify(t.channelSaved, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || t.channelSaveFail;
notify(msg, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const addChannel = async () => {
if (!selectedBot) return;
const channelType = String(newChannelDraft.channel_type || '').trim().toLowerCase() as ChannelType;
if (!channelType || !addableChannelTypes.includes(channelType)) return;
setIsSavingChannel(true);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels`, {
channel_type: channelType,
is_active: Boolean(newChannelDraft.is_active),
external_app_id: String(newChannelDraft.external_app_id || ''),
app_secret: String(newChannelDraft.app_secret || ''),
internal_port: Number(newChannelDraft.internal_port) || 8080,
extra_config: sanitizeChannelExtra(channelType, newChannelDraft.extra_config || {}),
});
await loadChannels(selectedBot.id);
setNewChannelPanelOpen(false);
resetNewChannelDraft();
} catch (error: any) {
const msg = error?.response?.data?.detail || t.channelAddFail;
notify(msg, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const removeChannel = async (channel: BotChannel) => {
if (!selectedBot || channel.locked || channel.channel_type === 'dashboard') return;
const ok = await confirm({
title: t.channels,
message: t.channelDeleteConfirm(channel.channel_type),
tone: 'warning',
});
if (!ok) return;
setIsSavingChannel(true);
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`);
await loadChannels(selectedBot.id);
} catch (error: any) {
const msg = error?.response?.data?.detail || t.channelDeleteFail;
notify(msg, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const isDashboardChannel = (channel: BotChannel) => String(channel.channel_type).toLowerCase() === 'dashboard';
const parseChannelListValue = (raw: unknown): string => {
if (!Array.isArray(raw)) return '';
return raw
.map((item) => String(item || '').trim())
.filter(Boolean)
.join('\n');
};
const parseChannelListInput = (raw: string): string[] => {
const rows: string[] = [];
String(raw || '')
.split(/[\n,]/)
.forEach((item) => {
const text = String(item || '').trim();
if (text && !rows.includes(text)) rows.push(text);
});
return rows;
};
const isChannelConfigured = (channel: BotChannel): boolean => {
const ctype = String(channel.channel_type || '').trim().toLowerCase();
if (ctype === 'email') {
const extra = channel.extra_config || {};
return Boolean(
String(extra.imapHost || '').trim()
&& String(extra.imapUsername || '').trim()
&& String(extra.imapPassword || '').trim()
&& String(extra.smtpHost || '').trim()
&& String(extra.smtpUsername || '').trim()
&& String(extra.smtpPassword || '').trim(),
);
}
return Boolean(String(channel.external_app_id || '').trim() || String(channel.app_secret || '').trim());
};
const sanitizeChannelExtra = (channelType: string, extra: Record<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),
});
if (selectedBot.docker_status === 'RUNNING') {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/stop`);
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/start`);
}
await refresh();
notify(t.channelSaved, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || t.channelSaveFail;
notify(msg, { tone: 'error' });
} finally {
setIsSavingGlobalDelivery(false);
}
};
const renderChannelFields = (channel: BotChannel, onPatch: (patch: Partial<BotChannel>) => void) => {
const ctype = String(channel.channel_type).toLowerCase();
if (ctype === 'telegram') {
return (
<>
<PasswordInput className="input" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
<input
className="input"
placeholder={lc.proxy}
value={String((channel.extra_config || {}).proxy || '')}
onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })}
autoComplete="off"
/>
<label className="field-label">
<input
type="checkbox"
checked={Boolean((channel.extra_config || {}).replyToMessage)}
onChange={(e) =>
onPatch({ extra_config: { ...(channel.extra_config || {}), replyToMessage: e.target.checked } })
}
style={{ marginRight: 6 }}
/>
{lc.replyToMessage}
</label>
</>
);
}
if (ctype === 'feishu') {
return (
<>
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
<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" />
</>
);
}
if (ctype === 'dingtalk') {
return (
<>
<input className="input" placeholder={lc.clientId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
if (ctype === 'slack') {
return (
<>
<input className="input" placeholder={lc.botToken} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
if (ctype === 'qq') {
return (
<>
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
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>
<PasswordInput className="input" value={String(extra.imapPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</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>
<PasswordInput className="input" value={String(extra.smtpPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</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>
</>
);
}
return null;
};
const openTemplateManager = async () => {
setBotListMenuOpen(false);
setIsLoadingTemplates(true);
try {
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);
} catch {
notify(t.templateLoadFail, { tone: 'error' });
} finally {
setIsLoadingTemplates(false);
}
};
const saveTemplateManager = async (scope: 'agent' | 'topic') => {
let payload: Record<string, unknown>;
try {
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) => bot.enabled !== false && 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) => bot.enabled !== false && 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();
} catch (error: any) {
notify(error?.response?.data?.detail || t.startFail, { tone: 'error' });
} finally {
setOperatingBotId(null);
setControlStateByBot((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
};
const restartBot = async (id: string, status: string) => {
const normalized = String(status || '').toUpperCase();
const ok = await confirm({
title: t.restart,
message: t.restartConfirm(id),
tone: 'warning',
});
if (!ok) return;
setOperatingBotId(id);
try {
if (normalized === 'RUNNING') {
setControlStateByBot((prev) => ({ ...prev, [id]: 'stopping' }));
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`);
updateBotStatus(id, 'STOPPED');
}
setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' }));
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`);
updateBotStatus(id, 'RUNNING');
await refresh();
} catch (error: any) {
notify(error?.response?.data?.detail || t.restartFail, { tone: 'error' });
} finally {
setOperatingBotId(null);
setControlStateByBot((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
};
const setBotEnabled = async (id: string, enabled: boolean) => {
setOperatingBotId(id);
setControlStateByBot((prev) => ({ ...prev, [id]: enabled ? 'enabling' : 'disabling' }));
try {
if (enabled) {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/enable`);
} else {
const ok = await confirm({
title: t.disable,
message: t.disableConfirm(id),
tone: 'warning',
});
if (!ok) return;
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/disable`);
}
await refresh();
notify(enabled ? t.enableDone : t.disableDone, { tone: 'success' });
} catch (error: any) {
notify(error?.response?.data?.detail || (enabled ? t.enableFail : t.disableFail), { tone: 'error' });
} finally {
setOperatingBotId(null);
setControlStateByBot((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
};
const send = async () => {
if (!selectedBot || !canChat || isSending) return;
if (!command.trim() && pendingAttachments.length === 0 && !quotedReply) return;
const text = normalizeUserMessageText(command);
const quoteText = normalizeAssistantMessageText(quotedReply?.text || '');
const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : '';
const payloadCore = text || (pendingAttachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : '');
const payloadText = `${quoteBlock}${payloadCore}`.trim();
if (!payloadText && pendingAttachments.length === 0) return;
try {
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
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);
}
addBotMessage(selectedBot.id, {
role: 'user',
text: payloadText,
attachments: [...pendingAttachments],
ts: Date.now(),
kind: 'final',
});
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
addBotMessage(selectedBot.id, {
role: 'assistant',
text: t.sendFailMsg(msg),
ts: Date.now(),
});
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
notify(msg, { tone: 'error' });
} finally {
setSendingByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
};
const sendControlCommand = async (slashCommand: '/new' | '/restart') => {
if (!selectedBot || !canSendControlCommand) return;
if (activeControlCommand) return;
try {
setControlCommandByBot((prev) => ({ ...prev, [selectedBot.id]: slashCommand }));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: slashCommand },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
if (slashCommand === '/new') {
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
}
setChatDatePickerOpen(false);
setControlCommandPanelOpen(false);
notify(t.controlCommandSent(slashCommand), { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
notify(msg, { tone: 'error' });
} finally {
setControlCommandByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
};
const updateChatDatePanelPosition = useCallback(() => {
const trigger = chatDateTriggerRef.current;
if (!trigger || typeof window === 'undefined') return;
const rect = trigger.getBoundingClientRect();
const viewportPadding = 12;
setChatDatePanelPosition({
bottom: Math.max(viewportPadding, window.innerHeight - rect.top + 8),
right: Math.max(viewportPadding, window.innerWidth - rect.right),
});
}, []);
const toggleChatDatePicker = () => {
if (!selectedBotId || chatDateJumping) return;
if (!chatDateValue) {
const fallbackTs = conversation[conversation.length - 1]?.ts || Date.now();
setChatDateValue(formatDateInputValue(fallbackTs));
}
setChatDatePickerOpen((prev) => {
const next = !prev;
if (!next) {
setChatDatePanelPosition(null);
return next;
}
updateChatDatePanelPosition();
return next;
});
};
const jumpConversationToDate = async () => {
if (!selectedBotId || chatDateJumping) return;
const safeDate = String(chatDateValue || '').trim();
if (!safeDate) {
notify(isZh ? '请选择日期。' : 'Choose a date first.', { tone: 'warning' });
return;
}
try {
setChatDateJumping(true);
const result = await fetchBotMessagesByDate(selectedBotId, safeDate);
if (result.items.length <= 0) {
notify(isZh ? '该日期附近没有可显示的对话。' : 'No conversation found near that date.', { tone: 'warning' });
return;
}
setBotMessages(selectedBotId, result.items);
setChatHasMore(Boolean(result.hasMoreBefore));
setChatLoadingMore(false);
setChatDatePickerOpen(false);
setChatDatePanelPosition(null);
setControlCommandPanelOpen(false);
setChatJumpAnchorId(result.anchorId);
chatAutoFollowRef.current = false;
if (!result.matchedExactDate && result.resolvedTs) {
notify(
isZh
? `所选日期没有消息,已定位到 ${formatConversationDate(result.resolvedTs, true)}`
: `No messages on that date. Jumped to ${formatConversationDate(result.resolvedTs, false)}.`,
{ tone: 'warning' },
);
}
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '按日期读取对话失败。' : 'Failed to load conversation by date.'), {
tone: 'error',
});
} finally {
setChatDateJumping(false);
}
};
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);
}
setChatDatePickerOpen(false);
setControlCommandPanelOpen(false);
notify(t.interruptSent, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
notify(msg, { tone: 'error' });
} finally {
setInterruptingByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
};
const copyUserPrompt = async (text: string) => {
await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail);
};
const editUserPrompt = (text: string) => {
const normalized = normalizeUserMessageText(text);
if (!normalized) return;
setCommand(normalized);
composerTextareaRef.current?.focus();
if (composerTextareaRef.current) {
const caret = normalized.length;
window.requestAnimationFrame(() => {
composerTextareaRef.current?.setSelectionRange(caret, caret);
});
}
notify(t.editPromptDone, { tone: 'success' });
};
const copyAssistantReply = async (text: string) => {
await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail);
};
const quoteAssistantReply = (message: ChatMessage) => {
const content = normalizeAssistantMessageText(message.text);
if (!content) return;
setQuotedReply((prev) => {
if (prev && prev.ts === message.ts && normalizeAssistantMessageText(prev.text) === content) {
return null;
}
return { id: message.id, ts: message.ts, text: content };
});
};
const fetchBotMessages = useCallback(async (botId: string): Promise<ChatMessage[]> => {
const safeLimit = Math.max(100, Math.min(500, chatPullPageSize * 3));
const res = await axios.get<any[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages`, {
params: { limit: safeLimit },
});
const rows = Array.isArray(res.data) ? res.data : [];
return rows
.map((row) => mapBotMessageResponseRow(row))
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0)
.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) => mapBotMessageResponseRow(row))
.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 fetchBotMessagesByDate = useCallback(async (
botId: string,
dateValue: string,
): Promise<{ items: ChatMessage[]; anchorId: number | null; resolvedTs: number | null; matchedExactDate: boolean; hasMoreBefore: boolean }> => {
const safeDate = String(dateValue || '').trim();
if (!safeDate) {
return {
items: [],
anchorId: null,
resolvedTs: null,
matchedExactDate: false,
hasMoreBefore: false,
};
}
const safeLimit = Math.max(40, Math.min(180, chatPullPageSize));
const tzOffsetMinutes = new Date().getTimezoneOffset();
const res = await axios.get<BotMessagesByDateResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages/by-date`, {
params: {
date: safeDate,
tz_offset_minutes: tzOffsetMinutes,
limit: safeLimit,
},
});
const rows = Array.isArray(res.data?.items) ? res.data.items : [];
const items = rows
.map((row) => mapBotMessageResponseRow(row))
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0);
const anchorRaw = Number(res.data?.anchor_id);
const resolvedRaw = Number(res.data?.resolved_ts);
return {
items,
anchorId: Number.isFinite(anchorRaw) && anchorRaw > 0 ? Math.floor(anchorRaw) : null,
resolvedTs: Number.isFinite(resolvedRaw) && resolvedRaw > 0 ? Math.floor(resolvedRaw) : null,
matchedExactDate: Boolean(res.data?.matched_exact_date),
hasMoreBefore: Boolean(res.data?.has_more_before),
};
}, [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]);
useEffect(() => {
if (!selectedBotId || !chatJumpAnchorId) return;
const anchorSelector = `[data-chat-message-id="${chatJumpAnchorId}"]`;
const scrollToAnchor = () => {
const box = chatScrollRef.current;
if (!box) return;
const anchor = box.querySelector<HTMLElement>(anchorSelector);
if (anchor) {
anchor.scrollIntoView({ block: 'start' });
} else {
box.scrollTop = 0;
}
setChatJumpAnchorId(null);
};
const raf = window.requestAnimationFrame(scrollToAnchor);
return () => window.cancelAnimationFrame(raf);
}, [selectedBotId, chatJumpAnchorId, messages.length]);
const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => {
if (!selectedBotId) {
notify(t.feedbackMessagePending, { tone: 'warning' });
return;
}
let targetMessageId = message.id;
if (!targetMessageId) {
try {
const latest = await fetchBotMessages(selectedBotId);
setBotMessages(selectedBotId, latest);
const normalizedTarget = normalizeAssistantMessageText(message.text);
const matched = latest
.filter((m) => m.role === 'assistant' && m.id)
.map((m) => ({ m, diff: Math.abs((m.ts || 0) - (message.ts || 0)) }))
.filter(({ m, diff }) => normalizeAssistantMessageText(m.text) === normalizedTarget && diff <= 10 * 60 * 1000)
.sort((a, b) => a.diff - b.diff)[0]?.m;
if (matched?.id) {
targetMessageId = matched.id;
}
} catch {
// ignore and fallback to warning below
}
}
if (!targetMessageId) {
notify(t.feedbackMessagePending, { tone: 'warning' });
return;
}
if (feedbackSavingByMessageId[targetMessageId]) return;
const nextFeedback: 'up' | 'down' | null = message.feedback === feedback ? null : feedback;
setFeedbackSavingByMessageId((prev) => ({ ...prev, [targetMessageId]: true }));
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/feedback`, { feedback: nextFeedback });
setBotMessageFeedback(selectedBotId, targetMessageId, nextFeedback);
if (nextFeedback === null) {
notify(t.feedbackCleared, { tone: 'success' });
} else {
notify(nextFeedback === 'up' ? t.feedbackUpSaved : t.feedbackDownSaved, { tone: 'success' });
}
} catch (error: any) {
const msg = error?.response?.data?.detail || t.feedbackSaveFail;
notify(msg, { tone: 'error' });
} finally {
setFeedbackSavingByMessageId((prev) => {
const next = { ...prev };
delete next[targetMessageId];
return next;
});
}
};
const onComposerKeyDown = (e: KeyboardEvent<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();
};
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' });
}
};
const onVoiceInput = () => {
if (isVoiceTranscribing) return;
if (isVoiceRecording) {
stopVoiceRecording();
return;
}
void startVoiceRecording();
};
const onPickAttachments = async (event: ChangeEvent<HTMLInputElement>) => {
if (!selectedBot || !event.target.files || event.target.files.length === 0) return;
const files = Array.from(event.target.files);
let effectiveUploadMaxMb = uploadMaxMb;
let effectiveAllowedAttachmentExtensions = [...allowedAttachmentExtensions];
try {
const res = await axios.get<SystemDefaultsResponse>(`${APP_ENDPOINTS.apiBase}/system/defaults`);
const latestUploadMaxMb = Number(res.data?.limits?.upload_max_mb);
if (Number.isFinite(latestUploadMaxMb) && latestUploadMaxMb > 0) {
effectiveUploadMaxMb = Math.max(1, Math.floor(latestUploadMaxMb));
setUploadMaxMb(effectiveUploadMaxMb);
}
const latestAllowedAttachmentExtensions = parseAllowedAttachmentExtensions(
res.data?.workspace?.allowed_attachment_extensions,
);
effectiveAllowedAttachmentExtensions = latestAllowedAttachmentExtensions;
setAllowedAttachmentExtensions(latestAllowedAttachmentExtensions);
} catch {
// Fall back to the most recently loaded defaults in memory.
}
const effectiveAllowedAttachmentExtensionSet = new Set(effectiveAllowedAttachmentExtensions);
if (effectiveAllowedAttachmentExtensionSet.size > 0) {
const disallowed = files.filter((file) => {
const name = String(file.name || '').trim().toLowerCase();
const dot = name.lastIndexOf('.');
const ext = dot >= 0 ? name.slice(dot) : '';
return !ext || !effectiveAllowedAttachmentExtensionSet.has(ext);
});
if (disallowed.length > 0) {
const names = disallowed.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', ');
notify(t.uploadTypeNotAllowed(names, effectiveAllowedAttachmentExtensions.join(', ')), { tone: 'warning' });
event.target.value = '';
return;
}
}
const maxBytes = effectiveUploadMaxMb * 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, effectiveUploadMaxMb), { tone: 'warning' });
event.target.value = '';
return;
}
const mediaFiles: File[] = [];
const normalFiles: File[] = [];
files.forEach((file) => {
if (isMediaUploadFile(file)) {
mediaFiles.push(file);
} else {
normalFiles.push(file);
}
});
const totalBytes = files.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
let uploadedBytes = 0;
const uploadedPaths: string[] = [];
const uploadBatch = async (batchFiles: File[], path: 'media' | 'uploads') => {
if (batchFiles.length === 0) return;
const batchBytes = batchFiles.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
const formData = new FormData();
batchFiles.forEach((file) => formData.append('files', file));
const res = await axios.post<WorkspaceUploadResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/workspace/upload`,
formData,
{
params: { path },
onUploadProgress: (progressEvent) => {
const loaded = Number(progressEvent.loaded || 0);
if (!Number.isFinite(loaded) || loaded < 0) {
setAttachmentUploadPercent(null);
return;
}
if (totalBytes <= 0) {
setAttachmentUploadPercent(null);
return;
}
const cappedLoaded = Math.max(0, Math.min(batchBytes, loaded));
const pct = Math.max(0, Math.min(100, Math.round(((uploadedBytes + cappedLoaded) / totalBytes) * 100)));
setAttachmentUploadPercent(pct);
},
},
);
const uploaded = normalizeAttachmentPaths((res.data?.files || []).map((v) => v.path));
uploadedPaths.push(...uploaded);
uploadedBytes += batchBytes;
if (totalBytes > 0) {
const pct = Math.max(0, Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)));
setAttachmentUploadPercent(pct);
}
};
setIsUploadingAttachments(true);
setAttachmentUploadPercent(0);
try {
await uploadBatch(mediaFiles, 'media');
await uploadBatch(normalFiles, 'uploads');
if (uploadedPaths.length > 0) {
setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploadedPaths])));
await loadWorkspaceTree(selectedBot.id, workspaceCurrentPath);
}
} catch (error: any) {
const msg = error?.response?.data?.detail || t.uploadFail;
notify(msg, { tone: 'error' });
} finally {
setIsUploadingAttachments(false);
setAttachmentUploadPercent(null);
event.target.value = '';
}
};
const onBaseProviderChange = (provider: string) => {
const preset = providerPresets[provider];
setEditForm((p) => ({
...p,
llm_provider: provider,
llm_model: preset?.model || p.llm_model,
api_base: preset?.apiBase ?? p.api_base,
}));
setProviderTestResult('');
};
const testProviderConnection = async () => {
if (!editForm.llm_provider || !editForm.llm_model || !editForm.api_key.trim()) {
notify(t.providerRequired, { tone: 'warning' });
return;
}
setIsTestingProvider(true);
setProviderTestResult('');
try {
const res = await axios.post(`${APP_ENDPOINTS.apiBase}/providers/test`, {
provider: editForm.llm_provider,
model: editForm.llm_model,
api_key: editForm.api_key.trim(),
api_base: editForm.api_base || undefined,
});
if (res.data?.ok) {
const preview = (res.data.models_preview || []).slice(0, 3).join(', ');
setProviderTestResult(t.connOk(preview));
} else {
setProviderTestResult(t.connFail(res.data?.detail || 'unknown error'));
}
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || 'request failed';
setProviderTestResult(t.connFail(msg));
} finally {
setIsTestingProvider(false);
}
};
useEffect(() => {
if (!selectedBotId) {
setChatHasMore(false);
setChatLoadingMore(false);
setChatDatePickerOpen(false);
setChatDateJumping(false);
setChatJumpAnchorId(null);
setChatDateValue('');
setWorkspaceEntries([]);
setWorkspaceCurrentPath('');
setWorkspaceParentPath(null);
setWorkspaceError('');
setChannels([]);
setTopics([]);
setExpandedTopicByKey({});
setNewTopicPanelOpen(false);
setTopicPresetMenuOpen(false);
setNewTopicAdvancedOpen(false);
setNewTopicKey('');
setNewTopicName('');
setNewTopicDescription('');
setNewTopicPurpose('');
setNewTopicIncludeWhen('');
setNewTopicExcludeWhen('');
setNewTopicExamplesPositive('');
setNewTopicExamplesNegative('');
setNewTopicPriority('50');
setNewTopicSource('');
setNewTopicAdvancedOpen(false);
setPendingAttachments([]);
setCronJobs([]);
setBotSkills([]);
setMarketSkills([]);
setShowSkillMarketInstallModal(false);
setSkillAddMenuOpen(false);
setEnvParams({});
setExpandedMcpByKey({});
setNewMcpPanelOpen(false);
resetNewMcpDraft();
setTopicFeedTopicKey('__all__');
setTopicFeedItems([]);
setTopicFeedNextCursor(null);
setTopicFeedError('');
setTopicFeedReadSavingById({});
setTopicFeedDeleteSavingById({});
setTopicFeedUnreadCount(0);
return;
}
setChatHasMore(false);
setChatLoadingMore(false);
setChatDatePickerOpen(false);
setChatDateJumping(false);
setChatJumpAnchorId(null);
setChatDateValue('');
setTopics([]);
setExpandedTopicByKey({});
setNewTopicPanelOpen(false);
setTopicPresetMenuOpen(false);
setNewTopicSource('');
setNewMcpPanelOpen(false);
resetNewMcpDraft();
setTopicFeedTopicKey('__all__');
setTopicFeedItems([]);
setTopicFeedNextCursor(null);
setTopicFeedError('');
setTopicFeedReadSavingById({});
setTopicFeedDeleteSavingById({});
let cancelled = false;
const loadAll = async () => {
try {
if (cancelled) return;
const page = await fetchBotMessagesPage(selectedBotId, { limit: chatPullPageSize });
if (cancelled) return;
setBotMessages(selectedBotId, page.items);
setChatHasMore(Boolean(page.hasMore));
await Promise.all([
loadWorkspaceTree(selectedBotId, ''),
loadCronJobs(selectedBotId),
loadBotSkills(selectedBotId),
loadBotEnvParams(selectedBotId),
loadTopics(selectedBotId),
loadTopicFeedStats(selectedBotId),
]);
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
} catch (error: any) {
const detail = String(error?.response?.data?.detail || '').trim();
if (!cancelled && detail) {
notify(detail, { tone: 'error' });
}
}
};
void loadAll();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBotId, chatPullPageSize, fetchBotMessagesPage, setBotMessages, syncChatScrollToBottom]);
useEffect(() => {
if (!selectedBotId || chatDateValue) return;
const fallbackTs = messages[messages.length - 1]?.ts || Date.now();
setChatDateValue(formatDateInputValue(fallbackTs));
}, [selectedBotId, chatDateValue, messages]);
useEffect(() => {
if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return;
let stopped = false;
const tick = async () => {
if (stopped) return;
await loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
};
void tick();
const timer = window.setInterval(() => {
void tick();
}, 2000);
return () => {
stopped = true;
window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceAutoRefresh, selectedBotId, selectedBot?.docker_status, workspaceCurrentPath]);
useEffect(() => {
if (controlCommandPanelOpen) return;
setChatDatePickerOpen(false);
setChatDatePanelPosition(null);
}, [controlCommandPanelOpen]);
useEffect(() => {
if (chatDatePickerOpen) return;
setChatDatePanelPosition(null);
}, [chatDatePickerOpen]);
useEffect(() => {
if (!chatDatePickerOpen) return;
updateChatDatePanelPosition();
const handleViewportChange = () => updateChatDatePanelPosition();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [chatDatePickerOpen, updateChatDatePanelPosition]);
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]);
useEffect(() => {
setWorkspaceQuery('');
}, [selectedBotId, workspaceCurrentPath]);
useEffect(() => {
if (!selectedBotId) {
setWorkspaceSearchEntries([]);
setWorkspaceSearchLoading(false);
return;
}
if (!workspaceQuery.trim()) {
setWorkspaceSearchEntries([]);
setWorkspaceSearchLoading(false);
return;
}
void loadWorkspaceSearchEntries(selectedBotId, workspaceCurrentPath);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBotId, workspaceCurrentPath, workspaceQuery]);
const saveBot = async (mode: 'params' | 'agent' | 'base') => {
const targetBotId = String(selectedBot?.id || selectedBotId || '').trim();
if (!targetBotId) {
notify(isZh ? '未选中 Bot无法保存。' : 'No bot selected.', { tone: 'warning' });
return;
}
setIsSaving(true);
try {
const payload: Record<string, string | number> = {};
if (mode === 'base') {
payload.name = editForm.name;
payload.access_password = editForm.access_password;
payload.image_tag = editForm.image_tag;
const selectedImageOption = baseImageOptions.find((opt) => opt.tag === editForm.image_tag);
if (selectedImageOption?.disabled) {
throw new Error(isZh ? '当前镜像不可用,请选择可用镜像。' : 'Selected image is unavailable.');
}
const normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores));
const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb));
const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb));
payload.cpu_cores = normalizedCpuCores;
payload.memory_mb = normalizedMemoryMb;
payload.storage_gb = normalizedStorageGb;
setEditForm((p) => ({
...p,
cpu_cores: normalizedCpuCores,
memory_mb: normalizedMemoryMb,
storage_gb: normalizedStorageGb,
}));
setParamDraft((p) => ({
...p,
cpu_cores: String(normalizedCpuCores),
memory_mb: String(normalizedMemoryMb),
storage_gb: String(normalizedStorageGb),
}));
}
if (mode === 'params') {
payload.llm_provider = editForm.llm_provider;
payload.llm_model = editForm.llm_model;
payload.api_base = editForm.api_base;
if (editForm.api_key.trim()) payload.api_key = editForm.api_key.trim();
payload.temperature = clampTemperature(Number(editForm.temperature));
payload.top_p = Number(editForm.top_p);
const normalizedMaxTokens = clampMaxTokens(Number(paramDraft.max_tokens));
payload.max_tokens = normalizedMaxTokens;
setEditForm((p) => ({
...p,
max_tokens: normalizedMaxTokens,
}));
setParamDraft((p) => ({ ...p, max_tokens: String(normalizedMaxTokens) }));
}
if (mode === 'agent') {
payload.agents_md = editForm.agents_md;
payload.soul_md = editForm.soul_md;
payload.user_md = editForm.user_md;
payload.tools_md = editForm.tools_md;
payload.identity_md = editForm.identity_md;
}
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${targetBotId}`, payload);
await refresh();
setShowBaseModal(false);
setShowParamModal(false);
setShowAgentModal(false);
notify(t.configUpdated, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || t.saveFail;
notify(msg, { tone: 'error' });
} finally {
setIsSaving(false);
}
};
const removeBot = async (botId?: string) => {
const targetId = botId || selectedBot?.id;
if (!targetId) return;
const ok = await confirm({
title: t.delete,
message: t.deleteBotConfirm(targetId),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${targetId}`, { params: { delete_workspace: true } });
await refresh();
if (selectedBotId === targetId) setSelectedBotId('');
notify(t.deleteBotDone, { tone: 'success' });
} catch {
notify(t.deleteFail, { tone: 'error' });
}
};
const clearConversationHistory = async () => {
if (!selectedBot) return;
const target = selectedBot.name || selectedBot.id;
const ok = await confirm({
title: t.clearHistory,
message: t.clearHistoryConfirm(target),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/messages`);
setBotMessages(selectedBot.id, []);
notify(t.clearHistoryDone, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || t.clearHistoryFail;
notify(msg, { tone: 'error' });
}
};
const exportConversationJson = () => {
if (!selectedBot) return;
try {
const payload = {
bot_id: selectedBot.id,
bot_name: selectedBot.name || selectedBot.id,
exported_at: new Date().toISOString(),
message_count: conversation.length,
messages: conversation.map((m) => ({
id: m.id || null,
role: m.role,
text: m.text,
attachments: m.attachments || [],
kind: m.kind || 'final',
feedback: m.feedback || null,
ts: m.ts,
datetime: new Date(m.ts).toISOString(),
})),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${selectedBot.id}-conversation-${stamp}.json`;
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
notify(t.exportHistoryFail, { tone: 'error' });
}
};
const tabMap: Record<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} />
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
<span className="workspace-entry-meta">{t.folder}</span>
</button>,
);
return;
}
const previewable = isPreviewableWorkspaceFile(node, workspaceDownloadExtensionSet);
const downloadOnlyFile = workspaceFileAction(node.path, workspaceDownloadExtensionSet) === 'download';
rendered.push(
<button
key={key}
className={`workspace-entry file ${previewable ? '' : 'disabled'}`}
disabled={workspaceFileLoading}
aria-disabled={!previewable || workspaceFileLoading}
onClick={() => {
if (workspaceFileLoading) return;
if (!previewable) return;
void openWorkspaceFilePreview(node.path);
}}
onMouseEnter={(event) => showWorkspaceHoverCard(node, event.currentTarget)}
onMouseLeave={hideWorkspaceHoverCard}
onFocus={(event) => showWorkspaceHoverCard(node, event.currentTarget)}
onBlur={hideWorkspaceHoverCard}
title={previewable ? (downloadOnlyFile ? t.download : t.previewTitle) : t.fileNotPreviewable}
>
<FileText size={14} />
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
<span className="workspace-entry-meta mono">{node.ext || '-'}</span>
</button>,
);
});
return rendered;
};
return (
<>
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}>
{showBotListPanel ? (
<section className="panel stack ops-bot-list">
<div className="row-between">
<h2 style={{ fontSize: 18 }}>
{normalizedBotListQuery
? `${t.titleBots} (${filteredBots.length}/${bots.length})`
: `${t.titleBots} (${bots.length})`}
</h2>
<div className="ops-list-actions" ref={botListMenuRef}>
<LucentIconButton
className="btn btn-primary btn-sm icon-btn"
onClick={onOpenCreateWizard}
tooltip={t.newBot}
aria-label={t.newBot}
>
<Plus size={14} />
</LucentIconButton>
<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}
</div>
</div>
<div className="ops-bot-list-toolbar">
<div className="ops-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={botListQuery}
onChange={(e) => setBotListQuery(e.target.value)}
placeholder={t.botSearchPlaceholder}
aria-label={t.botSearchPlaceholder}
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
inputMode="search"
name={botSearchInputName}
id={botSearchInputName}
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
/>
<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>
</div>
<div className="list-scroll">
{!botListPageSizeReady ? (
<div className="ops-bot-list-empty">{t.syncingPageSize}</div>
) : null}
{botListPageSizeReady
? pagedBots.map((bot) => {
const selected = selectedBotId === bot.id;
const controlState = controlStateByBot[bot.id];
const isOperating = operatingBotId === bot.id;
const isEnabled = bot.enabled !== false;
const isStarting = controlState === 'starting';
const isStopping = controlState === 'stopping';
const isEnabling = controlState === 'enabling';
const isDisabling = controlState === 'disabling';
const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING';
return (
<div
key={bot.id}
className={`ops-bot-card ${selected ? 'is-active' : ''} ${isEnabled ? (isRunning ? 'state-running' : 'state-stopped') : 'state-disabled'}`}
onClick={() => {
setSelectedBotId(bot.id);
if (compactMode) setCompactPanelTab('chat');
}}
>
<span className={`ops-bot-strip ${isEnabled ? (isRunning ? 'is-running' : 'is-stopped') : 'is-disabled'}`} aria-hidden="true" />
<div className="row-between ops-bot-top">
<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"
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>
<div className="mono ops-bot-id">{bot.id}</div>
</div>
<div className="ops-bot-top-actions">
{!isEnabled ? (
<span className="badge badge-err">{t.disabled}</span>
) : null}
<span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
</div>
</div>
<div className="ops-bot-meta">{t.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
<div className="ops-bot-actions">
<label
className="ops-bot-enable-switch"
title={isEnabled ? t.disable : t.enable}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isEnabled}
disabled={isOperating || isEnabling || isDisabling}
onChange={(e) => {
void setBotEnabled(bot.id, e.target.checked);
}}
aria-label={isZh ? '启用/停用' : 'Enable/Disable'}
/>
<span className="ops-bot-enable-switch-track" />
</label>
<div className="ops-bot-actions-main">
<LucentIconButton
className={`btn btn-sm ops-bot-icon-btn ${isRunning ? 'ops-bot-action-stop' : 'ops-bot-action-start'}`}
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
void (isRunning ? stopBot(bot.id, bot.docker_status) : startBot(bot.id, bot.docker_status));
}}
tooltip={isRunning ? t.stop : t.start}
aria-label={isRunning ? t.stop : t.start}
>
{isStarting || isStopping ? (
<span className="ops-control-pending">
<span className="ops-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : isRunning ? <Square size={14} /> : <Power size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
openResourceMonitor(bot.id);
}}
tooltip={isZh ? '资源监测' : 'Resource Monitor'}
aria-label={isZh ? '资源监测' : 'Resource Monitor'}
>
<Gauge size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
void removeBot(bot.id);
}}
tooltip={t.delete}
aria-label={t.delete}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
</div>
);
})
: null}
{botListPageSizeReady && filteredBots.length === 0 ? (
<div className="ops-bot-list-empty">{t.botSearchNoResult}</div>
) : null}
</div>
{botListPageSizeReady ? (
<div className="ops-bot-list-pagination">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-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 pager-status">{t.paginationPage(botListPage, botListTotalPages)}</div>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
onClick={() => setBotListPage((p) => Math.min(botListTotalPages, p + 1))}
disabled={botListPage >= botListTotalPages}
tooltip={t.paginationNext}
aria-label={t.paginationNext}
>
<ChevronRight size={14} />
</LucentIconButton>
</div>
) : null}
</section>
) : null}
<section className={`panel ops-chat-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'chat') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
{selectedBot ? (
<div className="ops-chat-shell">
<div className="ops-main-content-shell">
<div className="ops-main-content-frame">
<div className="ops-main-content-head">
<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>
</div>
</div>
<div className="ops-main-content-body">
{runtimeViewMode === 'topic' ? (
<TopicFeedPanel
isZh={isZh}
topicKey={topicFeedTopicKey}
topicOptions={activeTopicOptions}
topicState={topicPanelState}
items={topicFeedItems}
loading={topicFeedLoading}
loadingMore={topicFeedLoadingMore}
nextCursor={topicFeedNextCursor}
error={topicFeedError}
readSavingById={topicFeedReadSavingById}
deleteSavingById={topicFeedDeleteSavingById}
onTopicChange={setTopicFeedTopicKey}
onRefresh={() => void loadTopicFeed({ append: false, topicKey: topicFeedTopicKey })}
onMarkRead={(itemId) => void markTopicFeedItemRead(itemId)}
onDeleteItem={(item) => void deleteTopicFeedItem(item)}
onLoadMore={() => void loadTopicFeed({ append: true, cursor: topicFeedNextCursor, topicKey: topicFeedTopicKey })}
onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)}
onOpenTopicSettings={() => {
if (selectedBot) openTopicModal(selectedBot.id);
}}
onDetailOpenChange={setTopicDetailOpen}
layout="panel"
/>
) : (
<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}
</div>
) : (
conversationNodes
)}
{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>
</div>
) : 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)}
>
<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
accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined}
onChange={onPickAttachments}
style={{ display: 'none' }}
/>
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || Boolean(interruptingByBot[selectedBot.id])}
onClick={() => void sendControlCommand('/restart')}
aria-label="/restart"
title="/restart"
>
{activeControlCommand === '/restart' ? <RefreshCw size={11} className="animate-spin" /> : <RotateCcw size={11} />}
<span className="mono">/restart</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || Boolean(interruptingByBot[selectedBot.id])}
onClick={() => void sendControlCommand('/new')}
aria-label="/new"
title="/new"
>
{activeControlCommand === '/new' ? <RefreshCw size={11} className="animate-spin" /> : <Plus size={11} />}
<span className="mono">/new</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!selectedBot || !canChat || Boolean(activeControlCommand) || Boolean(interruptingByBot[selectedBot.id])}
onClick={() => void interruptExecution()}
aria-label="/stop"
title="/stop"
>
{interruptingByBot[selectedBot.id] ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
<span className="mono">/stop</span>
</button>
<button
type="button"
className="ops-control-command-chip"
ref={chatDateTriggerRef}
disabled={!selectedBotId || chatDateJumping}
onClick={toggleChatDatePicker}
aria-label={isZh ? '按日期定位对话' : 'Jump to date'}
title={isZh ? '按日期定位对话' : 'Jump to date'}
>
{chatDateJumping ? <RefreshCw size={11} className="animate-spin" /> : <Clock3 size={11} />}
<span className="mono">/time</span>
</button>
</div>
{chatDatePickerOpen ? (
<div
className="ops-control-date-panel"
style={chatDatePanelPosition ? { bottom: chatDatePanelPosition.bottom, right: chatDatePanelPosition.right } : undefined}
>
<label className="ops-control-date-label">
<span>{isZh ? '选择日期' : 'Select date'}</span>
<input
className="input ops-control-date-input"
type="date"
value={chatDateValue}
max={formatDateInputValue(Date.now())}
onChange={(event) => setChatDateValue(event.target.value)}
/>
</label>
<div className="ops-control-date-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setChatDatePickerOpen(false)}
>
{isZh ? '取消' : 'Cancel'}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={chatDateJumping || !chatDateValue}
onClick={() => void jumpConversationToDate()}
>
{chatDateJumping ? <RefreshCw size={14} className="animate-spin" /> : null}
<span style={{ marginLeft: chatDateJumping ? 6 : 0 }}>
{isZh ? '跳转' : 'Jump'}
</span>
</button>
</div>
</div>
) : null}
<button
type="button"
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`}
onClick={() => {
setChatDatePickerOpen(false);
setControlCommandPanelOpen((prev) => !prev);
}}
aria-label={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
title={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
>
{controlCommandPanelOpen ? <Command size={12} /> : <ChevronLeft size={13} />}
</button>
</div>
<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}
<button
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
disabled={!canChat || isVoiceTranscribing}
onClick={onVoiceInput}
aria-label={isVoiceRecording ? t.voiceStop : t.voiceStart}
title={
isVoiceTranscribing
? t.voiceTranscribing
: isVoiceRecording
? t.voiceStop
: t.voiceStart
}
>
{isVoiceTranscribing ? (
<RefreshCw size={16} className="animate-spin" />
) : isVoiceRecording ? (
<Square size={16} />
) : (
<Mic size={16} />
)}
</button>
<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>
</div>
</div>
{!canChat ? (
<div className="ops-chat-disabled-mask">
<div className="ops-chat-disabled-card">
{selectedBotControlState === 'starting'
? t.botStarting
: selectedBotControlState === 'stopping'
? t.botStopping
: !selectedBotEnabled
? t.botDisabledHint
: t.chatDisabled}
</div>
</div>
) : null}
</div>
)}
</div>
</div>
</div>
</div>
) : (
<div style={{ color: 'var(--muted)' }}>
{forcedBotMissing
? `${t.selectBot}: ${String(forcedBotId).trim()}`
: t.selectBot}
</div>
)}
</section>
<section className={`panel stack ops-runtime-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'runtime') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
{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}>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void restartBot(selectedBot.id, selectedBot.docker_status)}
disabled={operatingBotId === selectedBot.id || !selectedBotEnabled}
tooltip={t.restart}
aria-label={t.restart}
>
<RotateCcw size={14} className={operatingBotId === selectedBot.id ? 'animate-spin' : ''} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setRuntimeMenuOpen((v) => !v)}
disabled={!selectedBotEnabled}
tooltip={runtimeMoreLabel}
aria-label={runtimeMoreLabel}
aria-haspopup="menu"
aria-expanded={runtimeMenuOpen}
>
<EllipsisVertical size={14} />
</LucentIconButton>
{runtimeMenuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={runtimeMoreLabel}>
<>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
void (async () => {
const detail = await ensureSelectedBotDetail();
applyEditFormFromBot(detail);
setProviderTestResult('');
setShowBaseModal(true);
})();
}}
>
<Settings2 size={14} />
<span>{t.base}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
void (async () => {
const detail = await ensureSelectedBotDetail();
applyEditFormFromBot(detail);
setShowParamModal(true);
})();
}}
>
<SlidersHorizontal size={14} />
<span>{t.params}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
if (!selectedBot) return;
openChannelModal(selectedBot.id);
}}
>
<Waypoints size={14} />
<span>{t.channels}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
if (!selectedBot) return;
openTopicModal(selectedBot.id);
}}
>
<MessageSquareText size={14} />
<span>{t.topic}</span>
</button>
<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>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
if (!selectedBot) return;
openMcpModal(selectedBot.id);
}}
>
<Boxes size={14} />
<span>{t.mcp}</span>
</button>
<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);
void (async () => {
const detail = await ensureSelectedBotDetail();
applyEditFormFromBot(detail);
setShowAgentModal(true);
})();
}}
>
<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
className="card ops-runtime-card ops-runtime-state-card is-visual"
>
<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" />
</div>
<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>
</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">
<div className="workspace-path-wrap">
<div className="workspace-path mono" title={workspacePathDisplay}>
{workspacePathDisplay}
</div>
</div>
<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>
</div>
<div className="workspace-search-toolbar">
<div className="ops-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={workspaceQuery}
onChange={(e) => setWorkspaceQuery(e.target.value)}
placeholder={t.workspaceSearchPlaceholder}
aria-label={t.workspaceSearchPlaceholder}
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
inputMode="search"
name={workspaceSearchInputName}
id={workspaceSearchInputName}
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
/>
<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>
<div className="workspace-panel">
<div className="workspace-list">
{workspaceLoading || workspaceSearchLoading ? (
<div className="ops-empty-inline">{t.loadingDir}</div>
) : renderWorkspaceNodes(filteredWorkspaceEntries).length === 0 ? (
<div className="ops-empty-inline">{normalizedWorkspaceQuery ? t.workspaceSearchNoResult : t.emptyDir}</div>
) : (
renderWorkspaceNodes(filteredWorkspaceEntries)
)}
</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>
{showCompactBotPageClose ? (
<LucentIconButton
className="ops-compact-close-btn"
onClick={() => {
setSelectedBotId('');
setCompactPanelTab('chat');
}}
tooltip={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
aria-label={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
>
<X size={16} />
</LucentIconButton>
) : null}
{compactMode && !isCompactListPage ? (
<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}
{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">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void loadResourceSnapshot(resourceBotId)}
tooltip={isZh ? '立即刷新' : 'Refresh now'}
aria-label={isZh ? '立即刷新' : 'Refresh now'}
>
<RefreshCw size={14} className={resourceLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setShowResourceModal(false)}
tooltip={t.close}
aria-label={t.close}
>
<X size={14} />
</LucentIconButton>
</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>
<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>
<div>{isZh ? '采样时间' : 'Collected'}: <span className="mono">{resourceSnapshot.collected_at}</span></div>
<div>{isZh ? '策略说明' : 'Policy'}: <strong>{isZh ? '资源值 0 = 不限制' : 'Value 0 = Unlimited'}</strong></div>
</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>
)}
{showBaseModal && (
<div className="modal-mask" onClick={() => setShowBaseModal(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.baseConfig}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowBaseModal(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</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} />
<label className="field-label">{t.accessPassword}</label>
<PasswordInput
className="input"
value={editForm.access_password}
onChange={(e) => setEditForm((p) => ({ ...p, access_password: e.target.value }))}
placeholder={t.accessPasswordPlaceholder}
toggleLabels={passwordToggleLabels}
/>
<label className="field-label">{t.baseImageReadonly}</label>
<LucentSelect
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>
))}
</LucentSelect>
<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>
<label className="field-label">Provider</label>
<LucentSelect value={editForm.llm_provider} onChange={(e) => onBaseProviderChange(e.target.value)}>
<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>
<option value="xunfei">xunfei (spark)</option>
</LucentSelect>
<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>
<PasswordInput className="input" value={editForm.api_key} onChange={(e) => setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} toggleLabels={passwordToggleLabels} />
<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>
<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>
<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 && (
<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()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{lc.wizardSectionTitle}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
setShowChannelModal(false);
setChannelCreateMenuOpen(false);
setNewChannelPanelOpen(false);
resetNewChannelDraft();
}}
tooltip={t.close}
aria-label={t.close}
>
<X size={14} />
</LucentIconButton>
</div>
</div>
<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>
<LucentIconButton
className="btn btn-primary btn-sm icon-btn"
disabled={isSavingGlobalDelivery || !selectedBot}
onClick={() => void saveGlobalDelivery()}
tooltip={lc.saveChannel}
aria-label={lc.saveChannel}
>
<Save size={14} />
</LucentIconButton>
</div>
</div>
<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;
const hasCredential = isChannelConfigured(channel);
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" ref={channelCreateMenuRef}>
<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-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>
<div className="row-between ops-config-footer">
<span className="field-label">{t.topicAddHint}</span>
<button className="btn btn-primary btn-sm" disabled={isSavingTopic} onClick={() => void saveTopic(topic)}>
{isSavingTopic ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
<span style={{ marginLeft: 6 }}>{t.save}</span>
</button>
</div>
</>
) : null}
</div>
);
})
)}
</div>
{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" ref={topicPresetMenuRef}>
<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}
</div>
</div>
)}
{showSkillsModal && (
<div className="modal-mask" onClick={() => {
setSkillAddMenuOpen(false);
setShowSkillsModal(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.skillsPanel}</h3>
<span className="modal-sub">
{isZh ? '查看当前 Bot 已安装的技能。' : 'View the skills already installed for this bot.'}
</span>
</div>
<div className="modal-title-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
if (!selectedBot) return;
void loadBotSkills(selectedBot.id);
}}
tooltip={isZh ? '刷新已安装技能' : 'Refresh installed skills'}
aria-label={isZh ? '刷新已安装技能' : 'Refresh installed skills'}
>
<RefreshCw size={14} />
</LucentIconButton>
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => {
setSkillAddMenuOpen(false);
setShowSkillsModal(false);
}} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="stack">
<div className="row-between">
<div>
<div className="section-mini-title">{isZh ? '已安装技能' : 'Installed Skills'}</div>
<div className="field-label">
{isZh ? '这里展示当前 Bot 工作区中的技能。' : 'These skills are already present in the bot workspace.'}
</div>
</div>
<div className="field-label">
{isZh ? `${botSkills.length} 个已安装` : `${botSkills.length} installed`}
</div>
</div>
<div className="wizard-channel-list ops-skills-list-scroll">
{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>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => void removeBotSkill(skill)}
tooltip={t.removeSkill}
aria-label={t.removeSkill}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
))
)}
</div>
</div>
<div className="ops-skill-add-bar">
<input
ref={skillZipPickerRef}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
onChange={onPickSkillZip}
style={{ display: 'none' }}
/>
<div className="field-label ops-skill-add-hint">
{isSkillUploading
? (isZh ? '正在上传 ZIP 技能包...' : 'Uploading ZIP skill package...')
: (isZh ? '支持上传本地 ZIP或从技能市场安装技能到当前 Bot。' : 'Upload a local ZIP or install a skill from the marketplace into this bot.')}
</div>
<div className="ops-topic-create-menu-wrap" ref={skillAddMenuRef}>
<button
type="button"
className="btn btn-secondary btn-sm ops-skill-create-trigger"
onClick={() => setSkillAddMenuOpen((prev) => !prev)}
disabled={!selectedBot}
>
<Plus size={18} />
<span>{isZh ? '新增技能' : 'Add Skill'}</span>
</button>
{skillAddMenuOpen ? (
<div className="ops-topic-create-menu">
<button
className="ops-topic-create-menu-item"
type="button"
onClick={() => {
setSkillAddMenuOpen(false);
triggerSkillZipUpload();
}}
>
{isZh ? '本地上传 ZIP' : 'Upload Local ZIP'}
</button>
<button
className="ops-topic-create-menu-item"
type="button"
onClick={() => {
if (!selectedBot) return;
setSkillAddMenuOpen(false);
void loadMarketSkills(selectedBot.id);
setShowSkillMarketInstallModal(true);
}}
>
{isZh ? '从技能市场安装' : 'Install From Marketplace'}
</button>
</div>
) : null}
</div>
</div>
</div>
</div>
)}
<SkillMarketInstallModal
isZh={isZh}
open={showSkillMarketInstallModal}
items={marketSkills}
loading={isMarketSkillsLoading}
installingId={marketSkillInstallingId}
onClose={() => setShowSkillMarketInstallModal(false)}
onRefresh={async () => {
if (!selectedBot) return;
await loadMarketSkills(selectedBot.id);
}}
onInstall={async (skill) => {
await installMarketSkill(skill);
if (selectedBot) {
await loadBotSkills(selectedBot.id);
}
}}
formatBytes={formatBytes}
/>
{showMcpModal && (
<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()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.mcpPanel}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
setShowMcpModal(false);
setNewMcpPanelOpen(false);
resetNewMcpDraft();
}}
tooltip={t.close}
aria-label={t.close}
>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="field-label" style={{ marginBottom: 8 }}>{t.mcpPanelDesc}</div>
<div className="wizard-channel-list ops-config-list-scroll">
{mcpServers.length === 0 ? (
<div className="ops-empty-inline">{t.mcpEmpty}</div>
) : (
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">
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingMcp || !canRemoveMcpServer(row)}
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>
</div>
{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>
<PasswordInput
className="input"
value={row.botSecret}
placeholder={t.mcpBotSecretPlaceholder}
onChange={(e) => updateMcpServer(idx, { botSecret: e.target.value })}
autoComplete="new-password"
disabled={row.locked}
toggleLabels={passwordToggleLabels}
/>
</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}
</div>
);
})
)}
</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">
<label className="field-label">{t.mcpName}</label>
<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">
<label className="field-label">{t.mcpType}</label>
<LucentSelect
value={newMcpDraft.type}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, type: e.target.value as 'streamableHttp' | 'sse' }))}
>
<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={newMcpDraft.url}
placeholder={t.mcpUrlPlaceholder}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, url: e.target.value }))}
autoComplete="off"
/>
</div>
<div className="ops-config-field">
<label className="field-label">X-Bot-Id</label>
<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">
<label className="field-label">X-Bot-Secret</label>
<PasswordInput
className="input"
value={newMcpDraft.botSecret}
placeholder={t.mcpBotSecretPlaceholder}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, botSecret: e.target.value }))}
autoComplete="new-password"
toggleLabels={passwordToggleLabels}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{t.mcpToolTimeout}</label>
<input
className="input mono"
type="number"
min="1"
max="600"
value={newMcpDraft.toolTimeout}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, toolTimeout: e.target.value }))}
/>
</div>
</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}
</div>
</div>
)}
{showEnvParamsModal && (
<div className="modal-mask" onClick={() => setShowEnvParamsModal(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.envParams}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowEnvParamsModal(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<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 }} />
<PasswordInput
className="input"
value={value}
onChange={(e) => upsertEnvParam(key, e.target.value)}
placeholder={t.envValue}
autoComplete="off"
wrapperClassName="is-inline"
toggleLabels={{
show: t.showEnvValue,
hide: t.hideEnvValue,
}}
/>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => removeEnvParam(key)}
tooltip={t.removeEnvParam}
aria-label={t.removeEnvParam}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
))
)}
</div>
<div className="row-between">
<input
className="input mono"
value={envDraftKey}
onChange={(e) => setEnvDraftKey(e.target.value.toUpperCase())}
placeholder={t.envKey}
autoComplete="off"
/>
<PasswordInput
className="input"
value={envDraftValue}
onChange={(e) => setEnvDraftValue(e.target.value)}
placeholder={t.envValue}
autoComplete="off"
wrapperClassName="is-inline"
toggleLabels={{
show: t.showEnvValue,
hide: t.hideEnvValue,
}}
/>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
const key = String(envDraftKey || '').trim().toUpperCase();
if (!key) return;
upsertEnvParam(key, envDraftValue);
setEnvDraftKey('');
setEnvDraftValue('');
}}
tooltip={t.addEnvParam}
aria-label={t.addEnvParam}
>
<Plus size={14} />
</LucentIconButton>
</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()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.cronViewer}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => selectedBot && void loadCronJobs(selectedBot.id)}
tooltip={t.cronReload}
aria-label={t.cronReload}
disabled={cronLoading}
>
<RefreshCw size={14} className={cronLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowCronModal(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
{cronLoading ? (
<div className="ops-empty-inline">{t.cronLoading}</div>
) : cronJobs.length === 0 ? (
<div className="ops-empty-inline">{t.cronEmpty}</div>
) : (
<div className="ops-cron-list ops-cron-list-scroll">
{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">
<LucentIconButton
className="btn btn-danger btn-sm icon-btn"
onClick={() => void stopCronJob(job.id)}
tooltip={t.cronStop}
aria-label={t.cronStop}
disabled={stopping || job.enabled === false}
>
<PowerOff size={13} />
</LucentIconButton>
<LucentIconButton
className="btn btn-danger btn-sm icon-btn"
onClick={() => void deleteCronJob(job.id)}
tooltip={t.cronDelete}
aria-label={t.cronDelete}
disabled={stopping}
>
<Trash2 size={13} />
</LucentIconButton>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
)}
{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>
)}
{showAgentModal && (
<div className="modal-mask" onClick={() => setShowAgentModal(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.agentFiles}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowAgentModal(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<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>
)}
{showRuntimeActionModal && (
<div className="modal-mask" onClick={() => setShowRuntimeActionModal(false)}>
<div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.lastAction}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowRuntimeActionModal(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="workspace-preview-body">
<pre>{runtimeAction}</pre>
</div>
</div>
</div>
)}
{workspacePreview && (
<div className="modal-mask" onClick={closeWorkspacePreview}>
<div className={`modal-card modal-preview ${workspacePreviewFullscreen ? 'modal-preview-fullscreen' : ''}`} onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row workspace-preview-header">
<div className="workspace-preview-header-text">
<h3>{t.filePreview}</h3>
<span className="modal-sub mono workspace-preview-path-row">
<span className="workspace-path-segments" title={workspacePreview.path}>
{renderWorkspacePathSegments(workspacePreview.path, 'preview-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>
</div>
<div className="workspace-preview-header-actions">
{workspacePreview.isMarkdown ? (
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
if (workspacePreview.truncated) {
notify(t.fileEditDisabled, { tone: 'warning' });
return;
}
setWorkspacePreviewEditing((value) => !value);
}}
tooltip={workspacePreviewEditing ? t.previewMode : t.editFile}
aria-label={workspacePreviewEditing ? t.previewMode : t.editFile}
>
{workspacePreviewEditing ? <Eye size={14} /> : <Pencil size={14} />}
</LucentIconButton>
) : null}
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setWorkspacePreviewFullscreen((v) => !v)}
tooltip={workspacePreviewFullscreen ? (isZh ? '退出全屏' : 'Exit full screen') : (isZh ? '全屏预览' : 'Full screen')}
aria-label={workspacePreviewFullscreen ? (isZh ? '退出全屏' : 'Exit full screen') : (isZh ? '全屏预览' : 'Full screen')}
>
{workspacePreviewFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={closeWorkspacePreview}
tooltip={t.close}
aria-label={t.close}
>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div
className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''} ${workspacePreviewEditing ? 'is-editing' : ''} ${workspacePreview.isImage || workspacePreview.isVideo || workspacePreview.isAudio ? 'media' : ''}`}
>
{workspacePreview.isImage ? (
<img
className="workspace-preview-image"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
/>
) : 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"
/>
) : workspacePreview.isHtml ? (
<iframe
className="workspace-preview-embed"
src={buildWorkspaceRawHref(workspacePreview.path, false)}
title={workspacePreview.path}
/>
) : workspacePreview.isMarkdown && workspacePreviewEditing ? (
<textarea
className="textarea md-area mono workspace-preview-editor"
value={workspacePreviewDraft}
onChange={(event) => setWorkspacePreviewDraft(event.target.value)}
spellCheck={false}
/>
) : workspacePreview.isMarkdown ? (
<div className="workspace-markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents}
urlTransform={transformWorkspacePreviewMarkdownUrl}
>
{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 }}>
{workspacePreview.isMarkdown && workspacePreviewEditing ? (
<>
<button
className="btn btn-secondary"
onClick={() => {
setWorkspacePreviewDraft(workspacePreview.content || '');
setWorkspacePreviewEditing(false);
}}
disabled={workspacePreviewSaving}
>
{t.cancel}
</button>
<button
className="btn btn-primary"
onClick={() => void saveWorkspacePreviewMarkdown()}
disabled={workspacePreviewSaving}
>
{workspacePreviewSaving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
<span style={{ marginLeft: 6 }}>{t.save}</span>
</button>
</>
) : null}
{workspacePreview.isHtml ? (
<button
className="btn btn-secondary"
onClick={() => void copyWorkspacePreviewUrl(workspacePreview.path)}
>
{t.copyAddress}
</button>
) : (
<a
className="btn btn-secondary"
href={buildWorkspaceDownloadHref(workspacePreview.path, true)}
target="_blank"
rel="noopener noreferrer"
download={workspacePreview.path.split('/').pop() || 'workspace-file'}
>
{t.download}
</a>
)}
</div>
</div>
</div>
</div>
)}
{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>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '完整路径' : 'Full Path'}</span>
<span
className="workspace-entry-info-value workspace-entry-info-path mono"
title={`/root/.nanobot/workspace/${String(workspaceHoverCard.node.path || '').replace(/^\/+/, '')}`}
>
{renderWorkspacePathSegments(
`/root/.nanobot/workspace/${String(workspaceHoverCard.node.path || '').replace(/^\/+/, '')}`,
'hover-path',
)}
</span>
</div>
<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}
</>
);
}