a53ffebe0e
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>
246 lines
12 KiB
TypeScript
246 lines
12 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
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 {
|
||
selectedWorkflow: string | null;
|
||
}
|
||
|
||
export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||
const { t } = useTranslation();
|
||
const [detail, setDetail] = useState<WorkflowDetail | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [logs, setLogs] = useState<string[]>([]);
|
||
const [sseConnected, setSseConnected] = useState(false);
|
||
const [replyText, setReplyText] = useState('');
|
||
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) => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await apiClient.get(`/api/v1/workflow/${traceId}`);
|
||
setDetail(response.data);
|
||
} catch (err) {
|
||
console.error('Failed to fetch workflow detail', err);
|
||
setDetail(null);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!selectedWorkflow) {
|
||
setDetail(null);
|
||
setLogs([]);
|
||
return;
|
||
}
|
||
fetchDetail(selectedWorkflow);
|
||
setLogs([]);
|
||
|
||
const apiBase = import.meta.env.VITE_API_BASE_URL || `${window.location.protocol}//${window.location.host}`;
|
||
// 用 fetch-based SSE,token 走标准 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 () => { conn.close(); eventSourceRef.current = null; clearInterval(interval); };
|
||
}, [selectedWorkflow]);
|
||
|
||
useEffect(() => {
|
||
if (activeTab === 'chat' && logsEndRef.current) {
|
||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
}, [logs, activeTab]);
|
||
|
||
const handleReplySubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!replyText.trim() || !selectedWorkflow) return;
|
||
const message = replyText.trim();
|
||
setReplyText('');
|
||
setLogs((prev) => [...prev, `[You]: ${message}`]);
|
||
try {
|
||
await apiClient.post(`/api/v1/workflow/reply/${selectedWorkflow}`, { message });
|
||
} catch {
|
||
setLogs((prev) => [...prev, `[System Error]: Failed to send reply.`]);
|
||
}
|
||
};
|
||
|
||
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 (
|
||
<div className="flex-1 bg-bg-card border-l border-border-primary flex flex-col relative">
|
||
<div className="h-12 border-b border-border-primary/60 bg-bg-card/80 backdrop-blur flex items-center px-5 justify-between shrink-0">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-7 h-7 rounded-lg bg-accent-light flex items-center justify-center">
|
||
<Terminal size={14} className="text-accent" />
|
||
</div>
|
||
<h2 className="font-semibold text-sm text-text-primary truncate max-w-[180px]">{detail?.title || t('workflow.workflowDetails')}</h2>
|
||
<span className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium ${sseConnected ? 'bg-success-bg text-success' : 'bg-bg-secondary text-text-muted'}`}>
|
||
<Radio size={10} className={sseConnected ? 'animate-pulse' : ''} />
|
||
{sseConnected ? t('workflow.live') : t('workflow.disconnected')}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center bg-bg-secondary rounded-lg p-0.5">
|
||
<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>
|
||
</div>
|
||
<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>
|
||
|
||
<div className="flex-1 flex overflow-hidden bg-bg-secondary relative">
|
||
{activeTab === 'diagram' ? (
|
||
<div className="absolute inset-0">
|
||
{detail?.steps && detail.steps.length > 0 ? (
|
||
<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 && (
|
||
<div className="bg-bg-card border border-border-primary rounded-xl p-4 mb-4 shadow-sm shrink-0">
|
||
<h3 className="text-[10px] font-bold text-text-muted uppercase tracking-widest mb-1.5">{t('workflow.originalCommand')}</h3>
|
||
<p className="text-text-secondary text-sm">{detail.command}</p>
|
||
</div>
|
||
)}
|
||
<div className="flex-1 bg-bg-card border border-border-primary rounded-xl shadow-sm overflow-y-auto p-4 mb-4 space-y-2 font-mono text-xs">
|
||
{logs.length === 0 ? (
|
||
<div className="h-full flex items-center justify-center text-text-muted">
|
||
{t('workflow.waitingEvents')}
|
||
</div>
|
||
) : (
|
||
logs.map((log, index) => (
|
||
<div key={index} className={`p-2.5 rounded-lg text-xs ${log.startsWith('[You]') ? 'bg-accent-light/50 text-accent-text ml-8' : 'bg-bg-secondary text-text-secondary mr-8'}`}>
|
||
{log}
|
||
</div>
|
||
))
|
||
)}
|
||
<div ref={logsEndRef} />
|
||
</div>
|
||
<form onSubmit={handleReplySubmit} className="relative shrink-0">
|
||
<input type="text" value={replyText} onChange={(e) => setReplyText(e.target.value)}
|
||
placeholder={t('workflow.replyPlaceholder')}
|
||
className="w-full bg-bg-card border border-border-primary rounded-xl pl-4 pr-11 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent/15 focus:border-accent/40 text-text-primary placeholder:text-text-muted/50" />
|
||
<button type="submit" disabled={!replyText.trim()}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 bg-accent text-white rounded-lg hover:bg-accent-hover disabled:opacity-30 transition-colors">
|
||
<SendHorizontal size={14} />
|
||
</button>
|
||
</form>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|