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

2282 lines
92 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 { useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
import axios from 'axios';
import { Activity, Boxes, Check, Clock3, EllipsisVertical, Eye, EyeOff, FileText, FolderOpen, Hammer, MessageSquareText, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Save, Settings2, SlidersHorizontal, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
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';
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';
interface WorkspaceNode {
name: string;
path: string;
type: WorkspaceNodeType;
size?: number;
ext?: string;
mtime?: string;
children?: WorkspaceNode[];
}
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;
}
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 WorkspaceSkillOption {
id: string;
name: string;
type: 'dir' | 'file' | string;
path: string;
size?: number | null;
mtime?: string;
description?: string;
}
interface SkillUploadResponse {
status: string;
bot_id: string;
installed: string[];
skills: WorkspaceSkillOption[];
}
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'];
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 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 isPreviewableWorkspaceFile(node: WorkspaceNode) {
if (node.type !== 'file') return false;
const ext = (node.ext || '').toLowerCase();
return ['.md', '.json', '.log', '.txt', '.csv', '.pdf'].includes(ext);
}
function isPdfPath(path: string) {
return String(path || '').trim().toLowerCase().endsWith('.pdf');
}
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 mergeConversation(messages: ChatMessage[]) {
const merged: ChatMessage[] = [];
messages
.filter((msg) => msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0))
.forEach((msg) => {
const cleanText = msg.role === 'user' ? normalizeUserMessageText(msg.text) : normalizeAssistantMessageText(msg.text);
const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean);
if (!cleanText && attachments.length === 0) 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);
if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && Math.abs(msg.ts - last.ts) < 15000) {
last.ts = msg.ts;
return;
}
}
merged.push({ ...msg, text: cleanText, 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 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,
updateBotStatus,
locale,
addBotMessage,
setBotMessages,
} = useAppStore();
const { notify, confirm } = useLucentPrompt();
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 [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 [controlStateByBot, setControlStateByBot] = useState<Record<string, 'starting' | 'stopping'>>({});
const chatBottomRef = useRef<HTMLDivElement | null>(null);
const [workspaceEntries, setWorkspaceEntries] = useState<WorkspaceNode[]>([]);
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 [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(true);
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
const filePickerRef = useRef<HTMLInputElement | 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 [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
sendProgress: false,
sendToolHints: false,
});
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 runtimeMenuRef = useRef<HTMLDivElement | null>(null);
const markdownComponents = useMemo(
() => ({
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
const link = String(href || '').trim();
if (isExternalHttpLink(link)) {
return (
<a href={link} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
);
}
return (
<a href={link || '#'} {...props}>
{children}
</a>
);
},
}),
[],
);
const [editForm, setEditForm] = useState({
name: '',
llm_provider: '',
llm_model: '',
image_tag: '',
api_key: '',
api_base: '',
temperature: 0.2,
top_p: 1,
max_tokens: 8192,
agents_md: '',
soul_md: '',
user_md: '',
tools_md: '',
identity_md: '',
});
const bots = useMemo(() => Object.values(activeBots), [activeBots]);
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 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 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 isThinking = useMemo(() => {
if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false;
if (lastUserTs <= 0) return false;
if (lastAssistantFinalTs >= lastUserTs) return false;
// Keep showing running/thinking state until a final assistant reply arrives.
return true;
}, [selectedBot, lastUserTs, lastAssistantFinalTs]);
const displayState = useMemo(() => {
if (!selectedBot) return 'IDLE';
const backendState = normalizeRuntimeState(selectedBot.current_state);
if (selectedBot.docker_status !== 'RUNNING') return backendState;
if (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]);
const runtimeAction = useMemo(() => {
const action = summarizeProgressText(selectedBot?.last_action || '', isZh);
if (action && action !== t.processing) return action;
const eventText = summarizeProgressText(latestEvent?.text || '', isZh);
if (eventText && eventText !== t.processing) return eventText;
return '-';
}, [selectedBot, latestEvent, isZh, t.processing]);
const conversationNodes = useMemo(
() =>
conversation.map((item, idx) => (
<div key={`${item.ts}-${idx}`} 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>
)}
<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>
<span className="mono">{formatClock(item.ts)}</span>
</div>
<div className="ops-chat-text">
{item.text ? (
item.role === 'user' ? (
<div className="whitespace-pre-wrap">{normalizeUserMessageText(item.text)}</div>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{item.text}
</ReactMarkdown>
)
) : null}
{(item.attachments || []).length > 0 ? (
<div className="ops-chat-attachments">
{(item.attachments || []).map((rawPath) => {
const filePath = normalizeDashboardAttachmentPath(rawPath);
const isPdf = isPdfPath(filePath);
const href = `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(filePath)}${isPdf ? '&download=1' : ''}`;
const filename = filePath.split('/').pop() || filePath;
return (
<a
key={`${item.ts}-${filePath}`}
className="ops-attach-link mono"
href={href}
target="_blank"
rel="noreferrer"
download={isPdf ? filename : undefined}
>
{filename}
</a>
);
})}
</div>
) : null}
</div>
</div>
{item.role === 'user' && (
<div className="ops-avatar user" title={t.user}>
<UserRound size={18} />
</div>
)}
</div>
</div>
)),
[conversation, selectedBotId, t.user, t.you],
);
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(() => {
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(() => {
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;
const bot = selectedBot;
if (!bot) return;
setProviderTestResult('');
setEditForm({
name: bot.name || '',
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: bot.max_tokens ?? 8192,
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 || '',
});
setPendingAttachments([]);
}, [selectedBotId, selectedBot?.id]);
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 refresh = async () => {
const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`);
setBots(res.data);
};
const openWorkspaceFilePreview = async (path: string) => {
if (!selectedBotId || !path) return;
const normalizedPath = String(path || '').trim();
if (isPdfPath(normalizedPath)) {
const href = `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(normalizedPath)}&download=1`;
window.open(href, '_blank', 'noopener,noreferrer');
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),
});
} 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);
setWorkspaceCurrentPath(res.data?.cwd || '');
setWorkspaceParentPath(res.data?.parent ?? null);
} catch (error: any) {
setWorkspaceEntries([]);
setWorkspaceCurrentPath('');
setWorkspaceParentPath(null);
setWorkspaceError(error?.response?.data?.detail || t.workspaceLoadFail);
} finally {
setWorkspaceLoading(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),
});
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 send = async () => {
if (!selectedBot || !canChat || isSending) return;
if (!command.trim() && pendingAttachments.length === 0) return;
const text = normalizeUserMessageText(command);
const payloadText = text || (pendingAttachments.length > 0 ? t.attachmentMessage : '');
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([]);
} 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 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 onPickAttachments = async (event: ChangeEvent<HTMLInputElement>) => {
if (!selectedBot || !event.target.files || event.target.files.length === 0) return;
const files = Array.from(event.target.files);
const formData = new FormData();
files.forEach((file) => formData.append('files', file));
formData.append('path', 'uploads');
setIsUploadingAttachments(true);
try {
const res = await axios.post<WorkspaceUploadResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/workspace/upload`,
formData,
);
const uploaded = normalizeAttachmentPaths((res.data?.files || []).map((v) => v.path));
if (uploaded.length > 0) {
setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploaded])));
await loadWorkspaceTree(selectedBot.id, workspaceCurrentPath);
}
} catch (error: any) {
const msg = error?.response?.data?.detail || t.uploadFail;
notify(msg, { tone: 'error' });
} finally {
setIsUploadingAttachments(false);
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;
}
void loadWorkspaceTree(selectedBotId, '');
void loadCronJobs(selectedBotId);
void loadBotSkills(selectedBotId);
void loadBotEnvParams(selectedBotId);
// 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]);
const saveBot = async (mode: 'params' | 'agent' | 'base') => {
if (!selectedBot) return;
setIsSaving(true);
try {
const payload: Record<string, string | number> = {};
if (mode === 'base') {
payload.name = editForm.name;
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();
}
if (mode === 'params') {
payload.temperature = clampTemperature(Number(editForm.temperature));
payload.top_p = Number(editForm.top_p);
payload.max_tokens = Number(editForm.max_tokens);
}
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/${selectedBot.id}`, payload);
await refresh();
setShowBaseModal(false);
setShowParamModal(false);
setShowAgentModal(false);
notify(t.configUpdated, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || t.saveFail;
notify(msg, { tone: 'error' });
} finally {
setIsSaving(false);
}
};
const removeBot = async (botId?: string) => {
const targetId = botId || selectedBot?.id;
if (!targetId) return;
const ok = await confirm({
title: t.delete,
message: t.deleteBotConfirm(targetId),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${targetId}`, { params: { delete_workspace: true } });
await refresh();
if (selectedBotId === targetId) setSelectedBotId('');
notify(t.deleteBotDone, { tone: 'success' });
} catch {
notify(t.deleteFail, { tone: 'error' });
}
};
const clearConversationHistory = async () => {
if (!selectedBot) return;
const target = selectedBot.name || selectedBot.id;
const ok = await confirm({
title: t.clearHistory,
message: t.clearHistoryConfirm(target),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/messages`);
setBotMessages(selectedBot.id, []);
notify(t.clearHistoryDone, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || t.clearHistoryFail;
notify(msg, { tone: 'error' });
}
};
const exportConversationJson = () => {
if (!selectedBot) return;
try {
const payload = {
bot_id: selectedBot.id,
bot_name: selectedBot.name || selectedBot.id,
exported_at: new Date().toISOString(),
message_count: conversation.length,
messages: conversation.map((m) => ({
role: m.role,
text: m.text,
attachments: m.attachments || [],
kind: m.kind || 'final',
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">{node.name}</span>
<span className="workspace-entry-meta">{t.folder}</span>
</button>,
);
return;
}
const previewable = isPreviewableWorkspaceFile(node);
const pdfFile = String(node.ext || '').toLowerCase() === '.pdf';
rendered.push(
<button
key={key}
className={`workspace-entry file ${previewable ? '' : 'disabled'}`}
disabled={!previewable || workspaceFileLoading}
onClick={() => void openWorkspaceFilePreview(node.path)}
title={previewable ? (pdfFile ? t.download : t.previewTitle) : t.fileNotPreviewable}
>
<FileText size={14} />
<span className="workspace-entry-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 }}>{t.titleBots}</h2>
<div className="ops-list-actions">
<button
className="btn btn-secondary btn-sm icon-btn"
onClick={onOpenImageFactory}
title={t.manageImages}
aria-label={t.manageImages}
>
<Boxes size={14} />
</button>
<button
className="btn btn-primary btn-sm icon-btn"
onClick={onOpenCreateWizard}
title={t.newBot}
aria-label={t.newBot}
>
<Plus size={14} />
</button>
</div>
</div>
<div className="list-scroll" style={{ maxHeight: '72vh' }}>
{bots.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">{bot.name}</div>
<div className="mono ops-bot-id">{bot.id}</div>
</div>
<span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
</div>
<div className="ops-bot-meta">{t.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
<div className="ops-bot-actions">
{bot.docker_status === 'RUNNING' ? (
<button
className="btn btn-danger btn-sm icon-btn"
disabled={isOperating}
onClick={(e) => {
e.stopPropagation();
void stopBot(bot.id, bot.docker_status);
}}
title={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>
) : <PowerOff size={14} />}
</button>
) : (
<button
className="btn btn-success btn-sm ops-bot-icon-btn"
disabled={isOperating}
onClick={(e) => {
e.stopPropagation();
void startBot(bot.id, bot.docker_status);
}}
title={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} />}
</button>
)}
<button
className="btn btn-danger btn-sm ops-bot-icon-btn"
onClick={(e) => {
e.stopPropagation();
void removeBot(bot.id);
}}
title={t.delete}
aria-label={t.delete}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
</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-composer">
<input
ref={filePickerRef}
type="file"
multiple
onChange={onPickAttachments}
style={{ display: 'none' }}
/>
<textarea
className="input ops-composer-input"
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={onComposerKeyDown}
disabled={!canChat}
placeholder={
canChat
? t.inputPlaceholder
: t.disabledPlaceholder
}
/>
<button
className="btn btn-secondary icon-btn"
disabled={!canChat || isUploadingAttachments}
onClick={triggerPickAttachments}
title={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
aria-label={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
>
<Paperclip size={14} className={isUploadingAttachments ? 'animate-spin' : ''} />
</button>
<button
className="btn btn-primary"
disabled={!isChatEnabled || (!command.trim() && pendingAttachments.length === 0)}
onClick={() => void send()}
>
{isSending ? t.sending : t.send}
</button>
</div>
{pendingAttachments.length > 0 ? (
<div className="ops-pending-files">
{pendingAttachments.map((p) => (
<span key={p} className="ops-pending-chip mono">
{p.split('/').pop() || p}
<button
className="icon-btn ops-chip-remove"
onClick={() => setPendingAttachments((prev) => prev.filter((v) => v !== p))}
title={t.removeAttachment}
aria-label={t.removeAttachment}
>
<X size={12} />
</button>
</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}>
<button
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setRuntimeViewMode((m) => (m === 'visual' ? 'text' : 'visual'))}
title={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} />
</button>
<button
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setRuntimeMenuOpen((v) => !v)}
title={runtimeMoreLabel}
aria-label={runtimeMoreLabel}
aria-haspopup="menu"
aria-expanded={runtimeMenuOpen}
>
<EllipsisVertical size={14} />
</button>
{runtimeMenuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={runtimeMoreLabel}>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
setProviderTestResult('');
setShowBaseModal(true);
}}
>
<Settings2 size={14} />
<span>{t.base}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
onClick={() => {
setRuntimeMenuOpen(false);
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);
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><strong>{runtimeAction}</strong></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">
<button
className="workspace-refresh-icon-btn"
disabled={workspaceLoading || !selectedBotId}
onClick={() => void loadWorkspaceTree(selectedBot.id, workspaceCurrentPath)}
title={lc.refreshHint}
aria-label={lc.refreshHint}
>
<RefreshCw size={14} className={workspaceLoading ? 'animate-spin' : ''} />
</button>
<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>
<span className="workspace-path mono">{workspaceCurrentPath || '/'}</span>
</div>
<div className="workspace-panel">
<div className="workspace-list">
{workspaceLoading ? (
<div className="ops-empty-inline">{t.loadingDir}</div>
) : renderWorkspaceNodes(workspaceEntries).length === 0 ? (
<div className="ops-empty-inline">{t.emptyDir}</div>
) : (
renderWorkspaceNodes(workspaceEntries)
)}
</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 ? (
<button
className="ops-compact-fab-switch"
onClick={() => setCompactPanelTab((v) => (v === 'chat' ? 'runtime' : 'chat'))}
title={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} />}
</button>
) : null}
{showBaseModal && (
<div className="modal-mask" onClick={() => setShowBaseModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row">
<h3>{t.baseConfig}</h3>
<span className="modal-sub">{t.baseConfigSub}</span>
</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.baseImageReadonly}</label>
<input className="input" value={editForm.image_tag} disabled />
<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="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()}>
<h3>{t.modelParams}</h3>
<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={editForm.max_tokens} onChange={(e) => setEditForm((p) => ({ ...p, max_tokens: Number(e.target.value) }))} />
<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()}>
<h3>{lc.wizardSectionTitle}</h3>
<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>
<button
className="btn btn-primary btn-sm icon-btn"
disabled={isSavingGlobalDelivery || !selectedBot}
onClick={() => void saveGlobalDelivery()}
title={lc.saveChannel}
aria-label={lc.saveChannel}
>
<Save size={14} />
</button>
</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>
<button
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isDashboardChannel(channel) || isSavingChannel}
onClick={() => void removeChannel(channel)}
title={lc.remove}
>
<Trash2 size={14} />
</button>
</div>
</div>
{renderChannelFields(channel, idx)}
<div className="row-between">
<span className="field-label">{lc.customChannel}</span>
<button
className="btn btn-primary btn-sm icon-btn"
disabled={isSavingChannel}
onClick={() => void saveChannel(channel)}
title={lc.saveChannel}
aria-label={lc.saveChannel}
>
<Save size={14} />
</button>
</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>
<button
className="btn btn-secondary btn-sm icon-btn"
disabled={addableChannelTypes.length === 0 || isSavingChannel}
onClick={() => void addChannel()}
title={lc.addChannel}
aria-label={lc.addChannel}
>
<Plus size={14} />
</button>
</div>
<div className="row-between">
<span className="field-label">{lc.wizardSectionDesc}</span>
<button className="btn btn-primary" onClick={() => setShowChannelModal(false)}>{lc.close}</button>
</div>
</div>
</div>
)}
{showSkillsModal && (
<div className="modal-mask" onClick={() => setShowSkillsModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<h3>{t.skillsPanel}</h3>
<div className="wizard-channel-list">
{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>
<button
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => void removeBotSkill(skill)}
title={t.removeSkill}
>
<Trash2 size={14} />
</button>
</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 className="row-between">
<span className="field-label"> </span>
<button className="btn btn-primary" onClick={() => setShowSkillsModal(false)}>{t.close}</button>
</div>
</div>
</div>
)}
{showEnvParamsModal && (
<div className="modal-mask" onClick={() => setShowEnvParamsModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<h3>{t.envParams}</h3>
<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}
/>
<button
className="btn btn-secondary btn-sm wizard-icon-btn"
onClick={() => setEnvVisibleByKey((prev) => ({ ...prev, [key]: !prev[key] }))}
title={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
aria-label={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
>
{envVisibleByKey[key] ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => removeEnvParam(key)}
title={t.removeEnvParam}
>
<Trash2 size={14} />
</button>
</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}
/>
<button
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setEnvDraftVisible((v) => !v)}
title={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
aria-label={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
>
{envDraftVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
const key = String(envDraftKey || '').trim().toUpperCase();
if (!key) return;
upsertEnvParam(key, envDraftValue);
setEnvDraftValue('');
}}
title={t.addEnvParam}
aria-label={t.addEnvParam}
>
<Plus size={14} />
</button>
</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="row-between">
<h3>{t.cronViewer}</h3>
<button
className="btn btn-secondary btn-sm icon-btn"
onClick={() => selectedBot && void loadCronJobs(selectedBot.id)}
title={t.cronReload}
aria-label={t.cronReload}
disabled={cronLoading}
>
<RefreshCw size={14} className={cronLoading ? 'animate-spin' : ''} />
</button>
</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">
{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">
<button
className="btn btn-danger btn-sm icon-btn"
onClick={() => void stopCronJob(job.id)}
title={t.cronStop}
aria-label={t.cronStop}
disabled={stopping || job.enabled === false}
>
<PowerOff size={13} />
</button>
<button
className="btn btn-danger btn-sm icon-btn"
onClick={() => void deleteCronJob(job.id)}
title={t.cronDelete}
aria-label={t.cronDelete}
disabled={stopping}
>
<Trash2 size={13} />
</button>
</div>
</div>
);
})}
</div>
)}
<div className="row-between">
<button className="btn btn-secondary" onClick={() => setShowCronModal(false)}>{t.close}</button>
<span />
</div>
</div>
</div>
)}
{showAgentModal && (
<div className="modal-mask" onClick={() => setShowAgentModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<h3>{t.agentFiles}</h3>
<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>
)}
{workspacePreview && (
<div className="modal-mask" onClick={() => setWorkspacePreview(null)}>
<div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row">
<h3>{t.filePreview}</h3>
<span className="modal-sub mono">{workspacePreview.path}</span>
</div>
<div className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''}`}>
{workspacePreview.isMarkdown ? (
<div className="workspace-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} 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 }}>
<a
className="btn btn-secondary"
href={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}&download=1`}
target="_blank"
rel="noopener noreferrer"
download={workspacePreview.path.split('/').pop() || 'workspace-file'}
>
{t.download}
</a>
<button className="btn btn-primary" onClick={() => setWorkspacePreview(null)}>{t.close}</button>
</div>
</div>
</div>
</div>
)}
</>
);
}