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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Windup Product Domains + 最终产品业务能力与核心依赖 + + + FOUNDATION DOMAINS + 基础业务域:管理基础数据,被其他域消费 + + + + + WORKFLOW DOMAINS + 工作流域:任务调度与编排 + + + + + PIPELINE DOMAIN + 生成管线:提示词→出图→抠图→截帧 + + + + + RESULT DOMAINS + 结果域:处理生成产物(审核/预览/导出) + + + + + + + + + + user + 身份与会话 + + + + quota + 订阅与积分 + + + + + project + 全局约束容器 + + + + media + 输入参考素材 + + + + character / asset + 角色与产物 + + + + + workflow + 定义 / 节点 / 模板 + + + + execution + 调度 / 重试 / 缓存 + + + + orchestrator + 生成任务调度 + + + + ai_engine + 生成管线 + + + + review + 质检 + 人工审核 + + + + preview + 预览台 + + + + export + 格式转换与下载 + + + 定义 + + + 节点调用 + + + 调用管线 + + + 正式资产 + + + 导出数据 + + + + + 业务编排 + + 读取 / 消费 + + MVP 已实现 + + 部分实现 + + 待实现 + + + Windup · Final Product Domain Map + + 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/ 目录。 + +--- + +## 业务域全景 + +![Windup 最终产品业务域图](diagrams/product-domains.svg) + +--- + +## 依赖方向 + +``` + 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 @@ - + @@ -43,129 +43,106 @@ .metric-value { font-size: 9.5px; font-weight: 700; fill: #111827; } - - Windup Product Domains - 最终产品业务能力与核心依赖 + + Windup Product Domains + 最终产品业务能力与核心依赖 FOUNDATION DOMAINS - 基础业务域:管理基础数据,被其他域消费 - - + + WORKFLOW DOMAINS - 工作流域:任务调度与编排 - - - PIPELINE DOMAIN - 生成管线:提示词→出图→抠图→截帧 - + + + AI ENGINE DOMAIN + RESULT DOMAINS - 结果域:处理生成产物(审核/预览/导出) - - - - - + + + + + - + user 身份与会话 - + quota 订阅与积分 - - + + project 全局约束容器 - + media 输入参考素材 - + character / asset 角色与产物 - - - - workflow - 定义 / 节点 / 模板 + + + agent + 懒人智能体 - - - execution - 调度 / 重试 / 缓存 + + + + workflow + 工作流画布 - - - orchestrator - 生成任务调度 + + + orchestrator + 生成任务调度 - - - ai_engine - 生成管线 + + + ai_engine + 模型能力适配 - + review 质检 + 人工审核 - - - preview - 预览台 + + + playtest + 预览与试玩 - + export 格式转换与下载 - - 定义 - - - 节点调用 - - - 调用管线 - - - 正式资产 - - - 导出数据 - - + 业务编排 读取 / 消费 - - MVP 已实现 - - 部分实现 - - 待实现 + + 产物 / 输入流 Windup · Final Product Domain Map - + \ 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(),