Files
multiAgentTry/frontend/src/pages/WorkflowPage.tsx

735 lines
25 KiB
TypeScript
Raw Normal View History

import { useState, useEffect } from 'react';
import {
Workflow,
CheckCircle,
FileText,
Upload,
ChevronRight,
RefreshCw,
Zap,
Users,
ArrowRight,
AlertTriangle,
} from 'lucide-react';
import { api } from '../lib/api';
import type { Workflow as WorkflowType, WorkflowMeeting } from '../types';
// 工作流详情面板
function WorkflowDetailPanel({
workflow,
onClose,
onReload,
}: {
workflow: WorkflowType;
onClose: () => void;
onReload: () => void;
}) {
const [loading, setLoading] = useState<string | null>(null);
const [executionStatus, setExecutionStatus] = useState<Record<string, any>>({});
// 刷新执行节点状态
const refreshExecutionStatus = async (meetingId: string) => {
if (workflow.meetings.find(m => m.meeting_id === meetingId)?.node_type === 'execution') {
try {
const status = await api.workflow.getExecutionStatus(workflow.workflow_id, meetingId);
setExecutionStatus(prev => ({ ...prev, [meetingId]: status }));
} catch (err) {
// 忽略错误
}
}
};
useEffect(() => {
// 刷新所有执行节点的状态
workflow.meetings.forEach(m => {
if (m.node_type === 'execution') {
refreshExecutionStatus(m.meeting_id);
}
});
}, [workflow]);
const handleCompleteMeeting = async (meetingId: string) => {
setLoading(meetingId);
try {
await api.workflow.complete(workflow.workflow_id, meetingId);
onReload();
} catch (err) {
alert('标记完成失败');
} finally {
setLoading(null);
}
};
const handleFailAndJump = async (meetingId: string) => {
const meeting = workflow.meetings.find(m => m.meeting_id === meetingId);
if (!meeting?.on_failure) {
alert('此节点未配置失败跳转目标');
return;
}
if (!confirm(`确定要跳转到 "${meeting.on_failure}" 吗?`)) {
return;
}
setLoading(`fail-${meetingId}`);
try {
const result = await api.workflow.handleFailure(workflow.workflow_id, meetingId);
if (result.target) {
onReload();
} else {
alert('未配置失败跳转');
}
} catch (err) {
alert('操作失败');
} finally {
setLoading(null);
}
};
const getMeetingStatus = (meeting: WorkflowMeeting) => {
if (meeting.completed) return 'completed';
const allPrevCompleted = workflow.meetings
.slice(0, workflow.meetings.findIndex((m) => m.meeting_id === meeting.meeting_id))
.every((m) => m.completed);
return allPrevCompleted ? 'active' : 'pending';
};
return (
<div
style={{
position: 'fixed',
right: 0,
top: 0,
bottom: 0,
width: 480,
background: 'rgba(17, 24, 39, 0.98)',
borderLeft: '1px solid rgba(0, 240, 255, 0.1)',
padding: 24,
zIndex: 100,
overflow: 'auto',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#fff', margin: 0 }}></h2>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
color: 'rgba(255, 255, 255, 0.5)',
fontSize: 24,
cursor: 'pointer',
}}
>
×
</button>
</div>
{/* Workflow Info */}
<div
style={{
padding: 20,
background: 'rgba(0, 0, 0, 0.3)',
borderRadius: 12,
marginBottom: 20,
}}
>
<h3 style={{ fontSize: 18, fontWeight: 600, color: '#fff', margin: '0 0 8px 0' }}>
{workflow.name}
</h3>
<p style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.4)', margin: 0 }}>
{workflow.workflow_id}
</p>
<p style={{ fontSize: 14, color: 'rgba(255, 255, 255, 0.6)', margin: '12px 0 0 0' }}>
{workflow.description}
</p>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<span
style={{
padding: '4px 12px',
borderRadius: 12,
background:
workflow.status === 'completed'
? '#00ff9d20'
: workflow.status === 'in_progress'
? '#00f0ff20'
: '#ff950020',
color:
workflow.status === 'completed'
? '#00ff9d'
: workflow.status === 'in_progress'
? '#00f0ff'
: '#ff9500',
fontSize: 12,
}}
>
{workflow.status === 'completed' ? '已完成' : workflow.status === 'in_progress' ? '进行中' : '等待中'}
</span>
<span
style={{
padding: '4px 12px',
borderRadius: 12,
background: 'rgba(139, 92, 246, 0.2)',
color: '#8b5cf6',
fontSize: 12,
}}
>
: {workflow.progress}
</span>
</div>
</div>
{/* Progress Bar */}
<div style={{ marginBottom: 20 }}>
<div
style={{
height: 8,
background: 'rgba(255, 255, 255, 0.1)',
borderRadius: 4,
overflow: 'hidden',
}}
>
<div
style={{
width: workflow.progress,
height: '100%',
background: 'linear-gradient(90deg, #00f0ff, #8b5cf6)',
borderRadius: 4,
transition: 'width 0.5s ease',
}}
/>
</div>
</div>
{/* Meetings */}
<div>
<h4 style={{ fontSize: 14, fontWeight: 600, color: 'rgba(255, 255, 255, 0.7)', margin: '0 0 12px 0' }}>
({workflow.meetings.length})
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{workflow.meetings.map((meeting, index) => {
const status = getMeetingStatus(meeting);
const isExecution = meeting.node_type === 'execution';
const execStatus = executionStatus[meeting.meeting_id];
return (
<div
key={meeting.meeting_id}
style={{
padding: 16,
background:
status === 'completed'
? 'rgba(0, 255, 157, 0.1)'
: status === 'active'
? 'rgba(0, 240, 255, 0.1)'
: 'rgba(0, 0, 0, 0.2)',
borderRadius: 10,
border:
status === 'active'
? '1px solid rgba(0, 240, 255, 0.3)'
: '1px solid transparent',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
{/* 节点图标 */}
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background:
status === 'completed'
? '#00ff9d'
: status === 'active'
? isExecution ? '#ff9500' : '#00f0ff'
: 'rgba(255, 255, 255, 0.1)',
color: status === 'pending' ? 'rgba(255, 255, 255, 0.4)' : '#000',
fontSize: 12,
fontWeight: 600,
flexShrink: 0,
}}
>
{status === 'completed' ? <CheckCircle size={14} /> : isExecution ? <Zap size={14} /> : index + 1}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{/* 标题和类型 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<p style={{ fontSize: 14, fontWeight: 500, color: '#fff', margin: 0 }}>
{meeting.title}
</p>
{/* 节点类型标签 */}
<span
style={{
padding: '2px 8px',
borderRadius: 4,
background: isExecution ? '#ff950020' : '#00f0ff20',
color: isExecution ? '#ff9500' : '#00f0ff',
fontSize: 10,
fontWeight: 500,
}}
>
{isExecution ? '执行' : '会议'}
</span>
{/* 失败跳转标签 */}
{meeting.on_failure && (
<span
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
borderRadius: 4,
background: '#ff006e20',
color: '#ff006e',
fontSize: 10,
}}
>
<AlertTriangle size={10} />
{meeting.on_failure}
</span>
)}
</div>
{/* ID 和参会者 */}
<p style={{ fontSize: 11, color: 'rgba(255, 255, 255, 0.4)', margin: '4px 0 0 0' }}>
{meeting.meeting_id}
</p>
{/* 参会者列表 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 6, flexWrap: 'wrap' }}>
<Users size={12} color="rgba(255, 255, 255, 0.4)" />
{meeting.attendees.map((attendee, i) => (
<span
key={i}
style={{
padding: '2px 6px',
borderRadius: 4,
background: 'rgba(255, 255, 255, 0.1)',
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 10,
}}
>
{attendee}
</span>
))}
</div>
{/* 执行节点进度 */}
{isExecution && execStatus && (
<div style={{ marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 11, color: 'rgba(255, 255, 255, 0.5)' }}>
: {execStatus.progress}
</span>
<span style={{ fontSize: 11, color: execStatus.is_ready ? '#00ff9d' : '#ff9500' }}>
{execStatus.is_ready ? '就绪' : '进行中'}
</span>
</div>
{execStatus.missing && execStatus.missing.length > 0 && (
<div style={{ fontSize: 10, color: 'rgba(255, 255, 255, 0.4)' }}>
: {execStatus.missing.join(', ')}
</div>
)}
</div>
)}
{/* 依赖关系 */}
{meeting.depends_on && meeting.depends_on.length > 0 && (
<div style={{ marginTop: 6, fontSize: 10, color: 'rgba(255, 255, 255, 0.3)' }}>
: {meeting.depends_on.join(', ')}
</div>
)}
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end' }}>
{status === 'active' && !meeting.completed && (
<button
onClick={() => handleCompleteMeeting(meeting.meeting_id)}
disabled={loading === meeting.meeting_id}
style={{
padding: '6px 12px',
background: '#00ff9d',
border: 'none',
borderRadius: 6,
color: '#000',
fontSize: 12,
fontWeight: 600,
cursor: loading === meeting.meeting_id ? 'not-allowed' : 'pointer',
opacity: loading === meeting.meeting_id ? 0.6 : 1,
}}
>
{loading === meeting.meeting_id ? '...' : '完成'}
</button>
)}
{meeting.completed && meeting.on_failure && (
<button
onClick={() => handleFailAndJump(meeting.meeting_id)}
disabled={loading === `fail-${meeting.meeting_id}`}
style={{
padding: '4px 10px',
background: '#ff006e20',
border: '1px solid #ff006e50',
borderRadius: 6,
color: '#ff006e',
fontSize: 11,
cursor: 'pointer',
}}
>
</button>
)}
{meeting.completed && (
<span
style={{
padding: '4px 10px',
background: '#00ff9d20',
borderRadius: 12,
color: '#00ff9d',
fontSize: 11,
}}
>
</span>
)}
</div>
</div>
{/* 节点间的连接线 */}
{index < workflow.meetings.length - 1 && (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 8 }}>
<ArrowRight size={16} color="rgba(255, 255, 255, 0.2)" />
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
export function WorkflowPage() {
const [workflows, setWorkflows] = useState<WorkflowType[]>([]);
const [selectedWorkflow, setSelectedWorkflow] = useState<WorkflowType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [starting, setStarting] = useState<string | null>(null);
const loadWorkflows = async () => {
try {
setLoading(true);
// 加载已加载的工作流列表
const listRes = await api.workflow.list();
const loadedWorkflows: WorkflowType[] = [];
// 从已加载的工作流中获取详情
for (const wf of listRes.workflows) {
try {
const detail = await api.workflow.get(wf.workflow_id);
loadedWorkflows.push(detail);
} catch {
// 忽略加载失败的
}
}
setWorkflows(loadedWorkflows);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
};
const startDefaultWorkflow = async () => {
setStarting('default-dev-flow');
try {
const workflow = await api.workflow.start('default-dev-flow.yaml');
setWorkflows(prev => [...prev.filter(w => w.workflow_id !== workflow.workflow_id), workflow]);
setSelectedWorkflow(workflow);
} catch (err) {
alert('启动默认工作流失败: ' + (err instanceof Error ? err.message : '未知错误'));
} finally {
setStarting(null);
}
};
useEffect(() => {
loadWorkflows();
const interval = setInterval(loadWorkflows, 30000);
return () => clearInterval(interval);
}, []);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.yaml') && !file.name.endsWith('.yml')) {
alert('请上传 YAML 文件');
return;
}
setUploading(true);
try {
await file.text();
// 这里应该调用后端 API 上传文件
// 暂时模拟成功
alert(`文件 ${file.name} 上传成功`);
loadWorkflows();
} catch (err) {
alert('上传失败');
} finally {
setUploading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return '#00ff9d';
case 'in_progress':
return '#00f0ff';
default:
return '#ff9500';
}
};
if (loading && workflows.length === 0) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
<div style={{ color: '#00f0ff' }}>...</div>
</div>
);
}
return (
<div>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div>
<h1 style={{ fontSize: 28, fontWeight: 700, color: '#fff', margin: 0 }}></h1>
<p style={{ fontSize: 14, color: 'rgba(255, 255, 255, 0.5)', margin: '8px 0 0 0' }}>
-
</p>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button
onClick={loadWorkflows}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '10px 16px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: 8,
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 14,
cursor: 'pointer',
}}
>
<RefreshCw size={16} />
</button>
<button
onClick={startDefaultWorkflow}
disabled={starting === 'default-dev-flow'}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '10px 16px',
background: 'rgba(0, 240, 255, 0.1)',
border: '1px solid rgba(0, 240, 255, 0.3)',
borderRadius: 8,
color: '#00f0ff',
fontSize: 14,
cursor: starting === 'default-dev-flow' ? 'not-allowed' : 'pointer',
opacity: starting === 'default-dev-flow' ? 0.6 : 1,
}}
>
<Zap size={16} />
{starting === 'default-dev-flow' ? '启动中...' : '启动默认工作流'}
</button>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '10px 16px',
background: 'linear-gradient(135deg, #00f0ff 0%, #8b5cf6 100%)',
border: 'none',
borderRadius: 8,
color: '#000',
fontSize: 14,
fontWeight: 600,
cursor: uploading ? 'not-allowed' : 'pointer',
opacity: uploading ? 0.6 : 1,
}}
>
<Upload size={18} />
{uploading ? '上传中...' : '上传工作流'}
<input type="file" accept=".yaml,.yml" onChange={handleFileUpload} disabled={uploading} style={{ display: 'none' }} />
</label>
</div>
</div>
{error && (
<div
style={{
padding: '12px 16px',
background: '#ff006e20',
border: '1px solid #ff006e50',
borderRadius: 8,
color: '#ff006e',
marginBottom: 24,
}}
>
: {error}
</div>
)}
{/* Workflows Grid */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
gap: 16,
}}
>
{workflows.map((workflow) => (
<div
key={workflow.workflow_id}
onClick={() => setSelectedWorkflow(workflow)}
style={{
padding: 20,
background: 'rgba(17, 24, 39, 0.7)',
borderRadius: 12,
border: '1px solid rgba(0, 240, 255, 0.1)',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(0, 240, 255, 0.3)';
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(0, 240, 255, 0.1)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<div
style={{
width: 48,
height: 48,
borderRadius: 10,
background: `linear-gradient(135deg, ${getStatusColor(workflow.status)}40 0%, ${getStatusColor(workflow.status)}10 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Workflow size={24} color={getStatusColor(workflow.status)} />
</div>
<span
style={{
padding: '4px 10px',
borderRadius: 12,
background: `${getStatusColor(workflow.status)}20`,
color: getStatusColor(workflow.status),
fontSize: 11,
}}
>
{workflow.status === 'completed' ? '已完成' : workflow.status === 'in_progress' ? '进行中' : '等待中'}
</span>
</div>
<h3 style={{ fontSize: 16, fontWeight: 600, color: '#fff', margin: '0 0 4px 0' }}>
{workflow.name}
</h3>
<p style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.4)', margin: '0 0 12px 0' }}>
{workflow.workflow_id}
</p>
<p
style={{
fontSize: 13,
color: 'rgba(255, 255, 255, 0.6)',
margin: '0 0 16px 0',
lineHeight: 1.5,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{workflow.description}
</p>
{/* Progress */}
<div style={{ marginBottom: 12 }}>
<div
style={{
height: 6,
background: 'rgba(255, 255, 255, 0.1)',
borderRadius: 3,
overflow: 'hidden',
}}
>
<div
style={{
width: workflow.progress,
height: '100%',
background: 'linear-gradient(90deg, #00f0ff, #8b5cf6)',
borderRadius: 3,
}}
/>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FileText size={14} color="rgba(255, 255, 255, 0.4)" />
<span style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.5)' }}>
{workflow.meetings.length}
</span>
{/* 统计节点类型 */}
<span style={{ fontSize: 11, color: 'rgba(255, 255, 255, 0.3)' }}>
({workflow.meetings.filter(m => m.node_type === 'execution').length} + {workflow.meetings.filter(m => m.node_type === 'meeting').length} )
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 12, color: 'rgba(255, 255, 255, 0.5)' }}></span>
<ChevronRight size={14} color="rgba(255, 255, 255, 0.4)" />
</div>
</div>
</div>
))}
</div>
{workflows.length === 0 && !loading && (
<div style={{ textAlign: 'center', padding: 80 }}>
<Workflow size={48} color="rgba(255, 255, 255, 0.2)" style={{ marginBottom: 16 }} />
<p style={{ color: 'rgba(255, 255, 255, 0.5)', margin: 0 }}></p>
<p style={{ color: 'rgba(255, 255, 255, 0.3)', fontSize: 14 }}> YAML </p>
</div>
)}
{/* Detail Panel */}
{selectedWorkflow && (
<WorkflowDetailPanel
workflow={selectedWorkflow}
onClose={() => setSelectedWorkflow(null)}
onReload={loadWorkflows}
/>
)}
</div>
);
}