init workflow service

This commit is contained in:
2026-05-21 13:54:59 +08:00
parent bdafaf3aa1
commit 9316b843f6
60 changed files with 4316 additions and 0 deletions
+2
View File
@@ -11,3 +11,5 @@ solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js packages/workflow-template-develop/develop.esm.js
.DS_Store .DS_Store
*.py *.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": {}
}
+400
View File
@@ -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 配置
```
+21
View File
@@ -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>
+38
View File
@@ -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"
}
}
+12
View File
@@ -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}`);
+78
View File
@@ -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;
};
+10
View File
@@ -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;
}
+150
View File
@@ -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))
}
+8
View File
@@ -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,
},
],
},
]);
+6
View File
@@ -0,0 +1,6 @@
查看 [context.md](./context.md) 获取上下文
<!-- 长任务写在这里 -->
任务结束后要按需更新 context 文档
+22
View File
@@ -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"]
}
+43
View File
@@ -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",
},
});