From 9316b843f6a85e3a7232a3b0c50c9d3db0fd5992 Mon Sep 17 00:00:00 2001 From: flowingfate Date: Thu, 21 May 2026 13:54:59 +0800 Subject: [PATCH] init workflow service --- .gitignore | 2 + packages/workflow-dashboard/components.json | 25 ++ packages/workflow-dashboard/context.md | 400 ++++++++++++++++++ packages/workflow-dashboard/index.html | 21 + packages/workflow-dashboard/package.json | 38 ++ packages/workflow-dashboard/server.ts | 12 + packages/workflow-dashboard/server/api.ts | 78 ++++ .../workflow-dashboard/server/workflow.ts | 145 +++++++ packages/workflow-dashboard/shared/types.ts | 25 ++ packages/workflow-dashboard/src/app.tsx | 10 + .../src/components/ui/button.tsx | 58 +++ .../src/components/ui/card.tsx | 103 +++++ .../src/components/ui/dialog.tsx | 158 +++++++ .../src/components/ui/input.tsx | 20 + .../src/components/ui/label.tsx | 18 + .../src/components/ui/separator.tsx | 25 ++ .../src/components/ui/textarea.tsx | 18 + .../workflow-dashboard/src/editor/context.tsx | 283 +++++++++++++ .../src/editor/edges/conditional.tsx | 266 ++++++++++++ .../src/editor/edges/index.tsx | 6 + .../workflow-dashboard/src/editor/flow.tsx | 90 ++++ .../src/editor/injection.ts | 49 +++ .../src/editor/layout/index.ts | 239 +++++++++++ .../src/editor/model/add-node-view.ts | 59 +++ .../src/editor/model/edges.ts | 90 ++++ .../src/editor/model/edit-node-view.ts | 40 ++ .../src/editor/model/handlers.ts | 149 +++++++ .../src/editor/model/index.ts | 6 + .../src/editor/model/inject.ts | 27 ++ .../src/editor/model/nodes.ts | 50 +++ .../src/editor/nodes/end.tsx | 23 + .../src/editor/nodes/index.tsx | 9 + .../src/editor/nodes/node-toolbar.tsx | 21 + .../src/editor/nodes/nodes.style.tsx | 100 +++++ .../src/editor/nodes/role.tsx | 71 ++++ .../src/editor/nodes/start.tsx | 31 ++ .../src/editor/panel/add-node.tsx | 146 +++++++ .../src/editor/panel/edit-node.tsx | 148 +++++++ .../src/editor/panel/index.tsx | 23 + .../src/editor/panel/toolbar.tsx | 138 ++++++ .../src/editor/trans/index.ts | 4 + .../src/editor/trans/trans-in.ts | 156 +++++++ .../src/editor/trans/trans-out.ts | 70 +++ .../src/editor/trans/type.ts | 6 + .../src/editor/trans/validate.ts | 187 ++++++++ .../workflow-dashboard/src/editor/type.ts | 29 ++ .../src/editor/utils/eventer.ts | 31 ++ .../src/editor/utils/index.ts | 7 + .../src/editor/utils/use-click-out.tsx | 45 ++ packages/workflow-dashboard/src/index.css | 150 +++++++ packages/workflow-dashboard/src/lib/utils.ts | 6 + packages/workflow-dashboard/src/main.tsx | 8 + .../workflow-dashboard/src/pages/detail.tsx | 94 ++++ .../workflow-dashboard/src/pages/editor.tsx | 51 +++ .../workflow-dashboard/src/pages/home.tsx | 137 ++++++ packages/workflow-dashboard/src/router.tsx | 25 ++ packages/workflow-dashboard/task.md | 6 + packages/workflow-dashboard/tsconfig.json | 22 + packages/workflow-dashboard/vite-dev.ts | 43 ++ packages/workflow-dashboard/vite.config.ts | 19 + 60 files changed, 4316 insertions(+) create mode 100644 packages/workflow-dashboard/components.json create mode 100644 packages/workflow-dashboard/context.md create mode 100644 packages/workflow-dashboard/index.html create mode 100644 packages/workflow-dashboard/package.json create mode 100644 packages/workflow-dashboard/server.ts create mode 100644 packages/workflow-dashboard/server/api.ts create mode 100644 packages/workflow-dashboard/server/workflow.ts create mode 100644 packages/workflow-dashboard/shared/types.ts create mode 100644 packages/workflow-dashboard/src/app.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/button.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/card.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/dialog.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/input.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/label.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/separator.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/textarea.tsx create mode 100644 packages/workflow-dashboard/src/editor/context.tsx create mode 100644 packages/workflow-dashboard/src/editor/edges/conditional.tsx create mode 100644 packages/workflow-dashboard/src/editor/edges/index.tsx create mode 100644 packages/workflow-dashboard/src/editor/flow.tsx create mode 100644 packages/workflow-dashboard/src/editor/injection.ts create mode 100644 packages/workflow-dashboard/src/editor/layout/index.ts create mode 100644 packages/workflow-dashboard/src/editor/model/add-node-view.ts create mode 100644 packages/workflow-dashboard/src/editor/model/edges.ts create mode 100644 packages/workflow-dashboard/src/editor/model/edit-node-view.ts create mode 100644 packages/workflow-dashboard/src/editor/model/handlers.ts create mode 100644 packages/workflow-dashboard/src/editor/model/index.ts create mode 100644 packages/workflow-dashboard/src/editor/model/inject.ts create mode 100644 packages/workflow-dashboard/src/editor/model/nodes.ts create mode 100644 packages/workflow-dashboard/src/editor/nodes/end.tsx create mode 100644 packages/workflow-dashboard/src/editor/nodes/index.tsx create mode 100644 packages/workflow-dashboard/src/editor/nodes/node-toolbar.tsx create mode 100644 packages/workflow-dashboard/src/editor/nodes/nodes.style.tsx create mode 100644 packages/workflow-dashboard/src/editor/nodes/role.tsx create mode 100644 packages/workflow-dashboard/src/editor/nodes/start.tsx create mode 100644 packages/workflow-dashboard/src/editor/panel/add-node.tsx create mode 100644 packages/workflow-dashboard/src/editor/panel/edit-node.tsx create mode 100644 packages/workflow-dashboard/src/editor/panel/index.tsx create mode 100644 packages/workflow-dashboard/src/editor/panel/toolbar.tsx create mode 100644 packages/workflow-dashboard/src/editor/trans/index.ts create mode 100644 packages/workflow-dashboard/src/editor/trans/trans-in.ts create mode 100644 packages/workflow-dashboard/src/editor/trans/trans-out.ts create mode 100644 packages/workflow-dashboard/src/editor/trans/type.ts create mode 100644 packages/workflow-dashboard/src/editor/trans/validate.ts create mode 100644 packages/workflow-dashboard/src/editor/type.ts create mode 100644 packages/workflow-dashboard/src/editor/utils/eventer.ts create mode 100644 packages/workflow-dashboard/src/editor/utils/index.ts create mode 100644 packages/workflow-dashboard/src/editor/utils/use-click-out.tsx create mode 100644 packages/workflow-dashboard/src/index.css create mode 100644 packages/workflow-dashboard/src/lib/utils.ts create mode 100644 packages/workflow-dashboard/src/main.tsx create mode 100644 packages/workflow-dashboard/src/pages/detail.tsx create mode 100644 packages/workflow-dashboard/src/pages/editor.tsx create mode 100644 packages/workflow-dashboard/src/pages/home.tsx create mode 100644 packages/workflow-dashboard/src/router.tsx create mode 100644 packages/workflow-dashboard/task.md create mode 100644 packages/workflow-dashboard/tsconfig.json create mode 100644 packages/workflow-dashboard/vite-dev.ts create mode 100644 packages/workflow-dashboard/vite.config.ts diff --git a/.gitignore b/.gitignore index 4b95466..5d51e4b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ solve-issue-entry.ts packages/workflow-template-develop/develop.esm.js .DS_Store *.py +.claude +tmp \ No newline at end of file diff --git a/packages/workflow-dashboard/components.json b/packages/workflow-dashboard/components.json new file mode 100644 index 0000000..15addee --- /dev/null +++ b/packages/workflow-dashboard/components.json @@ -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": {} +} diff --git a/packages/workflow-dashboard/context.md b/packages/workflow-dashboard/context.md new file mode 100644 index 0000000..081f637 --- /dev/null +++ b/packages/workflow-dashboard/context.md @@ -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()` | 创建响应式 store(get/set/use/listen) | +| `SubModel` | 状态切片模板(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; // 角色定义(4 段式:identity/prepare/execute/report) + conditions: Record; // JSONata 条件表达式 + graph: Record; // 角色间的转移图 +}; +``` + +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 配置 +``` diff --git a/packages/workflow-dashboard/index.html b/packages/workflow-dashboard/index.html new file mode 100644 index 0000000..0948c36 --- /dev/null +++ b/packages/workflow-dashboard/index.html @@ -0,0 +1,21 @@ + + + + + + Workflow UI + + + + +
+ + + diff --git a/packages/workflow-dashboard/package.json b/packages/workflow-dashboard/package.json new file mode 100644 index 0000000..daae1d5 --- /dev/null +++ b/packages/workflow-dashboard/package.json @@ -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" + } +} diff --git a/packages/workflow-dashboard/server.ts b/packages/workflow-dashboard/server.ts new file mode 100644 index 0000000..74219fc --- /dev/null +++ b/packages/workflow-dashboard/server.ts @@ -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}`); diff --git a/packages/workflow-dashboard/server/api.ts b/packages/workflow-dashboard/server/api.ts new file mode 100644 index 0000000..b624eb2 --- /dev/null +++ b/packages/workflow-dashboard/server/api.ts @@ -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" }, + }); + } + }); +} diff --git a/packages/workflow-dashboard/server/workflow.ts b/packages/workflow-dashboard/server/workflow.ts new file mode 100644 index 0000000..97979af --- /dev/null +++ b/packages/workflow-dashboard/server/workflow.ts @@ -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(); + 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 = {}; + const conditions: WorkflowPayload["conditions"] = {}; + const graph: Record = {}; + + const expressionToName = new Map(); + 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 { + 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 { + 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 { + 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 { + 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 { + await unlink(join(WORKFLOW_DIR, `${name}.yaml`)); +} diff --git a/packages/workflow-dashboard/shared/types.ts b/packages/workflow-dashboard/shared/types.ts new file mode 100644 index 0000000..9e9aa5f --- /dev/null +++ b/packages/workflow-dashboard/shared/types.ts @@ -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; +}; diff --git a/packages/workflow-dashboard/src/app.tsx b/packages/workflow-dashboard/src/app.tsx new file mode 100644 index 0000000..80a0586 --- /dev/null +++ b/packages/workflow-dashboard/src/app.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import { Outlet } from "react-router"; + +export function Layout(): ReactNode { + return ( +
+ +
+ ); +} diff --git a/packages/workflow-dashboard/src/components/ui/button.tsx b/packages/workflow-dashboard/src/components/ui/button.tsx new file mode 100644 index 0000000..09df753 --- /dev/null +++ b/packages/workflow-dashboard/src/components/ui/button.tsx @@ -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) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/packages/workflow-dashboard/src/components/ui/card.tsx b/packages/workflow-dashboard/src/components/ui/card.tsx new file mode 100644 index 0000000..40cac5f --- /dev/null +++ b/packages/workflow-dashboard/src/components/ui/card.tsx @@ -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 ( +
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 ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/packages/workflow-dashboard/src/components/ui/dialog.tsx b/packages/workflow-dashboard/src/components/ui/dialog.tsx new file mode 100644 index 0000000..3fc1dda --- /dev/null +++ b/packages/workflow-dashboard/src/components/ui/dialog.tsx @@ -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 +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + }> + Close + + )} +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/packages/workflow-dashboard/src/components/ui/input.tsx b/packages/workflow-dashboard/src/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/packages/workflow-dashboard/src/components/ui/input.tsx @@ -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 ( + + ) +} + +export { Input } diff --git a/packages/workflow-dashboard/src/components/ui/label.tsx b/packages/workflow-dashboard/src/components/ui/label.tsx new file mode 100644 index 0000000..f162996 --- /dev/null +++ b/packages/workflow-dashboard/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +