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
+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 && (