html.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. """
  2. 将 messages 转为可视化 HTML 结构展示
  3. 功能:
  4. - 每条 message 有清晰的类型标识(系统 / 用户 / 助手 / 工具)
  5. - 工具类型标注工具名称和工具输出
  6. - 内容过长时支持展开/收起
  7. """
  8. import json
  9. from pathlib import Path
  10. from typing import Any, List, Union
  11. # 展开阈值:超过此字符数则默认折叠
  12. COLLAPSE_THRESHOLD = 300
  13. def _ensure_messages(messages: List[Any]) -> List[dict]:
  14. """将 Message 对象或 dict 统一转为 dict 列表"""
  15. result = []
  16. for m in messages:
  17. if hasattr(m, "to_dict"):
  18. result.append(m.to_dict())
  19. elif isinstance(m, dict):
  20. result.append(m)
  21. else:
  22. result.append({"role": "unknown", "content": str(m)})
  23. return result
  24. def _get_message_type_info(msg: dict) -> tuple[str, str, str]:
  25. """
  26. 根据消息内容返回 (类型标签, 简短说明, 样式类)
  27. """
  28. role = msg.get("role", "unknown")
  29. content = msg.get("content")
  30. desc = msg.get("description", "")
  31. if role == "system":
  32. return "系统", "系统指令", "msg-system"
  33. if role == "user":
  34. return "用户", "用户输入", "msg-user"
  35. if role == "assistant":
  36. if isinstance(content, dict):
  37. text = content.get("text", "")
  38. tool_calls = content.get("tool_calls")
  39. if tool_calls:
  40. names = [
  41. tc.get("function", {}).get("name", "?")
  42. for tc in (tool_calls if isinstance(tool_calls, list) else [])
  43. ]
  44. label = f"工具调用: {', '.join(names)}" if names else "工具调用"
  45. return "助手", label, "msg-assistant-tool"
  46. if text:
  47. return "助手", "文本回复", "msg-assistant"
  48. return "助手", desc or "助手消息", "msg-assistant"
  49. if role == "tool":
  50. tool_name = "unknown"
  51. if isinstance(content, dict):
  52. tool_name = content.get("tool_name", content.get("name", "unknown"))
  53. return "工具", tool_name, "msg-tool"
  54. return "未知", str(role), "msg-unknown"
  55. def _extract_display_content(msg: dict) -> str:
  56. """提取用于展示的文本内容"""
  57. role = msg.get("role", "unknown")
  58. content = msg.get("content")
  59. if role == "system" or role == "user":
  60. return str(content) if content else ""
  61. if role == "assistant" and isinstance(content, dict):
  62. return content.get("text", "") or ""
  63. if role == "tool" and isinstance(content, dict):
  64. result = content.get("result", content)
  65. if isinstance(result, list):
  66. return json.dumps(result, ensure_ascii=False, indent=2)
  67. return str(result) if result else ""
  68. return str(content) if content else ""
  69. def _extract_tool_info(msg: dict) -> tuple[str, str]:
  70. """提取 tool 消息的工具名和输出"""
  71. content = msg.get("content")
  72. if not isinstance(content, dict):
  73. return "unknown", str(content or "")
  74. tool_name = content.get("tool_name", content.get("name", msg.get("description", "unknown")))
  75. result = content.get("result", content.get("output", content))
  76. if isinstance(result, dict) or isinstance(result, list):
  77. output = json.dumps(result, ensure_ascii=False, indent=2)
  78. else:
  79. output = str(result) if result is not None else ""
  80. return tool_name, output
  81. def _render_collapsible(content: str, block_id: str = "") -> str:
  82. """生成可展开/收起的 HTML 片段"""
  83. content = content.strip()
  84. if not content:
  85. return '<pre class="content-body"></pre>'
  86. escaped = content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
  87. should_collapse = len(content) > COLLAPSE_THRESHOLD
  88. safe_id = "".join(c if c.isalnum() or c in "-_" else "-" for c in block_id) or "x"
  89. if should_collapse:
  90. preview = escaped[:COLLAPSE_THRESHOLD] + "…"
  91. full = escaped
  92. return f'''<div class="collapsible-wrap">
  93. <pre class="content-body content-preview" id="preview-{safe_id}">{preview}</pre>
  94. <pre class="content-body content-full" id="full-{safe_id}" style="display:none">{full}</pre>
  95. <button class="btn-toggle" onclick="toggleExpand('{safe_id}')">展开全部</button>
  96. </div>'''
  97. return f'<pre class="content-body">{escaped}</pre>'
  98. def _render_message(msg: dict, index: int) -> str:
  99. """渲染单条消息为 HTML"""
  100. type_label, short_desc, css_class = _get_message_type_info(msg)
  101. seq = msg.get("sequence", index)
  102. role = msg.get("role", "unknown")
  103. bid = f"m{index}"
  104. # 头部:类型 + 简短说明
  105. header = f'<div class="msg-header"><span class="msg-type {css_class}">{type_label}</span> <span class="msg-desc">{short_desc}</span></div>'
  106. body_parts = []
  107. if role == "assistant":
  108. content = msg.get("content")
  109. if isinstance(content, dict):
  110. tool_calls = content.get("tool_calls")
  111. text = content.get("text", "")
  112. if tool_calls:
  113. for tc in tool_calls:
  114. fn = tc.get("function", {})
  115. name = fn.get("name", "?")
  116. args_str = fn.get("arguments", "{}")
  117. try:
  118. args_json = json.loads(args_str)
  119. args_preview = json.dumps(args_json, ensure_ascii=False)[:200]
  120. if len(json.dumps(args_json)) > 200:
  121. args_preview += "…"
  122. except Exception:
  123. args_preview = args_str[:200] + ("…" if len(args_str) > 200 else "")
  124. body_parts.append(
  125. f'<div class="tool-call-item"><span class="tool-name">🛠 {name}</span><pre class="tool-args">{args_preview}</pre></div>'
  126. )
  127. if text:
  128. body_parts.append(_render_collapsible(text, f"{bid}-text"))
  129. elif role == "tool":
  130. tool_name, output = _extract_tool_info(msg)
  131. body_parts.append(f'<div class="tool-output-header"><span class="tool-name">🛠 {tool_name}</span></div>')
  132. body_parts.append(_render_collapsible(output, f"{bid}-tool"))
  133. else:
  134. content = _extract_display_content(msg)
  135. body_parts.append(_render_collapsible(content, bid))
  136. body = "\n".join(body_parts)
  137. return f'<div class="msg-item" data-role="{role}" data-seq="{seq}">{header}<div class="msg-body">{body}</div></div>'
  138. def _build_html(messages: List[dict], title: str = "Messages") -> str:
  139. """构建完整 HTML 文档"""
  140. items_html = "\n".join(_render_message(m, i) for i, m in enumerate(messages))
  141. return f"""<!DOCTYPE html>
  142. <html lang="zh-CN">
  143. <head>
  144. <meta charset="UTF-8">
  145. <meta name="viewport" content="width=device-width, initial-scale=1">
  146. <title>{title}</title>
  147. <style>
  148. * {{ box-sizing: border-box; }}
  149. body {{ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; line-height: 1.5; }}
  150. h1 {{ font-size: 1.25rem; margin-bottom: 16px; color: #333; }}
  151. .msg-list {{ display: flex; flex-direction: column; gap: 12px; }}
  152. .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; }}
  153. .msg-item[data-role="system"] {{ border-left-color: #64748b; }}
  154. .msg-item[data-role="user"] {{ border-left-color: #3b82f6; }}
  155. .msg-item[data-role="assistant"] {{ border-left-color: #22c55e; }}
  156. .msg-item[data-role="tool"] {{ border-left-color: #f59e0b; }}
  157. .msg-header {{ margin-bottom: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }}
  158. .msg-type {{ font-size: 0.75rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; }}
  159. .msg-system {{ background: #e2e8f0; color: #475569; }}
  160. .msg-user {{ background: #dbeafe; color: #1d4ed8; }}
  161. .msg-assistant {{ background: #dcfce7; color: #15803d; }}
  162. .msg-assistant-tool {{ background: #fef3c7; color: #b45309; }}
  163. .msg-tool {{ background: #fed7aa; color: #c2410c; }}
  164. .msg-desc {{ font-size: 0.875rem; color: #64748b; }}
  165. .msg-body {{ font-size: 0.875rem; }}
  166. .content-body {{ margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 0.8125rem; color: #334155; max-height: 400px; overflow-y: auto; }}
  167. .tool-call-item {{ margin-bottom: 8px; }}
  168. .tool-name {{ font-weight: 600; color: #0f172a; }}
  169. .tool-args {{ margin: 4px 0 0 0; padding: 8px; background: #f8fafc; border-radius: 4px; font-size: 0.75rem; overflow-x: auto; }}
  170. .tool-output-header {{ margin-bottom: 8px; }}
  171. .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; }}
  172. .btn-toggle:hover {{ background: #cbd5e1; }}
  173. .collapsible-wrap {{ position: relative; }}
  174. </style>
  175. </head>
  176. <body>
  177. <h1>{title}</h1>
  178. <div class="msg-list">{items_html}</div>
  179. <script>
  180. function toggleExpand(idSuffix) {{
  181. var preview = document.getElementById('preview-' + idSuffix);
  182. var full = document.getElementById('full-' + idSuffix);
  183. var btn = preview.parentElement.querySelector('.btn-toggle');
  184. if (!preview || !full) return;
  185. if (full.style.display === 'none') {{
  186. preview.style.display = 'none';
  187. full.style.display = 'block';
  188. if (btn) btn.textContent = '收起';
  189. }} else {{
  190. preview.style.display = 'block';
  191. full.style.display = 'none';
  192. if (btn) btn.textContent = '展开全部';
  193. }}
  194. }}
  195. </script>
  196. </body>
  197. </html>"""
  198. def messages_to_html(
  199. messages: List[Any],
  200. output_path: Union[str, Path],
  201. title: str = "Messages 可视化",
  202. ) -> Path:
  203. """
  204. 将 messages 转为 HTML 并写入文件
  205. Args:
  206. messages: Message 对象或 dict 列表
  207. output_path: 输出 HTML 文件路径
  208. title: 页面标题
  209. Returns:
  210. 输出文件的 Path
  211. """
  212. data = _ensure_messages(messages)
  213. html = _build_html(data, title)
  214. out = Path(output_path)
  215. out.parent.mkdir(parents=True, exist_ok=True)
  216. out.write_text(html, encoding="utf-8")
  217. return out
  218. async def trace_to_html(
  219. trace_id: str,
  220. output_path: Union[str, Path],
  221. base_path: str = ".trace",
  222. title: str | None = None,
  223. ) -> Path:
  224. """
  225. 从 Trace 加载 messages 并生成 HTML
  226. Args:
  227. trace_id: Trace ID
  228. output_path: 输出 HTML 文件路径
  229. base_path: Trace 存储根目录
  230. title: 页面标题,默认使用 trace_id
  231. Returns:
  232. 输出文件的 Path
  233. """
  234. from agent.trace import FileSystemTraceStore
  235. store = FileSystemTraceStore(base_path=base_path)
  236. messages = await store.get_trace_messages(trace_id)
  237. if not messages:
  238. raise FileNotFoundError(f"Trace {trace_id} 下没有找到 messages")
  239. page_title = title or f"Trace {trace_id[:8]}... Messages"
  240. return messages_to_html(messages, output_path, title=page_title)
  241. if __name__ == "__main__":
  242. import asyncio
  243. import sys
  244. from pathlib import Path
  245. # 添加项目根目录,使 agent 模块可被导入
  246. _project_root = Path(__file__).resolve().parent.parent.parent
  247. if str(_project_root) not in sys.path:
  248. sys.path.insert(0, str(_project_root))
  249. async def _main():
  250. import argparse
  251. parser = argparse.ArgumentParser(description="将 trace messages 转为 HTML")
  252. parser.add_argument("--trace", required=True, help="Trace ID")
  253. parser.add_argument("-o", "--output", default="messages.html", help="输出文件路径")
  254. parser.add_argument("--base-path", default=".trace", help="Trace 存储根目录")
  255. args = parser.parse_args()
  256. out = await trace_to_html(args.trace, args.output, base_path=args.base_path)
  257. print(f"已生成: {out.absolute()}")
  258. asyncio.run(_main())