Преглед на файлове

可视化可分类树相关修改

xueyiming преди 2 седмици
родител
ревизия
3e9c00a599

+ 2 - 2
.env.example

@@ -2,8 +2,8 @@
 OPENROUTER_API_KEY=sk-or-v1-...
 
 # Default model (any OpenRouter-supported model)
-# Examples: openai/gpt-4o, anthropic/claude-sonnet-4, google/gemini-2.5-pro-preview
-OPENROUTER_MODEL=anthropic/claude-sonnet-4
+# Examples: openai/gpt-4o, anthropic/claude-sonnet-5, google/gemini-2.5-pro-preview
+OPENROUTER_MODEL=anthropic/claude-sonnet-5
 
 # Agent defaults
 AGENT_MAX_ITERATIONS=20

+ 37 - 2
README.md

@@ -16,6 +16,10 @@ supply_agent/
 ├── skills/
 │   ├── loader.py      # SKILL.md 加载器(兼容 Cursor 格式)
 │   └── registry.py    # 技能注册表
+├── logging/
+│   ├── logger.py      # 运行日志(.log + .jsonl)
+│   ├── parser.py      # 日志解析
+│   └── visualize.py   # HTML 可视化生成
 └── agent/
     ├── core.py        # Agent 主类
     └── loop.py        # ReAct 循环(Reason → Act → Observe)
@@ -132,7 +136,7 @@ async for event in agent.astream("Your question"):
 
 通过 OpenRouter 可使用任意支持的模型,例如:
 
-- `anthropic/claude-sonnet-4`
+- `anthropic/claude-sonnet-5`
 - `openai/gpt-4o`
 - `google/gemini-2.5-pro-preview`
 - `meta-llama/llama-4-maverick`
@@ -144,10 +148,41 @@ async for event in agent.astream("Your question"):
 | 变量 | 说明 | 默认值 |
 |------|------|--------|
 | `OPENROUTER_API_KEY` | OpenRouter API 密钥 | (必填) |
-| `OPENROUTER_MODEL` | 默认模型 | `anthropic/claude-sonnet-4` |
+| `OPENROUTER_MODEL` | 默认模型 | `anthropic/claude-sonnet-5` |
 | `AGENT_MAX_ITERATIONS` | 最大循环次数 | `20` |
 | `AGENT_TEMPERATURE` | 生成温度 | `0.7` |
 | `SKILLS_DIR` | Skills 目录 | `skills` |
+| `LOGS_DIR` | Agent 运行日志目录 | `logs` |
+| `LOG_ENABLED` | 是否写入运行日志 | `true` |
+
+## 运行日志与可视化
+
+每次 `agent.run()` 会在 `logs/` 下写出:
+
+| 文件 | 说明 |
+|------|------|
+| `run_<id>.log` | 人类可读的完整日志 |
+| `run_<id>.jsonl` | 结构化事件流(推荐用于可视化) |
+
+事件类型:`run_start` → `llm_input` → `llm_output`(含 reasoning)→ `tool_call`(完整入参/返回)→ … → `run_end`。
+
+生成可视化页面:
+
+```bash
+# 无参数:为 logs/ 下全部运行生成可视化页面
+python scripts/visualize_run.py
+
+# 指定某次运行
+python scripts/visualize_run.py logs/run_20260714_134901_9daf6fe1.jsonl
+
+# 最新一次运行,并打开浏览器
+python scripts/visualize_run.py --latest --open
+
+# 安装后也可用
+supply-visualize --open
+```
+
+页面按步骤展示:LLM 输入(messages / tools)、思考过程、模型输出、工具调用的输入与输出。
 
 ## 运行测试
 

+ 2 - 2
agents/demand_belong_category_agent/__init__.py

@@ -3,6 +3,6 @@ find_agent — 内容发现 Agent
 
 职责:抖音搜索、视频解析、内容入库。
 """
-from agents.find_agent.agent import create_find_agent
+from agents.demand_belong_category_agent.agent import create_demand_belong_category_agent
 
-__all__ = ["create_find_agent"]
+__all__ = ["create_demand_belong_category_agent"]

+ 7 - 13
agents/demand_belong_category_agent/agent.py

@@ -6,24 +6,17 @@ find_agent 工厂 — 组装 Agent 实例。
 """
 from __future__ import annotations
 
+from pathlib import Path
+
 from supply_agent import Agent
 from supply_agent.config import Settings
-from agents.find_agent.tools import register_all_tools
-
-DEMAND_BELONG_CATEGORY_AGENT_SYSTEM_PROMPT = """\
-你是内容发现助手(find_agent),帮助用户搜索和分析短视频内容。
+from agents.demand_belong_category_agent.tools import register_all_tools
 
-## 工作流程
-1. 使用 douyin_search 搜索抖音视频
-2. 使用 qwen_video_analyze 解析视频内容
-3. 使用 save_video_content 将结果存入数据库
-4. 使用 query_video_content 查询历史数据
-
-请按步骤执行,给出清晰的分析报告。
-"""
+_PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
+DEMAND_BELONG_CATEGORY_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
 
 
-def create_find_agent(
+def create_demand_belong_category_agent(
     settings: Settings | None = None,
     *,
     model: str | None = None,
@@ -33,6 +26,7 @@ def create_find_agent(
         settings=settings,
         model=model,
         system_prompt=DEMAND_BELONG_CATEGORY_AGENT_SYSTEM_PROMPT,
+        max_iterations = 30,
     )
 
     # 本 Agent 专属工具

+ 31 - 0
agents/demand_belong_category_agent/prompt/system_prompt.md

@@ -0,0 +1,31 @@
+## 背景说明
+- 分类树用途: 内容标签体系
+- 分类树规模: 约[2000-3000]个节点,最大深度[11]层
+- 领域背景: 面向视频内容词语
+
+## 角色与任务
+你是一个"词语 → 分类树节点"挂载专家。给定一个词语或短语,你需要借助工具在已有分类树中逐层查看候选类目,最终把该词语挂载到树上最合适的一个或多个节点上。
+
+你必须严格基于分类树中真实存在的节点做判断,禁止编造或猜测树中不存在的类目名称、ID 或路径。
+
+## 可用工具
+- `query_global_tree_category(id, level)`: 获取指定节点的指定层级的子节点(id 为空时返回整颗树, level为空的时候返回指定节点到叶子节点的内容),每个子节点包含 id、名称、是否叶子节点。
+- `batch_insert_demand_belong_category(items)`: 批量插入节点到目标表
+
+## 工作流程
+1. 调用 `query_global_tree_category` 获取顶级类目,结合词语语义判断最相关的 1~2 个分支。
+2. 下钻进入相关性最高的节点,再次调用 `query_global_tree_category` 查看子类目,继续判断。
+3. 重复上述过程,直到到达叶子节点,或当前层级所有子节点相关度都达不到"有把握"的程度——此时停在上一层已确认的节点,不要为了到达叶子而勉强选择。
+4. 若某个分支下钻后发现子节点都不合适,回退到上一层,重新评估其他兄弟节点,而不是硬选一个。
+5. 若顶级类目层面就找不到相关分支,直接判定为无法归类,不要勉强挂载。
+6. 若词语同时与两个及以上互斥分支都高度相关(一词多义、跨领域词等),记录全部高置信候选,标记为存在歧义。
+7. 每次下钻前用一两句话写明判断依据,再决定是否调用工具;推理要言之有据,避免空泛。
+8. 单个词语的下钻步数建议不超过 8 次工具调用;仍无法收敛时,停在当前最有把握的节点并说明原因,不要无限下钻。
+
+## 分类判断原则
+- **确定性优先于深度**:能到叶子节点最好,但深入一层后判断不明确时,宁可停在上一层泛化节点。
+- **禁止幻觉**:只能引用工具真实返回的节点,不能编造节点名称、ID 或路径。
+- **同义/口语化处理**:识别缩写、俗称、错别字、拼音、中英对照等变体,映射到树中标准节点,不要因字面不完全一致就判定无法归类。
+- **保守优先**:候选类目普遍只是"沾边"而非"明显对应"时,优先停在更高层级或判定无法归类,不要为了凑数勉强挂载,避免污染类目体系。
+- **不新增节点**:确实找不到合适位置时,只输出"无法归类"及原因,不自行建议新增类目(除非任务另有要求)。
+

+ 94 - 14
agents/demand_belong_category_agent/run.py

@@ -1,26 +1,106 @@
 #!/usr/bin/env python3
 """Run find_agent interactively."""
 
-from agents.demand_belong_category_agent import create_find_agent
+from agents.demand_belong_category_agent import create_demand_belong_category_agent
 
 
 def main() -> None:
-    agent = create_find_agent()
+    agent = create_demand_belong_category_agent()
     print(f"demand_belong_category_agent ready | model={agent.model}")
     print(f"tools: {agent.tools.list_tools()}")
     print()
-
-    while True:
-        try:
-            user_input = input("You> ").strip()
-        except (EOFError, KeyboardInterrupt):
-            print("\nBye.")
-            break
-        if not user_input or user_input.lower() in ("exit", "quit", "q"):
-            break
-        result = agent.run(user_input)
-        print(f"\nAgent> {result.content}\n")
-
+    user_input = '''
+    以下是待分类的词语
+    
+时间界定
+暴雨预警
+防汛避险
+明确时间界定
+暴雨预警背景
+发布防汛避险通告
+健康防护
+健康防护知识
+地域风险预警
+广西洪灾
+洪涝灾害救援
+科普
+河南救援队广西洪灾健康防护
+科普洪涝灾害救援健康防护知识
+中毒
+症状
+食品安全
+食用安全警示
+剧烈中毒症状
+凉拌黄瓜食用安全警示
+科普食品安全知识
+全民健身
+寿命数据
+政策
+案例
+河南
+长寿公式
+防大于治
+寿命数据对比
+长寿公式引用
+边角地改造案例
+河南本地数据
+防大于治逻辑
+国家5年健身计划
+解读全民健身政策
+危险场景
+台风天
+危险场景具象化
+原理向警示逻辑转化
+官方荣誉
+紧急救援
+见义勇为
+官方荣誉背书
+紧急救援场景
+曾凡林见义勇为
+报道见义勇为事迹
+主任 
+医院放射科主任
+受贿
+受贿案
+通过APP答题受贿
+18套房产受贿
+揭露医院放射科主任受贿案
+饭局
+饭局现场
+饭局现场叙事
+公共饭局场景
+探讨儿童餐桌礼仪
+央视
+新闻联播
+权威背书
+央视权威背书
+于东来登上新闻联播
+发布会
+宕昌县山体滑坡救援事件
+官方
+权威
+灾害救援
+官方发布会通报
+权威信源引用
+通报灾害救援进展
+防汛安全
+防汛提示
+沈阳非必要不出门防汛提示
+发布防汛安全提示
+社区化场景落地
+解读国务院全民健身政策
+谣言
+辟谣
+事实与谣言对比
+蒲家人中奖谣言辟谣
+辟谣发票中奖谣言
+地缘政治
+政治博弈
+地缘政治绑定
+    '''
+    result = agent.run(user_input)
+    print(result.content)
+    print(f"\n[iterations={result.iterations}, tool_calls={result.tool_calls_made}]")
 
 if __name__ == "__main__":
     main()

+ 12 - 4
agents/demand_belong_category_agent/tools/__init__.py

@@ -1,5 +1,5 @@
 """
-find_agent 工具包
+demand_belong_category_agent 工具包
 
 在此注册本 Agent 专属的工具。
 """
@@ -8,19 +8,27 @@ from __future__ import annotations
 from collections.abc import Callable
 from typing import Any
 
-from agents.find_agent.tools.douyin_search import douyin_search
-from agents.find_agent.tools.qwen_video_analyze import qwen_video_analyze
+from agents.demand_belong_category_agent.tools.demand_belong_category import (
+    batch_insert_demand_belong_category,
+)
+from agents.demand_belong_category_agent.tools.global_tree_category import (
+    query_global_tree_category,
+)
 from supply_agent.tools.registry import ToolRegistry
 
 ALL_TOOLS: list[Callable[..., Any]] = [
+    query_global_tree_category,
+    batch_insert_demand_belong_category,
 ]
 
 __all__ = [
     "ALL_TOOLS",
+    "batch_insert_demand_belong_category",
+    "query_global_tree_category",
     "register_all_tools",
 ]
 
 
 def register_all_tools(registry: ToolRegistry) -> ToolRegistry:
-    """将 find_agent 包内的所有工具注册到 ToolRegistry。"""
+    """将 demand_belong_category_agent 包内的所有工具注册到 ToolRegistry。"""
     return registry.from_decorated(*ALL_TOOLS)

+ 112 - 0
agents/demand_belong_category_agent/tools/demand_belong_category.py

@@ -0,0 +1,112 @@
+"""
+需求归属分类写入工具
+
+批量向 MySQL demand_belong_category 表插入 name + category_id + reason。
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from supply_agent.tools import tool
+from supply_infra.db.repositories.demand_belong_category_repo import (
+    DemandBelongCategoryRepository,
+)
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+
+def _normalize_items(items: list[dict[str, Any]]) -> tuple[list[dict], list[str]]:
+    """校验并规范化待插入行,返回 (rows, errors)。"""
+    rows: list[dict] = []
+    errors: list[str] = []
+    seen_names: set[str] = set()
+
+    for idx, item in enumerate(items):
+        if not isinstance(item, dict):
+            errors.append(f"第 {idx} 项不是对象")
+            continue
+
+        name = item.get("name")
+        category_id = item.get("category_id")
+        reason = item.get("reason")
+
+        if name is None or (isinstance(name, str) and not name.strip()):
+            errors.append(f"第 {idx} 项缺少有效 name")
+            continue
+        if category_id is None:
+            errors.append(f"第 {idx} 项缺少 category_id")
+            continue
+
+        try:
+            category_id_int = int(category_id)
+        except (TypeError, ValueError):
+            errors.append(f"第 {idx} 项 category_id 无效: {category_id!r}")
+            continue
+
+        name_str = str(name).strip()
+        if name_str in seen_names:
+            errors.append(f"第 {idx} 项 name 在本次请求中重复: {name_str}")
+            continue
+        seen_names.add(name_str)
+
+        reason_str: str | None = None
+        if reason is not None:
+            reason_str = str(reason).strip() or None
+
+        rows.append(
+            {
+                "name": name_str,
+                "category_id": category_id_int,
+                "reason": reason_str,
+                "is_delete": 0,
+            }
+        )
+
+    return rows, errors
+
+
+@tool
+def batch_insert_demand_belong_category(items: list[dict[str, Any]]) -> str:
+    """
+    批量插入需求归属分类
+
+    向 demand_belong_category 表批量写入数据。name 有唯一约束,已存在的名称会被忽略。
+
+    Args:
+        items: 待插入列表,每项为 {"name": "需求名称", "category_id": 分类id, "reason": "归属原因"}。
+               reason 可选。
+               例如:[{"name": "表演需求", "category_id": 19, "reason": "属于表演类内容"},
+                     {"name": "理念需求", "category_id": 2, "reason": "偏理念表达"}]
+
+    Returns:
+        插入结果摘要,包含成功条数与跳过/失败说明。
+    """
+    if not items:
+        return "items 不能为空"
+
+    rows, errors = _normalize_items(items)
+    if not rows:
+        detail = ";".join(errors) if errors else "无有效数据"
+        return f"没有可插入的数据: {detail}"
+
+    try:
+        with get_session() as session:
+            repo = DemandBelongCategoryRepository(session)
+            inserted = repo.bulk_insert_ignore(rows)
+
+        skipped = len(rows) - inserted
+        parts = [f"提交 {len(rows)} 条,成功插入 {inserted} 条"]
+        if skipped > 0:
+            parts.append(f"因 name 已存在忽略 {skipped} 条")
+        if errors:
+            parts.append(f"校验失败 {len(errors)} 条: " + ";".join(errors))
+
+        message = "。".join(parts)
+        logger.info("batch_insert_demand_belong_category completed: %s", message)
+        return message
+
+    except Exception as e:
+        logger.error("batch_insert_demand_belong_category failed: %s", e, exc_info=True)
+        return f"批量插入需求归属分类失败: {e}"

+ 141 - 0
agents/demand_belong_category_agent/tools/global_tree_category.py

@@ -0,0 +1,141 @@
+"""
+全局分类树查询工具
+
+从 MySQL global_tree_category 表读取分类层级,格式化为 [id]名称 的树形文本。
+"""
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+from supply_agent.tools import tool
+from supply_infra.db.models.global_tree_category import GlobalTreeCategory
+from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+# 最末级(无子节点)标记
+_LEAF_MARK = "*"
+
+
+def _normalize_parent_id(parent_id: int | None) -> int | None:
+    """将 0 或 None 统一视为根节点。"""
+    if parent_id is None or parent_id == 0:
+        return None
+    return parent_id
+
+
+def _build_children_map(
+    categories: list[GlobalTreeCategory],
+) -> dict[int | None, list[GlobalTreeCategory]]:
+    children_map: dict[int | None, list[GlobalTreeCategory]] = {}
+    for category in categories:
+        parent_key = _normalize_parent_id(category.parent_id)
+        children_map.setdefault(parent_key, []).append(category)
+
+    for children in children_map.values():
+        children.sort(key=lambda c: (c.level or 0, c.id))
+
+    return children_map
+
+
+def _format_category_tree(
+    categories: list[GlobalTreeCategory],
+    *,
+    root_id: int | None = None,
+    max_levels: int | None = None,
+) -> str:
+    """
+    将分类列表格式化为层级分明的 [id]名称 树形文本。
+
+    Args:
+        categories: 全量分类列表
+        root_id: 若指定,仅输出该节点及其子树(含自身)
+        max_levels: 若指定,从起始节点算起最多输出多少层(含起始层)
+    """
+    if not categories:
+        return "(暂无分类数据)"
+
+    by_id = {category.id: category for category in categories}
+    children_map = _build_children_map(categories)
+    lines: list[str] = []
+
+    def render(category: GlobalTreeCategory, depth: int, relative_level: int) -> None:
+        if max_levels is not None and relative_level > max_levels:
+            return
+
+        indent = "  " * depth
+        name = category.name or ""
+        is_leaf = not children_map.get(category.id)
+        leaf_suffix = f" {_LEAF_MARK}" if is_leaf else ""
+        lines.append(f"{indent}[{category.id}]{name}{leaf_suffix}")
+
+        if is_leaf or (max_levels is not None and relative_level >= max_levels):
+            return
+
+        for child in children_map.get(category.id, []):
+            render(child, depth + 1, relative_level + 1)
+
+    if root_id is not None:
+        root = by_id.get(root_id)
+        if root is None:
+            return f"未找到 id={root_id} 的分类"
+        render(root, 0, 1)
+    else:
+        for root in children_map.get(None, []):
+            render(root, 0, 1)
+
+    if not lines:
+        return "(暂无分类数据)"
+    return "\n".join(lines)
+
+
+@tool
+def query_global_tree_category(
+    id: Optional[int] = None,
+    level: Optional[int] = None,
+) -> str:
+    """
+    查询全局分类树
+
+    从 global_tree_category 表读取分类层级,返回格式化的树形文本。
+    每行格式为 [id]名称,子级以两个空格缩进;最末级节点末尾带 * 标记。
+
+    Args:
+        id: 可选。不传查整棵树;传入则从该分类开始向下查询(含自身)。
+        level: 可选。不传查到最底层;传入则只查从起始节点算起的指定层数(含起始层)。
+              例如 id=1, level=3:输出 [1] 及其向下共 3 层。
+
+    Returns:
+        层级分明的分类树文本,例如:
+        [1]表象
+          [3]行为
+            [19]表演 *
+          [4]内容形态 *
+        [2]理念 *
+    """
+    if level is not None and level < 1:
+        return "level 必须大于等于 1"
+
+    try:
+        with get_session() as session:
+            repo = GlobalTreeCategoryRepository(session)
+            categories = repo.list_active_categories()
+            tree_text = _format_category_tree(
+                categories,
+                root_id=id,
+                max_levels=level,
+            )
+
+        logger.info(
+            "query_global_tree_category completed: id=%s level=%s categories=%d",
+            id,
+            level,
+            len(categories),
+        )
+        return tree_text
+
+    except Exception as e:
+        logger.error("query_global_tree_category failed: %s", e, exc_info=True)
+        return f"查询全局分类树失败: {e}"

+ 1 - 0
pyproject.toml

@@ -33,6 +33,7 @@ packages = ["supply_agent", "supply_infra", "agents"]
 
 [project.scripts]
 supply-scheduler = "supply_infra.scheduler.app:run_scheduler"
+supply-visualize = "supply_agent.logging.cli:main"
 
 [tool.ruff]
 line-length = 100

+ 28 - 0
scripts/visualize_run.py

@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+"""Generate an HTML visualization for an agent run log.
+
+Usage:
+  python scripts/visualize_run.py
+  python scripts/visualize_run.py --open
+  python scripts/visualize_run.py logs/run_xxx.jsonl
+  python scripts/visualize_run.py --latest --open
+
+  # 从 Python 直接调用(与 CWD 无关,使用项目根下 logs/)
+  from supply_agent.logging.cli import main
+  main([])
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parents[1]
+if str(_ROOT) not in sys.path:
+    sys.path.insert(0, str(_ROOT))
+
+from supply_agent.logging.cli import main
+
+if __name__ == "__main__":
+    # 只消费本脚本之后的参数,避免被其它启动方式污染 argv
+    raise SystemExit(main(sys.argv[1:]))

+ 7 - 2
supply_agent/agent/core.py

@@ -41,7 +41,7 @@ class Agent:
     Main Agent class — orchestrates LLM, tools, and skills.
 
     Usage:
-        agent = Agent(model="anthropic/claude-sonnet-4")
+        agent = Agent(model="anthropic/claude-sonnet-5")
         agent.tools.register(my_tool)
         result = agent.run("What is 2+2?")
     """
@@ -56,6 +56,7 @@ class Agent:
         skills: SkillRegistry | None = None,
         max_iterations: int | None = None,
         temperature: float | None = None,
+        reasoning_effort: str | None = "medium",
         logger: AgentLogger | None = None,
     ) -> None:
         self.settings = settings or get_settings()
@@ -63,7 +64,11 @@ class Agent:
             self.settings.logs_dir,
             enabled=self.settings.log_enabled,
         )
-        self.llm = LLMClient(self.settings, logger=self.logger)
+        self.llm = LLMClient(
+            self.settings,
+            logger=self.logger,
+            reasoning_effort=reasoning_effort,
+        )
         self.tools = tools or ToolRegistry()
         self.skills = skills or SkillRegistry(self.settings.skills_dir)
         self.system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT

+ 26 - 6
supply_agent/agent/loop.py

@@ -84,7 +84,12 @@ class AgentLoop:
                 result = self.tools.execute(tc.id, tc.name, tc.arguments)
                 if self.logger:
                     self.logger.log_tool_call(
-                        iterations, tc.name, tc.arguments, result.content, result.is_error
+                        iterations,
+                        tc.name,
+                        tc.arguments,
+                        result.content,
+                        result.is_error,
+                        tool_call_id=tc.id,
                     )
                 if tc.name == "load_skill" and not result.is_error:
                     self._on_skill_loaded(tc.arguments)
@@ -133,7 +138,12 @@ class AgentLoop:
                 result = await self.tools.aexecute(tc.id, tc.name, tc.arguments)
                 if self.logger:
                     self.logger.log_tool_call(
-                        iterations, tc.name, tc.arguments, result.content, result.is_error
+                        iterations,
+                        tc.name,
+                        tc.arguments,
+                        result.content,
+                        result.is_error,
+                        tool_call_id=tc.id,
                     )
                 if tc.name == "load_skill" and not result.is_error:
                     self._on_skill_loaded(tc.arguments)
@@ -194,12 +204,17 @@ class AgentLoop:
                 self.tool_calls_made += 1
                 yield AgentEvent(
                     type=AgentEventType.TOOL_CALL,
-                    data={"name": tc.name, "arguments": tc.arguments},
+                    data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
                 )
                 result = self.tools.execute(tc.id, tc.name, tc.arguments)
                 if self.logger:
                     self.logger.log_tool_call(
-                        iterations, tc.name, tc.arguments, result.content, result.is_error
+                        iterations,
+                        tc.name,
+                        tc.arguments,
+                        result.content,
+                        result.is_error,
+                        tool_call_id=tc.id,
                     )
                 yield AgentEvent(
                     type=AgentEventType.TOOL_RESULT,
@@ -248,12 +263,17 @@ class AgentLoop:
                 self.tool_calls_made += 1
                 yield AgentEvent(
                     type=AgentEventType.TOOL_CALL,
-                    data={"name": tc.name, "arguments": tc.arguments},
+                    data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
                 )
                 result = await self.tools.aexecute(tc.id, tc.name, tc.arguments)
                 if self.logger:
                     self.logger.log_tool_call(
-                        iterations, tc.name, tc.arguments, result.content, result.is_error
+                        iterations,
+                        tc.name,
+                        tc.arguments,
+                        result.content,
+                        result.is_error,
+                        tool_call_id=tc.id,
                     )
                 yield AgentEvent(
                     type=AgentEventType.TOOL_RESULT,

+ 1 - 1
supply_agent/config.py

@@ -22,7 +22,7 @@ class Settings(BaseSettings):
     # OpenRouter
     openrouter_api_key: str = Field(..., alias="OPENROUTER_API_KEY")
     openrouter_model: str = Field(
-        default="anthropic/claude-sonnet-4",
+        default="anthropic/claude-sonnet-5",
         alias="OPENROUTER_MODEL",
     )
     openrouter_base_url: str = Field(

+ 62 - 26
supply_agent/llm/client.py

@@ -19,10 +19,15 @@ class LLMClient:
         self,
         settings: Settings,
         logger: AgentLogger | None = None,
+        *,
+        reasoning_effort: str | None = "medium",
     ) -> None:
         self.settings = settings
         self.model = settings.openrouter_model
         self.logger = logger
+        # OpenRouter reasoning effort: low / medium / high / max / xhigh.
+        # None or "" disables the parameter.
+        self.reasoning_effort = reasoning_effort
 
         extra_headers: dict[str, str] = {}
         if settings.openrouter_site_url:
@@ -41,6 +46,13 @@ class LLMClient:
             default_headers=extra_headers or None,
         )
 
+    def _extra_body(self) -> dict[str, Any] | None:
+        """OpenRouter-only params (e.g. Claude reasoning effort)."""
+        effort = (self.reasoning_effort or "").strip()
+        if not effort:
+            return None
+        return {"reasoning": {"effort": effort}}
+
     def chat(
         self,
         messages: list[Message],
@@ -55,12 +67,17 @@ class LLMClient:
         if self.logger:
             self.logger.log_llm_input(iteration, self.model, messages, tools, temp)
 
-        response = self._client.chat.completions.create(
-            model=self.model,
-            messages=[m.to_api_dict() for m in messages],
-            tools=[t.to_api_dict() for t in tools] if tools else None,
-            temperature=temp,
-        )
+        kwargs: dict[str, Any] = {
+            "model": self.model,
+            "messages": [m.to_api_dict() for m in messages],
+            "tools": [t.to_api_dict() for t in tools] if tools else None,
+            "temperature": temp,
+        }
+        extra_body = self._extra_body()
+        if extra_body:
+            kwargs["extra_body"] = extra_body
+
+        response = self._client.chat.completions.create(**kwargs)
         raw_message = response.choices[0].message
         result = self._parse_response(raw_message)
 
@@ -83,12 +100,17 @@ class LLMClient:
         if self.logger:
             self.logger.log_llm_input(iteration, self.model, messages, tools, temp)
 
-        response = await self._async_client.chat.completions.create(
-            model=self.model,
-            messages=[m.to_api_dict() for m in messages],
-            tools=[t.to_api_dict() for t in tools] if tools else None,
-            temperature=temp,
-        )
+        kwargs: dict[str, Any] = {
+            "model": self.model,
+            "messages": [m.to_api_dict() for m in messages],
+            "tools": [t.to_api_dict() for t in tools] if tools else None,
+            "temperature": temp,
+        }
+        extra_body = self._extra_body()
+        if extra_body:
+            kwargs["extra_body"] = extra_body
+
+        response = await self._async_client.chat.completions.create(**kwargs)
         raw_message = response.choices[0].message
         result = self._parse_response(raw_message)
 
@@ -111,13 +133,18 @@ class LLMClient:
         if self.logger:
             self.logger.log_llm_input(iteration, self.model, messages, tools, temp)
 
-        stream = self._client.chat.completions.create(
-            model=self.model,
-            messages=[m.to_api_dict() for m in messages],
-            tools=[t.to_api_dict() for t in tools] if tools else None,
-            temperature=temp,
-            stream=True,
-        )
+        kwargs: dict[str, Any] = {
+            "model": self.model,
+            "messages": [m.to_api_dict() for m in messages],
+            "tools": [t.to_api_dict() for t in tools] if tools else None,
+            "temperature": temp,
+            "stream": True,
+        }
+        extra_body = self._extra_body()
+        if extra_body:
+            kwargs["extra_body"] = extra_body
+
+        stream = self._client.chat.completions.create(**kwargs)
         chunks: list[str] = []
         for chunk in stream:
             delta = chunk.choices[0].delta
@@ -146,13 +173,18 @@ class LLMClient:
         if self.logger:
             self.logger.log_llm_input(iteration, self.model, messages, tools, temp)
 
-        stream = await self._async_client.chat.completions.create(
-            model=self.model,
-            messages=[m.to_api_dict() for m in messages],
-            tools=[t.to_api_dict() for t in tools] if tools else None,
-            temperature=temp,
-            stream=True,
-        )
+        kwargs: dict[str, Any] = {
+            "model": self.model,
+            "messages": [m.to_api_dict() for m in messages],
+            "tools": [t.to_api_dict() for t in tools] if tools else None,
+            "temperature": temp,
+            "stream": True,
+        }
+        extra_body = self._extra_body()
+        if extra_body:
+            kwargs["extra_body"] = extra_body
+
+        stream = await self._async_client.chat.completions.create(**kwargs)
         chunks: list[str] = []
         async for chunk in stream:
             delta = chunk.choices[0].delta
@@ -178,10 +210,14 @@ class LLMClient:
                 )
                 for tc in choice_message.tool_calls
             ]
+        reasoning = getattr(choice_message, "reasoning", None)
+        if reasoning is not None and not isinstance(reasoning, str):
+            reasoning = str(reasoning)
         return Message(
             role=Role.ASSISTANT,
             content=choice_message.content,
             tool_calls=tool_calls,
+            reasoning=reasoning,
         )
 
     def set_model(self, model: str) -> None:

+ 10 - 1
supply_agent/logging/__init__.py

@@ -1,5 +1,14 @@
 """Logging module for SupplyAgent."""
 
 from supply_agent.logging.logger import AgentLogger, get_agent_logger
+from supply_agent.logging.parser import load_run_events, summarize_run
+from supply_agent.logging.visualize import generate_visualization, render_html
 
-__all__ = ["AgentLogger", "get_agent_logger"]
+__all__ = [
+    "AgentLogger",
+    "get_agent_logger",
+    "load_run_events",
+    "summarize_run",
+    "generate_visualization",
+    "render_html",
+]

+ 180 - 0
supply_agent/logging/cli.py

@@ -0,0 +1,180 @@
+"""CLI for visualizing agent run logs."""
+
+from __future__ import annotations
+
+import argparse
+import webbrowser
+from pathlib import Path
+
+from supply_agent.logging.visualize import generate_visualization
+
+# supply_agent/logging/cli.py → project root
+_PROJECT_ROOT = Path(__file__).resolve().parents[2]
+
+
+def _has_run_logs(directory: Path) -> bool:
+    if not directory.is_dir():
+        return False
+    return any(directory.glob("run_*.jsonl")) or any(directory.glob("run_*.log"))
+
+
+def _resolve_logs_dir(logs_dir: str | Path) -> Path:
+    """
+    Resolve logs directory.
+
+    Relative paths prefer the SupplyAgent project root (stable regardless of
+    CWD). Falls back to CWD only when that location actually contains run logs.
+    """
+    path = Path(logs_dir)
+    if path.is_absolute():
+        return path.resolve()
+
+    project_candidate = (_PROJECT_ROOT / path).resolve()
+    cwd_candidate = (Path.cwd() / path).resolve()
+
+    if _has_run_logs(project_candidate):
+        return project_candidate
+    if _has_run_logs(cwd_candidate):
+        return cwd_candidate
+    if project_candidate.is_dir():
+        return project_candidate
+    if cwd_candidate.is_dir():
+        return cwd_candidate
+    return project_candidate
+
+
+def _resolve_run_path(run: str | Path) -> Path:
+    path = Path(run)
+    if path.is_absolute():
+        return path.resolve()
+    cwd_candidate = (Path.cwd() / path).resolve()
+    if cwd_candidate.exists():
+        return cwd_candidate
+    project_candidate = (_PROJECT_ROOT / path).resolve()
+    if project_candidate.exists():
+        return project_candidate
+    # Stem / missing suffix: try under project logs/
+    logs = _resolve_logs_dir("logs")
+    for suffix in (".jsonl", ".log", ""):
+        candidate = logs / f"{path.name}{suffix}" if suffix else logs / path.name
+        if candidate.exists():
+            return candidate
+    return project_candidate
+
+
+def _find_latest_run(logs_dir: Path) -> Path:
+    runs = _list_all_runs(logs_dir)
+    if not runs:
+        raise FileNotFoundError(f"No run_*.jsonl / run_*.log found in {logs_dir}")
+    return runs[-1]
+
+
+def _list_all_runs(logs_dir: Path) -> list[Path]:
+    """
+    List all unique agent runs in logs_dir.
+
+    Prefers ``.jsonl`` over ``.log`` when both exist for the same stem.
+    Sorted by mtime ascending (oldest first).
+    """
+    logs_dir = _resolve_logs_dir(logs_dir)
+    if not logs_dir.is_dir():
+        raise FileNotFoundError(f"Logs directory not found: {logs_dir}")
+
+    by_stem: dict[str, Path] = {}
+    for path in logs_dir.glob("run_*.jsonl"):
+        by_stem[path.stem] = path
+    for path in logs_dir.glob("run_*.log"):
+        by_stem.setdefault(path.stem, path)
+
+    return sorted(by_stem.values(), key=lambda p: p.stat().st_mtime)
+
+
+def main(argv: list[str] | None = None) -> int:
+    """
+    Generate HTML visualizations for agent run logs.
+
+    Parameters
+    ----------
+    argv:
+        Optional CLI args. Use ``main()`` or ``main([])`` from Python to
+        visualize all runs under the project ``logs/`` directory (CWD-independent).
+    """
+    parser = argparse.ArgumentParser(
+        description="将 Agent 运行日志生成为可视化 HTML 页面",
+    )
+    parser.add_argument(
+        "run",
+        nargs="?",
+        help="日志路径(.jsonl / .log / 不带后缀的 run id);省略则处理 logs 目录下全部运行",
+    )
+    parser.add_argument(
+        "-o",
+        "--output",
+        help="输出 HTML 路径(仅单文件模式有效;默认与日志同目录同名 .html)",
+    )
+    parser.add_argument(
+        "--logs-dir",
+        default="logs",
+        help="日志目录(默认项目根下 logs;无参数时批量生成,或配合 --latest)",
+    )
+    parser.add_argument(
+        "--latest",
+        action="store_true",
+        help="仅可视化 logs 目录中最新一次运行",
+    )
+    parser.add_argument(
+        "--open",
+        action="store_true",
+        help="生成后在浏览器中打开(批量模式打开最新一份)",
+    )
+    # 直接 main() / main(None) 视为无参数批量,避免 IDE / 其它入口的 argv 干扰
+    args = parser.parse_args([] if argv is None else argv)
+
+    logs_dir = _resolve_logs_dir(args.logs_dir)
+
+    if args.latest:
+        run_paths = [_find_latest_run(logs_dir)]
+    elif args.run:
+        run_paths = [_resolve_run_path(args.run)]
+    else:
+        try:
+            run_paths = _list_all_runs(logs_dir)
+        except FileNotFoundError as exc:
+            print(exc)
+            return 1
+        if not run_paths:
+            print(f"未找到可可视化的日志: {logs_dir}/run_*.jsonl|*.log")
+            return 1
+        if args.output:
+            parser.error("批量生成时不能指定 --output,请省略 -o 或指定单个 run 路径")
+
+    outputs: list[Path] = []
+    errors = 0
+    for run_path in run_paths:
+        try:
+            out = generate_visualization(
+                run_path,
+                args.output if len(run_paths) == 1 else None,
+            )
+            outputs.append(out)
+            print(f"已生成可视化页面: {out}")
+        except Exception as exc:
+            errors += 1
+            print(f"跳过 {run_path}: {exc}")
+
+    if not outputs:
+        return 1
+
+    if args.open:
+        webbrowser.open(outputs[-1].resolve().as_uri())
+
+    if len(outputs) > 1:
+        print(f"共生成 {len(outputs)} 个页面" + (f",失败 {errors} 个" if errors else ""))
+
+    return 1 if errors else 0
+
+
+if __name__ == "__main__":
+    import sys
+
+    raise SystemExit(main(sys.argv[1:]))

+ 143 - 27
supply_agent/logging/logger.py

@@ -3,7 +3,7 @@ from __future__ import annotations
 import json
 import logging
 import uuid
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 from typing import Any
 
@@ -23,6 +23,36 @@ def _serialize(obj: Any) -> str:
     return json.dumps(obj, ensure_ascii=False, indent=2, default=default)
 
 
+def _try_parse_json(text: str) -> Any:
+    """Best-effort JSON parse; return original string on failure."""
+    try:
+        return json.loads(text)
+    except (json.JSONDecodeError, TypeError):
+        return text
+
+
+def _extract_usage(raw_response: Any) -> dict[str, Any] | None:
+    if raw_response is None:
+        return None
+    usage = getattr(raw_response, "usage", None)
+    if usage is None and isinstance(raw_response, dict):
+        usage = raw_response.get("usage")
+    if usage is None:
+        return None
+    if hasattr(usage, "model_dump"):
+        return usage.model_dump()
+    if isinstance(usage, dict):
+        return usage
+    return {"raw": str(usage)}
+
+
+def _extract_skill_name(skill_name_or_args: str) -> str:
+    parsed = _try_parse_json(skill_name_or_args)
+    if isinstance(parsed, dict):
+        return str(parsed.get("name") or skill_name_or_args)
+    return skill_name_or_args
+
+
 class _FullContentFormatter(logging.Formatter):
     """Formatter that never truncates message content."""
 
@@ -35,8 +65,9 @@ class AgentLogger:
     """
     Encapsulated logger for agent runs.
 
-    Each run writes to a separate file under ``logs/`` with complete
-    LLM input/output and tool execution records — nothing is truncated.
+    Each run writes:
+    - ``logs/run_<id>.log``   — human-readable full dump
+    - ``logs/run_<id>.jsonl`` — structured event stream for visualization
     """
 
     def __init__(self, logs_dir: Path | str = "logs", *, enabled: bool = True) -> None:
@@ -44,7 +75,10 @@ class AgentLogger:
         self.enabled = enabled
         self._run_id: str | None = None
         self._log_file: Path | None = None
+        self._jsonl_file: Path | None = None
         self._logger: logging.Logger | None = None
+        self._seq: int = 0
+        self._jsonl_fh: Any | None = None
 
     @property
     def run_id(self) -> str | None:
@@ -54,6 +88,10 @@ class AgentLogger:
     def log_file(self) -> Path | None:
         return self._log_file
 
+    @property
+    def jsonl_file(self) -> Path | None:
+        return self._jsonl_file
+
     def start_run(self, user_input: str, *, model: str) -> str:
         """Start a new run log file. Returns the run id."""
         if not self.enabled:
@@ -63,24 +101,36 @@ class AgentLogger:
         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
         self._run_id = f"{timestamp}_{uuid.uuid4().hex[:8]}"
         self._log_file = self.logs_dir / f"run_{self._run_id}.log"
+        self._jsonl_file = self.logs_dir / f"run_{self._run_id}.jsonl"
+        self._seq = 0
 
         self._logger = logging.getLogger(f"supply_agent.run.{self._run_id}")
         self._logger.setLevel(logging.DEBUG)
         self._logger.handlers.clear()
         self._logger.propagate = False
 
-        handler = logging.FileHandler(self._log_file, encoding="utf-8")
-        handler.setFormatter(_FullContentFormatter())
-        self._logger.addHandler(handler)
+        formatter = _FullContentFormatter()
+
+        file_handler = logging.FileHandler(self._log_file, encoding="utf-8")
+        file_handler.setFormatter(formatter)
+        self._logger.addHandler(file_handler)
+
+        console_handler = logging.StreamHandler()
+        console_handler.setFormatter(formatter)
+        self._logger.addHandler(console_handler)
 
-        self._write_block(
-            "RUN START",
+        self._jsonl_fh = open(self._jsonl_file, "w", encoding="utf-8")
+
+        self._emit(
+            "run_start",
             {
                 "run_id": self._run_id,
                 "model": model,
                 "user_input": user_input,
                 "log_file": str(self._log_file),
+                "jsonl_file": str(self._jsonl_file),
             },
+            title="RUN START",
         )
         return self._run_id
 
@@ -102,8 +152,15 @@ class AgentLogger:
             "temperature": temperature,
             "messages": [m.to_api_dict() for m in messages],
             "tools": [t.to_api_dict() for t in tools] if tools else None,
+            "message_count": len(messages),
+            "tool_count": len(tools) if tools else 0,
         }
-        self._write_block(f"LLM INPUT | iteration={iteration}", payload)
+        self._emit(
+            "llm_input",
+            payload,
+            iteration=iteration,
+            title=f"LLM INPUT | step={self._seq + 1} | iteration={iteration}",
+        )
 
     def log_llm_output(
         self,
@@ -111,13 +168,23 @@ class AgentLogger:
         response: Message,
         raw_response: Any | None = None,
     ) -> None:
-        """Log the complete LLM response."""
+        """Log the complete LLM response, highlighting reasoning and tool calls."""
         if not self.enabled or not self._logger:
             return
 
+        parsed = response.model_dump()
+        tool_calls = parsed.get("tool_calls") or []
+        usage = _extract_usage(raw_response)
+
         payload: dict[str, Any] = {
             "iteration": iteration,
-            "parsed": response.to_api_dict(),
+            "content": parsed.get("content"),
+            "reasoning": parsed.get("reasoning"),
+            "tool_calls": tool_calls,
+            "has_reasoning": bool(parsed.get("reasoning")),
+            "tool_call_count": len(tool_calls),
+            "usage": usage,
+            "parsed": parsed,
         }
         if raw_response is not None:
             if hasattr(raw_response, "model_dump"):
@@ -125,7 +192,12 @@ class AgentLogger:
             else:
                 payload["raw"] = raw_response
 
-        self._write_block(f"LLM OUTPUT | iteration={iteration}", payload)
+        self._emit(
+            "llm_output",
+            payload,
+            iteration=iteration,
+            title=f"LLM OUTPUT | step={self._seq + 1} | iteration={iteration}",
+        )
 
     def log_tool_call(
         self,
@@ -134,20 +206,32 @@ class AgentLogger:
         arguments: str,
         result: str,
         is_error: bool = False,
+        *,
+        tool_call_id: str | None = None,
     ) -> None:
         """Log a tool execution with full arguments and result."""
         if not self.enabled or not self._logger:
             return
 
-        self._write_block(
-            f"TOOL CALL | iteration={iteration} | tool={name}",
-            {
-                "iteration": iteration,
-                "tool": name,
-                "arguments": arguments,
-                "result": result,
-                "is_error": is_error,
-            },
+        args_parsed = _try_parse_json(arguments)
+        result_parsed = _try_parse_json(result)
+
+        payload = {
+            "iteration": iteration,
+            "tool": name,
+            "tool_call_id": tool_call_id,
+            "arguments": arguments,
+            "arguments_parsed": args_parsed,
+            "result": result,
+            "result_parsed": result_parsed,
+            "is_error": is_error,
+        }
+        status = "ERROR" if is_error else "OK"
+        self._emit(
+            "tool_call",
+            payload,
+            iteration=iteration,
+            title=f"TOOL CALL | step={self._seq + 1} | iteration={iteration} | {name} [{status}]",
         )
 
     def log_skill_loaded(self, iteration: int, skill_name: str) -> None:
@@ -155,9 +239,12 @@ class AgentLogger:
         if not self.enabled or not self._logger:
             return
 
-        self._write_block(
-            f"SKILL LOADED | iteration={iteration}",
-            {"skill": skill_name},
+        name = _extract_skill_name(skill_name)
+        self._emit(
+            "skill_loaded",
+            {"iteration": iteration, "skill": name},
+            iteration=iteration,
+            title=f"SKILL LOADED | step={self._seq + 1} | iteration={iteration} | {name}",
         )
 
     def end_run(self, result: AgentResult) -> None:
@@ -165,8 +252,8 @@ class AgentLogger:
         if not self.enabled or not self._logger:
             return
 
-        self._write_block(
-            "RUN END",
+        self._emit(
+            "run_end",
             {
                 "run_id": self._run_id,
                 "iterations": result.iterations,
@@ -174,14 +261,43 @@ class AgentLogger:
                 "skills_used": result.skills_used,
                 "final_content": result.content,
             },
+            title="RUN END",
         )
 
         for handler in self._logger.handlers:
             handler.close()
         self._logger.handlers.clear()
 
-    def _write_block(self, title: str, data: Any) -> None:
+        if self._jsonl_fh is not None:
+            self._jsonl_fh.close()
+            self._jsonl_fh = None
+
+    def _emit(
+        self,
+        event: str,
+        data: dict[str, Any],
+        *,
+        title: str,
+        iteration: int | None = None,
+    ) -> None:
         assert self._logger is not None
+        self._seq += 1
+        ts = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+
+        record: dict[str, Any] = {
+            "event": event,
+            "ts": ts,
+            "seq": self._seq,
+            "run_id": self._run_id,
+            "data": data,
+        }
+        if iteration is not None:
+            record["iteration"] = iteration
+
+        if self._jsonl_fh is not None:
+            self._jsonl_fh.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
+            self._jsonl_fh.flush()
+
         separator = "=" * 80
         body = _serialize(data)
         self._logger.info("%s\n%s\n%s\n%s", separator, title, separator, body)

+ 193 - 0
supply_agent/logging/parser.py

@@ -0,0 +1,193 @@
+"""Parse agent run logs (.jsonl or legacy .log) into structured events."""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+
+_TITLE_RE = re.compile(
+    r"^(RUN START|RUN END|LLM INPUT|LLM OUTPUT|TOOL CALL|SKILL LOADED)"
+    r"(?:\s*\|\s*(.+))?$",
+)
+
+
+def _title_to_event(title: str) -> tuple[str, dict[str, Any]]:
+    """Map a human log title to (event_type, extra_fields)."""
+    m = _TITLE_RE.match(title.strip())
+    if not m:
+        return "unknown", {"title": title}
+
+    kind = m.group(1)
+    rest = m.group(2) or ""
+    extra: dict[str, Any] = {}
+
+    iter_m = re.search(r"iteration=(\d+)", rest)
+    if iter_m:
+        extra["iteration"] = int(iter_m.group(1))
+
+    mapping = {
+        "RUN START": "run_start",
+        "RUN END": "run_end",
+        "LLM INPUT": "llm_input",
+        "LLM OUTPUT": "llm_output",
+        "TOOL CALL": "tool_call",
+        "SKILL LOADED": "skill_loaded",
+    }
+    return mapping.get(kind, "unknown"), extra
+
+
+def parse_jsonl(path: Path) -> list[dict[str, Any]]:
+    """Parse structured JSONL event stream."""
+    events: list[dict[str, Any]] = []
+    with path.open(encoding="utf-8") as fh:
+        for line_no, line in enumerate(fh, 1):
+            line = line.strip()
+            if not line:
+                continue
+            try:
+                events.append(json.loads(line))
+            except json.JSONDecodeError as exc:
+                raise ValueError(f"Invalid JSONL at {path}:{line_no}: {exc}") from exc
+    return events
+
+
+def parse_legacy_log(path: Path) -> list[dict[str, Any]]:
+    """Parse human-readable .log blocks into JSONL-compatible events."""
+    text = path.read_text(encoding="utf-8")
+    # Split on separator lines that follow a timestamp prefix
+    parts = re.split(
+        r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] =+\n",
+        text,
+    )
+    events: list[dict[str, Any]] = []
+    seq = 0
+
+    for part in parts:
+        part = part.strip()
+        if not part:
+            continue
+        # Format: TITLE\n====\n{json}
+        lines = part.split("\n", 2)
+        if len(lines) < 3:
+            continue
+        title = lines[0].strip()
+        # lines[1] is separator
+        body = lines[2]
+        # Strip trailing separator residue if any
+        body = re.sub(r"\n=+\s*$", "", body).strip()
+        try:
+            data = json.loads(body)
+        except json.JSONDecodeError:
+            continue
+
+        event_type, extra = _title_to_event(title)
+        seq += 1
+
+        # Normalize legacy llm_output shape: reasoning lived only under parsed/raw
+        if event_type == "llm_output" and "content" not in data:
+            parsed = data.get("parsed") or {}
+            data = {
+                **data,
+                "content": parsed.get("content"),
+                "reasoning": parsed.get("reasoning")
+                or ((data.get("raw") or {}).get("choices") or [{}])[0]
+                .get("message", {})
+                .get("reasoning"),
+                "tool_calls": parsed.get("tool_calls") or [],
+                "usage": (data.get("raw") or {}).get("usage"),
+            }
+
+        if event_type == "tool_call" and "arguments_parsed" not in data:
+            args = data.get("arguments", "")
+            try:
+                data["arguments_parsed"] = json.loads(args) if isinstance(args, str) else args
+            except (json.JSONDecodeError, TypeError):
+                data["arguments_parsed"] = args
+            result = data.get("result", "")
+            try:
+                data["result_parsed"] = json.loads(result) if isinstance(result, str) else result
+            except (json.JSONDecodeError, TypeError):
+                data["result_parsed"] = result
+
+        record: dict[str, Any] = {
+            "event": event_type,
+            "seq": seq,
+            "run_id": data.get("run_id"),
+            "data": data,
+        }
+        if "iteration" in extra:
+            record["iteration"] = extra["iteration"]
+        elif "iteration" in data:
+            record["iteration"] = data["iteration"]
+        events.append(record)
+
+    return events
+
+
+def load_run_events(path: Path | str) -> list[dict[str, Any]]:
+    """
+    Load events from a run file.
+
+    Accepts ``.jsonl``, ``.log``, or a stem without extension.
+    Prefers ``.jsonl`` when both exist.
+    """
+    path = Path(path)
+
+    if path.is_dir():
+        raise ValueError(f"Expected a run file, got directory: {path}")
+
+    candidates: list[Path] = []
+    if path.suffix == ".jsonl":
+        candidates = [path]
+    elif path.suffix == ".log":
+        jsonl = path.with_suffix(".jsonl")
+        candidates = [jsonl, path] if jsonl.exists() else [path]
+    elif path.exists():
+        candidates = [path]
+    else:
+        # Stem lookup: run_xxx → try .jsonl then .log
+        candidates = [path.with_suffix(".jsonl"), path.with_suffix(".log"), Path(str(path) + ".jsonl"), Path(str(path) + ".log")]
+
+    for candidate in candidates:
+        if not candidate.exists():
+            continue
+        if candidate.suffix == ".jsonl":
+            return parse_jsonl(candidate)
+        if candidate.suffix == ".log":
+            return parse_legacy_log(candidate)
+        # Unknown suffix: try jsonl lines first
+        try:
+            return parse_jsonl(candidate)
+        except ValueError:
+            return parse_legacy_log(candidate)
+
+    raise FileNotFoundError(f"No run log found for: {path}")
+
+
+def summarize_run(events: list[dict[str, Any]]) -> dict[str, Any]:
+    """Extract run-level metadata from event list."""
+    start = next((e for e in events if e.get("event") == "run_start"), None)
+    end = next((e for e in events if e.get("event") == "run_end"), None)
+    start_data = (start or {}).get("data") or {}
+    end_data = (end or {}).get("data") or {}
+
+    iterations = {
+        e.get("iteration")
+        for e in events
+        if e.get("iteration") is not None
+    }
+    tool_events = [e for e in events if e.get("event") == "tool_call"]
+
+    return {
+        "run_id": start_data.get("run_id") or end_data.get("run_id") or (start or {}).get("run_id"),
+        "model": start_data.get("model"),
+        "user_input": start_data.get("user_input"),
+        "iterations": end_data.get("iterations") or (max(iterations) if iterations else 0),
+        "tool_calls_made": end_data.get("tool_calls_made", len(tool_events)),
+        "skills_used": end_data.get("skills_used") or [],
+        "final_content": end_data.get("final_content"),
+        "event_count": len(events),
+    }

+ 861 - 0
supply_agent/logging/visualize.py

@@ -0,0 +1,861 @@
+"""Generate a standalone HTML visualization for an agent run."""
+
+from __future__ import annotations
+
+import html
+import json
+from pathlib import Path
+from typing import Any
+
+from supply_agent.logging.parser import load_run_events, summarize_run
+
+
+def _esc(text: Any) -> str:
+    if text is None:
+        return ""
+    return html.escape(str(text))
+
+
+def _pretty(obj: Any) -> str:
+    if obj is None:
+        return ""
+    if isinstance(obj, str):
+        try:
+            obj = json.loads(obj)
+        except (json.JSONDecodeError, TypeError):
+            return obj
+    return json.dumps(obj, ensure_ascii=False, indent=2)
+
+
+def _pre(text: Any, *, klass: str = "code") -> str:
+    content = _pretty(text) if not isinstance(text, str) else text
+    if content is None:
+        content = ""
+    return f'<pre class="{klass}">{_esc(content)}</pre>'
+
+
+def _message_fingerprint(msg: dict[str, Any]) -> str:
+    return json.dumps(msg, ensure_ascii=False, sort_keys=True, default=str)
+
+
+def _common_prefix_len(a: list[dict[str, Any]], b: list[dict[str, Any]]) -> int:
+    n = 0
+    for left, right in zip(a, b):
+        if _message_fingerprint(left) != _message_fingerprint(right):
+            break
+        n += 1
+    return n
+
+
+def _split_history_and_latest(
+    messages: list[dict[str, Any]],
+    prev_messages: list[dict[str, Any]] | None,
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+    """
+    Split request messages into (history, latest).
+
+    ``latest`` is only what is newly added since the previous LLM input;
+    ``history`` is everything else (hidden by default in the UI).
+    """
+    if not messages:
+        return [], []
+
+    if not prev_messages:
+        if len(messages) > 1 and messages[0].get("role") == "system":
+            return messages[:1], messages[1:]
+        return [], list(messages)
+
+    curr_has_sys = messages[0].get("role") == "system"
+    prev_has_sys = prev_messages[0].get("role") == "system"
+    curr_sys = messages[0] if curr_has_sys else None
+    prev_sys = prev_messages[0] if prev_has_sys else None
+    curr_rest = messages[1:] if curr_has_sys else messages
+    prev_rest = prev_messages[1:] if prev_has_sys else prev_messages
+
+    k = _common_prefix_len(curr_rest, prev_rest)
+    history_rest = curr_rest[:k]
+    latest = list(curr_rest[k:])
+
+    history: list[dict[str, Any]] = []
+    if curr_sys is not None:
+        history.append(curr_sys)
+    history.extend(history_rest)
+
+    # Surface an updated system prompt in the latest section
+    if (
+        curr_sys is not None
+        and prev_sys is not None
+        and _message_fingerprint(curr_sys) != _message_fingerprint(prev_sys)
+    ):
+        latest = [curr_sys, *latest]
+        history = list(history_rest)
+
+    if not latest:
+        return messages[:-1], messages[-1:]
+
+    return history, latest
+
+
+def _role_badge(role: str) -> str:
+    return f'<span class="badge role-{_esc(role)}">{_esc(role)}</span>'
+
+
+def _render_messages(
+    messages: list[dict[str, Any]] | None,
+    *,
+    index_offset: int = 0,
+    default_open: bool | None = None,
+) -> str:
+    if not messages:
+        return '<p class="muted">无消息</p>'
+    parts: list[str] = []
+    for i, msg in enumerate(messages):
+        role = msg.get("role", "?")
+        body_parts: list[str] = []
+        if msg.get("content"):
+            body_parts.append(_pre(msg["content"], klass="code prose"))
+        if msg.get("tool_calls"):
+            body_parts.append(
+                '<div class="sublabel">tool_calls</div>'
+                + _pre(msg["tool_calls"])
+            )
+        if msg.get("tool_call_id"):
+            body_parts.append(
+                f'<div class="meta-line">tool_call_id: <code>{_esc(msg["tool_call_id"])}</code></div>'
+            )
+        if msg.get("name"):
+            body_parts.append(
+                f'<div class="meta-line">name: <code>{_esc(msg["name"])}</code></div>'
+            )
+        if default_open is None:
+            open_attr = "open" if role != "system" else ""
+        else:
+            open_attr = "open" if default_open else ""
+        parts.append(
+            f"""
+            <details class="msg" {open_attr}>
+              <summary>{_role_badge(role)} <span class="msg-idx">#{index_offset + i + 1}</span>
+                <span class="msg-preview">{_esc(_preview(msg.get("content")))}</span>
+              </summary>
+              <div class="msg-body">{"".join(body_parts) or '<p class="muted">(empty)</p>'}</div>
+            </details>
+            """
+        )
+    return '<div class="msg-list">' + "".join(parts) + "</div>"
+
+
+def _preview(text: Any, limit: int = 80) -> str:
+    if not text:
+        return ""
+    s = " ".join(str(text).split())
+    return s if len(s) <= limit else s[: limit - 1] + "…"
+
+
+def _render_tools_schema(tools: list[dict[str, Any]] | None) -> str:
+    if not tools:
+        return '<p class="muted">本次请求未附带 tools</p>'
+    names = []
+    for t in tools:
+        fn = t.get("function") or t
+        names.append(fn.get("name", "?"))
+    chips = "".join(f'<span class="chip">{_esc(n)}</span>' for n in names)
+    return f"""
+    <div class="chip-row">{chips}</div>
+    <details>
+      <summary>查看完整 tools schema</summary>
+      {_pre(tools)}
+    </details>
+    """
+
+
+def _render_llm_input(
+    data: dict[str, Any],
+    seq: int,
+    iteration: Any,
+    *,
+    prev_messages: list[dict[str, Any]] | None = None,
+) -> str:
+    messages = list(data.get("messages") or [])
+    history, latest = _split_history_and_latest(messages, prev_messages)
+    history_count = len(history)
+    latest_count = len(latest)
+
+    history_html = ""
+    if history:
+        history_html = f"""
+        <details class="history-block">
+          <summary>查看历史输入({history_count} 条)</summary>
+          <div class="history-body">
+            {_render_messages(history, default_open=False)}
+          </div>
+        </details>
+        """
+
+    return f"""
+    <article class="card card-input" id="step-{seq}">
+      <header class="card-header">
+        <div class="step-num">Step {seq}</div>
+        <div class="card-title">LLM 输入</div>
+        <div class="card-tags">
+          <span class="tag">iteration {iteration}</span>
+          <span class="tag">{_esc(data.get("model", ""))}</span>
+          <span class="tag">temp {_esc(data.get("temperature", ""))}</span>
+          <span class="tag">新增 {latest_count}</span>
+          <span class="tag">共 {len(messages)} msgs</span>
+        </div>
+      </header>
+      <div class="card-body">
+        <h4>本轮新增输入</h4>
+        {_render_messages(latest, index_offset=history_count, default_open=True)}
+        {history_html}
+        <h4>Available Tools</h4>
+        {_render_tools_schema(data.get("tools"))}
+      </div>
+    </article>
+    """
+
+
+def _render_reasoning(reasoning: Any) -> str:
+    if not reasoning:
+        return """
+        <div class="reasoning empty">
+          <div class="sublabel">思考过程</div>
+          <p class="muted">本次回复未返回 reasoning 字段</p>
+        </div>
+        """
+    return f"""
+    <div class="reasoning">
+      <div class="sublabel">思考过程</div>
+      {_pre(reasoning, klass="code prose reasoning-text")}
+    </div>
+    """
+
+
+def _parse_tool_arguments(arguments: Any) -> Any:
+    if isinstance(arguments, str):
+        try:
+            return json.loads(arguments)
+        except (json.JSONDecodeError, TypeError):
+            return arguments
+    return arguments
+
+
+def _render_planned_tool_calls(tool_calls: list[dict[str, Any]]) -> str:
+    """Show each planned tool as name + args; keep raw JSON in a collapsible."""
+    if not tool_calls:
+        return ""
+
+    cards: list[str] = []
+    for i, tc in enumerate(tool_calls, 1):
+        name = tc.get("name") or (tc.get("function") or {}).get("name") or "?"
+        raw_args = tc.get("arguments")
+        if raw_args is None and isinstance(tc.get("function"), dict):
+            raw_args = tc["function"].get("arguments")
+        args = _parse_tool_arguments(raw_args)
+        tc_id = tc.get("id") or ""
+        cards.append(
+            f"""
+            <div class="planned-tool">
+              <div class="planned-tool-head">
+                <span class="chip">{_esc(name)}</span>
+                <span class="planned-tool-idx">#{i}</span>
+                {f'<code class="planned-tool-id">{_esc(tc_id)}</code>' if tc_id else ""}
+              </div>
+              <div class="sublabel">参数</div>
+              {_pre(args)}
+            </div>
+            """
+        )
+
+    return f"""
+    <h4>模型决定调用的工具</h4>
+    <div class="planned-tool-list">{"".join(cards)}</div>
+    <details class="raw-block">
+      <summary>查看原始 tool_calls JSON</summary>
+      {_pre(tool_calls)}
+    </details>
+    """
+
+
+def _render_llm_output(data: dict[str, Any], seq: int, iteration: Any) -> str:
+    tool_calls = data.get("tool_calls") or []
+    usage = data.get("usage")
+    usage_html = ""
+    if usage:
+        usage_html = f"""
+        <div class="usage">
+          <span>prompt: {_esc(usage.get("prompt_tokens", "—"))}</span>
+          <span>completion: {_esc(usage.get("completion_tokens", "—"))}</span>
+          <span>total: {_esc(usage.get("total_tokens", "—"))}</span>
+          <span>cost: {_esc(usage.get("cost", "—"))}</span>
+        </div>
+        """
+
+    tool_calls_html = _render_planned_tool_calls(tool_calls)
+    content = data.get("content")
+    content_html = (
+        f"<h4>模型输出文本</h4>{_pre(content, klass='code prose')}"
+        if content
+        else '<h4>模型输出文本</h4><p class="muted">(空,可能仅有 tool_calls)</p>'
+    )
+
+    raw_html = ""
+    if data.get("raw"):
+        raw_html = f"""
+        <details class="raw-block">
+          <summary>原始 API 响应 (raw)</summary>
+          {_pre(data["raw"])}
+        </details>
+        """
+
+    return f"""
+    <article class="card card-output" id="step-{seq}">
+      <header class="card-header">
+        <div class="step-num">Step {seq}</div>
+        <div class="card-title">LLM 输出</div>
+        <div class="card-tags">
+          <span class="tag">iteration {iteration}</span>
+          <span class="tag">{'有思考' if data.get('reasoning') or data.get('has_reasoning') else '无思考'}</span>
+          <span class="tag">{len(tool_calls)} tool calls</span>
+        </div>
+      </header>
+      <div class="card-body">
+        {_render_reasoning(data.get("reasoning"))}
+        {content_html}
+        {tool_calls_html}
+        {usage_html}
+        {raw_html}
+      </div>
+    </article>
+    """
+
+
+def _render_tool_call(data: dict[str, Any], seq: int, iteration: Any) -> str:
+    is_error = bool(data.get("is_error"))
+    status = "error" if is_error else "ok"
+    args = data.get("arguments_parsed", data.get("arguments"))
+    result = data.get("result_parsed", data.get("result"))
+    tool_id = data.get("tool_call_id") or ""
+
+    return f"""
+    <article class="card card-tool {status}" id="step-{seq}">
+      <header class="card-header">
+        <div class="step-num">Step {seq}</div>
+        <div class="card-title">工具调用 · {_esc(data.get("tool", "?"))}</div>
+        <div class="card-tags">
+          <span class="tag">iteration {iteration}</span>
+          <span class="tag tag-{status}">{"ERROR" if is_error else "OK"}</span>
+          {f'<span class="tag"><code>{_esc(tool_id)}</code></span>' if tool_id else ""}
+        </div>
+      </header>
+      <div class="card-body tool-io">
+        <div class="io-block">
+          <div class="sublabel">输入 (arguments)</div>
+          {_pre(args)}
+        </div>
+        <div class="io-arrow" aria-hidden="true">→</div>
+        <div class="io-block">
+          <div class="sublabel">输出 (result)</div>
+          {_pre(result)}
+        </div>
+      </div>
+    </article>
+    """
+
+
+def _render_skill(data: dict[str, Any], seq: int, iteration: Any) -> str:
+    return f"""
+    <article class="card card-skill" id="step-{seq}">
+      <header class="card-header">
+        <div class="step-num">Step {seq}</div>
+        <div class="card-title">技能加载</div>
+        <div class="card-tags">
+          <span class="tag">iteration {iteration}</span>
+          <span class="tag">{_esc(data.get("skill", ""))}</span>
+        </div>
+      </header>
+    </article>
+    """
+
+
+def _render_timeline(events: list[dict[str, Any]]) -> str:
+    parts: list[str] = []
+    prev_llm_messages: list[dict[str, Any]] | None = None
+    for ev in events:
+        etype = ev.get("event")
+        data = ev.get("data") or {}
+        seq = ev.get("seq", 0)
+        iteration = ev.get("iteration", data.get("iteration", "—"))
+
+        if etype == "run_start":
+            continue
+        if etype == "run_end":
+            continue
+        if etype == "llm_input":
+            messages = list(data.get("messages") or [])
+            parts.append(
+                _render_llm_input(
+                    data,
+                    seq,
+                    iteration,
+                    prev_messages=prev_llm_messages,
+                )
+            )
+            prev_llm_messages = messages
+        elif etype == "llm_output":
+            parts.append(_render_llm_output(data, seq, iteration))
+        elif etype == "tool_call":
+            parts.append(_render_tool_call(data, seq, iteration))
+        elif etype == "skill_loaded":
+            parts.append(_render_skill(data, seq, iteration))
+        else:
+            parts.append(
+                f"""
+                <article class="card" id="step-{seq}">
+                  <header class="card-header">
+                    <div class="step-num">Step {seq}</div>
+                    <div class="card-title">{_esc(etype)}</div>
+                  </header>
+                  <div class="card-body">{_pre(data)}</div>
+                </article>
+                """
+            )
+    return "\n".join(parts)
+
+
+def _nav_items(events: list[dict[str, Any]]) -> str:
+    items: list[str] = []
+    labels = {
+        "llm_input": "LLM 输入",
+        "llm_output": "LLM 输出",
+        "tool_call": "工具",
+        "skill_loaded": "技能",
+    }
+    for ev in events:
+        etype = ev.get("event")
+        if etype in ("run_start", "run_end"):
+            continue
+        data = ev.get("data") or {}
+        seq = ev.get("seq", 0)
+        label = labels.get(etype, etype or "?")
+        detail = ""
+        if etype == "tool_call":
+            detail = f" · {_esc(data.get('tool', ''))}"
+        elif etype in ("llm_input", "llm_output"):
+            detail = f" · iter {ev.get('iteration', data.get('iteration', ''))}"
+        items.append(
+            f'<a class="nav-item nav-{_esc(etype or "")}" href="#step-{seq}">'
+            f'<span class="nav-seq">{seq}</span>{label}{detail}</a>'
+        )
+    return "\n".join(items)
+
+
+_CSS = """
+:root {
+  --bg: #f4f6f9;
+  --bg-elev: #ffffff;
+  --bg-card: #ffffff;
+  --border: #d8dee8;
+  --text: #1a2332;
+  --muted: #6b7c93;
+  --accent: #2563eb;
+  --input: #2563eb;
+  --output: #059669;
+  --tool: #d97706;
+  --tool-ok: #059669;
+  --tool-err: #dc2626;
+  --skill: #7c3aed;
+  --code-bg: #f8fafc;
+  --mono: "JetBrains Mono", "SF Mono", "Fira Code", ui-monospace, monospace;
+  --sans: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
+}
+* { box-sizing: border-box; }
+html { scroll-behavior: smooth; }
+body {
+  margin: 0;
+  font-family: var(--sans);
+  background: var(--bg);
+  color: var(--text);
+  line-height: 1.55;
+}
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+.layout {
+  display: grid;
+  grid-template-columns: 260px 1fr;
+  min-height: 100vh;
+}
+.sidebar {
+  position: sticky;
+  top: 0;
+  height: 100vh;
+  overflow: auto;
+  background: var(--bg-elev);
+  border-right: 1px solid var(--border);
+  padding: 1.25rem 1rem;
+  box-shadow: 1px 0 0 rgba(26, 35, 50, 0.02);
+}
+.sidebar h1 {
+  font-size: 0.95rem;
+  margin: 0 0 0.25rem;
+  letter-spacing: 0.02em;
+  color: var(--text);
+}
+.sidebar .run-id {
+  font-family: var(--mono);
+  font-size: 0.7rem;
+  color: var(--muted);
+  word-break: break-all;
+  margin-bottom: 1rem;
+}
+.nav-item {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.4rem 0.55rem;
+  border-radius: 6px;
+  color: var(--text);
+  font-size: 0.8rem;
+  margin-bottom: 2px;
+}
+.nav-item:hover { background: #eef2f7; text-decoration: none; }
+.nav-seq {
+  font-family: var(--mono);
+  font-size: 0.7rem;
+  color: var(--muted);
+  min-width: 1.4rem;
+}
+.nav-llm_input { border-left: 3px solid var(--input); }
+.nav-llm_output { border-left: 3px solid var(--output); }
+.nav-tool_call { border-left: 3px solid var(--tool); }
+.nav-skill_loaded { border-left: 3px solid var(--skill); }
+
+.main { padding: 1.5rem 2rem 3rem; max-width: 1100px; }
+.hero {
+  background: linear-gradient(145deg, #ffffff 0%, #f0f5ff 55%, #f3faf6 100%);
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  padding: 1.5rem;
+  margin-bottom: 1.5rem;
+  box-shadow: 0 1px 2px rgba(26, 35, 50, 0.04);
+}
+.hero h2 { margin: 0 0 0.75rem; font-size: 1.35rem; }
+.stats {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.75rem;
+  margin: 1rem 0;
+}
+.stat {
+  background: #ffffff;
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 0.55rem 0.85rem;
+  min-width: 110px;
+  box-shadow: 0 1px 1px rgba(26, 35, 50, 0.03);
+}
+.stat .label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
+.stat .value { font-family: var(--mono); font-size: 1rem; margin-top: 0.15rem; }
+.user-prompt {
+  background: #eff6ff;
+  border: 1px solid #bfdbfe;
+  border-radius: 8px;
+  padding: 0.85rem 1rem;
+  white-space: pre-wrap;
+}
+.final {
+  margin-top: 1rem;
+  background: #ecfdf5;
+  border: 1px solid #a7f3d0;
+  border-radius: 8px;
+  padding: 0.85rem 1rem;
+}
+.final h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--output); }
+
+.timeline { display: flex; flex-direction: column; gap: 1rem; }
+.card {
+  background: var(--bg-card);
+  border: 1px solid var(--border);
+  border-radius: 10px;
+  overflow: hidden;
+  box-shadow: 0 1px 2px rgba(26, 35, 50, 0.04);
+}
+.card-input { border-top: 3px solid var(--input); }
+.card-output { border-top: 3px solid var(--output); }
+.card-tool { border-top: 3px solid var(--tool); }
+.card-tool.error { border-top-color: var(--tool-err); }
+.card-skill { border-top: 3px solid var(--skill); }
+.card-header {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.6rem;
+  padding: 0.75rem 1rem;
+  background: #f8fafc;
+  border-bottom: 1px solid var(--border);
+}
+.step-num {
+  font-family: var(--mono);
+  font-size: 0.75rem;
+  color: var(--muted);
+  background: #eef2f7;
+  padding: 0.15rem 0.45rem;
+  border-radius: 4px;
+}
+.card-title { font-weight: 600; font-size: 0.95rem; }
+.card-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-left: auto; }
+.tag {
+  font-size: 0.7rem;
+  font-family: var(--mono);
+  background: #ffffff;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  padding: 0.12rem 0.5rem;
+  color: var(--muted);
+}
+.tag-ok { color: var(--tool-ok); border-color: #86efac; background: #f0fdf4; }
+.tag-error { color: var(--tool-err); border-color: #fecaca; background: #fef2f2; }
+.card-body { padding: 1rem; }
+.card-body h4 {
+  margin: 1rem 0 0.5rem;
+  font-size: 0.8rem;
+  color: var(--muted);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+}
+.card-body h4:first-child { margin-top: 0; }
+.sublabel {
+  font-size: 0.72rem;
+  color: var(--muted);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  margin-bottom: 0.35rem;
+}
+.muted { color: var(--muted); font-size: 0.85rem; }
+.code {
+  font-family: var(--mono);
+  font-size: 0.78rem;
+  background: var(--code-bg);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  padding: 0.75rem;
+  overflow: auto;
+  max-height: 420px;
+  white-space: pre-wrap;
+  word-break: break-word;
+  margin: 0;
+  color: #334155;
+}
+.prose { line-height: 1.6; }
+.reasoning {
+  background: #fffbeb;
+  border: 1px solid #fde68a;
+  border-radius: 8px;
+  padding: 0.75rem;
+  margin-bottom: 1rem;
+}
+.reasoning.empty { opacity: 0.75; }
+.reasoning-text { max-height: 360px; border-color: #fcd34d; background: #fffef5; }
+.tool-io {
+  display: grid;
+  grid-template-columns: 1fr auto 1fr;
+  gap: 0.75rem;
+  align-items: start;
+}
+.io-arrow {
+  color: var(--muted);
+  font-size: 1.4rem;
+  padding-top: 1.6rem;
+}
+.io-block { min-width: 0; }
+.msg-list { display: flex; flex-direction: column; gap: 0.4rem; }
+.msg {
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  background: #f8fafc;
+}
+.msg summary {
+  cursor: pointer;
+  padding: 0.45rem 0.65rem;
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  list-style: none;
+}
+.msg summary::-webkit-details-marker { display: none; }
+.msg-body { padding: 0 0.65rem 0.65rem; }
+.msg-idx { font-family: var(--mono); font-size: 0.7rem; color: var(--muted); }
+.msg-preview { color: var(--muted); font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
+.badge {
+  font-size: 0.65rem;
+  font-weight: 600;
+  text-transform: uppercase;
+  padding: 0.12rem 0.4rem;
+  border-radius: 4px;
+  font-family: var(--mono);
+  color: #fff;
+}
+.role-system { background: #475569; }
+.role-user { background: #2563eb; }
+.role-assistant { background: #059669; }
+.role-tool { background: #d97706; }
+.chip-row { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 0.5rem; }
+.chip {
+  font-family: var(--mono);
+  font-size: 0.72rem;
+  background: #fff7ed;
+  border: 1px solid #fdba74;
+  color: #c2410c;
+  padding: 0.15rem 0.5rem;
+  border-radius: 999px;
+}
+.planned-tool-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.65rem;
+  margin-bottom: 0.5rem;
+}
+.planned-tool {
+  border: 1px solid #fed7aa;
+  background: #fffbeb;
+  border-radius: 8px;
+  padding: 0.65rem 0.75rem;
+}
+.planned-tool-head {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  margin-bottom: 0.45rem;
+  flex-wrap: wrap;
+}
+.planned-tool-idx {
+  font-family: var(--mono);
+  font-size: 0.7rem;
+  color: var(--muted);
+}
+.planned-tool-id {
+  font-size: 0.68rem;
+  color: var(--muted);
+  margin-left: auto;
+}
+.usage {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.75rem;
+  margin-top: 0.75rem;
+  font-family: var(--mono);
+  font-size: 0.72rem;
+  color: var(--muted);
+}
+.raw-block { margin-top: 0.75rem; }
+.history-block {
+  margin: 0.85rem 0 0.5rem;
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  background: #f8fafc;
+  padding: 0.35rem 0.65rem 0.65rem;
+}
+.history-block > summary {
+  cursor: pointer;
+  font-size: 0.85rem;
+  color: var(--muted);
+  padding: 0.35rem 0;
+  font-weight: 500;
+}
+.history-body { margin-top: 0.35rem; }
+.meta-line { font-size: 0.8rem; color: var(--muted); margin-bottom: 0.35rem; }
+code { font-family: var(--mono); font-size: 0.85em; }
+
+@media (max-width: 900px) {
+  .layout { grid-template-columns: 1fr; }
+  .sidebar { position: relative; height: auto; max-height: 40vh; }
+  .tool-io { grid-template-columns: 1fr; }
+  .io-arrow { display: none; }
+}
+"""
+
+
+def render_html(events: list[dict[str, Any]]) -> str:
+    """Render a full standalone HTML page for the given events."""
+    meta = summarize_run(events)
+    skills = meta.get("skills_used") or []
+    skills_str = ", ".join(skills) if skills else "—"
+
+    return f"""<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Agent Run · {_esc(meta.get("run_id") or "unknown")}</title>
+  <style>{_CSS}</style>
+</head>
+<body>
+  <div class="layout">
+    <aside class="sidebar">
+      <h1>Agent Trace</h1>
+      <div class="run-id">{_esc(meta.get("run_id"))}</div>
+      <nav>
+        {_nav_items(events)}
+      </nav>
+    </aside>
+    <main class="main">
+      <section class="hero">
+        <h2>运行概览</h2>
+        <div class="stats">
+          <div class="stat"><div class="label">Model</div><div class="value">{_esc(meta.get("model") or "—")}</div></div>
+          <div class="stat"><div class="label">Iterations</div><div class="value">{_esc(meta.get("iterations"))}</div></div>
+          <div class="stat"><div class="label">Tool Calls</div><div class="value">{_esc(meta.get("tool_calls_made"))}</div></div>
+          <div class="stat"><div class="label">Events</div><div class="value">{_esc(meta.get("event_count"))}</div></div>
+          <div class="stat"><div class="label">Skills</div><div class="value">{_esc(skills_str)}</div></div>
+        </div>
+        <div class="sublabel">用户输入</div>
+        <div class="user-prompt">{_esc(meta.get("user_input") or "—")}</div>
+        {f'''
+        <div class="final">
+          <h3>最终回答</h3>
+          <div class="user-prompt" style="background:transparent;border:none;padding:0">{_esc(meta.get("final_content") or "")}</div>
+        </div>
+        ''' if meta.get("final_content") else ""}
+      </section>
+
+      <section class="timeline">
+        <h2 style="margin:0 0 0.5rem;font-size:1.1rem">执行时间线</h2>
+        <p class="muted" style="margin:0 0 1rem">按步骤展示:LLM 输入 → 思考/输出 → 工具调用(含完整入参与返回)</p>
+        {_render_timeline(events)}
+      </section>
+    </main>
+  </div>
+</body>
+</html>
+"""
+
+
+def generate_visualization(
+    run_path: Path | str,
+    output: Path | str | None = None,
+) -> Path:
+    """
+    Load a run log and write an HTML visualization.
+
+    Returns the output HTML path.
+    """
+    run_path = Path(run_path)
+    events = load_run_events(run_path)
+    if not events:
+        raise ValueError(f"No events found in {run_path}")
+
+    html_doc = render_html(events)
+
+    if output is None:
+        # Prefer placing next to the source file
+        src = run_path
+        if src.suffix in {".log", ".jsonl"}:
+            out = src.with_suffix(".html")
+        else:
+            out = Path(str(src) + ".html")
+    else:
+        out = Path(output)
+
+    out.parent.mkdir(parents=True, exist_ok=True)
+    out.write_text(html_doc, encoding="utf-8")
+    return out

+ 42 - 4
supply_agent/tools/base.py

@@ -2,17 +2,29 @@ from __future__ import annotations
 
 import inspect
 import json
+import types
 from collections.abc import Callable
-from typing import Any, get_type_hints
+from typing import Any, Union, get_args, get_origin, get_type_hints
 
 from pydantic import BaseModel
 
 from supply_agent.types import ToolDefinition
 
 
-def _python_type_to_json_schema(py_type: type) -> dict[str, Any]:
+def _unwrap_optional(py_type: Any) -> Any:
+    """若为 Optional[T] / T | None,返回 T;否则原样返回。"""
+    origin = get_origin(py_type)
+    if origin is Union or origin is types.UnionType:
+        args = [a for a in get_args(py_type) if a is not type(None)]
+        if len(args) == 1:
+            return args[0]
+    return py_type
+
+
+def _python_type_to_json_schema(py_type: Any) -> dict[str, Any]:
     """Map Python types to JSON Schema types."""
-    origin = getattr(py_type, "__origin__", None)
+    py_type = _unwrap_optional(py_type)
+    origin = get_origin(py_type)
 
     if py_type is str or py_type is inspect.Parameter.empty:
         return {"type": "string"}
@@ -23,7 +35,7 @@ def _python_type_to_json_schema(py_type: type) -> dict[str, Any]:
     if py_type is bool:
         return {"type": "boolean"}
     if origin is list:
-        args = getattr(py_type, "__args__", (Any,))
+        args = get_args(py_type) or (Any,)
         item_type = args[0] if args else Any
         return {"type": "array", "items": _python_type_to_json_schema(item_type)}
     if origin is dict:
@@ -31,6 +43,30 @@ def _python_type_to_json_schema(py_type: type) -> dict[str, Any]:
     return {"type": "string"}
 
 
+def _coerce_args(func: Callable[..., Any], args: dict[str, Any]) -> dict[str, Any]:
+    """按函数类型注解做轻量转换,兼容模型把整数写成字符串等情况。"""
+    hints = get_type_hints(func)
+    coerced: dict[str, Any] = {}
+    for name, value in args.items():
+        if name not in hints or value is None:
+            coerced[name] = value
+            continue
+
+        target = _unwrap_optional(hints[name])
+        try:
+            if target is int and not isinstance(value, bool):
+                coerced[name] = int(value)
+            elif target is float and not isinstance(value, bool):
+                coerced[name] = float(value)
+            elif target is bool and isinstance(value, str):
+                coerced[name] = value.strip().lower() in {"1", "true", "yes", "y"}
+            else:
+                coerced[name] = value
+        except (TypeError, ValueError):
+            coerced[name] = value
+    return coerced
+
+
 def _build_parameters(func: Callable[..., Any]) -> dict[str, Any]:
     """Build JSON Schema parameters from function signature."""
     sig = inspect.signature(func)
@@ -121,6 +157,7 @@ class Tool:
             args = json.loads(arguments) if arguments.strip() else {}
         else:
             args = arguments
+        args = _coerce_args(self.func, args)
         try:
             result = self(**args)
             if inspect.isawaitable(result):
@@ -137,6 +174,7 @@ class Tool:
             args = json.loads(arguments) if arguments.strip() else {}
         else:
             args = arguments
+        args = _coerce_args(self.func, args)
         try:
             result = await self.acall(**args)
             return str(result) if result is not None else ""

+ 2 - 0
supply_agent/types.py

@@ -21,6 +21,8 @@ class Message(BaseModel):
     tool_calls: list[ToolCall] | None = None
     tool_call_id: str | None = None
     name: str | None = None
+    # Model reasoning / chain-of-thought text (OpenRouter); not sent back in API history.
+    reasoning: str | None = None
 
     def to_api_dict(self) -> dict[str, Any]:
         """Convert to OpenAI-compatible API format."""

+ 6 - 1
supply_infra/config.py

@@ -1,17 +1,22 @@
 from __future__ import annotations
 
 from functools import lru_cache
+from pathlib import Path
 from urllib.parse import quote_plus
 
 from pydantic import Field
 from pydantic_settings import BaseSettings, SettingsConfigDict
 
+# supply_infra/config.py -> project root; avoid depending on process cwd
+_PROJECT_ROOT = Path(__file__).resolve().parent.parent
+_ENV_FILE = _PROJECT_ROOT / ".env"
+
 
 class InfraSettings(BaseSettings):
     """Shared infrastructure settings."""
 
     model_config = SettingsConfigDict(
-        env_file=".env",
+        env_file=str(_ENV_FILE),
         env_file_encoding="utf-8",
         extra="ignore",
     )

+ 2 - 1
supply_infra/db/models/__init__.py

@@ -1,6 +1,7 @@
 """ORM entity models — one file per table."""
 
+from supply_infra.db.models.demand_belong_category import DemandBelongCategory
 from supply_infra.db.models.global_tree_category import GlobalTreeCategory
 from supply_infra.db.models.global_tree_element import GlobalTreeElement
 
-__all__ = ["GlobalTreeCategory", "GlobalTreeElement"]
+__all__ = ["DemandBelongCategory", "GlobalTreeCategory", "GlobalTreeElement"]

+ 34 - 0
supply_infra/db/models/demand_belong_category.py

@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class DemandBelongCategory(Base):
+    """需求归属分类。"""
+
+    __tablename__ = "demand_belong_category"
+    __table_args__ = (UniqueConstraint("name", name="demand_belong_category_pk"),)
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    name: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="需求名称")
+    category_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="分类id")
+    reason: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原因")
+    is_delete: Mapped[int] = mapped_column(
+        Integer, default=0, nullable=False, comment="是否删除 0-正常 1-删除"
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        comment="创建时间",
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )

+ 4 - 0
supply_infra/db/repositories/__init__.py

@@ -1,11 +1,15 @@
 """Data access layer (Repository pattern)."""
 
 from supply_infra.db.repositories.base import BaseRepository
+from supply_infra.db.repositories.demand_belong_category_repo import (
+    DemandBelongCategoryRepository,
+)
 from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
 from supply_infra.db.repositories.global_tree_element_repo import GlobalTreeElementRepository
 
 __all__ = [
     "BaseRepository",
+    "DemandBelongCategoryRepository",
     "GlobalTreeCategoryRepository",
     "GlobalTreeElementRepository",
 ]

+ 27 - 0
supply_infra/db/repositories/demand_belong_category_repo.py

@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from sqlalchemy.dialects.mysql import insert
+
+from supply_infra.db.models.demand_belong_category import DemandBelongCategory
+from supply_infra.db.repositories.base import BaseRepository
+
+_BATCH_SIZE = 1000
+
+
+class DemandBelongCategoryRepository(BaseRepository[DemandBelongCategory]):
+    """需求归属分类表 — 按 name 唯一键去重后批量写入。"""
+
+    model = DemandBelongCategory
+
+    def bulk_insert_ignore(self, rows: list[dict]) -> int:
+        """批量插入,MySQL 按 name 唯一索引忽略已存在行。"""
+        if not rows:
+            return 0
+
+        inserted = 0
+        for i in range(0, len(rows), _BATCH_SIZE):
+            batch = rows[i : i + _BATCH_SIZE]
+            stmt = insert(DemandBelongCategory).values(batch).prefix_with("IGNORE")
+            result = self.session.execute(stmt)
+            inserted += result.rowcount
+        return inserted

+ 9 - 0
supply_infra/db/repositories/global_tree_category_repo.py

@@ -14,6 +14,15 @@ class GlobalTreeCategoryRepository(BaseRepository[GlobalTreeCategory]):
 
     model = GlobalTreeCategory
 
+    def list_active_categories(self) -> list[GlobalTreeCategory]:
+        """返回所有未删除的分类,按 level、id 排序。"""
+        stmt = (
+            select(GlobalTreeCategory)
+            .where(GlobalTreeCategory.is_delete == 0)
+            .order_by(GlobalTreeCategory.level, GlobalTreeCategory.id)
+        )
+        return list(self.session.scalars(stmt).all())
+
     def get_source_id_map(self) -> dict[int, int]:
         """返回全量 source_id → MySQL id 映射,用于增量去重与 parent 关联。"""
         stmt = select(GlobalTreeCategory.source_id, GlobalTreeCategory.id)