aura-web/src/pages/WorkflowsPage.tsx

776 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useEffect, useMemo, useState } from 'react';
import {
ApartmentOutlined,
ClockCircleOutlined,
PlayCircleOutlined,
PlusOutlined,
ThunderboltOutlined
} from '@ant-design/icons';
import {
Button,
Card,
Drawer,
Empty,
Form,
Input,
Modal,
Select,
Space,
Switch,
Table,
Tag,
Tooltip,
Typography,
App as AntApp,
Popconfirm,
Tabs
} from 'antd';
import {
Workflow,
WorkflowAPI,
WorkflowGraph,
WorkflowNode,
WorkflowNodeType,
WorkflowRun,
WorkflowRunDetail,
streamWorkflowRun
} from '../api';
const { Text, Paragraph } = Typography;
const NODE_TYPE_LABEL: Record<WorkflowNodeType, string> = {
agent: '🤖 Agent',
skill: '🛠️ Skill',
http: '🌐 HTTP',
transform: '🔧 Transform',
branch: '🔀 Branch'
};
const STATUS_COLOR: Record<string, string> = {
running: 'blue',
success: 'green',
failed: 'red',
aborted: 'orange',
skipped: 'default'
};
const SAMPLE_GRAPH: WorkflowGraph = {
entry: 'fetch',
variables: { topic: 'Go 1.23 新特性' },
nodes: [
{
id: 'fetch',
type: 'agent',
name: '搜集资料',
config: {
agentId: '',
prompt: '请围绕主题"{{vars.topic}}"列出 5 条最近的关键信息,要点形式。'
},
next: 'summarize'
},
{
id: 'summarize',
type: 'agent',
name: '总结成稿',
config: {
agentId: '',
prompt: '基于以下要点写一段 200 字日报:\n{{steps.fetch.output.text}}'
},
next: ''
}
]
};
export default function WorkflowsPage() {
const { message, modal } = AntApp.useApp();
const [list, setList] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<Workflow | null>(null);
const [runsOpen, setRunsOpen] = useState(false);
const [runsFor, setRunsFor] = useState<Workflow | null>(null);
const load = async () => {
setLoading(true);
try {
const data = await WorkflowAPI.list();
setList(data);
} catch (e: any) {
message.error('加载失败:' + (e?.message ?? e));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const onCreate = () => {
setEditing({
id: '',
name: '新工作流',
description: '',
graph: SAMPLE_GRAPH,
scheduleCron: '',
scheduleEnabled: false,
enabled: true,
lastRunAt: 0,
runCount: 0,
createdAt: 0,
updatedAt: 0
});
setEditorOpen(true);
};
const onEdit = (w: Workflow) => {
setEditing(w);
setEditorOpen(true);
};
const onDelete = async (w: Workflow) => {
await WorkflowAPI.remove(w.id);
message.success('已删除');
load();
};
return (
<div className="page-container" style={{ maxWidth: 1400 }}>
<div
style={{
borderRadius: 24,
padding: '30px 30px 26px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
marginBottom: 24
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 20,
flexWrap: 'wrap',
marginBottom: 20
}}
>
<div style={{ maxWidth: 680 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16
}}
>
<ApartmentOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="page-title" style={{ marginBottom: 10 }}></h1>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
AgentHTTP
</div>
</div>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={onCreate}
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
>
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
{[
{ label: '工作流数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ label: '启用中', value: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
{ label: '定时运行', value: list.filter((item) => item.scheduleEnabled && item.scheduleCron).length, tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' }
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 18,
padding: '16px 18px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)'
}}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span
style={{
borderRadius: 999,
padding: '4px 8px',
background: item.tone,
color: item.color,
fontSize: 12,
fontWeight: 600
}}
>
</span>
</div>
</div>
))}
</div>
</div>
{loading ? null : list.length === 0 ? (
<div
style={{
borderRadius: 22,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '54px 24px'
}}
>
<Empty description="还没有工作流,点击上方开始搭建第一条自动化流程" />
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 18 }}>
{list.map((r) => (
<div
key={r.id}
style={{
borderRadius: 20,
border: '1px solid var(--color-border)',
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
padding: 20,
minHeight: 308,
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6 }}>{r.name}</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
{r.description || '还没有补充描述,可以说明这个流程负责什么自动化任务。'}
</div>
</div>
<Tag
bordered={false}
style={{
margin: 0,
borderRadius: 999,
background: r.enabled ? 'var(--color-success-soft)' : 'var(--color-surface-2)',
color: r.enabled ? 'var(--color-success)' : 'var(--color-text-secondary)',
height: 28,
lineHeight: '28px'
}}
>
{r.enabled ? '已启用' : '已停用'}
</Tag>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{r.graph?.nodes?.length ?? 0}</div>
</div>
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(34, 197, 94, 0.06)', border: '1px solid rgba(34, 197, 94, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{r.runCount}</div>
</div>
<div style={{ borderRadius: 16, padding: '14px 14px 12px', background: 'rgba(249, 115, 22, 0.06)', border: '1px solid rgba(249, 115, 22, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>
{r.scheduleEnabled && r.scheduleCron ? '定时' : '手动'}
</div>
</div>
</div>
<div
style={{
borderRadius: 16,
padding: '16px 16px 14px',
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%)',
border: '1px solid rgba(148, 163, 184, 0.14)',
marginBottom: 16
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
<ClockCircleOutlined />
</div>
{r.scheduleEnabled && r.scheduleCron ? (
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999 }}>
cron: {r.scheduleCron}
</Tag>
) : (
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999 }}>
</Tag>
)}
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)', marginTop: 10 }}>
{r.lastRunAt ? new Date(r.lastRunAt).toLocaleString() : '尚未运行'}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onEdit(r)} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
</Button>
<Button icon={<PlayCircleOutlined />} onClick={() => { setRunsFor(r); setRunsOpen(true); }} style={{ borderRadius: 12, height: 40 }}>
/
</Button>
<Popconfirm title="删除?" onConfirm={() => onDelete(r)}>
<Button danger style={{ borderRadius: 12, height: 40 }}>
</Button>
</Popconfirm>
</div>
</div>
))}
</div>
)}
{editing && (
<WorkflowEditor
open={editorOpen}
workflow={editing}
onClose={() => setEditorOpen(false)}
onSaved={() => {
setEditorOpen(false);
load();
}}
/>
)}
{runsFor && (
<RunsDrawer
open={runsOpen}
workflow={runsFor}
onClose={() => setRunsOpen(false)}
/>
)}
</div>
);
}
// ================== 编辑器 ==================
function WorkflowEditor({
open, workflow, onClose, onSaved
}: {
open: boolean;
workflow: Workflow;
onClose: () => void;
onSaved: () => void;
}) {
const { message } = AntApp.useApp();
const [form] = Form.useForm();
const [graphText, setGraphText] = useState(JSON.stringify(workflow.graph, null, 2));
const [saving, setSaving] = useState(false);
useEffect(() => {
form.setFieldsValue({
name: workflow.name,
description: workflow.description,
scheduleCron: workflow.scheduleCron,
scheduleEnabled: workflow.scheduleEnabled,
enabled: workflow.enabled
});
setGraphText(JSON.stringify(workflow.graph, null, 2));
}, [workflow]);
const onSave = async () => {
const values = await form.validateFields();
let graph: WorkflowGraph;
try {
graph = JSON.parse(graphText);
} catch (e: any) {
message.error('Graph JSON 解析失败:' + e.message);
return;
}
if (!graph.entry || !Array.isArray(graph.nodes)) {
message.error('Graph 必须包含 entry + nodes 数组');
return;
}
setSaving(true);
try {
const payload = { ...values, graph };
if (workflow.id) {
await WorkflowAPI.update(workflow.id, payload);
} else {
await WorkflowAPI.create(payload);
}
message.success('已保存');
onSaved();
} catch (e: any) {
message.error('保存失败:' + (e?.message ?? e));
} finally {
setSaving(false);
}
};
return (
<Drawer
title={workflow.id ? `编辑「${workflow.name}` : '新建工作流'}
width={840}
open={open}
onClose={onClose}
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
extra={
<Button type="primary" loading={saving} onClick={onSave} style={{ borderRadius: 10 }}>
</Button>
}
>
<Form form={form} layout="vertical">
<Form.Item label="名称" name="name" rules={[{ required: true, message: '必填' }]}>
<Input />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea rows={2} />
</Form.Item>
<Space size="large" style={{ marginBottom: 12 }}>
<Form.Item name="enabled" valuePropName="checked" noStyle>
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item name="scheduleEnabled" valuePropName="checked" noStyle>
<Switch checkedChildren="定时开" unCheckedChildren="定时关" />
</Form.Item>
</Space>
<Form.Item
label={
<Space>
<span>Cron </span>
<Text type="secondary" style={{ fontSize: 12 }}>5 "*/30 * * * *"</Text>
</Space>
}
name="scheduleCron"
>
<Input placeholder="例如 0 8 * * 1-5工作日早 8 点)" />
</Form.Item>
</Form>
<NodeQuickAdd
onAdd={(node) => {
try {
const g: WorkflowGraph = JSON.parse(graphText);
g.nodes = [...(g.nodes || []), node];
if (!g.entry) g.entry = node.id;
setGraphText(JSON.stringify(g, null, 2));
} catch {
message.error('Graph JSON 不合法,无法追加');
}
}}
/>
<Card
size="small"
title={<Text strong>Graph JSON</Text>}
style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}
extra={
<Tooltip title="还原为示例">
<Button size="small" onClick={() => setGraphText(JSON.stringify(SAMPLE_GRAPH, null, 2))} style={{ borderRadius: 8 }}>
</Button>
</Tooltip>
}
>
<Input.TextArea
rows={20}
value={graphText}
onChange={(e) => setGraphText(e.target.value)}
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
/>
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
<code>{'{{input.x}} {{vars.x}} {{steps.<nodeId>.output...}}'}</code><br />
agent / skill / http / transform / branchbranch <code>condition</code> bool JS next/elseNext
</Paragraph>
</Card>
</Drawer>
);
}
function NodeQuickAdd({ onAdd }: { onAdd: (n: WorkflowNode) => void }) {
const [type, setType] = useState<WorkflowNodeType>('agent');
const [id, setId] = useState('');
return (
<Card size="small" title="快速追加节点" style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}>
<Space wrap>
<Select
value={type}
style={{ width: 140 }}
onChange={(v) => setType(v as WorkflowNodeType)}
options={Object.entries(NODE_TYPE_LABEL).map(([k, label]) => ({
value: k, label
}))}
/>
<Input
placeholder="节点 id如 step1"
value={id}
onChange={(e) => setId(e.target.value)}
style={{ width: 200 }}
/>
<Button
type="primary"
disabled={!id.trim()}
style={{ borderRadius: 10 }}
onClick={() => {
const base: WorkflowNode = {
id: id.trim(),
type,
name: NODE_TYPE_LABEL[type],
config: defaultConfig(type),
next: ''
};
if (type === 'branch') base.elseNext = '';
onAdd(base);
setId('');
}}
>
</Button>
</Space>
</Card>
);
}
function defaultConfig(type: WorkflowNodeType): Record<string, any> {
switch (type) {
case 'agent':
return { agentId: '', prompt: '请帮我处理:{{input.text}}' };
case 'skill':
return { skillId: '', args: {} };
case 'http':
return { method: 'GET', url: 'https://example.com/api', headers: {}, body: null };
case 'transform':
return { code: 'return { value: input.text };' };
case 'branch':
return { condition: 'steps.prev?.output?.value === true' };
}
}
// ================== 运行抽屉 ==================
function RunsDrawer({
open, workflow, onClose
}: {
open: boolean;
workflow: Workflow;
onClose: () => void;
}) {
const { message } = AntApp.useApp();
const [tab, setTab] = useState('run');
const [runs, setRuns] = useState<WorkflowRun[]>([]);
const [detail, setDetail] = useState<WorkflowRunDetail | null>(null);
const [streaming, setStreaming] = useState(false);
const [steps, setSteps] = useState<any[]>([]);
const [finalRun, setFinalRun] = useState<any>(null);
const [inputJson, setInputJson] = useState('{}');
const loadRuns = async () => {
try {
const data = await WorkflowAPI.listRuns(workflow.id, 30);
setRuns(data);
} catch (e: any) {
message.error('加载历史失败:' + (e?.message ?? e));
}
};
useEffect(() => {
if (open) loadRuns();
}, [open]);
const onRunStream = () => {
let input: any = undefined;
if (inputJson.trim()) {
try {
input = JSON.parse(inputJson);
} catch (e: any) {
message.error('input JSON 不合法');
return;
}
}
setSteps([]);
setFinalRun(null);
setStreaming(true);
streamWorkflowRun(workflow.id, input, {
onStepStart: (d) => {
setSteps((s) => [...s, { ...d, status: 'running' }]);
},
onStepFinish: (d) => {
setSteps((s) => {
const idx = [...s].reverse().findIndex((x) => x.nodeId === d.nodeId && x.status === 'running');
if (idx === -1) return [...s, d];
const realIdx = s.length - 1 - idx;
const cp = [...s];
cp[realIdx] = d;
return cp;
});
},
onRunFinish: (d) => {
setStreaming(false);
setFinalRun(d);
loadRuns();
},
onError: (msg) => {
setStreaming(false);
message.error(msg);
}
});
};
const showDetail = async (runId: string) => {
try {
const d = await WorkflowAPI.getRun(runId);
setDetail(d);
} catch (e: any) {
message.error('加载详情失败:' + (e?.message ?? e));
}
};
return (
<Drawer
title={`运行:${workflow.name}`}
width={760}
open={open}
onClose={() => { setDetail(null); onClose(); }}
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
>
<Tabs
activeKey={tab}
onChange={setTab}
items={[
{
key: 'run',
label: '触发',
children: (
<div>
<Form layout="vertical">
<Form.Item label="InputJSON会作为 input.* 注入)">
<Input.TextArea
rows={4}
value={inputJson}
onChange={(e) => setInputJson(e.target.value)}
placeholder='{"text":"hello"}'
/>
</Form.Item>
</Form>
<Space style={{ marginBottom: 12 }}>
<Button type="primary" loading={streaming} onClick={onRunStream} style={{ borderRadius: 10 }}>
</Button>
<Button
style={{ borderRadius: 10 }}
onClick={async () => {
try {
const r = await WorkflowAPI.run(workflow.id, JSON.parse(inputJson || '{}'));
message.success(`已触发 run=${r.runId}`);
loadRuns();
} catch (e: any) {
message.error('触发失败:' + (e?.message ?? e));
}
}}
>
</Button>
</Space>
{steps.length > 0 && (
<Card size="small" title="实时进度" style={{ marginBottom: 12, borderRadius: 16 }}>
{steps.map((s, i) => (
<div key={i} style={{ marginBottom: 8 }}>
<Space>
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
<Text strong>{s.nodeId}</Text>
<Text type="secondary">{s.nodeType}</Text>
{s.durationMs != null && <Text type="secondary">{s.durationMs}ms</Text>}
</Space>
{s.error && <Paragraph type="danger" style={{ marginBottom: 0 }}>{s.error}</Paragraph>}
{s.output != null && (
<pre style={{ background: '#f5f5f5', padding: 6, borderRadius: 4, fontSize: 12, marginTop: 4, maxHeight: 160, overflow: 'auto' }}>
{JSON.stringify(s.output, null, 2)}
</pre>
)}
</div>
))}
</Card>
)}
{finalRun && (
<Card size="small" title="最终结果" style={{ borderRadius: 16 }}>
<pre style={{ fontSize: 12, margin: 0 }}>
{JSON.stringify(finalRun, null, 2)}
</pre>
</Card>
)}
</div>
)
},
{
key: 'history',
label: `历史 (${runs.length})`,
children: (
<div>
<Button size="small" onClick={loadRuns} style={{ marginBottom: 8, borderRadius: 8 }}></Button>
<Table<WorkflowRun>
rowKey="id"
size="small"
pagination={false}
dataSource={runs}
columns={[
{ title: '状态', dataIndex: 'status', render: (s) => <Tag color={STATUS_COLOR[s] || 'default'}>{s}</Tag>, width: 100 },
{ title: '触发', dataIndex: 'trigger', width: 80 },
{ title: '开始', dataIndex: 'startedAt', render: (v) => new Date(v).toLocaleString(), width: 180 },
{ title: '耗时', dataIndex: 'durationMs', render: (v) => `${v} ms`, width: 100 },
{ title: '', render: (_, r) => <Button size="small" onClick={() => showDetail(r.id)}></Button> }
]}
/>
{detail && (
<Modal
title={`Run ${detail.id}`}
open
onCancel={() => setDetail(null)}
onOk={() => setDetail(null)}
width={680}
>
<p><b></b><Tag color={STATUS_COLOR[detail.status] || 'default'}>{detail.status}</Tag>{detail.error && <Text type="danger" style={{ marginLeft: 8 }}>{detail.error}</Text>}</p>
<p><b></b>{detail.durationMs} ms</p>
<p><b>Input</b></p>
<pre style={{ background: '#f5f5f5', padding: 8, fontSize: 12 }}>{JSON.stringify(detail.input, null, 2)}</pre>
<p><b>Steps</b></p>
{detail.steps.map((s) => (
<Card size="small" key={s.id} style={{ marginBottom: 8, borderRadius: 14 }}>
<Space>
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
<Text strong>{s.nodeId}</Text>
<Text type="secondary">{s.nodeType}</Text>
<Text type="secondary">{s.durationMs} ms</Text>
</Space>
{s.error && <p style={{ color: 'red', marginTop: 4 }}>{s.error}</p>}
{s.output != null && (
<pre style={{ fontSize: 12, marginTop: 4, maxHeight: 200, overflow: 'auto' }}>
{JSON.stringify(s.output, null, 2)}
</pre>
)}
</Card>
))}
</Modal>
)}
</div>
)
}
]}
/>
</Drawer>
);
}