jihuaqiang 3 дней назад
Родитель
Сommit
38ecbe429e
2 измененных файлов с 322 добавлено и 5 удалено
  1. 304 0
      examples/create/html.py
  2. 18 5
      examples/create/run.py

+ 304 - 0
examples/create/html.py

@@ -0,0 +1,304 @@
+"""
+将 messages 转为可视化 HTML 结构展示
+
+功能:
+- 每条 message 有清晰的类型标识(系统 / 用户 / 助手 / 工具)
+- 工具类型标注工具名称和工具输出
+- 内容过长时支持展开/收起
+"""
+
+import json
+from pathlib import Path
+from typing import Any, List, Union
+
+# 展开阈值:超过此字符数则默认折叠
+COLLAPSE_THRESHOLD = 300
+
+
+def _ensure_messages(messages: List[Any]) -> List[dict]:
+    """将 Message 对象或 dict 统一转为 dict 列表"""
+    result = []
+    for m in messages:
+        if hasattr(m, "to_dict"):
+            result.append(m.to_dict())
+        elif isinstance(m, dict):
+            result.append(m)
+        else:
+            result.append({"role": "unknown", "content": str(m)})
+    return result
+
+
+def _get_message_type_info(msg: dict) -> tuple[str, str, str]:
+    """
+    根据消息内容返回 (类型标签, 简短说明, 样式类)
+    """
+    role = msg.get("role", "unknown")
+    content = msg.get("content")
+    desc = msg.get("description", "")
+
+    if role == "system":
+        return "系统", "系统指令", "msg-system"
+    if role == "user":
+        return "用户", "用户输入", "msg-user"
+    if role == "assistant":
+        if isinstance(content, dict):
+            text = content.get("text", "")
+            tool_calls = content.get("tool_calls")
+            if tool_calls:
+                names = [
+                    tc.get("function", {}).get("name", "?")
+                    for tc in (tool_calls if isinstance(tool_calls, list) else [])
+                ]
+                label = f"工具调用: {', '.join(names)}" if names else "工具调用"
+                return "助手", label, "msg-assistant-tool"
+            if text:
+                return "助手", "文本回复", "msg-assistant"
+        return "助手", desc or "助手消息", "msg-assistant"
+    if role == "tool":
+        tool_name = "unknown"
+        if isinstance(content, dict):
+            tool_name = content.get("tool_name", content.get("name", "unknown"))
+        return "工具", tool_name, "msg-tool"
+
+    return "未知", str(role), "msg-unknown"
+
+
+def _extract_display_content(msg: dict) -> str:
+    """提取用于展示的文本内容"""
+    role = msg.get("role", "unknown")
+    content = msg.get("content")
+
+    if role == "system" or role == "user":
+        return str(content) if content else ""
+
+    if role == "assistant" and isinstance(content, dict):
+        return content.get("text", "") or ""
+
+    if role == "tool" and isinstance(content, dict):
+        result = content.get("result", content)
+        if isinstance(result, list):
+            return json.dumps(result, ensure_ascii=False, indent=2)
+        return str(result) if result else ""
+
+    return str(content) if content else ""
+
+
+def _extract_tool_info(msg: dict) -> tuple[str, str]:
+    """提取 tool 消息的工具名和输出"""
+    content = msg.get("content")
+    if not isinstance(content, dict):
+        return "unknown", str(content or "")
+    tool_name = content.get("tool_name", content.get("name", msg.get("description", "unknown")))
+    result = content.get("result", content.get("output", content))
+    if isinstance(result, dict) or isinstance(result, list):
+        output = json.dumps(result, ensure_ascii=False, indent=2)
+    else:
+        output = str(result) if result is not None else ""
+    return tool_name, output
+
+
+def _render_collapsible(content: str, block_id: str = "") -> str:
+    """生成可展开/收起的 HTML 片段"""
+    content = content.strip()
+    if not content:
+        return '<pre class="content-body"></pre>'
+
+    escaped = content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
+    should_collapse = len(content) > COLLAPSE_THRESHOLD
+    safe_id = "".join(c if c.isalnum() or c in "-_" else "-" for c in block_id) or "x"
+
+    if should_collapse:
+        preview = escaped[:COLLAPSE_THRESHOLD] + "…"
+        full = escaped
+        return f'''<div class="collapsible-wrap">
+            <pre class="content-body content-preview" id="preview-{safe_id}">{preview}</pre>
+            <pre class="content-body content-full" id="full-{safe_id}" style="display:none">{full}</pre>
+            <button class="btn-toggle" onclick="toggleExpand('{safe_id}')">展开全部</button>
+        </div>'''
+    return f'<pre class="content-body">{escaped}</pre>'
+
+
+def _render_message(msg: dict, index: int) -> str:
+    """渲染单条消息为 HTML"""
+    type_label, short_desc, css_class = _get_message_type_info(msg)
+    seq = msg.get("sequence", index)
+    role = msg.get("role", "unknown")
+    bid = f"m{index}"
+
+    # 头部:类型 + 简短说明
+    header = f'<div class="msg-header"><span class="msg-type {css_class}">{type_label}</span> <span class="msg-desc">{short_desc}</span></div>'
+
+    body_parts = []
+
+    if role == "assistant":
+        content = msg.get("content")
+        if isinstance(content, dict):
+            tool_calls = content.get("tool_calls")
+            text = content.get("text", "")
+            if tool_calls:
+                for tc in tool_calls:
+                    fn = tc.get("function", {})
+                    name = fn.get("name", "?")
+                    args_str = fn.get("arguments", "{}")
+                    try:
+                        args_json = json.loads(args_str)
+                        args_preview = json.dumps(args_json, ensure_ascii=False)[:200]
+                        if len(json.dumps(args_json)) > 200:
+                            args_preview += "…"
+                    except Exception:
+                        args_preview = args_str[:200] + ("…" if len(args_str) > 200 else "")
+                    body_parts.append(
+                        f'<div class="tool-call-item"><span class="tool-name">🛠 {name}</span><pre class="tool-args">{args_preview}</pre></div>'
+                    )
+            if text:
+                body_parts.append(_render_collapsible(text, f"{bid}-text"))
+
+    elif role == "tool":
+        tool_name, output = _extract_tool_info(msg)
+        body_parts.append(f'<div class="tool-output-header"><span class="tool-name">🛠 {tool_name}</span></div>')
+        body_parts.append(_render_collapsible(output, f"{bid}-tool"))
+
+    else:
+        content = _extract_display_content(msg)
+        body_parts.append(_render_collapsible(content, bid))
+
+    body = "\n".join(body_parts)
+    return f'<div class="msg-item" data-role="{role}" data-seq="{seq}">{header}<div class="msg-body">{body}</div></div>'
+
+
+def _build_html(messages: List[dict], title: str = "Messages") -> str:
+    """构建完整 HTML 文档"""
+    items_html = "\n".join(_render_message(m, i) for i, m in enumerate(messages))
+    return f"""<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{title}</title>
+<style>
+* {{ box-sizing: border-box; }}
+body {{ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; line-height: 1.5; }}
+h1 {{ font-size: 1.25rem; margin-bottom: 16px; color: #333; }}
+.msg-list {{ display: flex; flex-direction: column; gap: 12px; }}
+.msg-item {{ background: #fff; border-radius: 8px; padding: 12px 16px; box-shadow: 0 1px 3px rgba(0,0,0,.08); border-left: 4px solid #94a3b8; }}
+.msg-item[data-role="system"] {{ border-left-color: #64748b; }}
+.msg-item[data-role="user"] {{ border-left-color: #3b82f6; }}
+.msg-item[data-role="assistant"] {{ border-left-color: #22c55e; }}
+.msg-item[data-role="tool"] {{ border-left-color: #f59e0b; }}
+.msg-header {{ margin-bottom: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }}
+.msg-type {{ font-size: 0.75rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; }}
+.msg-system {{ background: #e2e8f0; color: #475569; }}
+.msg-user {{ background: #dbeafe; color: #1d4ed8; }}
+.msg-assistant {{ background: #dcfce7; color: #15803d; }}
+.msg-assistant-tool {{ background: #fef3c7; color: #b45309; }}
+.msg-tool {{ background: #fed7aa; color: #c2410c; }}
+.msg-desc {{ font-size: 0.875rem; color: #64748b; }}
+.msg-body {{ font-size: 0.875rem; }}
+.content-body {{ margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 0.8125rem; color: #334155; max-height: 400px; overflow-y: auto; }}
+.tool-call-item {{ margin-bottom: 8px; }}
+.tool-name {{ font-weight: 600; color: #0f172a; }}
+.tool-args {{ margin: 4px 0 0 0; padding: 8px; background: #f8fafc; border-radius: 4px; font-size: 0.75rem; overflow-x: auto; }}
+.tool-output-header {{ margin-bottom: 8px; }}
+.btn-toggle {{ margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer; background: #e2e8f0; border: 1px solid #cbd5e1; border-radius: 4px; color: #475569; }}
+.btn-toggle:hover {{ background: #cbd5e1; }}
+.collapsible-wrap {{ position: relative; }}
+</style>
+</head>
+<body>
+<h1>{title}</h1>
+<div class="msg-list">{items_html}</div>
+<script>
+function toggleExpand(idSuffix) {{
+  var preview = document.getElementById('preview-' + idSuffix);
+  var full = document.getElementById('full-' + idSuffix);
+  var btn = preview.parentElement.querySelector('.btn-toggle');
+  if (!preview || !full) return;
+  if (full.style.display === 'none') {{
+    preview.style.display = 'none';
+    full.style.display = 'block';
+    if (btn) btn.textContent = '收起';
+  }} else {{
+    preview.style.display = 'block';
+    full.style.display = 'none';
+    if (btn) btn.textContent = '展开全部';
+  }}
+}}
+</script>
+</body>
+</html>"""
+
+
+def messages_to_html(
+    messages: List[Any],
+    output_path: Union[str, Path],
+    title: str = "Messages 可视化",
+) -> Path:
+    """
+    将 messages 转为 HTML 并写入文件
+
+    Args:
+        messages: Message 对象或 dict 列表
+        output_path: 输出 HTML 文件路径
+        title: 页面标题
+
+    Returns:
+        输出文件的 Path
+    """
+    data = _ensure_messages(messages)
+    html = _build_html(data, title)
+    out = Path(output_path)
+    out.parent.mkdir(parents=True, exist_ok=True)
+    out.write_text(html, encoding="utf-8")
+    return out
+
+
+async def trace_to_html(
+    trace_id: str,
+    output_path: Union[str, Path],
+    base_path: str = ".trace",
+    title: str | None = None,
+) -> Path:
+    """
+    从 Trace 加载 messages 并生成 HTML
+
+    Args:
+        trace_id: Trace ID
+        output_path: 输出 HTML 文件路径
+        base_path: Trace 存储根目录
+        title: 页面标题,默认使用 trace_id
+
+    Returns:
+        输出文件的 Path
+    """
+    from agent.trace import FileSystemTraceStore
+
+    store = FileSystemTraceStore(base_path=base_path)
+    messages = await store.get_trace_messages(trace_id)
+    if not messages:
+        raise FileNotFoundError(f"Trace {trace_id} 下没有找到 messages")
+    page_title = title or f"Trace {trace_id[:8]}... Messages"
+    return messages_to_html(messages, output_path, title=page_title)
+
+
+if __name__ == "__main__":
+    import asyncio
+    import sys
+    from pathlib import Path
+
+    # 添加项目根目录,使 agent 模块可被导入
+    _project_root = Path(__file__).resolve().parent.parent.parent
+    if str(_project_root) not in sys.path:
+        sys.path.insert(0, str(_project_root))
+
+    async def _main():
+        import argparse
+        parser = argparse.ArgumentParser(description="将 trace messages 转为 HTML")
+        parser.add_argument("--trace", required=True, help="Trace ID")
+        parser.add_argument("-o", "--output", default="messages.html", help="输出文件路径")
+        parser.add_argument("--base-path", default=".trace", help="Trace 存储根目录")
+        args = parser.parse_args()
+
+        out = await trace_to_html(args.trace, args.output, base_path=args.base_path)
+        print(f"已生成: {out.absolute()}")
+
+    asyncio.run(_main())

+ 18 - 5
examples/create/run.py

@@ -39,10 +39,12 @@ from agent.trace import (
     Trace,
     Message,
 )
+from examples.create.html import trace_to_html
 from agent.llm import create_openrouter_llm_call
 from agent.tools import get_tool_registry
 
-DEFAULT_MODEL = "anthropic/claude-sonnet-4.5"
+# DEFAULT_MODEL = "anthropic/claude-sonnet-4.5"
+DEFAULT_MODEL = "google/gemini-3-flash-preview"
 
 # ===== 非阻塞 stdin 检测 =====
 if sys.platform == 'win32':
@@ -579,6 +581,16 @@ async def main():
         if current_trace_id:
             await runner.stop(current_trace_id)
 
+    finally:
+        # 进程退出时自动生成 messages HTML 到 .trace/<id>/ 目录
+        if current_trace_id:
+            try:
+                html_path = store.base_path / current_trace_id / "messages.html"
+                await trace_to_html(current_trace_id, html_path, base_path=str(store.base_path))
+                print(f"\n✓ Messages 可视化已保存: {html_path}")
+            except Exception as e:
+                print(f"\n⚠ 生成 HTML 失败: {e}")
+
     # 6. 输出结果
     if final_response:
         print()
@@ -599,13 +611,14 @@ async def main():
 
     # 可视化提示
     if current_trace_id:
+        html_path = store.base_path / current_trace_id / "messages.html"
         print("=" * 60)
-        print("可视化 Step Tree:")
+        print("可视化:")
         print("=" * 60)
-        print("1. 启动 API Server:")
-        print("   python3 api_server.py")
+        print(f"1. 本地 HTML: {html_path}")
         print()
-        print("2. 浏览器访问:")
+        print("2. API Server:")
+        print("   python3 api_server.py")
         print("   http://localhost:8000/api/traces")
         print()
         print(f"3. Trace ID: {current_trace_id}")