康宇 10 jam lalu
induk
melakukan
2947c2e103

+ 1 - 0
.gitignore

@@ -57,3 +57,4 @@ htmlcov/
 # === ZCode / Tools ===
 .reasonix/
 .zcode/
+.agents

+ 683 - 26
API_DOCUMENTATION.md

@@ -1,36 +1,93 @@
 # 通风数据解读 API 接口文档
 
 > **服务器地址**: http://39.97.59.228:8070
+>
+> **文档版本**: v2.0
 > 
-> **文档版本**: v1.0
+> **最后更新**: 2026-08-05
 
 ---
 
 ## 目录
 
 - [接口概览](#接口概览)
+- [认证机制](#认证机制)
 - [SSE 流式响应格式](#sse-流式响应格式)
 - [点选巷道解读接口](#点选巷道解读接口)
 - [点选设备解读接口](#点选设备解读接口)
 - [对话式解读接口](#对话式解读接口)
+- [对话恢复接口(人工审批)](#对话恢复接口人工审批)
+- [配风计划审查接口(PDF 上传)](#配风计划审查接口pdf-上传)
 - [聊天历史查询接口](#聊天历史查询接口)
 - [会话列表接口](#会话列表接口)
 - [删除会话接口](#删除会话接口)
+- [会话权限模式管理](#会话权限模式管理)
+- [会话置顶](#会话置顶)
+- [会话标题编辑](#会话标题编辑)
+- [上下文用量查询](#上下文用量查询)
+- [模型管理接口](#模型管理接口)
+- [技能管理接口](#技能管理接口)
 - [工具清单](#工具清单)
+- [前端接入示例](#前端接入示例)
+- [错误处理](#错误处理)
 
 ---
 
 ## 接口概览
 
-| 方法 | 路径 | 描述 | 类型 |
-|------|------|------|------|
-| POST | `/api/interpret/click/tun` | 点选/悬浮巷道数据解读 | SSE 流式 |
-| POST | `/api/interpret/click/device` | 点选/悬浮设备数据解读 | SSE 流式 |
-| POST | `/api/chat` | 对话式数据解读 / 需风量计算 | SSE 流式 |
-| POST | `/api/vent/review/pdf` | 配风计划审查(PDF 上传) | SSE 流式 |
-| GET | `/api/chat/history/{session_id}` | 查询聊天历史 | 常规 |
-| GET | `/api/sessions` | 查询会话列表 | 常规 |
-| DELETE | `/api/sessions/{session_id}` | 删除会话 | 常规 |
+| 方法 | 路径 | 描述 | 类型 | 认证 |
+|------|------|------|------|------|
+| POST | `/api/interpret/click/tun` | 点选/悬浮巷道数据解读 | SSE 流式 | — |
+| POST | `/api/interpret/click/device` | 点选/悬浮设备数据解读 | SSE 流式 | — |
+| POST | `/api/chat` | 对话式数据解读 / 需风量计算 / PDF 审查 | SSE 流式 | ✓ |
+| POST | `/api/chat/resume` | 恢复中断的对话(人工审批) | SSE 流式 | ✓ |
+| POST | `/api/vent/review/pdf` | 配风计划审查(PDF 上传) | SSE 流式 | — |
+| GET | `/api/chat/history/{session_id}` | 查询聊天历史 | 常规 | ✓ |
+| GET | `/api/sessions` | 查询会话列表 | 常规 | ✓ |
+| DELETE | `/api/sessions/{session_id}` | 删除会话 | 常规 | ✓ |
+| GET | `/api/sessions/{session_id}/mode` | 获取会话权限模式 | 常规 | ✓ |
+| PUT | `/api/sessions/{session_id}/mode` | 设置会话权限模式 | 常规 | ✓ |
+| PUT | `/api/sessions/{session_id}/pin` | 切换会话置顶状态 | 常规 | ✓ |
+| PUT | `/api/sessions/{session_id}/title` | 编辑会话标题 | 常规 | ✓ |
+| GET | `/api/sessions/{session_id}/context` | 查询会话上下文用量 | 常规 | ✓ |
+| GET | `/api/model` | 获取当前模型信息 | 常规 | — |
+| POST | `/api/model/switch` | 切换运行时模型 | 常规 | — |
+| POST | `/api/model/thinking` | 切换思考级别 | 常规 | — |
+| GET | `/api/skills` | 获取技能列表 | 常规 | — |
+| GET | `/api/skills/{name}` | 查看技能详情 | 常规 | — |
+| POST | `/api/skills/{name}/toggle` | 启用/禁用技能 | 常规 | — |
+| POST | `/api/skills/upload` | 上传新技能包 | 常规 | — |
+
+---
+
+## 认证机制
+
+> **新增于 v2.0**
+
+所有标注 "✓ 认证" 的接口需要在请求头中携带登录令牌。
+
+### 请求头
+
+```
+x-access-token: <your_token>
+```
+
+### 认证流程
+
+1. 客户端在请求头中传递 `x-access-token`
+2. 服务端将令牌转发到通风系统 `GET /sys/user/getUserInfo` 进行验证
+3. 验证通过后,请求继续处理;验证失败返回 401
+
+### 认证错误码
+
+| HTTP 状态码 | 含义 |
+|-------------|------|
+| 401 | 缺少令牌、令牌无效或已过期 |
+| 502 | 认证服务连接失败 |
+| 504 | 认证服务响应超时 |
+| 500 | 认证服务地址未配置 |
+
+> **注意**:点选解读接口(`/api/interpret/click/*`)和配风计划审查接口(`/api/vent/review/pdf`)暂不要求认证。模型管理和技能管理接口也不要求认证。
 
 ---
 
@@ -53,16 +110,19 @@ X-Session-Id: {session_id}
 
 | type | 描述 | 核心字段 |
 |------|------|----------|
+| `agent_start` | 智能体开始处理(对话 Agent 启动时发送) | `agent`, `cn_agent`, `message` |
 | `thinking` | AI 正在思考/推理 | `source`, `node`, `message` |
 | `executing` | AI 正在执行工具调用 | `source`, `tools`, `message` |
 | `tool_call` | 检测到工具调用 | `source`, `tool`, `message` |
 | `tool_result` | 工具执行结果返回 | `source`, `tool`, `message` |
 | `updated_todo_list` | Todo 列表更新 | `source`, `todos`, `message` |
 | `token` | AI 生成的文本内容 | `source`, `content` |
+| `reasoning` | 推理模型思考过程(如 DeepSeek-R1 的 reasoning_content) | `source`, `content` |
+| `agent_done` | 智能体处理完成 | `agent`, `cn_agent`, `duration_ms`, `message` |
+| `interrupt` | 智能体暂停等待人工审批(计划模式) | `node`, `message`, `interrupts`, `plan`(可选) |
 | `done` | 流式响应结束 | `thread_id`, `session_id`, `message` |
 | `generating` | Fast 模式:LLM 开始生成报告 | `source` |
-| `progress` | Fast 模式:等待心跳进度 | `message` |
-| `reasoning` | Fast 模式:推理模型思考过程(如 DeepSeek-R1) | `content` |
+| `progress` | Fast 模式 / PDF 审查:进度更新 | `message` |
 | `error` | 发生错误 | `message` |
 
 > **`message` 字段**(v1.3+):所有事件均包含中文 `message` 字段,描述当前正在执行的操作,如 "正在分析您的问题..."、"查询巷道监测数据 完成"。前端可据此展示进度提示。
@@ -97,6 +157,8 @@ X-Session-Id: {session_id}
 
 **POST** `/api/interpret/click/tun`
 
+> **认证**:不需要
+
 ### 解读模式
 
 接口支持两种模式,通过 `mode` 参数控制:
@@ -175,6 +237,8 @@ data: {"type": "done", "thread_id": "xxx", "session_id": "xxx"}
 
 **POST** `/api/interpret/click/device`
 
+> **认证**:不需要
+
 ### 解读模式
 
 与巷道解读接口相同,支持 `mode` 参数:
@@ -226,23 +290,102 @@ data: {"type": "done", "thread_id": "xxx", "session_id": "xxx"}
 
 **POST** `/api/chat`
 
-### 请求示例
+> **认证**:需要 `x-access-token` 请求头
+>
+> **Content-Type**:`multipart/form-data`
 
-```json
-{
-  "message": "15216 辅运起坡段当前风速风量情况如何?",
-  "session_id": "可选,已有会话ID",
-  "thread_id": "可选,LangGraph线程ID"
-}
-```
+### 功能说明
 
-### 请求参数
+统一对话入口,根据用户意图自动路由到不同智能体:
+- **配风计划审查**(上传 PDF)→ 审查管线(form-reviewer + data-checker + calc-verifier)
+- **数据解读 / 需风量计算 / 其他** → 通风对话助手
+
+支持两种权限模式:
+- **plan**(计划模式):Agent 先制定执行计划,提交人工审批后再执行
+- **full**(完全访问):Agent 自动执行,无需审批
+
+> **注意**:接口已从 `application/json` 改为 `multipart/form-data`,以支持文件上传。
+
+### 请求参数(multipart/form-data)
 
 | 参数 | 类型 | 必填 | 描述 |
 |------|------|------|------|
 | `message` | string | 是 | 用户消息(自然语言) |
 | `session_id` | string | 否 | 会话 ID,新会话可不传 |
 | `thread_id` | string | 否 | LangGraph 线程 ID,用于恢复多轮对话状态 |
+| `mode` | string | 否 | 权限模式:`"plan"`(计划审批)或 `"full"`(完全访问)。仅新建会话时生效 |
+| `file` | file | 否 | 附件(PDF / 文档等),用于配风计划审查等场景 |
+
+### 请求示例(curl)
+
+```bash
+curl -X POST http://39.97.59.228:8070/api/chat \
+  -H "x-access-token: YOUR_TOKEN" \
+  -F "message=15216 辅运起坡段当前风速风量情况如何?" \
+  -F "session_id=optional-session-id" \
+  -F "mode=full"
+```
+
+带文件上传:
+```bash
+curl -X POST http://39.97.59.228:8070/api/chat \
+  -H "x-access-token: YOUR_TOKEN" \
+  -F "message=帮我审查这份配风计划" \
+  -F "file=@/path/to/plan.pdf" \
+  -F "mode=plan"
+```
+
+### 智能体开场事件(新增)
+
+对话 Agent 启动时会先发送 `agent_start` 事件:
+
+```json
+{
+  "type": "agent_start",
+  "agent": "通风对话助手",
+  "cn_agent": "通风对话助手",
+  "message": "「通风对话助手」开始处理..."
+}
+```
+
+对应完成时有 `agent_done` 事件:
+
+```json
+{
+  "type": "agent_done",
+  "agent": "通风对话助手",
+  "cn_agent": "通风对话助手",
+  "duration_ms": 4523,
+  "message": "「通风对话助手」完成(4523ms)"
+}
+```
+
+### 中断事件(计划模式)
+
+当权限模式为 `plan` 时,Agent 调用 `request_plan_approval` 工具后会暂停,发送 `interrupt` 事件:
+
+```json
+{
+  "type": "interrupt",
+  "node": "tools",
+  "message": "智能体已制定执行计划,等待您的审批...",
+  "interrupts": ["..."]
+}
+```
+
+此时流式响应**不会**发送 `done` 事件,前端应展示审批 UI。用户批准/拒绝后,调用 `/api/chat/resume` 接口继续。
+
+### reasoning 事件(推理模型)
+
+当使用 DeepSeek-R1 等推理模型时,模型的思考过程通过 `reasoning` 事件输出:
+
+```json
+{
+  "type": "reasoning",
+  "source": "main",
+  "content": "用户询问15216巷道风速,我需要先查询监测数据..."
+}
+```
 
 ### 流式响应详解
 
@@ -377,10 +520,52 @@ data: {"type": "done", "thread_id": "xxx", "session_id": "xxx"}
 
 ---
 
+## 对话恢复接口(人工审批)
+
+**POST** `/api/chat/resume`
+
+> **认证**:需要 `x-access-token` 请求头
+
+### 功能说明
+
+恢复被中断的 LangGraph 对话。当计划模式(plan)下 Agent 调用 `request_plan_approval` 工具后暂停,用户批准或拒绝后通过此接口继续执行。
+
+### 请求参数(JSON)
+
+| 参数 | 类型 | 必填 | 描述 |
+|------|------|------|------|
+| `session_id` | string | 是 | 会话 ID |
+| `thread_id` | string | 否 | LangGraph 线程 ID(不传则使用 session_id) |
+| `action` | string | 是 | `"approve"`(批准继续执行)或 `"reject"`(拒绝) |
+
+### 请求示例
+
+```json
+{
+  "session_id": "e5f6g7h8-...",
+  "thread_id": "a1b2c3d4-...",
+  "action": "approve"
+}
+```
+
+### 响应
+
+与 `/api/chat` 相同的 SSE 流式格式。拒绝时立即返回 error + done:
+
+```
+data: {"type": "error", "message": "用户拒绝了工具执行"}
+
+data: {"type": "done", "thread_id": "...", "session_id": "...", "message": "已取消执行"}
+```
+
+---
+
 ## 配风计划审查接口(PDF 上传)
 
 **POST** `/api/vent/review/pdf`
 
+> **认证**:不需要
+
 ### 功能说明
 
 上传配风计划 PDF 文件,系统自动进行三方面审查:
@@ -522,6 +707,8 @@ data: {"type":"done","session_id":"xxx","download_url":"http://localhost:8000/st
 
 **GET** `/api/chat/history/{session_id}`
 
+> **认证**:需要 `x-access-token` 请求头
+
 ### 请求参数(Query)
 
 | 参数 | 类型 | 默认值 | 描述 |
@@ -562,6 +749,10 @@ data: {"type":"done","session_id":"xxx","download_url":"http://localhost:8000/st
 
 **GET** `/api/sessions`
 
+> **认证**:需要 `x-access-token` 请求头
+
+> **v2.0 变更**:会话按当前登录用户过滤,仅返回该用户的会话。
+
 ### 请求参数(Query)
 
 | 参数 | 类型 | 默认值 | 描述 |
@@ -578,6 +769,8 @@ data: {"type":"done","session_id":"xxx","download_url":"http://localhost:8000/st
       "session_id": "xxx",
       "title": "15216 辅运起坡段...",
       "message_count": 5,
+      "user_name": "zhangsan",
+      "sort_order": 1,
       "created_at": "2024-01-15T10:30:00"
     }
   ],
@@ -585,12 +778,21 @@ data: {"type":"done","session_id":"xxx","download_url":"http://localhost:8000/st
 }
 ```
 
+### 新增字段(v2.0)
+
+| 字段 | 描述 |
+|------|------|
+| `user_name` | 会话所属用户名 |
+| `sort_order` | 置顶状态:`1` = 已置顶,`0` = 未置顶 |
+
 ---
 
 ## 删除会话接口
 
 **DELETE** `/api/sessions/{session_id}`
 
+> **认证**:需要 `x-access-token` 请求头
+
 ### 路径参数
 
 | 参数 | 类型 | 描述 |
@@ -608,9 +810,366 @@ data: {"type":"done","session_id":"xxx","download_url":"http://localhost:8000/st
 
 ---
 
+## 会话权限模式管理
+
+> **v2.0 新增**
+
+### 获取权限模式
+
+**GET** `/api/sessions/{session_id}/mode`
+
+> **认证**:需要 `x-access-token` 请求头
+
+#### 响应示例
+
+```json
+{
+  "session_id": "xxx",
+  "mode": "full"
+}
+```
+
+### 设置权限模式
+
+**PUT** `/api/sessions/{session_id}/mode`
+
+> **认证**:需要 `x-access-token` 请求头
+
+#### 请求参数(JSON)
+
+| 参数 | 类型 | 必填 | 描述 |
+|------|------|------|------|
+| `mode` | string | 是 | `"plan"`(计划审批模式)或 `"full"`(完全访问模式) |
+
+#### 请求示例
+
+```json
+{
+  "mode": "plan"
+}
+```
+
+#### 响应示例
+
+```json
+{
+  "session_id": "xxx",
+  "mode": "plan",
+  "message": "已切换为「plan」模式"
+}
+```
+
+### 权限模式说明
+
+| 模式 | 行为 |
+|------|------|
+| `plan` | Agent 先制定执行计划 → 调用 `request_plan_approval` 暂停 → 等待人工审批 → 审批后执行 |
+| `full` | Agent 自动执行所有操作,无需人工审批 |
+
+---
+
+## 会话置顶
+
+> **v2.0 新增**
+
+**PUT** `/api/sessions/{session_id}/pin`
+
+> **认证**:需要 `x-access-token` 请求头
+
+切换会话置顶状态(置顶 ⇄ 取消置顶),每次调用反转当前状态。
+
+#### 响应示例
+
+```json
+{
+  "session_id": "xxx",
+  "pinned": true,
+  "message": "已置顶"
+}
+```
+
+---
+
+## 会话标题编辑
+
+> **v2.0 新增**
+
+**PUT** `/api/sessions/{session_id}/title`
+
+> **认证**:需要 `x-access-token` 请求头
+
+#### 请求参数(JSON)
+
+| 参数 | 类型 | 必填 | 描述 |
+|------|------|------|------|
+| `title` | string | 是 | 新标题,最长 100 字 |
+
+#### 请求示例
+
+```json
+{
+  "title": "15216 工作面需风量计算"
+}
+```
+
+#### 响应示例
+
+```json
+{
+  "session_id": "xxx",
+  "title": "15216 工作面需风量计算",
+  "message": "标题已更新"
+}
+```
+
+---
+
+## 上下文用量查询
+
+> **v2.0 新增**
+
+**GET** `/api/sessions/{session_id}/context`
+
+> **认证**:需要 `x-access-token` 请求头
+
+查询会话最近一次请求的上下文 token 用量详情,用于监控 LLM 上下文窗口使用情况。
+
+#### 响应示例
+
+```json
+{
+  "session_id": "xxx",
+  "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"
+}
+```
+
+#### 响应字段
+
+| 字段 | 描述 |
+|------|------|
+| `total_limit` | 模型上下文窗口上限(tokens) |
+| `current_usage` | 当前已用 tokens |
+| `usage_pct` | 使用百分比 |
+| `breakdown.messages` | 历史消息占用的 tokens |
+| `breakdown.mcp` | MCP 工具描述占用的 tokens |
+| `breakdown.skills` | 技能描述占用的 tokens |
+| `breakdown.system_prompt` | 系统提示词占用的 tokens |
+| `breakdown.other` | 其他(工具结果等)占用的 tokens |
+| `updated_at` | 最近一次更新时间 |
+
+> 若无记录,各个数值字段返回 `null`。
+
+---
+
+## 模型管理接口
+
+> **v2.0 新增**
+
+### 获取当前模型信息
+
+**GET** `/api/model`
+
+返回当前运行时模型的名称、思考级别和可用选项。
+
+#### 响应示例
+
+```json
+{
+  "current": "deepseek-v4-pro",
+  "available": ["deepseek-v4-pro", "deepseek-v4-flash"],
+  "thinking_level": "off",
+  "thinking_levels": ["off", "high", "highest"]
+}
+```
+
+### 切换运行时模型
+
+**POST** `/api/model/switch`
+
+切换对话 Agent 使用的 LLM 模型。切换后立即生效(下次请求使用新模型)。
+
+#### 请求参数(JSON)
+
+| 参数 | 类型 | 必填 | 描述 |
+|------|------|------|------|
+| `model` | string | 是 | 模型名称,可选值见 `GET /api/model` 的 `available` 字段 |
+
+#### 请求示例
+
+```json
+{
+  "model": "deepseek-v4-flash"
+}
+```
+
+#### 响应示例
+
+```json
+{
+  "current": "deepseek-v4-flash",
+  "message": "已切换到 deepseek-v4-flash,下次请求生效"
+}
+```
+
+> **注意**:仅影响对话解读和配风计划审查的智能体,点选解读(click_routes)不受影响。
+
+### 切换思考级别
+
+**POST** `/api/model/thinking`
+
+切换模型思考深度。切换后立即生效。
+
+#### 请求参数(JSON)
+
+| 参数 | 类型 | 必填 | 描述 |
+|------|------|------|------|
+| `level` | string | 是 | `"off"`(关闭)、`"high"`(开启)、`"highest"`(最强) |
+
+#### 请求示例
+
+```json
+{
+  "level": "high"
+}
+```
+
+#### 响应示例
+
+```json
+{
+  "thinking_level": "high",
+  "message": "思考级别已切换为「high」,下次请求生效"
+}
+```
+
+---
+
+## 技能管理接口
+
+> **v2.0 新增**
+
+### 获取技能列表
+
+**GET** `/api/skills`
+
+返回所有已安装技能的列表(含启用状态和所属智能体)。
+
+#### 响应示例
+
+```json
+{
+  "skills": [
+    {
+      "name": "click-interpret-tun",
+      "display_name": "巷道点选解读",
+      "path": "skills/click-interpret-tun",
+      "description": "对巷道监测数据进行深度解读...",
+      "enabled": true,
+      "agents": ["点选解读 Agent"],
+      "has_skill_md": true
+    }
+  ]
+}
+```
+
+#### 响应字段
+
+| 字段 | 描述 |
+|------|------|
+| `name` | 技能内部名称(目录名) |
+| `display_name` | 技能显示名称(取自 SKILL.md frontmatter) |
+| `path` | 技能相对路径 |
+| `description` | 技能描述(取自 SKILL.md frontmatter,截取前120字) |
+| `enabled` | 是否启用 |
+| `agents` | 使用该技能的智能体列表 |
+| `has_skill_md` | 是否包含 SKILL.md 文件 |
+
+### 查看技能详情
+
+**GET** `/api/skills/{name}`
+
+返回指定技能的 SKILL.md 完整内容。
+
+#### 路径参数
+
+| 参数 | 类型 | 描述 |
+|------|------|------|
+| `name` | string | 技能名称(目录名) |
+
+#### 响应示例
+
+```json
+{
+  "name": "click-interpret-tun",
+  "content": "---\nname: 巷道点选解读\ndescription: |\n  对巷道监测数据..."
+}
+```
+
+### 启用/禁用技能
+
+**POST** `/api/skills/{name}/toggle`
+
+切换技能的启用状态。禁用后,对应智能体在下次请求时将跳过该技能。
+
+#### 路径参数
+
+| 参数 | 类型 | 描述 |
+|------|------|------|
+| `name` | string | 技能名称(目录名) |
+
+#### 响应示例
+
+```json
+{
+  "name": "click-interpret-tun",
+  "enabled": false,
+  "message": "技能「click-interpret-tun」已禁用"
+}
+```
+
+### 上传新技能
+
+**POST** `/api/skills/upload`
+
+上传一个 `.zip` 格式的技能包。
+
+#### 请求参数(multipart/form-data)
+
+| 参数 | 类型 | 必填 | 描述 |
+|------|------|------|------|
+| `file` | file | 是 | `.zip` 格式的技能包,解压后必须包含 SKILL.md |
+
+#### 约束
+
+- 仅支持 `.zip` 格式
+- 技能包内必须包含 `SKILL.md` 文件
+- 技能名不能与已有技能重复
+- 自动过滤路径穿越攻击
+
+#### 响应示例(成功)
+
+```json
+{
+  "name": "my-new-skill",
+  "message": "技能「my-new-skill」上传成功"
+}
+```
+
+---
+
 ## 工具清单
 
-系统工具分为数据查询、知识库检索、需风量计算、MCP 远程查询四类。
+系统工具分为数据查询、知识库检索、需风量计算、MCP 远程查询、用户偏好、系统工具六类。
 
 ### 数据查询工具(对话解读 / 点选解读)
 
@@ -621,6 +1180,12 @@ data: {"type":"done","session_id":"xxx","download_url":"http://localhost:8000/st
 | `query_devices_by_tunnel` | 根据巷道名称查询关联设备列表 |
 | `query_devices_by_tunnel_id` | 根据巷道 ID 查询已绑定设备 |
 | `get_tun_list_by_modelid` | 根据模型 ID 获取巷道列表 |
+| `query_tunnels_by_model` | 查询模型巷道列表 |
+| `query_tunnel_list` | 按名称搜索巷道 |
+| `get_device_kind_dict` | 查询设备类型字典 |
+| `get_device_list_by_kind` | 按类型查询设备列表 |
+| `query_device_realtime_data` | 查询设备实时快照数据 |
+| `list_ventanaly_monitor_data_days` | 查询监测历史时序数据 |
 | `query_knowledge_base` | 检索煤矿安全知识库(《煤矿安全规程》条款) |
 | `write_todos` | 任务清单管理工具 |
 
@@ -636,6 +1201,7 @@ data: {"type":"done","session_id":"xxx","download_url":"http://localhost:8000/st
 | `calc_tunnel_by_explosives` | 按炸药量计算掘进面需风量 |
 | `calc_tunnel_by_workers` | 按人数计算掘进面需风量 |
 | `calc_tunnel_by_wind_speed` | 按风速验算掘进面需风量 |
+| `calc_tunnel_by_vehicle` | 按胶轮车计算掘进面需风量 |
 | `calc_tunnel_air_volume_max` | 掘进面需风量综合计算(取各方法最大值) |
 | `calc_chamber_by_equipment` | 按设备发热量计算机电硐室需风量 |
 | `calc_chamber_by_wind_speed` | 按风速验算硐室需风量 |
@@ -644,6 +1210,13 @@ data: {"type":"done","session_id":"xxx","download_url":"http://localhost:8000/st
 | `calc_total_air_volume` | 汇总矿井总需风量 |
 | `get_needq_all_data` | 获取通防管控平台全部需风量数据(MCP) |
 
+### 反算 / 系统工具
+
+| 工具名称 | 描述 |
+|----------|------|
+| `calc_gas_emission_from_wind` | 测风报表瓦斯涌出量反算 |
+| `get_current_time` | 获取当前系统时间 |
+
 ### MCP 配风计划审查工具(仅 PDF 审查)
 
 | 工具名称 | MCP 服务端工具名 | 描述 |
@@ -653,6 +1226,20 @@ data: {"type":"done","session_id":"xxx","download_url":"http://localhost:8000/st
 | `query_gas_report` | `query_gas_identification_report` | 查询瓦斯等级鉴定报告 |
 | `query_wind_report` | `query_ventilation_report` | 查询测风报表数据 |
 
+### 用户偏好记忆工具
+
+| 工具名称 | 描述 |
+|----------|------|
+| `save_user_preference` | 保存用户偏好/习惯(Agent 可据此个性化回复) |
+| `list_user_preferences` | 查看当前用户已保存的所有偏好 |
+| `delete_user_preference` | 删除指定用户偏好 |
+
+### 计划审批工具(Human-in-the-Loop)
+
+| 工具名称 | 描述 |
+|----------|------|
+| `request_plan_approval` | 提交执行计划等待人工审批(用于 plan 模式) |
+
 ### write_todos 工具响应
 
 当调用 `write_todos` 时,会触发 `updated_todo_list` 事件:
@@ -681,12 +1268,21 @@ Todo 状态枚举:
 
 ### SSE 连接示例 (JavaScript)
 
+> **v2.0 更新**:需要携带认证令牌,使用 FormData 发送请求。
+
 ```javascript
-async function connectChat(message, sessionId = null) {
+async function connectChat(message, sessionId = null, mode = 'full', token = '') {
+  const formData = new FormData();
+  formData.append('message', message);
+  if (sessionId) formData.append('session_id', sessionId);
+  formData.append('mode', mode);
+
   const response = await fetch('http://39.97.59.228:8070/api/chat', {
     method: 'POST',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify({ message, session_id: sessionId })
+    headers: {
+      'x-access-token': token,
+    },
+    body: formData,
   });
 
   const reader = response.body.getReader();
@@ -710,6 +1306,9 @@ async function connectChat(message, sessionId = null) {
 
 function handleSSEMessage(data) {
   switch (data.type) {
+    case 'agent_start':
+      console.log('Agent 启动:', data.cn_agent);
+      break;
     case 'thinking':
       console.log('AI思考中...', data.node);
       break;
@@ -719,11 +1318,57 @@ function handleSSEMessage(data) {
     case 'token':
       appendContent(data.content);
       break;
+    case 'reasoning':
+      console.log('推理过程:', data.content);
+      break;
+    case 'interrupt':
+      // 展示审批 UI,等待用户批准/拒绝
+      showApprovalUI(data);
+      break;
+    case 'agent_done':
+      console.log('Agent 完成:', data.cn_agent, data.duration_ms + 'ms');
+      break;
     case 'done':
       console.log('完成', data.session_id);
       break;
   }
 }
+
+// 批准后恢复对话
+async function resumeChat(sessionId, threadId, action = 'approve', token = '') {
+  const response = await fetch('http://39.97.59.228:8070/api/chat/resume', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'x-access-token': token,
+    },
+    body: JSON.stringify({
+      session_id: sessionId,
+      thread_id: threadId,
+      action: action,
+    }),
+  });
+
+  // 同样使用 SSE 解析...
+}
+```
+
+### 带文件上传示例
+
+```javascript
+async function uploadAndChat(message, file, token = '') {
+  const formData = new FormData();
+  formData.append('message', message);
+  formData.append('file', file);
+
+  const response = await fetch('http://39.97.59.228:8070/api/chat', {
+    method: 'POST',
+    headers: { 'x-access-token': token },
+    body: formData,
+  });
+
+  // 解析 SSE 流...
+}
 ```
 
 ---
@@ -735,8 +1380,12 @@ function handleSSEMessage(data) {
 | HTTP 状态码 | 含义 |
 |-------------|------|
 | 400 | 请求参数错误 |
+| 401 | 未登录或令牌无效(详见[认证机制](#认证机制)) |
 | 404 | 会话不存在 |
+| 409 | 资源冲突(如技能名重复) |
 | 500 | 服务器内部错误 |
+| 502 | 认证服务不可用 |
+| 504 | 认证服务超时 |
 
 ### 错误响应格式
 
@@ -747,6 +1396,14 @@ function handleSSEMessage(data) {
 }
 ```
 
+或(非流式接口):
+
+```json
+{
+  "error": "详细错误信息"
+}
+```
+
 ---
 
-*文档生成时间: 2024年*
+*文档生成时间: 2024年 | 最后更新: 2026-08-05 (v2.0)*

+ 1 - 1
agents/needq_agent.py

@@ -115,7 +115,7 @@ def create_needq_calc_agent():
 
     _project_root = str(Path(__file__).parent.parent)
 
-    skills = ["skills/needq-calc"]
+    skills = [SKILLS_ROOT + "/needq-calc"]
     print(f"[skills] Agent=needq-calc-agent skills={skills}")
 
     agent = create_deep_agent(

+ 4 - 4
agents/review_agent.py

@@ -355,7 +355,7 @@ def create_form_review_agent():
     from deepagents import FilesystemPermission
     from api.model_config import get_model_instance
 
-    skills = ["skills/vent-plan-review-form"]
+    skills = [SKILLS_ROOT + "/vent-plan-review-form"]
     print(f"[skills] Agent=form-review-agent skills={skills}")
 
     agent = create_deep_agent(
@@ -385,7 +385,7 @@ def create_data_consistency_agent():
     from deepagents import FilesystemPermission
     from api.model_config import get_model_instance
 
-    skills = ["skills/vent-plan-review-data"]
+    skills = [SKILLS_ROOT + "/vent-plan-review-data"]
     print(f"[skills] Agent=data-consistency-agent skills={skills}")
 
     agent = create_deep_agent(
@@ -423,7 +423,7 @@ def create_calc_verification_agent():
     from deepagents import FilesystemPermission
     from api.model_config import get_model_instance
 
-    skills = ["skills/vent-plan-review-calc"]
+    skills = [SKILLS_ROOT + "/vent-plan-review-calc"]
     print(f"[skills] Agent=calc-verification-agent skills={skills}")
 
     agent = create_deep_agent(
@@ -470,7 +470,7 @@ def create_summary_agent():
     from deepagents import FilesystemPermission
     from api.model_config import get_model_instance
 
-    skills = ["skills/vent-plan-review-summary"]
+    skills = [SKILLS_ROOT + "/vent-plan-review-summary"]
     print(f"[skills] Agent=summary-agent skills={skills}")
 
     agent = create_deep_agent(

+ 214 - 25
agents/vent_agent.py

@@ -24,16 +24,20 @@ from langgraph.checkpoint.memory import MemorySaver
 
 
 SKILLS_ROOT = str((Path(__file__).parent.parent / "skills").resolve())
+PROJECT_ROOT = str((Path(__file__).parent.parent).resolve())
 
 load_dotenv()
 
 # 导入工具函数
 from tools.vent_tools import (
+    # 数据查询
     query_tun_data_by_id,
-    query_knowledge_base, 
+    query_knowledge_base,
+    query_device_data,
     query_device_data_by_id,
     query_devices_by_tunnel,
     query_devices_by_tunnel_id,
+    query_devices_by_model,
     get_tun_list_by_modelid,
     query_tunnels_by_model,
     query_tunnel_list,
@@ -42,11 +46,67 @@ from tools.vent_tools import (
     get_device_list_by_kind,
     query_device_realtime_data,
     get_needq_all_data,
+    get_dict_list_by_dictcode,
+    # 需风量 / 模型
+    get_model_param_pub_list,
+    get_model_wind,
+    get_sensor_wind,
+    simulate_needq_heading_face,
+    simulate_needq_room,
+    simulate_needq_ret_work_face,
+    simulate_needq_other,
+    # 设备信息
+    query_device_info,
+    query_device_type_info,
+    query_monitor_params,
+    # 故障诊断
+    check_model_connect_status,
+    check_model_one_dir_cycle,
+    check_model_one_dir_node,
+    check_model_diagonal_structure,
+    get_model_fault_diagnosis,
+    # 避灾路线
+    get_escape_path,
+    get_escape_path_each_exit,
+    # 关键阻力 / 压能
+    get_out_shafts,
+    get_in_shafts,
+    get_max_resistance_path,
+    get_three_area_distribution,
+    get_key_path_decision,
+    get_path_press_power,
+    # 网络解算
+    net_cal,
+    net_cal_for_plan,
+    # 报警 / 日志
+    get_alarm_log_history,
+    get_device_set_log_history,
+    get_sys_log_history,
+    # 场景管理
+    get_manage_system_by_strType,
+    query_system_by_systemID,
+    # 煤矿基础
+    get_gas_identify_vo,
+    get_by_mine_name,
+    query_control_testWind,
+    # 数据库 / 文件
+    execute_sql_query,
+    get_file_list_by_type,
+    get_file_base64_by_id,
+    # 报表
+    get_latest_report,
+    # 用户偏好
     save_user_preference,
     list_user_preferences,
     delete_user_preference,
+    # 计划审批
     request_plan_approval,
 )
+from tools.web_search_tools import web_search, web_fetch
+from tools.file_reader_tools import read_file_content
+from tools.time_tools import get_current_time
+from tools.chat_history_tools import search_chat_history, get_session_chat, get_current_session_id
+from tools.report_utils import save_report
 from tools.audit_middleware import create_audit_middleware
 from tools.context_tracker import init_system_components
 from langgraph.utils.runnable import RunnableCallable
@@ -145,6 +205,30 @@ DIALOG_INTERPRET_SYSTEM_PROMPT = f"""你是一名煤矿通风安全智能助手
 - 当用户要求"删除某条习惯""忘掉那个偏好"时,先调用 list_user_preferences 确认ID,再调用 delete_user_preference 删除
 - 保存偏好时,content 字段应精炼概括用户的要求(一句话),keywords 字段列出相关关键词
 - 系统已自动将用户偏好注入到每条消息前缀中,请主动参考这些偏好来个性化回复
+
+## 能力四:联网搜索
+- 当用户问题超出煤矿通风专业知识库覆盖范围时,使用 web_search 查询互联网公开信息
+- 获取搜索结果后可按需调用 web_fetch 查看详情页的完整内容
+- 搜索优先级:先查内部知识库(query_knowledge_base)→ 知识库信息不足或需要最新政策/行业新闻时再联网搜索
+- 引用网络信息时标注来源 URL,说明该信息的时效性和局限性
+
+## 能力五:文件内容读取
+- 当用户上传了文件,消息中会包含「文件临时路径」,请使用 read_file_content 工具读取文件内容
+- 支持的文件格式:PDF(.pdf)、Word(.docx)、Excel(.xlsx/.xlsm)、PowerPoint(.pptx)、纯文本(.txt/.md/.csv/.json)
+- 读取到文件内容后,根据用户的要求进行分析、总结、数据提取或计算
+- 如果文件内容为空或格式不支持,向用户说明具体情况
+
+## 能力六:聊天记录查询
+- 当用户询问"之前聊过什么""搜索历史""查看之前的对话""找一下关于xxx的记录"时,
+  使用 search_chat_history 搜索会话标题,再用 get_session_chat 读取具体内容
+- 聊天记录存储在数据库而非文件系统,请使用这两个专用工具,切勿用 ls/read_file/grep 查找
+- 搜索到相关会话后,可以总结、引用或提取其中的信息来回答用户
+
+## 能力七:报告保存
+- 当用户要求"生成报告""输出报告""保存为文档""导出分析结果"时,
+  调用 save_report 工具将 Markdown 内容保存为 .md 文件
+- 切勿使用 write_file / edit_file 等文件系统工具写文件(已被权限禁止)
+- save_report 会自动生成文件名和下载链接,返回给用户即可
 """
 
 
@@ -223,9 +307,9 @@ def create_click_interpret_agent():
     from deepagents.backends import FilesystemBackend
     from deepagents import FilesystemPermission
 
-    _project_root = str(Path(__file__).parent.parent)
+    # _project_root = str(Path(__file__).parent.parent)
 
-    skills = ["skills/click-interpret-tun", "skills/click-interpret-device"]
+    skills = [SKILLS_ROOT + "/click-interpret-tun", SKILLS_ROOT + "/click-interpret-device"]
     print(f"[skills] Agent=click-interpret-agent skills={skills}")
 
     agent = create_deep_agent(
@@ -240,7 +324,7 @@ def create_click_interpret_agent():
         ],
         skills=skills,
         system_prompt=CLICK_INTERPRET_SYSTEM_PROMPT,
-        backend=FilesystemBackend(root_dir=_project_root, virtual_mode=True),
+        backend=FilesystemBackend(root_dir=PROJECT_ROOT, virtual_mode=True),
         permissions=[
             FilesystemPermission(operations=["write"], paths=["/**"], mode="deny"),
         ],
@@ -268,29 +352,93 @@ def create_dialog_interpret_agent():
     from deepagents import FilesystemPermission
     from api.model_config import get_model_instance
 
-    _project_root = str(Path(__file__).parent.parent)
 
-    skills = ["skills/"]  # 父目录模式:SkillsMiddleware 自动扫描 skills/ 下所有子目录(含 SKILL.md 的技能目录)
+    # ── 动态注入当前日期到系统提示词 ──
+    from datetime import datetime, timezone, timedelta
+    _china_tz = timezone(timedelta(hours=8))
+    _now = datetime.now(_china_tz)
+    _dated_prompt = (
+        DIALOG_INTERPRET_SYSTEM_PROMPT
+        + f"\n## 系统时间\n当前日期时间:{_now.strftime('%Y年%m月%d日 %H:%M:%S')}(中国标准时间 CST,UTC+8)。\n"
+        + f"今天是 {_now.strftime('%Y')}年{_now.strftime('%m')}月{_now.strftime('%d')}日,周{'一二三四五六日'[_now.weekday()]}。\n"
+        + "所有涉及日期、时间的判断必须以本系统时间为准,不要使用你自己的训练数据中的日期。"
+    )
+
+    skills = [SKILLS_ROOT]  # 绝对路径:SkillsMiddleware 自动扫描下所有子目录(含 SKILL.md 的技能目录)
     print(f"[skills] Agent=dialog-interpret-agent skills={skills}")
+    print(f"[日期] 已注入当前系统时间: {_now.strftime('%Y-%m-%d %H:%M:%S')} CST")
 
     agent = create_deep_agent(
         model=get_model_instance(),
         tools=[
-            # 数据查询工具
+            # ── 数据查询工具 ──
             query_tun_data_by_id,
+            query_device_data,
             query_device_data_by_id,
             query_devices_by_tunnel,
             query_devices_by_tunnel_id,
+            query_devices_by_model,
             query_tunnel_list,
             query_tunnels_by_model,
+            get_tun_list_by_modelid,
             list_ventanaly_monitor_data_days,
             query_knowledge_base,
             get_device_kind_dict,
             get_device_list_by_kind,
             query_device_realtime_data,
-            # 基础工具
+            get_needq_all_data,
+            get_dict_list_by_dictcode,
+            # ── 需风量 / 模型数据 ──
+            get_model_param_pub_list,
+            get_model_wind,
+            get_sensor_wind,
+            simulate_needq_heading_face,
+            simulate_needq_room,
+            simulate_needq_ret_work_face,
+            simulate_needq_other,
+            # ── 设备信息 ──
+            query_device_info,
+            query_device_type_info,
+            query_monitor_params,
+            # ── 故障诊断 ──
+            check_model_connect_status,
+            check_model_one_dir_cycle,
+            check_model_one_dir_node,
+            check_model_diagonal_structure,
+            get_model_fault_diagnosis,
+            # ── 避灾路线 ──
+            get_escape_path,
+            get_escape_path_each_exit,
+            # ── 关键阻力 / 压能 ──
+            get_out_shafts,
+            get_in_shafts,
+            get_max_resistance_path,
+            get_three_area_distribution,
+            get_key_path_decision,
+            get_path_press_power,
+            # ── 网络解算 ──
+            net_cal,
+            net_cal_for_plan,
+            # ── 报警 / 日志 ──
+            get_alarm_log_history,
+            get_device_set_log_history,
+            get_sys_log_history,
+            # ── 场景管理 ──
+            get_manage_system_by_strType,
+            query_system_by_systemID,
+            # ── 煤矿基础 ──
+            get_gas_identify_vo,
+            get_by_mine_name,
+            query_control_testWind,
+            # ── 数据库 / 文件 ──
+            execute_sql_query,
+            get_file_list_by_type,
+            get_file_base64_by_id,
+            # ── 报表 ──
+            get_latest_report,
+            # ── 基础工具 ──
             write_todos,
-            # 辅助计算工具
+            # ── 辅助计算工具 ──
             calc_effective_area,
             calc_total_air_volume,
             # 采煤工作面计算
@@ -309,18 +457,29 @@ def create_dialog_interpret_agent():
             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)
+            # ── 计划审批(Human-in-the-Loop)──
             request_plan_approval,
+            # ── 联网搜索 ──
+            web_search,
+            web_fetch,
+            # ── 文件内容读取 ──
+            read_file_content,
+            # ── 聊天记录查询 ──
+            search_chat_history,
+            get_session_chat,
+            get_current_session_id,
+            # ── 报告保存 ──
+            save_report,
+            # ── 系统工具 ──
+            get_current_time,
         ],
         skills=skills,
-        system_prompt=DIALOG_INTERPRET_SYSTEM_PROMPT,
-        backend=FilesystemBackend(root_dir=_project_root, virtual_mode=True),
+        system_prompt=_dated_prompt,
+        backend=FilesystemBackend(root_dir=PROJECT_ROOT, virtual_mode=True),
         permissions=[
             FilesystemPermission(operations=["write"], paths=["/**"], mode="deny"),
         ],
@@ -341,29 +500,59 @@ def _init_context_tracker_for_dialog():
 
     _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,
+        query_tun_data_by_id, query_device_data, query_device_data_by_id,
+        query_devices_by_tunnel, query_devices_by_tunnel_id, query_devices_by_model,
+        query_tunnel_list, query_tunnels_by_model, get_tun_list_by_modelid,
         list_ventanaly_monitor_data_days, query_knowledge_base,
-        get_device_kind_dict, get_device_list_by_kind,
-        query_device_realtime_data, write_todos,
-        # 辅助计算
+        get_device_kind_dict, get_device_list_by_kind, get_dict_list_by_dictcode,
+        query_device_realtime_data,
+        # 需风量 / 模型
+        get_model_param_pub_list, get_model_wind, get_sensor_wind,
+        simulate_needq_heading_face, simulate_needq_room,
+        simulate_needq_ret_work_face, simulate_needq_other,
+        # 设备信息
+        query_device_info, query_device_type_info, query_monitor_params,
+        # 故障诊断
+        check_model_connect_status, check_model_one_dir_cycle,
+        check_model_one_dir_node, check_model_diagonal_structure,
+        get_model_fault_diagnosis,
+        # 避灾路线
+        get_escape_path, get_escape_path_each_exit,
+        # 关键阻力 / 压能
+        get_out_shafts, get_in_shafts, get_max_resistance_path,
+        get_three_area_distribution, get_key_path_decision, get_path_press_power,
+        # 网络解算
+        net_cal, net_cal_for_plan,
+        # 报警 / 日志
+        get_alarm_log_history, get_device_set_log_history, get_sys_log_history,
+        # 场景管理
+        get_manage_system_by_strType, query_system_by_systemID,
+        # 煤矿基础
+        get_gas_identify_vo, get_by_mine_name, query_control_testWind,
+        # 数据库 / 文件
+        execute_sql_query, get_file_list_by_type, get_file_base64_by_id,
+        # 报表
+        get_latest_report,
+        # 基础 + 计算
+        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,
+        # 联网搜索 / 文件读取 / 聊天记录 / 系统
+        web_search, web_fetch,
+        read_file_content,
+        search_chat_history, get_session_chat, get_current_session_id,
+        save_report,
+        get_current_time,
     ]
     tool_texts = [_tool_to_text(t) for t in _all_tools]
 

+ 46 - 4
api/chat_routes.py

@@ -8,6 +8,7 @@
 """
 
 import asyncio
+import re
 
 from fastapi import APIRouter, Depends, File, Form, UploadFile
 from fastapi.responses import StreamingResponse
@@ -22,6 +23,7 @@ from agents.review_agent import stream_review
 from db.chat_store import (
     get_messages, create_session, update_session_title,
     get_session_mode, set_session_mode, get_user_preferences,
+    get_session_info,
 )
 
 router = APIRouter()
@@ -119,8 +121,9 @@ async def chat(
     thread_id = thread_id or session_id
 
     # 设置当前用户上下文(供偏好工具等获取调用者身份)
-    from tools.vent_tools import _current_user
+    from tools.vent_tools import _current_user, _current_session
     _current_user.set(user_name)
+    _current_session.set(session_id)
 
     # 保存用户原始消息(后续注入偏好/计划前缀前),用于写入数据库
     original_message = message
@@ -162,6 +165,38 @@ async def chat(
         )
         print(f"[偏好] 已为用户 {user_name} 注入 {len(prefs)} 条偏好记忆")
 
+    # ── 注入引用会话内容(检测 #session:<uuid> 前缀)──
+    # 注意:用 original_message(原始用户输入)匹配,避免偏好注入等前缀干扰
+    ref_match = re.match(r'^#session:([a-f0-9-]+)\s*(.*)', original_message, re.IGNORECASE)
+    if ref_match:
+        ref_session_id = ref_match.group(1)
+        user_actual_message = ref_match.group(2) or original_message
+        try:
+            ref_msgs = get_messages(ref_session_id, limit=100)
+            ref_info = get_session_info(ref_session_id)
+            ref_title = (ref_info.get("title") or "") if ref_info else ""
+            if not ref_title:
+                ref_title = ref_session_id[:8]
+            # 始终更新聊天记录为干净的标题引用格式(无论是否有消息)
+            original_message = f"[📋 引用会话「{ref_title}」] {user_actual_message}"
+            if ref_msgs:
+                ref_lines = []
+                for m in ref_msgs:
+                    role_label = "用户" if m["role"] == "user" else "助手"
+                    ref_lines.append(f"[{role_label}]: {m['content']}")
+                ref_text = "\n".join(ref_lines)
+                message = (
+                    f"[引用的会话内容]\n"
+                    f"以下是用户引用的会话「{ref_title}」的完整历史对话:\n"
+                    f"{ref_text}\n\n"
+                    f"当前用户问题:{user_actual_message}"
+                )
+                print(f"[会话引用] 已注入会话 {ref_session_id}({ref_title})的 {len(ref_msgs)} 条消息")
+            else:
+                print(f"[会话引用] 会话 {ref_session_id}({ref_title})无消息,仅保存标题引用")
+        except Exception as e:
+            print(f"[会话引用] 注入失败: {e}")
+
     # ── Agent 推理意图(含附件文件名辅助判断)──
     intent = await classify_intent(message, filename)
 
@@ -190,9 +225,12 @@ async def chat(
         message = f"用户想进行配风计划审查,但未上传附件。请提示用户上传配风计划PDF文件。用户原始消息:{message}"
     else:
         print(f"[路由] 意图: {intent} → dialog_agent(统一)")
-        # 如有附件,将文件名信息追加到消息中
-        if filename:
-            message = f"用户上传了文件「{filename}」。{message}"
+        # 如有附件:保存到临时目录,将文件路径注入消息供 Agent 工具读取
+        if filename and file is not None:
+            from tools.pdf_tools import save_upload_file
+            saved_path = await save_upload_file(file)
+            message = f"用户上传了文件「{filename}」,文件临时路径:{saved_path}。{message}"
+            print(f"[文件] 已保存上传文件: {saved_path}")
 
     # ── 计划模式:注入"先规划 → 审批 → 执行"指令 ──
     if current_mode == "plan" and agent_cn_name == "通风对话助手":
@@ -243,6 +281,10 @@ async def resume_chat(
     session_id = req.session_id
     thread_id = req.thread_id or session_id
 
+    # 设置当前会话上下文
+    from tools.vent_tools import _current_session
+    _current_session.set(session_id)
+
     # plan 模式审批后清除所有配置式中断(只审批一次,之后执行到底)
     # ⚠️ 必须 copy,否则 pop 会原地修改模块级常量 MODE_INTERRUPT
     mode = get_session_mode(session_id)

+ 4 - 3
api/model_config.py

@@ -185,10 +185,9 @@ def get_model_instance():
     except (ValueError, TypeError):
         timeout = 180.0
 
-    # 确保模型字符串带有 provider 前缀
+    # 不再强制加 provider 前缀,让 init_chat_model 根据模型名自动推断
+    # deepseek-v4-pro / deepseek-v4-flash 会被自动识别为 deepseek provider → ChatDeepSeek
     model_str = current
-    if ":" not in model_str:
-        model_str = f"openai:{model_str}"
 
     # 构建思考级别对应的参数
     # ChatOpenAI 有独立的 reasoning_effort 字段(直接透传至 API 顶层)
@@ -208,7 +207,9 @@ def get_model_instance():
     if base_url and api_key:
         instance = init_chat_model(
             model_str,
+            api_key=api_key,
             openai_api_key=api_key,
+            api_base=base_url,
             openai_api_base=base_url,
             temperature=0,
             timeout=timeout,

+ 488 - 4
api/prompts.py

@@ -3,6 +3,240 @@
 Fast 模式系统提示词 —— 内嵌报告模板,不使用 Skills
 """
 
+# 规则条款(定义在 FAST 提示词之前,供拼接使用)
+_VENT_RULE = """
+第一百五十六条 井下风流中的空气成分必须符合下列安全健康指标要求:
+
+(一)采掘工作面的进风流中,氧气浓度不低于20%,二氧化碳浓度不超过0.5%。
+
+(二)有害气体的浓度不超过表5规定。
+
+表5 矿井有害气体最高允许浓度
+
+名称	最高允许浓度 /%
+一氧化碳 CO	0.0024
+氧化氮(换算成NO₂)	0.00025
+二氧化硫 SO₂	0.0005
+硫化氢 H₂S	0.00066
+氨 NH₃	0.004
+甲烷、二氧化碳和氢气的允许浓度按照本规程的有关规定执行。
+
+矿井中所有气体的浓度均按照体积百分比计算。
+
+第一百五十七条 井巷中的风流速度应当符合表6要求。
+
+表6 井巷中的允许风流速度
+
+井巷名称	允许风速/(m·s⁻¹)
+最低	最高
+无提升设备的风井和风硐	—	15
+专为升降物料的井筒	—	12
+风桥	—	10
+升降人员和物料的井筒	—	8
+总进风巷和总回风巷	—	8
+架线电机车巷道	—	8
+箕斗提升井兼作进风的井筒	1.0	8
+装有带式输送机兼作回风的井筒	—	6
+输送机巷,采区进、回风巷	—	6
+装有带式输送机兼作进风的井筒	0.25	6
+采煤工作面、掘进中的煤巷和半煤岩巷	0.25	4
+掘进中的岩巷	0.15	4
+其他通风人行巷道*	0.15	4
+安设风门的联络巷在符合本规程第一百五十六条规定的前提下不受最低风速限制。
+
+设有梯子间的井筒或者修理中的井筒,风速不得超过8 m/s;梯子间四周经封闭后,井筒中的最高允许风速可以按照表6规定执行。
+
+无瓦斯涌出的架线电机车巷道中的最低风速可以低于表6的规定值,但不得低于0.5 m/s。
+
+综合机械化采煤工作面,在采取煤层注水和采煤机喷雾降尘等措施后,其最大风速可以高于表6的规定值,但不得超过5 m/s。
+
+第一百五十八条 进风井口以下的空气温度(干球温度,下同)必须在2℃以上。
+
+第一百五十九条 矿井需要的风量应当按照下列要求分别计算,并选取其中的最大值:
+
+(一)按照井下同时工作的最多人数计算,每人每分钟供给风量不得少于4 m³。
+
+(二)按照采掘工作面、硐室及其他地点实际需要风量的总和进行计算。各地点的实际需要风量,必须使该地点的风流中的甲烷、二氧化碳和其他有害气体的浓度,风速、温度及每人供风量符合本规程的有关规定。
+
+使用煤矿用防爆型柴油动力装置机车运输的矿井,行驶车辆巷道(联络巷除外)的供风量还应当按照同时运行的最多车辆数验算巷道配风量,配风量验算取值每千瓦不小于4 m³/min。
+
+按照实际需要计算风量时,应当避免备用风量过大或者过小。煤矿企业应当根据具体条件制定风量计算方法,至少每5年修订1次。
+
+第一百六十条 矿井每年安排采掘作业计划时必须核定矿井通风能力,严禁超通风能力生产。
+
+第一百六十一条 矿井必须建立测风制度,每旬至少进行1次全面测风。对采掘工作面和其他用风地点,应当根据实际需要随时测风,每次测风结果应当记录并在测风地点的记录牌上更新。除采掘工作面外,实现了实时风量监测的测风地点,可以不再人工测风,但必须定期校准。
+
+应当根据测风结果采取措施,进行风量调节。
+
+第一百六十二条 矿井必须有足够数量的通风安全检测仪表。仪表必须由具备相应资质的检验单位进行检验。
+
+第一百六十三条 矿井必须有完整的独立通风系统。改变全矿井通风系统时,必须编制通风设计及安全措施,由煤矿企业技术负责人审批。
+
+第一百六十四条 贯通巷道必须遵守下列规定:
+
+(一)巷道贯通前应当制定贯通专项措施。
+
+综合机械化掘进巷道在相距50 m前、其他巷道在相距20 m前,必须停止一个工作面作业,贯通前做好调整通风系统的准备工作。
+
+停掘的工作面必须保持正常通风,设置栅栏及警标,每班必须检查风筒的完好状况和工作面及其回风流中的瓦斯浓度,瓦斯浓度超限时,必须立即处理。
+
+掘进的工作面每次爆破前,必须派专人和瓦斯检查工共同到停掘的工作面检查工作面及其回风流中的瓦斯浓度,瓦斯浓度超限时,必须先停止在掘工作面的工作,然后处理瓦斯,只有掘进的工作面和贯通的工作面及其回风流中的甲烷浓度都在1.0%以下时,掘进的工作面方可爆破。每次爆破前,2个工作面入口必须有专人警戒。
+
+(二)贯通时,必须由专人在现场统一指挥。
+
+(三)贯通后,必须停止贯通影响区域内的一切工作,立即调整通风系统;风流稳定后,方可恢复工作;影响区域由煤矿总工程师组织确定。
+
+间距小于20 m的平行巷道的联络巷贯通,必须遵守以上规定。
+
+第一百六十五条 进、回风井之间,总进、回风巷之间和采(盘)区进、回风巷之间的每条联络巷中,必须砌筑永久性风墙;需要使用的行人、行车联络巷,必须安设不少于2道正向联锁风门和2道反向风门,或者安设不少于2道同时具备正向和反向功能的联锁风门;需要使用的联络巷用作其他用途时,必须进行专项设计,由煤矿总工程师审批。
+
+第一百六十六条 箕斗提升井或者装有带式输送机的井筒兼作风井使用时,必须遵守下列规定:
+
+(一)生产矿井现有箕斗提升井兼作回风井时,井上下装、卸载装置和井塔(架)必须有防尘和封闭措施。装有带式输送机的井筒兼作回风井时,必须装设甲烷传感器并实现甲烷超限断电闭锁。
+
+(二)箕斗提升井或者装有带式输送机的井筒兼作进风井时应当有防尘措施。装有带式输送机的井筒中必须装设自动报警灭火装置、敷设消防管路。
+
+第一百六十七条 矿井开拓新水平和准备新采(盘)区的回风,必须引入总回风巷或者回风井。在未构成通风系统前,可以将此回风引入生产水平的进、回风中;但在有瓦斯喷出或者有突出危险的矿井中,开拓新水平和准备新采(盘)区时,必须先在无瓦斯喷出或者无突出危险的煤(岩)层中掘进巷道并构成通风系统,为构成通风系统的掘进巷道的回风,可以引入生产水平的进、回风中。上述2种引入生产水平进风之前的回风流中的甲烷和二氧化碳浓度都不得超过0.5%,其他有害气体浓度必须符合本规程第一百五十六条的规定,并制定安全措施,报煤矿企业技术负责人审批。
+
+第一百六十八条 生产水平和采(盘)区必须实行分区通风。
+
+准备采(盘)区,应当在采(盘)区构成按照设计贯穿整个采(盘)区的通风系统后,方可开掘回采巷道;采用倾斜长壁布置的,大巷必须至少超前2个区段,并构成通风系统后,方可开掘回采巷道。采煤工作面必须在采(盘)区和采煤工作面构成按照设计全部完工的完整通风、排水系统后,方可回采。
+
+高瓦斯、突出矿井的每个采(盘)区和开采容易自燃煤层的采(盘)区,必须设置至少1条专用回风巷;低瓦斯矿井开采煤层群或者分层开采,采用联合布置的采(盘)区,必须设置1条专用回风巷。
+
+生产采(盘)区进、回风巷必须贯穿整个采(盘)区,严禁一段为进风巷、一段为回风巷。
+
+第一百六十九条 采、掘工作面应当实行独立通风,严禁2个采煤工作面之间串联通风。
+
+同一采区内1个采煤工作面与其相连接的1个掘进工作面、相邻的2个掘进工作面,布置独立通风有困难时,在制定措施后,可以采用串联通风,但串联通风的次数不得超过1次。
+
+采区内为构成新区段通风系统的掘进巷道或者采煤工作面遇地质构造而重新掘进的巷道,布置独立通风有困难时,其回风可以串入采煤工作面,但必须制定安全措施,且串联通风的次数不得超过1次;构成独立通风系统后,必须立即改为独立通风。
+
+对于本条规定的串联通风,必须在进入被串联工作面的巷道中装设甲烷传感器,且甲烷和二氧化碳浓度都不得超过0.5%,其他有害气体浓度都应当符合本规程第一百五十六条的要求。
+
+开采有瓦斯喷出、有突出危险的煤层或者在距离突出煤层垂距小于10 m的区域掘进施工时,严禁任何2个工作面之间串联通风。
+
+第一百七十条 井下所有煤仓和溜煤眼都应当保持一定的存煤,不得放空;有涌水的煤仓和溜煤眼,可以放空,但放空后放煤口闸板必须关闭,并设置引水管。
+
+溜煤眼不得兼作风眼使用。
+
+第一百七十一条 煤层倾角大于8°的采煤工作面采用下行通风时,应当报煤矿总工程师批准,并遵守下列规定:
+
+(一)采煤工作面风速不得低于1 m/s。
+
+(二)在进、回风巷中必须设置消防供水管路。
+
+(三)有突出危险的采煤工作面严禁采用下行通风。
+
+第一百七十二条 采煤工作面必须采用矿井全风压通风,严禁采用局部通风机稀释回风隅角瓦斯。
+
+采掘工作面的进风和回风不得经过采空区。水采和连续采煤机开采的工作面由采空区回风时,工作面必须有足够的新鲜风流,工作面及其回风巷风流中的甲烷和二氧化碳浓度必须符合本规程第一百九十二条、第一百九十三条和第一百九十四条的规定。
+
+无煤柱开采沿空送巷和沿空留巷时,应当采取与采空区隔离的防止漏风措施。
+
+矿井在同一煤层、同翼或者同一采区相邻正在开采的采煤工作面沿空送巷时,采掘工作面严禁同时作业。
+
+第一百七十三条 采空区必须及时封闭。必须随采煤工作面的推进逐个封闭通至采空区的连通巷道。采区开采结束后45天内,必须在所有与已采区相连通的巷道中设置密闭墙,全部封闭采区。
+
+第一百七十四条 控制风流的风门、风桥、风墙、风窗等设施必须可靠。
+
+不应在倾角大于8°的倾斜运输巷中设置风门;如果必须设置风门,应当安设自动风门或者设专人管理,并有防止车辆或者风门碰撞人员以及车辆碰坏风门的安全措施。
+
+开采突出煤层时,工作面回风侧不得设置调节风量的设施。
+
+第一百七十五条 新井投产前必须进行1次矿井通风阻力测定,以后每3年至少测定1次。生产矿井转入新水平生产、改变一翼或者全矿井通风系统后,必须重新进行矿井通风阻力测定。
+
+第一百七十六条 矿井通风系统图必须标明风流方向、风量和通风设施的安装地点。必须按照季度绘制通风系统图,并按照月度补充修改。多煤层同时开采的矿井,必须绘制分层通风系统图。
+
+应当绘制矿井通风系统立体示意图和矿井通风网络图。
+
+第一百七十七条 矿井必须采用机械通风。
+
+主要通风机的安装和使用应当符合下列要求:
+
+(一)主要通风机必须安装在地面;装有通风机的井口必须封闭严密,其外部漏风率在无提升设备时不得超过5%,有提升设备时不得超过15%。
+
+(二)必须保证主要通风机连续运转。
+
+(三)必须安装2套同等能力的主要通风机装置,其中1套作备用,备用通风机必须能在10 min内开动。
+
+(四)严禁采用局部通风机或者风机群作为主要通风机使用。
+
+(五)装有主要通风机的出风井口应当安装防爆门,防爆门每6个月检查维修1次。
+
+(六)至少每月检查1次主要通风机。改变主要通风机转数、叶片角度或者对旋式主要通风机运转级数时,必须经煤矿总工程师批准。
+
+(七)新安装的主要通风机投入使用前,必须进行试运转和通风机性能测定,以后每5年至少进行1次性能测定。
+
+(八)主要通风机技术改造及更换叶片后必须进行性能测定。
+
+(九)井下严禁安设辅助通风机。
+
+第一百七十八条 生产矿井主要通风机必须装有反风设施,并能在10 min内改变巷道中的风流方向;当风流方向改变后,主要通风机的供给风量不应小于正常供风量的40%。
+
+每季度应当至少检查1次反风设施,每年应当进行1次反风演习;矿井通风系统有较大变化时,应当进行1次反风演习。
+
+第一百七十九条 严禁主要通风机房兼作他用。主要通风机房内必须安装水柱计(压力表)、电流表、电压表、轴承温度计等仪表,还必须有直通矿调度室的电话,并有反风操作系统图、司机岗位责任制和操作规程。主要通风机的运转应当由专职司机负责,司机应当每小时将通风机运转情况记入运转记录簿内;发现异常,立即报告。实现主要通风机集中监控、视频监视的主要通风机房可以不设专职司机,但必须实行巡检制度,具有监控记录功能,数据至少保存2年。
+
+第一百八十条 矿井必须制定主要通风机停止运转的应急救援预案。因检修、停电或者其他原因停止主要通风机运转时,必须制定停风的应对措施。
+
+变电所或者电厂在停电前,必须将预计停电时间通知矿调度室。
+
+主要通风机停止运转时,井下必须立即停止工作、切断电源,人员全部撤至应急救援预案规定的安全地带。
+
+主要通风机停止运转期间,必须打开井口防爆门和有关风门,利用自然风压通风;对由多台主要通风机联合通风的矿井,必须正确控制风流,防止风流紊乱。
+
+第一百八十一条 矿井开拓或者准备采区时,设计中必须根据该处全风压供风量和瓦斯涌出量编制通风设计。掘进巷道的通风方式、局部通风机和风筒的安装和使用等应当在作业规程中明确规定。
+
+第一百八十二条 掘进巷道必须采用矿井全风压通风或者压入式局部通风机通风(用于除尘且具备甲烷电闭锁功能的抽出式通风机除外)。
+
+第一百八十三条 安装和使用局部通风机和风筒时,必须遵守下列规定:
+
+(一)局部通风机由指定人员负责管理。
+
+(二)压入式局部通风机和启动装置安装在进风巷道中,距掘进巷道回风口不得小于10 m;全风压供给的风量必须大于局部通风机的吸入风量,局部通风机安装地点到回风口间的巷道中的最低风速必须符合本规程第一百五十七条的要求。
+
+(三)高瓦斯、突出矿井的煤巷、半煤岩巷和有瓦斯涌出的岩巷掘进工作面正常工作的局部通风机必须配备安装同等能力的备用局部通风机,并能自动切换。正常工作的局部通风机必须采用三专(专用开关、专用电缆、专用变压器)供电,专用变压器最多可以向4个不同掘进工作面的局部通风机供电;备用局部通风机电源必须取自同时带电的另一电源,当正常工作的局部通风机发生故障时,备用局部通风机能自动启动,保持掘进工作面正常通风。
+
+(四)其他掘进工作面和通风地点正常工作的局部通风机可以采用由三专供电的局部通风机,或者配备一台能够自动切换的同等能力备用局部通风机。正常工作的局部通风机和备用局部通风机的电源必须取自同时带电的不同母线段的相互独立的电源,保证正常工作的局部通风机发生故障时,备用局部通风机能投入正常工作。
+
+(五)采用抗静电、阻燃风筒。风筒口到掘进工作面的距离、正常工作的局部通风机和备用局部通风机自动切换的交叉风筒接头的规格和安设标准,应当在作业规程中明确规定。
+
+(六)正常工作和备用局部通风机均失电停止运转后,当电源恢复时,正常工作的局部通风机和备用局部通风机均不得自行启动,必须人工就地或者远程人工开启局部通风机,启动条件按照本规程第一百九十七条执行。
+
+(七)使用局部通风机供风的地点必须实行风电闭锁和甲烷电闭锁,保证当正常工作的局部通风机停止运转或者停风后能切断停风区内全部非本质安全型电气设备的电源。正常工作的局部通风机发生故障、切换到备用局部通风机工作时,该局部通风机通风范围内应当停止工作,排除故障;待故障被排除,恢复到正常工作的局部通风后方可恢复工作。使用2台局部通风机同时供风的,2台局部通风机都必须同时实现风电闭锁和甲烷电闭锁。
+
+(八)每15天至少进行1次风电闭锁和甲烷电闭锁试验,每天应当进行1次正常工作的局部通风机与备用局部通风机自动切换试验,试验期间不得影响局部通风,试验记录要存档备查。
+
+(九)严禁使用3台以上局部通风机同时向1个掘进工作面供风。不得使用1台局部通风机同时向2个以上作业的掘进工作面供风。
+
+第一百八十四条 使用局部通风机通风的掘进工作面,不得无计划停风;因检修、停电、出现故障等原因停风时,必须将人员全部撤至全风压进风流处,切断停风区非本质安全型电气设备的电源,设置栅栏、警示标志,禁止人员入内。
+
+第一百八十五条 井下爆炸物品库必须实行独立通风,回风风流必须直接引入矿井的总回风巷中。新建矿井采用对角式通风系统时,投产初期可以利用采区岩石上山或者用不燃性材料支护和不燃性背板背严的煤层上山作爆炸物品库的回风巷。必须保证爆炸物品库每小时能有其总容积4倍的风量。
+
+第一百八十六条 井下铅酸蓄电池动力装置充电硐室应当实行独立通风,在同一时间内,5 t及以下的铅酸蓄电池车辆充电电池的数量不超过3组、5 t以上的铅酸蓄电池车辆充电电池的数量不超过1组时,可以不采用独立通风,但必须在新鲜风流中。
+
+井下锂电池动力装置充电硐室应当符合下列要求:
+
+(一)硐室建设应当进行专项设计,由煤矿总工程师审批,竣工后由矿长组织验收,并制定管理制度。
+
+(二)应当实行独立通风,且回风风流应当直接引入总回风巷或者采(盘)区回风巷。
+
+(三)优先布置在岩层内;布置于煤层内时,必须采用砌碹或者锚网喷等不燃性材料支护。硐室内配置自动灭火装置,进风侧设置应急防火门。
+
+(四)应当实行视频监视和甲烷、一氧化碳、氢气、烟雾、温度等参数自动监测,具备超限自动切断充电电源功能;充电机应当有故障监控与自动切断充电电源功能。
+
+(五)充电时应当有人值守。
+
+井下充电硐室风流中以及局部积聚处的氢气浓度,应当小于0.5%。
+
+第一百八十七条 井下机电设备硐室必须设在进风风流中;采用扩散通风的硐室,其深度不得超过6 m、入口宽度不得小于1.5 m,并且无瓦斯涌出。
+
+采区变电所及实现采区变电所功能的中央变电所必须实行独立通风。
+
+"""
+
+
 FAST_TUN_SYSTEM_PROMPT = """你是一名煤矿通风安全专家。请根据提供的巷道监测数据,直接生成标准化结构化解读报告。
 
 ## 报告格式(严格按此模板输出,不要添加额外标题或代码块)
@@ -17,7 +251,6 @@ FAST_TUN_SYSTEM_PROMPT = """你是一名煤矿通风安全专家。请根据提
 - 设备状态:{状态}
 - 结论:{正常/重大风险/高风险/中风险}。{详细异常分析}
 - 依据:《煤矿安全规程》相关条款
-- 建议:{针对性通风整改措施}
 
 ## 风险分级标准
 - 风速偏低:实测<0.5倍下限→重大;0.5倍≤实测<0.8倍下限→高;0.8倍≤实测<下限→中
@@ -34,7 +267,11 @@ FAST_TUN_SYSTEM_PROMPT = """你是一名煤矿通风安全专家。请根据提
 - 严格按照模板格式输出,不要添加额外标题、总结、代码块、JSON
 - 禁止暴露巷道ID,统一用巷道名称代替
 - 数据缺失项标注"无数据"并说明影响
-"""
+- 禁止提供任何建议
+
+## 依据来源 必须来来自以下条款,禁止编造
+
+""" + _VENT_RULE
 
 FAST_DEVICE_SYSTEM_PROMPT = """你是一名煤矿通风安全设备专家。请根据提供的设备监测数据,直接生成标准化结构化解读报告。
 
@@ -46,7 +283,6 @@ FAST_DEVICE_SYSTEM_PROMPT = """你是一名煤矿通风安全设备专家。请
 - 报警状态:{报警级别} - {报警描述}
 - 结论:{正常/重大风险/高风险/中风险}。{详细异常分析}
 - 依据:《煤矿安全规程》相关条款
-- 建议:{针对性整改措施}
 
 ## 风险分级标准
 - 一级报警(重大):需要立即处理的严重异常
@@ -59,7 +295,10 @@ FAST_DEVICE_SYSTEM_PROMPT = """你是一名煤矿通风安全设备专家。请
 - 严格按照模板格式输出,不要添加额外标题、总结、代码块、JSON
 - 禁止暴露设备ID,统一用设备名称代替
 - 数据缺失项标注"无数据"并说明影响
-"""
+- 禁止提供任何建议
+
+## 依据来源 必须来来自以下条款,禁止编造
+""" + _VENT_RULE
 
 
 # ── 意图分类 prompt ──
@@ -73,3 +312,248 @@ _INTENT_CLASSIFY_PROMPT = """你是一个意图分类器。根据用户消息和
 用户消息:{message}
 {attachment_info}
 只回复一个词(review / dialog):"""
+
+# ── 意图分类 prompt ──
+
+_INTENT_CLASSIFY_PROMPT = """你是一个意图分类器。根据用户消息和可选的附件信息判断意图类型,只回复一个词。
+
+判断规则:
+- 如果用户想进行**配风计划审查**(消息中含"审查""配风计划""研判""审核"等词),或上传了文件名含"配风计划""风量""通风"的PDF/文档,回复 review
+- 其他情况(需风量计算、近..天历史数据查询/分析、通风数据解读、设备查询、规程咨询、知识库检索、闲聊等),回复 dialog
+
+用户消息:{message}
+{attachment_info}
+只回复一个词(review / dialog):"""
+
+# 规则条款(定义在 FAST 提示词之前,供拼接使用)
+_VENT_RULE = """
+第一百五十六条 井下风流中的空气成分必须符合下列安全健康指标要求:
+
+(一)采掘工作面的进风流中,氧气浓度不低于20%,二氧化碳浓度不超过0.5%。
+
+(二)有害气体的浓度不超过表5规定。
+
+表5 矿井有害气体最高允许浓度
+
+名称	最高允许浓度 /%
+一氧化碳 CO	0.0024
+氧化氮(换算成NO₂)	0.00025
+二氧化硫 SO₂	0.0005
+硫化氢 H₂S	0.00066
+氨 NH₃	0.004
+甲烷、二氧化碳和氢气的允许浓度按照本规程的有关规定执行。
+
+矿井中所有气体的浓度均按照体积百分比计算。
+
+第一百五十七条 井巷中的风流速度应当符合表6要求。
+
+表6 井巷中的允许风流速度
+
+井巷名称	允许风速/(m·s⁻¹)
+最低	最高
+无提升设备的风井和风硐	—	15
+专为升降物料的井筒	—	12
+风桥	—	10
+升降人员和物料的井筒	—	8
+总进风巷和总回风巷	—	8
+架线电机车巷道	—	8
+箕斗提升井兼作进风的井筒	1.0	8
+装有带式输送机兼作回风的井筒	—	6
+输送机巷,采区进、回风巷	—	6
+装有带式输送机兼作进风的井筒	0.25	6
+采煤工作面、掘进中的煤巷和半煤岩巷	0.25	4
+掘进中的岩巷	0.15	4
+其他通风人行巷道*	0.15	4
+安设风门的联络巷在符合本规程第一百五十六条规定的前提下不受最低风速限制。
+
+设有梯子间的井筒或者修理中的井筒,风速不得超过8 m/s;梯子间四周经封闭后,井筒中的最高允许风速可以按照表6规定执行。
+
+无瓦斯涌出的架线电机车巷道中的最低风速可以低于表6的规定值,但不得低于0.5 m/s。
+
+综合机械化采煤工作面,在采取煤层注水和采煤机喷雾降尘等措施后,其最大风速可以高于表6的规定值,但不得超过5 m/s。
+
+第一百五十八条 进风井口以下的空气温度(干球温度,下同)必须在2℃以上。
+
+第一百五十九条 矿井需要的风量应当按照下列要求分别计算,并选取其中的最大值:
+
+(一)按照井下同时工作的最多人数计算,每人每分钟供给风量不得少于4 m³。
+
+(二)按照采掘工作面、硐室及其他地点实际需要风量的总和进行计算。各地点的实际需要风量,必须使该地点的风流中的甲烷、二氧化碳和其他有害气体的浓度,风速、温度及每人供风量符合本规程的有关规定。
+
+使用煤矿用防爆型柴油动力装置机车运输的矿井,行驶车辆巷道(联络巷除外)的供风量还应当按照同时运行的最多车辆数验算巷道配风量,配风量验算取值每千瓦不小于4 m³/min。
+
+按照实际需要计算风量时,应当避免备用风量过大或者过小。煤矿企业应当根据具体条件制定风量计算方法,至少每5年修订1次。
+
+第一百六十条 矿井每年安排采掘作业计划时必须核定矿井通风能力,严禁超通风能力生产。
+
+第一百六十一条 矿井必须建立测风制度,每旬至少进行1次全面测风。对采掘工作面和其他用风地点,应当根据实际需要随时测风,每次测风结果应当记录并在测风地点的记录牌上更新。除采掘工作面外,实现了实时风量监测的测风地点,可以不再人工测风,但必须定期校准。
+
+应当根据测风结果采取措施,进行风量调节。
+
+第一百六十二条 矿井必须有足够数量的通风安全检测仪表。仪表必须由具备相应资质的检验单位进行检验。
+
+第一百六十三条 矿井必须有完整的独立通风系统。改变全矿井通风系统时,必须编制通风设计及安全措施,由煤矿企业技术负责人审批。
+
+第一百六十四条 贯通巷道必须遵守下列规定:
+
+(一)巷道贯通前应当制定贯通专项措施。
+
+综合机械化掘进巷道在相距50 m前、其他巷道在相距20 m前,必须停止一个工作面作业,贯通前做好调整通风系统的准备工作。
+
+停掘的工作面必须保持正常通风,设置栅栏及警标,每班必须检查风筒的完好状况和工作面及其回风流中的瓦斯浓度,瓦斯浓度超限时,必须立即处理。
+
+掘进的工作面每次爆破前,必须派专人和瓦斯检查工共同到停掘的工作面检查工作面及其回风流中的瓦斯浓度,瓦斯浓度超限时,必须先停止在掘工作面的工作,然后处理瓦斯,只有掘进的工作面和贯通的工作面及其回风流中的甲烷浓度都在1.0%以下时,掘进的工作面方可爆破。每次爆破前,2个工作面入口必须有专人警戒。
+
+(二)贯通时,必须由专人在现场统一指挥。
+
+(三)贯通后,必须停止贯通影响区域内的一切工作,立即调整通风系统;风流稳定后,方可恢复工作;影响区域由煤矿总工程师组织确定。
+
+间距小于20 m的平行巷道的联络巷贯通,必须遵守以上规定。
+
+第一百六十五条 进、回风井之间,总进、回风巷之间和采(盘)区进、回风巷之间的每条联络巷中,必须砌筑永久性风墙;需要使用的行人、行车联络巷,必须安设不少于2道正向联锁风门和2道反向风门,或者安设不少于2道同时具备正向和反向功能的联锁风门;需要使用的联络巷用作其他用途时,必须进行专项设计,由煤矿总工程师审批。
+
+第一百六十六条 箕斗提升井或者装有带式输送机的井筒兼作风井使用时,必须遵守下列规定:
+
+(一)生产矿井现有箕斗提升井兼作回风井时,井上下装、卸载装置和井塔(架)必须有防尘和封闭措施。装有带式输送机的井筒兼作回风井时,必须装设甲烷传感器并实现甲烷超限断电闭锁。
+
+(二)箕斗提升井或者装有带式输送机的井筒兼作进风井时应当有防尘措施。装有带式输送机的井筒中必须装设自动报警灭火装置、敷设消防管路。
+
+第一百六十七条 矿井开拓新水平和准备新采(盘)区的回风,必须引入总回风巷或者回风井。在未构成通风系统前,可以将此回风引入生产水平的进、回风中;但在有瓦斯喷出或者有突出危险的矿井中,开拓新水平和准备新采(盘)区时,必须先在无瓦斯喷出或者无突出危险的煤(岩)层中掘进巷道并构成通风系统,为构成通风系统的掘进巷道的回风,可以引入生产水平的进、回风中。上述2种引入生产水平进风之前的回风流中的甲烷和二氧化碳浓度都不得超过0.5%,其他有害气体浓度必须符合本规程第一百五十六条的规定,并制定安全措施,报煤矿企业技术负责人审批。
+
+第一百六十八条 生产水平和采(盘)区必须实行分区通风。
+
+准备采(盘)区,应当在采(盘)区构成按照设计贯穿整个采(盘)区的通风系统后,方可开掘回采巷道;采用倾斜长壁布置的,大巷必须至少超前2个区段,并构成通风系统后,方可开掘回采巷道。采煤工作面必须在采(盘)区和采煤工作面构成按照设计全部完工的完整通风、排水系统后,方可回采。
+
+高瓦斯、突出矿井的每个采(盘)区和开采容易自燃煤层的采(盘)区,必须设置至少1条专用回风巷;低瓦斯矿井开采煤层群或者分层开采,采用联合布置的采(盘)区,必须设置1条专用回风巷。
+
+生产采(盘)区进、回风巷必须贯穿整个采(盘)区,严禁一段为进风巷、一段为回风巷。
+
+第一百六十九条 采、掘工作面应当实行独立通风,严禁2个采煤工作面之间串联通风。
+
+同一采区内1个采煤工作面与其相连接的1个掘进工作面、相邻的2个掘进工作面,布置独立通风有困难时,在制定措施后,可以采用串联通风,但串联通风的次数不得超过1次。
+
+采区内为构成新区段通风系统的掘进巷道或者采煤工作面遇地质构造而重新掘进的巷道,布置独立通风有困难时,其回风可以串入采煤工作面,但必须制定安全措施,且串联通风的次数不得超过1次;构成独立通风系统后,必须立即改为独立通风。
+
+对于本条规定的串联通风,必须在进入被串联工作面的巷道中装设甲烷传感器,且甲烷和二氧化碳浓度都不得超过0.5%,其他有害气体浓度都应当符合本规程第一百五十六条的要求。
+
+开采有瓦斯喷出、有突出危险的煤层或者在距离突出煤层垂距小于10 m的区域掘进施工时,严禁任何2个工作面之间串联通风。
+
+第一百七十条 井下所有煤仓和溜煤眼都应当保持一定的存煤,不得放空;有涌水的煤仓和溜煤眼,可以放空,但放空后放煤口闸板必须关闭,并设置引水管。
+
+溜煤眼不得兼作风眼使用。
+
+第一百七十一条 煤层倾角大于8°的采煤工作面采用下行通风时,应当报煤矿总工程师批准,并遵守下列规定:
+
+(一)采煤工作面风速不得低于1 m/s。
+
+(二)在进、回风巷中必须设置消防供水管路。
+
+(三)有突出危险的采煤工作面严禁采用下行通风。
+
+第一百七十二条 采煤工作面必须采用矿井全风压通风,严禁采用局部通风机稀释回风隅角瓦斯。
+
+采掘工作面的进风和回风不得经过采空区。水采和连续采煤机开采的工作面由采空区回风时,工作面必须有足够的新鲜风流,工作面及其回风巷风流中的甲烷和二氧化碳浓度必须符合本规程第一百九十二条、第一百九十三条和第一百九十四条的规定。
+
+无煤柱开采沿空送巷和沿空留巷时,应当采取与采空区隔离的防止漏风措施。
+
+矿井在同一煤层、同翼或者同一采区相邻正在开采的采煤工作面沿空送巷时,采掘工作面严禁同时作业。
+
+第一百七十三条 采空区必须及时封闭。必须随采煤工作面的推进逐个封闭通至采空区的连通巷道。采区开采结束后45天内,必须在所有与已采区相连通的巷道中设置密闭墙,全部封闭采区。
+
+第一百七十四条 控制风流的风门、风桥、风墙、风窗等设施必须可靠。
+
+不应在倾角大于8°的倾斜运输巷中设置风门;如果必须设置风门,应当安设自动风门或者设专人管理,并有防止车辆或者风门碰撞人员以及车辆碰坏风门的安全措施。
+
+开采突出煤层时,工作面回风侧不得设置调节风量的设施。
+
+第一百七十五条 新井投产前必须进行1次矿井通风阻力测定,以后每3年至少测定1次。生产矿井转入新水平生产、改变一翼或者全矿井通风系统后,必须重新进行矿井通风阻力测定。
+
+第一百七十六条 矿井通风系统图必须标明风流方向、风量和通风设施的安装地点。必须按照季度绘制通风系统图,并按照月度补充修改。多煤层同时开采的矿井,必须绘制分层通风系统图。
+
+应当绘制矿井通风系统立体示意图和矿井通风网络图。
+
+第一百七十七条 矿井必须采用机械通风。
+
+主要通风机的安装和使用应当符合下列要求:
+
+(一)主要通风机必须安装在地面;装有通风机的井口必须封闭严密,其外部漏风率在无提升设备时不得超过5%,有提升设备时不得超过15%。
+
+(二)必须保证主要通风机连续运转。
+
+(三)必须安装2套同等能力的主要通风机装置,其中1套作备用,备用通风机必须能在10 min内开动。
+
+(四)严禁采用局部通风机或者风机群作为主要通风机使用。
+
+(五)装有主要通风机的出风井口应当安装防爆门,防爆门每6个月检查维修1次。
+
+(六)至少每月检查1次主要通风机。改变主要通风机转数、叶片角度或者对旋式主要通风机运转级数时,必须经煤矿总工程师批准。
+
+(七)新安装的主要通风机投入使用前,必须进行试运转和通风机性能测定,以后每5年至少进行1次性能测定。
+
+(八)主要通风机技术改造及更换叶片后必须进行性能测定。
+
+(九)井下严禁安设辅助通风机。
+
+第一百七十八条 生产矿井主要通风机必须装有反风设施,并能在10 min内改变巷道中的风流方向;当风流方向改变后,主要通风机的供给风量不应小于正常供风量的40%。
+
+每季度应当至少检查1次反风设施,每年应当进行1次反风演习;矿井通风系统有较大变化时,应当进行1次反风演习。
+
+第一百七十九条 严禁主要通风机房兼作他用。主要通风机房内必须安装水柱计(压力表)、电流表、电压表、轴承温度计等仪表,还必须有直通矿调度室的电话,并有反风操作系统图、司机岗位责任制和操作规程。主要通风机的运转应当由专职司机负责,司机应当每小时将通风机运转情况记入运转记录簿内;发现异常,立即报告。实现主要通风机集中监控、视频监视的主要通风机房可以不设专职司机,但必须实行巡检制度,具有监控记录功能,数据至少保存2年。
+
+第一百八十条 矿井必须制定主要通风机停止运转的应急救援预案。因检修、停电或者其他原因停止主要通风机运转时,必须制定停风的应对措施。
+
+变电所或者电厂在停电前,必须将预计停电时间通知矿调度室。
+
+主要通风机停止运转时,井下必须立即停止工作、切断电源,人员全部撤至应急救援预案规定的安全地带。
+
+主要通风机停止运转期间,必须打开井口防爆门和有关风门,利用自然风压通风;对由多台主要通风机联合通风的矿井,必须正确控制风流,防止风流紊乱。
+
+第一百八十一条 矿井开拓或者准备采区时,设计中必须根据该处全风压供风量和瓦斯涌出量编制通风设计。掘进巷道的通风方式、局部通风机和风筒的安装和使用等应当在作业规程中明确规定。
+
+第一百八十二条 掘进巷道必须采用矿井全风压通风或者压入式局部通风机通风(用于除尘且具备甲烷电闭锁功能的抽出式通风机除外)。
+
+第一百八十三条 安装和使用局部通风机和风筒时,必须遵守下列规定:
+
+(一)局部通风机由指定人员负责管理。
+
+(二)压入式局部通风机和启动装置安装在进风巷道中,距掘进巷道回风口不得小于10 m;全风压供给的风量必须大于局部通风机的吸入风量,局部通风机安装地点到回风口间的巷道中的最低风速必须符合本规程第一百五十七条的要求。
+
+(三)高瓦斯、突出矿井的煤巷、半煤岩巷和有瓦斯涌出的岩巷掘进工作面正常工作的局部通风机必须配备安装同等能力的备用局部通风机,并能自动切换。正常工作的局部通风机必须采用三专(专用开关、专用电缆、专用变压器)供电,专用变压器最多可以向4个不同掘进工作面的局部通风机供电;备用局部通风机电源必须取自同时带电的另一电源,当正常工作的局部通风机发生故障时,备用局部通风机能自动启动,保持掘进工作面正常通风。
+
+(四)其他掘进工作面和通风地点正常工作的局部通风机可以采用由三专供电的局部通风机,或者配备一台能够自动切换的同等能力备用局部通风机。正常工作的局部通风机和备用局部通风机的电源必须取自同时带电的不同母线段的相互独立的电源,保证正常工作的局部通风机发生故障时,备用局部通风机能投入正常工作。
+
+(五)采用抗静电、阻燃风筒。风筒口到掘进工作面的距离、正常工作的局部通风机和备用局部通风机自动切换的交叉风筒接头的规格和安设标准,应当在作业规程中明确规定。
+
+(六)正常工作和备用局部通风机均失电停止运转后,当电源恢复时,正常工作的局部通风机和备用局部通风机均不得自行启动,必须人工就地或者远程人工开启局部通风机,启动条件按照本规程第一百九十七条执行。
+
+(七)使用局部通风机供风的地点必须实行风电闭锁和甲烷电闭锁,保证当正常工作的局部通风机停止运转或者停风后能切断停风区内全部非本质安全型电气设备的电源。正常工作的局部通风机发生故障、切换到备用局部通风机工作时,该局部通风机通风范围内应当停止工作,排除故障;待故障被排除,恢复到正常工作的局部通风后方可恢复工作。使用2台局部通风机同时供风的,2台局部通风机都必须同时实现风电闭锁和甲烷电闭锁。
+
+(八)每15天至少进行1次风电闭锁和甲烷电闭锁试验,每天应当进行1次正常工作的局部通风机与备用局部通风机自动切换试验,试验期间不得影响局部通风,试验记录要存档备查。
+
+(九)严禁使用3台以上局部通风机同时向1个掘进工作面供风。不得使用1台局部通风机同时向2个以上作业的掘进工作面供风。
+
+第一百八十四条 使用局部通风机通风的掘进工作面,不得无计划停风;因检修、停电、出现故障等原因停风时,必须将人员全部撤至全风压进风流处,切断停风区非本质安全型电气设备的电源,设置栅栏、警示标志,禁止人员入内。
+
+第一百八十五条 井下爆炸物品库必须实行独立通风,回风风流必须直接引入矿井的总回风巷中。新建矿井采用对角式通风系统时,投产初期可以利用采区岩石上山或者用不燃性材料支护和不燃性背板背严的煤层上山作爆炸物品库的回风巷。必须保证爆炸物品库每小时能有其总容积4倍的风量。
+
+第一百八十六条 井下铅酸蓄电池动力装置充电硐室应当实行独立通风,在同一时间内,5 t及以下的铅酸蓄电池车辆充电电池的数量不超过3组、5 t以上的铅酸蓄电池车辆充电电池的数量不超过1组时,可以不采用独立通风,但必须在新鲜风流中。
+
+井下锂电池动力装置充电硐室应当符合下列要求:
+
+(一)硐室建设应当进行专项设计,由煤矿总工程师审批,竣工后由矿长组织验收,并制定管理制度。
+
+(二)应当实行独立通风,且回风风流应当直接引入总回风巷或者采(盘)区回风巷。
+
+(三)优先布置在岩层内;布置于煤层内时,必须采用砌碹或者锚网喷等不燃性材料支护。硐室内配置自动灭火装置,进风侧设置应急防火门。
+
+(四)应当实行视频监视和甲烷、一氧化碳、氢气、烟雾、温度等参数自动监测,具备超限自动切断充电电源功能;充电机应当有故障监控与自动切断充电电源功能。
+
+(五)充电时应当有人值守。
+
+井下充电硐室风流中以及局部积聚处的氢气浓度,应当小于0.5%。
+
+第一百八十七条 井下机电设备硐室必须设在进风风流中;采用扩散通风的硐室,其深度不得超过6 m、入口宽度不得小于1.5 m,并且无瓦斯涌出。
+
+采区变电所及实现采区变电所功能的中央变电所必须实行独立通风。
+
+"""

+ 25 - 0
api/session_routes.py

@@ -11,6 +11,7 @@ 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,
+    search_sessions,
 )
 
 router = APIRouter()
@@ -72,6 +73,30 @@ async def list_sessions(
     return {"sessions": sessions, "total": len(sessions)}
 
 
+@router.get("/sessions/search")
+async def search_session_titles(
+    q: str = "",
+    limit: int = 10,
+    user_info: dict = Depends(get_current_user),
+):
+    """按标题模糊搜索当前用户的会话(用于插入会话功能)。
+
+    Args:
+        q: 搜索关键词(匹配会话标题)
+        limit: 最大返回条数,默认 10
+
+    Returns:
+        { "sessions": [...], "total": N }
+    """
+    user_name = user_info.get("username", "admin")
+    if not q or not q.strip():
+        # 无关键词时返回最近会话
+        sessions = get_sessions(limit=limit, offset=0, user_name=user_name)
+        return {"sessions": sessions, "total": len(sessions)}
+    sessions = search_sessions(user_name, q.strip(), limit=limit)
+    return {"sessions": sessions, "total": len(sessions)}
+
+
 @router.delete("/sessions/{session_id}")
 async def remove_session(
     session_id: str,

+ 4 - 63
api/sse_core.py

@@ -145,9 +145,7 @@ async def sse_event_generator(
                     # 先检查推理/思考内容(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"
+                        yield f"data: {json.dumps({'type': 'thinking_token', 'source': source, 'content': reasoning}, ensure_ascii=False)}\n\n"
                     # 再提取常规文本内容
                     content = _extract_text_content(msg_obj)
                     if content and isinstance(content, str):
@@ -214,8 +212,7 @@ def _extract_text_content(msg_obj) -> str | None:
 
 
 # ── 调试开关:设为 True 时打印推理内容摘要(排查完毕后关闭)──
-_DEBUG_REASONING = True
-_debug_dump_done = False  # 只 dump 第一个非空 AI chunk 的完整属性
+_DEBUG_REASONING = False
 
 
 def _extract_reasoning_content(msg_obj) -> str | None:
@@ -229,66 +226,15 @@ def _extract_reasoning_content(msg_obj) -> str | None:
     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 类型的块
@@ -302,10 +248,7 @@ def _extract_reasoning_content(msg_obj) -> str | None:
                 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 "".join(parts)
 
     return None
 
@@ -519,9 +462,7 @@ async def resume_stream(
                     # 先检查推理/思考内容(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"
+                        yield f"data: {json.dumps({'type': 'thinking_token', 'source': 'main', 'content': reasoning}, ensure_ascii=False)}\n\n"
                     # 再提取常规文本内容
                     content = _extract_text_content(msg_obj)
                     if content and isinstance(content, str):

TEMPAT SAMPAH
data/chat_history.db-shm


TEMPAT SAMPAH
data/chat_history.db-wal


+ 44 - 1
db/chat_store.py

@@ -224,6 +224,49 @@ def get_sessions(limit: int = 20, offset: int = 0,
     return [dict(row) for row in rows]
 
 
+def get_session_info(session_id: str) -> dict | None:
+    """查询单个会话的元数据(标题、用户名、模式等)。
+
+    Args:
+        session_id: 会话 ID
+
+    Returns:
+        dict 或 None(会话不存在时)
+    """
+    conn = _get_connection()
+    row = conn.execute(
+        """SELECT session_id, title, user_name, mode, sort_order, created_at, updated_at
+           FROM sessions WHERE session_id = ?""",
+        (session_id,),
+    ).fetchone()
+    return dict(row) if row else None
+
+
+def search_sessions(user_name: str, keyword: str, limit: int = 10) -> list[dict]:
+    """按标题模糊搜索用户的会话。
+
+    Args:
+        user_name: 用户名
+        keyword: 搜索关键词(支持 SQL LIKE 通配符自动包裹)
+        limit: 最大返回条数
+
+    Returns:
+        匹配的会话列表,按 sort_order DESC, updated_at DESC 排序
+    """
+    conn = _get_connection()
+    like = f"%{keyword}%"
+    rows = conn.execute(
+        """SELECT session_id, title, user_name, sort_order, updated_at,
+                  (SELECT COUNT(*) FROM messages WHERE messages.session_id = sessions.session_id) AS message_count
+           FROM sessions
+           WHERE user_name = ? AND title LIKE ? AND title != ''
+           ORDER BY sort_order DESC, updated_at DESC
+           LIMIT ?""",
+        (user_name, like, limit),
+    ).fetchall()
+    return [dict(row) for row in rows]
+
+
 def delete_session(session_id: str):
     """删除会话及其所有消息"""
     conn = _get_connection()
@@ -296,7 +339,7 @@ def pin_session(session_id: str, pinned: bool = True):
     """
     conn = _get_connection()
     conn.execute(
-        "UPDATE sessions SET sort_order = ?, updated_at = datetime('now', 'localtime') WHERE session_id = ?",
+        "UPDATE sessions SET sort_order = ? WHERE session_id = ?",
         (1 if pinned else 0, session_id),
     )
     conn.commit()

+ 4 - 4
deploy.sh

@@ -10,7 +10,7 @@ HOST="39.97.59.228"
 USER="root"
 PASS="Ccri123456"
 PORT="22"
-REMOTE_DIR="/data/vent_agent/vent_deep_agent_1.4"
+REMOTE_DIR="/data/vent_agent/vent_deep_agent_1.5"
 APP_PORT="8070"
 
 # ── 本地项目根目录(脚本所在目录)──
@@ -204,7 +204,7 @@ fi
 echo "[步骤 3/5] 检查并安装 Python 依赖..."
 $SSH_CMD ${USER}@${HOST} << REMOTE_PIP
 set -e
-cd /data/vent_agent/vent_deep_agent_1.4
+cd /data/vent_agent/vent_deep_agent_1.5
 
 # 激活 conda 环境(与 restart.sh 保持一致)
 source /data/conda/conda3/etc/profile.d/conda.sh
@@ -248,7 +248,7 @@ echo ""
 echo "[步骤 5/5] 启动服务..."
 $SSH_CMD ${USER}@${HOST} << REMOTE_START
 set -e
-cd /data/vent_agent/vent_deep_agent_1.4
+cd /data/vent_agent/vent_deep_agent_1.5
 chmod +x restart.sh
 ./restart.sh
 REMOTE_START
@@ -274,7 +274,7 @@ else
     echo "========================================"
     echo "  ⚠ 服务可能未成功启动"
     echo "  手动检查: ssh root@39.97.59.228"
-    echo "  查看日志: tail -f /data/vent_agent/vent_deep_agent_1.4/vent_deep_agent.log"
+    echo "  查看日志: tail -f /data/vent_agent/vent_deep_agent_1.5/vent_deep_agent.log"
     echo "========================================"
 fi
 REMOTE_CHECK

+ 8 - 0
requirements.txt

@@ -39,3 +39,11 @@ pypandoc>=1.13
 # === 图片处理(PaddleOCR 前处理) ===
 Pillow>=10.0
 pdf2image>=1.17
+
+# === 联网搜索 ===
+ddgs>=9.0
+
+# === 文档内容提取 ===
+python-docx>=1.1.0
+openpyxl>=3.1.0
+python-pptx>=1.0.0

+ 1 - 3
skills_disabled.json

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

+ 176 - 7
static/index.html

@@ -325,7 +325,8 @@ body {
 /* Input area */
 .input-area {
   padding: 12px 20px; border-top: 1px solid var(--border);
-  display: flex; gap: 10px; align-items: center;
+  display: flex; gap: 10px; align-items: center; position: relative;
+  flex-wrap: wrap;
 }
 .input-area textarea {
   flex: 1; background: var(--bg-surface); border: 1px solid var(--border);
@@ -352,6 +353,63 @@ body {
 }
 .btn-attach:hover { border-color: var(--accent); color: var(--accent); }
 .btn-attach.has-file { border-color: var(--green); color: var(--green); }
+.btn-insert-session {
+  width: 44px; height: 44px; border-radius: var(--radius);
+  background: var(--bg-surface); border: 1px solid var(--border);
+  color: var(--text-secondary); font-size: 18px; cursor: pointer;
+  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
+  transition: background .1s, border-color .1s;
+}
+.btn-insert-session:hover { border-color: var(--accent); color: var(--accent); }
+.btn-insert-session.active { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
+
+/* Session reference chip */
+.session-ref-chip {
+  display: flex; align-items: center; gap: 6px;
+  background: var(--accent-dim); border: 1px solid var(--accent);
+  border-radius: 6px; padding: 4px 10px; font-size: 12px;
+  color: var(--accent); width: 100%; flex-shrink: 0;
+}
+.session-ref-chip .chip-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.session-ref-chip .chip-remove {
+  background: none; border: none; color: var(--text-muted); cursor: pointer;
+  font-size: 16px; padding: 0 2px; line-height: 1;
+}
+.session-ref-chip .chip-remove:hover { color: var(--red); }
+
+/* Session picker panel */
+.session-picker {
+  position: absolute; bottom: 100%; left: 12px; right: 12px;
+  background: var(--bg-secondary); border: 1px solid var(--border);
+  border-radius: var(--radius); box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+  z-index: 100; max-height: 320px; display: flex; flex-direction: column;
+  margin-bottom: 8px;
+}
+.picker-search { padding: 10px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
+.picker-search input {
+  width: 100%; background: var(--bg-tertiary); border: 1px solid var(--border);
+  border-radius: 6px; color: var(--text-primary); padding: 8px 12px;
+  font-size: 13px; outline: none; box-sizing: border-box;
+}
+.picker-search input:focus { border-color: var(--accent); }
+.picker-search input::placeholder { color: var(--text-muted); }
+.picker-list { flex: 1; overflow-y: auto; padding: 6px 0; }
+.picker-item {
+  padding: 10px 14px; cursor: pointer; font-size: 13px;
+  color: var(--text-primary); transition: background .1s;
+  display: flex; justify-content: space-between; align-items: center;
+  gap: 8px;
+}
+.picker-item:hover { background: var(--bg-hover); }
+.picker-item .picker-title {
+  flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.picker-item .picker-meta {
+  font-size: 11px; color: var(--text-muted); flex-shrink: 0;
+}
+.picker-empty {
+  padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px;
+}
 #file-input { display: none; }
 
 /* Detail Panel (right) */
@@ -703,11 +761,24 @@ body {
       </div>
     </div>
     <div class="status-bar" id="status-bar"></div>
-    <div class="input-area">
+    <div class="input-area" id="input-area">
       <button class="btn-attach" id="btn-attach" onclick="document.getElementById('file-input').click()" title="上传附件">📎</button>
+      <button class="btn-insert-session" id="btn-insert-session" onclick="toggleSessionPicker()" title="插入会话">📋</button>
       <input type="file" id="file-input" accept=".pdf,.doc,.docx,.txt" onchange="onFileSelected(this)">
+      <!-- 引用会话 chip -->
+      <div class="session-ref-chip" id="session-ref-chip" style="display:none;">
+        <span class="chip-label">📋 引用: <span id="chip-title"></span></span>
+        <button class="chip-remove" onclick="removeSessionRef()" title="取消引用">×</button>
+      </div>
       <textarea id="input" placeholder="输入您的问题..." rows="1" onkeydown="onInputKey(event)"></textarea>
       <button class="btn-send" id="btn-send" onclick="sendMessage()" title="发送">↑</button>
+      <!-- 会话选择面板 -->
+      <div class="session-picker" id="session-picker" style="display:none;">
+        <div class="picker-search">
+          <input type="text" id="picker-search-input" placeholder="搜索会话标题..." oninput="onPickerSearch(this.value)">
+        </div>
+        <div class="picker-list" id="picker-list"></div>
+      </div>
     </div>
   </main>
 
@@ -754,6 +825,9 @@ let todoItems = [];
 let toolLogs = [];
 let activeThinkBlock = null;
 let pendingDownloadUrl = null;
+let referencedSessionId = null;       // 引用的会话 ID
+let referencedSessionTitle = '';      // 引用的会话标题
+let pickerDebounceTimer = null;       // 搜索防抖
 
 // ── 认证令牌 ──
 // 支持从 localStorage(key: x-access-token)或 URL 参数获取
@@ -1137,6 +1211,88 @@ async function togglePin(sid) {
   } catch(e) { /* 静默 */ }
 }
 
+// ============================================================
+// Session Picker (插入会话)
+// ============================================================
+
+function toggleSessionPicker() {
+  const picker = document.getElementById('session-picker');
+  const btn = document.getElementById('btn-insert-session');
+  if (picker.style.display === 'none') {
+    picker.style.display = 'flex';
+    btn.classList.add('active');
+    loadPickerSessions('');
+  } else {
+    picker.style.display = 'none';
+    btn.classList.remove('active');
+  }
+}
+
+async function loadPickerSessions(keyword) {
+  const listEl = document.getElementById('picker-list');
+  try {
+    const params = keyword ? `?q=${encodeURIComponent(keyword)}` : '';
+    const res = await fetch(`${API}/sessions/search${params}`, { headers: authHeaders() });
+    const data = await res.json();
+    const sessions = data.sessions || [];
+
+    // 过滤掉当前会话
+    const filtered = sessions.filter(s => s.session_id !== currentSessionId);
+
+    if (!filtered.length) {
+      listEl.innerHTML = '<div class="picker-empty">' + (keyword ? '无匹配会话' : '暂无其他会话') + '</div>';
+      return;
+    }
+    listEl.innerHTML = filtered.map(s => {
+      const safeId = s.session_id.replace(/'/g, "\\'");
+      const safeTitle = escHtml(s.title || s.session_id.slice(0,8)).replace(/'/g, "&#39;");
+      return `
+      <div class="picker-item" onclick="selectSession('${safeId}', '${safeTitle}')">
+        <span class="picker-title">${escHtml(s.title || s.session_id.slice(0,8))}</span>
+        <span class="picker-meta">${s.message_count || 0}条</span>
+      </div>`;
+    }).join('');
+  } catch(e) {
+    listEl.innerHTML = '<div class="picker-empty">加载失败</div>';
+  }
+}
+
+function onPickerSearch(val) {
+  clearTimeout(pickerDebounceTimer);
+  pickerDebounceTimer = setTimeout(() => loadPickerSessions(val.trim()), 200);
+}
+
+function selectSession(id, title) {
+  referencedSessionId = id;
+  referencedSessionTitle = title;
+  // 显示引用 chip
+  const chip = document.getElementById('session-ref-chip');
+  document.getElementById('chip-title').textContent = title;
+  chip.style.display = 'flex';
+  // 关闭面板
+  document.getElementById('session-picker').style.display = 'none';
+  document.getElementById('btn-insert-session').classList.remove('active');
+  // 聚焦输入框
+  document.getElementById('input').focus();
+}
+
+function removeSessionRef() {
+  referencedSessionId = null;
+  referencedSessionTitle = '';
+  document.getElementById('session-ref-chip').style.display = 'none';
+  document.getElementById('input').focus();
+}
+
+// 点击面板外关闭
+document.addEventListener('click', function(e) {
+  const picker = document.getElementById('session-picker');
+  const btn = document.getElementById('btn-insert-session');
+  if (picker.style.display !== 'none' && !picker.contains(e.target) && e.target !== btn) {
+    picker.style.display = 'none';
+    btn.classList.remove('active');
+  }
+});
+
 async function loadHistory(sid) {
   try {
     const res = await fetch(`${API}/chat/history/${sid}`, { headers: authHeaders() });
@@ -1586,7 +1742,7 @@ async function sendMessage() {
   const message = input.value.trim();
   const fileInput = document.getElementById('file-input');
   const file = fileInput.files[0];
-  if (!message && !file) return;
+  if (!message && !file && !referencedSessionId) return;
 
   // Abort previous stream
   abortStream();
@@ -1599,9 +1755,15 @@ async function sendMessage() {
   pendingDownloadUrl = null;
   updateDetailPanel();
 
+  // 构建发送给后端的消息(含会话引用前缀)
+  let sendMessage = message || (file ? '请审查这份文件' : '');
+  if (referencedSessionId) {
+    sendMessage = `#session:${referencedSessionId} ${sendMessage}`.trim();
+  }
+
   // Build FormData
   const fd = new FormData();
-  fd.append('message', message || (file ? '请审查这份文件' : ''));
+  fd.append('message', sendMessage);
   if (currentSessionId) fd.append('session_id', currentSessionId);
   fd.append('mode', currentMode);
   if (file) {
@@ -1610,15 +1772,22 @@ async function sendMessage() {
   }
 
   // Add user message to UI
-  if (message) addMessage('user', message);
+  let displayMsg = message || '';
+  if (referencedSessionId) {
+    displayMsg = `📋 引用会话「${referencedSessionTitle}」` + (displayMsg ? `\n${displayMsg}` : '');
+  }
   if (file) {
-    addMessage('user', `📎 上传文件: ${file.name}\n${message || '请审查这份文件'}`);
+    displayMsg = `📎 上传文件: ${file.name}` + (displayMsg ? `\n${displayMsg}` : '');
+  }
+  if (displayMsg) {
+    addMessage('user', displayMsg);
   }
 
-  // Clear input
+  // Clear input and session reference
   input.value = '';
   fileInput.value = '';
   document.getElementById('btn-attach').classList.remove('has-file');
+  removeSessionRef();
   input.style.height = 'auto';
 
   // UI state

+ 199 - 55
tests/chat_test_cases.md

@@ -1,8 +1,10 @@
 # /api/chat 对话测试用例
 
-> 基于 dialog-interpret + needq-calc + wind-hazard-diagnosis 技能 + 已注册工具覆盖编写
+> 基于 dialog-interpret + needq-calc + wind-hazard-diagnosis 技能 + 全部 57 个 MCP 工具覆盖编写
 > 
 > 测试方式:`POST /api/chat`,参数 `message`(form-data)
+> 
+> 工具总数:57 个 MCP 包装函数 + 非 MCP 本地工具(知识库/计算/偏好/审批/搜索/文件/聊天记录/系统)
 
 ---
 
@@ -34,7 +36,7 @@
 
 ---
 
-## 二、设备查询(新增工具)
+## 二、设备查询
 
 ### 2.1 设备类型字典
 
@@ -59,12 +61,28 @@
 | C16 | "设备 FAN-001 当前读数多少" | 调用 query_device_realtime_data(device_id="FAN-001") |
 | C17 | "传感器 11111004 现在什么状态" | 同上,返回在线状态、读数、报警 |
 
-### 2.4 巷道关联设备
+### 2.4 设备信息查询(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C18 | "fanmain 是什么设备类型" | 调用 query_device_type_info(device_type="fanmain") → 返回大类小类信息 |
+| C19 | "风速传感器有哪些测点参数" | 调用 query_monitor_params → 列出点表监测参数 |
+| C20 | "帮我查一下 fanmain_stem_wp_2 的详细信息" | 调用 query_device_info(str_type="fanmain_stem_wp_2") → 设备信息列表 |
+
+### 2.5 巷道关联设备 + 模型设备
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C18 | "15216 辅运起坡段绑了哪些设备" | 调用 query_devices_by_tunnel |
-| C19 | "北二回风巷有哪些传感器" | 同上 |
+| C21 | "15216 辅运起坡段绑了哪些设备" | 调用 query_devices_by_tunnel |
+| C22 | "模型 2012326636757958658 下面绑了多少设备" | 调用 query_devices_by_model → 设备数量和清单 |
+
+### 2.6 设备监测风量 / 字典(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C23 | "帮我查一下这个模型的设备监测风量" | 调用 get_sensor_wind(model_id) → 各设备风量数据 |
+| C24 | "测风装置风量和模型解算风量对比" | 先 get_sensor_wind 再 get_model_wind → 对比差异 |
+| C25 | "系统字典里有没有通风方式的编码" | 调用 get_dict_list_by_dictcode → 字典项列表 |
 
 ---
 
@@ -72,9 +90,9 @@
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C20 | "15216 工作面近7天风速变化趋势" | 调用 list_ventanaly_monitor_data_days → 输出趋势图描述(均值/最值/趋势方向) |
-| C21 | "北二回风巷上个月温度怎么样" | 换算时间范围,调用历史数据工具 |
-| C22 | "三水平主运大巷最近24小时风量有波动吗" | 同上 |
+| C26 | "15216 工作面近7天风速变化趋势" | 调用 list_ventanaly_monitor_data_days → 输出趋势图描述(均值/最值/趋势方向) |
+| C27 | "北二回风巷上个月温度怎么样" | 换算时间范围,调用历史数据工具 |
+| C28 | "三水平主运大巷最近24小时风量有波动吗" | 同上 |
 
 ---
 
@@ -82,9 +100,9 @@
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C23 | "帮我查一下模型下面有哪些巷道" | 调用 query_tunnels_by_model |
-| C24 | "搜一下名字带'辅运'的巷道" | 调用 query_tunnel_list 模糊匹配 |
-| C25 | "15216 这个模型下有多少条巷道" | 调用 query_tunnels_by_model |
+| C29 | "帮我查一下模型下面有哪些巷道" | 调用 query_tunnels_by_model |
+| C30 | "搜一下名字带'辅运'的巷道" | 调用 query_tunnel_list 模糊匹配 |
+| C31 | "15216 这个模型下有多少条巷道" | 调用 query_tunnels_by_model |
 
 ---
 
@@ -92,51 +110,61 @@
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C26 | "煤矿安全规程对回风巷风速有什么要求" | 调用 query_knowledge_base → 返回条款原文 |
-| C27 | "采煤工作面瓦斯报警浓度是多少" | 同上 |
-| C28 | "AQ1056 风量计算方法是什么" | 同上 |
+| C32 | "煤矿安全规程对回风巷风速有什么要求" | 调用 query_knowledge_base → 返回条款原文 |
+| C33 | "采煤工作面瓦斯报警浓度是多少" | 同上 |
+| C34 | "AQ1056 风量计算方法是什么" | 同上 |
 
 ---
 
-## 六、需风量计算(needq-calc 技能)
+## 六、需风量计算(needq-calc 技能 + MCP 模拟
 
 ### 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 |
+| C35 | "计算一下 15216 工作面的需风量" | 触发 needq-calc,逐步引导填写参数(采高、控顶距、瓦斯涌出量等) |
+| C36 | "采煤面按瓦斯量算需风量,绝对涌出量 3.2,不均衡系数 1.5" | 调用 calc_face_by_gas |
+| C37 | "采煤面按人数算需风量,最多 40 人" | 调用 calc_face_by_workers |
+| C38 | "采煤面按风速验算,断面 18 平米" | 调用 calc_face_by_wind_speed |
 
 ### 6.2 掘进工作面计算
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C33 | "掘进面需风量怎么算" | 触发 needq-calc,引导参数 |
-| C34 | "掘进面炸药量 8kg,帮我算需风量" | 调用 calc_tunnel_by_explosives |
-| C35 | "掘进面 15 个人同时作业,需风量多少" | 调用 calc_tunnel_by_workers |
+| C39 | "掘进面需风量怎么算" | 触发 needq-calc,引导参数 |
+| C40 | "掘进面炸药量 8kg,帮我算需风量" | 调用 calc_tunnel_by_explosives |
+| C41 | "掘进面 15 个人同时作业,需风量多少" | 调用 calc_tunnel_by_workers |
 
 ### 6.3 硐室计算
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C36 | "机电硐室需风量,设备功率 200kW" | 调用 calc_chamber_by_equipment |
-| C37 | "硐室按风速验算,断面 12 平米" | 调用 calc_chamber_by_wind_speed |
+| C42 | "机电硐室需风量,设备功率 200kW" | 调用 calc_chamber_by_equipment |
+| C43 | "硐室按风速验算,断面 12 平米" | 调用 calc_chamber_by_wind_speed |
 
 ### 6.4 管控平台数据
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C38 | "通防管控平台上各地点需风量是多少" | 调用 get_needq_all_data → 展示所有用风地点数据 |
-| C39 | "管控平台的配风情况帮我看看" | 同上 |
+| C44 | "通防管控平台上各地点需风量是多少" | 调用 get_needq_all_data → 展示所有用风地点数据 |
+| C45 | "管控平台的配风情况帮我看看" | 同上 |
+| C46 | "管控平台上的默认模型参数有哪些" | 调用 get_model_param_pub_list → 分页展示 |
 
-### 6.5 汇总计算
+### 6.5 MCP 需风量模拟计算(新增)
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C40 | "帮我把刚才算的各地点汇总一下总需风量" | 调用 calc_total_air_volume |
-| C41 | "这个工作面的有效断面帮我算一下" | 调用 calc_effective_area |
+| C47 | "用管控平台的模型帮我算一下这个掘进面的需风量" | 调用 simulate_needq_heading_face → 模拟计算结果 |
+| C48 | "这个硐室在管控平台上需风量模拟是多少" | 调用 simulate_needq_room → 模拟计算结果 |
+| C49 | "采煤工作面用系统模拟算一下需风量" | 调用 simulate_needq_ret_work_face → 模拟计算结果 |
+| C50 | "其他用风地点也帮我模拟算一下" | 调用 simulate_needq_other → 模拟计算结果 |
+
+### 6.6 汇总计算
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C51 | "帮我把刚才算的各地点汇总一下总需风量" | 调用 calc_total_air_volume |
+| C52 | "这个工作面的有效断面帮我算一下" | 调用 calc_effective_area |
 
 ---
 
@@ -146,40 +174,40 @@
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C51 | "查询当前风速风量数据有没有问题" | 触发 wind-hazard-diagnosis,走 Step1~Step8 全流程:获取测风装置清单 → 逐台查实时数据 → 巷道类型判定 → 风速超限判定 → 需风量关联 → 重大隐患比对 → 输出分级结论 |
-| C52 | "帮我做一次测风数据判识" | 同上,触发完整诊断流程 |
-| C53 | "风速风量有没有隐患" | 同上,最后询问是否生成 md 报告 |
-| C54 | "全矿测风装置数据排查一下" | 获取全量清单,逐台判识,输出总览+逐台明细 |
+| C61 | "查询当前风速风量数据有没有问题" | 触发 wind-hazard-diagnosis,走 Step1~Step8 全流程 |
+| C62 | "帮我做一次测风数据判识" | 同上,触发完整诊断流程 |
+| C63 | "风速风量有没有隐患" | 同上,最后询问是否生成 md 报告 |
+| C64 | "全矿测风装置数据排查一下" | 获取全量清单,逐台判识,输出总览+逐台明细 |
 
 ### 7.2 生成报告
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C55 | "生成测风数据分析报告" | 触发技能 Step9:生成固定格式 `测风装置实时数据分析报告_YYYY-MM-DD.md`,严格按 0~八 章节输出 |
-| C56 | "把刚才的诊断结果写成报告" | 同上,复用前一轮已获取的数据生成报告 |
-| C57 | "不要报告,就告诉我结论" | 技能结束时不写文件,仅输出会话结论 |
+| C65 | "生成测风数据分析报告" | 触发技能 Step9:生成固定格式 md 报告 |
+| C66 | "把刚才的诊断结果写成报告" | 同上,复用前一轮已获取的数据 |
+| C67 | "不要报告,就告诉我结论" | 技能结束时不写文件,仅输出会话结论 |
 
 ### 7.3 重点巷道诊断
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C58 | "15216 工作面的测风数据有没有超限" | 触发技能,定位该工作面关联测风装置,逐项判识风速/风量/报警 |
-| C59 | "北二回风巷风速风量排查隐患" | 同上,按巷道类型(采区回风巷→0.25~6 m/s)判定 |
-| C60 | "总回风巷风量够不够" | 关联通风系统总需风量 qra,判定是否满足系统级需风量 |
+| C68 | "15216 工作面的测风数据有没有超限" | 触发技能,定位该工作面关联测风装置 |
+| C69 | "北二回风巷风速风量排查隐患" | 同上,按巷道类型判定 |
+| C70 | "总回风巷风量够不够" | 关联通风系统总需风量 qra 判定 |
 
 ### 7.4 模拟/离线数据场景
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C61 | "测风数据看起来很规律像是模拟的怎么办" | 技能 Step2 模拟数据识别:检查风量≠风速×断面×60、多台数据雷同 → 标注"模拟数据,不可用于生产判识" |
-| C62 | "有台测风装置离线了怎么处理" | 标记"无实时数据/离线",在报告中归入 🔴 高风险 |
+| C71 | "测风数据看起来很规律像是模拟的怎么办" | 模拟数据识别 → 标注"模拟数据,不可用于生产判识" |
+| C72 | "有台测风装置离线了怎么处理" | 标记"无实时数据/离线",归入 🔴 高风险 |
 
 ### 7.5 隐患分级与红线判定
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C63 | "有没有触犯重大隐患判定标准的" | 技能 Step7:系统级/用风点级供风量 < 需风量×75% → 判定是否构成重大事故隐患 |
-| C64 | "帮我列出所有高风险点" | 输出 🔴 高风险(离线/超速/活跃报警/风量严重不足)清单 |
+| C73 | "有没有触犯重大隐患判定标准的" | 系统级/用风点级供风量 < 需风量×75% → 判定重大隐患 |
+| C74 | "帮我列出所有高风险点" | 输出 🔴 高风险清单 |
 
 ---
 
@@ -187,10 +215,10 @@
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C65 | 第1轮:"15216 辅运起坡段风速怎么样" → 第2轮:"那风量呢" | 第2轮应记住上下文(同一巷道),无需重复巷道名 |
-| C66 | 第1轮:"帮我算采煤面需风量" → 第2轮:"刚才算的是按瓦斯算的吗" | 记住前一轮计算上下文 |
-| C67 | 第1轮:"列出所有风速传感器" → 第2轮:"第3个的实时数据" | 记住设备列表上下文,取第3个设备ID查询 |
-| C68 | 第1轮:"全矿风速风量排查" → 第2轮:"生成报告" | 第2轮复用前一轮已获取的诊断数据生成 md 报告 |
+| C75 | 第1轮:"15216 辅运起坡段风速怎么样" → 第2轮:"那风量呢" | 第2轮应记住上下文(同一巷道) |
+| C76 | 第1轮:"帮我算采煤面需风量" → 第2轮:"刚才算的是按瓦斯算的吗" | 记住前一轮计算上下文 |
+| C77 | 第1轮:"列出所有风速传感器" → 第2轮:"第3个的实时数据" | 记住设备列表上下文 |
+| C78 | 第1轮:"全矿风速风量排查" → 第2轮:"生成报告" | 复用前一轮诊断数据生成 md 报告 |
 
 ---
 
@@ -198,17 +226,133 @@
 
 | 编号 | 用例 | 预期行为 |
 |------|------|----------|
-| C69 | "全矿瓦斯浓度最高的三个点在哪儿" | 触发 skill 6.3 节:告知暂不支持跨巷道聚合排序,引导用户指定巷道 |
-| C70 | "某某不存在的巷道风速怎么样" | query_tunnel_list 无结果,告知用户未找到,建议检查名称 |
-| C71 | "今天天气怎么样" | 不进通风技能,作为通用对话回复或引导回通风话题 |
-| C72 | 空消息 "" | 返回 422 参数校验错误 |
-| C73 | "帮我解读"(无巷道名) | 反问用户需要解读哪条巷道 |
-| C74 | "计算需风量"(无参数) | needq-calc 技能引导用户逐步提供计算参数 |
-| C75 | "测风数据有没有隐患"(tf_mcp 不可用) | 技能 Step 依赖检查:明确告知"缺少 tf_mcp 或云知识库,无法完整执行" |
+| C79 | "全矿瓦斯浓度最高的三个点在哪儿" | 告知暂不支持跨巷道聚合排序,引导指定巷道 |
+| C80 | "某某不存在的巷道风速怎么样" | query_tunnel_list 无结果,告知未找到 |
+| C81 | "今天天气怎么样" | 不进通风技能,作为通用对话回复或引导回通风话题 |
+| C82 | 空消息 "" | 返回 422 参数校验错误 |
+| C83 | "帮我解读"(无巷道名) | 反问用户需要解读哪条巷道 |
+| C84 | "计算需风量"(无参数) | needq-calc 技能引导用户逐步提供计算参数 |
+| C85 | "测风数据有没有隐患"(tf_mcp 不可用) | 明确告知"缺少 tf_mcp 或云知识库,无法完整执行" |
+
+---
+
+## 十、模型数据查询(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C86 | "这个模型的巷道解算风量是多少" | 调用 get_model_wind(model_id) → 解算风量数据 |
+| C87 | "帮我查看管控平台默认模型参数" | 调用 get_model_param_pub_list → 分页模型参数 |
+| C88 | "设备监测风量和模型解算风量差多少" | get_sensor_wind + get_model_wind → 对比分析 |
+
+---
+
+## 十一、故障诊断(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C89 | "检查一下这个模型通风网络有没有问题" | 调用 check_model_connect_status → 连通块诊断 |
+| C90 | "模型里有没有循环风路" | 调用 check_model_one_dir_cycle → 循环风路检查 |
+| C91 | "帮我看一下有没有单风向节点" | 调用 check_model_one_dir_node → 单向节点检查 |
+| C92 | "模型角联结构诊断一下" | 调用 check_model_diagonal_structure → 角联诊断(耗时较长) |
+| C93 | "模型全面故障诊断,包含角联" | 调用 get_model_fault_diagnosis(include_diagonal=True) → 聚合诊断 |
+
+---
+
+## 十二、避灾路线(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C94 | "如果 4 号巷道发生火灾,6 号巷道的人怎么逃生" | 调用 get_escape_path(fire_tun_id="4", person_tun_id="6") → 逃生路线 |
+| C95 | "各出口的避灾路线都帮我算一下,CO 浓度 2000" | 调用 get_escape_path_each_exit → 各出口路线 |
+
+---
+
+## 十三、关键阻力 / 压能(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C96 | "这个模型有哪些回风井" | 调用 get_out_shafts → 回风井巷道ID列表 |
+| C97 | "进风井有哪些" | 调用 get_in_shafts → 进风井列表 |
+| C98 | "回风井的最大阻力路线是什么" | 先 get_out_shafts 取节点ID → get_max_resistance_path |
+| C99 | "三区阻力分布帮我看看" | 调用 get_three_area_distribution → 进风/用风/回风区阻力 |
+| C100 | "关键路径控风方案决策数据" | 调用 get_key_path_decision → 决策数据 |
+| C101 | "从进风井到回风井的压能图" | 调用 get_path_press_power → 节点压能数据 |
+
+---
+
+## 十四、网络解算(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C102 | "对当前模型做一次网络解算" | 调用 net_cal → 各巷道风量分配(耗时较长) |
+| C103 | "模拟反风方案,按这个方案解算" | 调用 net_cal_for_plan → 方案模拟结果 |
 
 ---
 
-## 十、意图路由测试(合并后统一走通风对话助手) 
+## 十五、报警 / 日志查询(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C104 | "最近一周有哪些设备报警了" | 调用 get_alarm_log_history → 报警记录 |
+| C105 | "上个月设备控制操作记录给我看看" | 调用 get_device_set_log_history → 控制历史 |
+| C106 | "最近有哪些人登录过系统" | 调用 get_sys_log_history → 登录历史 |
+
+---
+
+## 十六、场景管理(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C107 | "通风场景有哪些" | 调用 get_manage_system_by_strType → 场景列表 |
+| C108 | "帮我打开场景 ID 为 xxx 的数据" | 调用 query_system_by_systemID → 场景数据 |
+
+---
+
+## 十七、煤矿基础数据(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C109 | "合阳县金桥煤矿的瓦斯等级鉴定报告" | 调用 get_gas_identify_vo(mine_name="合阳县金桥") |
+| C110 | "这个矿的工作面设计规程是什么" | 调用 get_by_mine_name → 设计规程 |
+| C111 | "对 11111004 这个测风装置执行一键测风" | 调用 query_control_testWind → 测风结果 |
+
+---
+
+## 十八、数据库 / 文件(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C112 | "从 vent 库查一下风机运行参数" | 调用 execute_sql_query → SQL 查询结果 |
+| C113 | "文件共享中心有哪些测风报表" | 调用 get_file_list_by_type → 文件列表 |
+| C114 | "帮我把这个文件的内容取出来" | 调用 get_file_base64_by_id → Base64 文件内容 |
+
+---
+
+## 十九、测风报表(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C115 | "最新的测风报表给我看看" | 调用 get_latest_report → 最新报表数据 |
+| C116 | "合阳县金桥煤矿 2026年6月的测风报表" | 调用 get_latest_report(mine_name, date_month) |
+
+---
+
+## 二十、会话管理工具(新增)
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C117 | "当前这个会话的ID是什么" | 调用 get_current_session_id → 返回 session_id |
+| C118 | "帮我查一下之前关于瓦斯的那次对话" | search_chat_history → get_session_chat 串联查询 |
+
+---
+
+## 二十一、联网搜索 + 文件读取
+
+| 编号 | 用例 | 预期行为 |
+|------|------|----------|
+| C119 | "帮我搜一下最新的煤矿安全规程修订" | 调用 web_search → 搜索结果 |
+| C120 | "把这个网页内容抓下来分析" | 调用 web_fetch → 网页内容 |
+| C121 | "上传的这份测风报表帮我看看内容" | 调用 read_file_content → 解析 PDF/XLSX 内容 |
 
 ---
 

+ 142 - 0
tools/chat_history_tools.py

@@ -0,0 +1,142 @@
+# -*- coding: utf-8 -*-
+"""
+聊天记录查询工具模块
+
+提供 Agent 直接查询 SQLite 聊天记录的能力,解决 Agent 用文件系统工具
+找不到聊天记录的问题(聊天记录存在 SQLite,不在文件系统中)。
+
+工具列表:
+- search_chat_history: 按标题模糊搜索当前用户的会话
+- get_session_chat:   获取指定会话的完整消息历史
+"""
+
+import json
+
+from tools.vent_tools import _current_user, _current_session
+
+
+async def search_chat_history(query: str, max_results: int = 5) -> str:
+    """搜索当前用户的聊天历史记录(按会话标题匹配)。
+    
+    当用户询问"之前聊过什么""搜索历史记录""找一下关于xxx的对话"时调用。
+    返回匹配的会话列表,包含标题、消息数量、会话 ID。
+    
+    如果需要查看某个会话的具体对话内容,再调用 get_session_chat。
+    
+    Args:
+        query: 搜索关键词,匹配会话标题
+        max_results: 最大返回条数,默认 5
+    
+    Returns:
+        JSON 格式,包含 matching_sessions 列表。
+        每条含 session_id、title、message_count、updated_at。
+    """
+    from db.chat_store import search_sessions as _db_search
+
+    if not query or not query.strip():
+        return json.dumps({"error": "搜索关键词不能为空"}, ensure_ascii=False)
+
+    user_name = _current_user.get()
+    try:
+        sessions = _db_search(user_name, query.strip(), limit=max_results)
+        results = [
+            {
+                "session_id": s["session_id"],
+                "title": s.get("title", ""),
+                "message_count": s.get("message_count", 0),
+                "updated_at": s.get("updated_at", ""),
+            }
+            for s in sessions
+        ]
+        return json.dumps(
+            {"query": query.strip(), "matching_sessions": results},
+            ensure_ascii=False,
+        )
+    except Exception as e:
+        return json.dumps({"error": f"搜索失败: {str(e)}"}, ensure_ascii=False)
+
+
+async def get_session_chat(session_id: str, limit: int = 50) -> str:
+    """获取指定会话的完整消息历史。
+    
+    当用户要求"查看那个会话的内容""把之前讨论的内容找出来""总结一下上次的对话",
+    且已知 session_id 时调用。通常先通过 search_chat_history 获取 session_id,
+    再调用本工具读取具体内容。
+    
+    Args:
+        session_id: 会话 ID(UUID 格式)
+        limit: 最大返回消息条数,默认 50
+    
+    Returns:
+        JSON 格式,包含 session_info(标题、创建时间等)和 messages 列表。
+        每条消息含 role(user/assistant)、content、created_at。
+    """
+    from db.chat_store import get_messages as _db_msgs
+    from db.chat_store import get_session_info as _db_info
+
+    if not session_id or not session_id.strip():
+        return json.dumps({"error": "会话 ID 不能为空"}, ensure_ascii=False)
+
+    sid = session_id.strip()
+    try:
+        info = _db_info(sid)
+        if not info:
+            return json.dumps(
+                {"error": f"会话不存在: {sid}", "session_id": sid},
+                ensure_ascii=False,
+            )
+
+        msgs = _db_msgs(sid, limit=limit)
+        messages = [
+            {
+                "role": m["role"],
+                "content": m.get("content", ""),
+                "created_at": m.get("created_at", ""),
+            }
+            for m in msgs
+        ]
+
+        return json.dumps(
+            {
+                "session_id": sid,
+                "session_info": {
+                    "title": info.get("title", ""),
+                    "created_at": info.get("created_at", ""),
+                    "updated_at": info.get("updated_at", ""),
+                },
+                "message_count": len(messages),
+                "messages": messages,
+            },
+            ensure_ascii=False,
+        )
+    except Exception as e:
+        return json.dumps(
+            {"error": f"读取失败: {str(e)}", "session_id": sid},
+            ensure_ascii=False,
+        )
+
+
+async def get_current_session_id() -> str:
+    """获取当前本轮对话的数据库会话ID(session_id)。
+
+    当需要知道当前正在进行的会话的唯一标识符时调用此工具。
+    该 ID 可用于配合 get_session_chat 工具查看本轮会话的完整消息历史,
+    或配合 search_chat_history 跨会话检索。
+
+    无需任何参数,自动从请求上下文中获取。
+
+    Returns:
+        JSON 格式,包含当前会话的 session_id 和对应的 user_name。
+    """
+    user_name = _current_user.get()
+    session_id = _current_session.get()
+    if not session_id:
+        return json.dumps({
+            "error": "无法获取当前会话ID,可能不在对话上下文中",
+            "session_id": "",
+            "user_name": user_name,
+        }, ensure_ascii=False)
+    return json.dumps({
+        "session_id": session_id,
+        "user_name": user_name,
+    }, ensure_ascii=False)

+ 226 - 0
tools/file_reader_tools.py

@@ -0,0 +1,226 @@
+# -*- coding: utf-8 -*-
+"""
+文件内容读取工具模块
+
+提供统一的文件内容提取能力,自动识别文件格式,支持:
+- PDF(复用 tools/pdf_tools 的 PyPDF2 提取)
+- DOCX(Word 文档)
+- XLSX(Excel 表格)
+- PPTX(PowerPoint 演示文稿)
+- TXT / MD / CSV / JSON 等纯文本文件
+
+所有工具函数供 DeepAgent 调用,返回结构化 JSON 文本。
+"""
+
+import json
+import os
+import re
+
+# ── 最大提取字符数 ──
+_MAX_CONTENT_CHARS = 15000
+
+
+async def read_file_content(file_path: str) -> str:
+    """读取用户上传的文件内容。当用户上传了附件,你需要查看文件中的文字内容时调用此工具。
+    
+    支持的文件格式:
+    - PDF 文档(.pdf)
+    - Word 文档(.docx)
+    - Excel 表格(.xlsx)
+    - PowerPoint 演示文稿(.pptx)
+    - 纯文本(.txt / .md / .csv / .json)
+    
+    典型调用场景:
+    1. 用户上传了一个 Word 报告,需要你阅读和分析内容
+    2. 用户上传了 Excel 数据表,需要你提取和理解数据
+    3. 用户上传了 PDF 文档,需要你从中获取信息
+    
+    Args:
+        file_path: 文件的完整路径(从用户消息中的「文件临时路径」获取)
+    
+    Returns:
+        JSON 格式,包含 file_path、format(文件格式)、content(提取的文本)、
+        content_length(字符数)。文本最多保留 15000 字符,超出部分截断。
+    """
+    if not file_path or not file_path.strip():
+        return json.dumps({"error": "文件路径不能为空"}, ensure_ascii=False)
+
+    fp = file_path.strip()
+
+    if not os.path.exists(fp):
+        return json.dumps(
+            {"error": f"文件不存在: {fp}", "file_path": fp},
+            ensure_ascii=False,
+        )
+
+    ext = os.path.splitext(fp)[1].lower()
+
+    try:
+        if ext == ".pdf":
+            text = _read_pdf(fp)
+            fmt = "pdf"
+        elif ext == ".docx":
+            text = _read_docx(fp)
+            fmt = "docx"
+        elif ext in (".xlsx", ".xlsm"):
+            text = _read_xlsx(fp)
+            fmt = "xlsx"
+        elif ext == ".pptx":
+            text = _read_pptx(fp)
+            fmt = "pptx"
+        elif ext in (".txt", ".md", ".csv", ".json", ".xml", ".html", ".htm", ".log", ".yaml", ".yml", ".py", ".js", ".ts", ".sql", ".cfg", ".ini"):
+            text = _read_text(fp)
+            fmt = ext.lstrip(".")
+        else:
+            return json.dumps(
+                {
+                    "error": f"不支持的文件格式「{ext}」。支持:pdf / docx / xlsx / pptx / txt / md / csv / json",
+                    "file_path": fp,
+                    "format": ext,
+                },
+                ensure_ascii=False,
+            )
+    except Exception as e:
+        return json.dumps(
+            {"error": f"文件读取失败: {str(e)}", "file_path": fp, "format": ext},
+            ensure_ascii=False,
+        )
+
+    # ── 截断 ──
+    content_length = len(text)
+    if content_length > _MAX_CONTENT_CHARS:
+        text = text[:_MAX_CONTENT_CHARS] + f"\n…(内容已截断,原始 {content_length} 字符)"
+
+    return json.dumps(
+        {
+            "file_path": fp,
+            "format": fmt,
+            "content": text,
+            "content_length": min(content_length, _MAX_CONTENT_CHARS),
+        },
+        ensure_ascii=False,
+    )
+
+
+# ═══════════════════════════════════════════════════════════════
+# 各格式解析器(内部函数,不直接暴露给 Agent)
+# ═══════════════════════════════════════════════════════════════
+
+
+def _read_pdf(file_path: str) -> str:
+    """PDF 文本提取(复用 tools/pdf_tools 的 PyPDF2 提取器)。"""
+    from tools.pdf_tools import _extract_with_pypdf
+
+    text = _extract_with_pypdf(file_path)
+    if text:
+        return text.strip()
+    return "(PDF 文件无可提取的文字内容,可能是扫描件或图片型 PDF)"
+
+
+def _read_docx(file_path: str) -> str:
+    """Word 文档 (.docx) 文本提取。
+
+    提取内容:
+    - 正文段落文本
+    - 表格中的文字(标为 [表格] 前缀)
+    """
+    from docx import Document
+
+    doc = Document(file_path)
+    parts = []
+
+    for element in doc.element.body:
+        tag = element.tag.split("}")[-1] if "}" in element.tag else element.tag
+
+        if tag == "p":
+            # 段落
+            ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
+            texts = element.findall(".//w:t", ns)
+            line = "".join(t.text or "" for t in texts).strip()
+            if line:
+                parts.append(line)
+
+        elif tag == "tbl":
+            # 表格
+            rows = []
+            for row in element.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tr"):
+                cells = []
+                for cell in row.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tc"):
+                    texts = cell.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t")
+                    cell_text = " ".join(t.text or "" for t in texts).strip()
+                    cells.append(cell_text)
+                rows.append(" | ".join(cells))
+            if rows:
+                parts.append("[表格] " + "\n[表格] ".join(rows))
+
+    result = "\n".join(parts).strip()
+    return result if result else "(Word 文档中未找到文字内容)"
+
+
+def _read_xlsx(file_path: str) -> str:
+    """Excel 表格 (.xlsx) 文本提取。
+
+    每个 Sheet 标记为 [Sheet: xxx],每行用 | 分隔单元格。
+    """
+    from openpyxl import load_workbook
+
+    wb = load_workbook(file_path, read_only=True, data_only=True)
+    parts = []
+
+    for sheet_name in wb.sheetnames:
+        ws = wb[sheet_name]
+        parts.append(f"[Sheet: {sheet_name}]")
+
+        row_count = 0
+        for row in ws.iter_rows(max_row=200, values_only=True):
+            row_vals = [str(v) if v is not None else "" for v in row]
+            # 跳过完全空行
+            if any(c.strip() for c in row_vals):
+                parts.append(" | ".join(row_vals))
+                row_count += 1
+
+        if row_count == 0:
+            parts.append("(空表)")
+
+    wb.close()
+    result = "\n".join(parts).strip()
+    return result if result else "(Excel 文件中未找到数据)"
+
+
+def _read_pptx(file_path: str) -> str:
+    """PowerPoint 演示文稿 (.pptx) 文本提取。
+
+    每张幻灯片标记为 [幻灯片 N],提取所有文本框和占位符中的文字。
+    """
+    from pptx import Presentation
+
+    prs = Presentation(file_path)
+    parts = []
+
+    for i, slide in enumerate(prs.slides, 1):
+        parts.append(f"[幻灯片 {i}]")
+        for shape in slide.shapes:
+            if shape.has_text_frame:
+                for paragraph in shape.text_frame.paragraphs:
+                    line = paragraph.text.strip()
+                    if line:
+                        parts.append(line)
+            if shape.has_table:
+                table = shape.table
+                for row in table.rows:
+                    cells = [cell.text.strip() for cell in row.cells]
+                    parts.append(" | ".join(cells))
+
+    result = "\n".join(parts).strip()
+    return result if result else "(PPT 中未找到文字内容)"
+
+
+def _read_text(file_path: str) -> str:
+    """纯文本文件读取(UTF-8 / GBK 自动检测)。"""
+    for encoding in ("utf-8", "gbk", "gb2312", "latin-1"):
+        try:
+            with open(file_path, "r", encoding=encoding) as f:
+                return f.read().strip()
+        except (UnicodeDecodeError, UnicodeError):
+            continue
+    return "(无法解码文件内容,可能是二进制文件)"

+ 66 - 3
tools/report_utils.py

@@ -16,6 +16,7 @@ import os
 import re
 import uuid
 from pathlib import Path
+from urllib.parse import quote
 
 
 # ============================================================
@@ -211,7 +212,7 @@ def get_docx_download_url(word_filename: str) -> str:
     Returns:
         str: 完整下载 URL,如 "http://localhost:8000/static/reports/report_abc123.docx"
     """
-    return f"{SERVER_HOST}/static/reports/{word_filename}"
+    return f"{SERVER_HOST}/static/reports/{quote(word_filename)}"
 
 
 def generate_report_filename(title: str = None) -> str:
@@ -315,5 +316,67 @@ def save_review_report(md_content: str, title: str = None) -> tuple[str, str]:
     filename = generate_report_filename(title)
     save_path = str(REPORT_OUTPUT_DIR / filename)
     convert_markdown_to_docx(md_content, save_path)
-    download_url = get_docx_download_url(filename)
-    return save_path, download_url
+    download_url = get_docx_download_url(save_path)
+    return save_path, download_url
+
+
+# ═══════════════════════════════════════════════════════════════
+# Agent 工具:保存 Markdown 报告
+# ═══════════════════════════════════════════════════════════════
+
+async def save_report(content: str, filename: str = "") -> str:
+    """将 Markdown 格式的分析报告保存为 .md 文件,并返回下载链接。
+    
+    当你需要生成通风分析报告、风量计算报告、数据解读报告等输出文档时调用此工具。
+    直接传入完整的 Markdown 内容即可,工具会自动生成文件名并保存到报告目录。
+    
+    注意:你只需要调用一次本工具,将完整报告内容传入。不要尝试用 write_file
+    或 edit_file 等文件系统工具来写文件,那些操作已被权限限制。
+    
+    Args:
+        content: 完整的 Markdown 格式报告内容
+        filename: 可选,文件名(不含 .md 扩展名)。
+                  不传则自动生成:report_YYYYMMDD_HHMMSS.md
+    
+    Returns:
+        JSON 格式,包含 success、file_path(本地路径)、filename、
+        download_url(可通过浏览器访问的下载链接)。
+    """
+    import json
+    from datetime import datetime, timezone, timedelta
+
+    if not content or not content.strip():
+        return json.dumps({"error": "报告内容不能为空"}, ensure_ascii=False)
+
+    os.makedirs(str(REPORT_OUTPUT_DIR), exist_ok=True)
+
+    # 生成文件名
+    if filename and filename.strip():
+        safe_name = filename.strip().replace("/", "_").replace("\\", "_")
+        safe_name = re.sub(r"[<>:\"|?*]", "_", safe_name)
+        md_filename = f"{safe_name}.md"
+    else:
+        now = datetime.now(timezone(timedelta(hours=8)))
+        md_filename = now.strftime("report_%Y%m%d_%H%M%S.md")
+
+    file_path = str(REPORT_OUTPUT_DIR / md_filename)
+
+    try:
+        with open(file_path, "w", encoding="utf-8") as f:
+            f.write(content.strip())
+
+        # 构建下载 URL(文件名需 URL 编码,处理中文/特殊字符)
+        download_url = f"{SERVER_HOST}/static/reports/{quote(md_filename)}"
+
+        return json.dumps({
+            "success": True,
+            "file_path": file_path,
+            "filename": md_filename,
+            "download_url": download_url,
+            "message": f"报告已保存为 {md_filename},可通过 {download_url} 下载",
+        }, ensure_ascii=False)
+    except Exception as e:
+        return json.dumps({
+            "success": False,
+            "error": f"保存报告失败: {str(e)}",
+        }, ensure_ascii=False)

+ 92 - 0
tools/tool_names_cn.py

@@ -90,6 +90,11 @@ TOOL_NAME_CN: dict[str, str] = {
     # ================================================================
     "calc_gas_emission_from_wind": "测风报表涌出量反算",
     "get_current_time": "获取当前系统时间",
+    "read_file_content": "读取文件内容",
+    "search_chat_history": "搜索聊天记录",
+    "get_session_chat": "获取会话消息",
+    "get_current_session_id": "获取当前会话ID",
+    "save_report": "保存报告文件",
 
     # ================================================================
     # 用户偏好记忆
@@ -102,4 +107,91 @@ TOOL_NAME_CN: dict[str, str] = {
     # 计划审批(Human-in-the-Loop)
     # ================================================================
     "request_plan_approval": "提交执行计划审批",
+
+    # ================================================================
+    # 需风量 / 模型数据(新增)
+    # ================================================================
+    "get_model_param_pub_list": "查询模型参数列表",
+    "get_model_wind": "查询巷道解算风量",
+    "get_sensor_wind": "查询设备监测风量",
+    "simulate_needq_heading_face": "掘进面需风量模拟",
+    "simulate_needq_room": "硐室需风量模拟",
+    "simulate_needq_ret_work_face": "采煤面需风量模拟",
+    "simulate_needq_other": "其他地点需风量模拟",
+
+    # ================================================================
+    # 设备信息(新增)
+    # ================================================================
+    "query_device_info": "查询设备详细信息",
+    "query_device_type_info": "查询设备类型信息",
+    "query_monitor_params": "查询监测点参数",
+    "query_devices_by_model": "按模型查询设备",
+
+    # ================================================================
+    # 故障诊断(新增)
+    # ================================================================
+    "check_model_connect_status": "模型连通性检查",
+    "check_model_one_dir_cycle": "循环风路检查",
+    "check_model_one_dir_node": "单向节点检查",
+    "check_model_diagonal_structure": "角联结构诊断",
+    "get_model_fault_diagnosis": "模型故障诊断",
+
+    # ================================================================
+    # 避灾路线(新增)
+    # ================================================================
+    "get_escape_path": "避灾路线模拟",
+    "get_escape_path_each_exit": "避灾路线模拟(各出口)",
+
+    # ================================================================
+    # 关键阻力 / 压能(新增)
+    # ================================================================
+    "get_out_shafts": "查询回风井列表",
+    "get_in_shafts": "查询进风井列表",
+    "get_max_resistance_path": "最大阻力路线",
+    "get_three_area_distribution": "三区阻力分布",
+    "get_key_path_decision": "关键路径决策",
+    "get_path_press_power": "节点压能图",
+
+    # ================================================================
+    # 网络解算(新增)
+    # ================================================================
+    "net_cal": "网络解算",
+    "net_cal_for_plan": "方案模拟解算",
+
+    # ================================================================
+    # 报警 / 日志(新增)
+    # ================================================================
+    "get_alarm_log_history": "查询报警历史",
+    "get_device_set_log_history": "查询设备控制历史",
+    "get_sys_log_history": "查询登录历史",
+
+    # ================================================================
+    # 场景管理(新增)
+    # ================================================================
+    "get_manage_system_by_strType": "按类型查场景列表",
+    "query_system_by_systemID": "按ID查场景数据",
+
+    # ================================================================
+    # 煤矿基础(新增)
+    # ================================================================
+    "get_gas_identify_vo": "查询瓦斯等级鉴定报告",
+    "get_by_mine_name": "查询工作面设计规程",
+    "query_control_testWind": "一键测风",
+
+    # ================================================================
+    # 数据库 / 文件(新增)
+    # ================================================================
+    "execute_sql_query": "执行SQL查询",
+    "get_file_list_by_type": "按类型查文件列表",
+    "get_file_base64_by_id": "按ID查文件Base64",
+
+    # ================================================================
+    # 字典查询(新增)
+    # ================================================================
+    "get_dict_list_by_dictcode": "查询字典项",
+
+    # ================================================================
+    # 测风报表(新增)
+    # ================================================================
+    "get_latest_report": "查询最新测风报表",
 }

+ 773 - 2
tools/vent_tools.py

@@ -5,20 +5,92 @@ DeepAgents 工具函数模块
 所有工具函数供 DeepAgent 调用,通过 MCP 客户端获取模拟数据。
 工具函数返回结构化 JSON 文本,由 LLM 解析并生成自然语言回复。
 
-工具列表:
+工具列表(共 56 个):
+
+【数据查询(17个)】
 - query_device_data: 查询设备实时数据
 - query_device_data_by_id: 根据设备ID查询实时数据和报警数据
 - query_devices_by_tunnel: 根据巷道名称查询绑定设备
 - query_devices_by_tunnel_id: 根据巷道ID查询绑定设备
+- query_devices_by_model: 通过模型ID查询绑定设备
 - get_tun_list_by_modelid: 通过模型ID获取巷道列表(旧接口)
 - query_tunnels_by_model: 根据模型ID查询巷道列表(新接口)
 - query_tun_data_by_id: 根据巷道ID查询实时监测数据
+- query_tunnel_list: 按名称模糊搜索巷道
 - 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: 查询设备实时监测数据
+- get_dict_list_by_dictcode: 根据字典编码查询字典项
+
+【需风量 / 模型(7个)】
+- get_model_param_pub_list: 查询默认模型参数列表
+- get_model_wind: 获取巷道解算风量
+- get_sensor_wind: 获取设备监测风量
+- simulate_needq_heading_face: 掘进面需风量模拟计算
+- simulate_needq_room: 硐室需风量模拟计算
+- simulate_needq_ret_work_face: 采煤工作面需风量模拟计算
+- simulate_needq_other: 其他用风地点需风量模拟计算
+
+【设备信息(3个)】
+- query_device_info: 根据设备大类/小类查询设备信息
+- query_device_type_info: 根据设备类型编码查询类型信息
+- query_monitor_params: 查询监测点表参数
+
+【故障诊断(5个)】
+- check_model_connect_status: 模型网络连通检查
+- check_model_one_dir_cycle: 模型循环风路检查
+- check_model_one_dir_node: 模型单向节点检查
+- check_model_diagonal_structure: 模型角联结构诊断
+- get_model_fault_diagnosis: 模型故障诊断(聚合)
+
+【避灾路线(2个)】
+- get_escape_path: 避灾路线模拟
+- get_escape_path_each_exit: 避灾路线模拟(到各出口)
+
+【关键阻力 / 压能(6个)】
+- get_out_shafts: 获取回风井列表
+- get_in_shafts: 获取进风井列表
+- get_max_resistance_path: 最大阻力路线
+- get_three_area_distribution: 三区阻力分布
+- get_key_path_decision: 关键路径控风决策
+- get_path_press_power: 节点压能图
+
+【网络解算(2个)】
+- net_cal: 网络解算
+- net_cal_for_plan: 方案模拟解算
+
+【报警 / 日志(3个)】
+- get_alarm_log_history: 设备设施报警历史
+- get_device_set_log_history: 设备设施控制历史
+- get_sys_log_history: 系统登录人员历史
+
+【场景管理(2个)】
+- get_manage_system_by_strType: 通过场景类型获取场景列表
+- query_system_by_systemID: 根据场景ID获取场景数据
+
+【煤矿基础(2个)】
+- get_gas_identify_vo: 根据矿井名称获取瓦斯等级鉴定报告
+- get_by_mine_name: 根据矿井名称查询工作面设计规程
+- query_control_testWind: 测风装置一键测风
+
+【数据库 / 文件(3个)】
+- execute_sql_query: 从 vent 库执行 SQL 查询
+- get_file_list_by_type: 按业务类型获取文件列表
+- get_file_base64_by_id: 通过文件ID获取文件Base64
+
+【报表(1个)】
+- get_latest_report: 获取最新测风报表
+
+【用户偏好(3个)】
+- save_user_preference: 保存用户偏好
+- list_user_preferences: 查看用户偏好
+- delete_user_preference: 删除用户偏好
+
+【计划审批(1个)】
+- request_plan_approval: 提交执行计划审批
 """
 	
 import json
@@ -35,6 +107,11 @@ _current_user: contextvars.ContextVar[str] = contextvars.ContextVar(
     'current_user', default='admin'
 )
 
+# 当前会话 ID(供工具函数获取本轮会话上下文)
+_current_session: contextvars.ContextVar[str] = contextvars.ContextVar(
+    'current_session', default=''
+)
+
 
 # ============================================================
 # MCP 客户端辅助函数
@@ -201,7 +278,6 @@ async def query_tun_data_by_id(tun_id: str = "") -> str:
 
     Args:
         tun_id: 巷道ID。
-
     Returns:
         JSON格式的监测数据,包含风速、风量、瓦斯、温度、设备状态等信息。
         tunId	巷道唯一 ID
@@ -507,3 +583,698 @@ def request_plan_approval(plan_summary: str) -> str:
         return "计划已批准,开始执行。"
     else:
         return "计划被拒绝。"
+
+
+# ============================================================
+# 需风量 / 模型数据查询
+# ============================================================
+
+async def get_model_param_pub_list(page_no: int = 1, page_size: int = 50) -> str:
+    """从通防管控平台查询默认模型参数列表。
+
+    获取管控平台上配置的需风量计算模型的基本信息,包含分页数据。
+
+    Args:
+        page_no: 页码,默认 1。
+        page_size: 每页条数,默认 50。
+
+    Returns:
+        JSON 格式的模型参数列表。
+    """
+    return await _call_mcp_tool("get_model_param_pub_list", {
+        "page_no": page_no,
+        "page_size": page_size,
+    })
+
+
+async def get_model_wind(model_id: int) -> str:
+    """获取巷道解算风量数据。
+
+    根据模型ID获取该模型下所有巷道的网络解算风量数据,
+    包含各巷道的计算风量、风速等模拟结果。
+
+    Args:
+        model_id: 巷道模型ID(必填)。
+
+    Returns:
+        JSON 格式的巷道解算风量数据。
+    """
+    return await _call_mcp_tool("get_model_wind", {"model_id": model_id})
+
+
+async def get_sensor_wind(model_id: int) -> str:
+    """获取设备监测风量数据。
+
+    根据模型ID获取该模型下所有测风设备的实时监测风量数据。
+
+    Args:
+        model_id: 巷道模型ID(必填)。
+
+    Returns:
+        JSON 格式的设备监测风量数据。
+    """
+    return await _call_mcp_tool("get_sensor_wind", {"model_id": model_id})
+
+
+# ============================================================
+# 需风量模拟计算
+# ============================================================
+
+async def simulate_needq_heading_face(req_data: str) -> str:
+    """掘进面需风量模拟计算。
+
+    对指定掘进工作面进行需风量模拟计算,输入为结构化 JSON 对象。
+
+    Args:
+        req_data: 掘进面需风量计算的请求参数(JSON 对象字符串)。
+
+    Returns:
+        JSON 格式的模拟计算结果。
+    """
+    import json as _json
+    try:
+        args = _json.loads(req_data) if isinstance(req_data, str) else req_data
+    except _json.JSONDecodeError:
+        args = {"req_data": req_data}
+    return await _call_mcp_tool("simulate_needq_heading_face", args)
+
+
+async def simulate_needq_room(req_data: str) -> str:
+    """硐室需风量模拟计算。
+
+    对指定硐室进行需风量模拟计算,输入为结构化 JSON 对象。
+
+    Args:
+        req_data: 硐室需风量计算的请求参数(JSON 对象字符串)。
+
+    Returns:
+        JSON 格式的模拟计算结果。
+    """
+    import json as _json
+    try:
+        args = _json.loads(req_data) if isinstance(req_data, str) else req_data
+    except _json.JSONDecodeError:
+        args = {"req_data": req_data}
+    return await _call_mcp_tool("simulate_needq_room", args)
+
+
+async def simulate_needq_ret_work_face(req_data: str) -> str:
+    """采煤工作面需风量模拟计算。
+
+    对指定采煤工作面进行需风量模拟计算,输入为结构化 JSON 对象。
+
+    Args:
+        req_data: 采煤面需风量计算的请求参数(JSON 对象字符串)。
+
+    Returns:
+        JSON 格式的模拟计算结果。
+    """
+    import json as _json
+    try:
+        args = _json.loads(req_data) if isinstance(req_data, str) else req_data
+    except _json.JSONDecodeError:
+        args = {"req_data": req_data}
+    return await _call_mcp_tool("simulate_needq_ret_work_face", args)
+
+
+async def simulate_needq_other(req_data: str) -> str:
+    """其他用风地点需风量模拟计算。
+
+    对其他用风地点(如联络巷、其他巷道等)进行需风量模拟计算。
+
+    Args:
+        req_data: 其他地点需风量计算的请求参数(JSON 对象字符串)。
+
+    Returns:
+        JSON 格式的模拟计算结果。
+    """
+    import json as _json
+    try:
+        args = _json.loads(req_data) if isinstance(req_data, str) else req_data
+    except _json.JSONDecodeError:
+        args = {"req_data": req_data}
+    return await _call_mcp_tool("simulate_needq_other", args)
+
+
+# ============================================================
+# 设备信息查询
+# ============================================================
+
+async def query_device_info(device_kind: str = "", str_type: str = "") -> str:
+    """根据设备大类/小类查询设备信息列表。
+
+    查询指定设备类型下的所有设备详细信息,device_kind 和 str_type 至少传一个。
+
+    Args:
+        device_kind: 设备大类编码,如 "fanmain"(可选,与 str_type 至少传一个)。
+        str_type: 设备小类编码,如 "fanmain_stem_wp_2"(可选,与 device_kind 至少传一个)。
+
+    Returns:
+        JSON 格式的设备信息列表。
+    """
+    return await _call_mcp_tool("query_device_info", {
+        "device_kind": device_kind,
+        "str_type": str_type,
+    })
+
+
+async def query_device_type_info(device_type: str = "") -> str:
+    """根据设备类型编码查询设备大类和小类信息。
+
+    可传入大类编码(如 "fanmain")或完整小类编码(如 "fanmain_stem_wp_2")。
+
+    Args:
+        device_type: 设备类型编码(必填)。
+
+    Returns:
+        JSON 格式的设备类型信息。
+    """
+    return await _call_mcp_tool("query_device_type_info", {"device_type": device_type})
+
+
+async def query_monitor_params(device_kind: str = "", device_type: str = "",
+                                value_code: str = "") -> str:
+    """根据设备大类/小类/测点编码查询点表监测参数。
+
+    查询指定设备的监测参数配置信息。
+
+    Args:
+        device_kind: 设备大类编码,如 "fanmain"(可选)。
+        device_type: 设备类型编码,如 "fanmain_stem_wp_2"(可选)。
+        value_code: 测点编码,如 "Fan1Power1IA"(可选)。
+
+    Returns:
+        JSON 格式的监测参数信息。
+    """
+    return await _call_mcp_tool("query_monitor_params", {
+        "device_kind": device_kind,
+        "device_type": device_type,
+        "value_code": value_code,
+    })
+
+
+async def query_devices_by_model(model_id: str = "", device_type: str = None) -> str:
+    """通过模型ID查询该模型下已绑定的有效设备。
+
+    可选的 device_type 参数用于按设备类型过滤。
+
+    Args:
+        model_id: 模型ID(必填)。
+        device_type: 设备类型过滤条件(可选),后端可模糊匹配。
+
+    Returns:
+        JSON 格式的设备列表。
+    """
+    params = {"model_id": model_id}
+    if device_type:
+        params["device_type"] = device_type
+    return await _call_mcp_tool("query_devices_by_model", params)
+
+
+# ============================================================
+# 故障诊断
+# ============================================================
+
+async def check_model_connect_status(model_id: str = "") -> str:
+    """检查模型通风网络连通性。
+
+    诊断网络联通故障,返回连通块数量及各巷道所属连通块。
+
+    Args:
+        model_id: 模型ID(必填)。
+
+    Returns:
+        JSON 格式的网络连通诊断结果。
+    """
+    return await _call_mcp_tool("check_model_connect_status", {"model_id": model_id})
+
+
+async def check_model_one_dir_cycle(model_id: str = "") -> str:
+    """检查模型是否存在单向回路(循环风路)。
+
+    检测通风网络中的循环风路问题。
+
+    Args:
+        model_id: 模型ID(必填)。
+
+    Returns:
+        JSON 格式的循环风路检查结果。
+    """
+    return await _call_mcp_tool("check_model_one_dir_cycle", {"model_id": model_id})
+
+
+async def check_model_one_dir_node(model_id: str = "") -> str:
+    """检查模型是否存在单风向节点。
+
+    检测通风网络中所有节点是否有风流方向异常。
+
+    Args:
+        model_id: 模型ID(必填)。
+
+    Returns:
+        JSON 格式的单向节点检查结果。
+    """
+    return await _call_mcp_tool("check_model_one_dir_node", {"model_id": model_id})
+
+
+async def check_model_diagonal_structure(model_id: str = "") -> str:
+    """角联结构快速诊断。
+
+    检测通风网络中的角联结构,计算较重,可能耗时较长。
+
+    Args:
+        model_id: 模型ID(必填)。
+
+    Returns:
+        JSON 格式的角联结构诊断结果。
+    """
+    return await _call_mcp_tool("check_model_diagonal_structure", {"model_id": model_id})
+
+
+async def get_model_fault_diagnosis(model_id: str = "",
+                                     include_diagonal: bool = False) -> str:
+    """获取模型故障诊断聚合数据。
+
+    默认包含网络连通、循环风路、单向节点检查;
+    设置 include_diagonal=True 时额外追加角联结构诊断(较慢)。
+
+    Args:
+        model_id: 模型ID(必填)。
+        include_diagonal: 是否包含角联结构诊断,默认 False。
+
+    Returns:
+        JSON 格式的故障诊断聚合结果。
+    """
+    return await _call_mcp_tool("get_model_fault_diagnosis", {
+        "model_id": model_id,
+        "include_diagonal": include_diagonal,
+    })
+
+
+# ============================================================
+# 避灾路线
+# ============================================================
+
+async def get_escape_path(model_id: str = "", fire_tun_id: str = "",
+                           person_tun_id: str = "") -> str:
+    """灾变避灾路线模拟。
+
+    给定火源巷道ID和人员/起点巷道ID,分析避灾逃生路线。
+
+    Args:
+        model_id: 模型ID(必填)。
+        fire_tun_id: 火源巷道ID(必填)。
+        person_tun_id: 人员所在/起点巷道ID(必填)。
+
+    Returns:
+        JSON 格式的避灾路线数据。
+    """
+    return await _call_mcp_tool("get_escape_path", {
+        "model_id": model_id,
+        "fire_tun_id": fire_tun_id,
+        "person_tun_id": person_tun_id,
+    })
+
+
+async def get_escape_path_each_exit(model_id: str = "", fire_tun_id: str = "",
+                                     person_tun_id: str = "",
+                                     co_per: float = 2000.0,
+                                     during_time: float = 0.0) -> str:
+    """避灾路线模拟(到各出口)。
+
+    给定火源、人员起点、CO浓度与持续时间,分析到各出口的避灾路线。
+
+    Args:
+        model_id: 模型ID(必填)。
+        fire_tun_id: 火源巷道ID(必填)。
+        person_tun_id: 人员/起点巷道ID(必填)。
+        co_per: CO浓度参数,默认 2000。
+        during_time: 持续时间,默认 0。
+
+    Returns:
+        JSON 格式的各出口避灾路线数据。
+    """
+    return await _call_mcp_tool("get_escape_path_each_exit", {
+        "model_id": model_id,
+        "fire_tun_id": fire_tun_id,
+        "person_tun_id": person_tun_id,
+        "co_per": co_per,
+        "during_time": during_time,
+    })
+
+
+# ============================================================
+# 关键阻力 / 压能
+# ============================================================
+
+async def get_out_shafts(model_id: str = "") -> str:
+    """获取模型全部回风井巷道ID列表。
+
+    Args:
+        model_id: 模型ID(必填)。
+
+    Returns:
+        JSON 格式的回风井巷道ID列表。
+    """
+    return await _call_mcp_tool("get_out_shafts", {"model_id": model_id})
+
+
+async def get_in_shafts(model_id: str = "") -> str:
+    """获取模型全部进风井巷道ID列表。
+
+    Args:
+        model_id: 模型ID(必填)。
+
+    Returns:
+        JSON 格式的进风井巷道ID列表。
+    """
+    return await _call_mcp_tool("get_in_shafts", {"model_id": model_id})
+
+
+async def get_max_resistance_path(model_id: str = "", node_id: str = "") -> str:
+    """获取指定节点的最大阻力路线(关键阻力路线)。
+
+    node_id 必须是节点ID(nNodeID),不是巷道ID。
+    可先调用 get_out_shafts 获取回风井,再查巷道起止节点获取 node_id。
+
+    Args:
+        model_id: 模型ID(必填)。
+        node_id: 节点ID(必填),对应巷道 nFromID/nToID。
+
+    Returns:
+        JSON 格式的最大阻力路线数据。
+    """
+    return await _call_mcp_tool("get_max_resistance_path", {
+        "model_id": model_id,
+        "node_id": node_id,
+    })
+
+
+async def get_three_area_distribution(model_id: str = "") -> str:
+    """获取模型三区阻力分布数据。
+
+    返回进风区/用风区/回风区的分段阻力、总阻力等分布数据。
+
+    Args:
+        model_id: 模型ID(必填)。
+
+    Returns:
+        JSON 格式的三区阻力分布数据。
+    """
+    return await _call_mcp_tool("get_three_area_distribution", {"model_id": model_id})
+
+
+async def get_key_path_decision(model_id: str = "") -> str:
+    """获取模型关键路径控风方案决策数据。
+
+    Args:
+        model_id: 模型ID(必填)。
+
+    Returns:
+        JSON 格式的关键路径决策数据。
+    """
+    return await _call_mcp_tool("get_key_path_decision", {"model_id": model_id})
+
+
+async def get_path_press_power(model_id: str = "", id_from: str = "",
+                                id_to: str = "") -> str:
+    """获取模型节点压能图数据。
+
+    计算从起点节点到终点节点的路径压能图。id_from/id_to 为节点ID,不是巷道ID。
+
+    Args:
+        model_id: 模型ID(必填)。
+        id_from: 起点节点ID(必填)。
+        id_to: 终点节点ID(必填)。
+
+    Returns:
+        JSON 格式的节点压能图数据。
+    """
+    return await _call_mcp_tool("get_path_press_power", {
+        "model_id": model_id,
+        "id_from": id_from,
+        "id_to": id_to,
+    })
+
+
+# ============================================================
+# 网络解算
+# ============================================================
+
+async def net_cal(model_id: str = "") -> str:
+    """模型网络解算。
+
+    对指定模型执行通风网络解算,获取各巷道风量分配结果。
+    也用于反风模拟后的风量结果查询。耗时可能较长。
+
+    Args:
+        model_id: 模型ID(必填)。
+
+    Returns:
+        JSON 格式的网络解算结果。
+    """
+    return await _call_mcp_tool("net_cal", {"model_id": model_id})
+
+
+async def net_cal_for_plan(model_id: str = "", plan: str = "") -> str:
+    """方案模拟解算。
+
+    传入调控/反风等方案串,获取模拟解算结果。
+
+    Args:
+        model_id: 模型ID(必填)。
+        plan: 方案串,由业务侧定义的调控/反风方案内容(必填)。
+
+    Returns:
+        JSON 格式的方案模拟解算结果。
+    """
+    return await _call_mcp_tool("net_cal_for_plan", {
+        "model_id": model_id,
+        "plan": plan,
+    })
+
+
+# ============================================================
+# 报警 / 日志查询
+# ============================================================
+
+async def get_alarm_log_history(start_time: str, end_time: str,
+                                 device_id: str = "", device_type: str = "") -> str:
+    """获取设备设施报警历史数据。
+
+    按时间范围和可选设备条件查询报警历史记录。
+
+    Args:
+        start_time: 起始时间(必填),格式 "yyyy-MM-dd HH:mm:ss"。
+        end_time: 结束时间(必填),格式 "yyyy-MM-dd HH:mm:ss"。
+        device_id: 设备ID(可选)。
+        device_type: 设备类型(可选)。
+
+    Returns:
+        JSON 格式的报警历史数据。
+    """
+    return await _call_mcp_tool("get_alarm_log_history", {
+        "start_time": start_time,
+        "end_time": end_time,
+        "device_id": device_id,
+        "device_type": device_type,
+    })
+
+
+async def get_device_set_log_history(start_time: str, end_time: str,
+                                      device_id: str = "",
+                                      device_type: str = "") -> str:
+    """获取设备设施控制历史数据。
+
+    按时间范围和可选设备条件查询设备控制操作历史。
+
+    Args:
+        start_time: 起始时间(必填),格式 "yyyy-MM-dd HH:mm:ss"。
+        end_time: 结束时间(必填),格式 "yyyy-MM-dd HH:mm:ss"。
+        device_id: 设备ID(可选)。
+        device_type: 设备类型(可选)。
+
+    Returns:
+        JSON 格式的设备控制历史数据。
+    """
+    return await _call_mcp_tool("get_device_set_log_history", {
+        "start_time": start_time,
+        "end_time": end_time,
+        "device_id": device_id,
+        "device_type": device_type,
+    })
+
+
+async def get_sys_log_history(start_time: str, end_time: str,
+                               user_name: str = "") -> str:
+    """获取系统登录人员历史数据。
+
+    按时间范围和可选用户名查询系统登录历史。
+
+    Args:
+        start_time: 起始时间(必填),格式 "yyyy-MM-dd HH:mm:ss"。
+        end_time: 结束时间(必填),格式 "yyyy-MM-dd HH:mm:ss"。
+        user_name: 用户名(可选)。
+
+    Returns:
+        JSON 格式的系统登录历史数据。
+    """
+    return await _call_mcp_tool("get_sys_log_history", {
+        "start_time": start_time,
+        "end_time": end_time,
+        "user_name": user_name,
+    })
+
+
+# ============================================================
+# 场景管理
+# ============================================================
+
+async def get_manage_system_by_strType(strType: str) -> str:
+    """通过场景类型获取场景列表。
+
+    Args:
+        strType: 场景类型编码(必填)。
+
+    Returns:
+        JSON 格式的场景列表。
+    """
+    return await _call_mcp_tool("get_manage_system_by_strType", {"strType": strType})
+
+
+async def query_system_by_systemID(systemID: str) -> str:
+    """根据场景ID获取场景数据。
+
+    Args:
+        systemID: 场景ID(必填)。
+
+    Returns:
+        JSON 格式的场景数据。
+    """
+    return await _call_mcp_tool("query_system_by_systemID", {"systemID": systemID})
+
+
+# ============================================================
+# 煤矿基础数据
+# ============================================================
+
+async def get_gas_identify_vo(mine_name: str = "") -> str:
+    """根据矿井名称获取瓦斯等级鉴定报告。
+
+    Args:
+        mine_name: 矿井名称(可选)。
+
+    Returns:
+        JSON 格式的瓦斯等级鉴定报告数据。
+    """
+    return await _call_mcp_tool("get_gas_identify_vo", {"mine_name": mine_name})
+
+
+async def get_by_mine_name(mine_name: str = "") -> str:
+    """根据矿井名称查询工作面设计规程。
+
+    Args:
+        mine_name: 矿井名称(可选)。
+
+    Returns:
+        JSON 格式的工作面设计规程数据。
+    """
+    return await _call_mcp_tool("get_by_mine_name", {"mine_name": mine_name})
+
+
+async def query_control_testWind(ids: str) -> str:
+    """测风装置一键测风。
+
+    触发指定测风装置执行一键测风操作。
+
+    Args:
+        ids: 设备IDS(必填),多个设备按后端要求格式拼接,如 "11111004"。
+
+    Returns:
+        JSON 格式的测风结果。
+    """
+    return await _call_mcp_tool("query_control_testWind", {"ids": ids})
+
+
+# ============================================================
+# 数据库 / 文件
+# ============================================================
+
+async def execute_sql_query(sql_query: str) -> str:
+    """从通防管控平台 vent 库执行 SQL 查询。
+
+    直接查询通防管控平台的数据库,返回 SQL 执行结果。
+    ⚠️ 仅支持 SELECT 类只读查询。
+
+    Args:
+        sql_query: 要执行的 SQL 查询语句(必填)。
+
+    Returns:
+        JSON 格式的查询结果。
+    """
+    return await _call_mcp_tool("execute_sql_query", {"sql_query": sql_query})
+
+
+async def get_file_list_by_type(type: str) -> str:
+    """按业务类型获取文件共享中心文件列表。
+
+    Args:
+        type: 业务类型编码(必填)。
+
+    Returns:
+        JSON 格式的文件列表。
+    """
+    return await _call_mcp_tool("get_file_list_by_type", {"type": type})
+
+
+async def get_file_base64_by_id(id: str) -> str:
+    """通过文件ID获取文件的 Base64 编码内容。
+
+    Args:
+        id: 文件ID(必填)。
+
+    Returns:
+        JSON 格式的文件 Base64 编码数据。
+    """
+    return await _call_mcp_tool("get_file_base64_by_id", {"id": id})
+
+
+# ============================================================
+# 字典查询
+# ============================================================
+
+async def get_dict_list_by_dictcode(dictcode: str) -> str:
+    """根据字典编码查询字典项的类型和名称。
+
+    查询系统字典表中指定 dictcode 对应的所有字典项,
+    包含编码和中文名称映射。
+
+    Args:
+        dictcode: 字典编码(必填)。
+
+    Returns:
+        JSON 格式的字典项列表。
+    """
+    return await _call_mcp_tool("get_dict_list_by_dictcode", {"dictcode": dictcode})
+
+
+# ============================================================
+# 测风报表
+# ============================================================
+
+async def get_latest_report(mine_name: str = "", date_month: str = "") -> str:
+    """获取最新的测风报表。
+
+    根据矿井名称和年月获取最新的测风报表数据。
+
+    Args:
+        mine_name: 矿井名称(可选)。
+        date_month: 年月,格式如 "2026-06"(可选)。
+
+    Returns:
+        JSON 格式的测风报表数据。
+    """
+    return await _call_mcp_tool("get_latest_report", {
+        "mine_name": mine_name,
+        "date_month": date_month,
+    })

+ 179 - 0
tools/web_search_tools.py

@@ -0,0 +1,179 @@
+# -*- coding: utf-8 -*-
+"""
+联网搜索工具模块
+
+提供两大能力:
+- web_search: 通过 DuckDuckGo 搜索互联网公开信息
+- web_fetch:   抓取指定网页并提取正文文本
+
+所有工具函数供 DeepAgent 调用,返回结构化 JSON 文本,由 LLM 解析并生成自然语言回复。
+"""
+
+import asyncio
+import json
+import re
+import time
+
+import httpx
+from ddgs import DDGS
+
+# ── 搜索重试配置 ──
+_MAX_RETRIES = 3            # 最大重试次数
+_RETRY_BASE_DELAY = 2.0     # 重试基础延迟(秒),按指数增长:2 → 4 → 8
+_SEARCH_TIMEOUT = 20.0      # 单次搜索超时(秒)
+
+
+async def web_search(query: str, max_results: int = 5) -> str:
+    """搜索互联网获取公开信息。当用户询问的问题超出煤矿通风专业知识库范围、
+    或需要了解最新政策/新闻/行业动态时调用此工具。
+    
+    优先策略:
+    - 煤矿安全规程、技术标准 → 优先使用 query_knowledge_base(内部知识库)
+    - 最新政策动态、行业新闻、通用知识 → 使用 web_search
+    
+    Args:
+        query: 搜索关键词,建议使用简洁明确的中文词组
+        max_results: 最大返回结果数,默认 5 条
+    
+    Returns:
+        JSON 格式的搜索结果,包含 query 和 results 列表。
+        每条结果含 title(标题)、href(链接)、body(摘要)。
+    """
+    if not query or not query.strip():
+        return json.dumps({"error": "搜索关键词不能为空"}, ensure_ascii=False)
+
+    q = query.strip()
+    last_error = ""
+
+    for attempt in range(1, _MAX_RETRIES + 1):
+        try:
+            # 在线程池中运行同步 DDGS 调用(避免阻塞事件循环),并设置超时
+            results = await asyncio.wait_for(
+                asyncio.to_thread(_do_search, q, max_results),
+                timeout=_SEARCH_TIMEOUT,
+            )
+
+            formatted = []
+            for r in results:
+                formatted.append({
+                    "title": r.get("title", ""),
+                    "href": r.get("href", ""),
+                    "body": r.get("body", ""),
+                })
+
+            return json.dumps(
+                {"query": q, "results": formatted},
+                ensure_ascii=False,
+            )
+
+        except asyncio.TimeoutError:
+            last_error = f"搜索超时(>{_SEARCH_TIMEOUT}s)"
+        except Exception as e:
+            last_error = str(e)
+
+        if attempt < _MAX_RETRIES:
+            delay = _RETRY_BASE_DELAY ** attempt
+            await asyncio.sleep(delay)
+
+    return json.dumps(
+        {"error": f"搜索失败(已重试{_MAX_RETRIES}次): {last_error}", "query": q},
+        ensure_ascii=False,
+    )
+
+
+def _do_search(query: str, max_results: int) -> list[dict]:
+    """同步搜索辅助函数(在线程池中执行)。"""
+    with DDGS() as ddgs:
+        return list(ddgs.text(query, max_results=max_results))
+
+
+async def web_fetch(url: str) -> str:
+    """抓取指定网页并提取正文文本。通常在 web_search 获取到相关链接后,
+    需要查看页面详细内容时调用。
+    
+    Args:
+        url: 要抓取的网页完整 URL(含 https://)
+    
+    Returns:
+        JSON 格式,包含 url、title(页面标题)、content(提取的正文文本)、
+        content_length(正文字符数)。
+        正文最多保留 8000 字符,超出部分截断。
+    """
+    if not url or not url.strip():
+        return json.dumps({"error": "URL 不能为空"}, ensure_ascii=False)
+
+    url = url.strip()
+    try:
+        async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
+            resp = await client.get(
+                url,
+                headers={
+                    "User-Agent": (
+                        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+                        "AppleWebKit/537.36 (KHTML, like Gecko) "
+                        "Chrome/125.0.0.0 Safari/537.36"
+                    ),
+                    "Accept": "text/html,application/xhtml+xml",
+                    "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+                },
+            )
+            resp.raise_for_status()
+            html = resp.text
+
+    except httpx.HTTPStatusError as e:
+        return json.dumps(
+            {"error": f"请求失败 HTTP {e.response.status_code}", "url": url},
+            ensure_ascii=False,
+        )
+    except httpx.TimeoutException:
+        return json.dumps(
+            {"error": "请求超时(15s)", "url": url},
+            ensure_ascii=False,
+        )
+    except Exception as e:
+        return json.dumps(
+            {"error": f"抓取失败: {str(e)}", "url": url},
+            ensure_ascii=False,
+        )
+
+    # ── 提取标题 ──
+    title = ""
+    title_match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
+    if title_match:
+        title = title_match.group(1).strip()
+
+    # ── 去除不需要的标签及其内容 ──
+    for tag in ("script", "style", "nav", "footer", "header", "noscript", "iframe"):
+        html = re.sub(
+            rf"<{tag}[^>]*>.*?</{tag}>",
+            "",
+            html,
+            flags=re.IGNORECASE | re.DOTALL,
+        )
+
+    # ── 去除所有 HTML 标签,提取纯文本 ──
+    text = re.sub(r"<[^>]+>", " ", html)
+
+    # ── 清理 HTML 实体 ──
+    text = re.sub(r"&nbsp;", " ", text)
+    text = re.sub(r"&amp;", "&", text)
+    text = re.sub(r"&lt;", "<", text)
+    text = re.sub(r"&gt;", ">", text)
+    text = re.sub(r"&quot;", '"', text)
+    text = re.sub(r"&#?\w+;", " ", text)
+    text = re.sub(r"\s+", " ", text).strip()
+
+    # ── 截断到 8000 字符 ──
+    max_chars = 8000
+    if len(text) > max_chars:
+        text = text[:max_chars] + "…(内容已截断)"
+
+    return json.dumps(
+        {
+            "url": url,
+            "title": title,
+            "content": text,
+            "content_length": len(text),
+        },
+        ensure_ascii=False,
+    )