Просмотр исходного кода

add update schema button at prompt modifier

guantao 1 день назад
Родитель
Сommit
243b137333

+ 2 - 1
examples/process_pipeline/db_requirements.json

@@ -109,5 +109,6 @@
   "按url搜索到的6条帖子",
   "用AI生成真实感live图",
   "用ai生成真实摄影的美女写真组图,要求具有真实感,氛围感,人物一致性保持",
-  "图文排版(优先选择使用终端/API/agent友好工具的帖子)"
+  "图文排版(优先选择使用终端/API/agent友好工具的帖子)",
+  "给定一段文案或需求,用 AI 工具生成符合要求的排版图片的方法/教程/工作流"
 ]

+ 2 - 6
examples/process_pipeline/prompts/process_cluster.prompt

@@ -33,23 +33,19 @@ $system$
       “name”: “聚类名称”,
       “description”: “该聚类的整体描述,说明核心创作手段”,
       “modality”: “图文 | 视频”,
-      “inputs”: {},
-      “outputs”: {},
       “why”: “string | null”,
       “boundaries”: “string | null”,
       “steps”: [
         {
           “order”: 1,
-          “type”: “capability | sub_strategy | human_review | conditional”,
+          “type”: “capability | sub_strategy | human_review”,
           “description”: “步骤描述”,
-          “why”: “string | null”,
           “body”: “string | null”,
           “inputs”: {},
           “outputs”: {},
           “optional”: false
         }
-      ],
-      “case_references”: [“bili_BV1xxx”, “xhs_694e17e9000000001e006669”]
+      ]
     }
   ]
 }

+ 81 - 20
examples/process_pipeline/prompts/process_cluster.schema.json

@@ -2,45 +2,106 @@
   "$schema": "http://json-schema.org/draft-07/schema#",
   "title": "process_cluster_output",
   "type": "object",
-  "required": ["clusters-boundary"],
+  "required": [
+    "clusters-boundary"
+  ],
   "properties": {
     "clusters-boundary": {
       "type": "array",
       "minItems": 1,
       "items": {
         "type": "object",
-        "required": ["id-ref", "name-ref", "description", "modality", "steps-boundary", "case_references-boundary"],
+        "required": [
+          "id-ref",
+          "name-ref",
+          "description",
+          "modality",
+          "steps-boundary"
+        ],
         "properties": {
-          "id-ref": { "type": "string", "pattern": "^cluster-[A-Za-z0-9_-]+$" },
-          "name-ref": { "type": "string", "minLength": 1 },
-          "description": { "type": "string", "minLength": 1 },
-          "modality": { "type": "string", "enum": ["图文", "视频"] },
-          "inputs": { "type": ["object", "null"] },
-          "outputs": { "type": ["object", "null"] },
-          "why": { "type": ["string", "null"] },
-          "boundaries": { "type": ["string", "null"] },
+          "id-ref": {
+            "type": "string",
+            "pattern": "^cluster-[A-Za-z0-9_-]+$"
+          },
+          "name-ref": {
+            "type": "string",
+            "minLength": 1
+          },
+          "description": {
+            "type": "string",
+            "minLength": 1
+          },
+          "modality": {
+            "type": "string",
+            "enum": [
+              "图文",
+              "视频"
+            ]
+          },
+          "why": {
+            "type": [
+              "string",
+              "null"
+            ]
+          },
+          "boundaries": {
+            "type": [
+              "string",
+              "null"
+            ]
+          },
           "steps-boundary": {
             "type": "array",
             "minItems": 1,
             "items": {
               "type": "object",
-              "required": ["order-ref", "type-ref", "description-ref"],
+              "required": [
+                "order-ref",
+                "type-ref",
+                "description"
+              ],
               "properties": {
-                "order-ref": { "type": "integer", "minimum": 1 },
-                "type-ref": { "type": "string", "enum": ["capability", "sub_strategy", "human_review", "conditional"] },
-                "description-ref": { "type": "string", "minLength": 1 },
-                "why": { "type": ["string", "null"] },
-                "body": { "type": ["string", "null"] },
-                "inputs": { "type": "object" },
-                "outputs": { "type": "object" },
-                "optional": { "type": "boolean" }
+                "order-ref": {
+                  "type": "integer",
+                  "minimum": 1
+                },
+                "type-ref": {
+                  "type": "string",
+                  "enum": [
+                    "capability",
+                    "sub_strategy",
+                    "human_review"
+                  ]
+                },
+                "description": {
+                  "type": "string",
+                  "minLength": 1
+                },
+                "body": {
+                  "type": [
+                    "string",
+                    "null"
+                  ]
+                },
+                "inputs-boundary": {
+                  "type": "object"
+                },
+                "outputs-boundary": {
+                  "type": "object"
+                },
+                "optional": {
+                  "type": "boolean"
+                }
               }
             }
           },
           "case_references-boundary": {
             "type": "array",
             "minItems": 1,
-            "items": { "type": "string", "pattern": "^[a-z]+_[A-Za-z0-9_-]+$" }
+            "items": {
+              "type": "string",
+              "pattern": "^[a-z]+_[A-Za-z0-9_-]+$"
+            }
           }
         }
       }

+ 4 - 5
examples/process_pipeline/prompts/researcher.prompt

@@ -10,8 +10,7 @@ $system$
 
 ## 核心原则
 1. **渠道专注**:你只负责一个渠道的完整调研,绝不跨渠道。
-2. **结构化提取**:必须将 结果严格拆解为按序执行的步骤,如果只有一个步骤,则为一步即可
-3. **相关性过滤**:只记录与调研目标相关的**图片生成**工序(排除视频、音频及非AI桌面软件如PS)。
+2. **相关性过滤与质量优先**:只记录与调研目标高度相关的帖子。**质量绝对优先于数量(宁缺毋滥)**。不要为了凑齐指定的数量(如15个)而强行保存偏离核心需求(例如大量出现与目标不符的PPT排版教程等)或低关联度的内容。数量不足没关系,但保存的必须是高赞、高相关性的优质case。
 
 ## 可用工具
 
@@ -35,9 +34,9 @@ $system$
   - 准备 3-5 个关键词。每个关键词搜索 20 条结果。
   - 禁止搜索具体的软件名称,如 MJ,controlnet
 2. **搜索要求**:仅搜索/查看近半年的结果,不要查看过时的帖子
-3. **适度查看内容**:对点赞数高或标题符合业务需求的帖子查看详情(可看图片)
-    - 当调用 `content_search` 时,你会看到每条结果附带了 `quality_score`(质量得分)。**必须主动剔除得分低于 80 分的结果,只提取高质量帖子**。
-    - 在写入 case 前,你需要针对帖子执行多维度评估,包括:内容本身的知识质量(指导性与可信度)、是否包含多步骤执行、以及需求匹配度(具体解决的需求细分)
+3. **严格的质量把控(适度查看内容)**:对点赞数高且标题极度符合业务需求的帖子查看详情
+    - 当调用 `content_search` 时,你会看到每条结果附带了 `quality_score`(质量得分)。**必须主动剔除得分较低的结果,只提取高分帖子**。
+    - 在写入 case 前,你需要针对帖子执行多维度评估。若评估发现不符合需求,或者属于偏题内容,**请果断放弃保存,质量优先,坚决不凑数**
 
 ### 第三步:存储结果文件
 🚨 **绝对不能更改任务规定的 `output_file` 路径名**!

+ 40 - 11
examples/process_pipeline/prompts/researcher.schema.json

@@ -4,20 +4,42 @@
   "oneOf": [
     {
       "type": "object",
-      "required": ["cases-boundary"],
+      "required": [
+        "cases-boundary"
+      ],
       "properties": {
-        "初始关键词": { "type": "array", "items": { "type": "string" } },
-        "采集时间": { "type": "string" },
+        "初始关键词": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "采集时间": {
+          "type": "string"
+        },
         "cases-boundary": {
           "type": "array",
           "minItems": 1,
           "items": {
             "type": "object",
-            "required": ["case_id-ref", "source_url-ref", "title"],
+            "required": [
+              "case_id-ref",
+              "source_url-ref",
+              "title"
+            ],
             "properties": {
-              "case_id-ref": { "type": "string", "pattern": "^[a-z]+_[A-Za-z0-9_-]+$" },
-              "source_url-ref": { "type": "string", "minLength": 1 },
-              "title": { "type": "string", "minLength": 1 }
+              "case_id-ref": {
+                "type": "string",
+                "pattern": "^[a-z]+_[A-Za-z0-9_-]+$"
+              },
+              "source_url-ref": {
+                "type": "string",
+                "minLength": 1
+              },
+              "title": {
+                "type": "string",
+                "minLength": 1
+              }
             }
           }
         }
@@ -25,20 +47,27 @@
     },
     {
       "type": "object",
-      "required": ["工序发现-boundary"],
+      "required": [
+        "工序发现-boundary"
+      ],
       "properties": {
         "工序发现-boundary": {
           "type": "array",
           "minItems": 1,
           "items": {
             "type": "object",
-            "required": ["帖子链接-ref"],
+            "required": [
+              "帖子链接-ref"
+            ],
             "properties": {
-              "帖子链接-ref": { "type": "string", "minLength": 1 }
+              "帖子链接-ref": {
+                "type": "string",
+                "minLength": 1
+              }
             }
           }
         }
       }
     }
   ]
-}
+}

+ 235 - 0
examples/process_pipeline/script/update_schema.py

@@ -0,0 +1,235 @@
+"""
+Schema 自动更新脚本
+
+根据 prompt 文件中定义的输出格式,调用 GPT(via OpenRouter)自动生成/更新对应的 JSON Schema。
+
+用法:
+    python -m examples.process_pipeline.script.update_schema researcher
+    python -m examples.process_pipeline.script.update_schema researcher --dry-run
+    python -m examples.process_pipeline.script.update_schema researcher --model openai/gpt-5.4
+"""
+
+import argparse
+import asyncio
+import json
+import os
+import re
+import sys
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts"
+
+SYSTEM_PROMPT = """\
+你是一个 JSON Schema 专家。你的任务是根据给定的 prompt 文件中定义的"输出格式"部分,\
+生成或更新一个 JSON Schema (Draft-07)。
+
+## Contract Suffix 约定
+
+这个项目的 schema 使用特殊的字段名后缀来标记字段的"契约等级":
+
+1. **`-boundary`** 后缀:标记"容器边界"字段。
+   - 这些字段通常是 array 或 object 类型
+   - 表示该容器内部的元素结构可以自由演化
+   - 但容器本身(字段名、类型、是否 required)是稳定的
+   - 例:`cases-boundary`, `steps-boundary`, `clusters-boundary`
+
+2. **`-ref`** 后缀:标记"引用锚点"字段。
+   - 这些字段的名称和类型是不可变的(被外部引用)
+   - 通常是 id、name、url 等标识性字段
+   - 例:`case_id-ref`, `source_url-ref`, `name-ref`
+
+3. **无后缀**:普通内部字段,可以自由演化。
+
+## 规则
+
+1. 只关注 prompt 中"输出格式"或"Output Format"部分定义的 JSON 结构
+2. 为每个字段选择合适的 contract suffix:
+   - 顶层数组容器 → `-boundary`
+   - ID、URL、name 等标识字段 → `-ref`
+   - 其他字段 → 无后缀
+3. 合理设置 `required` 字段(只包含核心必需字段)
+4. 对于枚举值使用 `enum`
+5. 对于可选字段使用 `type: ["string", "null"]` 或不放入 required
+6. 输出必须是合法的 JSON Schema Draft-07,不要包含任何注释或额外文本
+7. 如果提供了现有 schema,尽量保留其中合理的约束(如 pattern、enum),只更新结构变化的部分
+
+## 输出要求
+
+直接输出完整的 JSON Schema,不要包含 markdown 代码块标记或任何其他文本。\
+"""
+
+
+def build_messages(prompt_content: str, existing_schema: Optional[dict]) -> List[Dict[str, Any]]:
+    """构造发给 LLM 的消息列表"""
+    user_parts = [f"## Prompt 文件内容\n\n{prompt_content}"]
+
+    if existing_schema:
+        user_parts.append(
+            f"\n\n## 现有 Schema(请在此基础上更新)\n\n```json\n{json.dumps(existing_schema, ensure_ascii=False, indent=2)}\n```"
+        )
+    else:
+        user_parts.append("\n\n## 现有 Schema\n\n无(请从零生成)")
+
+    user_parts.append(
+        "\n\n## 任务\n\n"
+        "请根据上面 prompt 中定义的输出 JSON 结构,生成完整的 JSON Schema (Draft-07)。"
+        "注意应用 contract suffix 约定(-boundary / -ref)。"
+        "直接输出 JSON,不要包含任何其他文本。"
+    )
+
+    return [
+        {"role": "system", "content": SYSTEM_PROMPT},
+        {"role": "user", "content": "".join(user_parts)},
+    ]
+
+
+def extract_json_from_response(content: str) -> dict:
+    """从 LLM 响应中提取 JSON(处理可能的 markdown 代码块包裹)"""
+    content = content.strip()
+
+    # 去掉 markdown 代码块
+    if content.startswith("```"):
+        lines = content.split("\n")
+        # 去掉首行 ```json 和末行 ```
+        if lines[0].startswith("```"):
+            lines = lines[1:]
+        if lines and lines[-1].strip() == "```":
+            lines = lines[:-1]
+        content = "\n".join(lines)
+
+    return json.loads(content)
+
+
+async def update_schema(
+    prompt_name: str,
+    model: str = "openai/gpt-5.4",
+    dry_run: bool = False,
+) -> dict:
+    """
+    根据 prompt 文件更新对应的 schema。
+
+    Args:
+        prompt_name: prompt 名称(不含扩展名)
+        model: 使用的模型
+        dry_run: 如果为 True,只打印不写入
+
+    Returns:
+        生成的 schema dict
+    """
+    from agent.llm.openrouter import create_openrouter_llm_call
+
+    prompt_file = PROMPTS_DIR / f"{prompt_name}.prompt"
+    schema_file = PROMPTS_DIR / f"{prompt_name}.schema.json"
+
+    if not prompt_file.exists():
+        raise FileNotFoundError(f"Prompt file not found: {prompt_file}")
+
+    # 读取 prompt
+    prompt_content = prompt_file.read_text(encoding="utf-8")
+
+    # 读取现有 schema(如果有)
+    existing_schema = None
+    if schema_file.exists():
+        try:
+            existing_schema = json.loads(schema_file.read_text(encoding="utf-8"))
+        except json.JSONDecodeError:
+            print(f"⚠️  现有 schema 文件 JSON 格式错误,将从零生成")
+
+    # 构造消息
+    messages = build_messages(prompt_content, existing_schema)
+
+    # 调用 LLM
+    llm_call = create_openrouter_llm_call(model=model)
+    print(f"🤖 Calling {model} to generate schema for '{prompt_name}'...")
+
+    result = await llm_call(messages=messages, model=model, temperature=0.1)
+    content = result.get("content", "")
+
+    if not content:
+        raise ValueError("LLM returned empty response")
+
+    # 解析 JSON
+    try:
+        new_schema = extract_json_from_response(content)
+    except json.JSONDecodeError as e:
+        print(f"❌ LLM 返回的内容不是合法 JSON:")
+        print(content[:500])
+        raise ValueError(f"Failed to parse schema from LLM response: {e}")
+
+    # 验证生成的 schema 本身是否合法
+    try:
+        import jsonschema
+        jsonschema.Draft7Validator.check_schema(new_schema)
+    except jsonschema.SchemaError as e:
+        print(f"⚠️  生成的 schema 不是合法的 Draft-07: {e.message}")
+        print("仍然输出结果,但请手动检查。")
+
+    # 输出
+    schema_json = json.dumps(new_schema, ensure_ascii=False, indent=2)
+
+    if dry_run:
+        print(f"\n{'='*60}")
+        print(f"[Dry Run] Generated schema for '{prompt_name}':")
+        print(f"{'='*60}")
+        print(schema_json)
+    else:
+        schema_file.write_text(schema_json + "\n", encoding="utf-8")
+        print(f"✅ Schema written to: {schema_file}")
+
+    # 打印 diff 摘要
+    if existing_schema:
+        old_keys = set(_flatten_keys(existing_schema))
+        new_keys = set(_flatten_keys(new_schema))
+        added = new_keys - old_keys
+        removed = old_keys - new_keys
+        if added:
+            print(f"   + Added: {', '.join(sorted(added)[:10])}")
+        if removed:
+            print(f"   - Removed: {', '.join(sorted(removed)[:10])}")
+        if not added and not removed:
+            print(f"   (no structural changes)")
+
+    return new_schema
+
+
+def _flatten_keys(obj: Any, prefix: str = "") -> List[str]:
+    """递归提取 schema 中所有 properties 的 key 路径"""
+    keys = []
+    if isinstance(obj, dict):
+        if "properties" in obj:
+            for k, v in obj["properties"].items():
+                full_key = f"{prefix}.{k}" if prefix else k
+                keys.append(full_key)
+                keys.extend(_flatten_keys(v, full_key))
+        if "items" in obj:
+            keys.extend(_flatten_keys(obj["items"], f"{prefix}[]"))
+        for variant_key in ("oneOf", "anyOf", "allOf"):
+            if variant_key in obj:
+                for i, variant in enumerate(obj[variant_key]):
+                    keys.extend(_flatten_keys(variant, f"{prefix}|{i}"))
+    return keys
+
+
+def main():
+    parser = argparse.ArgumentParser(description="根据 prompt 自动更新 JSON Schema")
+    parser.add_argument("prompt_name", help="Prompt 名称(不含 .prompt 扩展名)")
+    parser.add_argument("--model", default="openai/gpt-5.4", help="使用的模型(默认 openai/gpt-5.4)")
+    parser.add_argument("--dry-run", action="store_true", help="只打印生成结果,不写入文件")
+    args = parser.parse_args()
+
+    asyncio.run(update_schema(
+        prompt_name=args.prompt_name,
+        model=args.model,
+        dry_run=args.dry_run,
+    ))
+
+
+if __name__ == "__main__":
+    main()

+ 26 - 0
examples/process_pipeline/server.py

@@ -424,6 +424,32 @@ def save_prompt(name: str, req: PromptRequest):
         f.write(req.content.replace('\r\n', '\n'))
     return {"status": "ok"}
 
+@app.post("/api/prompts/{name}/update_schema")
+async def api_update_schema(name: str):
+    if "/" in name or "\\" in name:
+        raise HTTPException(status_code=400, detail="Invalid prompt name")
+    prompt_name = name.replace(".prompt", "")
+    
+    from script.update_schema import update_schema as script_update_schema
+    try:
+        # Calls the script to update the schema (this writes to the file)
+        await script_update_schema(prompt_name=prompt_name, model="openai/gpt-5.4", dry_run=False)
+        
+        schema_name = name.replace(".prompt", ".schema.json")
+        schema_path = PROMPTS_DIR / schema_name
+        
+        schema_content = ""
+        if schema_path.exists() and schema_path.is_file():
+            with open(schema_path, "r", encoding="utf-8") as f:
+                schema_content = f.read()
+                
+        return {"status": "ok", "schema_content": schema_content}
+    except Exception as e:
+        import traceback
+        traceback.print_exc()
+        raise HTTPException(status_code=500, detail=f"Failed to update schema: {str(e)}")
+
+
 async def run_pipeline_task(index: int, run_req: RunRequest):
     run_state = active_runs[index]
     run_state.status = "running"

+ 187 - 69
examples/process_pipeline/ui/app.js

@@ -1652,6 +1652,58 @@ function setupEventListeners() {
             }
         });
     }
+    const btnUpdateSchema = document.getElementById('update-schema-btn');
+    if (btnUpdateSchema) {
+        btnUpdateSchema.addEventListener('click', async () => {
+            if (!currentPromptName) return;
+            const originalText = btnUpdateSchema.innerHTML;
+            btnUpdateSchema.innerHTML = '<span style="font-size: 1.1em;">⏳</span> 更新中...';
+            btnUpdateSchema.disabled = true;
+            
+            try {
+                // First save the prompt so the backend reads the latest content
+                const reqBody = { content: elPromptTextarea.value };
+                if (elSchemaTextarea) reqBody.schema_content = elSchemaTextarea.value;
+                
+                let res = await fetch(`/api/prompts/${currentPromptName}`, {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify(reqBody)
+                });
+                if (!res.ok) {
+                    const errData = await res.json();
+                    throw new Error(errData.detail || "保存 Prompt 失败");
+                }
+                
+                // Then call the schema update API
+                res = await fetch(`/api/prompts/${currentPromptName}/update_schema`, {
+                    method: 'POST'
+                });
+                
+                if (res.ok) {
+                    const data = await res.json();
+                    if (elSchemaTextarea && data.schema_content) {
+                        elSchemaTextarea.value = data.schema_content;
+                    }
+                    btnUpdateSchema.innerHTML = '<span style="font-size: 1.1em;">✅</span> 更新成功';
+                    setTimeout(() => {
+                        btnUpdateSchema.innerHTML = originalText;
+                        btnUpdateSchema.disabled = false;
+                    }, 2000);
+                } else {
+                    const errData = await res.json();
+                    throw new Error(errData.detail || "更新 Schema 失败");
+                }
+            } catch(e) {
+                alert(e.message || '更新失败');
+                btnUpdateSchema.innerHTML = '<span style="font-size: 1.1em;">❌</span> 失败';
+                setTimeout(() => {
+                    btnUpdateSchema.innerHTML = originalText;
+                    btnUpdateSchema.disabled = false;
+                }, 2000);
+            }
+        });
+    }
 }
 
 // Boot
@@ -2170,14 +2222,26 @@ window.renderStructuredData = function(items, type, parentItem = null) {
             } else if (item.method && !item.method.includes('[')) {
                 actionStr = item.method;
             } else if (item.steps && Array.isArray(item.steps)) {
-                actionStr = item.steps.map(s => {
-                    if (s.action && s.action.main_action) {
-                        return s.action.mechanism ? `[${s.action.main_action}] ${s.action.mechanism}` : s.action.main_action;
-                    }
-                    return '未知';
-                }).join(' ➔ ');
+                const hasAnyValidIO = item.steps.some(s => hasValidIO(s.inputs) || hasValidIO(s.outputs));
+                if (hasAnyValidIO) {
+                    actionStr = item.steps.map(s => {
+                        if (s.action && s.action.main_action) {
+                            return s.action.mechanism ? `[${s.action.main_action}] ${s.action.mechanism}` : s.action.main_action;
+                        }
+                        if (s.method) return s.method;
+                        if (s.phase) return s.phase;
+                        return '未知';
+                    }).join(' ➔ ');
+                } else {
+                    actionStr = item.method || item.name || type === 'workflow' ? '工作流' : `节点 ${idx + 1}`;
+                }
+            }
+            
+            if (hasValidIO(item.inputs) || hasValidIO(item.outputs)) {
+                title = buildFullTitle(item.inputs, item.outputs, actionStr, item.method || item.name || `节点 ${idx + 1}`);
+            } else {
+                title = String(actionStr).replace(/</g, '&lt;').replace(/>/g, '&gt;');
             }
-            title = buildFullTitle(item.inputs, item.outputs, actionStr, item.method || item.name || `节点 ${idx + 1}`);
         } else {
             const escapeHtml = (s) => String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
             title = escapeHtml(item.method || item.name || '');
@@ -2519,6 +2583,21 @@ window.renderStructuredData = function(items, type, parentItem = null) {
 
         // Render steps array specially
         if (item.steps && Array.isArray(item.steps)) {
+            const allFragments = (parentItem && parentItem.fragments) || [];
+
+            const hasInputs = item.steps.some(s => s.inputs && Array.isArray(s.inputs) && s.inputs.length > 0) || allFragments.some(f => f.inputs && Array.isArray(f.inputs) && f.inputs.length > 0);
+            const hasOutputs = item.steps.some(s => s.outputs && Array.isArray(s.outputs) && s.outputs.length > 0) || allFragments.some(f => f.outputs && Array.isArray(f.outputs) && f.outputs.length > 0);
+            const hasAction = item.steps.some(s => s.action || s.method || s.description) || allFragments.some(f => f.action);
+            const hasApplyTo = item.steps.some(s => s.apply_to || s.apply_to_draft || s.apply_to_grounding) || allFragments.some(f => f.apply_to || f.apply_to_draft || f.apply_to_grounding);
+            const hasTools = item.steps.some(s => s.tools && Array.isArray(s.tools) && s.tools.length > 0) || allFragments.some(f => f.tools && Array.isArray(f.tools) && f.tools.length > 0);
+            const hasRelation = item.steps.some(s => s.relation);
+            const hasStepId = item.steps.some(s => s.step_id);
+
+            let minWidth = 400;
+            if (hasStepId) minWidth += 60;
+            if (hasRelation) minWidth += 120;
+            minWidth += 400; // base width for Fragments column
+
             html += `<div class="structured-row">
                 <div class="structured-label">steps</div>
                 <div class="structured-value" style="width: 100%; overflow-x: auto; padding-bottom: 8px;">
@@ -2530,95 +2609,134 @@ window.renderStructuredData = function(items, type, parentItem = null) {
                         .steps-table tr.expanded-row .expand-icon { transform: rotate(90deg); }
                         .cell-fade { position: absolute; bottom: 0; left: 0; right: 0; height: 30px; background: linear-gradient(to bottom, rgba(255,255,255,0), white); pointer-events: none; }
                         .steps-table tr.expanded-row .cell-fade { display: none; }
+                        .steps-table td { border-bottom: 1px solid rgba(0,0,0,0.05); }
+                        .steps-table tr.expanded-row .fragments-collapsed-view { display: none !important; }
+                        .steps-table tr.expanded-row .fragments-expanded-view { display: flex !important; }
                     </style>
-                    <table class="steps-table" style="width: 100%; min-width: 1200px; border-collapse: collapse; margin-top: 8px; font-size: 0.9em; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
+                    <table class="steps-table" style="width: 100%; min-width: ${minWidth}px; border-collapse: collapse; margin-top: 8px; font-size: 0.9em; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
                         <thead>
                             <tr style="background: rgba(0,0,0,0.03); border-bottom: 2px solid rgba(0,0,0,0.1); text-align: left;">
                                 <th style="padding: 12px 10px; width: 60px;">序号</th>
+                                ${hasStepId ? `<th style="padding: 12px 10px; width: 60px;">ID</th>` : ''}
                                 <th style="padding: 12px 10px; width: 70px;">阶段</th>
-                                <th style="display: none;">操作流</th>
-                                <th style="padding: 12px 10px; width: 200px;">输入</th>
-                                <th style="padding: 12px 10px; width: 120px;">动作</th>
-                                <th style="padding: 12px 10px; width: 200px;">输出</th>
-                                <th style="padding: 12px 10px; width: 200px;">作用域</th>
-                                <th style="padding: 12px 10px; width: 280px;">做法</th>
-                                <th style="padding: 12px 10px; width: 100px;">工具</th>
+                                ${hasRelation ? `<th style="padding: 12px 10px; width: 120px;">流转关系</th>` : ''}
+                                <th style="padding: 12px 10px; width: 280px; border-right: 1px solid rgba(0,0,0,0.05);">做法</th>
+                                <th style="padding: 12px 10px; width: auto;">原子操作 (Fragments)</th>
                             </tr>
                         </thead>
                         <tbody>`;
             
             const escapeHtml = (s) => String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
             item.steps.forEach((step, stepIdx) => {
-                let actionText = '未知';
-                if (step.action && step.action.main_action) {
-                    actionText = step.action.mechanism ? `[${step.action.main_action}] ${step.action.mechanism}` : step.action.main_action;
-                } else if (step.method) {
-                    actionText = step.method;
-                } else if (step.description) {
-                    actionText = step.description;
-                }
+                const stepFragments = allFragments.filter(f => f.fragment_id && (f.fragment_id === `f_${step.step_id}` || f.fragment_id.startsWith(`f_${step.step_id}_`)));
                 
-                let stepTitle = '';
-                const hasValidIO = (arr) => Array.isArray(arr) && arr.length > 0 && (arr[0].role || arr[0].description);
-                if (hasValidIO(step.inputs) || hasValidIO(step.outputs)) {
-                    stepTitle = buildFullTitle(step.inputs, step.outputs, actionText, step.method || step.description || `步骤 ${step.order || stepIdx + 1}`);
-                } else {
-                    stepTitle = escapeHtml(step.method || step.description || '');
-                    if (!stepTitle) stepTitle = escapeHtml(actionText);
-                    if (!stepTitle || stepTitle === '未知') stepTitle = escapeHtml(`步骤 ${step.order || stepIdx + 1}`);
+                let fragsToRender = stepFragments.length > 0 ? stepFragments : [step];
+                if (stepFragments.length === 0) {
+                     const hasFragFields = step.inputs || step.outputs || step.action || step.tools || step.apply_to || step.apply_to_draft || step.apply_to_grounding;
+                     if (!hasFragFields) fragsToRender = [];
                 }
                 
-                let actionHtml = escapeHtml(actionText);
-                if (step.action && step.action.main_action) {
-                    const badgeHtml = `<span class="data-type-badge" style="background:#e0e7ff;color:#3730a3;font-weight:normal;margin-right:6px;margin-bottom:2px;display:inline-block;">${escapeHtml(step.action.main_action)}</span>`;
-                    actionHtml = step.action.mechanism ? badgeHtml + escapeHtml(step.action.mechanism) : badgeHtml;
+                let fragmentsHtml = '';
+                if (fragsToRender.length > 0) {
+                    let collapsedIdsHtml = fragsToRender.map(src => {
+                        return src.fragment_id 
+                            ? `<span style="display: inline-block; font-family: monospace; font-size: 0.85em; font-weight: bold; color: #4338ca; background: #e0e7ff; border: 1px solid #c7d2fe; padding: 2px 6px; border-radius: 4px;">${escapeHtml(src.fragment_id)}</span>`
+                            : `<span style="display: inline-block; font-size: 0.85em; color: #64748b; background: #f1f5f9; padding: 2px 6px; border-radius: 4px;">含操作详情</span>`;
+                    }).join(' ');
+
+                    let expandedCardsHtml = fragsToRender.map(src => {
+                        let actionText = '未知';
+                        if (src.action && src.action.main_action) {
+                            actionText = src.action.mechanism ? `[${src.action.main_action}] ${src.action.mechanism}` : src.action.main_action;
+                        } else if (src.method) {
+                            actionText = src.method;
+                        } else if (src.description) {
+                            actionText = src.description;
+                        }
+                        
+                        let actionHtml = escapeHtml(actionText);
+                        if (src.action && src.action.main_action) {
+                            const badgeHtml = `<span class="data-type-badge" style="background:#e0e7ff;color:#3730a3;font-weight:normal;margin-right:6px;margin-bottom:2px;display:inline-block;">${escapeHtml(src.action.main_action)}</span>`;
+                            actionHtml = src.action.mechanism ? badgeHtml + escapeHtml(src.action.mechanism) : badgeHtml;
+                        } else {
+                            const match = actionText.match(/^\[(.*?)\]\s*(.*)$/);
+                            if (match) {
+                                actionHtml = `<span class="data-type-badge" style="background:#e0e7ff;color:#3730a3;font-weight:normal;margin-right:6px;margin-bottom:2px;display:inline-block;">${escapeHtml(match[1])}</span>${escapeHtml(match[2])}`;
+                            } else if (actionText === '未知') {
+                                actionHtml = '';
+                            }
+                        }
+                        
+                        let toolsHtml = '';
+                        if (src.tools && src.tools.length > 0) {
+                            toolsHtml = src.tools.map(t => `<span class="structured-badge tool-badge" style="display:inline-block; margin:2px;">${escapeHtml(t)}</span>`).join('');
+                        }
+                        
+                        return `<div style="border: 1px solid rgba(0,0,0,0.08); border-radius: 6px; background: white; position: relative; box-shadow: 0 1px 2px rgba(0,0,0,0.02); overflow: hidden;">
+                            <div style="display: flex; justify-content: space-between; align-items: center; background: #f8fafc; padding: 8px 12px; border-bottom: 1px solid rgba(0,0,0,0.05);">
+                                <div style="font-weight: bold; color: var(--text-main); font-size: 0.95em;">${actionHtml || '操作'}</div>
+                                ${src.fragment_id ? `<div style="font-family: monospace; font-size: 0.85em; font-weight: bold; color: #4338ca; background: #e0e7ff; border: 1px solid #c7d2fe; padding: 2px 8px; border-radius: 4px;">${escapeHtml(src.fragment_id)}</div>` : ''}
+                            </div>
+                            <div style="padding: 10px 12px; display: flex; flex-direction: column; gap: 8px;">
+                                ${src.inputs && src.inputs.length > 0 ? `
+                                <div style="display: flex; align-items: flex-start; gap: 12px;">
+                                    <div style="width: 48px; font-size: 0.8em; color: var(--text-muted); background: #f1f5f9; padding: 3px 6px; border-radius: 4px; text-align: center; flex-shrink: 0; margin-top: 1px;">输入</div>
+                                    <div style="flex-grow: 1; min-width: 0;">${renderDataObjList(src.inputs)}</div>
+                                </div>` : ''}
+                                
+                                ${src.outputs && src.outputs.length > 0 ? `
+                                <div style="display: flex; align-items: flex-start; gap: 12px;">
+                                    <div style="width: 48px; font-size: 0.8em; color: var(--text-muted); background: #f1f5f9; padding: 3px 6px; border-radius: 4px; text-align: center; flex-shrink: 0; margin-top: 1px;">输出</div>
+                                    <div style="flex-grow: 1; min-width: 0;">${renderDataObjList(src.outputs)}</div>
+                                </div>` : ''}
+
+                                ${(src.apply_to_draft || src.apply_to_grounding || src.apply_to) ? `
+                                <div style="display: flex; align-items: flex-start; gap: 12px;">
+                                    <div style="width: 48px; font-size: 0.8em; color: var(--text-muted); background: #f1f5f9; padding: 3px 6px; border-radius: 4px; text-align: center; flex-shrink: 0; margin-top: 1px;">作用域</div>
+                                    <div style="flex-grow: 1; min-width: 0; font-size: 0.9em; line-height: 1.5; color: var(--text-main); margin-top: 2px;">${renderApplyToVal(src.apply_to_draft || src.apply_to_grounding || src.apply_to)}</div>
+                                </div>` : ''}
+
+                                ${toolsHtml ? `
+                                <div style="display: flex; align-items: flex-start; gap: 12px;">
+                                    <div style="width: 48px; font-size: 0.8em; color: var(--text-muted); background: #f1f5f9; padding: 3px 6px; border-radius: 4px; text-align: center; flex-shrink: 0; margin-top: 1px;">工具</div>
+                                    <div style="flex-grow: 1; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px;">${toolsHtml}</div>
+                                </div>` : ''}
+                            </div>
+                        </div>`;
+                    }).join('');
+
+                    fragmentsHtml = `
+                        <div class="fragments-collapsed-view" style="display: flex; gap: 6px; flex-wrap: wrap; align-items: center; padding-top: 2px;">
+                            ${collapsedIdsHtml}
+                        </div>
+                        <div class="fragments-expanded-view" style="display: none; flex-direction: column; gap: 8px;">
+                            ${expandedCardsHtml}
+                        </div>
+                    `;
                 } else {
-                    const match = actionText.match(/^\[(.*?)\]\s*(.*)$/);
-                    if (match) {
-                        actionHtml = `<span class="data-type-badge" style="background:#e0e7ff;color:#3730a3;font-weight:normal;margin-right:6px;margin-bottom:2px;display:inline-block;">${escapeHtml(match[1])}</span>${escapeHtml(match[2])}`;
-                    }
-                }
-                
-                let toolsHtml = '';
-                if (step.tools && step.tools.length > 0) {
-                    toolsHtml = step.tools.map(t => `<span class="structured-badge tool-badge" style="display:inline-block; margin:2px;">${escapeHtml(t)}</span>`).join('');
+                    fragmentsHtml = `<div class="cell-content" style="max-height: 50px; overflow: hidden; color: var(--text-muted);">-</div><div class="cell-fade"></div>`;
                 }
 
                 html += `
-                    <tr style="border-bottom: 1px solid rgba(0,0,0,0.05); vertical-align: top;" onclick="this.classList.toggle('expanded-row')">
-                        <td style="padding: 14px 10px; font-weight: 500; color: var(--text-muted); text-align: center;">
+                    <tr style="vertical-align: top; border-bottom: 1px solid rgba(0,0,0,0.05);" onclick="this.classList.toggle('expanded-row')">
+                        <td style="padding: 14px 10px; font-weight: 500; color: var(--text-muted); text-align: center; border-right: 1px dashed rgba(0,0,0,0.05);">
                             <span class="expand-icon">▶</span> ${step.order || stepIdx + 1}
                         </td>
-                        <td style="padding: 14px 10px;">
+                        ${hasStepId ? `<td style="padding: 14px 10px; font-family: monospace; color: var(--text-muted); border-right: 1px dashed rgba(0,0,0,0.05);">${escapeHtml(step.step_id || '-')}</td>` : ''}
+                        <td style="padding: 14px 10px; border-right: 1px dashed rgba(0,0,0,0.05);">
                             ${step.phase ? `<span class="structured-badge" style="background:#f1f5f9; color:#475569; font-weight: 500;">${escapeHtml(step.phase)}</span>` : '-'}
                         </td>
-                        <td style="display: none;">
-                            <div style="font-weight: 600; color: var(--text-secondary); font-size: 0.9em; background: rgba(0,0,0,0.03); padding: 4px 8px; border-radius: 4px; display: inline-block; word-break: break-all;">
-                                ${stepTitle}
-                            </div>
-                        </td>
-                        <td style="padding: 14px 10px; position: relative;">
-                            <div class="cell-content" style="max-height: 50px; overflow: hidden;">${step.inputs && Array.isArray(step.inputs) && step.inputs.length > 0 ? renderDataObjList(step.inputs) : '-'}</div>
-                            <div class="cell-fade"></div>
-                        </td>
-                        <td style="padding: 14px 10px; font-weight: bold; color: var(--text-main); line-height: 1.5;">
-                            ${actionHtml}
-                        </td>
-                        <td style="padding: 14px 10px; position: relative;">
-                            <div class="cell-content" style="max-height: 50px; overflow: hidden;">${step.outputs && Array.isArray(step.outputs) && step.outputs.length > 0 ? renderDataObjList(step.outputs) : '-'}</div>
-                            <div class="cell-fade"></div>
-                        </td>
-                        <td style="padding: 14px 10px; position: relative;">
-                            <div class="cell-content" style="max-height: 50px; overflow: hidden;">${renderApplyToVal(step.apply_to_draft || step.apply_to_grounding || step.apply_to)}</div>
+                        ${hasRelation ? `
+                        <td style="padding: 14px 10px; color: var(--text-secondary); font-size: 0.9em; position: relative; border-right: 1px dashed rgba(0,0,0,0.05);">
+                            <div class="cell-content" style="max-height: 50px; overflow: hidden; font-family: monospace;">${step.relation ? escapeHtml(step.relation) : '-'}</div>
                             <div class="cell-fade"></div>
-                        </td>
-                        <td style="padding: 14px 10px; color: var(--text-main); font-size: 0.95em; line-height: 1.5; position: relative;">
+                        </td>` : ''}
+                        <td style="padding: 14px 10px; color: var(--text-main); font-size: 0.95em; line-height: 1.5; position: relative; border-right: 1px solid rgba(0,0,0,0.05);">
                             <div class="cell-content" style="max-height: 50px; overflow: hidden; white-space: pre-wrap;">${step.body ? escapeHtml(step.body) : '-'}</div>
                             <div class="cell-fade"></div>
                         </td>
                         <td style="padding: 14px 10px; position: relative;">
-                            <div class="cell-content" style="max-height: 50px; overflow: hidden;">${toolsHtml || '-'}</div>
-                            <div class="cell-fade"></div>
+                            ${fragmentsHtml}
                         </td>
                     </tr>
                 `;

+ 6 - 1
examples/process_pipeline/ui/index.html

@@ -435,7 +435,12 @@
                             placeholder="选择左侧节点或列表中的提示词进行编辑..."></textarea>
                     </div>
                     <div style="flex: 1; display: flex; flex-direction: column;">
-                        <h4 style="margin-top:0; margin-bottom:0.5rem; color:var(--text-main);">Schema JSON</h4>
+                        <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
+                            <h4 style="margin:0; color:var(--text-main);">Schema JSON</h4>
+                            <button id="update-schema-btn" class="btn" style="background: white; border: 1px solid #e2e8f0; color: #4f46e5; font-size: 0.85em; padding: 4px 10px; border-radius: 4px; display: flex; align-items: center; gap: 4px; cursor: pointer; transition: all 0.2s;" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background='white'">
+                                <span style="font-size: 1.1em;">✨</span> 基于 Prompt 更新
+                            </button>
+                        </div>
                         <textarea id="schema-textarea"
                             style="flex: 1; width:100%; background:rgba(0,0,0,0.03); color:var(--text-main); font-family:monospace; padding:1rem; border:1px solid rgba(0, 0, 0, 0.1); border-radius:6px; resize:none; font-size:14px; line-height: 1.5;"
                             placeholder="对应的 Schema JSON..."></textarea>