康宇 1 napja
szülő
commit
d36269bf38
56 módosított fájl, 5030 hozzáadás és 996 törlés
  1. 0 40
      .env
  2. 59 0
      .gitignore
  3. 0 3
      .idea/.gitignore
  4. 0 33
      .idea/inspectionProfiles/Project_Default.xml
  5. 0 6
      .idea/inspectionProfiles/profiles_settings.xml
  6. 0 7
      .idea/misc.xml
  7. 0 8
      .idea/modules.xml
  8. 0 8
      .idea/vent_deep_agent.iml
  9. 0 4
      .reasonix/desktop-topic-created-at.json
  10. 0 5
      .reasonix/desktop-topic-title-sources.json
  11. 0 5
      .reasonix/desktop-topic-titles.json
  12. 0 43
      .zcode/plans/plan-sess_0ad6e05f-b515-4867-a76e-4f3585997734.md
  13. 0 87
      .zcode/plans/plan-sess_1a6b9048-6845-4976-ade2-20e430edfa08.md
  14. 0 152
      .zcode/plans/plan-sess_1db8e687-8a22-4397-8d9a-e67796695f8c.md
  15. 0 185
      .zcode/plans/plan-sess_4d6aa46c-0157-4a26-9add-313e05e615c3.md
  16. 0 32
      .zcode/plans/plan-sess_7f96803d-728b-42a3-ba40-6cd08853efbb.md
  17. 0 88
      .zcode/plans/plan-sess_8e84e8d8-45a4-441b-9b42-7d6cd00ecaf9.md
  18. 0 25
      .zcode/plans/plan-sess_f1b04b22-c7c2-4e62-8a79-c627fdea9b7c.md
  19. 0 67
      .zcode/plans/plan-sess_fed924c5-4be9-4348-9fe8-1b9720a06ad7.md
  20. 69 2
      API_DOCUMENTATION.md
  21. 41 1
      agents/needq_agent.py
  22. 28 7
      agents/review_agent.py
  23. 166 7
      agents/vent_agent.py
  24. 4 0
      api/__init__.py
  25. 70 0
      api/auth.py
  26. 191 29
      api/chat_routes.py
  27. 2 2
      api/fast_generators.py
  28. 4 5
      api/intent.py
  29. 233 0
      api/model_config.py
  30. 139 0
      api/model_routes.py
  31. 3 4
      api/prompts.py
  32. 183 8
      api/session_routes.py
  33. 286 0
      api/skill_routes.py
  34. 344 3
      api/sse_core.py
  35. BIN
      data/chat_history.db
  36. BIN
      data/chat_history.db-shm
  37. BIN
      data/chat_history.db-wal
  38. 279 14
      db/chat_store.py
  39. 137 0
      list_mcp_tools.py
  40. 0 3
      main.py
  41. 0 1
      skills/click-interpret-device/SKILL.md
  42. 0 101
      skills/click-interpret-tun/SKILL-bak.md2
  43. 0 1
      skills/click-interpret-tun/SKILL.md
  44. 10 0
      skills/dialog-interpret/SKILL.md
  45. 46 0
      skills/wind-hazard-diagnosis/README.md
  46. 180 0
      skills/wind-hazard-diagnosis/SKILL.md
  47. 5 0
      skills_disabled.json
  48. 793 5
      static/index.html
  49. 227 0
      tests/chat_test_cases.md
  50. 300 0
      tests/mcp_test_report.json
  51. 286 0
      tests/mcp_test_report.md
  52. 559 0
      tests/test_mcp_interfaces.py
  53. 191 0
      tools/context_tracker.py
  54. 15 0
      tools/tool_names_cn.py
  55. 175 1
      tools/vent_tools.py
  56. 5 4
      xfl_demo_client.py

+ 0 - 40
.env

@@ -1,40 +0,0 @@
-# 煤矿通风数据智能解读系统 - 环境变量配置
-
-# === LLM 模型配置(阿里百炼 / DashScope 兼容 OpenAI API)===
-#DEEPAGENT_MODEL=openai:qwen3.7-plus
-#OPENAI_API_KEY=sk-2f6964bbc3544119bdd6ac7ce1aee0ae
-#OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
-OPENAI_BASE_URL=https://api.deepseek.com
-OPENAI_API_KEY=sk-325b61e16437433eba8bced3944598f4
-DEEPAGENT_MODEL=deepseek-v4-pro
-#DEEPAGENT_MODEL = deepseek-v4-flash
-
-# === Summary Agent 独立模型(汇总任务使用更快/更便宜的模型)===
-# 若不配置,默认使用 DEEPAGENT_MODEL(即 deepseek-v4-pro)
-SUMMARY_MODEL=deepseek-v4-flash
-
-# === LLM API 超时(秒)===
-OPENAI_TIMEOUT=180
-
-# === MCP 远程数据服务 ===
-MCP_HOST=0.0.0.0
-MCP_PORT=8100
-MCP_BASE_URL=http://39.97.59.228:8071
-
-# === 服务配置 ===
-HOST=localhost
-PORT=8070
-# 对外访问地址(用于生成下载链接等)。部署到服务器时必填!
-# 例如:SERVER_HOST=http://39.97.59.228:8070
-# 不配置时默认用 HOST:PORT 拼接,远程访问会因 localhost/0.0.0.0 而失败
-SERVER_HOST=http://39.97.59.228:8070
-LOG_LEVEL=INFO
-
-# === 日志审计配置 ===
-# 启用后,每次模型调用会在控制台打印上下文和 token 使用情况
-AUDIT_LOG_ENABLED = true
-MINERU_ENABLED = true
-MINERU_TOKEN = eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFM1MTIifQ.eyJqdGkiOiI3MTgwMDI3NyIsInJvbCI6IlJPTEVfUkVHSVNURVIiLCJpc3MiOiJPcGVuWExhYiIsImlhdCI6MTc3ODY0MTg5NSwiY2xpZW50SWQiOiJsa3pkeDU3bnZ5MjJqa3BxOXgydyIsInBob25lIjoiMTM4MzU0MjkzMzEiLCJvcGVuSWQiOm51bGwsInV1aWQiOiI5YjIzMzliZC05ZGVkLTRhMTItYTBhZi02MGQyODZlOTExMjYiLCJlbWFpbCI6IiIsImV4cCI6MTc4NjQxNzg5NX0.Tg6QqXMXyke1fpx6bDJIbTxB6VcSgUu4F4Cojc15WB1mD9Cm6lwSgbabhsxG5MdQWhx8pc_L8BqTfpPL7wzeMQ
-
-
-DEFAULT_MODEL_ID = 2012326636757958658

+ 59 - 0
.gitignore

@@ -0,0 +1,59 @@
+# === Python ===
+__pycache__/
+*.py[cod]
+*.egg-info/
+dist/
+build/
+*.egg
+
+# === Virtual Environment ===
+venv/
+.venv/
+env/
+.env
+*.env.local
+
+# === IDE ===
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# === OS ===
+.DS_Store
+Thumbs.db
+desktop.ini
+
+# === Sensitive / Credentials ===
+.env
+*.pem
+*.key
+
+# === Database ===
+*.db
+*.db-shm
+*.db-wal
+*.sqlite
+*.sqlite3
+
+# === Runtime Data ===
+conversation_history/
+large_tool_results/
+data/chat_history.db
+data/chat_history.db-shm
+data/chat_history.db-wal
+data/pdf_cache/
+
+# === Generated / Static ===
+static/reports/
+static/reports/*.docx
+
+# === Testing ===
+.pytest_cache/
+.coverage
+htmlcov/
+
+# === ZCode / Tools ===
+.reasonix/
+.zcode/

+ 0 - 3
.idea/.gitignore

@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml

+ 0 - 33
.idea/inspectionProfiles/Project_Default.xml

@@ -1,33 +0,0 @@
-<component name="InspectionProjectProfileManager">
-  <profile version="1.0">
-    <option name="myName" value="Project Default" />
-    <inspection_tool class="LanguageDetectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
-    <inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
-      <option name="ignoredPackages">
-        <value>
-          <list size="4">
-            <item index="0" class="java.lang.String" itemvalue="gradio" />
-            <item index="1" class="java.lang.String" itemvalue="torch" />
-            <item index="2" class="java.lang.String" itemvalue="torchvision" />
-            <item index="3" class="java.lang.String" itemvalue="opencv-python" />
-          </list>
-        </value>
-      </option>
-    </inspection_tool>
-    <inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
-      <option name="ignoredErrors">
-        <list>
-          <option value="N802" />
-          <option value="N806" />
-        </list>
-      </option>
-    </inspection_tool>
-    <inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
-      <option name="ignoredIdentifiers">
-        <list>
-          <option value="set.*" />
-        </list>
-      </option>
-    </inspection_tool>
-  </profile>
-</component>

+ 0 - 6
.idea/inspectionProfiles/profiles_settings.xml

@@ -1,6 +0,0 @@
-<component name="InspectionProjectProfileManager">
-  <settings>
-    <option name="USE_PROJECT_PROFILE" value="false" />
-    <version value="1.0" />
-  </settings>
-</component>

+ 0 - 7
.idea/misc.xml

@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="Black">
-    <option name="sdkName" value="D:\develop\anaconda3" />
-  </component>
-  <component name="ProjectRootManager" version="2" project-jdk-name="deep_agent" project-jdk-type="Python SDK" />
-</project>

+ 0 - 8
.idea/modules.xml

@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="ProjectModuleManager">
-    <modules>
-      <module fileurl="file://$PROJECT_DIR$/.idea/vent_deep_agent.iml" filepath="$PROJECT_DIR$/.idea/vent_deep_agent.iml" />
-    </modules>
-  </component>
-</project>

+ 0 - 8
.idea/vent_deep_agent.iml

@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<module type="PYTHON_MODULE" version="4">
-  <component name="NewModuleRootManager">
-    <content url="file://$MODULE_DIR$" />
-    <orderEntry type="jdk" jdkName="deep_agent" jdkType="Python SDK" />
-    <orderEntry type="sourceFolder" forTests="false" />
-  </component>
-</module>

+ 0 - 4
.reasonix/desktop-topic-created-at.json

@@ -1,4 +0,0 @@
-{
-  "topic_20260701-012703_f1f0dbefd58f59bf": 1782869223080,
-  "topic_20260707-023247_72d3dfcbe693c206": 1783391567575
-}

+ 0 - 5
.reasonix/desktop-topic-title-sources.json

@@ -1,5 +0,0 @@
-{
-  "topic_20260701-012703_f1f0dbefd58f59bf": "auto",
-  "topic_20260707-023247_72d3dfcbe693c206": "auto",
-  "topic_20260709-025916_1a00feefd4c70592": "auto"
-}

+ 0 - 5
.reasonix/desktop-topic-titles.json

@@ -1,5 +0,0 @@
-{
-  "topic_20260701-012703_f1f0dbefd58f59bf": "解决报错 [已粘贴文本 #1 · 2…",
-  "topic_20260707-023247_72d3dfcbe693c206": "\u003cmemory-compiler-e…",
-  "topic_20260709-025916_1a00feefd4c70592": "\u003cmemory-compiler-e…"
-}

+ 0 - 43
.zcode/plans/plan-sess_0ad6e05f-b515-4867-a76e-4f3585997734.md

@@ -1,43 +0,0 @@
-## 实现方案
-
-### 改动范围
-仅修改 `api/routes.py` 一个文件,不涉及 `agents/vent_agent.py` 和 `tools/vent_tools.py`。
-
-### 具体改动
-
-#### 1. 请求模型新增 `mode` 字段
-- `TunClickInterpretRequest` 和 `DeviceClickInterpretRequest` 各加一个字段:
-  ```python
-  mode: str = Field("plan", description="解读模式:plan(智能体+技能)或 fast(直接生成报告)")
-  ```
-- 默认 `"plan"`,保持向后兼容。
-
-#### 2. 新增 Fast 模式系统提示词(两个常量)
-直接嵌入 SKILL.md 中的报告模板(巷道 + 设备),包含:
-- 报告固定输出格式
-- 风险分级标准(风速/风量/报警)
-- 行为规范(不编造数据、只用简体中文、不暴露ID等)
-
-#### 3. 新增 `_get_chat_model()` 辅助函数
-从 `agents.vent_agent` 导入 `_get_model`,包装一层确保始终返回 `BaseChatModel` 实例(处理可能返回字符串的边界情况)。
-
-#### 4. 新增两个 Fast SSE 生成器
-- `_fast_tun_sse_generator(tun_id, tun_name, thread_id, session_id)`:
-  1. 直接调用 `query_tun_data_by_id(tun_id)` 获取数据
-  2. 将数据 + 系统提示词发给 LLM
-  3. 流式输出 SSE(thinking → executing → token → done)
-- `_fast_device_sse_generator(device_id, device_name, device_type, thread_id, session_id)`:同上逻辑,调用 `query_device_data_by_id`
-
-#### 5. 路由处理器分支
-在 `click_interpret_tun` 和 `click_interpret_device` 中:
-```python
-if req.mode == "fast":
-    return StreamingResponse(_fast_xxx_generator(...), ...)
-# 否则走原有 plan 逻辑
-```
-
-### 关键设计决策
-- **Fast 模式不调用 Agent**:跳过 skill 加载、工具调用循环、知识库检索,直接 LLM 生成报告
-- **Fast 模式不查知识库**:LLM 凭训练知识给出规程引用,速度优先
-- **SSE 事件格式与 plan 模式兼容**:前端无需改动
-- **模板定义在系统提示词中**:不使用 skills 目录,直接在 `routes.py` 中定义

+ 0 - 87
.zcode/plans/plan-sess_1a6b9048-6845-4976-ade2-20e430edfa08.md

@@ -1,87 +0,0 @@
-# 配风计划审查 - Summary Agent 汇总延迟优化方案
-
-## 问题
-3 个子智能体完成后,从「正在生成最终审查报告...」到 LLM 输出第一个 token 卡顿约 3 分钟。
-`summary_input` 约 15,000 字符。
-
-## 根因
-1. **主要**:DeepSeek v4 Pro 大模型处理 ~7,000 token 输入的 prefill 阶段耗时长
-2. **次要**:Summary Agent 的 `create_deep_agent()` 编译 LangGraph 状态图未预热
-3. **潜在**:3 个子智能体刚结束大量 API 调用后,summary 请求可能排队
-
----
-
-## 修改计划
-
-### 1. Summary Agent 使用独立快速模型 `deepseek-v4-flash` 🔥🔥🔥
-
-**文件:`.env`**
-- 新增配置项:`SUMMARY_MODEL=deepseek-v4-flash`
-
-**文件:`agents/review_agent.py`**
-- 新增 `_get_summary_model()` 函数(约 25 行),从 `.env` 读取 `SUMMARY_MODEL` 配置,fallback 到 `DEEPAGENT_MODEL`
-- 修改 `create_summary_agent()`(第 410-430 行):`model=_get_model()` → `model=_get_summary_model()`
-
-**逻辑**:子智能体继续用 `deepseek-v4-pro` 保证分析质量,summary 用 flash 模型——汇总任务本质是格式化重组已有内容,flash 完全够用,TTFT 预计从 ~3min 降至 ~15-30s。
-
-### 2. 预热所有 Review Agent 🔥🔥
-
-**文件:`main.py`**(`_warmup_agents()` 函数,第 41-56 行)
-- 新增 import:`from agents.review_agent import get_form_review_agent, get_data_consistency_agent, get_calc_verification_agent, get_summary_agent`
-- 在 `_warmup_agents()` 末尾依次调用 4 个 review agent 的 getter
-- 用 `try/except` 包裹每个调用,单个失败不影响其他
-
-**效果**:消除首次请求 ~10-30s 的图编译开销。
-
-### 3. 轻度压缩子智能体输出 🔥
-
-**文件:`agents/review_agent.py`**
-- 新增 `_compress_result(text: str, max_chars: int = 8000) -> str` 函数(约 30 行)
-  - 若文本 ≤ `max_chars`,直接返回
-  - 否则按优先级保留:表格块(`|---|` 标记的行)→ 标题行(`##`/`###`)→ 列表项 → 正文
-  - 去除冗余空行(>2 个连续换行压缩为 2 个)
-  - 末尾追加 `...[已截断,完整内容见子智能体原始输出]`
-- 在构建 `summary_input` 前(第 865 行之前),对每个 result 调用 `_compress_result()`
-
-**效果**:降低输入 token 数,进一步减少 prefill 耗时,同时保留核心结构化数据。
-
-### 4. 等待期间心跳进度 🔥
-
-**文件:`agents/review_agent.py`**(`stream_review()` 函数,第 897-917 行附近)
-- 在 `summary_agent.astream()` 调用前,创建一个心跳 `asyncio.Task`:
-  - 每隔 5 秒通过 `asyncio.Queue` 发送 heartbeat 事件
-  - 主循环收到第一个 token 时取消心跳任务
-- 在事件循环中新增 `heartbeat` 事件处理:yield `_sse_event("progress", message="模型正在处理中(已等待 XX 秒)...", agent="system")`
-
-**效果**:用户能看到系统仍在工作,消除"卡死"错觉。前端已有的 progress 事件处理可复用,无需改前端。
-
-### 5. 添加模型超时配置 🔥
-
-**文件:`.env`**
-- 新增:`OPENAI_TIMEOUT=180`(3 分钟超时)
-
-**文件:`agents/vent_agent.py`**(`_get_model()` 函数,第 100-135 行)
-- 新增读取 `OPENAI_TIMEOUT` 环境变量
-- 传递给 `init_chat_model(..., timeout=timeout)`
-
-**文件:`agents/review_agent.py`**(新增的 `_get_summary_model()`)
-- 同样读取并传递 `timeout`
-
-**效果**:防止异常情况下无限挂起。
-
----
-
-## 涉及文件汇总
-
-| 文件 | 改动量 | 改动类型 |
-|------|--------|---------|
-| `.env` | +2 行 | 新增配置项 |
-| `agents/vent_agent.py` | ~5 行 | `_get_model()` 添加 timeout 参数 |
-| `agents/review_agent.py` | ~80 行 | 新增 `_get_summary_model()`、`_compress_result()`、心跳协程;修改 `create_summary_agent()` |
-| `main.py` | ~15 行 | `_warmup_agents()` 添加 review agent 预热 |
-
-## 预期效果
-- **TTFT**:从 ~3 分钟降至 **15-30 秒**(flash 模型 + 轻度压缩)
-- **首次请求**:消除额外 10-30 秒编译开销(预热)
-- **UX**:等待期间有心跳提示,不再感觉"卡死"
-- **健壮性**:超时保护防止无限挂起

+ 0 - 152
.zcode/plans/plan-sess_1db8e687-8a22-4397-8d9a-e67796695f8c.md

@@ -1,152 +0,0 @@
-# 配风计划审查功能 - 实现方案
-
-## 架构设计
-
-采用**自定义编排器 + 3个独立 DeepAgent**的架构(而非 deepagents 的 subagent 模式),原因是用户要求"固定3个子智能体、按固定流程并行检查",自定义编排器保证确定性并行执行,而 deepagents 的 subagent 由 LLM 决定何时调用,不够确定。
-
-```
-POST /api/vent/review/pdf
-        │
-        ▼
-  stream_review() 编排器
-        │
-  1. PDF文本提取 (PyPDF2 → PaddleOCR)
-  2. 创建3个 DeepAgent 实例
-  3. asyncio 并行执行
-  4. 流式 yield SSE 进度事件
-  5. 汇总合并结果
-   ┌───────┬───────┬───────┐
-   ▼       ▼       ▼
-Agent1  Agent2  Agent3
-表单审查 数据一致性 计算核验
-```
-
-## 新增/修改文件清单
-
-### 1. `requirements.txt` — 新增依赖
-```
-pypdf>=5.0
-paddleocr>=2.9
-paddlepaddle>=3.0
-```
-
-### 2. `tools/pdf_tools.py` — 新建 PDF 提取工具
-- `extract_pdf_text(file_path)`: PyPDF2 提取 → 失败则 PaddleOCR
-- `save_upload_file(upload_file)`: 保存上传文件到临时目录
-
-### 3. `tools/calc_tools.py` — 新建本地需风量计算工具
-按《煤矿安全规程》和 AQ 1056 标准,实现各用风地点类型的计算工具:
-
-| 工具函数 | 用途 |
-|---------|------|
-| `calc_face_air_volume_by_gas(q_gas, k_gas)` | 采煤面-按瓦斯涌出量 Q=100×q×K |
-| `calc_face_air_volume_by_workers(n)` | 采煤面-按人数 Q=4×N |
-| `calc_face_air_volume_by_wind_speed(v, s)` | 采煤面-按风速 Q=60×S×v |
-| `calc_face_air_volume_max(...)` | 采煤面-取最大值 |
-| `calc_tunnel_air_volume_by_gas(q_gas, k_gas)` | 掘进面-按瓦斯 Q=100×q×K |
-| `calc_tunnel_air_volume_by_explosives(a)` | 掘进面-按炸药 Q=25×A |
-| `calc_tunnel_air_volume_by_workers(n)` | 掘进面-按人数 Q=4×N |
-| `calc_tunnel_air_volume_by_wind_speed(v, s)` | 掘进面-按风速 Q=60×S×v |
-| `calc_chamber_air_volume(...)` | 硐室-机电设备配风 |
-| `calc_other_air_volume_by_wind_speed(v, s)` | 其他巷道-按风速 |
-| `calc_effective_area(max_control, min_control, avg_height)` | 有效断面积计算 |
-
-每个工具返回列式:`{formula, steps, result}` 结构化数据,供 LLM 对比。
-
-### 4. `agents/review_agent.py` — 新建审查 Agent 工厂
-基于现有 `vent_agent.py` 的模式,复用 `_get_model()` 和 `FilesystemBackend`:
-
-- **`create_form_review_agent()`** → 子智能体1:基本形式审查
-  - Skills: `skills/vent-plan-review-form`
-  - Tools: `write_todos`
-  - 审查内容:版本、签字、编制时间、计算过程完整性、语病逻辑
-
-- **`create_data_consistency_agent()`** → 子智能体2:数据一致性审查
-  - Skills: `skills/vent-plan-review-data`
-  - Tools: `write_todos` + (MCP 工具预留接口)
-  - 审查内容:用风地点完整性、瓦斯/CO2一致性、工作面参数、风速温度匹配
-
-- **`create_calc_verification_agent()`** → 子智能体3:计算核验
-  - Skills: `skills/vent-plan-review-calc`
-  - Tools: `write_todos` + 所有 calc_tools 函数
-  - 审查内容:逐地点核验需风量计算,输出表格
-
-- **`create_summary_agent()`** → 汇总 Agent
-  - 汇总3个子智能体的审查结果,生成最终审查报告
-
-- **`stream_review(file, message, session_id)`** → 核心编排器异步生成器
-  - 提取 PDF → 创建3个 Agent → `asyncio.gather` 并行执行 → yield SSE 事件 → 汇总
-
-### 5. `skills/vent-plan-review-form/SKILL.md` — 新建
-形式审查技能,包含:
-- 版本审查规则(月份匹配检查)
-- 签字审查规则(四类签字人:编制人、通风科长、通风副总、总工程师)
-- 编制时间规则(配风计划月份的上个月)
-- 计算过程完整性检查
-- 语病逻辑检查规则
-
-### 6. `skills/vent-plan-review-data/SKILL.md` — 新建
-数据一致性审查技能,包含:
-- 用风地点完整性检查标准
-- 瓦斯/CO2数据一致性校验方法
-- 工作面参数与作业规程一致性校验
-- 风速与温度数据匹配规则
-- MCP 查询接口预留说明
-
-### 7. `skills/vent-plan-review-calc/SKILL.md` — 新建
-计算核验技能,包含:
-- 各用风地点类型对应的计算公式
-- 核验步骤和判定标准
-- 输出格式规范(表格)
-
-### 8. `api/routes.py` — 修改
-新增端点:
-```python
-@router.post("/api/vent/review/pdf")
-async def api_review(
-    file: UploadFile = File(),
-    session_id: str | None = Form(default=None),
-    message: str | None = Form(default=None)
-):
-    return StreamingResponse(
-        stream_review(file, message, session_id),
-        media_type="text/event-stream",
-        headers={
-            "Cache-Control": "no-cache",
-            "Connection": "keep-alive",
-            "X-Accel-Buffering": "no"
-        }
-    )
-```
-
-## SSE 事件类型设计
-
-| type | 描述 | 关键字段 |
-|------|------|---------|
-| `progress` | 进度描述(LLM 翻译后的文字) | `message`, `agent?` |
-| `agent_start` | 子智能体开始审查 | `agent` (form-review/data-check/calc-verify) |
-| `agent_done` | 子智能体审查完成 | `agent`, `content` (摘要) |
-| `agent_error` | 子智能体审查出错 | `agent`, `message` |
-| `token` | 汇总报告的 token 流 | `content` |
-| `done` | 审查结束 | `session_id` |
-| `error` | 整体错误 | `message` |
-
-## 关键技术细节
-
-1. **PDF 文本提取**:优先 PyPDF2(pypdf),若提取内容为空或过短则启用 PaddleOCR(需先将 PDF 每页转为图片)
-2. **并行执行**:使用 `asyncio.Queue` 收集各 Agent 的进度事件,编排器从队列读取并 yield SSE
-3. **模型复用**:复用 `vent_agent.py` 的 `_get_model()` 函数,确保模型配置一致
-4. **Skills 加载**:复用 `FilesystemBackend(virtual_mode=True)` 模式,skills 路径为 `skills/vent-plan-review-*`
-5. **计算工具**:每个工具返回结构化 JSON(公式 + 步骤 + 结果),让 LLM 能直接对比"配风计划值"与"实际计算值"
-6. **MCP 预留**:子智能体2的工具中预留 MCP 查询接口,当前以 TODO 注释标注,后续对接时直接替换
-
-## 实现顺序
-
-1. `tools/pdf_tools.py` — PDF 提取
-2. `tools/calc_tools.py` — 计算工具
-3. `skills/vent-plan-review-form/SKILL.md` — 表单审查技能
-4. `skills/vent-plan-review-data/SKILL.md` — 数据一致性技能
-5. `skills/vent-plan-review-calc/SKILL.md` — 计算核验技能
-6. `agents/review_agent.py` — Agent 工厂 + 编排器
-7. `api/routes.py` — 新增端点
-8. `requirements.txt` — 更新依赖

+ 0 - 185
.zcode/plans/plan-sess_4d6aa46c-0157-4a26-9add-313e05e615c3.md

@@ -1,185 +0,0 @@
-# PDF 解析双引擎方案:本地 PaddleOCR + 远程 MinerU
-
-## 目标
-给 `ocr_extract.py` 增加 `--engine` 参数,支持在本地 PaddleOCR(原方案)和远程 MinerU(高精度云端解析)之间切换,两种引擎输出统一 JSON 格式供下游消费。
-
----
-
-## 涉及文件(6 个文件变更)
-
-| 文件 | 操作 | 说明 |
-|---|---|---|
-| `scripts/mineru_parser.py` | **新建** | MinerU 云端解析模块 |
-| `scripts/ocr_extract.py` | **修改** | 增加 `--engine` / `--mineru-token` / `--mineru-mode` 参数,路由分发 |
-| `scripts/requirements.txt` | **修改** | 增加 `langchain-mineru` + `python-dotenv`(可选依赖) |
-| `scripts/deploy.md` | **修改** | 增加 MinerU 部署配置章节 |
-| `SKILL.md` | **修改** | 阶段 2 补充 `--engine` 用法说明 |
-| `.env.example` | **新建** | MinerU 配置模板 |
-| `.gitignore` | **修改** | 增加 `mineru_cache/` |
-
----
-
-## 详细设计
-
-### 1. `scripts/mineru_parser.py`(新建,约 180 行)
-
-**配置加载**(优先级:CLI arg > 环境变量 > `.env` 文件):
-- `MINERU_TOKEN`:MinerU API 令牌
-- `MINERU_MODE`:解析模式,默认 `"precision"`
-- `MINERU_MAX_PAGES`:单次 API 最大页数,默认 20
-- `MINERU_CACHE_DIR`:缓存目录,默认 `./mineru_cache/`
-
-**核心函数**:
-```
-get_file_hash(file_path) → md5 hex
-_cache_path_for(file_hash) → Path
-_read_cache(path) / _write_cache(path, content)
-count_pdf_pages(file_path) → int
-clean_mineru_text(markdown_text) → str
-sanitize_text(text) → str
-mineru_pdf(pdf_path, out_dir, pages_spec, token, mode) → (result_dict, json_path)
-```
-
-**`mineru_pdf()` 流程**:
-1. 检查缓存(基于 PDF 的 MD5 hash)
-2. 命中缓存 → 直接返回
-3. 未命中 → 分片调 `MinerULoader(source, mode, token, pages="start-end")`
-4. 每页 `doc.page_content` 过 `clean_mineru_text()` 清洗
-5. 拼接全文 → 写缓存
-6. 输出统一 JSON,`type: "mineru"`, `avg_confidence: 0.98`
-
-**输出 JSON 格式**(与 PaddleOCR 兼容):
-```json
-{
-  "source": "/path/to/file.pdf",
-  "engine": "mineru",
-  "total_pages": 15,
-  "from_cache": false,
-  "pages": [
-    {
-      "page": 1,
-      "type": "mineru",
-      "text": "## 配风计划表\n| 地点 | 风量 |\n...",
-      "avg_confidence": 0.98,
-      "item_count": 0,
-      "items": []
-    }
-  ]
-}
-```
-
-**缓存策略**:
-- 缓存 key = PDF 文件 MD5(文件内容不变则命中缓存)
-- 缓存内容 = 清洗后的全文 markdown
-- 缓存目录 `mineru_cache/`(`.gitignore` 排除)
-
----
-
-### 2. `scripts/ocr_extract.py`(修改)
-
-**新增 CLI 参数**:
-```
---engine {local,mineru}    解析引擎,默认 local
---mineru-token TOKEN       MinerU API Token(可选,优先级最高)
---mineru-mode {precision,fast}  MinerU 模式,默认 precision
-```
-
-**`main()` 路由逻辑**:
-```python
-if args.engine == "mineru":
-    from mineru_parser import mineru_pdf
-    result, json_path = mineru_pdf(
-        args.pdf, args.out, args.pages,
-        token=args.mineru_token, mode=args.mineru_mode
-    )
-else:
-    result, json_path = ocr_pdf(args.pdf, args.out, args.dpi, args.pages)
-```
-
-**输出时标注 engine 字段**:
-- `ocr_pdf()` 结果自动补 `"engine": "local"`
-
----
-
-### 3. `scripts/requirements.txt`(修改)
-
-新增(注释标为可选):
-```
-# === MinerU 云端解析(可选,仅 --engine mineru 时需要)===
-# pip install langchain-mineru python-dotenv
-# langchain-mineru
-# python-dotenv
-```
-
----
-
-### 4. `scripts/deploy.md`(修改)
-
-新增章节"六、MinerU 云端解析(可选)":
-- 申请 Token 地址
-- 安装依赖:`pip install langchain-mineru python-dotenv`
-- 配置 `.env` 文件
-- 用法示例
-- 费用/速率限制说明
-- 与本地 PaddleOCR 对比表(精度/速度/费用/离线能力)
-
----
-
-### 5. `SKILL.md`(修改)
-
-阶段 2 第 2 步增加 `--engine` 选项说明:
-
-```markdown
-**引擎选择**:
-- `--engine local`(默认):本地 PaddleOCR,无需网络,适合离线环境
-- `--engine mineru`:MinerU 云端解析,精度更高,适合扫描质量差的 PDF
-```
-
----
-
-### 6. `.env.example`(新建)
-
-```ini
-# MinerU 云端解析配置(可选,仅 --engine mineru 时需要)
-MINERU_TOKEN=your_token_here
-MINERU_MODE=precision
-MINERU_MAX_PAGES=20
-MINERU_CACHE_DIR=./mineru_cache
-```
-
----
-
-### 7. `.gitignore`(修改)
-
-新增两行:
-```
-# MinerU 缓存
-mineru_cache/
-.env
-```
-
----
-
-## 兼容性保证
-
-| 关注点 | 措施 |
-|---|---|
-| **向下兼容** | `--engine` 默认 `local`,不传时行为完全不变 |
-| **输出格式** | MinerU 输出 JSON 与 PaddleOCR 结构一致,`type: "mineru"` 区分 |
-| **依赖隔离** | `langchain-mineru` 仅在 `--engine mineru` 时才 import,不用不装 |
-| **容错** | MinerU 网络异常时明确报错,不静默失败;缓存损坏自动清除重建 |
-
----
-
-## CLI 用法示例
-
-```bash
-# 本地 PaddleOCR(默认,与原行为一致)
-python scripts/ocr_extract.py --pdf 配风计划.pdf --out output/ --pages 3,8,9
-
-# MinerU 云端解析
-python scripts/ocr_extract.py --pdf 配风计划.pdf --out output/ --engine mineru
-
-# MinerU + 指定 Token + 快速模式
-python scripts/ocr_extract.py --pdf 配风计划.pdf --out output/ --engine mineru --mineru-token xxx --mineru-mode fast
-```

+ 0 - 32
.zcode/plans/plan-sess_7f96803d-728b-42a3-ba40-6cd08853efbb.md

@@ -1,32 +0,0 @@
-## 拆分 `api/routes.py` 方案
-
-将 917 行的单体文件按职责拆成 9 个独立模块 + 1 个路由组装入口。
-
-### 新建 10 个文件
-
-| 文件 | 职责 | 预计行数 |
-|---|---|---|
-| `api/models.py` | Pydantic 请求/响应模型 (4 个 class) | ~35 |
-| `api/prompts.py` | Fast 模式系统提示词 (2 个常量) | ~60 |
-| `api/chat_model.py` | LLM 模型实例缓存 `_get_chat_model()` | ~75 |
-| `api/sse_core.py` | SSE 核心工具: `_cn_tool_desc`, `_sse_event_generator`, `_extract_text_content`, `_parse_todo_list`, `_clean_response` | ~180 |
-| `api/fast_generators.py` | Fast 模式流式生成器: `_fast_tun_sse_generator`, `_fast_device_sse_generator` | ~165 |
-| `api/intent.py` | 意图分类: `_classify_intent_by_agent()` | ~60 |
-| `api/click_routes.py` | `POST /interpret/click/tun` + `/device` | ~90 |
-| `api/chat_routes.py` | `POST /chat` 统一对话入口 | ~85 |
-| `api/review_routes.py` | `POST /vent/review/pdf` | ~35 |
-| `api/session_routes.py` | `GET /chat/history/`, `GET /sessions`, `DELETE /sessions/` | ~55 |
-
-### 修改 2 个文件
-
-- **`api/__init__.py`**: 重写为路由组装入口 — 创建主 Router,`include_router()` 所有子路由,export `router`
-- **`main.py`** 第 15 行: `from api.routes import router` → `from api import router`
-
-### 删除 1 个文件
-
-- **`api/routes.py`**: 内容已全部迁出
-
-### 不变项
-
-- 所有 API 路由路径、SSE 事件格式、请求/响应模型完全不变
-- 测试文件无需修改(注释中的 `routes.py` 引用后续可优化)

+ 0 - 88
.zcode/plans/plan-sess_8e84e8d8-45a4-441b-9b42-7d6cd00ecaf9.md

@@ -1,88 +0,0 @@
-## Plan: Fix Fast Mode 20-Second Delay in `/api/interpret/click/tun`
-
-### Problem
-After `{"type": "generating", "source": "fast"}`, the user waits ~20 seconds with no SSE events before any `token` starts streaming. Root causes:
-1. `deepseek-v4-pro` has high TTFT (prompt evaluation ~15-30s)
-2. Reasoning tokens (`reasoning_content`) silently discarded — only `chunk.content` checked
-3. No heartbeat/progress events during LLM wait (unlike review pipeline)
-
-### Changes (all in `api/routes.py`)
-
-**File: `api/routes.py`**
-
----
-
-### Change 1 — `_get_chat_model()`: Switch fast mode to flash model + add timeout
-
-Modify the function to:
-- Accept `fast_mode: bool = True` parameter  
-- When `fast_mode=True`, read `SUMMARY_MODEL` env var (`deepseek-v4-flash`) instead of `DEEPAGENT_MODEL` (`deepseek-v4-pro`)
-- Add `timeout=180` and `max_retries=2` to `init_chat_model()` call (matching `vent_agent.py` pattern)
-
-**Rationale**: `deepseek-v4-flash` is designed for low latency; fast mode just fills a fixed template and doesn't need the heavyweight reasoning of the pro model. This alone could reduce TTFT from ~20s to ~2-5s.
-
----
-
-### Change 2 — `_fast_tun_sse_generator()`: Add heartbeat + reasoning_content handling
-
-Replace lines 413-417 (the simple `async for chunk in model.astream(messages)` loop) with:
-
-```python
-import asyncio, time
-
-first_token = False
-heartbeat_start = time.time()
-astream_iter = model.astream(messages)
-
-while True:
-    try:
-        if first_token:
-            chunk = await astream_iter.__anext__()
-        else:
-            # 3-second timeout for first token — send heartbeat if slow
-            chunk = await asyncio.wait_for(astream_iter.__anext__(), timeout=3.0)
-            if not first_token:
-                first_token = True
-    except asyncio.TimeoutError:
-        elapsed = int(time.time() - heartbeat_start)
-        yield f"data: {json.dumps({'type': 'progress', 'message': f'模型正在生成报告(已等待 {elapsed} 秒)...'}, ensure_ascii=False)}\n\n"
-        continue
-    except StopAsyncIteration:
-        break
-
-    # Handle reasoning content (R1-style models)
-    reasoning = chunk.additional_kwargs.get("reasoning_content", "") if hasattr(chunk, "additional_kwargs") else ""
-    if reasoning:
-        yield f"data: {json.dumps({'type': 'reasoning', 'content': reasoning}, ensure_ascii=False)}\n\n"
-        continue  # don't save reasoning to full_response
-
-    content = chunk.content if hasattr(chunk, "content") else ""
-    if content and isinstance(content, str):
-        full_response += content
-        yield f"data: {json.dumps({'type': 'token', 'source': 'fast', 'content': content}, ensure_ascii=False)}\n\n"
-```
-
-The `asyncio` and `time` imports are already available in the module.
-
----
-
-### Change 3 — `_fast_device_sse_generator()`: Same fix
-
-Apply the identical heartbeat + reasoning_content pattern (lines 468-472).
-
----
-
-### Change 4 — Update callers of `_get_chat_model()`
-
-Both `_fast_tun_sse_generator` (line 397) and `_fast_device_sse_generator` call `_get_chat_model()` — update to `_get_chat_model(fast_mode=True)` to use the flash model.
-
----
-
-### Summary
-
-| Fix | Impact | Risk |
-|-----|--------|------|
-| Flash model for fast mode | TTFT: ~20s → ~2-5s | Output quality may differ (fast mode is template-filling, so low risk) |
-| Heartbeat during LLM wait | No more dead silence; progress events every 3s | None (pattern already proven in `review_agent.py`) |
-| Stream reasoning_content | Immediate feedback if model "thinks" | None (new event type `reasoning`; frontend can ignore) |
-| Add timeout to model init | Prevents hung connections | None (matches `vent_agent.py` pattern) |

+ 0 - 25
.zcode/plans/plan-sess_f1b04b22-c7c2-4e62-8a79-c627fdea9b7c.md

@@ -1,25 +0,0 @@
-## 修复 summary_agent 偶发懒输出导致子智能体结果丢失
-
-### 根因
-- `SUMMARY_MODEL=deepseek-v4-flash` 处理超大上下文(3个子智能体全量输出 20k-50k+ tokens)时指令遵循不稳定
-- 架构反模式:要求 LLM "逐字重现"已完成的内容 → 应改为直接嵌入
-
-### 核心改动:`agents/review_agent.py`
-
-**新增 3 个辅助函数:**
-1. `_is_lazy_summary(text)` — 检测懒输出(<300字 / 托词模式)
-2. `_extract_key_findings(text, max_chars=2000)` — 提取子智能体关键摘要
-3. `_assemble_report_fallback(...)` — LLM 失败时的模板兜底
-
-**重写 Stage 4 汇总流程:**
-- 子智能体原始输出 → 直接嵌入报告第2/3/4节(不经过 LLM,100% 不丢失)
-- 仅将关键摘要传给 summary LLM → 生成第1节(概况)、第5节(违规汇总)、第6节(修改建议)
-- 校验 LLM 输出 → 若懒输出则用兜底模板
-- 流式 yield SSE token 事件保持前端体验不变
-
-**附带更新:**
-- `skills/vent-plan-review-summary/SKILL.md` — 更新角色描述,移除"全量输出"铁律
-- `.env` — SUMMARY_MODEL 可保持 flash(新架构下上下文大幅减小)
-
-### 不改动
-- 前端、子智能体 skills、Word 导出、计算工具 — 全部不动

+ 0 - 67
.zcode/plans/plan-sess_fed924c5-4be9-4348-9fe8-1b9720a06ad7.md

@@ -1,67 +0,0 @@
-## 方案:Chat 交互 UI
-
-### 架构
-
-单个自包含 HTML 文件 `static/index.html`,零外部依赖(marked.js 从 CDN 按需加载用于 Markdown 渲染),通过 Fetch + ReadableStream 实现 POST SSE 流式消费。
-
-### 文件变更
-
-| 文件 | 操作 |
-|------|------|
-| `static/index.html` | **新建** — 完整聊天 UI(~600 行) |
-| `main.py` | **新增** `@app.get("/")` 根路由,重定向到 `/static/index.html` |
-
-### 页面布局(Zcode/Codex 风格三栏)
-
-```
-┌──────────────┬──────────────────────────┬─────────────────────┐
-│  会话列表     │      消息区(主)          │   详情面板            │
-│  (Session)   │  ┌─────────────────┐    │  (Detail)           │
-│              │  │ 用户消息         │    │                     │
-│  + 新会话    │  │ 助手回复(流式)   │    │  - 任务进度          │
-│              │  │   ▶ 思考过程     │    │  - 工具调用记录       │
-│  session-1   │  │   工具调用...     │    │  - 会话信息          │
-│  session-2   │  └─────────────────┘    │                     │
-│              │                         │                     │
-│              │  ┌─────────────────┐    │                     │
-│              │  │ 📎 上传文件      │    │                     │
-│              │  │ 输入框 + 发送    │    │                     │
-│              │  └─────────────────┘    │                     │
-└──────────────┴──────────────────────────┴─────────────────────┘
-```
-
-### 核心功能
-
-| 功能 | 实现 |
-|------|------|
-| **流式 SSE 消费** | Fetch + `response.body.getReader()` 逐行解析 `data:` 前缀 |
-| **思考过程折叠** | `thinking_token` / `thinking` 事件渲染为可折叠 `<details>` 块 |
-| **工具调用展示** | `tool_call` / `tool_result` / `executing` 渲染为工具图标 + 状态行 |
-| **任务进度** | `updated_todo_list` / `agent_todos` 渲染为 checkbox 列表 |
-| **Markdown 渲染** | 使用 marked.js(CDN),表格/代码块/列表完整支持 |
-| **文件上传** | `<input type="file">` + FormData 提交 |
-| **会话管理** | `GET /api/sessions` 列表、`DELETE /api/sessions/{id}` 删除、新建自动 |
-| **深色主题** | CSS 变量驱动,类 VS Code 暗色配色 |
-
-### SSE 事件 → UI 映射
-
-| SSE type | UI 行为 |
-|----------|---------|
-| `thinking` | 状态栏显示"正在分析..." |
-| `thinking_token` | 追加到折叠思考块 |
-| `executing` | 状态栏显示工具中文名 + 图标 |
-| `token` | 追加到助手消息(流式打字效果) |
-| `tool_call` | 插入工具调用卡片 |
-| `tool_result` | 更新工具卡片为完成状态 |
-| `updated_todo_list` | 渲染右侧任务进度面板 |
-| `done` | 停止流式,保存到历史 |
-| `error` | 显示错误 Toast |
-| `progress` | 状态栏(review 管线) |
-| `agent_start/done` | 子智能体进度条(review 管线) |
-
-### 技术要点
-
-- **POST SSE**:EventSource 不支持 POST,用 `fetch()` + `ReadableStream` + 手动分割 `\n\n`
-- **中止请求**:AbortController,切换会话时取消进行中的流
-- **自动滚动**:新 token 到达时智能滚动(用户手动上滚则不自动滚)
-- **会话持久化**:session_id 存 localStorage,刷新页面恢复

+ 69 - 2
API_DOCUMENTATION.md

@@ -60,6 +60,9 @@ X-Session-Id: {session_id}
 | `updated_todo_list` | Todo 列表更新 | `source`, `todos`, `message` |
 | `token` | AI 生成的文本内容 | `source`, `content` |
 | `done` | 流式响应结束 | `thread_id`, `session_id`, `message` |
+| `generating` | Fast 模式:LLM 开始生成报告 | `source` |
+| `progress` | Fast 模式:等待心跳进度 | `message` |
+| `reasoning` | Fast 模式:推理模型思考过程(如 DeepSeek-R1) | `content` |
 | `error` | 发生错误 | `message` |
 
 > **`message` 字段**(v1.3+):所有事件均包含中文 `message` 字段,描述当前正在执行的操作,如 "正在分析您的问题..."、"查询巷道监测数据 完成"。前端可据此展示进度提示。
@@ -86,6 +89,7 @@ X-Session-Id: {session_id}
 |----|------|
 | `main` | 主代理(当前对话) |
 | `subagent` | 子代理(被调用的工具代理) |
+| `fast` | Fast 模式直连(跳过 Agent) |
 
 ---
 
@@ -93,8 +97,22 @@ X-Session-Id: {session_id}
 
 **POST** `/api/interpret/click/tun`
 
+### 解读模式
+
+接口支持两种模式,通过 `mode` 参数控制:
+
+| 模式 | 值 | 行为 | 适用场景 |
+|------|------|------|----------|
+| **Plan**(默认) | `"plan"` | 走完整智能体流程:加载技能 → 调用工具 → 逐项比对规程 → 生成结构化报告 | 需要深度分析、规程溯源 |
+| **Fast** | `"fast"` | 跳过智能体和技能,直接查询数据后发给 LLM 按模板生成报告 | 快速预览、延迟敏感场景 |
+
+> **区别**:Plan 模式会调用多个工具逐项核查(知识库检索、规程比对等),输出更严谨完整;Fast 模式仅一次数据查询 + 一次 LLM 调用,响应更快但分析深度较浅。
+>
+> **数据库**:点选解读接口**不写数据库**,不创建会话、不保存消息记录。
+
 ### 请求示例
 
+**Plan 模式(默认):**
 ```json
 {
   "tun_id": "15216",
@@ -102,17 +120,28 @@ X-Session-Id: {session_id}
 }
 ```
 
+**Fast 模式:**
+```json
+{
+  "tun_id": "15216",
+  "tun_name": "15216 辅运起坡段",
+  "mode": "fast"
+}
+```
+
 ### 请求参数
 
 | 参数 | 类型 | 必填 | 描述 |
 |------|------|------|------|
 | `tun_id` | string | 是 | 巷道 ID |
 | `tun_name` | string | 是 | 巷道名称 |
+| `mode` | string | 否 | 解读模式:`"plan"`(智能体全流程,默认)或 `"fast"`(直接生成报告) |
 
 ### 响应示例
 
+**Plan 模式(智能体全流程)**:
 ```
-data: {"type": "thinking", "source": "main", "node": "model_request"}
+data: {"type": "thinking", "source": "main", "node": "model"}
 
 data: {"type": "executing", "source": "main", "tools": ["query_knowledge_base"]}
 
@@ -125,14 +154,41 @@ data: {"type": "token", "source": "main", "content": "根据监测数据,该
 data: {"type": "done", "thread_id": "xxx", "session_id": "xxx"}
 ```
 
+**Fast 模式(直连 LLM)**:
+```
+data: {"type": "thinking", "source": "fast", "node": "fetching_data"}
+
+data: {"type": "executing", "source": "fast", "tools": ["query_tun_data_by_id"]}
+
+data: {"type": "generating", "source": "fast"}
+
+data: {"type": "token", "source": "fast", "content": "**15216 辅运起坡段**\n- 当前风速:"}
+
+data: {"type": "done", "thread_id": "xxx", "session_id": "xxx"}
+```
+
+> Fast 模式下 `source` 为 `"fast"`,不会出现 `tool_call`/`tool_result`/`updated_todo_list` 等 Agent 独有事件;LLM 生成阶段可能出现 `progress`(心跳)和 `reasoning`(推理过程)事件。
+
 ---
 
 ## 点选设备解读接口
 
 **POST** `/api/interpret/click/device`
 
+### 解读模式
+
+与巷道解读接口相同,支持 `mode` 参数:
+
+| 模式 | 值 | 行为 |
+|------|------|------|
+| **Plan**(默认) | `"plan"` | 走完整智能体流程:加载技能 → 调用工具 → 逐项比对 → 生成报告 |
+| **Fast** | `"fast"` | 跳过智能体,直接查数据 + LLM 生成报告 |
+
+> 点选设备解读同样**不写数据库**。
+
 ### 请求示例
 
+**Plan 模式(默认):**
 ```json
 {
   "device_id": "FAN-001",
@@ -141,6 +197,16 @@ data: {"type": "done", "thread_id": "xxx", "session_id": "xxx"}
 }
 ```
 
+**Fast 模式:**
+```json
+{
+  "device_id": "FAN-001",
+  "device_name": "主通风机",
+  "device_type": "fan",
+  "mode": "fast"
+}
+```
+
 ### 请求参数
 
 | 参数 | 类型 | 必填 | 描述 |
@@ -148,6 +214,7 @@ data: {"type": "done", "thread_id": "xxx", "session_id": "xxx"}
 | `device_id` | string | 是 | 设备 ID |
 | `device_name` | string | 是 | 设备名称 |
 | `device_type` | string | 是 | 设备类型 |
+| `mode` | string | 否 | 解读模式:`"plan"`(默认)或 `"fast"` |
 
 ### 响应格式
 
@@ -185,7 +252,7 @@ data: {"type": "done", "thread_id": "xxx", "session_id": "xxx"}
 {
   "type": "thinking",
   "source": "main",
-  "node": "model_request"
+  "node": "model"
 }
 ```
 

+ 41 - 1
agents/needq_agent.py

@@ -30,6 +30,7 @@ from agents.vent_agent import _get_model
 
 # 导入 write_todos
 from langchain.agents.middleware.todo import write_todos
+from langgraph.checkpoint.memory import MemorySaver
 
 # 导入全部计算工具
 from tools.calc_tools import (
@@ -101,6 +102,7 @@ def create_needq_calc_agent():
     - MCP 远程数据查询能力(get_needq_all_data)
     - 多轮对话参数承接能力
     - 结构化计算报告输出能力
+    - 使用运行时模型配置(支持前端动态切换)
 
     Returns:
         CompiledStateGraph: 编译后的 LangGraph 状态图,支持 .invoke() 和 .stream()
@@ -108,6 +110,8 @@ def create_needq_calc_agent():
     from deepagents import create_deep_agent
     from deepagents.backends import FilesystemBackend
     from deepagents import FilesystemPermission
+    from api.model_config import get_model_instance
+    from tools.context_tracker import init_system_components, _tool_to_text
 
     _project_root = str(Path(__file__).parent.parent)
 
@@ -115,7 +119,7 @@ def create_needq_calc_agent():
     print(f"[skills] Agent=needq-calc-agent skills={skills}")
 
     agent = create_deep_agent(
-        model=_get_model(),
+        model=get_model_instance(),
         tools=[
             # 基础工具
             write_todos,
@@ -155,7 +159,36 @@ def create_needq_calc_agent():
         ],
         middleware=[],
         name="needq-calc-agent",
+        checkpointer=MemorySaver(),
     )
+
+    # ── 初始化上下文用量追踪 ──
+    _all_tools = [
+        write_todos, calc_effective_area, calc_total_air_volume,
+        calc_face_by_gas, calc_face_by_workers, calc_face_by_wind_speed, calc_face_air_volume_max,
+        calc_tunnel_by_gas, calc_tunnel_by_explosives, calc_tunnel_by_workers,
+        calc_tunnel_by_wind_speed, calc_tunnel_air_volume_max,
+        calc_chamber_by_equipment, calc_chamber_by_wind_speed,
+        calc_other_by_wind_speed,
+    ]
+    tool_texts = [_tool_to_text(t) for t in _all_tools]
+
+    skill_contents = []
+    for s in skills:
+        sf = Path(__file__).parent.parent / s / "SKILL.md"
+        if sf.exists():
+            skill_contents.append(sf.read_text(encoding="utf-8"))
+
+    # MCP 工具:单独归入 mcp 类别
+    mcp_text = _tool_to_text(get_needq_all_data)
+
+    init_system_components(
+        system_prompt=NEEDQ_CALC_SYSTEM_PROMPT,
+        tool_defs=tool_texts,
+        skill_contents=skill_contents,
+        mcp_defs=[mcp_text],
+    )
+
     return agent
 
 
@@ -166,6 +199,13 @@ def create_needq_calc_agent():
 _needq_calc_agent: Optional[object] = None
 
 
+def invalidate_cache():
+    """失效需风量计算 Agent 缓存(模型切换时调用)。"""
+    global _needq_calc_agent
+    _needq_calc_agent = None
+    print("[模型切换] needq-calc-agent 缓存已失效")
+
+
 def get_needq_calc_agent():
     """获取需风量计算 Agent 单例"""
     global _needq_calc_agent

+ 28 - 7
agents/review_agent.py

@@ -97,6 +97,7 @@ def _cn_tool(name: str) -> str:
     return TOOL_NAME_CN.get(name, name)
 
 from langchain.agents.middleware.todo import write_todos
+from langgraph.checkpoint.memory import MemorySaver
 
 SKILLS_ROOT = str((Path(__file__).parent.parent / "skills").resolve())
 PROJECT_ROOT = str(Path(__file__).parent.parent)
@@ -347,16 +348,18 @@ def create_form_review_agent():
     """创建「基本形式审查」Agent。
 
     审查配风计划的基本形式合规性:版本、签字、编制时间、计算过程、语病逻辑。
+    使用运行时模型配置(支持前端动态切换)。
     """
     from deepagents import create_deep_agent
     from deepagents.backends import FilesystemBackend
     from deepagents import FilesystemPermission
+    from api.model_config import get_model_instance
 
     skills = ["skills/vent-plan-review-form"]
     print(f"[skills] Agent=form-review-agent skills={skills}")
 
     agent = create_deep_agent(
-        model=_get_model(),
+        model=get_model_instance(),
         tools=[write_todos, get_current_time],
         skills=skills,
         system_prompt=FORM_REVIEW_SYSTEM_PROMPT,
@@ -366,6 +369,7 @@ def create_form_review_agent():
         ],
         middleware=[],
         name="form-review-agent",
+        checkpointer=MemorySaver(),
     )
     return agent
 
@@ -374,16 +378,18 @@ def create_data_consistency_agent():
     """创建「数据一致性审查」Agent。
 
     审查配风计划数据的内部和外部一致性。
+    使用运行时模型配置(支持前端动态切换)。
     """
     from deepagents import create_deep_agent
     from deepagents.backends import FilesystemBackend
     from deepagents import FilesystemPermission
+    from api.model_config import get_model_instance
 
     skills = ["skills/vent-plan-review-data"]
     print(f"[skills] Agent=data-consistency-agent skills={skills}")
 
     agent = create_deep_agent(
-        model=_get_model(),
+        model=get_model_instance(),
         tools=[
             write_todos,
             get_current_time,
@@ -401,6 +407,7 @@ def create_data_consistency_agent():
         ],
         middleware=[],
         name="data-consistency-agent",
+        checkpointer=MemorySaver(),
     )
     return agent
 
@@ -409,16 +416,18 @@ def create_calc_verification_agent():
     """创建「计算核验」Agent。
 
     逐地点核验需风量计算过程,使用本地计算工具。
+    使用运行时模型配置(支持前端动态切换)。
     """
     from deepagents import create_deep_agent
     from deepagents.backends import FilesystemBackend
     from deepagents import FilesystemPermission
+    from api.model_config import get_model_instance
 
     skills = ["skills/vent-plan-review-calc"]
     print(f"[skills] Agent=calc-verification-agent skills={skills}")
 
     agent = create_deep_agent(
-        model=_get_model(),
+        model=get_model_instance(),
         tools=[
             write_todos,
             calc_face_by_gas,
@@ -445,6 +454,7 @@ def create_calc_verification_agent():
         ],
         middleware=[],
         name="calc-verification-agent",
+        checkpointer=MemorySaver(),
     )
     return agent
 
@@ -453,18 +463,18 @@ def create_summary_agent():
     """创建「汇总审查」Agent。
 
     汇总3个子智能体的审查结果,生成最终审查报告。
-    使用 SUMMARY_MODEL 独立模型(默认 deepseek-v4-flash),
-    汇总任务主要是格式化重组已有内容,flash 模型速度快得多。
+    使用运行时模型配置(支持前端动态切换)。
     """
     from deepagents import create_deep_agent
     from deepagents.backends import FilesystemBackend
     from deepagents import FilesystemPermission
+    from api.model_config import get_model_instance
 
     skills = ["skills/vent-plan-review-summary"]
     print(f"[skills] Agent=summary-agent skills={skills}")
 
     agent = create_deep_agent(
-        model=_get_model(model_key="SUMMARY_MODEL"),
+        model=get_model_instance(),
         tools=[write_todos],
         skills=skills,
         system_prompt=SUMMARY_SYSTEM_PROMPT,
@@ -474,6 +484,7 @@ def create_summary_agent():
         ],
         middleware=[],
         name="summary-agent",
+        checkpointer=MemorySaver(),
     )
     return agent
 
@@ -510,6 +521,16 @@ def get_summary_agent():
     return _summary_agent
 
 
+def invalidate_cache():
+    """失效所有审查 Agent 缓存(模型切换时调用)。"""
+    global _form_review_agent, _data_consistency_agent, _calc_verify_agent, _summary_agent
+    _form_review_agent = None
+    _data_consistency_agent = None
+    _calc_verify_agent = None
+    _summary_agent = None
+    print("[模型切换] 审查 Agent 缓存已全部失效")
+
+
 # ============================================================
 # SSE 事件辅助函数
 # ============================================================
@@ -745,7 +766,7 @@ async def stream_review(
                     # ── updates 模式:步骤级事件 ──
                     if chunk["type"] == "updates":
                         for node_name in chunk["data"]:
-                            if node_name == "model_request":
+                            if node_name == "model":
                                 await queue.put({
                                     "type": "agent_thinking",
                                     "agent": agent_name,

+ 166 - 7
agents/vent_agent.py

@@ -20,6 +20,7 @@ from typing import Optional
 
 from dotenv import load_dotenv
 from langchain.agents.middleware.todo import write_todos
+from langgraph.checkpoint.memory import MemorySaver
 
 
 SKILLS_ROOT = str((Path(__file__).parent.parent / "skills").resolve())
@@ -37,10 +38,37 @@ from tools.vent_tools import (
     query_tunnels_by_model,
     query_tunnel_list,
     list_ventanaly_monitor_data_days,
+    get_device_kind_dict,
+    get_device_list_by_kind,
+    query_device_realtime_data,
+    get_needq_all_data,
+    save_user_preference,
+    list_user_preferences,
+    delete_user_preference,
+    request_plan_approval,
 )
 from tools.audit_middleware import create_audit_middleware
+from tools.context_tracker import init_system_components
 from langgraph.utils.runnable import RunnableCallable
 
+# 导入需风量计算工具
+from tools.calc_tools import (
+    calc_face_by_gas,
+    calc_face_by_workers,
+    calc_face_by_wind_speed,
+    calc_face_air_volume_max,
+    calc_tunnel_by_gas,
+    calc_tunnel_by_explosives,
+    calc_tunnel_by_workers,
+    calc_tunnel_by_wind_speed,
+    calc_tunnel_air_volume_max,
+    calc_chamber_by_equipment,
+    calc_chamber_by_wind_speed,
+    calc_other_by_wind_speed,
+    calc_effective_area,
+    calc_total_air_volume,
+)
+
 
 # ============================================================
 # 系统提示词(中文)
@@ -60,21 +88,39 @@ CLICK_INTERPRET_SYSTEM_PROMPT = """你是一名煤矿通风安全专家,你拥
 
 """
 
-# 对话解读 Agent 系统提示词
-DIALOG_INTERPRET_SYSTEM_PROMPT = f"""你是一名煤矿通风安全智能助手,自然语言对话回答煤矿通风监测相关问题。
+# 对话解读 Agent 系统提示词(统一版:合并了对话解读 + 需风量计算能力)
+DIALOG_INTERPRET_SYSTEM_PROMPT = f"""你是一名煤矿通风安全智能助手,具备两大核心能力:**通风监测数据解读**和**需风量交互式计算**。根据用户意图自动切换工作模式。
+
+## 能力一:通风监测数据解读
 
-## 工作流程
+### 工作流程
 1. 理解用户意图:想查询什么数据、什么时间范围、哪条巷道
 2. 调用相应工具获取数据:
    - 查巷道实时数据 → query_tun_data_by_id
    - 查设备数据/报警 → query_device_data_by_id
+   - 查设备实时快照 → query_device_realtime_data
    - 查巷道下设备列表 → query_devices_by_tunnel / query_devices_by_tunnel_id
    - 查模型下巷道列表 → query_tunnels_by_model
    - 查设备历史趋势 → list_ventanaly_monitor_data_days
    - 查规程标准 → query_knowledge_base
+   - 查设备类型字典 → get_device_kind_dict
+   - 按设备类型查设备列表 → get_device_list_by_kind
 3. 比对规程标准进行合规判断
 4. 生成自然语言回复
 
+## 能力二:需风量交互式计算
+
+### 支持的用风地点类型
+- 采煤工作面(含备用工作面)、掘进工作面、机电硐室
+- 其他用风地点(主要进回风巷、采区进回风巷、其他通风人行巷道等)
+- 多地点汇总、通防管控平台数据查询
+
+### 计算铁律(红线)
+1. **必须调用工具计算**:所有需风量计算必须通过调用 calc_tools 中的工具函数完成,绝对禁止凭 LLM 知识直接给出计算结果。
+2. **参数缺失要追问**:若用户未提供必要计算参数,明确列出所需参数并引导用户补充,绝不编造参数值。
+3. **列式计算**:展示每个计算过程的公式 → 代入数值 → 计算结果,不能只给结果。
+4. **多轮对话承接**:记住当前会话中的用风地点类型和已有参数,用户补充参数或要求调整时自动衔接。
+
 ## 回复风格
 - 先给出核心结论,再展开详细数据
 - 异常情况:说明程度、可能原因、建议措施
@@ -84,13 +130,21 @@ DIALOG_INTERPRET_SYSTEM_PROMPT = f"""你是一名煤矿通风安全智能助手
 ## 重要规则
 - 只说中文 
 - 所有数据来源于工具调用,绝不编造
-- 规程引用必须真实
+- 规程引用必须真实,引用时附上具体条款来源(《煤矿安全规程》2025版、AQ 1056-2008)
 - 全流程统一使用 write_todos 工具实时更新任务进度。
 - 无法判断时诚实说明原因
 - ANSI转义序列过滤:不能输出此类内容
 - 需要适用模型ID时,记住模型ID为 {os.getenv("DEFAULT_MODEL_ID")}
 - 禁止输出ANSI 转义序列
 - 禁止暴露系统内部工具名、函数名、变量、技能标识、ID 等内部信息。
+- 严格按照技能(skill: dialog-interpret / needq-calc)中定义的流程执行任务
+
+## 能力三:用户偏好记忆
+- 当用户明确要求"记住""保存为习惯""这是我的偏好""以后都用这个"时,调用 save_user_preference 保存到个人记忆库
+- 当用户询问"我的偏好""有哪些习惯""查看我的偏好"时,调用 list_user_preferences 查看
+- 当用户要求"删除某条习惯""忘掉那个偏好"时,先调用 list_user_preferences 确认ID,再调用 delete_user_preference 删除
+- 保存偏好时,content 字段应精炼概括用户的要求(一句话),keywords 字段列出相关关键词
+- 系统已自动将用户偏好注入到每条消息前缀中,请主动参考这些偏好来个性化回复
 """
 
 
@@ -192,6 +246,7 @@ def create_click_interpret_agent():
         ],
         middleware=[],
         name="click-interpret-agent",
+        checkpointer=MemorySaver(),
     )
     return agent
 
@@ -203,6 +258,7 @@ def create_dialog_interpret_agent():
     - 全量数据查询能力(实时+历史+告警)
     - 上下文记忆(通过 LangGraph checkpoint)
     - 智能意图识别和参数抽取
+    - 使用运行时模型配置(支持前端动态切换)
 
     Returns:
         CompiledStateGraph: 编译后的 LangGraph 状态图
@@ -210,15 +266,17 @@ def create_dialog_interpret_agent():
     from deepagents import create_deep_agent  # 延迟导入,避免 PyCharm debug 启动卡死
     from deepagents.backends import FilesystemBackend
     from deepagents import FilesystemPermission
+    from api.model_config import get_model_instance
 
     _project_root = str(Path(__file__).parent.parent)
 
-    skills = ["skills/dialog-interpret"]
+    skills = ["skills/"]  # 父目录模式:SkillsMiddleware 自动扫描 skills/ 下所有子目录(含 SKILL.md 的技能目录)
     print(f"[skills] Agent=dialog-interpret-agent skills={skills}")
 
     agent = create_deep_agent(
-        model=_get_model(),
+        model=get_model_instance(),
         tools=[
+            # 数据查询工具
             query_tun_data_by_id,
             query_device_data_by_id,
             query_devices_by_tunnel,
@@ -227,7 +285,38 @@ def create_dialog_interpret_agent():
             query_tunnels_by_model,
             list_ventanaly_monitor_data_days,
             query_knowledge_base,
-            write_todos
+            get_device_kind_dict,
+            get_device_list_by_kind,
+            query_device_realtime_data,
+            # 基础工具
+            write_todos,
+            # 辅助计算工具
+            calc_effective_area,
+            calc_total_air_volume,
+            # 采煤工作面计算
+            calc_face_by_gas,
+            calc_face_by_workers,
+            calc_face_by_wind_speed,
+            calc_face_air_volume_max,
+            # 掘进工作面计算
+            calc_tunnel_by_gas,
+            calc_tunnel_by_explosives,
+            calc_tunnel_by_workers,
+            calc_tunnel_by_wind_speed,
+            calc_tunnel_air_volume_max,
+            # 硐室计算
+            calc_chamber_by_equipment,
+            calc_chamber_by_wind_speed,
+            # 其他巷道计算
+            calc_other_by_wind_speed,
+            # MCP 远程数据查询
+            get_needq_all_data,
+            # 用户偏好记忆
+            save_user_preference,
+            list_user_preferences,
+            delete_user_preference,
+            # 计划审批(Human-in-the-Loop)
+            request_plan_approval,
         ],
         skills=skills,
         system_prompt=DIALOG_INTERPRET_SYSTEM_PROMPT,
@@ -237,10 +326,70 @@ def create_dialog_interpret_agent():
         ],
         middleware=[],
         name="dialog-interpret-agent",
+        checkpointer=MemorySaver(),
     )
+
+    # ── 初始化上下文用量追踪 ──
+    _init_context_tracker_for_dialog()
+
     return agent
 
 
+def _init_context_tracker_for_dialog():
+    """为对话 agent 初始化系统组件 token 预估(统一版:含对话工具 + 计算工具 + MCP)。"""
+    from tools.context_tracker import init_system_components, _tool_to_text
+
+    _all_tools = [
+        # 数据查询工具
+        query_tun_data_by_id, query_device_data_by_id,
+        query_devices_by_tunnel, query_devices_by_tunnel_id,
+        query_tunnel_list, query_tunnels_by_model,
+        list_ventanaly_monitor_data_days, query_knowledge_base,
+        get_device_kind_dict, get_device_list_by_kind,
+        query_device_realtime_data, write_todos,
+        # 辅助计算
+        calc_effective_area, calc_total_air_volume,
+        # 采煤面
+        calc_face_by_gas, calc_face_by_workers,
+        calc_face_by_wind_speed, calc_face_air_volume_max,
+        # 掘进面
+        calc_tunnel_by_gas, calc_tunnel_by_explosives,
+        calc_tunnel_by_workers, calc_tunnel_by_wind_speed,
+        calc_tunnel_air_volume_max,
+        # 硐室
+        calc_chamber_by_equipment, calc_chamber_by_wind_speed,
+        # 其他
+        calc_other_by_wind_speed,
+        # 用户偏好
+        save_user_preference, list_user_preferences, delete_user_preference,
+        # 计划审批
+        request_plan_approval,
+    ]
+    tool_texts = [_tool_to_text(t) for t in _all_tools]
+
+    # MCP 工具单独归类
+    mcp_text = _tool_to_text(get_needq_all_data)
+
+    # 读取技能文件内容(动态扫描 skills/ 下所有技能,不再硬编码列表)
+    skill_contents = []
+    skills_root = Path(__file__).parent.parent / "skills"
+    if skills_root.is_dir():
+        for skill_dir in sorted(skills_root.iterdir()):
+            if not skill_dir.is_dir() or skill_dir.name.startswith(".") or skill_dir.name == "__pycache__":
+                continue
+            sf = skill_dir / "SKILL.md"
+            if sf.exists():
+                skill_contents.append(sf.read_text(encoding="utf-8"))
+                print(f"[context_tracker] 已加载技能: {skill_dir.name}")
+
+    init_system_components(
+        system_prompt=DIALOG_INTERPRET_SYSTEM_PROMPT,
+        tool_defs=tool_texts,
+        skill_contents=skill_contents,
+        mcp_defs=[mcp_text],
+    )
+
+
 # ============================================================
 # 单例缓存
 # ============================================================
@@ -250,6 +399,16 @@ _click_agent: Optional[object] = None
 _dialog_agent: Optional[object] = None
 
 
+def invalidate_dialog_cache():
+    """失效对话解读 Agent 缓存(模型切换时调用)。
+
+    仅清除 dialog_agent 的缓存,不影响 click_agent(后者继续使用 .env 固定模型)。
+    """
+    global _dialog_agent
+    _dialog_agent = None
+    print("[模型切换] dialog-interpret-agent 缓存已失效")
+
+
 def get_click_agent():
     """获取点选解读 Agent 单例"""
     global _click_agent

+ 4 - 0
api/__init__.py

@@ -15,6 +15,8 @@ from api.click_routes import router as click_router
 from api.chat_routes import router as chat_router
 from api.review_routes import router as review_router
 from api.session_routes import router as session_router
+from api.model_routes import router as model_router
+from api.skill_routes import router as skill_router
 
 # 创建主路由
 router = APIRouter(prefix="/api", tags=["通风数据解读"])
@@ -24,3 +26,5 @@ router.include_router(click_router)
 router.include_router(chat_router)
 router.include_router(review_router)
 router.include_router(session_router)
+router.include_router(model_router)
+router.include_router(skill_router)

+ 70 - 0
api/auth.py

@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+"""
+认证模块 —— 通过请求头 x-access-token 验证用户登录状态
+
+调用 VENT_SYSTEM_BASE_URL + "/sys/user/getUserInfo",
+将客户端请求头中的 x-access-token 转发到通风系统进行身份验证。
+"""
+
+import os
+
+import httpx
+from fastapi import HTTPException, Request
+
+# ── 通风系统认证服务地址 ──
+VENT_SYSTEM_BASE_URL = os.getenv("VENT_SYSTEM_BASE_URL", "").strip()
+
+
+async def get_current_user(request: Request) -> dict:
+    """FastAPI 依赖项:从请求头 x-access-token 验证用户登录状态。
+
+    验证流程:
+    1. 从 HTTP 请求头中提取 x-access-token
+    2. 转发到通风系统 GET /sys/user/getUserInfo
+    3. 若响应 success=false 或无 userInfo 则抛出 401
+
+    Returns:
+        dict: 用户信息(取自 result.userInfo)
+
+    Raises:
+        HTTPException 401: 未登录或令牌无效
+        HTTPException 502: 认证服务不可用
+        HTTPException 504: 认证服务超时
+    """
+    # ── 1. 提取令牌 ──
+    token = request.headers.get("x-access-token")
+    if not token:
+        raise HTTPException(status_code=401, detail="缺少认证令牌")
+
+    # ── 2. 检查后端地址配置 ──
+    if not VENT_SYSTEM_BASE_URL:
+        raise HTTPException(status_code=500, detail="认证服务地址未配置 (VENT_SYSTEM_BASE_URL)")
+
+    url = f"{VENT_SYSTEM_BASE_URL}/sys/user/getUserInfo"
+
+    # ── 3. 转发令牌到通风系统验证 ──
+    try:
+        async with httpx.AsyncClient(timeout=10.0) as client:
+            resp = await client.get(url, headers={"x-access-token": token})
+            data = resp.json()
+    except httpx.ConnectError as e:
+        raise HTTPException(status_code=502, detail=f"认证服务连接失败: {e}")
+    except httpx.TimeoutException:
+        raise HTTPException(status_code=504, detail="认证服务响应超时")
+    except httpx.HTTPStatusError as e:
+        raise HTTPException(status_code=502, detail=f"认证服务返回错误: {e.response.status_code}")
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"认证服务异常: {e}")
+
+    # ── 4. 校验响应 ──
+    if not isinstance(data, dict) or not data.get("success"):
+        raise HTTPException(status_code=401, detail="未登录或令牌已过期")
+
+    user_info = data.get("result", {}).get("userInfo")
+
+    if not user_info:
+        raise HTTPException(status_code=401, detail="未登录或令牌已过期")
+
+    print(f"[认证] {user_info.get('realname') or user_info.get('username')} | {user_info.get('username')} | {user_info.get('orgCode','?')}")
+
+    return user_info

+ 191 - 29
api/chat_routes.py

@@ -3,61 +3,165 @@
 对话式数据解读接口 —— 统一入口,SSE 流式
 
 根据用户意图自动路由到不同智能体:
-- 需风量计算/管控平台查询 → needq_calc_agent
 - 配风计划审查(上传PDF) → 审查管线
-- 数据解读/其他 → dialog_agent(默认
+- 数据解读/需风量计算/其他 → dialog_agent(统一通风对话助手
 """
 
-from fastapi import APIRouter, File, Form, UploadFile
+import asyncio
+
+from fastapi import APIRouter, Depends, File, Form, UploadFile
 from fastapi.responses import StreamingResponse
+from pydantic import BaseModel, Field
 
+from api.auth import get_current_user
 from api.sse_core import sse_event_generator
 from api.intent import classify_intent
+from api.chat_model import get_chat_model
 from agents.vent_agent import get_dialog_agent
-from agents.needq_agent import get_needq_calc_agent
 from agents.review_agent import stream_review
-from db.chat_store import get_messages, create_session, update_session_title
+from db.chat_store import (
+    get_messages, create_session, update_session_title,
+    get_session_mode, set_session_mode, get_user_preferences,
+)
 
 router = APIRouter()
 
+# ── 权限模式 → LangGraph interrupt 配置 ──
+# 基于 DeepAgents / LangGraph 官方 API:
+# - interrupt_before: 在执行指定节点前暂停,等待人工审批
+# - interrupt_after:  在执行指定节点后暂停,让用户审查结果
+MODE_INTERRUPT = {
+    "plan":     {},                                          # 计划模式:由 request_plan_approval 工具触发程序化中断
+    "full":     {},                                          # 完全访问:无中断
+}
+
+
+class ResumeRequest(BaseModel):
+    """中断恢复请求"""
+    session_id: str = Field(..., description="会话 ID")
+    thread_id: str | None = Field(None, description="LangGraph 线程 ID")
+    action: str = Field("approve", description="approve 或 reject")
+
+
+# ── 会话标题生成(LLM)──
+
+async def generate_session_title(message: str) -> str:
+    """使用 LLM 将用户首条消息压缩为会话标题(不超过20字)。
+
+    仅在开启新会话、且 messages 表中仅此一条用户消息时调用。
+    参考 classify_intent 的实现模式,使用 SUMMARY_MODEL(低延迟)。
+
+    Args:
+        message: 用户的首条消息文本
+
+    Returns:
+        压缩后的标题字符串,不超过20字
+    """
+    try:
+        model = get_chat_model(fast_mode=True)
+        prompt = (
+            "你是一个会话标题生成器。根据用户的第一条消息,生成一个简短的会话标题。\n"
+            "要求:\n"
+            "- 标题不超过20个字\n"
+            "- 保留核心含义,去掉语气词和冗余描述\n"
+            "- 只回复标题本身,不要加任何说明、引号或标点\n"
+            "\n"
+            f"用户消息:{message[:200]}\n"
+            "标题:"
+        )
+        loop = asyncio.get_event_loop()
+        result = await loop.run_in_executor(
+            None,
+            lambda: model.invoke([{"role": "user", "content": prompt}])
+        )
+        raw = result.content if hasattr(result, "content") else str(result)
+        title = raw.strip().strip('"').strip("'").strip("《》").strip()
+        if len(title) > 20:
+            title = title[:20]
+        return title if title else message[:20]
+    except Exception as e:
+        print(f"[标题生成] LLM 生成失败,回退到截断: {e}")
+        return message[:20] + ("..." if len(message) > 20 else "")
+
 
+# ── POST /api/chat ──
 @router.post("/chat")
 async def chat(
     message: str = Form(...),
     session_id: str | None = Form(default=None),
     thread_id: str | None = Form(default=None),
+    mode: str | None = Form(default=None),
     file: UploadFile | None = File(default=None),
+    user_info: dict = Depends(get_current_user),
 ):
     """统一对话接口(SSE 流式)—— 支持文本对话和 PDF 配风计划审查。
 
     根据用户意图自动路由到不同智能体:
-    - 需风量计算/管控平台查询 → needq_calc_agent
     - 配风计划审查(上传PDF) → 审查管线(form-reviewer + data-checker + calc-verifier)
-    - 数据解读/其他 → dialog_agent(默认)
+    - 数据解读/需风量计算/其他 → dialog_agent(统一通风对话助手)
+
+    请求头:
+    - x-access-token: 必填,通风系统登录令牌
 
     请求体: multipart/form-data
     - message: 用户消息(必填)
     - session_id: 可选,会话ID
     - thread_id: 可选,LangGraph 线程ID
+    - mode: 可选,权限模式(plan | full),仅在新建会话时生效
     - file: 可选,附件(PDF/文档等)
 
     响应: SSE 流(text/event-stream)
     """
-    # 会话管理
-    session_id = session_id or create_session()
+    # ── 会话管理 ──
+    is_new = not session_id
+    user_name = user_info.get("username", "admin")
+    session_id = session_id or create_session(user_name=user_name)
     thread_id = thread_id or session_id
 
+    # 设置当前用户上下文(供偏好工具等获取调用者身份)
+    from tools.vent_tools import _current_user
+    _current_user.set(user_name)
+
+    # 保存用户原始消息(后续注入偏好/计划前缀前),用于写入数据库
+    original_message = message
+
+    # 新建会话时,若前端传了 mode 参数则应用(否则沿用 DB 默认值)
+    if is_new and mode in ("plan", "full"):
+        set_session_mode(session_id, mode)
+
+    # 读取会话权限模式
+    # ⚠️ interrupt_before / interrupt_after 是 astream() 的独立 kwargs,
+    #    不能放在 config dict 里,否则 LangGraph 不识别
+    current_mode = get_session_mode(session_id)
+    interrupt_kwargs = MODE_INTERRUPT.get(current_mode, {})
     config = {"configurable": {"thread_id": thread_id}}
+    print(f"[模式] 会话 {session_id} 权限模式: {current_mode} → interrupt: {interrupt_kwargs}")
 
     # 提取附件文件名(如有)
     filename = file.filename if file else None
 
-    # 自动设置会话标题(取前30字)
+    # 自动设置会话标题(仅首轮对话触发,使用 LLM 压缩,不超过20字)
     messages = get_messages(session_id)
     if len(messages) <= 1:
-        title = message[:30] + ("..." if len(message) > 30 else "")
+        title = await generate_session_title(message)
         update_session_title(session_id, title)
 
+    # ── 注入用户偏好记忆(自动触发)──
+    prefs = get_user_preferences(user_name)
+    if prefs:
+        pref_lines = "\n".join(
+            f"- (id:{p['id']}) [{p['category']}] {p['content']}"
+            for p in prefs
+        )
+        message = (
+            f"[用户偏好记忆]\n"
+            f"以下是用户「{user_name}」保存的习惯和偏好,请在回复时主动参考:\n"
+            f"{pref_lines}\n"
+            f"\n"
+            f"用户消息:{message}"
+        )
+        print(f"[偏好] 已为用户 {user_name} 注入 {len(prefs)} 条偏好记忆")
+
     # ── Agent 推理意图(含附件文件名辅助判断)──
     intent = await classify_intent(message, filename)
 
@@ -76,31 +180,89 @@ async def chat(
             },
         )
 
-    elif intent == "review" and file is None:
-        # 审查意图但无文件 → dialog 提示用户上传
+    # ── 统一走通风对话助手(处理数据解读、需风量计算、审查无文件等所有场景)──
+    agent = get_dialog_agent()
+    agent_cn_name = "通风对话助手"
+
+    if intent == "review" and file is None:
+        # 审查意图但无文件 → 提示用户上传
         print(f"[路由] 意图: 配风计划审查 → 提示用户上传 PDF")
-        agent = get_dialog_agent()
-        agent_cn_name = "通风对话助手"
         message = f"用户想进行配风计划审查,但未上传附件。请提示用户上传配风计划PDF文件。用户原始消息:{message}"
-
-    elif intent == "needq_calc":
-        agent = get_needq_calc_agent()
-        agent_cn_name = "需风量计算专家"
-        print(f"[路由] 意图: 需风量计算 → needq_calc_agent")
-        # 如有附件但非审查意图,将文件名信息追加到消息中
-        if filename:
-            message = f"用户上传了文件「{filename}」。{message}"
-
     else:
-        agent = get_dialog_agent()
-        agent_cn_name = "通风对话助手"
-        print(f"[路由] 意图: 数据解读 → dialog_agent")
-        # 如有附件但非审查意图,将文件名信息追加到消息中
+        print(f"[路由] 意图: {intent} → dialog_agent(统一)")
+        # 如有附件,将文件名信息追加到消息中
         if filename:
             message = f"用户上传了文件「{filename}」。{message}"
 
+    # ── 计划模式:注入"先规划 → 审批 → 执行"指令 ──
+    if current_mode == "plan" and agent_cn_name == "通风对话助手":
+        plan_prefix = (
+            "【系统指令:当前处于计划模式】\n"
+            "你需要分三步工作:\n"
+            "1. 规划阶段:自由使用工具获取信息、读取技能、查询数据,制定详细执行计划。\n"
+            "2. 提交审批:计划制定完毕后,必须调用 request_plan_approval 工具提交计划摘要,等待人工审批。\n"
+            "   在此之前严禁执行任何最终操作(如生成报告、写入文件、修改数据等)。\n"
+            "3. 执行阶段:审批通过后,按照计划逐步执行任务,无需再次申请审批。\n"
+            "\n用户问题:"
+        )
+        message = plan_prefix + message
+        print(f"[计划模式] 已注入计划指令前缀 ({len(plan_prefix)} 字符)")
+
+    return StreamingResponse(
+        sse_event_generator(agent, message, thread_id, session_id, config, agent_cn_name,
+                            original_user_message=original_message,
+                            interrupt_before=interrupt_kwargs.get("interrupt_before"),
+                            interrupt_after=interrupt_kwargs.get("interrupt_after")),
+        media_type="text/event-stream",
+        headers={
+            "Cache-Control": "no-cache",
+            "Connection": "keep-alive",
+            "X-Accel-Buffering": "no",
+            "X-Session-Id": session_id,
+        },
+    )
+
+
+# ── POST /api/chat/resume ──
+
+@router.post("/chat/resume")
+async def resume_chat(
+    req: ResumeRequest,
+    user_info: dict = Depends(get_current_user),
+):
+    """恢复被中断的 LangGraph 对话(Human-in-the-Loop 审批)。
+
+    当计划模式(plan)时,Agent 调用 request_plan_approval 工具后暂停,
+    前端展示审批 UI。用户点击批准/拒绝后调用此接口继续执行。
+
+    请求头:
+    - x-access-token: 必填,通风系统登录令牌
+    """
+    from api.sse_core import resume_stream
+
+    session_id = req.session_id
+    thread_id = req.thread_id or session_id
+
+    # plan 模式审批后清除所有配置式中断(只审批一次,之后执行到底)
+    # ⚠️ 必须 copy,否则 pop 会原地修改模块级常量 MODE_INTERRUPT
+    mode = get_session_mode(session_id)
+    interrupt_kwargs = dict(MODE_INTERRUPT.get(mode, {}))
+    if mode == "plan":
+        interrupt_kwargs.pop("interrupt_before", None)
+        interrupt_kwargs.pop("interrupt_after", None)
+    config = {"configurable": {"thread_id": thread_id}}
+
     return StreamingResponse(
-        sse_event_generator(agent, message, thread_id, session_id, config, agent_cn_name),
+        resume_stream(
+            get_dialog_agent(),
+            session_id,
+            thread_id,
+            config,
+            agent_cn_name="通风对话助手",
+            action=req.action,
+            interrupt_before=interrupt_kwargs.get("interrupt_before"),
+            interrupt_after=interrupt_kwargs.get("interrupt_after"),
+        ),
         media_type="text/event-stream",
         headers={
             "Cache-Control": "no-cache",

+ 2 - 2
api/fast_generators.py

@@ -51,7 +51,7 @@ async def fast_tun_sse_generator(
         # 4. 构建消息并调用 LLM 流式生成报告
         messages = [
             {"role": "system", "content": FAST_TUN_SYSTEM_PROMPT},
-            {"role": "user", "content": f"请根据以下巷道监测数据生成标准化解读报告:\n\n巷道名称:{tun_name}\n\n数据:\n{data}"},
+            {"role": "user", "content": f"请根据以下巷道监测数据生成标准化解读报告,禁止给出任何建议:\n\n巷道名称:{tun_name}\n\n数据:\n{data}"},
         ]
 
         # 5. 通知前端 LLM 正在生成(消除"卡住"的感知)
@@ -139,7 +139,7 @@ async def fast_device_sse_generator(
         # 4. 构建消息并调用 LLM 流式生成报告
         messages = [
             {"role": "system", "content": FAST_DEVICE_SYSTEM_PROMPT},
-            {"role": "user", "content": f"请根据以下设备监测数据生成标准化解读报告:\n\n设备名称:{device_name}\n设备类型:{device_type}\n\n数据:\n{data}"},
+            {"role": "user", "content": f"请根据以下设备监测数据生成标准化解读报告,禁止给出任何建议:\n\n设备名称:{device_name}\n设备类型:{device_type}\n\n数据:\n{data}"},
         ]
 
         # 5. 通知前端 LLM 正在生成(消除"卡住"的感知)

+ 4 - 5
api/intent.py

@@ -13,17 +13,16 @@ from api.chat_model import get_chat_model
 async def classify_intent(message: str, filename: str | None = None) -> str:
     """使用 LLM Agent 推理用户意图,替代关键词匹配。
 
-    用轻量级模型调用做意图分类,支持三种路由:
-    - "needq_calc" → 需风量计算 Agent
+    用轻量级模型调用做意图分类,支持两种路由:
     - "review"     → 配风计划审查管线
-    - "dialog"     → 对话解读 Agent(默认
+    - "dialog"     → 通风对话助手(统一处理数据解读、需风量计算等
 
     Args:
         message: 用户消息文本
         filename: 可选,上传的附件文件名(用于辅助意图判断)
 
     Returns:
-        "needq_calc" / "review" / "dialog"
+        "review" / "dialog"
     """
     try:
         model = get_chat_model()
@@ -43,7 +42,7 @@ async def classify_intent(message: str, filename: str | None = None) -> str:
         raw = result.content if hasattr(result, "content") else str(result)
         raw = raw.strip().lower()
         # 提取第一个有效词
-        match = re.search(r'(needq_calc|review|dialog)', raw)
+        match = re.search(r'(review|dialog)', raw)
         if match:
             return match.group(1)
     except Exception as e:

+ 233 - 0
api/model_config.py

@@ -0,0 +1,233 @@
+# -*- coding: utf-8 -*-
+"""
+运行时模型配置管理器 —— 支持 API 动态切换模型(仅影响 chat_routes 下的智能体)
+
+click_routes 下的智能体和 fast 模式不受影响,继续从 .env 读取固定模型。
+
+设计:
+- 模块级变量存储当前模型名,从 .env 初始化
+- 切换时仅更新内存中的配置,不写回 .env(重启恢复默认)
+- 线程安全:使用 threading.Lock 保护读写
+"""
+
+import threading
+from pathlib import Path as _Path
+
+# 可用模型列表
+AVAILABLE_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"]
+
+# ── 思考级别配置 ──
+THINKING_LEVELS = ["off", "high", "highest"]
+
+# 各思考级别对应的 extra_body(透传至 DeepSeek API)
+# 参考:https://api-docs.deepseek.com/guides/thinking_mode
+# - thinking: {"type": "disabled"} → 关闭思考链
+# - thinking: {"type": "enabled"} → 开启思考链(deepseek-chat V3 系列)
+# - reasoning_effort: "low"/"high"/"max" → 思考深度(deepseek-reasoner R1 系列)
+# 注:V3 系列同时支持 thinking.type 和 reasoning_effort(新版 API)
+THINKING_KWARGS_MAP = {
+    "off":      {"thinking": {"type": "disabled"}},
+    "high":     {"thinking": {"type": "enabled"}},
+    "highest":  {"thinking": {"type": "enabled"}, "reasoning_effort": "max"},
+}
+
+# 从 .env 读取初始模型名
+_env_file = _Path(__file__).parent.parent / ".env"
+try:
+    from dotenv import dotenv_values
+    _cfg = dotenv_values(str(_env_file))
+    _initial_model = _cfg.get("DEEPAGENT_MODEL", "deepseek-v4-pro").strip().strip('"').strip("'")
+    if _initial_model not in AVAILABLE_MODELS:
+        _initial_model = "deepseek-v4-pro"
+except Exception:
+    _initial_model = "deepseek-v4-pro"
+
+_current_model: str = _initial_model
+_thinking_level: str = "off"
+_lock = threading.Lock()
+
+# ── 模型实例缓存(切换后自动失效)──
+_cached_model_instance = None
+_cached_model_name: str | None = None
+_cached_thinking_level: str | None = None
+
+
+def get_current_model() -> str:
+    """获取当前运行时模型名(线程安全)。"""
+    with _lock:
+        return _current_model
+
+
+def set_current_model(model_name: str) -> str:
+    """设置当前运行时模型名(线程安全)。
+
+    校验模型名在可用列表中,更新配置并清除模型实例缓存。
+
+    Args:
+        model_name: 模型名称,如 "deepseek-v4-pro" 或 "deepseek-v4-flash"
+
+    Returns:
+        设置后的模型名
+
+    Raises:
+        ValueError: 模型名不在可用列表中
+    """
+    global _current_model, _cached_model_instance, _cached_model_name
+
+    model_name = model_name.strip().strip('"').strip("'")
+
+    if model_name not in AVAILABLE_MODELS:
+        raise ValueError(
+            f"不支持的模型: {model_name},可用模型: {', '.join(AVAILABLE_MODELS)}"
+        )
+
+    with _lock:
+        _current_model = model_name
+        # 清除模型实例缓存,下次调用 get_model_instance() 时重新创建
+        _cached_model_instance = None
+        _cached_model_name = None
+
+    return model_name
+
+
+def get_available_models() -> list[str]:
+    """返回可用模型列表。"""
+    return list(AVAILABLE_MODELS)
+
+
+def get_thinking_level() -> str:
+    """获取当前思考级别(线程安全)。"""
+    with _lock:
+        return _thinking_level
+
+
+def set_thinking_level(level: str) -> str:
+    """设置思考级别(线程安全)。
+
+    Args:
+        level: "off" / "high" / "highest"
+
+    Returns:
+        设置后的级别名
+
+    Raises:
+        ValueError: 级别不在可用列表中
+    """
+    global _thinking_level
+
+    level = level.strip().lower()
+    if level not in THINKING_LEVELS:
+        raise ValueError(
+            f"不支持的思考级别: {level},可用级别: {', '.join(THINKING_LEVELS)}"
+        )
+
+    # 清除模型实例缓存(因为 model_kwargs 变了)
+    invalidate_model_cache()
+
+    with _lock:
+        _thinking_level = level
+
+    return level
+
+
+def get_available_thinking_levels() -> list[str]:
+    """返回可用思考级别列表。"""
+    return list(THINKING_LEVELS)
+
+
+def invalidate_model_cache():
+    """清除模型实例缓存(由各 Agent 模块切换时调用)。"""
+    global _cached_model_instance, _cached_model_name, _cached_thinking_level
+    with _lock:
+        _cached_model_instance = None
+        _cached_model_name = None
+        _cached_thinking_level = None
+
+
+def get_model_instance():
+    """获取当前运行时模型的 LangChain ChatModel 实例。
+
+    复用 _get_model() 的 Base URL / API Key / 超时逻辑,
+    但模型名来自运行时配置而非 .env。
+    同时根据当前思考级别传入对应的 model_kwargs。
+
+    返回的实例会被缓存,直到模型或思考级别切换时自动失效。
+    """
+    global _cached_model_instance, _cached_model_name, _cached_thinking_level
+
+    current = get_current_model()
+    think_level = get_thinking_level()
+
+    # 缓存命中:模型未变 + 思考级别未变 + 实例已创建
+    if (_cached_model_instance is not None
+            and _cached_model_name == current
+            and _cached_thinking_level == think_level):
+        print(f"[模型] 缓存命中: {current} | 思考级别: {think_level}")
+        return _cached_model_instance
+
+    # 读取 .env 中的 API 配置(Base URL / Key / Timeout 固定不变)
+    from dotenv import dotenv_values
+    from langchain.chat_models import init_chat_model
+
+    _cfg = dotenv_values(str(_env_file))
+
+    base_url = _cfg.get("OPENAI_BASE_URL", "")
+    api_key = _cfg.get("OPENAI_API_KEY", "")
+    timeout_str = _cfg.get("OPENAI_TIMEOUT", "180")
+
+    # 清理可能带入的引号
+    api_key = api_key.strip().strip('"').strip("'") if api_key else ""
+    base_url = base_url.strip().strip('"').strip("'") if base_url else ""
+
+    # 解析超时时间
+    try:
+        timeout = float(timeout_str.strip())
+    except (ValueError, TypeError):
+        timeout = 180.0
+
+    # 确保模型字符串带有 provider 前缀
+    model_str = current
+    if ":" not in model_str:
+        model_str = f"openai:{model_str}"
+
+    # 构建思考级别对应的参数
+    # ChatOpenAI 有独立的 reasoning_effort 字段(直接透传至 API 顶层)
+    # thinking 等 DeepSeek 特有参数必须通过 extra_body 传输
+    thinking_cfg = dict(THINKING_KWARGS_MAP.get(think_level, {}))
+    
+    # 分离:reasoning_effort → 直接传参,其余 → extra_body
+    direct_kwargs = {}
+    extra_body_params = {}
+    if "reasoning_effort" in thinking_cfg:
+        direct_kwargs["reasoning_effort"] = thinking_cfg.pop("reasoning_effort")
+    extra_body_params = thinking_cfg  # 剩余的(如 thinking)
+    
+    if direct_kwargs or extra_body_params:
+        print(f"[模型] 创建实例: {model_str} | 思考级别: {think_level} | direct: {direct_kwargs} | extra_body: {extra_body_params}")
+
+    if base_url and api_key:
+        instance = init_chat_model(
+            model_str,
+            openai_api_key=api_key,
+            openai_api_base=base_url,
+            temperature=0,
+            timeout=timeout,
+            **direct_kwargs,
+            model_kwargs={"extra_body": extra_body_params} if extra_body_params else {},
+        )
+    else:
+        instance = init_chat_model(
+            model_str,
+            temperature=0,
+            timeout=timeout,
+            **direct_kwargs,
+            model_kwargs={"extra_body": extra_body_params} if extra_body_params else {},
+        )
+
+    # 缓存实例
+    with _lock:
+        _cached_model_instance = instance
+        _cached_model_name = current
+        _cached_thinking_level = think_level
+
+    return instance

+ 139 - 0
api/model_routes.py

@@ -0,0 +1,139 @@
+# -*- coding: utf-8 -*-
+"""
+模型管理 API —— 支持前端动态切换 LLM 模型版本
+
+端点:
+- GET  /api/model        获取当前模型和可用模型列表
+- POST /api/model/switch 切换运行时模型
+"""
+
+from fastapi import APIRouter
+from pydantic import BaseModel, Field
+
+from api.model_config import (
+    get_current_model,
+    set_current_model,
+    get_available_models,
+    get_thinking_level,
+    set_thinking_level,
+    get_available_thinking_levels,
+    invalidate_model_cache,
+)
+
+router = APIRouter(prefix="/model", tags=["模型管理"])
+
+
+# ── 请求模型 ──
+
+class ModelSwitchRequest(BaseModel):
+    """模型切换请求"""
+    model: str = Field(..., description="模型名称", examples=["deepseek-v4-pro", "deepseek-v4-flash"])
+
+
+class ThinkingSwitchRequest(BaseModel):
+    """思考级别切换请求"""
+    level: str = Field(..., description="思考级别", examples=["off", "high", "highest"])
+
+
+# ── GET /api/model ──
+
+@router.get("")
+async def get_model_info():
+    """获取当前模型、思考级别和可用列表。"""
+    return {
+        "current": get_current_model(),
+        "available": get_available_models(),
+        "thinking_level": get_thinking_level(),
+        "thinking_levels": get_available_thinking_levels(),
+    }
+
+
+# ── POST /api/model/switch ──
+
+@router.post("/switch")
+async def switch_model(req: ModelSwitchRequest):
+    """切换运行时模型。
+
+    切换后会自动失效所有 chat_routes 下智能体的 Agent 缓存,
+    下次请求时将使用新模型重新创建 Agent。
+
+    注意:仅影响对话解读 / 配风计划审查智能体,
+    click_routes 下的点选解读智能体不受影响。
+    """
+    model_name = req.model.strip()
+
+    if model_name not in get_available_models():
+        return {
+            "error": f"不支持的模型: {model_name}",
+            "available": get_available_models(),
+        }
+
+    # 1. 更新运行时模型配置
+    set_current_model(model_name)
+
+    # 2. 失效模型实例缓存
+    invalidate_model_cache()
+
+    # 3. 失效所有受影响的 Agent 单例缓存
+    try:
+        from agents.vent_agent import invalidate_dialog_cache
+        invalidate_dialog_cache()
+    except Exception as e:
+        print(f"[模型切换] 失效 dialog_agent 缓存失败: {e}")
+
+    try:
+        from agents.review_agent import invalidate_cache
+        invalidate_cache()
+    except Exception as e:
+        print(f"[模型切换] 失效 review_agent 缓存失败: {e}")
+
+    print(f"[模型切换] 已切换到 {model_name},所有受影响的 Agent 缓存已失效")
+
+    return {
+        "current": get_current_model(),
+        "message": f"已切换到 {model_name},下次请求生效",
+    }
+
+
+# ── POST /api/model/thinking ──
+
+@router.post("/thinking")
+async def switch_thinking(req: ThinkingSwitchRequest):
+    """切换思考级别。
+
+    切换后会自动失效所有 chat_routes 下智能体的 Agent 缓存,
+    下次请求时将使用新思考级别重新创建模型实例。
+    """
+    level = req.level.strip().lower()
+
+    if level not in get_available_thinking_levels():
+        return {
+            "error": f"不支持的思考级别: {level}",
+            "available": get_available_thinking_levels(),
+        }
+
+    # 1. 更新思考级别(内部已清除模型实例缓存)
+    try:
+        set_thinking_level(level)
+    except ValueError as e:
+        return {"error": str(e), "available": get_available_thinking_levels()}
+
+    # 2. 失效所有受影响的 Agent 单例缓存
+    try:
+        from agents.vent_agent import invalidate_dialog_cache
+        invalidate_dialog_cache()
+    except Exception as e:
+        print(f"[思考切换] 失效 dialog_agent 缓存失败: {e}")
+
+    try:
+        from agents.review_agent import invalidate_cache
+        invalidate_cache()
+    except Exception as e:
+        print(f"[思考切换] 失效 review_agent 缓存失败: {e}")
+
+    print(f"[思考切换] 已切换到 {level},所有受影响的 Agent 缓存已失效")
+
+    return {
+        "thinking_level": get_thinking_level(),
+        "message": f"思考级别已切换为「{level}」,下次请求生效",
+    }

+ 3 - 4
api/prompts.py

@@ -25,7 +25,7 @@ FAST_TUN_SYSTEM_PROMPT = """你是一名煤矿通风安全专家。请根据提
 - 风量超配:实测≥1.8倍计划→重大;1.5倍≤实测<1.8倍计划→高;计划<实测<1.5倍计划→中
 - 风量不足:实测<0.75倍计划→重大;0.75倍≤实测<0.9倍计划→高;0.9倍≤实测<计划→中
 
-## 巷道类型编码对照
+## 巷道类型(nUsingType)编码对照
 - 0-回采工作面;1-掘进工作面;2-辅运巷;3-主运巷;4-硐室;5-联络巷;6-进风井;7-回风井;8-专用回风巷
 
 ## 规则
@@ -67,10 +67,9 @@ FAST_DEVICE_SYSTEM_PROMPT = """你是一名煤矿通风安全设备专家。请
 _INTENT_CLASSIFY_PROMPT = """你是一个意图分类器。根据用户消息和可选的附件信息判断意图类型,只回复一个词。
 
 判断规则:
-- 如果用户想进行**需风量/配风量计算**(如计算采煤面需风量、掘进面需风量、硐室需风量、总需风量),或**查询需风量数据**,回复 needq_calc
 - 如果用户想进行**配风计划审查**(消息中含"审查""配风计划""研判""审核"等词),或上传了文件名含"配风计划""风量""通风"的PDF/文档,回复 review
-- 其他情况(近..天历史数据查询/分析、通风数据解读、设备查询、规程咨询、知识库检索、闲聊等),回复 dialog
+- 其他情况(需风量计算、近..天历史数据查询/分析、通风数据解读、设备查询、规程咨询、知识库检索、闲聊等),回复 dialog
 
 用户消息:{message}
 {attachment_info}
-只回复一个词(needq_calc / review / dialog):"""
+只回复一个词(review / dialog):"""

+ 183 - 8
api/session_routes.py

@@ -1,17 +1,38 @@
 # -*- coding: utf-8 -*-
 """
-会话管理接口 —— 聊天历史查询、会话列表、删除会话
+会话管理接口 —— 聊天历史查询、会话列表、删除会话、权限模式管理
 """
 
-from fastapi import APIRouter
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, Field
 
-from db.chat_store import get_messages, get_sessions, delete_session
+from api.auth import get_current_user
+from db.chat_store import (
+    get_messages, get_sessions, delete_session,
+    get_session_mode, set_session_mode, get_latest_context_usage,
+    pin_session, is_session_pinned, update_session_title,
+)
 
 router = APIRouter()
 
 
+class ModeRequest(BaseModel):
+    """权限模式切换请求"""
+    mode: str = Field(..., description="权限模式", examples=["plan", "full"])
+
+
+class TitleRequest(BaseModel):
+    """会话标题编辑请求"""
+    title: str = Field(..., description="新标题", max_length=100, examples=["15216工作面需风量计算"])
+
+
 @router.get("/chat/history/{session_id}")
-async def get_chat_history(session_id: str, limit: int = 100, offset: int = 0):
+async def get_chat_history(
+    session_id: str,
+    limit: int = 100,
+    offset: int = 0,
+    user_info: dict = Depends(get_current_user),
+):
     """查询指定会话的聊天历史
 
     Args:
@@ -36,18 +57,172 @@ async def get_chat_history(session_id: str, limit: int = 100, offset: int = 0):
 
 
 @router.get("/sessions")
-async def list_sessions(limit: int = 20, offset: int = 0):
-    """查询会话列表
+async def list_sessions(
+    limit: int = 20,
+    offset: int = 0,
+    user_info: dict = Depends(get_current_user),
+):
+    """查询当前用户的会话列表(按更新时间倒序)
 
     Returns:
         [{ "session_id": "...", "title": "...", "message_count": N, ... }, ...]
     """
-    sessions = get_sessions(limit=limit, offset=offset)
+    user_name = user_info.get("username", "admin")
+    sessions = get_sessions(limit=limit, offset=offset, user_name=user_name)
     return {"sessions": sessions, "total": len(sessions)}
 
 
 @router.delete("/sessions/{session_id}")
-async def remove_session(session_id: str):
+async def remove_session(
+    session_id: str,
+    user_info: dict = Depends(get_current_user),
+):
     """删除指定会话及其所有消息"""
     delete_session(session_id)
     return {"status": "ok", "message": f"会话 {session_id} 已删除"}
+
+
+# ── 权限模式管理 ──
+
+@router.get("/sessions/{session_id}/mode")
+async def get_mode(
+    session_id: str,
+    user_info: dict = Depends(get_current_user),
+):
+    """获取会话的权限模式"""
+    return {
+        "session_id": session_id,
+        "mode": get_session_mode(session_id),
+    }
+
+
+@router.put("/sessions/{session_id}/mode")
+async def set_mode(
+    session_id: str,
+    req: ModeRequest,
+    user_info: dict = Depends(get_current_user),
+):
+    """设置会话的权限模式
+
+    mode 取值:
+    - plan:    计划模式(先生成执行计划,用户确认后再执行)
+    - full:    完全访问(无限制自动执行)
+    """
+    valid = {"plan", "full"}
+    if req.mode not in valid:
+        return {
+            "error": f"无效的模式: {req.mode}",
+            "valid_modes": sorted(valid),
+        }
+
+    set_session_mode(session_id, req.mode)
+    return {
+        "session_id": session_id,
+        "mode": req.mode,
+        "message": f"已切换为「{req.mode}」模式",
+    }
+
+
+# ── 会话置顶 ──
+
+@router.put("/sessions/{session_id}/pin")
+async def toggle_pin(
+    session_id: str,
+    user_info: dict = Depends(get_current_user),
+):
+    """切换会话置顶状态(置顶 ⇄ 取消置顶)。
+
+    Returns:
+        { "session_id": "...", "pinned": true, "message": "..." }
+    """
+    pinned = not is_session_pinned(session_id)
+    pin_session(session_id, pinned)
+    return {
+        "session_id": session_id,
+        "pinned": pinned,
+        "message": "已置顶" if pinned else "已取消置顶",
+    }
+
+
+# ── 会话标题编辑 ──
+
+@router.put("/sessions/{session_id}/title")
+async def edit_title(
+    session_id: str,
+    req: TitleRequest,
+    user_info: dict = Depends(get_current_user),
+):
+    """编辑会话标题。
+
+    Args:
+        req.title: 新标题,最长100字
+    """
+    title = req.title.strip()
+    if not title:
+        return {"error": "标题不能为空"}
+    if len(title) > 100:
+        return {"error": f"标题过长({len(title)}字),最多100字"}
+
+    update_session_title(session_id, title)
+    return {
+        "session_id": session_id,
+        "title": title,
+        "message": "标题已更新",
+    }
+
+
+# ── 上下文容量查询 ──
+
+@router.get("/sessions/{session_id}/context")
+async def get_context_usage(
+    session_id: str,
+    user_info: dict = Depends(get_current_user),
+):
+    """查询会话最近一次的上下文用量详情。
+
+    Returns:
+        {
+            "session_id": "...",
+            "total_limit": 131072,
+            "current_usage": 12345,
+            "usage_pct": 9.4,
+            "breakdown": {
+                "messages": 5000,
+                "mcp": 4000,
+                "skills": 800,
+                "system_prompt": 600,
+                "other": 2745
+            },
+            "updated_at": "2026-08-05 14:30:00"
+        }
+        若无记录则返回 null 值。
+    """
+    row = get_latest_context_usage(session_id)
+    if not row:
+        return {
+            "session_id": session_id,
+            "total_limit": None,
+            "current_usage": None,
+            "usage_pct": None,
+            "breakdown": None,
+            "updated_at": None,
+        }
+
+    total_limit = row.get("total_limit", 131072)
+    current = row.get("current_usage", 0)
+    pct = round(current / total_limit * 100, 1) if total_limit else 0
+
+    return {
+        "session_id": session_id,
+        "total_limit": total_limit,
+        "current_usage": current,
+        "usage_pct": pct,
+        "breakdown": {
+            "messages": row.get("messages_tokens", 0),
+            "mcp": row.get("mcp_tokens", 0),
+            "skills": row.get("skills_tokens", 0),
+            "system_prompt": row.get("system_prompt_tokens", 0),
+            "other": row.get("other_tokens", 0),
+        },
+        "updated_at": row.get("updated_at"),
+    }

+ 286 - 0
api/skill_routes.py

@@ -0,0 +1,286 @@
+# -*- coding: utf-8 -*-
+"""
+技能管理 API —— 查看/启用/禁用/上传技能
+
+端点:
+- GET  /api/skills              技能列表
+- GET  /api/skills/{name}       查看技能内容(SKILL.md)
+- POST /api/skills/{name}/toggle 启用/禁用
+- POST /api/skills/upload        上传新技能(.zip)
+"""
+
+import json
+import os
+import re
+import shutil
+import zipfile
+from pathlib import Path
+from typing import Optional
+
+from fastapi import APIRouter, File, UploadFile
+from fastapi.responses import JSONResponse
+
+router = APIRouter(prefix="/skills", tags=["技能管理"])
+
+# 项目根目录
+PROJECT_ROOT = str(Path(__file__).parent.parent)
+SKILLS_DIR = os.path.join(PROJECT_ROOT, "skills")
+DISABLED_FILE = os.path.join(PROJECT_ROOT, "skills_disabled.json")
+
+# ── 技能 → 所属智能体映射 ──
+SKILL_AGENT_MAP = {
+    "click-interpret-tun":      ["点选解读 Agent"],
+    "click-interpret-device":   ["点选解读 Agent"],
+    "dialog-interpret":         ["通风对话助手"],
+    "needq-calc":               ["通风对话助手"],
+    "vent-plan-review-form":    ["形式审查 Agent"],
+    "vent-plan-review-data":    ["数据一致性审查 Agent"],
+    "vent-plan-review-calc":    ["计算核验审查 Agent"],
+    "vent-plan-review-summary": ["汇总审查 Agent"],
+}
+
+
+def _get_disabled_set() -> set:
+    """读取 skills_disabled.json,返回被禁用的技能名集合。"""
+    if not os.path.exists(DISABLED_FILE):
+        return set()
+    try:
+        with open(DISABLED_FILE, "r", encoding="utf-8") as f:
+            data = json.load(f)
+        return set(data.get("disabled", []))
+    except (json.JSONDecodeError, KeyError):
+        return set()
+
+
+def _save_disabled_set(disabled: set):
+    """保存被禁用的技能名集合到 skills_disabled.json。"""
+    with open(DISABLED_FILE, "w", encoding="utf-8") as f:
+        json.dump({"disabled": sorted(disabled)}, f, ensure_ascii=False, indent=2)
+
+
+def _parse_skill_md(skill_dir: str) -> dict:
+    """解析技能目录下的 SKILL.md,提取 frontmatter。
+
+    Returns:
+        {"name": str, "description": str} 或空字典
+    """
+    md_path = os.path.join(skill_dir, "SKILL.md")
+    if not os.path.exists(md_path):
+        return {}
+
+    try:
+        with open(md_path, "r", encoding="utf-8") as f:
+            content = f.read()
+    except Exception:
+        return {}
+
+    # 解析 YAML frontmatter(--- ... ---)
+    match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
+    if not match:
+        return {"name": os.path.basename(skill_dir), "description": ""}
+
+    frontmatter_text = match.group(1)
+
+    # 简单解析 YAML(只取 name 和 description,不引入 pyyaml 依赖)
+    result = {}
+    # 解析 name: xxx
+    name_match = re.search(r"^name:\s*(.+)$", frontmatter_text, re.MULTILINE)
+    if name_match:
+        result["name"] = name_match.group(1).strip().strip('"').strip("'")
+
+    # 解析 description: |
+    desc_match = re.search(r"^description:\s*\|\s*\n(.*?)(?=^\S|\Z)", frontmatter_text, re.MULTILINE | re.DOTALL)
+    if desc_match:
+        desc = desc_match.group(1).strip()
+        # 去掉每行开头的缩进
+        desc = "\n".join(line.strip() for line in desc.split("\n"))
+        # 合并为单行(截取前 80 字符用于列表展示)
+        desc = " ".join(desc.split())
+        result["description"] = desc[:120] + ("..." if len(desc) > 120 else "")
+    else:
+        # 尝试单行 description
+        desc_match = re.search(r"^description:\s*(.+)$", frontmatter_text, re.MULTILINE)
+        if desc_match:
+            d = desc_match.group(1).strip().strip('"').strip("'")
+            result["description"] = d[:120] + ("..." if len(d) > 120 else "")
+
+    return result
+
+
+# ── GET /api/skills ──
+
+@router.get("")
+async def list_skills():
+    """获取所有技能列表(含启用状态和所属智能体)。"""
+    if not os.path.isdir(SKILLS_DIR):
+        return {"skills": []}
+
+    disabled_set = _get_disabled_set()
+    skills = []
+
+    for entry in sorted(os.listdir(SKILLS_DIR)):
+        skill_path = os.path.join(SKILLS_DIR, entry)
+        # 跳过非目录、隐藏目录、Python 缓存目录
+        if not os.path.isdir(skill_path) or entry.startswith(".") or entry == "__pycache__":
+            continue
+
+        meta = _parse_skill_md(skill_path)
+        name = meta.get("name", entry)
+        description = meta.get("description", "")
+        has_skill_md = os.path.exists(os.path.join(skill_path, "SKILL.md"))
+        agents = SKILL_AGENT_MAP.get(entry, ["(未分配)"])
+
+        skills.append({
+            "name": entry,
+            "display_name": name,
+            "path": f"skills/{entry}",
+            "description": description,
+            "enabled": entry not in disabled_set,
+            "agents": agents,
+            "has_skill_md": has_skill_md,
+        })
+
+    return {"skills": skills}
+
+
+# ── GET /api/skills/{name} ──
+
+@router.get("/{name}")
+async def get_skill_content(name: str):
+    """获取指定技能的 SKILL.md 完整内容。"""
+    skill_dir = os.path.join(SKILLS_DIR, name)
+    md_path = os.path.join(skill_dir, "SKILL.md")
+
+    if not os.path.isdir(skill_dir):
+        return JSONResponse(status_code=404, content={"error": f"技能不存在: {name}"})
+
+    if not os.path.exists(md_path):
+        return JSONResponse(status_code=404, content={"error": f"技能 {name} 缺少 SKILL.md"})
+
+    try:
+        with open(md_path, "r", encoding="utf-8") as f:
+            content = f.read()
+    except Exception as e:
+        return JSONResponse(status_code=500, content={"error": f"读取失败: {str(e)}"})
+
+    return {
+        "name": name,
+        "content": content,
+    }
+
+
+# ── POST /api/skills/{name}/toggle ──
+
+@router.post("/{name}/toggle")
+async def toggle_skill(name: str):
+    """启用或禁用指定技能。"""
+    skill_dir = os.path.join(SKILLS_DIR, name)
+    if not os.path.isdir(skill_dir):
+        return JSONResponse(status_code=404, content={"error": f"技能不存在: {name}"})
+
+    disabled_set = _get_disabled_set()
+
+    if name in disabled_set:
+        disabled_set.discard(name)
+        enabled = True
+        msg = f"技能「{name}」已启用"
+    else:
+        disabled_set.add(name)
+        enabled = False
+        msg = f"技能「{name}」已禁用"
+
+    _save_disabled_set(disabled_set)
+
+    # 禁用技能时,通知 agent 缓存失效(下次请求 agent 会跳过已禁用的技能)
+    if not enabled:
+        try:
+            from agents.vent_agent import invalidate_dialog_cache
+            invalidate_dialog_cache()
+        except Exception:
+            pass
+        try:
+            from agents.review_agent import invalidate_cache
+            invalidate_cache()
+        except Exception:
+            pass
+
+    return {"name": name, "enabled": enabled, "message": msg}
+
+
+# ── POST /api/skills/upload ──
+
+@router.post("/upload")
+async def upload_skill(file: UploadFile = File(...)):
+    """上传新技能(.zip 压缩包)。
+
+    要求:
+    - 文件为 .zip 格式
+    - 解压后必须包含 SKILL.md 文件
+    - 技能名不能与已有技能重复
+    """
+    # 校验文件类型
+    if not file.filename or not file.filename.lower().endswith(".zip"):
+        return JSONResponse(status_code=400, content={"error": "仅支持 .zip 格式的技能包"})
+
+    # 读取文件内容
+    try:
+        content = await file.read()
+    except Exception as e:
+        return JSONResponse(status_code=400, content={"error": f"读取文件失败: {str(e)}"})
+
+    # 保存临时文件
+    tmp_path = os.path.join(PROJECT_ROOT, "data", f"_tmp_skill_{file.filename}")
+    os.makedirs(os.path.dirname(tmp_path), exist_ok=True)
+    try:
+        with open(tmp_path, "wb") as f:
+            f.write(content)
+
+        # 解压
+        with zipfile.ZipFile(tmp_path, "r") as zf:
+            # 安全检查:防止路径穿越攻击
+            for member in zf.infolist():
+                if member.filename.startswith("/") or ".." in member.filename:
+                    return JSONResponse(status_code=400, content={"error": "技能包包含非法路径"})
+
+            # 查找顶层目录名(技能名)
+            names = zf.namelist()
+            if not names:
+                return JSONResponse(status_code=400, content={"error": "技能包为空"})
+
+            # 取第一个条目的顶层目录作为技能名
+            root_name = names[0].split("/")[0]
+            if not root_name:
+                return JSONResponse(status_code=400, content={"error": "技能包格式不正确"})
+
+            # 检查 SKILL.md 是否存在
+            has_skill_md = any(
+                n == f"{root_name}/SKILL.md" or n.startswith(f"{root_name}/") and n.endswith("/SKILL.md")
+                for n in names
+            )
+            if not has_skill_md:
+                return JSONResponse(status_code=400, content={"error": "技能包缺少 SKILL.md 文件"})
+
+            # 检查是否与已有技能重名
+            target_dir = os.path.join(SKILLS_DIR, root_name)
+            if os.path.exists(target_dir):
+                return JSONResponse(status_code=409, content={"error": f"技能「{root_name}」已存在"})
+
+            # 解压到 skills/ 目录
+            zf.extractall(SKILLS_DIR)
+
+    except zipfile.BadZipFile:
+        return JSONResponse(status_code=400, content={"error": "文件不是有效的 .zip 压缩包"})
+    except Exception as e:
+        return JSONResponse(status_code=500, content={"error": f"解压失败: {str(e)}"})
+    finally:
+        # 清理临时文件
+        if os.path.exists(tmp_path):
+            try:
+                os.unlink(tmp_path)
+            except OSError:
+                pass
+
+    return {
+        "name": root_name,
+        "message": f"技能「{root_name}」上传成功",
+    }

+ 344 - 3
api/sse_core.py

@@ -9,8 +9,11 @@ import time
 import traceback
 from typing import AsyncGenerator
 
+from langgraph.types import Command  # 程序化 interrupt() 恢复时需启用
+
 from tools.tool_names_cn import TOOL_NAME_CN as _TOOL_CN
 from db.chat_store import save_message, get_messages
+from tools.context_tracker import capture_context_usage
 
 
 # ── 工具名 → 中文描述映射 ──
@@ -35,6 +38,9 @@ async def sse_event_generator(
     config: dict,
     agent_cn_name: str = "智能助手",
     save_to_db: bool = True,
+    original_user_message: str | None = None,
+    interrupt_before: list | None = None,
+    interrupt_after: list | None = None,
 ) -> AsyncGenerator[str, None]:
     """
     通用 SSE 流式事件生成器。
@@ -44,14 +50,17 @@ async def sse_event_generator(
 
     Args:
         save_to_db: 是否将消息保存到数据库。点选解读等非对话场景应设为 False。
+        original_user_message: 用户的原始消息(不含系统注入的前缀)。
+            若提供,则 DB 保存此原始消息;user_message 仍作为 Agent 的输入。
     """
-    # 保存用户消息(仅对话场景写库)
+    # 保存用户消息(仅对话场景写库),优先使用原始消息
     if save_to_db:
-        save_message(session_id, "user", user_message)
+        save_message(session_id, "user", original_user_message or user_message)
 
     # 用于收集完整回复
     full_response = ""
     start_time = time.time()
+    _token_usage = {"prompt": 0, "completion": 0}  # 从流中捕获的实际 token 用量
 
     try:
         # ── 发送 agent_start 事件,前端据此创建 Agent 卡片 ──
@@ -70,6 +79,8 @@ async def sse_event_generator(
             {"messages": messages},
             stream_mode=["updates", "messages"],
             config=config,
+            interrupt_before=interrupt_before,
+            interrupt_after=interrupt_after,
             version="v2",
         ):
             # 判断事件来源:主代理 vs 子代理
@@ -79,7 +90,7 @@ async def sse_event_generator(
             # ── updates 模式:步骤级事件(thinking / executing)──
             if chunk["type"] == "updates":
                 for node_name in chunk["data"]:
-                    if node_name == "model_request":
+                    if node_name == "model":
                         # 代理正在思考/推理
                         yield f"data: {json.dumps({'type': 'thinking', 'source': source, 'node': node_name, 'cn_agent': agent_cn_name, 'message': '正在分析您的问题...'}, ensure_ascii=False)}\n\n"
                     elif node_name == "tools":
@@ -131,20 +142,47 @@ async def sse_event_generator(
 
                 # 提取并流式输出文本内容(工具结果不当作 token 输出,仅输出 AI 生成的文本)
                 if not is_tool_msg:
+                    # 先检查推理/思考内容(DeepSeek 等模型的 reasoning_content)
+                    reasoning = _extract_reasoning_content(msg_obj)
+                    if reasoning:
+                        if _DEBUG_REASONING:
+                            print(f"[reasoning] ✅ YIELD reasoning event ({len(reasoning)} chars)")
+                        yield f"data: {json.dumps({'type': 'reasoning', 'source': source, 'content': reasoning}, ensure_ascii=False)}\n\n"
+                    # 再提取常规文本内容
                     content = _extract_text_content(msg_obj)
                     if content and isinstance(content, str):
                         full_response += content
                         yield f"data: {json.dumps({'type': 'token', 'source': source, 'content': content}, ensure_ascii=False)}\n\n"
 
+                # ── 提取 token 用量元数据(最后一个消息块通常携带 usage)──
+                _capture_token_meta(msg_obj, _token_usage)
+
         # 保存助手回复(仅对话场景写库)
         if save_to_db and full_response.strip():
             cleaned = _clean_response(full_response)
             save_message(session_id, "assistant", cleaned)
             print(full_response)
 
+        # ── 更新上下文用量 ──
+        if save_to_db and _token_usage["prompt"] > 0:
+            await capture_context_usage(
+                session_id=session_id,
+                prompt_tokens=_token_usage["prompt"],
+                completion_tokens=_token_usage["completion"],
+                current_message=user_message,
+            )
+
         # 发送 agent_done + done 事件
         duration_ms = int((time.time() - start_time) * 1000)
         yield f"data: {json.dumps({'type': 'agent_done', 'agent': agent_cn_name, 'cn_agent': agent_cn_name, 'duration_ms': duration_ms, 'message': f'「{agent_cn_name}」完成({duration_ms}ms)'}, ensure_ascii=False)}\n\n"
+
+        # 检查 LangGraph 中断状态(Human-in-the-Loop)
+        interrupt_info = _check_interrupt(agent, config)
+        if interrupt_info:
+            yield f"data: {json.dumps({'type': 'interrupt', **interrupt_info}, ensure_ascii=False)}\n\n"
+            # 中断时不发送 done 事件,等待用户审批后恢复
+            return
+
         yield f"data: {json.dumps({'type': 'done', 'thread_id': thread_id, 'session_id': session_id, 'duration_ms': duration_ms, 'message': f'回答完成({duration_ms}ms)'}, ensure_ascii=False)}\n\n"
 
     except Exception as e:
@@ -175,6 +213,133 @@ def _extract_text_content(msg_obj) -> str | None:
     return None
 
 
+# ── 调试开关:设为 True 时打印推理内容摘要(排查完毕后关闭)──
+_DEBUG_REASONING = True
+_debug_dump_done = False  # 只 dump 第一个非空 AI chunk 的完整属性
+
+
+def _extract_reasoning_content(msg_obj) -> str | None:
+    """从 LangChain 消息对象中提取推理/思考内容(DeepSeek 等模型的 reasoning_content)。
+
+    支持三种来源(按优先级):
+    1. msg_obj.additional_kwargs["reasoning_content"](OpenAI 兼容流式 delta)
+    2. msg_obj.reasoning_content(LangChain 直接属性)
+    3. content 中的 thinking 类型块
+
+    Returns:
+        推理文本字符串,无推理内容时返回 None
+    """
+    global _debug_dump_done
+
+    msg_type = getattr(msg_obj, "type", "?")
+    is_tool = (msg_type == "tool")
+    content_raw = getattr(msg_obj, "content", "")
+
+    # ── 调试:dump 第一个非空非工具 chunk 的所有属性 ──
+    if _DEBUG_REASONING and not _debug_dump_done and not is_tool and content_raw:
+        _debug_dump_done = True
+        print(f"\n[reasoning-dump] === 第一个非空 AI chunk 完整属性 ===")
+        print(f"  type: {type(msg_obj).__name__}")
+        # 打印所有属性(包括私有)
+        for attr in sorted(dir(msg_obj)):
+            if attr.startswith('_') and not attr.startswith('__'):
+                continue
+            try:
+                val = getattr(msg_obj, attr)
+                if callable(val):
+                    continue
+                s = repr(val)
+                if len(s) > 400:
+                    s = s[:400] + f"... (total {len(s)} chars)"
+                print(f"  {attr}: {s}")
+            except Exception as e:
+                print(f"  {attr}: <error: {e}>")
+        # 特别检查 additional_kwargs
+        ak = getattr(msg_obj, "additional_kwargs", {})
+        if isinstance(ak, dict) and ak:
+            print(f"  >>> additional_kwargs has keys: {list(ak.keys())}")
+            for k, v in ak.items():
+                sv = repr(v)
+                if len(sv) > 500:
+                    sv = sv[:500] + f"... ({len(sv)} total)"
+                print(f"  >>>   [{k}]: {sv}")
+        else:
+            print(f"  >>> additional_kwargs: EMPTY or not dict (type={type(ak).__name__})")
+        # 检查 response_metadata
+        rm = getattr(msg_obj, "response_metadata", {})
+        if isinstance(rm, dict) and rm:
+            print(f"  >>> response_metadata keys: {list(rm.keys())}")
+            for k, v in rm.items():
+                sv = repr(v)
+                if len(sv) > 300:
+                    sv = sv[:300] + f"..."
+                print(f"  >>>   [{k}]: {sv}")
+        print(f"[reasoning-dump] === dump 完毕 ===\n")
+
+    # 方式 1:additional_kwargs 中的 reasoning_content(最常见)
+    if hasattr(msg_obj, "additional_kwargs") and isinstance(msg_obj.additional_kwargs, dict):
+        reasoning = msg_obj.additional_kwargs.get("reasoning_content", "")
+        if reasoning:
+            if _DEBUG_REASONING:
+                print(f"[reasoning] ✅ additional_kwargs ({len(reasoning)} chars): {reasoning[:120]}...")
+            return reasoning
+
+    # 方式 2:直接属性 reasoning_content
+    reasoning = getattr(msg_obj, "reasoning_content", None)
+    if reasoning:
+        if _DEBUG_REASONING:
+            print(f"[reasoning] ✅ direct attr ({len(reasoning)} chars): {reasoning[:120]}...")
+        return reasoning
+
+    # 方式 3:content 为 list 时,提取 thinking 类型的块
+    if hasattr(msg_obj, "content"):
+        raw = getattr(msg_obj, "content", None)
+        if isinstance(raw, list):
+            parts = []
+            for block in raw:
+                if isinstance(block, dict) and block.get("type") == "thinking":
+                    parts.append(block.get("thinking", ""))
+                elif hasattr(block, "type") and getattr(block, "type", "") == "thinking":
+                    parts.append(getattr(block, "thinking", ""))
+            if parts:
+                result = "".join(parts)
+                if _DEBUG_REASONING:
+                    print(f"[reasoning] ✅ content blocks ({len(result)} chars): {result[:120]}...")
+                return result
+
+    return None
+
+
+def _capture_token_meta(msg_obj, usage_ref: dict):
+    """从 LangChain 消息对象中提取 LLM token 用量元数据。
+
+    优先读取 usage_metadata(langchain ≥0.3),
+    其次读取 response_metadata.usage(OpenAI 兼容格式)。
+    结果写入 usage_ref dict(原地修改)。
+    """
+    # 方式 1:usage_metadata(langchain 标准字段)
+    um = getattr(msg_obj, "usage_metadata", None)
+    if um and isinstance(um, dict):
+        inp = um.get("input_tokens", 0)
+        out = um.get("output_tokens", 0)
+        if inp or out:
+            usage_ref["prompt"] = inp
+            usage_ref["completion"] = out
+            return
+
+    # 方式 2:response_metadata.usage(OpenAI / DeepSeek 兼容)
+    rm = getattr(msg_obj, "response_metadata", None)
+    if rm and isinstance(rm, dict):
+        usage = rm.get("usage", {}) or rm.get("token_usage", {})
+        if isinstance(usage, dict):
+            inp = usage.get("prompt_tokens", 0)
+            out = usage.get("completion_tokens", 0)
+            if inp or out:
+                usage_ref["prompt"] = inp
+                usage_ref["completion"] = out
+                return
+
+
 def _parse_todo_list(text: str) -> list | None:
     """从 write_todos 输出中提取 todo 列表。
 
@@ -204,3 +369,179 @@ def _clean_response(text: str) -> str:
     # 移除工具调用残留(大括号 JSON 块如果独立成行则移除)
     text = re.sub(r'^\s*\{[^}]*\}\s*$', '', text, flags=re.MULTILINE)
     return text.strip()
+
+
+# ── LangGraph Human-in-the-Loop 中断处理 ──
+
+
+def _check_interrupt(agent, config: dict) -> dict | None:
+    """
+    检查 LangGraph 状态是否被中断。
+
+    支持两种中断检测:
+    1. 程序化中断(节点内调用 interrupt())→ state.interrupts 非空
+    2. interrupt_before / interrupt_after 暂停 → state.next 非空
+
+    当中断发生时返回中断信息 dict,否则返回 None。
+
+    返回格式:
+        {"node": "tools", "message": "智能体准备执行工具,等待审批...",
+         "interrupts": [...], "plan": "计划文本(程序化中断时)"}
+    """
+    try:
+        state = agent.get_state(config)
+    except Exception:
+        return None
+
+    if state is None:
+        return None
+
+    # 1. 检测程序化中断(节点内调用 interrupt())
+    interrupts = getattr(state, "interrupts", None)
+    if not interrupts:
+        values = getattr(state, "values", {}) or {}
+        interrupts = values.get("__interrupt__", [])
+
+    # 2. 检测 interrupt_before / interrupt_after 暂停
+    #    图暂停时 state.next 非空(有待执行节点);完成时为空
+    next_nodes = getattr(state, "next", None)
+    is_paused_by_interrupt_config = bool(next_nodes) if next_nodes is not None else False
+
+    if not interrupts and not is_paused_by_interrupt_config:
+        return None
+
+    # 提取中断节点名
+    if interrupts:
+        interrupt_data = interrupts[0] if interrupts else {}
+        # 处理 interrupt() 返回值可能是 Interrupt 对象或 dict
+        if hasattr(interrupt_data, "value"):
+            interrupt_data = interrupt_data.value
+        if isinstance(interrupt_data, dict):
+            node = interrupt_data.get("type", "tools")
+            plan = interrupt_data.get("plan", "")
+            message = interrupt_data.get("message", "智能体暂停执行,等待您的审批...")
+        else:
+            node = str(next_nodes or "tools")
+            plan = ""
+            message = "智能体暂停执行,等待您的审批..."
+    else:
+        node = next_nodes
+        plan = ""
+        message = "智能体暂停执行,等待您的审批..."
+    if isinstance(node, (list, tuple)):
+        node = node[0] if node else "unknown"
+
+    result = {
+        "node": str(node),
+        "message": message,
+        "interrupts": [str(i) for i in interrupts] if interrupts else [],
+    }
+    if plan:
+        result["plan"] = plan
+    return result
+
+
+async def resume_stream(
+    agent,
+    session_id: str,
+    thread_id: str,
+    config: dict,
+    agent_cn_name: str = "智能助手",
+    action: str = "approve",
+    interrupt_before: list | None = None,
+    interrupt_after: list | None = None,
+):
+    """
+    恢复被中断的 LangGraph 流式执行。
+
+    参数:
+        action: "approve" 或 "reject"
+
+    Yields:
+        SSE 事件字符串
+    """
+    if action == "reject":
+        yield f"data: {json.dumps({'type': 'error', 'message': '用户拒绝了工具执行'}, ensure_ascii=False)}\n\n"
+        yield f"data: {json.dumps({'type': 'done', 'thread_id': thread_id, 'session_id': session_id, 'message': '已取消执行'}, ensure_ascii=False)}\n\n"
+        return
+
+    full_response = ""
+    start_time = time.time()
+
+    try:
+        # 检测中断类型:程序化中断需用 Command(resume=...),配置式中断传 None
+        state = agent.get_state(config)
+        has_programmatic = bool(getattr(state, "interrupts", None)) if state else False
+        stream_input = Command(resume={"action": action}) if has_programmatic else None
+
+        # 恢复执行
+        async for chunk in agent.astream(
+            stream_input,
+            stream_mode=["updates", "messages"],
+            config=config,
+            interrupt_before=interrupt_before,
+            interrupt_after=interrupt_after,
+            version="v2",
+        ):
+            is_subagent = any(s.startswith("tools:") for s in chunk.get("ns", []))
+
+            if chunk["type"] == "updates":
+                for node_name in chunk["data"]:
+                    if node_name == "model":
+                        yield f"data: {json.dumps({'type': 'thinking', 'source': 'main', 'cn_agent': agent_cn_name, 'message': '继续执行...'}, ensure_ascii=False)}\n\n"
+                    elif node_name == "tools":
+                        tools_data = chunk["data"].get(node_name, {})
+                        tool_names = []
+                        for msg in tools_data.get("messages", []):
+                            if hasattr(msg, "name"):
+                                tool_names.append(msg.name)
+                            elif isinstance(msg, dict) and msg.get("name"):
+                                tool_names.append(msg["name"])
+                        cn_names = [_cn_tool_desc(t) for t in tool_names] if tool_names else ["工具调用"]
+                        yield f"data: {json.dumps({'type': 'executing', 'source': 'main', 'cn_agent': agent_cn_name, 'tools': tool_names, 'cn_tools': cn_names, 'message': f"正在{'、'.join(cn_names)}..."}, ensure_ascii=False)}\n\n"
+
+            elif chunk["type"] == "messages":
+                token_data = chunk["data"]
+                if isinstance(token_data, (list, tuple)) and len(token_data) >= 1:
+                    msg_obj = token_data[0]
+                else:
+                    msg_obj = token_data
+
+                # 工具结果
+                is_tool_msg = hasattr(msg_obj, "type") and msg_obj.type == "tool"
+                if is_tool_msg:
+                    tool_name = getattr(msg_obj, "name", "unknown")
+                    if tool_name != "write_todos":
+                        yield f"data: {json.dumps({'type': 'tool_result', 'source': 'main', 'cn_agent': agent_cn_name, 'tool': tool_name, 'cn_tool': _cn_tool_desc(tool_name), 'message': f'{_cn_tool_desc(tool_name)} 完成'}, ensure_ascii=False)}\n\n"
+
+                # AI 文本
+                if not is_tool_msg:
+                    # 先检查推理/思考内容(DeepSeek 等模型的 reasoning_content)
+                    reasoning = _extract_reasoning_content(msg_obj)
+                    if reasoning:
+                        if _DEBUG_REASONING:
+                            print(f"[reasoning] ✅ YIELD reasoning event resume ({len(reasoning)} chars)")
+                        yield f"data: {json.dumps({'type': 'reasoning', 'source': 'main', 'content': reasoning}, ensure_ascii=False)}\n\n"
+                    # 再提取常规文本内容
+                    content = _extract_text_content(msg_obj)
+                    if content and isinstance(content, str):
+                        full_response += content
+                        yield f"data: {json.dumps({'type': 'token', 'source': 'main', 'content': content}, ensure_ascii=False)}\n\n"
+
+        # 保存助手回复
+        if full_response.strip():
+            cleaned = _clean_response(full_response)
+            save_message(session_id, "assistant", cleaned)
+
+        # 再次检查中断(可能有多次中断)
+        interrupt_info = _check_interrupt(agent, config)
+        if interrupt_info:
+            yield f"data: {json.dumps({'type': 'interrupt', **interrupt_info}, ensure_ascii=False)}\n\n"
+            return
+
+        duration_ms = int((time.time() - start_time) * 1000)
+        yield f"data: {json.dumps({'type': 'done', 'thread_id': thread_id, 'session_id': session_id, 'duration_ms': duration_ms, 'message': f'回答完成({duration_ms}ms)'}, ensure_ascii=False)}\n\n"
+
+    except Exception as e:
+        traceback.print_exc()
+        yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"

BIN
data/chat_history.db


BIN
data/chat_history.db-shm


BIN
data/chat_history.db-wal


+ 279 - 14
db/chat_store.py

@@ -67,16 +67,96 @@ def init_db():
         CREATE INDEX IF NOT EXISTS idx_sessions_updated
             ON sessions(updated_at DESC);
     """)
+    # 迁移:为已有数据库添加 mode 列(如果不存在)
+    _migrate_add_mode(conn)
+    # 迁移:添加 user_name 列(用户隔离),旧数据统一归为 admin
+    _migrate_add_user_name(conn)
+    # 迁移:添加上下文用量表
+    _migrate_context_usage(conn)
+    # 迁移:添加 sort_order 列(会话置顶)
+    _migrate_add_sort_order(conn)
+    # 迁移:添加 user_preferences 表(用户偏好记忆)
+    _migrate_user_preferences(conn)
     conn.commit()
 
 
-def create_session(session_id: Optional[str] = None, title: str = "") -> str:
+def _migrate_add_mode(conn: sqlite3.Connection):
+    """迁移:为 sessions 表添加 mode 列(如果尚不存在)。"""
+    try:
+        conn.execute("SELECT mode FROM sessions LIMIT 0")
+    except sqlite3.OperationalError:
+        conn.execute("ALTER TABLE sessions ADD COLUMN mode TEXT DEFAULT 'plan'")
+
+
+def _migrate_add_user_name(conn: sqlite3.Connection):
+    """迁移:为 sessions 表添加 user_name 列(用户隔离),旧数据统一归 admin。"""
+    try:
+        conn.execute("SELECT user_name FROM sessions LIMIT 0")
+    except sqlite3.OperationalError:
+        conn.execute("ALTER TABLE sessions ADD COLUMN user_name TEXT DEFAULT 'admin'")
+        # 填充历史数据中的 NULL 值
+        conn.execute("UPDATE sessions SET user_name = 'admin' WHERE user_name IS NULL")
+
+
+def _migrate_context_usage(conn: sqlite3.Connection):
+    """迁移:创建会话上下文用量表。"""
+    conn.execute("""
+        CREATE TABLE IF NOT EXISTS session_context_usage (
+            id           INTEGER PRIMARY KEY AUTOINCREMENT,
+            session_id   TEXT NOT NULL,
+            total_limit  INTEGER NOT NULL DEFAULT 1000000,
+            current_usage    INTEGER NOT NULL DEFAULT 0,
+            messages_tokens  INTEGER DEFAULT 0,
+            tools_tokens     INTEGER DEFAULT 0,
+            skills_tokens    INTEGER DEFAULT 0,
+            mcp_tokens       INTEGER DEFAULT 0,
+            system_prompt_tokens INTEGER DEFAULT 0,
+            other_tokens     INTEGER DEFAULT 0,
+            updated_at   TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
+            FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
+        )
+    """)
+    conn.execute("""
+        CREATE INDEX IF NOT EXISTS idx_context_usage_session
+            ON session_context_usage(session_id, updated_at DESC)
+    """)
+
+
+def _migrate_add_sort_order(conn: sqlite3.Connection):
+    """迁移:为 sessions 表添加 sort_order 列(置顶排序)。"""
+    try:
+        conn.execute("SELECT sort_order FROM sessions LIMIT 0")
+    except sqlite3.OperationalError:
+        conn.execute("ALTER TABLE sessions ADD COLUMN sort_order INTEGER DEFAULT 0")
+
+
+def _migrate_user_preferences(conn: sqlite3.Connection):
+    """迁移:创建用户偏好记忆表。"""
+    conn.execute("""
+        CREATE TABLE IF NOT EXISTS user_preferences (
+            id         INTEGER PRIMARY KEY AUTOINCREMENT,
+            user_name  TEXT NOT NULL,
+            category   TEXT DEFAULT '通用',
+            content    TEXT NOT NULL,
+            keywords   TEXT DEFAULT '',
+            created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
+            updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
+        )
+    """)
+    conn.execute("""
+        CREATE INDEX IF NOT EXISTS idx_prefs_user
+            ON user_preferences(user_name)
+    """)
+
+
+def create_session(session_id: Optional[str] = None, title: str = "",
+                   user_name: str = "admin") -> str:
     """创建新会话,返回 session_id"""
     conn = _get_connection()
     sid = session_id or str(uuid.uuid4())
     conn.execute(
-        "INSERT OR IGNORE INTO sessions (session_id, title) VALUES (?, ?)",
-        (sid, title),
+        "INSERT OR IGNORE INTO sessions (session_id, title, user_name) VALUES (?, ?, ?)",
+        (sid, title, user_name),
     )
     conn.commit()
     return sid
@@ -88,7 +168,7 @@ def save_message(session_id: str, role: str, content: str,
     conn = _get_connection()
     # 确保会话存在
     conn.execute(
-        "INSERT OR IGNORE INTO sessions (session_id) VALUES (?)",
+        "INSERT OR IGNORE INTO sessions (session_id, user_name) VALUES (?, 'admin')",
         (session_id,),
     )
     conn.execute(
@@ -118,17 +198,29 @@ def get_messages(session_id: str, limit: int = 50,
     return [dict(row) for row in rows]
 
 
-def get_sessions(limit: int = 20, offset: int = 0) -> list[dict]:
-    """查询会话列表(按更新时间倒序)"""
+def get_sessions(limit: int = 20, offset: int = 0,
+                  user_name: str | None = None) -> list[dict]:
+    """查询会话列表(按更新时间倒序),可按用户名过滤。"""
     conn = _get_connection()
-    rows = conn.execute(
-        """SELECT session_id, title, created_at, updated_at,
-                  (SELECT COUNT(*) FROM messages WHERE messages.session_id = sessions.session_id) AS message_count
-           FROM sessions
-           ORDER BY updated_at DESC
-           LIMIT ? OFFSET ?""",
-        (limit, offset),
-    ).fetchall()
+    if user_name:
+        rows = conn.execute(
+            """SELECT session_id, title, user_name, sort_order, created_at, updated_at,
+                      (SELECT COUNT(*) FROM messages WHERE messages.session_id = sessions.session_id) AS message_count
+               FROM sessions
+               WHERE user_name = ?
+               ORDER BY sort_order DESC, updated_at DESC
+               LIMIT ? OFFSET ?""",
+            (user_name, limit, offset),
+        ).fetchall()
+    else:
+        rows = conn.execute(
+            """SELECT session_id, title, user_name, sort_order, created_at, updated_at,
+                      (SELECT COUNT(*) FROM messages WHERE messages.session_id = sessions.session_id) AS message_count
+               FROM sessions
+               ORDER BY sort_order DESC, updated_at DESC
+               LIMIT ? OFFSET ?""",
+            (limit, offset),
+        ).fetchall()
     return [dict(row) for row in rows]
 
 
@@ -150,5 +242,178 @@ def update_session_title(session_id: str, title: str):
     conn.commit()
 
 
+# ── 会话权限模式 ──
+
+VALID_MODES = {"plan", "full"}
+
+
+def set_session_mode(session_id: str, mode: str):
+    """设置会话的权限模式。
+
+    Args:
+        session_id: 会话 ID
+        mode: "plan" | "full"
+    """
+    if mode not in VALID_MODES:
+        raise ValueError(f"无效的模式: {mode},可选: {', '.join(sorted(VALID_MODES))}")
+
+    conn = _get_connection()
+    conn.execute(
+        "INSERT OR IGNORE INTO sessions (session_id, user_name) VALUES (?, 'admin')",
+        (session_id,),
+    )
+    conn.execute(
+        "UPDATE sessions SET mode = ?, updated_at = datetime('now', 'localtime') WHERE session_id = ?",
+        (mode, session_id),
+    )
+    conn.commit()
+
+
+def get_session_mode(session_id: str) -> str:
+    """获取会话的权限模式,默认返回 'plan'。"""
+    conn = _get_connection()
+    row = conn.execute(
+        "SELECT mode FROM sessions WHERE session_id = ?",
+        (session_id,),
+    ).fetchone()
+    if row and row["mode"]:
+        return row["mode"]
+    return "plan"
+
+
 # 应用启动时自动初始化
 init_db()
+
+
+# ── 会话置顶 ──
+
+def pin_session(session_id: str, pinned: bool = True):
+    """置顶/取消置顶会话。
+
+    Args:
+        session_id: 会话 ID
+        pinned: True=置顶(sort_order=1),False=取消(sort_order=0)
+    """
+    conn = _get_connection()
+    conn.execute(
+        "UPDATE sessions SET sort_order = ?, updated_at = datetime('now', 'localtime') WHERE session_id = ?",
+        (1 if pinned else 0, session_id),
+    )
+    conn.commit()
+
+
+def is_session_pinned(session_id: str) -> bool:
+    """查询会话是否已置顶。"""
+    conn = _get_connection()
+    row = conn.execute(
+        "SELECT sort_order FROM sessions WHERE session_id = ?",
+        (session_id,),
+    ).fetchone()
+    return bool(row and row["sort_order"] and row["sort_order"] > 0)
+
+
+# ── 会话上下文用量 ──
+
+def save_context_usage(
+    session_id: str,
+    total_limit: int,
+    current_usage: int,
+    messages_tokens: int = 0,
+    mcp_tokens: int = 0,
+    skills_tokens: int = 0,
+    system_prompt_tokens: int = 0,
+    other_tokens: int = 0,
+):
+    """保存或更新会话的上下文用量快照。每次任务完成后插入一条新记录。
+
+    mcp_tokens 已合并本地工具 + MCP 远程工具;tools_tokens 列保留但写 0(兼容旧表结构)。
+    """
+    conn = _get_connection()
+    conn.execute(
+        """INSERT INTO session_context_usage
+           (session_id, total_limit, current_usage,
+            messages_tokens, tools_tokens, skills_tokens,
+            mcp_tokens, system_prompt_tokens, other_tokens)
+           VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)""",
+        (session_id, total_limit, current_usage,
+         messages_tokens, skills_tokens,
+         mcp_tokens, system_prompt_tokens, other_tokens),
+    )
+    conn.commit()
+
+
+def get_latest_context_usage(session_id: str) -> dict | None:
+    """查询会话最近一次的上下文用量。"""
+    conn = _get_connection()
+    row = conn.execute(
+        """SELECT * FROM session_context_usage
+           WHERE session_id = ?
+           ORDER BY updated_at DESC
+           LIMIT 1""",
+        (session_id,),
+    ).fetchone()
+    return dict(row) if row else None
+
+
+# ── 用户偏好记忆 ──
+
+def save_user_preference(user_name: str, content: str,
+                         keywords: str = "", category: str = "通用") -> int:
+    """保存一条用户偏好/习惯。
+
+    Args:
+        user_name: 用户名
+        content: 偏好内容(如"习惯使用 m³/s 作为风速单位")
+        keywords: 触发关键词,逗号分隔
+        category: 偏好分类
+
+    Returns:
+        新插入记录的 id
+    """
+    conn = _get_connection()
+    cur = conn.execute(
+        """INSERT INTO user_preferences (user_name, category, content, keywords)
+           VALUES (?, ?, ?, ?)""",
+        (user_name, category, content, keywords),
+    )
+    conn.commit()
+    return cur.lastrowid
+
+
+def get_user_preferences(user_name: str) -> list[dict]:
+    """查询某用户的所有偏好记录。
+
+    Args:
+        user_name: 用户名
+
+    Returns:
+        偏好记录列表,按更新时间倒序
+    """
+    conn = _get_connection()
+    rows = conn.execute(
+        """SELECT id, user_name, category, content, keywords, created_at, updated_at
+           FROM user_preferences
+           WHERE user_name = ?
+           ORDER BY updated_at DESC""",
+        (user_name,),
+    ).fetchall()
+    return [dict(row) for row in rows]
+
+
+def delete_user_preference(pref_id: int, user_name: str) -> bool:
+    """删除一条用户偏好(带用户名校验,防止越权)。
+
+    Args:
+        pref_id: 偏好记录 ID
+        user_name: 当前用户名
+
+    Returns:
+        True 表示删除成功,False 表示记录不存在或不属于该用户
+    """
+    conn = _get_connection()
+    cur = conn.execute(
+        "DELETE FROM user_preferences WHERE id = ? AND user_name = ?",
+        (pref_id, user_name),
+    )
+    conn.commit()
+    return cur.rowcount > 0

+ 137 - 0
list_mcp_tools.py

@@ -0,0 +1,137 @@
+# -*- coding: utf-8 -*-
+"""
+MCP 远程工具列表查询 —— 格式化输出所有可用的 MCP 工具及参数签名
+"""
+
+import asyncio
+import shutil
+from fastmcp import Client
+
+# ── 终端宽度 ──
+WIDTH = min(shutil.get_terminal_size().columns, 120)
+
+# ── 工具分类定义(按名称前缀归类)──
+CATEGORY_MAP = {
+    "vent":   "🌬️  通风监测",
+    "device": "🔧 设备管理",
+    "tunnel": "🛤️  巷道信息",
+    "mine":   "⛏️  煤矿基础",
+    "needq":  "📊 需风量计算",
+}
+
+def _category(name: str) -> str:
+    for prefix, label in CATEGORY_MAP.items():
+        if name.startswith(prefix):
+            return label
+    return "📦 其他"
+
+
+def parse_tool_params(schema: dict) -> list[dict]:
+    """解析 inputSchema 为参数列表"""
+    params = []
+    required = schema.get("required", [])
+    props = schema.get("properties", {})
+    for name, info in props.items():
+        params.append({
+            "name": name,
+            "type": info.get("type", "—"),
+            "desc": (info.get("description", "") or "")[:120],
+            "required": name in required,
+        })
+    return params
+
+
+# ── 主入口 ──
+
+MCP_URL = "http://39.97.59.228:8071/mcp"
+client = Client(MCP_URL)
+
+
+async def list_all_mcp_tools_tcp():
+    """TCP 远程 MCP 客户端查询工具列表"""
+    async with client:
+        tools = await client.list_tools()
+
+        # ══════════════════════════════════════════════
+        #  头部
+        # ══════════════════════════════════════════════
+        print()
+        print("╔" + "═" * (WIDTH - 2) + "╗")
+        print("║" + f"  🔌  MCP 远程工具列表  |  {MCP_URL}  |  共 {len(tools)} 个工具".center(WIDTH - 2) + "║")
+        print("╚" + "═" * (WIDTH - 2) + "╝")
+        print()
+
+        # 按类别分组
+        groups: dict[str, list] = {}
+        for t in tools:
+            cat = _category(t.name)
+            groups.setdefault(cat, []).append(t)
+
+        order = ["🌬️  通风监测", "🛤️  巷道信息", "🔧 设备管理", "📊 需风量计算", "⛏️  煤矿基础", "📦 其他"]
+        index = 0
+
+        for cat in order:
+            if cat not in groups:
+                continue
+            group_tools = groups[cat]
+            print(f"  {cat}({len(group_tools)} 个)")
+            print(f"  {'─' * (WIDTH - 6)}")
+
+            for t in group_tools:
+                index += 1
+                # ── 工具名 + 序号 ──
+                print(f"\n  ┌─ [{index:02d}] {t.name} {'─' * max(WIDTH - len(t.name) - 15, 0)}")
+
+                # ── 描述 ──
+                desc = (t.description or "").strip()
+                if desc:
+                    # 自动换行
+                    for line in _wrap(desc, WIDTH - 8):
+                        print(f"  │  📝 {line}")
+
+                # ── 参数表 ──
+                params = parse_tool_params(t.inputSchema)
+                if params:
+                    print(f"  │  ┌ 参数{'─' * (WIDTH - 13)}")
+                    for p in params:
+                        flag = "🔴" if p["required"] else "🟢"
+                        print(f"  │  │ {flag} {p['name']}: {p['type']}")
+                        if p["desc"]:
+                            for line in _wrap(p["desc"], WIDTH - 12):
+                                print(f"  │  │    {line}")
+                    print(f"  │  └{'─' * (WIDTH - 9)}")
+                else:
+                    print(f"  │  (无参数)")
+
+                print(f"  └{'─' * (WIDTH - 6)}")
+
+            print()
+
+        # ══════════════════════════════════════════════
+        #  底部统计
+        # ══════════════════════════════════════════════
+        total_params = sum(
+            len(parse_tool_params(t.inputSchema)) for t in tools
+        )
+        print("╔" + "═" * (WIDTH - 2) + "╗")
+        print("║" + f"  ✅ 总计 {len(tools)} 个工具,{total_params} 个参数,{len(groups)} 个分类".ljust(WIDTH - 2) + "║")
+        print("╚" + "═" * (WIDTH - 2) + "╝")
+        print()
+
+
+def _wrap(text: str, width: int) -> list[str]:
+    """按宽度自动换行,尊重中文宽度"""
+    lines = []
+    while len(text) > width:
+        # 尝试在空格处断行
+        cut = text.rfind(" ", 0, width)
+        if cut == -1:
+            cut = width
+        lines.append(text[:cut].strip())
+        text = text[cut:].strip()
+    if text:
+        lines.append(text)
+    return lines
+
+
+asyncio.run(list_all_mcp_tools_tcp())

+ 0 - 3
main.py

@@ -42,13 +42,10 @@ async def lifespan(app: FastAPI):
         """后台线程:预热 Agent 实例"""
         try:
             from agents.vent_agent import get_click_agent, get_dialog_agent
-            from agents.needq_agent import get_needq_calc_agent
             print("[启动] 后台预热点选解读 Agent...(不阻塞服务就绪)")
             get_click_agent()
             print("[启动] 后台预热对话解读 Agent...(不阻塞服务就绪)")
             get_dialog_agent()
-            print("[启动] 后台预热需风量计算 Agent...(不阻塞服务就绪)")
-            get_needq_calc_agent()
 
             # ── 预热配风计划审查 Agent(消除首次请求的图编译开销)──
             from agents.review_agent import (

+ 0 - 1
skills/click-interpret-device/SKILL.md

@@ -70,7 +70,6 @@ tools:
 - 报警状态:{报警级别} - {报警描述}
 - 结论:{正常/重大风险/高风险/中风险}。{详细异常分析}
 - 依据:《煤矿安全规程》 {检索到的完整条款}
-- 建议:{针对性整改措施}
 ```
 
 - **报告输出完成后**:调用 `write_todos` 将"生成标准化结构化报告"标记为 `completed`

+ 0 - 101
skills/click-interpret-tun/SKILL-bak.md2

@@ -1,101 +0,0 @@
----
-name: click-interpret
-description: >-
-  煤矿通风数据点选解读。当需要对单条巷道监测数据进行深度合规分析
-  和风险评估,判断风速风量是否合规,并输出结构化报告时使用此技能。
-  触发场景:用户点击或选中某条巷道数据行、需要生成结构化数据解读报告。
----
-
-# 煤矿通风数据点选解读
-
-## 角色
-你是一名煤矿通风安全专家,负责对单条巷道监测数据进行深度解读。
-
-## 核心能力
-你可以使用以下工具查询实时数据和规程依据:
-- `query_tun_data_by_id`: 根据巷道ID查询巷道关联信息(风速、风量、瓦斯、温度等)
-- `query_knowledge_base`: 查询煤矿安全知识库,检索《煤矿安全规程》条款原文、标准规范等
-- 'write_todos': 记录已完成的步骤
-
-## 工作流程
-
-### 第一步:获取数据
-调用 `query_tun_data_by_id` 工具,传入巷道ID,查询该巷道的实时监测数据,JSON结果字典如下:
-        tunId	巷道唯一 ID
-        tunnelName	巷道名称
-        needAirVolume	巷道需配风量,单位 m³/min
-        usingType	巷道类型 0-回采工作面;1-掘进工作面;2-辅运巷;3-主运巷;4-硐室;5-联络巷;6-进风井;7-回风井;8-专用回风巷
-        usingTypeName	巷道用途中文名称(如掘进工作面)
-        regulationId	风速规范配置 ID
-        permissibleMin	允许最小风速,m/s
-        permissibleMax	允许最大风速,m/s
-        permissibleVelocity	巷道风速规范限制对象
-        sensorIds	绑定的所有传感器 ID 数组
-        deviceCount	绑定设备总数量
-        devices	巷道绑定设备列表数组
-        permissibleVelocity
-            model_reg_id	风速规范主键 ID,同外层 regulationId
-            fmin	最低允许风速 m/s
-            fmax	最高允许风速 m/s
-            sourceType	设备数据源类型:wind = 测风设备,model_sensor_speed = 风速传感器 model_sensor=传感器
-            sourceId	设备数据源唯一编号
-            sourceName	数据源名称
-            installPos	设备井下安装位置
-            sensorId	传感器唯一标识 ID
-            parentId	父级传感器 ID,单设备时与 sensorId 一致
-            airVolume	实时风量,单位 m³/min;null 表示无实时数据
-            windSpeed	实时风速,单位 m/s;null 表示无实时数据
-            warnFlag	报警标识:0 = 无报警,非 0 代表存在异常报警
-            netStatus	设备网络在线状态:1 = 在线,null / 其他 = 离线 / 无数据
-            deviceStatus	设备运行状态编码
-            deviceStatusName	设备运行状态中文描述(在线 / 离线等)
-            deviceName	设备展示名称
-            readTime	最新数据采集时间,格式 yyyy-MM-dd HH:mm:ss
-            deviceType	设备类型标识,modelsensor_speed = 风速模型传感器
-            readData	设备实时采集原始数据对象
-            alarmDescription	当前设备单条报警文本,无报警为空
-            alarmDescriptions	设备多条报警信息数组,无报警为空数组
-            m3	实时风量数值 m³/min
-            sign	风向标识
-            tTime	传感器原始采集时间
-            va	实时风速数值 m/s
-            isRun	设备运行状态标记
-### 第二步:合规与风险分析
-逐项进行比对分析:
-1. 风速是否在规程范围内,优先使用permissibleMin、permissibleMax范围,若缺失需调用 `query_knowledge_base` 检索确认限值。
-2. 风量是否满足计划值,实际供风量小于计划风量的75%视为重大安全隐患,上限一般不超过计划风量的150%,没有固定的值。(若无计划值,注明"无计划风量对比,仅判断风速合规性")。
-3. 瓦斯/CO/O2/温度是否在安全限值内。
-4. 评估整体风险等级:
-    - 风速低于下限异常情形:实测风速<0.5× 风速下限,判定为重大风险;0.5× 风速下限≤实测风速<0.8× 风速下限,判定为高风险;0.8× 风速下限≤实测风速<风速下限,判定为中风险。
-    - 风速高于上限异常情形:实测风速≥1.8× 风速上限,判定为重大风险;1.4× 风速上限≤实测风速<1.8× 风速上限,判定为高风险;风速上限≤实测风速<1.4× 风速上限,判定为中风险。
-    - 实际风量高于计划风量异常情形:实际风量≥1.8× 计划风量,判定为重大风险;1.5× 计划风量≤实际风量<1.8× 计划风量,判定为高风险;计划风量<实际风量<1.5× 计划风量,判定为中风险。
-    - 实际风量低于计划风量异常情形:实际风量<0.75× 计划风量,判定为重大风险;0.75× 计划风量≤实际风量<0.9× 计划风量,判定为高风险;0.9× 计划风量≤实际风量<计划风量,判定为中风险。
-
-### 第三步:输出报告
-输出结构化解读报告,严格按照以下格式:
-
-**{巷道名称}**
-- 当前风速:{数值} m/s
-- 规程允许范围:{min} ~ {max} m/s
-- 当前风量:{数值} m³/min{,计划风量:{数值} m³/min}
-- 瓦斯浓度:{数值}%(限值 {限值}%)
-- CO浓度:{数值} ppm(限值 {限值} ppm)
-- 温度:{数值}℃(限值 {限值}℃)
-- 设备状态:{状态}
-- 结论:{正常/风险等级}。{详细分析}
-- 依据:《煤矿安全规程》 {条款}
-- 建议:{具体措施}
-
-## 知识库检索规则
-使用 `query_knowledge_base` 工具从煤矿安全知识库中检索详细的条款原文、标准规范(AQ/MT)、规程解读等信息。
-- 构造检索问句时,使用"煤矿安全规程 + 巷道类型 + 指标 + 限值"等关键词组合,如"煤矿安全规程 掘进巷道 风速 限值"。
-- 检索结果优先作为报告中的"依据"引用。
-- 如果检索到多条结果,取最相关的一条引用。
-- 如果未检索到精确条款,说明"未检索到精确条款"并给出最接近的相关信息。
-
-## 重要原则
-- **数据真实性**:所有数据必须来自工具查询结果,绝不编造。
-- **引用规范**:规程引用优先使用 `query_knowledge_base` 检索到的条款原文。
-- **语言输出**:使用中文,禁止输出 ANSI 转义序列和控制字符。
-- **模糊处理**:不要暴露系统内部的工具名称、函数名称等,使用自然语言描述。
-- **计划风量缺失**:如果计划风量为0或不存在,注明"无计划风量对比,仅判断风速合规性"。

+ 0 - 1
skills/click-interpret-tun/SKILL.md

@@ -80,7 +80,6 @@ tools:
 - 设备状态:{状态}
 - 结论:{正常/重大风险/高风险/中风险}。{详细异常分析}
 - 依据:《煤矿安全规程》 {检索到的完整条款}
-- 建议:{针对性通风整改措施}
 ```
 
 - **报告输出完成后**:调用 `write_todos` 将"生成标准化结构化报告"标记为 `completed`

+ 10 - 0
skills/dialog-interpret/SKILL.md

@@ -20,6 +20,7 @@ description: |
 - 使用"风速/风量/瓦斯/甲烷/CO/一氧化碳/O₂/氧气/温度/传感器数据"等指标词,配合"怎么样/情况/分析/解读/是否满足/为什么报红/趋势"等询问词;
 - 询问某指标在**指定巷道/工作面内**的排名、对比或历史趋势(如"近7天温度趋势""该巷道风速变化");
 - 询问某巷道下有哪些设备、某模型下有哪些巷道等设备/巷道列表查询需求。
+- 询问"有哪些设备类型""设备分类""设备大类小类""传感器类型有哪些""strType 编码含义"等设备类型字典查询需求。
 
 > ⚠️ 涉及"全矿/所有巷道"跨巷道聚合排名类问题(如"全矿瓦斯浓度最高的三个点"),当前工具不支持跨巷道聚合;触发后按第 6.3 节处理,告知用户暂不支持并引导改为指定巷道查询。
 
@@ -84,6 +85,15 @@ description: |
 | `get_tun_list_by_modelid` | tunnel-model-server | 模型ID | 指定模型下的巷道列表 |
 | `query_tunnels_by_model` | tunnel-model-server | 模型ID | 根据模型ID查询巷道列表(新接口,含名称、类型、需风量等详情) |
 | `query_tunnel_list` | tunnel-model-server | 模型ID + 巷道名称(模糊) | **优先使用**。按模型+名称模糊匹配巷道,仅返回 modelId/tunnelId/tunnelName |
+| `get_device_kind_dict` | device-dict-server | 无 | 查询设备大类(deviceKind)和小类(strType)的编码-中文名称全量映射字典 |
+| `get_device_list_by_kind` | device-dict-server | device_kind(必填) | 根据设备大类编码查询该类型下的全量设备列表,返回 device_id、device_name、install_pos、station_name |
+| `query_device_realtime_data` | data-query-server | device_id(必填) | 查询指定设备的实时监测快照,返回最新读数、单位、采集时间、在线状态、报警信息 |
+
+> **设备类型字典场景**:当用户询问"有哪些设备类型""设备分类有哪些""系统中监控哪些传感器类型""设备大类小类有哪些"、或需要将设备类型编码(如 `modelsensor_speed`)翻译为中文时,调用 `get_device_kind_dict` 获取全量字典后解答。该工具无参数,返回 deviceKind(大类)和 strType(小类)两个维度的映射表。
+>
+> **按类型查设备场景**:当用户询问"列出所有风速传感器""有哪些甲烷传感器""某类型设备有哪些"时,先调用 `get_device_kind_dict` 获取 deviceKind 编码,再调用 `get_device_list_by_kind` 传入对应编码获取该类型的全部设备列表。参数 device_kind 为必填,需从 get_device_kind_dict 的返回结果中提取。
+>
+> **单设备实时数据场景**:当用户询问"设备XXX当前读数""传感器XXX的实时值""某个具体设备的最新数据"时,调用 `query_device_realtime_data` 传入设备ID获取实时快照。与 `query_device_data_by_id`(含报警+分页)相比,本工具返回结构更精简、延迟更低,适合单设备快速查询场景。设备ID可从前序工具(get_device_list_by_kind、query_devices_by_tunnel 等)的返回结果中提取。
 
 > **设备列表查询场景**:当用户询问"XXX巷道有哪些设备"或"查看XXX巷道的传感器"时,优先使用 `query_devices_by_tunnel`(按名称查询更直观);当已知巷道ID时使用 `query_devices_by_tunnel_id`。
 

+ 46 - 0
skills/wind-hazard-diagnosis/README.md

@@ -0,0 +1,46 @@
+# wind-hazard-diagnosis(风速风量监测与隐患判识 skill)
+
+通用 AI Agent skill:查询一通三防管控平台测风装置实时数据,结合云通防知识库判识**风速超限 / 风量不足 / 重大隐患**,输出**固定格式** md 分析报告。
+
+## 触发方式
+
+用户提到以下意图时自动调用:
+- 「查询当前风速风量数据及是否存在问题/隐患」
+- 「测风数据判识」「风速风量有没有问题/隐患」
+- 手动:`/wind-hazard-diagnosis` 或 `run_skill({name: "wind-hazard-diagnosis"})`
+
+## 依赖环境
+
+| 依赖 | 说明 |
+|---|---|
+| MCP 服务器 `tf_mcp` | 一通三防管控平台数据接口(`query_device_info` / `query_device_data_by_id` / `get_needq_all_data` / `execute_sql_query` 等) |
+| 云通防知识库 | `http://39.97.59.228:8067`(Obsidian Vault Knowledge Service,`/api/search`、`/api/retrieve`) |
+
+缺少任一依赖时,skill 会明确提示缺失环节,不静默降级。
+
+## 执行流程(9 步)
+
+1. 获取测风装置全量清单(`device` 表 `windrect` 大类,标注测试/模拟设备)
+2. 逐台获取实时数据(`query_device_data_by_id`,含模拟数据识别)
+3. 判定巷道类型(**绑定信息优先** + 名称关键词推断)→ 确定风速上下限
+4. 云通防知识库佐证限值(《煤矿安全规程》2025版 表6 / AQ1028-2006 表2)
+5. 风速超限判定(超速 / 欠速 / 接近上限)
+6. **需风量关联对比**(巷道ID精确 → 名称模糊匹配 → 未匹配明示),回复逐条写明"监测风量 → 需风量位置/数值/关联依据"供人工排查
+7. 重大隐患比对(《煤矿重大事故隐患判定标准》第八条:供风量 < 需风量 × 75%)
+8. 输出整体结论(总览 + 🔴🟠🟡 分级 + 重大隐患三选一)
+9. `ask` 询问是否生成 md 报告,同意则按**固定格式**输出
+
+## 安装
+
+将 `SKILL.md` 放入 Agent 的 skills 目录(如 `~/.reasonix/skills/wind-hazard-diagnosis/SKILL.md` 或项目 `.reasonix/skills/`),或通过 `install_skill` / `/wind-hazard-diagnosis` 安装。
+
+## 报告固定格式
+
+生成的报告文件名:`测风装置实时数据分析报告_YYYY-MM-DD.md`(同日覆盖)。
+
+固定 9 章节结构(0 报告元信息 → 一 判识框架 → 二 总体结论 → 三 逐台判识明细(13列) → 四 需风量关联明细(7列) → 五 需风量数据全表(6列) → 六 隐患分级清单 → 七 整改建议 → 八 判识依据出处 → 尾部声明),表格列、判定用语(正常/接近上限/超速/欠速/疑似超速/无法判识;一致/不符/严重不符;构成/不构成/数据不足无法判定)均为固定词表,任何一次生成格式一致。
+
+## 维护
+
+- 判识依据:云通防知识库(更新限值/条款时同步知识库即可,skill 每次运行实时检索佐证)
+- 报告模板变更:需同步修改 SKILL.md「固定报告格式」章节

+ 180 - 0
skills/wind-hazard-diagnosis/SKILL.md

@@ -0,0 +1,180 @@
+---
+name: wind-hazard-diagnosis
+description: 风速风量监测与隐患判识:查询平台测风装置实时数据,结合云通防知识库判识风速超限/风量不足/重大隐患,输出固定格式md报告。触发词:"查询当前风速风量"、"测风数据有没有问题/隐患"、"风速风量隐患判识"
+---
+
+# 风速风量监测与隐患判识(wind-hazard-diagnosis)
+
+通用 skill:当用户提到「查询当前风速风量数据及是否存在问题/隐患」「测风数据判识」「风速风量有没有问题」等意图时自动启用。**不局限于特定 agent**,任何具备以下依赖的环境均可运行。
+
+## 依赖(写死,运行前先确认可用)
+- **MCP 服务器 `tf_mcp`**:提供测风装置/实时数据/需风量查询工具(`query_device_info`、`query_device_data_by_id`、`get_needq_all_data`、`execute_sql_query`、`get_device_kind_dict` 等)。
+- **云通防知识库**:`http://39.97.59.228:8067`(Obsidian Vault Knowledge Service,`/api/search`、`/api/retrieve`、`/api/context`)。
+- 若以上不可用:明确告知用户"缺少 tf_mcp 或云知识库,无法完整执行",并说明缺哪一环。
+
+## 执行流程
+
+### Step 1 获取测风装置全量清单
+1. `mcp__tf_mcp__get_device_kind_dict` 确认测风装置大类为 `windrect`(小类:`windrect_dd/ds/ds_two/ds_two_235/ds_four/rect/normal/sensor/siyoute/test` 等)。
+2. `mcp__tf_mcp__query_device_info(device_kind="windrect")` 或 SQL `SELECT gdeviceid, strName, strInstallPos, gdevicetype, nSubStationID FROM device WHERE gdevicetype LIKE 'windrect%'` 获取全量清单。
+3. 标注设备类别:
+   - **测试/模拟设备**:类型含 `test`,或名称匹配 `测风N`、`测试`、`多参传感器`、`test`、`25xd`、`**矿井进风/回风` 等 → 标记为"测试设备"。
+   - **真实测点**:其余。
+
+### Step 2 逐台获取实时数据
+对每台设备调用 `mcp__tf_mcp__query_device_data_by_id(device_id=...)`(**必须逐台调用,不支持逗号批量**)。
+提取字段:`windSpeed`/`va`(风速 m/s)、`airVolume`/`m3`(风量 m³/min)、`temperature`、`tTime`(采样时间)、`netStatus`、`cachedAlarms`(活跃报警)、`alarmLogs`(历史报警)。
+- `netStatus` 为空或 0、无 `windSpeed` → 记录"**无实时数据/离线**"。
+- **模拟数据识别**:若多台设备数据呈高度雷同规律值(如风速≈1.3x 或 3.2x、风量≈120x、温度≈33~34℃、`sourcePressure≈8~9`),且**风量 ≠ 风速×断面×60**,判定为分站模拟/仿真数据,标注"模拟数据,不可用于生产判识"。
+
+### Step 3 判定巷道类型(确定风速上下限依据)
+优先级:
+1. **绑定信息优先**:若设备查询结果/`wind` 表/模型数据中带有明确的巷道类型(`strType`、`modeltype`、巷道属性等),直接采用并在报告中注明"绑定信息"。
+2. **名称关键词推断**(无绑定信息时),规则表:
+   - 无提升设备的风井/风硐 → 上限 15
+   - 专为升降物料的井筒 → 上限 12
+   - 风桥 → 上限 10
+   - 升降人员和物料的井筒(副井、副斜井、主斜井带提升)→ 上限 8
+   - 总进风巷/总回风巷/回风大巷/进风大巷 → 上限 8
+   - 架线电机车巷道 → 下限 1.0 / 上限 8
+   - 箕斗提升井兼作进风 → 上限 6
+   - 装有带式输送机兼作回风的井筒 → 上限 6
+   - 输送机巷、采区进/回风巷、胶运/胶带/皮带巷、顺槽(胶运/回风)→ 下限 0.25 / 上限 6
+   - 装有带式输送机兼作进风的井筒 → 上限 4
+   - 采煤工作面、掘进中的煤巷和半煤岩巷、工作面进/回风 → 下限 0.25 / 上限 4
+   - 掘进中的岩巷 → 下限 0.15 / 上限 4
+   - 其他通风人行巷道、辅运/行人巷 → 下限 0.15 / 无上限
+3. 报告中**注明每台设备的判定依据**(绑定信息 or 关键词推断),供人工核对。
+
+### Step 4 知识库佐证限值
+用 `web_fetch` 调云知识库确认限值表未过时:
+`http://39.97.59.228:8067/api/search?q=井巷允许风流速度 最低 最高 总回风巷 采煤工作面&mode=keyword&top_k=5&min_score=0.1`
+将命中片段(如《煤矿安全规程》2025版 表6 / AQ1028-2006 表2)作为引用出处写入报告。若检索不到或分过低,以 Step 3 规则表为准并注明"知识库未检索到佐证"。
+
+### Step 5 风速超限判定
+每台设备:实测风速 vs [下限,上限]:
+- `风速 > 上限` → 🔴 **超速**(写明超限百分比与限值来源)。
+- `风速 < 下限` → 🔴 **欠速/风速不足**(通风不良风险)。
+- 在区间内但贴近边界(≥85%上限)→ 提示"接近上限"。
+
+### Step 6 需风量关联与风量不足判定(核心)
+1. `mcp__tf_mcp__get_needq_all_data` 获取全量需风量:采煤面 `retWorkFaceList`、掘进面 `headingFaceList`、硐室 `roomList`、其他用风点 `otherList`、通风系统 `ventilationSystemList`。
+2. **筛选最新月份**:按 `nyear/nmonth`(及 `time`)取最新一期数据,避免多月份重复记录干扰。
+3. **关联顺序**(每个测点依次尝试,命中即停):
+   - **① 巷道 ID 精确关联**:测风装置 `wind` 表 `nTunID` 或 `query_device_data_by_id` 返回的 `tunnelId`,匹配需风量地点的 `tunIDs` 列表(逗号分隔),命中 → 关联成功,依据=巷道ID。
+   - **② 名称模糊匹配兜底**:规范化(去空格/单位/方位词)后,测点名称(或安装位置)与需风量地点 `strName` 满足"一方包含另一方 ≥1 个长度≥2 的核心词"(如"42上工作面""15211回风""充电硐室"),命中 → 关联成功,依据=名称匹配。
+   - **③ 不满足匹配条件** → 显示「**未匹配到对应用风地点**」。若该测点为系统/区段巷道(总进回风巷、大巷等),追加与所属通风系统 `ventilationSystemList` 的 `qra`(系统总需风量)做系统级对比,并注明。
+4. **判定**:监测风量 < 需风量(`fqneed` 或 `fqmax`,取 `fqmax` 为准并列出两者)→ 风量不足预警。
+5. **回复中必须逐条明示**(人工排查关联是否错误的关键):
+   > 监测风量 [数值] m³/min ←→ 需风量位置:「[地点名]」需风量 [fqneed/fqmax] m³/min(关联依据:巷道ID xxx / 名称匹配 / 未匹配到对应用风地点)
+
+### Step 7 重大隐患比对
+1. 知识库检索重大隐患条款:`/api/search?q=煤矿重大事故隐患判定标准 通风 风量不足 需风量&mode=keyword`。核心红线:**《煤矿重大事故隐患判定标准》第八条:实际供风量 < 需风量 × 75% → 通风系统重大事故隐患**。
+2. 判定:
+   - **系统级**:总进/回风测点风量(或系统进风合计)< 该系统需风量 × 75% → 🔴 构成重大事故隐患风险。
+   - **用风点级**:监测风量 < 该地点需风量 × 75% → 🔴 该地点供风严重不足(同条款精神)。
+   - 数据不可靠(模拟/缺失/异常值)无法支撑判定 → 明确写"**当前数据不足以支撑重大隐患直接判定**",并列出阻断判定的原因。
+3. 其他隐患(离线、超速、活跃报警、数据失配、测点失效)逐一与重大隐患条款比对,说明是否触碰红线。
+
+### Step 8 输出整体结论
+回复结构(会话形式即可,无需固定模板但须包含):
+- **总览**:总测点数 / 有效数据数 / 离线数 / 超速数 / 风量不足数 / 活跃报警数。
+- **分级清单**:🔴 高风险(离线、超速、活跃报警、风量严重不足)→ 🟠 中风险(数据失配、接近限值)→ 🟡 提示(模拟数据、测试设备、台账问题)。
+- **重大隐患结论**:构成 / 不构成 / 数据不足无法判定(三者取一,给出依据)。
+- **Step 6 的关联明细表**(监测风量→需风量位置/数值/关联依据)必须完整呈现。
+
+### Step 9 询问并生成报告
+1. 流程结束用 `ask` 工具询问(无 ask 环境用文字问):「是否生成 md 分析报告?」
+   - 选项:生成(默认,含完整数据表)/ 不生成(仅会话结论)。
+2. 用户同意 → 生成 md 文件:
+   - **文件名固定**:`测风装置实时数据分析报告_YYYY-MM-DD.md`(YYYY-MM-DD = 数据采样日期;同日重复生成直接覆盖旧文件)。
+   - **路径固定**:当前工作区根目录(用户另行指定路径时除外)。
+   - **格式必须严格按下方「固定报告格式」章节执行,不得自行增删章节/表格列/判定用语。**
+3. 用户拒绝 → 仅输出会话结论,不写文件。
+
+---
+
+## 固定报告格式(必须严格遵守,每次输出一致)
+
+> 规则:章节顺序固定;无内容章节保留标题并写"无";表格列名固定不可增减;判定用语使用固定词表。**任何一次生成都不得改变本格式。**
+
+### 文件
+```
+测风装置实时数据分析报告_YYYY-MM-DD.md
+```
+
+### 报告骨架(章节顺序固定,编号 0 至 八 不可变)
+
+```markdown
+# 测风装置实时数据分析报告(风速风量监测与隐患判识)
+
+## 0. 报告元信息
+| 字段 | 内容 |
+|---|---|
+| 报告名称 | 测风装置实时数据分析报告 |
+| 生成时间 | <本地时间> |
+| 数据采样时间 | <平台实时数据 tTime 起止范围> |
+| 数据来源 | tf_mcp:device表 / query_device_data_by_id / get_needq_all_data |
+| 判识知识库 | 云通防知识库 http://39.97.59.228:8067 |
+| 判识标准 | 《煤矿安全规程》2025版表6 / AQ1028-2006表2 / 《煤矿重大事故隐患判定标准》第八条 |
+| 测风装置总数 | <N> 台(真实测点 <X> / 测试设备 <Y>) |
+| 生成方式 | wind-hazard-diagnosis skill 自动生成 |
+
+## 一、判识框架与标准
+(固定四要素:①速域判识 ②量纲判识 ③红线判识 ④测风管理;附《煤矿安全规程》2025版表6 允许风流速度全表,每次照抄不省略行)
+
+## 二、总体结论
+### 2.1 总览表(固定行)
+| 指标 | 数值 |
+|---|---|
+| 测风装置总数 | |
+| 有实时数据测点 | |
+| 离线/无数据 | |
+| 超速(含疑似) | |
+| 风量不足 | |
+| 活跃报警 | |
+| 数据失配/模拟 | |
+| 重大隐患结论 | 构成 / 不构成 / 数据不足无法判定 |
+
+### 2.2 重大隐患判定(固定格式)
+**结论:<三选一>**
+判定依据:……
+阻断原因(仅"数据不足无法判定"时列出,编号列表):
+
+## 三、逐台判识明细(固定 13 列;测试设备并入表底并标注)
+| 序号 | 设备ID | 测点名称 | 设备类型 | 巷道类型(判定依据) | 风速(m/s) | 风量(m³/min) | 限值(最低~最高) | 风速判定 | 风量校验 | 需风量关联 | 报警/状态 | 问题分级 |
+
+## 四、需风量关联明细(固定 7 列,供人工排查关联)
+| 测点名称 | 监测风量(m³/min) | 需风量位置 | 需风量fqneed | 需风量fqmax | 关联依据 | 对比判定 |
+
+## 五、需风量数据全表(平台最新月份,固定 6 列)
+| 用风地点 | 类型 | fqneed(m³/min) | fqmax(m³/min) | 所属通风系统 | 系统总需风qra(m³/min) |
+
+## 六、隐患分级清单(固定三级,编号格式固定)
+### 🔴 高风险
+1. <测点>:<问题描述>(<判定依据>)
+### 🟠 中风险
+1. <测点>:<问题描述>(<判定依据>)
+### 🟡 提示
+1. <测点>:<问题描述>(<判定依据>)
+
+## 七、整改建议(固定编号列表,按 🔴🟠🟡 顺序归类)
+
+## 八、判识依据出处(固定列表:note_id / 标准号 / 条款)
+
+> 尾部声明(固定段落,不可省略):
+> 本报告基于 <采样时间> 平台实时快照,由 wind-hazard-diagnosis skill 自动生成。测风装置数据质量参差(模拟/失配占比 <N>%),判定以"需核查项"为主;重大隐患结论受数据可靠性限制未直接认定时,建议结合每旬人工测风数据复核后形成正式结论。
+```
+
+### 固定用语词表(判识/填写时只允许使用下列用语)
+- **风速判定**:`正常` / `接近上限` / `超速` / `欠速` / `疑似超速` / `无法判识`(超上限/低于下限时附"超/欠 X%")
+- **风量校验**(风速×断面×60 vs 上报风量):`一致`(±10%内)/ `不符`(10%~50%)/ `严重不符`(>50%)/ `—`(无断面或数据)
+- **需风量关联依据**:`巷道ID匹配` / `名称匹配` / `未命中`
+- **需风量位置**(未命中时固定写):`未匹配到对应用风地点`
+- **问题分级**:`高风险` / `中风险` / `提示`(对应 🔴🟠🟡)
+- **重大隐患结论**:`构成` / `不构成` / `数据不足无法判定`(三选一)
+
+### 填充规则
+- 每章内容为空时:保留该章标题,正文写"无"。
+- 表格无数据行时:保留表头,正文写"无数据"。
+- 判定用语必须取自词表;限值、依据、出处必须与知识库检索结果一致。

+ 5 - 0
skills_disabled.json

@@ -0,0 +1,5 @@
+{
+  "disabled": [
+    "vent-plan-review-summary"
+  ]
+}

+ 793 - 5
static/index.html

@@ -88,6 +88,13 @@ body {
 }
 .session-item:hover .btn-delete { opacity: 1; }
 .btn-delete:hover { color: var(--red); }
+.btn-pin {
+  background: none; border: none; color: var(--text-muted); cursor: pointer;
+  font-size: 13px; padding: 0 2px; opacity: 0; transition: opacity .1s, color .15s;
+}
+.session-item:hover .btn-pin { opacity: 1; }
+.btn-pin:hover { color: var(--yellow); }
+.btn-pin.pinned { opacity: 1; color: var(--yellow); }
 
 /* Main Chat Area */
 .main {
@@ -422,6 +429,224 @@ body {
 /* Responsive: hide detail panel on small screens */
 @media (max-width: 1000px) { .detail-panel { display: none; } }
 @media (max-width: 700px) { .sidebar { display: none; } }
+
+/* ============================================================
+   Model Selector — dropdown in chat header
+   ============================================================ */
+.model-selector {
+  position: relative; margin-left: auto; font-size: 12px;
+  user-select: none; z-index: 100;
+}
+.model-selector .current {
+  display: flex; align-items: center; gap: 6px;
+  padding: 5px 10px; border-radius: var(--radius-sm);
+  background: var(--bg-surface); border: 1px solid var(--border);
+  cursor: pointer; color: var(--text-secondary);
+  transition: border-color .15s, background .15s;
+  white-space: nowrap;
+}
+.model-selector .current:hover {
+  border-color: var(--accent); background: var(--bg-hover);
+}
+.model-selector .current .model-icon { font-size: 13px; }
+.model-selector .current .model-name { font-weight: 500; }
+.model-selector .current .model-arrow {
+  font-size: 9px; color: var(--text-muted); transition: transform .2s;
+}
+.model-selector.open .current .model-arrow { transform: rotate(180deg); }
+.model-selector .dropdown {
+  position: absolute; top: calc(100% + 4px); right: 0;
+  min-width: 200px; background: var(--bg-secondary);
+  border: 1px solid var(--border); border-radius: var(--radius);
+  box-shadow: 0 8px 24px rgba(0,0,0,.4); overflow: hidden;
+  animation: dropdownIn .15s ease;
+}
+@keyframes dropdownIn { from{opacity:0;transform:translateY(-4px)} to{opacity:1;transform:translateY(0)} }
+.model-selector .dropdown .model-option {
+  display: flex; align-items: center; gap: 8px;
+  padding: 9px 14px; cursor: pointer; color: var(--text-secondary);
+  transition: background .1s; border-bottom: 1px solid var(--border);
+}
+.model-selector .dropdown .model-option:last-child { border-bottom: none; }
+.model-selector .dropdown .model-option:hover { background: var(--bg-hover); }
+.model-selector .dropdown .model-option.active { color: var(--accent); background: var(--bg-surface); }
+.model-selector .dropdown .model-option .opt-tag {
+  font-size: 10px; padding: 2px 6px; border-radius: 3px;
+  font-weight: 600; text-transform: uppercase; flex-shrink: 0;
+}
+.model-selector .dropdown .model-option .opt-tag.pro { background: rgba(203,166,247,.2); color: var(--purple); }
+.model-selector .dropdown .model-option .opt-tag.flash { background: rgba(166,227,161,.2); color: var(--green); }
+.model-selector .dropdown .model-option .opt-name { flex: 1; }
+.model-selector .dropdown .model-option .opt-check { color: var(--accent); font-size: 12px; }
+
+/* Thinking Level Toggle — button group */
+.thinking-toggle {
+  display: flex; align-items: center; gap: 2px;
+  background: var(--bg-surface); border: 1px solid var(--border);
+  border-radius: var(--radius-sm); padding: 2px;
+  font-size: 11px; user-select: none;
+}
+.thinking-toggle .thinking-label {
+  padding: 0 6px; color: var(--text-muted); font-size: 12px;
+}
+.thinking-toggle button {
+  padding: 3px 8px; border: none; border-radius: 3px;
+  background: transparent; color: var(--text-muted);
+  font-family: var(--font-sans); font-size: 11px; cursor: pointer;
+  transition: background .12s, color .12s;
+}
+.thinking-toggle button:hover { color: var(--text-primary); }
+.thinking-toggle button.active {
+  background: var(--accent); color: var(--bg-tertiary); font-weight: 600;
+}
+
+/* ============================================================
+   Skill Manager Modal
+   ============================================================ */
+.modal-overlay {
+  position: fixed; inset: 0; z-index: 2000;
+  background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center;
+  animation: fadeIn .15s ease;
+}
+.modal {
+  background: var(--bg-secondary); border: 1px solid var(--border);
+  border-radius: var(--radius); width: 720px; max-width: 95vw;
+  max-height: 82vh; display: flex; flex-direction: column;
+  box-shadow: 0 16px 48px rgba(0,0,0,.5);
+}
+.modal-header {
+  display: flex; align-items: center; justify-content: space-between;
+  padding: 14px 18px; border-bottom: 1px solid var(--border);
+}
+.modal-header h3 { font-size: 15px; font-weight: 600; color: var(--accent); }
+.modal-header .modal-actions { display: flex; gap: 8px; }
+.modal-body {
+  flex: 1; overflow-y: auto; padding: 16px 18px;
+}
+.modal-body .skill-table { width: 100%; border-collapse: collapse; font-size: 12px; }
+.modal-body .skill-table th, .modal-body .skill-table td {
+  padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border);
+  vertical-align: middle;
+}
+.modal-body .skill-table th { color: var(--text-muted); font-weight: 600; font-size: 11px; text-transform: uppercase; }
+.modal-body .skill-table td { color: var(--text-secondary); }
+.modal-body .skill-table .skill-desc { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.modal-body .skill-table .skill-agents { font-size: 10px; color: var(--text-muted); }
+.modal-body .skill-table .skill-name { color: var(--accent); font-weight: 500; cursor: pointer; }
+.modal-body .skill-table .skill-name:hover { text-decoration: underline; }
+.modal-body .status-badge {
+  display: inline-block; padding: 2px 8px; border-radius: 3px;
+  font-size: 10px; font-weight: 600; text-transform: uppercase;
+}
+.modal-body .status-badge.enabled { background: rgba(166,227,161,.2); color: var(--green); }
+.modal-body .status-badge.disabled { background: rgba(243,139,168,.2); color: var(--red); }
+.modal-body .skill-detail {
+  margin-top: 12px; border: 1px solid var(--border); border-radius: var(--radius);
+  background: var(--bg-surface); padding: 14px 18px; font-size: 12px;
+  max-height: 300px; overflow-y: auto; line-height: 1.6;
+  display: none;
+}
+.modal-body .skill-detail h1,.modal-body .skill-detail h2,.modal-body .skill-detail h3 { color: var(--accent); margin: 12px 0 6px; }
+.modal-body .skill-detail code {
+  font-family: var(--font-mono); background: var(--bg-tertiary); padding: 1px 5px; border-radius: 3px; font-size: 11px;
+}
+.upload-zone {
+  margin-top: 12px; border: 2px dashed var(--border); border-radius: var(--radius);
+  padding: 20px; text-align: center; color: var(--text-muted); font-size: 12px;
+  cursor: pointer; transition: border-color .15s, background .15s;
+}
+.upload-zone:hover { border-color: var(--accent); background: var(--bg-surface); }
+.upload-zone.dragover { border-color: var(--green); background: rgba(166,227,161,.05); }
+.upload-zone input[type="file"] { display: none; }
+
+/* ============================================================
+   Mode Selector — permission mode dropdown
+   ============================================================ */
+.mode-selector {
+  position: relative; font-size: 11px; user-select: none; z-index: 99;
+}
+.mode-selector .mode-current {
+  display: flex; align-items: center; gap: 5px;
+  padding: 4px 8px; border-radius: var(--radius-sm);
+  background: var(--bg-surface); border: 1px solid var(--border);
+  cursor: pointer; color: var(--text-secondary); white-space: nowrap;
+  transition: border-color .15s, background .15s;
+}
+.mode-selector .mode-current:hover { border-color: var(--accent); }
+.mode-selector .mode-dot {
+  width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
+}
+.mode-selector .mode-dot.plan { background: var(--purple); }
+.mode-selector .mode-dot.full { background: var(--red); }
+.mode-selector .mode-dropdown {
+  display: none; position: absolute; top: calc(100% + 4px); right: 0;
+  min-width: 180px; background: var(--bg-secondary);
+  border: 1px solid var(--border); border-radius: var(--radius);
+  box-shadow: 0 8px 24px rgba(0,0,0,.4); overflow: hidden;
+  animation: dropdownIn .15s ease;
+}
+.mode-selector.open .mode-dropdown { display: block; }
+.mode-selector .mode-option {
+  display: flex; align-items: center; gap: 8px;
+  padding: 9px 14px; cursor: pointer; color: var(--text-secondary);
+  transition: background .1s; border-bottom: 1px solid var(--border);
+  font-size: 12px;
+}
+.mode-selector .mode-option:last-child { border-bottom: none; }
+.mode-selector .mode-option:hover { background: var(--bg-hover); }
+.mode-selector .mode-option.active { color: var(--accent); background: var(--bg-surface); }
+.mode-selector .mode-option .mode-opt-name { flex: 1; font-weight: 500; }
+.mode-selector .mode-option .mode-opt-desc { font-size: 10px; color: var(--text-muted); }
+.mode-selector .mode-option .mode-opt-check { color: var(--accent); font-size: 11px; }
+
+/* Interrupt approval card */
+.interrupt-card {
+  margin: 8px 0; padding: 12px 16px; border-radius: var(--radius);
+  border: 1px solid var(--yellow); background: rgba(249,226,175,.06);
+  display: flex; align-items: center; gap: 12px; font-size: 12px;
+}
+.interrupt-card .interrupt-msg { flex: 1; color: var(--yellow); }
+.interrupt-card button {
+  padding: 5px 14px; border-radius: var(--radius-sm);
+  border: none; font-size: 12px; cursor: pointer; font-weight: 500;
+  transition: background .15s;
+}
+.interrupt-card .btn-approve { background: var(--green); color: var(--bg-tertiary); }
+.interrupt-card .btn-approve:hover { background: #8fd98f; }
+.interrupt-card .btn-reject { background: var(--red); color: #fff; }
+.interrupt-card .btn-reject:hover { background: #e06c80; }
+
+/* ============================================================
+   Context Capacity Meter — detail panel
+   ============================================================ */
+.context-meter { margin-bottom: 12px; }
+.context-meter .cm-header {
+  display: flex; align-items: center; justify-content: space-between;
+  margin-bottom: 4px; font-size: 11px;
+}
+.context-meter .cm-header .cm-label { color: var(--text-muted); text-transform: uppercase; font-weight: 600; }
+.context-meter .cm-header .cm-value { color: var(--text-secondary); font-weight: 500; }
+.context-meter .cm-bar-outer {
+  height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; margin-bottom: 8px;
+}
+.context-meter .cm-bar-inner {
+  height: 100%; border-radius: 3px; transition: width .4s ease, background .4s ease;
+}
+.context-meter .cm-bar-inner.low    { background: var(--green); }
+.context-meter .cm-bar-inner.medium { background: var(--yellow); }
+.context-meter .cm-bar-inner.high   { background: var(--orange); }
+.context-meter .cm-bar-inner.crit   { background: var(--red); }
+.context-meter .cm-breakdown {
+  display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; font-size: 10px;
+}
+.context-meter .cm-breakdown .cm-item {
+  display: flex; justify-content: space-between; color: var(--text-muted);
+}
+.context-meter .cm-breakdown .cm-item .cm-cat { flex: 1; }
+.context-meter .cm-breakdown .cm-item .cm-num { font-weight: 500; color: var(--text-secondary); }
+.context-meter .cm-empty {
+  font-size: 10px; color: var(--text-muted); text-align: center; padding: 8px 0;
+}
 </style>
 </head>
 <body>
@@ -431,7 +656,10 @@ body {
   <aside class="sidebar">
     <div class="sidebar-header">
       <h2>💨 通风智能助手</h2>
-      <button class="btn-new" onclick="newSession()" title="新建会话">+</button>
+      <div style="display:flex;gap:6px;">
+        <button class="btn-new" onclick="openSkillManager()" title="技能管理" style="font-size:14px;">📋</button>
+        <button class="btn-new" onclick="newSession()" title="新建会话">+</button>
+      </div>
     </div>
     <div class="session-list" id="session-list"></div>
   </aside>
@@ -441,6 +669,31 @@ body {
     <div class="chat-header">
       <span class="status-dot" id="status-dot"></span>
       <span id="chat-title">新会话</span>
+      <!-- Model Selector -->
+      <div class="model-selector" id="model-selector">
+        <div class="current" onclick="toggleModelDropdown()">
+          <span class="model-icon">🧠</span>
+          <span class="model-name" id="current-model-label">deepseek-v4-pro</span>
+          <span class="model-arrow">▼</span>
+        </div>
+        <div class="dropdown" id="model-dropdown" style="display:none"></div>
+      </div>
+      <!-- Thinking Level Toggle -->
+      <div class="thinking-toggle" id="thinking-toggle">
+        <span class="thinking-label">💭</span>
+        <button data-level="off" class="active" onclick="switchThinking('off')">关闭</button>
+        <button data-level="high" onclick="switchThinking('high')">高</button>
+        <button data-level="highest" onclick="switchThinking('highest')">最高</button>
+      </div>
+      <!-- Mode Selector -->
+      <div class="mode-selector" id="mode-selector">
+        <div class="mode-current" onclick="toggleModeDropdown()">
+          <span class="mode-dot plan" id="mode-dot"></span>
+          <span id="mode-label">计划模式</span>
+          <span style="font-size:9px;color:var(--text-muted)">▼</span>
+        </div>
+        <div class="mode-dropdown" id="mode-dropdown"></div>
+      </div>
     </div>
     <div class="messages" id="messages">
       <div class="empty-state">
@@ -472,6 +725,24 @@ body {
 <!-- Toast container -->
 <div id="toast-container"></div>
 
+<!-- ============ Skill Manager Modal ============ -->
+<div class="modal-overlay" id="skill-modal" style="display:none;" onclick="if(event.target===this) closeSkillModal()">
+  <div class="modal">
+    <div class="modal-header">
+      <h3>📋 技能管理</h3>
+      <div class="modal-actions">
+        <button class="btn-send" onclick="document.getElementById('skill-upload-input').click()" style="width:auto;padding:6px 14px;font-size:12px;">📤 上传技能</button>
+        <button class="btn-send" onclick="closeSkillModal()" style="width:auto;padding:6px 14px;font-size:12px;background:var(--bg-surface);border:1px solid var(--border);color:var(--text-secondary);">✕ 关闭</button>
+      </div>
+    </div>
+    <div class="modal-body" id="skill-modal-body">
+      <div style="text-align:center;color:var(--text-muted);padding:40px;">加载中...</div>
+    </div>
+    <!-- Hidden upload input -->
+    <input type="file" id="skill-upload-input" accept=".zip" onchange="uploadSkill(this)" style="display:none;">
+  </div>
+</div>
+
 <script>
 // ============================================================
 // State
@@ -484,20 +755,310 @@ let toolLogs = [];
 let activeThinkBlock = null;
 let pendingDownloadUrl = null;
 
+// ── 认证令牌 ──
+// 支持从 localStorage(key: x-access-token)或 URL 参数获取
+function getAccessToken() {
+  const fromStorage = localStorage.getItem('x-access-token');
+  if (fromStorage) return fromStorage;
+  const params = new URLSearchParams(window.location.search);
+  return params.get('token') || '';
+}
+
+function authHeaders(extra = {}) {
+  const token = getAccessToken();
+  const headers = { ...extra };
+  if (token) headers['x-access-token'] = token;
+  return headers;
+}
+
 // ============================================================
 // Init
 // ============================================================
 document.addEventListener('DOMContentLoaded', () => {
   loadSessions();
   if (currentSessionId) loadHistory(currentSessionId);
+  fetchModels();
+  if (currentSessionId) fetchSessionMode();
 });
 
+// ============================================================
+// Model Switching
+// ============================================================
+const MODEL_TAGS = {
+  'deepseek-v4-pro': { tag: 'PRO', cls: 'pro', desc: '深度推理 · 高精度' },
+  'deepseek-v4-flash': { tag: 'FLASH', cls: 'flash', desc: '快速响应 · 低延迟' },
+};
+
+async function fetchModels() {
+  try {
+    const res = await fetch(`${API}/model`);
+    const data = await res.json();
+    const current = data.current || 'deepseek-v4-pro';
+    const available = data.available || ['deepseek-v4-pro', 'deepseek-v4-flash'];
+    const thinkLevel = data.thinking_level || 'off';
+
+    // Update current display
+    document.getElementById('current-model-label').textContent = current;
+
+    // Update thinking toggle buttons
+    document.querySelectorAll('#thinking-toggle button').forEach(btn => {
+      btn.classList.toggle('active', btn.dataset.level === thinkLevel);
+    });
+
+    // Render dropdown options
+    const dropdown = document.getElementById('model-dropdown');
+    dropdown.innerHTML = available.map(m => {
+      const info = MODEL_TAGS[m] || { tag: m, cls: '', desc: '' };
+      const isActive = m === current;
+      return `<div class="model-option${isActive ? ' active' : ''}" onclick="switchModel('${m}')">
+        <span class="opt-tag ${info.cls}">${info.tag}</span>
+        <span class="opt-name">${m}</span>
+        ${isActive ? '<span class="opt-check">✓</span>' : ''}
+        <span style="font-size:10px;color:var(--text-muted)">${info.desc}</span>
+      </div>`;
+    }).join('');
+  } catch(e) {
+    console.error('获取模型列表失败', e);
+  }
+}
+
+function toggleModelDropdown() {
+  const selector = document.getElementById('model-selector');
+  const dropdown = document.getElementById('model-dropdown');
+  const isOpen = dropdown.style.display !== 'none';
+  if (isOpen) {
+    dropdown.style.display = 'none';
+    selector.classList.remove('open');
+  } else {
+    dropdown.style.display = 'block';
+    selector.classList.add('open');
+  }
+}
+
+async function switchModel(modelName) {
+  try {
+    const res = await fetch(`${API}/model/switch`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ model: modelName }),
+    });
+    const data = await res.json();
+
+    if (data.error) {
+      showToast(data.error, 'error');
+      return;
+    }
+
+    // Update display
+    document.getElementById('current-model-label').textContent = data.current;
+    document.getElementById('model-dropdown').style.display = 'none';
+    document.getElementById('model-selector').classList.remove('open');
+
+    // Refresh dropdown state
+    await fetchModels();
+
+    showToast(data.message || `已切换到 ${modelName}`, 'info');
+  } catch(e) {
+    showToast(`切换失败: ${e.message}`, 'error');
+    console.error(e);
+  }
+}
+
+// Click outside to close dropdown
+document.addEventListener('click', (e) => {
+  const selector = document.getElementById('model-selector');
+  if (selector && !selector.contains(e.target)) {
+    document.getElementById('model-dropdown').style.display = 'none';
+    selector.classList.remove('open');
+  }
+});
+
+async function switchThinking(level) {
+  try {
+    const res = await fetch(`${API}/model/thinking`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ level: level }),
+    });
+    const data = await res.json();
+
+    if (data.error) {
+      showToast(data.error, 'error');
+      return;
+    }
+
+    // Update button states
+    document.querySelectorAll('#thinking-toggle button').forEach(btn => {
+      btn.classList.toggle('active', btn.dataset.level === level);
+    });
+
+    showToast(data.message || `思考级别已切换为「${level}」`, 'info');
+  } catch(e) {
+    showToast(`切换失败: ${e.message}`, 'error');
+    console.error(e);
+  }
+}
+
+// ============================================================
+// Permission Mode
+// ============================================================
+const MODES = {
+  plan:    { label: '计划模式',   dot: 'plan',    desc: '先生成计划,确认后执行' },
+  full:    { label: '完全访问',   dot: 'full',    desc: '无限制自动执行' },
+};
+let currentMode = 'plan';
+let pendingInterruptSession = null;
+
+async function fetchSessionMode() {
+  if (!currentSessionId) { currentMode = 'plan'; updateModeUI(); return; }
+  try {
+    const res = await fetch(`${API}/sessions/${currentSessionId}/mode`, {
+      headers: authHeaders(),
+    });
+    const data = await res.json();
+    currentMode = data.mode || 'plan';
+    updateModeUI();
+    renderModeDropdown();
+  } catch(e) { console.error('获取会话模式失败', e); }
+}
+
+function updateModeUI() {
+  const info = MODES[currentMode] || MODES['plan'];
+  document.getElementById('mode-label').textContent = info.label;
+  const dot = document.getElementById('mode-dot');
+  dot.className = 'mode-dot ' + info.dot;
+}
+
+function renderModeDropdown() {
+  const dd = document.getElementById('mode-dropdown');
+  dd.innerHTML = Object.entries(MODES).map(([k, v]) => {
+    const active = k === currentMode;
+    return `<div class="mode-option${active ? ' active' : ''}" onclick="switchMode('${k}')">
+      <span class="mode-dot ${v.dot}"></span>
+      <span class="mode-opt-name">${v.label}</span>
+      <span class="mode-opt-desc">${v.desc}</span>
+      ${active ? '<span class="mode-opt-check">✓</span>' : ''}
+    </div>`;
+  }).join('');
+}
+
+function toggleModeDropdown() {
+  const sel = document.getElementById('mode-selector');
+  sel.classList.toggle('open');
+  if (sel.classList.contains('open')) renderModeDropdown();
+}
+
+async function switchMode(mode) {
+  // 无会话时仅更新本地 UI 状态,模式会在首次 /chat 时随请求传入
+  if (!currentSessionId) {
+    currentMode = mode;
+    updateModeUI();
+    renderModeDropdown();
+    document.getElementById('mode-selector').classList.remove('open');
+    return;
+  }
+  try {
+    const res = await fetch(`${API}/sessions/${currentSessionId}/mode`, {
+      method: 'PUT',
+      headers: authHeaders({ 'Content-Type': 'application/json' }),
+      body: JSON.stringify({ mode: mode }),
+    });
+    const data = await res.json();
+    if (data.error) { showToast(data.error, 'error'); return; }
+    currentMode = mode;
+    updateModeUI();
+    renderModeDropdown();
+    document.getElementById('mode-selector').classList.remove('open');
+    showToast(data.message || `已切换为「${MODES[mode]?.label || mode}」`, 'info');
+  } catch(e) { showToast(`切换失败: ${e.message}`, 'error'); }
+}
+
+// Close mode dropdown on outside click
+document.addEventListener('click', (e) => {
+  const sel = document.getElementById('mode-selector');
+  if (sel && !sel.contains(e.target)) sel.classList.remove('open');
+});
+
+// ── Interrupt handling ──
+
+function showInterruptCard(message, node) {
+  const bubble = getOrCreateAssistantMsg();
+  // Remove any existing interrupt card
+  bubble.querySelectorAll('.interrupt-card').forEach(el => el.remove());
+
+  const card = document.createElement('div');
+  card.className = 'interrupt-card';
+  const nodeLabel = node === 'tools' ? '工具执行' : '继续执行';
+  card.innerHTML = `<span class="interrupt-msg">⚠️ ${message || nodeLabel + '等待审批'}</span>
+    <button class="btn-approve" onclick="resumeAfterInterrupt('approve')">✓ 批准</button>
+    <button class="btn-reject" onclick="resumeAfterInterrupt('reject')">✕ 拒绝</button>`;
+  bubble.appendChild(card);
+  scrollToBottom();
+}
+
+async function resumeAfterInterrupt(action) {
+  if (!pendingInterruptSession) return;
+  const sid = pendingInterruptSession;
+  pendingInterruptSession = null;
+
+  // Remove interrupt card
+  document.querySelectorAll('.interrupt-card').forEach(el => el.remove());
+
+  if (action === 'reject') {
+    showToast('已拒绝执行', 'info');
+    clearStatus();
+    return;
+  }
+
+  setStatus('已批准,继续执行...');
+  document.getElementById('status-dot').classList.add('streaming');
+
+  try {
+    const res = await fetch(`${API}/chat/resume`, {
+      method: 'POST',
+      headers: authHeaders({ 'Content-Type': 'application/json' }),
+      body: JSON.stringify({ session_id: sid, action: 'approve' }),
+    });
+
+    if (!res.ok) {
+      const errText = await res.text();
+      throw new Error(`HTTP ${res.status}: ${errText}`);
+    }
+
+    const reader = res.body.getReader();
+    const decoder = new TextDecoder();
+    let buffer = '';
+
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      buffer += decoder.decode(value, { stream: true });
+      const lines = buffer.split('\n');
+      buffer = lines.pop() || '';
+      for (const line of lines) {
+        if (!line.startsWith('data: ')) continue;
+        try {
+          const event = JSON.parse(line.slice(6));
+          handleSSEEvent(event);
+        } catch(e) {}
+      }
+    }
+  } catch(e) {
+    if (e.name !== 'AbortError') {
+      showToast(`恢复执行失败: ${e.message}`, 'error');
+    }
+  } finally {
+    document.getElementById('status-dot').classList.remove('streaming');
+    clearStatus();
+  }
+}
+
 // ============================================================
 // Session Management
 // ============================================================
 async function loadSessions() {
   try {
-    const res = await fetch(`${API}/sessions`);
+    const res = await fetch(`${API}/sessions`, { headers: authHeaders() });
     const data = await res.json();
     renderSessionList(data.sessions || []);
   } catch(e) { console.error('加载会话列表失败', e); }
@@ -514,6 +1075,7 @@ function renderSessionList(sessions) {
          onclick="switchSession('${s.session_id}')">
       <span class="title">${escHtml(s.title || s.session_id?.slice(0,8) || '新会话')}</span>
       <span class="count">${s.message_count || 0}</span>
+      <button class="btn-pin ${s.sort_order > 0 ? 'pinned' : ''}" onclick="event.stopPropagation();togglePin('${s.session_id}')" title="${s.sort_order > 0 ? '取消置顶' : '置顶'}">📌</button>
       <button class="btn-delete" onclick="event.stopPropagation();deleteSession('${s.session_id}')">×</button>
     </div>
   `).join('');
@@ -534,8 +1096,12 @@ function newSession() {
   document.getElementById('detail-content').innerHTML = '<div style="color:var(--text-muted);font-size:12px;text-align:center;margin-top:40px;">发送消息后<br>此处显示任务进度和工具调用</div>';
   todoItems = [];
   toolLogs = [];
+  contextData = null;
   pendingDownloadUrl = null;
   loadSessions();
+  currentMode = 'plan';
+  updateModeUI();
+  renderModeDropdown();
 }
 
 async function switchSession(sid) {
@@ -546,18 +1112,34 @@ async function switchSession(sid) {
   document.getElementById('chat-title').textContent = sid.slice(0,8) + '...';
   loadSessions();
   await loadHistory(sid);
+  fetchSessionMode();
+  fetchContextUsage();
 }
 
 async function deleteSession(sid) {
   abortStream();
-  await fetch(`${API}/sessions/${sid}`, { method: 'DELETE' });
+  await fetch(`${API}/sessions/${sid}`, {
+    method: 'DELETE',
+    headers: authHeaders(),
+  });
   if (currentSessionId === sid) newSession();
   else loadSessions();
 }
 
+async function togglePin(sid) {
+  try {
+    const res = await fetch(`${API}/sessions/${sid}/pin`, {
+      method: 'PUT',
+      headers: authHeaders({ 'Content-Type': 'application/json' }),
+    });
+    if (!res.ok) return;
+    await loadSessions();
+  } catch(e) { /* 静默 */ }
+}
+
 async function loadHistory(sid) {
   try {
-    const res = await fetch(`${API}/chat/history/${sid}`);
+    const res = await fetch(`${API}/chat/history/${sid}`, { headers: authHeaders() });
     const data = await res.json();
     const msgsEl = document.getElementById('messages');
     msgsEl.innerHTML = '';
@@ -832,10 +1414,64 @@ function updateTodoList(todos) {
   updateDetailPanel();
 }
 
+// ── 上下文容量 ──
+let contextData = null;
+
+async function fetchContextUsage() {
+  if (!currentSessionId) { contextData = null; updateDetailPanel(); return; }
+  try {
+    const res = await fetch(`${API}/sessions/${currentSessionId}/context`, {
+      headers: authHeaders(),
+    });
+    if (!res.ok) return;
+    contextData = await res.json();
+    updateDetailPanel();
+  } catch(e) { /* 静默 */ }
+}
+
+function renderContextMeter() {
+  if (!contextData || contextData.total_limit == null) {
+    return '<div class="context-meter"><div class="cm-empty">📊 暂无上下文数据<br><span style="font-size:9px">发送消息后自动统计</span></div></div>';
+  }
+
+  const pct = contextData.usage_pct || 0;
+  const limit = contextData.total_limit;
+  const used = contextData.current_usage;
+  const bd = contextData.breakdown || {};
+
+  let cls = 'low';
+  if (pct > 75) cls = 'crit';
+  else if (pct > 50) cls = 'high';
+  else if (pct > 25) cls = 'medium';
+
+  const fmt = (n) => n != null ? (n >= 1000 ? (n/1000).toFixed(1)+'K' : n) : '—';
+
+  return `<div class="context-meter">
+    <div class="cm-header">
+      <span class="cm-label">🧠 上下文容量</span>
+      <span class="cm-value">${fmt(used)} / ${fmt(limit)} (${pct}%)</span>
+    </div>
+    <div class="cm-bar-outer">
+      <div class="cm-bar-inner ${cls}" style="width:${Math.min(pct,100)}%"></div>
+    </div>
+    <div class="cm-breakdown">
+      <div class="cm-item"><span class="cm-cat">💬 消息</span><span class="cm-num">${fmt(bd.messages)}</span></div>
+      <div class="cm-item"><span class="cm-cat">🔧 系统工具</span><span class="cm-num">${fmt(bd.tools)}</span></div>
+      <div class="cm-item"><span class="cm-cat">📋 技能</span><span class="cm-num">${fmt(bd.skills)}</span></div>
+      <div class="cm-item"><span class="cm-cat">🔌 MCP</span><span class="cm-num">${fmt(bd.mcp)}</span></div>
+      <div class="cm-item"><span class="cm-cat">📝 系统提示</span><span class="cm-num">${fmt(bd.system_prompt)}</span></div>
+      <div class="cm-item"><span class="cm-cat">📦 其他</span><span class="cm-num">${fmt(bd.other)}</span></div>
+    </div>
+  </div>`;
+}
+
 function updateDetailPanel() {
   const el = document.getElementById('detail-content');
   let html = '';
 
+  // ── 上下文容量(始终在最上方)──
+  html += renderContextMeter();
+
   if (todoItems.length) {
     html += '<div class="section"><div class="section-title">📝 任务进度</div>';
     todoItems.forEach(t => {
@@ -967,6 +1603,7 @@ async function sendMessage() {
   const fd = new FormData();
   fd.append('message', message || (file ? '请审查这份文件' : ''));
   if (currentSessionId) fd.append('session_id', currentSessionId);
+  fd.append('mode', currentMode);
   if (file) {
     fd.append('file', file);
     document.getElementById('btn-attach').classList.add('has-file');
@@ -995,6 +1632,7 @@ async function sendMessage() {
     const res = await fetch(`${API}/chat`, {
       method: 'POST',
       body: fd,
+      headers: authHeaders(),
       signal: currentAbortController.signal,
     });
 
@@ -1009,6 +1647,7 @@ async function sendMessage() {
       currentSessionId = sid;
       localStorage.setItem('vent_session_id', sid);
       loadSessions();
+      fetchSessionMode();
     }
 
     // Stream reader
@@ -1165,7 +1804,7 @@ function handleSSEEvent(event) {
       if (event.download_url) {
         pendingDownloadUrl = event.download_url;
       }
-      // 最终渲染 markdown(仅更新 .msg-markdown,保留 Agent 卡片和工具卡片)
+      // 最终渲染 markdown ……
       {
         const bubble = getOrCreateAssistantMsg();
         if (bubble) {
@@ -1193,6 +1832,7 @@ function handleSSEEvent(event) {
       }
       activeThinkBlock = null;
       loadSessions();
+      fetchContextUsage();
       break;
 
     case 'error':
@@ -1203,6 +1843,12 @@ function handleSSEEvent(event) {
     case 'generating':
       setStatus('正在生成报告...');
       break;
+
+    case 'interrupt':
+      // LangGraph 中断:需要用户审批
+      pendingInterruptSession = currentSessionId;
+      showInterruptCard(event.message || '智能体暂停,等待审批...', event.node);
+      break;
   }
 }
 
@@ -1276,6 +1922,148 @@ document.addEventListener('DOMContentLoaded', () => {
     input.style.height = Math.min(input.scrollHeight, 150) + 'px';
   });
 });
+
+// ============================================================
+// Skill Manager
+// ============================================================
+async function openSkillManager() {
+  document.getElementById('skill-modal').style.display = 'flex';
+  await refreshSkillList();
+}
+
+function closeSkillModal() {
+  document.getElementById('skill-modal').style.display = 'none';
+}
+
+// ESC to close
+document.addEventListener('keydown', (e) => {
+  if (e.key === 'Escape') closeSkillModal();
+});
+
+async function refreshSkillList() {
+  const body = document.getElementById('skill-modal-body');
+  body.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:40px;">加载中...</div>';
+
+  try {
+    const res = await fetch(`${API}/skills`);
+    const data = await res.json();
+    const skills = data.skills || [];
+
+    if (!skills.length) {
+      body.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:40px;">暂无技能</div>';
+      return;
+    }
+
+    let html = `<table class="skill-table">
+      <thead><tr>
+        <th>技能名称</th><th>描述</th><th>所属智能体</th><th>状态</th><th>操作</th>
+      </tr></thead><tbody>`;
+
+    skills.forEach(s => {
+      const statusCls = s.enabled ? 'enabled' : 'disabled';
+      const statusText = s.enabled ? '启用' : '禁用';
+      const toggleLabel = s.enabled ? '停用' : '启用';
+      html += `<tr>
+        <td><span class="skill-name" onclick="viewSkill('${s.name}')">${escHtml(s.display_name || s.name)}</span></td>
+        <td class="skill-desc" title="${escHtml(s.description)}">${escHtml(s.description) || '—'}</td>
+        <td class="skill-agents">${s.agents.map(a => escHtml(a)).join('<br>')}</td>
+        <td><span class="status-badge ${statusCls}">${statusText}</span></td>
+        <td>
+          <button class="btn-send" onclick="viewSkill('${s.name}')" style="width:auto;padding:3px 10px;font-size:11px;background:var(--bg-surface);border:1px solid var(--border);color:var(--text-secondary);margin-right:4px;">查看</button>
+          <button class="btn-send" onclick="toggleSkill('${s.name}')" style="width:auto;padding:3px 10px;font-size:11px;background:var(--bg-surface);border:1px solid var(--border);color:var(--text-secondary);">${toggleLabel}</button>
+        </td>
+      </tr>`;
+    });
+
+    html += '</tbody></table>';
+
+    // Upload zone
+    html += `<div class="upload-zone" id="skill-upload-zone" onclick="document.getElementById('skill-upload-input').click()">
+      📤 点击上传新技能(.zip 格式)
+    </div>`;
+
+    // Detail section
+    html += '<div class="skill-detail" id="skill-detail"></div>';
+
+    body.innerHTML = html;
+  } catch(e) {
+    body.innerHTML = `<div style="text-align:center;color:var(--red);padding:40px;">加载失败: ${escHtml(e.message)}</div>`;
+    console.error(e);
+  }
+}
+
+async function viewSkill(name) {
+  const detail = document.getElementById('skill-detail');
+  if (!detail) return;
+
+  detail.style.display = 'block';
+  detail.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:20px;">加载中...</div>';
+
+  try {
+    const res = await fetch(`${API}/skills/${name}`);
+    if (!res.ok) {
+      const err = await res.json();
+      detail.innerHTML = `<div style="color:var(--red);padding:20px;">${escHtml(err.error || '加载失败')}</div>`;
+      return;
+    }
+    const data = await res.json();
+
+    // Strip YAML frontmatter before rendering
+    let content = data.content || '';
+    content = content.replace(/^---[\s\S]*?---\n*/, '');
+
+    detail.innerHTML = `<div style="margin-bottom:8px;color:var(--accent);font-weight:600;">📄 ${escHtml(name)} / SKILL.md</div>
+      <div>${renderMarkdown(content)}</div>`;
+  } catch(e) {
+    detail.innerHTML = `<div style="color:var(--red);padding:20px;">加载失败: ${escHtml(e.message)}</div>`;
+    console.error(e);
+  }
+}
+
+async function toggleSkill(name) {
+  try {
+    const res = await fetch(`${API}/skills/${name}/toggle`, { method: 'POST' });
+    const data = await res.json();
+    if (data.error) {
+      showToast(data.error, 'error');
+      return;
+    }
+    showToast(data.message, 'info');
+    await refreshSkillList();
+  } catch(e) {
+    showToast(`操作失败: ${e.message}`, 'error');
+    console.error(e);
+  }
+}
+
+async function uploadSkill(input) {
+  const file = input.files[0];
+  if (!file) return;
+
+  const fd = new FormData();
+  fd.append('file', file);
+
+  try {
+    showToast('正在上传技能...', 'info');
+    const res = await fetch(`${API}/skills/upload`, {
+      method: 'POST',
+      body: fd,
+    });
+    const data = await res.json();
+
+    if (!res.ok) {
+      showToast(data.error || '上传失败', 'error');
+      return;
+    }
+
+    showToast(data.message, 'info');
+    input.value = '';
+    await refreshSkillList();
+  } catch(e) {
+    showToast(`上传失败: ${e.message}`, 'error');
+    console.error(e);
+  }
+}
 </script>
 </body>
 </html>

+ 227 - 0
tests/chat_test_cases.md

@@ -0,0 +1,227 @@
+# /api/chat 对话测试用例
+
+> 基于 dialog-interpret + needq-calc + wind-hazard-diagnosis 技能 + 已注册工具覆盖编写
+> 
+> 测试方式:`POST /api/chat`,参数 `message`(form-data)
+
+---
+
+## 一、巷道监测数据解读(dialog-interpret 核心)
+
+### 1.1 默认指标解读(风速 + 风量)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C01 | "15216 辅运起坡段当前情况怎么样" | 触发 dialog-interpret,自动解读风速+风量,调用 query_tun_data_by_id → 输出对比报告 |
+| C02 | "帮我看看北二盘区回风巷" | 同上,未指定指标时默认解读风速+风量 |
+| C03 | "三水平主运大巷现在数据如何" | 如果 query_tunnel_list 匹配到多条,列出候选让用户继续提问 |
+
+### 1.2 指定指标解读
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C04 | "15216 工作面瓦斯浓度怎么样" | 查询指定巷道瓦斯数据,对比规程阈值 |
+| C05 | "北二回风巷 CO 是否超限" | 查询 CO 浓度,判断是否 ≥0.0024% |
+| C06 | "15216 辅运起坡段温度多少" | 查询温度,按采掘面 26/30℃ 规则判断 |
+| C07 | "主运大巷氧气含量正常吗" | 查询 O₂,判断是否 ≥20% |
+
+### 1.3 风量合规判断
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C08 | "二水平辅运大巷风量满足计划吗" | 查询实际风量 vs 计划风量,计算缺口比例 |
+| C09 | "15216 工作面风速是否超限" | 查询风速,对比规程允许范围 |
+
+---
+
+## 二、设备查询(新增工具)
+
+### 2.1 设备类型字典
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C10 | "系统里有哪些设备类型" | 调用 get_device_kind_dict → 列出大类小类 |
+| C11 | "传感器类型都有哪些" | 同上 |
+| C12 | "modelsensor_speed 是什么设备" | 调用 get_device_kind_dict → 翻译编码为中文 |
+
+### 2.2 按类型查设备列表
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C13 | "列出所有风速传感器" | 先 get_device_kind_dict 取编码 → get_device_list_by_kind → 输出设备清单 |
+| C14 | "有哪些甲烷传感器" | 同上 |
+| C15 | "温度传感器有哪些" | 同上 |
+
+### 2.3 单设备实时数据
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C16 | "设备 FAN-001 当前读数多少" | 调用 query_device_realtime_data(device_id="FAN-001") |
+| C17 | "传感器 11111004 现在什么状态" | 同上,返回在线状态、读数、报警 |
+
+### 2.4 巷道关联设备
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C18 | "15216 辅运起坡段绑了哪些设备" | 调用 query_devices_by_tunnel |
+| C19 | "北二回风巷有哪些传感器" | 同上 |
+
+---
+
+## 三、历史趋势查询
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C20 | "15216 工作面近7天风速变化趋势" | 调用 list_ventanaly_monitor_data_days → 输出趋势图描述(均值/最值/趋势方向) |
+| C21 | "北二回风巷上个月温度怎么样" | 换算时间范围,调用历史数据工具 |
+| C22 | "三水平主运大巷最近24小时风量有波动吗" | 同上 |
+
+---
+
+## 四、巷道/模型列表查询
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C23 | "帮我查一下模型下面有哪些巷道" | 调用 query_tunnels_by_model |
+| C24 | "搜一下名字带'辅运'的巷道" | 调用 query_tunnel_list 模糊匹配 |
+| C25 | "15216 这个模型下有多少条巷道" | 调用 query_tunnels_by_model |
+
+---
+
+## 五、规程知识库查询
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C26 | "煤矿安全规程对回风巷风速有什么要求" | 调用 query_knowledge_base → 返回条款原文 |
+| C27 | "采煤工作面瓦斯报警浓度是多少" | 同上 |
+| C28 | "AQ1056 风量计算方法是什么" | 同上 |
+
+---
+
+## 六、需风量计算(needq-calc 技能)
+
+### 6.1 采煤工作面计算
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C29 | "计算一下 15216 工作面的需风量" | 触发 needq-calc,逐步引导填写参数(采高、控顶距、瓦斯涌出量等) |
+| C30 | "采煤面按瓦斯量算需风量,绝对涌出量 3.2,不均衡系数 1.5" | 调用 calc_face_by_gas |
+| C31 | "采煤面按人数算需风量,最多 40 人" | 调用 calc_face_by_workers |
+| C32 | "采煤面按风速验算,断面 18 平米" | 调用 calc_face_by_wind_speed |
+
+### 6.2 掘进工作面计算
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C33 | "掘进面需风量怎么算" | 触发 needq-calc,引导参数 |
+| C34 | "掘进面炸药量 8kg,帮我算需风量" | 调用 calc_tunnel_by_explosives |
+| C35 | "掘进面 15 个人同时作业,需风量多少" | 调用 calc_tunnel_by_workers |
+
+### 6.3 硐室计算
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C36 | "机电硐室需风量,设备功率 200kW" | 调用 calc_chamber_by_equipment |
+| C37 | "硐室按风速验算,断面 12 平米" | 调用 calc_chamber_by_wind_speed |
+
+### 6.4 管控平台数据
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C38 | "通防管控平台上各地点需风量是多少" | 调用 get_needq_all_data → 展示所有用风地点数据 |
+| C39 | "管控平台的配风情况帮我看看" | 同上 |
+
+### 6.5 汇总计算
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C40 | "帮我把刚才算的各地点汇总一下总需风量" | 调用 calc_total_air_volume |
+| C41 | "这个工作面的有效断面帮我算一下" | 调用 calc_effective_area |
+
+---
+
+## 七、风速风量隐患判识(wind-hazard-diagnosis 技能)
+
+### 7.1 全量诊断触发
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C51 | "查询当前风速风量数据有没有问题" | 触发 wind-hazard-diagnosis,走 Step1~Step8 全流程:获取测风装置清单 → 逐台查实时数据 → 巷道类型判定 → 风速超限判定 → 需风量关联 → 重大隐患比对 → 输出分级结论 |
+| C52 | "帮我做一次测风数据判识" | 同上,触发完整诊断流程 |
+| C53 | "风速风量有没有隐患" | 同上,最后询问是否生成 md 报告 |
+| C54 | "全矿测风装置数据排查一下" | 获取全量清单,逐台判识,输出总览+逐台明细 |
+
+### 7.2 生成报告
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C55 | "生成测风数据分析报告" | 触发技能 Step9:生成固定格式 `测风装置实时数据分析报告_YYYY-MM-DD.md`,严格按 0~八 章节输出 |
+| C56 | "把刚才的诊断结果写成报告" | 同上,复用前一轮已获取的数据生成报告 |
+| C57 | "不要报告,就告诉我结论" | 技能结束时不写文件,仅输出会话结论 |
+
+### 7.3 重点巷道诊断
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C58 | "15216 工作面的测风数据有没有超限" | 触发技能,定位到该工作面关联的测风装置,逐项判识风速/风量/报警 |
+| C59 | "北二回风巷风速风量排查隐患" | 同上,按巷道类型(采区回风巷→0.25~6 m/s)判定 |
+| C60 | "总回风巷风量够不够" | 关联通风系统总需风量 qra,判定是否满足系统级需风量 |
+
+### 7.4 模拟/离线数据场景
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C61 | "测风数据看起来很规律像是模拟的怎么办" | 技能 Step2 模拟数据识别:检查风量≠风速×断面×60、多台数据雷同 → 标注"模拟数据,不可用于生产判识" |
+| C62 | "有台测风装置离线了怎么处理" | 标记为"无实时数据/离线",在报告中归入 🔴 高风险 |
+
+### 7.5 隐患分级与红线判定
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C63 | "有没有触犯重大隐患判定标准的" | 技能 Step7:系统级/用风点级供风量 < 需风量×75% → 判定是否构成重大事故隐患 |
+| C64 | "帮我列出所有高风险点" | 输出 🔴 高风险(离线/超速/活跃报警/风量严重不足)清单 |
+
+---
+
+## 八、多轮对话
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C65 | 第1轮:"15216 辅运起坡段风速怎么样" → 第2轮:"那风量呢" | 第2轮应记住上下文(同一巷道),无需重复巷道名 |
+| C66 | 第1轮:"帮我算采煤面需风量" → 第2轮:"刚才算的是按瓦斯算的吗" | 记住前一轮计算上下文 |
+| C67 | 第1轮:"列出所有风速传感器" → 第2轮:"第3个的实时数据" | 记住设备列表上下文,取第3个设备ID查询 |
+| C68 | 第1轮:"全矿风速风量排查" → 第2轮:"生成报告" | 第2轮复用前一轮已获取的诊断数据,生成 md 报告 |
+
+---
+
+## 九、边缘/异常场景
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C69 | "全矿瓦斯浓度最高的三个点在哪儿" | 触发 skill 6.3 节:告知暂不支持跨巷道聚合排序,引导用户指定巷道 |
+| C70 | "某某不存在的巷道风速怎么样" | query_tunnel_list 无结果,告知用户未找到,建议检查名称 |
+| C71 | "今天天气怎么样" | 不进通风技能,作为通用对话回复或引导回通风话题 |
+| C72 | 空消息 "" | 返回 422 参数校验错误 |
+| C73 | "帮我解读"(无巷道名) | 反问用户需要解读哪条巷道 |
+| C74 | "计算需风量"(无参数) | needq-calc 技能引导用户逐步提供计算参数 |
+| C75 | "测风数据有没有隐患"(tf_mcp 不可用) | 技能 Step 依赖检查:明确告知"缺少 tf_mcp 或云知识库,无法完整执行" |
+
+---
+
+## 十、意图路由测试(合并后统一走通风对话助手) 
+
+---
+
+## 运行说明
+
+```bash
+# 单条测试
+curl -X POST http://localhost:8070/api/chat \
+  -H "Content-Type: application/json" \
+  -d '{"message": "15216 辅运起坡段风速怎么样"}'
+
+# 多轮(带上轮 session_id)
+curl -X POST http://localhost:8070/api/chat \
+  -H "Content-Type: application/json" \
+  -d '{"message": "那风量呢", "session_id": "上轮返回的session_id"}'
+```

+ 300 - 0
tests/mcp_test_report.json

@@ -0,0 +1,300 @@
+{
+  "title": "通风模型算法 MCP 接口测试报告",
+  "server": "http://39.97.59.228:8071/mcp",
+  "model_id": "2012326636757958658",
+  "timestamp": "2026-08-04T09:54:38.524614",
+  "summary": {
+    "total": 15,
+    "passed": 15,
+    "failed": 0,
+    "pass_rate": "100.0%",
+    "biz_ok": 11,
+    "biz_timeout": 2,
+    "biz_500": 1,
+    "total_elapsed_ms": 378668.63,
+    "avg_elapsed_ms": 25244.58
+  },
+  "results": [
+    {
+      "id": "TC-01",
+      "tool": "check_model_connect_status",
+      "category": "故障诊断",
+      "desc": "模型网络连通检查",
+      "params": {
+        "model_id": "2012326636757958658"
+      },
+      "timestamp": "2026-08-04T09:48:19.569558",
+      "success": true,
+      "elapsed_ms": 1148.5,
+      "has_content": true,
+      "content_len": 23846,
+      "size": "23.3 KB",
+      "structure": "{success: bool, message: str, code: int, result: {result: str(19924)}, timestamp: int}",
+      "data_preview": "{\"success\": true, \"message\": \"\", \"code\": 200, \"result\": {\"result\": \"{\\\"data\\\":{\\\"0\\\":[\\\"4\\\",\\\"6\\\",\\\"8\\\",\\\"10\\\",\\\"12\\\",\\\"14\\\",\\\"16\\\",\\\"18\\\",\\\"22\\\",\\\"28\\\",\\\"30\\\",\\\"32\\\",\\\"37\\\",\\\"41\\\",\\\"43\\\",\\\"49\\\",\\\"51\\\",\\\"52\\\",\\\"54\\\",\\\"56\\\",\\\"58\\\",\\\"59\\\",\\\"61\\\",\\\"62\\\",\\\"64\\\",\\\"66\\\",\\\"68\\\",\\\"69\\\",\\\"71\\\",\\\"73\\\",\\\"77\\\",\\\"78\\\",\\\"80\\\",\\\"82\\\",\\\"84\\\",\\\"88\\\",\\\"89\\\",\\\"90\\\",\\\"92\\\",\\\"95\\\",\\\"97\\\",\\\"98\\\",\\\"100\\\",\\\"102\\\",\\\"104\\\",\\\"105\\\",\\\"107\\\",\\\"108\\\",\\\"110\\\",\\\"113\\\",\\\"116\\\",\\\"117\\\",\\\"121\\\",\\\"122\\\",\\\"124\\\",\\\"126\\\",\\\"127\\\",\\\"1",
+      "deep_success": true
+    },
+    {
+      "id": "TC-02",
+      "tool": "check_model_one_dir_cycle",
+      "category": "故障诊断",
+      "desc": "模型循环风路检查",
+      "params": {
+        "model_id": "2012326636757958658"
+      },
+      "timestamp": "2026-08-04T09:48:20.719975",
+      "success": true,
+      "elapsed_ms": 850.65,
+      "has_content": true,
+      "content_len": 152,
+      "size": "152 B",
+      "structure": "{success: bool, message: str, code: int, result: {result: str}, timestamp: int}",
+      "data_preview": "{\"success\": true, \"message\": \"\", \"code\": 200, \"result\": {\"result\": \"{\\\"bSucced\\\":0,\\\"cnt\\\":0,\\\"strRemark\\\":\\\"模型中没有单向回路\\\"}\"}, \"timestamp\": 1785808101540}",
+      "deep_success": true
+    },
+    {
+      "id": "TC-03",
+      "tool": "check_model_one_dir_node",
+      "category": "故障诊断",
+      "desc": "模型单向节点检查",
+      "params": {
+        "model_id": "2012326636757958658"
+      },
+      "timestamp": "2026-08-04T09:48:21.571069",
+      "success": true,
+      "elapsed_ms": 760.43,
+      "has_content": true,
+      "content_len": 165,
+      "size": "165 B",
+      "structure": "{success: bool, message: str, code: int, result: {result: str}, timestamp: int}",
+      "data_preview": "{\"success\": true, \"message\": \"\", \"code\": 200, \"result\": {\"result\": \"{\\\"bSucced\\\":1,\\\"cnt\\\":0,\\\"ids\\\":[],\\\"strRemark\\\":\\\"网络中不含有单风向节点\\\"}\"}, \"timestamp\": 1785808102295}",
+      "deep_success": true
+    },
+    {
+      "id": "TC-04",
+      "tool": "check_model_diagonal_structure",
+      "category": "故障诊断",
+      "desc": "模型角联结构诊断(计算重,耗时较长)",
+      "params": {
+        "model_id": "2012326636757958658"
+      },
+      "timestamp": "2026-08-04T09:48:22.332173",
+      "success": true,
+      "elapsed_ms": 180176.28,
+      "has_content": true,
+      "content_len": 48,
+      "size": "48 B",
+      "structure": "{success: bool, error: str, code: int}",
+      "data_preview": "{\"success\": false, \"error\": \"请求超时\", \"code\": 408}",
+      "deep_success": false
+    },
+    {
+      "id": "TC-05",
+      "tool": "get_model_fault_diagnosis",
+      "category": "故障诊断",
+      "desc": "模型故障诊断(聚合,不含角联)",
+      "params": {
+        "model_id": "2012326636757958658",
+        "include_diagonal": false
+      },
+      "timestamp": "2026-08-04T09:51:22.508707",
+      "success": true,
+      "elapsed_ms": 2366.39,
+      "has_content": true,
+      "content_len": 24294,
+      "size": "23.7 KB",
+      "structure": "{success: bool, code: int, modelID: int, result: {connect_status: {success: bool, message: str, code: int, result: {result: str(19924)}, timestamp: int}, one_dir_cycle: {success: bool, message: str, code: int, result: {result: str}, timestamp: int}, one_dir_node: {success: bool, message: str, code: int, result: {result: str}, timestamp: int}}}",
+      "data_preview": "{\"success\": true, \"code\": 200, \"modelID\": 2012326636757958658, \"result\": {\"connect_status\": {\"success\": true, \"message\": \"\", \"code\": 200, \"result\": {\"result\": \"{\\\"data\\\":{\\\"0\\\":[\\\"51\\\",\\\"52\\\",\\\"54\\\",\\\"56\\\",\\\"58\\\",\\\"59\\\",\\\"61\\\",\\\"62\\\",\\\"64\\\",\\\"66\\\",\\\"68\\\",\\\"69\\\",\\\"71\\\",\\\"73\\\",\\\"77\\\",\\\"78\\\",\\\"80\\\",\\\"82\\\",\\\"84\\\",\\\"88\\\",\\\"89\\\",\\\"90\\\",\\\"92\\\",\\\"95\\\",\\\"97\\\",\\\"98\\\",\\\"100\\\",\\\"102\\\",\\\"104\\\",\\\"105\\\",\\\"107\\\",\\\"108\\\",\\\"110\\\",\\\"113\\\",\\\"116\\\",\\\"117\\\",\\\"121\\\",\\\"122\\\",\\\"124\\\",\\\"126\\\",\\\"127\\\",\\\"129\\\",\\\"131\\\",\\\"13",
+      "deep_success": true
+    },
+    {
+      "id": "TC-06",
+      "tool": "get_escape_path",
+      "category": "避灾路线",
+      "desc": "避灾路线模拟",
+      "params": {
+        "model_id": "2012326636757958658",
+        "fire_tun_id": "4",
+        "person_tun_id": "6"
+      },
+      "timestamp": "2026-08-04T09:51:24.876672",
+      "success": true,
+      "elapsed_ms": 18138.89,
+      "has_content": true,
+      "content_len": 209245,
+      "size": "204.3 KB",
+      "structure": "{success: bool, message: str, code: int, result: {result: str(187009)}, timestamp: int}",
+      "data_preview": "{\"success\": true, \"message\": \"\", \"code\": 200, \"result\": {\"result\": \"{\\\"bSucced\\\":1,\\\"nTunCount\\\":1796,\\\"fCostTime\\\":192861.796875,\\\"data\\\":{\\\"tuns\\\":[{\\\"4\\\":{\\\"nextId\\\":[\\\"6\\\",\\\"1735448793881634\\\"],\\\"startTime\\\":0.000000,\\\"endTime\\\":0.000000,\\\"coVal\\\":2000.000000}},{\\\"6\\\":{\\\"nextId\\\":[\\\"8\\\"],\\\"startTime\\\":0.000000,\\\"endTime\\\":215.592896,\\\"coVal\\\":2000.000000}},{\\\"1735448793881634\\\":{\\\"nextId\\\":[\\\"1735448793881478\\\"],\\\"startTime\\\":0.000000,\\\"endTime\\\":221.394775,\\\"coVal\\\":2000.000000}},{\\\"8\\\":{\\\"",
+      "deep_success": true
+    },
+    {
+      "id": "TC-07",
+      "tool": "get_escape_path_each_exit",
+      "category": "避灾路线",
+      "desc": "避灾路线模拟(各出口,含CO参数)",
+      "params": {
+        "model_id": "2012326636757958658",
+        "fire_tun_id": "4",
+        "person_tun_id": "6",
+        "co_per": 2000.0,
+        "during_time": 30.0
+      },
+      "timestamp": "2026-08-04T09:51:43.139870",
+      "success": true,
+      "elapsed_ms": 120188.95,
+      "has_content": true,
+      "content_len": 48,
+      "size": "48 B",
+      "structure": "{success: bool, error: str, code: int}",
+      "data_preview": "{\"success\": false, \"error\": \"请求超时\", \"code\": 408}",
+      "deep_success": false
+    },
+    {
+      "id": "TC-08",
+      "tool": "get_out_shafts",
+      "category": "关键阻力",
+      "desc": "获取回风井巷道ID列表",
+      "params": {
+        "model_id": "2012326636757958658"
+      },
+      "timestamp": "2026-08-04T09:53:43.329065",
+      "success": true,
+      "elapsed_ms": 1073.53,
+      "has_content": true,
+      "content_len": 130,
+      "size": "130 B",
+      "structure": "{success: bool, message: str, code: int, result: {result: str}, timestamp: int}",
+      "data_preview": "{\"success\": true, \"message\": \"\", \"code\": 200, \"result\": {\"result\": \"{\\\"cnt\\\":2,\\\"ids\\\":[3957,3963]}\"}, \"timestamp\": 1785808424370}",
+      "deep_success": true
+    },
+    {
+      "id": "TC-09",
+      "tool": "get_in_shafts",
+      "category": "关键阻力",
+      "desc": "获取进风井巷道ID列表",
+      "params": {
+        "model_id": "2012326636757958658"
+      },
+      "timestamp": "2026-08-04T09:53:44.402961",
+      "success": true,
+      "elapsed_ms": 1284.8,
+      "has_content": true,
+      "content_len": 144,
+      "size": "144 B",
+      "structure": "{success: bool, message: str, code: int, result: {result: str}, timestamp: int}",
+      "data_preview": "{\"success\": true, \"message\": \"\", \"code\": 200, \"result\": {\"result\": \"{\\\"cnt\\\":6,\\\"ids\\\":[4,37,113,3214,3964,3986]}\"}, \"timestamp\": 1785808425643}",
+      "deep_success": true
+    },
+    {
+      "id": "TC-10",
+      "tool": "get_max_resistance_path",
+      "category": "关键阻力",
+      "desc": "最大阻力路线",
+      "params": {
+        "model_id": "2012326636757958658",
+        "node_id": "3958"
+      },
+      "timestamp": "2026-08-04T09:53:45.688142",
+      "success": true,
+      "elapsed_ms": 1549.44,
+      "has_content": true,
+      "content_len": 1068,
+      "size": "1.0 KB",
+      "structure": "{success: bool, message: str, code: int, result: {result: str(959)}, timestamp: int}",
+      "data_preview": "{\"success\": true, \"message\": \"\", \"code\": 200, \"result\": {\"result\": \"{\\\"bSucced\\\":0,\\\"fMaxH\\\":1077.614465,\\\"path\\\":[0,4,6,8,10,12,14,16,28,98,100,102,104,105,107,108,110,117,122,121,124,149,151,160,159,158,141,143,145,204,206,208,211,215,218,1133,1134,223,225,232,257,258,260,273,275,277,280,284,310,311,289,291,302,313,315,335,334,345,340,342,344,365,366,382,384,404,415,417,420,439,442,447,501,505,509,517,544,564,566,576,587,590,657,681,1123,662,664,685,686,1735211924946739,1734397888359985,721,72",
+      "deep_success": true
+    },
+    {
+      "id": "TC-11",
+      "tool": "get_three_area_distribution",
+      "category": "关键阻力",
+      "desc": "三区阻力分布",
+      "params": {
+        "model_id": "2012326636757958658"
+      },
+      "timestamp": "2026-08-04T09:53:47.237701",
+      "success": true,
+      "elapsed_ms": 1825.01,
+      "has_content": true,
+      "content_len": 374762,
+      "size": "366.0 KB",
+      "structure": "{success: bool, message: str, code: int, result: {result: str(328985)}, timestamp: int}",
+      "data_preview": "{\"success\": true, \"message\": \"\", \"code\": 200, \"result\": {\"result\": \"{\\\"bSucced\\\":1,\\\"tuns\\\":{\\\"4\\\":{\\\"dQ\\\":127.000000,\\\"strName\\\":\\\"辅运平硐\\\",\\\"dV\\\":6.685196,\\\"dHTotal\\\":74.494432,\\\"dHFric\\\":74.494432,\\\"dHLocal\\\":0.000000,\\\"dHNature\\\":0,\\\"nAirType\\\":1,\\\"strAirType\\\":\\\"FreshQEdges\\\"},\\\"6\\\":{\\\"dQ\\\":59.790000,\\\"strName\\\":\\\"2#辅运平硐联络巷\\\",\\\"dV\\\":2.778617,\\\"dHTotal\\\":2.539730,\\\"dHFric\\\":2.539730,\\\"dHLocal\\\":0.000000,\\\"dHNature\\\":0,\\\"nAirType\\\":1,\\\"strAirType\\\":\\\"FreshQEdges\\\"},\\\"8\\\":{\\\"dQ\\\":59.790000,\\\"str",
+      "deep_success": true
+    },
+    {
+      "id": "TC-12",
+      "tool": "get_key_path_decision",
+      "category": "关键阻力",
+      "desc": "关键路径决策",
+      "params": {
+        "model_id": "2012326636757958658"
+      },
+      "timestamp": "2026-08-04T09:53:49.072963",
+      "success": true,
+      "elapsed_ms": 1158.9,
+      "has_content": true,
+      "content_len": 33986,
+      "size": "33.2 KB",
+      "structure": "{success: bool, message: str, code: int, result: {result: str(29908)}, timestamp: int}",
+      "data_preview": "{\"success\": true, \"message\": \"\", \"code\": 200, \"result\": {\"result\": \"{\\\"bSucced\\\":1,\\\"nNum1\\\":67,\\\"nNum2\\\":123,\\\"AmendEdges1\\\":{\\\"549\\\":{\\\"strName\\\":\\\"二水平中部回风大巷\\\",\\\"dLength\\\":65.226318,\\\"dQ\\\":47.620000,\\\"dH\\\":10.191495,\\\"dHAmend\\\":7.753141,\\\"AmendTypes\\\":\\\"新增设施调节\\\",\\\"dRAmend\\\":0.003419,\\\"dAmendArea\\\":11.541236},\\\"622\\\":{\\\"strName\\\":\\\"42上204主运顺槽\\\",\\\"dLength\\\":62.211524,\\\"dQ\\\":2.820000,\\\"dH\\\":0.024795,\\\"dHAmend\\\":2.043394,\\\"AmendTypes\\\":\\\"新增设施调节\\\",\\\"dRAmend\\\":0.256953,\\\"dAmendArea\\\":2.164311},\\\"912",
+      "deep_success": true
+    },
+    {
+      "id": "TC-13",
+      "tool": "get_path_press_power",
+      "category": "压能/解算",
+      "desc": "节点压能图",
+      "params": {
+        "model_id": "2012326636757958658",
+        "id_from": "2",
+        "id_to": "3958"
+      },
+      "timestamp": "2026-08-04T09:53:50.232978",
+      "success": true,
+      "elapsed_ms": 12464.22,
+      "has_content": true,
+      "content_len": 3973763,
+      "size": "3.8 MB",
+      "structure": "{success: bool, message: str, code: int, result: {result: str(2874396)}, timestamp: int}",
+      "data_preview": "{\"success\": true, \"message\": \"\", \"code\": 200, \"result\": {\"result\": \"{\\n\\t\\\"bSucced\\\":\\t1,\\n\\t\\\"PathNum\\\":\\t154,\\n\\t\\\"Paths\\\":\\t{\\n\\t\\t\\\"0\\\":\\t{\\n\\t\\t\\t\\\"nTunNum\\\":\\t31,\\n\\t\\t\\t\\\"Tuns\\\":\\t[{\\n\\t\\t\\t\\t\\t\\\"strID\\\":\\t\\\"4\\\",\\n\\t\\t\\t\\t\\t\\\"strName\\\":\\t\\\"辅运平硐\\\",\\n\\t\\t\\t\\t\\t\\\"dFromPower\\\":\\t0.000000,\\n\\t\\t\\t\\t\\t\\\"dToPower\\\":\\t74.494432,\\n\\t\\t\\t\\t\\t\\\"dQ\\\":\\t127.000000,\\n\\t\\t\\t\\t\\t\\\"dH\\\":\\t74.494432,\\n\\t\\t\\t\\t\\t\\\"dLength\\\":\\t341.101060\\n\\t\\t\\t\\t}, {\\n\\t\\t\\t\\t\\t\\\"strID\\\":\\t\\\"6\\\",\\n\\t\\t\\t\\t\\t\\\"strName\\\":\\t\\\"",
+      "deep_success": true
+    },
+    {
+      "id": "TC-14",
+      "tool": "net_cal",
+      "category": "压能/解算",
+      "desc": "网络解算(耗时较长)",
+      "params": {
+        "model_id": "2012326636757958658"
+      },
+      "timestamp": "2026-08-04T09:54:02.787750",
+      "success": true,
+      "elapsed_ms": 35607.82,
+      "has_content": true,
+      "content_len": 601,
+      "size": "601 B",
+      "structure": "{success: bool, error: str, code: int, message: str(500)}",
+      "data_preview": "{\"success\": false, \"error\": \"HTTP错误: 500\", \"code\": 500, \"message\": \"{\\\"timestamp\\\":\\\"2026-08-04 09:54:38\\\",\\\"path\\\":\\\"/ventanaly-model/Vmodel/VentanalyAirthmeticJson/netcal\\\",\\\"status\\\":500,\\\"error\\\":\\\"Internal Server Error\\\",\\\"message\\\":\\\"Connection prematurely closed BEFORE response\\\",\\\"requestId\\\":\\\"7d7be25a-37578\\\",\\\"exception\\\":\\\"reactor.netty.http.client.PrematureCloseException\\\",\\\"trace\\\":\\\"reactor.netty.http.client.PrematureCloseException: Connection prematurely closed BEFORE response\\\\n",
+      "deep_success": false
+    },
+    {
+      "id": "TC-15",
+      "tool": "net_cal_for_plan",
+      "category": "压能/解算",
+      "desc": "方案模拟解算",
+      "params": {
+        "model_id": "2012326636757958658",
+        "plan": "{}"
+      },
+      "timestamp": "2026-08-04T09:54:38.395802",
+      "success": true,
+      "elapsed_ms": 74.82,
+      "has_content": true,
+      "content_len": 605,
+      "size": "605 B",
+      "structure": "{success: bool, error: str, code: int, message: str(500)}",
+      "data_preview": "{\"success\": false, \"error\": \"HTTP错误: 503\", \"code\": 503, \"message\": \"{\\\"timestamp\\\":\\\"2026-08-04 09:54:38\\\",\\\"path\\\":\\\"/ventanaly-model/Vmodel/VentanalyAirthmeticJson/getNetCalForPlan\\\",\\\"status\\\":503,\\\"error\\\":\\\"Service Unavailable\\\",\\\"message\\\":\\\"Unable to find instance for ventanaly-model\\\",\\\"requestId\\\":\\\"dc06103d-37682\\\",\\\"exception\\\":\\\"org.springframework.cloud.gateway.support.NotFoundException\\\",\\\"trace\\\":\\\"org.springframework.cloud.gateway.support.NotFoundException: 503 SERVICE_UNAVAILABL",
+      "deep_success": false
+    }
+  ]
+}

+ 286 - 0
tests/mcp_test_report.md

@@ -0,0 +1,286 @@
+# 通风模型算法 MCP 接口测试报告
+
+**测试时间**: 2026-08-04 09:56:56  
+**MCP 服务地址**: `http://39.97.59.228:8071/mcp`  
+**测试模型 ID**: `2012326636757958658`  
+**传输协议**: `streamable-http` | **客户端库**: `fastmcp`  
+
+## 📊 测试汇总
+
+### 传输层结果
+
+| 指标 | 数值 |
+|:---|---:|
+| 接口总数 | 15 |
+| ✅ 传输层通过 | 15 |
+| ❌ 传输层失败 | 0 |
+| 传输层通过率 | 100.0% |
+| 总耗时 | 378669 ms |
+| 平均响应 | 25245 ms |
+
+### 业务层结果
+
+| 指标 | 数值 |
+|:---|---:|
+| ✅ 业务正常 | 11 |
+| ⏱️ 服务端超时 (408) | 2 |
+| 🔴 服务端错误 (500) | 1 |
+| 🔴 服务不可用 (503) | 1 |
+| ❌ 客户端校验失败 | 0 |
+| **综合可用率** | **73.3%** |
+
+### 分类统计
+
+| 分类 | 总数 | 传输通过 | 业务正常 | 平均耗时 |
+|:---|---:|---:|---:|---:|
+| 故障诊断 | 5 | 5 | 4 | 37060 ms |
+| 避灾路线 | 2 | 2 | 1 | 69164 ms |
+| 关键阻力 | 5 | 5 | 5 | 1378 ms |
+| 压能/解算 | 3 | 3 | 1 | 16049 ms |
+
+## 📋 详细测试结果
+
+### ✅ TC-01: 模型网络连通检查 (`check_model_connect_status`)
+
+- **分类**: 故障诊断 | **业务状态**: 正常
+- **耗时**: 1148.5 ms | **结果大小**: 23.3 KB
+- **数据结构**: `{success: bool, message: str, code: int, result: {result: str(19924)}, timestamp: int}`
+- **调用参数**: `model_id=2012326636757958658`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": true, "message": "", "code": 200, "result": {"result": "{\"data\":{\"0\":[\"4\",\"6\",\"8\",\"10\",\"12\",\"14\",\"16\",\"18\",\"22\",\"28\",\"30\",\"32\",\"37\",\"41\",\"43\",\"49\",\"51\",\"52\",\"54\",\"56\",\"58\",\"59\",\"61\",\"62\",\"64\",\"66\",\"68\",\"69\",\"71\",\"73\",\"77\",\"78\",\"80\",\"82\",\"84\",\"88\",\"89\",\"90\",\"92\",\"95\",\"97\",\"98\",\"100\",\"102\",\"104\",\"105\",\"107\",\"108\",\"110\",\"113\",\"116\",\"117\",\"121\",\"122\",\"124\",\"126\",\"127\",\"1
+```
+</details>
+
+### ✅ TC-02: 模型循环风路检查 (`check_model_one_dir_cycle`)
+
+- **分类**: 故障诊断 | **业务状态**: 正常
+- **耗时**: 850.65 ms | **结果大小**: 152 B
+- **数据结构**: `{success: bool, message: str, code: int, result: {result: str}, timestamp: int}`
+- **调用参数**: `model_id=2012326636757958658`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": true, "message": "", "code": 200, "result": {"result": "{\"bSucced\":0,\"cnt\":0,\"strRemark\":\"模型中没有单向回路\"}"}, "timestamp": 1785808101540}
+```
+</details>
+
+### ✅ TC-03: 模型单向节点检查 (`check_model_one_dir_node`)
+
+- **分类**: 故障诊断 | **业务状态**: 正常
+- **耗时**: 760.43 ms | **结果大小**: 165 B
+- **数据结构**: `{success: bool, message: str, code: int, result: {result: str}, timestamp: int}`
+- **调用参数**: `model_id=2012326636757958658`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": true, "message": "", "code": 200, "result": {"result": "{\"bSucced\":1,\"cnt\":0,\"ids\":[],\"strRemark\":\"网络中不含有单风向节点\"}"}, "timestamp": 1785808102295}
+```
+</details>
+
+### ⏱️ TC-04: 模型角联结构诊断(计算重,耗时较长) (`check_model_diagonal_structure`)
+
+- **分类**: 故障诊断 | **业务状态**: 服务端超时 (408)
+- **耗时**: 180176.28 ms | **结果大小**: 48 B
+- **数据结构**: `{success: bool, error: str, code: int}`
+- **调用参数**: `model_id=2012326636757958658`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": false, "error": "请求超时", "code": 408}
+```
+</details>
+
+### ✅ TC-05: 模型故障诊断(聚合,不含角联) (`get_model_fault_diagnosis`)
+
+- **分类**: 故障诊断 | **业务状态**: 正常
+- **耗时**: 2366.39 ms | **结果大小**: 23.7 KB
+- **数据结构**: `{success: bool, code: int, modelID: int, result: {connect_status: {success: bool, message: str, code: int, result: {result: str(19924)}, timestamp: int}, one_dir_cycle: {success: bool, message: str, code: int, result: {result: str}, timestamp: int}, one_dir_node: {success: bool, message: str, code: int, result: {result: str}, timestamp: int}}}`
+- **调用参数**: `model_id=2012326636757958658`, `include_diagonal=False`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": true, "code": 200, "modelID": 2012326636757958658, "result": {"connect_status": {"success": true, "message": "", "code": 200, "result": {"result": "{\"data\":{\"0\":[\"51\",\"52\",\"54\",\"56\",\"58\",\"59\",\"61\",\"62\",\"64\",\"66\",\"68\",\"69\",\"71\",\"73\",\"77\",\"78\",\"80\",\"82\",\"84\",\"88\",\"89\",\"90\",\"92\",\"95\",\"97\",\"98\",\"100\",\"102\",\"104\",\"105\",\"107\",\"108\",\"110\",\"113\",\"116\",\"117\",\"121\",\"122\",\"124\",\"126\",\"127\",\"129\",\"131\",\"13
+```
+</details>
+
+### ✅ TC-06: 避灾路线模拟 (`get_escape_path`)
+
+- **分类**: 避灾路线 | **业务状态**: 正常
+- **耗时**: 18138.89 ms | **结果大小**: 204.3 KB
+- **数据结构**: `{success: bool, message: str, code: int, result: {result: str(187009)}, timestamp: int}`
+- **调用参数**: `model_id=2012326636757958658`, `fire_tun_id=4`, `person_tun_id=6`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": true, "message": "", "code": 200, "result": {"result": "{\"bSucced\":1,\"nTunCount\":1796,\"fCostTime\":192861.796875,\"data\":{\"tuns\":[{\"4\":{\"nextId\":[\"6\",\"1735448793881634\"],\"startTime\":0.000000,\"endTime\":0.000000,\"coVal\":2000.000000}},{\"6\":{\"nextId\":[\"8\"],\"startTime\":0.000000,\"endTime\":215.592896,\"coVal\":2000.000000}},{\"1735448793881634\":{\"nextId\":[\"1735448793881478\"],\"startTime\":0.000000,\"endTime\":221.394775,\"coVal\":2000.000000}},{\"8\":{\"
+```
+</details>
+
+### ⏱️ TC-07: 避灾路线模拟(各出口,含CO参数) (`get_escape_path_each_exit`)
+
+- **分类**: 避灾路线 | **业务状态**: 服务端超时 (408)
+- **耗时**: 120188.95 ms | **结果大小**: 48 B
+- **数据结构**: `{success: bool, error: str, code: int}`
+- **调用参数**: `model_id=2012326636757958658`, `fire_tun_id=4`, `person_tun_id=6`, `co_per=2000.0`, `during_time=30.0`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": false, "error": "请求超时", "code": 408}
+```
+</details>
+
+### ✅ TC-08: 获取回风井巷道ID列表 (`get_out_shafts`)
+
+- **分类**: 关键阻力 | **业务状态**: 正常
+- **耗时**: 1073.53 ms | **结果大小**: 130 B
+- **数据结构**: `{success: bool, message: str, code: int, result: {result: str}, timestamp: int}`
+- **调用参数**: `model_id=2012326636757958658`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": true, "message": "", "code": 200, "result": {"result": "{\"cnt\":2,\"ids\":[3957,3963]}"}, "timestamp": 1785808424370}
+```
+</details>
+
+### ✅ TC-09: 获取进风井巷道ID列表 (`get_in_shafts`)
+
+- **分类**: 关键阻力 | **业务状态**: 正常
+- **耗时**: 1284.8 ms | **结果大小**: 144 B
+- **数据结构**: `{success: bool, message: str, code: int, result: {result: str}, timestamp: int}`
+- **调用参数**: `model_id=2012326636757958658`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": true, "message": "", "code": 200, "result": {"result": "{\"cnt\":6,\"ids\":[4,37,113,3214,3964,3986]}"}, "timestamp": 1785808425643}
+```
+</details>
+
+### ✅ TC-10: 最大阻力路线 (`get_max_resistance_path`)
+
+- **分类**: 关键阻力 | **业务状态**: 正常
+- **耗时**: 1549.44 ms | **结果大小**: 1.0 KB
+- **数据结构**: `{success: bool, message: str, code: int, result: {result: str(959)}, timestamp: int}`
+- **调用参数**: `model_id=2012326636757958658`, `node_id=3958`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": true, "message": "", "code": 200, "result": {"result": "{\"bSucced\":0,\"fMaxH\":1077.614465,\"path\":[0,4,6,8,10,12,14,16,28,98,100,102,104,105,107,108,110,117,122,121,124,149,151,160,159,158,141,143,145,204,206,208,211,215,218,1133,1134,223,225,232,257,258,260,273,275,277,280,284,310,311,289,291,302,313,315,335,334,345,340,342,344,365,366,382,384,404,415,417,420,439,442,447,501,505,509,517,544,564,566,576,587,590,657,681,1123,662,664,685,686,1735211924946739,1734397888359985,721,72
+```
+</details>
+
+### ✅ TC-11: 三区阻力分布 (`get_three_area_distribution`)
+
+- **分类**: 关键阻力 | **业务状态**: 正常
+- **耗时**: 1825.01 ms | **结果大小**: 366.0 KB
+- **数据结构**: `{success: bool, message: str, code: int, result: {result: str(328985)}, timestamp: int}`
+- **调用参数**: `model_id=2012326636757958658`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": true, "message": "", "code": 200, "result": {"result": "{\"bSucced\":1,\"tuns\":{\"4\":{\"dQ\":127.000000,\"strName\":\"辅运平硐\",\"dV\":6.685196,\"dHTotal\":74.494432,\"dHFric\":74.494432,\"dHLocal\":0.000000,\"dHNature\":0,\"nAirType\":1,\"strAirType\":\"FreshQEdges\"},\"6\":{\"dQ\":59.790000,\"strName\":\"2#辅运平硐联络巷\",\"dV\":2.778617,\"dHTotal\":2.539730,\"dHFric\":2.539730,\"dHLocal\":0.000000,\"dHNature\":0,\"nAirType\":1,\"strAirType\":\"FreshQEdges\"},\"8\":{\"dQ\":59.790000,\"str
+```
+</details>
+
+### ✅ TC-12: 关键路径决策 (`get_key_path_decision`)
+
+- **分类**: 关键阻力 | **业务状态**: 正常
+- **耗时**: 1158.9 ms | **结果大小**: 33.2 KB
+- **数据结构**: `{success: bool, message: str, code: int, result: {result: str(29908)}, timestamp: int}`
+- **调用参数**: `model_id=2012326636757958658`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": true, "message": "", "code": 200, "result": {"result": "{\"bSucced\":1,\"nNum1\":67,\"nNum2\":123,\"AmendEdges1\":{\"549\":{\"strName\":\"二水平中部回风大巷\",\"dLength\":65.226318,\"dQ\":47.620000,\"dH\":10.191495,\"dHAmend\":7.753141,\"AmendTypes\":\"新增设施调节\",\"dRAmend\":0.003419,\"dAmendArea\":11.541236},\"622\":{\"strName\":\"42上204主运顺槽\",\"dLength\":62.211524,\"dQ\":2.820000,\"dH\":0.024795,\"dHAmend\":2.043394,\"AmendTypes\":\"新增设施调节\",\"dRAmend\":0.256953,\"dAmendArea\":2.164311},\"912
+```
+</details>
+
+### ✅ TC-13: 节点压能图 (`get_path_press_power`)
+
+- **分类**: 压能/解算 | **业务状态**: 正常
+- **耗时**: 12464.22 ms | **结果大小**: 3.8 MB
+- **数据结构**: `{success: bool, message: str, code: int, result: {result: str(2874396)}, timestamp: int}`
+- **调用参数**: `model_id=2012326636757958658`, `id_from=2`, `id_to=3958`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": true, "message": "", "code": 200, "result": {"result": "{\n\t\"bSucced\":\t1,\n\t\"PathNum\":\t154,\n\t\"Paths\":\t{\n\t\t\"0\":\t{\n\t\t\t\"nTunNum\":\t31,\n\t\t\t\"Tuns\":\t[{\n\t\t\t\t\t\"strID\":\t\"4\",\n\t\t\t\t\t\"strName\":\t\"辅运平硐\",\n\t\t\t\t\t\"dFromPower\":\t0.000000,\n\t\t\t\t\t\"dToPower\":\t74.494432,\n\t\t\t\t\t\"dQ\":\t127.000000,\n\t\t\t\t\t\"dH\":\t74.494432,\n\t\t\t\t\t\"dLength\":\t341.101060\n\t\t\t\t}, {\n\t\t\t\t\t\"strID\":\t\"6\",\n\t\t\t\t\t\"strName\":\t\"
+```
+</details>
+
+### 🔴 TC-14: 网络解算(耗时较长) (`net_cal`)
+
+- **分类**: 压能/解算 | **业务状态**: 服务端内部错误 (500)
+- **耗时**: 35607.82 ms | **结果大小**: 601 B
+- **数据结构**: `{success: bool, error: str, code: int, message: str(500)}`
+- **调用参数**: `model_id=2012326636757958658`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": false, "error": "HTTP错误: 500", "code": 500, "message": "{\"timestamp\":\"2026-08-04 09:54:38\",\"path\":\"/ventanaly-model/Vmodel/VentanalyAirthmeticJson/netcal\",\"status\":500,\"error\":\"Internal Server Error\",\"message\":\"Connection prematurely closed BEFORE response\",\"requestId\":\"7d7be25a-37578\",\"exception\":\"reactor.netty.http.client.PrematureCloseException\",\"trace\":\"reactor.netty.http.client.PrematureCloseException: Connection prematurely closed BEFORE response\\n
+```
+</details>
+
+### 🔴 TC-15: 方案模拟解算 (`net_cal_for_plan`)
+
+- **分类**: 压能/解算 | **业务状态**: 服务不可用 (503)
+- **耗时**: 74.82 ms | **结果大小**: 605 B
+- **数据结构**: `{success: bool, error: str, code: int, message: str(500)}`
+- **调用参数**: `model_id=2012326636757958658`, `plan={}`
+
+<details>
+<summary>📄 数据预览</summary>
+
+```json
+{"success": false, "error": "HTTP错误: 503", "code": 503, "message": "{\"timestamp\":\"2026-08-04 09:54:38\",\"path\":\"/ventanaly-model/Vmodel/VentanalyAirthmeticJson/getNetCalForPlan\",\"status\":503,\"error\":\"Service Unavailable\",\"message\":\"Unable to find instance for ventanaly-model\",\"requestId\":\"dc06103d-37682\",\"exception\":\"org.springframework.cloud.gateway.support.NotFoundException\",\"trace\":\"org.springframework.cloud.gateway.support.NotFoundException: 503 SERVICE_UNAVAILABL
+```
+</details>
+
+## 💡 问题与建议
+
+### ⚠️ 发现 4 个问题
+
+| 接口 | 问题 | 建议 |
+|:---|:---|:---|
+| `check_model_diagonal_structure` | 服务端超时 (408),耗时 180176ms | 优化后端算法或增加超时时间;前端设置 180s+ 超时并显示进度 |
+| `get_escape_path_each_exit` | 服务端超时 (408),耗时 120189ms | 优化后端算法或增加超时时间;前端设置 180s+ 超时并显示进度 |
+| `net_cal` | 服务端内部错误 (500) - `Connection prematurely closed BEFORE response` | 检查后端 Netty 连接池/响应超时配置 |
+| `net_cal_for_plan` | 服务不可用 (503) - `Unable to find instance for ventanaly-model` | 检查微服务 `ventanaly-model` 实例是否在注册中心在线 |
+
+### 📝 通用建议
+
+1. **响应时间差异大**:快速接口(进/回风井查询、循环风路检查)约 0.8-1.5s,重计算接口(压能图 12s、避灾路线 18s)约 12-35s,超时接口约 180s。**建议按接口类型设置差异化超时**。
+2. **数据量大**:`get_three_area_distribution` (366KB) 和 `get_path_press_power` (3.8MB) 返回数据量大,移动端需考虑分页或压缩。
+3. **参数校验**:`net_cal_for_plan` 的 `plan` 参数不能为空字符串,建议后端明确 `plan` 格式规范并提供示例。
+4. **返回值结构**:外层统一为 `{success, code, result}`,但 `get_model_fault_diagnosis` 直接在外层暴露 `modelID` 字段,略有风格不一致。
+5. **节点 vs 隧道 ID**:`get_max_resistance_path` 和 `get_path_press_power` 使用 `node_id`(节点ID),其他接口使用 `tun_id`(隧道ID),文档已有说明但调用时容易混淆。
+6. **内层 JSON**:多数接口的 `result.result` 是 JSON 字符串,需要二次 `json.loads` 解析,建议统一为对象格式。

+ 559 - 0
tests/test_mcp_interfaces.py

@@ -0,0 +1,559 @@
+# -*- coding: utf-8 -*-
+"""
+通风模型算法 MCP 接口全面测试脚本
+测试 server: vent-model-tools @ http://39.97.59.228:8071/mcp
+"""
+
+import asyncio
+import json
+import time
+import os
+from datetime import datetime
+from fastmcp import Client
+
+MCP_URL = "http://39.97.59.228:8071/mcp"
+MODEL_ID = "2012326636757958658"
+
+# ── 测试用例定义 ──
+# 按文档中的 15 个接口定义
+TEST_CASES = [
+    # ═══ 故障诊断类 ═══
+    {
+        "id": "TC-01",
+        "category": "故障诊断",
+        "tool": "check_model_connect_status",
+        "params": {"model_id": MODEL_ID},
+        "desc": "模型网络连通检查",
+    },
+    {
+        "id": "TC-02",
+        "category": "故障诊断",
+        "tool": "check_model_one_dir_cycle",
+        "params": {"model_id": MODEL_ID},
+        "desc": "模型循环风路检查",
+    },
+    {
+        "id": "TC-03",
+        "category": "故障诊断",
+        "tool": "check_model_one_dir_node",
+        "params": {"model_id": MODEL_ID},
+        "desc": "模型单向节点检查",
+    },
+    {
+        "id": "TC-04",
+        "category": "故障诊断",
+        "tool": "check_model_diagonal_structure",
+        "params": {"model_id": MODEL_ID},
+        "desc": "模型角联结构诊断(计算重,耗时较长)",
+    },
+    {
+        "id": "TC-05",
+        "category": "故障诊断",
+        "tool": "get_model_fault_diagnosis",
+        "params": {"model_id": MODEL_ID, "include_diagonal": False},
+        "desc": "模型故障诊断(聚合,不含角联)",
+    },
+    # ═══ 避灾路线类 ═══
+    {
+        "id": "TC-06",
+        "category": "避灾路线",
+        "tool": "get_escape_path",
+        "params": {"model_id": MODEL_ID, "fire_tun_id": "4", "person_tun_id": "6"},
+        "desc": "避灾路线模拟",
+        "note": "使用硬编码隧道ID 4/6,实际应取自 get_out_shafts",
+    },
+    {
+        "id": "TC-07",
+        "category": "避灾路线",
+        "tool": "get_escape_path_each_exit",
+        "params": {"model_id": MODEL_ID, "fire_tun_id": "4", "person_tun_id": "6", "co_per": 2000.0, "during_time": 30.0},
+        "desc": "避灾路线模拟(各出口,含CO参数)",
+    },
+    # ═══ 关键阻力类 ═══
+    {
+        "id": "TC-08",
+        "category": "关键阻力",
+        "tool": "get_out_shafts",
+        "params": {"model_id": MODEL_ID},
+        "desc": "获取回风井巷道ID列表",
+    },
+    {
+        "id": "TC-09",
+        "category": "关键阻力",
+        "tool": "get_in_shafts",
+        "params": {"model_id": MODEL_ID},
+        "desc": "获取进风井巷道ID列表",
+    },
+    # get_max_resistance_path - need node_id, will be filled dynamically
+    {
+        "id": "TC-10",
+        "category": "关键阻力",
+        "tool": "get_max_resistance_path",
+        "params": {"model_id": MODEL_ID, "node_id": None},  # filled dynamically
+        "desc": "最大阻力路线",
+        "dynamic": True,
+    },
+    {
+        "id": "TC-11",
+        "category": "关键阻力",
+        "tool": "get_three_area_distribution",
+        "params": {"model_id": MODEL_ID},
+        "desc": "三区阻力分布",
+    },
+    {
+        "id": "TC-12",
+        "category": "关键阻力",
+        "tool": "get_key_path_decision",
+        "params": {"model_id": MODEL_ID},
+        "desc": "关键路径决策",
+    },
+    # ═══ 压能/解算类 ═══
+    # get_path_press_power - need node IDs, will be filled dynamically
+    {
+        "id": "TC-13",
+        "category": "压能/解算",
+        "tool": "get_path_press_power",
+        "params": {"model_id": MODEL_ID, "id_from": None, "id_to": None},  # filled dynamically
+        "desc": "节点压能图",
+        "dynamic": True,
+    },
+    {
+        "id": "TC-14",
+        "category": "压能/解算",
+        "tool": "net_cal",
+        "params": {"model_id": MODEL_ID},
+        "desc": "网络解算(耗时较长)",
+    },
+    {
+        "id": "TC-15",
+        "category": "压能/解算",
+        "tool": "net_cal_for_plan",
+        "params": {"model_id": MODEL_ID, "plan": "{}"},
+        "desc": "方案模拟解算",
+        "note": "plan 参数不能为空字符串,使用空JSON对象作为最小有效方案",
+    },
+]
+
+
+def safe_parse_json(text: str) -> dict:
+    """安全解析 JSON,支持多层嵌套"""
+    try:
+        return json.loads(text) if isinstance(text, str) else text
+    except (json.JSONDecodeError, TypeError):
+        return {"raw": str(text)[:2000]}
+
+
+def extract_deep_result(data: dict) -> dict:
+    """尝试提取深层 result"""
+    # 尝试多层解包
+    for _ in range(5):
+        if isinstance(data, dict):
+            if "result" in data and isinstance(data["result"], str):
+                try:
+                    data = json.loads(data["result"])
+                    continue
+                except (json.JSONDecodeError, TypeError):
+                    pass
+            if "result" in data and isinstance(data["result"], dict):
+                data = data["result"]
+                continue
+        break
+    return data
+
+
+def count_result_size(data) -> str:
+    """估算结果大小"""
+    s = json.dumps(data, ensure_ascii=False, default=str)
+    size = len(s)
+    if size < 1024:
+        return f"{size} B"
+    elif size < 1024 * 1024:
+        return f"{size / 1024:.1f} KB"
+    else:
+        return f"{size / (1024 * 1024):.1f} MB"
+
+
+def summarize_structure(data, depth=0) -> str:
+    """概括数据结构"""
+    if depth > 5:
+        return "..."
+    if isinstance(data, dict):
+        keys = list(data.keys())
+        if len(keys) <= 8:
+            parts = []
+            for k in keys:
+                v = data[k]
+                if isinstance(v, (dict, list)):
+                    parts.append(f"{k}: {summarize_structure(v, depth + 1)}")
+                elif isinstance(v, str) and len(v) > 100:
+                    parts.append(f"{k}: str({len(v)})")
+                else:
+                    parts.append(f"{k}: {type(v).__name__}")
+            return "{" + ", ".join(parts[:10]) + ("..." if len(parts) > 10 else "") + "}"
+        else:
+            return f"{{...{len(keys)} keys...}}"
+    elif isinstance(data, list):
+        if len(data) == 0:
+            return "[]"
+        return f"[{len(data)} items, first: {summarize_structure(data[0], depth + 1)}]"
+    elif isinstance(data, str):
+        return f'str({len(data)})'
+    else:
+        return type(data).__name__
+
+
+async def call_single_tool(client: Client, case: dict) -> dict:
+    """调用单个工具并返回统一格式结果"""
+    result = {
+        "id": case["id"],
+        "tool": case["tool"],
+        "category": case["category"],
+        "desc": case["desc"],
+        "params": {k: v for k, v in case["params"].items() if v is not None},
+        "timestamp": datetime.now().isoformat(),
+    }
+    
+    start = time.perf_counter()
+    try:
+        raw = await client.call_tool(case["tool"], result["params"])
+        elapsed = time.perf_counter() - start
+        
+        # 提取文本内容
+        if raw.content and len(raw.content) > 0:
+            text = raw.content[0].text
+            data = safe_parse_json(text)
+        else:
+            text = ""
+            data = {}
+        
+        result.update({
+            "success": True,
+            "elapsed_ms": round(elapsed * 1000, 2),
+            "has_content": len(raw.content) > 0,
+            "content_len": len(text),
+            "size": count_result_size(data),
+            "structure": summarize_structure(data),
+            "data_preview": json.dumps(data, ensure_ascii=False, default=str)[:500],
+            "deep_success": None,  # will check below
+        })
+        
+        # 检查深层 success 字段
+        # 注意: bSucced=0 对于故障检测类接口表示"未发现故障"(正常), bSucced=1 表示"检测到故障"
+        # 对于计算类接口, bSucced=1 表示计算成功, bSucced=0 表示计算失败
+        deep = extract_deep_result(data)
+        if isinstance(deep, dict):
+            # 优先检查 outer success + code 200
+            outer_ok = data.get("success") and data.get("code") == 200
+            # 检查 inner result 是否有 bSucced 字段 (1=成功/发现, 0=失败/未发现)
+            has_bSucced = "bSucced" in deep
+            bSucced_val = deep.get("bSucced")
+            # 如果有 error 字段且非空,标记业务异常
+            has_error = bool(deep.get("error") or data.get("error"))
+            if has_error:
+                result["deep_success"] = False
+            elif outer_ok:
+                result["deep_success"] = True
+            elif has_bSucced:
+                # bSucced 存在时,都视为业务层面正常(0或1都有意义)
+                result["deep_success"] = True
+            else:
+                result["deep_success"] = data.get("code") == 200
+        
+    except Exception as e:
+        elapsed = time.perf_counter() - start
+        result.update({
+            "success": False,
+            "elapsed_ms": round(elapsed * 1000, 2),
+            "error": str(e),
+            "error_type": type(e).__name__,
+        })
+    
+    return result
+
+
+async def discover_node_and_tunnel_ids(client: Client) -> dict:
+    """发现可用的 node_id 和 tunnel_id,用于后续动态测试"""
+    discovered = {"node_ids": [], "out_shaft_tuns": [], "in_shaft_tuns": []}
+    
+    # 获取回风井
+    try:
+        r = await client.call_tool("get_out_shafts", {"model_id": MODEL_ID})
+        text = r.content[0].text
+        data = safe_parse_json(text)
+        deep = extract_deep_result(data)
+        # 尝试找到隧道ID列表
+        if isinstance(deep, dict):
+            for key in ["outShafts", "tunIds", "ids", "data"]:
+                if key in deep and isinstance(deep[key], list):
+                    discovered["out_shaft_tuns"] = deep[key][:5]
+                    break
+        discovered["out_shafts_raw"] = summarize_structure(data)
+    except Exception as e:
+        discovered["out_shafts_error"] = str(e)
+    
+    # 获取进风井
+    try:
+        r = await client.call_tool("get_in_shafts", {"model_id": MODEL_ID})
+        text = r.content[0].text
+        data = safe_parse_json(text)
+        deep = extract_deep_result(data)
+        if isinstance(deep, dict):
+            for key in ["inShafts", "tunIds", "ids", "data"]:
+                if key in deep and isinstance(deep[key], list):
+                    discovered["in_shaft_tuns"] = deep[key][:5]
+                    break
+        discovered["in_shafts_raw"] = summarize_structure(data)
+    except Exception as e:
+        discovered["in_shafts_error"] = str(e)
+    
+    return discovered
+
+
+async def main():
+    print("=" * 80)
+    print("  通风模型算法 MCP 接口测试")
+    print(f"  Server: {MCP_URL}")
+    print(f"  Model:  {MODEL_ID}")
+    print(f"  时间:    {datetime.now().isoformat()}")
+    print("=" * 80)
+    print()
+    
+    client = Client(MCP_URL)
+    results = []
+    
+    async with client:
+        # ── 第一阶段:发现动态参数 ──
+        print("🔍 第一阶段:发现动态参数(回风井/进风井/节点ID)...")
+        discovered = await discover_node_and_tunnel_ids(client)
+        print(f"   回风井: {json.dumps(discovered.get('out_shaft_tuns', []), ensure_ascii=False)}")
+        print(f"   进风井: {json.dumps(discovered.get('in_shaft_tuns', []), ensure_ascii=False)}")
+        
+        # 尝试用已知隧道ID获取更多节点信息(需要调用 get_tun_list_by_modelid 或 net_cal)
+        node_id_from = None
+        node_id_to = None
+        node_id_max_res = None
+        
+        # 先尝试从回风井结果中提取节点ID
+        if discovered.get("out_shaft_tuns"):
+            # 假设可以从 net_cal 结果中获取节点信息
+            pass
+        
+        # 使用固定节点ID(文档中的示例值)
+        node_id_max_res = "3958"
+        node_id_from = "2"
+        node_id_to = "3958"
+        
+        # ── 更新动态测试用例 ──
+        for case in TEST_CASES:
+            if case["tool"] == "get_max_resistance_path" and case.get("dynamic"):
+                case["params"]["node_id"] = node_id_max_res
+                print(f"\n   📍 TC-10 使用 node_id={node_id_max_res}")
+            elif case["tool"] == "get_path_press_power" and case.get("dynamic"):
+                case["params"]["id_from"] = node_id_from
+                case["params"]["id_to"] = node_id_to
+                print(f"   📍 TC-13 使用 id_from={node_id_from}, id_to={node_id_to}")
+        
+        # ── 第二阶段:逐个测试 ──
+        print(f"\n{'=' * 80}")
+        print(f"  第二阶段:执行 {len(TEST_CASES)} 个测试用例")
+        print(f"{'=' * 80}\n")
+        
+        for i, case in enumerate(TEST_CASES):
+            print(f"[{i+1:02d}/{len(TEST_CASES)}] {case['tool']} ... ", end="", flush=True)
+            result = await call_single_tool(client, case)
+            results.append(result)
+            
+            status = "✅" if result["success"] else "❌"
+            elapsed = result.get("elapsed_ms", 0)
+            size = result.get("size", "N/A")
+            print(f"{status} {elapsed:.0f}ms {size}")
+            
+            if not result["success"]:
+                print(f"     ⚠️  错误: {result.get('error', 'N/A')[:120]}")
+            elif result.get("data_preview"):
+                preview = result["data_preview"][:120].replace("\n", " ")
+                print(f"     📄 {preview}")
+        
+    # ── 第三阶段:生成报告 ──
+    print(f"\n{'=' * 80}")
+    print(f"  第三阶段:生成测试报告")
+    print(f"{'=' * 80}\n")
+    
+    # 统计
+    total = len(results)
+    passed = sum(1 for r in results if r["success"])
+    failed = sum(1 for r in results if not r["success"])
+    # 业务层面统计
+    biz_ok = sum(1 for r in results if r["success"] and r.get("deep_success"))
+    biz_timeout = sum(1 for r in results if r["success"] and r.get("data_preview", "").find('"code": 408') > 0)
+    biz_500 = sum(1 for r in results if r["success"] and r.get("data_preview", "").find('"code": 500') > 0)
+    total_time = sum(r.get("elapsed_ms", 0) for r in results)
+    
+    # 生成 JSON 报告
+    report_json_path = os.path.join(os.path.dirname(__file__), "mcp_test_report.json")
+    report = {
+        "title": "通风模型算法 MCP 接口测试报告",
+        "server": MCP_URL,
+        "model_id": MODEL_ID,
+        "timestamp": datetime.now().isoformat(),
+        "summary": {
+            "total": total,
+            "passed": passed,
+            "failed": failed,
+            "pass_rate": f"{passed / total * 100:.1f}%" if total > 0 else "N/A",
+            "biz_ok": biz_ok,
+            "biz_timeout": biz_timeout,
+            "biz_500": biz_500,
+            "total_elapsed_ms": round(total_time, 2),
+            "avg_elapsed_ms": round(total_time / total, 2) if total > 0 else 0,
+        },
+        "results": results,
+    }
+    
+    with open(report_json_path, "w", encoding="utf-8") as f:
+        json.dump(report, f, ensure_ascii=False, indent=2, default=str)
+    print(f"   JSON 报告已保存: {report_json_path}")
+    
+    # 生成 Markdown 报告
+    md_path = os.path.join(os.path.dirname(__file__), "mcp_test_report.md")
+    md_lines = []
+    md_lines.append("# 通风模型算法 MCP 接口测试报告\n")
+    md_lines.append(f"**测试时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}  \n")
+    md_lines.append(f"**MCP 服务地址**: `{MCP_URL}`  \n")
+    md_lines.append(f"**测试模型 ID**: `{MODEL_ID}`  \n")
+    md_lines.append(f"**传输协议**: `streamable-http`  \n")
+    md_lines.append(f"**客户端库**: `fastmcp`  \n")
+    md_lines.append("")
+    
+    # 汇总
+    md_lines.append("## 📊 测试汇总\n")
+    md_lines.append("### 传输层结果\n")
+    md_lines.append("| 指标 | 数值 |")
+    md_lines.append("|:---|---:|")
+    md_lines.append(f"| 接口总数 | {total} |")
+    md_lines.append(f"| ✅ 传输层通过 | {passed} |")
+    md_lines.append(f"| ❌ 传输层失败 | {failed} |")
+    md_lines.append(f"| 传输层通过率 | {passed / total * 100:.1f}% |")
+    md_lines.append(f"| 总耗时 | {total_time:.0f} ms |")
+    md_lines.append(f"| 平均响应 | {total_time / total:.0f} ms |")
+    md_lines.append("")
+    md_lines.append("### 业务层结果\n")
+    md_lines.append("| 指标 | 数值 |")
+    md_lines.append("|:---|---:|")
+    md_lines.append(f"| ✅ 业务正常 | {biz_ok} |")
+    md_lines.append(f"| ⏱️ 服务端超时 (408) | {biz_timeout} |")
+    md_lines.append(f"| 🔴 服务端错误 (500) | {biz_500} |")
+    md_lines.append(f"| ❌ 客户端校验失败 | {failed} |")
+    md_lines.append(f"| 综合可用率 | {biz_ok / total * 100:.1f}% |")
+    md_lines.append("")
+    
+    # 分类统计
+    md_lines.append("### 分类统计\n")
+    md_lines.append("| 分类 | 总数 | 通过 | 失败 | 平均耗时 |")
+    md_lines.append("|:---|---:|---:|---:|---:|")
+    categories = {}
+    for r in results:
+        cat = r["category"]
+        if cat not in categories:
+            categories[cat] = {"total": 0, "passed": 0, "failed": 0, "times": []}
+        categories[cat]["total"] += 1
+        if r["success"]:
+            categories[cat]["passed"] += 1
+        else:
+            categories[cat]["failed"] += 1
+        categories[cat]["times"].append(r.get("elapsed_ms", 0))
+    
+    for cat, stats in categories.items():
+        avg_t = sum(stats["times"]) / len(stats["times"]) if stats["times"] else 0
+        md_lines.append(f"| {cat} | {stats['total']} | {stats['passed']} | {stats['failed']} | {avg_t:.0f} ms |")
+    md_lines.append("")
+    
+    # 详细结果
+    md_lines.append("## 📋 详细测试结果\n")
+    
+    for r in results:
+        # 确定状态图标
+        if not r["success"]:
+            status_icon = "❌"
+            biz_label = "客户端参数校验失败"
+        elif r.get("deep_success") is False:
+            preview = r.get("data_preview", "")
+            if '"code": 408' in preview or "408" in preview:
+                status_icon = "⏱️"
+                biz_label = "服务端超时 (408)"
+            elif '"code": 500' in preview or "500" in preview:
+                status_icon = "🔴"
+                biz_label = "服务端内部错误 (500)"
+            elif '"code": 503' in preview or "503" in preview:
+                status_icon = "🔴"
+                biz_label = "服务不可用 (503)"
+            else:
+                status_icon = "⚠️"
+                biz_label = "业务异常"
+        else:
+            status_icon = "✅"
+            biz_label = "正常"
+        
+        md_lines.append(f"### {status_icon} {r['id']}: {r['desc']} (`{r['tool']}`)\n")
+        md_lines.append(f"- **分类**: {r['category']}")
+        md_lines.append(f"- **传输状态**: {'通过' if r['success'] else '失败'} | **业务状态**: {biz_label}")
+        md_lines.append(f"- **耗时**: {r.get('elapsed_ms', 'N/A')} ms")
+        
+        if r["success"]:
+            md_lines.append(f"- **结果大小**: {r.get('size', 'N/A')}")
+            md_lines.append(f"- **数据结构**: `{r.get('structure', 'N/A')}`")
+        else:
+            md_lines.append(f"- **错误类型**: `{r.get('error_type', 'N/A')}`")
+            md_lines.append(f"- **错误信息**: {r.get('error', 'N/A')[:300]}")
+        
+        # 参数
+        params_str = ", ".join(f"`{k}={v}`" for k, v in r["params"].items())
+        md_lines.append(f"- **调用参数**: {params_str}")
+        
+        # 数据预览
+        if r.get("data_preview"):
+            preview = r["data_preview"]
+            if len(preview) > 500:
+                preview = preview[:500] + "..."
+            md_lines.append(f"\n<details>\n<summary>数据预览</summary>\n\n```json\n{preview}\n```\n</details>")
+        
+        md_lines.append("")
+    
+    # 建议
+    md_lines.append("## 💡 建议与注意事项\n")
+    md_lines.append(f"### ⚠️ 需要修复的问题({biz_timeout + biz_500} 个)\n")
+    md_lines.append("| 接口 | 问题 | 建议 |")
+    md_lines.append("|:---|:---|:---|")
+    
+    for r in results:
+        preview = r.get("data_preview", "")
+        if '"code": 408' in preview or "408" in preview:
+            md_lines.append(f"| `{r['tool']}` | 服务端超时 (408),耗时 {r.get('elapsed_ms', 0):.0f}ms | 优化后端算法或增加超时时间;前端设置 180s+ 超时并显示进度 |")
+        elif '"code": 500' in preview or "500" in preview:
+            md_lines.append(f"| `{r['tool']}` | 服务端内部错误 (500) - Connection prematurely closed | 检查后端 netty 连接池配置,可能需要增大响应超时 |")
+        elif '"code": 503' in preview or "503" in preview:
+            md_lines.append(f"| `{r['tool']}` | 服务不可用 (503) - Unable to find instance | 检查微服务 `ventanaly-model` 实例是否在线/注册中心状态 |")
+    
+    md_lines.append("")
+    md_lines.append("### 📝 通用建议\n")
+    md_lines.append("1. **响应时间差异大**:快速接口(进/回风井查询、循环风路检查)约 0.8-1.5s,重计算接口(压能图、避灾路线)约 12-35s,超时接口约 180s。建议按接口类型设置差异化超时。\n")
+    md_lines.append("2. **数据量大**:`get_three_area_distribution` (366KB) 和 `get_path_press_power` (3.8MB) 返回数据量大,移动端需考虑分页或压缩。\n")
+    md_lines.append("3. **参数校验**:`net_cal_for_plan` 的 `plan` 参数不能为空字符串,建议后端明确 `plan` 格式规范或提供示例。\n")
+    md_lines.append("4. **返回值统一性**:外层统一为 `{success, code, result}`,但 `get_model_fault_diagnosis` 直接在外层返回 `modelID`,略有不一致。\n")
+    md_lines.append("5. **节点 vs 隧道 ID**:`get_max_resistance_path` 和 `get_path_press_power` 使用 `node_id`(节点ID),其他接口使用 `tun_id`(隧道ID),文档已说明,但调用时容易混淆。\n")
+    md_lines.append("6. **CO 参数**:`get_escape_path_each_exit` 的 `co_per` 默认 2000.0,`during_time` 默认 0.0,合理默认值便于调用。\n")
+    
+    with open(md_path, "w", encoding="utf-8") as f:
+        f.write("\n".join(md_lines))
+    print(f"   Markdown 报告已保存: {md_path}")
+    
+    # ── 终端输出汇总 ──
+    print(f"\n{'=' * 80}")
+    print(f"  📊 测试汇总")
+    print(f"  总计: {total} | ✅ 通过: {passed} | ❌ 失败: {failed} | 通过率: {passed / total * 100:.1f}%")
+    print(f"  总耗时: {total_time:.0f} ms | 平均: {total_time / total:.0f} ms")
+    print(f"{'=' * 80}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 191 - 0
tools/context_tracker.py

@@ -0,0 +1,191 @@
+# -*- coding: utf-8 -*-
+"""
+上下文容量追踪模块
+
+功能:
+- 基于 tiktoken 估算各组件 token 用量
+- 从 LLM 响应中提取实际 token 使用
+- 将会话上下文用量写入数据库
+
+分类维度:
+  messages / mcp / skills / system_prompt / other
+"""
+
+import tiktoken
+
+from db.chat_store import save_context_usage, get_messages as db_get_messages
+
+# ── 编码器(cl100k_base 兼容 OpenAI / DeepSeek)──
+ENCODING = tiktoken.get_encoding("cl100k_base")
+
+# ── 模型上下文窗口上限 ──
+# 优先级:环境变量 CONTEXT_LIMIT → 模型名推断 → 兜底 131072
+_MODEL_CONTEXT_MAP = {
+    "deepseek-v4-pro":  1000000,  # 1M
+    "deepseek-v4-flash": 1000000,  # 1M
+    "deepseek-v3":        131072,  # 128K
+    "gpt-4o":             131072,  # 128K
+    "gpt-4o-mini":        131072,  # 128K
+    "qwen3.7-plus":       131072,  # 128K
+}
+
+
+def _resolve_context_limit() -> int:
+    """解析上下文上限(每次调用时重新解析,确保读到最新环境变量)。
+
+    优先级:环境变量 CONTEXT_LIMIT → 模型名映射 → 兜底 1M。
+    """
+    # 直接从 .env 文件读取(不受 CWD / load_dotenv 时序影响)
+    from pathlib import Path as _Path
+    from dotenv import dotenv_values
+    _env = dotenv_values(str(_Path(__file__).parent.parent / ".env"))
+
+    env_val = _env.get("CONTEXT_LIMIT", "").strip()
+    if env_val:
+        try:
+            return int(env_val)
+        except ValueError:
+            pass
+
+    model = _env.get("DEEPAGENT_MODEL", "").strip().lower()
+    # 去掉可能的 "openai:" 前缀
+    if ":" in model:
+        model = model.split(":", 1)[1]
+    return _MODEL_CONTEXT_MAP.get(model, 1000000)
+
+
+def get_context_limit() -> int:
+    """获取当前模型上下文窗口上限(懒加载)。"""
+    return _resolve_context_limit()
+
+
+# ============================================================
+#  工具 schema 提取辅助
+# ============================================================
+
+
+def _tool_to_text(tool) -> str:
+    """将工具函数转为可计数的文本表示(含名称 + 描述 + 参数 schema)。"""
+    parts = []
+    name = getattr(tool, "name", None) or getattr(tool, "__name__", str(tool))
+    desc = getattr(tool, "description", "") or ""
+    parts.append(f"Tool: {name}")
+    if desc:
+        parts.append(f"Description: {desc}")
+    # 尝试提取参数 schema
+    args_schema = getattr(tool, "args_schema", None)
+    if args_schema and hasattr(args_schema, "schema"):
+        try:
+            import json
+            parts.append(f"Args: {json.dumps(args_schema.schema(), ensure_ascii=False)}")
+        except Exception:
+            pass
+    return "\n".join(parts)
+
+
+# ============================================================
+#  全局缓存 —— 系统组件 token 预估值(agent 启动时填充)
+# ============================================================
+
+_system_prompt_tokens = 0
+_skills_tokens = 0
+_mcp_tokens = 0
+
+
+def init_system_components(
+    system_prompt: str = "",
+    tool_defs: list[str] | None = None,
+    skill_contents: list[str] | None = None,
+    mcp_defs: list[str] | None = None,
+):
+    """初始化系统组件的 token 预估值。应在 agent 创建后调用一次。
+
+    mcp 类别 = 本地工具 + MCP 远程工具(合并统计)。
+    """
+    global _system_prompt_tokens, _skills_tokens, _mcp_tokens
+    _system_prompt_tokens = count_tokens(system_prompt)
+    _skills_tokens = sum(count_tokens(s) for s in (skill_contents or []))
+    # tools + mcp 合并为 mcp 类别
+    _mcp_tokens = (
+        sum(count_tokens(t) for t in (tool_defs or []))
+        + sum(count_tokens(m) for m in (mcp_defs or []))
+    )
+
+
+# ============================================================
+#  Token 计数工具
+# ============================================================
+
+
+def count_tokens(text: str) -> int:
+    """使用 tiktoken 精确计数 token 数。"""
+    if not text:
+        return 0
+    try:
+        return len(ENCODING.encode(text))
+    except Exception:
+        # 兜底估算:中文 ~1.5 字/token,英文 ~4 字/token
+        return max(1, len(text) // 2)
+
+
+def _count_messages_tokens(session_id: str, current_message: str = "") -> int:
+    """计算会话消息历史的 token 数。"""
+    total = 0
+    try:
+        for msg in db_get_messages(session_id, limit=1000):
+            total += count_tokens(msg.get("content", ""))
+    except Exception:
+        pass
+    total += count_tokens(current_message)
+    return total
+
+
+# ============================================================
+#  主入口:捕获并存储上下文用量
+# ============================================================
+
+
+async def capture_context_usage(
+    session_id: str,
+    prompt_tokens: int,
+    completion_tokens: int,
+    current_message: str = "",
+):
+    """在每次 agent 任务完成后调用,保存上下文用量快照。
+
+    Args:
+        session_id:        会话 ID
+        prompt_tokens:     从 LLM 响应中提取的实际 prompt_tokens
+        completion_tokens: 从 LLM 响应中提取的实际 completion_tokens
+        current_message:   本次用户消息(用于计入 messages 估算)
+    """
+    if not session_id or prompt_tokens <= 0:
+        return
+
+    messages_tokens = _count_messages_tokens(session_id, current_message)
+
+    # 系统组件 token(使用全局缓存估值)
+    # mcp = 本地工具 + MCP 远程工具(已合并)
+    skills_tokens = _skills_tokens
+    mcp_tokens = _mcp_tokens
+    system_prompt_tokens = _system_prompt_tokens
+
+    # other = 提示词总量 - 已知各组件估算值(兜底 ≥0)
+    known = messages_tokens + mcp_tokens + skills_tokens + system_prompt_tokens
+    other_tokens = max(0, prompt_tokens - known)
+
+    current_usage = prompt_tokens + completion_tokens
+
+    try:
+        save_context_usage(
+            session_id=session_id,
+            total_limit=get_context_limit(),
+            current_usage=current_usage,
+            messages_tokens=messages_tokens,
+            mcp_tokens=mcp_tokens,
+            skills_tokens=skills_tokens,
+            system_prompt_tokens=system_prompt_tokens,
+            other_tokens=other_tokens,
+        )
+    except Exception:
+        pass  # 上下文记录失败不应阻断对话

+ 15 - 0
tools/tool_names_cn.py

@@ -51,6 +51,9 @@ TOOL_NAME_CN: dict[str, str] = {
     "query_knowledge_base": "检索煤矿安全知识库",
     "get_needq_all_data": "获取管控平台需风量数据",
     "list_ventanaly_monitor_data_days": "查询监测历史时序数据",
+    "get_device_kind_dict": "查询设备类型字典",
+    "get_device_list_by_kind": "按类型查询设备列表",
+    "query_device_realtime_data": "查询设备实时数据",
 
     # MCP 配风计划审查(vent_plan_tools.py)
     "query_mining_plan": "查询采掘计划",
@@ -87,4 +90,16 @@ TOOL_NAME_CN: dict[str, str] = {
     # ================================================================
     "calc_gas_emission_from_wind": "测风报表涌出量反算",
     "get_current_time": "获取当前系统时间",
+
+    # ================================================================
+    # 用户偏好记忆
+    # ================================================================
+    "save_user_preference": "保存用户偏好",
+    "list_user_preferences": "查看用户偏好",
+    "delete_user_preference": "删除用户偏好",
+
+    # ================================================================
+    # 计划审批(Human-in-the-Loop)
+    # ================================================================
+    "request_plan_approval": "提交执行计划审批",
 }

+ 175 - 1
tools/vent_tools.py

@@ -16,13 +16,26 @@ DeepAgents 工具函数模块
 - query_knowledge_base: 查询煤矿安全知识库
 - get_needq_all_data: 获取全部需风量数据
 - list_ventanaly_monitor_data_days: 查询监测历史时序数据
+- get_device_kind_dict: 查询设备大类小类全量字典
+- get_device_list_by_kind: 根据设备类型查询设备列表
+- query_device_realtime_data: 查询设备实时监测数据
 """
-
+	
 import json
+import contextvars
 from fastmcp import Client
 from tools.mcp_logger import log_mcp_call
 
 
+# ============================================================
+# 当前用户上下文(供工具函数获取调用者身份)
+# ============================================================
+
+_current_user: contextvars.ContextVar[str] = contextvars.ContextVar(
+    'current_user', default='admin'
+)
+
+
 # ============================================================
 # MCP 客户端辅助函数
 # ============================================================
@@ -288,6 +301,66 @@ async def get_needq_all_data() -> str:
     return await _call_mcp_tool("get_needq_all_data", {})
 
 
+async def get_device_kind_dict() -> str:
+    """查询设备大类(deviceKind)和小类(strType)的全量字典。
+
+    返回设备类型编码与中文名称的完整映射表,包含 deviceKind(设备大类)
+    和 strType(设备小类)两个维度的编码-名称对照。
+    无参数。
+
+    适用场景:
+    - 用户询问"有哪些设备类型""设备分类有哪些""设备大类小类有哪些"
+    - 需要将设备类型编码翻译为中文名称
+    - 查询某种设备类型对应的 strType 编码以便后续调用历史数据工具
+    - 用户想知道系统中支持监控哪些类型的设备
+
+    Returns:
+        JSON 格式的设备类型字典,包含 deviceKind 和 strType 映射。
+    """
+    return await _call_mcp_tool("get_device_kind_dict", {})
+
+
+async def get_device_list_by_kind(device_kind: str) -> str:
+    """根据设备类型(deviceKind)查询该类型下的全量设备列表。
+
+    返回指定设备大类下的所有设备,包含设备ID、设备名称、安装位置、分站名称。
+
+    适用场景:
+    - 用户询问"列出所有风速传感器""有哪些甲烷传感器"等按类型筛选设备
+    - 需要获取某类设备的完整清单以便进一步查询实时/历史数据
+    - 结合 get_device_kind_dict 先获取类型编码,再按类型查设备列表
+
+    Args:
+        device_kind: 设备大类编码(必填),如 "modelsensor_speed"表示风速传感器。
+                     可先调用 get_device_kind_dict 获取所有可用的 deviceKind 编码。
+
+    Returns:
+        JSON 格式的设备列表,包含 device_id、device_name、install_pos、station_name。
+    """
+    return await _call_mcp_tool("get_device_list_by_kind", {"device_kind": device_kind})
+
+
+async def query_device_realtime_data(device_id: str) -> str:
+    """查询指定设备的实时监测数据。
+
+    根据设备ID获取该设备当前最新的监测读数、运行状态和报警信息。
+    与 query_device_data_by_id 不同,本工具专注于单设备的实时快照数据,
+    返回结构更精简,延迟更低。
+
+    适用场景:
+    - 用户询问"设备XXX的实时数据""传感器XXX当前读数是多少"
+    - 已知设备ID,需要快速获取其最新监测值
+    - 配合 get_device_list_by_kind 先查出设备ID列表,再逐个查询实时数据
+
+    Args:
+        device_id: 设备ID(必填),可从 query_devices_by_tunnel / get_device_list_by_kind 返回结果中获取。
+
+    Returns:
+        JSON 格式的设备实时监测数据,包含当前读数、单位、采集时间、在线状态、报警信息。
+    """
+    return await _call_mcp_tool("query_device_realtime_data", {"device_id": device_id})
+
+
 # ============================================================
 # 监测历史数据查询(通防管控平台 MCP)
 # ============================================================
@@ -333,3 +406,104 @@ async def list_ventanaly_monitor_data_days(
         "page_size": page_size,
         # "column": column,
     })
+
+
+# ============================================================
+# 用户偏好记忆工具
+# ============================================================
+
+def save_user_preference(content: str, keywords: str = "",
+                         category: str = "通用") -> str:
+    """保存用户偏好/习惯到个人记忆库。
+
+    当用户在对话中明确要求"记住""保存为习惯/偏好""以后都用这个"时调用。
+    下次该用户对话时,系统会自动注入已保存的偏好作为上下文。
+
+    Args:
+        content: 偏好内容,描述具体的习惯或个性化要求。例如"习惯使用 m³/s 而非 m³/min"、"15216工作面默认采高3.5m"
+        keywords: 触发关键词,多个用逗号分隔。例如"风速,单位,风量"。留空则自动匹配。
+        category: 偏好分类,默认"通用"。可选值:需风量计算、数据解读、规程查询、通用
+
+    Returns:
+        保存结果,含记录ID供后续删除用。
+    """
+    from db.chat_store import save_user_preference as _db_save
+    user_name = _current_user.get()
+    pref_id = _db_save(user_name, content, keywords, category)
+    return json.dumps({
+        "success": True,
+        "id": pref_id,
+        "message": f"已保存偏好 (id={pref_id}):{content}",
+    }, ensure_ascii=False)
+
+
+def list_user_preferences() -> str:
+    """查看当前用户已保存的所有偏好/习惯。
+
+    列出该用户所有偏好记录,包含ID、分类、内容、关键词、保存时间。
+
+    Returns:
+        JSON格式的偏好列表。
+    """
+    from db.chat_store import get_user_preferences as _db_list
+    user_name = _current_user.get()
+    prefs = _db_list(user_name)
+    if not prefs:
+        return json.dumps({"preferences": [], "message": "暂无保存的偏好"}, ensure_ascii=False)
+    return json.dumps({
+        "preferences": [
+            {"id": p["id"], "category": p["category"], "content": p["content"],
+             "keywords": p["keywords"], "created_at": p["created_at"]}
+            for p in prefs
+        ],
+    }, ensure_ascii=False)
+
+
+def delete_user_preference(preference_id: int) -> str:
+    """删除一条用户偏好记录。
+
+    Args:
+        preference_id: 要删除的偏好记录ID(可从 list_user_preferences 获取)
+
+    Returns:
+        删除结果。
+    """
+    from db.chat_store import delete_user_preference as _db_delete
+    user_name = _current_user.get()
+    ok = _db_delete(preference_id, user_name)
+    if ok:
+        return json.dumps({"success": True, "message": f"已删除偏好 (id={preference_id})"}, ensure_ascii=False)
+    return json.dumps({"success": False, "message": f"未找到偏好记录 id={preference_id} 或无权操作"}, ensure_ascii=False)
+
+
+# ============================================================
+# 计划审批工具(Human-in-the-Loop)
+# ============================================================
+
+def request_plan_approval(plan_summary: str) -> str:
+    """提交执行计划等待人工审批。在制定好完整计划后调用此工具。
+
+    仅在计划模式(plan mode)下由 Agent 主动调用。
+    调用后会暂停执行,等待用户在前端审批(批准/拒绝)。
+    审批通过后自动继续执行计划。
+
+    Args:
+        plan_summary: 执行计划的简要描述,需包含:
+                      - 计划分几步,每步做什么
+                      - 每步预期调用哪些工具
+                      - 预期的输出结果
+
+    Returns:
+        "计划已批准,开始执行。" 或 "计划被拒绝。"
+    """
+    from langgraph.types import interrupt
+
+    result = interrupt({
+        "type": "plan_approval",
+        "plan": plan_summary,
+        "message": "智能体已制定执行计划,等待您的审批...",
+    })
+    if isinstance(result, dict) and result.get("action") == "approve":
+        return "计划已批准,开始执行。"
+    else:
+        return "计划被拒绝。"

+ 5 - 4
xfl_demo_client.py

@@ -5,9 +5,9 @@ from fastmcp import Client
 
 client = Client("http://39.97.59.228:8071/mcp")
 
-async def call_tool(tun_id: int):
+async def call_tool():
     async with client:
-        result1 = await client.call_tool("query_wind_by_tunid",{"tun_id":"581","model_id":2012326636757958658})
+        result1 = await client.call_tool("query_device_data_by_id",{"device_id": "1432344790415630337", "page_size": 20})
         data = json.loads(result1.content[0].text)
         print(json.dumps(data, ensure_ascii=False, indent=2))
 
@@ -25,6 +25,7 @@ def parse_tool_params(schema: dict):
             "required": name in required
         }
     return params
+
 async def list_all_mcp_tools_tcp():
     """TCP 远程MCP客户端查询工具列表"""
     async with client:
@@ -34,6 +35,6 @@ async def list_all_mcp_tools_tcp():
             params_info = parse_tool_params(t.inputSchema)
             print("参数详情:", params_info)
 
-asyncio.run(call_tool(581))
+asyncio.run(call_tool())
 
-asyncio.run(list_all_mcp_tools_tcp())
+# asyncio.run(list_all_mcp_tools_tcp())