Merge pull request 'init workflow dashboard' (#387) from jshang/workflow-dashboard into main
Reviewed-on: uncaged/workflow#387
This commit is contained in:
@@ -11,3 +11,5 @@ solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
.DS_Store
|
||||
*.py
|
||||
.claude
|
||||
tmp
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
|
||||
# Workflow UI — 开发上下文文档
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
workflow-dashboard 是一个 Web 图形编辑器,用于可视化展示和编辑工作流(Workflow)的结构。
|
||||
|
||||
**核心场景**:
|
||||
- 用户本地执行 `uwf connect` 命令,通过 WebSocket 连接到此 Web 服务
|
||||
- CLI 将本地 YAML 工作流文件发送到 server
|
||||
- Server 解析后,提供图形化界面展示工作流的节点拓扑,允许用户进行逻辑编排和节点编辑
|
||||
- 编辑完成后,数据可回传给 CLI 或持久化
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 层 | 技术 | 说明 |
|
||||
|---|------|------|
|
||||
| 图编辑器 | @xyflow/react v12 | 节点/边渲染、拖拽、连线(strict 连接模式) |
|
||||
| 前端框架 | React 19 | UI 组件 |
|
||||
| 路由 | react-router v7 | Hash 模式路由 |
|
||||
| 状态管理 | 自研 (context.tsx) | 基于 useSyncExternalStore + Immer |
|
||||
| 样式 | Tailwind CSS v4 | 原子化 CSS |
|
||||
| 图标 | lucide-react | 图标库 |
|
||||
| 构建工具 | Vite 8 | Dev server + 打包 |
|
||||
| 后端框架 | Elysia | 轻量 REST API(当前为 stub) |
|
||||
|
||||
## 3. 目录结构
|
||||
|
||||
```
|
||||
workflow-dashboard/
|
||||
├── server.ts # Vite dev server 入口 (port 3000)
|
||||
├── vite.config.ts # Vite 配置(react + tailwind + elysia 插件 + @ 别名)
|
||||
├── vite-dev.ts # 自定义 Vite 插件
|
||||
├── components.json # shadcn 配置
|
||||
├── server/
|
||||
│ ├── api.ts # Elysia REST API (health + workflow CRUD)
|
||||
│ └── workflow.ts # Workflow 文件读写 + 格式转换
|
||||
├── tmp/workflow/ # Workflow YAML 存储目录(开发阶段)
|
||||
├── src/
|
||||
│ ├── main.tsx # React DOM 入口
|
||||
│ ├── router.tsx # React Router 配置
|
||||
│ ├── app.tsx # 根布局组件
|
||||
│ ├── lib/utils.ts # Tailwind cn() 工具
|
||||
│ ├── components/ui/ # shadcn 组件(button, card, dialog, input, textarea)
|
||||
│ ├── pages/
|
||||
│ │ ├── home.tsx # Home 列表页(workflow 管理)
|
||||
│ │ └── detail.tsx # Workflow 详情/编辑页
|
||||
│ └── editor/ # ★ 核心编辑器
|
||||
│ ├── flow.tsx # FlowEditor 组件 + 公开 API 导出
|
||||
│ ├── type.ts # 内部类型定义
|
||||
│ ├── context.tsx # 自研状态管理框架
|
||||
│ ├── injection.ts # DI 容器(FlowModel / Injection)
|
||||
│ ├── model/ # 状态模型层
|
||||
│ ├── nodes/ # 节点渲染组件
|
||||
│ ├── edges/ # 边渲染组件
|
||||
│ ├── panel/ # UI 面板(工具栏、添加/编辑面板)
|
||||
│ ├── trans/ # 数据转换层(内外格式互转)
|
||||
│ ├── layout/ # 自动布局算法
|
||||
│ └── utils/ # 工具函数
|
||||
```
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 外部格式 — WorkFlowSteps(与 CLI 交换的数据)
|
||||
|
||||
`WorkFlowSteps` 是 `WorkFlowStep[]`,每个 step 描述一个角色节点及其转移关系:
|
||||
|
||||
```typescript
|
||||
type WorkFlowRole = {
|
||||
name: string; // 角色名称(唯一标识)
|
||||
description: string; // 角色描述
|
||||
identity: string; // 身份定义(system prompt)
|
||||
prepare: string; // 执行前准备指令
|
||||
execute: string; // 核心执行指令
|
||||
report: string; // 输出格式指令
|
||||
};
|
||||
|
||||
type WorkFlowTransition = {
|
||||
target: string; // 目标角色名 或 'END'
|
||||
condition: string | null; // 条件表达式,null 为 else(无条件兜底)
|
||||
};
|
||||
|
||||
type WorkFlowStep = {
|
||||
role: WorkFlowRole;
|
||||
transitions: WorkFlowTransition[];
|
||||
};
|
||||
```
|
||||
|
||||
### 4.2 内部格式 — ReactFlow Nodes & Edges
|
||||
|
||||
编辑器内部使用 ReactFlow 的 Node/Edge 模型:
|
||||
|
||||
**节点类型**:
|
||||
- `start` → 起始节点(右侧 1 个 source handle)
|
||||
- `end` → 结束节点(左侧 1 个 target handle)
|
||||
- `role` → 角色节点(6 个 handle,见下方)
|
||||
|
||||
**Role 节点 Handle 布局**:
|
||||
|
||||
| 位置 | 类型 | ID | 颜色 |
|
||||
|------|------|----|------|
|
||||
| 左侧 | target (in) | `input` | 蓝色 |
|
||||
| 上方 30% | target (in) | `input-top` | 蓝色 |
|
||||
| 下方 30% | target (in) | `input-bottom` | 蓝色 |
|
||||
| 右侧 | source (out) | `output` | 绿色 |
|
||||
| 上方 70% | source (out) | `output-top` | 绿色 |
|
||||
| 下方 70% | source (out) | `output-bottom` | 绿色 |
|
||||
|
||||
- target handle 设置了 `isConnectableStart`,可以从 in 拖向 out 发起连线(`onConnect` 自动纠正方向)
|
||||
- source handle 设置了 `isConnectableEnd`
|
||||
|
||||
**RoleNodeData** 对齐上游 `RoleDefinition`:
|
||||
```typescript
|
||||
type RoleNodeData = {
|
||||
name: string;
|
||||
description: string;
|
||||
identity: string;
|
||||
prepare: string;
|
||||
execute: string;
|
||||
report: string;
|
||||
};
|
||||
```
|
||||
|
||||
**边类型**:
|
||||
- `default`(GradientEdge)→ 渐变色边(绿→蓝),节点仅有一条出边时使用
|
||||
- `conditional`(ConditionalEdge)→ 带条件标签的渐变色边,节点有多条出边时使用
|
||||
|
||||
**边渲染特性**:
|
||||
- 渐变色:SVG linearGradient,从 source 端绿色(#10b981)到 target 端蓝色(#3b82f6)
|
||||
- 选中时:变为琥珀色(#f59e0b)单色,方便识别
|
||||
- 缺少条件时:红色(#ff5252)
|
||||
- 交互区域:20px 宽透明路径用于点击
|
||||
|
||||
### 4.3 Else 分支机制
|
||||
|
||||
当一个节点有多条 conditional 出边时:
|
||||
- **edges 数组中排第一个的 conditional 边自动成为 else**(兜底分支)
|
||||
- else 边显示灰色 `else` badge(不可点击,无需设置条件)
|
||||
- 其余边显示 `if` badge(需要设置条件,可点击编辑)
|
||||
- 只有一条 conditional 出边时不显示 else 标签
|
||||
- else 边在有 if 兄弟存在时不能被删除(`onBeforeDelete` 保护)
|
||||
- 序列化时 else 边输出 `condition: null`
|
||||
- 反序列化时 `condition: null` 的 transition 排序到第一个
|
||||
|
||||
### 4.4 条件边自动升级与降级
|
||||
|
||||
- **升级**:当用户从某节点拖出第二条边时,`edgesModel.onConnect` 自动将该节点所有出边升级为 `conditional` 类型。
|
||||
- **降级**:当删除 conditional 边后,若该 source 仅剩一条 conditional 出边,`handlers.onDelete` 自动将其降级回 `default` 类型。
|
||||
|
||||
### 4.5 连线约束
|
||||
|
||||
`onConnect` 中的校验逻辑:
|
||||
1. 禁止自连(source === target)
|
||||
2. 禁止同一对节点之间的重复边(source+target 去重)
|
||||
3. 方向归一化:从 input handle 拖到 output handle 时自动反转 source/target
|
||||
4. Handle 类型校验:source 端必须是 output handle,target 端必须是 input handle
|
||||
|
||||
### 4.6 数据转换层(trans/)
|
||||
|
||||
```
|
||||
WorkFlowSteps ──transIn()──→ { nodes, edges } ──transOut()──→ WorkFlowSteps
|
||||
(反序列化) (序列化)
|
||||
```
|
||||
|
||||
- `transIn(steps)`: 外部步骤列表 → ReactFlow 节点和边
|
||||
- `transOut(nodes, edges)`: ReactFlow 节点和边 → 外部步骤列表
|
||||
- `validate(nodes, edges)`: 校验图结构合法性
|
||||
|
||||
三个函数都是**纯函数**。
|
||||
|
||||
### 4.7 验证规则
|
||||
|
||||
1. start 恰好 1 个,输出恰好 1 条
|
||||
2. end 恰好 1 个,输入 ≥1 条,输出 0 条
|
||||
3. role 节点:输入 ≥1、输出 ≥1
|
||||
4. 多输出时:第一条 conditional 边为 else(跳过 condition 检查),其余必须有非空 condition
|
||||
5. role 节点总数 ≥2
|
||||
6. 无孤立节点(正向 BFS 从 start 可达 + 反向 BFS 从 end 可达)
|
||||
|
||||
## 5. 架构分层
|
||||
|
||||
### 5.1 状态管理框架(context.tsx)
|
||||
|
||||
自研的轻量响应式系统,核心概念:
|
||||
|
||||
| 概念 | 说明 |
|
||||
|------|------|
|
||||
| `generate<T>()` | 创建响应式 store(get/set/use/listen) |
|
||||
| `SubModel<T, A>` | 状态切片模板(name + make() + create()) |
|
||||
| `Model` | 事务管理器 + undo/redo 栈 |
|
||||
| `define.model()` | 定义有状态有 actions 的模型 |
|
||||
| `define.view()` | 定义只读视图模型 |
|
||||
| `define.memoize()` | 定义缓存计算模型 |
|
||||
| `define.compute()` | 定义响应式依赖计算(自动追踪) |
|
||||
|
||||
使用 `useSyncExternalStore` 桥接 React 渲染。
|
||||
|
||||
### 5.2 模型层(model/)
|
||||
|
||||
| 模型 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| `nodesModel` | nodes.ts | 节点数组状态 + CRUD 操作 |
|
||||
| `edgesModel` | edges.ts | 边数组状态 + 连线 + conditional 自动升级 + 连线约束 |
|
||||
| `addNodeViewModel` | add-node-view.ts | 添加节点面板的 UI 状态 |
|
||||
| `editNodeViewModel` | edit-node-view.ts | 编辑节点面板的 UI 状态 |
|
||||
| `injection` | inject.ts | DI 实例视图模型 |
|
||||
| `handlers` | handlers.ts | 事件处理器集合(拖拽、连线、删除保护、快捷键、布局、加载/保存) |
|
||||
|
||||
### 5.3 DI 容器(injection.ts)
|
||||
|
||||
```
|
||||
FlowModel(公开 API) Injection(内部实现)
|
||||
├─ load(steps) ──emit──→ emit('load', steps) → handlers.loadSteps()
|
||||
├─ on('save', cb) emit('save', steps) ← handlers.saveData()
|
||||
└─ 持有 Injection 实例
|
||||
```
|
||||
|
||||
- `FlowModel` 是外部消费者唯一接触的类,提供 `load()` 和 `on('save')` 接口
|
||||
- 构造函数接受可选的 `inital_steps` 参数,用于加载默认工作流
|
||||
- `Injection` 是内部事件总线,解耦 server 通信与 UI 状态
|
||||
|
||||
### 5.4 事务与 Undo/Redo
|
||||
|
||||
Model 提供事务机制:
|
||||
- `startTransaction()` 快照当前状态
|
||||
- `endTransaction()` 将快照推入 undo 栈
|
||||
- Ctrl+Z / Ctrl+Y 触发撤销/重做
|
||||
- 拖拽、添加节点、删除等操作自动包裹在事务中
|
||||
|
||||
## 6. 节点体系
|
||||
|
||||
### 6.1 渲染组件
|
||||
|
||||
```
|
||||
ReactFlow
|
||||
├─ nodeTypes: { start: NodeStart, end: NodeEnd, role: NodeRole }
|
||||
└─ edgeTypes: { default: GradientEdge, conditional: ConditionalEdge }
|
||||
```
|
||||
|
||||
`NodeRole` 显示角色名(data.name),使用 teal 色系图标和标签。Handle 分蓝色(in)和绿色(out)两种颜色。
|
||||
|
||||
### 6.2 节点编辑
|
||||
|
||||
角色节点的编辑器直接内联在 AddNodePanel 和 EditNodePanel 中,可编辑字段:
|
||||
- name(必填)
|
||||
- description、identity、prepare、execute、report(textarea)
|
||||
|
||||
## 7. UI 面板
|
||||
|
||||
| 面板 | 位置 | 内容 |
|
||||
|------|------|------|
|
||||
| Toolbar | 顶部居中 | Undo/Redo、添加角色、自动布局、保存 |
|
||||
| AddNodePanel | 右下角 | 角色节点创建表单(name + 6 字段 → 确认) |
|
||||
| EditNodePanel | 右下角 | 角色节点编辑表单(预填当前数据 → 确认) |
|
||||
|
||||
AddNodePanel 和 EditNodePanel 互斥显示,点击外部自动关闭。
|
||||
|
||||
## 8. 自动布局(layout/)
|
||||
|
||||
`LayoutLR(nodes, edges)` 算法:
|
||||
1. 拓扑排序分层(BFS,start → layer 0,end → max+1)
|
||||
2. 按层分组
|
||||
3. 计算 X/Y 坐标(水平间距 80px,垂直间距 40px)
|
||||
4. 无变化时返回原数组(避免无效重渲染)
|
||||
|
||||
## 9. 核心数据流
|
||||
|
||||
### 加载工作流
|
||||
|
||||
```
|
||||
FlowModel.load(steps) / FlowModel(initialSteps)
|
||||
→ Injection.emit('load', steps)
|
||||
→ handlers.loadSteps()
|
||||
→ transIn(steps) → { nodes, edges }
|
||||
(condition: null 的 transition 排序到第一个,成为 else)
|
||||
→ nodesModel.set(nodes)
|
||||
→ edgesModel.set(edges)
|
||||
→ autoLayoutLR()
|
||||
→ model.reset()(清空 undo/redo)
|
||||
```
|
||||
|
||||
### 保存工作流
|
||||
|
||||
```
|
||||
用户点击 Save
|
||||
→ handlers.saveData()
|
||||
→ validate(nodes, edges)
|
||||
→ 校验失败 → Toast 提示错误
|
||||
→ 校验通过 → transOut(nodes, edges) → WorkFlowSteps
|
||||
(第一条 conditional 边序列化为 condition: null)
|
||||
→ Injection.emit('save', steps)
|
||||
→ FlowModel.emit('save', steps)
|
||||
→ 外部消费者(server/CLI)接收
|
||||
```
|
||||
|
||||
### 连线与条件边升级
|
||||
|
||||
```
|
||||
用户拖线连接两个节点
|
||||
→ edgesModel.onConnect(params)
|
||||
→ normalizeConnection(方向纠正)
|
||||
→ 校验(自连、重复、handle 类型)
|
||||
→ 检查 source 已有出边数量
|
||||
→ 已有出边 → 新边 + 已有边全部升级为 conditional
|
||||
→ 首条出边 → 创建普通边
|
||||
```
|
||||
|
||||
### 删除保护
|
||||
|
||||
```
|
||||
用户选中节点/边按 Delete
|
||||
→ handlers.onBeforeDelete({ nodes, edges })
|
||||
→ start/end 节点 → 阻止
|
||||
→ else 边(有 if 兄弟时)→ 阻止
|
||||
→ 其他 → 允许
|
||||
```
|
||||
|
||||
## 10. 上游数据模型参考
|
||||
|
||||
workflow-dashboard 消费的 YAML 工作流最终映射自 `WorkflowPayload`(定义在 workflow-protocol):
|
||||
|
||||
```typescript
|
||||
type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>; // 角色定义(4 段式:identity/prepare/execute/report)
|
||||
conditions: Record<string, ConditionDefinition>; // JSONata 条件表达式
|
||||
graph: Record<string, Transition[]>; // 角色间的转移图
|
||||
};
|
||||
```
|
||||
|
||||
workflow-dashboard 使用 `WorkFlowSteps` 格式作为交换数据,其中 `WorkFlowRole` 的字段与 `RoleDefinition` 对齐(description/identity/prepare/execute/report),`WorkFlowTransition` 对应 graph 中的 `Transition`。外部(CLI/server)负责 `WorkflowPayload` ↔ `WorkFlowSteps` 的转换。
|
||||
|
||||
## 11. 当前状态与待完善项
|
||||
|
||||
- **WebSocket 集成**: 尚未实现,CLI connect 的 WebSocket 通信待开发
|
||||
- **验证**: 图结构校验 + 可达性检测 + else 分支规则已实现
|
||||
- **只读模式**: Detail 页面有"编辑/预览"切换按钮,但编辑器尚未实现真正的只读模式(禁止交互)
|
||||
|
||||
## 12. 业务系统
|
||||
|
||||
### 12.1 路由
|
||||
|
||||
| 路由 | 页面 | 文件 |
|
||||
|------|------|------|
|
||||
| `/` | Home — Workflow 列表 | `src/pages/home.tsx` |
|
||||
| `/workflow/:name` | Detail — 预览/编辑 | `src/pages/detail.tsx` |
|
||||
|
||||
### 12.2 后端 API
|
||||
|
||||
Elysia REST API(`server/api.ts`),通过 Vite 插件(`vite-dev.ts`)集成到 dev server。
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/workflows` | 列出所有 workflow(name + description) |
|
||||
| GET | `/api/workflows/:name` | 获取单个 workflow(返回 WorkFlowSteps JSON) |
|
||||
| POST | `/api/workflows` | 新建 workflow(body: `{name, description}`) |
|
||||
| PUT | `/api/workflows/:name` | 保存 workflow(body: WorkFlowSteps JSON) |
|
||||
| DELETE | `/api/workflows/:name` | 删除 workflow |
|
||||
|
||||
### 12.3 数据存储
|
||||
|
||||
- 存储目录:`tmp/workflow/`,文件名 `{name}.yaml`
|
||||
- 存储格式:WorkflowPayload YAML(与上游 workflow-protocol 一致)
|
||||
- Server 端负责 WorkflowPayload ↔ WorkFlowSteps 转换(`server/workflow.ts`)
|
||||
|
||||
字段映射:
|
||||
| WorkFlowRole | RoleDefinition |
|
||||
|--------------|---------------|
|
||||
| name | roles map key |
|
||||
| description | description |
|
||||
| identity | goal |
|
||||
| prepare | capabilities (join/split by `\n`) |
|
||||
| execute | procedure |
|
||||
| report | output |
|
||||
|
||||
条件映射:WorkFlowTransition.condition 存储表达式字符串,保存时提取为 named conditions map。
|
||||
|
||||
### 12.4 shadcn/ui
|
||||
|
||||
已初始化 shadcn(`components.json`),使用 `@` 路径别名。已安装组件:
|
||||
- button、card、dialog、input、textarea
|
||||
- 组件位于 `src/components/ui/`
|
||||
|
||||
### 12.5 目录结构更新
|
||||
|
||||
```
|
||||
workflow-dashboard/
|
||||
├── server/
|
||||
│ ├── api.ts # Elysia REST API(health + workflow CRUD)
|
||||
│ └── workflow.ts # Workflow 文件读写 + 格式转换
|
||||
├── src/
|
||||
│ ├── components/ui/ # shadcn 组件
|
||||
│ ├── pages/
|
||||
│ │ ├── home.tsx # Home 列表页
|
||||
│ │ └── detail.tsx # Workflow 详情/编辑页
|
||||
│ └── ...
|
||||
├── tmp/workflow/ # Workflow YAML 存储目录(开发阶段)
|
||||
└── components.json # shadcn 配置
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workflow UI</title>
|
||||
<link rel="stylesheet" href="./src/index.css" />
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-dashboard",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun server.ts",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"elysia": "^1.4.28",
|
||||
"immer": "^11.1.8",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router": "^7.15.1",
|
||||
"shadcn": "^4.8.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/bun": "^1.2.14",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createServer } from "vite";
|
||||
|
||||
const PORT = 3000;
|
||||
|
||||
const server = await createServer({
|
||||
server: { port: PORT },
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
|
||||
// biome-ignore lint/nursery/noConsole: CLI user-facing output
|
||||
console.log(`Workflow UI running at http://localhost:${PORT}`);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import type { WorkFlowSteps } from "../shared/types.ts";
|
||||
import {
|
||||
listWorkflows,
|
||||
getWorkflow,
|
||||
createWorkflow,
|
||||
saveWorkflow,
|
||||
deleteWorkflow,
|
||||
} from "./workflow.ts";
|
||||
|
||||
export function createApi() {
|
||||
return new Elysia({ prefix: "/api" })
|
||||
.get("/health", () => ({ status: "ok" }))
|
||||
.get("/workflows", () => listWorkflows())
|
||||
.get("/workflows/:name", async ({ params }) => {
|
||||
try {
|
||||
const steps = await getWorkflow(params.name);
|
||||
return steps;
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
})
|
||||
.post(
|
||||
"/workflows",
|
||||
async ({ body }) => {
|
||||
await createWorkflow(body.name, body.description);
|
||||
return { ok: true };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
description: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/workflows/:name",
|
||||
async ({ params, body }) => {
|
||||
const steps: WorkFlowSteps = typeof body === "string" ? JSON.parse(body) : body;
|
||||
await saveWorkflow(params.name, steps);
|
||||
return { ok: true };
|
||||
},
|
||||
{
|
||||
body: t.Array(
|
||||
t.Object({
|
||||
role: t.Object({
|
||||
name: t.String(),
|
||||
description: t.String(),
|
||||
identity: t.String(),
|
||||
prepare: t.String(),
|
||||
execute: t.String(),
|
||||
report: t.String(),
|
||||
}),
|
||||
transitions: t.Array(
|
||||
t.Object({
|
||||
target: t.String(),
|
||||
condition: t.Union([t.String(), t.Null()]),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
)
|
||||
.delete("/workflows/:name", async ({ params }) => {
|
||||
try {
|
||||
await deleteWorkflow(params.name);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { readdir, readFile, writeFile, unlink, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import YAML from "yaml";
|
||||
import type {
|
||||
WorkflowPayload,
|
||||
RoleDefinition,
|
||||
Transition,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
||||
|
||||
const WORKFLOW_DIR = join(import.meta.dirname, "..", "tmp", "workflow");
|
||||
|
||||
async function ensureDir() {
|
||||
await mkdir(WORKFLOW_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
||||
const conditionMap = new Map<string, string>();
|
||||
for (const [name, def] of Object.entries(payload.conditions)) {
|
||||
conditionMap.set(name, def.expression);
|
||||
}
|
||||
|
||||
const steps: WorkFlowSteps = [];
|
||||
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
|
||||
const graphTransitions = payload.graph[roleName] ?? [];
|
||||
const transitions: WorkFlowTransition[] = graphTransitions.map((t) => ({
|
||||
target: t.role === "$END" ? "END" : t.role,
|
||||
condition: t.condition ? (conditionMap.get(t.condition) ?? t.condition) : null,
|
||||
}));
|
||||
|
||||
steps.push({
|
||||
role: {
|
||||
name: roleName,
|
||||
description: roleDef.description,
|
||||
identity: roleDef.goal,
|
||||
prepare: roleDef.capabilities.join("\n"),
|
||||
execute: roleDef.procedure,
|
||||
report: roleDef.output,
|
||||
},
|
||||
transitions,
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
||||
const roles: Record<string, RoleDefinition> = {};
|
||||
const conditions: WorkflowPayload["conditions"] = {};
|
||||
const graph: Record<string, Transition[]> = {};
|
||||
|
||||
const expressionToName = new Map<string, string>();
|
||||
let condIdx = 0;
|
||||
|
||||
for (const step of steps) {
|
||||
const r = step.role;
|
||||
roles[r.name] = {
|
||||
description: r.description,
|
||||
goal: r.identity,
|
||||
capabilities: r.prepare ? r.prepare.split("\n").filter(Boolean) : [],
|
||||
procedure: r.execute,
|
||||
output: r.report,
|
||||
frontmatter: "",
|
||||
};
|
||||
|
||||
const transitions: Transition[] = step.transitions.map((t) => {
|
||||
let condName: string | null = null;
|
||||
if (t.condition) {
|
||||
if (expressionToName.has(t.condition)) {
|
||||
condName = expressionToName.get(t.condition)!;
|
||||
} else {
|
||||
condName = `cond${condIdx++}`;
|
||||
expressionToName.set(t.condition, condName);
|
||||
conditions[condName] = {
|
||||
description: "",
|
||||
expression: t.condition,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
role: t.target === "END" ? "$END" : t.target,
|
||||
condition: condName,
|
||||
};
|
||||
});
|
||||
|
||||
graph[r.name] = transitions;
|
||||
}
|
||||
|
||||
if (steps.length > 0) {
|
||||
graph["$START"] = [{ role: steps[0].role.name, condition: null }];
|
||||
}
|
||||
|
||||
return { name, description, roles, conditions, graph };
|
||||
}
|
||||
|
||||
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
||||
await ensureDir();
|
||||
const files = await readdir(WORKFLOW_DIR);
|
||||
const results: WorkflowSummary[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".yaml")) continue;
|
||||
const content = await readFile(join(WORKFLOW_DIR, file), "utf-8");
|
||||
const payload = YAML.parse(content) as WorkflowPayload;
|
||||
results.push({ name: payload.name, description: payload.description });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getWorkflow(name: string): Promise<WorkFlowSteps> {
|
||||
const content = await readFile(join(WORKFLOW_DIR, `${name}.yaml`), "utf-8");
|
||||
const payload = YAML.parse(content) as WorkflowPayload;
|
||||
return payloadToSteps(payload);
|
||||
}
|
||||
|
||||
export async function createWorkflow(name: string, description: string): Promise<void> {
|
||||
await ensureDir();
|
||||
const payload: WorkflowPayload = {
|
||||
name,
|
||||
description,
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
|
||||
}
|
||||
|
||||
export async function saveWorkflow(name: string, steps: WorkFlowSteps): Promise<void> {
|
||||
const filePath = join(WORKFLOW_DIR, `${name}.yaml`);
|
||||
let description = "";
|
||||
try {
|
||||
const existing = await readFile(filePath, "utf-8");
|
||||
const existingPayload = YAML.parse(existing) as WorkflowPayload;
|
||||
description = existingPayload.description;
|
||||
} catch {
|
||||
// file doesn't exist, use empty description
|
||||
}
|
||||
const payload = stepsToPayload(name, description, steps);
|
||||
await writeFile(filePath, YAML.stringify(payload), "utf-8");
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(name: string): Promise<void> {
|
||||
await unlink(join(WORKFLOW_DIR, `${name}.yaml`));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type WorkFlowRole = {
|
||||
name: string;
|
||||
description: string;
|
||||
identity: string;
|
||||
prepare: string;
|
||||
execute: string;
|
||||
report: string;
|
||||
};
|
||||
|
||||
export type WorkFlowTransition = {
|
||||
target: string;
|
||||
condition: string | null;
|
||||
};
|
||||
|
||||
export type WorkFlowStep = {
|
||||
role: WorkFlowRole;
|
||||
transitions: WorkFlowTransition[];
|
||||
};
|
||||
|
||||
export type WorkFlowSteps = WorkFlowStep[];
|
||||
|
||||
export type WorkflowSummary = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export function Layout(): ReactNode {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-background text-foreground">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,283 @@
|
||||
import { createContext, useMemo, useSyncExternalStore, useContext, useLayoutEffect } from 'react';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { useReactFlow, ReactFlowInstance } from '@xyflow/react';
|
||||
import type { AnyWorkNode } from './type';
|
||||
|
||||
type Reduce<T> = (data: T) => T;
|
||||
type Setter<T> = (ch: Reduce<T> | T) => void;
|
||||
|
||||
interface State<T, A> {
|
||||
readonly get: () => T;
|
||||
readonly set: Setter<T>;
|
||||
readonly use: () => T;
|
||||
readonly listen: (cb: VoidFunction) => VoidFunction;
|
||||
readonly actions: A;
|
||||
readonly onlyView: boolean;
|
||||
}
|
||||
type Use = <T, A>(sub: SubModel<T, A>) => [T, A];
|
||||
type UseV = <T>(sub: SubModel<T, any>) => T;
|
||||
type Create<T, A> = (set: Setter<T>, get: () => T, model: Model) => A;
|
||||
|
||||
export const uuid = () => Math.round((Math.random() + 1) * Date.now()).toString(36);
|
||||
|
||||
export function generate<T>(val: T) {
|
||||
const listener = new Set<VoidFunction>();
|
||||
const get = () => val;
|
||||
function set(ch: T | ((prev: T) => T)) {
|
||||
const next = (typeof ch === 'function') ? (ch as (prev: T) => T)(val) : ch;
|
||||
if (Object.is(val, next)) return;
|
||||
val = next;
|
||||
listener.forEach(call => call());
|
||||
}
|
||||
const listen = (call: VoidFunction) => {
|
||||
listener.add(call);
|
||||
return () => listener.delete(call);
|
||||
};
|
||||
const use = () => useSyncExternalStore(listen, get, get);
|
||||
return { get, set, use, listen };
|
||||
}
|
||||
|
||||
class SubModel<T, A> {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private make: () => T,
|
||||
private create: Create<T, A>,
|
||||
private onlyView = false,
|
||||
) {}
|
||||
|
||||
public gen(model: Model): State<T, A> {
|
||||
const { make, create, onlyView } = this;
|
||||
const { get, set, use, listen } = generate(make());
|
||||
const actions = create(set, get, model);
|
||||
return { get, set, use, listen, actions, onlyView };
|
||||
}
|
||||
|
||||
use(): [T, A] {
|
||||
const { query } = useContext(Context);
|
||||
const { use, actions } = query(this);
|
||||
return [use(), actions];
|
||||
}
|
||||
useData(): T {
|
||||
const { query } = useContext(Context);
|
||||
return query(this).use();
|
||||
}
|
||||
useCreation(): A {
|
||||
const { query } = useContext(Context);
|
||||
return query(this).actions;
|
||||
}
|
||||
}
|
||||
|
||||
type Snapshot = [name: string, data: any];
|
||||
class Model {
|
||||
private ustack: Snapshot[][] = [];
|
||||
private rstack: Snapshot[][] = [];
|
||||
private transaction = 0;
|
||||
private backup = new Map<string, any>();
|
||||
public flow = {} as ReactFlowInstance<AnyWorkNode>;
|
||||
private stackListeners = new Set<() => void>();
|
||||
public readonly stackState: readonly [boolean, boolean] = [false, false];
|
||||
|
||||
constructor(
|
||||
private readonly store: Map<string, State<any, any>>,
|
||||
public readonly use: Use,
|
||||
) {}
|
||||
|
||||
public reset() {
|
||||
this.ustack = [];
|
||||
this.rstack = [];
|
||||
this.transaction = 0;
|
||||
this.backup.clear();
|
||||
this.triggerStackState();
|
||||
}
|
||||
|
||||
public readonly listenStackState = (cb: () => void) => {
|
||||
this.stackListeners.add(cb);
|
||||
return () => this.stackListeners.delete(cb);
|
||||
}
|
||||
|
||||
private triggerStackState() {
|
||||
// @ts-expect-error
|
||||
this.stackState = [this.canUndo(), this.canRedo()];
|
||||
this.stackListeners.forEach(call => call());
|
||||
}
|
||||
|
||||
private getStackState = () => this.stackState;
|
||||
public useStackState() {
|
||||
const get = this.getStackState;
|
||||
return useSyncExternalStore(this.listenStackState, get, get);
|
||||
}
|
||||
|
||||
public log() {
|
||||
console.log('undo stack:', this.ustack);
|
||||
console.log('redo stack:', this.rstack);
|
||||
const snapshots: Record<string, any> = {};
|
||||
this.store.forEach((state, name) => {
|
||||
snapshots[name] = state.get();
|
||||
});
|
||||
console.log('current state:', snapshots);
|
||||
}
|
||||
|
||||
public undo() {
|
||||
const { ustack, rstack, store } = this;
|
||||
const item = ustack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
rstack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
|
||||
public redo() {
|
||||
const { ustack, rstack, store } = this;
|
||||
const item = rstack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
ustack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
|
||||
public canUndo() {
|
||||
return this.ustack.length > 0;
|
||||
}
|
||||
|
||||
public canRedo() {
|
||||
return this.rstack.length > 0;
|
||||
}
|
||||
|
||||
public startTransaction() {
|
||||
if (this.transaction === 0) {
|
||||
this.backup.clear();
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
this.backup.set(name, state.get());
|
||||
});
|
||||
}
|
||||
this.transaction += 1;
|
||||
return this.endTransaction;
|
||||
}
|
||||
|
||||
public endTransaction = () => {
|
||||
if (this.transaction === 0) return;
|
||||
this.transaction -= 1;
|
||||
if (this.transaction === 0) {
|
||||
const changes: Snapshot[] = [];
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
const before = this.backup.get(name);
|
||||
if (Object.is(before, state.get())) return;
|
||||
changes.push([name, before]);
|
||||
});
|
||||
this.backup.clear();
|
||||
if (changes.length === 0) return;
|
||||
this.ustack.push(changes);
|
||||
this.rstack.length = 0;
|
||||
this.triggerStackState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function build() {
|
||||
const store = new Map<string, State<any, any>>();
|
||||
|
||||
const mem: Record<string, any> = {};
|
||||
function use<T, A>(m: SubModel<T, A>): [T, A] {
|
||||
const state = query(m);
|
||||
return [state.get(), state.actions];
|
||||
}
|
||||
|
||||
const model = new Model(store, use);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// @ts-ignore
|
||||
window.__md__ = model;
|
||||
}
|
||||
|
||||
function query<T, A>(m: SubModel<T, A>): State<T, A> {
|
||||
const exist = store.get(m.name);
|
||||
if (exist) return exist as State<T, A>;
|
||||
const created = m.gen(model);
|
||||
store.set(m.name, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
return { query, model, mem, use }
|
||||
}
|
||||
|
||||
const Context = createContext(build());
|
||||
|
||||
export function useModel() {
|
||||
return useContext(Context).model;
|
||||
}
|
||||
|
||||
export function RegisterFlowToContext() {
|
||||
const { model } = useContext(Context);
|
||||
const instance = useReactFlow<AnyWorkNode>();
|
||||
useLayoutEffect(() => {
|
||||
model.flow = instance;
|
||||
}, [instance]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ModelProvider: FC<PropsWithChildren> = (p) => (
|
||||
<Context.Provider value={useMemo(build, [])}>
|
||||
{p.children}
|
||||
</Context.Provider>
|
||||
);
|
||||
|
||||
function defineModel<T, A>(name: string, make: () => T, create: Create<T, A>) {
|
||||
return new SubModel<T, A>(name, make, create);
|
||||
}
|
||||
|
||||
const defaultCreate: Create<any, Setter<any>> = (set) => set;
|
||||
function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A>
|
||||
function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>>
|
||||
function defineView<T>(name: string, make: () => T, create?: any): any {
|
||||
return new SubModel<T, any>(name, make, create ?? defaultCreate, true);
|
||||
}
|
||||
|
||||
function memoize<T>(init: (use: Use, model: Model) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
const { mem, model, use } = useContext(Context);
|
||||
const fn = mem[id] || (mem[id] = init(use, model));
|
||||
return fn as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compute<T>(calc: (use: UseV) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
const { mem, query } = useContext(Context);
|
||||
let state: ReturnType<typeof generate<T>> = mem[id];
|
||||
if (state) return state.use();
|
||||
|
||||
const deps = new Set<SubModel<any, any>>();
|
||||
let usev = (m: SubModel<any, any>) => (deps.add(m), query(m).get());
|
||||
mem[id] = state = generate<T>(calc(usev));
|
||||
if (deps.size) {
|
||||
usev = m => query(m).get();
|
||||
const update = () => state.set(calc(usev));
|
||||
deps.forEach(m => query(m).listen(update));
|
||||
}
|
||||
return state.use();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const define = {
|
||||
model: defineModel,
|
||||
view: defineView,
|
||||
memoize,
|
||||
compute,
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
getSmoothStepPath,
|
||||
EdgeLabelRenderer,
|
||||
useReactFlow,
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
} from "@xyflow/react";
|
||||
import { useState, useRef, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
||||
import { useModel } from "../context.tsx";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const SOURCE_COLOR = "#10b981";
|
||||
const TARGET_COLOR = "#3b82f6";
|
||||
const LACK_COLOR = "#ff5252";
|
||||
const RADIUS = 12;
|
||||
|
||||
function GradientPath({
|
||||
id,
|
||||
path,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
hasCondition,
|
||||
selected,
|
||||
}: {
|
||||
id: string;
|
||||
path: string;
|
||||
sourceX: number;
|
||||
sourceY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
hasCondition: boolean | null;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const gradientId = `gradient-${id}`;
|
||||
const showLack = hasCondition === false;
|
||||
const strokeStyle = selected
|
||||
? { stroke: '#f59e0b', strokeWidth: 2 }
|
||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||
|
||||
return (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1={sourceX}
|
||||
y1={sourceY}
|
||||
x2={targetX}
|
||||
y2={targetY}
|
||||
>
|
||||
<stop offset="0%" stopColor={showLack ? LACK_COLOR : SOURCE_COLOR} />
|
||||
<stop offset="100%" stopColor={showLack ? LACK_COLOR : TARGET_COLOR} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={20}
|
||||
className="react-flow__edge-interaction"
|
||||
/>
|
||||
<path
|
||||
id={id}
|
||||
d={path}
|
||||
fill="none"
|
||||
className="react-flow__edge-path"
|
||||
style={strokeStyle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ElseBadge({ labelX, labelY }: { labelX: number; labelY: number }): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
}}
|
||||
>
|
||||
<span className="inline-block px-1 bg-white rounded text-[10px] border border-gray-300 text-gray-500">
|
||||
else
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionLabelProps = {
|
||||
condition: string | undefined;
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
onSave: (value: string) => void;
|
||||
};
|
||||
|
||||
function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelProps): ReactNode {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function handleBadgeClick() {
|
||||
setInputValue(condition || "");
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (inputValue.trim()) {
|
||||
onSave(inputValue.trim());
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
handleSave();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleClickOutside(e: PointerEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("pointerdown", handleClickOutside, true);
|
||||
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute pointer-events-auto"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
zIndex: isOpen ? 1000 : undefined,
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div onClick={handleBadgeClick} onKeyDown={undefined} className="cursor-pointer">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block px-1 bg-white rounded text-[10px]",
|
||||
condition
|
||||
? "border border-gray-300 text-black"
|
||||
: "border border-dashed text-red-500",
|
||||
)}
|
||||
style={condition ? undefined : { borderColor: LACK_COLOR }}
|
||||
>
|
||||
if
|
||||
</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1 z-50 bg-white rounded shadow-lg border border-gray-200 p-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input
|
||||
type="text"
|
||||
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
|
||||
placeholder="输入条件"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="p-0.5 text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
<Check size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
|
||||
const siblings = allEdges.filter(e => e.source === source && e.type === 'conditional');
|
||||
return siblings.length >= 2 && siblings[0].id === edgeId;
|
||||
}
|
||||
|
||||
export function ConditionalEdge({
|
||||
id,
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
data,
|
||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
});
|
||||
const flow = useReactFlow();
|
||||
const model = useModel();
|
||||
|
||||
const allEdges = flow.getEdges();
|
||||
const isElse = useMemo(() => isElseEdge(id, source, allEdges), [id, source, allEdges]);
|
||||
|
||||
const condition = data?.condition;
|
||||
function handleSave(value: string) {
|
||||
model.startTransaction();
|
||||
flow.updateEdgeData(id, { condition: value });
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GradientPath
|
||||
id={id}
|
||||
path={edgePath}
|
||||
sourceX={sourceX}
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={isElse ? null : (condition ? true : false)}
|
||||
selected={!!selected}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
{isElse
|
||||
? <ElseBadge labelX={labelX} labelY={labelY} />
|
||||
: <ConditionLabel condition={condition} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||
}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GradientEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
}: EdgeProps<Edge>): ReactNode {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
});
|
||||
|
||||
return (
|
||||
<GradientPath
|
||||
id={id}
|
||||
path={edgePath}
|
||||
sourceX={sourceX}
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={null}
|
||||
selected={!!selected}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ConditionalEdge, GradientEdge } from './conditional';
|
||||
|
||||
export const edgeTypes = {
|
||||
conditional: ConditionalEdge,
|
||||
default: GradientEdge,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { memo, createElement, useLayoutEffect, useEffect, createContext, useContext } from 'react';
|
||||
import { ReactFlow, ReactFlowProvider, Controls, Background, type Edge } from '@xyflow/react';
|
||||
// @ts-ignore
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { nodesModel, edgesModel, handlers, injection } from './model';
|
||||
import { ModelProvider, RegisterFlowToContext } from './context';
|
||||
import { nodeTypes } from './nodes';
|
||||
import { edgeTypes } from './edges';
|
||||
import { Dialogs, TopCenterPanel } from './panel';
|
||||
import type { AnyWorkNode } from './type';
|
||||
import { FlowModel, InternalField } from './injection';
|
||||
|
||||
export * from './trans/type';
|
||||
|
||||
const proOptions = { hideAttribution: true };
|
||||
|
||||
const ReadonlyContext = createContext(false);
|
||||
export const useReadonly = () => useContext(ReadonlyContext);
|
||||
|
||||
function Flow() {
|
||||
const [nodes, { onNodesChange }] = nodesModel.use();
|
||||
const [edges, { onEdgesChange, onConnect }] = edgesModel.use();
|
||||
const { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } = handlers.use();
|
||||
const readonly = useReadonly();
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%' }} tabIndex={0} onKeyDown={readonly ? undefined : handleKeyDown}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow<AnyWorkNode, Edge>
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={readonly ? undefined : onNodesChange}
|
||||
onEdgesChange={readonly ? undefined : onEdgesChange}
|
||||
onConnect={readonly ? undefined : onConnect}
|
||||
fitView
|
||||
proOptions={proOptions}
|
||||
onNodeDragStart={readonly ? undefined : onNodeDragStart}
|
||||
onNodeDragStop={readonly ? undefined : onNodeDragStop}
|
||||
onConnectEnd={readonly ? undefined : onConnectEnd}
|
||||
onBeforeDelete={readonly ? undefined : onBeforeDelete}
|
||||
onDelete={readonly ? undefined : onDelete}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodesDraggable={!readonly}
|
||||
nodesConnectable={!readonly}
|
||||
elementsSelectable={!readonly}
|
||||
>
|
||||
<RegisterFlowToContext />
|
||||
<Background />
|
||||
<Controls />
|
||||
{!readonly && <TopCenterPanel />}
|
||||
{!readonly && <Dialogs />}
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MemoFlow = memo(Flow);
|
||||
|
||||
interface Props {
|
||||
model: FlowModel;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
function Connect({ model }: { model: FlowModel }) {
|
||||
const { loadSteps } = handlers.use();
|
||||
const inject = injection.useCreation();
|
||||
const instance = model[InternalField];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return inject(instance);
|
||||
}, [instance]);
|
||||
|
||||
useEffect(() => {
|
||||
return instance.on('load', loadSteps);
|
||||
}, [instance]);
|
||||
|
||||
return <MemoFlow />;
|
||||
}
|
||||
|
||||
export { FlowModel };
|
||||
// biome-ignore lint/style/noDefaultExport: FlowEditor is the main public component
|
||||
export default ({ model, readonly = false }: Props) => (
|
||||
<ReadonlyContext.Provider value={readonly}>
|
||||
<ModelProvider>
|
||||
{createElement(Connect, { model })}
|
||||
</ModelProvider>
|
||||
</ReadonlyContext.Provider>
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { WorkFlowSteps } from "./trans";
|
||||
import { Eventer } from './utils/eventer';
|
||||
|
||||
interface PublicEvents {
|
||||
save: WorkFlowSteps;
|
||||
}
|
||||
|
||||
interface PrivateEvents {
|
||||
load: WorkFlowSteps;
|
||||
}
|
||||
|
||||
export const InternalField = Symbol('InternalField');
|
||||
|
||||
export class Injection extends Eventer<PrivateEvents> {
|
||||
constructor(
|
||||
public readonly emitPublic: Eventer<PublicEvents>['emit'],
|
||||
private inital_steps?: WorkFlowSteps,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public on: Eventer<PrivateEvents>['on'] = (type, lisenter) => {
|
||||
const off = super.on(type, lisenter);
|
||||
if (type === 'load' && this.inital_steps) {
|
||||
lisenter(this.inital_steps);
|
||||
this.inital_steps = undefined;
|
||||
}
|
||||
return off;
|
||||
};
|
||||
}
|
||||
|
||||
export class FlowModel {
|
||||
private readonly eventer = new Eventer<PublicEvents>();
|
||||
public on = this.eventer.on.bind(this.eventer);
|
||||
public off = this.eventer.off.bind(this.eventer);
|
||||
|
||||
public readonly [InternalField]: Injection;
|
||||
|
||||
constructor(inital_steps?: WorkFlowSteps) {
|
||||
this[InternalField] = new Injection(
|
||||
this.eventer.emit.bind(this.eventer),
|
||||
inital_steps,
|
||||
);
|
||||
}
|
||||
|
||||
public load(steps: WorkFlowSteps) {
|
||||
this[InternalField].emit('load', steps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
|
||||
const DEFAULT_NODE_WIDTH = 120;
|
||||
const DEFAULT_NODE_HEIGHT = 50;
|
||||
const HORIZONTAL_GAP = 80; // 层与层之间的水平间距
|
||||
const VERTICAL_GAP = 40; // 同层节点之间的垂直间距
|
||||
|
||||
/**
|
||||
* 获取节点的尺寸
|
||||
*/
|
||||
function getNodeSize(node: Node): { width: number; height: number } {
|
||||
return {
|
||||
width: node.measured?.width ?? DEFAULT_NODE_WIDTH,
|
||||
height: node.measured?.height ?? DEFAULT_NODE_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建邻接表(出边)和入度表
|
||||
*/
|
||||
function buildGraph(nodes: Node[], edges: Edge[]) {
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const outgoing = new Map<string, string[]>(); // nodeId -> [targetIds]
|
||||
const incoming = new Map<string, string[]>(); // nodeId -> [sourceIds]
|
||||
const inDegree = new Map<string, number>();
|
||||
|
||||
// 初始化
|
||||
for (const node of nodes) {
|
||||
outgoing.set(node.id, []);
|
||||
incoming.set(node.id, []);
|
||||
inDegree.set(node.id, 0);
|
||||
}
|
||||
|
||||
// 构建图
|
||||
for (const edge of edges) {
|
||||
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
|
||||
outgoing.get(edge.source)!.push(edge.target);
|
||||
incoming.get(edge.target)!.push(edge.source);
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return { outgoing, incoming, inDegree };
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用拓扑排序将节点分层
|
||||
* - 'start' 节点固定在第 0 层
|
||||
* - 'end' 节点固定在最后一层
|
||||
* - 孤立节点放在中间层
|
||||
*/
|
||||
function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
||||
const { outgoing, inDegree } = buildGraph(nodes, edges);
|
||||
const layers = new Map<string, number>();
|
||||
const queue: string[] = [];
|
||||
|
||||
// 1. start 节点固定在第 0 层
|
||||
layers.set('start', 0);
|
||||
queue.push('start');
|
||||
|
||||
// 2. BFS 分层(排除 end 节点,稍后单独处理)
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const currentLayer = layers.get(current)!;
|
||||
|
||||
for (const target of outgoing.get(current) ?? []) {
|
||||
// 跳过 end 节点,稍后处理
|
||||
if (target === 'end') continue;
|
||||
|
||||
const newLayer = currentLayer + 1;
|
||||
const existingLayer = layers.get(target);
|
||||
|
||||
if (existingLayer === undefined) {
|
||||
layers.set(target, newLayer);
|
||||
inDegree.set(target, (inDegree.get(target) ?? 1) - 1);
|
||||
if (inDegree.get(target) === 0) {
|
||||
queue.push(target);
|
||||
}
|
||||
} else {
|
||||
// 如果已有层级,取更大的值(确保所有前驱都在前面)
|
||||
layers.set(target, Math.max(existingLayer, newLayer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 找到当前最大层级
|
||||
let maxLayer = 0;
|
||||
for (const layer of layers.values()) {
|
||||
maxLayer = Math.max(maxLayer, layer);
|
||||
}
|
||||
|
||||
// 4. 处理孤立节点(没有被分配层级的非 start/end 节点)
|
||||
// 把它们放在中间层
|
||||
const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2));
|
||||
for (const node of nodes) {
|
||||
if (node.id !== 'start' && node.id !== 'end' && !layers.has(node.id)) {
|
||||
layers.set(node.id, middleLayer);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 重新计算最大层级(可能因为孤立节点而变化)
|
||||
maxLayer = 0;
|
||||
for (const [id, layer] of layers) {
|
||||
if (id !== 'end') {
|
||||
maxLayer = Math.max(maxLayer, layer);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. end 节点固定在最后一层
|
||||
layers.set('end', maxLayer + 1);
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按层级分组节点
|
||||
*/
|
||||
function groupByLayer<N extends Node>(nodes: N[], layers: Map<string, number>): Map<number, N[]> {
|
||||
const groups = new Map<number, N[]>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const layer = layers.get(node.id) ?? 0;
|
||||
if (!groups.has(layer)) {
|
||||
groups.set(layer, []);
|
||||
}
|
||||
groups.get(layer)!.push(node);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每层的最大宽度
|
||||
*/
|
||||
function calculateLayerWidths(layerGroups: Map<number, Node[]>): Map<number, number> {
|
||||
const widths = new Map<number, number>();
|
||||
|
||||
for (const [layer, nodesInLayer] of layerGroups) {
|
||||
let maxWidth = 0;
|
||||
for (const node of nodesInLayer) {
|
||||
const { width } = getNodeSize(node);
|
||||
maxWidth = Math.max(maxWidth, width);
|
||||
}
|
||||
widths.set(layer, maxWidth);
|
||||
}
|
||||
|
||||
return widths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每层的 X 起始位置
|
||||
*/
|
||||
function calculateLayerXPositions(
|
||||
layerWidths: Map<number, number>,
|
||||
maxLayer: number
|
||||
): Map<number, number> {
|
||||
const xPositions = new Map<number, number>();
|
||||
let currentX = 0;
|
||||
|
||||
for (let layer = 0; layer <= maxLayer; layer++) {
|
||||
xPositions.set(layer, currentX);
|
||||
const layerWidth = layerWidths.get(layer) ?? DEFAULT_NODE_WIDTH;
|
||||
currentX += layerWidth + HORIZONTAL_GAP;
|
||||
}
|
||||
|
||||
return xPositions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo: 1-N 情况下的布局优化
|
||||
* Todo: 如果计算完了之后,所有节点的位置都没变,则不更新节点,避免不必要的重渲染
|
||||
* node 中有 measured 属性,可以获得其尺寸,如果没有,则使用一个默认尺寸 120*50
|
||||
* edge 的 source 和 target 分别对应两端的 node 的 id
|
||||
*
|
||||
* 算法步骤:
|
||||
* 1. 使用拓扑排序将节点分层(从左到右)
|
||||
* 2. 计算每层的 X 位置
|
||||
* 3. 在每层内垂直居中排列节点
|
||||
*/
|
||||
export function LayoutLR<N extends Node>(nodes: N[], edges: Edge[]): N[] {
|
||||
if (nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1. 分配层级
|
||||
const layers = assignLayers(nodes, edges);
|
||||
|
||||
// 2. 按层级分组
|
||||
const layerGroups = groupByLayer(nodes, layers);
|
||||
|
||||
// 3. 计算每层宽度和 X 位置
|
||||
const maxLayer = Math.max(...layers.values());
|
||||
const layerWidths = calculateLayerWidths(layerGroups);
|
||||
const layerXPositions = calculateLayerXPositions(layerWidths, maxLayer);
|
||||
|
||||
// 4. 计算每层的总高度,用于垂直居中
|
||||
const layerHeights = new Map<number, number>();
|
||||
for (const [layer, nodesInLayer] of layerGroups) {
|
||||
let totalHeight = 0;
|
||||
for (const node of nodesInLayer) {
|
||||
const { height } = getNodeSize(node);
|
||||
totalHeight += height;
|
||||
}
|
||||
totalHeight += (nodesInLayer.length - 1) * VERTICAL_GAP;
|
||||
layerHeights.set(layer, totalHeight);
|
||||
}
|
||||
|
||||
// 找到最大高度,用于垂直居中对齐
|
||||
const maxHeight = Math.max(...layerHeights.values());
|
||||
|
||||
// 5. 为每个节点分配位置,并检查是否有变化
|
||||
const layoutedNodes: N[] = [];
|
||||
let hasChanged = false;
|
||||
|
||||
for (const [layer, nodesInLayer] of layerGroups) {
|
||||
const layerHeight = layerHeights.get(layer) ?? 0;
|
||||
const startY = (maxHeight - layerHeight) / 2; // 垂直居中
|
||||
const x = layerXPositions.get(layer) ?? 0;
|
||||
|
||||
let currentY = startY;
|
||||
|
||||
for (const node of nodesInLayer) {
|
||||
const { height } = getNodeSize(node);
|
||||
const newPosition = { x, y: currentY };
|
||||
if (node.position.x !== newPosition.x || node.position.y !== newPosition.y) {
|
||||
hasChanged = true;
|
||||
layoutedNodes.push({
|
||||
...node,
|
||||
position: newPosition,
|
||||
});
|
||||
} else {
|
||||
layoutedNodes.push(node);
|
||||
}
|
||||
currentY += height + VERTICAL_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanged ? layoutedNodes : nodes;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Edge } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import { edgesModel } from './edges';
|
||||
import type { RoleNodeData, AnyWorkNode } from '../type';
|
||||
|
||||
type ConnectHandle = {
|
||||
id?: string | null;
|
||||
nodeId: string;
|
||||
type: 'source' | 'target';
|
||||
};
|
||||
|
||||
export type AddNodeState = {
|
||||
fromNode: AnyWorkNode;
|
||||
fromHandle: ConnectHandle;
|
||||
position: { x: number; y: number };
|
||||
};
|
||||
|
||||
type CommitParams = {
|
||||
data: RoleNodeData;
|
||||
};
|
||||
|
||||
function addNodeView() {
|
||||
return null as (AddNodeState | null);
|
||||
}
|
||||
|
||||
export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, get, model) => {
|
||||
function start(state: AddNodeState) {
|
||||
set(state);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
set(null);
|
||||
}
|
||||
|
||||
function commit(params: CommitParams) {
|
||||
const state = get();
|
||||
if (!state) return;
|
||||
set(null);
|
||||
|
||||
const { fromNode, fromHandle, position } = state;
|
||||
const { data } = params;
|
||||
|
||||
const id = `n${Date.now()}`;
|
||||
const node = { id, data, position, type: 'role' as const, origin: [0.0, 0.5] as [number, number] };
|
||||
|
||||
const [fnid, fhid] = [fromNode.id, fromHandle.id];
|
||||
const newEdge: Edge = fromHandle.type === 'source'
|
||||
? { id: `e${fnid}-${id}`, source: fnid, target: id, sourceHandle: fhid, animated: true }
|
||||
: { id: `e${id}-${fnid}`, source: id, target: fnid, targetHandle: fhid, animated: true };
|
||||
|
||||
model.startTransaction();
|
||||
model.use(nodesModel)[1].set((nds) => nds.concat(node));
|
||||
model.use(edgesModel)[1].set((eds) => eds.concat(newEdge));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return { start, commit, cancel };
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
applyEdgeChanges,
|
||||
type Edge,
|
||||
type EdgeChange,
|
||||
type Connection,
|
||||
} from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
|
||||
function makeEdges(): Edge[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function isInputHandle(handle: string | null | undefined): boolean {
|
||||
return handle === 'input' || handle === 'input-top' || handle === 'input-bottom';
|
||||
}
|
||||
|
||||
function isOutputHandle(handle: string | null | undefined): boolean {
|
||||
return handle === 'output' || handle === 'output-top' || handle === 'output-bottom';
|
||||
}
|
||||
|
||||
function normalizeConnection(params: Edge | Connection): Edge | Connection {
|
||||
if (isInputHandle(params.sourceHandle) && isOutputHandle(params.targetHandle)) {
|
||||
return {
|
||||
...params,
|
||||
source: params.target,
|
||||
sourceHandle: params.targetHandle ?? null,
|
||||
target: params.source,
|
||||
targetHandle: params.sourceHandle ?? null,
|
||||
} as Edge | Connection;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
let edgeCounter = 0;
|
||||
|
||||
export const edgesModel = define.model('edges', makeEdges, (set, get, model) => {
|
||||
function onEdgesChange(changes: EdgeChange[]) {
|
||||
const whites = new Set(['add', 'replace']);
|
||||
if (changes.some(c => whites.has(c.type))) {
|
||||
model.startTransaction();
|
||||
set((eds) => applyEdgeChanges(changes, eds));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
return;
|
||||
}
|
||||
set((eds) => applyEdgeChanges(changes, eds));
|
||||
}
|
||||
|
||||
function onConnect(params: Edge | Connection) {
|
||||
const normalized = normalizeConnection(params);
|
||||
|
||||
if (normalized.source === normalized.target) return;
|
||||
|
||||
if (!isOutputHandle(normalized.sourceHandle) || !isInputHandle(normalized.targetHandle)) return;
|
||||
|
||||
const currentEdges = get();
|
||||
const duplicate = currentEdges.some(
|
||||
e => e.source === normalized.source && e.target === normalized.target,
|
||||
);
|
||||
if (duplicate) return;
|
||||
|
||||
model.startTransaction();
|
||||
|
||||
const id = `e-${normalized.source}-${normalized.target}-${++edgeCounter}`;
|
||||
const edge: Edge = {
|
||||
...normalized,
|
||||
id,
|
||||
animated: true,
|
||||
} as Edge;
|
||||
|
||||
const existingFromSource = currentEdges.filter(e => e.source === normalized.source);
|
||||
|
||||
if (existingFromSource.length > 0) {
|
||||
edge.type = 'conditional';
|
||||
edge.data = { condition: '' };
|
||||
|
||||
const promoted = currentEdges.map(e => {
|
||||
if (e.source === normalized.source && e.type !== 'conditional') {
|
||||
return { ...e, type: 'conditional' as const, data: { condition: '' } };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
set([...promoted, edge]);
|
||||
} else {
|
||||
set((eds) => [...eds, edge]);
|
||||
}
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return { onEdgesChange, onConnect, set };
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import type { RoleNodeData, WorkNode } from '../type';
|
||||
|
||||
export type EditNodeState = {
|
||||
node: WorkNode<'role'>;
|
||||
};
|
||||
|
||||
function editNodeView() {
|
||||
return null as (EditNodeState | null);
|
||||
}
|
||||
|
||||
export const editNodeViewModel = define.view('editNodeView', editNodeView, (set, get, model) => {
|
||||
function start(nodeId: string) {
|
||||
const [nodes] = model.use(nodesModel);
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (!node || node.type !== 'role') return;
|
||||
set({ node: node as WorkNode<'role'> });
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
set(null);
|
||||
}
|
||||
|
||||
function commit(data: RoleNodeData) {
|
||||
const state = get();
|
||||
if (!state) return;
|
||||
set(null);
|
||||
|
||||
const { editNode } = model.use(nodesModel)[1];
|
||||
|
||||
model.startTransaction();
|
||||
editNode(state.node.id, (node) => {
|
||||
node.data = data as any;
|
||||
});
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return { start, commit, cancel };
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { OnNodeDrag, OnConnectEnd, OnBeforeDelete, OnDelete } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import { addNodeViewModel } from './add-node-view';
|
||||
import type { AnyWorkNode } from '../type';
|
||||
import { LayoutLR } from '../layout';
|
||||
import { nodesModel } from './nodes';
|
||||
import { edgesModel } from './edges';
|
||||
import { injection } from './inject';
|
||||
import { transIn, transOut, validate } from '../trans';
|
||||
import type { WorkFlowSteps } from '../trans';
|
||||
import { editNodeViewModel } from './edit-node-view';
|
||||
|
||||
export const handlers = define.memoize((use, model) => {
|
||||
const onNodeDragStart: OnNodeDrag<AnyWorkNode> = () => {
|
||||
model.startTransaction();
|
||||
};
|
||||
const onNodeDragStop: OnNodeDrag<AnyWorkNode> = () => {
|
||||
model.endTransaction();
|
||||
};
|
||||
const onConnectEnd: OnConnectEnd = (event, state) => {
|
||||
const { isValid, to, fromHandle, fromNode } = state;
|
||||
if (isValid) return;
|
||||
if (!to || !fromHandle || !fromNode) return;
|
||||
const { clientX, clientY } = event as MouseEvent;
|
||||
use(addNodeViewModel)[1].start({
|
||||
fromNode: fromNode as any as AnyWorkNode,
|
||||
fromHandle: fromHandle,
|
||||
position: model.flow.screenToFlowPosition({ x: clientX, y: clientY }),
|
||||
});
|
||||
};
|
||||
|
||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'start' || node.type === 'end') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (edges.length > 0) {
|
||||
const allEdges = use(edgesModel)[0];
|
||||
for (const edge of edges) {
|
||||
if (edge.type !== 'conditional') continue;
|
||||
const siblings = allEdges.filter(e => e.source === edge.source && e.type === 'conditional');
|
||||
if (siblings.length >= 2 && siblings[0].id === edge.id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
model.startTransaction();
|
||||
return true;
|
||||
};
|
||||
const onDelete: OnDelete = ({ edges: deletedEdges }) => {
|
||||
if (deletedEdges.length > 0) {
|
||||
const currentEdges = use(edgesModel)[0];
|
||||
const sourcesToCheck = new Set(
|
||||
deletedEdges
|
||||
.filter(e => e.type === 'conditional')
|
||||
.map(e => e.source),
|
||||
);
|
||||
|
||||
if (sourcesToCheck.size > 0) {
|
||||
let needsDowngrade = false;
|
||||
const updatedEdges = currentEdges.map(e => {
|
||||
if (!sourcesToCheck.has(e.source) || e.type !== 'conditional') return e;
|
||||
const siblings = currentEdges.filter(s => s.source === e.source && s.type === 'conditional');
|
||||
if (siblings.length === 1) {
|
||||
needsDowngrade = true;
|
||||
const { data: _, ...rest } = e;
|
||||
return { ...rest, type: 'default' as const };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
if (needsDowngrade) {
|
||||
use(edgesModel)[1].set(updatedEdges);
|
||||
}
|
||||
}
|
||||
}
|
||||
model.endTransaction();
|
||||
};
|
||||
|
||||
function autoLayoutLR() {
|
||||
const [nodes, { set }] = use(nodesModel);
|
||||
const edges = use(edgesModel)[0];
|
||||
|
||||
const layoutedNodes = LayoutLR(nodes, edges);
|
||||
model.startTransaction();
|
||||
set(layoutedNodes);
|
||||
model.endTransaction();
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
use(addNodeViewModel)[1].cancel();
|
||||
use(editNodeViewModel)[1].cancel();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.code === 'Escape') {
|
||||
const [addView, addViewActions] = use(addNodeViewModel);
|
||||
const [editView, editViewActions] = use(editNodeViewModel);
|
||||
if (addView) addViewActions.cancel();
|
||||
if (editView) editViewActions.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === 'KeyZ') {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.shiftKey) model.redo();
|
||||
else model.undo();
|
||||
}
|
||||
} else if (event.code === 'KeyY') {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
model.redo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadSteps(steps: WorkFlowSteps) {
|
||||
resetView();
|
||||
const { nodes, edges } = transIn(steps);
|
||||
use(nodesModel)[1].set(nodes);
|
||||
use(edgesModel)[1].set(edges);
|
||||
autoLayoutLR();
|
||||
model.reset();
|
||||
}
|
||||
|
||||
function saveData() {
|
||||
const nodes = use(nodesModel)[0];
|
||||
const edges = use(edgesModel)[0];
|
||||
const result = validate(nodes, edges);
|
||||
if (result.valid) {
|
||||
const steps = transOut(nodes, edges);
|
||||
const instance = use(injection)[0];
|
||||
instance.emitPublic('save', steps);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
onNodeDragStart,
|
||||
onNodeDragStop,
|
||||
onConnectEnd,
|
||||
onBeforeDelete,
|
||||
onDelete,
|
||||
autoLayoutLR,
|
||||
handleKeyDown,
|
||||
loadSteps,
|
||||
saveData,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export { nodesModel } from './nodes';
|
||||
export { edgesModel } from './edges';
|
||||
export { addNodeViewModel, type AddNodeState } from './add-node-view';
|
||||
export { editNodeViewModel, type EditNodeState } from './edit-node-view';
|
||||
export { handlers } from './handlers';
|
||||
export { injection } from './inject';
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 外部注入的回调函数,存到这里以方便内部调用,避免透传
|
||||
*/
|
||||
|
||||
import { define } from "../context.tsx";
|
||||
import { Injection } from '../injection.ts';
|
||||
|
||||
|
||||
const NOOP = () => {};
|
||||
const placeholder = new Injection(NOOP);
|
||||
|
||||
function make(): Injection {
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
export const injection = define.view('injection', make, (set) => {
|
||||
function reset() {
|
||||
set(make());
|
||||
}
|
||||
|
||||
function inject(instance: Injection) {
|
||||
set(instance);
|
||||
return reset;
|
||||
}
|
||||
|
||||
return inject;
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { produce, type Draft } from 'immer';
|
||||
import { applyNodeChanges, NodeChange } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import type { AnyWorkNode } from '../type';
|
||||
|
||||
|
||||
function makeNodes(): AnyWorkNode[] {
|
||||
return [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
data: { label: 'Start' },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
data: { label: 'End' },
|
||||
position: { x: 1000, y: 0 },
|
||||
type: 'end',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const nodesModel = define.model('nodes', makeNodes, (set, _get, model) => {
|
||||
const whites = new Set<NodeChange['type']>(['add', 'replace']);
|
||||
function onNodesChange(changes: NodeChange<AnyWorkNode>[]) {
|
||||
if (changes.some(c => whites.has(c.type))) {
|
||||
model.startTransaction();
|
||||
set((nds) => applyNodeChanges(changes, nds));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
return;
|
||||
}
|
||||
set((nds) => applyNodeChanges(changes, nds));
|
||||
};
|
||||
|
||||
function editNode(id: string, updater: (node: Draft<AnyWorkNode>) => void) {
|
||||
set(produce((draft) => {
|
||||
const node = draft.find(n => n.id === id);
|
||||
if (node) updater(node);
|
||||
}));
|
||||
}
|
||||
|
||||
function deleteNode(id: string) {
|
||||
model.startTransaction();
|
||||
set((nds) => nds.filter(n => n.id !== id));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return { onNodesChange, set, editNode, deleteNode };
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Handle, Position, Node, NodeProps } from '@xyflow/react';
|
||||
import { EndNode } from './nodes.style';
|
||||
|
||||
interface NodeData {
|
||||
label: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type NodeType = Node<NodeData, 'end'>;
|
||||
type Props = NodeProps<NodeType>;
|
||||
|
||||
export function NodeEnd({ data }: Props) {
|
||||
return (
|
||||
<EndNode>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="input"
|
||||
/>
|
||||
{data?.label || 'End'}
|
||||
</EndNode>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NodeStart } from './start';
|
||||
import { NodeEnd } from './end';
|
||||
import { NodeRole } from './role';
|
||||
|
||||
export const nodeTypes = {
|
||||
start: NodeStart,
|
||||
end: NodeEnd,
|
||||
role: NodeRole,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
|
||||
type Props = {
|
||||
onEdit: (() => void) | undefined;
|
||||
onDelete: (() => void) | undefined;
|
||||
};
|
||||
|
||||
export function NodeToolbarActions({ onEdit, onDelete }: Props): ReactNode {
|
||||
return (
|
||||
<div className="flex gap-1 px-2 py-1 bg-white rounded-lg shadow-md border border-gray-200">
|
||||
<Button variant="ghost" size="icon-xs" onClick={onEdit} title="编辑">
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" className="hover:bg-destructive/10 hover:text-destructive" onClick={onDelete} title="删除">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
type Props = {
|
||||
className: string | null;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function BaseNode({ className, children }: Props): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-2 border-border bg-white px-4 py-3 text-center text-sm font-medium min-w-[120px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StartNode({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<BaseNode className="bg-gradient-to-br from-green-50 to-green-200 border-green-500 text-green-500">
|
||||
{children}
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
export function EndNode({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<BaseNode className="bg-gradient-to-br from-indigo-50 to-blue-100 border-blue-600 text-blue-600">
|
||||
{children}
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeContent({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 px-3.5 py-3 min-w-[160px] max-w-[240px]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeIcon({ className, children }: Props): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-8 h-8 rounded-lg shrink-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeBody({ children }: { children: ReactNode }): ReactNode {
|
||||
return <div className="flex-1 min-w-0">{children}</div>;
|
||||
}
|
||||
|
||||
export function NodeKindLabel({ className, children }: Props): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-[10px] font-semibold uppercase tracking-wide mb-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeHint({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div className="text-[13px] text-gray-800 leading-snug break-words">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeSubHint({ children }: { children: ReactNode }): ReactNode {
|
||||
return <div className="text-[11px] text-gray-400 mt-0.5">{children}</div>;
|
||||
}
|
||||
|
||||
export function RoleIcon({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<NodeIcon className="bg-gradient-to-br from-teal-50 to-teal-200 text-teal-700">
|
||||
{children}
|
||||
</NodeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoleKindLabel({
|
||||
children,
|
||||
}: { children: ReactNode }): ReactNode {
|
||||
return <NodeKindLabel className="text-teal-700">{children}</NodeKindLabel>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Handle, Position, NodeToolbar, useNodeConnections, type NodeProps } from '@xyflow/react';
|
||||
import { Users } from 'lucide-react';
|
||||
import {
|
||||
NodeContent,
|
||||
NodeBody,
|
||||
RoleIcon,
|
||||
RoleKindLabel,
|
||||
NodeHint,
|
||||
} from './nodes.style';
|
||||
import { NodeToolbarActions } from './node-toolbar';
|
||||
import { editNodeViewModel } from '../model/edit-node-view';
|
||||
import { nodesModel } from '../model';
|
||||
import type { WorkNode } from '../type';
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { useReadonly } from '../flow';
|
||||
|
||||
type Props = NodeProps<WorkNode<'role'>>;
|
||||
|
||||
const containerClass = "bg-white border border-gray-200 rounded-[10px] shadow-sm transition-all duration-200 hover:shadow-md hover:border-gray-400 [&_.react-flow\\_\\_handle]:w-3 [&_.react-flow\\_\\_handle]:h-3 [&_.react-flow\\_\\_handle]:border-2 [&_.react-flow\\_\\_handle]:transition-all [&_.react-flow\\_\\_handle]:duration-150";
|
||||
const targetClass = "!bg-blue-100 !border-blue-500 hover:!bg-blue-500 hover:!border-blue-600";
|
||||
const sourceClass = "!bg-emerald-100 !border-emerald-500 hover:!bg-emerald-500 hover:!border-emerald-600";
|
||||
|
||||
export function NodeRole({ data, id, selected }: Props) {
|
||||
const startEdit = editNodeViewModel.useCreation().start;
|
||||
const { deleteNode } = nodesModel.useCreation();
|
||||
const connections = useNodeConnections();
|
||||
const readonly = useReadonly();
|
||||
|
||||
const connectedHandles = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const c of connections) {
|
||||
if (c.target === id && c.targetHandle) set.add(c.targetHandle);
|
||||
if (c.source === id && c.sourceHandle) set.add(c.sourceHandle);
|
||||
}
|
||||
return set;
|
||||
}, [connections, id]);
|
||||
|
||||
const hasInputConnection = connectedHandles.has('input') || connectedHandles.has('input-top') || connectedHandles.has('input-bottom');
|
||||
const hasOutputConnection = connectedHandles.has('output') || connectedHandles.has('output-top') || connectedHandles.has('output-bottom');
|
||||
|
||||
const showHandle = (handleId: string, alwaysShow: boolean) => {
|
||||
if (readonly) return connectedHandles.has(handleId);
|
||||
return alwaysShow;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{showHandle('input', true) && <Handle type="target" position={Position.Left} id="input" className={targetClass} isConnectableStart />}
|
||||
{showHandle('input-top', hasInputConnection) && <Handle type="target" position={Position.Top} id="input-top" style={{ left: '30%' }} className={targetClass} isConnectableStart />}
|
||||
{showHandle('input-bottom', hasInputConnection) && <Handle type="target" position={Position.Bottom} id="input-bottom" style={{ left: '30%' }} className={targetClass} isConnectableStart />}
|
||||
<NodeContent>
|
||||
<RoleIcon>
|
||||
<Users size={16} />
|
||||
</RoleIcon>
|
||||
<NodeBody>
|
||||
<RoleKindLabel>Role</RoleKindLabel>
|
||||
<NodeHint>{data.name}</NodeHint>
|
||||
</NodeBody>
|
||||
</NodeContent>
|
||||
<NodeToolbar isVisible={selected && !readonly} position={Position.Bottom}>
|
||||
<NodeToolbarActions
|
||||
onEdit={() => startEdit(id)}
|
||||
onDelete={() => deleteNode(id)}
|
||||
/>
|
||||
</NodeToolbar>
|
||||
{showHandle('output', true) && <Handle type="source" position={Position.Right} id="output" className={sourceClass} isConnectableEnd />}
|
||||
{showHandle('output-top', hasOutputConnection) && <Handle type="source" position={Position.Top} id="output-top" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />}
|
||||
{showHandle('output-bottom', hasOutputConnection) && <Handle type="source" position={Position.Bottom} id="output-bottom" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Handle, Position, Node, NodeProps, useNodeConnections } from '@xyflow/react';
|
||||
import { StartNode } from './nodes.style';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface NodeData {
|
||||
label: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type NodeType = Node<NodeData, 'start'>;
|
||||
type Props = NodeProps<NodeType>;
|
||||
|
||||
export function NodeStart({ data, id }: Props) {
|
||||
const connections = useNodeConnections();
|
||||
|
||||
const outputConnected = useMemo(() => {
|
||||
return connections.some((conn) => conn.source === id);
|
||||
}, [connections, id]);
|
||||
|
||||
return (
|
||||
<StartNode>
|
||||
{data?.label || 'Start'}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="output"
|
||||
isConnectable={!outputConnected}
|
||||
/>
|
||||
</StartNode>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { addNodeViewModel, type AddNodeState } from "../model/index.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "../../components/ui/dialog.tsx";
|
||||
import { Input } from "../../components/ui/input.tsx";
|
||||
import { Textarea } from "../../components/ui/textarea.tsx";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import { Label } from "../../components/ui/label.tsx";
|
||||
import type { RoleNodeData } from "../type.ts";
|
||||
|
||||
type FormProps = {
|
||||
state: AddNodeState;
|
||||
onSubmit: (params: { data: RoleNodeData }) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
const [name, setName] = useState("新角色");
|
||||
const [description, setDescription] = useState("");
|
||||
const [identity, setIdentity] = useState("");
|
||||
const [prepare, setPrepare] = useState("");
|
||||
const [execute, setExecute] = useState("");
|
||||
const [report, setReport] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setName("新角色");
|
||||
setDescription("");
|
||||
setIdentity("");
|
||||
setPrepare("");
|
||||
setExecute("");
|
||||
setReport("");
|
||||
}, [state]);
|
||||
|
||||
function handleConfirm() {
|
||||
if (!name.trim()) return;
|
||||
onSubmit({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description,
|
||||
identity,
|
||||
prepare,
|
||||
execute,
|
||||
report,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加角色节点</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">名称 *</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="角色名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">描述</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="角色描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">身份 (Identity)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={identity}
|
||||
onChange={(e) => setIdentity(e.target.value)}
|
||||
placeholder="角色身份定义"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">准备 (Prepare)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={prepare}
|
||||
onChange={(e) => setPrepare(e.target.value)}
|
||||
placeholder="执行前准备指令"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">执行 (Execute)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={execute}
|
||||
onChange={(e) => setExecute(e.target.value)}
|
||||
placeholder="核心执行指令"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">报告 (Report)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={report}
|
||||
onChange={(e) => setReport(e.target.value)}
|
||||
placeholder="输出格式指令"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddNodeDialog(): ReactNode {
|
||||
const state = addNodeViewModel.useData();
|
||||
const { commit, cancel } = addNodeViewModel.useCreation();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={state !== null}
|
||||
onOpenChange={(open) => { if (!open) cancel(); }}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import {
|
||||
editNodeViewModel,
|
||||
type EditNodeState,
|
||||
} from "../model/edit-node-view.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "../../components/ui/dialog.tsx";
|
||||
import { Input } from "../../components/ui/input.tsx";
|
||||
import { Textarea } from "../../components/ui/textarea.tsx";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import { Label } from "../../components/ui/label.tsx";
|
||||
import type { RoleNodeData } from "../type.ts";
|
||||
|
||||
type FormProps = {
|
||||
state: EditNodeState;
|
||||
onSubmit: (data: RoleNodeData) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
const data = state.node.data;
|
||||
const [name, setName] = useState(data.name);
|
||||
const [description, setDescription] = useState(data.description);
|
||||
const [identity, setIdentity] = useState(data.identity);
|
||||
const [prepare, setPrepare] = useState(data.prepare);
|
||||
const [execute, setExecute] = useState(data.execute);
|
||||
const [report, setReport] = useState(data.report);
|
||||
|
||||
useEffect(() => {
|
||||
setName(data.name);
|
||||
setDescription(data.description);
|
||||
setIdentity(data.identity);
|
||||
setPrepare(data.prepare);
|
||||
setExecute(data.execute);
|
||||
setReport(data.report);
|
||||
}, [data]);
|
||||
|
||||
function handleConfirm() {
|
||||
if (!name.trim()) return;
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
description,
|
||||
identity,
|
||||
prepare,
|
||||
execute,
|
||||
report,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑角色节点</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">名称 *</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="角色名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">描述</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="角色描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">身份 (Identity)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={identity}
|
||||
onChange={(e) => setIdentity(e.target.value)}
|
||||
placeholder="角色身份定义"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">准备 (Prepare)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={prepare}
|
||||
onChange={(e) => setPrepare(e.target.value)}
|
||||
placeholder="执行前准备指令"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">执行 (Execute)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={execute}
|
||||
onChange={(e) => setExecute(e.target.value)}
|
||||
placeholder="核心执行指令"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">报告 (Report)</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
value={report}
|
||||
onChange={(e) => setReport(e.target.value)}
|
||||
placeholder="输出格式指令"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditNodeDialog(): ReactNode {
|
||||
const state = editNodeViewModel.useData();
|
||||
const { commit, cancel } = editNodeViewModel.useCreation();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={state !== null}
|
||||
onOpenChange={(open) => { if (!open) cancel(); }}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Panel } from '@xyflow/react';
|
||||
import { AddNodeDialog } from './add-node';
|
||||
import { EditNodeDialog } from './edit-node';
|
||||
import { Toolbar } from './toolbar';
|
||||
|
||||
|
||||
export function Dialogs() {
|
||||
return (
|
||||
<>
|
||||
<AddNodeDialog />
|
||||
<EditNodeDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function TopCenterPanel() {
|
||||
return (
|
||||
<Panel position="top-center">
|
||||
<Toolbar />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
Undo2,
|
||||
Redo2,
|
||||
Users,
|
||||
LayoutList,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { useReactFlow, useStoreApi } from "@xyflow/react";
|
||||
import { useModel } from "../context.tsx";
|
||||
import { handlers, nodesModel } from "../model/index.ts";
|
||||
import { Separator } from "../../components/ui/separator.tsx";
|
||||
import { Button } from "../../components/ui/button.tsx";
|
||||
import type { RoleNodeData, WorkNode } from "../type.ts";
|
||||
import { uuid } from "../utils/index.ts";
|
||||
import { useState } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const DEFAULT_ROLE_DATA: RoleNodeData = {
|
||||
name: '新角色',
|
||||
description: '',
|
||||
identity: '',
|
||||
prepare: '',
|
||||
execute: '',
|
||||
report: '',
|
||||
};
|
||||
|
||||
export function Toolbar(): ReactNode {
|
||||
const model = useModel();
|
||||
const flow = useReactFlow();
|
||||
const store = useStoreApi();
|
||||
const nodesActions = nodesModel.useCreation();
|
||||
const { autoLayoutLR } = handlers.use();
|
||||
const [canUndo, canRedo] = model.useStackState();
|
||||
|
||||
function handleUndo() {
|
||||
model.undo();
|
||||
}
|
||||
|
||||
function handleRedo() {
|
||||
model.redo();
|
||||
}
|
||||
|
||||
function handleAddNode() {
|
||||
const { x, y, zoom } = flow.getViewport();
|
||||
const { width, height } = store.getState();
|
||||
const centerX = (width / 2 - x) / zoom;
|
||||
const centerY = (height / 2 - y) / zoom;
|
||||
|
||||
const id = `n${uuid()}`;
|
||||
const node: WorkNode<'role'> = {
|
||||
id,
|
||||
type: 'role',
|
||||
position: { x: centerX - 80, y: centerY - 40 },
|
||||
data: { ...DEFAULT_ROLE_DATA },
|
||||
};
|
||||
|
||||
model.startTransaction();
|
||||
nodesActions.set((nds) => nds.concat(node));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-[10px] shadow-md">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon-sm" title="撤销 (Undo)" onClick={handleUndo} disabled={!canUndo}>
|
||||
<Undo2 />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" title="重做 (Redo)" onClick={handleRedo} disabled={!canRedo}>
|
||||
<Redo2 />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
<Button variant="ghost" size="icon-sm" title="添加角色" onClick={handleAddNode}>
|
||||
<Users />
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
<Button variant="ghost" size="icon-sm" title="自动布局" onClick={autoLayoutLR}>
|
||||
<LayoutList />
|
||||
</Button>
|
||||
|
||||
<SaveButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveButton(): ReactNode {
|
||||
const { saveData } = handlers.use();
|
||||
const [toast, setToast] = useState<{
|
||||
open: boolean;
|
||||
severity: "success" | "error";
|
||||
message: ReactNode;
|
||||
}>({ open: false, severity: "success", message: "" });
|
||||
|
||||
function handleSave() {
|
||||
const { valid, errors } = saveData();
|
||||
if (valid) {
|
||||
setToast({ open: true, severity: "success", message: "流程保存成功" });
|
||||
} else {
|
||||
const errorMessages = errors.map(
|
||||
({ message, nodeId }) => (
|
||||
<div key={nodeId ?? message}>
|
||||
{nodeId ? `节点 ${nodeId}:` : ""}
|
||||
{message}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
setToast({
|
||||
open: true,
|
||||
severity: "error",
|
||||
message: errorMessages || "流程校验失败",
|
||||
});
|
||||
}
|
||||
setTimeout(() => setToast((prev) => ({ ...prev, open: false })), 4000);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="icon-sm" title="保存流程" onClick={handleSave}>
|
||||
<Save />
|
||||
</Button>
|
||||
{toast.open && (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-lg text-sm text-white shadow-lg",
|
||||
toast.severity === "success" ? "bg-green-600" : "bg-red-600",
|
||||
)}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './type';
|
||||
export * from './trans-in';
|
||||
export * from './trans-out';
|
||||
export * from './validate';
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
|
||||
import type { WorkFlowStep } from './type';
|
||||
import { uuid } from '../utils';
|
||||
|
||||
type Result = {
|
||||
nodes: AnyWorkNode[];
|
||||
edges: AnyWorkEdge[];
|
||||
};
|
||||
|
||||
const OUT_HANDLES = ['output-top', 'output', 'output-bottom'] as const;
|
||||
const IN_HANDLES = ['input-top', 'input', 'input-bottom'] as const;
|
||||
|
||||
function assignHandles(
|
||||
indices: number[],
|
||||
edges: AnyWorkEdge[],
|
||||
handles: readonly string[],
|
||||
key: 'sourceHandle' | 'targetHandle',
|
||||
): void {
|
||||
if (indices.length === 1) {
|
||||
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
|
||||
} else if (indices.length === 2) {
|
||||
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
|
||||
edges[indices[1]] = { ...edges[indices[1]], [key]: handles[0] };
|
||||
} else {
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
edges[indices[i]] = { ...edges[indices[i]], [key]: handles[i % handles.length] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function transIn(steps: WorkFlowStep[]): Result {
|
||||
const startNode: AnyWorkNode = { id: 'start', type: 'start', data: { label: 'Start' }, position: { x: 0, y: 0 } };
|
||||
const endNode: AnyWorkNode = { id: 'end', type: 'end', data: { label: 'End' }, position: { x: 250, y: 0 } };
|
||||
|
||||
if (steps.length === 0) {
|
||||
return { nodes: [startNode, endNode], edges: [] };
|
||||
}
|
||||
|
||||
const nodes: AnyWorkNode[] = [startNode, endNode];
|
||||
const edges: AnyWorkEdge[] = [];
|
||||
const nameToId = new Map<string, string>();
|
||||
const idToOrder = new Map<string, number>();
|
||||
nameToId.set('END', 'end');
|
||||
idToOrder.set('start', -1);
|
||||
idToOrder.set('end', steps.length);
|
||||
|
||||
for (let si = 0; si < steps.length; si++) {
|
||||
const step = steps[si];
|
||||
const nodeId = `n${uuid()}`;
|
||||
nameToId.set(step.role.name, nodeId);
|
||||
idToOrder.set(nodeId, si);
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'role',
|
||||
data: { ...step.role },
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
const firstStepId = nameToId.get(steps[0].role.name)!;
|
||||
edges.push({
|
||||
id: `e-start-${firstStepId}`,
|
||||
source: 'start',
|
||||
sourceHandle: 'output',
|
||||
target: firstStepId,
|
||||
targetHandle: 'input',
|
||||
animated: true,
|
||||
});
|
||||
|
||||
for (const step of steps) {
|
||||
const sourceId = nameToId.get(step.role.name)!;
|
||||
const sourceOrder = idToOrder.get(sourceId)!;
|
||||
const hasMultipleTransitions = step.transitions.length > 1;
|
||||
|
||||
const sorted = hasMultipleTransitions
|
||||
? [...step.transitions].sort((a, b) => {
|
||||
if (a.condition === null && b.condition !== null) return -1;
|
||||
if (a.condition !== null && b.condition === null) return 1;
|
||||
return 0;
|
||||
})
|
||||
: step.transitions;
|
||||
|
||||
const elseEdges: AnyWorkEdge[] = [];
|
||||
const ifEdges: AnyWorkEdge[] = [];
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const t = sorted[i];
|
||||
const targetId = nameToId.get(t.target);
|
||||
if (!targetId) continue;
|
||||
|
||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||
|
||||
if (hasMultipleTransitions || t.condition !== null) {
|
||||
const edge: ConditionalEdge = {
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'input',
|
||||
type: 'conditional',
|
||||
data: { condition: t.condition ?? '' },
|
||||
animated: true,
|
||||
};
|
||||
if (hasMultipleTransitions && i === 0) {
|
||||
elseEdges.push(edge);
|
||||
} else {
|
||||
ifEdges.push(edge);
|
||||
}
|
||||
} else {
|
||||
elseEdges.push({
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'input',
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// out: else → output (right); if → sort by target order desc (rightmost first), then top/bottom
|
||||
for (const e of elseEdges) {
|
||||
edges.push({ ...e, sourceHandle: 'output' });
|
||||
}
|
||||
if (ifEdges.length > 0) {
|
||||
const sortedIf = [...ifEdges].sort((a, b) => {
|
||||
const oa = idToOrder.get(a.target) ?? 0;
|
||||
const ob = idToOrder.get(b.target) ?? 0;
|
||||
return ob - oa;
|
||||
});
|
||||
const ifHandles = ['output-top', 'output-bottom'] as const;
|
||||
for (let i = 0; i < sortedIf.length; i++) {
|
||||
edges.push({ ...sortedIf[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// in: group by target, sort by source order asc (leftmost first), assign input > input-top > input-bottom
|
||||
const incomingByTarget = new Map<string, number[]>();
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const target = edges[i].target;
|
||||
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
|
||||
incomingByTarget.get(target)!.push(i);
|
||||
}
|
||||
|
||||
for (const indices of incomingByTarget.values()) {
|
||||
indices.sort((a, b) => {
|
||||
const oa = idToOrder.get(edges[a].source) ?? 0;
|
||||
const ob = idToOrder.get(edges[b].source) ?? 0;
|
||||
return oa - ob;
|
||||
});
|
||||
assignHandles(indices, edges, IN_HANDLES, 'targetHandle');
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, WorkNode, ConditionalEdge } from '../type';
|
||||
import type { WorkFlowStep, WorkFlowTransition } from './type';
|
||||
|
||||
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
||||
const nodeMap = new Map<string, AnyWorkNode>();
|
||||
for (const node of nodes) {
|
||||
nodeMap.set(node.id, node);
|
||||
}
|
||||
|
||||
const outgoingEdges = new Map<string, AnyWorkEdge[]>();
|
||||
for (const edge of edges) {
|
||||
if (!outgoingEdges.has(edge.source)) {
|
||||
outgoingEdges.set(edge.source, []);
|
||||
}
|
||||
outgoingEdges.get(edge.source)!.push(edge);
|
||||
}
|
||||
|
||||
const startOutEdges = outgoingEdges.get('start') ?? [];
|
||||
if (startOutEdges.length === 0) return [];
|
||||
|
||||
const firstNodeId = startOutEdges[0].target;
|
||||
const visited = new Set<string>();
|
||||
const steps: WorkFlowStep[] = [];
|
||||
|
||||
traverse(firstNodeId, nodeMap, outgoingEdges, visited, steps);
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function traverse(
|
||||
nodeId: string,
|
||||
nodeMap: Map<string, AnyWorkNode>,
|
||||
outgoingEdges: Map<string, AnyWorkEdge[]>,
|
||||
visited: Set<string>,
|
||||
steps: WorkFlowStep[],
|
||||
): void {
|
||||
if (visited.has(nodeId) || nodeId === 'start' || nodeId === 'end') return;
|
||||
visited.add(nodeId);
|
||||
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node || node.type !== 'role') return;
|
||||
|
||||
const roleNode = node as WorkNode<'role'>;
|
||||
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
||||
|
||||
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
|
||||
const targetNode = nodeMap.get(edge.target);
|
||||
const target = edge.target === 'end'
|
||||
? 'END'
|
||||
: (targetNode?.type === 'role' ? (targetNode as WorkNode<'role'>).data.name : edge.target);
|
||||
|
||||
let condition: string | null = null;
|
||||
if (edge.type === 'conditional') {
|
||||
const isElse = outEdges.length >= 2 && index === 0;
|
||||
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
|
||||
}
|
||||
|
||||
return { target, condition };
|
||||
});
|
||||
|
||||
const { name, description, identity, prepare, execute, report } = roleNode.data;
|
||||
steps.push({
|
||||
role: { name, description, identity, prepare, execute, report },
|
||||
transitions,
|
||||
});
|
||||
|
||||
for (const edge of outEdges) {
|
||||
traverse(edge.target, nodeMap, outgoingEdges, visited, steps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
WorkFlowRole,
|
||||
WorkFlowTransition,
|
||||
WorkFlowStep,
|
||||
WorkFlowSteps,
|
||||
} from "../../../shared/types.ts";
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
|
||||
|
||||
export type ValidationError = {
|
||||
nodeId: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ValidationResult = {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
};
|
||||
|
||||
export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
const outgoing = buildEdgeMap(edges, 'source');
|
||||
const incoming = buildEdgeMap(edges, 'target');
|
||||
|
||||
const startNodes = nodes.filter(n => n.type === 'start');
|
||||
const endNodes = nodes.filter(n => n.type === 'end');
|
||||
const roleNodes = nodes.filter(n => n.type === 'role');
|
||||
|
||||
validateStartNode(startNodes, outgoing, errors);
|
||||
validateEndNode(endNodes, incoming, outgoing, errors);
|
||||
validateRoleNodes(roleNodes, outgoing, incoming, errors);
|
||||
validateRoleCount(roleNodes, errors);
|
||||
validateReachability(nodes, edges, startNodes, endNodes, errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
function buildEdgeMap(
|
||||
edges: AnyWorkEdge[],
|
||||
key: 'source' | 'target',
|
||||
): Map<string, AnyWorkEdge[]> {
|
||||
const map = new Map<string, AnyWorkEdge[]>();
|
||||
for (const edge of edges) {
|
||||
const id = edge[key];
|
||||
if (!map.has(id)) {
|
||||
map.set(id, []);
|
||||
}
|
||||
map.get(id)!.push(edge);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function validateStartNode(
|
||||
startNodes: AnyWorkNode[],
|
||||
outgoing: Map<string, AnyWorkEdge[]>,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (startNodes.length === 0) {
|
||||
errors.push({ nodeId: null, message: '缺少 Start 节点' });
|
||||
return;
|
||||
}
|
||||
if (startNodes.length > 1) {
|
||||
errors.push({ nodeId: null, message: 'Start 节点只能有一个' });
|
||||
return;
|
||||
}
|
||||
|
||||
const startId = startNodes[0].id;
|
||||
const outEdges = outgoing.get(startId) ?? [];
|
||||
if (outEdges.length === 0) {
|
||||
errors.push({ nodeId: startId, message: 'Start 节点必须有一个输出连接' });
|
||||
} else if (outEdges.length > 1) {
|
||||
errors.push({ nodeId: startId, message: 'Start 节点只能有一个输出连接' });
|
||||
}
|
||||
}
|
||||
|
||||
function validateEndNode(
|
||||
endNodes: AnyWorkNode[],
|
||||
incoming: Map<string, AnyWorkEdge[]>,
|
||||
outgoing: Map<string, AnyWorkEdge[]>,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (endNodes.length === 0) {
|
||||
errors.push({ nodeId: null, message: '缺少 End 节点' });
|
||||
return;
|
||||
}
|
||||
if (endNodes.length > 1) {
|
||||
errors.push({ nodeId: null, message: 'End 节点只能有一个' });
|
||||
return;
|
||||
}
|
||||
|
||||
const endId = endNodes[0].id;
|
||||
const inEdges = incoming.get(endId) ?? [];
|
||||
if (inEdges.length === 0) {
|
||||
errors.push({ nodeId: endId, message: 'End 节点必须有至少一个输入连接' });
|
||||
}
|
||||
|
||||
const outEdges = outgoing.get(endId) ?? [];
|
||||
if (outEdges.length > 0) {
|
||||
errors.push({ nodeId: endId, message: 'End 节点不能有输出连接' });
|
||||
}
|
||||
}
|
||||
|
||||
function validateRoleNodes(
|
||||
roleNodes: AnyWorkNode[],
|
||||
outgoing: Map<string, AnyWorkEdge[]>,
|
||||
incoming: Map<string, AnyWorkEdge[]>,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
for (const node of roleNodes) {
|
||||
const inEdges = incoming.get(node.id) ?? [];
|
||||
const outEdges = outgoing.get(node.id) ?? [];
|
||||
|
||||
if (inEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: '角色节点缺少输入连接' });
|
||||
}
|
||||
if (outEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: '角色节点缺少输出连接' });
|
||||
}
|
||||
|
||||
if (outEdges.length > 1) {
|
||||
const conditionalEdges = outEdges.filter(e => e.type === 'conditional');
|
||||
if (conditionalEdges.length !== outEdges.length) {
|
||||
errors.push({ nodeId: node.id, message: '多输出节点的所有出边必须附带条件' });
|
||||
} else {
|
||||
const ifEdges = conditionalEdges.slice(1);
|
||||
for (const edge of ifEdges) {
|
||||
const condEdge = edge as ConditionalEdge;
|
||||
if (!condEdge.data?.condition?.trim()) {
|
||||
errors.push({ nodeId: node.id, message: '条件边的条件表达式不能为空' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateRoleCount(
|
||||
roleNodes: AnyWorkNode[],
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (roleNodes.length < 2) {
|
||||
errors.push({ nodeId: null, message: '工作流至少需要 2 个角色节点' });
|
||||
}
|
||||
}
|
||||
|
||||
function validateReachability(
|
||||
nodes: AnyWorkNode[],
|
||||
edges: AnyWorkEdge[],
|
||||
startNodes: AnyWorkNode[],
|
||||
endNodes: AnyWorkNode[],
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (startNodes.length !== 1 || endNodes.length !== 1) return;
|
||||
|
||||
const forwardAdj = new Map<string, string[]>();
|
||||
const backwardAdj = new Map<string, string[]>();
|
||||
for (const edge of edges) {
|
||||
if (!forwardAdj.has(edge.source)) forwardAdj.set(edge.source, []);
|
||||
forwardAdj.get(edge.source)!.push(edge.target);
|
||||
if (!backwardAdj.has(edge.target)) backwardAdj.set(edge.target, []);
|
||||
backwardAdj.get(edge.target)!.push(edge.source);
|
||||
}
|
||||
|
||||
const reachableFromStart = bfs(startNodes[0].id, forwardAdj);
|
||||
const reachableFromEnd = bfs(endNodes[0].id, backwardAdj);
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'start' || node.type === 'end') continue;
|
||||
if (!reachableFromStart.has(node.id)) {
|
||||
errors.push({ nodeId: node.id, message: '节点不可从 Start 到达(孤立节点)' });
|
||||
}
|
||||
if (!reachableFromEnd.has(node.id)) {
|
||||
errors.push({ nodeId: node.id, message: '节点无法到达 End(死端节点)' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bfs(startId: string, adj: Map<string, string[]>): Set<string> {
|
||||
const visited = new Set<string>();
|
||||
const queue = [startId];
|
||||
visited.add(startId);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
for (const next of adj.get(current) ?? []) {
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
return visited;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Node, Edge } from '@xyflow/react';
|
||||
|
||||
type AnyKeyBase = { [key: string]: unknown | undefined };
|
||||
|
||||
export type RoleNodeData = AnyKeyBase & {
|
||||
name: string;
|
||||
description: string;
|
||||
identity: string;
|
||||
prepare: string;
|
||||
execute: string;
|
||||
report: string;
|
||||
};
|
||||
|
||||
export type NodeMap = {
|
||||
start: { label: string };
|
||||
end: { label: string };
|
||||
role: RoleNodeData;
|
||||
};
|
||||
|
||||
export type WorkNodeType = keyof NodeMap;
|
||||
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
|
||||
export type AnyWorkNode = WorkNode<'start'> | WorkNode<'end'> | WorkNode<'role'>;
|
||||
|
||||
export type ConditionalEdgeData = AnyKeyBase & {
|
||||
condition: string;
|
||||
};
|
||||
|
||||
export type ConditionalEdge = Edge<ConditionalEdgeData, 'conditional'>;
|
||||
export type AnyWorkEdge = ConditionalEdge | Edge;
|
||||
@@ -0,0 +1,31 @@
|
||||
interface Maper<T> { [key: string]: T }
|
||||
type Listen<T> = (data: T) => void;
|
||||
|
||||
export class Eventer<M extends Maper<any>> {
|
||||
private lisenters = {} as { [K in keyof M]: Set<Function> };
|
||||
|
||||
public on<K extends keyof M>(key: K, lisenter: Listen<M[K]>) {
|
||||
let set = this.lisenters[key];
|
||||
if (set == undefined) {
|
||||
set = new Set();
|
||||
this.lisenters[key] = set;
|
||||
}
|
||||
|
||||
set.add(lisenter);
|
||||
return () => this.off(key, lisenter);
|
||||
}
|
||||
|
||||
public off<K extends keyof M>(key: K, lisenter?: Listen<M[K]>) {
|
||||
const set = this.lisenters[key];
|
||||
if (set === undefined) return;
|
||||
if (lisenter === undefined) set.clear();
|
||||
else set.delete(lisenter);
|
||||
}
|
||||
|
||||
public emit<K extends keyof M>(key: K, data: M[K]) {
|
||||
const set = this.lisenters[key];
|
||||
if (set === undefined) return;
|
||||
// Todo: maybe implement stoping bubble
|
||||
set.forEach(call => call(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
|
||||
export function uuid() {
|
||||
const now = Date.now();
|
||||
const randon = 1 + Math.random();
|
||||
return Math.round(now * randon).toString(36);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
|
||||
function judge(container: HTMLElement, target: HTMLElement): boolean {
|
||||
if (container === target) {
|
||||
return true;
|
||||
}
|
||||
if (target === document.body) {
|
||||
return false;
|
||||
}
|
||||
let parent = target.parentElement;
|
||||
return parent ? judge(container, parent) : false;
|
||||
}
|
||||
|
||||
export function useClickOutRef<T extends HTMLElement>(
|
||||
callback: () => void,
|
||||
delay = 0,
|
||||
) {
|
||||
const ref = useRef<T>(null);
|
||||
const flag = useRef<boolean>(delay === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!delay) return;
|
||||
const timer = setTimeout(() => {
|
||||
flag.current = true;
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
useEffect(() => {
|
||||
function handle(ev: MouseEvent) {
|
||||
if (!flag.current) return;
|
||||
const container = ref.current;
|
||||
const target = ev.target as HTMLElement;
|
||||
if (container && target) {
|
||||
if (judge(container, target)) return;
|
||||
callback();
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handle);
|
||||
return () => document.removeEventListener('click', handle);
|
||||
}, [callback]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius: 0.625rem;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--success: oklch(0.55 0.15 160);
|
||||
--success-foreground: oklch(0.985 0 0);
|
||||
--warning: oklch(0.75 0.18 75);
|
||||
--warning-foreground: oklch(0.145 0 0);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--success: oklch(0.6 0.15 160);
|
||||
--success-foreground: oklch(0.985 0 0);
|
||||
--warning: oklch(0.75 0.18 75);
|
||||
--warning-foreground: oklch(0.145 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "react-router";
|
||||
import { router } from "./router.tsx";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(<RouterProvider router={router} />);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect, useRef, type ReactNode } from "react";
|
||||
import { useParams, useNavigate, useLocation } from "react-router";
|
||||
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Pencil, Eye } from "lucide-react";
|
||||
|
||||
export function DetailPage(): ReactNode {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const editing = location.pathname.endsWith("/edit");
|
||||
const [model, setModel] = useState<FlowModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const nameRef = useRef(name);
|
||||
nameRef.current = name;
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
let cancelled = false;
|
||||
|
||||
fetch(`/api/workflows/${encodeURIComponent(name)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("not found");
|
||||
return res.json() as Promise<WorkFlowSteps>;
|
||||
})
|
||||
.then((steps) => {
|
||||
if (cancelled) return;
|
||||
const m = new FlowModel(steps.length > 0 ? steps : undefined);
|
||||
m.on("save", (savedSteps) => {
|
||||
const n = nameRef.current;
|
||||
if (!n) return;
|
||||
setSaving(true);
|
||||
fetch(`/api/workflows/${encodeURIComponent(n)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(savedSteps),
|
||||
}).then(() => setSaving(false));
|
||||
});
|
||||
setModel(m);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) navigate("/");
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [name, navigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const basePath = `/workflow/${encodeURIComponent(name!)}`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-3 border-b px-4 py-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => navigate("/")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h1 className="text-base font-medium">{name}</h1>
|
||||
<div className="flex-1" />
|
||||
{editing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(basePath)}
|
||||
>
|
||||
<Eye className="size-3.5" data-icon="inline-start" />
|
||||
预览
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`${basePath}/edit`)}
|
||||
>
|
||||
<Pencil className="size-3.5" data-icon="inline-start" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{saving && <span className="text-xs text-muted-foreground">保存中...</span>}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{model && <FlowEditor model={model} readonly={!editing} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
|
||||
|
||||
const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
{
|
||||
role: {
|
||||
name: "planner",
|
||||
description: "分析需求并制定实施计划",
|
||||
identity: "你是一位资深的技术架构师",
|
||||
prepare: "阅读用户需求,理解项目背景",
|
||||
execute: "制定详细的实施计划和步骤分解",
|
||||
report: "输出结构化的计划文档,包含步骤列表和预期产出",
|
||||
},
|
||||
transitions: [{ target: "developer", condition: null }],
|
||||
},
|
||||
{
|
||||
role: {
|
||||
name: "developer",
|
||||
description: "根据计划编写代码实现",
|
||||
identity: "你是一位经验丰富的全栈开发者",
|
||||
prepare: "阅读计划文档,理解技术要求",
|
||||
execute: "编写高质量的代码实现",
|
||||
report: "输出变更文件列表和实现摘要",
|
||||
},
|
||||
transitions: [{ target: "reviewer", condition: null }],
|
||||
},
|
||||
{
|
||||
role: {
|
||||
name: "reviewer",
|
||||
description: "审查代码质量并决定是否通过",
|
||||
identity: "你是一位严谨的代码审查员",
|
||||
prepare: "阅读代码变更和实现摘要",
|
||||
execute: "检查代码质量、安全性和最佳实践",
|
||||
report: "输出审查结果,包含 approved 状态和评审意见",
|
||||
},
|
||||
transitions: [
|
||||
{ target: "END", condition: null },
|
||||
{ target: "developer", condition: "steps[-1].output.approved = false" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function EditorPage(): ReactNode {
|
||||
const [model] = useState(() => new FlowModel(DEFAULT_STEPS));
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<FlowEditor model={model} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardAction } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Plus, Trash2, Workflow } from "lucide-react";
|
||||
import type { WorkflowSummary } from "../../shared/types.ts";
|
||||
|
||||
export function HomePage(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newDesc, setNewDesc] = useState("");
|
||||
|
||||
const fetchWorkflows = useCallback(async () => {
|
||||
const res = await fetch("/api/workflows");
|
||||
const data = await res.json();
|
||||
setWorkflows(data);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkflows();
|
||||
}, [fetchWorkflows]);
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
await fetch("/api/workflows", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
|
||||
});
|
||||
setNewName("");
|
||||
setNewDesc("");
|
||||
setCreateOpen(false);
|
||||
fetchWorkflows();
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
await fetch(`/api/workflows/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
fetchWorkflows();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl p-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Workflows</h1>
|
||||
<p className="text-muted-foreground mt-1">管理你的工作流定义</p>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger render={<Button />}>
|
||||
<Plus className="size-4" data-icon="inline-start" />
|
||||
新建 Workflow
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleCreate}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建 Workflow</DialogTitle>
|
||||
<DialogDescription>输入工作流的名称和描述</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<Input
|
||||
placeholder="名称 (kebab-case,如 solve-issue)"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="描述"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" disabled={!newName.trim()}>
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground py-12 text-center">加载中...</div>
|
||||
) : workflows.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<Workflow className="mx-auto size-12 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground mt-4">还没有任何 Workflow</p>
|
||||
<p className="text-muted-foreground/70 text-sm mt-1">点击上方按钮创建第一个工作流</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{workflows.map((wf) => (
|
||||
<Card
|
||||
key={wf.name}
|
||||
className="cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={() => navigate(`/workflow/${encodeURIComponent(wf.name)}`)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{wf.name}</CardTitle>
|
||||
<CardDescription>{wf.description || "无描述"}</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(wf.name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createBrowserRouter } from "react-router";
|
||||
import { Layout } from "./app.tsx";
|
||||
import { HomePage } from "./pages/home.tsx";
|
||||
import { DetailPage } from "./pages/detail.tsx";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
Component: Layout,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
Component: HomePage,
|
||||
},
|
||||
{
|
||||
path: "workflow/:name",
|
||||
Component: DetailPage,
|
||||
},
|
||||
{
|
||||
path: "workflow/:name/edit",
|
||||
Component: DetailPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
查看 [context.md](./context.md) 获取上下文
|
||||
|
||||
<!-- 长任务写在这里 -->
|
||||
|
||||
任务结束后要按需更新 context 文档
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun-types"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "server", "shared", "vite-dev.ts", "server.ts"]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Plugin } from "vite";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { createApi } from "./server/api.ts";
|
||||
|
||||
function buildRequest(req: IncomingMessage, body: string | null): Request {
|
||||
const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (typeof value === "string") headers.set(key, value);
|
||||
else if (Array.isArray(value)) for (const v of value) headers.append(key, v);
|
||||
}
|
||||
return new Request(url, { method: req.method ?? "GET", headers, body });
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string | null> {
|
||||
if (req.method === "GET" || req.method === "HEAD") return null;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString();
|
||||
}
|
||||
|
||||
export function elysiaPlugin(): Plugin {
|
||||
const api = createApi();
|
||||
|
||||
return {
|
||||
name: "elysia-api",
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
if (!req.url?.startsWith("/api")) return next();
|
||||
|
||||
const body = await readBody(req);
|
||||
const request = buildRequest(req, body);
|
||||
const response = await api.handle(request);
|
||||
|
||||
res.statusCode = response.status;
|
||||
response.headers.forEach((value, key) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
res.end(await response.arrayBuffer());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import path from "node:path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { elysiaPlugin } from "./vite-dev.ts";
|
||||
|
||||
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), elysiaPlugin()],
|
||||
root: ".",
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(import.meta.dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user