From c480ec173ad3545c4183860d559d37b6a48bf354 Mon Sep 17 00:00:00 2001
From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com>
Date: Fri, 31 Jul 2026 12:22:12 +0800
Subject: [PATCH 1/7] refactor(domain): rename generation to orchestrator, add
domain boundary docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Rename server/generation/ to server/orchestrator/ (任务调度)
- Update all import references (web/api/generation.py keeps its name)
- Update module-split.md with 4-domain structure (foundation/workflow/pipeline/result)
- Add domain boundary explanations and rationale
- Update product-domains.svg with color coding and domain annotations
- Remove unimplemented modules from documentation
---
backend/docs/diagrams/product-domains.json | 270 ++++++++++++++++++
backend/docs/diagrams/product-domains.svg | 171 +++++++++++
backend/docs/module-split.md | 205 +++++++++++++
.../{generation => orchestrator}/__init__.py | 2 +-
.../{generation => orchestrator}/interface.py | 2 +-
.../{generation => orchestrator}/model.py | 0
.../app/src/windup_app/web/api/generation.py | 2 +-
7 files changed, 649 insertions(+), 3 deletions(-)
create mode 100644 backend/docs/diagrams/product-domains.json
create mode 100644 backend/docs/diagrams/product-domains.svg
create mode 100644 backend/docs/module-split.md
rename backend/packages/app/src/windup_app/server/{generation => orchestrator}/__init__.py (89%)
rename backend/packages/app/src/windup_app/server/{generation => orchestrator}/interface.py (97%)
rename backend/packages/app/src/windup_app/server/{generation => orchestrator}/model.py (100%)
diff --git a/backend/docs/diagrams/product-domains.json b/backend/docs/diagrams/product-domains.json
new file mode 100644
index 0000000..4f246ef
--- /dev/null
+++ b/backend/docs/diagrams/product-domains.json
@@ -0,0 +1,270 @@
+{
+ "schema_version": 1,
+ "mode": "architecture",
+ "template_type": "architecture",
+ "style": 1,
+ "quality_profile": "standard",
+ "width": 1200,
+ "height": 760,
+ "title": "Windup Product Domains",
+ "subtitle": "最终产品业务能力与核心依赖",
+ "containers": [
+ {
+ "id": "foundation-domains",
+ "x": 40,
+ "y": 120,
+ "width": 1120,
+ "height": 120,
+ "label": "Foundation Domains",
+ "stroke": "#b9c6d8",
+ "fill": "#f4f8fc"
+ },
+ {
+ "id": "workflow-domains",
+ "x": 40,
+ "y": 290,
+ "width": 700,
+ "height": 150,
+ "label": "Workflow Domains",
+ "stroke": "#c9b8d9",
+ "fill": "#faf5fc"
+ },
+ {
+ "id": "ai-engine-domain",
+ "x": 780,
+ "y": 290,
+ "width": 380,
+ "height": 150,
+ "label": "AI Engine Domain",
+ "stroke": "#89c4a8",
+ "fill": "#f0f9f4"
+ },
+ {
+ "id": "result-domains",
+ "x": 40,
+ "y": 490,
+ "width": 1120,
+ "height": 120,
+ "label": "Result Domains",
+ "stroke": "#b8d0c2",
+ "fill": "#f5fbf7"
+ }
+ ],
+ "nodes": [
+ {
+ "id": "user",
+ "kind": "rect",
+ "x": 80,
+ "y": 155,
+ "width": 150,
+ "height": 55,
+ "label": "user",
+ "sublabel": "身份与会话",
+ "fill": "#e8f0fb",
+ "stroke": "#6685b2"
+ },
+ {
+ "id": "quota",
+ "kind": "rect",
+ "x": 270,
+ "y": 155,
+ "width": 150,
+ "height": 55,
+ "label": "quota",
+ "sublabel": "订阅与积分",
+ "fill": "#fff2d9",
+ "stroke": "#c28a36"
+ },
+ {
+ "id": "project",
+ "kind": "double_rect",
+ "x": 460,
+ "y": 155,
+ "width": 170,
+ "height": 55,
+ "label": "project",
+ "sublabel": "全局约束容器",
+ "fill": "#e8f0fb",
+ "stroke": "#6685b2"
+ },
+ {
+ "id": "media",
+ "kind": "rect",
+ "x": 670,
+ "y": 155,
+ "width": 150,
+ "height": 55,
+ "label": "media",
+ "sublabel": "输入参考素材",
+ "fill": "#e8f0fb",
+ "stroke": "#6685b2"
+ },
+ {
+ "id": "character",
+ "kind": "rect",
+ "x": 860,
+ "y": 155,
+ "width": 180,
+ "height": 55,
+ "label": "character / asset",
+ "sublabel": "角色与产物",
+ "fill": "#e8f0fb",
+ "stroke": "#6685b2"
+ },
+ {
+ "id": "workflow",
+ "kind": "double_rect",
+ "x": 80,
+ "y": 330,
+ "width": 180,
+ "height": 70,
+ "label": "workflow",
+ "sublabel": "定义 / 节点 / 模板",
+ "fill": "#f2e9f8",
+ "stroke": "#89659e"
+ },
+ {
+ "id": "execution",
+ "kind": "rect",
+ "x": 320,
+ "y": 330,
+ "width": 180,
+ "height": 70,
+ "label": "execution",
+ "sublabel": "调度 / 重试 / 缓存",
+ "fill": "#f2e9f8",
+ "stroke": "#89659e"
+ },
+ {
+ "id": "generation",
+ "kind": "rect",
+ "x": 560,
+ "y": 330,
+ "width": 180,
+ "height": 70,
+ "label": "generation",
+ "sublabel": "生成业务编排",
+ "fill": "#fff2d9",
+ "stroke": "#c28a36"
+ },
+ {
+ "id": "ai-engine",
+ "kind": "rect",
+ "x": 880,
+ "y": 330,
+ "width": 180,
+ "height": 70,
+ "label": "ai_engine",
+ "sublabel": "模型能力适配",
+ "fill": "#e4f2ea",
+ "stroke": "#5c9974"
+ },
+ {
+ "id": "review",
+ "kind": "rect",
+ "x": 230,
+ "y": 525,
+ "width": 180,
+ "height": 55,
+ "label": "review",
+ "sublabel": "质检 + 人工审核",
+ "fill": "#e4f2ea",
+ "stroke": "#5c9974"
+ },
+ {
+ "id": "playtest",
+ "kind": "rect",
+ "x": 510,
+ "y": 525,
+ "width": 180,
+ "height": 55,
+ "label": "playtest",
+ "sublabel": "预览与试玩",
+ "fill": "#e4f2ea",
+ "stroke": "#5c9974"
+ },
+ {
+ "id": "export",
+ "kind": "rect",
+ "x": 790,
+ "y": 525,
+ "width": 180,
+ "height": 55,
+ "label": "export",
+ "sublabel": "格式转换与下载",
+ "fill": "#e4f2ea",
+ "stroke": "#5c9974"
+ }
+ ],
+ "arrows": [
+ {
+ "id": "workflow-execution",
+ "source": "workflow",
+ "target": "execution",
+ "source_port": "right",
+ "target_port": "left",
+ "flow": "control",
+ "label": "定义",
+ "label_style": "offset"
+ },
+ {
+ "id": "execution-generation",
+ "source": "execution",
+ "target": "generation",
+ "source_port": "right",
+ "target_port": "left",
+ "flow": "control",
+ "label": "节点调用",
+ "label_style": "offset"
+ },
+ {
+ "id": "generation-ai",
+ "source": "generation",
+ "target": "ai-engine",
+ "source_port": "right",
+ "target_port": "left",
+ "flow": "control",
+ "label": "AI 能力",
+ "label_style": "offset"
+ },
+ {
+ "id": "review-playtest",
+ "source": "review",
+ "target": "playtest",
+ "source_port": "right",
+ "target_port": "left",
+ "flow": "read",
+ "label": "正式资产",
+ "label_style": "offset"
+ },
+ {
+ "id": "playtest-export",
+ "source": "playtest",
+ "target": "export",
+ "source_port": "right",
+ "target_port": "left",
+ "flow": "read",
+ "label": "导出数据",
+ "label_style": "offset"
+ }
+ ],
+ "legend_orientation": "horizontal",
+ "legend_x": 70,
+ "legend_y": 680,
+ "legend_locked": false,
+ "legend": [
+ {
+ "flow": "control",
+ "label": "业务编排"
+ },
+ {
+ "flow": "read",
+ "label": "读取 / 消费"
+ },
+ {
+ "flow": "write",
+ "label": "产物 / 输入流"
+ }
+ ],
+ "footer": "Windup · Final Product Domain Map"
+}
diff --git a/backend/docs/diagrams/product-domains.svg b/backend/docs/diagrams/product-domains.svg
new file mode 100644
index 0000000..b6872d8
--- /dev/null
+++ b/backend/docs/diagrams/product-domains.svg
@@ -0,0 +1,171 @@
+
diff --git a/backend/docs/module-split.md b/backend/docs/module-split.md
new file mode 100644
index 0000000..956c7e0
--- /dev/null
+++ b/backend/docs/module-split.md
@@ -0,0 +1,205 @@
+# 后端模块拆分
+
+> 当前阶段:MVP 已实现 project / media / character / orchestrator / ai_engine。
+> 按业务域拆分,每个域是一个完整的业务能力单元。
+
+---
+
+## 项目层级
+
+```
+backend/
+├── packages/
+│ ├── common/ # 共享:Response、BizException、BizCode 枚举
+│ ├── framework/ # 基础设施:KodoStorage、ChatProvider、DB 配置
+│ ├── ai_engine/ # 生成管线(独立包)
+│ └── app/ # 业务应用
+│ └── src/windup_app/
+│ ├── web/api/ # FastAPI 路由
+│ │ ├── generation.py # 生成 API(前端契约)
+│ │ ├── media.py # 媒体 API
+│ │ ├── project.py # 项目 API
+│ │ └── character.py # 角色 API
+│ ├── bootstrap/app.py # 组装入口(composition root)
+│ └── server/ # 业务域(按域分组)
+│ ├── media/ # [foundation] 用户素材上传
+│ ├── project/ # [foundation] 项目约束配置
+│ ├── character/ # [foundation] 角色资产数据
+│ └── orchestrator/ # [workflow] 生成任务调度
+```
+
+> 注:`[foundation]` / `[workflow]` 标注所属业务域。
+> `ai_engine` 是独立包,不属于 server/ 目录。
+
+---
+
+## 业务域全景
+
+
+
+---
+
+## 依赖方向
+
+```
+ common
+ ▲
+ framework
+ ▲
+ ┌──────┴──────┐
+ai_engine foundation
+ ▲ ▲ ▲
+ │ ┌────┘ │
+ │ workflow result
+ │ │ │
+ └─────┘─────────┘
+```
+
+**规则**:
+- foundation → framework, common
+- workflow → foundation, ai_engine, framework, common
+- result → foundation, framework, common
+- ai_engine → framework.providers(接口), common
+- **禁止**:foundation → workflow / result / ai_engine
+- **禁止**:ai_engine → foundation / workflow / result
+- **禁止**:result → workflow
+
+---
+
+## 域边界划分
+
+### 为什么按这四个域拆分?
+
+每个域回答一个业务问题:
+
+| 域 | 回答的问题 | 包含什么 |
+|---|---|---|
+| foundation | 数据从哪来、存到哪 | 项目配置、角色数据、用户素材 |
+| workflow | 何时生、为谁生、生完怎么办 | 任务调度、工作流编排 |
+| pipeline | 怎么生 | 提示词 → 出图 → 抠图 → 截帧 |
+| result | 生出来的东西怎么用 | 审核、预览、导出 |
+
+### 边界职责
+
+**foundation(基础业务域)**
+- 职责:管理基础数据,被其他域消费
+- 边界:各模块独立 CRUD,不包含生成、审核、导出逻辑
+- 禁止:依赖 workflow / result / ai_engine
+
+**workflow(工作流域)**
+- 职责:编排调度,调用 foundation + ai_engine
+- 边界:任务管理、约束加载、积分扣减、结果上传
+- 禁止:依赖 result 域;直接 import ai_engine 内部实现
+
+**pipeline(生成管线域)**
+- 职责:实际生成过程(提示词 → 出图 → 抠图 → 截帧)
+- 边界:AI 调用、帧处理、像素化
+- 禁止:知道 project 约束、character 数据结构、图片存储位置
+
+**result(结果域)**
+- 职责:处理生成产物,提供预览和导出
+- 边界:读取正式资产,独立演进
+- 禁止:依赖 workflow 域;调用 ai_engine
+
+---
+
+## 基础业务域(Foundation)
+
+### media — 用户素材上传
+
+**对应表:** `windup_media` **接口:** `MediaService`
+
+| 方法 | 说明 |
+|---|---|
+| `upload(data, metadata)` | 上传文件到对象存储,返回 URL |
+
+**文件分类 `MediaCategory`**:`reference-image` / `outfit-preview` / `action-frame` / `general`
+
+### project — 项目约束配置
+
+**对应表:** `windup_project` **接口:** `ProjectService`
+
+| 字段 | 用途 |
+|---|---|
+| `character_perspective` | 视角 → 生成朝向 |
+| `directional_movement` | 方向数 → 生成方向变体 |
+| `sprite_width` / `sprite_height` | 尺寸 → 输出帧大小 |
+| `game_style` | 画风 → 提示词风格 |
+| `sprite_sample_url` | 风格参考图 → 图生图模式 |
+
+| 方法 | 说明 |
+|---|---|
+| `create_project(project)` | 创建项目 |
+| `get_project(id)` | 按 ID 查询 |
+| `list_projects(page, page_size, user_id)` | 分页查询 |
+| `delete_project(id)` | 删除 |
+
+### character — 角色资产数据
+
+**对应表:** `windup_character` **接口:** `CharacterService`
+
+`character_data` JSONB 三层嵌套:outfit → action → frame
+
+| 方法 | 说明 |
+|---|---|
+| `create_character(session, **fields)` | 创建角色 |
+| `get_character(session, character_id)` | 按 ID 查询 |
+| `list_characters(session, *, project_id, page, page_size)` | 分页查询 |
+| `update_character(session, character_id, **fields)` | 更新角色 |
+| `delete_character(session, character_id)` | 删除角色(含媒体清理) |
+
+---
+
+## 工作流域(Workflow)
+
+### orchestrator — 生成任务调度
+
+**对应表:** `windup_generation_task` **接口:** `GenerationService`
+
+管理生成任务生命周期:创建任务 → 加载项目约束 → 调 ai_engine → 上传结果 → 回写状态。
+
+| 方法 | 说明 |
+|---|---|
+| `generate_character_image(input)` | 提交角色图片生成任务 |
+| `generate_character_action(input)` | 提交角色动作生成任务 |
+| `get_task(project_id, task_id)` | 查询任务状态与结果 |
+
+---
+
+## 生成管线域(Pipeline)
+
+### ai_engine — 生成管线
+
+**接口:** `CharacterGeneratorPort`
+
+生成管线:提示词 → 出图 → 抠图 → 截帧 → 返回产物。
+
+| 组件 | 职责 |
+|---|---|
+| `strategy/` | 策略分发(VIDEO_I2V / PER_FRAME / PROC_IDLE) |
+| `prompt/` | 提示词构建(walk / jump / attack) |
+| `slicing/` | 帧提取(imageio/pyav) |
+| `postprocess/` | 像素化、脚线对齐、sprite sheet 打包 |
+| `master_prep.py` | 母版预处理 |
+
+---
+
+## 结果域(Result)
+
+| 模块 | 状态 | 职责 |
+|---|---|---|
+| review | 🟡 前端页面体现 | 质检 + 人工审核 |
+| preview | 🟡 前端页面体现 | 预览台:组装可播放数据(帧 + 帧率 + 循环) |
+| export | ⬜ 待实现 | GIF / 精灵图 / 引擎格式转换 |
+
+---
+
+## MVP 已实现模块
+
+| 模块 | 域 | 数据表 | API |
+|---|---|---|---|
+| media | foundation | windup_media | POST /media/upload |
+| project | foundation | windup_project | POST/GET/DELETE /projects |
+| character | foundation | windup_character | POST/GET/PATCH/DELETE /characters |
+| orchestrator | workflow | windup_generation_task | POST /generation/image, POST /generation/action, GET /generation/tasks/{id} |
+| ai_engine | pipeline | (无独立表) | (内部调用,不暴露 API) |
diff --git a/backend/packages/app/src/windup_app/server/generation/__init__.py b/backend/packages/app/src/windup_app/server/orchestrator/__init__.py
similarity index 89%
rename from backend/packages/app/src/windup_app/server/generation/__init__.py
rename to backend/packages/app/src/windup_app/server/orchestrator/__init__.py
index a6701de..c21a7b8 100644
--- a/backend/packages/app/src/windup_app/server/generation/__init__.py
+++ b/backend/packages/app/src/windup_app/server/orchestrator/__init__.py
@@ -1,6 +1,6 @@
"""生成任务领域。"""
-from windup_app.server.generation.model import (
+from windup_app.server.orchestrator.model import (
ActionType,
CharacterActionFrame,
CharacterActionInput,
diff --git a/backend/packages/app/src/windup_app/server/generation/interface.py b/backend/packages/app/src/windup_app/server/orchestrator/interface.py
similarity index 97%
rename from backend/packages/app/src/windup_app/server/generation/interface.py
rename to backend/packages/app/src/windup_app/server/orchestrator/interface.py
index b43bace..83e38aa 100644
--- a/backend/packages/app/src/windup_app/server/generation/interface.py
+++ b/backend/packages/app/src/windup_app/server/orchestrator/interface.py
@@ -22,7 +22,7 @@
from abc import ABC, abstractmethod
-from windup_app.server.generation.model import (
+from windup_app.server.orchestrator.model import (
CharacterActionInput,
CharacterImageInput,
GenerationTask,
diff --git a/backend/packages/app/src/windup_app/server/generation/model.py b/backend/packages/app/src/windup_app/server/orchestrator/model.py
similarity index 100%
rename from backend/packages/app/src/windup_app/server/generation/model.py
rename to backend/packages/app/src/windup_app/server/orchestrator/model.py
diff --git a/backend/packages/app/src/windup_app/web/api/generation.py b/backend/packages/app/src/windup_app/web/api/generation.py
index be3a5e9..872c1c0 100644
--- a/backend/packages/app/src/windup_app/web/api/generation.py
+++ b/backend/packages/app/src/windup_app/web/api/generation.py
@@ -16,7 +16,7 @@
from windup_common.result import Response
from windup_framework.db import get_session
-from windup_app.server.generation.model import (
+from windup_app.server.orchestrator.model import (
ActionType,
GenerationTask,
)
From ce7a97bebacd2ab55967ac49fb4a81c546227314 Mon Sep 17 00:00:00 2001
From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com>
Date: Tue, 4 Aug 2026 12:29:23 +0800
Subject: [PATCH 2/7] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E6=A8=A1?=
=?UTF-8?q?=E5=9D=97=E6=8B=86=E5=88=86=E6=96=87=E6=A1=A3=E4=B8=8E=E6=9E=B6?=
=?UTF-8?q?=E6=9E=84=E5=9B=BE=EF=BC=8C=E6=96=B0=E5=A2=9E=20workflow=20?=
=?UTF-8?q?=E5=92=8C=20agent=20=E6=A8=A1=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- module-split.md: 新增 workflow(工作流画布)和 agent(懒人智能体)模块定义
- product-domains.svg: 更新架构图,调整模块布局,调用链路为 agent → workflow → orchestrator → ai_engine
---
backend/docs/diagrams/product-domains.svg | 119 +++++++-----------
backend/docs/module-split.md | 79 ++++++++++--
.../app/src/windup_app/web/sse/.gitkeep | 0
3 files changed, 120 insertions(+), 78 deletions(-)
delete mode 100644 backend/packages/app/src/windup_app/web/sse/.gitkeep
diff --git a/backend/docs/diagrams/product-domains.svg b/backend/docs/diagrams/product-domains.svg
index b6872d8..1444299 100644
--- a/backend/docs/diagrams/product-domains.svg
+++ b/backend/docs/diagrams/product-domains.svg
@@ -1,4 +1,4 @@
-
\ No newline at end of file
diff --git a/backend/docs/module-split.md b/backend/docs/module-split.md
index 956c7e0..a741196 100644
--- a/backend/docs/module-split.md
+++ b/backend/docs/module-split.md
@@ -25,7 +25,9 @@ backend/
│ ├── media/ # [foundation] 用户素材上传
│ ├── project/ # [foundation] 项目约束配置
│ ├── character/ # [foundation] 角色资产数据
-│ └── orchestrator/ # [workflow] 生成任务调度
+│ ├── orchestrator/ # [workflow] 生成任务调度
+│ ├── workflow/ # [workflow] 工作流画布(功能型卡片)
+│ └── agent/ # [workflow] Agent 智能体(懒人模式)
```
> 注:`[foundation]` / `[workflow]` 标注所属业务域。
@@ -51,18 +53,31 @@ ai_engine foundation
▲ ▲ ▲
│ ┌────┘ │
│ workflow result
- │ │ │
- └─────┘─────────┘
+ │ │ │ │ │
+ │ │ │ │ │
+ └───┘──┘──┘─────┘
+ │ │
+ orchestrator
+ │
+ agent
```
+**模块关系**:
+- **workflow**(工作流画布):卡片编排,调用 orchestrator 触动生成
+- **orchestrator**(生成任务调度):管理生成任务生命周期,调用 ai_engine
+- **agent**(懒人智能体):一句话入口,内部调用 workflow API
+- **ai_engine**(生成管线):实际 AI 生成,被 orchestrator 调用
+
**规则**:
- foundation → framework, common
-- workflow → foundation, ai_engine, framework, common
+- workflow → orchestrator, foundation, framework, common
+- orchestrator → ai_engine, foundation, framework, common
+- agent → workflow, framework, common
- result → foundation, framework, common
- ai_engine → framework.providers(接口), common
-- **禁止**:foundation → workflow / result / ai_engine
-- **禁止**:ai_engine → foundation / workflow / result
-- **禁止**:result → workflow
+- **禁止**:foundation → workflow / orchestrator / agent / result / ai_engine
+- **禁止**:ai_engine → foundation / workflow / orchestrator / agent / result
+- **禁止**:result → workflow / orchestrator / agent
---
@@ -164,6 +179,54 @@ ai_engine foundation
| `generate_character_action(input)` | 提交角色动作生成任务 |
| `get_task(project_id, task_id)` | 查询任务状态与结果 |
+### workflow — 工作流画布
+
+**对应表:** `windup_workflow` / `windup_canvas_card` / `windup_generation_attempt`
+**接口:** `WorkflowService` / `CardService`
+
+功能型卡片体系:CHARACTER(角色实体)→ CANDIDATE(母版候选)/ ACTION(角色动作)/ EXPORT(资产导出)。
+
+**卡片类型 `CardType`:** character / candidate / action / export
+
+**WorkflowService 接口:**
+
+| 方法 | 说明 |
+|---|---|
+| `create_workflow(user_id, project_id, name)` | 创建工作流(自动创建 CHARACTER 根卡片) |
+| `get_workflow(workflow_id)` | 获取工作流详情(含全部 active 卡片) |
+| `delete_workflow(workflow_id)` | 删除工作流(级联软删除) |
+
+**CardService 接口:**
+
+| 方法 | 说明 |
+|---|---|
+| `create_card(workflow_id, card_type, parent_card_id, ...)` | 创建子卡片(ACTION/EXPORT) |
+| `confirm_card(card_id, user_input, spec_overrides?)` | 确认卡片,触发生成 |
+| `regenerate_card(card_id, user_input?)` | 重新生成(新 attempt) |
+| `delete_card(card_id)` | 删除卡片(级联软删除) |
+
+**SSE 衔接:** 确认卡片返回 `GenerationAttempt`(含 `task_id`),前端订阅 Task SSE 获取进度。
+
+### agent — Agent 智能体
+
+**对应表:** `agent_session` / `agent_message` / `agent_tool_call`
+**接口:** `AgentService`
+
+懒人智能体:一句话搞定角色资产生成。Agent 内部调用 Workflow API 完成实际操作。
+
+**工具定义:** create_project / create_character / generate_character_image / generate_character_action
+
+**AgentService 接口:**
+
+| 方法 | 说明 |
+|---|---|
+| `create_session(user_id, context?)` | 创建 Agent 会话 |
+| `send_message(session_id, content)` | 发送用户消息 |
+| `send_choice(session_id, message_id, value)` | 发送用户选择 |
+| `get_messages(session_id, limit?, before?)` | 获取会话历史 |
+
+**SSE 事件:** message / tool_call / tool_result / state_change / error
+
---
## 生成管线域(Pipeline)
@@ -202,4 +265,6 @@ ai_engine foundation
| project | foundation | windup_project | POST/GET/DELETE /projects |
| character | foundation | windup_character | POST/GET/PATCH/DELETE /characters |
| orchestrator | workflow | windup_generation_task | POST /generation/image, POST /generation/action, GET /generation/tasks/{id} |
+| workflow | workflow | windup_workflow, windup_canvas_card, windup_generation_attempt | POST/GET/DELETE /workflow, POST/PATCH/DELETE /workflow/{id}/cards, POST /cards/{id}/confirm, POST /cards/{id}/regenerate |
+| agent | workflow | agent_session, agent_message, agent_tool_call | POST /agent/sessions, POST /agent/sessions/{id}/messages, POST /agent/sessions/{id}/choices, GET /agent/sessions/{id}/messages |
| ai_engine | pipeline | (无独立表) | (内部调用,不暴露 API) |
diff --git a/backend/packages/app/src/windup_app/web/sse/.gitkeep b/backend/packages/app/src/windup_app/web/sse/.gitkeep
deleted file mode 100644
index e69de29..0000000
From a93345f0e284486a8dd17a06af14e2a29489d6ec Mon Sep 17 00:00:00 2001
From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com>
Date: Tue, 4 Aug 2026 12:30:36 +0800
Subject: [PATCH 3/7] =?UTF-8?q?feat(workflow):=20=E6=96=B0=E5=A2=9E?=
=?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=94=BB=E5=B8=83=E4=B8=8E=E6=99=BA?=
=?UTF-8?q?=E8=83=BD=E4=BD=93=E6=8E=A5=E5=8F=A3=E5=AE=9A=E4=B9=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- server/workflow/: 领域模型、Pydantic Schema、服务接口
- model.py: CardType/CardStatus/Workflow/CanvasCard/GenerationAttempt
- schema.py: 显式定义 CHARACTER/ACTION/EXPORT 卡片输入参数
- interface.py: WorkflowService + CardService 抽象接口
- server/agent/: 领域模型、Pydantic Schema、服务接口
- model.py: AgentSession/AgentMessage/ToolCall/ContentBlock
- schema.py: 会话/消息/工具调用请求响应模型
- interface.py: AgentService 抽象接口
- web/api/: API 路由定义
- workflow.py: 9 个工作流画布端点
- agent.py: 5 个智能体端点
- bootstrap/app.py: 注册 workflow_router 和 agent_router
---
.../app/src/windup_app/bootstrap/app.py | 4 +
.../src/windup_app/server/agent/__init__.py | 1 +
.../src/windup_app/server/agent/interface.py | 91 +++++
.../app/src/windup_app/server/agent/model.py | 121 +++++++
.../app/src/windup_app/server/agent/schema.py | 173 ++++++++++
.../windup_app/server/workflow/__init__.py | 1 +
.../windup_app/server/workflow/interface.py | 134 ++++++++
.../src/windup_app/server/workflow/model.py | 131 ++++++++
.../src/windup_app/server/workflow/schema.py | 310 ++++++++++++++++++
.../app/src/windup_app/web/api/agent.py | 166 ++++++++++
.../app/src/windup_app/web/api/workflow.py | 178 ++++++++++
11 files changed, 1310 insertions(+)
create mode 100644 backend/packages/app/src/windup_app/server/agent/__init__.py
create mode 100644 backend/packages/app/src/windup_app/server/agent/interface.py
create mode 100644 backend/packages/app/src/windup_app/server/agent/model.py
create mode 100644 backend/packages/app/src/windup_app/server/agent/schema.py
create mode 100644 backend/packages/app/src/windup_app/server/workflow/__init__.py
create mode 100644 backend/packages/app/src/windup_app/server/workflow/interface.py
create mode 100644 backend/packages/app/src/windup_app/server/workflow/model.py
create mode 100644 backend/packages/app/src/windup_app/server/workflow/schema.py
create mode 100644 backend/packages/app/src/windup_app/web/api/agent.py
create mode 100644 backend/packages/app/src/windup_app/web/api/workflow.py
diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py
index 89f7b43..8a8a33d 100644
--- a/backend/packages/app/src/windup_app/bootstrap/app.py
+++ b/backend/packages/app/src/windup_app/bootstrap/app.py
@@ -6,10 +6,14 @@
from fastapi import FastAPI
+from windup_app.web.api.agent import router as agent_router
from windup_app.web.api.media import router as media_router
+from windup_app.web.api.workflow import router as workflow_router
def create_app() -> FastAPI:
app = FastAPI(title="windup", version="0.1.0")
app.include_router(media_router)
+ app.include_router(workflow_router)
+ app.include_router(agent_router)
return app
diff --git a/backend/packages/app/src/windup_app/server/agent/__init__.py b/backend/packages/app/src/windup_app/server/agent/__init__.py
new file mode 100644
index 0000000..dc087b7
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/agent/__init__.py
@@ -0,0 +1 @@
+"""Agent 智能体领域。"""
diff --git a/backend/packages/app/src/windup_app/server/agent/interface.py b/backend/packages/app/src/windup_app/server/agent/interface.py
new file mode 100644
index 0000000..7f829fa
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/agent/interface.py
@@ -0,0 +1,91 @@
+"""Agent 智能体领域服务接口。
+
+懒人智能体:一句话搞定角色资产生成。
+用户通过自然语言与 Agent 对话,Agent 自动调用工具完成项目创建、角色生成、动作生成等操作。
+
+调用流程
+--------
+1. 前端调用 ``POST /agent/sessions`` 创建会话,拿到 ``session_id``。
+2. 前端订阅 ``GET /agent/sessions/{session_id}/stream`` 获取 Agent 事件流。
+3. 前端调用 ``POST /agent/sessions/{session_id}/messages`` 发送用户消息。
+4. Agent 通过 SSE 推送处理结果(message/tool_call/tool_result 等事件)。
+5. 若 tool_call 含 task_id,前端订阅 Task SSE 获取生成进度。
+
+工具定义
+--------
+Agent 可调用的工具(通过 SSE tool_call 事件推送):
+
+- ``create_project``: 创建项目(返回 project_id)
+- ``create_character``: 创建角色(返回 character_id)
+- ``generate_character_image``: 生成角色图片(返回 task_id,可订阅 Task SSE)
+- ``generate_character_action``: 生成角色动作(返回 task_id,可订阅 Task SSE)
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+from windup_app.server.agent.model import (
+ AgentMessage,
+ AgentSession,
+ ToolCall,
+)
+
+
+class AgentService(ABC):
+ """Agent 用例的抽象边界。"""
+
+ # -- 会话管理 ----------------------------------------------------------
+
+ @abstractmethod
+ def create_session(self, *, user_id: int, context: dict | None = None) -> AgentSession:
+ """创建 Agent 会话。
+
+ 返回的 session_id 用于 SSE 订阅和消息发送。
+ """
+
+ @abstractmethod
+ def get_session(self, session_id: str) -> AgentSession | None:
+ """获取会话信息。"""
+
+ @abstractmethod
+ def close_session(self, session_id: str) -> None:
+ """关闭会话。"""
+
+ # -- 消息交互 ----------------------------------------------------------
+
+ @abstractmethod
+ def send_message(self, session_id: str, *, content: str, message_id: str | None = None) -> AgentMessage:
+ """发送用户消息。
+
+ Agent 收到消息后异步处理,通过 SSE 推送结果。
+ 返回用户消息记录。
+ """
+
+ @abstractmethod
+ def send_choice(self, session_id: str, *, message_id: str, value: str) -> None:
+ """发送用户选择(按钮点击)。
+
+ 对应 Agent 消息中的 buttons 块。
+ """
+
+ @abstractmethod
+ def get_messages(
+ self,
+ session_id: str,
+ *,
+ limit: int = 50,
+ before: str | None = None,
+ ) -> list[AgentMessage]:
+ """获取会话历史消息。
+
+ 参数:
+ - limit: 返回条数,默认50
+ - before: 分页,此 message_id 之前的消息
+ """
+
+ # -- 工具调用记录 ------------------------------------------------------
+
+ @abstractmethod
+ def get_tool_calls(self, session_id: str) -> list[ToolCall]:
+ """获取会话的全部工具调用记录。"""
diff --git a/backend/packages/app/src/windup_app/server/agent/model.py b/backend/packages/app/src/windup_app/server/agent/model.py
new file mode 100644
index 0000000..baa182f
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/agent/model.py
@@ -0,0 +1,121 @@
+"""Agent 智能体领域模型。
+
+懒人智能体:一句话搞定角色资产生成。
+用户通过自然语言与 Agent 对话,Agent 自动调用工具完成项目创建、角色生成、动作生成等操作。
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from enum import StrEnum
+
+
+# -- 枚举 ----------------------------------------------------------------
+
+
+class SessionStatus(StrEnum):
+ """会话状态。"""
+
+ ACTIVE = "active"
+ CLOSED = "closed"
+
+
+class MessageRole(StrEnum):
+ """消息角色。"""
+
+ USER = "user"
+ ASSISTANT = "assistant"
+
+
+class AgentState(StrEnum):
+ """Agent 状态(仅 assistant 消息)。"""
+
+ PROCESSING = "processing" # Agent 正在处理
+ WAITING_INPUT = "waiting_input" # 等待用户输入
+ DONE = "done" # 处理完成
+
+
+class ToolName(StrEnum):
+ """已定义的工具名称。"""
+
+ CREATE_PROJECT = "create_project"
+ CREATE_CHARACTER = "create_character"
+ GENERATE_CHARACTER_IMAGE = "generate_character_image"
+ GENERATE_CHARACTER_ACTION = "generate_character_action"
+
+
+# -- 会话 ---------------------------------------------------------------
+
+
+@dataclass
+class AgentSession:
+ """Agent 会话。"""
+
+ id: int | None = None
+ session_id: str = ""
+ user_id: int = 0
+ project_id: int | None = None
+ status: SessionStatus = SessionStatus.ACTIVE
+ context: dict = field(default_factory=dict)
+ create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+# -- 消息 ---------------------------------------------------------------
+
+
+@dataclass
+class ContentBlock:
+ """内容块——Agent 消息的基本单元。"""
+
+ type: str = "" # text / image / buttons / confirm / progress / divider / markdown / code
+ # text / markdown / code
+ content: str = ""
+ # image
+ url: str = ""
+ caption: str = ""
+ # buttons / confirm
+ prompt: str = ""
+ options: list[dict] = field(default_factory=list) # [{value, label, icon?}]
+ confirm_label: str = "确认"
+ reject_label: str = "取消"
+ # progress
+ stage: str = ""
+ current: int = 0
+ total: int = 0
+ text: str = ""
+ # code
+ language: str = ""
+
+
+@dataclass
+class AgentMessage:
+ """Agent 消息。"""
+
+ id: int | None = None
+ session_id: str = ""
+ message_id: str = ""
+ role: MessageRole = MessageRole.USER
+ blocks: list[ContentBlock] = field(default_factory=list)
+ state: AgentState | None = None
+ create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+# -- 工具调用 -----------------------------------------------------------
+
+
+@dataclass
+class ToolCall:
+ """Agent 工具调用。"""
+
+ id: int | None = None
+ session_id: str = ""
+ message_id: str = ""
+ call_id: str = ""
+ tool_name: ToolName = ToolName.CREATE_PROJECT
+ arguments: dict = field(default_factory=dict)
+ result: dict | None = None
+ error: str | None = None
+ task_id: int | None = None
+ create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
diff --git a/backend/packages/app/src/windup_app/server/agent/schema.py b/backend/packages/app/src/windup_app/server/agent/schema.py
new file mode 100644
index 0000000..f8da68c
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/agent/schema.py
@@ -0,0 +1,173 @@
+"""Agent 智能体 API Schema。
+
+定义前端请求/响应的 Pydantic 模型,与 server 层解耦。
+前端团队参考此文件了解接口契约。
+"""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 请求模型
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+class SessionCreateRequest(BaseModel):
+ """创建 Agent 会话。"""
+
+ user_id: int = Field(description="用户 ID")
+ context: dict | None = Field(
+ default=None,
+ description="初始上下文,如 {project_id: 123}。可选,后续通过对话补充。",
+ )
+
+
+class MessageSendRequest(BaseModel):
+ """发送用户消息。"""
+
+ content: str = Field(min_length=1, max_length=2000, description="用户消息内容")
+ message_id: str | None = Field(
+ default=None,
+ description="客户端消息 ID(用于去重),省略则由后端生成",
+ )
+
+
+class ChoiceSendRequest(BaseModel):
+ """发送用户选择(按钮点击)。"""
+
+ message_id: str = Field(description="对应的 Agent 消息 ID(buttons 块所在的 message)")
+ value: str = Field(description="选择的值(ButtonOption.value)")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 响应模型
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+class SessionOut(BaseModel):
+ """会话响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ session_id: str = Field(description="会话 ID,用于后续 SSE 订阅和消息发送")
+ created_at: str = Field(description="创建时间(ISO 8601)")
+
+
+class ContentBlockOut(BaseModel):
+ """内容块响应。"""
+
+ type: str = Field(description="块类型:text/image/buttons/confirm/progress/divider/markdown/code")
+ # text / markdown / code
+ content: str | None = None
+ # image
+ url: str | None = None
+ caption: str | None = None
+ # buttons
+ prompt: str | None = None
+ options: list[dict] | None = None # [{value, label, icon?}]
+ # confirm
+ confirm_label: str | None = None
+ reject_label: str | None = None
+ # progress
+ stage: str | None = None
+ current: int | None = None
+ total: int | None = None
+ text: str | None = None
+ # code
+ language: str | None = None
+
+
+class MessageOut(BaseModel):
+ """消息响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ message_id: str = Field(description="消息 ID")
+ session_id: str = Field(description="会话 ID")
+ role: str = Field(description="角色:user / assistant")
+ blocks: list[ContentBlockOut] = Field(
+ default_factory=list,
+ description="内容块列表",
+ )
+ state: str | None = Field(
+ default=None,
+ description="Agent 状态:processing / waiting_input / done(仅 assistant 消息)",
+ )
+ timestamp: float = Field(description="时间戳(秒)")
+
+
+class ToolCallOut(BaseModel):
+ """工具调用响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ call_id: str = Field(description="调用 ID")
+ tool: str = Field(description="工具名称")
+ args: dict = Field(default_factory=dict, description="工具参数")
+ task_id: int | None = Field(default=None, description="任务 ID(生成任务时有值,可订阅 Task SSE)")
+ message: str | None = Field(default=None, description="说明文字")
+
+
+class ToolResultOut(BaseModel):
+ """工具结果响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ call_id: str = Field(description="关联的 tool_call ID")
+ tool: str = Field(description="工具名称")
+ result: dict | None = Field(default=None, description="工具返回结果")
+ error: str | None = Field(default=None, description="错误信息(失败时)")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# SSE 事件结构(前端参考)
+# ══════════════════════════════════════════════════════════════════════════════
+#
+# ── Agent SSE 事件类型 ──
+#
+# event: message
+# data: {
+# "message_id": "msg_001",
+# "session_id": "session_abc123",
+# "role": "assistant",
+# "blocks": [
+# {"type": "text", "content": "好的,我需要了解几个细节:"},
+# {"type": "buttons", "prompt": "游戏类型", "options": [
+# {"value": "side_scroller", "label": "横版游戏"},
+# {"value": "top_down", "label": "俯视角"}
+# ]}
+# ],
+# "state": "waiting_input",
+# "timestamp": 1234567890.123
+# }
+#
+# event: tool_call
+# data: {
+# "call_id": "c1",
+# "tool": "create_project",
+# "args": {"name": "甲壳虫", "perspective": 1},
+# "task_id": null,
+# "message": "正在创建项目..."
+# }
+#
+# event: tool_result
+# data: {
+# "call_id": "c1",
+# "tool": "create_project",
+# "result": {"project_id": 123},
+# "error": null
+# }
+#
+# event: state_change
+# data: {
+# "state": "waiting_input",
+# "previous_state": "processing"
+# }
+#
+# event: error
+# data: {
+# "error": "会话已过期",
+# "code": "SESSION_EXPIRED"
+# }
diff --git a/backend/packages/app/src/windup_app/server/workflow/__init__.py b/backend/packages/app/src/windup_app/server/workflow/__init__.py
new file mode 100644
index 0000000..cc21c20
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/workflow/__init__.py
@@ -0,0 +1 @@
+"""工作流画布领域。"""
diff --git a/backend/packages/app/src/windup_app/server/workflow/interface.py b/backend/packages/app/src/windup_app/server/workflow/interface.py
new file mode 100644
index 0000000..f343406
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/workflow/interface.py
@@ -0,0 +1,134 @@
+"""工作流画布领域服务接口。
+
+API 层只依赖本模块定义的抽象。具体实现在应用装配层继承后通过依赖注入提供。
+
+调用流程
+--------
+1. 前端调用 ``POST /workflow`` 创建工作流,拿到 ``workflow_id``。
+2. 前端调用 ``POST /workflow/{id}/cards`` 创建子卡片(ACTION / EXPORT)。
+3. 前端调用 ``POST /cards/{id}/confirm`` 确认卡片,触发生成。
+ - 返回的 ``GenerationAttempt`` 包含 ``task_id``,前端可订阅 Task SSE 获取进度。
+4. 前端通过 ``GET /workflow/{id}`` 获取画布最新状态。
+5. 生成完成后,前端从 ``card.latest_result`` 取出结果。
+
+回退流程
+--------
+前端调用 ``POST /cards/{id}/regenerate`` 触发重新生成。
+- CHARACTER:旧 CANDIDATE 全部 INACTIVE,重新生成候选。ACTION / EXPORT 不受影响。
+- ACTION / EXPORT:创建新 attempt,重新执行。
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+from windup_app.server.workflow.model import (
+ CanvasCard,
+ GenerationAttempt,
+ Workflow,
+)
+
+
+class WorkflowService(ABC):
+ """工作流用例的抽象边界。"""
+
+ # -- 工作流 CRUD --------------------------------------------------------
+
+ @abstractmethod
+ def create_workflow(self, *, user_id: int, project_id: int, name: str) -> Workflow:
+ """创建工作流,自动创建 CHARACTER 根卡片。
+
+ 返回的工作流已包含一张 DRAFT 状态的 CHARACTER 卡片。
+ """
+
+ @abstractmethod
+ def get_workflow(self, workflow_id: int) -> Workflow | None:
+ """获取工作流详情(含全部 active 卡片)。
+
+ 返回的 Workflow.cards 已按 parent_card_id 组装为树结构。
+ """
+
+ @abstractmethod
+ def delete_workflow(self, workflow_id: int) -> None:
+ """删除工作流(级联软删除所有卡片和尝试记录)。"""
+
+
+class CardService(ABC):
+ """卡片用例的抽象边界。"""
+
+ # -- 卡片 CRUD ----------------------------------------------------------
+
+ @abstractmethod
+ def create_card(
+ self,
+ workflow_id: int,
+ *,
+ card_type: str,
+ parent_card_id: int,
+ direction: str | None = None,
+ user_input: dict | None = None,
+ spec_overrides: dict | None = None,
+ ) -> CanvasCard:
+ """创建子卡片(ACTION / EXPORT)。
+
+ ACTION 卡片创建时自动从选定的 CANDIDATE 复制母版图到 user_input.master_image_url。
+ """
+
+ @abstractmethod
+ def update_card(
+ self,
+ card_id: int,
+ *,
+ user_input: dict | None = None,
+ position_x: float | None = None,
+ position_y: float | None = None,
+ ) -> CanvasCard:
+ """更新卡片用户输入或位置(不触发生成)。"""
+
+ @abstractmethod
+ def confirm_card(
+ self,
+ card_id: int,
+ *,
+ user_input: dict,
+ spec_overrides: dict | None = None,
+ ) -> GenerationAttempt:
+ """确认卡片,触发生成。
+
+ - CHARACTER:生成候选图 → 创建 CANDIDATE 卡片。
+ - ACTION:生成动画帧。
+ - EXPORT:打包导出。
+
+ 返回的 GenerationAttempt 包含 task_id,前端可订阅 Task SSE。
+ """
+
+ @abstractmethod
+ def regenerate_card(
+ self,
+ card_id: int,
+ *,
+ user_input: dict | None = None,
+ ) -> GenerationAttempt:
+ """重新生成(创建新的 GenerationAttempt)。
+
+ - CHARACTER:旧 CANDIDATE 全部 INACTION,重新生成候选。ACTION/EXPORT 不受影响。
+ - ACTION / EXPORT:创建新 attempt,重新执行。
+ """
+
+ @abstractmethod
+ def delete_card(self, card_id: int) -> None:
+ """删除卡片(级联软删除所有子卡片)。"""
+
+ @abstractmethod
+ def get_card(self, card_id: int) -> CanvasCard | None:
+ """获取单张卡片。"""
+
+ @abstractmethod
+ def list_cards(self, workflow_id: int) -> list[CanvasCard]:
+ """获取工作流下全部 active 卡片。"""
+
+ # -- 生成尝试 -----------------------------------------------------------
+
+ @abstractmethod
+ def get_attempts(self, card_id: int) -> list[GenerationAttempt]:
+ """获取某张卡片的全部生成尝试记录。"""
diff --git a/backend/packages/app/src/windup_app/server/workflow/model.py b/backend/packages/app/src/windup_app/server/workflow/model.py
new file mode 100644
index 0000000..dde386c
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/workflow/model.py
@@ -0,0 +1,131 @@
+"""工作流画布领域模型。
+
+功能型卡片体系:CHARACTER(角色实体)→ CANDIDATE(母版候选)/ ACTION(角色动作)/ EXPORT(资产导出)。
+前端通过卡片 API 统一操作,后端负责持久化画布结构和生成任务状态。
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from enum import StrEnum
+
+
+# -- 枚举 ----------------------------------------------------------------
+
+
+class WorkflowStatus(StrEnum):
+ """工作流状态。"""
+
+ ACTIVE = "active"
+ ARCHIVED = "archived"
+
+
+class CardType(StrEnum):
+ """卡片类型——功能型,按职责划分。"""
+
+ CHARACTER = "character" # 角色实体根节点
+ CANDIDATE = "candidate" # 母版候选
+ ACTION = "action" # 角色动作
+ EXPORT = "export" # 资产导出
+
+
+class CardStatus(StrEnum):
+ """卡片状态。"""
+
+ DRAFT = "draft" # 已创建,待用户填写
+ GENERATING = "generating" # 生成中
+ COMPLETED = "completed" # 完成
+ FAILED = "failed" # 失败
+ INACTIVE = "inactive" # 已失效(软删除)
+
+
+class AttemptStatus(StrEnum):
+ """生成尝试状态。"""
+
+ PENDING = "pending"
+ RUNNING = "running"
+ COMPLETED = "completed"
+ FAILED = "failed"
+
+
+class Direction(StrEnum):
+ """角色朝向——多方向扩展时使用。"""
+
+ FRONT = "front"
+ SIDE = "side"
+ BACK = "back"
+ LEFT = "left"
+
+
+# -- 工作流 ---------------------------------------------------------------
+
+
+@dataclass
+class Workflow:
+ """工作流——一个画布实例。"""
+
+ id: int | None = None
+ user_id: int = 0
+ project_id: int | None = None
+ name: str = "未命名工作流"
+ status: WorkflowStatus = WorkflowStatus.ACTIVE
+ project_context: dict = field(default_factory=dict)
+ schema_version: int = 1
+ version: int = 1
+ create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+# -- 画布卡片 -------------------------------------------------------------
+
+
+@dataclass
+class CanvasCard:
+ """画布卡片——工作流节点。
+
+ user_input / latest_result 结构按 card_type 不同,见 schema.py 中的注释。
+ """
+
+ id: int | None = None
+ workflow_id: int = 0
+ card_type: CardType = CardType.CHARACTER
+ status: CardStatus = CardStatus.DRAFT
+ parent_card_id: int | None = None
+ direction: Direction | None = None
+ position_x: float = 0.0
+ position_y: float = 0.0
+ user_input: dict = field(default_factory=dict)
+ latest_result: dict | None = None
+ spec_overrides: dict = field(default_factory=dict)
+ is_active: bool = True
+ version: int = 1
+ create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+ @property
+ def is_terminal(self) -> bool:
+ return self.status in (CardStatus.COMPLETED, CardStatus.FAILED, CardStatus.INACTIVE)
+
+
+# -- 生成尝试 -------------------------------------------------------------
+
+
+@dataclass
+class GenerationAttempt:
+ """生成尝试——每次触发生成创建一条记录。"""
+
+ id: int | None = None
+ card_id: int = 0
+ task_id: int | None = None
+ attempt_no: int = 1
+ status: AttemptStatus = AttemptStatus.PENDING
+ input_payload: dict = field(default_factory=dict)
+ result: dict | None = None
+ error_message: str | None = None
+ create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+ @property
+ def is_terminal(self) -> bool:
+ return self.status in (AttemptStatus.COMPLETED, AttemptStatus.FAILED)
diff --git a/backend/packages/app/src/windup_app/server/workflow/schema.py b/backend/packages/app/src/windup_app/server/workflow/schema.py
new file mode 100644
index 0000000..dc02eb9
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/workflow/schema.py
@@ -0,0 +1,310 @@
+"""工作流画布 API Schema。
+
+定义前端请求/响应的 Pydantic 模型,与 server 层解耦。
+前端团队参考此文件了解接口契约。
+
+卡片输入参数按 card_type 区分,每种类型有明确的 user_input / spec_overrides 结构。
+"""
+
+from __future__ import annotations
+
+from enum import StrEnum
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from windup_app.server.workflow.model import (
+ AttemptStatus,
+ CardStatus,
+ CardType,
+ Direction,
+ WorkflowStatus,
+)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 工作流
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+class WorkflowCreateRequest(BaseModel):
+ """创建工作流。
+
+ 自动创建一张 CHARACTER 根卡片作为画布起点。
+ """
+
+ project_id: int = Field(description="关联项目 ID,项目约束从这里读取")
+ name: str = Field(default="未命名工作流", max_length=100, description="工作流名称")
+
+
+class WorkflowOut(BaseModel):
+ """工作流详情响应(含全部 active 卡片)。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ project_id: int | None = None
+ name: str
+ status: str = Field(description="active / archived")
+ project_context: dict = Field(
+ default_factory=dict,
+ description="项目约束快照:{perspective, sprite_width, sprite_height, game_style, ...}",
+ )
+ version: int = Field(description="乐观锁版本号,更新时需携带")
+ cards: list[CanvasCardOut] = Field(default_factory=list, description="全部有效卡片")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 卡片输入参数(按 card_type 显式定义)
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+# ── CHARACTER 卡片 ────────────────────────────────────────────────────────────
+
+
+class CharacterConfirmInput(BaseModel):
+ """CHARACTER 卡片确认时的用户输入。
+
+ 用户填写角色描述后提交,后端生成 N 张候选图。
+ """
+
+ description: str = Field(min_length=1, max_length=500, description="角色描述,如'甲壳虫战士,手持长剑'")
+ num_images: int = Field(default=6, ge=1, le=12, description="生成候选图数量,默认6张")
+
+
+class CharacterUpdateInput(BaseModel):
+ """CHARACTER 卡片更新时的用户输入(选定候选后回填)。"""
+
+ selected_candidate_id: int | None = Field(default=None, description="选定的 CANDIDATE 卡片 ID")
+
+
+# ── ACTION 卡片 ───────────────────────────────────────────────────────────────
+
+
+class PresetActionType(StrEnum):
+ """预设动作类型——规格与提示词已预先优化。"""
+
+ IDLE = "idle"
+ WALK = "walk"
+ RUN = "run"
+ JUMP = "jump"
+
+
+class ActionCreateInput(BaseModel):
+ """ACTION 卡片创建时的用户输入。"""
+
+ action_type: PresetActionType | str = Field(
+ description="动作类型:预设(idle/walk/run/jump) 或自定义(任意字符串)"
+ )
+ action_name: str | None = Field(
+ default=None, max_length=50,
+ description="自定义动作名称(action_type 为自定义时必填)",
+ )
+ description: str = Field(
+ min_length=1, max_length=300,
+ description="动作描述,如'大步流星地走'、'向上跳跃'",
+ )
+ reference_image_url: str | None = Field(
+ default=None,
+ description="姿势参考图 URL(自定义动作可选,预设动作忽略)",
+ )
+
+
+class ActionSpecOverrides(BaseModel):
+ """ACTION 卡片高级项覆盖。
+
+ 这些参数有默认值,用户可在"高级项"中覆盖。
+ 覆盖后卡片上出现记号标明该处已偏离默认。
+ """
+
+ num_frames: int = Field(default=36, ge=1, le=120, description="生成帧数,默认36帧")
+ fps: int = Field(default=10, ge=1, le=60, description="帧率,默认10 FPS")
+ loop: Literal["none", "linear", "pingpong"] = Field(
+ default="linear", description="循环模式:none=不循环, linear=线性, pingpong=乒乓"
+ )
+
+
+# ── EXPORT 卡片 ──────────────────────────────────────────────────────────────
+
+
+class ExportFormat(StrEnum):
+ """导出格式。"""
+
+ PNG_SEQUENCE = "png_sequence" # PNG 序列帧
+ SPRITE_SHEET = "sprite_sheet" # 精灵图集
+ PLIST = "plist" # Cocos SpriteFrames
+
+
+class ExportCreateInput(BaseModel):
+ """EXPORT 卡片创建时的用户输入。"""
+
+ formats: list[ExportFormat] = Field(
+ min_length=1,
+ description="导出格式列表,至少选一种",
+ )
+ fps: int = Field(default=10, ge=1, le=60, description="导出帧率")
+
+
+# ── latest_result 结构(按 card_type)─────────────────────────────────────────
+
+
+class CharacterLatestResult(BaseModel):
+ """CHARACTER 生成结果。"""
+
+ candidate_ids: list[int] = Field(description="生成的 CANDIDATE 卡片 ID 列表")
+
+
+class CandidateLatestResult(BaseModel):
+ """CANDIDATE 生成结果(与 user_input 相同)。"""
+
+ image_url: str = Field(description="候选图 URL")
+
+
+class ActionFrame(BaseModel):
+ """动画帧。"""
+
+ index: int = Field(description="帧序号,从0开始")
+ image_url: str = Field(description="帧图片 URL")
+ duration_ms: int = Field(default=125, description="帧持续时间(ms)")
+
+
+class ActionLatestResult(BaseModel):
+ """ACTION 生成结果。"""
+
+ first_frame_url: str | None = Field(default=None, description="首帧图 URL")
+ frames: list[ActionFrame] = Field(default_factory=list, description="完整动画帧序列")
+
+
+class ExportLatestResult(BaseModel):
+ """EXPORT 生成结果。"""
+
+ package_url: str = Field(description="导出包下载 URL")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 画布卡片请求/响应
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+class CardCreateRequest(BaseModel):
+ """创建子卡片(ACTION / EXPORT)。
+
+ 由前端"+"菜单触发,parent_card_id 必须指向一张 CHARACTER 卡片。
+ ACTION 卡片创建时自动从选定的 CANDIDATE 复制母版图。
+ """
+
+ card_type: Literal["action", "export"] = Field(description="卡片类型")
+ parent_card_id: int = Field(description="父卡片 ID,必须是 CHARACTER 类型")
+ direction: Direction | None = Field(default=None, description="方向:front/side/back/left,单方向时省略")
+ user_input: ActionCreateInput | ExportCreateInput = Field(
+ description="用户输入:ACTION 用 ActionCreateInput,EXPORT 用 ExportCreateInput",
+ )
+ spec_overrides: ActionSpecOverrides | None = Field(
+ default=None,
+ description="ACTION 高级项覆盖(帧数/FPS/循环),EXPORT 省略",
+ )
+
+
+class CardUpdateRequest(BaseModel):
+ """更新卡片用户输入(不触发生成)。
+
+ CHARACTER:更新 selected_candidate_id(选定候选)
+ ACTION:更新描述等草稿
+ """
+
+ user_input: CharacterUpdateInput | ActionCreateInput | ExportCreateInput = Field(
+ description="更新后的用户输入,结构按 card_type"
+ )
+ position_x: float | None = Field(default=None, description="画布 X 坐标(拖动后保存)")
+ position_y: float | None = Field(default=None, description="画布 Y 坐标(拖动后保存)")
+
+
+class CharacterConfirmRequest(BaseModel):
+ """CHARACTER 卡片确认请求。"""
+
+ card_type: Literal["character"] = "character"
+ user_input: CharacterConfirmInput = Field(description="角色描述和候选图数量")
+ spec_overrides: None = None # CHARACTER 无高级项
+
+
+class ActionConfirmRequest(BaseModel):
+ """ACTION 卡片确认请求。"""
+
+ card_type: Literal["action"] = "action"
+ user_input: ActionCreateInput = Field(description="动作输入(可修改描述后确认)")
+ spec_overrides: ActionSpecOverrides | None = Field(default=None, description="高级项覆盖")
+
+
+class ExportConfirmRequest(BaseModel):
+ """EXPORT 卡片确认请求。"""
+
+ card_type: Literal["export"] = "export"
+ user_input: ExportCreateInput = Field(description="导出格式和帧率")
+ spec_overrides: None = None # EXPORT 无高级项
+
+
+# 前端按 card_type 选择对应的 ConfirmRequest 发送
+CardConfirmRequest = CharacterConfirmRequest | ActionConfirmRequest | ExportConfirmRequest
+
+
+class CardRegenerateRequest(BaseModel):
+ """重新生成(创建新的 GenerationAttempt)。
+
+ CHARACTER:旧 CANDIDATE 全部 INACTIVE,重新生成候选。ACTION/EXPORT 不受影响。
+ ACTION / EXPORT:创建新 attempt,重新执行。
+ """
+
+ user_input: CharacterConfirmInput | ActionCreateInput | ExportCreateInput | None = Field(
+ default=None,
+ description="可选:修改输入后重新生成,省略则沿用上次输入",
+ )
+
+
+class CanvasCardOut(BaseModel):
+ """卡片响应。
+
+ user_input / latest_result 的结构按 card_type 不同,参考对应的 Pydantic 模型。
+ """
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ card_type: str = Field(description="character / candidate / action / export")
+ status: str = Field(description="draft / generating / completed / failed / inactive")
+ parent_card_id: int | None = None
+ direction: str | None = Field(default=None, description="front / side / back / left / null")
+ position_x: float = 0.0
+ position_y: float = 0.0
+ user_input: dict = Field(
+ default_factory=dict,
+ description="用户输入,结构按 card_type 见对应的 Input 模型",
+ )
+ latest_result: dict | None = Field(
+ default=None,
+ description="生成结果,结构按 card_type 见对应的 Result 模型",
+ )
+ spec_overrides: dict = Field(
+ default_factory=dict,
+ description="高级项覆盖,ACTION 用 ActionSpecOverrides 结构",
+ )
+ version: int = 1
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 生成尝试
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+class GenerationAttemptOut(BaseModel):
+ """生成尝试响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ card_id: int
+ task_id: int | None = Field(default=None, description="关联的 GenerationTask ID,可用于订阅 Task SSE")
+ attempt_no: int = Field(description="第几次尝试,从 1 开始")
+ status: str = Field(description="pending / running / completed / failed")
+ input_payload: dict = Field(default_factory=dict, description="生成输入快照")
+ result: dict | None = Field(default=None, description="生成结果")
+ error_message: str | None = None
diff --git a/backend/packages/app/src/windup_app/web/api/agent.py b/backend/packages/app/src/windup_app/web/api/agent.py
new file mode 100644
index 0000000..051ca96
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/api/agent.py
@@ -0,0 +1,166 @@
+"""Agent 智能体 API。
+
+懒人智能体:一句话搞定角色资产生成。
+用户通过自然语言与 Agent 对话,Agent 自动调用工具完成项目创建、角色生成、动作生成等操作。
+
+端点一览
+--------
+POST /agent/sessions 创建 Agent 会话
+GET /agent/sessions/{session_id}/stream Agent SSE 事件流
+POST /agent/sessions/{session_id}/messages 发送用户消息
+POST /agent/sessions/{session_id}/choices 发送用户选择(按钮点击)
+GET /agent/sessions/{session_id}/messages 获取会话历史
+
+SSE 事件类型
+-----------
+- message: Agent 回复(富内容:text/image/buttons/confirm/progress)
+- tool_call: Agent 调用工具(含 task_id,可订阅 Task SSE 获取进度)
+- tool_result: 工具返回结果
+- state_change: Agent 状态变更(processing/waiting_input/done)
+- error: 错误信息
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy.orm import Session
+
+from windup_common.result import Response, ListResponse
+from windup_framework.db import get_session
+
+logger = logging.getLogger("windup.agent.api")
+
+router = APIRouter(prefix="/agent", tags=["agent"])
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 请求模型
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+class SessionCreateRequest(BaseModel):
+ """创建 Agent 会话。"""
+
+ user_id: int = Field(description="用户 ID")
+ context: dict | None = Field(
+ default=None,
+ description="初始上下文,如 {project_id: 123}。可选,后续通过对话补充。",
+ )
+
+
+class MessageSendRequest(BaseModel):
+ """发送用户消息。"""
+
+ content: str = Field(min_length=1, max_length=2000, description="用户消息内容")
+ message_id: str | None = Field(
+ default=None,
+ description="客户端消息 ID(用于去重),省略则由后端生成",
+ )
+
+
+class ChoiceSendRequest(BaseModel):
+ """发送用户选择(按钮点击)。"""
+
+ message_id: str = Field(description="对应的 Agent 消息 ID(buttons 块所在的 message)")
+ value: str = Field(description="选择的值(ButtonOption.value)")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 响应模型
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+class SessionOut(BaseModel):
+ """会话响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ session_id: str = Field(description="会话 ID,用于后续 SSE 订阅和消息发送")
+ created_at: str = Field(description="创建时间(ISO 8601)")
+
+
+class MessageOut(BaseModel):
+ """消息响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ message_id: str = Field(description="消息 ID")
+ role: str = Field(description="角色:user / assistant")
+ blocks: list[dict] = Field(
+ default_factory=list,
+ description="内容块列表,结构见 ContentBlock 定义",
+ )
+ state: str | None = Field(
+ default=None,
+ description="Agent 状态:processing / waiting_input / done(仅 assistant 消息)",
+ )
+ timestamp: float = Field(description="时间戳(秒)")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 端点
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+@router.post("/sessions", response_model=Response[SessionOut])
+def create_session(
+ body: SessionCreateRequest,
+ session: Session = Depends(get_session),
+) -> Response[SessionOut]:
+ """创建 Agent 会话。
+
+ 返回 session_id,前端用于:
+ 1. 订阅 Agent SSE:GET /agent/sessions/{session_id}/stream
+ 2. 发送消息:POST /agent/sessions/{session_id}/messages
+ 3. 发送选择:POST /agent/sessions/{session_id}/choices
+ """
+ # TODO: agent_service.create_session
+ raise NotImplementedError
+
+
+@router.post("/sessions/{session_id}/messages", response_model=Response[MessageOut])
+def send_message(
+ session_id: str,
+ body: MessageSendRequest,
+ session: Session = Depends(get_session),
+) -> Response[MessageOut]:
+ """发送用户消息。
+
+ Agent 收到消息后通过 SSE 推送处理结果(message/tool_call/tool_result 等事件)。
+ """
+ # TODO: agent_service.send_message
+ raise NotImplementedError
+
+
+@router.post("/sessions/{session_id}/choices", response_model=Response[None])
+def send_choice(
+ session_id: str,
+ body: ChoiceSendRequest,
+ session: Session = Depends(get_session),
+) -> Response[None]:
+ """发送用户选择(按钮点击)。
+
+ 对应 Agent 消息中的 buttons 块。Agent 收到后继续处理。
+ """
+ # TODO: agent_service.send_choice
+ raise NotImplementedError
+
+
+@router.get("/sessions/{session_id}/messages", response_model=ListResponse[MessageOut])
+def get_messages(
+ session_id: str,
+ limit: int = 50,
+ before: str | None = None,
+ session: Session = Depends(get_session),
+) -> ListResponse[MessageOut]:
+ """获取会话历史消息。
+
+ 参数:
+ - limit: 返回条数,默认50
+ - before: 分页,此 message_id 之前的消息
+ """
+ # TODO: agent_service.get_messages
+ raise NotImplementedError
diff --git a/backend/packages/app/src/windup_app/web/api/workflow.py b/backend/packages/app/src/windup_app/web/api/workflow.py
new file mode 100644
index 0000000..39929ab
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/api/workflow.py
@@ -0,0 +1,178 @@
+"""工作流画布 API。
+
+契约层:定义端点和请求/响应模型,与 server 层解耦。
+实际逻辑由 server 层实现,本文件只做参数校验和格式转换。
+
+端点一览
+--------
+POST /workflow 创建工作流
+GET /workflow/{id} 获取工作流详情
+DELETE /workflow/{id} 删除工作流
+POST /workflow/{wf_id}/cards 创建子卡片
+PATCH /workflow/{wf_id}/cards/{card_id} 更新卡片
+POST /workflow/{wf_id}/cards/{card_id}/confirm 确认卡片(触发生成)
+POST /workflow/{wf_id}/cards/{card_id}/regenerate 重新生成
+DELETE /workflow/{wf_id}/cards/{card_id} 删除卡片
+GET /workflow/{wf_id}/cards/{card_id}/attempts 获取生成尝试历史
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from windup_common.result import Response, ListResponse
+from windup_framework.db import get_session
+
+from windup_app.server.workflow.schema import (
+ CanvasCardOut,
+ CardConfirmRequest,
+ CardCreateRequest,
+ CardRegenerateRequest,
+ CardUpdateRequest,
+ GenerationAttemptOut,
+ WorkflowCreateRequest,
+ WorkflowOut,
+)
+
+logger = logging.getLogger("windup.workflow.api")
+
+router = APIRouter(prefix="/workflow", tags=["workflow"])
+
+
+# ── 工作流 CRUD ─────────────────────────────────────────────────────────────
+
+
+@router.post("", response_model=Response[WorkflowOut])
+def create_workflow(
+ body: WorkflowCreateRequest,
+ session: Session = Depends(get_session),
+) -> Response[WorkflowOut]:
+ """创建工作流。
+
+ 自动创建一张 CHARACTER 根卡片作为画布起点。
+ 返回的工作流已包含该卡片。
+ """
+ # TODO: service.create_workflow
+ raise NotImplementedError
+
+
+@router.get("/{workflow_id}", response_model=Response[WorkflowOut])
+def get_workflow(
+ workflow_id: int,
+ session: Session = Depends(get_session),
+) -> Response[WorkflowOut]:
+ """获取工作流详情(含全部 active 卡片)。
+
+ 前端进入画布时调用,获取完整的卡片树。
+ """
+ # TODO: service.get_workflow
+ raise NotImplementedError
+
+
+@router.delete("/{workflow_id}", response_model=Response[None])
+def delete_workflow(
+ workflow_id: int,
+ session: Session = Depends(get_session),
+) -> Response[None]:
+ """删除工作流(级联软删除所有卡片)。"""
+ # TODO: service.delete_workflow
+ raise NotImplementedError
+
+
+# ── 卡片操作 ────────────────────────────────────────────────────────────────
+
+
+@router.post("/{workflow_id}/cards", response_model=Response[CanvasCardOut])
+def create_card(
+ workflow_id: int,
+ body: CardCreateRequest,
+ session: Session = Depends(get_session),
+) -> Response[CanvasCardOut]:
+ """创建子卡片(ACTION / EXPORT)。
+
+ 由前端"+"菜单触发。ACTION 卡片创建时自动复制母版图。
+ """
+ # TODO: card_service.create_card
+ raise NotImplementedError
+
+
+@router.patch("/{workflow_id}/cards/{card_id}", response_model=Response[CanvasCardOut])
+def update_card(
+ workflow_id: int,
+ card_id: int,
+ body: CardUpdateRequest,
+ session: Session = Depends(get_session),
+) -> Response[CanvasCardOut]:
+ """更新卡片用户输入或位置(不触发生成)。
+
+ 用于保存草稿、拖动画布位置等场景。
+ """
+ # TODO: card_service.update_card
+ raise NotImplementedError
+
+
+@router.post("/{workflow_id}/cards/{card_id}/confirm", response_model=Response[GenerationAttemptOut])
+def confirm_card(
+ workflow_id: int,
+ card_id: int,
+ body: CardConfirmRequest,
+ session: Session = Depends(get_session),
+) -> Response[GenerationAttemptOut]:
+ """确认卡片,触发生成。
+
+ - CHARACTER:生成候选图 → 自动创建 CANDIDATE 卡片。
+ - ACTION:生成动画帧。
+ - EXPORT:打包导出。
+
+ 返回的 GenerationAttempt 包含 task_id,前端应订阅 Task SSE 获取进度:
+ ``GET /generation/tasks/{task_id}/stream``
+ """
+ # TODO: card_service.confirm_card
+ raise NotImplementedError
+
+
+@router.post("/{workflow_id}/cards/{card_id}/regenerate", response_model=Response[GenerationAttemptOut])
+def regenerate_card(
+ workflow_id: int,
+ card_id: int,
+ body: CardRegenerateRequest,
+ session: Session = Depends(get_session),
+) -> Response[GenerationAttemptOut]:
+ """重新生成(创建新的 GenerationAttempt)。
+
+ - CHARACTER:旧 CANDIDATE 全部 INACTIVE,重新生成候选。ACTION/EXPORT 不受影响。
+ - ACTION / EXPORT:创建新 attempt,重新执行。
+ """
+ # TODO: card_service.regenerate_card
+ raise NotImplementedError
+
+
+@router.delete("/{workflow_id}/cards/{card_id}", response_model=Response[None])
+def delete_card(
+ workflow_id: int,
+ card_id: int,
+ session: Session = Depends(get_session),
+) -> Response[None]:
+ """删除卡片(级联软删除所有子卡片)。
+
+ 删除 CHARACTER 会级联删除其下的 CANDIDATE、ACTION、EXPORT。
+ """
+ # TODO: card_service.delete_card
+ raise NotImplementedError
+
+
+@router.get("/{workflow_id}/cards/{card_id}/attempts", response_model=ListResponse[GenerationAttemptOut])
+def get_attempts(
+ workflow_id: int,
+ card_id: int,
+ session: Session = Depends(get_session),
+) -> ListResponse[GenerationAttemptOut]:
+ """获取某张卡片的全部生成尝试记录。
+
+ 用于展示历史生成结果、调试等。
+ """
+ # TODO: card_service.get_attempts
+ raise NotImplementedError
From 510ae669458b15420fd0fa3e4c4b9c9fb20cc5b0 Mon Sep 17 00:00:00 2001
From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com>
Date: Tue, 4 Aug 2026 12:33:07 +0800
Subject: [PATCH 4/7] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E6=9E=B6?=
=?UTF-8?q?=E6=9E=84=E5=9B=BE=20JSON=20=E6=BA=90=E6=96=87=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/docs/diagrams/product-domains.json | 90 +++++++++++-----------
1 file changed, 45 insertions(+), 45 deletions(-)
diff --git a/backend/docs/diagrams/product-domains.json b/backend/docs/diagrams/product-domains.json
index 4f246ef..01ee6ae 100644
--- a/backend/docs/diagrams/product-domains.json
+++ b/backend/docs/diagrams/product-domains.json
@@ -4,7 +4,7 @@
"template_type": "architecture",
"style": 1,
"quality_profile": "standard",
- "width": 1200,
+ "width": 1140,
"height": 760,
"title": "Windup Product Domains",
"subtitle": "最终产品业务能力与核心依赖",
@@ -23,18 +23,18 @@
"id": "workflow-domains",
"x": 40,
"y": 290,
- "width": 700,
- "height": 150,
+ "width": 680,
+ "height": 160,
"label": "Workflow Domains",
"stroke": "#c9b8d9",
"fill": "#faf5fc"
},
{
"id": "ai-engine-domain",
- "x": 780,
+ "x": 900,
"y": 290,
- "width": 380,
- "height": 150,
+ "width": 200,
+ "height": 160,
"label": "AI Engine Domain",
"stroke": "#89c4a8",
"fill": "#f0f9f4"
@@ -112,48 +112,48 @@
"stroke": "#6685b2"
},
{
- "id": "workflow",
- "kind": "double_rect",
- "x": 80,
- "y": 330,
- "width": 180,
- "height": 70,
- "label": "workflow",
- "sublabel": "定义 / 节点 / 模板",
+ "id": "agent",
+ "kind": "rect",
+ "x": 70,
+ "y": 335,
+ "width": 160,
+ "height": 65,
+ "label": "agent",
+ "sublabel": "懒人智能体",
"fill": "#f2e9f8",
"stroke": "#89659e"
},
{
- "id": "execution",
- "kind": "rect",
- "x": 320,
- "y": 330,
- "width": 180,
- "height": 70,
- "label": "execution",
- "sublabel": "调度 / 重试 / 缓存",
+ "id": "workflow",
+ "kind": "double_rect",
+ "x": 270,
+ "y": 335,
+ "width": 160,
+ "height": 65,
+ "label": "workflow",
+ "sublabel": "工作流画布",
"fill": "#f2e9f8",
"stroke": "#89659e"
},
{
- "id": "generation",
+ "id": "orchestrator",
"kind": "rect",
- "x": 560,
- "y": 330,
+ "x": 510,
+ "y": 335,
"width": 180,
- "height": 70,
- "label": "generation",
- "sublabel": "生成业务编排",
+ "height": 65,
+ "label": "orchestrator",
+ "sublabel": "生成任务调度",
"fill": "#fff2d9",
"stroke": "#c28a36"
},
{
"id": "ai-engine",
"kind": "rect",
- "x": 880,
- "y": 330,
- "width": 180,
- "height": 70,
+ "x": 930,
+ "y": 335,
+ "width": 140,
+ "height": 65,
"label": "ai_engine",
"sublabel": "模型能力适配",
"fill": "#e4f2ea",
@@ -198,33 +198,33 @@
],
"arrows": [
{
- "id": "workflow-execution",
- "source": "workflow",
- "target": "execution",
+ "id": "agent-workflow",
+ "source": "agent",
+ "target": "workflow",
"source_port": "right",
"target_port": "left",
"flow": "control",
- "label": "定义",
+ "label": "",
"label_style": "offset"
},
{
- "id": "execution-generation",
- "source": "execution",
- "target": "generation",
+ "id": "workflow-orchestrator",
+ "source": "workflow",
+ "target": "orchestrator",
"source_port": "right",
"target_port": "left",
"flow": "control",
- "label": "节点调用",
+ "label": "",
"label_style": "offset"
},
{
- "id": "generation-ai",
- "source": "generation",
+ "id": "orchestrator-ai",
+ "source": "orchestrator",
"target": "ai-engine",
"source_port": "right",
"target_port": "left",
"flow": "control",
- "label": "AI 能力",
+ "label": "",
"label_style": "offset"
},
{
@@ -234,7 +234,7 @@
"source_port": "right",
"target_port": "left",
"flow": "read",
- "label": "正式资产",
+ "label": "",
"label_style": "offset"
},
{
@@ -244,7 +244,7 @@
"source_port": "right",
"target_port": "left",
"flow": "read",
- "label": "导出数据",
+ "label": "",
"label_style": "offset"
}
],
From 8045e2c4222d733e271eeba9c6466852d54de000 Mon Sep 17 00:00:00 2001
From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com>
Date: Tue, 4 Aug 2026 12:34:50 +0800
Subject: [PATCH 5/7] =?UTF-8?q?feat(sse):=20=E6=96=B0=E5=A2=9E=20SSE=20?=
=?UTF-8?q?=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=E6=A8=A1=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- event_bus.py: 双模式 EventBus(Task/Session),内存发布订阅
- stream.py: Task SSE 端点,推送生成任务进度
- session.py: Agent SSE 端点,推送对话事件
- progress.py: 进度跟踪工具
---
.../app/src/windup_app/web/sse/__init__.py | 10 ++
.../app/src/windup_app/web/sse/event_bus.py | 83 +++++++++++++
.../app/src/windup_app/web/sse/progress.py | 37 ++++++
.../app/src/windup_app/web/sse/session.py | 78 ++++++++++++
.../app/src/windup_app/web/sse/stream.py | 112 ++++++++++++++++++
5 files changed, 320 insertions(+)
create mode 100644 backend/packages/app/src/windup_app/web/sse/__init__.py
create mode 100644 backend/packages/app/src/windup_app/web/sse/event_bus.py
create mode 100644 backend/packages/app/src/windup_app/web/sse/progress.py
create mode 100644 backend/packages/app/src/windup_app/web/sse/session.py
create mode 100644 backend/packages/app/src/windup_app/web/sse/stream.py
diff --git a/backend/packages/app/src/windup_app/web/sse/__init__.py b/backend/packages/app/src/windup_app/web/sse/__init__.py
new file mode 100644
index 0000000..1984392
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/sse/__init__.py
@@ -0,0 +1,10 @@
+"""SSE 模块:Server-Sent Events 实时推送(双模式)。
+
+- ``sse_router``: Task mode —— 生成任务进度推送
+- ``session_router``: Session mode —— Agent 多轮对话事件流
+"""
+
+from windup_app.web.sse.session import router as session_router
+from windup_app.web.sse.stream import router as sse_router
+
+__all__ = ["sse_router", "session_router"]
diff --git a/backend/packages/app/src/windup_app/web/sse/event_bus.py b/backend/packages/app/src/windup_app/web/sse/event_bus.py
new file mode 100644
index 0000000..21006f2
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/sse/event_bus.py
@@ -0,0 +1,83 @@
+"""SSE 内存发布-订阅中心(双模式)。
+
+支持两种订阅模式:
+
+- **Task mode**: ``task_id → events → 终态关闭``(用于生成任务进度推送)
+- **Session mode**: ``session_id → events → 持续连接``(用于 Agent 多轮对话)
+
+线程安全:``publish`` 可从任意线程调用,``subscribe``/``unsubscribe`` 须在
+event loop 中调用(AsyncAPI 路由天然满足)。
+"""
+
+from __future__ import annotations
+
+import asyncio
+from collections import defaultdict
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class SSEEvent:
+ """统一 SSE 事件。"""
+
+ id: str # 任务 ID(str) 或会话 ID
+ id_type: str # "task" | "session"
+ event: str # 事件类型
+ data: dict # 事件数据
+
+
+class EventBus:
+ """内存发布-订阅:后台线程 publish,异步 SSE generator subscribe。"""
+
+ def __init__(self) -> None:
+ # Task mode: task_id(str) → subscriber queues
+ self._task_queues: dict[str, list[asyncio.Queue[SSEEvent]]] = defaultdict(list)
+ # Session mode: session_id → subscriber queues
+ self._session_queues: dict[str, list[asyncio.Queue[SSEEvent]]] = defaultdict(list)
+
+ # ── Task mode ─────────────────────────────────────────────────────────
+
+ async def subscribe_task(self, task_id: int) -> asyncio.Queue[SSEEvent]:
+ """订阅任务事件流。"""
+ key = str(task_id)
+ queue: asyncio.Queue[SSEEvent] = asyncio.Queue()
+ self._task_queues[key].append(queue)
+ return queue
+
+ async def unsubscribe_task(self, task_id: int, queue: asyncio.Queue[SSEEvent]) -> None:
+ """取消任务订阅。"""
+ key = str(task_id)
+ subscribers = self._task_queues.get(key)
+ if subscribers and queue in subscribers:
+ subscribers.remove(queue)
+ if not subscribers:
+ del self._task_queues[key]
+
+ def publish_task(self, task_id: int, event: str, data: dict) -> None:
+ """发布任务事件。线程安全(put_nowait 无阻塞)。"""
+ key = str(task_id)
+ sse_event = SSEEvent(id=key, id_type="task", event=event, data=data)
+ for queue in self._task_queues.get(key, []):
+ queue.put_nowait(sse_event)
+
+ # ── Session mode ──────────────────────────────────────────────────────
+
+ async def subscribe_session(self, session_id: str) -> asyncio.Queue[SSEEvent]:
+ """订阅会话事件流(Agent 多轮对话)。"""
+ queue: asyncio.Queue[SSEEvent] = asyncio.Queue()
+ self._session_queues[session_id].append(queue)
+ return queue
+
+ async def unsubscribe_session(self, session_id: str, queue: asyncio.Queue[SSEEvent]) -> None:
+ """取消会话订阅。"""
+ subscribers = self._session_queues.get(session_id)
+ if subscribers and queue in subscribers:
+ subscribers.remove(queue)
+ if not subscribers:
+ del self._session_queues[session_id]
+
+ def publish_session(self, session_id: str, event: str, data: dict) -> None:
+ """发布会话事件。线程安全(put_nowait 无阻塞)。"""
+ sse_event = SSEEvent(id=session_id, id_type="session", event=event, data=data)
+ for queue in self._session_queues.get(session_id, []):
+ queue.put_nowait(sse_event)
diff --git a/backend/packages/app/src/windup_app/web/sse/progress.py b/backend/packages/app/src/windup_app/web/sse/progress.py
new file mode 100644
index 0000000..1bcb742
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/sse/progress.py
@@ -0,0 +1,37 @@
+"""SSE-aware ProgressPort 实现。
+
+实现 ``windup_ai_engine.ports.ProgressPort`` 协议,将生成管线的每一步
+进度通过 ``EventBus`` 广播给 SSE 订阅者。
+
+后台线程调用 ``step`` 时,通过 ``loop.call_soon_threadsafe`` 安全地
+将事件发布到 event loop。
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+from windup_app.web.sse.event_bus import EventBus
+
+
+class SSEProgressPort:
+ """实现 ProgressPort:每步都 publish 到 EventBus。"""
+
+ def __init__(
+ self,
+ event_bus: EventBus,
+ task_id: int,
+ loop: asyncio.AbstractEventLoop,
+ ) -> None:
+ self._bus = event_bus
+ self._task_id = task_id
+ self._loop = loop
+
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
+ """进度回调:从后台线程安全地发布到 event loop。"""
+ self._loop.call_soon_threadsafe(
+ self._bus.publish_task,
+ self._task_id,
+ "progress",
+ {"stage": stage, "current": i, "total": total, "note": note},
+ )
diff --git a/backend/packages/app/src/windup_app/web/sse/session.py b/backend/packages/app/src/windup_app/web/sse/session.py
new file mode 100644
index 0000000..de7856b
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/sse/session.py
@@ -0,0 +1,78 @@
+"""SSE 流端点(Session mode)—— Agent 多轮对话。
+
+提供 ``GET /agent/sessions/{session_id}/stream`` 端点,
+客户端通过 EventSource 订阅 Agent 事件流。
+
+Session mode 特点:
+- 持续连接,不自动关闭(除非客户端断开)
+- 支持多种事件类型:thinking / message / tool_call / tool_result / status / error
+- 双向通信:客户端通过 HTTP POST 发送用户消息
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+
+from fastapi import APIRouter, Request
+from fastapi.responses import StreamingResponse
+
+from windup_app.web.sse.event_bus import EventBus, SSEEvent
+
+logger = logging.getLogger("windup.sse.session")
+
+router = APIRouter(prefix="/agent", tags=["sse"])
+
+# SSE 心跳间隔(秒)
+_HEARTBEAT_TIMEOUT = 30.0
+
+
+@router.get("/sessions/{session_id}/stream")
+async def stream_session(
+ session_id: str,
+ request: Request,
+) -> StreamingResponse:
+ """SSE:Agent 多轮对话事件流。
+
+ 事件类型:
+ - ``thinking``: Agent 思考过程
+ - ``message``: Agent 回复文本
+ - ``tool_call``: Agent 调用工具
+ - ``tool_result``: 工具返回结果
+ - ``status``: 会话状态(waiting_input / processing / done)
+ - ``error``: 错误信息
+
+ Session mode 不会自动关闭连接,持续监听直到客户端断开。
+ """
+ event_bus: EventBus = request.app.state.event_bus
+ queue = await event_bus.subscribe_session(session_id)
+ logger.debug("SSE Session 订阅: session_id=%s", session_id)
+
+ async def _event_generator():
+ try:
+ while True:
+ if await request.is_disconnected():
+ logger.debug("SSE Session 客户端断开: session_id=%s", session_id)
+ break
+ try:
+ event: SSEEvent = await asyncio.wait_for(
+ queue.get(), timeout=_HEARTBEAT_TIMEOUT,
+ )
+ payload = json.dumps(event.data, ensure_ascii=False)
+ yield f"event: {event.event}\ndata: {payload}\n\n"
+ except asyncio.TimeoutError:
+ yield ": heartbeat\n\n"
+ finally:
+ await event_bus.unsubscribe_session(session_id, queue)
+ logger.debug("SSE Session 取消订阅: session_id=%s", session_id)
+
+ return StreamingResponse(
+ _event_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
diff --git a/backend/packages/app/src/windup_app/web/sse/stream.py b/backend/packages/app/src/windup_app/web/sse/stream.py
new file mode 100644
index 0000000..931a86f
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/sse/stream.py
@@ -0,0 +1,112 @@
+"""SSE 流端点(Task mode)。
+
+提供 ``GET /generation/tasks/{task_id}/stream`` 端点,
+客户端通过 EventSource 订阅任务进度推送。
+"""
+
+from __future__ import annotations
+
+import asyncio
+import dataclasses
+import json
+import logging
+
+from fastapi import APIRouter, Depends, Query, Request
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+
+from windup_app.server.orchestrator.model import TaskStatus
+from windup_app.server.orchestrator import task_repo
+from windup_app.web.sse.event_bus import EventBus, SSEEvent
+from windup_framework.db import get_session
+
+logger = logging.getLogger("windup.sse.stream")
+
+router = APIRouter(prefix="/generation", tags=["sse"])
+
+# SSE 心跳间隔(秒):超过此时间无事件则发送注释保活
+_HEARTBEAT_TIMEOUT = 30.0
+
+
+def _task_to_sse_data(task) -> dict | None:
+ """任务领域对象 → SSE 事件 data(仅终态)。"""
+ if task.status == TaskStatus.COMPLETED:
+ result_dict = None
+ if task.result is not None:
+ result_dict = dataclasses.asdict(task.result)
+ return result_dict or {}
+ if task.status == TaskStatus.FAILED:
+ return {"error": task.error_message or "未知错误"}
+ return None
+
+
+@router.get("/tasks/{task_id}/stream")
+async def stream_task(
+ task_id: int,
+ request: Request,
+ project_id: int = Query(..., gt=0),
+ session: Session = Depends(get_session),
+) -> StreamingResponse:
+ """SSE:实时推送任务进度与最终结果。
+
+ 事件类型:
+ - ``status``: 任务状态变更(pending/running/completed/failed)
+ - ``progress``: 生成管线进度(stage/current/total)
+ - ``completed``: 任务完成,携带最终结果
+ - ``failed``: 任务失败,携带错误信息
+
+ 若客户端订阅时任务已处于终态,立即推送终态事件并关闭连接。
+ """
+ from windup_app.server.orchestrator.service import service as generation_service
+
+ event_bus: EventBus = request.app.state.event_bus
+
+ # 检查任务初始状态:若已终态,立即推送并关闭
+ task = generation_service.get_task(session, project_id, task_id)
+ if task is not None and task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED):
+ event = "completed" if task.status == TaskStatus.COMPLETED else "failed"
+ data = _task_to_sse_data(task) or {}
+
+ async def _immediate():
+ yield f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+
+ return StreamingResponse(
+ _immediate(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
+ )
+
+ # 订阅事件流
+ queue = await event_bus.subscribe_task(task_id)
+ logger.debug("SSE 订阅: task_id=%d", task_id)
+
+ async def _event_generator():
+ try:
+ while True:
+ if await request.is_disconnected():
+ logger.debug("SSE 客户端断开: task_id=%d", task_id)
+ break
+ try:
+ event: SSEEvent = await asyncio.wait_for(
+ queue.get(), timeout=_HEARTBEAT_TIMEOUT,
+ )
+ payload = json.dumps(event.data, ensure_ascii=False)
+ yield f"event: {event.event}\ndata: {payload}\n\n"
+ if event.event in ("completed", "failed"):
+ logger.debug("SSE 终态: task_id=%d event=%s", task_id, event.event)
+ break
+ except asyncio.TimeoutError:
+ yield ": heartbeat\n\n"
+ finally:
+ await event_bus.unsubscribe_task(task_id, queue)
+ logger.debug("SSE 取消订阅: task_id=%d", task_id)
+
+ return StreamingResponse(
+ _event_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no", # nginx 透传
+ },
+ )
From 24e1602e4d4c377c3d1f6c4878eee0a7b0171c91 Mon Sep 17 00:00:00 2001
From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com>
Date: Tue, 4 Aug 2026 12:37:20 +0800
Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20ruff=20lint=20?=
=?UTF-8?q?=E9=94=99=E8=AF=AF=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=9C=AA=E4=BD=BF?=
=?UTF-8?q?=E7=94=A8=E7=9A=84=E5=AF=BC=E5=85=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/packages/app/src/windup_app/server/workflow/schema.py | 4 ----
backend/packages/app/src/windup_app/web/sse/stream.py | 1 -
2 files changed, 5 deletions(-)
diff --git a/backend/packages/app/src/windup_app/server/workflow/schema.py b/backend/packages/app/src/windup_app/server/workflow/schema.py
index dc02eb9..1cbffe1 100644
--- a/backend/packages/app/src/windup_app/server/workflow/schema.py
+++ b/backend/packages/app/src/windup_app/server/workflow/schema.py
@@ -14,11 +14,7 @@
from pydantic import BaseModel, ConfigDict, Field
from windup_app.server.workflow.model import (
- AttemptStatus,
- CardStatus,
- CardType,
Direction,
- WorkflowStatus,
)
diff --git a/backend/packages/app/src/windup_app/web/sse/stream.py b/backend/packages/app/src/windup_app/web/sse/stream.py
index 931a86f..f3d9874 100644
--- a/backend/packages/app/src/windup_app/web/sse/stream.py
+++ b/backend/packages/app/src/windup_app/web/sse/stream.py
@@ -16,7 +16,6 @@
from sqlalchemy.orm import Session
from windup_app.server.orchestrator.model import TaskStatus
-from windup_app.server.orchestrator import task_repo
from windup_app.web.sse.event_bus import EventBus, SSEEvent
from windup_framework.db import get_session
From 838501620f0e31792ed5e33bb2978ea76152a2c6 Mon Sep 17 00:00:00 2001
From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com>
Date: Wed, 5 Aug 2026 11:44:47 +0800
Subject: [PATCH 7/7] =?UTF-8?q?refactor(backend):=20SSE=20=E9=80=9A?=
=?UTF-8?q?=E7=94=A8=E5=8C=96=20+=20workflow=5Frun=20=E6=9B=BF=E6=8D=A2?=
=?UTF-8?q?=E6=97=A7=20workflow=20+=20Agent=20=E7=AE=80=E5=8C=96=E4=B8=BA?=
=?UTF-8?q?=20LLM=20=E4=BB=A3=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- SSE: event_bus 改为通用 channel pub/sub,stream.py 去掉业务域引用,删除 session.py
- workflow_run: 新增树形执行记录模型(WorkflowRun + WorkflowRunNode),替代旧卡片体系
- agent: 删除 server/agent 模块,web/api/agent.py 简化为 POST /ai/chat LLM 代理
- bootstrap: 挂载 SSE 路由,创建 EventBus 实例,更新导入
---
.../app/src/windup_app/bootstrap/app.py | 16 +-
.../src/windup_app/server/agent/__init__.py | 1 -
.../src/windup_app/server/agent/interface.py | 91 ------
.../app/src/windup_app/server/agent/model.py | 121 -------
.../app/src/windup_app/server/agent/schema.py | 173 ----------
.../windup_app/server/workflow/__init__.py | 1 -
.../windup_app/server/workflow/interface.py | 134 --------
.../src/windup_app/server/workflow/model.py | 131 --------
.../src/windup_app/server/workflow/schema.py | 306 ------------------
.../server/workflow_run/__init__.py | 1 +
.../server/workflow_run/interface.py | 123 +++++++
.../windup_app/server/workflow_run/model.py | 79 +++++
.../windup_app/server/workflow_run/schema.py | 151 +++++++++
.../app/src/windup_app/web/api/agent.py | 207 +++++-------
.../app/src/windup_app/web/api/workflow.py | 178 ----------
.../src/windup_app/web/api/workflow_run.py | 144 +++++++++
.../app/src/windup_app/web/sse/__init__.py | 12 +-
.../app/src/windup_app/web/sse/event_bus.py | 69 ++--
.../app/src/windup_app/web/sse/progress.py | 10 +-
.../app/src/windup_app/web/sse/session.py | 78 -----
.../app/src/windup_app/web/sse/stream.py | 85 ++---
21 files changed, 642 insertions(+), 1469 deletions(-)
delete mode 100644 backend/packages/app/src/windup_app/server/agent/__init__.py
delete mode 100644 backend/packages/app/src/windup_app/server/agent/interface.py
delete mode 100644 backend/packages/app/src/windup_app/server/agent/model.py
delete mode 100644 backend/packages/app/src/windup_app/server/agent/schema.py
delete mode 100644 backend/packages/app/src/windup_app/server/workflow/__init__.py
delete mode 100644 backend/packages/app/src/windup_app/server/workflow/interface.py
delete mode 100644 backend/packages/app/src/windup_app/server/workflow/model.py
delete mode 100644 backend/packages/app/src/windup_app/server/workflow/schema.py
create mode 100644 backend/packages/app/src/windup_app/server/workflow_run/__init__.py
create mode 100644 backend/packages/app/src/windup_app/server/workflow_run/interface.py
create mode 100644 backend/packages/app/src/windup_app/server/workflow_run/model.py
create mode 100644 backend/packages/app/src/windup_app/server/workflow_run/schema.py
delete mode 100644 backend/packages/app/src/windup_app/web/api/workflow.py
create mode 100644 backend/packages/app/src/windup_app/web/api/workflow_run.py
delete mode 100644 backend/packages/app/src/windup_app/web/sse/session.py
diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py
index 8a8a33d..e5c8c6a 100644
--- a/backend/packages/app/src/windup_app/bootstrap/app.py
+++ b/backend/packages/app/src/windup_app/bootstrap/app.py
@@ -6,14 +6,22 @@
from fastapi import FastAPI
-from windup_app.web.api.agent import router as agent_router
+from windup_app.web.api.agent import router as ai_router
from windup_app.web.api.media import router as media_router
-from windup_app.web.api.workflow import router as workflow_router
+from windup_app.web.api.workflow_run import router as workflow_run_router
+from windup_app.web.sse import EventBus, sse_router
def create_app() -> FastAPI:
app = FastAPI(title="windup", version="0.1.0")
+
+ # SSE 基础设施
+ app.state.event_bus = EventBus()
+ app.include_router(sse_router)
+
+ # 业务路由
app.include_router(media_router)
- app.include_router(workflow_router)
- app.include_router(agent_router)
+ app.include_router(workflow_run_router)
+ app.include_router(ai_router)
+
return app
diff --git a/backend/packages/app/src/windup_app/server/agent/__init__.py b/backend/packages/app/src/windup_app/server/agent/__init__.py
deleted file mode 100644
index dc087b7..0000000
--- a/backend/packages/app/src/windup_app/server/agent/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Agent 智能体领域。"""
diff --git a/backend/packages/app/src/windup_app/server/agent/interface.py b/backend/packages/app/src/windup_app/server/agent/interface.py
deleted file mode 100644
index 7f829fa..0000000
--- a/backend/packages/app/src/windup_app/server/agent/interface.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""Agent 智能体领域服务接口。
-
-懒人智能体:一句话搞定角色资产生成。
-用户通过自然语言与 Agent 对话,Agent 自动调用工具完成项目创建、角色生成、动作生成等操作。
-
-调用流程
---------
-1. 前端调用 ``POST /agent/sessions`` 创建会话,拿到 ``session_id``。
-2. 前端订阅 ``GET /agent/sessions/{session_id}/stream`` 获取 Agent 事件流。
-3. 前端调用 ``POST /agent/sessions/{session_id}/messages`` 发送用户消息。
-4. Agent 通过 SSE 推送处理结果(message/tool_call/tool_result 等事件)。
-5. 若 tool_call 含 task_id,前端订阅 Task SSE 获取生成进度。
-
-工具定义
---------
-Agent 可调用的工具(通过 SSE tool_call 事件推送):
-
-- ``create_project``: 创建项目(返回 project_id)
-- ``create_character``: 创建角色(返回 character_id)
-- ``generate_character_image``: 生成角色图片(返回 task_id,可订阅 Task SSE)
-- ``generate_character_action``: 生成角色动作(返回 task_id,可订阅 Task SSE)
-"""
-
-from __future__ import annotations
-
-from abc import ABC, abstractmethod
-
-from windup_app.server.agent.model import (
- AgentMessage,
- AgentSession,
- ToolCall,
-)
-
-
-class AgentService(ABC):
- """Agent 用例的抽象边界。"""
-
- # -- 会话管理 ----------------------------------------------------------
-
- @abstractmethod
- def create_session(self, *, user_id: int, context: dict | None = None) -> AgentSession:
- """创建 Agent 会话。
-
- 返回的 session_id 用于 SSE 订阅和消息发送。
- """
-
- @abstractmethod
- def get_session(self, session_id: str) -> AgentSession | None:
- """获取会话信息。"""
-
- @abstractmethod
- def close_session(self, session_id: str) -> None:
- """关闭会话。"""
-
- # -- 消息交互 ----------------------------------------------------------
-
- @abstractmethod
- def send_message(self, session_id: str, *, content: str, message_id: str | None = None) -> AgentMessage:
- """发送用户消息。
-
- Agent 收到消息后异步处理,通过 SSE 推送结果。
- 返回用户消息记录。
- """
-
- @abstractmethod
- def send_choice(self, session_id: str, *, message_id: str, value: str) -> None:
- """发送用户选择(按钮点击)。
-
- 对应 Agent 消息中的 buttons 块。
- """
-
- @abstractmethod
- def get_messages(
- self,
- session_id: str,
- *,
- limit: int = 50,
- before: str | None = None,
- ) -> list[AgentMessage]:
- """获取会话历史消息。
-
- 参数:
- - limit: 返回条数,默认50
- - before: 分页,此 message_id 之前的消息
- """
-
- # -- 工具调用记录 ------------------------------------------------------
-
- @abstractmethod
- def get_tool_calls(self, session_id: str) -> list[ToolCall]:
- """获取会话的全部工具调用记录。"""
diff --git a/backend/packages/app/src/windup_app/server/agent/model.py b/backend/packages/app/src/windup_app/server/agent/model.py
deleted file mode 100644
index baa182f..0000000
--- a/backend/packages/app/src/windup_app/server/agent/model.py
+++ /dev/null
@@ -1,121 +0,0 @@
-"""Agent 智能体领域模型。
-
-懒人智能体:一句话搞定角色资产生成。
-用户通过自然语言与 Agent 对话,Agent 自动调用工具完成项目创建、角色生成、动作生成等操作。
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from enum import StrEnum
-
-
-# -- 枚举 ----------------------------------------------------------------
-
-
-class SessionStatus(StrEnum):
- """会话状态。"""
-
- ACTIVE = "active"
- CLOSED = "closed"
-
-
-class MessageRole(StrEnum):
- """消息角色。"""
-
- USER = "user"
- ASSISTANT = "assistant"
-
-
-class AgentState(StrEnum):
- """Agent 状态(仅 assistant 消息)。"""
-
- PROCESSING = "processing" # Agent 正在处理
- WAITING_INPUT = "waiting_input" # 等待用户输入
- DONE = "done" # 处理完成
-
-
-class ToolName(StrEnum):
- """已定义的工具名称。"""
-
- CREATE_PROJECT = "create_project"
- CREATE_CHARACTER = "create_character"
- GENERATE_CHARACTER_IMAGE = "generate_character_image"
- GENERATE_CHARACTER_ACTION = "generate_character_action"
-
-
-# -- 会话 ---------------------------------------------------------------
-
-
-@dataclass
-class AgentSession:
- """Agent 会话。"""
-
- id: int | None = None
- session_id: str = ""
- user_id: int = 0
- project_id: int | None = None
- status: SessionStatus = SessionStatus.ACTIVE
- context: dict = field(default_factory=dict)
- create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
- update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
-
-
-# -- 消息 ---------------------------------------------------------------
-
-
-@dataclass
-class ContentBlock:
- """内容块——Agent 消息的基本单元。"""
-
- type: str = "" # text / image / buttons / confirm / progress / divider / markdown / code
- # text / markdown / code
- content: str = ""
- # image
- url: str = ""
- caption: str = ""
- # buttons / confirm
- prompt: str = ""
- options: list[dict] = field(default_factory=list) # [{value, label, icon?}]
- confirm_label: str = "确认"
- reject_label: str = "取消"
- # progress
- stage: str = ""
- current: int = 0
- total: int = 0
- text: str = ""
- # code
- language: str = ""
-
-
-@dataclass
-class AgentMessage:
- """Agent 消息。"""
-
- id: int | None = None
- session_id: str = ""
- message_id: str = ""
- role: MessageRole = MessageRole.USER
- blocks: list[ContentBlock] = field(default_factory=list)
- state: AgentState | None = None
- create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
-
-
-# -- 工具调用 -----------------------------------------------------------
-
-
-@dataclass
-class ToolCall:
- """Agent 工具调用。"""
-
- id: int | None = None
- session_id: str = ""
- message_id: str = ""
- call_id: str = ""
- tool_name: ToolName = ToolName.CREATE_PROJECT
- arguments: dict = field(default_factory=dict)
- result: dict | None = None
- error: str | None = None
- task_id: int | None = None
- create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
diff --git a/backend/packages/app/src/windup_app/server/agent/schema.py b/backend/packages/app/src/windup_app/server/agent/schema.py
deleted file mode 100644
index f8da68c..0000000
--- a/backend/packages/app/src/windup_app/server/agent/schema.py
+++ /dev/null
@@ -1,173 +0,0 @@
-"""Agent 智能体 API Schema。
-
-定义前端请求/响应的 Pydantic 模型,与 server 层解耦。
-前端团队参考此文件了解接口契约。
-"""
-
-from __future__ import annotations
-
-from pydantic import BaseModel, ConfigDict, Field
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# 请求模型
-# ══════════════════════════════════════════════════════════════════════════════
-
-
-class SessionCreateRequest(BaseModel):
- """创建 Agent 会话。"""
-
- user_id: int = Field(description="用户 ID")
- context: dict | None = Field(
- default=None,
- description="初始上下文,如 {project_id: 123}。可选,后续通过对话补充。",
- )
-
-
-class MessageSendRequest(BaseModel):
- """发送用户消息。"""
-
- content: str = Field(min_length=1, max_length=2000, description="用户消息内容")
- message_id: str | None = Field(
- default=None,
- description="客户端消息 ID(用于去重),省略则由后端生成",
- )
-
-
-class ChoiceSendRequest(BaseModel):
- """发送用户选择(按钮点击)。"""
-
- message_id: str = Field(description="对应的 Agent 消息 ID(buttons 块所在的 message)")
- value: str = Field(description="选择的值(ButtonOption.value)")
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# 响应模型
-# ══════════════════════════════════════════════════════════════════════════════
-
-
-class SessionOut(BaseModel):
- """会话响应。"""
-
- model_config = ConfigDict(from_attributes=True)
-
- session_id: str = Field(description="会话 ID,用于后续 SSE 订阅和消息发送")
- created_at: str = Field(description="创建时间(ISO 8601)")
-
-
-class ContentBlockOut(BaseModel):
- """内容块响应。"""
-
- type: str = Field(description="块类型:text/image/buttons/confirm/progress/divider/markdown/code")
- # text / markdown / code
- content: str | None = None
- # image
- url: str | None = None
- caption: str | None = None
- # buttons
- prompt: str | None = None
- options: list[dict] | None = None # [{value, label, icon?}]
- # confirm
- confirm_label: str | None = None
- reject_label: str | None = None
- # progress
- stage: str | None = None
- current: int | None = None
- total: int | None = None
- text: str | None = None
- # code
- language: str | None = None
-
-
-class MessageOut(BaseModel):
- """消息响应。"""
-
- model_config = ConfigDict(from_attributes=True)
-
- message_id: str = Field(description="消息 ID")
- session_id: str = Field(description="会话 ID")
- role: str = Field(description="角色:user / assistant")
- blocks: list[ContentBlockOut] = Field(
- default_factory=list,
- description="内容块列表",
- )
- state: str | None = Field(
- default=None,
- description="Agent 状态:processing / waiting_input / done(仅 assistant 消息)",
- )
- timestamp: float = Field(description="时间戳(秒)")
-
-
-class ToolCallOut(BaseModel):
- """工具调用响应。"""
-
- model_config = ConfigDict(from_attributes=True)
-
- call_id: str = Field(description="调用 ID")
- tool: str = Field(description="工具名称")
- args: dict = Field(default_factory=dict, description="工具参数")
- task_id: int | None = Field(default=None, description="任务 ID(生成任务时有值,可订阅 Task SSE)")
- message: str | None = Field(default=None, description="说明文字")
-
-
-class ToolResultOut(BaseModel):
- """工具结果响应。"""
-
- model_config = ConfigDict(from_attributes=True)
-
- call_id: str = Field(description="关联的 tool_call ID")
- tool: str = Field(description="工具名称")
- result: dict | None = Field(default=None, description="工具返回结果")
- error: str | None = Field(default=None, description="错误信息(失败时)")
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# SSE 事件结构(前端参考)
-# ══════════════════════════════════════════════════════════════════════════════
-#
-# ── Agent SSE 事件类型 ──
-#
-# event: message
-# data: {
-# "message_id": "msg_001",
-# "session_id": "session_abc123",
-# "role": "assistant",
-# "blocks": [
-# {"type": "text", "content": "好的,我需要了解几个细节:"},
-# {"type": "buttons", "prompt": "游戏类型", "options": [
-# {"value": "side_scroller", "label": "横版游戏"},
-# {"value": "top_down", "label": "俯视角"}
-# ]}
-# ],
-# "state": "waiting_input",
-# "timestamp": 1234567890.123
-# }
-#
-# event: tool_call
-# data: {
-# "call_id": "c1",
-# "tool": "create_project",
-# "args": {"name": "甲壳虫", "perspective": 1},
-# "task_id": null,
-# "message": "正在创建项目..."
-# }
-#
-# event: tool_result
-# data: {
-# "call_id": "c1",
-# "tool": "create_project",
-# "result": {"project_id": 123},
-# "error": null
-# }
-#
-# event: state_change
-# data: {
-# "state": "waiting_input",
-# "previous_state": "processing"
-# }
-#
-# event: error
-# data: {
-# "error": "会话已过期",
-# "code": "SESSION_EXPIRED"
-# }
diff --git a/backend/packages/app/src/windup_app/server/workflow/__init__.py b/backend/packages/app/src/windup_app/server/workflow/__init__.py
deleted file mode 100644
index cc21c20..0000000
--- a/backend/packages/app/src/windup_app/server/workflow/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""工作流画布领域。"""
diff --git a/backend/packages/app/src/windup_app/server/workflow/interface.py b/backend/packages/app/src/windup_app/server/workflow/interface.py
deleted file mode 100644
index f343406..0000000
--- a/backend/packages/app/src/windup_app/server/workflow/interface.py
+++ /dev/null
@@ -1,134 +0,0 @@
-"""工作流画布领域服务接口。
-
-API 层只依赖本模块定义的抽象。具体实现在应用装配层继承后通过依赖注入提供。
-
-调用流程
---------
-1. 前端调用 ``POST /workflow`` 创建工作流,拿到 ``workflow_id``。
-2. 前端调用 ``POST /workflow/{id}/cards`` 创建子卡片(ACTION / EXPORT)。
-3. 前端调用 ``POST /cards/{id}/confirm`` 确认卡片,触发生成。
- - 返回的 ``GenerationAttempt`` 包含 ``task_id``,前端可订阅 Task SSE 获取进度。
-4. 前端通过 ``GET /workflow/{id}`` 获取画布最新状态。
-5. 生成完成后,前端从 ``card.latest_result`` 取出结果。
-
-回退流程
---------
-前端调用 ``POST /cards/{id}/regenerate`` 触发重新生成。
-- CHARACTER:旧 CANDIDATE 全部 INACTIVE,重新生成候选。ACTION / EXPORT 不受影响。
-- ACTION / EXPORT:创建新 attempt,重新执行。
-"""
-
-from __future__ import annotations
-
-from abc import ABC, abstractmethod
-
-from windup_app.server.workflow.model import (
- CanvasCard,
- GenerationAttempt,
- Workflow,
-)
-
-
-class WorkflowService(ABC):
- """工作流用例的抽象边界。"""
-
- # -- 工作流 CRUD --------------------------------------------------------
-
- @abstractmethod
- def create_workflow(self, *, user_id: int, project_id: int, name: str) -> Workflow:
- """创建工作流,自动创建 CHARACTER 根卡片。
-
- 返回的工作流已包含一张 DRAFT 状态的 CHARACTER 卡片。
- """
-
- @abstractmethod
- def get_workflow(self, workflow_id: int) -> Workflow | None:
- """获取工作流详情(含全部 active 卡片)。
-
- 返回的 Workflow.cards 已按 parent_card_id 组装为树结构。
- """
-
- @abstractmethod
- def delete_workflow(self, workflow_id: int) -> None:
- """删除工作流(级联软删除所有卡片和尝试记录)。"""
-
-
-class CardService(ABC):
- """卡片用例的抽象边界。"""
-
- # -- 卡片 CRUD ----------------------------------------------------------
-
- @abstractmethod
- def create_card(
- self,
- workflow_id: int,
- *,
- card_type: str,
- parent_card_id: int,
- direction: str | None = None,
- user_input: dict | None = None,
- spec_overrides: dict | None = None,
- ) -> CanvasCard:
- """创建子卡片(ACTION / EXPORT)。
-
- ACTION 卡片创建时自动从选定的 CANDIDATE 复制母版图到 user_input.master_image_url。
- """
-
- @abstractmethod
- def update_card(
- self,
- card_id: int,
- *,
- user_input: dict | None = None,
- position_x: float | None = None,
- position_y: float | None = None,
- ) -> CanvasCard:
- """更新卡片用户输入或位置(不触发生成)。"""
-
- @abstractmethod
- def confirm_card(
- self,
- card_id: int,
- *,
- user_input: dict,
- spec_overrides: dict | None = None,
- ) -> GenerationAttempt:
- """确认卡片,触发生成。
-
- - CHARACTER:生成候选图 → 创建 CANDIDATE 卡片。
- - ACTION:生成动画帧。
- - EXPORT:打包导出。
-
- 返回的 GenerationAttempt 包含 task_id,前端可订阅 Task SSE。
- """
-
- @abstractmethod
- def regenerate_card(
- self,
- card_id: int,
- *,
- user_input: dict | None = None,
- ) -> GenerationAttempt:
- """重新生成(创建新的 GenerationAttempt)。
-
- - CHARACTER:旧 CANDIDATE 全部 INACTION,重新生成候选。ACTION/EXPORT 不受影响。
- - ACTION / EXPORT:创建新 attempt,重新执行。
- """
-
- @abstractmethod
- def delete_card(self, card_id: int) -> None:
- """删除卡片(级联软删除所有子卡片)。"""
-
- @abstractmethod
- def get_card(self, card_id: int) -> CanvasCard | None:
- """获取单张卡片。"""
-
- @abstractmethod
- def list_cards(self, workflow_id: int) -> list[CanvasCard]:
- """获取工作流下全部 active 卡片。"""
-
- # -- 生成尝试 -----------------------------------------------------------
-
- @abstractmethod
- def get_attempts(self, card_id: int) -> list[GenerationAttempt]:
- """获取某张卡片的全部生成尝试记录。"""
diff --git a/backend/packages/app/src/windup_app/server/workflow/model.py b/backend/packages/app/src/windup_app/server/workflow/model.py
deleted file mode 100644
index dde386c..0000000
--- a/backend/packages/app/src/windup_app/server/workflow/model.py
+++ /dev/null
@@ -1,131 +0,0 @@
-"""工作流画布领域模型。
-
-功能型卡片体系:CHARACTER(角色实体)→ CANDIDATE(母版候选)/ ACTION(角色动作)/ EXPORT(资产导出)。
-前端通过卡片 API 统一操作,后端负责持久化画布结构和生成任务状态。
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from enum import StrEnum
-
-
-# -- 枚举 ----------------------------------------------------------------
-
-
-class WorkflowStatus(StrEnum):
- """工作流状态。"""
-
- ACTIVE = "active"
- ARCHIVED = "archived"
-
-
-class CardType(StrEnum):
- """卡片类型——功能型,按职责划分。"""
-
- CHARACTER = "character" # 角色实体根节点
- CANDIDATE = "candidate" # 母版候选
- ACTION = "action" # 角色动作
- EXPORT = "export" # 资产导出
-
-
-class CardStatus(StrEnum):
- """卡片状态。"""
-
- DRAFT = "draft" # 已创建,待用户填写
- GENERATING = "generating" # 生成中
- COMPLETED = "completed" # 完成
- FAILED = "failed" # 失败
- INACTIVE = "inactive" # 已失效(软删除)
-
-
-class AttemptStatus(StrEnum):
- """生成尝试状态。"""
-
- PENDING = "pending"
- RUNNING = "running"
- COMPLETED = "completed"
- FAILED = "failed"
-
-
-class Direction(StrEnum):
- """角色朝向——多方向扩展时使用。"""
-
- FRONT = "front"
- SIDE = "side"
- BACK = "back"
- LEFT = "left"
-
-
-# -- 工作流 ---------------------------------------------------------------
-
-
-@dataclass
-class Workflow:
- """工作流——一个画布实例。"""
-
- id: int | None = None
- user_id: int = 0
- project_id: int | None = None
- name: str = "未命名工作流"
- status: WorkflowStatus = WorkflowStatus.ACTIVE
- project_context: dict = field(default_factory=dict)
- schema_version: int = 1
- version: int = 1
- create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
- update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
-
-
-# -- 画布卡片 -------------------------------------------------------------
-
-
-@dataclass
-class CanvasCard:
- """画布卡片——工作流节点。
-
- user_input / latest_result 结构按 card_type 不同,见 schema.py 中的注释。
- """
-
- id: int | None = None
- workflow_id: int = 0
- card_type: CardType = CardType.CHARACTER
- status: CardStatus = CardStatus.DRAFT
- parent_card_id: int | None = None
- direction: Direction | None = None
- position_x: float = 0.0
- position_y: float = 0.0
- user_input: dict = field(default_factory=dict)
- latest_result: dict | None = None
- spec_overrides: dict = field(default_factory=dict)
- is_active: bool = True
- version: int = 1
- create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
- update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
-
- @property
- def is_terminal(self) -> bool:
- return self.status in (CardStatus.COMPLETED, CardStatus.FAILED, CardStatus.INACTIVE)
-
-
-# -- 生成尝试 -------------------------------------------------------------
-
-
-@dataclass
-class GenerationAttempt:
- """生成尝试——每次触发生成创建一条记录。"""
-
- id: int | None = None
- card_id: int = 0
- task_id: int | None = None
- attempt_no: int = 1
- status: AttemptStatus = AttemptStatus.PENDING
- input_payload: dict = field(default_factory=dict)
- result: dict | None = None
- error_message: str | None = None
- create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
- update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
-
- @property
- def is_terminal(self) -> bool:
- return self.status in (AttemptStatus.COMPLETED, AttemptStatus.FAILED)
diff --git a/backend/packages/app/src/windup_app/server/workflow/schema.py b/backend/packages/app/src/windup_app/server/workflow/schema.py
deleted file mode 100644
index 1cbffe1..0000000
--- a/backend/packages/app/src/windup_app/server/workflow/schema.py
+++ /dev/null
@@ -1,306 +0,0 @@
-"""工作流画布 API Schema。
-
-定义前端请求/响应的 Pydantic 模型,与 server 层解耦。
-前端团队参考此文件了解接口契约。
-
-卡片输入参数按 card_type 区分,每种类型有明确的 user_input / spec_overrides 结构。
-"""
-
-from __future__ import annotations
-
-from enum import StrEnum
-from typing import Literal
-
-from pydantic import BaseModel, ConfigDict, Field
-
-from windup_app.server.workflow.model import (
- Direction,
-)
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# 工作流
-# ══════════════════════════════════════════════════════════════════════════════
-
-
-class WorkflowCreateRequest(BaseModel):
- """创建工作流。
-
- 自动创建一张 CHARACTER 根卡片作为画布起点。
- """
-
- project_id: int = Field(description="关联项目 ID,项目约束从这里读取")
- name: str = Field(default="未命名工作流", max_length=100, description="工作流名称")
-
-
-class WorkflowOut(BaseModel):
- """工作流详情响应(含全部 active 卡片)。"""
-
- model_config = ConfigDict(from_attributes=True)
-
- id: int
- project_id: int | None = None
- name: str
- status: str = Field(description="active / archived")
- project_context: dict = Field(
- default_factory=dict,
- description="项目约束快照:{perspective, sprite_width, sprite_height, game_style, ...}",
- )
- version: int = Field(description="乐观锁版本号,更新时需携带")
- cards: list[CanvasCardOut] = Field(default_factory=list, description="全部有效卡片")
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# 卡片输入参数(按 card_type 显式定义)
-# ══════════════════════════════════════════════════════════════════════════════
-
-
-# ── CHARACTER 卡片 ────────────────────────────────────────────────────────────
-
-
-class CharacterConfirmInput(BaseModel):
- """CHARACTER 卡片确认时的用户输入。
-
- 用户填写角色描述后提交,后端生成 N 张候选图。
- """
-
- description: str = Field(min_length=1, max_length=500, description="角色描述,如'甲壳虫战士,手持长剑'")
- num_images: int = Field(default=6, ge=1, le=12, description="生成候选图数量,默认6张")
-
-
-class CharacterUpdateInput(BaseModel):
- """CHARACTER 卡片更新时的用户输入(选定候选后回填)。"""
-
- selected_candidate_id: int | None = Field(default=None, description="选定的 CANDIDATE 卡片 ID")
-
-
-# ── ACTION 卡片 ───────────────────────────────────────────────────────────────
-
-
-class PresetActionType(StrEnum):
- """预设动作类型——规格与提示词已预先优化。"""
-
- IDLE = "idle"
- WALK = "walk"
- RUN = "run"
- JUMP = "jump"
-
-
-class ActionCreateInput(BaseModel):
- """ACTION 卡片创建时的用户输入。"""
-
- action_type: PresetActionType | str = Field(
- description="动作类型:预设(idle/walk/run/jump) 或自定义(任意字符串)"
- )
- action_name: str | None = Field(
- default=None, max_length=50,
- description="自定义动作名称(action_type 为自定义时必填)",
- )
- description: str = Field(
- min_length=1, max_length=300,
- description="动作描述,如'大步流星地走'、'向上跳跃'",
- )
- reference_image_url: str | None = Field(
- default=None,
- description="姿势参考图 URL(自定义动作可选,预设动作忽略)",
- )
-
-
-class ActionSpecOverrides(BaseModel):
- """ACTION 卡片高级项覆盖。
-
- 这些参数有默认值,用户可在"高级项"中覆盖。
- 覆盖后卡片上出现记号标明该处已偏离默认。
- """
-
- num_frames: int = Field(default=36, ge=1, le=120, description="生成帧数,默认36帧")
- fps: int = Field(default=10, ge=1, le=60, description="帧率,默认10 FPS")
- loop: Literal["none", "linear", "pingpong"] = Field(
- default="linear", description="循环模式:none=不循环, linear=线性, pingpong=乒乓"
- )
-
-
-# ── EXPORT 卡片 ──────────────────────────────────────────────────────────────
-
-
-class ExportFormat(StrEnum):
- """导出格式。"""
-
- PNG_SEQUENCE = "png_sequence" # PNG 序列帧
- SPRITE_SHEET = "sprite_sheet" # 精灵图集
- PLIST = "plist" # Cocos SpriteFrames
-
-
-class ExportCreateInput(BaseModel):
- """EXPORT 卡片创建时的用户输入。"""
-
- formats: list[ExportFormat] = Field(
- min_length=1,
- description="导出格式列表,至少选一种",
- )
- fps: int = Field(default=10, ge=1, le=60, description="导出帧率")
-
-
-# ── latest_result 结构(按 card_type)─────────────────────────────────────────
-
-
-class CharacterLatestResult(BaseModel):
- """CHARACTER 生成结果。"""
-
- candidate_ids: list[int] = Field(description="生成的 CANDIDATE 卡片 ID 列表")
-
-
-class CandidateLatestResult(BaseModel):
- """CANDIDATE 生成结果(与 user_input 相同)。"""
-
- image_url: str = Field(description="候选图 URL")
-
-
-class ActionFrame(BaseModel):
- """动画帧。"""
-
- index: int = Field(description="帧序号,从0开始")
- image_url: str = Field(description="帧图片 URL")
- duration_ms: int = Field(default=125, description="帧持续时间(ms)")
-
-
-class ActionLatestResult(BaseModel):
- """ACTION 生成结果。"""
-
- first_frame_url: str | None = Field(default=None, description="首帧图 URL")
- frames: list[ActionFrame] = Field(default_factory=list, description="完整动画帧序列")
-
-
-class ExportLatestResult(BaseModel):
- """EXPORT 生成结果。"""
-
- package_url: str = Field(description="导出包下载 URL")
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# 画布卡片请求/响应
-# ══════════════════════════════════════════════════════════════════════════════
-
-
-class CardCreateRequest(BaseModel):
- """创建子卡片(ACTION / EXPORT)。
-
- 由前端"+"菜单触发,parent_card_id 必须指向一张 CHARACTER 卡片。
- ACTION 卡片创建时自动从选定的 CANDIDATE 复制母版图。
- """
-
- card_type: Literal["action", "export"] = Field(description="卡片类型")
- parent_card_id: int = Field(description="父卡片 ID,必须是 CHARACTER 类型")
- direction: Direction | None = Field(default=None, description="方向:front/side/back/left,单方向时省略")
- user_input: ActionCreateInput | ExportCreateInput = Field(
- description="用户输入:ACTION 用 ActionCreateInput,EXPORT 用 ExportCreateInput",
- )
- spec_overrides: ActionSpecOverrides | None = Field(
- default=None,
- description="ACTION 高级项覆盖(帧数/FPS/循环),EXPORT 省略",
- )
-
-
-class CardUpdateRequest(BaseModel):
- """更新卡片用户输入(不触发生成)。
-
- CHARACTER:更新 selected_candidate_id(选定候选)
- ACTION:更新描述等草稿
- """
-
- user_input: CharacterUpdateInput | ActionCreateInput | ExportCreateInput = Field(
- description="更新后的用户输入,结构按 card_type"
- )
- position_x: float | None = Field(default=None, description="画布 X 坐标(拖动后保存)")
- position_y: float | None = Field(default=None, description="画布 Y 坐标(拖动后保存)")
-
-
-class CharacterConfirmRequest(BaseModel):
- """CHARACTER 卡片确认请求。"""
-
- card_type: Literal["character"] = "character"
- user_input: CharacterConfirmInput = Field(description="角色描述和候选图数量")
- spec_overrides: None = None # CHARACTER 无高级项
-
-
-class ActionConfirmRequest(BaseModel):
- """ACTION 卡片确认请求。"""
-
- card_type: Literal["action"] = "action"
- user_input: ActionCreateInput = Field(description="动作输入(可修改描述后确认)")
- spec_overrides: ActionSpecOverrides | None = Field(default=None, description="高级项覆盖")
-
-
-class ExportConfirmRequest(BaseModel):
- """EXPORT 卡片确认请求。"""
-
- card_type: Literal["export"] = "export"
- user_input: ExportCreateInput = Field(description="导出格式和帧率")
- spec_overrides: None = None # EXPORT 无高级项
-
-
-# 前端按 card_type 选择对应的 ConfirmRequest 发送
-CardConfirmRequest = CharacterConfirmRequest | ActionConfirmRequest | ExportConfirmRequest
-
-
-class CardRegenerateRequest(BaseModel):
- """重新生成(创建新的 GenerationAttempt)。
-
- CHARACTER:旧 CANDIDATE 全部 INACTIVE,重新生成候选。ACTION/EXPORT 不受影响。
- ACTION / EXPORT:创建新 attempt,重新执行。
- """
-
- user_input: CharacterConfirmInput | ActionCreateInput | ExportCreateInput | None = Field(
- default=None,
- description="可选:修改输入后重新生成,省略则沿用上次输入",
- )
-
-
-class CanvasCardOut(BaseModel):
- """卡片响应。
-
- user_input / latest_result 的结构按 card_type 不同,参考对应的 Pydantic 模型。
- """
-
- model_config = ConfigDict(from_attributes=True)
-
- id: int
- card_type: str = Field(description="character / candidate / action / export")
- status: str = Field(description="draft / generating / completed / failed / inactive")
- parent_card_id: int | None = None
- direction: str | None = Field(default=None, description="front / side / back / left / null")
- position_x: float = 0.0
- position_y: float = 0.0
- user_input: dict = Field(
- default_factory=dict,
- description="用户输入,结构按 card_type 见对应的 Input 模型",
- )
- latest_result: dict | None = Field(
- default=None,
- description="生成结果,结构按 card_type 见对应的 Result 模型",
- )
- spec_overrides: dict = Field(
- default_factory=dict,
- description="高级项覆盖,ACTION 用 ActionSpecOverrides 结构",
- )
- version: int = 1
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# 生成尝试
-# ══════════════════════════════════════════════════════════════════════════════
-
-
-class GenerationAttemptOut(BaseModel):
- """生成尝试响应。"""
-
- model_config = ConfigDict(from_attributes=True)
-
- id: int
- card_id: int
- task_id: int | None = Field(default=None, description="关联的 GenerationTask ID,可用于订阅 Task SSE")
- attempt_no: int = Field(description="第几次尝试,从 1 开始")
- status: str = Field(description="pending / running / completed / failed")
- input_payload: dict = Field(default_factory=dict, description="生成输入快照")
- result: dict | None = Field(default=None, description="生成结果")
- error_message: str | None = None
diff --git a/backend/packages/app/src/windup_app/server/workflow_run/__init__.py b/backend/packages/app/src/windup_app/server/workflow_run/__init__.py
new file mode 100644
index 0000000..18b7be4
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/workflow_run/__init__.py
@@ -0,0 +1 @@
+"""工作流执行记录领域。"""
diff --git a/backend/packages/app/src/windup_app/server/workflow_run/interface.py b/backend/packages/app/src/windup_app/server/workflow_run/interface.py
new file mode 100644
index 0000000..841e5fc
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/workflow_run/interface.py
@@ -0,0 +1,123 @@
+"""工作流执行记录领域服务接口。
+
+API 层只依赖本模块定义的抽象。具体实现在应用装配层继承后通过依赖注入提供。
+
+职责
+----
+- 记录前端每一步的执行结果(哪个能力、什么输入、什么输出)
+- 支持回滚到某个节点(标记下游为 rolled_back)
+- 支持版本管理(修改角色模板 → 新版本 run)
+- 支持跨树 diff(新旧 run 对比,识别可复用节点)
+
+不做
+----
+- 不定义节点类型(由前端定义)
+- 不管节点拼装和推进(由前端负责)
+- 不执行业务逻辑(由原子能力 API 负责)
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+
+from windup_app.server.workflow_run.model import (
+ NodeStatus,
+ WorkflowRun,
+ WorkflowRunNode,
+)
+
+
+class WorkflowRunService(ABC):
+ """执行记录用例的抽象边界。"""
+
+ # -- 执行记录 CRUD --------------------------------------------------------
+
+ @abstractmethod
+ def create_run(
+ self,
+ *,
+ project_id: int,
+ parent_run_id: int | None = None,
+ root_capability: str,
+ root_input: dict | None = None,
+ ) -> WorkflowRun:
+ """创建执行记录。
+
+ 修改角色模板时传 parent_run_id,形成版本链。
+ """
+
+ @abstractmethod
+ def get_run(self, run_id: int) -> WorkflowRun | None:
+ """获取执行记录详情(不含节点树)。"""
+
+ @abstractmethod
+ def get_run_tree(self, run_id: int) -> tuple[WorkflowRun, list[WorkflowRunNode]] | None:
+ """获取执行记录及其全部节点,按树形结构组装。"""
+
+ @abstractmethod
+ def delete_run(self, run_id: int) -> None:
+ """软删除执行记录。"""
+
+ # -- 节点操作 -------------------------------------------------------------
+
+ @abstractmethod
+ def create_node(
+ self,
+ run_id: int,
+ *,
+ parent_node_id: int | None = None,
+ capability: str,
+ input: dict | None = None,
+ task_id: int | None = None,
+ node_order: int = 0,
+ ) -> WorkflowRunNode:
+ """记录一个执行步骤。"""
+
+ @abstractmethod
+ def update_node(
+ self,
+ node_id: int,
+ *,
+ status: NodeStatus | None = None,
+ output: dict | None = None,
+ ) -> WorkflowRunNode:
+ """更新节点状态和/或输出。"""
+
+ @abstractmethod
+ def rollback_to_node(self, run_id: int, node_id: int) -> WorkflowRun:
+ """回滚到指定节点——标记该节点及其下游为 rolled_back。"""
+
+ # -- Diff -----------------------------------------------------------------
+
+ @abstractmethod
+ def diff_runs(self, new_run_id: int, old_run_id: int) -> DiffResult:
+ """对比新旧 run,返回可复用节点和需要重新执行的能力。"""
+
+
+# -- Diff 结果模型 ---------------------------------------------------------
+
+
+@dataclass
+class ReusableNode:
+ """可复用的节点信息。"""
+
+ old_node_id: int
+ capability: str
+ input: dict
+
+
+@dataclass
+class NeedsRerun:
+ """需要重新执行的能力。"""
+
+ capability: str
+ reason: str
+
+
+@dataclass
+class DiffResult:
+ """跨 run diff 结果。"""
+
+ reusable_nodes: list[ReusableNode] = field(default_factory=list)
+ needs_rerun: list[NeedsRerun] = field(default_factory=list)
diff --git a/backend/packages/app/src/windup_app/server/workflow_run/model.py b/backend/packages/app/src/windup_app/server/workflow_run/model.py
new file mode 100644
index 0000000..ecb7501
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/workflow_run/model.py
@@ -0,0 +1,79 @@
+"""工作流执行记录领域模型。
+
+树形执行记录体系:WorkflowRun(执行记录)→ WorkflowRunNode(执行节点)。
+后端只做记录,不定义节点类型,节点的拼装和推进由前端负责。
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from enum import StrEnum
+
+
+# -- 枚举 ----------------------------------------------------------------
+
+
+class RunStatus(StrEnum):
+ """执行记录状态。"""
+
+ ACTIVE = "active"
+ SOFT_DELETED = "soft_deleted"
+
+
+class NodeStatus(StrEnum):
+ """执行节点状态。"""
+
+ PENDING = "pending"
+ RUNNING = "running"
+ COMPLETED = "completed"
+ FAILED = "failed"
+ ROLLED_BACK = "rolled_back"
+
+
+# -- 执行记录 -------------------------------------------------------------
+
+
+@dataclass
+class WorkflowRun:
+ """执行记录——一个角色的完整生命周期。
+
+ 修改角色模板时,创建新 run(parent_run_id 指向旧 run),形成版本链。
+ """
+
+ id: int | None = None
+ project_id: int = 0
+ parent_run_id: int | None = None
+ root_capability: str = "" # 根节点能力类型(如 "generate_images")
+ root_input: dict = field(default_factory=dict)
+ root_output: dict | None = None
+ status: RunStatus = RunStatus.ACTIVE
+ version: int = 1
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+# -- 执行节点 -------------------------------------------------------------
+
+
+@dataclass
+class WorkflowRunNode:
+ """执行节点——树形结构中的一个步骤。
+
+ capability 字段由前端定义(如 "generate_images"、"generate_frames"、"export"),
+ 后端不校验具体值,只负责记录。
+ """
+
+ id: int | None = None
+ run_id: int = 0
+ parent_node_id: int | None = None
+ capability: str = "" # 原子能力名称
+ input: dict = field(default_factory=dict)
+ output: dict | None = None
+ status: NodeStatus = NodeStatus.PENDING
+ task_id: int | None = None # 关联的生成任务 ID(用于 SSE 进度订阅)
+ node_order: int = 0 # 同级节点的执行顺序
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+ @property
+ def is_terminal(self) -> bool:
+ return self.status in (NodeStatus.COMPLETED, NodeStatus.FAILED, NodeStatus.ROLLED_BACK)
diff --git a/backend/packages/app/src/windup_app/server/workflow_run/schema.py b/backend/packages/app/src/windup_app/server/workflow_run/schema.py
new file mode 100644
index 0000000..d3ac866
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/workflow_run/schema.py
@@ -0,0 +1,151 @@
+"""工作流执行记录 API Schema。
+
+定义前端请求/响应的 Pydantic 模型,与 server 层解耦。
+前端团队参考此文件了解接口契约。
+
+后端不定义节点类型,capability 字段由前端自定义。
+"""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 执行记录
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+class WorkflowRunCreateRequest(BaseModel):
+ """创建执行记录。"""
+
+ project_id: int = Field(description="关联项目 ID,项目约束从这里读取")
+ parent_run_id: int | None = Field(
+ default=None,
+ description="父执行记录 ID(修改角色模板时指向旧 run,形成版本链)",
+ )
+ root_capability: str = Field(
+ description="根节点能力类型(如 'generate_images')",
+ )
+ root_input: dict = Field(
+ default_factory=dict,
+ description="根节点输入参数",
+ )
+
+
+class WorkflowRunOut(BaseModel):
+ """执行记录响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ project_id: int
+ parent_run_id: int | None = None
+ root_capability: str = Field(description="根节点能力类型")
+ root_input: dict = Field(default_factory=dict, description="根节点输入")
+ root_output: dict | None = Field(default=None, description="根节点输出")
+ status: str = Field(description="active / soft_deleted")
+ version: int = Field(description="版本号,从 1 递增")
+
+
+class WorkflowRunTreeOut(BaseModel):
+ """执行记录树形结构响应(含全部节点)。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ run: WorkflowRunOut
+ nodes: list[WorkflowRunNodeOut] = Field(
+ default_factory=list,
+ description="全部节点,按创建顺序排列,前端根据 parent_node_id 组装树形结构",
+ )
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 执行节点
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+class NodeCreateRequest(BaseModel):
+ """记录一个执行步骤。"""
+
+ parent_node_id: int | None = Field(
+ default=None,
+ description="父节点 ID(根节点为 null)",
+ )
+ capability: str = Field(
+ description="原子能力名称(如 'generate_images'、'generate_frames'、'export')",
+ )
+ input: dict = Field(
+ default_factory=dict,
+ description="该步骤的输入参数",
+ )
+ task_id: int | None = Field(
+ default=None,
+ description="关联的生成任务 ID(用于 SSE 进度订阅)",
+ )
+ node_order: int = Field(
+ default=0,
+ description="同级节点的执行顺序",
+ )
+
+
+class NodeUpdateRequest(BaseModel):
+ """更新节点状态和/或输出。"""
+
+ status: str | None = Field(
+ default=None,
+ description="新状态:pending / running / completed / failed / rolled_back",
+ )
+ output: dict | None = Field(
+ default=None,
+ description="该步骤的输出结果",
+ )
+
+
+class WorkflowRunNodeOut(BaseModel):
+ """执行节点响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ run_id: int
+ parent_node_id: int | None = None
+ capability: str = Field(description="原子能力名称")
+ input: dict = Field(default_factory=dict, description="输入参数")
+ output: dict | None = Field(default=None, description="输出结果")
+ status: str = Field(description="pending / running / completed / failed / rolled_back")
+ task_id: int | None = Field(default=None, description="关联的生成任务 ID")
+ node_order: int = Field(default=0, description="同级节点执行顺序")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Diff 结果
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+class ReusableNodeOut(BaseModel):
+ """可复用节点。"""
+
+ old_node_id: int = Field(description="旧 run 中的节点 ID")
+ capability: str = Field(description="能力名称")
+ input: dict = Field(default_factory=dict, description="输入参数")
+
+
+class NeedsRerunOut(BaseModel):
+ """需要重新执行的能力。"""
+
+ capability: str = Field(description="能力名称")
+ reason: str = Field(description="需要重新执行的原因")
+
+
+class DiffResultOut(BaseModel):
+ """跨 run diff 结果。"""
+
+ reusable_nodes: list[ReusableNodeOut] = Field(
+ default_factory=list,
+ description="可复用的节点列表",
+ )
+ needs_rerun: list[NeedsRerunOut] = Field(
+ default_factory=list,
+ description="需要重新执行的能力列表",
+ )
diff --git a/backend/packages/app/src/windup_app/web/api/agent.py b/backend/packages/app/src/windup_app/web/api/agent.py
index 051ca96..de721bd 100644
--- a/backend/packages/app/src/windup_app/web/api/agent.py
+++ b/backend/packages/app/src/windup_app/web/api/agent.py
@@ -1,103 +1,64 @@
-"""Agent 智能体 API。
-
-懒人智能体:一句话搞定角色资产生成。
-用户通过自然语言与 Agent 对话,Agent 自动调用工具完成项目创建、角色生成、动作生成等操作。
-
-端点一览
---------
-POST /agent/sessions 创建 Agent 会话
-GET /agent/sessions/{session_id}/stream Agent SSE 事件流
-POST /agent/sessions/{session_id}/messages 发送用户消息
-POST /agent/sessions/{session_id}/choices 发送用户选择(按钮点击)
-GET /agent/sessions/{session_id}/messages 获取会话历史
-
-SSE 事件类型
------------
-- message: Agent 回复(富内容:text/image/buttons/confirm/progress)
-- tool_call: Agent 调用工具(含 task_id,可订阅 Task SSE 获取进度)
-- tool_result: 工具返回结果
-- state_change: Agent 状态变更(processing/waiting_input/done)
-- error: 错误信息
+"""AI Proxy(LLM 代理)API。
+
+后端是无状态 LLM 代理,不做 agent 编排。
+前端维护 conversation history,传完整 messages 数组。
+OpenAI 兼容格式,前端可用 OpenAI SDK 解析。
+
+端点
+----
+POST /ai/chat LLM 代理(流式响应)
+
+认证
+----
+复用现有 JWT 体系,user_id 从 token 解析(用于日志/审计,不存对话)。
"""
from __future__ import annotations
+import json
import logging
-from fastapi import APIRouter, Depends
-from pydantic import BaseModel, ConfigDict, Field
-from sqlalchemy.orm import Session
-
-from windup_common.result import Response, ListResponse
-from windup_framework.db import get_session
+from fastapi import APIRouter, Request
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel, Field
-logger = logging.getLogger("windup.agent.api")
+logger = logging.getLogger("windup.ai.proxy")
-router = APIRouter(prefix="/agent", tags=["agent"])
+router = APIRouter(prefix="/ai", tags=["ai"])
# ══════════════════════════════════════════════════════════════════════════════
-# 请求模型
+# 请求/响应模型
# ══════════════════════════════════════════════════════════════════════════════
-class SessionCreateRequest(BaseModel):
- """创建 Agent 会话。"""
-
- user_id: int = Field(description="用户 ID")
- context: dict | None = Field(
- default=None,
- description="初始上下文,如 {project_id: 123}。可选,后续通过对话补充。",
- )
-
-
-class MessageSendRequest(BaseModel):
- """发送用户消息。"""
-
- content: str = Field(min_length=1, max_length=2000, description="用户消息内容")
- message_id: str | None = Field(
- default=None,
- description="客户端消息 ID(用于去重),省略则由后端生成",
- )
-
-
-class ChoiceSendRequest(BaseModel):
- """发送用户选择(按钮点击)。"""
-
- message_id: str = Field(description="对应的 Agent 消息 ID(buttons 块所在的 message)")
- value: str = Field(description="选择的值(ButtonOption.value)")
-
+class ChatMessage(BaseModel):
+ """对话消息。"""
-# ══════════════════════════════════════════════════════════════════════════════
-# 响应模型
-# ══════════════════════════════════════════════════════════════════════════════
-
-
-class SessionOut(BaseModel):
- """会话响应。"""
+ role: str = Field(description="消息角色:system / user / assistant / tool")
+ content: str | None = Field(default=None, description="消息内容")
+ tool_calls: list[dict] | None = Field(default=None, description="工具调用(assistant 角色)")
+ tool_call_id: str | None = Field(default=None, description="工具结果关联的调用 ID(tool 角色)")
- model_config = ConfigDict(from_attributes=True)
- session_id: str = Field(description="会话 ID,用于后续 SSE 订阅和消息发送")
- created_at: str = Field(description="创建时间(ISO 8601)")
+class ToolDefinition(BaseModel):
+ """工具定义(OpenAI function calling 格式)。"""
+ type: str = Field(default="function", description="工具类型,固定为 function")
+ function: dict = Field(description="函数定义:{name, description, parameters}")
-class MessageOut(BaseModel):
- """消息响应。"""
- model_config = ConfigDict(from_attributes=True)
+class ChatRequest(BaseModel):
+ """LLM 代理请求。"""
- message_id: str = Field(description="消息 ID")
- role: str = Field(description="角色:user / assistant")
- blocks: list[dict] = Field(
- default_factory=list,
- description="内容块列表,结构见 ContentBlock 定义",
- )
- state: str | None = Field(
+ system: str | None = Field(default=None, description="系统提示词")
+ messages: list[ChatMessage] = Field(description="对话历史(前端维护完整列表)")
+ model: str | None = Field(default=None, description="模型名称,省略则使用默认模型")
+ temperature: float | None = Field(default=None, ge=0, le=2, description="温度参数")
+ tools: list[ToolDefinition] | None = Field(
default=None,
- description="Agent 状态:processing / waiting_input / done(仅 assistant 消息)",
+ description="可用工具列表(前端定义,后端透传给 LLM)",
)
- timestamp: float = Field(description="时间戳(秒)")
# ══════════════════════════════════════════════════════════════════════════════
@@ -105,62 +66,38 @@ class MessageOut(BaseModel):
# ══════════════════════════════════════════════════════════════════════════════
-@router.post("/sessions", response_model=Response[SessionOut])
-def create_session(
- body: SessionCreateRequest,
- session: Session = Depends(get_session),
-) -> Response[SessionOut]:
- """创建 Agent 会话。
-
- 返回 session_id,前端用于:
- 1. 订阅 Agent SSE:GET /agent/sessions/{session_id}/stream
- 2. 发送消息:POST /agent/sessions/{session_id}/messages
- 3. 发送选择:POST /agent/sessions/{session_id}/choices
- """
- # TODO: agent_service.create_session
- raise NotImplementedError
-
-
-@router.post("/sessions/{session_id}/messages", response_model=Response[MessageOut])
-def send_message(
- session_id: str,
- body: MessageSendRequest,
- session: Session = Depends(get_session),
-) -> Response[MessageOut]:
- """发送用户消息。
-
- Agent 收到消息后通过 SSE 推送处理结果(message/tool_call/tool_result 等事件)。
- """
- # TODO: agent_service.send_message
- raise NotImplementedError
-
-
-@router.post("/sessions/{session_id}/choices", response_model=Response[None])
-def send_choice(
- session_id: str,
- body: ChoiceSendRequest,
- session: Session = Depends(get_session),
-) -> Response[None]:
- """发送用户选择(按钮点击)。
-
- 对应 Agent 消息中的 buttons 块。Agent 收到后继续处理。
+@router.post("/chat")
+async def chat(
+ body: ChatRequest,
+ request: Request,
+) -> StreamingResponse:
+ """LLM 代理(流式响应)。
+
+ 后端职责:
+ 1. 从 JWT 解析 user_id(用于日志/审计)
+ 2. 调用 create_chat_model 工厂创建 LLM 实例
+ 3. 流式转发 LLM 响应
+ 4. 不解析 tool_calls、不执行工具、不存对话历史
+
+ 响应格式:OpenAI 兼容 SSE
+ ```
+ data: {"choices": [{"delta": {"content": "好"}}]}
+ data: {"choices": [{"delta": {"tool_calls": [...]}}]}
+ data: [DONE]
+ ```
"""
- # TODO: agent_service.send_choice
- raise NotImplementedError
-
-
-@router.get("/sessions/{session_id}/messages", response_model=ListResponse[MessageOut])
-def get_messages(
- session_id: str,
- limit: int = 50,
- before: str | None = None,
- session: Session = Depends(get_session),
-) -> ListResponse[MessageOut]:
- """获取会话历史消息。
-
- 参数:
- - limit: 返回条数,默认50
- - before: 分页,此 message_id 之前的消息
- """
- # TODO: agent_service.get_messages
- raise NotImplementedError
+ # TODO: 调用 create_chat_model 创建 LLM 实例,流式转发
+ # 当前返回占位实现
+ async def _placeholder():
+ yield f'data: {json.dumps({"choices": [{"delta": {"content": "AI Proxy 尚未实现,请配置 LLM provider。"}}]}, ensure_ascii=False)}\n\n'
+ yield "data: [DONE]\n\n"
+
+ return StreamingResponse(
+ _placeholder(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
diff --git a/backend/packages/app/src/windup_app/web/api/workflow.py b/backend/packages/app/src/windup_app/web/api/workflow.py
deleted file mode 100644
index 39929ab..0000000
--- a/backend/packages/app/src/windup_app/web/api/workflow.py
+++ /dev/null
@@ -1,178 +0,0 @@
-"""工作流画布 API。
-
-契约层:定义端点和请求/响应模型,与 server 层解耦。
-实际逻辑由 server 层实现,本文件只做参数校验和格式转换。
-
-端点一览
---------
-POST /workflow 创建工作流
-GET /workflow/{id} 获取工作流详情
-DELETE /workflow/{id} 删除工作流
-POST /workflow/{wf_id}/cards 创建子卡片
-PATCH /workflow/{wf_id}/cards/{card_id} 更新卡片
-POST /workflow/{wf_id}/cards/{card_id}/confirm 确认卡片(触发生成)
-POST /workflow/{wf_id}/cards/{card_id}/regenerate 重新生成
-DELETE /workflow/{wf_id}/cards/{card_id} 删除卡片
-GET /workflow/{wf_id}/cards/{card_id}/attempts 获取生成尝试历史
-"""
-
-from __future__ import annotations
-
-import logging
-
-from fastapi import APIRouter, Depends
-from sqlalchemy.orm import Session
-
-from windup_common.result import Response, ListResponse
-from windup_framework.db import get_session
-
-from windup_app.server.workflow.schema import (
- CanvasCardOut,
- CardConfirmRequest,
- CardCreateRequest,
- CardRegenerateRequest,
- CardUpdateRequest,
- GenerationAttemptOut,
- WorkflowCreateRequest,
- WorkflowOut,
-)
-
-logger = logging.getLogger("windup.workflow.api")
-
-router = APIRouter(prefix="/workflow", tags=["workflow"])
-
-
-# ── 工作流 CRUD ─────────────────────────────────────────────────────────────
-
-
-@router.post("", response_model=Response[WorkflowOut])
-def create_workflow(
- body: WorkflowCreateRequest,
- session: Session = Depends(get_session),
-) -> Response[WorkflowOut]:
- """创建工作流。
-
- 自动创建一张 CHARACTER 根卡片作为画布起点。
- 返回的工作流已包含该卡片。
- """
- # TODO: service.create_workflow
- raise NotImplementedError
-
-
-@router.get("/{workflow_id}", response_model=Response[WorkflowOut])
-def get_workflow(
- workflow_id: int,
- session: Session = Depends(get_session),
-) -> Response[WorkflowOut]:
- """获取工作流详情(含全部 active 卡片)。
-
- 前端进入画布时调用,获取完整的卡片树。
- """
- # TODO: service.get_workflow
- raise NotImplementedError
-
-
-@router.delete("/{workflow_id}", response_model=Response[None])
-def delete_workflow(
- workflow_id: int,
- session: Session = Depends(get_session),
-) -> Response[None]:
- """删除工作流(级联软删除所有卡片)。"""
- # TODO: service.delete_workflow
- raise NotImplementedError
-
-
-# ── 卡片操作 ────────────────────────────────────────────────────────────────
-
-
-@router.post("/{workflow_id}/cards", response_model=Response[CanvasCardOut])
-def create_card(
- workflow_id: int,
- body: CardCreateRequest,
- session: Session = Depends(get_session),
-) -> Response[CanvasCardOut]:
- """创建子卡片(ACTION / EXPORT)。
-
- 由前端"+"菜单触发。ACTION 卡片创建时自动复制母版图。
- """
- # TODO: card_service.create_card
- raise NotImplementedError
-
-
-@router.patch("/{workflow_id}/cards/{card_id}", response_model=Response[CanvasCardOut])
-def update_card(
- workflow_id: int,
- card_id: int,
- body: CardUpdateRequest,
- session: Session = Depends(get_session),
-) -> Response[CanvasCardOut]:
- """更新卡片用户输入或位置(不触发生成)。
-
- 用于保存草稿、拖动画布位置等场景。
- """
- # TODO: card_service.update_card
- raise NotImplementedError
-
-
-@router.post("/{workflow_id}/cards/{card_id}/confirm", response_model=Response[GenerationAttemptOut])
-def confirm_card(
- workflow_id: int,
- card_id: int,
- body: CardConfirmRequest,
- session: Session = Depends(get_session),
-) -> Response[GenerationAttemptOut]:
- """确认卡片,触发生成。
-
- - CHARACTER:生成候选图 → 自动创建 CANDIDATE 卡片。
- - ACTION:生成动画帧。
- - EXPORT:打包导出。
-
- 返回的 GenerationAttempt 包含 task_id,前端应订阅 Task SSE 获取进度:
- ``GET /generation/tasks/{task_id}/stream``
- """
- # TODO: card_service.confirm_card
- raise NotImplementedError
-
-
-@router.post("/{workflow_id}/cards/{card_id}/regenerate", response_model=Response[GenerationAttemptOut])
-def regenerate_card(
- workflow_id: int,
- card_id: int,
- body: CardRegenerateRequest,
- session: Session = Depends(get_session),
-) -> Response[GenerationAttemptOut]:
- """重新生成(创建新的 GenerationAttempt)。
-
- - CHARACTER:旧 CANDIDATE 全部 INACTIVE,重新生成候选。ACTION/EXPORT 不受影响。
- - ACTION / EXPORT:创建新 attempt,重新执行。
- """
- # TODO: card_service.regenerate_card
- raise NotImplementedError
-
-
-@router.delete("/{workflow_id}/cards/{card_id}", response_model=Response[None])
-def delete_card(
- workflow_id: int,
- card_id: int,
- session: Session = Depends(get_session),
-) -> Response[None]:
- """删除卡片(级联软删除所有子卡片)。
-
- 删除 CHARACTER 会级联删除其下的 CANDIDATE、ACTION、EXPORT。
- """
- # TODO: card_service.delete_card
- raise NotImplementedError
-
-
-@router.get("/{workflow_id}/cards/{card_id}/attempts", response_model=ListResponse[GenerationAttemptOut])
-def get_attempts(
- workflow_id: int,
- card_id: int,
- session: Session = Depends(get_session),
-) -> ListResponse[GenerationAttemptOut]:
- """获取某张卡片的全部生成尝试记录。
-
- 用于展示历史生成结果、调试等。
- """
- # TODO: card_service.get_attempts
- raise NotImplementedError
diff --git a/backend/packages/app/src/windup_app/web/api/workflow_run.py b/backend/packages/app/src/windup_app/web/api/workflow_run.py
new file mode 100644
index 0000000..7e164d5
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/api/workflow_run.py
@@ -0,0 +1,144 @@
+"""工作流执行记录 API。
+
+契约层:定义端点和请求/响应模型,与 server 层解耦。
+实际逻辑由 server 层实现,本文件只做参数校验和格式转换。
+
+端点一览
+--------
+POST /workflow-runs 创建执行记录
+GET /workflow-runs/{id} 获取执行记录详情
+GET /workflow-runs/{id}/tree 获取执行记录树(含全部节点)
+DELETE /workflow-runs/{id} 软删除执行记录
+POST /workflow-runs/{id}/nodes 记录一个执行步骤
+PATCH /workflow-runs/{id}/nodes/{node_id} 更新节点状态/结果
+POST /workflow-runs/{id}/rollback/{node_id} 回滚到指定节点
+POST /workflow-runs/{id}/diff/{old_run_id} 对比新旧 run
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from windup_common.result import Response
+from windup_framework.db import get_session
+
+from windup_app.server.workflow_run.schema import (
+ DiffResultOut,
+ NodeCreateRequest,
+ NodeUpdateRequest,
+ WorkflowRunCreateRequest,
+ WorkflowRunNodeOut,
+ WorkflowRunOut,
+ WorkflowRunTreeOut,
+)
+
+logger = logging.getLogger("windup.workflow_run.api")
+
+router = APIRouter(prefix="/workflow-runs", tags=["workflow-run"])
+
+
+# ── 执行记录 CRUD ───────────────────────────────────────────────────────────
+
+
+@router.post("", response_model=Response[WorkflowRunOut])
+def create_run(
+ body: WorkflowRunCreateRequest,
+ session: Session = Depends(get_session),
+) -> Response[WorkflowRunOut]:
+ """创建执行记录。
+
+ 修改角色模板时传 parent_run_id,形成版本链。
+ """
+ # TODO: service.create_run
+ raise NotImplementedError
+
+
+@router.get("/{run_id}", response_model=Response[WorkflowRunOut])
+def get_run(
+ run_id: int,
+ session: Session = Depends(get_session),
+) -> Response[WorkflowRunOut]:
+ """获取执行记录详情(不含节点树)。"""
+ # TODO: service.get_run
+ raise NotImplementedError
+
+
+@router.get("/{run_id}/tree", response_model=Response[WorkflowRunTreeOut])
+def get_run_tree(
+ run_id: int,
+ session: Session = Depends(get_session),
+) -> Response[WorkflowRunTreeOut]:
+ """获取执行记录及其全部节点。
+
+ 前端根据 parent_node_id 组装树形结构。
+ """
+ # TODO: service.get_run_tree
+ raise NotImplementedError
+
+
+@router.delete("/{run_id}", response_model=Response[None])
+def delete_run(
+ run_id: int,
+ session: Session = Depends(get_session),
+) -> Response[None]:
+ """软删除执行记录。"""
+ # TODO: service.delete_run
+ raise NotImplementedError
+
+
+# ── 节点操作 ────────────────────────────────────────────────────────────────
+
+
+@router.post("/{run_id}/nodes", response_model=Response[WorkflowRunNodeOut])
+def create_node(
+ run_id: int,
+ body: NodeCreateRequest,
+ session: Session = Depends(get_session),
+) -> Response[WorkflowRunNodeOut]:
+ """记录一个执行步骤。
+
+ 前端提交原子能力调用后,通过此端点记录步骤。
+ task_id 用于关联 SSE 进度订阅。
+ """
+ # TODO: service.create_node
+ raise NotImplementedError
+
+
+@router.patch("/{run_id}/nodes/{node_id}", response_model=Response[WorkflowRunNodeOut])
+def update_node(
+ run_id: int,
+ node_id: int,
+ body: NodeUpdateRequest,
+ session: Session = Depends(get_session),
+) -> Response[WorkflowRunNodeOut]:
+ """更新节点状态和/或输出。
+
+ 前端收到 SSE completed 事件后,通过此端点更新节点结果。
+ """
+ # TODO: service.update_node
+ raise NotImplementedError
+
+
+@router.post("/{run_id}/rollback/{node_id}", response_model=Response[WorkflowRunOut])
+def rollback_to_node(
+ run_id: int,
+ node_id: int,
+ session: Session = Depends(get_session),
+) -> Response[WorkflowRunOut]:
+ """回滚到指定节点——标记该节点及其下游为 rolled_back。"""
+ # TODO: service.rollback_to_node
+ raise NotImplementedError
+
+
+@router.post("/{run_id}/diff/{old_run_id}", response_model=Response[DiffResultOut])
+def diff_runs(
+ run_id: int,
+ old_run_id: int,
+ session: Session = Depends(get_session),
+) -> Response[DiffResultOut]:
+ """对比新旧 run,返回可复用节点和需要重新执行的能力。"""
+ # TODO: service.diff_runs
+ raise NotImplementedError
diff --git a/backend/packages/app/src/windup_app/web/sse/__init__.py b/backend/packages/app/src/windup_app/web/sse/__init__.py
index 1984392..820dfe6 100644
--- a/backend/packages/app/src/windup_app/web/sse/__init__.py
+++ b/backend/packages/app/src/windup_app/web/sse/__init__.py
@@ -1,10 +1,12 @@
-"""SSE 模块:Server-Sent Events 实时推送(双模式)。
+"""SSE 模块:Server-Sent Events 通用推送基础设施。
-- ``sse_router``: Task mode —— 生成任务进度推送
-- ``session_router``: Session mode —— Agent 多轮对话事件流
+- ``sse_router``: ``GET /sse/{channel}/stream`` 通用频道订阅端点
+- ``EventBus``: 内存发布-订阅中心
+- ``SSEProgressPort``: 后台线程 → EventBus 进度桥接
"""
-from windup_app.web.sse.session import router as session_router
+from windup_app.web.sse.event_bus import EventBus, SSEEvent
+from windup_app.web.sse.progress import SSEProgressPort
from windup_app.web.sse.stream import router as sse_router
-__all__ = ["sse_router", "session_router"]
+__all__ = ["sse_router", "EventBus", "SSEEvent", "SSEProgressPort"]
diff --git a/backend/packages/app/src/windup_app/web/sse/event_bus.py b/backend/packages/app/src/windup_app/web/sse/event_bus.py
index 21006f2..c2085de 100644
--- a/backend/packages/app/src/windup_app/web/sse/event_bus.py
+++ b/backend/packages/app/src/windup_app/web/sse/event_bus.py
@@ -1,12 +1,12 @@
-"""SSE 内存发布-订阅中心(双模式)。
+"""SSE 通用发布-订阅中心。
-支持两种订阅模式:
+纯基础设施模块,只提供 channel 级别的发布/订阅能力,不关心调用方是谁。
-- **Task mode**: ``task_id → events → 终态关闭``(用于生成任务进度推送)
-- **Session mode**: ``session_id → events → 持续连接``(用于 Agent 多轮对话)
+调用方自定义 channel 命名(如 ``task:123``、``run:456:node:7``),
+EventBus 只负责将事件推送给对应 channel 的订阅者。
线程安全:``publish`` 可从任意线程调用,``subscribe``/``unsubscribe`` 须在
-event loop 中调用(AsyncAPI 路由天然满足)。
+event loop 中调用(FastAPI 路由天然满足)。
"""
from __future__ import annotations
@@ -20,9 +20,8 @@
class SSEEvent:
"""统一 SSE 事件。"""
- id: str # 任务 ID(str) 或会话 ID
- id_type: str # "task" | "session"
- event: str # 事件类型
+ channel: str # 频道名(如 "task:123")
+ event: str # 事件类型(如 "progress"、"completed"、"failed")
data: dict # 事件数据
@@ -30,54 +29,24 @@ class EventBus:
"""内存发布-订阅:后台线程 publish,异步 SSE generator subscribe。"""
def __init__(self) -> None:
- # Task mode: task_id(str) → subscriber queues
- self._task_queues: dict[str, list[asyncio.Queue[SSEEvent]]] = defaultdict(list)
- # Session mode: session_id → subscriber queues
- self._session_queues: dict[str, list[asyncio.Queue[SSEEvent]]] = defaultdict(list)
+ self._queues: dict[str, list[asyncio.Queue[SSEEvent]]] = defaultdict(list)
- # ── Task mode ─────────────────────────────────────────────────────────
-
- async def subscribe_task(self, task_id: int) -> asyncio.Queue[SSEEvent]:
- """订阅任务事件流。"""
- key = str(task_id)
- queue: asyncio.Queue[SSEEvent] = asyncio.Queue()
- self._task_queues[key].append(queue)
- return queue
-
- async def unsubscribe_task(self, task_id: int, queue: asyncio.Queue[SSEEvent]) -> None:
- """取消任务订阅。"""
- key = str(task_id)
- subscribers = self._task_queues.get(key)
- if subscribers and queue in subscribers:
- subscribers.remove(queue)
- if not subscribers:
- del self._task_queues[key]
-
- def publish_task(self, task_id: int, event: str, data: dict) -> None:
- """发布任务事件。线程安全(put_nowait 无阻塞)。"""
- key = str(task_id)
- sse_event = SSEEvent(id=key, id_type="task", event=event, data=data)
- for queue in self._task_queues.get(key, []):
- queue.put_nowait(sse_event)
-
- # ── Session mode ──────────────────────────────────────────────────────
-
- async def subscribe_session(self, session_id: str) -> asyncio.Queue[SSEEvent]:
- """订阅会话事件流(Agent 多轮对话)。"""
+ async def subscribe(self, channel: str) -> asyncio.Queue[SSEEvent]:
+ """订阅频道事件流。"""
queue: asyncio.Queue[SSEEvent] = asyncio.Queue()
- self._session_queues[session_id].append(queue)
+ self._queues[channel].append(queue)
return queue
- async def unsubscribe_session(self, session_id: str, queue: asyncio.Queue[SSEEvent]) -> None:
- """取消会话订阅。"""
- subscribers = self._session_queues.get(session_id)
+ async def unsubscribe(self, channel: str, queue: asyncio.Queue[SSEEvent]) -> None:
+ """取消订阅。"""
+ subscribers = self._queues.get(channel)
if subscribers and queue in subscribers:
subscribers.remove(queue)
if not subscribers:
- del self._session_queues[session_id]
+ del self._queues[channel]
- def publish_session(self, session_id: str, event: str, data: dict) -> None:
- """发布会话事件。线程安全(put_nowait 无阻塞)。"""
- sse_event = SSEEvent(id=session_id, id_type="session", event=event, data=data)
- for queue in self._session_queues.get(session_id, []):
+ def publish(self, channel: str, event: str, data: dict) -> None:
+ """发布事件。线程安全(put_nowait 无阻塞)。"""
+ sse_event = SSEEvent(channel=channel, event=event, data=data)
+ for queue in self._queues.get(channel, []):
queue.put_nowait(sse_event)
diff --git a/backend/packages/app/src/windup_app/web/sse/progress.py b/backend/packages/app/src/windup_app/web/sse/progress.py
index 1bcb742..7e2f404 100644
--- a/backend/packages/app/src/windup_app/web/sse/progress.py
+++ b/backend/packages/app/src/windup_app/web/sse/progress.py
@@ -5,6 +5,12 @@
后台线程调用 ``step`` 时,通过 ``loop.call_soon_threadsafe`` 安全地
将事件发布到 event loop。
+
+使用示例::
+
+ port = SSEProgressPort(event_bus, task_id=123, loop=loop)
+ port.step("generating", 3, 6, "正在生成第 4 张候选图")
+ # → EventBus.publish("task:123", "progress", {...})
"""
from __future__ import annotations
@@ -30,8 +36,8 @@ def __init__(
def step(self, stage: str, i: int, total: int, note: str = "") -> None:
"""进度回调:从后台线程安全地发布到 event loop。"""
self._loop.call_soon_threadsafe(
- self._bus.publish_task,
- self._task_id,
+ self._bus.publish,
+ f"task:{self._task_id}",
"progress",
{"stage": stage, "current": i, "total": total, "note": note},
)
diff --git a/backend/packages/app/src/windup_app/web/sse/session.py b/backend/packages/app/src/windup_app/web/sse/session.py
deleted file mode 100644
index de7856b..0000000
--- a/backend/packages/app/src/windup_app/web/sse/session.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""SSE 流端点(Session mode)—— Agent 多轮对话。
-
-提供 ``GET /agent/sessions/{session_id}/stream`` 端点,
-客户端通过 EventSource 订阅 Agent 事件流。
-
-Session mode 特点:
-- 持续连接,不自动关闭(除非客户端断开)
-- 支持多种事件类型:thinking / message / tool_call / tool_result / status / error
-- 双向通信:客户端通过 HTTP POST 发送用户消息
-"""
-
-from __future__ import annotations
-
-import asyncio
-import json
-import logging
-
-from fastapi import APIRouter, Request
-from fastapi.responses import StreamingResponse
-
-from windup_app.web.sse.event_bus import EventBus, SSEEvent
-
-logger = logging.getLogger("windup.sse.session")
-
-router = APIRouter(prefix="/agent", tags=["sse"])
-
-# SSE 心跳间隔(秒)
-_HEARTBEAT_TIMEOUT = 30.0
-
-
-@router.get("/sessions/{session_id}/stream")
-async def stream_session(
- session_id: str,
- request: Request,
-) -> StreamingResponse:
- """SSE:Agent 多轮对话事件流。
-
- 事件类型:
- - ``thinking``: Agent 思考过程
- - ``message``: Agent 回复文本
- - ``tool_call``: Agent 调用工具
- - ``tool_result``: 工具返回结果
- - ``status``: 会话状态(waiting_input / processing / done)
- - ``error``: 错误信息
-
- Session mode 不会自动关闭连接,持续监听直到客户端断开。
- """
- event_bus: EventBus = request.app.state.event_bus
- queue = await event_bus.subscribe_session(session_id)
- logger.debug("SSE Session 订阅: session_id=%s", session_id)
-
- async def _event_generator():
- try:
- while True:
- if await request.is_disconnected():
- logger.debug("SSE Session 客户端断开: session_id=%s", session_id)
- break
- try:
- event: SSEEvent = await asyncio.wait_for(
- queue.get(), timeout=_HEARTBEAT_TIMEOUT,
- )
- payload = json.dumps(event.data, ensure_ascii=False)
- yield f"event: {event.event}\ndata: {payload}\n\n"
- except asyncio.TimeoutError:
- yield ": heartbeat\n\n"
- finally:
- await event_bus.unsubscribe_session(session_id, queue)
- logger.debug("SSE Session 取消订阅: session_id=%s", session_id)
-
- return StreamingResponse(
- _event_generator(),
- media_type="text/event-stream",
- headers={
- "Cache-Control": "no-cache",
- "Connection": "keep-alive",
- "X-Accel-Buffering": "no",
- },
- )
diff --git a/backend/packages/app/src/windup_app/web/sse/stream.py b/backend/packages/app/src/windup_app/web/sse/stream.py
index f3d9874..5a0c812 100644
--- a/backend/packages/app/src/windup_app/web/sse/stream.py
+++ b/backend/packages/app/src/windup_app/web/sse/stream.py
@@ -1,89 +1,56 @@
-"""SSE 流端点(Task mode)。
+"""SSE 通用推送端点。
-提供 ``GET /generation/tasks/{task_id}/stream`` 端点,
-客户端通过 EventSource 订阅任务进度推送。
+提供 ``GET /sse/{channel}/stream`` 端点,客户端通过 EventSource 订阅任意频道的事件流。
+
+本模块是纯基础设施,不引用任何业务域代码。调用方通过 EventBus.publish(channel, event, data)
+发布事件,本端点负责将事件流式推送给订阅者。
+
+行为:
+- 收到 ``completed`` 或 ``failed`` 事件后关闭连接
+- 超时无事件则发送心跳保活
"""
from __future__ import annotations
import asyncio
-import dataclasses
import json
import logging
-from fastapi import APIRouter, Depends, Query, Request
+from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
-from sqlalchemy.orm import Session
-from windup_app.server.orchestrator.model import TaskStatus
from windup_app.web.sse.event_bus import EventBus, SSEEvent
-from windup_framework.db import get_session
logger = logging.getLogger("windup.sse.stream")
-router = APIRouter(prefix="/generation", tags=["sse"])
+router = APIRouter(tags=["sse"])
# SSE 心跳间隔(秒):超过此时间无事件则发送注释保活
_HEARTBEAT_TIMEOUT = 30.0
-
-def _task_to_sse_data(task) -> dict | None:
- """任务领域对象 → SSE 事件 data(仅终态)。"""
- if task.status == TaskStatus.COMPLETED:
- result_dict = None
- if task.result is not None:
- result_dict = dataclasses.asdict(task.result)
- return result_dict or {}
- if task.status == TaskStatus.FAILED:
- return {"error": task.error_message or "未知错误"}
- return None
+# 收到这些事件类型后关闭连接
+_TERMINAL_EVENTS = {"completed", "failed"}
-@router.get("/tasks/{task_id}/stream")
-async def stream_task(
- task_id: int,
+@router.get("/sse/{channel}/stream")
+async def stream_channel(
+ channel: str,
request: Request,
- project_id: int = Query(..., gt=0),
- session: Session = Depends(get_session),
) -> StreamingResponse:
- """SSE:实时推送任务进度与最终结果。
+ """SSE:订阅指定频道的事件流。
- 事件类型:
- - ``status``: 任务状态变更(pending/running/completed/failed)
- - ``progress``: 生成管线进度(stage/current/total)
- - ``completed``: 任务完成,携带最终结果
- - ``failed``: 任务失败,携带错误信息
-
- 若客户端订阅时任务已处于终态,立即推送终态事件并关闭连接。
+ 频道名由调用方自定义(如 ``task:123``、``run:456:node:7``)。
+ 事件类型和数据结构由调用方定义,本端点只负责推送。
"""
- from windup_app.server.orchestrator.service import service as generation_service
-
event_bus: EventBus = request.app.state.event_bus
-
- # 检查任务初始状态:若已终态,立即推送并关闭
- task = generation_service.get_task(session, project_id, task_id)
- if task is not None and task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED):
- event = "completed" if task.status == TaskStatus.COMPLETED else "failed"
- data = _task_to_sse_data(task) or {}
-
- async def _immediate():
- yield f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
-
- return StreamingResponse(
- _immediate(),
- media_type="text/event-stream",
- headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
- )
-
- # 订阅事件流
- queue = await event_bus.subscribe_task(task_id)
- logger.debug("SSE 订阅: task_id=%d", task_id)
+ queue = await event_bus.subscribe(channel)
+ logger.debug("SSE 订阅: channel=%s", channel)
async def _event_generator():
try:
while True:
if await request.is_disconnected():
- logger.debug("SSE 客户端断开: task_id=%d", task_id)
+ logger.debug("SSE 客户端断开: channel=%s", channel)
break
try:
event: SSEEvent = await asyncio.wait_for(
@@ -91,14 +58,14 @@ async def _event_generator():
)
payload = json.dumps(event.data, ensure_ascii=False)
yield f"event: {event.event}\ndata: {payload}\n\n"
- if event.event in ("completed", "failed"):
- logger.debug("SSE 终态: task_id=%d event=%s", task_id, event.event)
+ if event.event in _TERMINAL_EVENTS:
+ logger.debug("SSE 终态: channel=%s event=%s", channel, event.event)
break
except asyncio.TimeoutError:
yield ": heartbeat\n\n"
finally:
- await event_bus.unsubscribe_task(task_id, queue)
- logger.debug("SSE 取消订阅: task_id=%d", task_id)
+ await event_bus.unsubscribe(channel, queue)
+ logger.debug("SSE 取消订阅: channel=%s", channel)
return StreamingResponse(
_event_generator(),