Files
KiloStar/frontend/src/components/Chat/RightPanel.tsx
T
zhaoxi a53ffebe0e 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>
2026-06-03 07:34:43 +00:00

246 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 () => { 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>
);
}