Przeglądaj źródła

feat: add langgraph web agent runtime

Sheep 2 dni temu
rodzic
commit
d9ad813490
33 zmienionych plików z 4400 dodań i 41 usunięć
  1. 1 0
      .gitignore
  2. 347 0
      docs/AGENT_DESIGN_REVIEW.md
  3. 363 0
      docs/build_agent_brief_doc.py
  4. BIN
      docs/市民请集合智能助手Agent简版建设方案.docx
  5. 29 0
      scripts/start-mcp.ps1
  6. 16 0
      scripts/start-test-web.ps1
  7. 45 0
      scripts/start-web-agent.ps1
  8. 25 0
      smqjh-admin-agent/electron-builder.installer.json
  9. 79 24
      smqjh-admin-agent/src/main/assistant/assistantOrchestrator.ts
  10. 0 3
      smqjh-admin-agent/src/main/cloud/smqjhClient.ts
  11. 6 2
      smqjh-admin-agent/src/main/config/appConfig.ts
  12. 108 0
      smqjh-admin-agent/src/main/exports/tableExport.ts
  13. 83 6
      smqjh-admin-agent/src/main/ipc/registerIpc.ts
  14. 1 0
      smqjh-admin-agent/src/main/preload.ts
  15. 71 1
      smqjh-admin-agent/src/renderer/App.tsx
  16. 3 0
      smqjh-admin-agent/src/shared/bridge.ts
  17. 13 0
      smqjh-admin-agent/src/shared/types.ts
  18. 57 0
      smqjh-agent-runtime/README.md
  19. 876 0
      smqjh-agent-runtime/package-lock.json
  20. 20 0
      smqjh-agent-runtime/package.json
  21. 305 0
      smqjh-agent-runtime/public/app.js
  22. 90 0
      smqjh-agent-runtime/public/index.html
  23. 502 0
      smqjh-agent-runtime/public/styles.css
  24. 583 0
      smqjh-agent-runtime/src/agentGraph.ts
  25. 39 0
      smqjh-agent-runtime/src/config.ts
  26. 89 0
      smqjh-agent-runtime/src/mcpClient.ts
  27. 204 0
      smqjh-agent-runtime/src/server.ts
  28. 62 0
      smqjh-agent-runtime/src/types.ts
  29. 15 0
      smqjh-agent-runtime/tsconfig.json
  30. 235 0
      smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/BusinessSchemaRegistry.java
  31. 39 4
      smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/DeepSeekMcpClient.java
  32. 25 1
      smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/MonthlySettlementPlanner.java
  33. 69 0
      smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/ToolRegistry.java

+ 1 - 0
.gitignore

@@ -19,6 +19,7 @@ config/local.json
 # Node / Electron
 node_modules/
 **/node_modules/
+smqjh-admin-agent/appConfig.js
 dist/
 **/dist/
 release/

+ 347 - 0
docs/AGENT_DESIGN_REVIEW.md

@@ -0,0 +1,347 @@
+# 市民请集合智能助手 Agent 设计复盘与改造方案
+
+日期:2026-06-05
+
+## 结论摘要
+
+当前版本已经不是纯聊天窗口,而是一个“桌面端 + MCP + DeepSeek + 只读数据库工具”的 Agent 原型。它的优点是边界清楚、安全意识强、MCP 已经接管了 DeepSeek 和数据库配置;但它仍然更像“AI 帮忙挑固定任务”,还不是成熟的业务智能体。
+
+更成熟的 Agent 应该是:模型根据用户目标自主规划,按需调用工具,拿到结果后继续判断是否还需要查数据库、查外部页面、生成表格或进入人工确认,直到给出可执行、可审计、可下载的结果。工具和安全边界由系统控制,决策和编排尽量交给模型。
+
+一句话方向:从“前端/代码规则驱动的任务助手”升级为“模型驱动、工具受控、可追踪的业务 Agent”。
+
+## 当前设计
+
+当前项目分成两部分:
+
+- `smqjh-admin-agent`:Electron 桌面端,负责窗口、对话 UI、后台登录会话、IPC、表格渲染、导出和本地任务执行。
+- `smqjh-mcp-server`:Java MCP 服务,负责本机配置读取、DeepSeek 调用、只读数据库查询、业务工具注册和工具结果返回。
+
+当前链路大致如下:
+
+```mermaid
+flowchart LR
+  User["管理员自然语言输入"] --> UI["Electron 对话界面"]
+  UI --> Orchestrator["AssistantOrchestrator"]
+  Orchestrator --> Plan["MCP DeepSeek 工具规划"]
+  Plan --> Tools["MCP 工具或本地任务"]
+  Tools --> DB["MySQL 只读查询"]
+  Tools --> Cloud["smqjh 网关接口"]
+  Tools --> Market["慢慢买/苏宁公开价格线索"]
+  Tools --> Summary["MCP DeepSeek 总结"]
+  Summary --> UIResult["回答 + 表格 + 导出"]
+```
+
+已经具备的能力:
+
+| 能力 | 当前状态 |
+| --- | --- |
+| 桌面端 EXE 原型 | 已有 Electron 框架和界面 |
+| 后台登录会话 | 已支持,并持久化 token |
+| DeepSeek | 已迁移到 MCP 侧托管 |
+| 数据库查询 | 已支持只读 SELECT、白名单表、默认 LIMIT、超时和只读事务 |
+| 商品查询 | 已有 `smqjh.product.lookup.summary` |
+| 订单统计 | 已有 `smqjh.order.count.query` |
+| 通用 SQL 查询 | 已有 `smqjh.database.readonly.query` |
+| 商品比价 | 已支持系统价 + 慢慢买/苏宁线索 |
+| 月结需求 | 已有 `smqjh.settlement.monthly.plan` 规划工具 |
+
+## 当前主要问题
+
+### 1. 编排仍然偏一次性
+
+现在的主流程基本是:
+
+1. DeepSeek 判断一次要不要用工具。
+2. 最多执行 3 个工具。
+3. 再让 DeepSeek 总结结果。
+
+这个流程缺少成熟 Agent 常见的循环能力:如果工具结果为空、SQL 字段不对、查到多个候选、需要再查明细,模型应该基于观察结果继续调用下一步工具,而不是直接结束或让用户自己查。
+
+这解释了之前出现的体验问题:模型能识别意图,也能生成 SQL,但系统没有把 SQL 自动执行并回填结果。
+
+### 2. 工具分布有点混乱
+
+一部分能力在 MCP:数据库、DeepSeek、配置、订单统计、商品查询。
+
+另一部分还在桌面端本地任务:商品比价、部分导出、市场页面抓取。
+
+这样会导致模型眼里的工具不完整,桌面端还承担了太多业务逻辑。更好的做法是 MCP 统一暴露业务工具,桌面端只负责展示、会话和文件下载。
+
+### 3. 表结构上下文不足
+
+现在 DeepSeek 主要靠提示词里的“常用表、常用字段、SQL 示例”来生成查询。这个方式能跑起来,但不够稳。真正要让它智能查询数据库,需要把表结构、字段说明、业务口径、逻辑删除规则、金额单位等作为 MCP resources 或专门的 schema 工具提供。
+
+否则模型会出现:
+
+- 表名猜错。
+- 字段单位搞错。
+- 商品关键词提取不准。
+- 同一个需求在不同问法下表现不一致。
+
+### 4. 业务结果还没有完全“产物化”
+
+用户要的是结果,不是过程。比如:
+
+- “帮我做表格”应该直接生成 Excel,并给出下载/打开入口。
+- “查订单物流状态”应该直接给表格。
+- “生成月结报告”应该输出结算表、核对表、异常清单。
+- “有差异”应该自动标记“需人工确认”。
+
+当前已经能渲染一些表格,但文件产物、任务状态、异常清单和后续复核动作还需要变成一等能力。
+
+### 5. UI 需要表达 Agent 的过程
+
+现在用户看到“思考中”“处理中”时,不知道系统到底在做什么。成熟的 Agent UI 应该能显示:
+
+- 正在理解意图。
+- 正在调用哪个工具。
+- 正在查询哪类数据。
+- 已拿到多少条结果。
+- 是否生成文件。
+- 哪些地方需要人工确认。
+
+这不是花哨交互,而是让管理员信任结果。
+
+## 市场上主流 Agent 的正确制作步骤
+
+下面不是某一家产品的唯一标准,而是目前 OpenAI、LangChain、Anthropic、MCP 等主流 Agent 体系共同体现出的落地路径。
+
+### 标准步骤
+
+| 阶段 | 应该做什么 | 关键产物 |
+| --- | --- | --- |
+| 1. 定义业务目标 | 先明确 Agent 要替谁完成什么工作,不是先做聊天框 | 场景清单、成功标准、风险分级 |
+| 2. 设计工具边界 | 把系统能力封装成工具,模型只能通过工具读写业务系统 | Tool schema、权限、输入输出规范 |
+| 3. 提供上下文 | 给模型表结构、接口说明、业务口径、示例和历史上下文 | MCP resources、业务知识库、字段字典 |
+| 4. 建立 Agent 循环 | 模型规划、调用工具、观察结果、继续规划、最终回答 | 多轮工具调用循环、最大步数、失败重试 |
+| 5. 加安全护栏 | 对写操作、敏感数据、金额、发券、结算确认加人工审批 | 只读限制、审批流、审计日志、脱敏 |
+| 6. 结构化输出 | 让模型输出表格、JSON、Excel、任务状态,而不是长文本 | 表格 schema、artifact 文件、下载入口 |
+| 7. 做评测集 | 用真实管理员问题持续测试准确率 | eval 用例、回归测试、工具调用追踪 |
+| 8. 上线监控 | 记录调用链、失败原因、耗时、SQL、工具结果 | tracing、日志、告警、成本统计 |
+
+### 官方资料参考
+
+- [OpenAI: A Practical Guide to Building Agents](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf)
+- [OpenAI Agents SDK 文档](https://platform.openai.com/docs/guides/agents)
+- [LangChain Agents 文档](https://docs.langchain.com/oss/javascript/langchain/agents)
+- [Anthropic: Building Effective Agents](https://www.anthropic.com/engineering/building-effective-agents)
+- [Model Context Protocol 官方文档](https://modelcontextprotocol.io/docs)
+
+## 当前方案与主流做法对比
+
+| 对比项 | 当前 smqjh-agent | 主流成熟 Agent | 建议 |
+| --- | --- | --- | --- |
+| 决策方式 | DeepSeek 一次性选择工具 | 模型循环规划,按观察结果继续行动 | 改成多步 agent loop |
+| 工具来源 | MCP + 本地任务混合 | 工具集中注册,schema 清晰 | 统一迁移到 MCP |
+| 数据库能力 | 有只读 SQL 工具,但表结构靠提示词 | schema/resource + SQL 执行 + 校验 | 增加 schema 查询工具 |
+| 结果展示 | 文本和部分表格 | 文本、表格、文件、状态、依据分离 | 增加 artifact 层 |
+| 人工确认 | 主要靠 dryRun 和文案 | 写操作前强制审批,差异自动标记 | 做审批/复核队列 |
+| 业务知识 | 写在 prompt 和代码里 | 可维护的业务知识库和字段字典 | 建立 `business-schema` 文档/资源 |
+| 可观测性 | 有日志,但链路不完整 | 每次工具调用、SQL、耗时、错误可追踪 | 增加 trace 面板 |
+| 评测 | 暂无固定问题集 | 用真实问题做回归评测 | 建管理员问题 eval 集 |
+
+## 推荐目标架构
+
+```mermaid
+flowchart TB
+  UI["Electron 桌面端<br/>只负责展示、登录、文件入口"] --> Agent["Agent Runtime<br/>多步规划和工具循环"]
+  Agent --> MCP["Java MCP Server<br/>统一工具和资源"]
+  MCP --> LLM["DeepSeek<br/>意图判断、SQL 草拟、总结"]
+  MCP --> Schema["业务 Schema Resource<br/>表结构、字段、业务口径"]
+  MCP --> DB["MySQL 只读连接池<br/>SELECT 校验、限流、审计"]
+  MCP --> API["smqjh-cloud API<br/>登录态、导出、业务接口"]
+  MCP --> Market["外部价格工具<br/>慢慢买、苏宁、后续授权平台"]
+  MCP --> Artifact["文件产物服务<br/>Excel、报告、异常清单"]
+```
+
+核心原则:
+
+1. 桌面端少做业务判断,只做展示和用户交互。
+2. MCP 统一提供工具、资源、提示词和安全边界。
+3. DeepSeek 负责判断“下一步查什么”,系统负责“能不能查、怎么安全查”。
+4. 数据库查询默认只读,写操作必须进入人工确认。
+5. 所有结果都要有依据:SQL、接口地址、外部请求地址、文件来源。
+
+## 需要优先调整的点
+
+### 第一阶段:让它真正像 Agent
+
+目标:解决“识别到了但不执行”“问法变化就不会查”“回答不连贯”。
+
+建议改动:
+
+1. 把 `AssistantOrchestrator` 改为多步循环:
+   - 第一步让 DeepSeek 判断下一步动作。
+   - 执行工具。
+   - 把观察结果再喂给 DeepSeek。
+   - 如果还需要查,就继续。
+   - 最多 5 到 8 步,避免无限循环。
+
+2. 增加一个 MCP 工具:`smqjh.database.smart.query`。
+   - 输入:管理员自然语言问题。
+   - 内部:DeepSeek 根据 schema 生成 SELECT。
+   - 校验:只读 SQL、安全表、LIMIT、逻辑删除。
+   - 输出:执行 SQL、表格 rows、依据、口径说明。
+
+3. 增加 schema 工具或资源:
+   - `smqjh.schema.search`
+   - `smqjh.schema.getTable`
+   - `smqjh.schema.businessRules`
+
+4. 明确规则:只要用户问的是业务系统事实,Agent 不应该回复“你可以去后台查”或“你可以执行 SQL”,而是自己调用工具。
+
+### 第二阶段:把业务产物做完整
+
+目标:让“查询、比价、月结、订单、物流、商品描述”都能输出表格和文件。
+
+建议改动:
+
+1. 增加通用表格输出 schema:
+   - `title`
+   - `columns`
+   - `rows`
+   - `summary`
+   - `evidence`
+   - `warnings`
+
+2. 增加文件产物工具:
+   - `smqjh.artifact.excel.create`
+   - `smqjh.artifact.open`
+   - `smqjh.artifact.list`
+
+3. 月结功能拆成工具链:
+   - `smqjh.settlement.enterprise.list`
+   - `smqjh.settlement.export.inputs`
+   - `smqjh.settlement.reconcile`
+   - `smqjh.settlement.report.create`
+   - `smqjh.settlement.confirmation.mark`
+
+4. 商品价格对比工具继续保留,但迁移到 MCP:
+   - 系统商品价先查数据库。
+   - 外部价格优先查慢慢买和苏宁。
+   - 结果必须包含平台、商品名、总价、折算单价、规格匹配度、依据地址。
+   - 规格不一致时标记“需人工确认”。
+
+### 第三阶段:上线治理
+
+目标:支持多个客户端使用,降低对业务系统影响,并可审计。
+
+建议改动:
+
+1. MCP 从“每台客户端本机服务”逐步演进为“内网中心 MCP 服务”。
+   - 10 个客户端都各连 MySQL,短期可以接受。
+   - 但长期更建议集中 MCP,统一连接池、限流、审计和权限。
+
+2. 数据库只读连接池:
+   - 每个查询短连接也能用,但不利于限流。
+   - 中心 MCP 可以用 HikariCP 之类连接池。
+   - 限制最大连接数、查询超时、默认 LIMIT。
+
+3. 权限分级:
+   - 只读查询:自动执行。
+   - 导出文件:自动生成,但记录日志。
+   - 发券、充值、结算确认、修改订单:必须人工确认。
+
+4. 追踪和评测:
+   - 每次记录用户问题、选用工具、SQL、耗时、结果行数、失败原因。
+   - 建一批真实问题作为回归评测,例如:
+     - 当前百事可乐 500ml 的商品描述是什么?
+     - 某手机号下了多少单?
+     - 某订单物流状态是什么?
+     - 铜仁移动 2026 年 5 月月结报告生成。
+     - 某商品和慢慢买/苏宁做价格对比并导出 Excel。
+
+## 针对当前痛点的具体调整
+
+| 痛点 | 原因 | 调整方式 |
+| --- | --- | --- |
+| SQL 生成了但不执行 | 编排把 SQL 当成回复内容,没有进入工具执行 | `smart.query` 内部生成并执行 SQL |
+| 问“商品描述”白屏 | 前端或工具结果异常没有兜底 | 加 ErrorBoundary、IPC 异常兜底、结果 schema 校验 |
+| 问订单物流状态不会查 | 没有专门工具时编排不敢走通用数据库查询 | 通用业务事实统一走 `database.smart.query` |
+| 商品比价像固定规则 | 前端/本地任务承担了太多判断 | 迁移到 MCP,由 DeepSeek 决定搜索词和筛选口径 |
+| 表格显示不稳定 | 文本 Markdown 和表格混在一起 | 回答文本和 `tables` 分离渲染 |
+| 需要 Excel 但没有文件 | 文件不是一等产物 | 增加 artifact 工具,结果直接给文件入口 |
+| 多客户端怕影响数据库 | 每客户端直连无法统一治理 | 中期改中心 MCP,连接池和限流 |
+
+## 是否需要改成 Python + LangChain
+
+不建议现在为了“更智能”直接大换语言。
+
+原因:
+
+- 当前真正的问题不是 Java、TypeScript 或 Python,而是 Agent loop、工具 schema、schema 上下文、结果产物和评测没有完善。
+- Java MCP 很适合贴近现有 Spring/Java 业务系统,也方便后续做内网服务、权限和审计。
+- Electron + TypeScript 做桌面端合适,MCP 侧 Java 做业务工具也合适。
+
+可以考虑的折中:
+
+- 短期:继续 Java MCP + Electron,把 Agent loop 和工具体系补完整。
+- 中期:如果要做复杂多 Agent 编排、长期记忆、评测平台,可以单独增加 Python/LangGraph 服务,但不替换现有 MCP。
+- 长期:MCP 保持为业务工具网关,Agent runtime 可以是 Java、TypeScript 或 Python,按团队维护成本选。
+
+## 推荐下一步开发清单
+
+优先级 P0:
+
+1. 新增 `smqjh.database.smart.query`。
+2. 桌面端编排改成多步工具循环。
+3. 工具结果和表格结果分离,前端稳定渲染。
+4. 加 ErrorBoundary,避免单次异常白屏。
+5. 输出内容必须包含依据。
+
+优先级 P1:
+
+1. 商品比价迁移到 MCP。
+2. 增加 Excel artifact 工具。
+3. 增加 schema resource 或 schema 查询工具。
+4. 月结结算报告工具链落地。
+5. 增加工具调用日志和 trace 面板。
+
+优先级 P2:
+
+1. 中心 MCP 部署方案。
+2. 数据库连接池、限流、慢 SQL 告警。
+3. 人工确认和审批队列。
+4. 管理员问题评测集。
+
+## 最终目标体验
+
+管理员输入:
+
+> 当前百事可乐 500ml 的商品描述是什么?
+
+Agent 应该自动:
+
+1. 判断这是业务系统事实查询。
+2. 查 schema,定位商品表和 SKU 表。
+3. 生成只读 SQL。
+4. 通过 MCP 执行。
+5. 返回商品描述、商品状态、价格、规格、依据 SQL。
+
+管理员输入:
+
+> 帮我生成铜仁移动 2026 年 5 月月结报告。
+
+Agent 应该自动:
+
+1. 识别企业和月份。
+2. 调用导出接口或读取上传表。
+3. 读取订单、运费、员工积分表。
+4. 按规则计算商品总额、运费、积分抵扣、现金支付、差异。
+5. 标记需人工确认项。
+6. 生成 Excel。
+7. 给出下载入口和核对摘要。
+
+管理员输入:
+
+> 查一下这个订单的物流状态。
+
+Agent 应该自动:
+
+1. 从输入中提取订单号。
+2. 查询订单表、配送字段、快递单号。
+3. 如果需要,调用物流接口或提示暂无物流接口。
+4. 用表格给出订单状态、配送状态、快递公司、运单号、更新时间和依据。
+
+这才是我们要做的“智能体”,而不是让管理员根据按钮或固定任务项自己拼流程。

+ 363 - 0
docs/build_agent_brief_doc.py

@@ -0,0 +1,363 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from docx import Document
+from docx.enum.section import WD_SECTION_START
+from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
+from docx.enum.text import WD_ALIGN_PARAGRAPH
+from docx.oxml import OxmlElement
+from docx.oxml.ns import qn
+from docx.shared import Inches, Pt, RGBColor
+
+
+ROOT = Path(__file__).resolve().parents[1]
+OUT = ROOT / "docs" / "市民请集合智能助手Agent简版建设方案.docx"
+
+
+BLUE = "2E74B5"
+DARK_BLUE = "1F4D78"
+INK = "0B2545"
+MUTED = "667085"
+LIGHT_FILL = "F2F4F7"
+CALLOUT_FILL = "F4F6F9"
+BORDER = "D0D5DD"
+
+
+def set_cell_shading(cell, fill: str) -> None:
+    tc_pr = cell._tc.get_or_add_tcPr()
+    shd = tc_pr.find(qn("w:shd"))
+    if shd is None:
+        shd = OxmlElement("w:shd")
+        tc_pr.append(shd)
+    shd.set(qn("w:fill"), fill)
+
+
+def set_cell_margins(cell, top=80, bottom=80, start=120, end=120) -> None:
+    tc_pr = cell._tc.get_or_add_tcPr()
+    tc_mar = tc_pr.first_child_found_in("w:tcMar")
+    if tc_mar is None:
+        tc_mar = OxmlElement("w:tcMar")
+        tc_pr.append(tc_mar)
+    for edge, value in (("top", top), ("bottom", bottom), ("start", start), ("end", end)):
+        node = tc_mar.find(qn(f"w:{edge}"))
+        if node is None:
+            node = OxmlElement(f"w:{edge}")
+            tc_mar.append(node)
+        node.set(qn("w:w"), str(value))
+        node.set(qn("w:type"), "dxa")
+
+
+def set_table_width(table, widths_dxa: list[int]) -> None:
+    tbl_pr = table._tbl.tblPr
+    tbl_w = tbl_pr.find(qn("w:tblW"))
+    if tbl_w is None:
+        tbl_w = OxmlElement("w:tblW")
+        tbl_pr.append(tbl_w)
+    tbl_w.set(qn("w:w"), str(sum(widths_dxa)))
+    tbl_w.set(qn("w:type"), "dxa")
+    tbl_ind = tbl_pr.find(qn("w:tblInd"))
+    if tbl_ind is None:
+        tbl_ind = OxmlElement("w:tblInd")
+        tbl_pr.append(tbl_ind)
+    tbl_ind.set(qn("w:w"), "120")
+    tbl_ind.set(qn("w:type"), "dxa")
+
+    grid = table._tbl.tblGrid
+    if grid is None:
+        grid = OxmlElement("w:tblGrid")
+        table._tbl.insert(0, grid)
+    for child in list(grid):
+        grid.remove(child)
+    for width in widths_dxa:
+        col = OxmlElement("w:gridCol")
+        col.set(qn("w:w"), str(width))
+        grid.append(col)
+
+    for row in table.rows:
+        for cell, width in zip(row.cells, widths_dxa):
+            tc_pr = cell._tc.get_or_add_tcPr()
+            tc_w = tc_pr.find(qn("w:tcW"))
+            if tc_w is None:
+                tc_w = OxmlElement("w:tcW")
+                tc_pr.append(tc_w)
+            tc_w.set(qn("w:w"), str(width))
+            tc_w.set(qn("w:type"), "dxa")
+            cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
+            set_cell_margins(cell)
+
+
+def set_east_asia_font(run, font_name="Microsoft YaHei") -> None:
+    run.font.name = font_name
+    run._element.rPr.rFonts.set(qn("w:eastAsia"), font_name)
+
+
+def add_para(doc, text: str, style: str | None = None, bold_prefix: str | None = None):
+    p = doc.add_paragraph(style=style)
+    if bold_prefix and text.startswith(bold_prefix):
+        r = p.add_run(bold_prefix)
+        r.bold = True
+        set_east_asia_font(r)
+        tail = p.add_run(text[len(bold_prefix):])
+        set_east_asia_font(tail)
+    else:
+        r = p.add_run(text)
+        set_east_asia_font(r)
+    return p
+
+
+def add_bullet(doc, text: str):
+    p = doc.add_paragraph(style="List Bullet")
+    r = p.add_run(text)
+    set_east_asia_font(r)
+    return p
+
+
+def add_number(doc, text: str):
+    p = doc.add_paragraph(style="List Number")
+    r = p.add_run(text)
+    set_east_asia_font(r)
+    return p
+
+
+def add_heading(doc, text: str, level: int):
+    p = doc.add_heading(text, level=level)
+    for run in p.runs:
+        set_east_asia_font(run)
+    return p
+
+
+def add_callout(doc, title: str, body: str):
+    table = doc.add_table(rows=1, cols=1)
+    table.alignment = WD_TABLE_ALIGNMENT.LEFT
+    table.style = "Table Grid"
+    set_table_width(table, [9360])
+    cell = table.cell(0, 0)
+    set_cell_shading(cell, CALLOUT_FILL)
+    p = cell.paragraphs[0]
+    r = p.add_run(title)
+    r.bold = True
+    r.font.color.rgb = RGBColor.from_string(DARK_BLUE)
+    set_east_asia_font(r)
+    p2 = cell.add_paragraph()
+    r2 = p2.add_run(body)
+    set_east_asia_font(r2)
+    doc.add_paragraph()
+
+
+def add_table(doc, headers: list[str], rows: list[list[str]], widths_dxa: list[int]):
+    table = doc.add_table(rows=1, cols=len(headers))
+    table.alignment = WD_TABLE_ALIGNMENT.LEFT
+    table.style = "Table Grid"
+    set_table_width(table, widths_dxa)
+    hdr = table.rows[0]
+    for i, text in enumerate(headers):
+        cell = hdr.cells[i]
+        set_cell_shading(cell, LIGHT_FILL)
+        p = cell.paragraphs[0]
+        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        r = p.add_run(text)
+        r.bold = True
+        r.font.color.rgb = RGBColor.from_string(INK)
+        set_east_asia_font(r)
+    for row in rows:
+        cells = table.add_row().cells
+        for i, text in enumerate(row):
+            p = cells[i].paragraphs[0]
+            p.alignment = WD_ALIGN_PARAGRAPH.LEFT
+            r = p.add_run(text)
+            set_east_asia_font(r)
+    set_table_width(table, widths_dxa)
+    doc.add_paragraph()
+    return table
+
+
+def configure_styles(doc: Document) -> None:
+    section = doc.sections[0]
+    section.page_width = Inches(8.5)
+    section.page_height = Inches(11)
+    for margin in ("top_margin", "bottom_margin", "left_margin", "right_margin"):
+        setattr(section, margin, Inches(1))
+    section.header_distance = Inches(0.492)
+    section.footer_distance = Inches(0.492)
+
+    styles = doc.styles
+    normal = styles["Normal"]
+    normal.font.name = "Calibri"
+    normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
+    normal.font.size = Pt(11)
+    normal.paragraph_format.space_after = Pt(6)
+    normal.paragraph_format.line_spacing = 1.10
+
+    title = styles["Title"]
+    title.font.name = "Calibri"
+    title._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
+    title.font.size = Pt(22)
+    title.font.bold = True
+    title.font.color.rgb = RGBColor.from_string(INK)
+    title.paragraph_format.space_after = Pt(8)
+
+    subtitle = styles["Subtitle"]
+    subtitle.font.name = "Calibri"
+    subtitle._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
+    subtitle.font.size = Pt(11)
+    subtitle.font.color.rgb = RGBColor.from_string(MUTED)
+    subtitle.paragraph_format.space_after = Pt(14)
+
+    for name, size, color, before, after in [
+        ("Heading 1", 16, BLUE, 16, 8),
+        ("Heading 2", 13, BLUE, 12, 6),
+        ("Heading 3", 12, DARK_BLUE, 8, 4),
+    ]:
+        style = styles[name]
+        style.font.name = "Calibri"
+        style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
+        style.font.size = Pt(size)
+        style.font.bold = True
+        style.font.color.rgb = RGBColor.from_string(color)
+        style.paragraph_format.space_before = Pt(before)
+        style.paragraph_format.space_after = Pt(after)
+
+    for name in ("List Bullet", "List Number"):
+        style = styles[name]
+        style.font.name = "Calibri"
+        style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
+        style.font.size = Pt(11)
+        style.paragraph_format.space_after = Pt(8)
+        style.paragraph_format.line_spacing = 1.167
+
+    footer = section.footer.paragraphs[0]
+    footer.alignment = WD_ALIGN_PARAGRAPH.RIGHT
+    r = footer.add_run("市民请集合智能助手 Agent 简版建设方案")
+    r.font.size = Pt(9)
+    r.font.color.rgb = RGBColor.from_string(MUTED)
+    set_east_asia_font(r)
+
+
+def build_doc() -> None:
+    doc = Document()
+    configure_styles(doc)
+
+    p = doc.add_paragraph(style="Title")
+    r = p.add_run("市民请集合智能助手 Agent 简版建设方案")
+    set_east_asia_font(r)
+    p = doc.add_paragraph(style="Subtitle")
+    r = p.add_run("结合当前 smqjh-agent 项目、Agent 设计复盘方案与结算需求文档整理")
+    set_east_asia_font(r)
+    add_para(doc, "版本:讨论稿 v0.1    日期:2026-06-08    适用:内部产品/研发/运营沟通")
+
+    add_callout(
+        doc,
+        "核心判断",
+        "这个产品不应只是一个聊天框,而应逐步升级为“模型自主规划、MCP 统一工具、业务系统只读查询、结果表格化/文件化、关键动作人工确认”的内部业务 Agent。"
+    )
+
+    add_heading(doc, "1. 项目定位", 1)
+    add_para(doc, "市民请集合智能助手定位为面向后台管理员和运营人员的桌面端业务 Agent。第一版先解决“查数据、看依据、生成表格、辅助结算、记录人工确认事项”,后续再扩展到充值、消费履约、发券、对账、运维巡检等流程。")
+    add_bullet(doc, "不是通用聊天机器人:回答范围限定在 smqjh 项目业务和日常简单问答。")
+    add_bullet(doc, "不是简单任务按钮集合:模型应根据用户目标判断是否需要查询数据库、调用接口、生成文件或进入人工确认。")
+    add_bullet(doc, "不是自动写入业务系统:涉及结算确认、充值导入、发券、扣减等动作必须进入人工确认。")
+
+    add_heading(doc, "2. 当前项目已具备的底座", 1)
+    add_table(
+        doc,
+        ["能力", "当前状态", "下一步意义"],
+        [
+            ["桌面端界面", "Electron 已搭建,包含对话、任务、配置、日志。", "继续作为管理员统一入口,重点优化过程展示和文件入口。"],
+            ["MCP 服务", "Java MCP 已接管 DeepSeek、数据库配置和工具注册。", "后续把更多业务工具迁到 MCP,桌面端少做业务判断。"],
+            ["只读数据库", "已支持 SELECT 校验、白名单表、默认 LIMIT、超时和只读事务。", "业务事实查询默认由 Agent 自动完成,不让用户自己执行 SQL。"],
+            ["智能 SQL 查询", "已新增 smart query,由 DeepSeek 生成 SQL,MCP 安全执行并返回表格。", "解决“识别到了但不执行”的关键问题。"],
+            ["Excel 产物", "已支持通用表格导出和价格对比导出。", "用户说“做成表格/导出 Excel”时可以直接产出文件。"],
+            ["结算雏形", "已建立月结企业清单和企业月结处理计划工具。", "作为第一个明确业务场景继续深化。"],
+        ],
+        [1700, 3650, 4010],
+    )
+
+    add_heading(doc, "3. 推荐目标架构", 1)
+    add_number(doc, "桌面端:只负责登录、对话、结果展示、文件打开、人工确认和日志查看。")
+    add_number(doc, "Agent Runtime:负责多步循环,按“规划 -> 调工具 -> 观察 -> 再规划 -> 总结”的方式完成任务。")
+    add_number(doc, "Java MCP:统一暴露业务工具、schema、业务口径、DeepSeek 调用、数据库只读查询和后续文件产物工具。")
+    add_number(doc, "业务系统/数据库:提供只读查询和导出接口;写入类动作必须经过人工确认。")
+    add_number(doc, "审计与评测:记录用户问题、工具调用、SQL、结果行数、耗时、失败原因,形成可回放链路。")
+
+    add_heading(doc, "4. 一期建议功能清单", 1)
+    add_table(
+        doc,
+        ["模块", "一期功能", "验收口径"],
+        [
+            ["对话工作台", "自然语言输入、多轮上下文、表格结果展示。", "用户问商品、订单、会员、物流时,Agent 能自动查并直接回答。"],
+            ["工具调用", "MCP 工具规划、智能 SQL、商品查询、订单统计、月结计划。", "工具调用过程有日志,有失败提示,不再让用户自己查后台。"],
+            ["文件产物", "把任意表格结果导出 Excel,后续扩展为结算报告。", "上一轮表格可直接导出并打开。"],
+            ["人工确认", "差异、金额、写入、发券、充值导入统一标记人工确认。", "Agent 只给建议和待确认项,不擅自改业务系统。"],
+            ["配置与权限", "DeepSeek、数据库、MCP 配置集中在 MCP;顶级 admin 不强制租户 ID。", "配置可持久化,重启后不反复输入。"],
+            ["日志追踪", "记录工具、SQL、导出文件、异常和耗时。", "出现错误时能定位是模型、SQL、数据库还是接口问题。"],
+        ],
+        [1700, 4300, 3860],
+    )
+
+    add_heading(doc, "5. 第一个业务需求:结算、充值、消费", 1)
+    add_para(doc, "结算需求来自你提供的《结算.docx》,目前可拆成三个业务场景:")
+    add_heading(doc, "5.1 结算流程", 2)
+    add_number(doc, "平台生成结算报告,包含产品订单/商品费用、运费、积分抵扣、现金支付等数据。")
+    add_number(doc, "补齐海博编码、商品编码、SKU 编码等业务字段。")
+    add_number(doc, "价格浮动或金额差异时只标记“需人工确认”,不自动修改系统。")
+    add_number(doc, "发送客户核对,之后打印盖章、开具电子发票、等待客户付款。")
+    add_heading(doc, "5.2 充值流程", 2)
+    add_number(doc, "从企业获取客户充值信息表。")
+    add_number(doc, "Agent 辅助排错、匹配企业/员工/手机号/金额。")
+    add_number(doc, "人工确认后导入业务系统。")
+    add_number(doc, "生成上账成功凭据和上账记录。")
+    add_heading(doc, "5.3 消费履约流程", 2)
+    add_number(doc, "C 端用户即时发货配送。")
+    add_number(doc, "企业用户统一发货,非及时性发货需要导出订单并按仓库分配。")
+    add_number(doc, "联系供应商配合入库,再选择闪送、物流快递等配送方式。")
+
+    add_heading(doc, "6. 结算 Agent 功能拆解", 1)
+    add_table(
+        doc,
+        ["子能力", "Agent 需要做的事", "输出产物", "人工确认"],
+        [
+            ["月结企业识别", "识别铜仁移动、招商银行贵阳分行、中数未来等月结企业,匹配 channelId/channelNo。", "月结企业清单。", "企业范围变化时确认。"],
+            ["月份选择", "由管理员选择月份,自动计算开始/结束时间。", "结算月份和筛选条件。", "跨月、补单时确认。"],
+            ["数据导出", "调用员工列表、订单表、商品订单、运费账单、对账汇总模板等接口。", "原始导出表。", "接口失败或字段缺失时确认。"],
+            ["数据补齐", "从商品表补齐商品编码、SKU、海博编码等。", "补齐后的订单/商品明细。", "商品匹配不唯一时确认。"],
+            ["汇总核对", "计算商品总额、运费、积分抵扣、现金支付、差异。", "结算汇总表和蓝色核对区。", "任意差异非 0 时确认。"],
+            ["报告生成", "生成客户可核对的结算报告。", "Excel/Word/PDF 结算报告。", "发送客户前确认。"],
+            ["状态记录", "记录待客户核对、待盖章、待开票、待付款、已完成。", "结算状态和提醒。", "状态变更确认。"],
+        ],
+        [1450, 3350, 2300, 2270],
+    )
+
+    add_heading(doc, "7. 落地路线", 1)
+    add_table(
+        doc,
+        ["阶段", "目标", "交付内容"],
+        [
+            ["阶段 1:Agent 底座稳定", "让业务查询不再依赖固定任务匹配。", "多步 Agent loop、smart query、schema 工具、通用表格导出。"],
+            ["阶段 2:结算场景跑通", "先完成铜仁移动等月结企业的报告生成闭环。", "导出接口调用、Excel 合并、汇总计算、差异标记、人工确认清单。"],
+            ["阶段 3:充值/消费扩展", "把充值导入、消费履约、配送状态纳入 Agent。", "充值校验、分仓订单、供应商/物流状态查询。"],
+            ["阶段 4:治理与上线", "支持多人使用,同时可审计、可限流、可回归测试。", "中心 MCP、连接池、权限、trace 面板、评测集。"],
+        ],
+        [1500, 3300, 4560],
+    )
+
+    add_heading(doc, "8. 后续需要确认的信息", 1)
+    add_bullet(doc, "结算表最终标准模板:客户侧希望看到的列、盖章版格式、开票字段。")
+    add_bullet(doc, "各导出接口真实参数:月份、企业渠道、订单状态、是否已付款、是否排除逻辑删除。")
+    add_bullet(doc, "运费账单和订单表的稳定关联字段:订单号、第三方订单号、运单号是否一一对应。")
+    add_bullet(doc, "商品编码补齐口径:海博编码、商品编码、SKU 编码分别来自哪些表和字段。")
+    add_bullet(doc, "人工确认流程入口:是在 Agent 对话里确认,还是推到业务系统待办/审批。")
+
+    add_callout(
+        doc,
+        "建议",
+        "下一步优先把“铜仁移动 2026 年 5 月结算”作为第一条端到端样例做穿:导出原始表、补齐商品字段、计算汇总、标记差异、生成 Excel 报告。等这条链路稳定,再复制到招商银行贵阳分行和中数未来。"
+    )
+
+    OUT.parent.mkdir(parents=True, exist_ok=True)
+    doc.save(OUT)
+    print(OUT)
+
+
+if __name__ == "__main__":
+    build_doc()

BIN
docs/市民请集合智能助手Agent简版建设方案.docx


+ 29 - 0
scripts/start-mcp.ps1

@@ -0,0 +1,29 @@
+$ErrorActionPreference = "Stop"
+
+$root = Split-Path -Parent $PSScriptRoot
+$mcpDir = Join-Path $root "smqjh-mcp-server"
+$runScript = Join-Path $mcpDir "run.ps1"
+$logsDir = Join-Path $root "logs"
+
+if (-not (Test-Path $runScript)) {
+  throw "MCP run script not found: $runScript"
+}
+
+New-Item -ItemType Directory -Force -Path $logsDir | Out-Null
+$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
+$stdout = Join-Path $logsDir "mcp-$stamp.out.log"
+$stderr = Join-Path $logsDir "mcp-$stamp.err.log"
+
+$command = "& '$runScript'"
+
+Start-Process `
+  -FilePath "powershell" `
+  -ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", $command) `
+  -WorkingDirectory $mcpDir `
+  -WindowStyle Hidden `
+  -RedirectStandardOutput $stdout `
+  -RedirectStandardError $stderr
+
+Write-Host "MCP start requested."
+Write-Host "stdout: $stdout"
+Write-Host "stderr: $stderr"

+ 16 - 0
scripts/start-test-web.ps1

@@ -0,0 +1,16 @@
+$ErrorActionPreference = "Stop"
+
+$root = Split-Path -Parent $PSScriptRoot
+$mcpScript = Join-Path $root "scripts\start-mcp.ps1"
+$webScript = Join-Path $root "scripts\start-web-agent.ps1"
+
+& $mcpScript
+Start-Sleep -Seconds 2
+& $webScript
+
+$port = if ($env:WEB_AGENT_PORT) { $env:WEB_AGENT_PORT } else { "5188" }
+$url = "http://127.0.0.1:$port"
+Start-Sleep -Seconds 2
+Start-Process $url
+
+Write-Host "Opened: $url"

+ 45 - 0
scripts/start-web-agent.ps1

@@ -0,0 +1,45 @@
+$ErrorActionPreference = "Stop"
+
+$root = Split-Path -Parent $PSScriptRoot
+$runtimeDir = Join-Path $root "smqjh-agent-runtime"
+$logsDir = Join-Path $root "logs"
+
+if (-not (Test-Path $runtimeDir)) {
+  throw "Runtime directory not found: $runtimeDir"
+}
+
+New-Item -ItemType Directory -Force -Path $logsDir | Out-Null
+
+if (-not $env:SMQJH_MCP_URL) {
+  $env:SMQJH_MCP_URL = "http://127.0.0.1:8765/mcp"
+}
+if (-not $env:WEB_AGENT_PORT) {
+  $env:WEB_AGENT_PORT = "5188"
+}
+
+if (-not (Test-Path (Join-Path $runtimeDir "node_modules"))) {
+  Push-Location $runtimeDir
+  try {
+    npm install
+  } finally {
+    Pop-Location
+  }
+}
+
+$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
+$stdout = Join-Path $logsDir "web-agent-$stamp.out.log"
+$stderr = Join-Path $logsDir "web-agent-$stamp.err.log"
+
+Start-Process `
+  -FilePath "powershell" `
+  -ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "npm run dev") `
+  -WorkingDirectory $runtimeDir `
+  -WindowStyle Hidden `
+  -RedirectStandardOutput $stdout `
+  -RedirectStandardError $stderr
+
+Write-Host "Web Agent start requested."
+Write-Host "URL: http://127.0.0.1:$env:WEB_AGENT_PORT"
+Write-Host "MCP: $env:SMQJH_MCP_URL"
+Write-Host "stdout: $stdout"
+Write-Host "stderr: $stderr"

+ 25 - 0
smqjh-admin-agent/electron-builder.installer.json

@@ -0,0 +1,25 @@
+{
+  "appId": "com.smqjh.admin.agent",
+  "productName": "市民请集合智能助手",
+  "directories": {
+    "output": "release"
+  },
+  "files": [
+    "dist/**/*",
+    "build/icon.ico",
+    "build/icon.png",
+    "package.json"
+  ],
+  "win": {
+    "icon": "build/icon.ico",
+    "target": [
+      "nsis"
+    ],
+    "artifactName": "${productName}-${version}-${arch}-installer.${ext}",
+    "signAndEditExecutable": false
+  },
+  "nsis": {
+    "oneClick": false,
+    "allowToChangeInstallationDirectory": true
+  }
+}

+ 79 - 24
smqjh-admin-agent/src/main/assistant/assistantOrchestrator.ts

@@ -26,9 +26,56 @@ export class AssistantOrchestrator {
 
   async run(request: AssistantRunRequest): Promise<AssistantRunResponse> {
     const tools = await this.loadToolDescriptors();
-    const plan = await this.planViaMcp(request, tools);
+    const byName = new Map(tools.map((tool) => [tool.name, tool]));
+    const observations: AssistantToolObservation[] = [];
+    const seenCalls = new Set<string>();
+    const maxSteps = 6;
+
+    for (let step = 0; step < maxSteps; step += 1) {
+      const plan = await this.planViaMcp(request, tools, observations);
+
+      if (plan.mode !== "tool" || plan.toolCalls.length === 0) {
+        break;
+      }
+
+      const executableCalls = plan.toolCalls.filter((call) => {
+        const signature = toolCallSignature(call.name, call.arguments);
+        if (seenCalls.has(signature)) {
+          this.deps.logStore.add("warn", `智能体跳过重复工具调用:${call.name}`);
+          return false;
+        }
+        seenCalls.add(signature);
+        return true;
+      });
+
+      if (executableCalls.length === 0) {
+        break;
+      }
+
+      this.deps.logStore.add("info", "Agent loop 执行工具步骤", {
+        step: step + 1,
+        tools: executableCalls.map((call) => call.name).join(", ")
+      });
+
+      for (const call of executableCalls) {
+        const resolvedName = resolveToolName(call.name, byName);
+        const descriptor = resolvedName ? byName.get(resolvedName) : undefined;
+        if (!descriptor) {
+          observations.push({
+            name: call.name,
+            source: "local",
+            arguments: call.arguments,
+            ok: false,
+            error: "DeepSeek 选择了未注册工具"
+          });
+          continue;
+        }
+
+        observations.push(await this.executeTool(descriptor, call.arguments));
+      }
+    }
 
-    if (plan.mode !== "tool" || plan.toolCalls.length === 0) {
+    if (observations.length === 0) {
       const result = await this.chatViaMcp(request);
       return {
         content: result.content,
@@ -38,26 +85,6 @@ export class AssistantOrchestrator {
       };
     }
 
-    const byName = new Map(tools.map((tool) => [tool.name, tool]));
-    const observations: AssistantToolObservation[] = [];
-
-    for (const call of plan.toolCalls) {
-      const resolvedName = resolveToolName(call.name, byName);
-      const descriptor = resolvedName ? byName.get(resolvedName) : undefined;
-      if (!descriptor) {
-        observations.push({
-          name: call.name,
-          source: "local",
-          arguments: call.arguments,
-          ok: false,
-          error: "DeepSeek 选择了未注册工具"
-        });
-        continue;
-      }
-
-      observations.push(await this.executeTool(descriptor, call.arguments));
-    }
-
     const toolCalls: AssistantToolCallRecord[] = observations.map((item) => ({
       name: item.name,
       source: item.source,
@@ -132,10 +159,15 @@ export class AssistantOrchestrator {
     return tools;
   }
 
-  private async planViaMcp(request: AssistantRunRequest, tools: AssistantToolDescriptor[]): Promise<AssistantToolPlan> {
+  private async planViaMcp(
+    request: AssistantRunRequest,
+    tools: AssistantToolDescriptor[],
+    observations: AssistantToolObservation[]
+  ): Promise<AssistantToolPlan> {
     const result = await this.deps.mcpClient.callTool("smqjh.ai.tool.plan", {
       request,
-      tools
+      tools,
+      observations
     });
     const record = asRecord(result.structuredContent);
     const rawCalls = Array.isArray(record.toolCalls) ? record.toolCalls : [];
@@ -264,6 +296,24 @@ function toStringParams(args: Record<string, unknown>): Record<string, string> {
   );
 }
 
+function toolCallSignature(name: string, args: Record<string, unknown>): string {
+  return `${name}:${stableStringify(args)}`;
+}
+
+function stableStringify(value: unknown): string {
+  if (!value || typeof value !== "object") {
+    return JSON.stringify(value);
+  }
+  if (Array.isArray(value)) {
+    return `[${value.map(stableStringify).join(",")}]`;
+  }
+  const record = value as Record<string, unknown>;
+  return `{${Object.keys(record)
+    .sort()
+    .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
+    .join(",")}}`;
+}
+
 function repairToolArguments(name: string, args: Record<string, unknown>, message: string): Record<string, unknown> {
   if (!isProductTool(name)) {
     return args;
@@ -425,8 +475,13 @@ function buildTableTitle(baseTitle: string, record: Record<string, unknown>): st
 function toolTitle(name: string): string {
   const titles: Record<string, string> = {
     "smqjh.database.readonly.query": "数据库只读查询结果",
+    "smqjh.database.smart.query": "智能数据库查询结果",
+    "smqjh.schema.search": "业务表搜索结果",
+    "smqjh.schema.getTable": "业务表说明",
+    "smqjh.schema.businessRules": "业务规则",
     "smqjh.order.count.query": "订单统计结果",
     "smqjh.product.lookup.summary": "商品资料查询结果",
+    "smqjh.settlement.enterprise.list": "月结企业清单",
     "smqjh.settlement.monthly.plan": "企业月结计划",
     "product.price.compare": "商品价格对比"
   };

+ 0 - 3
smqjh-admin-agent/src/main/cloud/smqjhClient.ts

@@ -296,8 +296,5 @@ export class SmqjhClient {
 }
 
 function tenantCodeForUser(username: string | undefined, tenantCode: string | undefined): string {
-  if ((username ?? "").trim().toLowerCase() === "admin") {
-    return "";
-  }
   return (tenantCode ?? "").trim();
 }

+ 6 - 2
smqjh-admin-agent/src/main/config/appConfig.ts

@@ -25,11 +25,11 @@ export const DEFAULT_CONFIG: AppConfig = {
   },
   mcp: {
     enabled: true,
-    host: "127.0.0.1",
+    host: "192.168.1.20",
     port: 8765,
     path: "/mcp",
     authToken: "",
-    allowExternal: false
+    allowExternal: true
   },
   auth: {
     tokenPath: "/smqjh-auth/oauth2/token",
@@ -71,6 +71,10 @@ export class ConfigStore {
     try {
       const raw = await fs.readFile(this.filePath(), "utf8");
       this.config = mergeConfig(DEFAULT_CONFIG, JSON.parse(raw) as Partial<AppConfig>);
+      if (!this.config.tenantCode.trim()) {
+        this.config.tenantCode = DEFAULT_CONFIG.tenantCode;
+        await this.save(this.config);
+      }
     } catch (error) {
       this.config = DEFAULT_CONFIG;
       await this.save(this.config);

+ 108 - 0
smqjh-admin-agent/src/main/exports/tableExport.ts

@@ -0,0 +1,108 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import * as XLSX from "xlsx";
+import type { AssistantResultTable, TableExportRequest, TableExportResult } from "../../shared/types";
+
+export async function exportTablesWorkbook(request: TableExportRequest, outputDir: string): Promise<TableExportResult> {
+  const tables = request.tables.map(normalizeTable).filter((table) => table.columns.length > 0);
+  if (tables.length === 0) {
+    throw new Error("没有可导出的表格数据");
+  }
+
+  await mkdir(outputDir, { recursive: true });
+  const createdAt = new Date();
+  const fileName = `${sanitizeFileName(request.title || tables[0].title || "智能体查询结果")}_${formatTimestamp(createdAt)}.xlsx`;
+  const filePath = path.join(outputDir, fileName);
+
+  const workbook = XLSX.utils.book_new();
+  let rowCount = 0;
+  for (const [index, table] of tables.entries()) {
+    const data = [
+      table.columns.map(safeExcelText),
+      ...table.rows.map((row) => table.columns.map((column) => safeExcelText(formatCell(row[column]))))
+    ];
+    rowCount += table.rows.length;
+    const sheet = XLSX.utils.aoa_to_sheet(data);
+    sheet["!cols"] = table.columns.map((column) => ({ wch: Math.min(Math.max(column.length + 8, 14), 42) }));
+    if (data.length > 0) {
+      sheet["!autofilter"] = { ref: XLSX.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: data.length - 1, c: table.columns.length - 1 } }) };
+    }
+    XLSX.utils.book_append_sheet(workbook, sheet, sanitizeSheetName(table.title || `表格${index + 1}`, index));
+  }
+
+  const metaSheet = XLSX.utils.aoa_to_sheet([
+    ["导出标题", request.title || tables[0].title || "智能体查询结果"],
+    ["导出时间", createdAt.toLocaleString("zh-CN", { hour12: false })],
+    ["表格数量", tables.length],
+    ["数据行数", rowCount],
+    ["说明", "本文件由市民请集合智能助手根据 MCP 工具结果生成;涉及 SQL、接口或外部页面的依据请以对话中的依据列为准。"]
+  ]);
+  metaSheet["!cols"] = [{ wch: 16 }, { wch: 96 }];
+  XLSX.utils.book_append_sheet(workbook, metaSheet, "说明");
+
+  const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
+  await writeFile(filePath, buffer);
+
+  return {
+    filePath,
+    fileName,
+    tableCount: tables.length,
+    rowCount,
+    createdAt: createdAt.toISOString()
+  };
+}
+
+function normalizeTable(table: AssistantResultTable): AssistantResultTable {
+  const rows = Array.isArray(table.rows) ? table.rows : [];
+  const inferredColumns = rows.length > 0 ? Array.from(new Set(rows.flatMap((row) => Object.keys(row)))) : [];
+  return {
+    title: String(table.title || "智能体查询结果"),
+    columns: table.columns?.length ? table.columns.map(String) : inferredColumns,
+    rows
+  };
+}
+
+function formatCell(value: unknown): string {
+  if (value === null || value === undefined) {
+    return "";
+  }
+  if (value instanceof Date) {
+    return value.toLocaleString("zh-CN", { hour12: false });
+  }
+  if (typeof value === "object") {
+    return JSON.stringify(value);
+  }
+  return String(value);
+}
+
+function sanitizeFileName(value: string): string {
+  const cleaned = value
+    .replace(/[<>:"/\\|?*\x00-\x1F]/g, " ")
+    .replace(/\s+/g, " ")
+    .trim()
+    .slice(0, 80);
+  return cleaned || "智能体查询结果";
+}
+
+function sanitizeSheetName(value: string, index: number): string {
+  const cleaned = value.replace(/[\\/?*\[\]:]/g, " ").replace(/\s+/g, " ").trim().slice(0, 25);
+  return cleaned || `表格${index + 1}`;
+}
+
+function formatTimestamp(value: Date): string {
+  const pad = (item: number) => String(item).padStart(2, "0");
+  return [
+    value.getFullYear(),
+    pad(value.getMonth() + 1),
+    pad(value.getDate()),
+    "_",
+    pad(value.getHours()),
+    pad(value.getMinutes()),
+    pad(value.getSeconds())
+  ].join("");
+}
+
+function safeExcelText(value: string): string {
+  const text = String(value ?? "");
+  return /^[=+\-@]/.test(text) ? `'${text}` : text;
+}

+ 83 - 6
smqjh-admin-agent/src/main/ipc/registerIpc.ts

@@ -2,11 +2,14 @@ import { app, ipcMain, shell } from "electron";
 import path from "node:path";
 import type {
   AssistantChatRequest,
+  AssistantChatResponse,
   AssistantIntentRequest,
+  AssistantIntentResponse,
   AssistantRunRequest,
   DashboardSnapshot,
   LoginRequest,
   PriceComparisonExportRequest,
+  TableExportRequest,
   TaskRunRequest
 } from "../../shared/types";
 import type { AgentRunner } from "../agent/runner";
@@ -16,6 +19,7 @@ import type { SessionState } from "../cloud/session";
 import type { ConfigStore } from "../config/appConfig";
 import type { DeepSeekClient } from "../deepseek/deepSeekClient";
 import { exportPriceComparisonWorkbook } from "../exports/priceComparisonExport";
+import { exportTablesWorkbook } from "../exports/tableExport";
 import type { LogStore } from "../agent/logStore";
 import type { AssistantOrchestrator } from "../assistant/assistantOrchestrator";
 import type { McpClient } from "../mcp/mcpClient";
@@ -92,24 +96,61 @@ export function registerIpc(deps: {
 
   ipcMain.handle("assistant:chat", async (_event, request: AssistantChatRequest) => {
     try {
-      const result = await deps.deepSeekClient.chat(request);
-      deps.logStore.add("info", "DeepSeek 对话完成", { model: result.model });
+      const mcpResult = await deps.mcpClient.callTool("smqjh.ai.chat", {
+        request: {
+          ...request,
+          message: "",
+          history: request.messages
+        }
+      });
+      const result = asRecord(mcpResult.structuredContent) as unknown as AssistantChatResponse;
+      deps.logStore.add("info", "MCP DeepSeek 对话完成", { model: result.model });
       return result;
     } catch (error) {
       const message = error instanceof Error ? error.message : String(error);
-      deps.logStore.add("error", `DeepSeek 对话失败:${message}`);
+      deps.logStore.add("error", `MCP DeepSeek 对话失败:${message}`);
       throw new Error(message);
     }
   });
 
   ipcMain.handle("assistant:intent", async (_event, request: AssistantIntentRequest) => {
     try {
-      const result = await deps.deepSeekClient.extractIntent(request);
-      deps.logStore.add("info", "DeepSeek 意图识别完成", { model: result.model, taskId: result.taskId, confidence: result.confidence });
+      const mcpResult = await deps.mcpClient.callTool("smqjh.ai.tool.plan", {
+        request: {
+          message: request.message,
+          history: request.history,
+          authenticated: request.authenticated,
+          username: request.username,
+          environmentName: request.environmentName,
+          baseUrl: request.baseUrl
+        },
+        tools: request.availableTasks.map((task) => ({
+          name: task.id,
+          source: "local",
+          title: task.title,
+          description: task.description,
+          inputSchema: {
+            type: "object",
+            properties: {},
+            additionalProperties: true
+          }
+        }))
+      });
+      const record = asRecord(mcpResult.structuredContent);
+      const firstCall = Array.isArray(record.toolCalls) ? asRecord(record.toolCalls[0]) : {};
+      const taskId = typeof firstCall.name === "string" ? normalizeMcpToolName(firstCall.name) : undefined;
+      const result: AssistantIntentResponse = {
+        taskId,
+        confidence: taskId ? 0.75 : 0,
+        params: stringifyParams(asRecord(firstCall.arguments)),
+        reason: typeof record.reason === "string" ? record.reason : undefined,
+        model: typeof record.model === "string" ? record.model : "mcp-deepseek"
+      };
+      deps.logStore.add("info", "MCP DeepSeek 意图识别完成", { model: result.model, taskId: result.taskId, confidence: result.confidence });
       return result;
     } catch (error) {
       const message = error instanceof Error ? error.message : String(error);
-      deps.logStore.add("error", `DeepSeek 意图识别失败:${message}`);
+      deps.logStore.add("error", `MCP DeepSeek 意图识别失败:${message}`);
       throw new Error(message);
     }
   });
@@ -145,6 +186,22 @@ export function registerIpc(deps: {
     }
   });
 
+  ipcMain.handle("exports:tables", async (_event, request: TableExportRequest) => {
+    try {
+      const result = await exportTablesWorkbook(request, app.getPath("downloads"));
+      deps.logStore.add("success", "智能体表格 Excel 已导出", {
+        fileName: result.fileName,
+        tableCount: result.tableCount,
+        rowCount: result.rowCount
+      });
+      return result;
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      deps.logStore.add("error", `智能体表格 Excel 导出失败:${message}`);
+      throw new Error(message);
+    }
+  });
+
   ipcMain.handle("exports:open-file", async (_event, filePath: string) => {
     const exportPath = assertExportPath(filePath);
     const message = await shell.openPath(exportPath);
@@ -168,3 +225,23 @@ function assertExportPath(filePath: string): string {
   }
   return resolved;
 }
+
+function asRecord(value: unknown): Record<string, unknown> {
+  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
+}
+
+function stringifyParams(params: Record<string, unknown>): Record<string, string> {
+  return Object.fromEntries(
+    Object.entries(params).map(([key, value]) => [key, value === undefined || value === null ? "" : String(value)])
+  );
+}
+
+function normalizeMcpToolName(name: string): string {
+  if (name === "smqjh.product.price.compare") {
+    return "product.price.compare";
+  }
+  if (name.startsWith("smqjh.")) {
+    return name.slice("smqjh.".length);
+  }
+  return name;
+}

+ 1 - 0
smqjh-admin-agent/src/main/preload.ts

@@ -19,6 +19,7 @@ const bridge: AgentBridge = {
   extractIntent: (request) => ipcRenderer.invoke("assistant:intent", request),
   runAssistant: (request) => ipcRenderer.invoke("assistant:run", request),
   exportPriceComparison: (request) => ipcRenderer.invoke("exports:price-comparison", request),
+  exportTables: (request) => ipcRenderer.invoke("exports:tables", request),
   openExportedFile: (filePath) => ipcRenderer.invoke("exports:open-file", filePath),
   showExportedFile: (filePath) => ipcRenderer.invoke("exports:show-file", filePath),
   onLog: (callback: (entry: LogEntry) => void) => {

+ 71 - 1
smqjh-admin-agent/src/renderer/App.tsx

@@ -30,6 +30,7 @@ import type {
   PriceComparisonExportResult,
   PriceComparisonRow,
   SessionStatus,
+  TableExportResult,
   TaskRunResult
 } from "../shared/types";
 import logoUrl from "./assets/logo.png";
@@ -65,6 +66,7 @@ interface ChatAttachment {
   fileName: string;
   filePath: string;
   rowCount: number;
+  tableCount?: number;
 }
 
 interface Feedback {
@@ -296,6 +298,7 @@ export function App() {
     setChatBusy(true);
 
     const rowsForExport = isComparisonExportIntent(text) ? getLatestComparisonRows(messages) ?? getComparisonRows(lastResult?.data) : undefined;
+    const tablesForExport = isComparisonExportIntent(text) ? getLatestMcpTables(messages) : undefined;
     if (rowsForExport) {
       const actionMessage: ChatMessage = {
         id: crypto.randomUUID(),
@@ -343,6 +346,54 @@ export function App() {
       return;
     }
 
+    if (tablesForExport) {
+      const actionMessage: ChatMessage = {
+        id: crypto.randomUUID(),
+        role: "assistant",
+        text: "收到,我正在把上一轮表格结果整理成 Excel 文件。"
+      };
+      setMessages((current) => [...current, userMessage, actionMessage]);
+
+      try {
+        const exported = await window.smqjhAgent.exportTables({
+          title: buildTableExportTitle(tablesForExport),
+          tables: tablesForExport
+        });
+        setMessages((current) =>
+          current.map((message) =>
+            message.id === actionMessage.id
+              ? {
+                  ...message,
+                  text: formatTableExportResult(exported),
+                  attachment: {
+                    kind: "excel",
+                    fileName: exported.fileName,
+                    filePath: exported.filePath,
+                    rowCount: exported.rowCount,
+                    tableCount: exported.tableCount
+                  }
+                }
+              : message
+          )
+        );
+      } catch (error) {
+        const message = friendlyError(error);
+        setMessages((current) =>
+          current.map((item) =>
+            item.id === actionMessage.id
+              ? {
+                  ...item,
+                  text: `Excel 导出失败:${message || "写入文件失败"}。上一轮表格数据还在当前会话里,可以稍后重试导出。`
+                }
+              : item
+          )
+        );
+      } finally {
+        setChatBusy(false);
+      }
+      return;
+    }
+
     const latestOrderStats = isOrderStatsTableIntent(text) ? getLatestOrderStats(messages) : undefined;
     if (latestOrderStats) {
       setMessages((current) => [
@@ -1400,15 +1451,34 @@ function getLatestComparisonRows(messages: ChatMessage[]): PriceComparisonRow[]
   return undefined;
 }
 
+function getLatestMcpTables(messages: ChatMessage[]): AssistantResultTable[] | undefined {
+  for (let index = messages.length - 1; index >= 0; index -= 1) {
+    const tables = messages[index].mcpTables?.filter((table) => table.columns.length > 0);
+    if (tables?.length) {
+      return tables;
+    }
+  }
+  return undefined;
+}
+
 function buildComparisonExportTitle(rows: PriceComparisonRow[]): string {
   const systemRow = rows.find((row) => row.platform === "smqjh系统") ?? rows[0];
   return `商品价格对比_${systemRow?.productName || "导出"}`;
 }
 
+function buildTableExportTitle(tables: AssistantResultTable[]): string {
+  const title = tables[0]?.title?.trim();
+  return title ? `智能体查询结果_${title}` : "智能体查询结果";
+}
+
 function formatExportResult(result: PriceComparisonExportResult): string {
   return `已经按上一轮价格对比结果生成 Excel:${result.fileName}\n\n共导出 ${result.rowCount} 行数据,文件已保存到本机下载目录。`;
 }
 
+function formatTableExportResult(result: TableExportResult): string {
+  return `已经把上一轮表格结果生成 Excel:${result.fileName}\n\n共导出 ${result.tableCount} 个表、${result.rowCount} 行数据,文件已保存到本机下载目录。`;
+}
+
 function NavButton(props: { active: boolean; icon: JSX.Element; label: string; onClick: () => void }) {
   return (
     <button className={props.active ? "nav-button active" : "nav-button"} type="button" aria-pressed={props.active} onClick={props.onClick}>
@@ -1963,7 +2033,7 @@ function AttachmentCard({ attachment }: { attachment: ChatAttachment }) {
         <FileSpreadsheet size={20} />
         <div>
           <strong>{attachment.fileName}</strong>
-          <span>{attachment.rowCount} 行数据</span>
+          <span>{attachment.tableCount ? `${attachment.tableCount} 个表,` : ""}{attachment.rowCount} 行数据</span>
         </div>
       </div>
       <div className="attachment-actions">

+ 3 - 0
smqjh-admin-agent/src/shared/bridge.ts

@@ -15,6 +15,8 @@ import type {
   PriceComparisonExportRequest,
   PriceComparisonExportResult,
   SessionStatus,
+  TableExportRequest,
+  TableExportResult,
   TaskRunRequest,
   TaskRunResult
 } from "./types";
@@ -36,6 +38,7 @@ export interface AgentBridge {
   extractIntent(request: AssistantIntentRequest): Promise<AssistantIntentResponse>;
   runAssistant(request: AssistantRunRequest): Promise<AssistantRunResponse>;
   exportPriceComparison(request: PriceComparisonExportRequest): Promise<PriceComparisonExportResult>;
+  exportTables(request: TableExportRequest): Promise<TableExportResult>;
   openExportedFile(filePath: string): Promise<void>;
   showExportedFile(filePath: string): Promise<void>;
   onLog(callback: (entry: LogEntry) => void): () => void;

+ 13 - 0
smqjh-admin-agent/src/shared/types.ts

@@ -213,6 +213,19 @@ export interface PriceComparisonExportResult {
   createdAt: string;
 }
 
+export interface TableExportRequest {
+  title?: string;
+  tables: AssistantResultTable[];
+}
+
+export interface TableExportResult {
+  filePath: string;
+  fileName: string;
+  tableCount: number;
+  rowCount: number;
+  createdAt: string;
+}
+
 export interface CloudResult<T = unknown> {
   ok: boolean;
   status: number;

+ 57 - 0
smqjh-agent-runtime/README.md

@@ -0,0 +1,57 @@
+# 市民请集合 Web Agent Runtime
+
+测试阶段先用浏览器运行成熟 Agent 编排,正式上线时再把同一套 runtime 接回 Electron/EXE。
+
+## 架构
+
+- Web UI:只负责聊天、表格渲染、工具轨迹展示。
+- LangGraph Runtime:负责多步规划、工具调用循环、结果汇总。
+- Java MCP:负责 DeepSeek、数据库只读查询、业务工具、配置和安全边界。
+
+```mermaid
+flowchart LR
+  User["管理员 Web 对话"] --> Web["Web UI"]
+  Web --> Runtime["LangGraph Agent Runtime"]
+  Runtime --> MCP["Java MCP Server"]
+  MCP --> DeepSeek["DeepSeek"]
+  MCP --> DB["MySQL 只读查询"]
+  MCP --> Tools["smqjh 业务工具"]
+```
+
+## 本地启动
+
+先启动 MCP:
+
+```powershell
+..\scripts\start-mcp.ps1
+```
+
+再启动 Web Agent:
+
+```powershell
+..\scripts\start-web-agent.ps1
+```
+
+浏览器访问:
+
+```text
+http://127.0.0.1:5188
+```
+
+## 环境变量
+
+| 变量 | 默认值 | 说明 |
+| --- | --- | --- |
+| `WEB_AGENT_HOST` | `0.0.0.0` | Web Runtime 监听地址 |
+| `WEB_AGENT_PORT` | `5188` | Web Runtime 端口 |
+| `SMQJH_MCP_URL` | `http://127.0.0.1:8765/mcp` | MCP 服务地址;给同事测试时可改为实际内网 IP |
+| `SMQJH_MCP_TOKEN` | 空 | MCP token,当前为空即可 |
+| `SMQJH_ENVIRONMENT_NAME` | `test-gateway` | 传给 DeepSeek 的环境名 |
+| `SMQJH_BASE_URL` | `http://192.168.1.242:8080` | smqjh 测试网关 |
+
+## 设计约束
+
+- Runtime 不保存 DeepSeek Key、数据库账号或后台账号。
+- 业务事实查询必须通过 MCP 工具执行,不让管理员自己复制 SQL。
+- 工具输出中的 `columns/rows` 会独立渲染为表格。
+- 写入、发券、结算确认等危险动作继续走 MCP 审批/人工确认。

+ 876 - 0
smqjh-agent-runtime/package-lock.json

@@ -0,0 +1,876 @@
+{
+  "name": "smqjh-agent-runtime",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "smqjh-agent-runtime",
+      "version": "0.1.0",
+      "dependencies": {
+        "@langchain/langgraph": "^1.0.2"
+      },
+      "devDependencies": {
+        "@types/node": "^20.11.30",
+        "tsx": "^4.20.5",
+        "typescript": "^5.9.3"
+      }
+    },
+    "node_modules/@cfworker/json-schema": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
+      "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+      "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+      "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+      "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+      "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+      "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+      "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+      "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+      "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+      "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+      "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+      "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+      "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+      "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+      "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+      "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+      "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+      "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+      "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+      "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+      "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@langchain/core": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.1.tgz",
+      "integrity": "sha512-NNG/cC5FGuHDOAP56h0ddp8Rfk8p+othWzEK5RV9JIG6RvnF5vGa5r0AEGtKfQieed7s1kC42GuIzVOBvMBL/g==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@cfworker/json-schema": "^4.0.2",
+        "@standard-schema/spec": "^1.1.0",
+        "js-tiktoken": "^1.0.12",
+        "langsmith": ">=0.5.0 <1.0.0",
+        "mustache": "^4.2.0",
+        "p-queue": "^6.6.2",
+        "zod": "^3.25.76 || ^4"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/@langchain/langgraph": {
+      "version": "1.4.6",
+      "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.6.tgz",
+      "integrity": "sha512-5xaohfKGmA2Aw1P9TfzC15RI8SqVP2Vi9Xtfz8ClBeksd5NXImBp6e0Hss6FnErDPgXP5uTohpm50ca4t5Pljg==",
+      "license": "MIT",
+      "dependencies": {
+        "@langchain/langgraph-checkpoint": "^1.1.3",
+        "@langchain/langgraph-sdk": "~1.9.25",
+        "@langchain/protocol": "^0.0.18",
+        "@standard-schema/spec": "1.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@langchain/core": "^1.1.48",
+        "zod": "^3.25.32 || ^4.2.0",
+        "zod-to-json-schema": "^3.x"
+      },
+      "peerDependenciesMeta": {
+        "zod-to-json-schema": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@langchain/langgraph-checkpoint": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.3.tgz",
+      "integrity": "sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@langchain/core": "^1.1.48"
+      }
+    },
+    "node_modules/@langchain/langgraph-sdk": {
+      "version": "1.9.25",
+      "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.25.tgz",
+      "integrity": "sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A==",
+      "license": "MIT",
+      "dependencies": {
+        "@langchain/protocol": "^0.0.18",
+        "@types/json-schema": "^7.0.15",
+        "p-queue": "^9.0.1",
+        "p-retry": "^7.1.1"
+      },
+      "peerDependencies": {
+        "@langchain/core": "^1.1.48",
+        "react": "^18 || ^19",
+        "react-dom": "^18 || ^19",
+        "svelte": "^4.0.0 || ^5.0.0",
+        "vue": "^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "react": {
+          "optional": true
+        },
+        "react-dom": {
+          "optional": true
+        },
+        "svelte": {
+          "optional": true
+        },
+        "vue": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@langchain/langgraph-sdk/node_modules/eventemitter3": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+      "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+      "license": "MIT"
+    },
+    "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
+      "version": "9.3.0",
+      "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz",
+      "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==",
+      "license": "MIT",
+      "dependencies": {
+        "eventemitter3": "^5.0.4",
+        "p-timeout": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
+      "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@langchain/protocol": {
+      "version": "0.0.18",
+      "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.18.tgz",
+      "integrity": "sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==",
+      "license": "MIT"
+    },
+    "node_modules/@standard-schema/spec": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+      "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+      "license": "MIT"
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "20.19.43",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
+      "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+      "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.1",
+        "@esbuild/android-arm": "0.28.1",
+        "@esbuild/android-arm64": "0.28.1",
+        "@esbuild/android-x64": "0.28.1",
+        "@esbuild/darwin-arm64": "0.28.1",
+        "@esbuild/darwin-x64": "0.28.1",
+        "@esbuild/freebsd-arm64": "0.28.1",
+        "@esbuild/freebsd-x64": "0.28.1",
+        "@esbuild/linux-arm": "0.28.1",
+        "@esbuild/linux-arm64": "0.28.1",
+        "@esbuild/linux-ia32": "0.28.1",
+        "@esbuild/linux-loong64": "0.28.1",
+        "@esbuild/linux-mips64el": "0.28.1",
+        "@esbuild/linux-ppc64": "0.28.1",
+        "@esbuild/linux-riscv64": "0.28.1",
+        "@esbuild/linux-s390x": "0.28.1",
+        "@esbuild/linux-x64": "0.28.1",
+        "@esbuild/netbsd-arm64": "0.28.1",
+        "@esbuild/netbsd-x64": "0.28.1",
+        "@esbuild/openbsd-arm64": "0.28.1",
+        "@esbuild/openbsd-x64": "0.28.1",
+        "@esbuild/openharmony-arm64": "0.28.1",
+        "@esbuild/sunos-x64": "0.28.1",
+        "@esbuild/win32-arm64": "0.28.1",
+        "@esbuild/win32-ia32": "0.28.1",
+        "@esbuild/win32-x64": "0.28.1"
+      }
+    },
+    "node_modules/eventemitter3": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/is-network-error": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz",
+      "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/js-tiktoken": {
+      "version": "1.0.21",
+      "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz",
+      "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "base64-js": "^1.5.1"
+      }
+    },
+    "node_modules/langsmith": {
+      "version": "0.7.11",
+      "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.7.11.tgz",
+      "integrity": "sha512-kK7tjvLo3KbyrXonZDJJMvX6jE5Ek9uwqtS2WX8AEGc5IwLoMrJlGR8wWk1UD8H/x0goiJDMsRht4qZd7Ijvcg==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "p-queue": "6.6.2"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "*",
+        "@opentelemetry/exporter-trace-otlp-proto": "*",
+        "@opentelemetry/sdk-trace-base": "*",
+        "openai": "*",
+        "ws": ">=7"
+      },
+      "peerDependenciesMeta": {
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@opentelemetry/exporter-trace-otlp-proto": {
+          "optional": true
+        },
+        "@opentelemetry/sdk-trace-base": {
+          "optional": true
+        },
+        "openai": {
+          "optional": true
+        },
+        "ws": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/mustache": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
+      "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
+      "license": "MIT",
+      "peer": true,
+      "bin": {
+        "mustache": "bin/mustache"
+      }
+    },
+    "node_modules/p-finally": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+      "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/p-queue": {
+      "version": "6.6.2",
+      "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
+      "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "eventemitter3": "^4.0.4",
+        "p-timeout": "^3.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-retry": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
+      "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
+      "license": "MIT",
+      "dependencies": {
+        "is-network-error": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-timeout": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+      "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "p-finally": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/tsx": {
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+      "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "~0.28.0"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/zod": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+      "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    }
+  }
+}

+ 20 - 0
smqjh-agent-runtime/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "smqjh-agent-runtime",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "description": "Web test runtime for the SMQJH LangGraph agent.",
+  "scripts": {
+    "dev": "tsx src/server.ts",
+    "build": "tsc -p tsconfig.json",
+    "start": "node dist/server.js"
+  },
+  "dependencies": {
+    "@langchain/langgraph": "^1.0.2"
+  },
+  "devDependencies": {
+    "@types/node": "^20.11.30",
+    "tsx": "^4.20.5",
+    "typescript": "^5.9.3"
+  }
+}

+ 305 - 0
smqjh-agent-runtime/public/app.js

@@ -0,0 +1,305 @@
+const state = {
+  history: [],
+  lastTables: []
+};
+
+const els = {
+  messages: document.querySelector("#messages"),
+  form: document.querySelector("#chatForm"),
+  input: document.querySelector("#messageInput"),
+  inputCount: document.querySelector("#inputCount"),
+  sendBtn: document.querySelector("#sendBtn"),
+  statusDot: document.querySelector("#statusDot"),
+  sessionStatus: document.querySelector("#sessionStatus"),
+  mcpStatus: document.querySelector("#mcpStatus"),
+  mcpEndpoint: document.querySelector("#mcpEndpoint"),
+  toolCount: document.querySelector("#toolCount"),
+  toolList: document.querySelector("#toolList"),
+  traceList: document.querySelector("#traceList"),
+  refreshBtn: document.querySelector("#refreshBtn")
+};
+
+boot();
+
+function boot() {
+  addAssistantMessage(
+    "你好,我是市民请集合智能助手 Web 测试版。你可以直接问业务系统里的订单、商品、会员、月结等问题,我会通过 MCP 工具查询后再回答。"
+  );
+  bindEvents();
+  refreshStatus();
+}
+
+function bindEvents() {
+  els.form.addEventListener("submit", (event) => {
+    event.preventDefault();
+    sendMessage();
+  });
+  els.input.addEventListener("input", () => {
+    els.inputCount.textContent = `${els.input.value.trim().length} 字`;
+  });
+  els.input.addEventListener("keydown", (event) => {
+    if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
+      event.preventDefault();
+      sendMessage();
+    }
+  });
+  els.refreshBtn.addEventListener("click", refreshStatus);
+  document.querySelector("[data-scroll-tools]").addEventListener("click", () => {
+    document.querySelector("#toolsPanel").scrollIntoView({ behavior: "smooth" });
+  });
+  document.querySelector("[data-scroll-trace]").addEventListener("click", () => {
+    document.querySelector("#tracePanel").scrollIntoView({ behavior: "smooth" });
+  });
+}
+
+async function refreshStatus() {
+  setStatus("checking", "检查中", "MCP 连接检测中");
+  try {
+    const health = await getJson("/api/health");
+    const tools = await getJson("/api/tools");
+    const mcp = health.mcp || {};
+    els.mcpEndpoint.textContent = mcp.path ? `${mcp.name || "mcp"} ${mcp.path}` : "-";
+    els.toolCount.textContent = `${tools.tools?.length || 0} 个`;
+    renderTools(tools.tools || []);
+    if (health.ok) {
+      setStatus("ok", "已连接", `${mcp.environmentName || "MCP"} 可用`);
+    } else {
+      setStatus("error", "未连接", mcp.message || "MCP 不可用");
+    }
+  } catch (error) {
+    setStatus("error", "未连接", error.message || "状态检查失败");
+  }
+}
+
+function setStatus(mode, title, detail) {
+  els.statusDot.classList.toggle("ok", mode === "ok");
+  els.statusDot.classList.toggle("error", mode === "error");
+  els.sessionStatus.textContent = title;
+  els.mcpStatus.textContent = detail;
+}
+
+async function sendMessage() {
+  const message = els.input.value.trim();
+  if (!message) {
+    return;
+  }
+
+  addUserMessage(message);
+  els.input.value = "";
+  els.inputCount.textContent = "0 字";
+  setBusy(true);
+  const thinkingId = addThinkingMessage();
+
+  try {
+    const response = await postJson("/api/chat", {
+      message,
+      history: state.history.slice(-12)
+    });
+    removeMessage(thinkingId);
+    if (!response.ok) {
+      throw new Error(response.message || "请求失败");
+    }
+    const result = response.result;
+    addAssistantMessage(result.content, result.tables || []);
+    renderTrace(result.trace || [], result.toolCalls || []);
+    state.history.push({ role: "user", content: message });
+    state.history.push({ role: "assistant", content: result.content });
+  } catch (error) {
+    removeMessage(thinkingId);
+    addAssistantMessage(`暂时无法完成:${error.message || error}`);
+  } finally {
+    setBusy(false);
+  }
+}
+
+function setBusy(busy) {
+  els.sendBtn.disabled = busy;
+  els.sendBtn.querySelector(".send-text").textContent = busy ? "处理中" : "发送";
+}
+
+function addUserMessage(content) {
+  appendMessage({ role: "user", content });
+}
+
+function addAssistantMessage(content, tables = []) {
+  appendMessage({ role: "assistant", content, tables });
+}
+
+function addThinkingMessage() {
+  const id = `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+  appendMessage({
+    id,
+    role: "assistant",
+    content: '<span class="typing">正在处理 <i></i><i></i><i></i></span>',
+    trustedHtml: true
+  });
+  return id;
+}
+
+function removeMessage(id) {
+  const node = document.querySelector(`[data-message-id="${id}"]`);
+  node?.remove();
+}
+
+function appendMessage({ id = "", role, content, tables = [], trustedHtml = false }) {
+  const row = document.createElement("div");
+  row.className = `message-row ${role}`;
+  if (id) {
+    row.dataset.messageId = id;
+  }
+
+  const avatar = document.createElement("div");
+  avatar.className = "avatar";
+  if (role === "assistant") {
+    const img = document.createElement("img");
+    img.src = "/assets/logo.png";
+    img.alt = "";
+    avatar.appendChild(img);
+  } else {
+    avatar.textContent = "我";
+  }
+
+  const bubble = document.createElement("div");
+  bubble.className = "bubble";
+  if (trustedHtml) {
+    bubble.innerHTML = content;
+  } else {
+    bubble.textContent = content;
+  }
+
+  if (tables.length) {
+    state.lastTables = tables;
+    tables.forEach((table, index) => {
+      bubble.appendChild(renderTable(table, index));
+    });
+  }
+
+  row.append(avatar, bubble);
+  els.messages.appendChild(row);
+  els.messages.scrollTop = els.messages.scrollHeight;
+}
+
+function renderTable(table, index) {
+  const card = document.createElement("div");
+  card.className = "table-card";
+
+  const head = document.createElement("div");
+  head.className = "table-head";
+  const title = document.createElement("strong");
+  title.textContent = table.title || "结果表格";
+  const download = document.createElement("button");
+  download.type = "button";
+  download.textContent = "下载 CSV";
+  download.addEventListener("click", () => downloadCsv(table, index));
+  head.append(title, download);
+
+  const wrap = document.createElement("div");
+  wrap.className = "table-wrap";
+  const el = document.createElement("table");
+  const thead = document.createElement("thead");
+  const tr = document.createElement("tr");
+  for (const column of table.columns || []) {
+    const th = document.createElement("th");
+    th.textContent = column;
+    tr.appendChild(th);
+  }
+  thead.appendChild(tr);
+  el.appendChild(thead);
+
+  const tbody = document.createElement("tbody");
+  for (const row of table.rows || []) {
+    const bodyTr = document.createElement("tr");
+    for (const column of table.columns || []) {
+      const td = document.createElement("td");
+      td.textContent = row[column] ?? "";
+      bodyTr.appendChild(td);
+    }
+    tbody.appendChild(bodyTr);
+  }
+  el.appendChild(tbody);
+  wrap.appendChild(el);
+  card.append(head, wrap);
+  return card;
+}
+
+function downloadCsv(table, index) {
+  const rows = [
+    table.columns,
+    ...(table.rows || []).map((row) => table.columns.map((column) => row[column] ?? ""))
+  ];
+  const csv = rows.map((row) => row.map(csvCell).join(",")).join("\r\n");
+  const blob = new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" });
+  const url = URL.createObjectURL(blob);
+  const link = document.createElement("a");
+  link.href = url;
+  link.download = `${safeFileName(table.title || "agent-table")}-${index + 1}.csv`;
+  link.click();
+  URL.revokeObjectURL(url);
+}
+
+function csvCell(value) {
+  const text = String(value ?? "");
+  return `"${text.replace(/"/g, '""')}"`;
+}
+
+function safeFileName(value) {
+  return String(value).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 60);
+}
+
+function renderTools(tools) {
+  els.toolList.innerHTML = "";
+  for (const tool of tools) {
+    const item = document.createElement("div");
+    item.className = "tool-item";
+    const title = document.createElement("strong");
+    title.textContent = tool.title || tool.name;
+    const desc = document.createElement("span");
+    desc.textContent = `${tool.name}${tool.description ? ` · ${tool.description}` : ""}`;
+    item.append(title, desc);
+    els.toolList.appendChild(item);
+  }
+}
+
+function renderTrace(trace, toolCalls) {
+  els.traceList.innerHTML = "";
+  const rows = trace.length ? trace : toolCalls.map((item) => ({
+    phase: item.ok ? "tool" : "error",
+    title: item.name,
+    detail: item.error || `${item.durationMs || 0}ms`
+  }));
+
+  for (const item of rows.slice(-12).reverse()) {
+    const node = document.createElement("div");
+    node.className = `trace-item phase-${item.phase}`;
+    const title = document.createElement("strong");
+    title.textContent = item.title || item.phase;
+    const detail = document.createElement("span");
+    detail.textContent = item.detail || item.at || "";
+    node.append(title, detail);
+    els.traceList.appendChild(node);
+  }
+}
+
+async function getJson(url) {
+  const response = await fetch(url);
+  const payload = await response.json();
+  if (!response.ok) {
+    throw new Error(payload.message || `HTTP ${response.status}`);
+  }
+  return payload;
+}
+
+async function postJson(url, body) {
+  const response = await fetch(url, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json"
+    },
+    body: JSON.stringify(body)
+  });
+  const payload = await response.json();
+  if (!response.ok) {
+    throw new Error(payload.message || `HTTP ${response.status}`);
+  }
+  return payload;
+}

+ 90 - 0
smqjh-agent-runtime/public/index.html

@@ -0,0 +1,90 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>市民请集合智能助手 Web 测试版</title>
+    <link rel="stylesheet" href="/styles.css" />
+  </head>
+  <body>
+    <aside class="sidebar">
+      <div class="brand">
+        <img src="/assets/logo.png" alt="" />
+        <div>
+          <strong>市民请集合</strong>
+          <span>Web Agent 测试版</span>
+        </div>
+      </div>
+      <nav class="nav">
+        <button class="nav-item active" type="button">对话</button>
+        <button class="nav-item" type="button" data-scroll-tools>工具</button>
+        <button class="nav-item" type="button" data-scroll-trace>轨迹</button>
+      </nav>
+      <div class="session-card">
+        <span class="status-dot" id="statusDot"></span>
+        <div>
+          <strong id="sessionStatus">检查中</strong>
+          <span id="mcpStatus">MCP 连接检测中</span>
+        </div>
+      </div>
+    </aside>
+
+    <main class="shell">
+      <header class="topbar">
+        <div>
+          <h1>市民请集合智能助手</h1>
+          <p>LangGraph Runtime · MCP · DeepSeek</p>
+        </div>
+        <button id="refreshBtn" class="icon-button" type="button" title="刷新状态">刷新</button>
+      </header>
+
+      <section class="workspace">
+        <section class="chat-card">
+          <div id="messages" class="messages"></div>
+          <form id="chatForm" class="composer">
+            <textarea
+              id="messageInput"
+              rows="3"
+              placeholder="输入管理员想完成的操作,例如:当前百事可乐 500ml 的商品描述是什么,或生成铜仁移动 2026-05 月结报告"
+            ></textarea>
+            <div class="composer-footer">
+              <span id="inputCount">0 字</span>
+              <button id="sendBtn" class="send-button" type="submit">
+                <span class="send-icon">➤</span>
+                <span class="send-text">发送</span>
+              </button>
+            </div>
+          </form>
+        </section>
+
+        <aside class="inspector">
+          <section class="panel">
+            <div class="panel-title">
+              <h2>执行上下文</h2>
+            </div>
+            <dl class="kv">
+              <div><dt>运行模式</dt><dd>Web 测试</dd></div>
+              <div><dt>框架</dt><dd>LangGraph</dd></div>
+              <div><dt>MCP</dt><dd id="mcpEndpoint">-</dd></div>
+              <div><dt>工具</dt><dd id="toolCount">-</dd></div>
+            </dl>
+          </section>
+          <section class="panel" id="toolsPanel">
+            <div class="panel-title">
+              <h2>可用工具</h2>
+            </div>
+            <div id="toolList" class="tool-list"></div>
+          </section>
+          <section class="panel" id="tracePanel">
+            <div class="panel-title">
+              <h2>执行轨迹</h2>
+            </div>
+            <div id="traceList" class="trace-list"></div>
+          </section>
+        </aside>
+      </section>
+    </main>
+
+    <script src="/app.js" type="module"></script>
+  </body>
+</html>

+ 502 - 0
smqjh-agent-runtime/public/styles.css

@@ -0,0 +1,502 @@
+:root {
+  color-scheme: light;
+  font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
+  --bg: #f4f7f9;
+  --sidebar: #202731;
+  --sidebar-soft: #2c3644;
+  --line: #dce4ea;
+  --text: #10202b;
+  --muted: #657385;
+  --brand: #19735e;
+  --brand-strong: #0d5f4e;
+  --assistant: #f0f3f6;
+  --user: #e4f5ee;
+  --danger: #b42318;
+  --shadow: 0 16px 40px rgba(20, 37, 49, 0.08);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  display: grid;
+  grid-template-columns: 236px minmax(0, 1fr);
+  background: var(--bg);
+  color: var(--text);
+}
+
+button,
+textarea {
+  font: inherit;
+}
+
+.sidebar {
+  min-height: 100vh;
+  padding: 28px 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+  background: var(--sidebar);
+  color: #fff;
+}
+
+.brand {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+  padding: 0 8px;
+}
+
+.brand img {
+  width: 42px;
+  height: 42px;
+  border-radius: 50%;
+  object-fit: cover;
+}
+
+.brand strong {
+  display: block;
+  font-size: 18px;
+  line-height: 1.2;
+}
+
+.brand span,
+.session-card span {
+  display: block;
+  color: #c4ccd8;
+  font-size: 12px;
+  margin-top: 3px;
+}
+
+.nav {
+  display: grid;
+  gap: 8px;
+}
+
+.nav-item {
+  border: 0;
+  border-radius: 8px;
+  padding: 12px 14px;
+  color: #e9eef5;
+  background: transparent;
+  text-align: left;
+  cursor: pointer;
+}
+
+.nav-item.active,
+.nav-item:hover {
+  background: var(--sidebar-soft);
+}
+
+.session-card {
+  margin-top: auto;
+  padding: 12px;
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.status-dot {
+  width: 10px;
+  height: 10px;
+  border-radius: 999px;
+  background: #f59e0b;
+  box-shadow: 0 0 0 6px rgba(245, 158, 11, 0.16);
+  flex: 0 0 auto;
+}
+
+.status-dot.ok {
+  background: #12b981;
+  box-shadow: 0 0 0 6px rgba(18, 185, 129, 0.16);
+}
+
+.status-dot.error {
+  background: #ef4444;
+  box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.16);
+}
+
+.shell {
+  min-width: 0;
+  padding: 28px 24px 24px;
+}
+
+.topbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16px;
+  margin-bottom: 18px;
+}
+
+.topbar h1 {
+  margin: 0;
+  font-size: 28px;
+  letter-spacing: 0;
+}
+
+.topbar p {
+  margin: 4px 0 0;
+  color: var(--muted);
+}
+
+.icon-button {
+  border: 1px solid var(--line);
+  background: #fff;
+  color: var(--text);
+  border-radius: 8px;
+  height: 36px;
+  padding: 0 14px;
+  cursor: pointer;
+}
+
+.workspace {
+  display: grid;
+  grid-template-columns: minmax(580px, 1fr) 360px;
+  gap: 16px;
+  align-items: stretch;
+}
+
+.chat-card,
+.panel {
+  background: #fff;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  box-shadow: var(--shadow);
+}
+
+.chat-card {
+  min-height: calc(100vh - 118px);
+  display: grid;
+  grid-template-rows: minmax(0, 1fr) auto;
+  overflow: hidden;
+}
+
+.messages {
+  padding: 18px;
+  overflow: auto;
+  scroll-behavior: smooth;
+}
+
+.message-row {
+  display: grid;
+  grid-template-columns: 34px minmax(0, 1fr);
+  gap: 10px;
+  margin-bottom: 16px;
+}
+
+.message-row.user {
+  grid-template-columns: minmax(0, 1fr) 34px;
+}
+
+.avatar {
+  width: 34px;
+  height: 34px;
+  border-radius: 50%;
+  background: #d9f0e9;
+  color: var(--brand-strong);
+  display: grid;
+  place-items: center;
+  font-size: 14px;
+  font-weight: 700;
+  overflow: hidden;
+}
+
+.avatar img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.bubble {
+  width: min(100%, 760px);
+  border-radius: 8px;
+  padding: 14px 16px;
+  background: var(--assistant);
+  line-height: 1.7;
+  white-space: pre-wrap;
+}
+
+.message-row.user .bubble {
+  justify-self: end;
+  background: var(--user);
+}
+
+.message-row.user .avatar {
+  grid-column: 2;
+  grid-row: 1;
+}
+
+.message-row.user .bubble {
+  grid-column: 1;
+  grid-row: 1;
+}
+
+.typing {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  white-space: nowrap;
+}
+
+.typing i {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: #7aa89e;
+  animation: pulse 1s infinite ease-in-out;
+}
+
+.typing i:nth-child(2) {
+  animation-delay: 0.15s;
+}
+
+.typing i:nth-child(3) {
+  animation-delay: 0.3s;
+}
+
+@keyframes pulse {
+  0%,
+  80%,
+  100% {
+    opacity: 0.3;
+    transform: translateY(0);
+  }
+  40% {
+    opacity: 1;
+    transform: translateY(-2px);
+  }
+}
+
+.table-card {
+  margin-top: 14px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: #fff;
+  overflow: hidden;
+}
+
+.table-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 12px;
+  background: #f8fafb;
+  border-bottom: 1px solid var(--line);
+}
+
+.table-head strong {
+  font-size: 14px;
+}
+
+.table-head button {
+  border: 1px solid var(--line);
+  background: #fff;
+  color: var(--brand-strong);
+  border-radius: 8px;
+  padding: 6px 10px;
+  cursor: pointer;
+  white-space: nowrap;
+}
+
+.table-wrap {
+  overflow: auto;
+  max-height: 420px;
+}
+
+table {
+  width: 100%;
+  min-width: 640px;
+  border-collapse: collapse;
+  font-size: 13px;
+}
+
+th,
+td {
+  border-bottom: 1px solid var(--line);
+  padding: 10px 12px;
+  text-align: left;
+  vertical-align: top;
+  word-break: break-word;
+}
+
+th {
+  position: sticky;
+  top: 0;
+  z-index: 1;
+  background: #f8fafb;
+  color: #2e3a46;
+  font-weight: 700;
+}
+
+.composer {
+  margin: 0 18px 18px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  padding: 10px;
+  background: #fff;
+}
+
+.composer:focus-within {
+  border-color: var(--brand);
+  box-shadow: 0 0 0 3px rgba(25, 115, 94, 0.12);
+}
+
+.composer textarea {
+  width: 100%;
+  min-height: 68px;
+  max-height: 180px;
+  border: 0;
+  outline: 0;
+  resize: vertical;
+  color: var(--text);
+}
+
+.composer-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.send-button {
+  border: 0;
+  border-radius: 8px;
+  min-width: 92px;
+  height: 42px;
+  padding: 0 16px;
+  background: var(--brand);
+  color: #fff;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  cursor: pointer;
+}
+
+.send-button:disabled {
+  cursor: not-allowed;
+  opacity: 0.66;
+}
+
+.send-icon,
+.send-text {
+  white-space: nowrap;
+}
+
+.inspector {
+  display: grid;
+  align-content: start;
+  gap: 14px;
+  max-height: calc(100vh - 118px);
+  overflow: auto;
+}
+
+.panel {
+  padding: 16px;
+  box-shadow: none;
+}
+
+.panel-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.panel h2 {
+  margin: 0;
+  font-size: 18px;
+}
+
+.kv {
+  display: grid;
+  gap: 12px;
+  margin: 0;
+}
+
+.kv div {
+  display: grid;
+  grid-template-columns: 86px minmax(0, 1fr);
+  gap: 8px;
+}
+
+.kv dt {
+  color: var(--muted);
+}
+
+.kv dd {
+  margin: 0;
+  font-weight: 700;
+  word-break: break-all;
+}
+
+.tool-list,
+.trace-list {
+  display: grid;
+  gap: 8px;
+}
+
+.tool-item,
+.trace-item {
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  padding: 10px;
+  background: #fff;
+}
+
+.tool-item strong,
+.trace-item strong {
+  display: block;
+  font-size: 14px;
+}
+
+.tool-item span,
+.trace-item span {
+  display: block;
+  color: var(--muted);
+  font-size: 12px;
+  margin-top: 4px;
+  word-break: break-word;
+}
+
+.phase-error strong {
+  color: var(--danger);
+}
+
+@media (max-width: 1120px) {
+  body {
+    grid-template-columns: 1fr;
+  }
+
+  .sidebar {
+    min-height: auto;
+    flex-direction: row;
+    align-items: center;
+  }
+
+  .nav {
+    grid-auto-flow: column;
+    margin-left: auto;
+  }
+
+  .session-card {
+    margin-top: 0;
+    min-width: 190px;
+  }
+
+  .workspace {
+    grid-template-columns: 1fr;
+  }
+
+  .inspector {
+    max-height: none;
+  }
+}

+ 583 - 0
smqjh-agent-runtime/src/agentGraph.ts

@@ -0,0 +1,583 @@
+import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
+import type { RuntimeConfig } from "./config.js";
+import { McpClient } from "./mcpClient.js";
+import type {
+  AgentResultTable,
+  AgentRunRequest,
+  AgentRunResponse,
+  AgentTraceEvent,
+  ChatMessage,
+  McpToolDescriptor,
+  PlannedToolCall,
+  ToolObservation
+} from "./types.js";
+
+const AgentState = Annotation.Root({
+  message: Annotation<string>({
+    reducer: (_current, next) => next ?? "",
+    default: () => ""
+  }),
+  history: Annotation<ChatMessage[]>({
+    reducer: (_current, next) => next ?? [],
+    default: () => []
+  }),
+  tools: Annotation<McpToolDescriptor[]>({
+    reducer: (_current, next) => next ?? [],
+    default: () => []
+  }),
+  plannedToolCalls: Annotation<PlannedToolCall[]>({
+    reducer: (_current, next) => next ?? [],
+    default: () => []
+  }),
+  observations: Annotation<ToolObservation[]>({
+    reducer: (current, next) => [...(current ?? []), ...(next ?? [])],
+    default: () => []
+  }),
+  visitedSignatures: Annotation<string[]>({
+    reducer: (_current, next) => next ?? [],
+    default: () => []
+  }),
+  trace: Annotation<AgentTraceEvent[]>({
+    reducer: (current, next) => [...(current ?? []), ...(next ?? [])],
+    default: () => []
+  }),
+  steps: Annotation<number>({
+    reducer: (_current, next) => next ?? 0,
+    default: () => 0
+  }),
+  final: Annotation<AgentRunResponse | undefined>({
+    reducer: (_current, next) => next,
+    default: () => undefined
+  })
+});
+
+type AgentStateType = typeof AgentState.State;
+
+export class SmqjhAgentGraph {
+  private readonly mcp: McpClient;
+  private readonly graph: {
+    invoke(input: Partial<AgentStateType>): Promise<AgentStateType>;
+  };
+
+  constructor(private readonly config: RuntimeConfig) {
+    this.mcp = new McpClient(config.mcp.url, config.mcp.token);
+    this.graph = this.buildGraph();
+  }
+
+  async health(): Promise<unknown> {
+    return this.mcp.status();
+  }
+
+  async tools(): Promise<McpToolDescriptor[]> {
+    return this.loadRunnableTools();
+  }
+
+  async run(input: AgentRunRequest): Promise<AgentRunResponse> {
+    const result = await this.graph.invoke({
+      message: input.message,
+      history: input.history ?? [],
+      visitedSignatures: [],
+      trace: [trace("system", "收到用户请求", input.message)]
+    });
+
+    const final = result.final as AgentRunResponse | undefined;
+    const observations = asArray<ToolObservation>(result.observations);
+    const traceEvents = asArray<AgentTraceEvent>(result.trace);
+    return (
+      final ?? {
+        content: "这次没有生成有效回答,请稍后重试。",
+        model: "none",
+        usedMcp: false,
+        steps: typeof result.steps === "number" ? result.steps : 0,
+        toolCalls: observations,
+        tables: extractTables(observations),
+        trace: traceEvents
+      }
+    );
+  }
+
+  private buildGraph(): { invoke(input: Partial<AgentStateType>): Promise<AgentStateType> } {
+    const workflow = new StateGraph(AgentState)
+      .addNode("loadTools", this.loadToolsNode)
+      .addNode("plan", this.planNode)
+      .addNode("executeTools", this.executeToolsNode)
+      .addNode("summarize", this.summarizeNode)
+      .addNode("chat", this.chatNode)
+      .addEdge(START, "loadTools")
+      .addEdge("loadTools", "plan")
+      .addConditionalEdges("plan", this.routeAfterPlan, {
+        executeTools: "executeTools",
+        summarize: "summarize",
+        chat: "chat"
+      })
+      .addEdge("executeTools", "plan")
+      .addEdge("summarize", END)
+      .addEdge("chat", END);
+
+    return workflow.compile() as unknown as { invoke(input: Partial<AgentStateType>): Promise<AgentStateType> };
+  }
+
+  private loadToolsNode = async (): Promise<Partial<AgentStateType>> => {
+    try {
+      const tools = await this.loadRunnableTools();
+      return {
+        tools,
+        trace: [trace("system", "MCP 工具加载完成", `可用工具 ${tools.length} 个`)]
+      };
+    } catch (error) {
+      return {
+        tools: [],
+        trace: [trace("error", "MCP 工具加载失败", errorMessage(error))]
+      };
+    }
+  };
+
+  private planNode = async (state: AgentStateType): Promise<Partial<AgentStateType>> => {
+    if (state.steps >= this.config.maxSteps) {
+      return {
+        plannedToolCalls: [],
+        trace: [trace("plan", "达到最大工具步数", `maxSteps=${this.config.maxSteps}`)]
+      };
+    }
+
+    try {
+      const result = await this.mcp.callTool("smqjh.ai.tool.plan", {
+        request: this.buildAssistantRequest(state),
+        tools: state.tools,
+        observations: state.observations
+      });
+      const record = asRecord(result.structuredContent);
+      const rawCalls = Array.isArray(record.toolCalls) ? record.toolCalls : [];
+      const visited = new Set(state.visitedSignatures);
+      const plannedToolCalls = rawCalls
+        .map((item) => normalizeToolCall(item, state.message))
+        .filter((item): item is PlannedToolCall => Boolean(item))
+        .filter((item) => !visited.has(toolSignature(item.name, item.arguments)))
+        .slice(0, 3);
+
+      return {
+        plannedToolCalls,
+        trace: [
+          trace(
+            "plan",
+            plannedToolCalls.length ? "DeepSeek 已规划工具调用" : "DeepSeek 判断无需继续调用工具",
+            reasonText(record),
+            { toolCalls: plannedToolCalls }
+          )
+        ]
+      };
+    } catch (error) {
+      return {
+        plannedToolCalls: [],
+        trace: [trace("error", "DeepSeek 工具规划失败", errorMessage(error))]
+      };
+    }
+  };
+
+  private executeToolsNode = async (state: AgentStateType): Promise<Partial<AgentStateType>> => {
+    const byName = new Map(state.tools.map((tool) => [tool.name, tool]));
+    const observations: ToolObservation[] = [];
+    const visited = new Set(state.visitedSignatures);
+
+    for (const call of state.plannedToolCalls) {
+      const resolvedName = resolveToolName(call.name, byName);
+      const signature = toolSignature(resolvedName || call.name, call.arguments);
+      visited.add(signature);
+
+      if (!resolvedName) {
+        observations.push({
+          name: call.name,
+          source: "mcp",
+          arguments: call.arguments,
+          ok: false,
+          error: "DeepSeek 选择了未注册 MCP 工具"
+        });
+        continue;
+      }
+
+      const startedAt = Date.now();
+      try {
+        const result = await this.mcp.callTool(resolvedName, call.arguments);
+        observations.push({
+          name: resolvedName,
+          source: "mcp",
+          arguments: call.arguments,
+          ok: true,
+          result: result.structuredContent ?? result,
+          durationMs: Date.now() - startedAt
+        });
+      } catch (error) {
+        observations.push({
+          name: resolvedName,
+          source: "mcp",
+          arguments: call.arguments,
+          ok: false,
+          error: errorMessage(error),
+          durationMs: Date.now() - startedAt
+        });
+      }
+    }
+
+    return {
+      observations,
+      plannedToolCalls: [],
+      visitedSignatures: Array.from(visited),
+      steps: state.steps + 1,
+      trace: observations.map((item) =>
+        trace(
+          item.ok ? "tool" : "error",
+          item.ok ? `工具执行完成:${item.name}` : `工具执行失败:${item.name}`,
+          item.ok ? `${item.durationMs ?? 0}ms` : item.error,
+          { arguments: item.arguments, result: summarizeResult(item.result) }
+        )
+      )
+    };
+  };
+
+  private summarizeNode = async (state: AgentStateType): Promise<Partial<AgentStateType>> => {
+    const tables = extractTables(state.observations);
+    try {
+      const result = await this.mcp.callTool("smqjh.ai.tool.summarize", {
+        request: this.buildAssistantRequest(state),
+        observations: state.observations
+      });
+      const record = asRecord(result.structuredContent);
+      const content = typeof record.content === "string" && record.content.trim()
+        ? record.content.trim()
+        : fallbackSummary(state.observations, tables);
+      const model = typeof record.model === "string" && record.model ? record.model : "mcp-deepseek";
+
+      return {
+        final: {
+          content,
+          model,
+          usedMcp: true,
+          steps: state.steps,
+          toolCalls: state.observations,
+          tables,
+          trace: state.trace
+        },
+        trace: [trace("summary", "结果总结完成", `${tables.length} 个表格`)]
+      };
+    } catch (error) {
+      const content = fallbackSummary(state.observations, tables);
+      return {
+        final: {
+          content,
+          model: "tool-only",
+          usedMcp: true,
+          steps: state.steps,
+          toolCalls: state.observations,
+          tables,
+          trace: state.trace
+        },
+        trace: [trace("error", "结果总结失败,已使用工具结果兜底", errorMessage(error))]
+      };
+    }
+  };
+
+  private chatNode = async (state: AgentStateType): Promise<Partial<AgentStateType>> => {
+    try {
+      const result = await this.mcp.callTool("smqjh.ai.chat", {
+        request: this.buildAssistantRequest(state)
+      });
+      const record = asRecord(result.structuredContent);
+      const content = typeof record.content === "string" && record.content.trim()
+        ? record.content.trim()
+        : "DeepSeek 没有返回有效内容。";
+      const model = typeof record.model === "string" && record.model ? record.model : "mcp-deepseek";
+
+      return {
+        final: {
+          content,
+          model,
+          usedMcp: true,
+          steps: state.steps,
+          toolCalls: [],
+          tables: [],
+          trace: state.trace
+        },
+        trace: [trace("chat", "普通对话完成", model)]
+      };
+    } catch (error) {
+      return {
+        final: {
+          content: `暂时无法完成回答:${errorMessage(error)}`,
+          model: "none",
+          usedMcp: false,
+          steps: state.steps,
+          toolCalls: [],
+          tables: [],
+          trace: state.trace
+        },
+        trace: [trace("error", "普通对话失败", errorMessage(error))]
+      };
+    }
+  };
+
+  private routeAfterPlan = (state: AgentStateType): "executeTools" | "summarize" | "chat" => {
+    if (state.plannedToolCalls.length > 0 && state.steps < this.config.maxSteps) {
+      return "executeTools";
+    }
+    if (state.observations.length > 0) {
+      return "summarize";
+    }
+    return "chat";
+  };
+
+  private buildAssistantRequest(state: AgentStateType): Record<string, unknown> {
+    return {
+      message: state.message,
+      history: state.history,
+      environmentName: this.config.environmentName,
+      baseUrl: this.config.baseUrl,
+      authenticated: true,
+      username: "web-agent"
+    };
+  }
+
+  private async loadRunnableTools(): Promise<McpToolDescriptor[]> {
+    const tools = await this.mcp.listTools();
+    return tools.filter((tool) => !tool.name.startsWith("smqjh.ai."));
+  }
+}
+
+function normalizeToolCall(value: unknown, message: string): PlannedToolCall | undefined {
+  const record = asRecord(value);
+  const name = typeof record.name === "string" ? record.name.trim() : "";
+  if (!name) {
+    return undefined;
+  }
+  return {
+    name,
+    arguments: repairToolArguments(name, asRecord(record.arguments), message)
+  };
+}
+
+function repairToolArguments(name: string, args: Record<string, unknown>, message: string): Record<string, unknown> {
+  if (name === "smqjh.database.smart.query" && typeof args.question !== "string") {
+    return { ...args, question: message };
+  }
+  if (name === "smqjh.product.lookup.summary") {
+    const current = typeof args.productKeyword === "string" ? args.productKeyword.trim() : "";
+    return { ...args, productKeyword: current || extractProductKeyword(message) };
+  }
+  return args;
+}
+
+function extractProductKeyword(message: string): string {
+  return message
+    .replace(/[,。!??;;::]/g, " ")
+    .replace(/帮我|麻烦|查询一下|查一下|查询|查看|当前|业务系统|系统里面|系统里|后台|我方|我们的|商品库|商品表/g, " ")
+    .replace(/商品描述是什么|商品描述|描述是什么|描述|价格是多少|价格|定价|是多少|是什么|呢/g, " ")
+    .replace(/\s+/g, " ")
+    .trim();
+}
+
+function resolveToolName(name: string, byName: Map<string, McpToolDescriptor>): string | undefined {
+  const candidates = [
+    name,
+    name.startsWith("smqjh.") ? name.slice("smqjh.".length) : `smqjh.${name}`,
+    name === "product.lookup.summary" ? "smqjh.product.lookup.summary" : "",
+    name === "order.count.query" ? "smqjh.order.count.query" : "",
+    name === "database.smart.query" ? "smqjh.database.smart.query" : "",
+    name === "database.readonly.query" ? "smqjh.database.readonly.query" : "",
+    name === "cloud.health" ? "smqjh.cloud.health" : ""
+  ].filter(Boolean);
+  return candidates.find((candidate) => byName.has(candidate));
+}
+
+function extractTables(observations: ToolObservation[]): AgentResultTable[] {
+  const tables: AgentResultTable[] = [];
+  for (const observation of observations) {
+    if (!observation.ok) {
+      continue;
+    }
+    tables.push(...extractTablesFromValue(toolTitle(observation.name), observation.result));
+  }
+  return tables.slice(0, 6);
+}
+
+function extractTablesFromValue(title: string, value: unknown): AgentResultTable[] {
+  const tables: AgentResultTable[] = [];
+  const seen = new WeakSet<object>();
+
+  function visit(node: unknown, currentTitle: string, depth: number): void {
+    if (!node || typeof node !== "object" || depth > 5) {
+      return;
+    }
+    if (seen.has(node)) {
+      return;
+    }
+    seen.add(node);
+
+    const record = node as Record<string, unknown>;
+    addRowsTable(currentTitle, record, "rows");
+    addRowsTable(currentTitle, record, "comparisonRows");
+
+    for (const key of ["data", "structuredContent", "result", "record"]) {
+      if (record[key]) {
+        visit(record[key], currentTitle, depth + 1);
+      }
+    }
+  }
+
+  function addRowsTable(currentTitle: string, record: Record<string, unknown>, rowsKey: "rows" | "comparisonRows"): void {
+    const rows = Array.isArray(record[rowsKey]) ? record[rowsKey] : undefined;
+    if (!rows || !rows.every(isRecord)) {
+      return;
+    }
+    const rowRecords = rows.slice(0, 200).map((row) => normalizeRow(row as Record<string, unknown>));
+    const providedColumns = Array.isArray(record.columns) ? record.columns.map((item) => String(item)) : [];
+    const columns = providedColumns.length
+      ? providedColumns
+      : Array.from(new Set(rowRecords.flatMap((row) => Object.keys(row))));
+    if (!columns.length) {
+      return;
+    }
+    tables.push({
+      title: buildTableTitle(currentTitle, record, rowsKey),
+      columns,
+      rows: rowRecords
+    });
+  }
+
+  visit(value, title, 0);
+  return tables;
+}
+
+function fallbackSummary(observations: ToolObservation[], tables: AgentResultTable[]): string {
+  const okCount = observations.filter((item) => item.ok).length;
+  const failed = observations.filter((item) => !item.ok);
+  const lines = [`已执行 ${observations.length} 个 MCP 工具,成功 ${okCount} 个。`];
+  if (tables.length) {
+    lines.push("结果已整理为下方表格。");
+  }
+  if (failed.length) {
+    lines.push(`失败工具:${failed.map((item) => `${item.name}:${item.error || "调用失败"}`).join(";")}`);
+  }
+  const evidence = findEvidence(observations);
+  if (evidence) {
+    lines.push(`依据:${evidence}`);
+  }
+  return lines.join("\n\n");
+}
+
+function findEvidence(observations: ToolObservation[]): string {
+  for (const observation of observations) {
+    const sql = findKeyValue(observation.result, "executedSql", 0);
+    if (typeof sql === "string" && sql.trim()) {
+      return sql.trim();
+    }
+    const evidence = findKeyValue(observation.result, "evidence", 0);
+    if (typeof evidence === "string" && evidence.trim()) {
+      return evidence.trim();
+    }
+  }
+  return "";
+}
+
+function findKeyValue(value: unknown, key: string, depth: number): unknown {
+  if (!value || typeof value !== "object" || depth > 4) {
+    return undefined;
+  }
+  const record = value as Record<string, unknown>;
+  if (record[key] !== undefined) {
+    return record[key];
+  }
+  for (const child of Object.values(record)) {
+    const result = findKeyValue(child, key, depth + 1);
+    if (result !== undefined) {
+      return result;
+    }
+  }
+  return undefined;
+}
+
+function trace(phase: AgentTraceEvent["phase"], title: string, detail?: string, data?: unknown): AgentTraceEvent {
+  return {
+    at: new Date().toISOString(),
+    phase,
+    title,
+    detail,
+    data
+  };
+}
+
+function reasonText(record: Record<string, unknown>): string {
+  return typeof record.reason === "string" && record.reason.trim() ? record.reason.trim() : "";
+}
+
+function summarizeResult(value: unknown): unknown {
+  const record = asRecord(value);
+  if (typeof record.rowCount === "number") {
+    return { rowCount: record.rowCount, title: record.title, summary: record.summary };
+  }
+  if (Array.isArray(record.rows)) {
+    return { rows: record.rows.length };
+  }
+  return value;
+}
+
+function buildTableTitle(baseTitle: string, record: Record<string, unknown>, rowsKey: string): string {
+  const title = typeof record.title === "string" && record.title.trim() ? record.title.trim() : baseTitle;
+  const count = record.rowCount === undefined ? "" : `(${record.rowCount} 行)`;
+  return rowsKey === "comparisonRows" ? `${title}:价格对比` : `${title}${count}`;
+}
+
+function toolTitle(name: string): string {
+  const titles: Record<string, string> = {
+    "smqjh.config.get": "运行配置",
+    "smqjh.cloud.health": "网关连通检查",
+    "smqjh.schema.search": "业务表搜索结果",
+    "smqjh.schema.getTable": "业务表说明",
+    "smqjh.schema.businessRules": "业务规则",
+    "smqjh.database.readonly.query": "数据库只读查询结果",
+    "smqjh.database.smart.query": "智能数据库查询结果",
+    "smqjh.order.count.query": "订单统计结果",
+    "smqjh.product.lookup.summary": "商品资料查询结果",
+    "smqjh.settlement.enterprise.list": "月结企业清单",
+    "smqjh.settlement.monthly.plan": "企业月结计划"
+  };
+  return titles[name] ?? name;
+}
+
+function toolSignature(name: string, args: Record<string, unknown>): string {
+  return `${name}:${stableStringify(args)}`;
+}
+
+function stableStringify(value: unknown): string {
+  if (!value || typeof value !== "object") {
+    return JSON.stringify(value);
+  }
+  if (Array.isArray(value)) {
+    return `[${value.map(stableStringify).join(",")}]`;
+  }
+  const record = value as Record<string, unknown>;
+  return `{${Object.keys(record)
+    .sort()
+    .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
+    .join(",")}}`;
+}
+
+function asRecord(value: unknown): Record<string, unknown> {
+  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
+}
+
+function asArray<T>(value: unknown): T[] {
+  return Array.isArray(value) ? (value as T[]) : [];
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return Boolean(value && typeof value === "object" && !Array.isArray(value));
+}
+
+function normalizeRow(row: Record<string, unknown>): Record<string, string> {
+  return Object.fromEntries(
+    Object.entries(row).map(([key, value]) => [key, value === undefined || value === null ? "" : String(value)])
+  );
+}
+
+function errorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error);
+}

+ 39 - 0
smqjh-agent-runtime/src/config.ts

@@ -0,0 +1,39 @@
+export interface RuntimeConfig {
+  host: string;
+  port: number;
+  environmentName: string;
+  baseUrl: string;
+  maxSteps: number;
+  mcp: {
+    url: string;
+    token: string;
+  };
+}
+
+export function loadRuntimeConfig(): RuntimeConfig {
+  return {
+    host: process.env.WEB_AGENT_HOST || "0.0.0.0",
+    port: numberEnv("WEB_AGENT_PORT", 5188),
+    environmentName: process.env.SMQJH_ENVIRONMENT_NAME || "test-gateway",
+    baseUrl: process.env.SMQJH_BASE_URL || "http://192.168.1.242:8080",
+    maxSteps: numberEnv("SMQJH_AGENT_MAX_STEPS", 6),
+    mcp: {
+      url: normalizeUrl(process.env.SMQJH_MCP_URL || process.env.MCP_URL || "http://127.0.0.1:8765/mcp"),
+      token: process.env.SMQJH_MCP_TOKEN || process.env.MCP_TOKEN || ""
+    }
+  };
+}
+
+function numberEnv(key: string, fallback: number): number {
+  const raw = process.env[key];
+  if (!raw) {
+    return fallback;
+  }
+  const value = Number(raw);
+  return Number.isFinite(value) && value > 0 ? value : fallback;
+}
+
+function normalizeUrl(value: string): string {
+  const url = value.trim();
+  return url.endsWith("/") ? url.slice(0, -1) : url;
+}

+ 89 - 0
smqjh-agent-runtime/src/mcpClient.ts

@@ -0,0 +1,89 @@
+import type { McpToolDescriptor, McpToolResult } from "./types.js";
+
+interface McpRpcResponse<T> {
+  jsonrpc: "2.0";
+  id: number | string | null;
+  result?: T;
+  error?: {
+    code: number;
+    message: string;
+  };
+}
+
+export class McpClient {
+  private requestId = 1;
+
+  constructor(
+    private readonly endpoint: string,
+    private readonly token = ""
+  ) {}
+
+  async status(): Promise<unknown> {
+    const response = await fetch(this.endpoint, {
+      method: "GET",
+      headers: this.headers()
+    });
+    return parseJsonResponse(response);
+  }
+
+  async listTools(): Promise<McpToolDescriptor[]> {
+    const response = await this.rpc<{ tools?: McpToolDescriptor[] }>("tools/list", {});
+    return Array.isArray(response.tools) ? response.tools : [];
+  }
+
+  async callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
+    return this.rpc<McpToolResult>("tools/call", {
+      name,
+      arguments: args
+    });
+  }
+
+  private async rpc<T>(method: string, params: Record<string, unknown>): Promise<T> {
+    const response = await fetch(this.endpoint, {
+      method: "POST",
+      headers: this.headers(),
+      body: JSON.stringify({
+        jsonrpc: "2.0",
+        id: this.requestId++,
+        method,
+        params
+      })
+    });
+
+    const payload = (await parseJsonResponse(response)) as McpRpcResponse<T>;
+    if (!response.ok) {
+      throw new Error(payload.error?.message || `MCP HTTP ${response.status}`);
+    }
+    if (payload.error) {
+      throw new Error(payload.error.message || "MCP call failed");
+    }
+    if (payload.result === undefined) {
+      throw new Error("MCP returned no result");
+    }
+    return payload.result;
+  }
+
+  private headers(): Record<string, string> {
+    const headers: Record<string, string> = {
+      "Content-Type": "application/json"
+    };
+    const token = this.token.trim();
+    if (token) {
+      headers.Authorization = `Bearer ${token}`;
+      headers["X-MCP-Token"] = token;
+    }
+    return headers;
+  }
+}
+
+async function parseJsonResponse(response: Response): Promise<unknown> {
+  const text = await response.text();
+  if (!text) {
+    return {};
+  }
+  try {
+    return JSON.parse(text) as unknown;
+  } catch {
+    throw new Error(`MCP returned non-JSON response: ${text.slice(0, 200)}`);
+  }
+}

+ 204 - 0
smqjh-agent-runtime/src/server.ts

@@ -0,0 +1,204 @@
+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
+import { createReadStream, existsSync } from "node:fs";
+import { readFile, stat } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { loadRuntimeConfig } from "./config.js";
+import { SmqjhAgentGraph } from "./agentGraph.js";
+import type { AgentRunRequest } from "./types.js";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const runtimeRoot = path.resolve(__dirname, "..");
+const publicRoot = path.join(runtimeRoot, "public");
+const repoRoot = path.resolve(runtimeRoot, "..");
+const logoPath = path.join(repoRoot, "smqjh-admin-agent", "build", "logo.png");
+
+const config = loadRuntimeConfig();
+const agent = new SmqjhAgentGraph(config);
+
+const server = createServer(async (req, res) => {
+  applyCors(res);
+  if (req.method === "OPTIONS") {
+    sendText(res, 204, "");
+    return;
+  }
+
+  try {
+    const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
+    if (url.pathname === "/api/health" && req.method === "GET") {
+      await handleHealth(res);
+      return;
+    }
+    if (url.pathname === "/api/tools" && req.method === "GET") {
+      await handleTools(res);
+      return;
+    }
+    if (url.pathname === "/api/chat" && req.method === "POST") {
+      await handleChat(req, res);
+      return;
+    }
+    if (url.pathname === "/assets/logo.png" && req.method === "GET") {
+      await serveLogo(res);
+      return;
+    }
+    if (req.method === "GET") {
+      await serveStatic(url.pathname, res);
+      return;
+    }
+
+    sendJson(res, 405, { ok: false, message: "Method not allowed" });
+  } catch (error) {
+    sendJson(res, 500, {
+      ok: false,
+      message: error instanceof Error ? error.message : String(error)
+    });
+  }
+});
+
+server.listen(config.port, config.host, () => {
+  console.log(`SMQJH Web Agent started: http://127.0.0.1:${config.port}`);
+  console.log(`MCP endpoint: ${config.mcp.url}`);
+});
+
+async function handleHealth(res: ServerResponse): Promise<void> {
+  try {
+    const mcp = await agent.health();
+    sendJson(res, 200, {
+      ok: true,
+      runtime: {
+        name: "smqjh-agent-runtime",
+        mode: "web",
+        framework: "LangGraph",
+        environmentName: config.environmentName,
+        baseUrl: config.baseUrl
+      },
+      mcp
+    });
+  } catch (error) {
+    sendJson(res, 200, {
+      ok: false,
+      runtime: {
+        name: "smqjh-agent-runtime",
+        mode: "web",
+        framework: "LangGraph"
+      },
+      mcp: {
+        ok: false,
+        message: error instanceof Error ? error.message : String(error)
+      }
+    });
+  }
+}
+
+async function handleTools(res: ServerResponse): Promise<void> {
+  const tools = await agent.tools();
+  sendJson(res, 200, {
+    ok: true,
+    tools
+  });
+}
+
+async function handleChat(req: IncomingMessage, res: ServerResponse): Promise<void> {
+  const body = await readJsonBody<AgentRunRequest>(req);
+  const message = typeof body.message === "string" ? body.message.trim() : "";
+  if (!message) {
+    sendJson(res, 400, { ok: false, message: "message is required" });
+    return;
+  }
+  const history = Array.isArray(body.history) ? body.history : [];
+  const result = await agent.run({ message, history });
+  sendJson(res, 200, {
+    ok: true,
+    result
+  });
+}
+
+async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
+  const chunks: Buffer[] = [];
+  let size = 0;
+  for await (const chunk of req) {
+    const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+    size += buffer.length;
+    if (size > 1024 * 1024) {
+      throw new Error("Request body is too large");
+    }
+    chunks.push(buffer);
+  }
+  const raw = Buffer.concat(chunks).toString("utf8");
+  if (!raw) {
+    return {} as T;
+  }
+  return JSON.parse(raw) as T;
+}
+
+async function serveLogo(res: ServerResponse): Promise<void> {
+  if (!existsSync(logoPath)) {
+    sendText(res, 404, "logo not found");
+    return;
+  }
+  res.writeHead(200, {
+    "Content-Type": "image/png",
+    "Cache-Control": "public, max-age=3600"
+  });
+  createReadStream(logoPath).pipe(res);
+}
+
+async function serveStatic(urlPath: string, res: ServerResponse): Promise<void> {
+  const cleanPath = decodeURIComponent(urlPath === "/" ? "/index.html" : urlPath);
+  const filePath = path.resolve(publicRoot, `.${cleanPath}`);
+  const relative = path.relative(publicRoot, filePath);
+  if (relative.startsWith("..") || path.isAbsolute(relative)) {
+    sendText(res, 403, "Forbidden");
+    return;
+  }
+
+  let info;
+  try {
+    info = await stat(filePath);
+  } catch {
+    sendText(res, 404, "Not found");
+    return;
+  }
+  if (!info.isFile()) {
+    sendText(res, 404, "Not found");
+    return;
+  }
+
+  const data = await readFile(filePath);
+  res.writeHead(200, {
+    "Content-Type": contentType(filePath),
+    "Cache-Control": filePath.endsWith(".html") ? "no-cache" : "public, max-age=300"
+  });
+  res.end(data);
+}
+
+function applyCors(res: ServerResponse): void {
+  res.setHeader("Access-Control-Allow-Origin", "*");
+  res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
+  res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,X-MCP-Token");
+}
+
+function sendJson(res: ServerResponse, status: number, payload: unknown): void {
+  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
+  res.end(JSON.stringify(payload));
+}
+
+function sendText(res: ServerResponse, status: number, text: string): void {
+  res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
+  res.end(text);
+}
+
+function contentType(filePath: string): string {
+  const ext = path.extname(filePath).toLowerCase();
+  const types: Record<string, string> = {
+    ".html": "text/html; charset=utf-8",
+    ".css": "text/css; charset=utf-8",
+    ".js": "text/javascript; charset=utf-8",
+    ".json": "application/json; charset=utf-8",
+    ".png": "image/png",
+    ".ico": "image/x-icon",
+    ".svg": "image/svg+xml"
+  };
+  return types[ext] || "application/octet-stream";
+}

+ 62 - 0
smqjh-agent-runtime/src/types.ts

@@ -0,0 +1,62 @@
+export type ChatRole = "user" | "assistant";
+
+export interface ChatMessage {
+  role: ChatRole;
+  content: string;
+}
+
+export interface McpToolDescriptor {
+  name: string;
+  title?: string;
+  description?: string;
+  inputSchema?: unknown;
+}
+
+export interface McpToolResult {
+  content?: Array<{ type: string; text?: string }>;
+  structuredContent?: unknown;
+}
+
+export interface PlannedToolCall {
+  name: string;
+  arguments: Record<string, unknown>;
+}
+
+export interface ToolObservation {
+  name: string;
+  source: "mcp";
+  arguments: Record<string, unknown>;
+  ok: boolean;
+  result?: unknown;
+  error?: string;
+  durationMs?: number;
+}
+
+export interface AgentTraceEvent {
+  at: string;
+  phase: "system" | "plan" | "tool" | "summary" | "chat" | "error";
+  title: string;
+  detail?: string;
+  data?: unknown;
+}
+
+export interface AgentResultTable {
+  title: string;
+  columns: string[];
+  rows: Array<Record<string, string>>;
+}
+
+export interface AgentRunRequest {
+  message: string;
+  history?: ChatMessage[];
+}
+
+export interface AgentRunResponse {
+  content: string;
+  model: string;
+  usedMcp: boolean;
+  steps: number;
+  toolCalls: ToolObservation[];
+  tables: AgentResultTable[];
+  trace: AgentTraceEvent[];
+}

+ 15 - 0
smqjh-agent-runtime/tsconfig.json

@@ -0,0 +1,15 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "lib": ["ES2022", "DOM"],
+    "strict": true,
+    "esModuleInterop": true,
+    "forceConsistentCasingInFileNames": true,
+    "skipLibCheck": true,
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src/**/*.ts"]
+}

+ 235 - 0
smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/BusinessSchemaRegistry.java

@@ -0,0 +1,235 @@
+package com.smqjh.agent.mcp;
+
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+final class BusinessSchemaRegistry {
+    private final Map<String, TableInfo> tables = new LinkedHashMap<>();
+
+    BusinessSchemaRegistry() {
+        add(new TableInfo(
+            "smqjh_oms.oms_order",
+            "订单主表",
+            "订单、支付、配送、物流、收货人、渠道、金额等主数据。默认过滤 COALESCE(delete_status,0)=0。",
+            new String[] {
+                "order_id: 订单ID",
+                "order_number: 订单号",
+                "member_id: 会员ID",
+                "channel_id/channel_name: 企业渠道",
+                "hb_order_status: 业务订单状态",
+                "hb_logistic_status: 物流/配送状态",
+                "is_payed: 是否已付款,1 表示已支付",
+                "actual_total/total/order_money: 订单金额,查询销售额优先 COALESCE(actual_total,total,order_money,0)",
+                "freight_amount: 运费金额",
+                "dvy_type/dvy_id/dvy_flow_id/dvy_no/dvy_name: 配送方式、配送单号、快递/物流信息",
+                "consignee_name/consignee_mobile/consignee_address: 收货人信息",
+                "pay_time/dvy_time/arrive_time/complete_time/create_time: 支付、发货、到达、完成、创建时间",
+                "out_trade_no/transaction_id/third_order_id: 外部交易/第三方订单线索",
+                "delete_status: 逻辑删除状态"
+            },
+            "SELECT order_number,hb_order_status,hb_logistic_status,dvy_type,dvy_flow_id,dvy_no,dvy_name,dvy_time,arrive_time,complete_time FROM smqjh_oms.oms_order WHERE COALESCE(delete_status,0)=0 AND order_number LIKE '%订单号%' LIMIT 20"
+        ));
+        add(new TableInfo(
+            "smqjh_oms.oms_order_item",
+            "订单商品明细表",
+            "订单内商品行、商品金额、数量、SKU、商品名称。月结、商品汇总、订单明细通常需要关联订单主表。",
+            new String[] {
+                "order_id/order_number: 关联订单",
+                "prod_id/sku_id: 商品与SKU",
+                "prod_name/sku_name: 商品名称和规格",
+                "price: 商品单价",
+                "prod_count/prod_num: 商品数量",
+                "product_total_amount/actual_total: 商品行小计,字段以实际库结构为准"
+            },
+            "SELECT oi.order_number,oi.prod_name,oi.sku_name,oi.price,oi.prod_count FROM smqjh_oms.oms_order_item oi JOIN smqjh_oms.oms_order o ON o.order_id=oi.order_id WHERE COALESCE(o.delete_status,0)=0 LIMIT 50"
+        ));
+        add(new TableInfo(
+            "smqjh_system.sm_member",
+            "会员表",
+            "会员手机号、企业归属、用户基础信息。按手机号查会员或订单时常与订单表 member_id 关联。",
+            new String[] {
+                "id: 会员ID",
+                "mobile: 手机号",
+                "nick_name/username/name: 用户名称,字段以实际库结构为准",
+                "channel_id/channel_no: 企业渠道归属",
+                "create_time/update_time: 创建和更新时间"
+            },
+            "SELECT id,mobile,channel_id,create_time FROM smqjh_system.sm_member WHERE mobile LIKE '%手机号%' LIMIT 20"
+        ));
+        add(new TableInfo(
+            "smqjh_system.ums_member_account",
+            "会员积分/账户流水表",
+            "积分充值、消费、抵扣、企业月结积分核对相关流水。只读查询时注意正负值含义。",
+            new String[] {
+                "member_id: 会员ID",
+                "mobile: 手机号",
+                "channel_id: 企业渠道",
+                "amount/score/point: 积分或金额字段,按实际库结构确认",
+                "third_order_no/order_number: 关联订单",
+                "create_time: 流水时间",
+                "deleted: 逻辑删除"
+            },
+            "SELECT * FROM smqjh_system.ums_member_account WHERE COALESCE(deleted,0)=0 LIMIT 50"
+        ));
+        add(new TableInfo(
+            "smqjh_system.sm_channel",
+            "渠道/企业表",
+            "企业用户渠道。月结企业目前包括招商银行贵阳分行、中数未来、铜仁移动。",
+            new String[] {
+                "id: channelId",
+                "channel_no: 渠道编号",
+                "channel_name: 企业名称",
+                "status: 状态"
+            },
+            "SELECT id,channel_no,channel_name,status FROM smqjh_system.sm_channel WHERE status=1 LIMIT 50"
+        ));
+        add(new TableInfo(
+            "smqjh_pms.tz_prod",
+            "商城商品表",
+            "商城商品主表。商品描述、品牌、状态、基础价格通常在这里。",
+            new String[] {
+                "prod_id: 商品ID",
+                "prod_name: 商品名",
+                "brand_name: 品牌",
+                "brief: 商品描述",
+                "price/ori_price: 价格字段,当前工具按分转元展示,最终以业务口径为准",
+                "status: 商品状态",
+                "is_delete: 逻辑删除"
+            },
+            "SELECT p.prod_id,p.prod_name,p.brand_name,p.brief,p.price,p.ori_price,p.status FROM smqjh_pms.tz_prod p WHERE COALESCE(p.is_delete,0)=0 AND p.prod_name LIKE '%商品关键词%' LIMIT 20"
+        ));
+        add(new TableInfo(
+            "smqjh_pms.tz_sku",
+            "商城 SKU 表",
+            "商品规格、SKU 编码、SKU 价格。商品精确匹配时通常与 tz_prod 关联。",
+            new String[] {
+                "sku_id: SKU ID",
+                "prod_id: 商品ID",
+                "prod_name/sku_name: 商品和规格名",
+                "sku_code: SKU 编码",
+                "price/ori_price: SKU 价格字段",
+                "status: SKU 状态",
+                "is_delete: 逻辑删除"
+            },
+            "SELECT p.prod_id,p.prod_name,s.sku_id,s.sku_name,s.sku_code,s.price FROM smqjh_pms.tz_prod p LEFT JOIN smqjh_pms.tz_sku s ON s.prod_id=p.prod_id AND COALESCE(s.is_delete,0)=0 WHERE COALESCE(p.is_delete,0)=0 AND (p.prod_name LIKE '%关键词%' OR s.sku_name LIKE '%关键词%') LIMIT 20"
+        ));
+    }
+
+    ObjectNode search(String query) {
+        String normalized = query == null ? "" : query.toLowerCase(Locale.ROOT);
+        ArrayNode rows = Jsons.MAPPER.createArrayNode();
+        Set<String> emitted = new LinkedHashSet<>();
+        for (TableInfo table : tables.values()) {
+            if (!emitted.add(table.name)) {
+                continue;
+            }
+            String haystack = (table.name + " " + table.title + " " + table.description + " " + String.join(" ", table.fields)).toLowerCase(Locale.ROOT);
+            if (normalized.isBlank() || haystack.contains(normalized) || matchesBusinessKeyword(normalized, haystack)) {
+                rows.add(toSummary(table));
+            }
+        }
+        ObjectNode result = Jsons.object();
+        result.put("ok", true);
+        result.put("query", query == null ? "" : query);
+        result.put("rowCount", rows.size());
+        result.set("columns", Jsons.MAPPER.valueToTree(new String[] {"table", "title", "description", "exampleSql"}));
+        result.set("rows", rows);
+        return result;
+    }
+
+    ObjectNode getTable(String name) {
+        String normalized = normalizeTableName(name);
+        TableInfo table = tables.get(normalized);
+        if (table == null) {
+            throw new IllegalArgumentException("未登记的业务表: " + name);
+        }
+        ObjectNode result = Jsons.object();
+        result.put("ok", true);
+        result.put("table", table.name);
+        result.put("title", table.title);
+        result.put("description", table.description);
+        result.set("fields", Jsons.MAPPER.valueToTree(table.fields));
+        result.put("exampleSql", table.exampleSql);
+        return result;
+    }
+
+    ObjectNode businessRules(String topic) {
+        String normalized = topic == null ? "" : topic.trim();
+        ObjectNode result = Jsons.object();
+        result.put("ok", true);
+        result.put("topic", normalized);
+        ArrayNode rules = Jsons.MAPPER.createArrayNode();
+        rules.add("只要用户询问业务系统事实,Agent 应优先自动调用 MCP 工具查询,不让用户自己执行 SQL 或去后台翻。");
+        rules.add("数据库查询默认只读 SELECT,默认过滤逻辑删除,明细查询必须 LIMIT。");
+        rules.add("订单销售额口径优先使用 COALESCE(actual_total,total,order_money,0),并区分已支付 is_payed=1。");
+        rules.add("商品查询默认排除逻辑删除商品/SKU,优先匹配商品名、SKU 名、SKU 编码和品牌。");
+        rules.add("结算当前包含三条流程:平台生成结算报告、企业充值导入、企业用户统一发货/消费履约。");
+        rules.add("月结企业包括:招商银行贵阳分行(channelId=54/channelNo=1)、中数未来(channelId=55/channelNo=2)、铜仁移动(channelId=58/channelNo=5)。");
+        rules.add("月结范围为企业用户定时月结、所有已付款订单;商品总额、运费、积分抵扣、现金支付有差异时只标记“需人工确认”,不自动修改业务系统。");
+        rules.add("结算报告后续流程:发送客户核对 -> 打印盖章 -> 开电子发票 -> 等待客户付款。");
+        rules.add("充值流程:从企业获取客户充值信息表 -> 调整排错 -> 导入业务系统 -> 开具成功上账凭据和上账记录。");
+        rules.add("消费流程:C 端用户即时配送;企业用户统一发货,非及时性发货需导出订单分仓、联系供应商、入库,再配送闪送或物流快递。");
+        result.set("rules", rules);
+        return result;
+    }
+
+    String promptContext() {
+        StringBuilder builder = new StringBuilder();
+        builder.append("业务表与口径:\n");
+        Set<String> emitted = new LinkedHashSet<>();
+        for (TableInfo table : tables.values()) {
+            if (!emitted.add(table.name)) {
+                continue;
+            }
+            builder.append("- ").append(table.name).append(":").append(table.title).append("。").append(table.description).append("\n");
+            builder.append("  常用字段:").append(String.join(";", table.fields)).append("\n");
+            builder.append("  SQL 示例:").append(table.exampleSql).append("\n");
+        }
+        builder.append("业务规则:订单默认排除逻辑删除;销售额优先 COALESCE(actual_total,total,order_money,0);月结只标记差异不修改系统。\n");
+        return builder.toString();
+    }
+
+    private void add(TableInfo table) {
+        tables.put(normalizeTableName(table.name), table);
+        String shortName = table.name.substring(table.name.lastIndexOf('.') + 1);
+        tables.putIfAbsent(shortName.toLowerCase(Locale.ROOT), table);
+    }
+
+    private ObjectNode toSummary(TableInfo table) {
+        ObjectNode row = Jsons.object();
+        row.put("table", table.name);
+        row.put("title", table.title);
+        row.put("description", table.description);
+        row.put("exampleSql", table.exampleSql);
+        return row;
+    }
+
+    private String normalizeTableName(String name) {
+        return (name == null ? "" : name.trim().replace("`", "")).toLowerCase(Locale.ROOT);
+    }
+
+    private boolean matchesBusinessKeyword(String query, String haystack) {
+        if ((query.contains("物流") || query.contains("配送") || query.contains("订单")) && haystack.contains("订单")) {
+            return true;
+        }
+        if ((query.contains("商品") || query.contains("sku") || query.contains("价格")) && haystack.contains("商品")) {
+            return true;
+        }
+        if ((query.contains("会员") || query.contains("手机号")) && haystack.contains("会员")) {
+            return true;
+        }
+        if ((query.contains("结算") || query.contains("月结") || query.contains("渠道")) && haystack.contains("渠道")) {
+            return true;
+        }
+        return false;
+    }
+
+    private record TableInfo(String name, String title, String description, String[] fields, String exampleSql) {
+    }
+}

+ 39 - 4
smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/DeepSeekMcpClient.java

@@ -32,12 +32,13 @@ final class DeepSeekMcpClient {
             "你是“市民请集合智能助手”的 MCP 侧工具调度器,只输出 JSON,不输出 Markdown。",
             "目标:根据管理员自然语言问题,自主判断是否需要调用工具;只要能通过工具查询业务系统事实,就不要让用户自己执行 SQL、自己去后台查、或自己补充非必要字段。",
             "范围:只处理 smqjh 项目业务、后台运营、商品、订单、会员、积分、券、月结、物流、系统配置、日志,以及日常简单问答。超出范围且没有工具时走 chat。",
-            "工具优先级:业务系统事实查询优先选 MCP 工具;外部价格对比选 product.price.compare;简单寒暄、常识解释、天气闲聊等无业务数据问题选 chat。",
+            "工具优先级:业务系统事实查询优先选 MCP 工具;通用事实查询优先选 smqjh.database.smart.query;外部价格对比选 product.price.compare;简单寒暄、常识解释、天气闲聊等无业务数据问题选 chat。",
+            "如果已有 observations,要基于工具结果判断是否还需要继续调用工具;不要重复调用同名同参数工具。结果已经足够回答时返回 chat,toolCalls 为空。",
             "不要把普通问答错判成商品比价。只有用户明确提到价格对比、市面价、电商、慢慢买、苏宁、淘宝、京东、拼多多、同平台价格等,才选 product.price.compare。",
             "商品资料、商品描述、系统内定价、商品状态、品牌、规格,选 smqjh.product.lookup.summary,并把 productKeyword 提炼为真实商品名和规格。",
-            "订单数量、销售额、实付金额、营业额、按手机号统计,优先选 smqjh.order.count.query;如果需要明细字段或该工具无法表达,再选 smqjh.database.readonly.query。",
-            "订单物流状态、配送状态、快递单号、运单号、订单明细、会员资料、任意后台表内字段查询,选 smqjh.database.readonly.query 并生成安全 SELECT。",
-            "企业月结、铜仁移动/招商银行贵阳分行/中数未来结算、结算流程、结算月份,选 smqjh.settlement.monthly.plan。",
+            "订单数量、销售额、实付金额、营业额、按手机号统计,优先选 smqjh.order.count.query;如果需要明细字段或该工具无法表达,再选 smqjh.database.smart.query。",
+            "订单物流状态、配送状态、快递单号、运单号、订单明细、会员资料、任意后台表内字段查询,选 smqjh.database.smart.query,让工具内部生成并执行安全 SELECT。",
+            "询问有哪些月结企业、企业清单、channelId/channelNo 范围,选 smqjh.settlement.enterprise.list;询问某企业某月份结算计划,选 smqjh.settlement.monthly.plan。",
             "SQL 安全要求:只能单条 SELECT;禁止写入、DDL、多语句、注释;明细查询 LIMIT 100 以内;默认排除逻辑删除;只访问 smqjh 白名单业务表。",
             "常用表:smqjh_oms.oms_order、smqjh_oms.oms_order_item、smqjh_system.sm_member、smqjh_system.ums_member_account、smqjh_system.sm_channel、smqjh_pms.tz_prod、smqjh_pms.tz_sku。",
             "常用订单字段:order_id、order_number、member_id、channel_id、channel_name、hb_order_status、hb_logistic_status、order_money、total、actual_total、freight_amount、dvy_type、dvy_id、dvy_flow_id、dvy_no、dvy_name、pay_time、dvy_time、create_time、consignee_mobile、is_payed、delete_status。",
@@ -56,6 +57,7 @@ final class DeepSeekMcpClient {
         ObjectNode userPayload = Jsons.object();
         userPayload.put("message", request.path("message").asText(""));
         userPayload.set("history", request.path("history").isArray() ? request.path("history") : Jsons.MAPPER.createArrayNode());
+        userPayload.set("observations", args.path("observations").isArray() ? args.path("observations") : Jsons.MAPPER.createArrayNode());
         messages.add(message("user", Jsons.stringify(userPayload)));
 
         String content = completionContent(messages);
@@ -72,6 +74,39 @@ final class DeepSeekMcpClient {
         return result;
     }
 
+    ObjectNode generateReadOnlySql(String question, String schemaContext, JsonNode args) throws Exception {
+        ArrayNode messages = Jsons.MAPPER.createArrayNode();
+        messages.add(message("system", String.join("\n",
+            "你是“市民请集合智能助手”的数据库查询规划器,只输出 JSON,不输出 Markdown。",
+            "你的任务:把管理员自然语言问题转换成一条可执行的 MySQL SELECT。系统会自动执行,所以不要只给建议。",
+            "只能生成单条 SELECT;禁止 INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE/GRANT/REVOKE/CALL/SET;禁止多语句和 SQL 注释。",
+            "只能访问给定 schema 中的 smqjh 业务白名单表;不要访问 information_schema、mysql、performance_schema、sys。",
+            "默认排除逻辑删除数据;明细查询必须 LIMIT 100 以内;聚合查询可以不 LIMIT。",
+            "如果用户问订单数量/销售额,金额优先 COALESCE(actual_total,total,order_money,0),并明确是否过滤 is_payed=1。",
+            "如果用户问商品描述/状态/价格,优先查 smqjh_pms.tz_prod 和 smqjh_pms.tz_sku,过滤 COALESCE(is_delete,0)=0。",
+            "如果用户问订单物流/配送/快递,优先查 smqjh_oms.oms_order 的 hb_logistic_status、dvy_type、dvy_flow_id、dvy_no、dvy_name、dvy_time、arrive_time、complete_time。",
+            "如果字段不确定,选择最可能字段并在 warnings 标注“字段需按实际库结构复核”;不要让用户自己执行 SQL。",
+            "返回 JSON:{\"title\":\"表格标题\",\"summary\":\"查询口径说明\",\"sql\":\"SELECT ...\",\"warnings\":[\"...\"]}。"
+        )));
+        messages.add(message("system", schemaContext));
+        ObjectNode userPayload = Jsons.object();
+        userPayload.put("question", question);
+        userPayload.put("environmentName", config.environmentName);
+        userPayload.put("baseUrl", config.baseUrl);
+        userPayload.set("context", args.path("context"));
+        messages.add(message("user", Jsons.stringify(userPayload)));
+
+        ObjectNode parsed = parseJsonObject(completionContent(messages));
+        ObjectNode result = Jsons.object();
+        result.put("title", parsed.path("title").asText("智能数据库查询结果"));
+        result.put("summary", parsed.path("summary").asText(""));
+        result.put("sql", parsed.path("sql").asText(""));
+        result.set("warnings", parsed.path("warnings").isArray() ? parsed.path("warnings") : Jsons.MAPPER.createArrayNode());
+        result.put("model", config.deepSeek.model);
+        result.put("provider", "mcp-deepseek");
+        return result;
+    }
+
     ObjectNode summarizeToolResults(JsonNode args) throws Exception {
         JsonNode request = args.path("request");
         JsonNode observations = args.path("observations").isArray() ? args.path("observations") : Jsons.MAPPER.createArrayNode();

+ 25 - 1
smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/MonthlySettlementPlanner.java

@@ -24,6 +24,7 @@ final class MonthlySettlementPlanner {
         LocalDate end = start.plusMonths(1);
 
         ObjectNode root = Jsons.object();
+        root.put("title", enterprise.name() + " " + month + " 月结处理计划");
         root.put("enterpriseName", enterprise.name());
         root.put("channelId", enterprise.channelId());
         root.put("channelNo", enterprise.channelNo());
@@ -31,7 +32,10 @@ final class MonthlySettlementPlanner {
         root.put("startTime", start + " 00:00:00");
         root.put("endTime", end + " 00:00:00");
         root.put("settlementScope", "企业用户定时月结;所有已付款订单;逻辑删除订单不参与");
-        root.set("exports", exports(enterprise, start, end));
+        ArrayNode exportRows = exports(enterprise, start, end);
+        root.set("columns", Jsons.MAPPER.valueToTree(new String[] {"name", "method", "endpoint", "params", "note"}));
+        root.set("rows", exportRows);
+        root.set("exports", exportRows);
         root.set("rules", rules());
         root.set("invoiceStates", invoiceStates());
         root.put("adminTenantRule", "顶级 admin 登录/调用不需要 tenantCode;非 admin 仍需要 tenantCode 或租户上下文");
@@ -39,6 +43,26 @@ final class MonthlySettlementPlanner {
         return root;
     }
 
+    ObjectNode enterprises() {
+        ObjectNode root = Jsons.object();
+        ArrayNode rows = Jsons.MAPPER.createArrayNode();
+        for (Enterprise enterprise : ENTERPRISES) {
+            ObjectNode row = Jsons.object();
+            row.put("channelId", enterprise.channelId());
+            row.put("channelNo", enterprise.channelNo());
+            row.put("enterpriseName", enterprise.name());
+            row.put("settlementType", "企业用户定时月结");
+            row.put("scope", "所有已付款订单,排除逻辑删除订单");
+            rows.add(row);
+        }
+        root.put("title", "月结企业清单");
+        root.set("columns", Jsons.MAPPER.valueToTree(new String[] {"channelId", "channelNo", "enterpriseName", "settlementType", "scope"}));
+        root.set("rows", rows);
+        root.put("rowCount", rows.size());
+        root.put("note", "目前来自管理员确认的 sm_channel 月结企业配置;顶级 admin 不需要 tenantCode。");
+        return root;
+    }
+
     private ArrayNode exports(Enterprise enterprise, LocalDate start, LocalDate end) {
         ArrayNode exports = Jsons.MAPPER.createArrayNode();
         exports.add(exportSpec(

+ 69 - 0
smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/ToolRegistry.java

@@ -13,6 +13,7 @@ final class ToolRegistry {
     private final SmqjhCloudClient cloud;
     private final MonthlySettlementPlanner settlementPlanner;
     private final DeepSeekMcpClient deepSeek;
+    private final BusinessSchemaRegistry schema;
 
     ToolRegistry(AppConfig config) {
         this.config = config;
@@ -20,15 +21,21 @@ final class ToolRegistry {
         this.cloud = new SmqjhCloudClient(config);
         this.settlementPlanner = new MonthlySettlementPlanner();
         this.deepSeek = new DeepSeekMcpClient(config);
+        this.schema = new BusinessSchemaRegistry();
     }
 
     List<ObjectNode> listTools() {
         List<ObjectNode> tools = new ArrayList<>();
         tools.add(tool("smqjh.config.get", "读取本机运行配置", "返回网关、数据库、DeepSeek、MCP 状态。敏感字段会脱敏。", objectSchema(Jsons.object())));
         tools.add(tool("smqjh.cloud.health", "网关连通检查", "调用验证码接口检查 smqjh 网关是否可达。", objectSchema(Jsons.object())));
+        tools.add(tool("smqjh.schema.search", "搜索业务表和规则", "按自然语言搜索 smqjh 业务表、字段和查询口径。", objectSchema(requiredProps("query", "string", "业务问题、表名或字段关键词"), "query")));
+        tools.add(tool("smqjh.schema.getTable", "读取业务表说明", "返回单张业务表的字段说明、口径和示例 SQL。", objectSchema(requiredProps("table", "string", "表名,例如 smqjh_oms.oms_order"), "table")));
+        tools.add(tool("smqjh.schema.businessRules", "读取业务规则", "返回订单、商品、会员、结算、充值、消费等业务口径。", objectSchema(requiredProps("topic", "string", "业务主题,例如 月结/订单/商品"), "topic")));
         tools.add(tool("smqjh.database.readonly.query", "执行只读 SQL", "只允许 SELECT,自动校验危险语句、表白名单和默认 LIMIT。", objectSchema(requiredProps("sql", "string", "只读 SELECT SQL"), "sql")));
+        tools.add(tool("smqjh.database.smart.query", "智能数据库查询", "输入管理员自然语言问题,由 DeepSeek 结合业务 schema 生成安全 SELECT,经白名单和只读校验后自动执行并返回表格。", objectSchema(smartQueryProps(), "question")));
         tools.add(tool("smqjh.order.count.query", "订单统计查询", "按手机号和日期区间统计订单数量和销售额,只读查询数据库。", objectSchema(orderCountProps())));
         tools.add(tool("smqjh.product.lookup.summary", "商品资料查询", "按商品关键词查询商品名、品牌、价格、状态和描述。", objectSchema(requiredProps("productKeyword", "string", "商品关键词"), "productKeyword")));
+        tools.add(tool("smqjh.settlement.enterprise.list", "月结企业清单", "返回当前已确认的企业月结渠道清单和结算范围。", objectSchema(Jsons.object())));
         tools.add(tool("smqjh.settlement.monthly.plan", "企业月结处理计划", "根据企业和月份返回月结导出接口、参数、核对规则和人工确认条件。", objectSchema(settlementProps(), "enterprise", "month")));
         tools.add(tool("smqjh.ai.tool.plan", "DeepSeek 工具规划", "由 MCP 侧 DeepSeek 判断管理员问题需要调用哪些工具。", objectSchema(aiPlanProps(), "request", "tools")));
         tools.add(tool("smqjh.ai.tool.summarize", "DeepSeek 工具结果总结", "由 MCP 侧 DeepSeek 汇总工具执行结果,生成面向管理员的回答。", objectSchema(aiSummarizeProps(), "request", "observations")));
@@ -42,9 +49,14 @@ final class ToolRegistry {
         Object result = switch (name) {
             case "smqjh.config.get" -> maskedConfig();
             case "smqjh.cloud.health" -> cloud.health();
+            case "smqjh.schema.search" -> schema.search(requiredText(args, "query"));
+            case "smqjh.schema.getTable" -> schema.getTable(requiredText(args, "table"));
+            case "smqjh.schema.businessRules" -> schema.businessRules(requiredText(args, "topic"));
             case "smqjh.database.readonly.query" -> database.runReadOnlyQuery(requiredText(args, "sql"));
+            case "smqjh.database.smart.query" -> smartQuery(args);
             case "smqjh.order.count.query" -> database.countOrders(text(args, "mobile"), text(args, "dateFrom"), text(args, "dateTo"));
             case "smqjh.product.lookup.summary" -> database.productLookupSummary(requiredText(args, "productKeyword"));
+            case "smqjh.settlement.enterprise.list" -> settlementPlanner.enterprises();
             case "smqjh.settlement.monthly.plan" -> settlementPlanner.plan(requiredText(args, "enterprise"), requiredText(args, "month"));
             case "smqjh.ai.tool.plan" -> deepSeek.planToolUse(args);
             case "smqjh.ai.tool.summarize" -> deepSeek.summarizeToolResults(args);
@@ -62,6 +74,56 @@ final class ToolRegistry {
         return payload;
     }
 
+    private ObjectNode smartQuery(JsonNode args) throws Exception {
+        String question = requiredText(args, "question");
+        ObjectNode plan = deepSeek.generateReadOnlySql(question, schema.promptContext(), args);
+        String sql = plan.path("sql").asText("").trim();
+        if (sql.isBlank()) {
+            throw new IllegalArgumentException("DeepSeek 未生成可执行 SELECT");
+        }
+        boolean repaired = false;
+        String firstError = "";
+        ObjectNode queryResult;
+        try {
+            queryResult = database.runReadOnlyQuery(sql);
+        } catch (Exception error) {
+            firstError = error.getMessage();
+            ObjectNode retryArgs = Jsons.object();
+            retryArgs.put("context", String.join("\n",
+                "第一次 SQL 执行失败,需要重新生成更稳妥的 SELECT。",
+                "失败 SQL:" + sql,
+                "数据库错误:" + firstError,
+                "要求:优先使用 schema 中明确列出的字段;如果字段不确定,用更少字段或聚合口径;仍然只输出 JSON 和单条 SELECT。"
+            ));
+            ObjectNode retryPlan = deepSeek.generateReadOnlySql(question, schema.promptContext(), retryArgs);
+            String retrySql = retryPlan.path("sql").asText("").trim();
+            if (retrySql.isBlank() || retrySql.equalsIgnoreCase(sql)) {
+                throw error;
+            }
+            plan = retryPlan;
+            sql = retrySql;
+            repaired = true;
+            queryResult = database.runReadOnlyQuery(sql);
+        }
+        ObjectNode result = Jsons.object();
+        result.put("ok", queryResult.path("ok").asBoolean(false));
+        result.put("question", question);
+        result.put("title", plan.path("title").asText("智能数据库查询结果"));
+        result.put("summary", plan.path("summary").asText(""));
+        result.put("generatedSql", sql);
+        result.put("executedSql", queryResult.path("executedSql").asText(""));
+        result.put("sqlRepaired", repaired);
+        if (!firstError.isBlank()) {
+            result.put("firstSqlError", firstError);
+        }
+        result.put("rowCount", queryResult.path("rowCount").asInt(0));
+        result.set("columns", queryResult.path("columns"));
+        result.set("rows", queryResult.path("rows"));
+        result.set("warnings", plan.path("warnings").isArray() ? plan.path("warnings") : Jsons.MAPPER.createArrayNode());
+        result.put("evidence", "MCP 工具 smqjh.database.smart.query;DeepSeek 生成 SELECT;ReadOnlyDatabaseClient 执行白名单、只读、LIMIT、超时校验");
+        return result;
+    }
+
     private ObjectNode maskedConfig() {
         ObjectNode root = Jsons.object();
         root.put("environmentName", config.environmentName);
@@ -136,6 +198,13 @@ final class ToolRegistry {
         return props;
     }
 
+    private ObjectNode smartQueryProps() {
+        ObjectNode props = Jsons.object();
+        props.set("question", prop("string", "管理员自然语言问题,例如:当前百事可乐 500ml 的商品描述是什么、查订单物流状态、某手机号下了多少单"));
+        props.set("context", prop("string", "可选补充上下文,例如上轮结果、企业、月份、订单号"));
+        return props;
+    }
+
     private ObjectNode settlementProps() {
         ObjectNode props = Jsons.object();
         props.set("enterprise", prop("string", "企业名称、渠道号或 channelId,例如 铜仁移动 / 5 / 58"));