feat: 新增工具插件、系统日志、workflow配置及前端优化

1. 新增工具插件(edit_file, python_executor, search_file, shell_executor, write_file)
2. 新增系统事件日志模块和API
3. 新增workflow配置文件和详情API
4. 前端增加SSE、错误边界、设置引导等组件
5. 优化认证加密、速率限制、配置加载等工具模块
6. 删除废弃的cluster和health API
7. 补充单元测试和集成测试

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 07:34:43 +00:00
parent f04fef916f
commit a53ffebe0e
57 changed files with 2804 additions and 271 deletions
+14 -1
View File
@@ -1,7 +1,8 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import i18n from './i18n';
import { TopBar } from './components/Layout/TopBar';
import { CollapsibleSidebar } from './components/Layout/CollapsibleSidebar';
import { SetupGuideModal } from './components/Layout/SetupGuideModal';
import { SettingsLayout } from './components/Settings/SettingsLayout';
import { AgentLayout } from './components/Agent/AgentLayout';
import { PluginLayout } from './components/Plugin/PluginLayout';
@@ -19,6 +20,7 @@ function App() {
isAuthenticated,
setIsAuthenticated,
mode,
setMode,
showSettings,
workTab,
agentTab,
@@ -27,6 +29,7 @@ function App() {
} = useAppStore();
const { loadSessions } = useChatStore();
const [showSetupGuide, setShowSetupGuide] = useState(false);
useEffect(() => {
applyTheme();
@@ -52,6 +55,7 @@ function App() {
useEffect(() => {
if (isAuthenticated) {
loadSessions();
setShowSetupGuide(true);
}
}, [isAuthenticated, loadSessions]);
@@ -63,6 +67,15 @@ function App() {
<div className="flex flex-col h-screen w-screen bg-bg-primary text-text-primary font-sans overflow-hidden">
<TopBar />
{showSetupGuide && (
<SetupGuideModal
onClose={() => setShowSetupGuide(false)}
onNavigateToAgent={() => {
setMode('agent');
}}
/>
)}
<div className="flex flex-1 overflow-hidden relative">
{showSettings ? (
<SettingsLayout />
+130
View File
@@ -0,0 +1,130 @@
// 基于 fetch + ReadableStream 的轻量 SSE 客户端,带指数退避自动重连。
//
// 原生 EventSource 无法携带自定义 header,只能把 token 放进 URL query
// 而 token 进 URL 会被网关/浏览器历史/Referer 记录,存在泄露风险。
// 这里用 fetch 手动读取 text/event-streamtoken 走标准 Authorization header。
export interface SSEHandlers {
onOpen?: () => void;
onMessage?: (data: string) => void;
onError?: (err: unknown) => void;
// 连接断开、准备重连时回调,附带本次退避延迟(毫秒)
onReconnect?: (delayMs: number) => void;
}
export interface SSEOptions {
// 初始重连延迟(毫秒),默认 1000
baseDelayMs?: number;
// 最大重连延迟(毫秒),默认 30000
maxDelayMs?: number;
// 鉴权失败(401/403)时是否停止重连,默认 true
stopOnAuthError?: boolean;
}
export interface SSEConnection {
close: () => void;
}
const AUTH_ERROR_STATUSES = new Set([401, 403]);
export function connectSSE(
url: string,
token: string,
handlers: SSEHandlers,
options: SSEOptions = {},
): SSEConnection {
const baseDelay = options.baseDelayMs ?? 1000;
const maxDelay = options.maxDelayMs ?? 30000;
const stopOnAuthError = options.stopOnAuthError ?? true;
let controller = new AbortController();
let closed = false;
let attempt = 0;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleReconnect = () => {
if (closed) return;
// 指数退避 + 抖动,封顶 maxDelay
const backoff = Math.min(baseDelay * 2 ** attempt, maxDelay);
const delay = backoff / 2 + Math.random() * (backoff / 2);
attempt += 1;
handlers.onReconnect?.(delay);
retryTimer = setTimeout(() => {
if (closed) return;
controller = new AbortController();
void run();
}, delay);
};
const run = async () => {
try {
const resp = await fetch(url, {
method: 'GET',
headers: {
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: controller.signal,
});
if (!resp.ok || !resp.body) {
handlers.onError?.(new Error(`SSE connect failed: ${resp.status}`));
if (stopOnAuthError && AUTH_ERROR_STATUSES.has(resp.status)) {
closed = true;
return;
}
scheduleReconnect();
return;
}
// 连接成功,重置退避计数
attempt = 0;
handlers.onOpen?.();
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const rawEvent = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const data = parseEventData(rawEvent);
if (data !== null) handlers.onMessage?.(data);
}
}
// 流正常结束(服务端关闭),非主动 close 则尝试重连
if (!closed) scheduleReconnect();
} catch (err) {
if (controller.signal.aborted || closed) return;
handlers.onError?.(err);
scheduleReconnect();
}
};
void run();
return {
close: () => {
closed = true;
if (retryTimer) clearTimeout(retryTimer);
controller.abort();
},
};
}
function parseEventData(rawEvent: string): string | null {
// 只关心 data: 行,多行 data 用 \n 拼接,忽略注释(:)与其他字段
const dataLines = rawEvent
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^ /, ''));
if (dataLines.length === 0) return null;
return dataLines.join('\n');
}
@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next';
import { useAppStore } from '../../store/useAppStore';
import { ProvidersSettings } from './ProvidersSettings';
import { WorkerIndividualSettings } from './WorkerIndividualSettings';
import { WorkflowConfigSettings } from './WorkflowConfigSettings';
import { SystemLogsView } from './SystemLogsView';
export function AgentLayout() {
const { t } = useTranslation();
@@ -10,6 +12,8 @@ export function AgentLayout() {
const tabs = [
{ key: 'worker', label: t('agent.individual') },
{ key: 'providers', label: t('agent.providerManagement') },
{ key: 'config', label: t('agent.config') },
{ key: 'logs', label: t('agent.systemLogs') },
];
return (
@@ -32,6 +36,8 @@ export function AgentLayout() {
<div className="flex-1 overflow-y-auto p-8">
{innerAgentTab === 'worker' && <WorkerIndividualSettings />}
{innerAgentTab === 'providers' && <ProvidersSettings />}
{innerAgentTab === 'config' && <WorkflowConfigSettings />}
{innerAgentTab === 'logs' && <SystemLogsView />}
</div>
</div>
);
@@ -0,0 +1,144 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { RefreshCw, Search } from 'lucide-react';
interface EventLog {
id: number;
trace_id: string;
event_type: string;
level: string;
node_name: string | null;
message: string;
metadata: Record<string, any> | null;
created_at: string | null;
}
const LEVEL_STYLES: Record<string, { bg: string; text: string; label: string }> = {
error: { bg: 'bg-[rgba(196,145,122,0.12)]', text: 'text-[#a0705a]', label: 'ERROR' },
warn: { bg: 'bg-[rgba(196,168,130,0.15)]', text: 'text-[#9a7d5e]', label: 'WARN' },
info: { bg: 'bg-[rgba(156,175,136,0.12)]', text: 'text-[#7a8e6a]', label: 'INFO' },
};
export function SystemLogsView() {
const { t } = useTranslation();
const [logs, setLogs] = useState<EventLog[]>([]);
const [loading, setLoading] = useState(false);
const [traceFilter, setTraceFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [levelFilter, setLevelFilter] = useState('');
const fetchLogs = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (traceFilter) params.set('trace_id', traceFilter);
if (typeFilter) params.set('event_type', typeFilter);
if (levelFilter) params.set('level', levelFilter);
params.set('limit', '200');
const resp = await fetch(`/api/v1/system/logs?${params.toString()}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
});
if (!resp.ok) throw new Error('Failed to fetch logs');
const data = await resp.json();
setLogs(data.logs || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchLogs(); }, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
fetchLogs();
};
return (
<div className="max-w-6xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-text-primary">{t('agent.systemLogs')}</h2>
<button onClick={fetchLogs} disabled={loading} className="p-2 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<form onSubmit={handleSearch} className="grid grid-cols-4 gap-3 mb-5">
<input
type="text"
value={traceFilter}
onChange={(e) => setTraceFilter(e.target.value)}
placeholder={t('agent.logFilterTraceId')}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent/15"
/>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
>
<option value="">{t('agent.logFilterAllTypes')}</option>
<option value="workflow_start">workflow_start</option>
<option value="step_enter">step_enter</option>
<option value="step_complete">step_complete</option>
<option value="step_error">step_error</option>
<option value="workflow_complete">workflow_complete</option>
<option value="workflow_fail">workflow_fail</option>
<option value="system">system</option>
</select>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
>
<option value="">{t('agent.logFilterAllLevels')}</option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
<button type="submit" className="flex items-center justify-center gap-2 px-4 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">
<Search size={14} /> {t('agent.logSearch')}
</button>
</form>
<div className="bg-bg-card border border-border-primary rounded-xl overflow-hidden">
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto">
<table className="w-full text-xs">
<thead className="bg-bg-secondary sticky top-0">
<tr>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logLevel')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logType')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">Trace ID</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logNode')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logMessage')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logTime')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{logs.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-text-muted">{t('agent.noLogs')}</td></tr>
) : (
logs.map((log) => {
const style = LEVEL_STYLES[log.level] || LEVEL_STYLES.info;
return (
<tr key={log.id} className="hover:bg-bg-secondary/50 transition-colors">
<td className="px-4 py-2.5">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${style.bg} ${style.text}`}>{style.label}</span>
</td>
<td className="px-4 py-2.5 text-text-secondary font-mono">{log.event_type}</td>
<td className="px-4 py-2.5 text-text-muted font-mono">{log.trace_id.slice(-8)}</td>
<td className="px-4 py-2.5 text-text-secondary">{log.node_name || '-'}</td>
<td className="px-4 py-2.5 text-text-primary max-w-xs truncate" title={log.message}>{log.message}</td>
<td className="px-4 py-2.5 text-text-muted whitespace-nowrap">{log.created_at ? new Date(log.created_at).toLocaleString() : '-'}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
@@ -0,0 +1,152 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
interface WorkflowConfig {
retry: {
max_attempts: number;
};
}
export function WorkflowConfigSettings() {
const { t } = useTranslation();
const [config, setConfig] = useState<WorkflowConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/v1/system/config/workflow', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error(`Failed to load config: ${response.statusText}`);
}
const data = await response.json();
setConfig(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load configuration');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!config) return;
try {
setSaving(true);
setError(null);
setSuccessMessage(null);
const response = await fetch('/api/v1/system/config/workflow', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `Failed to save: ${response.statusText}`);
}
setSuccessMessage(t('agent.configSaved'));
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save configuration');
} finally {
setSaving(false);
}
};
const handleMaxAttemptsChange = (value: string) => {
const numValue = parseInt(value, 10);
if (!isNaN(numValue) && numValue >= 1 && numValue <= 100) {
setConfig((prev) => prev ? {
...prev,
retry: { ...prev.retry, max_attempts: numValue }
} : null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-muted">{t('common.loading')}</div>
</div>
);
}
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-error">{error || 'No configuration available'}</div>
</div>
);
}
return (
<div className="max-w-2xl">
<h2 className="text-xl font-bold text-text-primary mb-6">
{t('agent.workflowConfig')}
</h2>
{error && (
<div className="mb-4 p-4 bg-error/10 border border-error/20 rounded-lg text-error text-sm">
{error}
</div>
)}
{successMessage && (
<div className="mb-4 p-4 bg-success/10 border border-success/20 rounded-lg text-success text-sm">
{successMessage}
</div>
)}
<div className="bg-bg-card border border-border-primary rounded-lg p-6 space-y-6">
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
{t('agent.maxRetryAttempts')}
</label>
<input
type="number"
min="1"
max="100"
value={config.retry.max_attempts}
onChange={(e) => handleMaxAttemptsChange(e.target.value)}
className="w-full px-4 py-2 bg-bg-secondary border border-border-primary rounded-lg text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
/>
<p className="mt-2 text-xs text-text-muted">
{t('agent.maxRetryAttemptsDesc')}
</p>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
onClick={loadConfig}
disabled={saving}
className="px-4 py-2 text-sm font-medium text-text-secondary bg-bg-secondary border border-border-primary rounded-lg hover:bg-bg-hover transition-colors disabled:opacity-50"
>
{t('common.reset')}
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm font-medium text-white bg-accent rounded-lg hover:bg-accent-dark transition-colors disabled:opacity-50"
>
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>
);
}
+105 -11
View File
@@ -1,8 +1,11 @@
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Terminal, RefreshCw, SendHorizontal, LayoutList, GitFork, Radio } from 'lucide-react';
import { Terminal, RefreshCw, SendHorizontal, LayoutList, GitFork, Radio, PlayCircle, ListChecks } from 'lucide-react';
import apiClient from '../../api/client';
import { connectSSE } from '../../api/sse';
import type { SSEConnection } from '../../api/sse';
import type { WorkflowDetail } from '../../types';
import { ErrorBoundary } from '../ErrorBoundary';
import { WorkflowDiagram } from './WorkflowDiagram';
interface RightPanelProps {
@@ -16,8 +19,9 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
const [logs, setLogs] = useState<string[]>([]);
const [sseConnected, setSseConnected] = useState(false);
const [replyText, setReplyText] = useState('');
const [activeTab, setActiveTab] = useState<'chat' | 'diagram'>('chat');
const eventSourceRef = useRef<EventSource | null>(null);
const [resuming, setResuming] = useState(false);
const [activeTab, setActiveTab] = useState<'chat' | 'steps' | 'diagram'>('chat');
const eventSourceRef = useRef<SSEConnection | null>(null);
const logsEndRef = useRef<HTMLDivElement>(null);
const fetchDetail = async (traceId: string) => {
@@ -43,14 +47,28 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
setLogs([]);
const apiBase = import.meta.env.VITE_API_BASE_URL || `${window.location.protocol}//${window.location.host}`;
const es = new EventSource(`${apiBase}/api/v1/workflow/sse/${selectedWorkflow}`);
eventSourceRef.current = es;
es.onopen = () => setSseConnected(true);
es.onmessage = (event) => setLogs((prev) => [...prev, event.data]);
es.onerror = () => setSseConnected(false);
// 用 fetch-based SSEtoken 走标准 Authorization header,不进 URL
const token = localStorage.getItem('token') || '';
const conn = connectSSE(
`${apiBase}/api/v1/workflow/sse/${selectedWorkflow}`,
token,
{
onOpen: () => setSseConnected(true),
onMessage: (data) => setLogs((prev) => [...prev, data]),
onError: () => setSseConnected(false),
onReconnect: (delayMs) => {
setSseConnected(false);
setLogs((prev) => [
...prev,
`[System]: ${t('workflow.sseReconnecting', { seconds: Math.round(delayMs / 1000) })}`,
]);
},
},
);
eventSourceRef.current = conn;
const interval = setInterval(() => fetchDetail(selectedWorkflow), 3000);
return () => { es.close(); eventSourceRef.current = null; clearInterval(interval); };
return () => { conn.close(); eventSourceRef.current = null; clearInterval(interval); };
}, [selectedWorkflow]);
useEffect(() => {
@@ -72,6 +90,27 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
}
};
const handleResume = async () => {
if (!selectedWorkflow || resuming) return;
setResuming(true);
try {
await apiClient.post(`/api/v1/workflow/${selectedWorkflow}/resume`);
setLogs((prev) => [...prev, `[System]: ${t('workflow.resumeTriggered')}`]);
fetchDetail(selectedWorkflow);
} catch (err: any) {
const detailMsg = err?.response?.data?.detail || t('workflow.resumeFailed');
setLogs((prev) => [...prev, `[System Error]: ${detailMsg}`]);
} finally {
setResuming(false);
}
};
// 只有非终态(未 completed/failed)的工作流才允许 resume
const canResume =
!!detail &&
detail.status !== 'completed' &&
detail.status !== 'failed';
if (!selectedWorkflow) return null;
return (
@@ -93,6 +132,9 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
<button onClick={() => setActiveTab('chat')} className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'chat' ? 'bg-bg-card text-accent shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>
<LayoutList size={12} /> {t('workflow.chatLog')}
</button>
<button onClick={() => setActiveTab('steps')} className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'steps' ? 'bg-bg-card text-accent shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>
<ListChecks size={12} /> {t('workflow.steps')}
</button>
<button onClick={() => setActiveTab('diagram')} className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'diagram' ? 'bg-bg-card text-accent shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>
<GitFork size={12} /> {t('workflow.diagram')}
</button>
@@ -100,6 +142,11 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
<button onClick={() => fetchDetail(selectedWorkflow)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</button>
{canResume && (
<button onClick={handleResume} disabled={resuming} title={t('workflow.resume')} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all disabled:opacity-40">
<PlayCircle size={14} className={resuming ? 'animate-pulse' : ''} />
</button>
)}
</div>
</div>
@@ -107,11 +154,58 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
{activeTab === 'diagram' ? (
<div className="absolute inset-0">
{detail?.steps && detail.steps.length > 0 ? (
<WorkflowDiagram steps={detail.steps} currentStep={0} status={detail.status} />
) : (
<ErrorBoundary>
<WorkflowDiagram steps={detail.steps} currentStep={detail.current_step ?? 0} status={detail.status} />
</ErrorBoundary>
) : (
<div className="h-full flex items-center justify-center text-text-muted text-sm">Workflow steps are not yet generated.</div>
)}
</div>
) : activeTab === 'steps' ? (
<div className="flex-1 overflow-y-auto p-5 space-y-3">
{detail?.steps && detail.steps.length > 0 ? (
detail.steps.map((step: any, idx: number) => {
const isCurrent = idx === (detail.current_step ?? 0);
const isDone = idx < (detail.current_step ?? 0);
return (
<div key={idx} className={`bg-bg-card border rounded-xl p-4 transition-all ${isCurrent ? 'border-accent shadow-sm' : isDone ? 'border-border-primary opacity-70' : 'border-border-primary opacity-50'}`}>
<div className="flex items-center gap-2 mb-2">
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold ${isCurrent ? 'bg-accent text-white' : isDone ? 'bg-success/20 text-success' : 'bg-bg-secondary text-text-muted'}`}>
{isDone ? '✓' : idx + 1}
</span>
<span className="text-sm font-semibold text-text-primary">{step.node_name || step.name || `Step ${idx + 1}`}</span>
{isCurrent && <span className="text-[10px] px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium">{t('workflow.status.running')}</span>}
</div>
{step.output && (
<pre className="mt-2 p-3 bg-bg-secondary rounded-lg text-xs text-text-secondary font-mono overflow-x-auto whitespace-pre-wrap max-h-48">
{typeof step.output === 'string' ? step.output : JSON.stringify(step.output, null, 2)}
</pre>
)}
{step.error && (
<div className="mt-2 p-3 bg-error/10 border border-error/20 rounded-lg text-xs text-error">
{step.error}
</div>
)}
</div>
);
})
) : (
<div className="h-full flex items-center justify-center text-text-muted text-sm">
{t('workflow.noStepsYet')}
</div>
)}
</div>
) : loading && !detail ? (
<div className="flex-1 flex flex-col p-5 gap-4 animate-pulse">
<div className="h-16 bg-bg-card border border-border-primary rounded-xl" />
<div className="flex-1 bg-bg-card border border-border-primary rounded-xl p-4 space-y-3">
<div className="h-4 w-3/4 bg-bg-secondary rounded" />
<div className="h-4 w-1/2 bg-bg-secondary rounded" />
<div className="h-4 w-5/6 bg-bg-secondary rounded" />
<div className="h-4 w-2/3 bg-bg-secondary rounded" />
</div>
<div className="h-10 bg-bg-card border border-border-primary rounded-xl" />
</div>
) : (
<div className="flex-1 flex flex-col p-5 overflow-hidden">
{detail?.command && (
@@ -19,19 +19,26 @@ interface WorkflowDiagramProps {
}
export function WorkflowDiagram({ steps, currentStep, status }: WorkflowDiagramProps) {
const isWorkflowActive = status === 'llm_working' || status === 'tool_working';
const isWorkflowActive =
status === 'llm_working' ||
status === 'tool_working' ||
status === 'running' ||
status === 'hang_up';
const initialNodes = useMemo(() => {
return steps.map((step, index) => {
const isCurrent = step.step === currentStep && isWorkflowActive;
// 后端 current_step 是 0-based 的 workflow_pointer;优先用 per-step 的
// 运行期 status 着色,pointer 仅用来标"正在跑的那一步"。
const isCurrent = index === currentStep && isWorkflowActive;
const isCompleted = step.status === 'completed';
const isFailed = step.status === 'failed';
const isWorking = step.status === 'working';
let bgColor = 'var(--bg-card)';
let borderColor = 'var(--border-primary)';
let textColor = 'var(--text-secondary)';
if (isCurrent) {
if (isCurrent || isWorking) {
bgColor = 'var(--bg-active)';
borderColor = 'var(--accent)';
textColor = 'var(--accent)';
@@ -61,7 +68,7 @@ export function WorkflowDiagram({ steps, currentStep, status }: WorkflowDiagramP
border: `2px solid ${borderColor}`,
borderRadius: '10px',
color: textColor,
boxShadow: isCurrent ? '0 0 20px -4px var(--accent-glow)' : 'none',
boxShadow: (isCurrent || isWorking) ? '0 0 20px -4px var(--accent-glow)' : 'none',
fontSize: '12px',
},
};
@@ -75,7 +82,7 @@ export function WorkflowDiagram({ steps, currentStep, status }: WorkflowDiagramP
id: `e${steps[i].step}-${steps[i + 1].step}`,
source: steps[i].step.toString(),
target: steps[i + 1].step.toString(),
animated: steps[i].step === currentStep && isWorkflowActive,
animated: i === currentStep && isWorkflowActive,
style: { stroke: 'var(--border-primary)', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: 'var(--border-primary)' },
});
@@ -11,6 +11,7 @@ export function WorkflowListView({ onSelectWorkflow }: WorkflowListViewProps) {
const { t } = useTranslation();
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<string>('all');
useEffect(() => {
const fetchWorkflows = async () => {
@@ -103,50 +104,90 @@ export function WorkflowListView({ onSelectWorkflow }: WorkflowListViewProps) {
</div>
</div>
{workflows.length === 0 ? (
<div className="flex flex-col items-center justify-center border border-dashed border-border-primary rounded-xl bg-bg-card/50 p-16 text-center">
<div className="w-14 h-14 bg-bg-secondary rounded-xl flex items-center justify-center mb-4">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-7 h-7 text-text-muted"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</div>
<h3 className="text-base font-semibold text-text-primary mb-1">{t('workflow.noWorkflows')}</h3>
<p className="text-sm text-text-muted max-w-xs">{t('workflow.workflowsAppearHere')}</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-3">
{workflows.map((wf) => {
const style = getStatusStyle(wf.status);
return (
<div
key={wf.trace_id}
onClick={() => onSelectWorkflow(wf.trace_id)}
className="p-4 bg-bg-card rounded-xl border border-border-primary cursor-pointer transition-all hover:shadow-[0_4px_16px_rgba(0,0,0,0.05)] hover:-translate-y-0.5 hover:border-[#d5d0ca] dark:hover:border-white/10"
>
<div className="flex justify-between items-center mb-3">
<span className={`text-[10px] font-semibold px-2.5 py-[3px] rounded-[20px] tracking-[0.3px] ${style.bg} ${style.text}`}>
{style.label}
</span>
</div>
<h3 className="text-[13px] font-semibold text-text-primary mb-[5px] line-clamp-1" title={wf.title || t('common.unnamed')}>
{wf.title || t('common.unnamed')}
</h3>
{wf.command && (
<p className="text-xs text-text-muted leading-relaxed mb-3.5 line-clamp-2">
{wf.command}
</p>
)}
<div className="flex justify-between items-center pt-3 border-t border-border-primary">
<span className="text-[10px] text-[#b5afa8] font-mono bg-bg-primary px-2 py-0.5 rounded">
{wf.trace_id.slice(-8)}
</span>
{wf.created_at && (
<span className="text-[11px] text-text-muted">{new Date(wf.created_at).toLocaleDateString()}</span>
)}
</div>
{/* Status Filter Tabs */}
<div className="flex items-center gap-1 mb-5 bg-bg-card border border-border-primary rounded-lg p-1">
{[
{ key: 'all', label: t('workflow.filterAll'), count: stats.total },
{ key: 'running', label: t('workflow.status.running'), count: stats.running },
{ key: 'completed', label: t('workflow.status.completed'), count: stats.completed },
{ key: 'failed', label: t('workflow.status.failed'), count: workflows.filter((w) => w.status === 'failed').length },
{ key: 'queued', label: t('workflow.queued'), count: stats.queued },
].map((tab) => (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
statusFilter === tab.key
? 'bg-accent-light text-accent shadow-sm'
: 'text-text-muted hover:text-text-secondary hover:bg-bg-secondary'
}`}
>
{tab.label} <span className="opacity-60 ml-1">{tab.count}</span>
</button>
))}
</div>
{(() => {
const filteredWorkflows = workflows.filter((w) => {
if (statusFilter === 'all') return true;
if (statusFilter === 'running') return w.status?.includes('working');
if (statusFilter === 'completed') return w.status === 'completed';
if (statusFilter === 'failed') return w.status === 'failed';
if (statusFilter === 'queued') return !w.status || (!w.status.includes('working') && w.status !== 'completed' && w.status !== 'failed');
return true;
});
if (filteredWorkflows.length === 0) {
return (
<div className="flex flex-col items-center justify-center border border-dashed border-border-primary rounded-xl bg-bg-card/50 p-16 text-center">
<div className="w-14 h-14 bg-bg-secondary rounded-xl flex items-center justify-center mb-4">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-7 h-7 text-text-muted"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</div>
);
})}
</div>
)}
<h3 className="text-base font-semibold text-text-primary mb-1">
{statusFilter === 'all' ? t('workflow.noWorkflows') : t('workflow.noWorkflowsInFilter')}
</h3>
<p className="text-sm text-text-muted max-w-xs">{t('workflow.workflowsAppearHere')}</p>
</div>
);
}
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-3">
{filteredWorkflows.map((wf) => {
const style = getStatusStyle(wf.status);
return (
<div
key={wf.trace_id}
onClick={() => onSelectWorkflow(wf.trace_id)}
className="p-4 bg-bg-card rounded-xl border border-border-primary cursor-pointer transition-all hover:shadow-[0_4px_16px_rgba(0,0,0,0.05)] hover:-translate-y-0.5 hover:border-[#d5d0ca] dark:hover:border-white/10"
>
<div className="flex justify-between items-center mb-3">
<span className={`text-[10px] font-semibold px-2.5 py-[3px] rounded-[20px] tracking-[0.3px] ${style.bg} ${style.text}`}>
{style.label}
</span>
</div>
<h3 className="text-[13px] font-semibold text-text-primary mb-[5px] line-clamp-1" title={wf.title || t('common.unnamed')}>
{wf.title || t('common.unnamed')}
</h3>
{wf.command && (
<p className="text-xs text-text-muted leading-relaxed mb-3.5 line-clamp-2">
{wf.command}
</p>
)}
<div className="flex justify-between items-center pt-3 border-t border-border-primary">
<span className="text-[10px] text-[#b5afa8] font-mono bg-bg-primary px-2 py-0.5 rounded">
{wf.trace_id.slice(-8)}
</span>
{wf.created_at && (
<span className="text-[11px] text-text-muted">{new Date(wf.created_at).toLocaleDateString()}</span>
)}
</div>
</div>
);
})}
</div>
);
})()}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
message?: string;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, message: error.message };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
}
reset = () => {
this.setState({ hasError: false, message: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="h-full flex flex-col items-center justify-center gap-3 p-6 text-center">
<div className="text-sm text-danger font-medium">Something went wrong while rendering.</div>
{this.state.message && (
<div className="text-xs text-text-muted max-w-[280px] break-words">{this.state.message}</div>
)}
<button
onClick={this.reset}
className="mt-1 px-3 py-1.5 text-xs rounded-lg border border-border-primary text-text-secondary hover:text-accent hover:border-accent transition-all"
>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertCircle, X } from 'lucide-react';
import apiClient from '../../api/client';
interface SetupGuideModalProps {
onClose: () => void;
onNavigateToAgent: () => void;
}
export function SetupGuideModal({ onClose, onNavigateToAgent }: SetupGuideModalProps) {
const { t } = useTranslation();
const [missingNodes, setMissingNodes] = useState<string[]>([]);
useEffect(() => {
const checkNodes = async () => {
try {
const resp = await apiClient.get('/api/v1/agent');
const systemNodes = resp.data.system_nodes || [];
const regulatory = systemNodes.find((n: any) => n.node_name === 'regulatory_node');
const consciousness = systemNodes.find((n: any) => n.node_name === 'consciousness_node');
const missing = [];
if (!regulatory || !regulatory.provider_title || !regulatory.model_id) {
missing.push('regulatory_node');
}
if (!consciousness || !consciousness.provider_title || !consciousness.model_id) {
missing.push('consciousness_node');
}
setMissingNodes(missing);
} catch (err) {
console.error('Failed to check system nodes:', err);
}
};
checkNodes();
}, []);
if (missingNodes.length === 0) {
onClose();
return null;
}
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-bg-card border border-border-primary rounded-2xl shadow-2xl max-w-md w-full p-6 relative">
<button
onClick={onClose}
className="absolute top-4 right-4 p-1 text-text-muted hover:text-text-primary transition-colors"
>
<X size={18} />
</button>
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-accent-light flex items-center justify-center shrink-0">
<AlertCircle size={20} className="text-accent" />
</div>
<div>
<h2 className="text-lg font-semibold text-text-primary mb-1">
{t('setup.coreNodesNotConfigured')}
</h2>
<p className="text-sm text-text-muted">
{t('setup.pleaseConfigureBeforeUse')}
</p>
</div>
</div>
<div className="bg-bg-secondary rounded-lg p-4 mb-5">
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
{t('setup.missingNodes')}:
</div>
<ul className="space-y-1.5">
{missingNodes.includes('regulatory_node') && (
<li className="flex items-center gap-2 text-sm text-text-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t('setup.regulatoryNode')}
</li>
)}
{missingNodes.includes('consciousness_node') && (
<li className="flex items-center gap-2 text-sm text-text-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t('setup.consciousnessNode')}
</li>
)}
</ul>
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-lg border border-border-primary text-sm font-medium text-text-secondary hover:bg-bg-secondary transition-colors"
>
{t('common.skip')}
</button>
<button
onClick={() => {
onClose();
onNavigateToAgent();
}}
className="flex-1 px-4 py-2.5 rounded-lg bg-accent text-white text-sm font-semibold hover:bg-accent-hover transition-colors"
>
{t('setup.goToConfig')}
</button>
</div>
</div>
</div>
);
}
+37 -2
View File
@@ -76,6 +76,10 @@
"waitingEvents": "Waiting for events...",
"replyPlaceholder": "Reply to the workflow...",
"refresh": "Refresh Data",
"resume": "Resume Workflow",
"resumeTriggered": "Resume request sent, the workflow is recovering...",
"resumeFailed": "Failed to resume workflow",
"sseReconnecting": "Connection lost, retrying in {{seconds}}s...",
"workflowDetails": "Workflow Details",
"loading": "Loading Workflows...",
"titleRequired": "Please enter a workflow title",
@@ -90,7 +94,11 @@
"failed": "Failed"
},
"total": "Total",
"queued": "Queued"
"queued": "Queued",
"filterAll": "All",
"noWorkflowsInFilter": "No workflows match current filter",
"steps": "Steps",
"noStepsYet": "Workflow steps not yet generated"
},
"settings": {
"settings": "Settings",
@@ -163,6 +171,22 @@
"editWorker": "Edit Worker",
"provider": "Provider",
"model": "Model",
"config": "Config",
"workflowConfig": "Workflow Config",
"maxRetryAttempts": "Max Loop Retry Attempts",
"maxRetryAttemptsDesc": "When a workflow graph contains cycles, this limits the maximum number of times the engine may re-enter a loop, preventing infinite retries. Default: 5.",
"configSaved": "Configuration saved and hot-reloaded",
"systemLogs": "System Logs",
"logFilterTraceId": "Filter by Trace ID",
"logFilterAllTypes": "All event types",
"logFilterAllLevels": "All levels",
"logSearch": "Search",
"logLevel": "Level",
"logType": "Type",
"logNode": "Node",
"logMessage": "Message",
"logTime": "Time",
"noLogs": "No log entries yet",
"description": "Description",
"systemPrompt": "System Prompt",
"outputTemplate": "Output Template (JSON)",
@@ -224,6 +248,17 @@
"none": "None",
"creating": "Creating...",
"actions": "Actions",
"cancel": "Cancel"
"cancel": "Cancel",
"skip": "Skip for now",
"reset": "Reset",
"save": "Save"
},
"setup": {
"coreNodesNotConfigured": "Core Nodes Not Configured",
"pleaseConfigureBeforeUse": "Regulatory and Consciousness nodes are core components of the system. Please configure them before using workflow features.",
"missingNodes": "Missing Nodes",
"regulatoryNode": "Regulatory Node",
"consciousnessNode": "Consciousness Node",
"goToConfig": "Go to Configuration"
}
}
+38 -3
View File
@@ -37,7 +37,7 @@
"placeholder": "让 kilostar 做点什么...",
"send": "发送",
"selectChat": "选择对话记录或创建新对话以开始",
"assistantName": "kilostar 助手",
"assistantName": "KiloStar 助手",
"errorCommunication": "抱歉,与服务器通信时出错。",
"mistakeWarning": "KiloStar 可能会犯错,重要信息请自行核实。",
"addAttachment": "添加附件",
@@ -76,6 +76,10 @@
"waitingEvents": "等待事件...",
"replyPlaceholder": "回复工作流...",
"refresh": "刷新数据",
"resume": "恢复工作流",
"resumeTriggered": "恢复请求已发送,工作流正在恢复中...",
"resumeFailed": "恢复工作流失败",
"sseReconnecting": "连接断开,{{seconds}}秒后重试...",
"workflowDetails": "工作流详情",
"loading": "正在加载工作流...",
"titleRequired": "请输入工作流标题",
@@ -90,7 +94,11 @@
"failed": "失败"
},
"total": "总数",
"queued": "排队中"
"queued": "排队中",
"filterAll": "全部",
"noWorkflowsInFilter": "当前筛选下无工作流",
"steps": "步骤",
"noStepsYet": "工作流步骤尚未生成"
},
"settings": {
"settings": "设置",
@@ -163,6 +171,22 @@
"editWorker": "编辑工作节点",
"provider": "供应商",
"model": "模型",
"config": "配置",
"workflowConfig": "工作流配置",
"maxRetryAttempts": "最大环重试次数",
"maxRetryAttemptsDesc": "工作流图中有环时,防止因异常反复进入环路的最大重试次数。默认为 5。",
"configSaved": "配置已保存并热重载生效",
"systemLogs": "系统日志",
"logFilterTraceId": "按 Trace ID 筛选",
"logFilterAllTypes": "所有事件类型",
"logFilterAllLevels": "所有级别",
"logSearch": "查询",
"logLevel": "级别",
"logType": "类型",
"logNode": "节点",
"logMessage": "消息",
"logTime": "时间",
"noLogs": "暂无日志记录",
"description": "描述",
"systemPrompt": "系统提示词",
"outputTemplate": "输出模板 (JSON)",
@@ -224,6 +248,17 @@
"none": "无",
"creating": "创建中...",
"actions": "操作",
"cancel": "取消"
"cancel": "取消",
"skip": "稍后再说",
"reset": "重置",
"save": "保存"
},
"setup": {
"coreNodesNotConfigured": "核心节点未配置",
"pleaseConfigureBeforeUse": "Regulatory 和 Consciousness 节点是系统的核心,请先完成配置后再使用工作流功能。",
"missingNodes": "未配置的节点",
"regulatoryNode": "监管节点 (Regulatory Node)",
"consciousnessNode": "意识节点 (Consciousness Node)",
"goToConfig": "前往配置"
}
}
+1
View File
@@ -93,6 +93,7 @@ export interface WorkflowDetail {
status: string;
command?: string;
steps: WorkflowStep[];
current_step?: number;
context_blackboard?: Record<string, unknown>;
}