2026-03-01 16:26:03 +00:00
|
|
|
|
import { useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
|
|
|
|
|
|
import axios from 'axios';
|
2026-03-02 04:38:01 +00:00
|
|
|
|
import { Activity, Boxes, Check, Clock3, EllipsisVertical, Eye, EyeOff, FileText, FolderOpen, Hammer, Maximize2, MessageSquareText, Minimize2, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Save, Settings2, SlidersHorizontal, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
|
2026-03-01 16:26:03 +00:00
|
|
|
|
import ReactMarkdown from 'react-markdown';
|
|
|
|
|
|
import remarkGfm from 'remark-gfm';
|
2026-03-02 02:54:40 +00:00
|
|
|
|
import rehypeRaw from 'rehype-raw';
|
|
|
|
|
|
import rehypeSanitize from 'rehype-sanitize';
|
2026-03-01 16:26:03 +00:00
|
|
|
|
import { APP_ENDPOINTS } from '../../config/env';
|
|
|
|
|
|
import { useAppStore } from '../../store/appStore';
|
|
|
|
|
|
import type { ChatMessage } from '../../types/bot';
|
|
|
|
|
|
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from './messageParser';
|
|
|
|
|
|
import nanobotLogo from '../../assets/nanobot-logo.png';
|
|
|
|
|
|
import './BotDashboardModule.css';
|
|
|
|
|
|
import { channelsZhCn } from '../../i18n/channels.zh-cn';
|
|
|
|
|
|
import { channelsEn } from '../../i18n/channels.en';
|
|
|
|
|
|
import { pickLocale } from '../../i18n';
|
|
|
|
|
|
import { dashboardZhCn } from '../../i18n/dashboard.zh-cn';
|
|
|
|
|
|
import { dashboardEn } from '../../i18n/dashboard.en';
|
|
|
|
|
|
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-01 19:44:06 +00:00
|
|
|
|
isImage: boolean;
|
2026-03-01 16:26:03 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface WorkspaceUploadResponse {
|
|
|
|
|
|
bot_id: string;
|
|
|
|
|
|
files: Array<{ name: string; path: string; size: number }>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CronJob {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
enabled?: boolean;
|
|
|
|
|
|
schedule?: {
|
|
|
|
|
|
kind?: 'at' | 'every' | 'cron' | string;
|
|
|
|
|
|
atMs?: number;
|
|
|
|
|
|
everyMs?: number;
|
|
|
|
|
|
expr?: string;
|
|
|
|
|
|
tz?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
payload?: {
|
|
|
|
|
|
message?: string;
|
|
|
|
|
|
channel?: string;
|
|
|
|
|
|
to?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
state?: {
|
|
|
|
|
|
nextRunAtMs?: number;
|
|
|
|
|
|
lastRunAtMs?: number;
|
|
|
|
|
|
lastStatus?: string;
|
|
|
|
|
|
lastError?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CronJobsResponse {
|
|
|
|
|
|
bot_id: string;
|
|
|
|
|
|
version: number;
|
|
|
|
|
|
jobs: CronJob[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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': 'Kimi(Moonshot)接口,模型示例 moonshot-v1-8k',
|
|
|
|
|
|
en: 'Kimi (Moonshot) endpoint, model example: moonshot-v1-8k',
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
minimax: {
|
|
|
|
|
|
model: 'MiniMax-Text-01',
|
|
|
|
|
|
apiBase: 'https://api.minimax.chat/v1',
|
|
|
|
|
|
note: {
|
|
|
|
|
|
'zh-cn': 'MiniMax 接口,模型示例 MiniMax-Text-01',
|
|
|
|
|
|
en: 'MiniMax endpoint, model example: MiniMax-Text-01',
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
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();
|
2026-03-02 04:38:01 +00:00
|
|
|
|
return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].includes(ext);
|
2026-03-01 16:26:03 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isPdfPath(path: string) {
|
|
|
|
|
|
return String(path || '').trim().toLowerCase().endsWith('.pdf');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 19:44:06 +00:00
|
|
|
|
function isImagePath(path: string) {
|
|
|
|
|
|
const normalized = String(path || '').trim().toLowerCase();
|
|
|
|
|
|
return normalized.endsWith('.png') || normalized.endsWith('.jpg') || normalized.endsWith('.jpeg') || normalized.endsWith('.webp');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 04:38:01 +00:00
|
|
|
|
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),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 19:44:06 +00:00
|
|
|
|
function isPreviewableWorkspacePath(path: string) {
|
|
|
|
|
|
const normalized = String(path || '').trim().toLowerCase();
|
2026-03-02 04:38:01 +00:00
|
|
|
|
return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) =>
|
2026-03-01 19:44:06 +00:00
|
|
|
|
normalized.endsWith(ext),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/';
|
|
|
|
|
|
|
|
|
|
|
|
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\/[^)\s]+)\)/g,
|
|
|
|
|
|
'[$1]($2)',
|
|
|
|
|
|
);
|
2026-03-01 20:27:58 +00:00
|
|
|
|
const workspacePathPattern =
|
2026-03-02 04:38:01 +00:00
|
|
|
|
/\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b/gi;
|
2026-03-01 19:44:06 +00:00
|
|
|
|
return normalizedExistingLinks.replace(workspacePathPattern, (fullPath) => {
|
|
|
|
|
|
const normalized = normalizeDashboardAttachmentPath(fullPath);
|
|
|
|
|
|
if (!normalized) return fullPath;
|
|
|
|
|
|
return `[${fullPath}](${buildWorkspaceLink(normalized)})`;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 16:26:03 +00:00
|
|
|
|
function normalizeAttachmentPaths(raw: unknown): string[] {
|
|
|
|
|
|
if (!Array.isArray(raw)) return [];
|
|
|
|
|
|
return raw
|
|
|
|
|
|
.map((v) => String(v || '').trim().replace(/\\/g, '/'))
|
|
|
|
|
|
.filter((v) => v.length > 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeDashboardAttachmentPath(path: string): string {
|
|
|
|
|
|
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();
|
2026-03-01 19:44:06 +00:00
|
|
|
|
const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable';
|
2026-03-01 16:26:03 +00:00
|
|
|
|
const [selectedBotId, setSelectedBotId] = useState('');
|
|
|
|
|
|
const [command, setCommand] = useState('');
|
|
|
|
|
|
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);
|
2026-03-02 04:38:01 +00:00
|
|
|
|
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
|
2026-03-01 16:26:03 +00:00
|
|
|
|
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);
|
2026-03-01 19:44:06 +00:00
|
|
|
|
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
|
2026-03-01 20:27:58 +00:00
|
|
|
|
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
|
2026-03-01 16:26:03 +00:00
|
|
|
|
const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
|
2026-03-02 04:38:01 +00:00
|
|
|
|
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) =>
|
|
|
|
|
|
`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(filePath)}${forceDownload ? '&download=1' : ''}`;
|
|
|
|
|
|
const closeWorkspacePreview = () => {
|
|
|
|
|
|
setWorkspacePreview(null);
|
|
|
|
|
|
setWorkspacePreviewFullscreen(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
const triggerWorkspaceFileDownload = (filePath: string) => {
|
|
|
|
|
|
if (!selectedBotId) return;
|
|
|
|
|
|
const normalized = String(filePath || '').trim();
|
|
|
|
|
|
if (!normalized) return;
|
|
|
|
|
|
const filename = normalized.split('/').pop() || 'workspace-file';
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
|
link.href = buildWorkspaceDownloadHref(normalized, true);
|
|
|
|
|
|
link.download = filename;
|
|
|
|
|
|
link.rel = 'noopener noreferrer';
|
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
link.remove();
|
|
|
|
|
|
};
|
2026-03-01 19:44:06 +00:00
|
|
|
|
const openWorkspacePathFromChat = (path: string) => {
|
|
|
|
|
|
const normalized = String(path || '').trim();
|
|
|
|
|
|
if (!normalized) return;
|
2026-03-02 04:38:01 +00:00
|
|
|
|
if (isPdfPath(normalized) || isOfficePath(normalized)) {
|
|
|
|
|
|
triggerWorkspaceFileDownload(normalized);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-01 19:44:06 +00:00
|
|
|
|
if (!isPreviewableWorkspacePath(normalized)) {
|
|
|
|
|
|
notify(fileNotPreviewableLabel, { tone: 'warning' });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
void openWorkspaceFilePreview(normalized);
|
|
|
|
|
|
};
|
|
|
|
|
|
const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => {
|
|
|
|
|
|
const source = String(text || '');
|
|
|
|
|
|
if (!source) return [source];
|
|
|
|
|
|
const pattern =
|
2026-03-02 04:38:01 +00:00
|
|
|
|
/\[(\/root\/\.nanobot\/workspace\/[^\]]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps))\]\((https:\/\/workspace\.local\/open\/[^)\s]+)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b|https:\/\/workspace\.local\/open\/[^\s)]+/gi;
|
2026-03-01 19:44:06 +00:00
|
|
|
|
const nodes: ReactNode[] = [];
|
|
|
|
|
|
let lastIndex = 0;
|
|
|
|
|
|
let matchIndex = 0;
|
|
|
|
|
|
let match = pattern.exec(source);
|
|
|
|
|
|
while (match) {
|
|
|
|
|
|
if (match.index > lastIndex) {
|
|
|
|
|
|
nodes.push(source.slice(lastIndex, match.index));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const raw = match[0];
|
|
|
|
|
|
const markdownPath = match[1] ? String(match[1]) : '';
|
|
|
|
|
|
const markdownHref = match[2] ? String(match[2]) : '';
|
|
|
|
|
|
let normalizedPath = '';
|
|
|
|
|
|
let displayText = raw;
|
|
|
|
|
|
|
|
|
|
|
|
if (markdownPath && markdownHref) {
|
|
|
|
|
|
normalizedPath = normalizeDashboardAttachmentPath(markdownPath);
|
|
|
|
|
|
displayText = markdownPath;
|
|
|
|
|
|
} else if (raw.startsWith(WORKSPACE_LINK_PREFIX)) {
|
|
|
|
|
|
normalizedPath = String(parseWorkspaceLink(raw) || '').trim();
|
|
|
|
|
|
displayText = normalizedPath ? `/root/.nanobot/workspace/${normalizedPath}` : raw;
|
|
|
|
|
|
} else if (raw.startsWith('/root/.nanobot/workspace/')) {
|
|
|
|
|
|
normalizedPath = normalizeDashboardAttachmentPath(raw);
|
|
|
|
|
|
displayText = raw;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (normalizedPath) {
|
|
|
|
|
|
nodes.push(
|
|
|
|
|
|
<a
|
|
|
|
|
|
key={`${keyPrefix}-ws-${matchIndex}`}
|
|
|
|
|
|
href="#"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-03-01 16:26:03 +00:00
|
|
|
|
const markdownComponents = useMemo(
|
|
|
|
|
|
() => ({
|
|
|
|
|
|
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
|
|
|
|
|
const link = String(href || '').trim();
|
2026-03-01 19:44:06 +00:00
|
|
|
|
const workspacePath = parseWorkspaceLink(link);
|
|
|
|
|
|
if (workspacePath) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<a
|
|
|
|
|
|
href="#"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
2026-03-02 04:38:01 +00:00
|
|
|
|
openWorkspacePathFromChat(workspacePath);
|
2026-03-01 19:44:06 +00:00
|
|
|
|
}}
|
|
|
|
|
|
{...props}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-01 16:26:03 +00:00
|
|
|
|
if (isExternalHttpLink(link)) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<a href={link} target="_blank" rel="noopener noreferrer" {...props}>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<a href={link || '#'} {...props}>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
2026-03-01 19:44:06 +00:00
|
|
|
|
p: ({ children, ...props }: { children?: ReactNode }) => (
|
|
|
|
|
|
<p {...props}>{renderWorkspaceAwareChildren(children, 'md-p')}</p>
|
|
|
|
|
|
),
|
|
|
|
|
|
li: ({ children, ...props }: { children?: ReactNode }) => (
|
|
|
|
|
|
<li {...props}>{renderWorkspaceAwareChildren(children, 'md-li')}</li>
|
|
|
|
|
|
),
|
|
|
|
|
|
code: ({ children, ...props }: { children?: ReactNode }) => (
|
|
|
|
|
|
<code {...props}>{renderWorkspaceAwareChildren(children, 'md-code')}</code>
|
|
|
|
|
|
),
|
2026-03-01 16:26:03 +00:00
|
|
|
|
}),
|
2026-03-01 19:44:06 +00:00
|
|
|
|
[fileNotPreviewableLabel, notify, selectedBotId],
|
2026-03-01 16:26:03 +00:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const [editForm, setEditForm] = useState({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
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(() => {
|
2026-03-01 20:04:42 +00:00
|
|
|
|
const action = normalizeAssistantMessageText(selectedBot?.last_action || '').trim();
|
|
|
|
|
|
if (action) return action;
|
|
|
|
|
|
const eventText = normalizeAssistantMessageText(latestEvent?.text || '').trim();
|
|
|
|
|
|
if (eventText) return eventText;
|
2026-03-01 16:26:03 +00:00
|
|
|
|
return '-';
|
2026-03-01 20:04:42 +00:00
|
|
|
|
}, [selectedBot, latestEvent]);
|
|
|
|
|
|
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]);
|
2026-03-01 20:27:58 +00:00
|
|
|
|
const runtimeActionDisplay = runtimeActionHasMore ? runtimeActionSummary : runtimeAction;
|
2026-03-01 19:44:06 +00:00
|
|
|
|
|
|
|
|
|
|
const shouldCollapseProgress = (text: string) => {
|
|
|
|
|
|
const normalized = String(text || '').trim();
|
|
|
|
|
|
if (!normalized) return false;
|
|
|
|
|
|
const lines = normalized.split('\n').length;
|
|
|
|
|
|
return lines > 6 || normalized.length > 520;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-01 16:26:03 +00:00
|
|
|
|
const conversationNodes = useMemo(
|
|
|
|
|
|
() =>
|
2026-03-01 19:44:06 +00:00
|
|
|
|
conversation.map((item, idx) => {
|
|
|
|
|
|
const itemKey = `${item.ts}-${idx}`;
|
|
|
|
|
|
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
|
2026-03-01 20:04:42 +00:00
|
|
|
|
const fullText = String(item.text || '');
|
|
|
|
|
|
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
|
|
|
|
|
|
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
|
|
|
|
|
|
const collapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText));
|
2026-03-01 19:44:06 +00:00
|
|
|
|
const expanded = Boolean(expandedProgressByKey[itemKey]);
|
2026-03-01 20:04:42 +00:00
|
|
|
|
const displayText = isProgressBubble && !expanded ? summaryText : fullText;
|
2026-03-01 19:44:06 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div key={itemKey} className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
|
2026-03-01 16:26:03 +00:00
|
|
|
|
<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>
|
2026-03-01 20:27:58 +00:00
|
|
|
|
<div className="ops-chat-meta-right">
|
|
|
|
|
|
<span className="mono">{formatClock(item.ts)}</span>
|
|
|
|
|
|
{collapsible ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="ops-chat-expand-icon-btn"
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setExpandedProgressByKey((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[itemKey]: !prev[itemKey],
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
title={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
|
|
|
|
|
|
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
|
|
|
|
|
|
>
|
|
|
|
|
|
{expanded ? '×' : '…'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
2026-03-01 16:26:03 +00:00
|
|
|
|
</div>
|
2026-03-01 19:44:06 +00:00
|
|
|
|
<div className={`ops-chat-text ${collapsible && !expanded ? 'is-collapsed' : ''}`}>
|
2026-03-01 16:26:03 +00:00
|
|
|
|
{item.text ? (
|
|
|
|
|
|
item.role === 'user' ? (
|
|
|
|
|
|
<div className="whitespace-pre-wrap">{normalizeUserMessageText(item.text)}</div>
|
|
|
|
|
|
) : (
|
2026-03-02 02:54:40 +00:00
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
|
|
|
|
|
components={markdownComponents}
|
|
|
|
|
|
>
|
2026-03-01 20:04:42 +00:00
|
|
|
|
{decorateWorkspacePathsForMarkdown(displayText)}
|
2026-03-01 16:26:03 +00:00
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
)
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{(item.attachments || []).length > 0 ? (
|
|
|
|
|
|
<div className="ops-chat-attachments">
|
|
|
|
|
|
{(item.attachments || []).map((rawPath) => {
|
|
|
|
|
|
const filePath = normalizeDashboardAttachmentPath(rawPath);
|
|
|
|
|
|
const filename = filePath.split('/').pop() || filePath;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<a
|
|
|
|
|
|
key={`${item.ts}-${filePath}`}
|
|
|
|
|
|
className="ops-attach-link mono"
|
2026-03-02 04:38:01 +00:00
|
|
|
|
href="#"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
if (isPdfPath(filePath) || isOfficePath(filePath)) {
|
|
|
|
|
|
triggerWorkspaceFileDownload(filePath);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
openWorkspacePathFromChat(filePath);
|
|
|
|
|
|
}}
|
2026-03-01 16:26:03 +00:00
|
|
|
|
>
|
|
|
|
|
|
{filename}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{item.role === 'user' && (
|
|
|
|
|
|
<div className="ops-avatar user" title={t.user}>
|
|
|
|
|
|
<UserRound size={18} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-01 19:44:06 +00:00
|
|
|
|
)}),
|
|
|
|
|
|
[conversation, expandedProgressByKey, isZh, selectedBotId, t.user, t.you],
|
2026-03-01 16:26:03 +00:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const forced = String(forcedBotId || '').trim();
|
|
|
|
|
|
if (forced) {
|
|
|
|
|
|
if (activeBots[forced]) {
|
|
|
|
|
|
if (selectedBotId !== forced) setSelectedBotId(forced);
|
|
|
|
|
|
} else if (selectedBotId) {
|
|
|
|
|
|
setSelectedBotId('');
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-01 19:44:06 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setExpandedProgressByKey({});
|
2026-03-01 20:27:58 +00:00
|
|
|
|
setShowRuntimeActionModal(false);
|
2026-03-01 19:44:06 +00:00
|
|
|
|
}, [selectedBotId]);
|
|
|
|
|
|
|
2026-03-01 16:26:03 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!compactMode) {
|
|
|
|
|
|
setIsCompactMobile(false);
|
|
|
|
|
|
setCompactPanelTab('chat');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const media = window.matchMedia('(max-width: 980px)');
|
|
|
|
|
|
const apply = () => setIsCompactMobile(media.matches);
|
|
|
|
|
|
apply();
|
|
|
|
|
|
media.addEventListener('change', apply);
|
|
|
|
|
|
return () => media.removeEventListener('change', apply);
|
|
|
|
|
|
}, [compactMode]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!selectedBotId) return;
|
|
|
|
|
|
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();
|
2026-03-02 04:38:01 +00:00
|
|
|
|
setWorkspacePreviewFullscreen(false);
|
|
|
|
|
|
if (isPdfPath(normalizedPath) || isOfficePath(normalizedPath)) {
|
|
|
|
|
|
triggerWorkspaceFileDownload(normalizedPath);
|
2026-03-01 16:26:03 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-01 19:44:06 +00:00
|
|
|
|
if (isImagePath(normalizedPath)) {
|
|
|
|
|
|
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
|
|
|
|
|
|
setWorkspacePreview({
|
|
|
|
|
|
path: normalizedPath,
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
truncated: false,
|
|
|
|
|
|
ext: fileExt ? `.${fileExt}` : '',
|
|
|
|
|
|
isMarkdown: false,
|
|
|
|
|
|
isImage: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-01 16:26:03 +00:00
|
|
|
|
setWorkspaceFileLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await axios.get<WorkspaceFileResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, {
|
|
|
|
|
|
params: { path, max_bytes: 400000 },
|
|
|
|
|
|
});
|
|
|
|
|
|
const filePath = res.data.path || path;
|
|
|
|
|
|
const textExt = (filePath.split('.').pop() || '').toLowerCase();
|
|
|
|
|
|
let content = res.data.content || '';
|
|
|
|
|
|
if (textExt === 'json') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
content = JSON.stringify(JSON.parse(content), null, 2);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Keep original content when JSON is not strictly parseable.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
setWorkspacePreview({
|
|
|
|
|
|
path: filePath,
|
|
|
|
|
|
content,
|
|
|
|
|
|
truncated: Boolean(res.data.truncated),
|
|
|
|
|
|
ext: textExt ? `.${textExt}` : '',
|
|
|
|
|
|
isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown),
|
2026-03-01 19:44:06 +00:00
|
|
|
|
isImage: false,
|
2026-03-01 16:26:03 +00:00
|
|
|
|
});
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
const msg = error?.response?.data?.detail || t.fileReadFail;
|
|
|
|
|
|
notify(msg, { tone: 'error' });
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setWorkspaceFileLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadWorkspaceTree = async (botId: string, path: string = '') => {
|
|
|
|
|
|
if (!botId) return;
|
|
|
|
|
|
setWorkspaceLoading(true);
|
|
|
|
|
|
setWorkspaceError('');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, {
|
|
|
|
|
|
params: { path },
|
|
|
|
|
|
});
|
|
|
|
|
|
const entries = Array.isArray(res.data?.entries) ? res.data.entries : [];
|
|
|
|
|
|
setWorkspaceEntries(entries);
|
|
|
|
|
|
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),
|
|
|
|
|
|
});
|
2026-03-01 19:44:06 +00:00
|
|
|
|
if (selectedBot.docker_status === 'RUNNING') {
|
|
|
|
|
|
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/stop`);
|
|
|
|
|
|
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/start`);
|
|
|
|
|
|
}
|
2026-03-01 16:26:03 +00:00
|
|
|
|
await refresh();
|
|
|
|
|
|
notify(t.channelSaved, { tone: 'success' });
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
const msg = error?.response?.data?.detail || t.channelSaveFail;
|
|
|
|
|
|
notify(msg, { tone: 'error' });
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsSavingGlobalDelivery(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-02 04:38:01 +00:00
|
|
|
|
const downloadOnlyFile = isPdfPath(node.path) || isOfficePath(node.path);
|
2026-03-01 16:26:03 +00:00
|
|
|
|
rendered.push(
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={key}
|
|
|
|
|
|
className={`workspace-entry file ${previewable ? '' : 'disabled'}`}
|
|
|
|
|
|
disabled={!previewable || workspaceFileLoading}
|
|
|
|
|
|
onClick={() => void openWorkspaceFilePreview(node.path)}
|
2026-03-02 04:38:01 +00:00
|
|
|
|
title={previewable ? (downloadOnlyFile ? t.download : t.previewTitle) : t.fileNotPreviewable}
|
2026-03-01 16:26:03 +00:00
|
|
|
|
>
|
|
|
|
|
|
<FileText size={14} />
|
|
|
|
|
|
<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>
|
2026-03-01 20:04:42 +00:00
|
|
|
|
<div className="ops-runtime-row">
|
|
|
|
|
|
<span>{t.lastAction}</span>
|
2026-03-01 20:27:58 +00:00
|
|
|
|
<div className="ops-runtime-action-inline">
|
|
|
|
|
|
<strong className="ops-runtime-action-text">{runtimeActionDisplay}</strong>
|
2026-03-01 20:04:42 +00:00
|
|
|
|
{runtimeActionHasMore ? (
|
|
|
|
|
|
<button
|
2026-03-01 20:27:58 +00:00
|
|
|
|
className="ops-runtime-expand-btn"
|
|
|
|
|
|
onClick={() => setShowRuntimeActionModal(true)}
|
|
|
|
|
|
title={isZh ? '查看完整内容' : 'Show full content'}
|
|
|
|
|
|
aria-label={isZh ? '查看完整内容' : 'Show full content'}
|
2026-03-01 20:04:42 +00:00
|
|
|
|
>
|
2026-03-01 20:27:58 +00:00
|
|
|
|
…
|
2026-03-01 20:04:42 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-01 16:26:03 +00:00
|
|
|
|
<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);
|
2026-03-01 19:44:06 +00:00
|
|
|
|
setEnvDraftKey('');
|
2026-03-01 16:26:03 +00:00
|
|
|
|
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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-01 20:27:58 +00:00
|
|
|
|
{showRuntimeActionModal && (
|
|
|
|
|
|
<div className="modal-mask" onClick={() => setShowRuntimeActionModal(false)}>
|
|
|
|
|
|
<div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
<div className="modal-title-row">
|
|
|
|
|
|
<h3>{t.lastAction}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="workspace-preview-body">
|
|
|
|
|
|
<pre>{runtimeAction}</pre>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="row-between">
|
|
|
|
|
|
<span />
|
|
|
|
|
|
<button className="btn btn-primary" onClick={() => setShowRuntimeActionModal(false)}>{t.close}</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-01 16:26:03 +00:00
|
|
|
|
{workspacePreview && (
|
2026-03-02 04:38:01 +00:00
|
|
|
|
<div className="modal-mask" onClick={closeWorkspacePreview}>
|
|
|
|
|
|
<div className={`modal-card modal-preview ${workspacePreviewFullscreen ? 'modal-preview-fullscreen' : ''}`} onClick={(e) => e.stopPropagation()}>
|
2026-03-01 16:26:03 +00:00
|
|
|
|
<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' : ''}`}>
|
2026-03-01 19:44:06 +00:00
|
|
|
|
{workspacePreview.isImage ? (
|
|
|
|
|
|
<img
|
|
|
|
|
|
className="workspace-preview-image"
|
|
|
|
|
|
src={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}`}
|
|
|
|
|
|
alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : workspacePreview.isMarkdown ? (
|
2026-03-01 16:26:03 +00:00
|
|
|
|
<div className="workspace-markdown">
|
2026-03-02 02:54:40 +00:00
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
|
|
|
|
|
components={markdownComponents}
|
|
|
|
|
|
>
|
2026-03-01 16:26:03 +00:00
|
|
|
|
{workspacePreview.content}
|
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<pre>{workspacePreview.content}</pre>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{workspacePreview.truncated ? (
|
|
|
|
|
|
<div className="ops-empty-inline">{t.fileTruncated}</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<div className="row-between">
|
|
|
|
|
|
<span className="workspace-preview-meta mono">{workspacePreview.ext || '-'}</span>
|
|
|
|
|
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
2026-03-02 04:38:01 +00:00
|
|
|
|
<button
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
onClick={() => setWorkspacePreviewFullscreen((v) => !v)}
|
|
|
|
|
|
title={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} />}
|
|
|
|
|
|
</button>
|
2026-03-01 16:26:03 +00:00
|
|
|
|
<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>
|
2026-03-02 04:38:01 +00:00
|
|
|
|
<button className="btn btn-primary" onClick={closeWorkspacePreview}>{t.close}</button>
|
2026-03-01 16:26:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|