Selaa lähdekoodia

1. 扩展了工作流编辑器字段「引入」能力,输出节点的接收字段支持从前驱节点输出引入、输入节点的输入变量支持从后继节点输入引入,关联语义与普通节点对齐;
2. 统一了全局对话框按钮风格,主操作改为蓝色渐变、次要操作改为暗色透明、危险操作改为红色渐变,并强制将 warning 按钮降级为默认样式,彻底消除橙黄色按钮;
3. 收敛了所有弹出对话框的关闭行为,为删除确认、字段引入、标签编辑、文档删除等弹窗统一设置 maskClosable=false,避免误点击遮罩层导致操作丢失;
4. 新增了工作流 CRUD 接口设计文档(docs/workflow-crud-api.md),覆盖名称规范、图结构校验、版本快照与分类绑定等约定;
5. 新增了工作流节点输出统一包装({status, message, data})实施方案文档(docs/workflow-node-output-envelope.md),明确严格按画布声明产出、重试触发条件与节点级配置覆盖策略;
6. 更新了 prompt.md 需求记录。

weisijie 1 kuukausi sitten
vanhempi
commit
d5cfb9cf0d

+ 586 - 0
docs/workflow-crud-api.md

@@ -0,0 +1,586 @@
+# 工作流增删改查接口说明(Workflow CRUD API)
+
+> 版本:v1.0
+> 适用范围:平台前端、运维工具对工作流元数据与图结构进行创建、查询、修改、删除、分类绑定等管理操作。
+> 实现来源:`backend/src/main/java/com/agent/management/controller/WorkflowController.java`、`service/impl/WorkflowServiceImpl.java`、`parser/WorkflowNameNormalizer.java`。
+> 如需第三方系统提交运行并订阅执行流,请参考 [`external-workflow-api-spec.md`](./external-workflow-api-spec.md)。
+
+---
+
+## 1. 设计要点
+
+| 要点 | 说明 |
+|---|---|
+| 标识符 | 工作流对外暴露的唯一标识为 `name`(kebab-case),URL 路由全部按 `name` 寻址;旧数字 ID 作为字符串回退兼容(路径参数 `{name}` 若为纯数字且无 kebab-case 匹配,则按 ID 查询) |
+| 名称规范 | `name` 必须为 kebab-case:`^[a-z0-9]+(-[a-z0-9]+)*$`,长度 ≤ 64;非法字符在创建/重命名时抛 `BusinessException`(HTTP 400) |
+| 中文名 | `displayName` 承载可读性与本地化,可为任意字符;未提供时默认取 `name` |
+| 图结构校验 | 保存 `graphData` 时强制校验:至少 1 个 `userInput` 节点 + 至少 1 个 `output` 节点,且二者之间存在长度 ≥ 2 的有向路径(中间至少经过 1 个非输出节点) |
+| 部分更新 | 修改接口对 `displayName`/`description`/`graphData` 支持部分更新:字段为 `null` 时跳过,不覆盖原值 |
+| 版本快照 | 保存接口在持久化后自动创建一份版本快照(双轨目录:优先 `name`,回退 `id`);快照失败不影响保存结果,仅 WARN 日志 |
+| 分类绑定 | 工作流分类(`categoryId`)通过独立接口设置;`categoryId = null` 表示取消分类 |
+
+---
+
+## 2. 通用约定
+
+### 2.1 基础路径
+
+```
+http(s)://<host>:<port>/api/workflows
+```
+
+- 默认端口:`2438`(见 `application.yml: server.port`)
+- 路径前缀:`/api/workflows`(与对外发布的 `/api/v1/workflows` 隔离,前者用于平台内部管理,后者需 `X-API-Key` 鉴权)
+
+### 2.2 内容类型
+
+| 场景 | Content-Type |
+|---|---|
+| 请求体(JSON) | `application/json` |
+| 响应体 | `application/json` |
+
+### 2.3 统一响应包络
+
+所有接口统一返回 `Result<T>`:
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": { ... }
+}
+```
+
+错误时:
+
+```json
+{
+  "code": 400,
+  "message": "工作流名称必须为 kebab-case 格式(仅小写字母、数字、短横线,禁用大写/空格/下划线/中文): 我的流程",
+  "data": null
+}
+```
+
+### 2.4 错误码
+
+| code | 含义 | 触发场景 |
+|---|---|---|
+| `200` | 成功 | 正常返回 |
+| `400` | 业务校验失败 | 名称非法、名称重复、图结构不合法、工作流不存在 |
+| `500` | 服务器内部错误 | 未捕获异常(堆栈已记录到日志,响应体不泄漏) |
+
+> 业务异常(`BusinessException`)默认 code = 400,HTTP 状态码仍为 200,由 `Result.code` 区分;系统异常 HTTP 状态码 = 500。
+
+### 2.5 标识符与字段
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `id` | number | 工作流主键(内部使用,URL 不依赖) |
+| `name` | string | kebab-case 唯一标识,URL 路由使用此字段;旧数据迁移后默认值为 `String.valueOf(id)`(纯数字天然合法) |
+| `displayName` | string | 中文显示名,可为任意字符 |
+| `description` | string | 工作流描述(用途、输入输出、注意事项等) |
+| `graphData` | string | 节点和边数据,JSON 字符串;空图 = `{"nodes":[],"edges":[]}` |
+| `categoryId` | number | 分类 ID,可空 |
+| `categoryName` | string | 分类名称(仅响应体,由后端关联查询填充) |
+| `tags` | array | 关联标签列表(仅响应体) |
+| `createdAt` / `updatedAt` | string (ISO-8601) | 创建/更新时间 |
+
+---
+
+## 3. 接口总览
+
+| # | 方法 | 路径 | 用途 |
+|---|---|---|---|
+| 3.1 | `GET`    | `/api/workflows`                    | 分页查询工作流列表(支持搜索、分类过滤) |
+| 3.2 | `GET`    | `/api/workflows/{name}`             | 查询单个工作流详情 |
+| 3.3 | `POST`   | `/api/workflows`                    | 创建工作流(仅元数据,图数据默认为空) |
+| 3.4 | `POST`   | `/api/workflows/{name}/save`        | 保存工作流(元数据 + 图数据,支持部分更新 + 自动版本快照) |
+| 3.5 | `POST`   | `/api/workflows/{name}/delete`      | 删除工作流 |
+| 3.6 | `PUT`    | `/api/workflows/{name}/category`    | 设置/取消工作流分类 |
+
+---
+
+## 4. 接口详细规范
+
+### 3.1 分页查询工作流列表
+
+**请求**
+
+```
+GET /api/workflows?search={kw}&categoryId={id}&page={page}&size={size}
+```
+
+**查询参数**
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---|---|---|
+| `search` | string | 否 | 空 | 关键字,匹配 `name` / `displayName` / `description`(大小写不敏感,子串匹配) |
+| `categoryId` | number | 否 | 空 | 分类 ID;过滤时包含子孙分类(调用 `WfCategoryService.getDescendantCategoryIds` 展开后再过滤) |
+| `page` | number | 否 | `1` | 页码,从 1 开始 |
+| `size` | number | 否 | `12` | 每页条数 |
+
+**响应**
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "items": [
+      {
+        "id": 12,
+        "name": "research-report",
+        "displayName": "研究报告生成",
+        "description": "根据主题生成结构化研究报告",
+        "graphData": "{\"nodes\":[...],\"edges\":[...]}",
+        "categoryId": 3,
+        "categoryName": "研发辅助",
+        "createdAt": "2026-07-01T08:30:00",
+        "updatedAt": "2026-07-15T10:12:33",
+        "tags": [
+          { "id": 5, "name": "报告" },
+          { "id": 8, "name": "LLM" }
+        ]
+      }
+    ],
+    "total": 1,
+    "page": 1,
+    "size": 12
+  }
+}
+```
+
+> **实现说明:** 列表先全量加载 (`WorkflowService.listWorkflows`) 再内存过滤分页,适合工作流数量 < 1000 的场景;若规模增长需切换为数据库分页。
+
+---
+
+### 3.2 查询单个工作流详情
+
+**请求**
+
+```
+GET /api/workflows/{name}
+```
+
+**路径参数**
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `name` | string | 是 | 工作流 `name`(kebab-case);若为纯数字字符串且无 kebab-case 匹配,回退按 `id` 查询(兼容旧 URL) |
+
+**响应**
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "id": 12,
+    "name": "research-report",
+    "displayName": "研究报告生成",
+    "description": "根据主题生成结构化研究报告",
+    "graphData": "{\"nodes\":[...],\"edges\":[...]}",
+    "categoryId": 3,
+    "categoryName": "研发辅助",
+    "createdAt": "2026-07-01T08:30:00",
+    "updatedAt": "2026-07-15T10:12:33",
+    "tags": [
+      { "id": 5, "name": "报告" }
+    ]
+  }
+}
+```
+
+**错误码**
+
+| code | 触发场景 |
+|---|---|
+| `400` | 工作流不存在:`工作流不存在: {name}` |
+
+---
+
+### 3.3 创建工作流
+
+**请求**
+
+```
+POST /api/workflows
+Content-Type: application/json
+```
+
+**请求体**
+
+```json
+{
+  "name": "research-report",
+  "displayName": "研究报告生成",
+  "description": "根据主题生成结构化研究报告"
+}
+```
+
+**字段说明**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `name` | string | 是 | kebab-case 唯一标识;服务端会执行 `normalize + validate`:转小写、空格/下划线转短横线、合并连续短横线、剔非法字符,最终必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$` 且长度 ≤ 64 |
+| `displayName` | string | 否 | 中文名;为空时默认取规范化后的 `name` |
+| `description` | string | 否 | 工作流描述 |
+
+**响应**
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "id": 13,
+    "name": "research-report",
+    "displayName": "研究报告生成",
+    "description": "根据主题生成结构化研究报告",
+    "graphData": "{\"nodes\":[],\"edges\":[]}",
+    "categoryId": null,
+    "categoryName": null,
+    "createdAt": "2026-07-15T10:15:00",
+    "updatedAt": "2026-07-15T10:15:00",
+    "tags": []
+  }
+}
+```
+
+> 创建时 `graphData` 默认为 `{"nodes":[],"edges":[]}`(空图),不触发图结构校验;后续通过 §3.4 保存时才校验。
+
+**错误码**
+
+| code | 触发场景 |
+|---|---|
+| `400` | 名称非法:`工作流名称必须为 kebab-case 格式(仅小写字母、数字、短横线,禁用大写/空格/下划线/中文): {input}` |
+| `400` | 名称重复:`工作流名称已存在: {name}` |
+
+---
+
+### 3.4 保存工作流(更新)
+
+**请求**
+
+```
+POST /api/workflows/{name}/save
+Content-Type: application/json
+```
+
+**路径参数**
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `name` | string | 是 | 目标工作流 `name`(或旧数字 ID) |
+
+**请求体**
+
+```json
+{
+  "name": "research-report-v2",
+  "displayName": "研究报告生成(增强版)",
+  "description": "增加了多源检索与图表生成",
+  "graphData": "{\"nodes\":[...],\"edges\":[...]}"
+}
+```
+
+**字段说明**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `name` | string | 否 | 新名称;非空时执行规范化 + 唯一性校验(排除自身 ID),通过后更新;与原值相同则跳过唯一性校验 |
+| `displayName` | string | 否 | 为 `null` 时保留原值;为空串时覆盖为空串 |
+| `description` | string | 否 | 为 `null` 时保留原值 |
+| `graphData` | string | 否 | 为 `null` 时保留原图;非空时先经 `NodeTypeUtils.normalizeGraphData` 规范化,再走图结构校验 |
+
+> **部分更新语义:** 任一字段为 `null` 都不会被覆盖;如需清空 `description`,请显式传 `""`。
+
+**响应**
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "id": 13,
+    "name": "research-report-v2",
+    "displayName": "研究报告生成(增强版)",
+    "description": "增加了多源检索与图表生成",
+    "graphData": "{\"nodes\":[...],\"edges\":...}",
+    "categoryId": 3,
+    "categoryName": "研发辅助",
+    "createdAt": "2026-07-01T08:30:00",
+    "updatedAt": "2026-07-15T10:20:00",
+    "tags": [...]
+  }
+}
+```
+
+**副作用:自动版本快照**
+
+保存成功后,控制器会调用 `WorkflowVersionServiceImpl.createSnapshot(workflowId, name, graphData, name, null)` 创建一份版本快照(双轨目录:优先 `name`,回退 `id`)。快照创建失败不影响保存结果,仅输出 WARN 日志:
+
+```
+[Save] 创建版本快照失败,不影响保存: {errorMessage}
+```
+
+快照可用于回滚(`POST /api/workflows/{name}/versions/{version}/rollback`)与历史对比。
+
+**错误码**
+
+| code | 触发场景 |
+|---|---|
+| `400` | 工作流不存在:`工作流不存在: {name}` |
+| `400` | 新名称非法:`工作流名称必须为 kebab-case 格式...` |
+| `400` | 新名称重复:`工作流名称已存在: {name}` |
+| `400` | 图数据为空:`工作流图数据不能为空` |
+| `400` | 缺少输入节点:`工作流必须包含至少一个用户输入节点` |
+| `400` | 缺少输出节点:`工作流必须包含至少一个输出节点` |
+| `400` | 输入输出未连通:`用户输入节点与输出节点必须通过其他节点连接起来` |
+
+**图结构校验规则**
+
+1. 至少 1 个 `userInput` 类型节点
+2. 至少 1 个 `output` 类型节点
+3. 从任一 `userInput` 到任一 `output` 必须存在有向路径,且路径上至少包含 1 个非输出中间节点(直接 `userInput → output` 边不满足)
+4. DAG 无环(由 `DagResolver.resolve` 隐式保证)
+
+---
+
+### 3.5 删除工作流
+
+**请求**
+
+```
+POST /api/workflows/{name}/delete
+```
+
+**路径参数**
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `name` | string | 是 | 目标工作流 `name`(或旧数字 ID) |
+
+无请求体。
+
+**响应**
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": null
+}
+```
+
+**错误码**
+
+| code | 触发场景 |
+|---|---|
+| `400` | 工作流不存在:`工作流不存在: {name}` |
+
+> **注意:** 当前实现仅删除工作流记录本身,不会级联清理:
+> - 历史运行记录(`WorkflowRun` / `WorkflowRunNode`)
+> - 工作目录文件(`<data-dir>/workflow-runs/{workflowId}/{runId}/`)
+> - 版本快照目录(`<data-dir>/workflow-versions/{name|id}/`)
+> - 标签关联(`TagAssignment` 中 `entityType=workflow, entityId={id}`)
+>
+> 如需彻底清理,请手动调用运行历史与版本管理接口,或编写专门的清理脚本。
+
+---
+
+### 3.6 设置/取消工作流分类
+
+**请求**
+
+```
+PUT /api/workflows/{name}/category
+Content-Type: application/json
+```
+
+**路径参数**
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `name` | string | 是 | 目标工作流 `name`(或旧数字 ID) |
+
+**请求体**
+
+```json
+{
+  "categoryId": 3
+}
+```
+
+**字段说明**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `categoryId` | number | 否 | 分类 ID;传 `null` 表示取消分类(`categoryId` 置空) |
+
+**响应**
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": null
+}
+```
+
+> 接口不校验 `categoryId` 是否存在(即使分类被删除,工作流 `categoryId` 仍保留原值),列表查询时 `categoryName` 关联查询失败则为 `null`。
+
+**错误码**
+
+| code | 触发场景 |
+|---|---|
+| `400` | 工作流不存在:`工作流不存在: {name}` |
+
+---
+
+## 5. curl 调用示例
+
+### 5.1 创建工作流
+
+```bash
+curl -X POST http://localhost:2438/api/workflows \
+  -H "Content-Type: application/json" \
+  -d '{
+    "name": "research-report",
+    "displayName": "研究报告生成",
+    "description": "根据主题生成结构化研究报告"
+  }'
+```
+
+### 5.2 查询列表(带搜索 + 分类过滤)
+
+```bash
+curl "http://localhost:2438/api/workflows?search=report&categoryId=3&page=1&size=20"
+```
+
+### 5.3 查询单个详情
+
+```bash
+curl http://localhost:2438/api/workflows/research-report
+
+# 兼容旧数字 ID
+curl http://localhost:2438/api/workflows/12
+```
+
+### 5.4 保存(更新图结构 + 重命名)
+
+```bash
+curl -X POST http://localhost:2438/api/workflows/research-report/save \
+  -H "Content-Type: application/json" \
+  -d '{
+    "name": "research-report-v2",
+    "displayName": "研究报告生成(增强版)",
+    "description": "增加了多源检索",
+    "graphData": "{\"nodes\":[{\"id\":\"n1\",\"type\":\"userInput\",\"data\":{\"label\":\"输入\"}},{\"id\":\"n2\",\"type\":\"llm\",\"data\":{\"label\":\"生成\"}},{\"id\":\"n3\",\"type\":\"output\",\"data\":{\"label\":\"输出\"}}],\"edges\":[{\"source\":\"n1\",\"target\":\"n2\"},{\"source\":\"n2\",\"target\":\"n3\"}]}"
+  }'
+```
+
+### 5.5 仅修改描述(部分更新)
+
+```bash
+curl -X POST http://localhost:2438/api/workflows/research-report/save \
+  -H "Content-Type: application/json" \
+  -d '{
+    "description": "更新后的描述,其他字段保持不变"
+  }'
+```
+
+> 注意:`name` / `displayName` / `graphData` 未传(或为 `null`)时不会覆盖原值。
+
+### 5.6 删除工作流
+
+```bash
+curl -X POST http://localhost:2438/api/workflows/research-report/delete
+```
+
+### 5.7 设置分类
+
+```bash
+curl -X PUT http://localhost:2438/api/workflows/research-report/category \
+  -H "Content-Type: application/json" \
+  -d '{ "categoryId": 3 }'
+```
+
+### 5.8 取消分类
+
+```bash
+curl -X PUT http://localhost:2438/api/workflows/research-report/category \
+  -H "Content-Type: application/json" \
+  -d '{ "categoryId": null }'
+```
+
+---
+
+## 6. 名称规范化规则详解
+
+`WorkflowNameNormalizer` 提供 `normalize` 与 `normalizeStrict` 两个方法,创建/重命名时使用 `normalizeStrict`:
+
+| 步骤 | 操作 | 示例 |
+|---|---|---|
+| 1. 转小写 + trim | `"My Workflow"` → `"my workflow"` | — |
+| 2. 空格/下划线转短横线 | `"my workflow_v2"` → `"my-workflow-v2"` | — |
+| 3. 合并连续短横线 | `"my--workflow"` → `"my-workflow"` | — |
+| 4. 去首尾短横线 | `"-my-workflow-"` → `"my-workflow"` | — |
+| 5. 剔除非法字符 | `"my-workflow中文"` → `"my-workflow"` | 中文、特殊符号被移除 |
+| 6. 校验最终结果 | 必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$` 且 ≤ 64 字符 | 不匹配则抛 `BusinessException` |
+
+**典型用例:**
+
+| 输入 | 规范化后 | 是否合法 |
+|---|---|---|
+| `research-report` | `research-report` | ✅ |
+| `Research Report` | `research-report` | ✅ |
+| `research_report_v2` | `research-report-v2` | ✅ |
+| `research--report` | `research-report` | ✅ |
+| `研究报告` | ``(空) | ❌ 全部被剔除 |
+| `report!` | `report` | ✅ |
+| `-invalid` | `invalid` | ✅ |
+| `a` | `a` | ✅ |
+| ``(空串) | `` | ❌ 长度为 0 |
+
+---
+
+## 7. 与版本管理接口的协作
+
+保存接口(§3.4)会自动创建版本快照,相关版本管理接口(已在 `WorkflowController` 实现,本文不展开):
+
+| 接口 | 方法 | 路径 |
+|---|---|---|
+| 列出版本 | `GET` | `/api/workflows/{name}/versions` |
+| 查询版本内容 | `GET` | `/api/workflows/{name}/versions/{version}/content` |
+| 回滚到版本 | `POST` | `/api/workflows/{name}/versions/{version}/rollback` |
+| 修改版本说明 | `PUT` | `/api/workflows/{name}/versions/{version}/message` |
+
+回滚操作内部会调用 `WorkflowService.updateWorkflow` 恢复 `graphData`,并触发与 §3.4 相同的图结构校验。
+
+---
+
+## 8. 限制与约束
+
+| 项目 | 限制 | 说明 |
+|---|---|---|
+| `name` 长度 | ≤ 64 字符 | `WorkflowNameNormalizer.MAX_LENGTH` |
+| `name` 字符集 | `[a-z0-9-]` | kebab-case 严格匹配 |
+| `graphData` 大小 | 无显式限制 | 受 `spring.servlet.multipart.max-request-size`(默认 500MB)间接约束;前端建议 < 1MB |
+| 图节点数 | 无显式限制 | 实际受 DAG 解析性能约束,建议 < 100 |
+| 并发更新 | 无乐观锁 | 同一工作流并发 `save` 可能后写覆盖先写;多用户协作场景需前端层面加锁 |
+| 删除级联 | 不清理历史运行/版本/工作目录 | 见 §3.5 注意事项 |
+| 名称重命名 | 允许 | 重命名后旧 `name` URL 失效;历史运行目录仍按原 `id` 寻址,不受影响 |
+
+---
+
+## 9. 与现有内部接口的映射关系
+
+| 接口 | Controller 方法 | Service 方法 | 关键实现 |
+|---|---|---|---|
+| `GET /api/workflows` | `WorkflowController.list` | `WorkflowService.listWorkflows` | 内存过滤分页 + `WfCategoryService.getDescendantCategoryIds` 展开子孙分类 |
+| `GET /api/workflows/{name}` | `WorkflowController.get` | `WorkflowService.getWorkflow` | `name` 优先,回退 `id`(数字字符串匹配) |
+| `POST /api/workflows` | `WorkflowController.create` | `WorkflowService.createWorkflow` | `WorkflowNameNormalizer.normalizeStrict` + `existsByNameExcluding` 唯一性校验 |
+| `POST /api/workflows/{name}/save` | `WorkflowController.save` | `WorkflowService.updateWorkflow` | 部分更新 + `NodeTypeUtils.normalizeGraphData` + `validateWorkflowGraph` 图结构校验 + 自动版本快照 |
+| `POST /api/workflows/{name}/delete` | `WorkflowController.delete` | `WorkflowService.deleteWorkflow` | `getWorkflow` + `workflowRepo.deleteById` |
+| `PUT /api/workflows/{name}/category` | `WorkflowController.setCategory` | `WorkflowService.updateCategory` | 直接更新 `categoryId`(允许 `null`) |

+ 423 - 0
docs/workflow-node-output-envelope.md

@@ -0,0 +1,423 @@
+# 工作流节点输出统一包装(`{status, message, data}`)实施方案 v2
+
+> 修订说明:本版在 v1(机械包装)基础上新增"灵活产出 + 重试机制"。已确认设计决策:
+> - **严格按画布声明**:data 内字段 = 节点 outputs/variables 声明,多余字段丢弃
+> - **重试触发**:仅结构问题(JSON 解析失败/必填字段缺失)+ 类型问题(无法转换)
+> - **SmartAction 与 LLM 节点统一逻辑**:用户在画布声明多个变量,LLM 按描述填充
+> - **配置粒度**:全局默认 + 节点级覆盖
+
+## 一、需求总览
+
+### 1.1 统一输出格式
+
+```json
+{
+  "status": 200,
+  "message": "调用成功",
+  "data": {
+    "fieldA": "XXXX", //(字段A输出结果)
+    "fieldB": 15.83 //(字段B输出结果)
+  }
+}
+```
+
+- `status`:200 成功 / 400 失败
+- `message`:人类可读结果/失败原因
+- `data`:节点声明的输出变量集合(严格按画布声明,不收纳多余字段)
+
+### 1.2 后继节点解析规则
+
+- 从前序节点 **`data` 字段内部**取变量(不再读扁平 Map 顶层)
+- 支持读取 `data` 内的子字段(如 `data.profile.name`)
+- 失败节点(`status=400`)写入上下文,下游可感知失败状态
+
+### 1.3 灵活产出能力(v2 新增)
+
+| 能力 | 适用节点 | 说明 |
+|---|---|---|
+| **结构化输出** | LLM / SmartAction / Agent / Hermes* | 即使只声明 1 个字段也走 JSON 模式,让 LLM 按字段定义产出 |
+| **自动重试** | 同上 | JSON 解析失败、必填字段缺失、类型转换失败时自动重试 |
+| **字段填充校验** | 同上 | 必填字段缺失则触发重试或失败 |
+
+### 1.4 SmartAction 升级
+
+- 画布上支持声明 outputs 列表(与 LLM 节点一致)
+- 执行器读取 `actionPrompt`(操作要求)作为 user prompt
+- 走与 LLM 节点完全一致的结构化输出 + 重试流程
+
+---
+
+## 二、核心设计
+
+### 2.1 `NodeOutputEnvelope` 值对象
+
+**文件**:`backend/.../engine/NodeOutputEnvelope.java`
+
+```java
+public class NodeOutputEnvelope {
+    public static final int STATUS_SUCCESS = 200;
+    public static final int STATUS_FAILED = 400;
+
+    private final int status;
+    private final String message;
+    private final Map<String, Object> data;
+
+    public static NodeOutputEnvelope success(String message, Map<String, Object> data);
+    public static NodeOutputEnvelope failure(String message);
+    public static NodeOutputEnvelope of(int status, String message, Map<String, Object> data);
+
+    public Map<String, Object> toMap();
+    public static NodeOutputEnvelope fromObject(Object raw);
+    public static boolean isEnvelope(Object raw);
+}
+```
+
+### 2.2 `StructuredOutputHelper` 增强(核心)
+
+**文件**:`backend/.../engine/StructuredOutputHelper.java`
+
+**改造点**:
+
+1. **强制 JSON 输出**:`needsStructuredOutput()` 改为只要 `outputs` 非空就返回 true(移除 `size > 1` 判断),让所有 LLM 类节点统一走结构化模式。
+
+2. **新增 `extractWithRetry()` 方法**:
+
+```java
+public static class ExtractionResult {
+    public final Map<String, Object> data;
+    public final String errorMessage;  // null 表示成功
+    public final int attempts;         // 实际尝试次数
+}
+
+public static ExtractionResult extractWithRetry(
+    ChatClient client,
+    String userPrompt,
+    String systemPrompt,
+    JsonNode outputsDecl,
+    RetryPolicy retryPolicy
+);
+```
+
+**流程**:
+```
+for (attempt = 1; attempt <= maxRetries; attempt++) {
+    String prompt = userPrompt + buildInstruction(outputsDecl, lastError);
+    String response = client.prompt().user(prompt).system(systemPrompt).call().content();
+    Map<String, Object> parsed = parse(response, outputsDecl);
+    ValidationResult validation = OutputValidator.validate(parsed, outputsDecl);
+    if (validation.ok) return ExtractionResult.success(parsed, attempt);
+    lastError = validation.errorMessage;
+}
+return ExtractionResult.failure(lastError, attempt);
+}
+```
+
+3. **`buildInstruction()` 增强**:接受 `lastError` 参数,重试时在指令中追加"上一次返回错误:xxx,请修正"。
+
+4. **指令强化**:在 JSON 模板前增加"严格按要求输出,字段缺失或类型不符将导致流程失败"的强调。
+
+### 2.3 新增 `OutputValidator`
+
+**文件**:`backend/.../engine/OutputValidator.java`
+
+```java
+public class OutputValidator {
+    public static class ValidationResult {
+        public final boolean ok;
+        public final String errorMessage;
+        public final List<String> missingRequired;  // 缺失的必填字段
+        public final List<String> typeMismatched;    // 类型不匹配的字段
+    }
+
+    public static ValidationResult validate(Map<String, Object> parsed, JsonNode outputsDecl);
+}
+```
+
+**校验规则**:
+- **必填字段**:`outputs[i].required == true` 但 `parsed` 中无值或为 null → 加入 `missingRequired`
+- **类型校验**:声明为 `number` 但值为字符串且无法转换、声明为 `array` 但非数组、声明为 `object` 但非 Map → 加入 `typeMismatched`
+- **类型自动转换**:声明 `number` + 字符串数字 → 静默转换(不算 mismatch)
+
+### 2.4 新增 `RetryPolicy` (默认重试次数在application.yml中设置,同步更新application.yml.example)
+
+**文件**:`backend/.../engine/RetryPolicy.java`
+
+```java
+public class RetryPolicy {
+    public final int maxRetries;             // 默认 2,节点级可覆盖
+    public final boolean retryOnParseError;  // JSON 解析失败重试,默认 true
+    public final boolean retryOnMissingRequired;  // 必填字段缺失重试,默认 true
+    public final boolean retryOnTypeMismatch;     // 类型不匹配重试,默认 true
+
+    public static RetryPolicy fromNodeData(JsonNode data, WorkflowProperties props);
+    public static RetryPolicy defaultPolicy();
+}
+```
+
+**配置读取优先级**:
+1. 节点 `data.maxRetries` / `data.retryOn*`
+2. 全局 `application.yml` 中 `workflow.llm.retry.*`
+3. 代码内默认值(maxRetries=2,全部 retryOn=true)
+
+### 2.5 全局配置(`application.yml`)
+
+```yaml
+workflow:
+  llm:
+    retry:
+      max-retries: 2
+      retry-on-parse-error: true
+      retry-on-missing-required: true
+      retry-on-type-mismatch: true
+```
+
+**新增 `WorkflowProperties`** 配置类(`@ConfigurationProperties("workflow")`),通过 Spring 注入。
+
+### 2.6 各节点改造明细
+
+#### 2.6.1 LLM 类节点(LLM / SmartAction / Agent / HermesAgent / HermesSmartAction)
+
+**统一调用模式**:
+
+```java
+RetryPolicy policy = RetryPolicy.fromNodeData(data, workflowProperties);
+StructuredOutputHelper.ExtractionResult extResult = StructuredOutputHelper.extractWithRetry(
+    client, userPrompt, systemPrompt, outputsDecl, policy);
+
+if (extResult.errorMessage != null) {
+    return NodeExecutionResult.success(nodeId,
+        NodeOutputEnvelope.failure(extResult.errorMessage).toMap());
+}
+
+Map<String, Object> data = new LinkedHashMap<>(extResult.data);
+data.put("elapsed_time", elapsed);  // LLM 节点特有
+String msg = "LLM 调用成功" + (extResult.attempts > 1 ? "(重试 " + (extResult.attempts - 1) + " 次)" : "");
+return NodeExecutionResult.success(nodeId,
+    NodeOutputEnvelope.success(msg, data).toMap());
+```
+
+#### 2.6.2 SmartActionExecutor 升级
+
+**关键改动**:支持 outputs 声明,走与 LLM 一致的结构化输出。
+
+```java
+@Override
+public NodeExecutionResult execute(...) {
+    String actionPrompt = TemplateRenderer.render(data.path("actionPrompt").asText(""), workspace.getVariables());
+    if (actionPrompt.isEmpty()) {
+        return NodeExecutionResult.success(nodeId,
+            NodeOutputEnvelope.failure("智能操作节点的操作要求为空").toMap());
+    }
+
+    JsonNode outputsDecl = data.path("outputs");
+    if (outputsDecl.isArray() && outputsDecl.size() > 0) {
+        // 结构化模式:走 LLM 节点的统一流程
+        return executeStructured(nodeId, data, actionPrompt, workspace, outputsDecl);
+    }
+
+    // 降级模式:无 outputs 声明,按单变量透传(兼容旧工作流)
+    String result = callLlm(...);
+    String varName = NodeTypeUtils.resolveOutputVarName(data, "result");
+    return NodeExecutionResult.success(nodeId,
+        NodeOutputEnvelope.success("智能操作执行成功", Map.of(varName, result)).toMap());
+}
+```
+
+#### 2.6.3 KnowledgeRetrievalExecutor
+
+固定 4 字段,无需 LLM 重试。直接组装 envelope:
+
+```java
+return NodeExecutionResult.success(nodeId,
+    NodeOutputEnvelope.success("知识检索完成", output).toMap());
+```
+
+#### 2.6.4 UserInputExecutor
+
+按 variables 取值后包装:
+
+```java
+return NodeExecutionResult.success(nodeId,
+    NodeOutputEnvelope.success("用户输入已收集", collected).toMap());
+```
+
+#### 2.6.5 ConditionExecutor
+
+```java
+Map<String, Object> data = Map.of("selectedBranch", sourceHandle);
+return NodeExecutionResult.success(nodeId,
+    NodeOutputEnvelope.success("条件路由完成:" + sourceHandle, data).toMap(), sourceHandle);
+```
+
+#### 2.6.6 OutputExecutor
+
+合并所有前置 envelope.data:
+
+```java
+Map<String, Object> aggregatedData = new LinkedHashMap<>();
+int worstStatus = STATUS_SUCCESS;
+List<String> messages = new ArrayList<>();
+
+for (entry : context.getAllNodeScopedOutputs()) {
+    NodeOutputEnvelope env = NodeOutputEnvelope.fromObject(entry.getValue());
+    if (env.getStatus() == STATUS_FAILED) {
+        worstStatus = STATUS_FAILED;
+        messages.add("[" + entry.getKey() + "] " + env.getMessage());
+    }
+    if (env.getData() != null) aggregatedData.putAll(env.getData());
+}
+
+// 工作目录文件
+aggregatedData.put("_workingDirFiles", files);
+aggregatedData.put("_runId", context.getRunId());
+
+String message = worstStatus == STATUS_SUCCESS
+    ? "工作流执行成功"
+    : "部分节点失败:" + String.join("; ", messages);
+return NodeExecutionResult.success(nodeId,
+    NodeOutputEnvelope.of(worstStatus, message, aggregatedData).toMap());
+```
+
+### 2.7 `WorkflowContext` 改造
+
+`nodeScopedOutputs: Map<nodeId, Map<String, Object>>` 的 value 改为 envelope.toMap()。
+
+`snapshotAllOutputs()` 改为解包 envelope.data 后扁平化(保持 key 形如 `{nodeId}__{varName}`,value 是业务字段值)。
+
+### 2.8 `NodeWorkspaceBuilder` 改造
+
+`scopedOutputs` 保留 envelope 原貌;`variables` 扁平表注入解包后的 data 字段。
+
+### 2.9 `NodeInputResolver` 改造
+
+五级匹配全部穿透 envelope.data:
+- `workspace.getScopedOutput(nodeId, fieldName)` 先解包 envelope
+- 模糊匹配遍历 envelope.data.entrySet()
+- JsonPath 提取以 envelope.data 为根
+
+### 2.10 `WorkflowLevelExecutor` 失败处理
+
+```java
+NodeExecutionResult result = executor.execute(...);
+Map<String, Object> output = result.getOutput();
+if (output != null) {
+    context.setNodeOutput(nodeId, output);
+} else if (result.getStatus() == FAILED) {
+    Map<String, Object> failEnv = NodeOutputEnvelope.failure(result.getError()).toMap();
+    context.setNodeOutput(nodeId, failEnv);
+}
+```
+
+保留 failStrategy=abort/skip 行为。
+
+### 2.11 前端适配
+
+#### `useWorkflowRunner.js` 新增 helper
+```js
+function isEnvelope(o) { return o && typeof o === 'object' && 'status' in o && 'data' in o }
+function unwrapNodeOutput(o) { return isEnvelope(o) ? (o.data || {}) : o }
+function nodeOutputStatus(o) { return isEnvelope(o) ? o.status : null }
+function nodeOutputMessage(o) { return isEnvelope(o) ? o.message : '' }
+```
+
+#### SmartAction 节点配置面板(`WorkflowEditor.vue`)
+- 添加 outputs 列表编辑器(与 LLM 节点结构一致)
+- 添加"重试配置"折叠面板(maxRetries 数字输入、retryOn 复选框组)
+
+#### LLM 节点配置面板
+- 添加"重试配置"折叠面板(同上)
+
+#### 运行结果展示
+- `v-for` 遍历 `unwrapNodeOutput(r.output)` 而非 `r.output`
+- 失败(status=400)时显示红色徽章 + message
+- 重试次数 > 0 时在 message 末尾显示"(重试 N 次)"
+
+---
+
+## 三、实施阶段
+
+### 阶段 1:核心抽象(无破坏性)
+1. 新建 `NodeOutputEnvelope` 类
+2. 新建 `NodeOutputEnvelopeTest` 单元测试
+3. 验证:`mvn compile` + 测试通过
+
+### 阶段 2:质保组件(独立工具类)
+4. 新建 `OutputValidator`
+5. 新建 `RetryPolicy`(含 fromNodeData 解析)
+6. 新建 `WorkflowProperties` 配置类
+7. 改造 `StructuredOutputHelper`:强制 JSON 输出 + `extractWithRetry` + lastError 注入
+8. 新增 `StructuredOutputHelperTest`(覆盖重试、降级、类型转换)
+9. 验证:`mvn compile` + 单元测试
+
+### 阶段 3:LLM 类执行器改造
+10. `LlmExecutor`:使用 `extractWithRetry` + envelope 包装
+11. `SmartActionExecutor`:新增 outputs 声明支持,复用 `extractWithRetry`
+12. `AgentExecutor`:按 Skill outputs 走结构化(若 skill 已声明 outputs)
+13. `HermesAgentExecutor` / `HermesSmartActionExecutor`:同步改造
+14. 验证:`mvn compile`
+
+### 阶段 4:非 LLM 节点改造
+15. `KnowledgeRetrievalExecutor`:envelope 包装(固定 4 字段)
+16. `UserInputExecutor`:envelope 包装
+17. `ConditionExecutor`:envelope 包装 + selectedBranch 放入 data
+18. `OutputExecutor`:聚合所有前置 envelope.data
+19. 验证:`mvn compile`
+
+### 阶段 5:消费端适配
+20. `WorkflowContext.snapshotAllOutputs`:解包 envelope.data 扁平化
+21. `NodeWorkspace.getScopedOutput`:穿透 envelope
+22. `NodeWorkspaceBuilder.build`:注入 variables 时解包
+23. `NodeInputResolver`:五级匹配穿透 data
+24. `WorkflowLevelExecutor.executeOneNode`:失败兜底为 envelope
+25. 验证:`mvn compile` + NodeInputResolverTest 调整
+
+### 阶段 6:前端适配
+26. `useWorkflowRunner.js`:新增 unwrap helper
+27. `WorkflowEditor.vue` 运行结果 Tab:适配 envelope + status 徽章 + 重试次数展示
+28. `WorkflowEditor.vue` SmartAction 配置面板:新增 outputs 编辑器 + 重试配置面板
+29. `WorkflowEditor.vue` LLM 配置面板:新增重试配置面板
+30. `RunHistory.vue`:适配 envelope
+31. 验证:`npm run build`
+
+### 阶段 7:回归测试
+32. 更新 `NodeInputResolverTest`:mock 数据改为 envelope 结构
+33. 新增 `NodeOutputEnvelopeTest`、`OutputValidatorTest`、`RetryPolicyTest`
+34. `mvn test` 全量通过
+35. `npm run build` 通过
+
+---
+
+## 四、风险与缓解
+
+| 风险 | 等级 | 缓解 |
+|---|---|---|
+| LLM 强制 JSON 模式可能拒绝遵循(小模型) | 中 | 重试 + 类型转换容错;保留单输出降级路径 |
+| 重试导致工作流耗时翻倍 | 中 | 全局默认 2 次;用户可节点级关闭;监控 attempts 指标 |
+| NodeInputResolver 五级匹配改造引入 bug | 高 | 保留 legacy 兼容分支 + 完整单元测试 |
+| SmartAction 新增 outputs 声明兼容旧工作流 | 中 | 检测 `data.outputs` 是否存在,无声明走降级模式(旧工作流不破坏) |
+| Hermes 执行器与内置执行器双路径需同步 | 低 | 提取共享逻辑到工具类 |
+
+---
+
+## 五、不变项(无需改动)
+
+- `ContextPromptHelper`(取 workspace.getVariable,已由 NodeWorkspaceBuilder 解包)
+- `TemplateRenderer`(同上)
+- `JsonPathExtractor` / `VariableConverter`(纯工具)
+- 前端 `ioInference.js` / `useNodeFields.js`(静态推断)
+- 画布节点组件(基于 `data.outputs/inputs` 静态渲染)
+
+---
+
+## 六、验收标准
+
+1. 单元测试:`NodeOutputEnvelopeTest` / `OutputValidatorTest` / `RetryPolicyTest` / `StructuredOutputHelperTest` 全部通过
+2. 单元测试:`NodeInputResolverTest` 在 envelope 结构下正确解析
+3. 单元测试:`mvn test` 全量通过
+4. 构建:`mvn compile` 通过
+5. 构建:`npm run build` 通过
+6. 端到端(用户手动测试):
+   - LLM 多输出声明:LLM 按字段产出 JSON,下游能解析 data 字段
+   - LLM 故意返回错误 JSON:自动重试,最终失败时 status=400 + message 包含原因
+   - SmartAction 声明 outputs:按字段产出
+   - RunHistory 详情:节点输出展示 data 字段,失败节点显示 status 徽章

+ 48 - 0
frontend/src/assets/styles/global.css

@@ -149,3 +149,51 @@ body {
 .n-dialog__content {
   color: var(--text-secondary) !important;
 }
+
+/* ===== 对话框按钮统一风格 ===== */
+/* 主操作(确认/保存):蓝色渐变,与页面主按钮一致 */
+.n-dialog .n-button--primary-type {
+  background: var(--gradient-accent) !important;
+  border: none !important;
+  color: #fff !important;
+}
+
+.n-dialog .n-button--primary-type:hover {
+  filter: brightness(1.1);
+}
+
+/* 取消/次要操作:暗色透明背景,与深色主题融合 */
+.n-dialog .n-button--default-type {
+  background: rgba(56, 189, 248, 0.06) !important;
+  border: 1px solid var(--border-color) !important;
+  color: var(--text-secondary) !important;
+}
+
+.n-dialog .n-button--default-type:hover {
+  background: rgba(56, 189, 248, 0.12) !important;
+  border-color: var(--border-color-hover) !important;
+  color: var(--text-primary) !important;
+}
+
+/* 危险操作(删除/丢弃):红色调 */
+.n-dialog .n-button--error-type {
+  background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%) !important;
+  border: none !important;
+  color: #fff !important;
+}
+
+.n-dialog .n-button--error-type:hover {
+  filter: brightness(1.1);
+}
+
+/* 防止意外出现橙黄色 warning 按钮:强制降级为 default 样式 */
+.n-dialog .n-button--warning-type {
+  background: rgba(56, 189, 248, 0.06) !important;
+  border: 1px solid var(--border-color) !important;
+  color: var(--text-secondary) !important;
+}
+
+.n-dialog .n-button--warning-type:hover {
+  background: rgba(56, 189, 248, 0.12) !important;
+  color: var(--text-primary) !important;
+}

+ 1 - 0
frontend/src/components/skill/CategoryPicker.vue

@@ -97,6 +97,7 @@ function handleConfirm() {
     style="max-width: 480px; width: 90vw"
     :bordered="false"
     :closable="false"
+    :mask-closable="false"
     @update:show="$emit('update:show', $event)"
   >
     <div v-if="loading" class="picker-loading">

+ 2 - 0
frontend/src/components/skill/FileTree.vue

@@ -46,6 +46,7 @@ function showCreateDialog(title, placeholder, onConfirm) {
       autofocus: true,
       'onUpdate:value': (val) => { inputValue = val }
     }),
+    maskClosable: false,
     positiveText: '确定',
     negativeText: '取消',
     onPositiveClick: () => {
@@ -86,6 +87,7 @@ function handleDelete(node) {
   dialog.warning({
     title: '确认删除',
     content: `确定要删除「${node.name}」吗?${node.type === 'directory' ? '目录下的所有文件也会被删除。' : ''}`,
+    maskClosable: false,
     positiveText: '确认删除',
     negativeText: '取消',
     onPositiveClick: () => {

+ 1 - 1
frontend/src/components/skill/SkillForm.vue

@@ -119,7 +119,7 @@ function closeModal() {
 </script>
 
 <template>
-  <n-modal :show="show" @update:show="closeModal">
+  <n-modal :show="show" @update:show="closeModal" :mask-closable="false">
     <div class="edit-modal">
       <!-- 头部 -->
       <div class="modal-header">

+ 1 - 0
frontend/src/components/tag/TagSelector.vue

@@ -275,6 +275,7 @@ const mindmapData = computed(() => {
     title="选择标签"
     style="max-width: 720px; width: 90vw"
     :bordered="false"
+    :mask-closable="false"
     @update:show="$emit('update:show', $event)"
   >
     <!-- 搜索框 -->

+ 2 - 0
frontend/src/components/tag/TreeNodeItem.vue

@@ -114,6 +114,7 @@ function onToggle(id) {
     v-model:show="showAddDialog"
     preset="dialog"
     title="添加子标签"
+    :mask-closable="false"
     positive-text="确定"
     negative-text="取消"
     @positive-click="confirmAdd"
@@ -133,6 +134,7 @@ function onToggle(id) {
     v-model:show="showEditDialog"
     preset="dialog"
     title="编辑标签"
+    :mask-closable="false"
     positive-text="确定"
     negative-text="取消"
     @positive-click="confirmEdit"

+ 1 - 0
frontend/src/components/workflow/TemplatePicker.vue

@@ -46,6 +46,7 @@ function onSelect(tpl) {
     style="max-width: 720px; width: 90vw"
     :bordered="false"
     :closable="false"
+    :mask-closable="false"
   >
     <template #header>
       <div class="picker-header">

+ 1 - 0
frontend/src/composables/useConfirmDelete.js

@@ -36,6 +36,7 @@ export function useConfirmDelete() {
     dialog.create({
       title: '确认删除',
       content: body,
+      maskClosable: false,
       positiveText: '确认删除',
       negativeText: '取消',
       icon() {

+ 3 - 1
frontend/src/views/TagManagement.vue

@@ -319,6 +319,7 @@ onMounted(() => {
       v-model:show="showAddRootDialog"
       preset="dialog"
       title="添加根级标签"
+      :mask-closable="false"
       positive-text="确定"
       negative-text="取消"
       @positive-click="confirmAddRoot"
@@ -338,6 +339,7 @@ onMounted(() => {
       v-model:show="showGroupModal"
       preset="card"
       :title="editingGroup ? '编辑标签组' : '新建标签组'"
+      :mask-closable="false"
       style="max-width: 420px"
       :bordered="false"
     >
@@ -356,7 +358,7 @@ onMounted(() => {
     </n-modal>
 
     <!-- 导入对话框 -->
-    <n-modal v-model:show="showImportModal" preset="card" title="导入标签" style="max-width: 560px" :bordered="false">
+    <n-modal v-model:show="showImportModal" preset="card" title="导入标签" :mask-closable="false" style="max-width: 560px" :bordered="false">
       <div v-if="importData" class="import-content">
         <p class="import-hint">文件包含 {{ importData.groups.length }} 个标签组,请处理冲突:</p>
         <div class="import-group-list">

+ 2 - 0
frontend/src/views/knowledge/DocumentManagement.vue

@@ -222,6 +222,7 @@ function confirmDeleteCategory({ id, name }) {
   dialog.warning({
     title: '确认删除',
     content: `确认删除分类「${name}」?删除前请确保该分类下没有子分类和文档。`,
+    maskClosable: false,
     positiveText: '删除',
     negativeText: '取消',
     onPositiveClick: async () => {
@@ -251,6 +252,7 @@ function confirmDelete(doc) {
   dialog.warning({
     title: '确认删除',
     content: `确认删除文档「${doc.name}」?该操作会同时删除已生成的向量记录,不可恢复。`,
+    maskClosable: false,
     positiveText: '删除',
     negativeText: '取消',
     onPositiveClick: async () => {

+ 4 - 3
frontend/src/views/skill/SkillEdit.vue

@@ -500,6 +500,7 @@ function handleRollback(v) {
   dialog.warning({
     title: '确认回滚',
     content: `确定要回滚到 v${v.number} 吗?当前工作区内容将被替换。`,
+    maskClosable: false,
     positiveText: '确认回滚',
     negativeText: '取消',
     onPositiveClick: async () => {
@@ -642,7 +643,7 @@ onMounted(() => {
           版本历史
         </n-button>
 
-        <n-button :type="advancedMode ? 'warning' : 'default'" quaternary @click="toggleAdvancedMode">
+        <n-button :type="advancedMode ? 'primary' : 'default'" quaternary @click="toggleAdvancedMode">
           <template #icon><n-icon>
               <CodeSlashOutline />
             </n-icon></template>
@@ -1250,11 +1251,11 @@ onMounted(() => {
 }
 
 .diff-btn {
-  color: #f59e0b;
+  color: var(--color-primary);
 }
 
 .diff-btn:hover {
-  background: rgba(245, 158, 11, 0.15);
+  background: rgba(37, 99, 235, 0.15);
 }
 
 .rollback-btn {

+ 27 - 12
frontend/src/views/workflow/WorkflowEditor.vue

@@ -504,34 +504,43 @@ function openImportDialog(mode) {
   importDialog.open = true
 }
 
-// 确认引入:从前驱输出导入到 inputs,或从后继输入导入到 outputs
+// 确认引入:从前驱输出导入到 inputs(普通节点)或 fields(输出节点),
+//          或从后继输入导入到 outputs(普通节点)或 variables(输入节点)
 function confirmImportField(candidate) {
   if (!selectedNode.value) return
+  const nodeType = selectedNode.value.type
   if (importDialog.mode === 'predecessor') {
-    const newInput = {
+    // 输出节点:引入到 fields(接收字段仍作为业务输出);其他节点:引入到 inputs(含 mapping)
+    const targetField = nodeType === 'output' ? 'fields' : 'inputs'
+    const newField = {
       name: candidate.fieldName,
       label: candidate.fieldLabel || candidate.fieldName,
       type: candidate.fieldType || 'string',
-      description: candidate.fieldDescription || '',
-      mapping: {
+      description: candidate.fieldDescription || ''
+    }
+    if (targetField === 'inputs') {
+      newField.mapping = {
         sourceNodeId: candidate.nodeId,
         sourceField: candidate.fieldName,
         sourcePath: null,
         autoMapped: false
       }
     }
-    const inputs = [...(selectedData.value?.inputs || []), newInput]
-    vfUpdateNode(selectedNode.value.id, { inputs })
+    const updated = [...(selectedData.value?.[targetField] || []), newField]
+    vfUpdateNode(selectedNode.value.id, { [targetField]: updated })
   } else if (importDialog.mode === 'successor') {
-    const newOutput = {
+    // 输入节点:引入到 variables(输入变量仍对业务暴露);其他节点:引入到 outputs
+    const targetField = nodeType === 'userInput' ? 'variables' : 'outputs'
+    const newField = {
       name: candidate.fieldName,
       label: candidate.fieldLabel || candidate.fieldName,
       type: candidate.fieldType || 'string',
       description: candidate.fieldDescription || ''
     }
-    const outputs = [...(selectedData.value?.outputs || []), newOutput]
-    vfUpdateNode(selectedNode.value.id, { outputs })
+    const updated = [...(selectedData.value?.[targetField] || []), newField]
+    vfUpdateNode(selectedNode.value.id, { [targetField]: updated })
     // 同步设置后继节点对应 input 的 mapping,指向本节点该输出字段
+    // (userInput 的 variables 即为其对后继的输出,见 ioInference.getNodeOutputs)
     const successorNode = getNodes.value.find(n => n.id === candidate.nodeId)
     if (successorNode) {
       const succInputs = (successorNode.data?.inputs || []).map(inp => {
@@ -1321,7 +1330,10 @@ function applyGraphData(graphData) {
                 <div class="prop-section">
                   <div class="prop-header">
                     <span class="prop-label">输入变量</span>
-                    <button class="text-btn" @click="addVariable">+ 添加</button>
+                    <div style="display:flex;gap:4px">
+                      <button class="text-btn" @click="openImportDialog('successor')">引入</button>
+                      <button class="text-btn" @click="addVariable">+ 添加</button>
+                    </div>
                   </div>
                   <div v-for="(v, i) in selectedData?.variables || []" :key="i" class="var-form">
                     <div class="var-form-row">
@@ -1885,7 +1897,10 @@ function applyGraphData(graphData) {
                 <div class="prop-section">
                   <div class="prop-header">
                     <span class="prop-label">接收字段</span>
-                    <button class="text-btn" @click="addOutputField">+ 添加</button>
+                    <div style="display:flex;gap:4px">
+                      <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
+                      <button class="text-btn" @click="addOutputField">+ 添加</button>
+                    </div>
                   </div>
                   <div v-for="(f, i) in selectedData?.fields || []" :key="i" class="var-form">
                     <div class="var-form-row">
@@ -2165,7 +2180,7 @@ function applyGraphData(graphData) {
     </Teleport>
 
     <!-- 字段引入对话框:从前驱输出或后继输入中引入字段 -->
-    <n-modal v-model:show="importDialog.open" preset="card" style="max-width:560px"
+    <n-modal v-model:show="importDialog.open" preset="card" :mask-closable="false" style="max-width:560px"
       :title="importDialog.mode === 'predecessor' ? '从前驱节点引入字段' : '向后继节点引入字段'">
       <div class="import-candidate-list">
         <div v-if="!importDialog.candidates.length" class="empty-hint">暂无可引入字段</div>

+ 43 - 0
prompt.md

@@ -1205,3 +1205,46 @@ java.lang.IllegalStateException: rag-ai-bridge is disabled
 
 ---
 
+对整个项目(而非本次变更)进行code-review。
+
+---
+
+为什么我在日志里还是可以看到“ 变量 fragment_mass 被节点 node-1-1783926357665 覆盖(旧值类型=String)”这样的日志?我们不是设计了机制,某个节点的输入,可以与前序节点的变量关联吗?即使有同名变量,也不会覆盖,只是让后继节点选择而已。请检查设计,给我一个详细的说明。
+
+---
+
+1. 各类弹出对话框,若不小心点到对话框之外,不要让其直接关闭,非常容易误操作;
+2. 各类弹出对话框,整体色调、默认按钮和取消按钮的样式等,统一一下风格,另外,不要再出现橙黄色按钮了。
+
+---
+
+我需要一个工作流增删改的接口说明,写到docs下,md格式
+
+---
+
+在工作流编辑中:
+1. 对于输出节点来说,其输出变量,既要对业务输出,同时又来源于前序节点。因此,应与其他节点的“输入变量”或“前置数据”引入类似,同样应该具有“引入”操作,来接收前序节点的输出变量。不同之处在于,引入的变量仍作为输出变量即可,而非输入变量。
+2. 同理,对于输入节点来说,其输入变量,既来源于业务,又要对后继节点输出,因此,应与其他节点的“输出变量”引入操作类似,同样应该具有“引入”操作,来关联后继节点的输入变量。不同之处在于,引入的变量仍作为输入变量即可,而非输出变量。
+
+---
+
+各节点的输出(不包括本地文件写入,只关注节点输出的变量),我希望统一成如下Json格式:
+{
+  "status": 200/400(200表示成功,400表示失败),
+  "message": "成功/XXX原因导致失败",
+  "data": ...(各节点定义的变量组合,以Json方式列出)
+}
+例如,LLM节点,定义了“output”变量(字符串)和“elapsed_time”变量,那么输出应为:
+{
+  "status": 200,
+  "message": "调用成功",
+  "data": {
+    "output": "XXXXXXX(输出结果)",
+    "elapsed_time": 15.83
+  }
+}
+后继节点在接收变量时,统一解析前序节点的“data”字段中的变量(及其子字段)。
+针对各类节点(LLM、智能操作、技能、智能体等),设计具体格式化输出的实现方式。
+
+---
+