7900 lines
334 KiB
TypeScript
7900 lines
334 KiB
TypeScript
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': 'Kimi(Moonshot)接口,模型示例 moonshot-v1-8k',
|
||
en: 'Kimi (Moonshot) endpoint, model example: moonshot-v1-8k',
|
||
},
|
||
},
|
||
minimax: {
|
||
model: 'MiniMax-Text-01',
|
||
apiBase: 'https://api.minimax.chat/v1',
|
||
note: {
|
||
'zh-cn': 'MiniMax 接口,模型示例 MiniMax-Text-01',
|
||
en: 'MiniMax endpoint, model example: MiniMax-Text-01',
|
||
},
|
||
},
|
||
xunfei: {
|
||
model: 'astron-code-latest',
|
||
apiBase: 'https://spark-api-open.xf-yun.com/v1',
|
||
note: {
|
||
'zh-cn': '讯飞星火(OpenAI 兼容)接口,模型示例 astron-code-latest',
|
||
en: 'Xunfei Spark (OpenAI-compatible), model example: astron-code-latest',
|
||
},
|
||
},
|
||
};
|
||
const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack', 'email'];
|
||
const RUNTIME_STALE_MS = 45000;
|
||
const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']);
|
||
|
||
function formatClock(ts: number) {
|
||
const d = new Date(ts);
|
||
const hh = String(d.getHours()).padStart(2, '0');
|
||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||
return `${hh}:${mm}:${ss}`;
|
||
}
|
||
|
||
function formatConversationDate(ts: number, isZh: boolean) {
|
||
const d = new Date(ts);
|
||
try {
|
||
return d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
weekday: 'short',
|
||
});
|
||
} catch {
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
}
|
||
|
||
function formatDateInputValue(ts: number): string {
|
||
const d = new Date(ts);
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
const year = d.getFullYear();
|
||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
function mapBotMessageResponseRow(row: any): ChatMessage {
|
||
const roleRaw = String(row?.role || '').toLowerCase();
|
||
const role: ChatMessage['role'] =
|
||
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
|
||
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
|
||
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
|
||
return {
|
||
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
|
||
role,
|
||
text: String(row?.text || ''),
|
||
attachments: normalizeAttachmentPaths(row?.media),
|
||
ts: Number(row?.ts || Date.now()),
|
||
feedback,
|
||
kind: 'final',
|
||
} as ChatMessage;
|
||
}
|
||
|
||
function stateLabel(s?: string) {
|
||
return (s || 'IDLE').toUpperCase();
|
||
}
|
||
|
||
function resolvePresetText(raw: unknown, locale: 'zh-cn' | 'en'): string {
|
||
if (typeof raw === 'string') return raw.trim();
|
||
if (!raw || typeof raw !== 'object') return '';
|
||
const bag = raw as Record<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}
|
||
</>
|
||
);
|
||
}
|