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

4259 lines
176 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

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

import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
import axios from 'axios';
import { Activity, ArrowUp, Boxes, Check, ChevronLeft, ChevronRight, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Maximize2, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Repeat2, Reply, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { APP_ENDPOINTS } from '../../config/env';
import { useAppStore } from '../../store/appStore';
import type { ChatMessage } from '../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from './messageParser';
import nanobotLogo from '../../assets/nanobot-logo.png';
import './BotDashboardModule.css';
import { channelsZhCn } from '../../i18n/channels.zh-cn';
import { channelsEn } from '../../i18n/channels.en';
import { pickLocale } from '../../i18n';
import { dashboardZhCn } from '../../i18n/dashboard.zh-cn';
import { dashboardEn } from '../../i18n/dashboard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { clearBotAccessPassword, getBotAccessPassword, isBotUnauthorizedError, setBotAccessPassword } from '../../utils/botAccess';
interface BotDashboardModuleProps {
onOpenCreateWizard?: () => void;
onOpenImageFactory?: () => void;
forcedBotId?: string;
compactMode?: boolean;
}
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
type WorkspaceNodeType = 'dir' | 'file';
type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
type RuntimeViewMode = 'visual' | 'text';
type CompactPanelTab = 'chat' | 'runtime';
type QuotedReply = { id?: number; text: string; ts: number };
const BOT_LIST_PAGE_SIZE = 8;
interface WorkspaceNode {
name: string;
path: string;
type: WorkspaceNodeType;
size?: number;
ext?: string;
ctime?: string;
mtime?: string;
children?: WorkspaceNode[];
}
interface WorkspaceHoverCardState {
node: WorkspaceNode;
top: number;
left: number;
above: boolean;
}
interface WorkspaceTreeResponse {
bot_id: string;
root: string;
cwd: string;
parent: string | null;
entries: WorkspaceNode[];
}
interface WorkspaceFileResponse {
bot_id: string;
path: string;
size: number;
is_markdown: boolean;
truncated: boolean;
content: string;
}
interface WorkspacePreviewState {
path: string;
content: string;
truncated: boolean;
ext: string;
isMarkdown: boolean;
isImage: boolean;
isHtml: boolean;
}
interface WorkspaceUploadResponse {
bot_id: string;
files: Array<{ name: string; path: string; size: number }>;
}
interface CronJob {
id: string;
name: string;
enabled?: boolean;
schedule?: {
kind?: 'at' | 'every' | 'cron' | string;
atMs?: number;
everyMs?: number;
expr?: string;
tz?: string;
};
payload?: {
message?: string;
channel?: string;
to?: string;
};
state?: {
nextRunAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
};
}
interface CronJobsResponse {
bot_id: string;
version: number;
jobs: CronJob[];
}
interface BotChannel {
id: string | number;
bot_id: string;
channel_type: ChannelType | string;
external_app_id: string;
app_secret: string;
internal_port: number;
is_active: boolean;
extra_config: Record<string, unknown>;
locked?: boolean;
}
interface NanobotImage {
tag: string;
status: string;
}
interface DockerImage {
tag: string;
version?: string;
image_id?: string;
}
interface BaseImageOption {
tag: string;
label: string;
disabled: boolean;
needsRegister: boolean;
}
interface WorkspaceSkillOption {
id: string;
name: string;
type: 'dir' | 'file' | string;
path: string;
size?: number | null;
mtime?: string;
description?: string;
}
interface BotResourceSnapshot {
bot_id: string;
docker_status: string;
configured: {
cpu_cores: number;
memory_mb: number;
storage_gb: number;
};
runtime: {
docker_status: string;
limits: {
cpu_cores?: number | null;
memory_bytes?: number | null;
storage_bytes?: number | null;
nano_cpus?: number;
storage_opt_raw?: string;
};
usage: {
cpu_percent: number;
memory_bytes: number;
memory_limit_bytes: number;
memory_percent: number;
network_rx_bytes: number;
network_tx_bytes: number;
blk_read_bytes: number;
blk_write_bytes: number;
pids: number;
container_rw_bytes: number;
};
};
workspace: {
path: string;
usage_bytes: number;
configured_limit_bytes?: number | null;
usage_percent: number;
};
enforcement: {
cpu_limited: boolean;
memory_limited: boolean;
storage_limited: boolean;
};
note: string;
collected_at: string;
}
interface SkillUploadResponse {
status: string;
bot_id: string;
installed: string[];
skills: WorkspaceSkillOption[];
}
interface SystemDefaultsResponse {
limits?: {
upload_max_mb?: number;
};
}
type BotEnvParams = Record<string, string>;
const providerPresets: Record<string, { model: string; apiBase?: string; note: { 'zh-cn': string; en: string } }> = {
openrouter: {
model: 'openai/gpt-4o-mini',
apiBase: 'https://openrouter.ai/api/v1',
note: {
'zh-cn': 'OpenRouter 网关,模型示例 openai/gpt-4o-mini',
en: 'OpenRouter gateway, model example: openai/gpt-4o-mini',
},
},
dashscope: {
model: 'qwen-plus',
apiBase: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
note: {
'zh-cn': '阿里云 DashScope千问模型示例 qwen-plus',
en: 'Alibaba DashScope (Qwen), model example: qwen-plus',
},
},
openai: {
model: 'gpt-4o-mini',
note: {
'zh-cn': 'OpenAI 原生接口',
en: 'OpenAI native endpoint',
},
},
deepseek: {
model: 'deepseek-chat',
note: {
'zh-cn': 'DeepSeek 原生接口',
en: 'DeepSeek native endpoint',
},
},
kimi: {
model: 'moonshot-v1-8k',
apiBase: 'https://api.moonshot.cn/v1',
note: {
'zh-cn': 'KimiMoonshot接口模型示例 moonshot-v1-8k',
en: 'Kimi (Moonshot) endpoint, model example: moonshot-v1-8k',
},
},
minimax: {
model: 'MiniMax-Text-01',
apiBase: 'https://api.minimax.chat/v1',
note: {
'zh-cn': 'MiniMax 接口,模型示例 MiniMax-Text-01',
en: 'MiniMax endpoint, model example: MiniMax-Text-01',
},
},
};
const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack'];
const RUNTIME_STALE_MS = 45000;
function formatClock(ts: number) {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function formatConversationDate(ts: number, isZh: boolean) {
const d = new Date(ts);
try {
return d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
});
} catch {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
}
function stateLabel(s?: string) {
return (s || 'IDLE').toUpperCase();
}
function normalizeRuntimeState(s?: string) {
const raw = stateLabel(s);
if (raw.includes('ERROR') || raw.includes('FAIL')) return 'ERROR';
if (raw.includes('TOOL') || raw.includes('EXEC') || raw.includes('ACTION')) return 'TOOL_CALL';
if (raw.includes('THINK') || raw.includes('PLAN') || raw.includes('REASON') || raw === 'RUNNING') return 'THINKING';
if (raw.includes('SUCCESS') || raw.includes('DONE') || raw.includes('COMPLETE')) return 'SUCCESS';
if (raw.includes('IDLE') || raw.includes('STOP')) return 'IDLE';
return raw;
}
function parseBotTimestamp(raw?: string | number) {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
const text = String(raw || '').trim();
if (!text) return 0;
const ms = Date.parse(text);
return Number.isFinite(ms) ? ms : 0;
}
function isPreviewableWorkspaceFile(node: WorkspaceNode) {
if (node.type !== 'file') return false;
const ext = (node.ext || '').toLowerCase();
return ['.md', '.json', '.log', '.txt', '.csv', '.html', '.htm', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].includes(ext);
}
function isPdfPath(path: string) {
return String(path || '').trim().toLowerCase().endsWith('.pdf');
}
function isImagePath(path: string) {
const normalized = String(path || '').trim().toLowerCase();
return normalized.endsWith('.png') || normalized.endsWith('.jpg') || normalized.endsWith('.jpeg') || normalized.endsWith('.webp');
}
const MEDIA_UPLOAD_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.heic', '.heif', '.tif', '.tiff',
'.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma',
'.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts',
]);
function isMediaUploadFile(file: File): boolean {
const mime = String(file.type || '').toLowerCase();
if (mime.startsWith('image/') || mime.startsWith('audio/') || mime.startsWith('video/')) {
return true;
}
const name = String(file.name || '').trim().toLowerCase();
const dot = name.lastIndexOf('.');
if (dot < 0) return false;
return MEDIA_UPLOAD_EXTENSIONS.has(name.slice(dot));
}
function isHtmlPath(path: string) {
const normalized = String(path || '').trim().toLowerCase();
return normalized.endsWith('.html') || normalized.endsWith('.htm');
}
function isOfficePath(path: string) {
const normalized = String(path || '').trim().toLowerCase();
return ['.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) =>
normalized.endsWith(ext),
);
}
function isPreviewableWorkspacePath(path: string) {
const normalized = String(path || '').trim().toLowerCase();
return ['.md', '.json', '.log', '.txt', '.csv', '.html', '.htm', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) =>
normalized.endsWith(ext),
);
}
function workspaceFileAction(path: string): 'preview' | 'download' | 'unsupported' {
const normalized = String(path || '').trim();
if (!normalized) return 'unsupported';
if (isPdfPath(normalized) || isOfficePath(normalized)) return 'download';
if (isImagePath(normalized) || isHtmlPath(normalized)) return 'preview';
const lower = normalized.toLowerCase();
if (['.md', '.json', '.log', '.txt', '.csv'].some((ext) => lower.endsWith(ext))) return 'preview';
return 'unsupported';
}
const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/';
const WORKSPACE_ABS_PATH_PATTERN = /\/root\/\.nanobot\/workspace\/[^\s<>"'`)\],,。!?;:]+/gi;
function buildWorkspaceLink(path: string) {
return `${WORKSPACE_LINK_PREFIX}${encodeURIComponent(path)}`;
}
function parseWorkspaceLink(href: string): string | null {
const link = String(href || '').trim();
if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null;
const encoded = link.slice(WORKSPACE_LINK_PREFIX.length);
try {
const decoded = decodeURIComponent(encoded || '').trim();
return decoded || null;
} catch {
return null;
}
}
function decorateWorkspacePathsForMarkdown(text: string) {
const source = String(text || '');
const normalizedExistingLinks = source.replace(
/\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\s*\n?\s*\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)/gi,
(_full, markdownPath: string) => {
const normalized = normalizeDashboardAttachmentPath(markdownPath);
if (!normalized) return String(_full || '');
return `[${markdownPath}](${buildWorkspaceLink(normalized)})`;
},
);
return normalizedExistingLinks.replace(WORKSPACE_ABS_PATH_PATTERN, (fullPath) => {
const normalized = normalizeDashboardAttachmentPath(fullPath);
if (!normalized) return fullPath;
return `[${fullPath}](${buildWorkspaceLink(normalized)})`;
});
}
function normalizeAttachmentPaths(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((v) => String(v || '').trim().replace(/\\/g, '/'))
.filter((v) => v.length > 0);
}
function normalizeDashboardAttachmentPath(path: string): string {
const v = String(path || '').trim().replace(/\\/g, '/');
if (!v) return '';
const prefix = '/root/.nanobot/workspace/';
if (v.startsWith(prefix)) return v.slice(prefix.length);
return v.replace(/^\/+/, '');
}
function isExternalHttpLink(href: string): boolean {
return /^https?:\/\//i.test(String(href || '').trim());
}
function parseQuotedReplyBlock(input: string): { quoted: string; body: string } {
const source = String(input || '');
const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i);
const quoted = normalizeAssistantMessageText(match?.[1] || '');
const body = source.replace(/\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]\s*/gi, '').trim();
return { quoted, body };
}
function mergeConversation(messages: ChatMessage[]) {
const merged: ChatMessage[] = [];
messages
.filter((msg) => msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0))
.forEach((msg) => {
const parsedUser = msg.role === 'user' ? parseQuotedReplyBlock(msg.text) : { quoted: '', body: msg.text };
const userQuoted = parsedUser.quoted;
const userBody = parsedUser.body;
const cleanText = msg.role === 'user' ? normalizeUserMessageText(userBody) : normalizeAssistantMessageText(msg.text);
const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean);
if (!cleanText && attachments.length === 0 && !userQuoted) return;
const last = merged[merged.length - 1];
if (last && last.role === msg.role) {
const normalizedLast = last.role === 'user' ? normalizeUserMessageText(last.text) : normalizeAssistantMessageText(last.text);
const normalizedCurrent = msg.role === 'user' ? normalizeUserMessageText(cleanText) : normalizeAssistantMessageText(cleanText);
const lastKind = last.kind || 'final';
const currentKind = msg.kind || 'final';
const sameAttachmentSet =
JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments);
const sameQuoted = normalizeAssistantMessageText(last.quoted_reply || '') === normalizeAssistantMessageText(userQuoted);
if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && sameQuoted && Math.abs(msg.ts - last.ts) < 15000) {
last.ts = msg.ts;
last.id = msg.id || last.id;
if (typeof msg.feedback !== 'undefined') {
last.feedback = msg.feedback;
}
return;
}
}
merged.push({ ...msg, text: cleanText, quoted_reply: userQuoted || undefined, attachments });
});
return merged.slice(-120);
}
function clampTemperature(value: number) {
if (Number.isNaN(value)) return 0.2;
return Math.min(1, Math.max(0, value));
}
function clampMaxTokens(value: number) {
if (Number.isNaN(value)) return 8192;
return Math.min(32768, Math.max(256, Math.round(value)));
}
function clampCpuCores(value: number) {
if (Number.isNaN(value)) return 1;
if (value === 0) return 0;
return Math.min(16, Math.max(0.1, Math.round(value * 10) / 10));
}
function clampMemoryMb(value: number) {
if (Number.isNaN(value)) return 1024;
if (value === 0) return 0;
return Math.min(65536, Math.max(256, Math.round(value)));
}
function clampStorageGb(value: number) {
if (Number.isNaN(value)) return 10;
if (value === 0) return 0;
return Math.min(1024, Math.max(1, Math.round(value)));
}
function formatBytes(bytes: number): string {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const idx = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024)));
const scaled = value / Math.pow(1024, idx);
return `${scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)} ${units[idx]}`;
}
function formatPercent(value: number): string {
const n = Number(value || 0);
if (!Number.isFinite(n)) return '0.00%';
return `${Math.max(0, n).toFixed(2)}%`;
}
function formatWorkspaceTime(raw: string | undefined, isZh: boolean): string {
const text = String(raw || '').trim();
if (!text) return '-';
const dt = new Date(text);
if (Number.isNaN(dt.getTime())) return '-';
try {
return dt.toLocaleString(isZh ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} catch {
return dt.toLocaleString();
}
}
function formatCronSchedule(job: CronJob, isZh: boolean) {
const s = job.schedule || {};
if (s.kind === 'every' && Number(s.everyMs) > 0) {
const sec = Math.round(Number(s.everyMs) / 1000);
return isZh ? `${sec}s` : `every ${sec}s`;
}
if (s.kind === 'cron') {
if (s.tz) return `${s.expr || '-'} (${s.tz})`;
return s.expr || '-';
}
if (s.kind === 'at' && Number(s.atMs) > 0) {
return new Date(Number(s.atMs)).toLocaleString();
}
return '-';
}
export function BotDashboardModule({
onOpenCreateWizard,
onOpenImageFactory,
forcedBotId,
compactMode = false,
}: BotDashboardModuleProps) {
const {
activeBots,
setBots,
mergeBot,
updateBotStatus,
locale,
addBotMessage,
setBotMessages,
setBotMessageFeedback,
} = useAppStore();
const { notify, confirm } = useLucentPrompt();
const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable';
const [selectedBotId, setSelectedBotId] = useState('');
const [command, setCommand] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [showBaseModal, setShowBaseModal] = useState(false);
const [showParamModal, setShowParamModal] = useState(false);
const [showChannelModal, setShowChannelModal] = useState(false);
const [showSkillsModal, setShowSkillsModal] = useState(false);
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
const [showCronModal, setShowCronModal] = useState(false);
const [showAgentModal, setShowAgentModal] = useState(false);
const [showResourceModal, setShowResourceModal] = useState(false);
const [resourceBotId, setResourceBotId] = useState('');
const [resourceSnapshot, setResourceSnapshot] = useState<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'>>({});
const chatBottomRef = useRef<HTMLDivElement | null>(null);
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 [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false);
const [workspaceQuery, setWorkspaceQuery] = useState('');
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
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 [botSkills, setBotSkills] = useState<WorkspaceSkillOption[]>([]);
const [isSkillUploading, setIsSkillUploading] = useState(false);
const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
const [envParams, setEnvParams] = useState<BotEnvParams>({});
const [envDraftKey, setEnvDraftKey] = useState('');
const [envDraftValue, setEnvDraftValue] = useState('');
const [envDraftVisible, setEnvDraftVisible] = useState(false);
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
const [isSavingChannel, setIsSavingChannel] = useState(false);
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
const [localDockerImages, setLocalDockerImages] = useState<DockerImage[]>([]);
const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
sendProgress: false,
sendToolHints: false,
});
const [uploadMaxMb, setUploadMaxMb] = useState(100);
const [newChannelType, setNewChannelType] = useState<ChannelType>('feishu');
const [runtimeViewMode, setRuntimeViewMode] = useState<RuntimeViewMode>('visual');
const [runtimeMenuOpen, setRuntimeMenuOpen] = 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 [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | null>(null);
const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
const applyEditFormFromBot = useCallback((bot?: any) => {
if (!bot) return;
setProviderTestResult('');
setEditForm({
name: bot.name || '',
access_password: bot.access_password || '',
llm_provider: bot.llm_provider || 'dashscope',
llm_model: bot.llm_model || '',
image_tag: bot.image_tag || '',
api_key: '',
api_base: bot.api_base || '',
temperature: clampTemperature(bot.temperature ?? 0.2),
top_p: bot.top_p ?? 1,
max_tokens: clampMaxTokens(bot.max_tokens ?? 8192),
cpu_cores: clampCpuCores(bot.cpu_cores ?? 1),
memory_mb: clampMemoryMb(bot.memory_mb ?? 1024),
storage_gb: clampStorageGb(bot.storage_gb ?? 10),
agents_md: bot.agents_md || '',
soul_md: bot.soul_md || bot.system_prompt || '',
user_md: bot.user_md || '',
tools_md: bot.tools_md || '',
identity_md: bot.identity_md || '',
});
setParamDraft({
max_tokens: String(clampMaxTokens(bot.max_tokens ?? 8192)),
cpu_cores: String(clampCpuCores(bot.cpu_cores ?? 1)),
memory_mb: String(clampMemoryMb(bot.memory_mb ?? 1024)),
storage_gb: String(clampStorageGb(bot.storage_gb ?? 10)),
});
setPendingAttachments([]);
}, []);
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => {
const query = [`path=${encodeURIComponent(filePath)}`];
if (forceDownload) query.push('download=1');
return `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/download?${query.join('&')}`;
};
const closeWorkspacePreview = () => {
setWorkspacePreview(null);
setWorkspacePreviewFullscreen(false);
};
const triggerWorkspaceFileDownload = (filePath: string) => {
if (!selectedBotId) return;
const normalized = String(filePath || '').trim();
if (!normalized) return;
const filename = normalized.split('/').pop() || 'workspace-file';
const link = document.createElement('a');
link.href = buildWorkspaceDownloadHref(normalized, true);
link.download = filename;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
};
const copyWorkspacePreviewUrl = async (filePath: string) => {
const normalized = String(filePath || '').trim();
if (!selectedBotId || !normalized) return;
const hrefRaw = buildWorkspaceDownloadHref(normalized, false);
const href = (() => {
try {
return new URL(hrefRaw, window.location.origin).href;
} catch {
return hrefRaw;
}
})();
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(href);
} else {
const ta = document.createElement('textarea');
ta.value = href;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
notify(t.urlCopied, { tone: 'success' });
} catch {
notify(t.urlCopyFail, { tone: 'error' });
}
};
const copyTextToClipboard = async (textRaw: string, successMsg: string, failMsg: string) => {
const text = String(textRaw || '');
if (!text.trim()) return;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
notify(successMsg, { tone: 'success' });
} catch {
notify(failMsg, { tone: 'error' });
}
};
const openWorkspacePathFromChat = async (path: string) => {
const normalized = String(path || '').trim();
if (!normalized) return;
const action = workspaceFileAction(normalized);
if (action === 'download') {
triggerWorkspaceFileDownload(normalized);
return;
}
if (action === 'preview') {
void openWorkspaceFilePreview(normalized);
return;
}
try {
await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/tree`, {
params: { path: normalized },
});
await loadWorkspaceTree(selectedBotId, normalized);
return;
} catch {
if (!isPreviewableWorkspacePath(normalized) || action === 'unsupported') {
notify(fileNotPreviewableLabel, { tone: 'warning' });
return;
}
}
};
const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => {
const source = String(text || '');
if (!source) return [source];
const pattern =
/\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)|\/root\/\.nanobot\/workspace\/[^\s<>"'`)\],,。!?;:]+|https:\/\/workspace\.local\/open\/[^)\r\n]+/gi;
const nodes: ReactNode[] = [];
let lastIndex = 0;
let matchIndex = 0;
let match = pattern.exec(source);
while (match) {
if (match.index > lastIndex) {
nodes.push(source.slice(lastIndex, match.index));
}
const raw = match[0];
const markdownPath = match[1] ? String(match[1]) : '';
const markdownHref = match[2] ? String(match[2]) : '';
let normalizedPath = '';
let displayText = raw;
if (markdownPath && markdownHref) {
normalizedPath = normalizeDashboardAttachmentPath(markdownPath);
displayText = markdownPath;
} else if (raw.startsWith(WORKSPACE_LINK_PREFIX)) {
normalizedPath = String(parseWorkspaceLink(raw) || '').trim();
displayText = normalizedPath ? `/root/.nanobot/workspace/${normalizedPath}` : raw;
} else if (raw.startsWith('/root/.nanobot/workspace/')) {
normalizedPath = normalizeDashboardAttachmentPath(raw);
displayText = raw;
}
if (normalizedPath) {
nodes.push(
<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>
);
},
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, 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',
});
const bots = useMemo(
() =>
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 || ''));
}),
[activeBots],
);
const normalizedBotListQuery = botListQuery.trim().toLowerCase();
const filteredBots = useMemo(() => {
if (!normalizedBotListQuery) return bots;
return bots.filter((bot) => {
const id = String(bot.id || '').toLowerCase();
const name = String(bot.name || '').toLowerCase();
return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery);
});
}, [bots, normalizedBotListQuery]);
const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / BOT_LIST_PAGE_SIZE));
const pagedBots = useMemo(() => {
const page = Math.min(Math.max(1, botListPage), botListTotalPages);
const start = (page - 1) * BOT_LIST_PAGE_SIZE;
return filteredBots.slice(start, start + BOT_LIST_PAGE_SIZE);
}, [filteredBots, botListPage, botListTotalPages]);
const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined;
const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]);
const messages = selectedBot?.messages || [];
const events = selectedBot?.events || [];
const isZh = locale === 'zh';
const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn });
const lc = isZh ? channelsZhCn : channelsEn;
const botAccessCheckRef = useRef<Record<string, Promise<boolean> | undefined>>({});
const botPasswordResolverRef = useRef<((value: string | null) => void) | null>(null);
const [botPasswordDialog, setBotPasswordDialog] = useState<{
open: boolean;
botName: string;
invalid: boolean;
value: string;
}>({
open: false,
botName: '',
invalid: false,
value: '',
});
const promptForBotPassword = (botName: string, invalid: boolean): Promise<string | null> => {
setBotPasswordDialog({
open: true,
botName,
invalid,
value: '',
});
return new Promise((resolve) => {
botPasswordResolverRef.current = resolve;
});
};
const closeBotPasswordDialog = (value: string | null) => {
const resolver = botPasswordResolverRef.current;
botPasswordResolverRef.current = null;
setBotPasswordDialog((prev) => ({ ...prev, open: false, value: '' }));
if (resolver) resolver(value && String(value).trim() ? String(value).trim() : null);
};
const verifyBotPassword = async (botId: string): Promise<boolean> => {
await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`);
return true;
};
const ensureBotAccess = async (botId: string): Promise<boolean> => {
const normalizedBotId = String(botId || '').trim();
if (!normalizedBotId) return false;
const bot = activeBots[normalizedBotId];
if (!bot?.has_access_password) return true;
const inFlight = botAccessCheckRef.current[normalizedBotId];
if (inFlight) return inFlight;
const checkPromise = (async () => {
const botName = String(bot.name || bot.id || normalizedBotId).trim();
let askForNewPassword = false;
for (let attempt = 0; attempt < 3; attempt += 1) {
let password = getBotAccessPassword(normalizedBotId);
if (!password || askForNewPassword) {
const input = await promptForBotPassword(botName, askForNewPassword);
if (input === null) {
notify(isZh ? '已取消密码输入,无法访问该机器人。' : 'Password input cancelled. Bot access blocked.', {
tone: 'warning',
});
return false;
}
setBotAccessPassword(normalizedBotId, input);
password = input;
}
if (!password) {
askForNewPassword = true;
continue;
}
try {
await verifyBotPassword(normalizedBotId);
return true;
} catch (error: any) {
if (isBotUnauthorizedError(error, normalizedBotId)) {
clearBotAccessPassword(normalizedBotId);
askForNewPassword = true;
notify(isZh ? '访问密码错误,请重试。' : 'Access password is invalid. Please retry.', { tone: 'warning' });
continue;
}
throw error;
}
}
return false;
})();
botAccessCheckRef.current[normalizedBotId] = checkPromise;
try {
return await checkPromise;
} finally {
delete botAccessCheckRef.current[normalizedBotId];
}
};
const baseImageOptions = useMemo<BaseImageOption[]>(() => {
const readyTags = new Set(
availableImages
.filter((img) => String(img.status || '').toUpperCase() === 'READY')
.map((img) => String(img.tag || '').trim())
.filter(Boolean),
);
const allTags = new Set<string>();
localDockerImages.forEach((img) => {
const tag = String(img.tag || '').trim();
if (tag) allTags.add(tag);
});
availableImages.forEach((img) => {
const tag = String(img.tag || '').trim();
if (tag) allTags.add(tag);
});
if (editForm.image_tag) {
allTags.add(editForm.image_tag);
}
return Array.from(allTags)
.sort((a, b) => a.localeCompare(b))
.map((tag) => {
const isReady = readyTags.has(tag);
if (isReady) {
return { tag, label: `${tag} · READY`, disabled: false, needsRegister: false };
}
const hasInDocker = localDockerImages.some((row) => String(row.tag || '').trim() === tag);
if (hasInDocker) {
return {
tag,
label: isZh ? `${tag} · 本地镜像(未登记)` : `${tag} · local image (unregistered)`,
disabled: false,
needsRegister: true,
};
}
return {
tag,
label: isZh ? `${tag} · 不可用` : `${tag} · unavailable`,
disabled: true,
needsRegister: false,
};
});
}, [availableImages, localDockerImages, editForm.image_tag, isZh]);
const runtimeMoreLabel = isZh ? '更多' : 'More';
const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined;
const isSending = selectedBot ? Boolean(sendingByBot[selectedBot.id]) : false;
const canChat = Boolean(selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState);
const isChatEnabled = Boolean(canChat && !isSending);
const conversation = useMemo(() => mergeConversation(messages), [messages]);
const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
const workspaceFiles = useMemo(
() => workspaceEntries.filter((v) => v.type === 'file' && isPreviewableWorkspaceFile(v)),
[workspaceEntries],
);
const workspacePathDisplay = workspaceCurrentPath
? `/${String(workspaceCurrentPath || '').replace(/^\/+/, '')}`
: '/';
const normalizedWorkspaceQuery = workspaceQuery.trim().toLowerCase();
const filteredWorkspaceEntries = useMemo(() => {
const sourceEntries = normalizedWorkspaceQuery ? workspaceSearchEntries : workspaceEntries;
if (!normalizedWorkspaceQuery) return sourceEntries;
return sourceEntries.filter((entry) => {
const name = String(entry.name || '').toLowerCase();
const path = String(entry.path || '').toLowerCase();
return name.includes(normalizedWorkspaceQuery) || path.includes(normalizedWorkspaceQuery);
});
}, [workspaceEntries, workspaceSearchEntries, normalizedWorkspaceQuery]);
const addableChannelTypes = useMemo(() => {
const exists = new Set(channels.map((c) => String(c.channel_type).toLowerCase()));
return optionalChannelTypes.filter((t) => !exists.has(t));
}, [channels]);
const envEntries = useMemo(
() =>
Object.entries(envParams || {})
.filter(([k]) => String(k || '').trim().length > 0)
.sort(([a], [b]) => a.localeCompare(b)),
[envParams],
);
const lastUserTs = useMemo(() => [...conversation].reverse().find((m) => m.role === 'user')?.ts || 0, [conversation]);
const lastAssistantFinalTs = useMemo(
() =>
[...conversation].reverse().find((m) => m.role === 'assistant' && (m.kind || 'final') !== 'progress')?.ts || 0,
[conversation],
);
const botUpdatedAtTs = useMemo(() => parseBotTimestamp(selectedBot?.updated_at), [selectedBot?.updated_at]);
const latestRuntimeSignalTs = useMemo(() => {
const latestEventTs = latestEvent?.ts || 0;
return Math.max(latestEventTs, botUpdatedAtTs, lastUserTs);
}, [latestEvent?.ts, botUpdatedAtTs, lastUserTs]);
const hasFreshRuntimeSignal = useMemo(
() => latestRuntimeSignalTs > 0 && Date.now() - latestRuntimeSignalTs < RUNTIME_STALE_MS,
[latestRuntimeSignalTs],
);
const isThinking = useMemo(() => {
if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false;
if (lastUserTs <= 0) return false;
if (lastAssistantFinalTs >= lastUserTs) return false;
return hasFreshRuntimeSignal;
}, [selectedBot, lastUserTs, lastAssistantFinalTs, hasFreshRuntimeSignal]);
const displayState = useMemo(() => {
if (!selectedBot) return 'IDLE';
const backendState = normalizeRuntimeState(selectedBot.current_state);
if (selectedBot.docker_status !== 'RUNNING') return backendState;
if (hasFreshRuntimeSignal && (backendState === 'TOOL_CALL' || backendState === 'THINKING' || backendState === 'ERROR')) {
return backendState;
}
if (isThinking) {
if (latestEvent?.state === 'TOOL_CALL') return 'TOOL_CALL';
return 'THINKING';
}
if (
latestEvent &&
['THINKING', 'TOOL_CALL', 'ERROR'].includes(latestEvent.state) &&
Date.now() - latestEvent.ts < 15000
) {
return latestEvent.state;
}
if (latestEvent?.state === 'ERROR') return 'ERROR';
return 'IDLE';
}, [selectedBot, isThinking, latestEvent, hasFreshRuntimeSignal]);
const runtimeAction = useMemo(() => {
const action = normalizeAssistantMessageText(selectedBot?.last_action || '').trim();
if (action) return action;
const eventText = normalizeAssistantMessageText(latestEvent?.text || '').trim();
if (eventText) return eventText;
return '-';
}, [selectedBot, latestEvent]);
const runtimeActionSummary = useMemo(() => {
const full = String(runtimeAction || '').trim();
if (!full || full === '-') return '-';
return summarizeProgressText(full, isZh);
}, [runtimeAction, isZh]);
const runtimeActionHasMore = useMemo(() => {
const full = String(runtimeAction || '').trim();
const summary = String(runtimeActionSummary || '').trim();
return Boolean(full && full !== '-' && summary && full !== summary);
}, [runtimeAction, runtimeActionSummary]);
const runtimeActionDisplay = runtimeActionHasMore ? runtimeActionSummary : runtimeAction;
const resourceBot = useMemo(() => bots.find((b) => b.id === resourceBotId), [bots, resourceBotId]);
const hideWorkspaceHoverCard = () => setWorkspaceHoverCard(null);
const showWorkspaceHoverCard = (node: WorkspaceNode, anchor: HTMLElement) => {
const rect = anchor.getBoundingClientRect();
const panelHeight = 160;
const panelWidth = 420;
const gap = 8;
const viewportPadding = 8;
const belowSpace = window.innerHeight - rect.bottom;
const aboveSpace = rect.top;
const above = belowSpace < panelHeight && aboveSpace > panelHeight;
const leftRaw = rect.left + 8;
const left = Math.max(viewportPadding, Math.min(leftRaw, window.innerWidth - panelWidth - viewportPadding));
const top = above ? rect.top - gap : rect.bottom + gap;
setWorkspaceHoverCard({ node, top, left, above });
};
const shouldCollapseProgress = (text: string) => {
const normalized = String(text || '').trim();
if (!normalized) return false;
const lines = normalized.split('\n').length;
return lines > 6 || normalized.length > 520;
};
const conversationNodes = useMemo(
() =>
conversation.map((item, idx) => {
const itemKey = `${item.id || item.ts}-${idx}`;
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
const fullText = String(item.text || '');
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
const collapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText));
const expanded = Boolean(expandedProgressByKey[itemKey]);
const displayText = isProgressBubble && !expanded ? summaryText : fullText;
const currentDayKey = new Date(item.ts).toDateString();
const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : '';
const showDateDivider = idx === 0 || currentDayKey !== prevDayKey;
return (
<div key={itemKey}>
{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={() =>
setExpandedProgressByKey((prev) => ({
...prev,
[itemKey]: !prev[itemKey],
}))
}
tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
>
{expanded ? '×' : '…'}
</LucentIconButton>
) : null}
</div>
</div>
<div className={`ops-chat-text ${collapsible && !expanded ? '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(item.text)}</div>
</>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={markdownComponents}
>
{decorateWorkspacePathsForMarkdown(displayText)}
</ReactMarkdown>
)
) : null}
{(item.attachments || []).length > 0 ? (
<div className="ops-chat-attachments">
{(item.attachments || []).map((rawPath) => {
const filePath = normalizeDashboardAttachmentPath(rawPath);
const fileAction = workspaceFileAction(filePath);
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,
feedbackSavingByMessageId,
isZh,
selectedBotId,
t.badReply,
t.copyPrompt,
t.copyReply,
t.quoteReply,
t.quotedReplyLabel,
t.goodReply,
t.user,
t.you,
],
);
useEffect(() => {
setBotListPage(1);
}, [normalizedBotListQuery]);
useEffect(() => {
setBotListPage((prev) => Math.min(Math.max(prev, 1), botListTotalPages));
}, [botListTotalPages]);
useEffect(() => {
const forced = String(forcedBotId || '').trim();
if (forced) {
if (activeBots[forced]) {
if (selectedBotId !== forced) setSelectedBotId(forced);
} else if (selectedBotId) {
setSelectedBotId('');
}
return;
}
if (!selectedBotId && bots.length > 0) setSelectedBotId(bots[0].id);
if (selectedBotId && !activeBots[selectedBotId] && bots.length > 0) setSelectedBotId(bots[0].id);
}, [bots, selectedBotId, activeBots, forcedBotId]);
useEffect(() => {
chatBottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [selectedBotId, conversation.length]);
useEffect(() => {
setQuotedReply(null);
}, [selectedBotId]);
useEffect(() => {
const onPointerDown = (event: MouseEvent) => {
if (!runtimeMenuRef.current) return;
if (!runtimeMenuRef.current.contains(event.target as Node)) {
setRuntimeMenuOpen(false);
}
};
document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown);
}, []);
useEffect(() => {
setRuntimeMenuOpen(false);
}, [selectedBotId]);
useEffect(() => {
setExpandedProgressByKey({});
setShowRuntimeActionModal(false);
setWorkspaceHoverCard(null);
}, [selectedBotId]);
useEffect(() => {
if (!selectedBotId) return;
let alive = true;
const loadBotDetail = async () => {
try {
const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`);
if (alive) mergeBot(res.data);
} catch (error) {
console.error(`Failed to fetch bot detail for ${selectedBotId}`, error);
}
};
void loadBotDetail();
return () => {
alive = false;
};
}, [selectedBotId, mergeBot]);
useEffect(() => {
if (!workspaceHoverCard) return;
const close = () => setWorkspaceHoverCard(null);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [workspaceHoverCard]);
useEffect(() => {
let alive = true;
const loadSystemDefaults = async () => {
try {
const res = await axios.get<SystemDefaultsResponse>(`${APP_ENDPOINTS.apiBase}/system/defaults`);
const configured = Number(res.data?.limits?.upload_max_mb);
if (!Number.isFinite(configured) || configured <= 0 || !alive) return;
setUploadMaxMb(Math.max(1, Math.floor(configured)));
} catch {
// keep default limit
}
};
void loadSystemDefaults();
return () => {
alive = false;
};
}, []);
useEffect(() => {
if (!compactMode) {
setIsCompactMobile(false);
setCompactPanelTab('chat');
return;
}
const media = window.matchMedia('(max-width: 980px)');
const apply = () => setIsCompactMobile(media.matches);
apply();
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}, [compactMode]);
useEffect(() => {
if (!selectedBotId) return;
if (showBaseModal || showParamModal || showAgentModal) return;
applyEditFormFromBot(selectedBot);
}, [
selectedBotId,
selectedBot?.id,
selectedBot?.updated_at,
showBaseModal,
showParamModal,
showAgentModal,
applyEditFormFromBot,
]);
useEffect(() => {
if (!selectedBotId || !selectedBot) {
setGlobalDelivery({ sendProgress: false, sendToolHints: false });
return;
}
setGlobalDelivery({
sendProgress: Boolean(selectedBot.send_progress),
sendToolHints: Boolean(selectedBot.send_tool_hints),
});
}, [selectedBotId, selectedBot?.send_progress, selectedBot?.send_tool_hints]);
const loadImageOptions = async () => {
const [imagesRes, dockerImagesRes] = await Promise.allSettled([
axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`),
axios.get<DockerImage[]>(`${APP_ENDPOINTS.apiBase}/docker-images`),
]);
if (imagesRes.status === 'fulfilled') {
setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []);
} else {
setAvailableImages([]);
}
if (dockerImagesRes.status === 'fulfilled') {
setLocalDockerImages(Array.isArray(dockerImagesRes.value.data) ? dockerImagesRes.value.data : []);
} else {
setLocalDockerImages([]);
}
};
const refresh = async () => {
const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`);
setBots(botsRes.data);
await loadImageOptions();
};
const ensureSelectedBotDetail = useCallback(async () => {
if (!selectedBotId) return selectedBot;
try {
const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`);
mergeBot(res.data);
return res.data;
} catch {
return selectedBot;
}
}, [selectedBotId, selectedBot, mergeBot]);
const loadResourceSnapshot = async (botId: string) => {
if (!botId) return;
setResourceLoading(true);
setResourceError('');
try {
const res = await axios.get<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 (isPdfPath(normalizedPath) || isOfficePath(normalizedPath)) {
triggerWorkspaceFileDownload(normalizedPath);
return;
}
if (isImagePath(normalizedPath)) {
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
setWorkspacePreview({
path: normalizedPath,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: true,
isHtml: false,
});
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,
});
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,
});
} catch (error: any) {
const msg = error?.response?.data?.detail || t.fileReadFail;
notify(msg, { tone: 'error' });
} finally {
setWorkspaceFileLoading(false);
}
};
const loadWorkspaceTree = async (botId: string, path: string = '') => {
if (!botId) return;
setWorkspaceLoading(true);
setWorkspaceError('');
try {
const res = await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, {
params: { path },
});
const entries = Array.isArray(res.data?.entries) ? res.data.entries : [];
setWorkspaceEntries(entries);
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 loadChannels = async (botId: string) => {
if (!botId) return;
const res = await axios.get<BotChannel[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`);
setChannels(Array.isArray(res.data) ? res.data : []);
};
const loadBotSkills = async (botId: string) => {
if (!botId) return;
const res = await axios.get<WorkspaceSkillOption[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skills`);
setBotSkills(Array.isArray(res.data) ? res.data : []);
};
const loadBotEnvParams = async (botId: string) => {
if (!botId) return;
try {
const res = await axios.get<{ env_params?: Record<string, string> }>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/env-params`);
const rows = res.data?.env_params && typeof res.data.env_params === 'object' ? res.data.env_params : {};
const next: BotEnvParams = {};
Object.entries(rows).forEach(([k, v]) => {
const key = String(k || '').trim().toUpperCase();
if (!key) return;
next[key] = String(v ?? '');
});
setEnvParams(next);
} catch {
setEnvParams({});
}
};
const saveBotEnvParams = async () => {
if (!selectedBot) return;
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/env-params`, { env_params: envParams });
setShowEnvParamsModal(false);
notify(t.envParamsSaved, { tone: 'success' });
} catch (error: any) {
notify(error?.response?.data?.detail || t.envParamsSaveFail, { tone: 'error' });
}
};
const upsertEnvParam = (key: string, value: string) => {
const normalized = String(key || '').trim().toUpperCase();
if (!normalized) return;
setEnvParams((prev) => ({ ...(prev || {}), [normalized]: String(value ?? '') }));
};
const removeEnvParam = (key: string) => {
const normalized = String(key || '').trim().toUpperCase();
if (!normalized) return;
setEnvParams((prev) => {
const next = { ...(prev || {}) };
delete next[normalized];
return next;
});
};
const removeBotSkill = async (skill: WorkspaceSkillOption) => {
if (!selectedBot) return;
const ok = await confirm({
title: t.removeSkill,
message: t.toolsRemoveConfirm(skill.name || skill.id),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/${encodeURIComponent(skill.id)}`);
await loadBotSkills(selectedBot.id);
} catch (error: any) {
notify(error?.response?.data?.detail || t.toolsRemoveFail, { tone: 'error' });
}
};
const triggerSkillZipUpload = () => {
if (!selectedBot || isSkillUploading) return;
skillZipPickerRef.current?.click();
};
const onPickSkillZip = async (event: ChangeEvent<HTMLInputElement>) => {
if (!selectedBot || !event.target.files || event.target.files.length === 0) return;
const file = event.target.files[0];
const filename = String(file?.name || '').toLowerCase();
if (!filename.endsWith('.zip')) {
notify(t.invalidZipFile, { tone: 'warning' });
event.target.value = '';
return;
}
const formData = new FormData();
formData.append('file', file);
setIsSkillUploading(true);
try {
const res = await axios.post<SkillUploadResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/upload`,
formData,
);
const nextSkills = Array.isArray(res.data?.skills) ? res.data.skills : [];
setBotSkills(nextSkills);
} catch (error: any) {
notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' });
} finally {
setIsSkillUploading(false);
event.target.value = '';
}
};
const loadCronJobs = async (botId: string) => {
if (!botId) return;
setCronLoading(true);
try {
const res = await axios.get<CronJobsResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/cron/jobs`, {
params: { include_disabled: true },
});
setCronJobs(Array.isArray(res.data?.jobs) ? res.data.jobs : []);
} catch {
setCronJobs([]);
} finally {
setCronLoading(false);
}
};
const stopCronJob = async (jobId: string) => {
if (!selectedBot || !jobId) return;
setCronActionJobId(jobId);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/cron/jobs/${jobId}/stop`);
await loadCronJobs(selectedBot.id);
} catch (error: any) {
notify(error?.response?.data?.detail || t.cronStopFail, { tone: 'error' });
} finally {
setCronActionJobId('');
}
};
const deleteCronJob = async (jobId: string) => {
if (!selectedBot || !jobId) return;
const ok = await confirm({
title: t.cronDelete,
message: t.cronDeleteConfirm(jobId),
tone: 'warning',
});
if (!ok) return;
setCronActionJobId(jobId);
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/cron/jobs/${jobId}`);
await loadCronJobs(selectedBot.id);
} catch (error: any) {
notify(error?.response?.data?.detail || t.cronDeleteFail, { tone: 'error' });
} finally {
setCronActionJobId('');
}
};
const updateChannelLocal = (index: number, patch: Partial<BotChannel>) => {
setChannels((prev) => prev.map((c, i) => (i === index ? { ...c, ...patch } : c)));
};
const saveChannel = async (channel: BotChannel) => {
if (!selectedBot) return;
setIsSavingChannel(true);
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`, {
channel_type: channel.channel_type,
external_app_id: channel.external_app_id,
app_secret: channel.app_secret,
internal_port: Number(channel.internal_port),
is_active: channel.is_active,
extra_config: sanitizeChannelExtra(String(channel.channel_type), channel.extra_config || {}),
});
await loadChannels(selectedBot.id);
notify(t.channelSaved, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || t.channelSaveFail;
notify(msg, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const addChannel = async () => {
if (!selectedBot || !addableChannelTypes.includes(newChannelType)) return;
setIsSavingChannel(true);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels`, {
channel_type: newChannelType,
is_active: true,
external_app_id: '',
app_secret: '',
internal_port: 8080,
extra_config: {},
});
await loadChannels(selectedBot.id);
const rest = addableChannelTypes.filter((t) => t !== newChannelType);
if (rest.length > 0) setNewChannelType(rest[0]);
} catch (error: any) {
const msg = error?.response?.data?.detail || t.channelAddFail;
notify(msg, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const removeChannel = async (channel: BotChannel) => {
if (!selectedBot || channel.channel_type === 'dashboard') return;
const ok = await confirm({
title: t.channels,
message: t.channelDeleteConfirm(channel.channel_type),
tone: 'warning',
});
if (!ok) return;
setIsSavingChannel(true);
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`);
await loadChannels(selectedBot.id);
} catch (error: any) {
const msg = error?.response?.data?.detail || t.channelDeleteFail;
notify(msg, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const isDashboardChannel = (channel: BotChannel) => String(channel.channel_type).toLowerCase() === 'dashboard';
const sanitizeChannelExtra = (channelType: string, extra: Record<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, idx: number) => {
const ctype = String(channel.channel_type).toLowerCase();
if (ctype === 'telegram') {
return (
<>
<input className="input" type="password" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
<input
className="input"
placeholder={lc.proxy}
value={String((channel.extra_config || {}).proxy || '')}
onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })}
/>
<label className="field-label">
<input
type="checkbox"
checked={Boolean((channel.extra_config || {}).replyToMessage)}
onChange={(e) =>
updateChannelLocal(idx, { 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) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
<input className="input" placeholder={lc.encryptKey} value={String((channel.extra_config || {}).encryptKey || '')} onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} />
<input className="input" placeholder={lc.verificationToken} value={String((channel.extra_config || {}).verificationToken || '')} onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} />
</>
);
}
if (ctype === 'dingtalk') {
return (
<>
<input className="input" placeholder={lc.clientId} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
</>
);
}
if (ctype === 'slack') {
return (
<>
<input className="input" placeholder={lc.botToken} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
</>
);
}
if (ctype === 'qq') {
return (
<>
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
</>
);
}
return null;
};
const stopBot = async (id: string, status: string) => {
if (status !== 'RUNNING') return;
setOperatingBotId(id);
setControlStateByBot((prev) => ({ ...prev, [id]: 'stopping' }));
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`);
updateBotStatus(id, 'STOPPED');
await refresh();
} catch {
notify(t.stopFail, { tone: 'error' });
} finally {
setOperatingBotId(null);
setControlStateByBot((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
};
const startBot = async (id: string, status: string) => {
if (status === 'RUNNING') return;
setOperatingBotId(id);
setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' }));
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`);
updateBotStatus(id, 'RUNNING');
await refresh();
} catch {
notify(t.startFail, { tone: 'error' });
} finally {
setOperatingBotId(null);
setControlStateByBot((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
};
const restartBot = async (id: string, status: string) => {
const normalized = String(status || '').toUpperCase();
const ok = await confirm({
title: t.restart,
message: t.restartConfirm(id),
tone: 'warning',
});
if (!ok) return;
setOperatingBotId(id);
try {
if (normalized === 'RUNNING') {
setControlStateByBot((prev) => ({ ...prev, [id]: 'stopping' }));
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`);
updateBotStatus(id, 'STOPPED');
}
setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' }));
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`);
updateBotStatus(id, 'RUNNING');
await refresh();
} catch {
notify(t.restartFail, { tone: 'error' });
} finally {
setOperatingBotId(null);
setControlStateByBot((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
};
const send = async () => {
if (!selectedBot || !canChat || isSending) return;
if (!command.trim() && pendingAttachments.length === 0 && !quotedReply) return;
const text = normalizeUserMessageText(command);
const quoteText = normalizeAssistantMessageText(quotedReply?.text || '');
const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : '';
const payloadCore = text || (pendingAttachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : '');
const payloadText = `${quoteBlock}${payloadCore}`.trim();
if (!payloadText && pendingAttachments.length === 0) return;
try {
setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: payloadText, attachments: pendingAttachments },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
addBotMessage(selectedBot.id, {
role: 'assistant',
text: t.sendFailMsg(msg),
ts: Date.now(),
});
notify(msg, { tone: 'error' });
} finally {
setSendingByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
};
const interruptExecution = async () => {
if (!selectedBot || !canChat) return;
if (interruptingByBot[selectedBot.id]) return;
try {
setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: '/stop' },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
notify(t.interruptSent, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
notify(msg, { tone: 'error' });
} finally {
setInterruptingByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
};
const copyUserPrompt = async (text: string) => {
await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail);
};
const editUserPrompt = (text: string) => {
const normalized = normalizeUserMessageText(text);
if (!normalized) return;
setCommand(normalized);
composerTextareaRef.current?.focus();
if (composerTextareaRef.current) {
const caret = normalized.length;
window.requestAnimationFrame(() => {
composerTextareaRef.current?.setSelectionRange(caret, caret);
});
}
notify(t.editPromptDone, { tone: 'success' });
};
const copyAssistantReply = async (text: string) => {
await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail);
};
const quoteAssistantReply = (message: ChatMessage) => {
const content = normalizeAssistantMessageText(message.text);
if (!content) return;
setQuotedReply((prev) => {
if (prev && prev.ts === message.ts && normalizeAssistantMessageText(prev.text) === content) {
return null;
}
return { id: message.id, ts: message.ts, text: content };
});
};
const fetchBotMessages = async (botId: string): Promise<ChatMessage[]> => {
const res = await axios.get<any[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages`, {
params: { limit: 300 },
});
const rows = Array.isArray(res.data) ? res.data : [];
return rows
.map((row) => {
const roleRaw = String(row?.role || '').toLowerCase();
const role: ChatMessage['role'] =
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
return {
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
role,
text: String(row?.text || ''),
attachments: normalizeAttachmentPaths(row?.media),
ts: Number(row?.ts || Date.now()),
feedback,
kind: 'final',
} as ChatMessage;
})
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0)
.slice(-300);
};
const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => {
if (!selectedBotId) {
notify(t.feedbackMessagePending, { tone: 'warning' });
return;
}
let targetMessageId = message.id;
if (!targetMessageId) {
try {
const latest = await fetchBotMessages(selectedBotId);
setBotMessages(selectedBotId, latest);
const normalizedTarget = normalizeAssistantMessageText(message.text);
const matched = latest
.filter((m) => m.role === 'assistant' && m.id)
.map((m) => ({ m, diff: Math.abs((m.ts || 0) - (message.ts || 0)) }))
.filter(({ m, diff }) => normalizeAssistantMessageText(m.text) === normalizedTarget && diff <= 10 * 60 * 1000)
.sort((a, b) => a.diff - b.diff)[0]?.m;
if (matched?.id) {
targetMessageId = matched.id;
}
} catch {
// ignore and fallback to warning below
}
}
if (!targetMessageId) {
notify(t.feedbackMessagePending, { tone: 'warning' });
return;
}
if (feedbackSavingByMessageId[targetMessageId]) return;
const nextFeedback: 'up' | 'down' | null = message.feedback === feedback ? null : feedback;
setFeedbackSavingByMessageId((prev) => ({ ...prev, [targetMessageId]: true }));
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/feedback`, { feedback: nextFeedback });
setBotMessageFeedback(selectedBotId, targetMessageId, nextFeedback);
if (nextFeedback === null) {
notify(t.feedbackCleared, { tone: 'success' });
} else {
notify(nextFeedback === 'up' ? t.feedbackUpSaved : t.feedbackDownSaved, { tone: 'success' });
}
} catch (error: any) {
const msg = error?.response?.data?.detail || t.feedbackSaveFail;
notify(msg, { tone: 'error' });
} finally {
setFeedbackSavingByMessageId((prev) => {
const next = { ...prev };
delete next[targetMessageId];
return next;
});
}
};
const onComposerKeyDown = (e: KeyboardEvent<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 onVoiceInput = () => {
notify(t.voiceUnavailable, { tone: 'warning' });
};
const onPickAttachments = async (event: ChangeEvent<HTMLInputElement>) => {
if (!selectedBot || !event.target.files || event.target.files.length === 0) return;
const files = Array.from(event.target.files);
const maxBytes = uploadMaxMb * 1024 * 1024;
const tooLarge = files.filter((f) => Number(f.size) > maxBytes);
if (tooLarge.length > 0) {
const names = tooLarge.map((f) => String(f.name || '').trim() || 'unknown').slice(0, 3).join(', ');
notify(t.uploadTooLarge(names, uploadMaxMb), { tone: 'warning' });
event.target.value = '';
return;
}
const mediaFiles: File[] = [];
const normalFiles: File[] = [];
files.forEach((file) => {
if (isMediaUploadFile(file)) {
mediaFiles.push(file);
} else {
normalFiles.push(file);
}
});
const totalBytes = files.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
let uploadedBytes = 0;
const uploadedPaths: string[] = [];
const uploadBatch = async (batchFiles: File[], path: 'media' | 'uploads') => {
if (batchFiles.length === 0) return;
const batchBytes = batchFiles.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
const formData = new FormData();
batchFiles.forEach((file) => formData.append('files', file));
const res = await axios.post<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) {
setWorkspaceEntries([]);
setWorkspaceCurrentPath('');
setWorkspaceParentPath(null);
setWorkspaceError('');
setChannels([]);
setPendingAttachments([]);
setCronJobs([]);
setBotSkills([]);
setEnvParams({});
return;
}
let cancelled = false;
const loadAll = async () => {
try {
const granted = await ensureBotAccess(selectedBotId);
if (!granted || cancelled) return;
await Promise.all([
loadWorkspaceTree(selectedBotId, ''),
loadCronJobs(selectedBotId),
loadBotSkills(selectedBotId),
loadBotEnvParams(selectedBotId),
]);
} catch (error: any) {
const detail = String(error?.response?.data?.detail || '').trim();
if (isBotUnauthorizedError(error, selectedBotId)) {
clearBotAccessPassword(selectedBotId);
if (!cancelled) {
notify(isZh ? '访问密码校验失败,请重新进入该机器人。' : 'Bot password check failed. Reopen the bot and retry.', {
tone: 'error',
});
}
return;
}
if (!cancelled && detail) {
notify(detail, { tone: 'error' });
}
}
};
void loadAll();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBotId]);
useEffect(() => {
if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return;
let stopped = false;
const tick = async () => {
if (stopped) return;
await loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
};
void tick();
const timer = window.setInterval(() => {
void tick();
}, 2000);
return () => {
stopped = true;
window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceAutoRefresh, selectedBotId, selectedBot?.docker_status, workspaceCurrentPath]);
useEffect(() => {
setWorkspaceQuery('');
}, [selectedBotId, workspaceCurrentPath]);
useEffect(() => {
if (!selectedBotId) {
setWorkspaceSearchEntries([]);
setWorkspaceSearchLoading(false);
return;
}
if (!workspaceQuery.trim()) {
setWorkspaceSearchEntries([]);
setWorkspaceSearchLoading(false);
return;
}
void loadWorkspaceSearchEntries(selectedBotId, workspaceCurrentPath);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBotId, workspaceCurrentPath, workspaceQuery]);
const saveBot = async (mode: 'params' | 'agent' | 'base') => {
const targetBotId = String(selectedBot?.id || selectedBotId || '').trim();
if (!targetBotId) {
notify(isZh ? '未选中 Bot无法保存。' : 'No bot selected.', { tone: 'warning' });
return;
}
setIsSaving(true);
try {
const payload: Record<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.');
}
if (selectedImageOption?.needsRegister) {
await axios.post(`${APP_ENDPOINTS.apiBase}/images/register`, {
tag: editForm.image_tag,
source_dir: 'manual',
});
}
const normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores));
const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb));
const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb));
payload.cpu_cores = normalizedCpuCores;
payload.memory_mb = normalizedMemoryMb;
payload.storage_gb = normalizedStorageGb;
setEditForm((p) => ({
...p,
cpu_cores: normalizedCpuCores,
memory_mb: normalizedMemoryMb,
storage_gb: normalizedStorageGb,
}));
setParamDraft((p) => ({
...p,
cpu_cores: String(normalizedCpuCores),
memory_mb: String(normalizedMemoryMb),
storage_gb: String(normalizedStorageGb),
}));
}
if (mode === 'params') {
payload.llm_provider = editForm.llm_provider;
payload.llm_model = editForm.llm_model;
payload.api_base = editForm.api_base;
if (editForm.api_key.trim()) payload.api_key = editForm.api_key.trim();
payload.temperature = clampTemperature(Number(editForm.temperature));
payload.top_p = Number(editForm.top_p);
const normalizedMaxTokens = clampMaxTokens(Number(paramDraft.max_tokens));
payload.max_tokens = normalizedMaxTokens;
setEditForm((p) => ({
...p,
max_tokens: normalizedMaxTokens,
}));
setParamDraft((p) => ({ ...p, max_tokens: String(normalizedMaxTokens) }));
}
if (mode === 'agent') {
payload.agents_md = editForm.agents_md;
payload.soul_md = editForm.soul_md;
payload.user_md = editForm.user_md;
payload.tools_md = editForm.tools_md;
payload.identity_md = editForm.identity_md;
}
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${targetBotId}`, payload);
if (mode === 'base') {
const nextPassword = String(editForm.access_password || '').trim();
if (nextPassword) {
setBotAccessPassword(targetBotId, nextPassword);
} else {
clearBotAccessPassword(targetBotId);
}
}
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);
const downloadOnlyFile = isPdfPath(node.path) || isOfficePath(node.path);
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' : ''}`}>
{!compactMode ? (
<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">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onOpenImageFactory}
tooltip={t.manageImages}
aria-label={t.manageImages}
>
<Boxes size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-primary btn-sm icon-btn"
onClick={onOpenCreateWizard}
tooltip={t.newBot}
aria-label={t.newBot}
>
<Plus size={14} />
</LucentIconButton>
</div>
</div>
<div className="ops-bot-list-toolbar">
<div className="ops-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
value={botListQuery}
onChange={(e) => setBotListQuery(e.target.value)}
placeholder={t.botSearchPlaceholder}
aria-label={t.botSearchPlaceholder}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
name="bot-search"
/>
<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">
{pagedBots.map((bot) => {
const selected = selectedBotId === bot.id;
const controlState = controlStateByBot[bot.id];
const isOperating = operatingBotId === bot.id;
const isStarting = controlState === 'starting';
const isStopping = controlState === 'stopping';
return (
<div key={bot.id} className={`ops-bot-card ${selected ? 'is-active' : ''}`} onClick={() => setSelectedBotId(bot.id)}>
<span className={`ops-bot-strip ${bot.docker_status === 'RUNNING' ? 'is-running' : 'is-stopped'}`} aria-hidden="true" />
<div className="row-between ops-bot-top">
<div className="ops-bot-name-wrap">
<div className="ops-bot-name-row">
<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">
<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">
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
onClick={(e) => {
e.stopPropagation();
openResourceMonitor(bot.id);
}}
tooltip={isZh ? '资源监测' : 'Resource Monitor'}
aria-label={isZh ? '资源监测' : 'Resource Monitor'}
>
<Gauge size={14} />
</LucentIconButton>
{bot.docker_status === 'RUNNING' ? (
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-stop"
disabled={isOperating}
onClick={(e) => {
e.stopPropagation();
void stopBot(bot.id, bot.docker_status);
}}
tooltip={t.stop}
aria-label={t.stop}
>
{isStopping ? (
<span className="ops-control-pending">
<span className="ops-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : <Square size={14} />}
</LucentIconButton>
) : (
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-start"
disabled={isOperating}
onClick={(e) => {
e.stopPropagation();
void startBot(bot.id, bot.docker_status);
}}
tooltip={t.start}
aria-label={t.start}
>
{isStarting ? (
<span className="ops-control-pending">
<span className="ops-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : <Power size={14} />}
</LucentIconButton>
)}
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
onClick={(e) => {
e.stopPropagation();
void removeBot(bot.id);
}}
tooltip={t.delete}
aria-label={t.delete}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
);
})}
{filteredBots.length === 0 ? (
<div className="ops-bot-list-empty">{t.botSearchNoResult}</div>
) : null}
</div>
<div className="ops-bot-list-pagination">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setBotListPage((p) => Math.max(1, p - 1))}
disabled={botListPage <= 1}
tooltip={t.paginationPrev}
aria-label={t.paginationPrev}
>
<ChevronLeft size={14} />
</LucentIconButton>
<div className="ops-bot-list-page-indicator">{t.paginationPage(botListPage, botListTotalPages)}</div>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setBotListPage((p) => Math.min(botListTotalPages, p + 1))}
disabled={botListPage >= botListTotalPages}
tooltip={t.paginationNext}
aria-label={t.paginationNext}
>
<ChevronRight size={14} />
</LucentIconButton>
</div>
</section>
) : null}
<section className={`panel ops-chat-panel ${compactMode && isCompactMobile && compactPanelTab !== 'chat' ? 'ops-compact-hidden' : ''}`}>
{selectedBot ? (
<div className="ops-chat-shell">
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
<div className="ops-chat-scroll">
{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);
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}
<div className="ops-composer">
<input
ref={filePickerRef}
type="file"
multiple
onChange={onPickAttachments}
style={{ display: 'none' }}
/>
<div className="ops-composer-shell">
<textarea
ref={composerTextareaRef}
className="input ops-composer-input"
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={onComposerKeyDown}
disabled={!canChat}
placeholder={
canChat
? t.inputPlaceholder
: t.disabledPlaceholder
}
/>
<div className="ops-composer-tools-right">
<LucentIconButton
className="ops-composer-inline-btn"
disabled={!canChat}
onClick={onVoiceInput}
tooltip={t.voiceInput}
aria-label={t.voiceInput}
>
<Mic size={16} />
</LucentIconButton>
<LucentIconButton
className="ops-composer-inline-btn"
disabled={!canChat || isUploadingAttachments}
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 || (!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>
{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}
{!canChat ? (
<div className="ops-chat-disabled-mask">
<div className="ops-chat-disabled-card">
{selectedBotControlState === 'starting'
? t.botStarting
: selectedBotControlState === 'stopping'
? t.botStopping
: t.chatDisabled}
</div>
</div>
) : null}
</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 && isCompactMobile && compactPanelTab !== 'runtime' ? 'ops-compact-hidden' : ''}`}>
{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={() => setRuntimeViewMode((m) => (m === 'visual' ? 'text' : 'visual'))}
tooltip={runtimeViewMode === 'visual' ? (isZh ? '切换为文字面板' : 'Switch to text panel') : (isZh ? '切换为机器人面板' : 'Switch to bot panel')}
aria-label={runtimeViewMode === 'visual' ? (isZh ? '切换为文字面板' : 'Switch to text panel') : (isZh ? '切换为机器人面板' : 'Switch to bot panel')}
>
<Repeat2 size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void restartBot(selectedBot.id, selectedBot.docker_status)}
disabled={operatingBotId === selectedBot.id}
tooltip={t.restart}
aria-label={t.restart}
>
<RefreshCw size={14} className={operatingBotId === selectedBot.id ? 'animate-spin' : ''} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setRuntimeMenuOpen((v) => !v)}
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) void loadChannels(selectedBot.id);
setShowChannelModal(true);
}}
>
<Waypoints size={14} />
<span>{t.channels}</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) 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 ${runtimeViewMode === 'visual' ? 'is-visual' : ''}`}
onDoubleClick={() => setRuntimeViewMode((m) => (m === 'visual' ? 'text' : 'visual'))}
title={isZh ? '双击切换动画/文字状态视图' : 'Double click to toggle visual/text state view'}
>
{runtimeViewMode === '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 className="ops-runtime-row"><span>{t.container}</span><strong className="mono">{selectedBot.docker_status}</strong></div>
<div className="ops-runtime-row"><span>{t.current}</span><strong className="mono">{displayState}</strong></div>
<div className="ops-runtime-row">
<span>{t.lastAction}</span>
<div className="ops-runtime-action-inline">
<strong className="ops-runtime-action-text">{runtimeActionDisplay}</strong>
{runtimeActionHasMore ? (
<LucentIconButton
className="ops-runtime-expand-btn"
onClick={() => setShowRuntimeActionModal(true)}
tooltip={isZh ? '查看完整内容' : 'Show full content'}
aria-label={isZh ? '查看完整内容' : 'Show full content'}
>
</LucentIconButton>
) : null}
</div>
</div>
<div className="ops-runtime-row"><span>Provider</span><strong className="mono">{selectedBot.llm_provider || '-'}</strong></div>
<div className="ops-runtime-row"><span>Model</span><strong className="mono">{selectedBot.llm_model || '-'}</strong></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"
value={workspaceQuery}
onChange={(e) => setWorkspaceQuery(e.target.value)}
placeholder={t.workspaceSearchPlaceholder}
aria-label={t.workspaceSearchPlaceholder}
/>
<button
type="button"
className="ops-search-inline-btn"
onClick={() => {
if (workspaceQuery.trim()) {
setWorkspaceQuery('');
return;
}
setWorkspaceQuery((v) => v.trim());
}}
title={workspaceQuery.trim() ? t.clearSearch : t.searchAction}
aria-label={workspaceQuery.trim() ? t.clearSearch : t.searchAction}
>
{workspaceQuery.trim() ? <X size={14} /> : <Search size={14} />}
</button>
</div>
</div>
<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>
{compactMode && isCompactMobile ? (
<LucentIconButton
className={`ops-compact-fab-switch ${compactPanelTab === 'chat' ? 'is-chat' : 'is-runtime'}`}
onClick={() => setCompactPanelTab((v) => (v === 'chat' ? 'runtime' : 'chat'))}
tooltip={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')}
aria-label={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')}
>
{compactPanelTab === 'chat' ? <Activity size={18} /> : <MessageSquareText size={18} />}
</LucentIconButton>
) : 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>
<span className="modal-sub">{t.baseConfigSub}</span>
</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>
<input
className="input"
type="password"
value={editForm.access_password}
onChange={(e) => setEditForm((p) => ({ ...p, access_password: e.target.value }))}
placeholder={t.accessPasswordPlaceholder}
/>
<label className="field-label">{t.baseImageReadonly}</label>
<select
className="select"
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>
))}
</select>
{baseImageOptions.find((opt) => opt.tag === editForm.image_tag)?.needsRegister ? (
<div className="field-label" style={{ color: 'var(--warning)' }}>
{isZh ? '该镜像尚未登记,保存时会自动加入镜像注册表。' : 'This image is not registered yet. It will be auto-registered on save.'}
</div>
) : null}
<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>
<select className="select" 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>
</select>
<label className="field-label">{t.modelName}</label>
<input className="input" value={editForm.llm_model} onChange={(e) => setEditForm((p) => ({ ...p, llm_model: e.target.value }))} placeholder={t.modelNamePlaceholder} />
<label className="field-label">{t.newApiKey}</label>
<input className="input" type="password" value={editForm.api_key} onChange={(e) => setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} />
<label className="field-label">API Base</label>
<input className="input" value={editForm.api_base} onChange={(e) => setEditForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="API Base URL" />
<div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{providerPresets[editForm.llm_provider]?.note[noteLocale]}
</div>
<button className="btn btn-secondary" onClick={() => void testProviderConnection()} disabled={isTestingProvider}>
{isTestingProvider ? t.testing : t.testModelConnection}
</button>
{providerTestResult && <div className="card">{providerTestResult}</div>}
<div className="slider-row">
<label className="field-label">Temperature: {Number(editForm.temperature).toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={editForm.temperature} onChange={(e) => setEditForm((p) => ({ ...p, temperature: clampTemperature(Number(e.target.value)) }))} />
</div>
<div className="slider-row">
<label className="field-label">Top P: {Number(editForm.top_p).toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={editForm.top_p} onChange={(e) => setEditForm((p) => ({ ...p, top_p: Number(e.target.value) }))} />
</div>
<label className="field-label">Max Tokens</label>
<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)}>
<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>{lc.wizardSectionTitle}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowChannelModal(false)} 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">
{channels.map((channel, idx) => (
isDashboardChannel(channel) ? null : (
<div key={`${channel.id}-${channel.channel_type}`} className="card wizard-channel-card wizard-channel-compact">
<div className="row-between">
<strong style={{ textTransform: 'uppercase' }}>{channel.channel_type}</strong>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label className="field-label">
<input
type="checkbox"
checked={channel.is_active}
onChange={(e) => updateChannelLocal(idx, { is_active: e.target.checked })}
disabled={isDashboardChannel(channel)}
style={{ marginRight: 6 }}
/>
{lc.enabled}
</label>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isDashboardChannel(channel) || isSavingChannel}
onClick={() => void removeChannel(channel)}
tooltip={lc.remove}
aria-label={lc.remove}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
{renderChannelFields(channel, idx)}
<div className="row-between">
<span className="field-label">{lc.customChannel}</span>
<LucentIconButton
className="btn btn-primary btn-sm icon-btn"
disabled={isSavingChannel}
onClick={() => void saveChannel(channel)}
tooltip={lc.saveChannel}
aria-label={lc.saveChannel}
>
<Save size={14} />
</LucentIconButton>
</div>
</div>
)
))}
</div>
<div className="row-between">
<select
className="select"
value={newChannelType}
onChange={(e) => setNewChannelType(e.target.value as ChannelType)}
disabled={addableChannelTypes.length === 0 || isSavingChannel}
>
{addableChannelTypes.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
disabled={addableChannelTypes.length === 0 || isSavingChannel}
onClick={() => void addChannel()}
tooltip={lc.addChannel}
aria-label={lc.addChannel}
>
<Plus size={14} />
</LucentIconButton>
</div>
</div>
</div>
)}
{showSkillsModal && (
<div className="modal-mask" onClick={() => 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>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowSkillsModal(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</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 className="row-between">
<input
ref={skillZipPickerRef}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
onChange={onPickSkillZip}
style={{ display: 'none' }}
/>
<button
className="btn btn-secondary btn-sm"
disabled={isSkillUploading}
onClick={triggerSkillZipUpload}
title={isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
aria-label={isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
>
{isSkillUploading ? <RefreshCw size={14} className="animate-spin" /> : null}
<span style={{ marginLeft: isSkillUploading ? 6 : 0 }}>
{isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
</span>
</button>
<span className="field-label">{t.zipOnlyHint}</span>
</div>
</div>
</div>
)}
{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 }} />
<input
className="input"
type={envVisibleByKey[key] ? 'text' : 'password'}
value={value}
onChange={(e) => upsertEnvParam(key, e.target.value)}
placeholder={t.envValue}
/>
<LucentIconButton
className="btn btn-secondary btn-sm wizard-icon-btn"
onClick={() => setEnvVisibleByKey((prev) => ({ ...prev, [key]: !prev[key] }))}
tooltip={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
aria-label={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
>
{envVisibleByKey[key] ? <EyeOff size={14} /> : <Eye size={14} />}
</LucentIconButton>
<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}
/>
<input
className="input"
type={envDraftVisible ? 'text' : 'password'}
value={envDraftValue}
onChange={(e) => setEnvDraftValue(e.target.value)}
placeholder={t.envValue}
/>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setEnvDraftVisible((v) => !v)}
tooltip={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
aria-label={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
>
{envDraftVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</LucentIconButton>
<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>
)}
{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">{workspacePreview.path}</span>
</div>
<div className="workspace-preview-header-actions">
<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' : ''}`}>
{workspacePreview.isImage ? (
<img
className="workspace-preview-image"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
/>
) : workspacePreview.isHtml ? (
<iframe
className="workspace-preview-embed"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
title={workspacePreview.path}
/>
) : workspacePreview.isMarkdown ? (
<div className="workspace-markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={markdownComponents}
>
{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.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 mono">
{`/root/.nanobot/workspace/${String(workspaceHoverCard.node.path || '').replace(/^\/+/, '')}`}
</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}
{botPasswordDialog.open ? (
<div className="modal-mask" onClick={() => closeBotPasswordDialog(null)}>
<div className="modal-card" style={{ width: 'min(520px, calc(100vw - 28px))' }} onClick={(event) => event.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>
{botPasswordDialog.invalid
? (isZh ? `访问密码错误:${botPasswordDialog.botName}` : `Invalid access password: ${botPasswordDialog.botName}`)
: (isZh ? `请输入访问密码:${botPasswordDialog.botName}` : `Enter access password for ${botPasswordDialog.botName}`)}
</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => closeBotPasswordDialog(null)} tooltip={isZh ? '关闭' : 'Close'} aria-label={isZh ? '关闭' : 'Close'}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="stack" style={{ gap: 12 }}>
<input
className="input"
autoFocus
type="password"
value={botPasswordDialog.value}
onChange={(event) =>
setBotPasswordDialog((prev) => ({
...prev,
value: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === 'Enter') closeBotPasswordDialog(botPasswordDialog.value);
}}
placeholder={isZh ? '输入 Bot 访问密码' : 'Enter bot access password'}
/>
<div className="row-between">
<button className="btn btn-secondary" onClick={() => closeBotPasswordDialog(null)}>
{isZh ? '取消' : 'Cancel'}
</button>
<button className="btn btn-primary" onClick={() => closeBotPasswordDialog(botPasswordDialog.value)}>
{isZh ? '确认' : 'Confirm'}
</button>
</div>
</div>
</div>
</div>
) : null}
</>
);
}