Prechádzať zdrojové kódy

feat:新增基于AgentServer的AI助手(前端页面、后端代理与会话归属、fwh-agent-mcp服务、PII脱敏、技能与设计文档),待测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 6 dní pred
rodič
commit
72760d25bc
72 zmenil súbory, kde vykonal 8805 pridanie a 0 odobranie
  1. 168 0
      deploy/agent-assistant/SETUP.md
  2. 72 0
      deploy/agent-assistant/skills/fwh-assistant/SKILL.md
  3. 23 0
      deploy/agent-assistant/skills/fwh-assistant/references/ppt-template.md
  4. 201 0
      deploy/agent-assistant/skills/fwh-assistant/references/tou-template.md
  5. 17 0
      deploy/agent-assistant/skills/fwh-assistant/references/write-operations.md
  6. BIN
      deploy/agent-assistant/skills/fwh-assistant/templates/company-template.pptx
  7. 13 0
      deploy/config.env.example
  8. 99 0
      deploy/scripts/sync_agent_knowledge.sh
  9. 15 0
      deploy/systemd/fwh-agent-mcp.service
  10. 25 0
      docs/ai-assistant-agentserver/README.md
  11. 274 0
      docs/ai-assistant-agentserver/agent-mcp.md
  12. 83 0
      docs/ai-assistant-agentserver/legacy-kiro-assistant.md
  13. 98 0
      docs/ai-assistant-agentserver/requirements.md
  14. 205 0
      docs/ai-assistant-agentserver/technical-design.md
  15. 127 0
      docs/ai-assistant-agentserver/ui-design.md
  16. 3 0
      package.json
  17. 87 0
      pnpm-lock.yaml
  18. 123 0
      service/agentmcp/agentmcp_test.go
  19. 110 0
      service/agentmcp/bootstrap.go
  20. 67 0
      service/agentmcp/http.go
  21. 119 0
      service/agentmcp/kit/args.go
  22. 138 0
      service/agentmcp/kit/middleware.go
  23. 280 0
      service/agentmcp/kit/result.go
  24. 146 0
      service/agentmcp/kit/result_test.go
  25. 68 0
      service/agentmcp/kit/tool.go
  26. 53 0
      service/agentmcp/server.go
  27. 304 0
      service/agentmcp/tools/alarm.go
  28. 129 0
      service/agentmcp/tools/faultrecord.go
  29. 168 0
      service/agentmcp/tools/logs.go
  30. 216 0
      service/agentmcp/tools/misc.go
  31. 136 0
      service/agentmcp/tools/ops.go
  32. 31 0
      service/agentmcp/tools/registry.go
  33. 84 0
      service/agentmcp/tools/shutdown.go
  34. 155 0
      service/agentmcp/tools/sim.go
  35. 237 0
      service/agentmcp/tools/site.go
  36. 146 0
      service/agentmcp/tools/tools_test.go
  37. 376 0
      service/agentmcp/tools/tou.go
  38. 57 0
      service/cmd/agent-mcp/main.go
  39. 44 0
      service/cmd/deploy_agent_mcp.sh
  40. 28 0
      service/config/config.go
  41. 107 0
      service/controllers/agentControllers.go
  42. 3 0
      service/main.go
  43. 144 0
      service/repositories/mongo/agentSessionRepo.go
  44. 3 0
      service/repositories/mongo/init.go
  45. 22 0
      service/routes/agentRoutes.go
  46. 1 0
      service/routes/routes.go
  47. 276 0
      service/services/agentServices.go
  48. 47 0
      service/utils/agentserver/access.go
  49. 43 0
      service/utils/agentserver/access_test.go
  50. 170 0
      service/utils/agentserver/client.go
  51. 154 0
      service/utils/agentserver/guard.go
  52. 139 0
      service/utils/agentserver/guard_test.go
  53. 225 0
      service/utils/agentserver/proxy.go
  54. 152 0
      service/utils/agentserver/proxy_test.go
  55. 139 0
      service/utils/agentserver/routes.go
  56. 67 0
      service/utils/agentserver/routes_test.go
  57. 171 0
      service/utils/agentserver/sse.go
  58. 82 0
      service/utils/agentserver/sse_test.go
  59. 70 0
      service/utils/agentserver/types.go
  60. 187 0
      service/utils/pii/pii_test.go
  61. 141 0
      service/utils/pii/rules.go
  62. 251 0
      service/utils/pii/stream.go
  63. 11 0
      src/router/record.ts
  64. 55 0
      src/service/agent/index.ts
  65. 176 0
      src/views/ai-assistant/components/ChatHeader.vue
  66. 295 0
      src/views/ai-assistant/components/FilePanel.vue
  67. 186 0
      src/views/ai-assistant/components/SessionSidebar.vue
  68. 79 0
      src/views/ai-assistant/components/WelcomePanel.vue
  69. 27 0
      src/views/ai-assistant/composables/useAgentClient.ts
  70. 203 0
      src/views/ai-assistant/composables/useAgentSessions.ts
  71. 62 0
      src/views/ai-assistant/composables/useSessionFiles.ts
  72. 392 0
      src/views/ai-assistant/index.vue

+ 168 - 0
deploy/agent-assistant/SETUP.md

@@ -0,0 +1,168 @@
+# 新 AI 助手部署与配置
+
+新 AI 助手(`/ai-assistant` 页面)基于 AgentServer。设计见 `docs/ai-assistant-agentserver/`。本文是上线前需要**手动**完成的配置,按顺序执行。旧 Kiro 助手不受影响,不需要改动。
+
+涉及的组件(同一台机器):
+
+| 组件 | 地址 | 说明 |
+| --- | --- | --- |
+| AgentServer | `http://127.0.0.1:8787`,后台 `/admin/` | systemd `agent-server`,用户 `agentsvc` |
+| fwh-agent-mcp(只读) | `http://127.0.0.1:8091/mcp` | systemd `fwh-agent-mcp` |
+| fwh-agent-mcp(写,二期) | `http://127.0.0.1:8092/mcp` | 可选,见第 2.3 节 |
+| ToolBox 后端 | `127.0.0.1:8301`,路由前缀 `/api` | systemd `fwh-toolbox-server` |
+| 对外入口 | Caddy → Apache `:8300` → `/fwh-toolbox/api` → `:8301/api` | 见第 6 节 |
+
+## 1. 生成令牌
+
+fwh-agent-mcp 用 Bearer 令牌鉴权,AgentServer 连接时携带。生成一个随机值,后面两处都要用:
+
+```bash
+openssl rand -hex 32
+```
+
+## 2. 部署 fwh-agent-mcp
+
+### 2.1 配置 `service/.env`(部署目录 `/var/www/fwh-toolbox/server/.env`)
+
+```
+AGENT_MCP_TOKEN=<第 1 步生成的令牌>
+AGENT_MCP_LISTEN=127.0.0.1:8091
+AGENT_MCP_MAX_CONCURRENCY=8
+AGENT_MCP_LOG_PATH=/var/www/fwh-toolbox/server/logs/agent-mcp.log
+```
+
+### 2.2 安装服务
+
+```bash
+cd service/cmd && ./deploy_agent_mcp.sh                         # 构建并拷贝到部署目录、重启服务
+sudo cp deploy/systemd/fwh-agent-mcp.service /etc/systemd/system/  # 首次
+sudo systemctl daemon-reload && sudo systemctl enable --now fwh-agent-mcp
+curl -s http://127.0.0.1:8091/healthz                            # 返回 200 即可
+journalctl -u fwh-agent-mcp -f                                   # 查看日志
+```
+
+### 2.3 写工具服务(二期,可选)
+
+复制一份 unit 为 `fwh-agent-mcp-ops.service`,启动参数改为 `-set=ops -listen=127.0.0.1:8092`,令牌可以与只读服务相同或单独生成。
+
+## 3. 同步资料库
+
+AgentServer 开启了 `ProtectHome`,读不到 `/home` 下的 Wiki,需要拷贝到 `/srv/agent-server/knowledge/`:
+
+```bash
+deploy/scripts/sync_agent_knowledge.sh                    # 需要 sudo
+AGENT_KB_DRY_RUN=y deploy/scripts/sync_agent_knowledge.sh # 预演
+```
+
+源目录可用环境变量覆盖(`WIKI_DOCS_DIR`、`DATA_KB_DIR`),说明见脚本头部。同步采用**手动**方式:Wiki 或数据知识库更新后,需要时再运行一次上面的命令,不配置定时任务。
+
+注意:`docs/knowledge` 目前是空目录,数据知识库需要补充内容后才有意义。
+
+## 4. 部署技能 `fwh-assistant`
+
+技能源码在本仓库 `deploy/agent-assistant/skills/fwh-assistant/`(SKILL.md、references/、templates/company-template.pptx),不放进 AgentServer 仓库。
+
+AgentServer 目前没有自定义技能的入口,需求见 AgentServer 仓库 `docs/design/12-custom-skills.md`。该功能实现后,把这个目录作为技能源导入(或打包为 zip 上传),在 Profile 中勾选 `custom-skills:fwh-assistant`。在此之前 Profile 不加载该技能。
+
+## 5. AgentServer 后台配置
+
+打开 `http://127.0.0.1:8787/admin/`。
+
+1. **概览**:确认 Claude 已登录(未登录时点“登录”)。
+2. **MCP 服务 › 添加**:
+
+   | 项 | 只读服务 | 写服务(二期) |
+   | --- | --- | --- |
+   | 名称 | `fwh` | `fwhops` |
+   | 标题 | FWH 业务数据 | FWH 业务操作 |
+   | 传输 | 远程 HTTP | 远程 HTTP |
+   | 地址 | `http://127.0.0.1:8091/mcp` | `http://127.0.0.1:8092/mcp` |
+   | 请求头 | `Authorization` = `Bearer <令牌>` | 同左 |
+
+   点“测试连接”能列出工具后保存。只读服务的工具策略保持默认“直接调用”;写服务保持“跟随权限模式”。名称必须用 `fwh` / `fwhops`,技能中的工具名依赖它。
+3. **资料库 › 新增**:
+   - `fwh-wiki`:路径 `/srv/agent-server/knowledge/fwh-wiki`,说明“FranklinWH 产品文档:硬件(aGate/aPower 等)、软件(PCS、调度、OTA 等)、系统(告警码表、事件列表、系统配置)”,只读;
+   - `fwh-data-knowledge`:路径 `/srv/agent-server/knowledge/fwh-data-knowledge`,说明“ToolBox 数据字段与业务术语说明”,只读(`docs/knowledge` 有内容后再登记)。
+4. **Profiles › 新建 `fwh-assistant`**:
+   - 引擎 Claude,模型按需(如 sonnet);
+   - 权限模式“需审批”;
+   - 技能:`report-conventions`;AgentServer 支持自定义技能后加勾 `custom-skills:fwh-assistant`(§4);
+   - 资料库:`fwh-wiki`(以及有内容后的 `fwh-data-knowledge`);
+   - MCP 服务:`fwh`;
+   - 文件访问:受限(只写工作区);
+   - 系统提示词(可选):“你是 FranklinWH ToolBox 的 AI 助手,回答 FranklinWH 储能产品与运维数据相关的问题。用中文回答。”
+5. **Profiles › 复制为 `fwh-assistant-ops`**(二期):在上一个基础上加勾 MCP 服务 `fwhops`。
+6. **Playground**:选 `fwh-assistant`,问“站点 <SN> 的概况和最近 7 天告警”,确认工具能调用、不弹审批。
+7. **API Keys › 签发**:名称 `fwh-toolbox`,可用 Profile 勾选 `fwh-assistant`(及 `fwh-assistant-ops`),默认 `fwh-assistant`。Key 只显示一次,记下来;记下两个 Profile 的 ID(`prf_…`,Profiles 页可见)。
+
+## 6. ToolBox 后端配置
+
+`/var/www/fwh-toolbox/server/.env` 增加:
+
+```
+AGENT_ENABLED=true
+AGENT_SERVER_URL=http://127.0.0.1:8787
+AGENT_SERVER_API_KEY=ask_xxx                       # 第 5.7 步签发的 Key
+AGENT_PROFILE_DEFAULT=prf_xxx                      # fwh-assistant 的 ID
+AGENT_PROFILE_BY_ROLE=                             # 可选:角色:ProfileID,逗号分隔,如 ops:prf_yyy
+AGENT_ALLOWED_ROLES=                               # 可选:为空表示所有登录用户可用
+```
+
+重启:`sudo systemctl restart fwh-toolbox-server`。前端重新构建发布后,左侧菜单出现“AI 助手”。
+
+## 7. 反向代理:关闭流式接口的缓冲
+
+对话事件流的对外路径为 `/fwh-toolbox/api/agent/v1/sessions/<会话ID>/stream`(SSE)。中间任何一层缓冲都会导致回答一段一段地出现或很久才出现。
+
+### 7.1 Apache(`/etc/apache2/sites-available/006-fwh-toolbox.conf`)
+
+现有配置:
+
+```apache
+ProxyPass       /fwh-toolbox/api http://localhost:8301/api
+ProxyPassReverse        /fwh-toolbox/api http://localhost:8301/api
+```
+
+在它**之前**加一条(Apache 按配置顺序匹配,必须放在前面):
+
+```apache
+# 新 AI 助手事件流(SSE):立即转发,长连接
+ProxyPassMatch  "^/fwh-toolbox/api/agent/v1/sessions/([^/]+)/stream$" "http://localhost:8301/api/agent/v1/sessions/$1/stream" flushpackets=on timeout=3600
+```
+
+然后 `sudo apachectl configtest && sudo systemctl reload apache2`。
+
+### 7.2 Caddy(`/etc/caddy/sites/002-lmt.zenpower.cc.caddy`、`001-link-ser.dev.caddy` 中的 fwh-toolbox 块)
+
+Caddy 对 `text/event-stream` 响应默认会立即刷新,通常不用改。若仍有缓冲,把现有块:
+
+```caddy
+handle /fwh-toolbox/* {
+        reverse_proxy localhost:8300
+}
+```
+
+改为:
+
+```caddy
+handle /fwh-toolbox/* {
+        reverse_proxy localhost:8300 {
+                flush_interval -1
+        }
+}
+```
+
+然后 `sudo caddy reload --config /etc/caddy/Caddyfile --force`。
+
+### 7.3 上传大小
+
+AgentServer 默认单文件上限 50MB;ToolBox 后端代理与 Apache 不应小于这个值(Apache 默认不限制请求体;Fiber 的 `BodyLimit` 由后端配置)。
+
+## 8. 验证清单
+
+- [ ] `curl http://127.0.0.1:8091/healthz` 返回 200;AgentServer 后台 MCP 服务 `fwh` 状态正常
+- [ ] Playground 用 `fwh-assistant` 能查询站点数据
+- [ ] ToolBox 登录后左侧有“AI 助手”,能新建会话并得到流式回答(文字逐字出现)
+- [ ] 回答中引用 Wiki 文档时能给出路径
+- [ ] 多日功率流分析时,导出文件出现在工作区 `.agent/mcp/`,上下文中没有明细
+- [ ] 用另一个账号打开别人的会话链接,提示无权访问

+ 72 - 0
deploy/agent-assistant/skills/fwh-assistant/SKILL.md

@@ -0,0 +1,72 @@
+---
+name: fwh-assistant
+description: FranklinWH(FWH)储能业务数据助手。只要问题涉及 FranklinWH 产品(aGate、aPower、aHub 等)、站点/设备 SN、告警与事件、停机、功率流、MQTT/EMS 日志、升级记录、故障录波、TOU 调度模版、SIM 卡、EMS 参数、Plane 工作项,或要基于这些数据做分析、出报告/PPT,就先读这个技能。它说明 fwh 工具怎么组合、大数据怎么处理、产出放在哪里。
+---
+
+# FranklinWH 业务数据助手
+
+你在为 FranklinWH ToolBox(内部运维分析平台)的用户解答问题。业务数据通过 MCP 工具 `mcp__gateway__fwh__*` 获取(只读);产品知识在资料库中(先用 `mcp__gateway__kb_list_roots` 查看)。
+
+## 业务背景(简要)
+
+- **aGate**:家庭能源网关,站点的主设备;**站点 SN 即 aGate 的 SN**。**aPower**:储能电池/逆变器,一个站点可有多台(N 台)。**aHub / PowerLink**:扩展设备。
+- SN 格式 `{产品代码}{版本}{工厂}{年}{周}{序号}`,产品代码 aGate=1006、aPower=1005、aHub/PowerLink=1002。不确定时用 `sn_parse`。
+- 国家 ID:0=全部、2=美国、3=澳大利亚。时间默认按站点当地时间;日期 `yyyy-MM-dd`,时间范围 `yyyy-MM-dd HH:mm:ss`。
+- 功率单位:功率流数据以工具返回说明为准(W 或 kW);TOU 模版中的功率字段是 kW、按单台 aPower 计。回答时写明单位。
+
+## 工具一览(`mcp__gateway__fwh__` 前缀省略)
+
+| 领域 | 工具 |
+| --- | --- |
+| 站点 | `site_search`(搜索/分页)、`site_get`(单站点概况:版本、网络、工作模式、当前告警数)、`site_power_flow`(单日功率流)、`site_power_flow_export`(多日导出 CSV) |
+| 告警 | `alarm_current`(当前告警)、`alarm_events`(时间范围内告警/事件)、`alarm_snapshot`(某日快照)、`alarm_stats`(全量统计)、`alarm_sites_by_code`(触发某告警码的站点) |
+| 停机 | `shutdown_stats`(某日汇总,不传日期取最新)、`shutdown_sites`(某日停机站点) |
+| 日志 | `mqtt_log_search`、`ems_log_records`、`update_log_system`(传记录 ID 看详情)、`update_log_ahub` |
+| 故障录波 | `fault_record_list`、`fault_record_detail`、`fault_record_callable`、`fault_record_call_history` |
+| 调度 | `tou_template_list`、`tou_template_get`(`env=prod/test`,详情含调度模式识别) |
+| SIM | `sim_get`(自动判断平台)、`sim_search`、`sim_usage_monthly` |
+| 其他 | `ems_param_lookup`(PID/VID 宏名与描述)、`plane_issue_search`、`plane_issue_get`、`sn_parse` |
+
+参数以工具自身的描述为准,不要臆造参数或工具名。写操作工具(`mcp__gateway__fwhops__*`)只在部分配置中提供,见 references/write-operations.md;没有这些工具时如实告诉用户无法修改数据。
+
+## 常用流程
+
+- **站点故障排查**:`site_get` → `alarm_current` → `alarm_events`(故障前后各几小时到几天)→ 需要时 `mqtt_log_search`、`fault_record_list` / `fault_record_detail` → 结合资料库中的告警说明给出判断。
+- **告警码分析**:资料库查告警码含义(`System/AlarmTable.md` 等)→ `alarm_sites_by_code` 看影响面 → 抽样 `site_get` / `alarm_events`。
+- **停机分析**:`shutdown_stats` → `shutdown_sites` → 按国家、版本等维度汇总(站点多时导出或分批,再用 Python 统计)。
+- **功率流分析**:单日用 `site_power_flow`;多日用 `site_power_flow_export`,在工作区用 pandas 分析并出图。
+- **升级问题**:`update_log_system` 找到记录 → 传记录 ID 看详情;aHub 用 `update_log_ahub`。
+- **TOU 模版**:`tou_template_list` → `tou_template_get`;字段与调度模式见 references/tou-template.md。对比生产服与测试服时分别用 `env=prod`、`env=test` 取详情再逐策略、逐时段比较。
+- **SIM 卡**:`sim_get` / `sim_search`;用量单位以工具说明为准,月度明细用 `sim_usage_monthly`。
+
+## 数据量规则(必须遵守)
+
+- **不要把大批量明细读进上下文**再手工过滤、统计。筛选、分页、时间范围、limit 都在调用工具时传好,只取需要的数据。
+- 工具返回 `truncated=true` 时,说明结果被截断:缩小时间范围或条件,或改用 `*_export`。
+- `*_export` 和返回文件的工具会把数据保存到工作区 `.agent/mcp/`,结果里只有路径。用 Python(pandas / duckdb)读取该文件计算,**不要用 Read 把整个文件读进上下文**;需要看结构时只读前几行。
+- 判断为“大量数据”的标准:预计超过数百行或数十 KB,或需要跨多条记录聚合、排序、去重,或需要遍历一段时间的时序明细。
+- 找不到合适的工具时,不要用其他方式拼凑结果,按下面的格式说明缺口:
+
+  > 这个操作暂时无法完成,因为缺少可用工具。
+  > - 缺少的能力:<例如“按站点与时间范围在服务端聚合功率流的工具”>
+  > - 它的作用:<在这个任务中负责什么>
+  > - 建议:<补充工具 / 缩小范围 / 由你提供已汇总的数据 / 改用相近工具>
+
+## 回答规范
+
+- 中文回答;数据结果优先用表格;关键数字给出来源(哪个工具、什么条件、什么时间范围)和计算口径。
+- 告警码、事件码要结合资料库解释含义;资料库里查不到时说明“未在资料中找到”。
+- 不确定的推断明确标注为推断;不要编造站点数据。
+- 工具报“暂不可用”或超时:告诉用户是哪个数据源不可用,建议稍后重试,不要反复重试同一调用。
+
+## 产出文件
+
+- 最终交付物写到 `outputs/`,中间脚本和数据放在工作区其他位置;报告、图表、Word/Excel/PDF 的写法遵循 `report-conventions` 技能。
+- 用户要求“用公司模板 / company template / 用模板做 PPT”时,见 references/ppt-template.md。
+- 产出文件会自动显示为文件卡片,回复中说明文件名和内容即可,不需要输出下载链接或特殊标记。
+
+## 参考文件
+
+- references/tou-template.md — TOU 模版结构、字段字典、日类型配对、调度模式 A~H 与扩展模式的识别规则。
+- references/ppt-template.md — 公司 PPT 模版的使用方法。
+- references/write-operations.md — 写操作工具(`fwhops`)的使用规则。

+ 23 - 0
deploy/agent-assistant/skills/fwh-assistant/references/ppt-template.md

@@ -0,0 +1,23 @@
+# 公司 PPT 模版
+
+用户提到“公司模板 / company template / 用模板生成 PPT”时使用。
+
+## 模版位置
+
+模版随技能分发:本技能目录下的 `templates/company-template.pptx`。先把它复制到工作区再编辑,不要修改技能目录里的原文件:
+
+本技能目录的绝对路径就是读取本文件时的路径去掉 `references/ppt-template.md`。例如:
+
+```python
+import shutil
+template = "<本技能目录>/templates/company-template.pptx"
+shutil.copy(template, "work/deck.pptx")
+```
+
+## 生成规则
+
+- 用 python-pptx 打开复制后的文件,优先使用模版中已有的幻灯片布局(`prs.slide_layouts`),先列出布局名称再选用。
+- 保持模版的配色和字体,不要自行指定颜色、字体。
+- 第一页用标题布局,最后一页用结束/感谢布局;删除模版中用不到的示例页。
+- 图表先用 matplotlib 导出 PNG(遵循 `report-conventions`),再插入幻灯片。
+- 成品保存到 `outputs/`,文件名用中文描述性名称;需要 PDF 时用 `mcp__gateway__convert_document` 转换。

+ 201 - 0
deploy/agent-assistant/skills/fwh-assistant/references/tou-template.md

@@ -0,0 +1,201 @@
+# TOU 调度模版:结构、字段与调度模式
+
+一个模版 = 模版主体(`template`)+ 一个或多个策略(`strategyList`);每个策略 = 一组月份 × 日类型 + 覆盖一整天的时段列表(`detailVoList`)。云端字段有拼写问题(`derSchdule`、`eletic*`、`ccociate*`、`mookRunCount`),以原样字段名读写。
+
+- 读取:`mcp__gateway__fwh__tou_template_list` → `mcp__gateway__fwh__tou_template_get`,`env=prod`(生产服,默认)或 `env=test`(测试服)。
+- 写入只在测试服,且只有配置了写工具时才能做,见 write-operations.md。
+- 协议原文(设备侧语义)在 Wiki 资料库 `Software/ScriptingForSchedulingConfiguration.md`,只作参考。
+
+## 字段字典
+
+### 1. `template`(模板主体 · `FwhTestTouTemplateInfo`)
+
+| 字段                 | 类型              | 含义 / 取值                                                                                             |
+| -------------------- | ----------------- | ------------------------------------------------------------------------------------------------------- |
+| `countryId`          | number            | 国家 ID                                                                                                 |
+| `provinceId`         | number            | 州/省 ID                                                                                                |
+| `derSchdule`         | string            | DER 调度配置                                                                                            |
+| `eleCompanyFullName` | string            | 电力公司全称                                                                                            |
+| `electricCompany`    | string            | 电力公司(简称)                                                                                        |
+| `gridType`           | string            | 电网类型 / 套餐类型                                                                                     |
+| `electricityType`    | number            | 电价类型:`1`分时电价 `2`阶梯电价 `3`固定电价 `4`分时&阶梯电价 `5`BB套餐 `6`Peak Demand                 |
+| `name`               | string            | 模板/策略表名称                                                                                         |
+| `mookRunCount`       | number            | 模式执行次数(loop):`0`不限次数 `1`执行一次                                                             |
+| `priority`           | number            | 模式优先级,默认`0`;`0` 最低,数字越大优先级越高                                                       |
+| `env`                | number(位域)      | 模式使用条件,默认`0`;bit0~1 光伏;bit2~3 馈网;bit4~5 电网充电                                        |
+| `templateId`         | number            | 模板 ID。新增时省略,更新时必填 |
+| `sourceType`         | number(必填)      | 模板类型。**必填,当前仅允许 `0`系统模板**(`1`API模板 `2`用户推荐 暂不支持写入)                       |
+
+### 2. `strategyList[]`(策略 · `FwhTestTouTemplateStrategy`)
+
+一个策略 = 一组「季节/日类型」+ 覆盖一整天的时段列表。
+
+| 字段           | 类型         | 含义 / 取值                                                      |
+| -------------- | ------------ | ---------------------------------------------------------------- |
+| `ccociateType` | number       | 关联类型:`1`每天 `2`工作日 `3`周末/节假日(与 `dayType`/`dayName` 配套,见下方「日类型配套逻辑」) |
+| `ccociateSn`   | string       | 关联序号:每天为`""`;工作日与其配对的周末/节假日共用同一序号(从 `"0"` 递增) |
+| `detailVoList` | array        | 时段明细表,**所有时段必须覆盖一整天**(见下表)                 |
+| `seasonName`   | string       | 季节名,用于标识策略范围,可自定义,如`summer`、`All months`     |
+| `month`        | string       | 逗号分隔的月份,如`1,2,3,4,5,6,7,8,9,10,11,12`                   |
+| `dayName`      | string       | 日类型可读名:每天=`Every day` 工作日=`Weekdays` 周末/节假日=`Weekends,Holidays`(与 `dayType` 对应) |
+| `dayType`      | number       | 日类型编码:`1`工作日 `2`周末/节假日 `3`每天(可读名见 `dayName`) |
+
+#### 日类型配套逻辑(`ccociateType` / `ccociateSn` / `dayType` / `dayName`)
+
+这四个字段必须成套填写,用于表示策略的「日类型」及工作日与周末/节假日的配对关系:
+
+| 日类型         | `ccociateType` | `ccociateSn`                 | `dayType` | `dayName`             |
+| -------------- | :------------: | ---------------------------- | :-------: | --------------------- |
+| 每天           | `1`            | `""`                         | `3`       | `Every day`           |
+| 工作日         | `2`            | 序号(从 `"0"` 递增)          | `1`       | `Weekdays`            |
+| 周末或节假日   | `3`            | 同组工作日的序号             | `2`       | `Weekends,Holidays`   |
+
+- **每天**是独立策略,无需配对,`ccociateSn` 恒为空串 `""`。
+- **工作日(2) 与 周末/节假日(3) 成对出现**,通过**相同的 `ccociateSn`** 关联为同一组。
+- `ccociateSn` 是从 `"0"` 开始的序号字符串:第一对「工作日+周末/节假日」用 `"0"`,第二对用 `"1"`,以此类推(每新增一对 +1)。
+- 因此,用「每天」时只有 1 条策略;用「工作日/周末」时至少成对出现 2 条策略(一条 `type=2`、一条 `type=3`,共用同一 `ccociateSn`)。
+
+**策略级电价字段(可选)**:以下字段描述该策略各时段的电价,**类型为数值(number);未设置时直接省略该 key**(不要传 `""` 或 `null`)。
+
+| 字段                     | 类型   | 含义                    |
+| ------------------------ | ------ | ----------------------- |
+| `eleticRatePeak`         | number | 峰时购电电价            |
+| `eleticRateShoulder`     | number | 平时购电电价            |
+| `eleticRateValley`       | number | 谷时购电电价            |
+| `eleticRateSharp`        | number | 尖峰购电电价            |
+| `eleticRateSuperOffPeak` | number | 超低谷购电电价          |
+| `eleticRateGridFee`      | number | 电网附加费              |
+| `eleticSellPeak`         | number | 峰时卖电(上网)电价    |
+| `eleticSellShoulder`     | number | 平时卖电(上网)电价    |
+| `eleticSellValley`       | number | 谷时卖电(上网)电价    |
+| `eleticSellSharp`        | number | 尖峰卖电(上网)电价    |
+| `eleticSellSuperOffPeak` | number | 超低谷卖电(上网)电价  |
+
+### 3. `detailVoList[]`(时段明细 · `FwhTestTouTemplateDetailVo`)
+
+同一策略内的所有时段必须铺满一整天:每个 `endHourTime` 等于下一段的 `startHourTime`,第一段是 `00:00`,最后一段是 `24:00`。**数值字段传数字**(`waveType`、`solarCutoff` 每段必填;功率/SOC/使能等为可选);**可选数值字段未设置时直接省略该 key**(不要传 `""` 或 `null`)。字符串字段(时间、名称、优先级、`powerOffApower`)传字符串。
+
+| 字段               | 类型              | 含义 / 取值                                                                                                                                                       |
+| ------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `startHourTime`    | string            | 开始时间,格式`HH:mm`,如 `06:30`                                                                                                                                 |
+| `endHourTime`      | string            | 结束时间,格式`HH:mm`;需与下一段 `startHourTime` 衔接,所有时段覆盖一整天                                                                                        |
+| `waveType`         | number(flag,必填) | 峰平谷定义:`0`谷/off-peak `1`平/mid-peak `2`峰/on-peak `3`尖峰/super-on-peak `4`低谷/super-off-peak                                                              |
+| `name`             | string            | 时段名,一般为`supper-off-peak`、`off-peak`、`mid-peak`、`on-peak`、`supper-on-peak` 等                                                                           |
+| `briefDescribe`    | string            | 时段描述,如`Self-consumption`                                                                                                                                    |
+| `solarPriority`    | string            | **光伏供电优先级**,格式 `"市电,负载,FHP"`,如 `"1,3,2"`;优先级 `0/1/2/3`,可相同(表示按比例/功率分配),`0`=不涉及,数字越大优先级越高                           |
+| `loadPriority`     | string            | **负载取电优先级**,格式 `"市电,光伏,FHP"`,如 `"1,3,2"`;优先级 `0/1/2/3`,可相同(按比例/功率分配),`0`=不使用此源,数字越大优先级越高                           |
+| `gridDischargeMax` | number(kW,0.1,可选) | 电池最大允许馈网功率;可选,默认`0` 不能馈网;`0`不可馈网,`-1`不限制                                                                                             |
+| `gridChargeMax`    | number(kW,0.1,可选) | 电网允许最大充电功率;可选,默认`0` 不允许;`0`不允许,`-1`不限制                                                                                                 |
+| `chargePower`      | number(kW,0.1,可选) | 固定电池充电功率,按单台功率计算;可选,默认`-1` 未设置                                                                                                           |
+| `dischargePower`   | number(kW,0.1,可选) | 固定电池放电功率,按单台功率计算;可选,默认`-1` 未设置                                                                                                           |
+| `chargeMax`        | number(kW,0.1,可选) | 电池最大允许充电功率,按单台功率计算;`-1`无限制,默认 `-1`                                                                                                       |
+| `dischargeMax`     | number(kW,0.1,可选) | 电池最大允许放电功率,按单台功率计算;`-1`无限制,默认 `-1`                                                                                                       |
+| `gcaoMax`          | number(kW,0.1,可选) | GCAO(grid charge after an outage):停电后补电至预留 SOC 的单台市电取电功率;可选;`0`不允许,`-1`不限制;默认未设置(`-3`);使能后未设值则为 `5kw*N`(用 `-2` 标识) |
+| `gridFeedMax`      | number(kW,0.1,可选) | 电网允许最大馈网功率;可选,默认`-2`;`0`不允许,`-1`不限制,`-2`未设置                                                                                           |
+| `gridMax`          | number(kW,0.1,可选) | 电网允许最大取电功率;可选,默认`-1` 不限制;`0`不允许,`-1`不限制                                                                                                |
+| `minDischargeSoc`  | number(%,整数,可选) | 放电截止 SOC;达到此 SOC 后电池不放电,可选,默认`0%`;若 `reserved_soc` 大于此值则以 `reserved_soc` 为准                                                         |
+| `maxChargeSoc`     | number(%,整数,可选) | 充电截止 SOC;可选,默认`100%`                                                                                                                                    |
+| `heatEnable`       | number(可选)      | 充电加热使能:`0`未设置(默认) `1`不使能 `2`使能                                                                                                                   |
+| `powerOffApower`   | string            | 主动关闭 APower;可选,`0`不控制(默认) `1`关机 `2`开机(预留)                                                                                                      |
+| `rampTime`         | number(秒,可选)   | 爬坡时间,`0~300`;默认 `0`,BB套餐默认 `300`                                                                                                                     |
+| `offGrid`          | number(可选)      | 主动断开市电继电器:`0`不控制(默认) `1`断开 `2`闭合(预留)                                                                                                         |
+| `solarCutoff`      | number(必填)      | 主动断开光伏继电器:`0`不控制(默认) `1`断开 `2`闭合(预留)                                                                                                         |
+
+**要点**:
+
+- `solarPriority` / `loadPriority` 是 3 值字符串,顺序 `[市电, 负载或光伏, FHP/aPower]`;`0`=不参与,数字越大优先级越高,相同非 0 值=按比例分配。
+- 功率字段单位 **kW、精度 0.1**,按单台 aPower 计(总功率 = 值 × 台数 N):`gridChargeMax`、`gridDischargeMax`、`gridFeedMax`、`gridMax`、`chargePower`、`dischargePower`、`chargeMax`、`dischargeMax`、`gcaoMax`。常见哨兵值:`-1`不限制、`0`不允许/不可、`-2`未设置(仅 `gridFeedMax`/`gcaoMax`)、`-3`未设置(仅 `gcaoMax`)。
+
+### 完整 detailVoList 单段示例
+
+未设置的可选功率/SOC 字段直接省略(下例只保留有值字段):
+
+```json
+{
+  "startHourTime": "00:00",
+  "endHourTime": "11:00",
+  "waveType": 1,
+  "name": "mid-peak",
+  "briefDescribe": "Self-consumption",
+  "solarPriority": "1,3,2",
+  "loadPriority": "1,3,2",
+  "solarCutoff": 0,
+  "powerOffApower": ""
+}
+```
+
+带部分功率设置的示例段(有值传数字,其余省略):
+
+```json
+{
+  "startHourTime": "18:00",
+  "endHourTime": "21:00",
+  "waveType": 2,
+  "name": "on-peak",
+  "briefDescribe": "Zero Import",
+  "solarPriority": "9,9,9",
+  "loadPriority": "9,9,9",
+  "gridDischargeMax": 0.1,
+  "gridFeedMax": 0.1,
+  "dischargePower": 10,
+  "gridMax": 0,
+  "gcaoMax": 0,
+  "heatEnable": 1,
+  "solarCutoff": 0,
+  "powerOffApower": ""
+}
+```
+
+## 调度模式识别
+
+
+
+### 调度模式定义表
+
+优先级顺序:光伏 `[市电, 负载, aPower]`,负载 `[市电, 光伏, aPower]`。
+
+| 模式 | 名称 | 光伏优先级 | 负载优先级 | 语义 |
+| --- | --- | --- | --- | --- |
+| A | Excess solar export to grid(光伏余电馈网) | `1,2,0` | `1,3,2` | 光伏优先供载,剩余馈网;不足由 aPower 补,再不足由市电补;aPower 不充电 |
+| B | aPower standby(电池待机) | `1,2,0` | `1,2,0` | 光伏优先供载,剩余馈网;不足由市电补;aPower 不充不放 |
+| C | Excess solar charge aPower(光伏余电充电) | `1,3,2` | `1,2,0` | 光伏优先供载,剩余给 aPower 充电,再剩余馈网;不足由市电补;aPower 不放电 |
+| D | Self-consumption(自发自用) | `1,3,2` | `1,3,2` | 光伏优先供载,剩余充电,再剩余馈网;不足由 aPower 补,再不足由市电补 |
+| E/G | aPower charge from solar / grid(优先充电) | `1,2,3` | `1,2,0` | 光伏优先给 aPower 充电,剩余供载,再剩余馈网;不足由市电补;aPower 不放电。用户在 PCS 设置中使能电网充电时表现为 G |
+| F/H | aPower supply home / export to grid(优先供载) | `1,2,0` | `1,2,3` | aPower 优先供载,光伏馈网;不足由光伏补,再不足由市电补;aPower 不充电。用户在 PCS 设置中使能电池馈网时表现为 H |
+| — | 未设置优先级 | `""` | `""` | 两个优先级同时为空字符串 |
+| 强制充电 | aPower charge from grid / Backup only(强制充电 / 仅备电) | `""` | `""` | 电网不限充电,aPower 以固定功率充电并保持满电 |
+| 强制放电 | aPower forced discharge(强制放电) | `""` | `""` | 不限 aPower 馈网,aPower 以固定功率放电至预留 SOC |
+| Zero Import | Zero Import(零取电,特殊模式) | `9,9,9` | `9,9,9` | 时段内尽量不从电网取电,由 aPower 放电供载,馈网限制在极小值;按原始值精确匹配,不归一化 |
+
+写入时使用表中的数值。E/G、F/H 各是一个模式:E 变 G、F 变 H 取决于模版之外用户的 PCS 设置(电网充电使能、电池馈网使能),模版中统一称 E/G、F/H。
+
+> **补充信息(仅用于理解 Zero Import,不作为需求)**:Zero Import 来自澳洲 GloBird 的 ZEROHERO 套餐(参考模版 4477)。套餐条款:晚高峰(当地时间 18:00–21:00,每天)每小时从电网取电少于 0.03 kWh 即视为“Zero”;另有 ZEROCHARGE 功能,按 28 天账单周期每天限 50 kWh(属于套餐规则,由普通时段参数实现,不是调度模式)。
+
+### 扩展模式的参数
+
+使用扩展模式时按下表设置:“固定”参数取表中的值不可改;“必填”参数必须有值(可调整数值);“初始值”参数可调整或省略。
+
+| 模式 | 参数(单位 kW,按单台 aPower) | 来源 |
+| --- | --- | --- |
+| 强制充电 | 固定:优先级 `""`/`""`;必填:`chargePower=8`(单台最大充电功率)、`gridChargeMax=-1`(默认不限) | wiki《常规调度配置·仅备电》(原文 8000 W)、`tou-template.md` |
+| 强制放电 | 固定:优先级 `""`/`""`;必填:`dischargePower=10`(单台最大放电功率)、`gridDischargeMax=-1`(默认不限) | wiki《常规调度配置·强制aPower放电》(原文 10000 W) |
+| Zero Import | 固定:优先级 `9,9,9`/`9,9,9`、`gridDischargeMax=0.1`、`gridFeedMax=0.1`;必填:`dischargePower=10`;初始值:`gridMax=0`、`gcaoMax=0`、`heatEnable=1` | 云端 `dispatchId=9` |
+
+识别顺序(解析时):
+
+1. 按原始值精确匹配特殊模式(Zero Import:`9,9,9`/`9,9,9`);
+2. 两个优先级都为空时:只设置了 `chargePower` → 强制充电;只设置了 `dischargePower` → 强制放电;两者都没设置 → 未设置优先级;两者都设置了 → 自定义并标出异常;
+3. 归一化后匹配 A~H;
+4. 都不匹配 → 自定义(只有一个优先级为空也归为自定义并标出异常)。
+
+“设置了”指字段有值且不为 `-1`(未设置的哨兵值)。Zero Import 只按优先级识别;如果已有数据的固定参数与定义不一致(如 `gridFeedMax` 不是 0.1),仍识别为 Zero Import,但标出不一致,且在用户未改动该时段时原样保留。识别结果只看优先级和上述字段,A~H 时段上额外设置的功率参数作为高级参数显示,不改变模式。
+
+## 写入规则(测试服)
+
+1. 每个策略的时段覆盖 00:00→24:00,首尾衔接、无空档、无重叠,格式 `HH:mm`。
+2. 工作日与周末/节假日成对,`ccociateSn` 相同;每天类型 `ccociateSn` 为 `""`。
+3. 月份在各策略间不重叠。
+4. 数值字段传数字;可选数值字段未设置时省略该 key,不传 `""` 或 `null`。功率单位 kW(精度 0.1),按单台 aPower 计;SOC 0~100;`rampTime` 0~300。
+5. 优先级只取调度模式定义表中的值,或两个同时为空;已有的非标准值(如 `9,9,9`)在用户没有要求修改该时段时原样保留。
+6. `template.sourceType` 只能为 `0`(系统模版);`template.templateId` 新增时省略、更新时必填。
+7. 写入前向用户摘要改动并取得确认;写入后用 `tou_template_get`(`env=test`)回读核对。

+ 17 - 0
deploy/agent-assistant/skills/fwh-assistant/references/write-operations.md

@@ -0,0 +1,17 @@
+# 写操作工具(`mcp__gateway__fwhops__*`)
+
+只在勾选了 `fwhops` 服务的配置中提供。每次调用都会弹出审批卡片,由用户确认后才执行;被拒绝时停止该操作,按用户给出的原因调整,不要换个方式绕过。
+
+工具以服务账号身份执行,业务系统中记录的操作人是“AI 助手”,不是当前用户。
+
+| 工具 | 用途 | 规则 |
+| --- | --- | --- |
+| `feedback_create` | 在 Plane 创建问题反馈 / 需求 | 调用前先向用户摘要标题、类型(bug / feature)、优先级和描述;描述中写清背景、复现步骤或期望效果 |
+| `tou_template_save` | 新增或更新**测试服** TOU 模版 | 只支持测试服;先按 tou-template.md 的规则构建并自检(时段覆盖整天、日类型成对、数值类型正确),向用户摘要“新增/更新、模版名、地区、策略与关键时段”,保存后用 `mcp__gateway__fwh__tou_template_get`(`env=test`)回读核对 |
+| `site_info_refresh` | 触发站点信息表从 FWH 平台重新同步 | 同步约需 1.5 小时;已在同步中时不要重复触发 |
+
+通用规则:
+
+1. 调用前在回复中说明将要写入什么、写到哪个环境,再调用工具(审批卡片会同时显示参数)。
+2. 写操作失败时把错误原样告诉用户,不要自动重试。
+3. 没有这些工具时,告诉用户当前配置不支持修改数据,可以到 ToolBox 对应页面操作。

BIN
deploy/agent-assistant/skills/fwh-assistant/templates/company-template.pptx


+ 13 - 0
deploy/config.env.example

@@ -103,6 +103,19 @@ PLANE_WEB_LABEL_FEEDBACK="CHANGE_ME"
 KIRO_RUN_AS_USER=jerry-remote
 KIRO_HOME=/home/jerry-remote
 
+# ---------- 新 AI 助手(AgentServer,可选)----------
+# 见 docs/ai-assistant-agentserver/technical-design.md §3.2;未开启时菜单与 /agent/* 接口不可用
+# AGENT_ENABLED=true
+# AGENT_SERVER_URL=http://127.0.0.1:8787
+# TODO: [必填] AgentServer 后台签发的平台 API Key(只放后端,勿提交真实值)
+# AGENT_SERVER_API_KEY="CHANGE_ME"
+# 默认 Profile;留空使用 API Key 的默认 Profile
+# AGENT_PROFILE_DEFAULT=prf_xxx
+# 可选:角色 → Profile,按顺序取第一个命中的
+# AGENT_PROFILE_BY_ROLE=ops:prf_yyy,admin:prf_zzz
+# 可选:允许使用的角色,逗号分隔;留空表示所有登录用户
+# AGENT_ALLOWED_ROLES=
+
 # ---------- SIM 卡每日流量日报(可选)----------
 # TODO: [可选] 钉钉自定义机器人 Webhook;留空则不统计、不发送日报。
 # 不要把真实 access_token 或加签密钥提交到仓库。

+ 99 - 0
deploy/scripts/sync_agent_knowledge.sh

@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+# =============================================================================
+# FranklinWH Toolbox - 新 AI 助手资料库同步脚本(单向:源 → AgentServer 资料库目录)
+# =============================================================================
+# 作用:把产品 Wiki 与数据知识库同步到 AgentServer 的资料库目录,供后台“资料库”登记使用。
+#   AgentServer 以 agentsvc 运行且开启 ProtectHome,看不到 /home 下的源目录,
+#   所以需要拷贝到 /srv/agent-server/knowledge/ 下。
+#
+# 同步内容(rsync --delete,目标目录与源保持一致):
+#   * FranklinWH-Wiki client/docs  → ${KNOWLEDGE_ROOT}/fwh-wiki
+#       只同步 *.md;排除 .vitepress / public / casdoor / graphify-out / node_modules
+#   * FranklinWH-ToolBox docs/knowledge → ${KNOWLEDGE_ROOT}/fwh-data-knowledge
+#       同步全部文件
+# 权限:目录属组 agentsvc、setgid,文件 0640、目录 2750(agentsvc 只读即可)。
+#
+# 用法:
+#   ./sync_agent_knowledge.sh                       # 执行同步
+#   AGENT_KB_DRY_RUN=y ./sync_agent_knowledge.sh    # 预演,只打印 rsync 将做的改动
+#
+# 环境变量:
+#   WIKI_DOCS_DIR      Wiki 文档源目录   (默认 /home/jerry-remote/web/FranklinWH-Wiki/client/docs)
+#   DATA_KB_DIR        数据知识库源目录  (默认 <repo>/docs/knowledge)
+#   KNOWLEDGE_ROOT     资料库根目录      (默认 /srv/agent-server/knowledge)
+#   AGENT_SVC_GROUP    AgentServer 运行组 (默认 agentsvc)
+#   AGENT_KB_DRY_RUN   只打印不落盘 (y/n) (默认 n)
+#   AGENT_KB_USE_SUDO  非 root 时是否经 sudo 写入 (y/n) (默认 y;目标目录自己可写时可设 n)
+#
+# 同步方式:手动。Wiki 或数据知识库更新后,需要时手动运行一次本脚本(不配置定时任务)。
+# =============================================================================
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+
+WIKI_DOCS_DIR="${WIKI_DOCS_DIR:-/home/jerry-remote/web/FranklinWH-Wiki/client/docs}"
+DATA_KB_DIR="${DATA_KB_DIR:-${REPO_DIR}/docs/knowledge}"
+KNOWLEDGE_ROOT="${KNOWLEDGE_ROOT:-/srv/agent-server/knowledge}"
+AGENT_SVC_GROUP="${AGENT_SVC_GROUP:-agentsvc}"
+AGENT_KB_DRY_RUN="${AGENT_KB_DRY_RUN:-n}"
+AGENT_KB_USE_SUDO="${AGENT_KB_USE_SUDO:-y}"
+
+c_blue='\033[34m'; c_green='\033[32m'; c_yellow='\033[33m'; c_red='\033[31m'; c_reset='\033[0m'
+info() { echo -e "${c_blue}[INFO]${c_reset} $*"; }
+ok()   { echo -e "${c_green}[ OK ]${c_reset} $*"; }
+warn() { echo -e "${c_yellow}[WARN]${c_reset} $*"; }
+err()  { echo -e "${c_red}[FAIL]${c_reset} $*" >&2; }
+
+# 目标目录属 agentsvc,当前用户通常没有写权限:非 root 时经 sudo 执行
+SUDO=""
+if [[ "$(id -u)" -ne 0 && "${AGENT_KB_USE_SUDO}" == "y" ]]; then
+    if ! command -v sudo >/dev/null 2>&1; then
+        err "需要 root 权限写入 ${KNOWLEDGE_ROOT},但系统中没有 sudo"
+        exit 1
+    fi
+    SUDO="sudo"
+    info "写入 ${KNOWLEDGE_ROOT} 需要 root 权限,将通过 sudo 执行(可能提示输入密码)"
+fi
+
+command -v rsync >/dev/null 2>&1 || { err "未安装 rsync(sudo apt install rsync)"; exit 1; }
+getent group "${AGENT_SVC_GROUP}" >/dev/null || { err "系统中没有组 ${AGENT_SVC_GROUP},请先安装 AgentServer(deploy/install.sh)"; exit 1; }
+[[ -d "${KNOWLEDGE_ROOT}" ]] || { err "资料库根目录不存在:${KNOWLEDGE_ROOT}(AgentServer 安装时创建)"; exit 1; }
+
+RSYNC_OPTS=(-rlt --delete --prune-empty-dirs --chmod=D2750,F0640)
+[[ "${AGENT_KB_DRY_RUN}" == "y" ]] && RSYNC_OPTS+=(--dry-run --itemize-changes)
+
+# sync_dir <源> <目标子目录名> [rsync 过滤参数...]
+sync_dir() {
+    local src="$1" name="$2"; shift 2
+    local dst="${KNOWLEDGE_ROOT}/${name}"
+    if [[ ! -d "${src}" ]]; then
+        warn "源目录不存在,跳过:${src}"
+        return 0
+    fi
+    info "同步 ${src} → ${dst}"
+    ${SUDO} mkdir -p "${dst}"
+    ${SUDO} rsync "${RSYNC_OPTS[@]}" "$@" "${src%/}/" "${dst}/"
+    if [[ "${AGENT_KB_DRY_RUN}" != "y" ]]; then
+        ${SUDO} chgrp -R "${AGENT_SVC_GROUP}" "${dst}"
+        ${SUDO} chmod 2750 "${dst}"
+        local count
+        count="$(${SUDO} find "${dst}" -type f | wc -l)"
+        ok "${name}:${count} 个文件"
+    fi
+}
+
+# Wiki:只要 Markdown;先排除无关目录,再放行目录与 *.md,其余排除
+sync_dir "${WIKI_DOCS_DIR}" "fwh-wiki" \
+    --exclude='.vitepress/' --exclude='public/' --exclude='casdoor/' \
+    --exclude='graphify-out/' --exclude='node_modules/' \
+    --include='*/' --include='*.md' --exclude='*'
+
+sync_dir "${DATA_KB_DIR}" "fwh-data-knowledge" \
+    --exclude='.DS_Store' --exclude='node_modules/'
+
+if [[ "${AGENT_KB_DRY_RUN}" == "y" ]]; then
+    ok "预演完成(未写入任何文件)"
+else
+    ok "资料库同步完成。首次使用时在 AgentServer 后台“资料库”中登记这些目录(见 deploy/agent-assistant/SETUP.md)"
+fi

+ 15 - 0
deploy/systemd/fwh-agent-mcp.service

@@ -0,0 +1,15 @@
+[Unit]
+Description=fwh-agent-mcp(新 AI 助手业务数据 MCP 服务,只读工具集)
+After=network-online.target fwh-toolbox-server.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+WorkingDirectory=/var/www/fwh-toolbox/server
+# 读取同目录 .env:AGENT_MCP_TOKEN(必填)、AGENT_MCP_LISTEN、AGENT_MCP_MAX_CONCURRENCY、AGENT_MCP_LOG_PATH
+ExecStart=/var/www/fwh-toolbox/server/fwh-agent-mcp -set=readonly -listen=127.0.0.1:8091
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target

+ 25 - 0
docs/ai-assistant-agentserver/README.md

@@ -0,0 +1,25 @@
+# 新 AI 助手(AgentServer):文档导航
+
+本目录是 ToolBox **新 AI 助手**的需求与设计。新助手基于 AgentServer(`~/web/AgentServer`,自托管的 Claude Code / Codex Agent 服务):对话、事件、文件、审批、沙箱由 AgentServer 提供,ToolBox 负责入口、鉴权、会话归属,业务数据由新建的、无用户状态的 MCP 服务 fwh-agent-mcp 提供。
+
+新助手与旧 Kiro 助手**相互独立**:新代码、新数据集合、新 MCP 服务,不修改、不复用旧助手;两者可并存,旧助手的下线另行决定。
+
+当前状态:需求与设计初稿(2026-10-24),尚未编码。AgentServer 已支持第三方 MCP 服务(ADR-0021);还需要一处通用的结果处理改动(agent-mcp 5.2)。
+
+建议阅读顺序:requirements → technical-design → ui-design → agent-mcp。
+
+## 文档职责
+
+| 文档 | 内容 | 何时读取 |
+| --- | --- | --- |
+| [requirements.md](requirements.md) | 新助手的目标、功能与非功能需求、待确认事项 | 确定做什么、讨论验收范围时 |
+| [technical-design.md](technical-design.md) | 总体架构、ToolBox 前后端新模块(会话归属与代理)、AgentServer 配置、技能、实施步骤与验收用例 | 实现 ToolBox 侧、排期时 |
+| [ui-design.md](ui-design.md) | 独立页面的路由与菜单、三栏布局、欢迎页、交互细节、主题与代码结构 | 实现或评审前端页面时 |
+| [agent-mcp.md](agent-mcp.md) | fwh-agent-mcp:定位、与旧 MCP 的区别、框架、工具清单、大数据处理、部署与接入、写工具规划 | 实现业务工具时 |
+| [legacy-kiro-assistant.md](legacy-kiro-assistant.md) | 旧助手的实现、能力清单与问题 | 核对新助手要覆盖的能力时 |
+
+AgentServer 自身的设计以其仓库 `docs/` 为准,重点参考:`docs/integration.md`、`docs/design/04-api.md`、`docs/design/08-platform-connectors.md`、`docs/adr/0021-third-party-mcp-via-gateway.md`、`docs/design/06-security.md`。
+
+## 维护方式
+
+范围变化更新 `requirements.md` 并关闭对应待确认条目;ToolBox 侧设计变化写入 `technical-design.md`,页面调整写入 `ui-design.md`,MCP 设计变化写入 `agent-mcp.md`;`legacy-kiro-assistant.md` 只描述旧助手,不随新代码更新。编码开始后另建 `implementation-status.md`。

+ 274 - 0
docs/ai-assistant-agentserver/agent-mcp.md

@@ -0,0 +1,274 @@
+# FWH Agent MCP:设计
+
+> 状态:设计初稿,2026-10-24。基于 ToolBox master `a10cbda`、AgentServer `969a993`(ADR-0021 第三方 MCP 服务)。本文描述为新 AI 助手**新建**的 MCP Server(下称 Agent MCP),与旧助手的 `service/cmd/mcp` 无关:旧 MCP 保持现状继续服务 Kiro,新 MCP 不复用其工具代码。
+
+## 1. 定位
+
+Agent MCP 是 FWH 业务数据的**无用户状态的工具服务**:
+
+- 只回答“数据是什么”,不知道、也不关心是谁在问。没有 `userId`、`sessionId` 参数,不按用户隔离数据,不记录调用人。
+- 由 AgentServer 后台“MCP 服务”登记为远程 HTTP 服务(ADR-0021),经 MCP Gateway 转接给 Agent。Gateway 用固定请求头连接、多个会话共用连接,本来就不传会话信息,与“无用户”的定位一致。
+- 谁能用、能用到什么程度,由 ToolBox 和 AgentServer 在上层控制:ToolBox 决定用户能否使用 AI 助手、创建会话时选用哪个 Profile;Profile 决定勾选哪些 MCP 服务;AgentServer 按工具策略决定是否审批。调用追溯走“ToolBox 会话归属 → AgentServer 审计(gateway_call 含参数)”,见 [technical-design.md](technical-design.md)。
+
+### 1.1 为什么不沿用旧 MCP
+
+| 旧 MCP 的做法 | 在新框架下的问题 |
+| --- | --- |
+| 工具参数里带 `userId`/`sessionId`,由模型填写(`storage_*`、反馈、批量下载 owner 等) | 身份不可信;新框架不传用户信息,参数失去意义 |
+| 大结果写到 `/tmp/fwh-toolbox-agent/results` 并返回本地路径 | AgentServer 的 Agent 在沙箱里,看不到这个目录 |
+| 文件经 MinIO `upload/`、`download/` 前缀与 Agent 交换 | 新框架的上传和产出都在 AgentServer 会话工作区 |
+| 222 个工具都没有声明 `readOnlyHint` | ADR-0021 默认策略下全部变成“跟随权限模式”,每次查询都弹审批;逐个手工设置也难以维护 |
+| 大量一对一包装接口和原始存储访问(`data_mongo_*`、`data_redis_*`、`data_pgsql_*`、Plane 全量接口) | 工具多、描述短,模型选错工具;原始访问的结果大、难控制 |
+| 只有 stdio | 只能作为本地进程接入;本地进程在 AgentServer 的 bwrap 沙箱中运行,要开放网络和读取 `.env`,不合适 |
+| 依赖 Kiro steering 中的路由表决定用哪个工具 | 新框架没有 steering,工具描述要能自解释 |
+
+## 2. 设计原则
+
+1. **无用户**:不接收、不推断调用人;写操作(若有)以固定的服务账号身份执行。
+2. **任务粒度**:一个工具对应一个排查/分析动作(“查站点概况”“查一段时间的告警事件”),在服务端完成过滤、分页、聚合,而不是暴露底层接口或存储。
+3. **结果小而明确**:默认返回紧凑结果并给出总数与是否截断;需要大量数据时用专门的导出工具,以文件形式交给 Agent(见第 5 节),不把明细塞进上下文。
+4. **自解释**:工具描述写清用途、适用场景、参数格式、单位和返回字段;不依赖外部路由表。
+5. **全部显式声明读写**:每个工具声明 `readOnlyHint`,由测试保证。
+6. **读写分离部署**:只读工具与写工具放在两个 MCP 服务中(第 7 节),Profile 按需勾选,权限边界清晰。
+
+## 3. 框架
+
+### 3.1 位置与目录
+
+放在 ToolBox 仓库,与主服务同一个 Go module,直接复用 `repositories`、`services`、`dto`、`config`。
+
+```
+service/
+  cmd/agent-mcp/main.go          入口:读取配置、初始化基础设施、启动 HTTP 服务
+  agentmcp/
+    bootstrap.go                 初始化 DB / Redis / MinIO / repositories / FWH API / Plane(与主服务相同的依赖,只取需要的部分)
+    server.go                    NewServer(set):创建 MCPServer、挂中间件、按工具集注册
+    http.go                      Streamable HTTP(无状态)+ Bearer 鉴权 + /healthz
+    kit/
+      tool.go                    工具定义 Def[T]、注册函数
+      result.go                  结果构造:Object / List / Table / Export,统一的截断与大小上限
+      errors.go                  UserError:给模型看的错误信息(参数不对、无数据、上游不可用)
+      middleware.go              日志、panic 恢复、单次超时、并发上限
+    tools/
+      site.go  alarm.go  shutdown.go  logs.go  faultrecord.go
+      tou.go   sim.go    emspara.go   plane.go  util.go
+      readonly.go                只读工具集
+      ops.go                     写工具集(第 7 节,二期)
+    agentmcp_test.go             约束测试(第 3.5 节)
+  utils/pii/                     从 cmd/mcp/tools/helper 的 pii_*.go 拷贝为独立包(旧 MCP 不改)
+```
+
+同一个二进制,用参数选择工具集:
+
+```
+fwh-agent-mcp -set=readonly -listen=127.0.0.1:8091   # 只读服务
+fwh-agent-mcp -set=ops      -listen=127.0.0.1:8092   # 写服务(二期)
+```
+
+### 3.2 工具定义
+
+使用 mcp-go v0.57(`go.mod` 当前版本)的结构体生成参数 schema(`mcp.WithInputSchema[T]`)和类型化处理函数(`mcp.NewTypedToolHandler`),参数校验交给 `server.WithInputSchemaValidation()`:
+
+```go
+// kit/tool.go
+type Def[T any] struct {
+    Name        string // <领域>_<动作>,小写下划线,≤ 40 字符
+    Title       string // 中文短标题,审批卡片与后台显示
+    Description string // 用途、适用场景、返回字段与单位
+    ReadOnly    bool
+    Handler     func(ctx context.Context, args T) (*mcp.CallToolResult, error)
+}
+
+func (d Def[T]) Register(s *server.MCPServer) {
+    s.AddTool(
+        mcp.NewTool(d.Name,
+            mcp.WithDescription(d.Description),
+            mcp.WithInputSchema[T](),
+            mcp.WithTitleAnnotation(d.Title),
+            mcp.WithReadOnlyHintAnnotation(d.ReadOnly),
+            mcp.WithDestructiveHintAnnotation(false),
+            mcp.WithOpenWorldHintAnnotation(false),
+        ),
+        mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args T) (*mcp.CallToolResult, error) {
+            return d.Handler(ctx, args)
+        }),
+    )
+}
+```
+
+一个工具的写法:
+
+```go
+// tools/alarm.go
+type alarmEventsArgs struct {
+    SN    string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+    From  string `json:"from" jsonschema:"开始时间,yyyy-MM-dd HH:mm:ss,站点当地时间"`
+    To    string `json:"to" jsonschema:"结束时间,同上;与开始时间相差不超过 31 天"`
+    Kind  string `json:"kind,omitempty" jsonschema:"alarm / event / all,默认 all"`
+    Limit int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~500,默认 100"`
+}
+
+var AlarmEvents = kit.Def[alarmEventsArgs]{
+    Name:  "alarm_events",
+    Title: "查询站点告警/事件记录",
+    Description: "查询单个站点在一段时间内的告警和事件记录,按时间倒序。用于排查某次故障前后发生了什么。" +
+        "返回字段:time(站点当地时间)、code、name、level、device、status。超过 limit 时返回 truncated=true,请缩短时间范围。",
+    ReadOnly: true,
+    Handler: func(ctx context.Context, a alarmEventsArgs) (*mcp.CallToolResult, error) {
+        from, to, err := kit.ParseRange(a.From, a.To, 31*24*time.Hour)
+        if err != nil {
+            return kit.Fail(err), nil
+        }
+        rows, err := repositories.QueryEventOrAlarmListOnTimeRange(a.SN, from, to, kindOf(a.Kind))
+        if err != nil {
+            return kit.Upstream("FWH 平台", err), nil
+        }
+        return kit.List(mapAlarmRows(rows), kit.Limit(a.Limit, 100)), nil
+    },
+}
+```
+
+处理函数只做三件事:校验与换算参数、调用 `repositories`/`services`、把上游数据映射成精简的行结构。字段映射集中写,丢弃对分析无用的字段,统一时间格式与单位。
+
+schema 由 mcp-go 依赖的 `google/jsonschema-go` 生成:`jsonschema` 标签的内容就是字段描述(不支持 `key=value` 选项),没有 `omitempty` 的字段为必填。枚举、取值范围写进描述,并在处理函数中校验(`kit.Enum`、`kit.Limit` 等辅助函数);需要在 schema 中声明枚举时,在 `Def.Register` 里对生成的 schema 做补充。AgentServer 网关只接受 `type: object` 的 inputSchema,测试中要检查。
+
+### 3.3 结果构造
+
+`kit/result.go` 提供统一的返回形式,所有工具都通过它返回:
+
+| 函数 | 用途 | 形式 |
+| --- | --- | --- |
+| `Object(v)` | 单个对象(站点概况、模版详情) | 紧凑 JSON(不缩进、省略空值) |
+| `List(rows, limit)` | 列表 | `{"total":N,"returned":n,"truncated":bool,"items":[...]}` |
+| `Table(cols, rows, limit)` | 行多、字段固定的数据 | 首行列名的 TSV 文本,比 JSON 省一半以上字符 |
+| `Export(name, csv)` | 导出工具(第 5 节) | `EmbeddedResource`(`text/csv`) |
+| `Fail(err)` / `Upstream(name, err)` | 错误 | `isError` + 一句中文说明 + 处理建议 |
+
+统一限制:单次结果(非导出)不超过 30,000 字符;超过时按行截断并标明 `truncated`,附一句“请缩小范围或使用 xxx_export”。这样不会触发 AgentServer 的 10 万字截断(那条路径会把前 10 万字仍放进上下文)。
+
+所有对象和行在序列化前经过 `utils/pii` 脱敏(规则沿用旧 MCP:用户姓名、电话、邮箱、详细地址等)。
+
+### 3.4 中间件与运行约束
+
+- 日志:工具名、参数摘要(截断到 2KB)、耗时、结果大小、是否截断、错误;写入独立日志文件,不含调用人。
+- 超时:单次调用默认 60 秒(导出工具 300 秒),超时返回 `Upstream` 错误。AgentServer 侧第三方工具超时为 10 分钟,不会先于 Agent MCP 超时。
+- 并发:全局信号量(默认 8),保护 FWH 云平台与数据库;排队超过 30 秒返回“服务繁忙”。
+- panic 恢复:返回“内部错误”,日志记录堆栈。
+
+### 3.5 约束测试
+
+`agentmcp_test.go` 注册全部工具集后检查:
+
+- 名称匹配 `^[a-z][a-z0-9_]{2,39}$`,且全局唯一。AgentServer 中引擎侧完整名称为 `mcp__gateway__<服务名>__<工具名>`,服务名用 3~6 个字符(`fwh`、`fwhops`)时总长不超过 64;
+- 每个工具都有 `title`、非空中文描述,并显式声明 `readOnlyHint`;`readonly` 工具集中不得有 `readOnlyHint=false` 的工具,`ops` 工具集中不得有只读工具;
+- inputSchema 的 `type` 为 `object`;
+- `kit.List`、`kit.Table` 的截断与 30,000 字符上限。
+
+各领域的映射函数(上游结构 → 精简行)配单测,用固定的上游 JSON 样本。
+
+## 4. 工具清单(一期,只读)
+
+按领域组织,约 30 个。“来源”列是复用的现有数据访问函数,实现时以代码为准。
+
+| 领域 | 工具 | 说明 | 来源 |
+| --- | --- | --- | --- |
+| 站点 | `site_search` | 按 SN 片段、国家、激活状态、版本搜索站点,分页 | `QuerySiteInfoList`、站点信息表 |
+| | `site_get` | 单站点概况:基本信息、版本、网络、工作模式、当前告警数 | `QuerySiteBaseInfoList`、`QuerySiteModeInfo`、`QuerySiteAlarmList` |
+| | `site_power_flow` | 单站点单日功率流(5 分钟粒度,`Table`) | `QuerySitePowerFlow` |
+| | `site_power_flow_export` | 单站点多日功率流导出(≤ 31 天,CSV) | 同上,按天循环 |
+| 告警 | `alarm_current` | 站点当前告警列表 | `QuerySiteAlarmList` |
+| | `alarm_events` | 站点一段时间内的告警/事件记录 | `QueryEventOrAlarmListOnTimeRange` |
+| | `alarm_snapshot` | 站点某日的告警快照 | `QueryAlarmSnapshotInfo`、PG 快照数据 |
+| | `alarm_stats` | 全量站点的告警统计(按日期、国家汇总) | `QueryAlarmStatistics`、告警统计服务 |
+| | `alarm_sites_by_code` | 触发某告警码的站点列表 | `QueryAlarmStatisticsFromCode` |
+| 停机 | `shutdown_stats` | 某日停机统计汇总(不传日期取最新) | 停机统计服务 |
+| | `shutdown_sites` | 某日停机站点列表 | `QuerySiteShutdownList` |
+| 日志 | `mqtt_log_search` | 站点 MQTT 日志,按命令类型与时间范围 | `QueryMqttLogListOnTimeRange` |
+| | `ems_log_records` | EMS 日志召唤记录 | FWH Pro API |
+| | `update_log_system` | 系统升级记录;传记录 ID 时返回详情 | FWH Pro API |
+| | `update_log_ahub` | aHub 升级记录 | FWH Pro API |
+| 故障录波 | `fault_record_list` | 故障录波日志列表 | FWH Pro API |
+| | `fault_record_detail` | 录波子记录 | FWH Pro API |
+| | `fault_record_callable` | 设备可召唤的录波列表 | FWH Pro API |
+| | `fault_record_call_history` | 录波召唤记录 | FWH Pro API |
+| 调度 | `tou_template_list` | TOU 模版列表,`env=prod/test` | 调度配置服务 |
+| | `tou_template_get` | TOU 模版详情:按季节 × 日类型整理时段,附调度模式识别结果 | 同上 + 调度配置文档中的解析规则 |
+| SIM | `sim_get` | 按 ICCID 查询,自动判断平台(SmartHub / Acceleronix) | SIM 卡管理服务 |
+| | `sim_search` | 按状态、平台、关键字搜索 | 同上 |
+| | `sim_usage_monthly` | 单卡某月每日用量 | 同上 |
+| EMS 参数 | `ems_param_lookup` | 按 PID / VID 查询宏名与描述 | emspara |
+| Plane | `plane_issue_search` | 按关键字、状态、项目搜索工作项(只读) | Plane V1 |
+| | `plane_issue_get` | 工作项详情(含最近评论) | Plane V1 |
+| 工具 | `sn_parse` | 解析设备 SN 的含义(型号、产地、批次等) | 旧 `util_parse_sn` 的规则(拷贝) |
+
+一期不提供:
+
+- 原始存储访问(Mongo / Redis / PG 任意查询)。确实需要时,二期增加限定集合、限定字段、只读账号、带行数上限的查询工具,或针对具体场景补任务级工具;
+- 批量下载、站点分析、站点监控配置等与 ToolBox 页面任务绑定的工具(它们的“任务属于谁”在无用户模型下没有意义);
+- 写工具(第 7 节)。
+
+## 5. 大数据与文件
+
+### 5.1 导出工具
+
+需要明细分析的场景(多日功率流、长时间告警明细、批量 SIM 用量)用 `*_export` 工具:服务端分页拉齐数据,生成 CSV,以 `EmbeddedResource`(`text/csv`,URI 如 `fwh://export/site_power_flow/SN123_20261001_20261031.csv`)返回,同时附一段文本摘要(行数、列名、时间范围、前 5 行)。导出上限 20MB,超过时要求缩小范围。
+
+### 5.2 AgentServer 需要的小改动
+
+ADR-0021 的结果处理(`mcp-gateway/server.ts` 的 `postProcess`)目前:超过 10 万字的文本保存到 `.agent/mcp/` 但前 10 万字仍放进上下文;`resource` 类型的内容原样交给引擎。为了让导出数据只落盘、不进上下文,建议在 AgentServer 增加一条通用规则:
+
+- **`resource` 内容一律保存到工作区 `.agent/mcp/`**(文件名取 URI 最后一段,`blob` 解码为二进制,`text` 原样写入),在结果中替换为“已保存到 `<路径>`(大小、MIME)”,不内联内容。
+
+这是通用行为,不含 FWH 逻辑,约 20 行改动加测试。改动之前的过渡办法:导出工具改为返回普通文本并控制在 10 万字以内(约 1,500~3,000 行),超出部分要求分段导出。
+
+### 5.3 二进制文件
+
+故障录波原始文件、日志包等二进制内容在一期不直接交给 Agent。需要时提供“解析后的结果”工具(如录波通道数据导出为 CSV),由 Agent MCP 在服务端解析;是否需要见 Q-03。上述 5.2 的改动落地后,也可以用 `blob` 资源交付原始文件。
+
+## 6. 部署与接入
+
+### 6.1 Agent MCP
+
+- systemd 单元 `fwh-agent-mcp.service`:`fwh-agent-mcp -set=readonly -listen=127.0.0.1:8091`,工作目录与主服务相同(读取同一份 `service/.env`)。
+- `.env` 新增:`AGENT_MCP_TOKEN`(Bearer 令牌)、`AGENT_MCP_MAX_CONCURRENCY`、`AGENT_MCP_LOG_PATH`。
+- 部署脚本:新增 `service/cmd/deploy_agent_mcp.sh`(构建、拷贝、重启),不改动旧 MCP 的 `deploy_mcp.sh`。
+- AgentServer 与 ToolBox 不在同一台机器时,监听内网地址,防火墙只放行 AgentServer 主机(见 requirements Q-01)。
+
+### 6.2 AgentServer 登记
+
+后台“MCP 服务” › 添加:
+
+| 项 | 值 |
+| --- | --- |
+| 名称 | `fwh` |
+| 标题 | FWH 业务数据 |
+| 传输 | 远程 HTTP,`http://127.0.0.1:8091/mcp` |
+| 请求头 | `Authorization: Bearer <AGENT_MCP_TOKEN>` |
+| 工具策略 | 全部只读,默认“直接调用” |
+
+Profile `fwh-assistant` 勾选 `fwh`。工具使用的业务说明(字段含义、常见排查流程、各工具的配合方式)写在 AgentServer 技能 `fwh-assistant` 中([technical-design.md](technical-design.md) 第 6 节);Gateway 不转发上游的 `instructions`,所以不依赖它。
+
+## 7. 写工具(二期)
+
+写工具单独部署为第二个 MCP 服务 `fwhops`(`-set=ops`),只勾选进需要它的 Profile(例如只给运维角色用的 Profile)。工具策略保持“跟随权限模式”,Profile 的权限模式为“需审批”,每次调用都弹审批卡片。
+
+候选:
+
+| 工具 | 说明 | 注意 |
+| --- | --- | --- |
+| `feedback_create` | 在 Plane 创建 Intake Issue | 提交人固定为服务账号“AI 助手”;描述中由 Agent 写明背景。需要真实提交人时由 ToolBox 页面提交,不经过 MCP |
+| `tou_template_save` | 保存测试服 TOU 模版 | 只允许测试服;写入前按调度配置需求中的规则校验,返回差异 |
+| `site_info_refresh` | 触发站点信息表更新 | 幂等;限频 |
+
+无用户模型下,写工具的“谁做的”只记录在 ToolBox 会话归属和 AgentServer 审计中,业务系统里看到的是服务账号。如果某个写操作在业务上必须归属到具体的人,就不放进 MCP。
+
+## 8. 待确认
+
+| 编号 | 问题 | 建议 |
+| --- | --- | --- |
+| Q-01 | 一期工具清单(第 4 节)是否覆盖主要使用场景,是否有遗漏的高频查询 | 按旧助手的调用日志统计高频工具补充 |
+| Q-02 | 是否需要原始存储查询工具(Mongo / PG) | 一期不做;收集到具体场景后补任务级工具 |
+| Q-03 | 故障录波、日志包等二进制数据是否需要交给 Agent 分析 | 先提供解析后的 CSV;原始文件待 5.2 改动后再评估 |
+| Q-04 | 5.2 的 AgentServer 改动是否接受 | 建议做,导出类工具依赖它 |
+| Q-05 | 写工具(第 7 节)是否需要、给哪些角色 | 一期不做;二期先上 `feedback_create` |
+| Q-06 | 是否需要访问测试服 FWH 平台的工具 | 只在调度模版上区分 `env`;其他工具只查生产服 |

+ 83 - 0
docs/ai-assistant-agentserver/legacy-kiro-assistant.md

@@ -0,0 +1,83 @@
+# 旧 AI 助手(Kiro)参考
+
+> 状态:2026-10-23 梳理,基于 master `a10cbda`。新 AI 助手与旧助手**相互独立**,不修改、不复用旧助手的代码和 MCP;本文只用于核对新助手需要覆盖的能力(第 4 节)和需要避免的问题(第 5 节)。新助手的需求见 [requirements.md](requirements.md)。
+
+## 1. 架构概览
+
+```
+浏览器(顶栏 AiChat 抽屉,自写聊天 UI)
+  │ POST /ai/chat(SSE)、/ai/sessions/*、/ai/permission、/ai/files/*
+  ▼
+ToolBox Go 后端(Fiber)
+  ACPManager:每个用户一个 kiro-cli 子进程(降权到 KIRO_RUN_AS_USER)
+  │ JSON-RPC over stdio(ACP 协议 + _kiro.dev/* 扩展)
+  ▼
+kiro-cli acp --agent fwh-toolbox-agent
+  ├─ 内置工具:read / write / grep / glob / code / shell(白名单) / web_* / knowledge / subagent …
+  ├─ knowledgeBase:FranklinWH-Wiki 文档(语义索引)
+  ├─ skills / steering:deploy/kiro/
+  └─ MCP(stdio):fwh-tools(./fwh-mcp-server,222 个工具)、drawio、officecli、markitdown
+                    │
+                    └─ PostgreSQL / MongoDB / Redis / FWH 云平台 API / Plane / SIM 平台 / MinIO
+```
+
+## 2. 代码入口
+
+| 层 | 位置 | 现状 |
+| --- | --- | --- |
+| 前端入口 | `src/layout/header/Actions/AiChat/index.vue` | 顶栏按钮 + 可拖拽宽度的 NDrawer |
+| 前端面板 | `ChatPanel.vue`、`MessageItem.vue` | 自写消息列表、工具标签、权限卡片、文件下载按钮、会话列表 |
+| 前端逻辑 | `useAiChat.ts`(916 行) | 手写 SSE 解析;`$$FILE_DOWNLOAD$$` 标记解析为下载片段;剥离 `[context]` 前缀 |
+| 前端缓存 | `chatDB.ts` | IndexedDB 缓存会话消息(服务端只有 Kiro 回放,没有完整事件流) |
+| 前端 API | `src/service/aiChat/index.ts` | 13 个接口(见第 3 节) |
+| 路由/控制器 | `service/routes/aiChatRoutes.go`、`service/controllers/aiChatControllers.go` | SSE 输出、文件上传下载(MinIO) |
+| 会话管理 | `service/services/aiChatServices.go` | 内存 map(userId → 会话 + 子进程)、Redis 活跃会话与历史列表(上限 50)、Mongo 会话记录 |
+| ACP 客户端 | `service/utils/acp/acp.go`(1569 行) | 进程管理、JSON-RPC、session/update 映射、反向请求(readTextFile/writeTextFile 走 MinIO,权限请求转前端,10 分钟超时拒绝) |
+| 文件存储 | `service/utils/filestore/`、`service/utils/agentfiles/` | MinIO `upload/`、`download/` 前缀按 userId/sessionId 隔离;`/tmp/fwh-toolbox-agent` 定时清理 |
+| MCP Server | `service/cmd/mcp/`(设计见 `DESIGN.md`) | stdio;PII 脱敏、大结果落盘到 `/tmp/fwh-toolbox-agent/results`、调用日志 |
+| Agent 配置 | `deploy/kiro/agents/fwh-toolbox-agent.json` | 工具白名单、shell 正则白名单、读写路径、MCP 定义 |
+| 技能/引导 | `deploy/kiro/skills/fwh-toolbox-{rules,workflow}/`、`deploy/kiro/steering/*.md` | 操作路由表、数据防护规则、TOU/Plane/直连查询参考、PPT/Python/文件输出规范 |
+| 部署 | `deploy/scripts/{install_kiro_cli,deploy_kiro_cli,sync_kiro_config}.sh` | 安装 kiro-cli、同步配置并替换 `__WIKI_DOCS__` 等占位符 |
+| 配置 | `service/.env`:`KIRO_RUN_AS_USER`、`KIRO_HOME`、`KIRO_DEBUG_LOG` | `service/main.go` 调用 `controllers.InitAIChat` |
+
+## 3. 后端接口
+
+| 接口 | 用途 |
+| --- | --- |
+| `POST /ai/chat` | 发消息,SSE 返回 `session_info`、`welcome`、`text`、`tool_call`、`tool_update`、`permission`、`metadata`、`done`、`error` |
+| `GET /ai/sessions/list`、`GET /ai/sessions/current` | 会话列表;当前会话的 agent / model / 上下文用量 |
+| `POST /ai/sessions/new`、`POST /ai/sessions/:id`、`GET /ai/sessions/:id/load` | 新建;切换(重启子进程并 session/load);SSE 推送回放消息 |
+| `PATCH`、`DELETE /ai/sessions/:id` | 改标题;删除(超过 50 条时调用 `kiro-cli chat --delete-session`) |
+| `POST /ai/cancel`、`POST /ai/permission` | 取消本轮;回复权限请求 |
+| `POST /ai/files/upload`、`GET /ai/files/download`、`GET /ai/files/list` | 上传(≤2MB,文本)、下载、列出 MinIO 中的上传/产出文件 |
+
+## 4. 现有能力清单
+
+新助手需要覆盖的能力(编号供 requirements 引用):
+
+| 编号 | 能力 | 实现方式 |
+| --- | --- | --- |
+| C-01 | 流式对话、思考过程 | ACP `agent_message_chunk` / `agent_thought_chunk`(思考与正文混在同一文本流) |
+| C-02 | 工具调用展示 | `tool_call` / `tool_call_update`,`acp.DescribeTool` 生成中文简述 |
+| C-03 | 业务数据查询与操作 | fwh-tools MCP:站点、告警、停机、功率流、录波、MQTT/EMS 日志、TOU 模版、SIM、Plane、Mongo/PG/Redis 直查、批量下载、站点分析、自动升级配置等 |
+| C-04 | 产品知识问答 | Kiro knowledgeBase 索引 FranklinWH-Wiki Markdown |
+| C-05 | 文件上传分析 | 上传到 MinIO,Agent 经 `readTextFile` 反向请求读取;仅文本、≤2MB |
+| C-06 | 报告/文档生成 | Python(`fwh-pyrun`)、officecli、drawio MCP 生成到 `/tmp/fwh-toolbox-agent`,再经 MCP 上传 MinIO;回复中输出 `$$FILE_DOWNLOAD$$` 标记供前端渲染下载按钮 |
+| C-07 | 写操作审批 | Kiro 权限请求 → 前端卡片 → `/ai/permission` |
+| C-08 | 会话管理 | 新建、切换、回放、改标题、删除;按 ToolBox 登录用户隔离 |
+| C-09 | 取消 | `/ai/cancel` → ACP `session/cancel`;连接断开时也取消 |
+| C-10 | 提交反馈 | MCP `service_feedback_create` → Plane Intake |
+| C-11 | 数据防护 | MCP 侧 PII 脱敏、大结果落盘;steering 禁止把大批量数据读进上下文 |
+
+## 5. 问题清单
+
+| 编号 | 问题 |
+| --- | --- |
+| P-01 | **维护成本高**:ACP 客户端、Kiro 扩展通知、会话恢复、回放、进程降权都由 ToolBox 自己维护,Kiro 协议或版本变化需要跟着改(`acp.go` 1569 行 + `useAiChat.ts` 916 行)。 |
+| P-02 | **资源模型**:每个在线用户常驻一个 kiro-cli 子进程,切换会话要重启进程;没有全局并发上限和排队。 |
+| P-03 | **身份靠提示词传递**:`[context] userId=…, sessionId=…` 拼在用户消息前,MCP 工具的 `userId`/`sessionId` 由模型自己填写,可被省略或伪造;前端还要剥离这段前缀。 |
+| P-04 | **写操作审批不完整**:`allowedTools` 中 `@fwh-tools` 整体自动信任,`service_tou_template_save`、`service_auto_update_delete_*`、`service_site_info_update_status` 等写工具调用时没有人工确认。 |
+| P-05 | **事件不完整、不可续传**:服务端只有 Kiro 的回放文本,工具调用、权限记录不落库;前端用 IndexedDB 补;SSE 断开即丢失本轮输出。 |
+| P-06 | **文件链路绕**:产出文件靠模型在回复里输出 `$$FILE_DOWNLOAD$$` 标记,漏写或写错就没有下载入口;上传只支持 2MB 文本,不支持截图、Excel、PDF。 |
+| P-07 | **沙箱弱**:隔离依赖 Kiro 的工具白名单和 shell 正则,没有 OS 级沙箱;`read` 允许 `./**`(服务目录,含 `.env`)。 |
+| P-08 | **可观测性弱**:没有按会话的用量统计、审计日志和管理后台,调试依赖 `KIRO_DEBUG_LOG` 打开 kiro-chat.log。 |

+ 98 - 0
docs/ai-assistant-agentserver/requirements.md

@@ -0,0 +1,98 @@
+# 新 AI 助手:需求
+
+> 状态:需求初稿,2026-10-24(取代 10-23 的“迁移”版本:新助手与旧 Kiro 助手相互独立,业务工具改用无用户状态的新 MCP)。实现方式见 [technical-design.md](technical-design.md) 与 [agent-mcp.md](agent-mcp.md);旧助手的能力与问题见 [legacy-kiro-assistant.md](legacy-kiro-assistant.md)。
+
+## 1. 目标与范围
+
+在 ToolBox 中新建一个基于 AgentServer 的 AI 助手:
+
+1. 业务能力覆盖旧助手的主要场景(legacy 第 4 节 C-01~C-11);
+2. Agent 运行、事件、文件、审批、沙箱都交给 AgentServer,ToolBox 只负责入口、鉴权、会话归属和业务数据工具;
+3. 业务数据工具是新建的、无用户状态的 MCP 服务;
+4. 与旧助手相互独立:不修改、不复用旧助手的代码、数据和 MCP,两者可以同时使用,旧助手的下线另行决定。
+
+**范围内**:ToolBox 新前端模块与后端模块、新 MCP 服务 fwh-agent-mcp、AgentServer 上的 Profile / 技能 / 资料库 / MCP 服务配置,以及 AgentServer 结果处理的一处通用改动(agent-mcp 5.2)。
+
+**范围外**:
+
+- 旧助手的任何改动和数据迁移(Kiro 会话不迁移);
+- 模型凭证(ToolBox 只作为 AgentServer API 的调用方);
+- 一期不做写操作工具(agent-mcp 第 7 节);
+- Codex 引擎(引擎固定 Claude,由 Profile 决定)。
+
+## 2. 功能需求
+
+### FR-01 页面与对话
+
+- 新助手以独立的大页面展示:左侧菜单新增“AI 助手”,不放在顶栏右上角;页面布局为会话栏、对话区、文件栏([ui-design.md](ui-design.md))。
+- 旧助手的顶栏入口保持不变,两者并存。
+- 流式显示正文;思考过程单独折叠。
+- 工具调用以卡片显示中文标题、参数摘要和状态(运行中 / 成功 / 失败 / 拒绝),可展开查看结果。
+- 可中断当前运行;切换标签页、关闭标签页或刷新页面都不中断运行,回到会话后从断点继续显示。
+- 运行中再次发送时提示“上一轮尚未结束”。
+
+### FR-02 会话
+
+- 会话按 ToolBox 登录用户隔离:只能看到、打开、改名、删除自己的会话。
+- 新建、切换、改名、删除;按最近活跃排序;首条消息自动生成标题(前 30 字)。
+- 打开历史会话显示完整时间线(文本、工具调用、审批、文件卡片),不依赖浏览器缓存。
+- ToolBox 或 AgentServer 重启后,历史会话可继续对话。
+
+### FR-03 使用权限
+
+- 可配置哪些角色能使用新助手(默认所有登录用户)。
+- 可按角色使用不同的 Profile(不同的工具范围、技能),在会话创建时确定。
+
+### FR-04 业务数据查询
+
+- Agent 通过 fwh-agent-mcp 查询站点、告警、停机、功率流、日志、故障录波、TOU 模版、SIM 卡、EMS 参数、Plane 工作项等数据(工具清单见 agent-mcp 第 4 节)。
+- 工具不区分使用者;查询类工具无需审批,直接调用。
+- 单次返回结果紧凑,超过上限时明确标注截断并提示缩小范围。
+- 需要大量明细时,数据以文件形式进入会话工作区,由 Agent 用 Python 分析,不进入模型上下文。
+- 保留个人信息脱敏。
+
+### FR-05 知识库问答
+
+- 检索 FranklinWH-Wiki 产品文档和 `docs/knowledge` 数据知识库;Wiki 更新后同步(频率见 Q-04)。
+- 回答引用资料时给出文档路径或标题。
+
+### FR-06 文件上传与产出
+
+- 点击、拖拽、粘贴截图上传;支持截图、CSV、Excel、PDF、Word、PPT、JSON、日志文本;单文件上限按 AgentServer 设置(默认 50MB)。
+- Agent 产出的文件自动显示为文件卡片,可下载;文件栏汇总本会话的上传与产出文件,图片、PDF、Markdown、CSV 可预览。
+- 报告、PPT 遵循公司规范与模版(技能 `fwh-assistant`)。
+
+### FR-07 审批与提问
+
+- 需要审批的操作弹出卡片:允许一次 / 本会话允许 / 拒绝(可填原因)。
+- Agent 主动提问以选择卡片回答。
+- 超时(默认 10 分钟)按拒绝处理。
+
+### FR-08 运行状态
+
+- 显示当前模型,可在 Profile 允许的范围内切换。
+- 显示本轮用量;排队时显示位置。
+
+## 3. 非功能需求
+
+| 编号 | 要求 |
+| --- | --- |
+| NF-01 隔离 | 用户不能读取或操作他人的会话、运行、审批和文件。由 ToolBox 后端实现:浏览器只持有 ToolBox 登录令牌,AgentServer API Key 只在后端 |
+| NF-02 安全 | Agent 只能写自己的会话工作区,读不到 ToolBox 源码、`.env` 和服务器凭证;业务凭证只在 fwh-agent-mcp 内;MCP 访问令牌只在 AgentServer 服务端与 ToolBox `.env` |
+| NF-03 追溯 | 每次工具调用可追溯到 ToolBox 用户:ToolBox 会话归属 → AgentServer 审计(gateway_call 含参数) |
+| NF-04 并发 | 多用户同时使用时排队而非失败;fwh-agent-mcp 有自己的并发上限,保护业务系统 |
+| NF-05 性能 | 单个查询工具 60 秒内返回(导出 300 秒);超时给出明确错误 |
+| NF-06 独立 | 新模块上线、下线不影响旧助手;新代码不引用旧助手的包 |
+
+## 4. 待确认事项
+
+| 编号 | 问题 | 建议 |
+| --- | --- | --- |
+| Q-01 | AgentServer 与 ToolBox 是否同机部署 | 同机时 fwh-agent-mcp 监听 127.0.0.1;跨机时监听内网地址并限制来源 |
+| Q-02 | 新助手对哪些角色开放、是否需要按角色区分 Profile | 先对所有登录用户开放同一个只读 Profile |
+| Q-03 | 每用户会话数量上限与保留期 | 50 条;更早的会话在 AgentServer 侧归档 |
+| Q-04 | Wiki 知识库同步频率 | 已定:复制到 `/srv/agent-server/knowledge/`,需要时手动运行同步脚本,不配置定时任务 |
+| Q-05 | 旧助手何时下线 | 新助手验收后观察一段时间再定 |
+| Q-06 | drawio 图表能力是否需要 | 先不做,按反馈决定 |
+
+MCP 相关的待确认事项见 [agent-mcp.md](agent-mcp.md) 第 8 节。

+ 205 - 0
docs/ai-assistant-agentserver/technical-design.md

@@ -0,0 +1,205 @@
+# 新 AI 助手:技术设计
+
+> 状态:设计初稿,2026-10-24。需求见 [requirements.md](requirements.md),FWH 业务工具见 [agent-mcp.md](agent-mcp.md)。AgentServer 接口与事件以其仓库 `docs/design/04-api.md` 和后台“API 协议”页为准,第三方 MCP 接入见其 ADR-0021。
+
+## 1. 总体架构
+
+新助手是 ToolBox 中**独立的新模块**:独立的入口、前后端代码、数据集合和 MCP 服务;旧 Kiro 助手([legacy-kiro-assistant.md](legacy-kiro-assistant.md))原样保留,两者互不调用、互不共享数据。
+
+```
+浏览器(ToolBox)
+  ├─ 旧:顶栏 AiChat → /ai/*(不改动)
+  └─ 新:菜单「AI 助手」页面(/ai-assistant)→ 会话栏 + <AgentChat> + 文件栏
+         │ @agent-server/client:baseUrl = <ToolBox API>/agent,Authorization = ToolBox 登录令牌
+         ▼
+ToolBox Go 后端(新模块 agent)
+  ├─ /agent/sessions/*:会话列表、创建、改名、删除(AgentSessions)
+  └─ /agent/v1/*:AgentServer 代理(鉴权 → 归属校验 → 换 API Key → 转发,SSE 透传)
+         ▼
+AgentServer /v1
+  Profile fwh-assistant(或按角色的其他 Profile):Claude、技能、资料库、MCP 服务
+  MCP Gateway ── Bearer AGENT_MCP_TOKEN(固定请求头,无会话信息)──┐
+                                                                     ▼
+ToolBox fwh-agent-mcp(新服务,无用户状态,只读工具集)
+         ▼
+FWH 云 API / PostgreSQL / MongoDB / Redis / Plane / SIM 平台
+```
+
+职责划分:
+
+| 组件 | 负责 |
+| --- | --- |
+| ToolBox 前端(新模块) | 独立页面:会话栏、`<AgentChat>`、文件栏、主题适配([ui-design.md](ui-design.md)) |
+| ToolBox 后端(新模块) | 用户鉴权、按角色选择 Profile、会话归属、AgentServer 代理 |
+| AgentServer | Agent 运行、事件落库与续传、工作区与文件、审批、沙箱、技能、资料库、MCP Gateway 与审计 |
+| fwh-agent-mcp | 业务数据查询(无用户)、PII 脱敏、业务凭证 |
+
+## 2. 关键决策
+
+| 编号 | 决策 | 理由 |
+| --- | --- | --- |
+| D-01 | 新助手与旧助手完全分开:新路由前缀 `/agent`、新集合、新前端目录、新 MCP 服务 | 旧助手可以继续使用到新助手验收完成,下线时直接删除整块旧代码,没有交叉依赖 |
+| D-02 | 业务工具用**无用户状态**的新 MCP(agent-mcp.md) | AgentServer Gateway 以固定请求头连接 MCP、多会话共用连接,不传会话信息;身份由上层控制更简单可靠 |
+| D-03 | 用户隔离在 ToolBox 后端实现:浏览器只持有 ToolBox 登录令牌,所有 AgentServer 请求经代理并校验会话归属 | AgentServer 不区分使用者,同一 API Key 下的会话彼此可见;API Key 不出后端 |
+| D-04 | 用户能力差异通过 **Profile** 表达:ToolBox 创建会话时按用户角色选择 Profile,Profile 决定技能、资料库和勾选的 MCP 服务 | MCP 无用户后,权限边界上移到“会话用哪个 Profile” |
+| D-05 | 前端使用 `@agent-server/chat-ui` 的 `<AgentChat>` | 流式、工具卡片、审批/提问、附件、产出文件、中断、断线续传都已具备 |
+
+## 3. ToolBox 后端
+
+### 3.1 代码位置
+
+| 文件 | 内容 |
+| --- | --- |
+| `service/config/config.go` | 新增 `Agent` 段 |
+| `service/utils/agentserver/client.go` | AgentServer HTTP 客户端(会话创建、改名、删除、子资源反查) |
+| `service/utils/agentserver/proxy.go` | `/agent/v1/*` 代理:白名单、转发、SSE 透传、事件解析 |
+| `service/services/agentServices.go` | 会话与归属、Profile 选择 |
+| `service/repositories/mongo/agentSessionRepo.go` | `AgentSessions`、`AgentResources` |
+| `service/controllers/agentControllers.go` | HTTP 处理 |
+| `service/routes/agentRoutes.go` | 路由注册(`routes.go` 中调用 `AgentSetup`) |
+
+不引用 `utils/acp`、`services/aiChatServices.go`、`utils/filestore` 等旧助手代码。
+
+### 3.2 配置
+
+```
+AGENT_ENABLED=true
+AGENT_SERVER_URL=http://127.0.0.1:8787
+AGENT_SERVER_API_KEY=ask_xxx                    # 只在后端
+AGENT_PROFILE_DEFAULT=prf_xxx                   # 默认 Profile(fwh-assistant)
+AGENT_PROFILE_BY_ROLE=ops:prf_yyy,admin:prf_zzz # 可选:角色 → Profile,按顺序取第一个命中的
+AGENT_ALLOWED_ROLES=                            # 可选:为空表示所有登录用户可用
+```
+
+API Key 在 AgentServer 后台签发时,勾选上述全部 Profile 为可用。
+
+### 3.3 数据
+
+`AgentSessions`(新集合,不复用 `AiChatSessions`):
+
+| 字段 | 说明 |
+| --- | --- |
+| `sessionId` | AgentServer 会话 ID(`ses_…`),唯一索引 |
+| `userId` | ToolBox 用户名(`AuthGetUserName`) |
+| `profileId` | 创建时选用的 Profile |
+| `title`、`createdAt`、`lastActive` | |
+| `deletedAt` | 软删除 |
+
+索引:`{ userId, deletedAt, lastActive }`。
+
+`AgentResources`(代理校验不带会话 ID 的请求用):`resourceId`(`run_` / `itx_` / `file_`,唯一)、`sessionId`、`createdAt`(TTL 与会话保留期一致)。
+
+### 3.4 会话接口
+
+| 接口 | 行为 |
+| --- | --- |
+| `GET /agent/sessions` | 本人未删除的会话,按 `lastActive` 倒序,上限 50 |
+| `POST /agent/sessions` | 按角色选 Profile → `POST /v1/sessions { profileId }` → 写 `AgentSessions` → 返回 `{ sessionId, title }` |
+| `PATCH /agent/sessions/:id` | 校验归属 → `PATCH /v1/sessions/:id { title }` → 更新 |
+| `DELETE /agent/sessions/:id` | 校验归属 → `DELETE /v1/sessions/:id` → 软删除 |
+
+用户角色变化后,已有会话继续使用创建时的 Profile;新建会话使用新角色对应的 Profile。
+
+### 3.5 AgentServer 代理
+
+路由 `ALL /agent/v1/*`,先经 ToolBox 鉴权中间件,然后:
+
+1. **白名单**(其余返回 403):
+
+   | 方法 | 路径 | 归属校验 |
+   | --- | --- | --- |
+   | GET | `/v1/sessions/:id`、`/events`、`/files`、`/stream`(SSE) | 路径中的会话 |
+   | POST | `/v1/sessions/:id/messages`、`/v1/sessions/:id/files` | 路径中的会话 |
+   | PATCH | `/v1/sessions/:id`(只保留 `title`、`model`) | 路径中的会话 |
+   | GET、POST | `/v1/runs/:id`、`/v1/runs/:id/interrupt` | `AgentResources` |
+   | GET | `/v1/interactions?sessionId=` | 查询参数中的会话 |
+   | POST | `/v1/interactions/:id` | `AgentResources` |
+   | GET | `/v1/files/:fileId` | `AgentResources` |
+   | GET | `/v1/profiles`、`/v1/providers` | 无(只读元数据) |
+
+   不放行 `GET /v1/sessions`(会列出所有用户的会话)、`POST /v1/sessions`、`DELETE`、`/v1/usage`、`/v1/browser-tokens`。
+
+2. **归属校验**:会话属于当前用户且未删除;子资源先查 `AgentResources`,查不到时反查 AgentServer(`GET /v1/runs/:id` 与文件列表都带 `sessionId`;交互在本人最近活跃的会话中用 `GET /v1/interactions?sessionId=` 查找),确认后补写。不符统一返回 404。
+
+3. **转发**:`Authorization` 换成 API Key,去掉 Cookie 与 ToolBox 专有头,保留 `Last-Event-ID`;multipart 原样转发(Fiber `BodyLimit` 按 AgentServer 上传上限调整)。
+
+4. **记录子资源**:`POST /messages` 响应中的 `runId`、`POST /files` 响应中的文件 ID;SSE 与 `/events` 透传时解析 `run.started`、`interaction.required`、`file.changed`,异步批量写入,不阻塞转发;发消息时刷新 `lastActive`。
+
+5. **SSE**:逐块写出并立即 `Flush`,客户端断开时取消上游请求,加 `X-Accel-Buffering: no`;Caddy/nginx 对 `/agent/v1/sessions/*/stream` 关闭缓冲。断线续传由 SDK 的 `Last-Event-ID` 完成,代理不重试。
+
+## 4. ToolBox 前端
+
+### 4.1 页面
+
+新助手是菜单路由 `/ai-assistant` 下的独立大页面(左侧会话栏、中间对话、右侧文件栏),不放在顶栏。路由、布局、交互与代码结构见 [ui-design.md](ui-design.md);代码在 `src/views/ai-assistant/` 与 `src/service/agent/`。
+
+不修改顶栏的 `Actions/AiChat/` 和 `src/service/aiChat/`,旧助手入口保持不变。
+
+### 4.2 客户端
+
+```ts
+const client = new AgentClient({
+  baseUrl: `${getBaseUrl()}/agent`,         // SDK 请求 /agent/v1/...
+  getToken: async () => getToolBoxToken(),  // 与 serverReq.ts 相同的登录令牌
+})
+```
+
+依赖按 AgentServer `docs/integration.md` 安装三个 SDK 包(URL tgz,锁定版本),`main.ts` 引入 `@agent-server/chat-ui/style.css`。同源请求 ToolBox 后端,不需要 AgentServer 的 CORS 与浏览器令牌。
+
+主题:ToolBox 切换明暗时同步设置 `data-theme`(若组件只认 `<html>` 上的属性,需确认不影响 NaiveUI);必要时覆盖 `--app-*` 变量对齐 ToolBox 主色。
+
+## 5. AgentServer 配置
+
+均在 AgentServer 后台完成,不改 AgentServer 代码([agent-mcp.md](agent-mcp.md) 5.2 的结果处理改动除外)。
+
+| 项 | 配置 |
+| --- | --- |
+| MCP 服务 | `fwh`:远程 HTTP,`http://127.0.0.1:8091/mcp`,请求头 `Authorization: Bearer <AGENT_MCP_TOKEN>`;二期 `fwhops` |
+| 技能 | 新写 `fwh-assistant`(第 6 节),源码在 ToolBox `deploy/agent-assistant/skills/fwh-assistant/`。AgentServer 暂无自定义技能入口,需求见 AgentServer 仓库 `docs/design/12-custom-skills.md`;实现后作为技能源导入 |
+| 资料库 | `fwh-wiki`:`/srv/agent-server/knowledge/fwh-wiki`(手动运行同步脚本从 FranklinWH-Wiki `client/docs` 拷贝,排除 `.vitepress`、`public`、`casdoor`;home 下的路径受 ProtectHome 限制,不能直接引用);`fwh-data-knowledge`:同样方式同步 `docs/knowledge` |
+| Profile `fwh-assistant` | 引擎 Claude;权限模式“需审批”;技能 `report-conventions`、`custom-skills:fwh-assistant`(AgentServer 支持后);资料库 `fwh-wiki`、`fwh-data-knowledge`(有内容后);MCP 服务 `fwh`;文件访问 restricted;按需开放 WebSearch / WebFetch |
+| Profile `fwh-assistant-ops`(二期) | 在上一个基础上加勾 `fwhops` |
+| API Key | 平台 `fwh-toolbox`,可用 Profile 为上述全部,默认 `fwh-assistant` |
+
+## 6. 技能 `fwh-assistant`
+
+从头编写,旧的 `deploy/kiro/skills/`、`deploy/kiro/steering/` 只作为素材,不迁移、不改动。内容:
+
+- **业务背景**:产品线(aGate、aPower 等)、站点与设备的关系、SN 规则、时区与单位约定;
+- **工具使用**:每类问题的推荐工具组合(例如“站点故障排查:`site_get` → `alarm_events` → `mqtt_log_search` → `fault_record_list`”);何时用 `*_export` 并在工作区用 pandas 分析;
+- **数据防护**:不把大批量明细读进上下文(原 `mcp-data-guard.md` 的规则,改写为面向新工具);工具不足时说明缺口,不用其他方式拼凑;
+- **产出规范**:报告结构、图表、公司 PPT 模版 `templates/company-template.pptx`(随技能分发);产出写入 `outputs/`;
+- **references/**:告警码与事件说明、TOU 模版字段(来自调度配置文档)、功率流字段解读等,按需读取。
+
+工具名在技能中写引擎侧可见的名称(`mcp__gateway__fwh__site_get` 等)。
+
+## 7. 实施步骤
+
+| 阶段 | 内容 | 依赖 | 完成标志 |
+| --- | --- | --- | --- |
+| S1 | Agent MCP 框架(`kit`、HTTP、约束测试)+ 站点、告警两个领域 | — | `go test ./agentmcp/...` 通过;AgentServer 后台“测试连接”能列出工具 |
+| S2 | Agent MCP 其余领域工具与导出工具 | S1 | 单测通过 |
+| S3 | AgentServer:`resource` 结果落盘(agent-mcp.md 5.2) | — | AgentServer 测试通过 |
+| S4 | 技能 `fwh-assistant`、资料库同步、Profile、API Key;在 Playground 回归第 8 节业务用例 | S1、S2 | Playground 通过 |
+| S5 | ToolBox 后端新模块:配置、集合、会话接口、代理 | — | 接口与代理单测通过 |
+| S6 | ToolBox 前端新页面(ui-design.md) | S5 | 本地联调通过 |
+| S7 | 测试环境上线,新页面与旧助手并存,用户验收第 8 节用例 | S4~S6 | 验收通过 |
+| S8 | 决定旧助手下线时间;下线时删除旧助手全部代码、`cmd/mcp`、`deploy/kiro/`、Kiro 配置与部署脚本 | S7 | 单独评审 |
+
+S1~S4(MCP 与 AgentServer 侧)和 S5~S6(ToolBox 前后端)可以并行。
+
+## 8. 验收用例
+
+| 编号 | 用例 | 覆盖 |
+| --- | --- | --- |
+| T-01 | 问“站点 XXXX 的概况和最近 7 天告警”,回答含表格;工具卡片显示中文标题,没有弹审批 | 只读工具直接调用 |
+| T-02 | 分析某站点 30 天功率流:Agent 调用导出工具,数据落在工作区文件,用 Python 统计并出图;上下文中没有明细 | 导出与落盘 |
+| T-03 | 问一个 Wiki 中的产品问题(如某告警码含义),回答引用文档路径 | 资料库 |
+| T-04 | 粘贴截图 + 上传 xlsx,生成带中文图表的 Word 报告和公司模版 PPT,文件卡片可下载、PDF 可预览 | 上传与产出 |
+| T-05 | 运行中刷新页面,回到同一会话且输出继续;运行中切换到其他标签页再回来,输出未中断;运行中点击中断 | 续传与中断 |
+| T-11 | 宽屏三栏、1024~1279px 文件栏收起、< 1024px 两侧都收起为抽屉,均可正常使用 | 页面布局 |
+| T-06 | 用户 A 用自己的令牌请求用户 B 的会话(stream / events / files / messages)及 B 的 runId / interactionId / fileId,全部 404;`GET /agent/v1/sessions` 返回 403 | 用户隔离 |
+| T-07 | 配置了角色映射时,不同角色的用户新建会话使用不同 Profile;无权使用的用户看不到“AI 助手”菜单、接口返回 403 | Profile 选择 |
+| T-08 | 让 Agent 读取 ToolBox `service/.env`、`~/.ssh`:被拦截并写入 AgentServer 审计 | 沙箱 |
+| T-09 | 重启 ToolBox 后端、AgentServer、fwh-agent-mcp 后,历史会话时间线完整且可继续对话;fwh-agent-mcp 停止时,工具返回“暂不可用”而不是挂起 | 可恢复 |
+| T-10 | 旧 AI 助手在新模块上线后功能不受影响 | 相互独立 |

+ 127 - 0
docs/ai-assistant-agentserver/ui-design.md

@@ -0,0 +1,127 @@
+# 新 AI 助手:页面设计
+
+> 状态:设计初稿,2026-10-24。新助手以**独立的大页面**展示(菜单路由),不放在顶栏右上角。需求见 [requirements.md](requirements.md),前后端结构见 [technical-design.md](technical-design.md)。对话组件来自 `@agent-server/chat-ui`(AgentServer `packages/chat-ui`)。
+
+## 1. 路由与菜单
+
+在 `src/router/record.ts` 增加一级菜单:
+
+```ts
+{
+  path: 'ai-assistant',
+  name: 'aiAssistant',
+  icon: 'i-ph:sparkle',          // 与旧助手的 i-ph:chat-circle-dots 区分
+  label: 'AI 助手',
+  meta: {
+    componentName: 'AiAssistant',
+    showTab: true,
+  },
+  component: 'ai-assistant/index',
+},
+```
+
+- 单标签页(不设 `enableMultiTab`):一个页面同时只打开一个会话,避免多个标签各自维持 SSE 连接。
+- 当前会话写在查询参数 `?session=ses_xxx`(切换会话时 `router.replace`):刷新页面、从浏览器历史返回都能回到同一个会话;链接发给别人时,对方打开会因归属校验失败而回到新会话。
+- 页面组件名为 `AiAssistant`,进入标签页的 KeepAlive:切换到其他标签时组件不销毁,进行中的回答继续接收;关闭标签时 `dispose` 订阅(运行本身不中断,下次打开从断点继续显示)。
+- 菜单可见性按现有菜单权限机制配置为 FR-03 允许的角色;`AGENT_ENABLED=false` 时不注册该菜单。
+- 旧助手顶栏按钮保持不变,两者并存。
+
+## 2. 布局
+
+宽屏(≥ 1280px)三栏,中间为对话主区:
+
+```
+┌───────────────┬──────────────────────────────────────────────┬─────────────────┐
+│ + 新对话      │ 站点 XXXX 告警排查   ✎     [Sonnet 5.5 ▾]  ⋯ │ 本会话文件    ⇥ │
+│ 🔍 搜索会话    ├──────────────────────────────────────────────┤ ─────────────── │
+│               │                                              │ 上传            │
+│ 今天          │        (对话时间线,最大宽度 880px 居中)      │  📄 sales.xlsx  │
+│ ● 站点XXXX告警 │   用户:……                                    │  🖼 截图1.png   │
+│   SIM 用量统计 │   助手:……                                    │ 产出            │
+│ 昨天          │     ▸ 已调用 查询站点概况(0.8s)              │  📊 report.docx │
+│   TOU 模版对比 │     ▸ 已调用 查询站点告警/事件记录(1.2s)     │  📈 trend.png   │
+│ 最近 7 天      │     ┌ 审批:保存 TOU 模版(测试服)…  ┐        │                 │
+│   …           │     └ [允许一次][本会话允许][拒绝]   ┘        │ ┌─────────────┐ │
+│               │     📊 report.docx  2.1 MB  [下载]            │ │  文件预览    │ │
+│               │                                              │ │ (图片/PDF/  │ │
+│               ├──────────────────────────────────────────────┤ │  Markdown/   │ │
+│ ⇤ 收起         │ [📎] 输入问题,Enter 发送,Shift+Enter 换行   ▶ │ │  CSV 前 200 行)│ │
+│               │ 本轮 12.3k / 1.8k tokens · 排队中(第 2 位)    │ └─────────────┘ │
+└───────────────┴──────────────────────────────────────────────┴─────────────────┘
+   240px                       自适应                               320px
+```
+
+| 区域 | 内容 | 实现 |
+| --- | --- | --- |
+| 左栏:会话 | 新对话按钮;按标题搜索;按“今天 / 昨天 / 最近 7 天 / 更早”分组;悬停显示改名、删除;当前会话高亮;可收起 | NaiveUI(`NInput`、`NScrollbar`、`NDropdown`、`NPopconfirm`),数据来自 `/agent/sessions` |
+| 顶部栏 | 会话标题(点击改名);模型选择(Profile 允许的模型);更多菜单:删除会话、复制会话链接 | NaiveUI;模型切换调用 `PATCH /agent/v1/sessions/:id { model }` |
+| 中间:对话 | 时间线、工具卡片、审批与提问卡片、文件卡片、输入框(附件、拖拽、粘贴截图)、中断、断线续传 | `<AgentChat :client :session @open-file>`;`#toolbar` 插槽放用量与排队信息,`#empty` 插槽放欢迎页(第 3 节) |
+| 右栏:文件 | 本会话的上传与产出文件(按来源分组,最新在前);点击在下方预览;下载 | `client.sessions.files(id)`,收到 `file.changed` 事件时刷新;预览用 chat-ui 的 `useFileObjectUrl`、`MarkdownView` |
+
+对话主区的时间线限制最大宽度(880px)居中,宽屏下不至于一行过长;表格类回复允许横向滚动。
+
+### 2.1 窄屏
+
+| 宽度 | 布局 |
+| --- | --- |
+| 1024~1279px | 右栏默认收起,顶部栏出现“文件”按钮,以右侧 `NDrawer` 打开 |
+| < 1024px | 左栏也收起为顶部栏的“会话”按钮,以左侧 `NDrawer` 打开 |
+
+左右栏的展开/收起状态记在 `localStorage`(读写包在 try/catch 中,失败时用默认值)。
+
+## 3. 欢迎页(空会话)
+
+新对话或空会话时,主区显示:
+
+- 一句说明:“可以查询站点、告警、停机、功率流、日志、录波、TOU 模版、SIM 卡数据,解答产品问题,分析上传的文件并生成报告。”
+- 示例问题卡片(2 × 3),点击即发送(`session.send(text)`):
+  - 查一下站点 XXXX 最近 7 天的告警和事件
+  - 统计昨天停机站点按国家分布
+  - 分析站点 XXXX 本月的功率流,生成趋势图
+  - 告警码 XXXX 是什么意思,通常怎么处理
+  - 对比生产服和测试服的 TOU 模版 XXXX
+  - 帮我把上传的 Excel 整理成一份 Word 报告
+
+  示例中的 SN、告警码等用占位符,点击后填入输入框而不直接发送,让用户补全(带占位符的卡片显示“填写后发送”)。
+- 底部提示:“数据来自 FWH 平台,结果仅供参考;涉及修改数据的操作会先请你确认。”
+
+## 4. 交互细节
+
+| 场景 | 行为 |
+| --- | --- |
+| 首次进入(无 `?session=`) | 打开最近活跃的会话;没有会话时显示欢迎页,发送第一条消息时才创建会话(避免产生空会话) |
+| 新对话 | 主区回到欢迎页,地址去掉 `?session=`;左栏不出现新条目,直到发送第一条消息 |
+| 发送第一条消息 | `POST /agent/sessions` → 发送消息 → 用前 30 字改名 → 左栏插入并高亮 |
+| 切换会话 | 当前会话 `dispose`,打开目标会话(拉取历史事件 + 订阅),右栏刷新文件列表 |
+| 会话不存在 / 不属于本人 | 提示“会话不存在或无权访问”,回到欢迎页 |
+| 运行中切换会话或离开页面 | 不中断运行;左栏该会话显示“进行中”小圆点(依据最近一次打开时的运行状态,回到该会话时以实际状态为准) |
+| 运行中再次发送 | 输入框保持可编辑,发送按钮置灰并提示“上一轮尚未结束,可先中断” |
+| 删除当前会话 | 二次确认 → 删除 → 回到欢迎页 |
+| 审批 / 提问卡片待处理 | 页面标签显示提醒标记;离开页面期间到达的审批,回到页面时卡片仍在(来自 `GET /v1/interactions`) |
+| AgentServer 或 fwh-agent-mcp 不可用 | 顶部栏下方显示横幅“AI 服务暂不可用”,输入框禁用;工具级别的不可用由工具卡片显示错误,不影响对话 |
+
+## 5. 主题与样式
+
+- 引入 `@agent-server/chat-ui/style.css`;组件的原子类已在包内编译隔离,ToolBox 的 UnoCSS 不需要扫描 SDK。
+- 明暗模式:ToolBox 切换主题时同步设置 `data-theme`(组件读取 `<html>` 上的属性,需验证不影响 NaiveUI 的主题)。
+- 配色:在页面根元素上覆盖 `--app-*` 变量,使按钮、链接、选中色与 ToolBox 主色一致;完整变量见 `@agent-server/ui-tokens` 的 `variables.css`。
+- 左栏、顶部栏、右栏使用 NaiveUI 组件,与 ToolBox 其他页面一致;中间对话区保持 chat-ui 自身风格。
+
+## 6. 代码结构
+
+```
+src/views/ai-assistant/
+  index.vue                    页面:三栏布局、路由参数、窄屏抽屉
+  components/
+    SessionSidebar.vue         左栏:会话列表、搜索、分组、改名、删除
+    ChatHeader.vue             顶部栏:标题、模型选择、更多菜单
+    WelcomePanel.vue           欢迎页与示例问题
+    FilePanel.vue              右栏:文件列表与预览
+  composables/
+    useAgentClient.ts          AgentClient 单例(baseUrl = /agent,令牌 = ToolBox 登录令牌)
+    useAgentSessions.ts        会话列表、创建、改名、删除、当前会话与路由同步
+    useSessionFiles.ts         文件列表与 file.changed 刷新
+src/service/agent/index.ts     /agent/sessions/* 接口
+```
+
+不修改 `src/layout/header/Actions/AiChat/`、`src/service/aiChat/`。

+ 3 - 0
package.json

@@ -22,6 +22,9 @@
     "install": "rm -rf /var/www/fwh-toolbox/fwh-toolbox/* && cp -rf ./dist/* /var/www/fwh-toolbox/fwh-toolbox/"
   },
   "dependencies": {
+    "@agent-server/chat-ui": "https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.2.0.tgz",
+    "@agent-server/client": "https://agent.zenpower.cc/sdk/agent-server-client-0.2.0.tgz",
+    "@agent-server/shared": "https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz",
     "@antv/g2": "^5.4.8",
     "@dotenvx/dotenvx": "^1.59.1",
     "@iconify-json/boxicons": "^1.2.0",

+ 87 - 0
pnpm-lock.yaml

@@ -11,6 +11,15 @@ importers:
 
   .:
     dependencies:
+      '@agent-server/chat-ui':
+        specifier: https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.2.0.tgz
+        version: https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.2.0.tgz(@agent-server/client@https://agent.zenpower.cc/sdk/agent-server-client-0.2.0.tgz(@agent-server/shared@https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz))(@agent-server/shared@https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz)(vue@3.5.31(typescript@6.0.2))
+      '@agent-server/client':
+        specifier: https://agent.zenpower.cc/sdk/agent-server-client-0.2.0.tgz
+        version: https://agent.zenpower.cc/sdk/agent-server-client-0.2.0.tgz(@agent-server/shared@https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz)
+      '@agent-server/shared':
+        specifier: https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz
+        version: https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz
       '@antv/g2':
         specifier: ^5.4.8
         version: 5.4.8
@@ -255,6 +264,24 @@ importers:
 
 packages:
 
+  '@agent-server/chat-ui@https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.2.0.tgz':
+    resolution: {integrity: sha512-am+dIhA2R6CjTSiytv5Cp5yCHWPBXPwXjI+ZX31jDAsxQnCV04uCI/3cEUtJhkkCY4tuXOk//e+8yuWaad6dng==, tarball: https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.2.0.tgz}
+    version: 0.2.0
+    peerDependencies:
+      '@agent-server/client': 0.2.0
+      '@agent-server/shared': 0.2.0
+      vue: ^3.5.0
+
+  '@agent-server/client@https://agent.zenpower.cc/sdk/agent-server-client-0.2.0.tgz':
+    resolution: {integrity: sha512-zt+GSmk61jWp5OEoIrEylhnU1EpcWM5LUjPHqnAnkSEMvc4JrqQ9nOd6f2cXEUN5JMdp21cYFtgGRPVF9r6U5Q==, tarball: https://agent.zenpower.cc/sdk/agent-server-client-0.2.0.tgz}
+    version: 0.2.0
+    peerDependencies:
+      '@agent-server/shared': 0.2.0
+
+  '@agent-server/shared@https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz':
+    resolution: {integrity: sha512-dLZIJUwmKRFQQxsuwX+3wguc74ERGu1/jog9Cxa/VvUSWCMXCU1f18HbuWBGO+y0513NOIoQVunMVMlm3dZABA==, tarball: https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz}
+    version: 0.2.0
+
   '@antfu/eslint-config@7.7.3':
     resolution: {integrity: sha512-BtroDxTvmWtvr3yJkdWVCvwsKlnEdkreoeOyrdNezc/W5qaiQNf2xjcsQ3N5Yy0x27h+0WFfW8rG8YlVioG6dw==}
     hasBin: true
@@ -2474,6 +2501,9 @@ packages:
   argparse@2.0.1:
     resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
 
+  argparse@3.0.3:
+    resolution: {integrity: sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==}
+
   assertion-error@2.0.1:
     resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
     engines: {node: '>=12'}
@@ -2980,6 +3010,10 @@ packages:
     resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
     engines: {node: '>=0.12'}
 
+  entities@8.1.0:
+    resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
+    engines: {node: '>=20.19.0'}
+
   error-stack-parser-es@1.0.5:
     resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
 
@@ -3729,6 +3763,9 @@ packages:
   line-column@1.0.2:
     resolution: {integrity: sha512-Ktrjk5noGYlHsVnYWh62FLVs4hTb8A3e+vucNZMgPeAOITdshMSgv4cCZQeRDjm7+goqmo6+liZwTXo+U3sVww==}
 
+  linkify-it@6.1.0:
+    resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==}
+
   local-pkg@1.1.2:
     resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
     engines: {node: '>=14'}
@@ -3772,6 +3809,10 @@ packages:
   magic-string@0.30.21:
     resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
 
+  markdown-it@15.0.3:
+    resolution: {integrity: sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==}
+    hasBin: true
+
   markdown-table@3.0.4:
     resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
 
@@ -3824,6 +3865,9 @@ packages:
   mdn-data@2.27.1:
     resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
 
+  mdurl@2.1.0:
+    resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
+
   memorystream@0.3.1:
     resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
     engines: {node: '>= 0.10.0'}
@@ -4267,6 +4311,10 @@ packages:
     engines: {node: '>=18'}
     hasBin: true
 
+  punycode.js@2.3.1:
+    resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
+    engines: {node: '>=6'}
+
   punycode@2.3.1:
     resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
     engines: {node: '>=6'}
@@ -4773,6 +4821,9 @@ packages:
     engines: {node: '>=14.17'}
     hasBin: true
 
+  uc.micro@3.0.0:
+    resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==}
+
   ufo@1.6.3:
     resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
 
@@ -5274,6 +5325,19 @@ packages:
 
 snapshots:
 
+  '@agent-server/chat-ui@https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.2.0.tgz(@agent-server/client@https://agent.zenpower.cc/sdk/agent-server-client-0.2.0.tgz(@agent-server/shared@https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz))(@agent-server/shared@https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz)(vue@3.5.31(typescript@6.0.2))':
+    dependencies:
+      '@agent-server/client': https://agent.zenpower.cc/sdk/agent-server-client-0.2.0.tgz(@agent-server/shared@https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz)
+      '@agent-server/shared': https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz
+      markdown-it: 15.0.3
+      vue: 3.5.31(typescript@6.0.2)
+
+  '@agent-server/client@https://agent.zenpower.cc/sdk/agent-server-client-0.2.0.tgz(@agent-server/shared@https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz)':
+    dependencies:
+      '@agent-server/shared': https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz
+
+  '@agent-server/shared@https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz': {}
+
   '@antfu/eslint-config@7.7.3(@typescript-eslint/rule-tester@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.57.2(typescript@6.0.2))(@typescript-eslint/utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@unocss/eslint-plugin@66.6.7(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(@vue/compiler-sfc@3.5.31)(eslint-plugin-format@2.0.1(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)(vitest@4.1.2)':
     dependencies:
       '@antfu/install-pkg': 1.1.0
@@ -7554,6 +7618,8 @@ snapshots:
 
   argparse@2.0.1: {}
 
+  argparse@3.0.3: {}
+
   assertion-error@2.0.1: {}
 
   ast-kit@2.2.0:
@@ -7992,6 +8058,8 @@ snapshots:
 
   entities@7.0.1: {}
 
+  entities@8.1.0: {}
+
   error-stack-parser-es@1.0.5: {}
 
   es-module-lexer@2.0.0: {}
@@ -8779,6 +8847,10 @@ snapshots:
       isarray: 1.0.0
       isobject: 2.1.0
 
+  linkify-it@6.1.0:
+    dependencies:
+      uc.micro: 3.0.0
+
   local-pkg@1.1.2:
     dependencies:
       mlly: 1.8.2
@@ -8827,6 +8899,15 @@ snapshots:
     dependencies:
       '@jridgewell/sourcemap-codec': 1.5.5
 
+  markdown-it@15.0.3:
+    dependencies:
+      argparse: 3.0.3
+      entities: 8.1.0
+      linkify-it: 6.1.0
+      mdurl: 2.1.0
+      punycode.js: 2.3.1
+      uc.micro: 3.0.0
+
   markdown-table@3.0.4: {}
 
   marked@14.0.0: {}
@@ -8948,6 +9029,8 @@ snapshots:
 
   mdn-data@2.27.1: {}
 
+  mdurl@2.1.0: {}
+
   memorystream@0.3.1: {}
 
   merge-source-map@1.1.0:
@@ -9606,6 +9689,8 @@ snapshots:
       picocolors: 1.1.1
       sade: 1.8.1
 
+  punycode.js@2.3.1: {}
+
   punycode@2.3.1: {}
 
   quansync@0.2.11: {}
@@ -10053,6 +10138,8 @@ snapshots:
 
   typescript@6.0.2: {}
 
+  uc.micro@3.0.0: {}
+
   ufo@1.6.3: {}
 
   unconfig-core@7.5.0:

+ 123 - 0
service/agentmcp/agentmcp_test.go

@@ -0,0 +1,123 @@
+package agentmcp
+
+import (
+	"encoding/json"
+	"regexp"
+	"testing"
+	"unicode"
+
+	"fwh-toolbox-server/agentmcp/kit"
+)
+
+// 约束测试(agent-mcp.md 3.5):名称、标题、描述、读写声明、schema 类型。
+var nameRE = regexp.MustCompile(`^[a-z][a-z0-9_]{2,39}$`)
+
+// 引擎侧完整名称 mcp__gateway__<服务名>__<工具名> 不能超过 64;服务名最长按 6 个字符(fwhops)计算
+const enginePrefixLen = len("mcp__gateway__") + len("fwhops") + len("__")
+
+func TestToolConstraints(t *testing.T) {
+	seen := map[string]string{}
+	for _, set := range []string{SetReadOnly, SetOps} {
+		list, err := Tools(set)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(list) == 0 {
+			t.Fatalf("工具集 %s 为空", set)
+		}
+		for _, r := range list {
+			m := r.Meta()
+			if !nameRE.MatchString(m.Name) {
+				t.Errorf("%s: 名称不符合 ^[a-z][a-z0-9_]{2,39}$", m.Name)
+			}
+			if enginePrefixLen+len(m.Name) > 64 {
+				t.Errorf("%s: 引擎侧完整名称超过 64 字符", m.Name)
+			}
+			if prev, dup := seen[m.Name]; dup {
+				t.Errorf("%s: 与工具集 %s 中的工具重名", m.Name, prev)
+			}
+			seen[m.Name] = set
+			if m.Title == "" {
+				t.Errorf("%s: 缺少 title", m.Name)
+			}
+			if !hasHan(m.Description) {
+				t.Errorf("%s: 描述应为中文", m.Name)
+			}
+			if set == SetReadOnly && !m.ReadOnly {
+				t.Errorf("%s: readonly 工具集中出现写工具", m.Name)
+			}
+			if set == SetOps && m.ReadOnly {
+				t.Errorf("%s: ops 工具集中出现只读工具", m.Name)
+			}
+
+			tool := r.Tool()
+			if tool.Annotations.ReadOnlyHint == nil {
+				t.Errorf("%s: 未显式声明 readOnlyHint", m.Name)
+			} else if *tool.Annotations.ReadOnlyHint != m.ReadOnly {
+				t.Errorf("%s: readOnlyHint 与定义不一致", m.Name)
+			}
+			if tool.Annotations.Title != m.Title {
+				t.Errorf("%s: title 注解与定义不一致", m.Name)
+			}
+
+			b, err := json.Marshal(tool)
+			if err != nil {
+				t.Fatalf("%s: 序列化失败: %v", m.Name, err)
+			}
+			var decoded struct {
+				InputSchema map[string]any `json:"inputSchema"`
+			}
+			if err := json.Unmarshal(b, &decoded); err != nil {
+				t.Fatalf("%s: 解析失败: %v", m.Name, err)
+			}
+			if decoded.InputSchema["type"] != "object" {
+				t.Errorf("%s: inputSchema.type 应为 object,实际 %v", m.Name, decoded.InputSchema["type"])
+			}
+		}
+	}
+}
+
+// 一期清单(agent-mcp.md 第 4 节)全部注册
+func TestReadOnlyToolList(t *testing.T) {
+	want := []string{
+		"site_search", "site_get", "site_power_flow", "site_power_flow_export",
+		"alarm_current", "alarm_events", "alarm_snapshot", "alarm_stats", "alarm_sites_by_code",
+		"shutdown_stats", "shutdown_sites",
+		"mqtt_log_search", "ems_log_records", "update_log_system", "update_log_ahub",
+		"fault_record_list", "fault_record_detail", "fault_record_callable", "fault_record_call_history",
+		"tou_template_list", "tou_template_get",
+		"sim_get", "sim_search", "sim_usage_monthly",
+		"ems_param_lookup", "plane_issue_search", "plane_issue_get", "sn_parse",
+	}
+	list, _ := Tools(SetReadOnly)
+	got := map[string]bool{}
+	for _, r := range list {
+		got[r.Meta().Name] = true
+	}
+	for _, n := range want {
+		if !got[n] {
+			t.Errorf("缺少工具 %s", n)
+		}
+	}
+	if len(got) != len(want) {
+		t.Errorf("只读工具数量 %d,期望 %d", len(got), len(want))
+	}
+}
+
+func TestExportTimeoutByName(t *testing.T) {
+	if !(kit.Meta{Name: "site_power_flow_export"}).IsExport() {
+		t.Error("site_power_flow_export 应识别为导出工具")
+	}
+	if (kit.Meta{Name: "site_power_flow"}).IsExport() {
+		t.Error("site_power_flow 不是导出工具")
+	}
+}
+
+func hasHan(s string) bool {
+	for _, r := range s {
+		if unicode.Is(unicode.Han, r) {
+			return true
+		}
+	}
+	return false
+}

+ 110 - 0
service/agentmcp/bootstrap.go

@@ -0,0 +1,110 @@
+package agentmcp
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/spf13/viper"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
+
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/config"
+	"fwh-toolbox-server/database"
+	"fwh-toolbox-server/logger"
+	"fwh-toolbox-server/repositories"
+)
+
+// Settings fwh-agent-mcp 自身的运行配置(AGENT_MCP_*),与主服务读取同一份 .env。
+type Settings struct {
+	Token          string // AGENT_MCP_TOKEN:Bearer 令牌,必填
+	Listen         string // AGENT_MCP_LISTEN:默认 127.0.0.1:8091
+	MaxConcurrency int    // AGENT_MCP_MAX_CONCURRENCY:默认 8
+	LogPath        string // AGENT_MCP_LOG_PATH:默认 /var/log/fwh-agent-mcp/agent-mcp.log
+}
+
+// LoadSettings 读取 AGENT_MCP_*:先读 .env / config.env(与 config.LoadConfig 相同的查找顺序),环境变量优先。
+func LoadSettings() Settings {
+	v := viper.New()
+	v.SetDefault("AGENT_MCP_LISTEN", "127.0.0.1:8091")
+	v.SetDefault("AGENT_MCP_MAX_CONCURRENCY", 8)
+	v.SetDefault("AGENT_MCP_LOG_PATH", "/var/log/fwh-agent-mcp/agent-mcp.log")
+	for _, p := range []string{"./.env", "./config.env"} {
+		v.SetConfigFile(p)
+		v.SetConfigType("env")
+		if err := v.ReadInConfig(); err == nil {
+			break
+		}
+	}
+	v.AutomaticEnv()
+	return Settings{
+		Token:          v.GetString("AGENT_MCP_TOKEN"),
+		Listen:         v.GetString("AGENT_MCP_LISTEN"),
+		MaxConcurrency: v.GetInt("AGENT_MCP_MAX_CONCURRENCY"),
+		LogPath:        v.GetString("AGENT_MCP_LOG_PATH"),
+	}
+}
+
+// InitLogger 初始化日志:写入独立的日志文件;目录不可写时退回 stderr。
+// 同时设置 zap 全局 logger 与 logger 包变量(database 等模块直接引用 logger.Sugar)。
+func InitLogger(path string) *zap.Logger {
+	outputs := []string{"stderr"}
+	if path != "" {
+		if err := os.MkdirAll(filepath.Dir(path), 0o755); err == nil {
+			outputs = []string{path}
+		} else {
+			fmt.Fprintf(os.Stderr, "[fwh-agent-mcp] 创建日志目录失败,改为输出到 stderr: %v\n", err)
+		}
+	}
+	cfg := zap.Config{
+		Level:    zap.NewAtomicLevelAt(zap.InfoLevel),
+		Encoding: "json",
+		EncoderConfig: zapcore.EncoderConfig{
+			TimeKey:        "ts",
+			LevelKey:       "level",
+			NameKey:        "logger",
+			CallerKey:      "caller",
+			MessageKey:     "msg",
+			StacktraceKey:  "stacktrace",
+			LineEnding:     zapcore.DefaultLineEnding,
+			EncodeLevel:    zapcore.LowercaseLevelEncoder,
+			EncodeTime:     zapcore.ISO8601TimeEncoder,
+			EncodeDuration: zapcore.SecondsDurationEncoder,
+			EncodeCaller:   zapcore.ShortCallerEncoder,
+		},
+		OutputPaths:      outputs,
+		ErrorOutputPaths: outputs,
+	}
+	l, err := cfg.Build(zap.AddStacktrace(zap.ErrorLevel))
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "[fwh-agent-mcp] logger 初始化失败: %v\n", err)
+		l = zap.NewNop()
+	}
+	zap.ReplaceGlobals(l)
+	logger.Log = l
+	logger.Sugar = l.Sugar()
+	logger.Std = zap.NewStdLog(l)
+	return l
+}
+
+// InitInfra 加载主服务配置并初始化工具用到的基础设施:数据库、Redis、FWH 生产/测试 API、Plane。
+func InitInfra() error {
+	cfg, err := config.LoadConfig()
+	if err != nil {
+		return fmt.Errorf("加载配置失败: %w", err)
+	}
+	if err := database.Init(&cfg.App); err != nil {
+		return fmt.Errorf("数据库初始化失败: %w", err)
+	}
+	cache.Init(cfg.App.RedisAddr)
+	if err := repositories.Init(context.Background()); err != nil {
+		return fmt.Errorf("Repository 初始化失败: %w", err)
+	}
+	repositories.FwhHttpInit(&cfg.FwhApi)
+	repositories.FwhTestHttpInit(&cfg.FwhTestApi)
+	// 必须用 InitPlane(含 Feedback 专用实例 repositories.Plane),否则 feedback_create 会空指针
+	repositories.InitPlane(&cfg.PlaneWebApi)
+	return nil
+}

+ 67 - 0
service/agentmcp/http.go

@@ -0,0 +1,67 @@
+package agentmcp
+
+import (
+	"context"
+	"crypto/subtle"
+	"errors"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/mark3labs/mcp-go/server"
+	"go.uber.org/zap"
+)
+
+// Handler 返回 HTTP 处理器:/mcp(Streamable HTTP,无状态,需 Bearer 令牌)与 /healthz。
+func Handler(s *server.MCPServer, token string) http.Handler {
+	mcpHandler := server.NewStreamableHTTPServer(s,
+		server.WithStateLess(true),
+		server.WithEndpointPath("/mcp"),
+	)
+	mux := http.NewServeMux()
+	mux.Handle("/mcp", bearerAuth(token, mcpHandler))
+	mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte("ok"))
+	})
+	return mux
+}
+
+// bearerAuth 校验 Authorization: Bearer <token>,常量时间比较。
+func bearerAuth(token string, next http.Handler) http.Handler {
+	want := []byte("Bearer " + token)
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		got := []byte(strings.TrimSpace(r.Header.Get("Authorization")))
+		if subtle.ConstantTimeCompare(got, want) != 1 {
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(http.StatusUnauthorized)
+			_, _ = w.Write([]byte(`{"jsonrpc":"2.0","error":{"code":-32001,"message":"unauthorized"},"id":null}`))
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+// Serve 启动 HTTP 服务,ctx 取消时优雅关闭。
+func Serve(ctx context.Context, addr string, h http.Handler, log *zap.Logger) error {
+	srv := &http.Server{
+		Addr:              addr,
+		Handler:           h,
+		ReadHeaderTimeout: 10 * time.Second,
+	}
+	errCh := make(chan error, 1)
+	go func() { errCh <- srv.ListenAndServe() }()
+	log.Info("[agent-mcp] HTTP 服务已启动", zap.String("addr", addr))
+
+	select {
+	case <-ctx.Done():
+		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		return srv.Shutdown(shutdownCtx)
+	case err := <-errCh:
+		if errors.Is(err, http.ErrServerClosed) {
+			return nil
+		}
+		return err
+	}
+}

+ 119 - 0
service/agentmcp/kit/args.go

@@ -0,0 +1,119 @@
+package kit
+
+import (
+	"fmt"
+	"strings"
+	"time"
+)
+
+const (
+	DateLayout     = "2006-01-02"
+	DateTimeLayout = time.DateTime // 2006-01-02 15:04:05
+	MonthLayout    = "2006-01"
+)
+
+// ArgError 参数错误:Handler 返回给模型的可读说明。
+type ArgError struct{ Msg string }
+
+func (e *ArgError) Error() string { return e.Msg }
+
+func argErrorf(format string, a ...any) error { return &ArgError{Msg: fmt.Sprintf(format, a...)} }
+
+// Required 必填字符串参数(去掉首尾空白)。
+func Required(name, v string) (string, error) {
+	v = strings.TrimSpace(v)
+	if v == "" {
+		return "", argErrorf("参数 %s 必填", name)
+	}
+	return v, nil
+}
+
+// ParseDate 解析 yyyy-MM-dd;v 为空且 allowEmpty 时返回 nil。
+func ParseDate(name, v string, allowEmpty bool) (*time.Time, error) {
+	v = strings.TrimSpace(v)
+	if v == "" {
+		if allowEmpty {
+			return nil, nil
+		}
+		return nil, argErrorf("参数 %s 必填,格式 yyyy-MM-dd", name)
+	}
+	t, err := time.Parse(DateLayout, v)
+	if err != nil {
+		return nil, argErrorf("参数 %s 格式错误,需要 yyyy-MM-dd:%s", name, v)
+	}
+	return &t, nil
+}
+
+// ParseDateTime 解析 yyyy-MM-dd HH:mm:ss;只给日期时按当天 00:00:00 处理。
+func ParseDateTime(name, v string) (time.Time, error) {
+	v = strings.TrimSpace(v)
+	if v == "" {
+		return time.Time{}, argErrorf("参数 %s 必填,格式 yyyy-MM-dd HH:mm:ss", name)
+	}
+	if t, err := time.Parse(DateTimeLayout, v); err == nil {
+		return t, nil
+	}
+	if t, err := time.Parse(DateLayout, v); err == nil {
+		return t, nil
+	}
+	return time.Time{}, argErrorf("参数 %s 格式错误,需要 yyyy-MM-dd HH:mm:ss:%s", name, v)
+}
+
+// ParseRange 解析时间范围并检查 from < to、跨度不超过 max。
+func ParseRange(from, to string, max time.Duration) (time.Time, time.Time, error) {
+	f, err := ParseDateTime("from", from)
+	if err != nil {
+		return f, f, err
+	}
+	t, err := ParseDateTime("to", to)
+	if err != nil {
+		return f, t, err
+	}
+	if !t.After(f) {
+		return f, t, argErrorf("结束时间必须晚于开始时间")
+	}
+	if max > 0 && t.Sub(f) > max {
+		return f, t, argErrorf("时间范围不能超过 %s,请缩短范围后分段查询", humanDuration(max))
+	}
+	return f, t, nil
+}
+
+func humanDuration(d time.Duration) string {
+	if d%(24*time.Hour) == 0 {
+		return fmt.Sprintf("%d 天", int(d/(24*time.Hour)))
+	}
+	return d.String()
+}
+
+// Limit 归一化条数:<=0 取默认值,超过上限取上限。
+func Limit(v, def, max int) int {
+	if v <= 0 {
+		return def
+	}
+	if v > max {
+		return max
+	}
+	return v
+}
+
+// Page 归一化页码(从 1 开始)。
+func Page(v int) int {
+	if v <= 0 {
+		return 1
+	}
+	return v
+}
+
+// Enum 校验枚举值;v 为空返回默认值。
+func Enum(name, v, def string, allowed ...string) (string, error) {
+	v = strings.TrimSpace(v)
+	if v == "" {
+		return def, nil
+	}
+	for _, a := range allowed {
+		if strings.EqualFold(v, a) {
+			return a, nil
+		}
+	}
+	return "", argErrorf("参数 %s 取值错误:%s,可选值:%s", name, v, strings.Join(allowed, " / "))
+}

+ 138 - 0
service/agentmcp/kit/middleware.go

@@ -0,0 +1,138 @@
+package kit
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"runtime/debug"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+	"go.uber.org/zap"
+)
+
+const (
+	// DefaultTimeout 普通工具单次调用超时
+	DefaultTimeout = 60 * time.Second
+	// ExportTimeout 导出工具单次调用超时
+	ExportTimeout = 300 * time.Second
+	// QueueWait 并发已满时的最长排队时间
+	QueueWait = 30 * time.Second
+
+	maxParamsLogBytes = 2 * 1024
+)
+
+var callSeq int64
+
+// Middleware 统一的工具调用中间件:并发上限、超时、panic 恢复、调用日志(不含调用人)。
+//
+// 数据访问函数大多不接收 context,超时后无法真正中止上游请求:处理函数在后台继续执行,
+// 直到返回才释放并发名额,从而保证对 FWH 平台和数据库的并发压力不超过上限。
+func Middleware(log *zap.Logger, maxConcurrency int) server.ToolHandlerMiddleware {
+	if maxConcurrency <= 0 {
+		maxConcurrency = 8
+	}
+	sem := make(chan struct{}, maxConcurrency)
+
+	return func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
+		return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+			name := req.Params.Name
+			seq := atomic.AddInt64(&callSeq, 1)
+			started := time.Now()
+			fields := []zap.Field{zap.Int64("seq", seq), zap.String("tool", name)}
+
+			// 1. 并发名额
+			waitCtx, cancelWait := context.WithTimeout(ctx, QueueWait)
+			select {
+			case sem <- struct{}{}:
+				cancelWait()
+			case <-waitCtx.Done():
+				cancelWait()
+				log.Warn("[agent-mcp] 排队超时", fields...)
+				return mcp.NewToolResultError("服务繁忙,请稍后重试。"), nil
+			}
+
+			timeout := DefaultTimeout
+			if strings.HasSuffix(name, "_export") {
+				timeout = ExportTimeout
+			}
+			callCtx, cancel := context.WithTimeout(ctx, timeout)
+			defer cancel()
+
+			log.Info("[agent-mcp] 调用", append(fields, zap.String("params", paramsSummary(req)))...)
+
+			type outcome struct {
+				res *mcp.CallToolResult
+				err error
+			}
+			done := make(chan outcome, 1)
+			go func() {
+				defer func() { <-sem }()
+				defer func() {
+					if r := recover(); r != nil {
+						log.Error("[agent-mcp] panic", append(fields, zap.Any("panic", r), zap.String("stack", string(debug.Stack())))...)
+						done <- outcome{res: mcp.NewToolResultError("内部错误,请稍后重试。")}
+					}
+				}()
+				res, err := next(callCtx, req)
+				done <- outcome{res: res, err: err}
+			}()
+
+			select {
+			case o := <-done:
+				size, isErr := resultInfo(o.res)
+				l := log.Info
+				if o.err != nil {
+					l = log.Error
+				} else if isErr {
+					l = log.Warn
+				}
+				l("[agent-mcp] 完成", append(fields,
+					zap.Int64("duration_ms", time.Since(started).Milliseconds()),
+					zap.Int("result_size", size),
+					zap.Bool("is_error", isErr),
+					zap.Error(o.err),
+				)...)
+				if o.err != nil {
+					// 不把 Go error 透传为协议错误,统一给模型一条可读信息
+					return mcp.NewToolResultError(fmt.Sprintf("工具执行失败:%v", o.err)), nil
+				}
+				return o.res, nil
+			case <-callCtx.Done():
+				log.Warn("[agent-mcp] 超时", append(fields, zap.Duration("timeout", timeout))...)
+				return mcp.NewToolResultError(fmt.Sprintf("查询超时(%d 秒),请缩小时间范围或稍后重试。", int(timeout.Seconds()))), nil
+			}
+		}
+	}
+}
+
+func paramsSummary(req mcp.CallToolRequest) string {
+	b, err := json.Marshal(req.GetArguments())
+	if err != nil {
+		return ""
+	}
+	if len(b) > maxParamsLogBytes {
+		return string(b[:maxParamsLogBytes]) + "...(truncated)"
+	}
+	return string(b)
+}
+
+func resultInfo(r *mcp.CallToolResult) (size int, isErr bool) {
+	if r == nil {
+		return 0, false
+	}
+	for _, c := range r.Content {
+		switch t := c.(type) {
+		case mcp.TextContent:
+			size += len(t.Text)
+		case mcp.EmbeddedResource:
+			if tr, ok := t.Resource.(mcp.TextResourceContents); ok {
+				size += len(tr.Text)
+			}
+		}
+	}
+	return size, r.IsError
+}

+ 280 - 0
service/agentmcp/kit/result.go

@@ -0,0 +1,280 @@
+package kit
+
+import (
+	"bytes"
+	"encoding/csv"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"reflect"
+	"strings"
+	"unicode/utf8"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/utils/pii"
+)
+
+// MaxResultChars 单次结果(非导出)的最大字符数。低于 AgentServer 网关的 10 万字截断阈值,
+// 避免触发"前 10 万字仍进上下文"的路径。
+const MaxResultChars = 30000
+
+// MaxExportBytes 导出文件上限。
+const MaxExportBytes = 20 << 20
+
+// ---- 错误 ----
+
+// Fail 返回给模型的业务错误(参数不对、无数据等)。
+func Fail(err error) *mcp.CallToolResult {
+	var ae *ArgError
+	if errors.As(err, &ae) {
+		return mcp.NewToolResultError(ae.Msg)
+	}
+	return mcp.NewToolResultError(err.Error())
+}
+
+// Failf 以格式化文本返回错误。
+func Failf(format string, a ...any) *mcp.CallToolResult {
+	return mcp.NewToolResultError(fmt.Sprintf(format, a...))
+}
+
+// Upstream 上游(FWH 平台、数据库、Plane 等)调用失败。
+func Upstream(source string, err error) *mcp.CallToolResult {
+	return mcp.NewToolResultError(fmt.Sprintf("调用%s失败:%v。可以稍后重试,或缩小查询范围。", source, err))
+}
+
+// Empty 无数据时的统一返回(不是错误)。
+func Empty(msg string) *mcp.CallToolResult {
+	if msg == "" {
+		msg = "没有查询到数据。"
+	}
+	return mcp.NewToolResultText(msg)
+}
+
+// ---- 对象与列表 ----
+
+// Object 单个对象:脱敏、去掉空值后以紧凑 JSON 返回。
+func Object(v any) *mcp.CallToolResult {
+	tree, err := sanitize(v)
+	if err != nil {
+		return mcp.NewToolResultError(err.Error())
+	}
+	b, _ := json.Marshal(tree)
+	text := string(b)
+	if utf8.RuneCountInString(text) > MaxResultChars {
+		text = truncateRunes(text, MaxResultChars) + "\n(结果过长已截断,请缩小查询范围)"
+	}
+	return mcp.NewToolResultText(text)
+}
+
+// ListResult 列表结果的结构。
+type ListResult struct {
+	Total     int    `json:"total"`
+	Returned  int    `json:"returned"`
+	Truncated bool   `json:"truncated"`
+	Hint      string `json:"hint,omitempty"`
+	Items     []any  `json:"items"`
+}
+
+// List 列表:脱敏、去空值,最多返回 limit 条;序列化后超过 MaxResultChars 时继续减少条数。
+// items 必须是切片(nil 视为空);total < 0 时以切片长度作为总数。
+func List(items any, total, limit int, hint string) *mcp.CallToolResult {
+	tree, err := sanitize(items)
+	if err != nil {
+		return mcp.NewToolResultError(err.Error())
+	}
+	arr, _ := tree.([]any)
+	if total < 0 {
+		total = len(arr)
+	}
+	if len(arr) == 0 {
+		return Empty("")
+	}
+	r := buildList(arr, total, limit, hint)
+	b, _ := json.Marshal(r)
+	return mcp.NewToolResultText(string(b))
+}
+
+func buildList(arr []any, total, limit int, hint string) ListResult {
+	n := len(arr)
+	if limit > 0 && n > limit {
+		n = limit
+	}
+	for {
+		r := ListResult{Total: total, Returned: n, Truncated: n < total, Items: arr[:n]}
+		if r.Truncated {
+			r.Hint = hint
+			if r.Hint == "" {
+				r.Hint = "结果已截断,请缩小查询范围或分页查询"
+			}
+		}
+		b, _ := json.Marshal(r)
+		if utf8.RuneCount(b) <= MaxResultChars || n <= 1 {
+			return r
+		}
+		n = n * 3 / 4
+	}
+}
+
+// Table 行多、字段固定的数据:首行列名的 TSV,比 JSON 省字符。单元格按列名脱敏。
+func Table(cols []string, rows [][]string, limit int, hint string) *mcp.CallToolResult {
+	if len(rows) == 0 {
+		return Empty("")
+	}
+	total := len(rows)
+	n := total
+	if limit > 0 && n > limit {
+		n = limit
+	}
+	var buf strings.Builder
+	header := fmt.Sprintf("# total=%d", total)
+	colLine := strings.Join(cols, "\t")
+	buf.WriteString(colLine)
+	buf.WriteByte('\n')
+	chars := utf8.RuneCountInString(colLine) + 1
+	written := 0
+	for _, row := range rows[:n] {
+		line := tsvLine(cols, row)
+		lc := utf8.RuneCountInString(line) + 1
+		// 预留 200 字符给头部说明
+		if chars+lc+200 > MaxResultChars {
+			break
+		}
+		buf.WriteString(line)
+		buf.WriteByte('\n')
+		chars += lc
+		written++
+	}
+	header += fmt.Sprintf(" returned=%d truncated=%t", written, written < total)
+	if written < total {
+		if hint == "" {
+			hint = "结果已截断,请缩小查询范围"
+		}
+		header += "\n# " + hint
+	}
+	return mcp.NewToolResultText(header + "\n" + buf.String())
+}
+
+func tsvLine(cols []string, row []string) string {
+	cells := make([]string, len(row))
+	for i, c := range row {
+		if i < len(cols) {
+			c = pii.MaskField(cols[i], c)
+		}
+		cells[i] = strings.NewReplacer("\t", " ", "\n", " ", "\r", " ").Replace(c)
+	}
+	return strings.Join(cells, "\t")
+}
+
+// Export 导出:CSV 以 EmbeddedResource(text/csv)返回,并附文本摘要。
+// AgentServer 网关会把资源内容保存到会话工作区(agent-mcp.md 5.2)。
+func Export(uri string, cols []string, rows [][]string, summary string) *mcp.CallToolResult {
+	if len(rows) == 0 {
+		return Empty("没有可导出的数据。")
+	}
+	var buf bytes.Buffer
+	w := csv.NewWriter(&buf)
+	_ = w.Write(cols)
+	for _, row := range rows {
+		masked := make([]string, len(row))
+		for i, c := range row {
+			if i < len(cols) {
+				c = pii.MaskField(cols[i], c)
+			}
+			masked[i] = c
+		}
+		_ = w.Write(masked)
+		if buf.Len() > MaxExportBytes {
+			return Failf("导出数据超过 %d MB,请缩小时间范围后分段导出", MaxExportBytes>>20)
+		}
+	}
+	w.Flush()
+
+	preview := rows
+	if len(preview) > 5 {
+		preview = preview[:5]
+	}
+	var head strings.Builder
+	fmt.Fprintf(&head, "已导出 %d 行,%d 列,%.1f KB。\n资源:%s\n列:%s\n", len(rows), len(cols), float64(buf.Len())/1024, uri, strings.Join(cols, ", "))
+	if summary != "" {
+		head.WriteString(summary + "\n")
+	}
+	head.WriteString("前 5 行:\n")
+	head.WriteString(strings.Join(cols, "\t") + "\n")
+	for _, r := range preview {
+		head.WriteString(tsvLine(cols, r) + "\n")
+	}
+	head.WriteString("完整数据在资源文件中,请用 Python(pandas)读取分析,不要把全文读入上下文。")
+
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{
+			mcp.NewTextContent(head.String()),
+			mcp.NewEmbeddedResource(mcp.TextResourceContents{URI: uri, MIMEType: "text/csv", Text: buf.String()}),
+		},
+	}
+}
+
+// ---- 内部 ----
+
+// sanitize 序列化 → 脱敏 → 解析为通用树 → 去掉空值。
+func sanitize(v any) (any, error) {
+	if v == nil || (reflect.ValueOf(v).Kind() == reflect.Ptr && reflect.ValueOf(v).IsNil()) {
+		return nil, nil
+	}
+	b, err := pii.Marshal(v)
+	if err != nil {
+		return nil, err
+	}
+	dec := json.NewDecoder(bytes.NewReader(b))
+	dec.UseNumber()
+	var tree any
+	if err := dec.Decode(&tree); err != nil {
+		return nil, fmt.Errorf("结果解析失败: %w", err)
+	}
+	return Prune(tree), nil
+}
+
+// Prune 去掉 null、空字符串、空数组、空对象(递归)。数组中的元素保留位置,只清理其内部。
+func Prune(v any) any {
+	switch t := v.(type) {
+	case map[string]any:
+		for k, val := range t {
+			p := Prune(val)
+			if isEmpty(p) {
+				delete(t, k)
+			} else {
+				t[k] = p
+			}
+		}
+		return t
+	case []any:
+		for i := range t {
+			t[i] = Prune(t[i])
+		}
+		return t
+	default:
+		return v
+	}
+}
+
+func isEmpty(v any) bool {
+	switch t := v.(type) {
+	case nil:
+		return true
+	case string:
+		return t == ""
+	case map[string]any:
+		return len(t) == 0
+	case []any:
+		return len(t) == 0
+	}
+	return false
+}
+
+func truncateRunes(s string, n int) string {
+	if utf8.RuneCountInString(s) <= n {
+		return s
+	}
+	r := []rune(s)
+	return string(r[:n])
+}

+ 146 - 0
service/agentmcp/kit/result_test.go

@@ -0,0 +1,146 @@
+package kit
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+	"time"
+	"unicode/utf8"
+
+	"github.com/mark3labs/mcp-go/mcp"
+)
+
+func textOf(t *testing.T, r *mcp.CallToolResult) string {
+	t.Helper()
+	if len(r.Content) == 0 {
+		t.Fatal("结果没有内容")
+	}
+	tc, ok := r.Content[0].(mcp.TextContent)
+	if !ok {
+		t.Fatalf("第一段内容不是文本: %T", r.Content[0])
+	}
+	return tc.Text
+}
+
+type row struct {
+	SN    string  `json:"sn"`
+	Phone string  `json:"phone"`
+	Note  string  `json:"note"`
+	Empty *string `json:"empty"`
+}
+
+func TestListLimitAndTruncation(t *testing.T) {
+	rows := make([]row, 300)
+	for i := range rows {
+		rows[i] = row{SN: "1006", Phone: "13800000000", Note: strings.Repeat("x", 50)}
+	}
+	r := List(rows, -1, 100, "")
+	var got ListResult
+	if err := json.Unmarshal([]byte(textOf(t, r)), &got); err != nil {
+		t.Fatal(err)
+	}
+	if got.Total != 300 || got.Returned != 100 || !got.Truncated || got.Hint == "" {
+		t.Errorf("limit 截断不正确: total=%d returned=%d truncated=%v", got.Total, got.Returned, got.Truncated)
+	}
+	item := got.Items[0].(map[string]any)
+	if item["phone"] == "13800000000" {
+		t.Error("phone 应被脱敏")
+	}
+	if _, ok := item["empty"]; ok {
+		t.Error("null 字段应被去掉")
+	}
+}
+
+func TestListCharCap(t *testing.T) {
+	rows := make([]row, 500)
+	for i := range rows {
+		rows[i] = row{SN: "1006", Note: strings.Repeat("长", 200)}
+	}
+	text := textOf(t, List(rows, -1, 500, ""))
+	if utf8.RuneCountInString(text) > MaxResultChars {
+		t.Errorf("结果 %d 字符,超过上限 %d", utf8.RuneCountInString(text), MaxResultChars)
+	}
+	var got ListResult
+	_ = json.Unmarshal([]byte(text), &got)
+	if !got.Truncated || got.Returned >= 500 {
+		t.Errorf("应因字符上限截断: returned=%d", got.Returned)
+	}
+}
+
+func TestListEmpty(t *testing.T) {
+	r := List([]row{}, -1, 10, "")
+	if r.IsError || !strings.Contains(textOf(t, r), "没有") {
+		t.Error("空列表应返回无数据提示")
+	}
+}
+
+func TestTableCharCap(t *testing.T) {
+	cols := []string{"time", "value"}
+	rows := make([][]string, 5000)
+	for i := range rows {
+		rows[i] = []string{"2026-10-01 00:00:00", strings.Repeat("9", 20)}
+	}
+	text := textOf(t, Table(cols, rows, 0, ""))
+	if utf8.RuneCountInString(text) > MaxResultChars {
+		t.Errorf("表格 %d 字符,超过上限", utf8.RuneCountInString(text))
+	}
+	if !strings.Contains(text, "truncated=true") {
+		t.Error("表格应标注 truncated=true")
+	}
+}
+
+func TestTableMasksByColumn(t *testing.T) {
+	text := textOf(t, Table([]string{"sn", "email"}, [][]string{{"1006", "alice@example.com"}}, 0, ""))
+	if strings.Contains(text, "alice@") {
+		t.Errorf("email 列应脱敏: %s", text)
+	}
+}
+
+func TestExportResource(t *testing.T) {
+	r := Export("fwh://export/test.csv", []string{"a", "b"}, [][]string{{"1", "2"}, {"3", "4"}}, "测试")
+	if len(r.Content) != 2 {
+		t.Fatalf("导出应含文本摘要与资源两段内容,实际 %d", len(r.Content))
+	}
+	er, ok := r.Content[1].(mcp.EmbeddedResource)
+	if !ok {
+		t.Fatalf("第二段应为 EmbeddedResource: %T", r.Content[1])
+	}
+	tr := er.Resource.(mcp.TextResourceContents)
+	if tr.MIMEType != "text/csv" || !strings.HasPrefix(tr.Text, "a,b\n1,2\n") {
+		t.Errorf("CSV 内容不正确: %q", tr.Text)
+	}
+}
+
+func TestObjectPrunesAndMasks(t *testing.T) {
+	text := textOf(t, Object(map[string]any{"sn": "1006", "address": "1 Main St", "blank": "", "list": []any{}}))
+	if strings.Contains(text, "Main St") || strings.Contains(text, "blank") || strings.Contains(text, "list") {
+		t.Errorf("对象应脱敏并去掉空值: %s", text)
+	}
+}
+
+func TestParseRange(t *testing.T) {
+	if _, _, err := ParseRange("2026-10-01 00:00:00", "2026-10-01 00:00:00", 0); err == nil {
+		t.Error("from == to 应报错")
+	}
+	if _, _, err := ParseRange("2026-10-01", "2026-12-01", 31*24*time.Hour); err == nil {
+		t.Error("超过最大跨度应报错")
+	}
+	if _, _, err := ParseRange("2026-10-01", "2026-10-02 12:00:00", 31*24*time.Hour); err != nil {
+		t.Errorf("合法范围不应报错: %v", err)
+	}
+}
+
+func TestEnumAndLimit(t *testing.T) {
+	if v, _ := Enum("k", "", "all", "all", "alarm"); v != "all" {
+		t.Error("空值应取默认")
+	}
+	if v, _ := Enum("k", "ALARM", "all", "all", "alarm"); v != "alarm" {
+		t.Error("应大小写不敏感")
+	}
+	if _, err := Enum("k", "x", "all", "all", "alarm"); err == nil {
+		t.Error("非法值应报错")
+	}
+	if Limit(0, 10, 50) != 10 || Limit(99, 10, 50) != 50 || Limit(5, 10, 50) != 5 {
+		t.Error("Limit 归一化不正确")
+	}
+}

+ 68 - 0
service/agentmcp/kit/tool.go

@@ -0,0 +1,68 @@
+// Package kit 是 fwh-agent-mcp 的工具框架:工具定义、参数辅助、统一结果与中间件。
+//
+// 设计见 docs/ai-assistant-agentserver/agent-mcp.md 第 3 节。工具不感知调用者:
+// 没有 userId / sessionId 参数,也不记录调用人。
+package kit
+
+import (
+	"context"
+	"strings"
+
+	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+)
+
+// Meta 工具的静态信息,供注册表与约束测试使用。
+type Meta struct {
+	Name        string
+	Title       string
+	Description string
+	ReadOnly    bool
+}
+
+// IsExport 导出类工具(*_export)使用更长的超时并以 CSV 资源返回。
+func (m Meta) IsExport() bool {
+	return strings.HasSuffix(m.Name, "_export")
+}
+
+// Registrar 可注册到 MCPServer 的工具。
+type Registrar interface {
+	Meta() Meta
+	Tool() mcp.Tool
+	Register(s *server.MCPServer)
+}
+
+// Def 一个类型化工具的定义。T 为参数结构体,schema 由 mcp.WithInputSchema[T] 生成:
+// `jsonschema:"..."` 标签即字段描述,没有 omitempty 的字段为必填;枚举与取值范围写进描述并在 Handler 中校验。
+type Def[T any] struct {
+	Name        string // <领域>_<动作>,小写下划线,≤ 40 字符
+	Title       string // 中文短标题(审批卡片与后台显示)
+	Description string // 用途、适用场景、返回字段与单位
+	ReadOnly    bool
+	Handler     func(ctx context.Context, args T) (*mcp.CallToolResult, error)
+}
+
+func (d Def[T]) Meta() Meta {
+	return Meta{Name: d.Name, Title: d.Title, Description: d.Description, ReadOnly: d.ReadOnly}
+}
+
+func (d Def[T]) Tool() mcp.Tool {
+	return mcp.NewTool(d.Name,
+		mcp.WithDescription(d.Description),
+		mcp.WithInputSchema[T](),
+		mcp.WithTitleAnnotation(d.Title),
+		mcp.WithReadOnlyHintAnnotation(d.ReadOnly),
+		mcp.WithDestructiveHintAnnotation(false),
+		mcp.WithIdempotentHintAnnotation(d.ReadOnly),
+		mcp.WithOpenWorldHintAnnotation(false),
+	)
+}
+
+func (d Def[T]) Register(s *server.MCPServer) {
+	s.AddTool(d.Tool(), mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args T) (*mcp.CallToolResult, error) {
+		return d.Handler(ctx, args)
+	}))
+}
+
+// NoArgs 无参数工具的参数类型。
+type NoArgs struct{}

+ 53 - 0
service/agentmcp/server.go

@@ -0,0 +1,53 @@
+// Package agentmcp 是新 AI 助手使用的业务数据 MCP 服务(fwh-agent-mcp)。
+//
+// 无用户状态:工具不接收、不推断调用者;由 AgentServer 后台登记为远程 HTTP 服务,经 MCP Gateway 转接给 Agent。
+// 设计见 docs/ai-assistant-agentserver/agent-mcp.md。与旧助手的 cmd/mcp 相互独立。
+package agentmcp
+
+import (
+	"fmt"
+
+	"github.com/mark3labs/mcp-go/server"
+	"go.uber.org/zap"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/agentmcp/tools"
+)
+
+const (
+	ServerName    = "fwh-agent-mcp"
+	ServerVersion = "1.0.0"
+
+	SetReadOnly = "readonly"
+	SetOps      = "ops"
+)
+
+// Tools 返回指定工具集。
+func Tools(set string) ([]kit.Registrar, error) {
+	switch set {
+	case SetReadOnly:
+		return tools.ReadOnly(), nil
+	case SetOps:
+		return tools.Ops(), nil
+	}
+	return nil, fmt.Errorf("未知的工具集:%s(可选 readonly / ops)", set)
+}
+
+// NewServer 创建 MCPServer 并注册工具集。
+func NewServer(set string, log *zap.Logger, maxConcurrency int) (*server.MCPServer, error) {
+	list, err := Tools(set)
+	if err != nil {
+		return nil, err
+	}
+	s := server.NewMCPServer(
+		ServerName+"-"+set,
+		ServerVersion,
+		server.WithToolCapabilities(false),
+		server.WithInputSchemaValidation(),
+		server.WithToolHandlerMiddleware(kit.Middleware(log, maxConcurrency)),
+	)
+	for _, t := range list {
+		t.Register(s)
+	}
+	return s, nil
+}

+ 304 - 0
service/agentmcp/tools/alarm.go

@@ -0,0 +1,304 @@
+package tools
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"strings"
+	"time"
+	"unicode/utf8"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/repositories"
+	"fwh-toolbox-server/services"
+)
+
+func parseCountry(v string) (domain.CountryType, error) {
+	c, err := kit.Enum("country", v, "all", "all", "us", "au")
+	if err != nil {
+		return 0, err
+	}
+	switch c {
+	case "us":
+		return domain.CTRY_US, nil
+	case "au":
+		return domain.CTRY_AU, nil
+	}
+	return domain.CTRY_ALL, nil
+}
+
+// ---- alarm_current ----
+
+type snArgs struct {
+	SN string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+}
+
+var AlarmCurrent = kit.Def[snArgs]{
+	Name:  "alarm_current",
+	Title: "查询站点当前告警",
+	Description: "查询单个站点当前未结束的告警列表(FWH 平台实时告警)。" +
+		"返回字段:alarmEqSn(告警设备 SN)、alarmCode / commonAlarmCode(告警码)、level(等级)、time(发生时间)、logName / logNameEn(告警名称)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a snArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		res, err := repositories.QuerySiteAlarmList(sn)
+		if err != nil {
+			return kit.Upstream("FWH 平台实时告警接口", err), nil
+		}
+		if res == nil || res.Result == nil || len(*res.Result) == 0 {
+			return kit.Empty("该站点当前没有未结束的告警。"), nil
+		}
+		return kit.List(*res.Result, -1, 200, ""), nil
+	},
+}
+
+// ---- alarm_events ----
+
+type alarmEventsArgs struct {
+	SN    string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	From  string `json:"from" jsonschema:"开始时间,yyyy-MM-dd HH:mm:ss,站点当地时间"`
+	To    string `json:"to" jsonschema:"结束时间,同上;与开始时间相差不超过 31 天"`
+	Kind  string `json:"kind,omitempty" jsonschema:"alarm(只看告警)/ event(只看事件)/ all(默认)"`
+	Limit int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~500,默认 100"`
+}
+
+// EventRow alarm_events 的精简行
+type EventRow struct {
+	Kind      string `json:"kind"`    // 告警 / 事件
+	LogTime   string `json:"logTime"` // 设备时间
+	StartTime string `json:"startTime,omitempty"`
+	EndTime   string `json:"endTime,omitempty"`
+	Code      string `json:"code,omitempty"`
+	Name      string `json:"name,omitempty"`
+	Cmd       string `json:"cmd,omitempty"`
+	Data      string `json:"data,omitempty"` // 原始 logData,超过 500 字截断
+}
+
+var AlarmEvents = kit.Def[alarmEventsArgs]{
+	Name:  "alarm_events",
+	Title: "查询站点告警/事件记录",
+	Description: "查询单个站点在一段时间内的告警和事件记录(FWH 平台日志),按时间倒序。用于排查某次故障前后发生了什么。" +
+		"返回字段:kind(告警/事件)、logTime(设备当地时间)、startTime、endTime(告警结束时间,未结束为空)、code(告警码,事件为 0)、name、cmd、data(原始日志数据,超过 500 字截断)。" +
+		"超过 limit 时 truncated=true,请缩短时间范围。时间范围较大时查询较慢。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a alarmEventsArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		from, to, err := kit.ParseRange(a.From, a.To, 31*24*time.Hour)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		kind, err := kit.Enum("kind", a.Kind, "all", "all", "alarm", "event")
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		t := repositories.EventAndAlarm
+		switch kind {
+		case "alarm":
+			t = repositories.Alarm
+		case "event":
+			t = repositories.Event
+		}
+		items, err := repositories.QueryEventOrAlarmListOnTimeRange(sn, from, to, t)
+		if err != nil {
+			return kit.Upstream("FWH 平台事件告警日志接口", err), nil
+		}
+		rows := MapEventRows(items)
+		return kit.List(rows, -1, kit.Limit(a.Limit, 100, 500), "结果已截断,请缩短时间范围或改用 kind 过滤"), nil
+	},
+}
+
+// MapEventRows 转为精简行并按设备时间倒序。
+func MapEventRows(items []domain.EventAlarmItem) []EventRow {
+	rows := make([]EventRow, 0, len(items))
+	for _, it := range items {
+		r := EventRow{
+			Kind:      "事件",
+			LogTime:   it.LogTime,
+			StartTime: it.StartTime,
+			Code:      it.AlarmCode,
+			Name:      it.LogName,
+			Cmd:       strings.TrimSpace(it.CmdName + " " + it.CmdType),
+			Data:      clip(it.LogData, 500),
+		}
+		if it.LogType == "1" {
+			r.Kind = "告警"
+		}
+		if it.EndTime != nil {
+			r.EndTime = *it.EndTime
+		}
+		if r.Code == "0" {
+			r.Code = ""
+		}
+		rows = append(rows, r)
+	}
+	sort.SliceStable(rows, func(i, j int) bool { return rows[i].LogTime > rows[j].LogTime })
+	return rows
+}
+
+func clip(s string, n int) string {
+	if utf8.RuneCountInString(s) <= n {
+		return s
+	}
+	return string([]rune(s)[:n]) + "…"
+}
+
+// ---- alarm_snapshot ----
+
+type alarmSnapshotArgs struct {
+	SN   string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	Date string `json:"date" jsonschema:"日期,yyyy-MM-dd"`
+}
+
+var AlarmSnapshot = kit.Def[alarmSnapshotArgs]{
+	Name:  "alarm_snapshot",
+	Title: "查询站点告警快照",
+	Description: "查询单个站点某一天的告警快照记录(告警发生时设备上传的快照文件信息)。" +
+		"返回字段:id、fileName、fileUrl、时间等(以实际返回为准)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a alarmSnapshotArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		day, err := kit.ParseDate("date", a.Date, false)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		items, err := repositories.QueryAlarmSnapshotInfo(sn, day.Format(kit.DateLayout))
+		if err != nil {
+			return kit.Upstream("FWH 平台告警快照接口", err), nil
+		}
+		return kit.List(items, -1, 100, ""), nil
+	},
+}
+
+// ---- alarm_stats ----
+
+type alarmStatsArgs struct {
+	Source  string `json:"source,omitempty" jsonschema:"daily(默认):ToolBox 每日告警统计,按告警码汇总站点数;realtime:FWH 平台当前告警统计"`
+	Date    string `json:"date,omitempty" jsonschema:"daily 时的统计日期 yyyy-MM-dd(北京时间),不填取最新"`
+	Country string `json:"country,omitempty" jsonschema:"realtime 时的国家:all(默认)、us(美国)、au(澳大利亚)"`
+	Page    int    `json:"page,omitempty" jsonschema:"realtime 时的页码,默认 1"`
+	Top     int    `json:"top,omitempty" jsonschema:"daily 时返回告警码数量最多的前 N 个,1~200,默认 50"`
+}
+
+// CodeCount 告警码计数
+type CodeCount struct {
+	Code  string `json:"code"`
+	Count uint   `json:"count"`
+}
+
+var AlarmStats = kit.Def[alarmStatsArgs]{
+	Name:  "alarm_stats",
+	Title: "查询全站告警统计",
+	Description: "查询全量站点的告警统计。source=daily(默认):ToolBox 每日统计,返回 date、siteStatistics(有告警的站点数)、codes(按触发站点数倒序的告警码列表 code/count);" +
+		"source=realtime:FWH 平台当前告警统计(按国家、分页),返回 total 与 records。" +
+		"查看某个告警码涉及哪些站点用 alarm_sites_by_code。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a alarmStatsArgs) (*mcp.CallToolResult, error) {
+		src, err := kit.Enum("source", a.Source, "daily", "daily", "realtime")
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		if src == "realtime" {
+			country, err := parseCountry(a.Country)
+			if err != nil {
+				return kit.Fail(err), nil
+			}
+			res, err := repositories.QueryAlarmStatistics(country, kit.Page(a.Page))
+			if err != nil {
+				return kit.Upstream("FWH 平台告警统计接口", err), nil
+			}
+			if res == nil || res.Data == nil {
+				return kit.Empty(""), nil
+			}
+			return kit.List(res.Data.Records, res.Data.Total, 200, fmt.Sprintf("共 %d 页,可用 page 翻页", res.Data.PageTotal)), nil
+		}
+
+		day, err := kit.ParseDate("date", a.Date, true)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		var datePtr *string
+		if day != nil {
+			s := day.Format(kit.DateLayout)
+			datePtr = &s
+		}
+		dash, err := services.GetAlarmStatistics(datePtr)
+		if err != nil {
+			return kit.Upstream("ToolBox 告警统计", err), nil
+		}
+		if dash == nil {
+			return kit.Empty("没有该日期的告警统计。"), nil
+		}
+		codes := SortCodeCounts(dash.CodeStatistics)
+		top := kit.Limit(a.Top, 50, 200)
+		total := len(codes)
+		if len(codes) > top {
+			codes = codes[:top]
+		}
+		return kit.Object(map[string]any{
+			"date":           dash.Date,
+			"siteStatistics": dash.SiteStatistics,
+			"codeTotal":      total,
+			"codes":          codes,
+		}), nil
+	},
+}
+
+// SortCodeCounts 按计数倒序、告警码升序排列。
+func SortCodeCounts(m map[string]uint) []CodeCount {
+	out := make([]CodeCount, 0, len(m))
+	for k, v := range m {
+		out = append(out, CodeCount{Code: k, Count: v})
+	}
+	sort.Slice(out, func(i, j int) bool {
+		if out[i].Count != out[j].Count {
+			return out[i].Count > out[j].Count
+		}
+		return out[i].Code < out[j].Code
+	})
+	return out
+}
+
+// ---- alarm_sites_by_code ----
+
+type alarmSitesByCodeArgs struct {
+	Code    string `json:"code" jsonschema:"告警码"`
+	Country string `json:"country,omitempty" jsonschema:"国家:all(默认)、us(美国)、au(澳大利亚)"`
+	Page    int    `json:"page,omitempty" jsonschema:"页码,默认 1"`
+}
+
+var AlarmSitesByCode = kit.Def[alarmSitesByCodeArgs]{
+	Name:        "alarm_sites_by_code",
+	Title:       "查询触发某告警码的站点",
+	Description: "查询当前触发指定告警码的站点列表(FWH 平台实时告警,按国家、分页)。返回 total 与 items(站点 SN、告警时间等,以实际返回为准)。",
+	ReadOnly:    true,
+	Handler: func(ctx context.Context, a alarmSitesByCodeArgs) (*mcp.CallToolResult, error) {
+		code, err := kit.Required("code", a.Code)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		country, err := parseCountry(a.Country)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		res, err := repositories.QueryAlarmStatisticsFromCode(country, code, kit.Page(a.Page))
+		if err != nil {
+			return kit.Upstream("FWH 平台告警统计接口", err), nil
+		}
+		if res == nil || res.Data == nil {
+			return kit.Empty(""), nil
+		}
+		return kit.List(res.Data.Records, res.Data.Total, 200, fmt.Sprintf("共 %d 页,可用 page 翻页", res.Data.PageTotal)), nil
+	},
+}

+ 129 - 0
service/agentmcp/tools/faultrecord.go

@@ -0,0 +1,129 @@
+package tools
+
+import (
+	"context"
+	"time"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/repositories"
+)
+
+// deviceTypeDesc 故障录波设备类型说明
+const deviceTypeDesc = "deviceType:0 aPower-PE、1 aGate/MCA、2 aPower-MPPT、3 aHub"
+
+// ---- fault_record_list ----
+
+type faultRecordListArgs struct {
+	SN    string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	From  string `json:"from" jsonschema:"开始时间,yyyy-MM-dd HH:mm:ss"`
+	To    string `json:"to" jsonschema:"结束时间,同上;与开始时间相差不超过 92 天"`
+	Limit int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~300,默认 100"`
+}
+
+var FaultRecordList = kit.Def[faultRecordListArgs]{
+	Name:  "fault_record_list",
+	Title: "查询故障录波记录",
+	Description: "查询站点在一段时间内的故障录波日志(FWH 平台)。返回字段:id(录波记录 ID,用于 fault_record_detail)、" + deviceTypeDesc +
+		"、apowerId / powerlinkId(对应设备 SN)、gatewayId、triggerType(1 手动、2 自动)、触发时间与状态等(以实际返回为准)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a faultRecordListArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		from, to, err := kit.ParseRange(a.From, a.To, 92*24*time.Hour)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		items, err := repositories.QueryFaultRecordList(sn, from.Format(kit.DateTimeLayout), to.Format(kit.DateTimeLayout))
+		if err != nil {
+			return kit.Upstream("FWH 平台故障录波接口", err), nil
+		}
+		return kit.List(items, -1, kit.Limit(a.Limit, 100, 300), "结果已截断,请缩短时间范围"), nil
+	},
+}
+
+// ---- fault_record_detail ----
+
+type faultRecordDetailArgs struct {
+	FaultID int64 `json:"faultId" jsonschema:"故障录波记录 ID(fault_record_list 返回的 id)"`
+}
+
+var FaultRecordDetail = kit.Def[faultRecordDetailArgs]{
+	Name:  "fault_record_detail",
+	Title: "查询故障录波子记录",
+	Description: "查询一条故障录波记录下的子记录(各设备的录波文件)。返回字段:id、faultId、" + deviceTypeDesc +
+		"、apowerId、gatewayId、fileName(不含后缀)、文件状态与时间等(以实际返回为准)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a faultRecordDetailArgs) (*mcp.CallToolResult, error) {
+		if a.FaultID <= 0 {
+			return kit.Failf("参数 faultId 必填且为正整数"), nil
+		}
+		items, err := repositories.QueryFaultRecordSubList(a.FaultID)
+		if err != nil {
+			return kit.Upstream("FWH 平台故障录波接口", err), nil
+		}
+		return kit.List(items, -1, 200, ""), nil
+	},
+}
+
+// ---- fault_record_callable ----
+
+type faultRecordCallableArgs struct {
+	SN         string `json:"sn" jsonschema:"设备 SN(aGate / aPower / MPPT / aHub)"`
+	DeviceType *int   `json:"deviceType,omitempty" jsonschema:"设备类型:0 aPower、1 aGate(默认)、2 MPPT、3 aHub"`
+}
+
+var FaultRecordCallable = kit.Def[faultRecordCallableArgs]{
+	Name:  "fault_record_callable",
+	Title: "查询可召唤的故障录波",
+	Description: "查询设备上可召唤(尚未上传)的自动故障录波数据列表(FWH 平台)。" +
+		"返回字段:idx / indexNumber(数据序号)、failureTime(故障时间)、paramVersionNumber(故障信息)、triggerSourceCode(故障码)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a faultRecordCallableArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		devType := 1
+		if a.DeviceType != nil {
+			devType = *a.DeviceType
+		}
+		if devType < 0 || devType > 3 {
+			return kit.Failf("参数 deviceType 取值 0~3"), nil
+		}
+		items, err := repositories.QueryFaultRecordCallableList(devType, sn)
+		if err != nil {
+			return kit.Upstream("FWH 平台故障录波接口", err), nil
+		}
+		if len(items) == 0 {
+			return kit.Empty("该设备没有可召唤的故障录波数据。"), nil
+		}
+		return kit.List(items, -1, 200, ""), nil
+	},
+}
+
+// ---- fault_record_call_history ----
+
+type faultRecordCallHistoryArgs struct {
+	Page int `json:"page,omitempty" jsonschema:"页码,默认 1"`
+	Size int `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
+}
+
+var FaultRecordCallHistory = kit.Def[faultRecordCallHistoryArgs]{
+	Name:  "fault_record_call_history",
+	Title: "查询故障录波召唤记录",
+	Description: "分页查询 FWH 平台上全部站点的故障录波召唤记录(手动召唤的历史),按时间倒序。" +
+		"返回字段与 fault_record_list 相同。查看单个站点的录波请用 fault_record_list。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a faultRecordCallHistoryArgs) (*mcp.CallToolResult, error) {
+		size := kit.Limit(a.Size, 20, 100)
+		items, err := repositories.QueryFaultRecordCallRecordPage(kit.Page(a.Page), size)
+		if err != nil {
+			return kit.Upstream("FWH 平台故障录波接口", err), nil
+		}
+		return kit.List(items, -1, size, "可用 page 翻页"), nil
+	},
+}

+ 168 - 0
service/agentmcp/tools/logs.go

@@ -0,0 +1,168 @@
+package tools
+
+import (
+	"context"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/repositories"
+)
+
+// ---- mqtt_log_search ----
+
+type mqttLogSearchArgs struct {
+	SN      string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	CmdType string `json:"cmdType" jsonschema:"MQTT 命令类型,如 0(连接中断)、101(登录)、105(心跳)、107(参数更新)、201、211 等"`
+	From    string `json:"from" jsonschema:"开始时间,yyyy-MM-dd HH:mm:ss"`
+	To      string `json:"to" jsonschema:"结束时间,同上;与开始时间相差不超过 7 天"`
+	Limit   int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~300,默认 50"`
+}
+
+// MqttRow mqtt_log_search 的精简行
+type MqttRow struct {
+	LogTime string `json:"logTime"`        // 设备时间
+	Time    string `json:"time,omitempty"` // UTC 时间
+	Cmd     string `json:"cmd,omitempty"`
+	Data    string `json:"data,omitempty"` // 原始报文,超过 1500 字截断
+}
+
+var MqttLogSearch = kit.Def[mqttLogSearchArgs]{
+	Name:  "mqtt_log_search",
+	Title: "查询站点 MQTT 日志",
+	Description: "查询单个站点在一段时间内某类 MQTT 命令的日志(FWH 平台),按时间倒序。" +
+		"返回字段:logTime(设备时间)、time(UTC)、cmd、data(原始报文 JSON,超过 1500 字截断)。报文较大,建议缩小时间范围、减少 limit。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a mqttLogSearchArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		cmd, err := kit.Required("cmdType", a.CmdType)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		from, to, err := kit.ParseRange(a.From, a.To, 7*24*time.Hour)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		items, err := repositories.QueryMqttLogListOnTimeRange(sn, domain.MqttCmdType(cmd), from, to)
+		if err != nil {
+			return kit.Upstream("FWH 平台 MQTT 日志接口", err), nil
+		}
+		rows := make([]MqttRow, 0, len(items))
+		for _, it := range items {
+			rows = append(rows, MqttRow{
+				LogTime: it.LogTime,
+				Time:    it.Time,
+				Cmd:     strings.TrimSpace(it.CmdType + " " + it.CmdName),
+				Data:    clip(it.LogData, 1500),
+			})
+		}
+		sort.SliceStable(rows, func(i, j int) bool { return rows[i].LogTime > rows[j].LogTime })
+		return kit.List(rows, -1, kit.Limit(a.Limit, 50, 300), "结果已截断,请缩短时间范围"), nil
+	},
+}
+
+// ---- ems_log_records ----
+
+type emsLogRecordsArgs struct {
+	SN      string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	Date    string `json:"date,omitempty" jsonschema:"日志日期 yyyy-MM-dd,不填查询全部"`
+	LogType int    `json:"logType,omitempty" jsonschema:"日志类型,默认 0(全部),取值以 FWH 平台定义为准"`
+}
+
+var EmsLogRecords = kit.Def[emsLogRecordsArgs]{
+	Name:  "ems_log_records",
+	Title: "查询 EMS 日志召唤记录",
+	Description: "查询站点最近的 EMS 日志召唤记录(FWH 平台)。返回字段:id、fileName、callTime(召唤时间)、completeTime、status(1 成功、0 召唤中、-1 失败)、fileUrl、linkId(SN)。" +
+		"没有召唤记录时返回提示。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a emsLogRecordsArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		date := ""
+		if d, err := kit.ParseDate("date", a.Date, true); err != nil {
+			return kit.Fail(err), nil
+		} else if d != nil {
+			date = d.Format(kit.DateLayout)
+		}
+		rec, err := repositories.QueryEmsLogCallRecord(sn, date, a.LogType)
+		if err != nil {
+			// 上游在"没有召唤日志"时也返回 error,这里按无数据处理并带上原因
+			return kit.Empty("未找到 EMS 日志召唤记录:" + err.Error()), nil
+		}
+		return kit.Object(rec), nil
+	},
+}
+
+// ---- update_log_system ----
+
+type updateLogSystemArgs struct {
+	SN      string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	OrderID string `json:"orderId,omitempty" jsonschema:"升级单 ID(列表中的 id),填写时返回该升级单在本站点的明细"`
+	Limit   int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~200,默认 50"`
+}
+
+var UpdateLogSystem = kit.Def[updateLogSystemArgs]{
+	Name:  "update_log_system",
+	Title: "查询系统升级记录",
+	Description: "查询站点的系统(固件)升级记录(FWH 平台)。不填 orderId 时返回升级单列表:id、orderNo(升级单号)、name、upgradeType、upgradeTime(UTC)、upgradeNum 等;" +
+		"填 orderId 时返回该升级单在本站点各设备的升级明细(版本、状态、时间等,以实际返回为准)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a updateLogSystemArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		limit := kit.Limit(a.Limit, 50, 200)
+		if id := strings.TrimSpace(a.OrderID); id != "" {
+			orderID, err := strconv.ParseInt(id, 10, 64)
+			if err != nil {
+				return kit.Failf("参数 orderId 必须是数字:%s", id), nil
+			}
+			items, err := repositories.QuerySystemUpdateLogDetail(orderID, sn)
+			if err != nil {
+				return kit.Upstream("FWH 平台升级记录接口", err), nil
+			}
+			return kit.List(items, -1, limit, ""), nil
+		}
+		items, err := repositories.QuerySystemUpdateLog(sn)
+		if err != nil {
+			return kit.Upstream("FWH 平台升级记录接口", err), nil
+		}
+		return kit.List(items, -1, limit, ""), nil
+	},
+}
+
+// ---- update_log_ahub ----
+
+type updateLogAhubArgs struct {
+	SN    string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	Limit int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~200,默认 50"`
+}
+
+var UpdateLogAhub = kit.Def[updateLogAhubArgs]{
+	Name:        "update_log_ahub",
+	Title:       "查询 aHub 升级记录",
+	Description: "查询站点下 aHub(PowerLink)的升级记录(FWH 平台),返回升级时间、版本、状态等(以实际返回为准)。",
+	ReadOnly:    true,
+	Handler: func(ctx context.Context, a updateLogAhubArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		items, err := repositories.QueryAHubUpdateLog(sn)
+		if err != nil {
+			return kit.Upstream("FWH 平台 aHub 升级记录接口", err), nil
+		}
+		return kit.List(items, -1, kit.Limit(a.Limit, 50, 200), ""), nil
+	},
+}

+ 216 - 0
service/agentmcp/tools/misc.go

@@ -0,0 +1,216 @@
+package tools
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/repositories"
+)
+
+// ---- ems_param_lookup ----
+
+type emsParamLookupArgs struct {
+	Kind string `json:"kind" jsonschema:"参数类型:pid 或 vid"`
+	IDs  []int  `json:"ids" jsonschema:"PID / VID 编号列表,最多 50 个"`
+}
+
+var EmsParamLookup = kit.Def[emsParamLookupArgs]{
+	Name:  "ems_param_lookup",
+	Title: "查询 EMS 参数含义",
+	Description: "按 PID 或 VID 编号查询 EMS 参数的宏名与描述(来自 EMS 参数字典),用于解读日志、报文中的参数编号。" +
+		"返回 items:id、macroName、description;未找到的编号放在 notFound。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a emsParamLookupArgs) (*mcp.CallToolResult, error) {
+		kind, err := kit.Enum("kind", a.Kind, "", "pid", "vid")
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		if kind == "" {
+			return kit.Failf("参数 kind 必填:pid 或 vid"), nil
+		}
+		if len(a.IDs) == 0 || len(a.IDs) > 50 {
+			return kit.Failf("参数 ids 需要 1~50 个编号"), nil
+		}
+		lookup := repositories.GetEmsparaPidInfo
+		if kind == "vid" {
+			lookup = repositories.GetEmsparaVidInfo
+		}
+		items := make([]map[string]any, 0, len(a.IDs))
+		var notFound []int
+		for _, id := range a.IDs {
+			if id < 0 {
+				notFound = append(notFound, id)
+				continue
+			}
+			name, desc, err := lookup(uint(id))
+			if err != nil {
+				notFound = append(notFound, id)
+				continue
+			}
+			items = append(items, map[string]any{"id": id, "macroName": name, "description": desc})
+		}
+		return kit.Object(map[string]any{"kind": kind, "items": items, "notFound": notFound}), nil
+	},
+}
+
+// ---- plane_issue_search ----
+
+type planeIssueSearchArgs struct {
+	Query string `json:"query" jsonschema:"搜索关键字"`
+	Limit int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~50,默认 20"`
+}
+
+var PlaneIssueSearch = kit.Def[planeIssueSearchArgs]{
+	Name:  "plane_issue_search",
+	Title: "搜索 Plane 工作项",
+	Description: "在 Plane 项目管理系统中按关键字搜索工作项(问题、需求),只读。返回工作项 ID、项目、序号、名称、状态等(以 Plane 返回为准)。" +
+		"查看详情与评论用 plane_issue_get。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a planeIssueSearchArgs) (*mcp.CallToolResult, error) {
+		q, err := kit.Required("query", a.Query)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		raw, err := repositories.PlaneSearchWorkItems(q, kit.Limit(a.Limit, 20, 50), "")
+		if err != nil {
+			return kit.Upstream("Plane", err), nil
+		}
+		return rawJSON(raw), nil
+	},
+}
+
+// ---- plane_issue_get ----
+
+type planeIssueGetArgs struct {
+	ID         string `json:"id,omitempty" jsonschema:"工作项 UUID;与 identifier 二选一"`
+	Identifier string `json:"identifier,omitempty" jsonschema:"工作项标识,如 PROJ-42;与 id 二选一"`
+	Comments   *bool  `json:"comments,omitempty" jsonschema:"是否附带评论,默认 true"`
+}
+
+var PlaneIssueGet = kit.Def[planeIssueGetArgs]{
+	Name:        "plane_issue_get",
+	Title:       "查询 Plane 工作项详情",
+	Description: "查询 Plane 工作项详情(按 UUID 或 PROJ-42 形式的标识),默认附带评论。返回 item(工作项字段)与 comments(评论列表),只读。",
+	ReadOnly:    true,
+	Handler: func(ctx context.Context, a planeIssueGetArgs) (*mcp.CallToolResult, error) {
+		var raw []byte
+		var err error
+		switch {
+		case strings.TrimSpace(a.ID) != "":
+			raw, err = repositories.PlaneGetWorkItem(strings.TrimSpace(a.ID))
+		case strings.TrimSpace(a.Identifier) != "":
+			proj, seq, perr := parseIdentifier(a.Identifier)
+			if perr != nil {
+				return kit.Fail(perr), nil
+			}
+			raw, err = repositories.PlaneGetWorkItemByIdentifier(proj, seq)
+		default:
+			return kit.Failf("参数 id 与 identifier 必须填写一个"), nil
+		}
+		if err != nil {
+			return kit.Upstream("Plane", err), nil
+		}
+		var item map[string]any
+		if err := json.Unmarshal(raw, &item); err != nil {
+			return kit.Failf("Plane 返回内容无法解析:%v", err), nil
+		}
+		out := map[string]any{"item": item}
+		if a.Comments == nil || *a.Comments {
+			if id, _ := item["id"].(string); id != "" {
+				if cr, err := repositories.PlaneListWorkItemComments(id); err == nil {
+					var comments any
+					if json.Unmarshal(cr, &comments) == nil {
+						out["comments"] = comments
+					}
+				} else {
+					out["commentsError"] = err.Error()
+				}
+			}
+		}
+		return kit.Object(out), nil
+	},
+}
+
+// parseIdentifier 解析 PROJ-42 形式的标识。
+func parseIdentifier(s string) (string, int, error) {
+	s = strings.TrimSpace(s)
+	i := strings.LastIndex(s, "-")
+	if i <= 0 || i == len(s)-1 {
+		return "", 0, fmt.Errorf("identifier 格式应为 PROJ-42:%s", s)
+	}
+	var seq int
+	if _, err := fmt.Sscanf(s[i+1:], "%d", &seq); err != nil || seq <= 0 {
+		return "", 0, fmt.Errorf("identifier 序号必须是正整数:%s", s)
+	}
+	return strings.ToUpper(s[:i]), seq, nil
+}
+
+// rawJSON 把上游返回的 JSON 字节作为对象输出(脱敏、去空值、限长)。
+func rawJSON(raw []byte) *mcp.CallToolResult {
+	var v any
+	if err := json.Unmarshal(raw, &v); err != nil {
+		return kit.Failf("返回内容无法解析:%v", err)
+	}
+	return kit.Object(v)
+}
+
+// ---- sn_parse ----
+
+type snParseArgs struct {
+	SN string `json:"sn" jsonschema:"设备 SN,标准长度 20 位,如 10060006A03X25310091"`
+}
+
+var SnParse = kit.Def[snParseArgs]{
+	Name:  "sn_parse",
+	Title: "解析设备 SN",
+	Description: "解析 FranklinWH 设备 SN 的含义。格式:{产品代码 4 位}{硬件版本 4 位}{工厂 1 位}{版本标识 2 位}{预留 1 位}{年 2 位}{周 2 位}{序号}。" +
+		"产品代码:1006 aGate、1005 aPower、1002 aHub/PowerLink、1003 MPPT。返回 productCode、productName、hwVersion、factory、versionFlag、year、week、serialNumber、summary。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a snParseArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		r, err := ParseSN(sn)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		return kit.Object(r), nil
+	},
+}
+
+// ParseSN 按 SN 规则拆分字段(规则来自旧 MCP 的 util_parse_sn)。
+func ParseSN(sn string) (map[string]any, error) {
+	sn = strings.TrimSpace(sn)
+	if len(sn) < 20 {
+		return nil, fmt.Errorf("SN 长度不足,标准长度为 20 位:%s", sn)
+	}
+	productCode := sn[0:4]
+	productName := map[string]string{
+		"1006": "aGate (网关)",
+		"1005": "aPower (储能电池)",
+		"1002": "aHub / PowerLink",
+		"1003": "MPPT",
+	}[productCode]
+	if productName == "" {
+		productName = "未知产品"
+	}
+	hw, factory, verFlag, year, week, serial := sn[4:8], string(sn[8]), sn[9:11], sn[12:14], sn[14:16], sn[16:]
+	return map[string]any{
+		"sn":           sn,
+		"productCode":  productCode,
+		"productName":  productName,
+		"hwVersion":    hw,
+		"factory":      factory,
+		"versionFlag":  verFlag,
+		"year":         "20" + year,
+		"week":         week,
+		"serialNumber": serial,
+		"summary":      fmt.Sprintf("%s | HW:%s | 工厂:%s | 20%s年第%s周 | 序号:%s", productName, hw, factory, year, week, serial),
+	}, nil
+}

+ 136 - 0
service/agentmcp/tools/ops.go

@@ -0,0 +1,136 @@
+package tools
+
+import (
+	"context"
+	"encoding/json"
+	"strings"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/services"
+)
+
+// ServiceAccount 写操作在业务系统中的执行身份(Agent MCP 不区分调用者)
+const ServiceAccount = "AI 助手"
+
+// ---- feedback_create ----
+
+type feedbackCreateArgs struct {
+	Title       string `json:"title" jsonschema:"标题"`
+	Type        string `json:"type" jsonschema:"类型:bug 或 feature"`
+	Priority    string `json:"priority,omitempty" jsonschema:"优先级:urgent、high、medium(默认)、low、none"`
+	Category    string `json:"category,omitempty" jsonschema:"分类:bug 类为 bug、ui、performance、other;feature 类为 new、improvement、experience"`
+	Description string `json:"description,omitempty" jsonschema:"详细描述:背景、现象、影响站点、复现步骤等"`
+}
+
+var FeedbackCreate = kit.Def[feedbackCreateArgs]{
+	Name:  "feedback_create",
+	Title: "提交问题反馈到 Plane",
+	Description: "在 Plane 中创建一条 Intake 工作项(问题反馈或需求)。提交人固定为服务账号“AI 助手”,请在描述中写明背景与来源。" +
+		"返回 Plane 创建结果(工作项 ID 等)。这是写操作,执行前会请求确认。",
+	ReadOnly: false,
+	Handler: func(ctx context.Context, a feedbackCreateArgs) (*mcp.CallToolResult, error) {
+		title, err := kit.Required("title", a.Title)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		typ, err := kit.Enum("type", a.Type, "", "bug", "feature")
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		if typ == "" {
+			return kit.Failf("参数 type 必填:bug 或 feature"), nil
+		}
+		priority, err := kit.Enum("priority", a.Priority, "medium", "urgent", "high", "medium", "low", "none")
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		res, err := services.SubmitFeedback(dto.FeedbackSubmitDTO{
+			Title:       title,
+			Type:        typ,
+			Priority:    priority,
+			Category:    strings.TrimSpace(a.Category),
+			Description: a.Description,
+		}, ServiceAccount)
+		if err != nil {
+			return kit.Upstream("Plane", err), nil
+		}
+		return kit.Object(res), nil
+	},
+}
+
+// ---- tou_template_save ----
+
+type touTemplateSaveArgs struct {
+	Payload string `json:"payload" jsonschema:"模版请求体 JSON:{\"template\":{...},\"strategyList\":[{...}],\"lang\":\"ZH_CN\"}。template.templateId 有值为更新、省略为新增;字段定义见技能 references 中的 TOU 模版说明"`
+}
+
+var TouTemplateSave = kit.Def[touTemplateSaveArgs]{
+	Name:  "tou_template_save",
+	Title: "保存 TOU 调度模版(测试服)",
+	Description: "在 FWH 测试服新增或更新 TOU 调度模版,只支持测试服。保存前校验每个策略的时段首尾衔接覆盖全天、sourceType 为 0;" +
+		"返回 templateId 与按调度模式识别后的策略摘要。这是写操作,执行前会请求确认。",
+	ReadOnly: false,
+	Handler: func(ctx context.Context, a touTemplateSaveArgs) (*mcp.CallToolResult, error) {
+		raw, err := kit.Required("payload", a.Payload)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		var payload dto.FwhTestUpdateOrSaveTouTemplatePayload
+		if err := json.Unmarshal([]byte(raw), &payload); err != nil {
+			return kit.Failf("参数 payload 不是有效的 JSON:%v", err), nil
+		}
+		if payload.Template.SourceType != 0 {
+			return kit.Failf("template.sourceType 只能为 0(系统模版)"), nil
+		}
+		if len(payload.StrategyList) == 0 {
+			return kit.Failf("strategyList 不能为空"), nil
+		}
+		organized, _ := OrganizeTouDetail(payload)
+		if issues := strategyIssues(organized); len(issues) > 0 {
+			return kit.Failf("模版校验未通过:%s", strings.Join(issues, ";")), nil
+		}
+		if payload.Lang == "" {
+			payload.Lang = "ZH_CN"
+		}
+		id, err := services.FwhTestSaveTouTemplate(payload)
+		if err != nil {
+			return kit.Upstream("FWH 测试服 TOU 模版接口", err), nil
+		}
+		return kit.Object(map[string]any{"templateId": id, "message": "已保存到测试服", "strategies": organized["strategies"]}), nil
+	},
+}
+
+// strategyIssues 汇总整理结果中的时段衔接问题。
+func strategyIssues(organized map[string]any) []string {
+	var out []string
+	sts, _ := organized["strategies"].([]map[string]any)
+	for _, st := range sts {
+		if is, ok := st["issues"].([]string); ok {
+			name, _ := st["seasonName"].(string)
+			day, _ := st["dayName"].(string)
+			for _, i := range is {
+				out = append(out, name+"/"+day+":"+i)
+			}
+		}
+	}
+	return out
+}
+
+// ---- site_info_refresh ----
+
+var SiteInfoRefresh = kit.Def[kit.NoArgs]{
+	Name:  "site_info_refresh",
+	Title: "触发站点信息表更新",
+	Description: "触发 ToolBox 站点信息表的更新任务(从 FWH 平台同步最新站点数据),任务在后台执行。" +
+		"返回是否已触发;同步通常需要数分钟,完成后 site_search 的结果才会更新。这是写操作,执行前会请求确认。",
+	ReadOnly: false,
+	Handler: func(ctx context.Context, _ kit.NoArgs) (*mcp.CallToolResult, error) {
+		if err := services.UpdateSiteInfoDate(); err != nil {
+			return kit.Upstream("ToolBox 站点信息更新", err), nil
+		}
+		return mcp.NewToolResultText("已触发站点信息表更新,任务在后台执行。"), nil
+	},
+}

+ 31 - 0
service/agentmcp/tools/registry.go

@@ -0,0 +1,31 @@
+// Package tools 是 fwh-agent-mcp 的业务工具。工具不感知调用者,清单见 agent-mcp.md 第 4、7 节。
+package tools
+
+import "fwh-toolbox-server/agentmcp/kit"
+
+// ReadOnly 只读工具集(-set=readonly)
+func ReadOnly() []kit.Registrar {
+	return []kit.Registrar{
+		// 站点
+		SiteSearch, SiteGet, SitePowerFlow, SitePowerFlowExport,
+		// 告警
+		AlarmCurrent, AlarmEvents, AlarmSnapshot, AlarmStats, AlarmSitesByCode,
+		// 停机
+		ShutdownStats, ShutdownSites,
+		// 日志
+		MqttLogSearch, EmsLogRecords, UpdateLogSystem, UpdateLogAhub,
+		// 故障录波
+		FaultRecordList, FaultRecordDetail, FaultRecordCallable, FaultRecordCallHistory,
+		// 调度
+		TouTemplateList, TouTemplateGet,
+		// SIM
+		SimGet, SimSearch, SimUsageMonthly,
+		// 其他
+		EmsParamLookup, PlaneIssueSearch, PlaneIssueGet, SnParse,
+	}
+}
+
+// Ops 写工具集(-set=ops)
+func Ops() []kit.Registrar {
+	return []kit.Registrar{FeedbackCreate, TouTemplateSave, SiteInfoRefresh}
+}

+ 84 - 0
service/agentmcp/tools/shutdown.go

@@ -0,0 +1,84 @@
+package tools
+
+import (
+	"context"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/repositories"
+	"fwh-toolbox-server/services"
+)
+
+// ---- shutdown_stats ----
+
+type shutdownStatsArgs struct {
+	Date string `json:"date,omitempty" jsonschema:"统计日期 yyyy-MM-dd(北京时间),不填取最新可用日期"`
+}
+
+var ShutdownStats = kit.Def[shutdownStatsArgs]{
+	Name:  "shutdown_stats",
+	Title: "查询停机统计",
+	Description: "查询 ToolBox 每日停机统计汇总。返回:date;count(当日停机站点数,all/us/au);" +
+		"weekly(截至该日最近 7 天每天的停机站点数,all/us/au 各 7 个数,按时间顺序);yearly(最近 12 个月每月的停机站点数)。" +
+		"查看当日具体是哪些站点用 shutdown_sites。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a shutdownStatsArgs) (*mcp.CallToolResult, error) {
+		day, err := kit.ParseDate("date", a.Date, true)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		var datePtr *string
+		if day != nil {
+			s := day.Format(kit.DateLayout)
+			datePtr = &s
+		} else {
+			latest, err := services.GetShutdownStatisticsDate()
+			if err != nil {
+				return kit.Upstream("ToolBox 停机统计", err), nil
+			}
+			datePtr = latest
+		}
+		dash, err := services.GetShutdownStatistics(datePtr)
+		if err != nil {
+			return kit.Upstream("ToolBox 停机统计", err), nil
+		}
+		out := map[string]any{"date": dash.Date, "weekly": dash.Weekly, "yearly": dash.Yearly}
+		if item, err := services.GetShutdownInfoMap(datePtr); err == nil && item != nil {
+			out["count"] = item.Count
+			out["status"] = item.Status
+		}
+		return kit.Object(out), nil
+	},
+}
+
+// ---- shutdown_sites ----
+
+type shutdownSitesArgs struct {
+	Date    string `json:"date" jsonschema:"日期 yyyy-MM-dd"`
+	Country string `json:"country,omitempty" jsonschema:"国家:all(默认)、us(美国)、au(澳大利亚)"`
+	Limit   int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~500,默认 200"`
+}
+
+var ShutdownSites = kit.Def[shutdownSitesArgs]{
+	Name:  "shutdown_sites",
+	Title: "查询停机站点列表",
+	Description: "查询某一天发生停机(Shutdown)的站点列表(FWH 平台)。" +
+		"返回字段:gatewayId(站点 SN)、shutdownType(1 系统停机、2 aPower 停机)、country、province、activeTime(激活时间)、deviceTime(停机开始的设备时间)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a shutdownSitesArgs) (*mcp.CallToolResult, error) {
+		day, err := kit.ParseDate("date", a.Date, false)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		country, err := parseCountry(a.Country)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		items, err := repositories.QuerySiteShutdownList(day.Format(kit.DateLayout), int(country))
+		if err != nil {
+			return kit.Upstream("FWH 平台停机列表接口", err), nil
+		}
+		return kit.List(items, -1, kit.Limit(a.Limit, 200, 500), "结果已截断,可按 country 分别查询"), nil
+	},
+}

+ 155 - 0
service/agentmcp/tools/sim.go

@@ -0,0 +1,155 @@
+package tools
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/services"
+)
+
+// ---- sim_get ----
+
+type simGetArgs struct {
+	ICCID string `json:"iccid" jsonschema:"SIM 卡 GICCID(19 位)"`
+}
+
+var SimGet = kit.Def[simGetArgs]{
+	Name:  "sim_get",
+	Title: "查询 SIM 卡",
+	Description: "按 GICCID 查询单张 SIM 卡的详细信息(ToolBox SIM 卡管理,平台为 SmartHub 或 Acceleronix,自动判断)。" +
+		"返回状态、平台、关联站点 SN、本月用量(KB)、激活时间等(IMEI/IMSI/MSISDN 已脱敏)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a simGetArgs) (*mcp.CallToolResult, error) {
+		id, err := kit.Required("iccid", a.ICCID)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		info, err := services.GetSimCardInfo(id)
+		if err != nil {
+			return kit.Upstream("SIM 卡管理", err), nil
+		}
+		if info == nil {
+			return kit.Empty("没有找到该 SIM 卡。"), nil
+		}
+		return kit.Object(info), nil
+	},
+}
+
+// ---- sim_search ----
+
+type simSearchArgs struct {
+	Search   string `json:"search,omitempty" jsonschema:"搜索关键字(GICCID 片段)"`
+	Status   string `json:"status,omitempty" jsonschema:"状态过滤,多个用逗号分隔:UNKNOWN、NOT_ACTIVATED、ACTIVATED、DEACTIVATED、RETIRED"`
+	Platform string `json:"platform,omitempty" jsonschema:"平台过滤,多个用逗号分隔:SmartHub、Acceleronix"`
+	Page     int    `json:"page,omitempty" jsonschema:"页码,默认 1"`
+	Size     int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
+}
+
+var simStatuses = []string{"UNKNOWN", "NOT_ACTIVATED", "ACTIVATED", "DEACTIVATED", "RETIRED"}
+var simPlatforms = []string{"SmartHub", "Acceleronix"}
+
+var SimSearch = kit.Def[simSearchArgs]{
+	Name:        "sim_search",
+	Title:       "搜索 SIM 卡",
+	Description: "按关键字、状态、平台分页搜索 SIM 卡(ToolBox SIM 卡管理)。返回 total 与 items(GICCID、状态、平台、关联 SN、用量等)。",
+	ReadOnly:    true,
+	Handler: func(ctx context.Context, a simSearchArgs) (*mcp.CallToolResult, error) {
+		status, err := enumList("status", a.Status, simStatuses)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		platform, err := enumList("platform", a.Platform, simPlatforms)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		page := kit.Page(a.Page)
+		size := kit.Limit(a.Size, 20, 100)
+		data, err := services.GetSimCardBatchInfo(uint(page), uint(size), strings.TrimSpace(a.Search), status, platform)
+		if err != nil {
+			return kit.Upstream("SIM 卡管理", err), nil
+		}
+		if data == nil {
+			return kit.Empty(""), nil
+		}
+		return kit.List(data.Data, int(data.Total), size, fmt.Sprintf("共 %d 条,当前第 %d 页,可用 page 翻页", data.Total, page)), nil
+	},
+}
+
+// enumList 校验逗号分隔的多选值;为空返回 nil。
+func enumList(name, v string, allowed []string) (*string, error) {
+	v = strings.TrimSpace(v)
+	if v == "" {
+		return nil, nil
+	}
+	parts := strings.Split(v, ",")
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		e, err := kit.Enum(name, p, "", allowed...)
+		if err != nil {
+			return nil, err
+		}
+		if e != "" {
+			out = append(out, e)
+		}
+	}
+	joined := strings.Join(out, ",")
+	return &joined, nil
+}
+
+// ---- sim_usage_monthly ----
+
+type simUsageMonthlyArgs struct {
+	ICCID string `json:"iccid" jsonschema:"SIM 卡 GICCID(19 位)"`
+	Month string `json:"month" jsonschema:"月份,yyyy-MM"`
+}
+
+var SimUsageMonthly = kit.Def[simUsageMonthlyArgs]{
+	Name:        "sim_usage_monthly",
+	Title:       "查询 SIM 卡月度每日用量",
+	Description: "查询单张 SIM 卡某个月每天的数据用量。返回 month、allDataUsage(月用量,KB)、updateTime(UTC),以及 days(按日排序的每日用量明细)。",
+	ReadOnly:    true,
+	Handler: func(ctx context.Context, a simUsageMonthlyArgs) (*mcp.CallToolResult, error) {
+		id, err := kit.Required("iccid", a.ICCID)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		month, err := kit.Required("month", a.Month)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		data, err := services.GetSimCardMonthlyData(month, id)
+		if err != nil {
+			return kit.Upstream("SIM 卡管理", err), nil
+		}
+		if data == nil {
+			return kit.Empty("该月没有用量数据。"), nil
+		}
+		return kit.Object(MonthlyUsageView(data)), nil
+	},
+}
+
+// MonthlyUsageView 把按日索引的 map 转为按日排序的列表。
+func MonthlyUsageView(d *domain.SimCardMonthlyData) map[string]any {
+	days := make([]uint8, 0, len(d.DailyUsage))
+	for k := range d.DailyUsage {
+		days = append(days, k)
+	}
+	sort.Slice(days, func(i, j int) bool { return days[i] < days[j] })
+	list := make([]map[string]any, 0, len(days))
+	for _, day := range days {
+		list = append(list, map[string]any{"day": day, "usage": d.DailyUsage[day]})
+	}
+	return map[string]any{
+		"iccid":        d.SimId,
+		"month":        d.Month,
+		"allDataUsage": d.AllDataUsage,
+		"updateTime":   d.UpdateTime,
+		"days":         list,
+	}
+}

+ 237 - 0
service/agentmcp/tools/site.go

@@ -0,0 +1,237 @@
+package tools
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/repositories"
+	"fwh-toolbox-server/services"
+)
+
+// ---- site_search ----
+
+type siteSearchArgs struct {
+	Keyword string `json:"keyword,omitempty" jsonschema:"搜索关键字,匹配 SN、站点名称等;不填则按默认排序列出"`
+	Page    int    `json:"page,omitempty" jsonschema:"页码,从 1 开始,默认 1"`
+	Size    int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
+}
+
+var SiteSearch = kit.Def[siteSearchArgs]{
+	Name:  "site_search",
+	Title: "搜索站点",
+	Description: "在 ToolBox 站点信息表中搜索站点(按 SN 片段、站点名称等关键字),分页返回。" +
+		"返回字段:sn(aGate SN)、name、国家/州省/城市、激活状态与时间、在线状态、系统与固件版本、aPower 数量等(以实际返回为准,个人信息已脱敏)。" +
+		"返回 total 为符合条件的总数,用 page 翻页。查看单个站点完整概况请用 site_get。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a siteSearchArgs) (*mcp.CallToolResult, error) {
+		page := kit.Page(a.Page)
+		size := kit.Limit(a.Size, 20, 100)
+		data, err := services.QuerySiteInfoDataList(dto.SiteInfoRequest{
+			Search:   strings.TrimSpace(a.Keyword),
+			PageNum:  uint(page),
+			PageSize: uint(size),
+		})
+		if err != nil {
+			return kit.Upstream("站点信息表", err), nil
+		}
+		if data == nil {
+			return kit.Empty(""), nil
+		}
+		return kit.List(data.Data, int(data.Total), size, fmt.Sprintf("共 %d 条,当前第 %d 页,可用 page 翻页", data.Total, page)), nil
+	},
+}
+
+// ---- site_get ----
+
+type siteGetArgs struct {
+	SN string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+}
+
+var SiteGet = kit.Def[siteGetArgs]{
+	Name:  "site_get",
+	Title: "查询站点概况",
+	Description: "查询单个站点的概况,合并三部分:info(站点信息表中的基本信息、版本、网络、激活状态等)、" +
+		"mode(当前工作模式与相关设置)、currentAlarms(当前未结束的告警,最多 20 条)。" +
+		"任一部分查询失败时在 errors 中说明,不影响其他部分。排查站点问题时通常先调用本工具。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a siteGetArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		out := map[string]any{"sn": sn}
+		var errs []string
+
+		if info, err := services.GetSiteInfoData(sn); err != nil {
+			errs = append(errs, "info: "+err.Error())
+		} else {
+			out["info"] = info
+		}
+		if mode, err := repositories.QuerySiteModeInfo(sn); err != nil {
+			errs = append(errs, "mode: "+err.Error())
+		} else if mode != nil {
+			out["mode"] = mode.Result
+		}
+		if alarms, err := repositories.QuerySiteAlarmList(sn); err != nil {
+			errs = append(errs, "currentAlarms: "+err.Error())
+		} else if alarms != nil && alarms.Result != nil {
+			list := *alarms.Result
+			out["currentAlarmTotal"] = len(list)
+			if len(list) > 20 {
+				list = list[:20]
+			}
+			out["currentAlarms"] = list
+		}
+		if len(errs) > 0 {
+			out["errors"] = errs
+		}
+		return kit.Object(out), nil
+	},
+}
+
+// ---- site_power_flow ----
+
+type sitePowerFlowArgs struct {
+	SN   string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	Date string `json:"date" jsonschema:"日期,yyyy-MM-dd,站点当地日期"`
+}
+
+// powerFlowCols 功率流表格的列(功率单位 kW,SOC 单位 %,电量单位 kWh)
+var powerFlowCols = []string{"time", "pv_kw", "grid_kw", "apower_kw", "load_kw", "gen_kw", "soc_pct", "run_status", "kwh_total"}
+
+var SitePowerFlow = kit.Def[sitePowerFlowArgs]{
+	Name:  "site_power_flow",
+	Title: "查询站点单日功率流",
+	Description: "查询单个站点某一天的功率流(约 5 分钟一个点),以 TSV 表格返回。" +
+		"列:time(设备当地时间)、pv_kw(光伏)、grid_kw(电网,正为取电、负为馈网,以平台定义为准)、apower_kw(aPower,正为放电、负为充电,以平台定义为准)、" +
+		"load_kw(家庭负载)、gen_kw(发电机)、soc_pct(电池 SOC,%)、run_status(0 待机 1 充电 2 放电 3 故障)、kwh_total(可用电量 kWh)。" +
+		"需要多天数据时用 site_power_flow_export。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a sitePowerFlowArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		day, err := kit.ParseDate("date", a.Date, false)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		rows, err := powerFlowRows(sn, *day)
+		if err != nil {
+			return kit.Upstream("FWH 平台功率流接口", err), nil
+		}
+		return kit.Table(powerFlowCols, rows, 0, "数据过多已截断,请用 site_power_flow_export 导出完整数据"), nil
+	},
+}
+
+// ---- site_power_flow_export ----
+
+type sitePowerFlowExportArgs struct {
+	SN   string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	From string `json:"from" jsonschema:"开始日期,yyyy-MM-dd(含)"`
+	To   string `json:"to" jsonschema:"结束日期,yyyy-MM-dd(含),与开始日期相差不超过 31 天"`
+}
+
+var SitePowerFlowExport = kit.Def[sitePowerFlowExportArgs]{
+	Name:  "site_power_flow_export",
+	Title: "导出站点多日功率流",
+	Description: "导出单个站点一段日期(最多 31 天)的功率流明细为 CSV 文件,列与 site_power_flow 相同,另加 date 列。" +
+		"文件保存到会话工作区,返回文件信息与前 5 行预览;请用 Python(pandas)读取分析。某天查询失败时跳过并在摘要中说明。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a sitePowerFlowExportArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		from, err := kit.ParseDate("from", a.From, false)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		to, err := kit.ParseDate("to", a.To, false)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		if to.Before(*from) {
+			return kit.Failf("结束日期不能早于开始日期"), nil
+		}
+		if to.Sub(*from) > 30*24*time.Hour {
+			return kit.Failf("日期范围不能超过 31 天,请分段导出"), nil
+		}
+
+		cols := append([]string{"date"}, powerFlowCols...)
+		var rows [][]string
+		var failed []string
+		for d := *from; !d.After(*to); d = d.AddDate(0, 0, 1) {
+			if ctx.Err() != nil {
+				return kit.Failf("导出超时,已完成到 %s,请缩短日期范围", d.Format(kit.DateLayout)), nil
+			}
+			dayRows, err := powerFlowRows(sn, d)
+			if err != nil {
+				failed = append(failed, d.Format(kit.DateLayout))
+				continue
+			}
+			for _, r := range dayRows {
+				rows = append(rows, append([]string{d.Format(kit.DateLayout)}, r...))
+			}
+		}
+		summary := fmt.Sprintf("站点 %s,%s ~ %s。", sn, from.Format(kit.DateLayout), to.Format(kit.DateLayout))
+		if len(failed) > 0 {
+			summary += fmt.Sprintf("以下日期查询失败已跳过:%s。", strings.Join(failed, ", "))
+		}
+		uri := fmt.Sprintf("fwh://export/site_power_flow/%s_%s_%s.csv", sn, from.Format("20060102"), to.Format("20060102"))
+		return kit.Export(uri, cols, rows, summary), nil
+	},
+}
+
+// powerFlowRows 查询单日功率流并转为表格行(列顺序见 powerFlowCols)。
+func powerFlowRows(sn string, day time.Time) ([][]string, error) {
+	res, err := services.QuerySitePowerFlow(sn, day)
+	if err != nil {
+		return nil, err
+	}
+	if res == nil || res.Result == nil {
+		return nil, nil
+	}
+	return PowerFlowToRows(res.Result), nil
+}
+
+// PowerFlowToRows 把平台的列式功率流数组转为行;数组长度不一致时缺失值留空。
+func PowerFlowToRows(p *dto.SitePowerFlow) [][]string {
+	n := len(p.DeviceTimeArray)
+	rows := make([][]string, 0, n)
+	for i := 0; i < n; i++ {
+		rows = append(rows, []string{
+			p.DeviceTimeArray[i],
+			f32At(p.PowerSunArray, i),
+			f32At(p.PowerUtiArray, i),
+			f32At(p.PowerFhpArray, i),
+			f32At(p.PowerLoadArray, i),
+			f32At(p.PowerGenArray, i),
+			f32At(p.SocArray, i),
+			uintAt(p.RunStatusArray, i),
+			f32At(p.KwhTotalArray, i),
+		})
+	}
+	return rows
+}
+
+func f32At(a []float32, i int) string {
+	if i >= len(a) {
+		return ""
+	}
+	return strconv.FormatFloat(float64(a[i]), 'f', -1, 32)
+}
+
+func uintAt(a []uint, i int) string {
+	if i >= len(a) {
+		return ""
+	}
+	return strconv.FormatUint(uint64(a[i]), 10)
+}

+ 146 - 0
service/agentmcp/tools/tools_test.go

@@ -0,0 +1,146 @@
+package tools
+
+import (
+	"testing"
+
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+)
+
+func TestRecognizeMode(t *testing.T) {
+	cases := []struct {
+		solar, load string
+		params      map[string]any
+		want        string
+		issues      bool
+	}{
+		{"1,2,0", "1,3,2", nil, "A", false},
+		{"2,5,0", "2,9,5", nil, "A", false}, // 归一化后匹配
+		{"1,2,0", "1,2,0", nil, "B", false},
+		{"1,3,2", "1,2,0", nil, "C", false},
+		{"1,3,2", "1,3,2", nil, "D", false},
+		{"1,2,3", "1,2,0", nil, "E/G", false},
+		{"1,2,0", "1,2,3", nil, "F/H", false},
+		{"9,9,9", "9,9,9", map[string]any{"gridDischargeMax": 0.1, "gridFeedMax": 0.1}, "Zero Import", false},
+		{"9,9,9", "9,9,9", map[string]any{"gridFeedMax": 5.0}, "Zero Import", true},
+		{"5,5,5", "5,5,5", nil, "自定义", false}, // 归一化为 1,1,1,不是 Zero Import
+		{"", "", nil, "未设置", false},
+		{"", "", map[string]any{"chargePower": 8.0}, "强制充电", false},
+		{"", "", map[string]any{"dischargePower": 10.0}, "强制放电", false},
+		{"", "", map[string]any{"chargePower": 8.0, "dischargePower": 10.0}, "自定义", true},
+		{"1,2,0", "", nil, "自定义", true},
+	}
+	for _, c := range cases {
+		got, issues := RecognizeMode(c.solar, c.load, c.params)
+		if got != c.want || (len(issues) > 0) != c.issues {
+			t.Errorf("RecognizeMode(%q,%q)=%s issues=%v,期望 %s issues=%v", c.solar, c.load, got, issues, c.want, c.issues)
+		}
+	}
+}
+
+func TestNormalizePriority(t *testing.T) {
+	for in, want := range map[string]string{"9,9,9": "1,1,1", "2,5,1": "2,3,1", "0,5,9": "0,1,2"} {
+		if got, ok := normalizePriority(in); !ok || got != want {
+			t.Errorf("normalizePriority(%s)=%s,期望 %s", in, got, want)
+		}
+	}
+	if _, ok := normalizePriority("1,2"); ok {
+		t.Error("长度不为 3 应失败")
+	}
+}
+
+func TestOrganizeTouDetail(t *testing.T) {
+	detail := map[string]any{
+		"template": map[string]any{"name": "T", "templateId": 1},
+		"strategyList": []any{map[string]any{
+			"seasonName": "All", "month": "1,2,3", "dayName": "Every day",
+			"eleticRatePeak": 0.3,
+			"detailVoList": []any{
+				map[string]any{"startHourTime": "06:00", "endHourTime": "24:00", "waveType": 2, "solarPriority": "1,3,2", "loadPriority": "1,3,2", "chargeMax": -1},
+				map[string]any{"startHourTime": "00:00", "endHourTime": "06:00", "waveType": 0, "solarPriority": "", "loadPriority": "", "chargePower": 8},
+			},
+		}},
+	}
+	out, ok := OrganizeTouDetail(detail)
+	if !ok {
+		t.Fatal("应能整理")
+	}
+	st := out["strategies"].([]map[string]any)[0]
+	ps := st["periods"].([]TouPeriod)
+	if ps[0].Start != "00:00" || ps[0].Mode != "强制充电" || ps[1].Mode != "D" || ps[1].WaveType != "峰" {
+		t.Errorf("时段整理不正确: %+v", ps)
+	}
+	if _, has := ps[1].Params["chargeMax"]; has {
+		t.Error("-1 应视为未设置")
+	}
+	if _, has := st["issues"]; has {
+		t.Errorf("时段覆盖全天,不应有问题: %v", st["issues"])
+	}
+	if _, ok := OrganizeTouDetail(map[string]any{"foo": 1}); ok {
+		t.Error("没有 strategyList 时应返回 false")
+	}
+}
+
+func TestCheckCoverageGap(t *testing.T) {
+	issues := checkCoverage([]TouPeriod{{Start: "00:00", End: "06:00"}, {Start: "07:00", End: "24:00"}})
+	if len(issues) != 1 {
+		t.Errorf("应发现 1 处不衔接: %v", issues)
+	}
+}
+
+func TestPowerFlowToRows(t *testing.T) {
+	p := &dto.SitePowerFlow{
+		DeviceTimeArray: []string{"2026-10-01 00:00:00", "2026-10-01 00:05:00"},
+		PowerSunArray:   []float32{1.5, 2},
+		SocArray:        []float32{80},
+		RunStatusArray:  []uint{1, 2},
+	}
+	rows := PowerFlowToRows(p)
+	if len(rows) != 2 || len(rows[0]) != len(powerFlowCols) {
+		t.Fatalf("行列数不正确: %v", rows)
+	}
+	if rows[0][1] != "1.5" || rows[0][6] != "80" || rows[1][6] != "" || rows[1][7] != "2" {
+		t.Errorf("映射不正确: %v", rows)
+	}
+}
+
+func TestMapEventRows(t *testing.T) {
+	end := "2026-10-01 10:00:00"
+	rows := MapEventRows([]domain.EventAlarmItem{
+		{LogType: "0", AlarmCode: "0", LogTime: "2026-10-01 08:00:00", LogName: "启动"},
+		{LogType: "1", AlarmCode: "E101", LogTime: "2026-10-01 09:00:00", EndTime: &end},
+	})
+	if rows[0].Kind != "告警" || rows[0].EndTime != end || rows[1].Code != "" {
+		t.Errorf("映射或排序不正确: %+v", rows)
+	}
+}
+
+func TestParseSN(t *testing.T) {
+	r, err := ParseSN("10060006A03X25310091")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if r["productName"] != "aGate (网关)" || r["year"] != "2025" || r["week"] != "31" || r["serialNumber"] != "0091" {
+		t.Errorf("解析不正确: %v", r)
+	}
+	if _, err := ParseSN("1006"); err == nil {
+		t.Error("长度不足应报错")
+	}
+}
+
+func TestParseIdentifier(t *testing.T) {
+	p, n, err := parseIdentifier("fwh-42")
+	if err != nil || p != "FWH" || n != 42 {
+		t.Errorf("parseIdentifier 错误: %s %d %v", p, n, err)
+	}
+	if _, _, err := parseIdentifier("FWH"); err == nil {
+		t.Error("缺少序号应报错")
+	}
+}
+
+func TestSortCodeCounts(t *testing.T) {
+	got := SortCodeCounts(map[string]uint{"b": 2, "a": 2, "c": 5})
+	if got[0].Code != "c" || got[1].Code != "a" || got[2].Code != "b" {
+		t.Errorf("排序不正确: %v", got)
+	}
+}

+ 376 - 0
service/agentmcp/tools/tou.go

@@ -0,0 +1,376 @@
+package tools
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/services"
+)
+
+func parseEnv(v string) (string, error) {
+	return kit.Enum("env", v, "prod", "prod", "test")
+}
+
+// ---- tou_template_list ----
+
+type touTemplateListArgs struct {
+	Env    string `json:"env,omitempty" jsonschema:"服务器环境:prod(生产,默认)/ test(测试)"`
+	Search string `json:"search,omitempty" jsonschema:"搜索关键字(模版名称、电力公司等)"`
+	Type   *int   `json:"type,omitempty" jsonschema:"模版类型:0 系统模版、1 API 模版、2 用户推荐;不填为全部"`
+	Status *int   `json:"status,omitempty" jsonschema:"发布状态:0 未发布、2 已发布;不填为全部"`
+	Page   int    `json:"page,omitempty" jsonschema:"页码,默认 1"`
+	Size   int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
+}
+
+var TouTemplateList = kit.Def[touTemplateListArgs]{
+	Name:  "tou_template_list",
+	Title: "查询 TOU 调度模版列表",
+	Description: "分页查询 FWH 云平台的 TOU 调度模版(生产服或测试服)。返回 total 与 items(模版 ID、名称、类型、国家/地区、电力公司、电价类型、发布状态、更新时间等,以实际返回为准)。" +
+		"查看某个模版的时段与调度模式用 tou_template_get。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a touTemplateListArgs) (*mcp.CallToolResult, error) {
+		env, err := parseEnv(a.Env)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		page := kit.Page(a.Page)
+		size := kit.Limit(a.Size, 20, 100)
+		var data *dto.ResPage[interface{}]
+		if env == "test" {
+			data, err = services.FwhTestGetTouTemplateProList(uint(page), uint(size), a.Type, a.Status, strings.TrimSpace(a.Search))
+		} else {
+			data, err = services.GetTouTemplateProList(uint(page), uint(size), a.Type, a.Status, strings.TrimSpace(a.Search))
+		}
+		if err != nil {
+			return kit.Upstream("FWH 平台 TOU 模版接口("+env+")", err), nil
+		}
+		if data == nil {
+			return kit.Empty(""), nil
+		}
+		return kit.List(data.Data, int(data.Total), size, fmt.Sprintf("共 %d 条,当前第 %d 页,可用 page 翻页", data.Total, page)), nil
+	},
+}
+
+// ---- tou_template_get ----
+
+type touTemplateGetArgs struct {
+	Env string `json:"env,omitempty" jsonschema:"服务器环境:prod(生产,默认)/ test(测试)"`
+	ID  int    `json:"id" jsonschema:"模版 ID"`
+	Raw bool   `json:"raw,omitempty" jsonschema:"为 true 时返回云端原始详情(字段完整但较长),默认 false 返回整理后的结构"`
+}
+
+var TouTemplateGet = kit.Def[touTemplateGetArgs]{
+	Name:  "tou_template_get",
+	Title: "查询 TOU 调度模版详情",
+	Description: "查询单个 TOU 调度模版的详情,默认整理为:template(模版主体字段)与 strategies(按季节 × 日类型的策略)。" +
+		"每个策略含 seasonName、month、dayName、ccociateSn、电价,以及 periods(时段):start、end、waveType(谷/平/峰/尖峰/低谷)、" +
+		"solarPriority / loadPriority(原始值)、mode(识别出的调度模式:A~H、E/G、F/H、强制充电、强制放电、Zero Import、未设置、自定义)、params(设置了的扩展参数,-1 视为未设置)、issues(异常说明)。" +
+		"优先级顺序:光伏 [市电, 负载, aPower],负载 [市电, 光伏, aPower]。raw=true 返回原始详情。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a touTemplateGetArgs) (*mcp.CallToolResult, error) {
+		env, err := parseEnv(a.Env)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		if a.ID <= 0 {
+			return kit.Failf("参数 id 必填且为正整数"), nil
+		}
+		var data *interface{}
+		if env == "test" {
+			data, err = services.FwhTestGetTouTemplateProDetailInfo(uint(a.ID))
+		} else {
+			data, err = services.GetTouTemplateProDetailInfo(uint(a.ID))
+		}
+		if err != nil {
+			return kit.Upstream("FWH 平台 TOU 模版接口("+env+")", err), nil
+		}
+		if data == nil || *data == nil {
+			return kit.Empty("模版不存在。"), nil
+		}
+		if a.Raw {
+			return kit.Object(*data), nil
+		}
+		organized, ok := OrganizeTouDetail(*data)
+		if !ok {
+			// 结构与预期不符时退回原始详情,避免整理失败导致无结果
+			return kit.Object(*data), nil
+		}
+		return kit.Object(organized), nil
+	},
+}
+
+// ---- 详情整理与调度模式识别(规则见 docs/despatch-config/requirements.md 第 3 节) ----
+
+var waveTypeNames = map[int]string{0: "谷", 1: "平", 2: "峰", 3: "尖峰", 4: "低谷"}
+
+// touParamKeys 时段中的扩展参数(-1 为未设置的哨兵值)
+var touParamKeys = []string{
+	"gridDischargeMax", "gridChargeMax", "chargePower", "dischargePower", "chargeMax", "dischargeMax",
+	"gcaoMax", "gridFeedMax", "gridMax", "minDischargeSoc", "maxChargeSoc", "heatEnable", "powerOffApower",
+	"rampTime", "offGrid", "solarCutoff",
+}
+
+// baseModes 基础模式:归一化后的光伏/负载优先级 → 模式
+var baseModes = []struct {
+	Name, Solar, Load string
+}{
+	{"A", "1,2,0", "1,3,2"},
+	{"B", "1,2,0", "1,2,0"},
+	{"C", "1,3,2", "1,2,0"},
+	{"D", "1,3,2", "1,3,2"},
+	{"E/G", "1,2,3", "1,2,0"},
+	{"F/H", "1,2,0", "1,2,3"},
+}
+
+// TouPeriod 整理后的时段
+type TouPeriod struct {
+	Start         string         `json:"start"`
+	End           string         `json:"end"`
+	WaveType      string         `json:"waveType,omitempty"`
+	Name          string         `json:"name,omitempty"`
+	SolarPriority string         `json:"solarPriority"`
+	LoadPriority  string         `json:"loadPriority"`
+	Mode          string         `json:"mode"`
+	Params        map[string]any `json:"params,omitempty"`
+	Issues        []string       `json:"issues,omitempty"`
+}
+
+// OrganizeTouDetail 把云端详情整理为 template + strategies;找不到 strategyList 时返回 false。
+func OrganizeTouDetail(detail any) (map[string]any, bool) {
+	b, err := json.Marshal(detail)
+	if err != nil {
+		return nil, false
+	}
+	var root map[string]any
+	if err := json.Unmarshal(b, &root); err != nil {
+		return nil, false
+	}
+	// 兼容 { template, strategyList } 与 { ...模版字段, strategyList } 两种形态
+	strategies, _ := root["strategyList"].([]any)
+	template := root
+	if t, ok := root["template"].(map[string]any); ok {
+		template = t
+		if strategies == nil {
+			strategies, _ = t["strategyList"].([]any)
+		}
+	}
+	if strategies == nil {
+		return nil, false
+	}
+
+	tpl := map[string]any{}
+	for k, v := range template {
+		if k != "strategyList" {
+			tpl[k] = v
+		}
+	}
+	out := make([]map[string]any, 0, len(strategies))
+	for _, s := range strategies {
+		sm, ok := s.(map[string]any)
+		if !ok {
+			continue
+		}
+		st := map[string]any{}
+		for _, k := range []string{"seasonName", "month", "dayName", "dayType", "ccociateType", "ccociateSn"} {
+			if v, ok := sm[k]; ok {
+				st[k] = v
+			}
+		}
+		rates := map[string]any{}
+		for k, v := range sm {
+			if strings.HasPrefix(k, "eleticRate") || strings.HasPrefix(k, "eleticSell") {
+				rates[k] = v
+			}
+		}
+		if len(rates) > 0 {
+			st["rates"] = rates
+		}
+		details, _ := sm["detailVoList"].([]any)
+		periods := make([]TouPeriod, 0, len(details))
+		for _, d := range details {
+			if dm, ok := d.(map[string]any); ok {
+				periods = append(periods, organizePeriod(dm))
+			}
+		}
+		sort.SliceStable(periods, func(i, j int) bool { return periods[i].Start < periods[j].Start })
+		st["periods"] = periods
+		if issues := checkCoverage(periods); len(issues) > 0 {
+			st["issues"] = issues
+		}
+		out = append(out, st)
+	}
+	return map[string]any{"template": tpl, "strategies": out}, true
+}
+
+func organizePeriod(dm map[string]any) TouPeriod {
+	p := TouPeriod{
+		Start:         str(dm["startHourTime"]),
+		End:           str(dm["endHourTime"]),
+		Name:          str(dm["name"]),
+		SolarPriority: str(dm["solarPriority"]),
+		LoadPriority:  str(dm["loadPriority"]),
+	}
+	if w, ok := num(dm["waveType"]); ok {
+		if n, ok := waveTypeNames[int(w)]; ok {
+			p.WaveType = n
+		} else {
+			p.WaveType = fmt.Sprintf("未知(%v)", w)
+		}
+	}
+	params := map[string]any{}
+	for _, k := range touParamKeys {
+		v, ok := dm[k]
+		if !ok || v == nil || str(v) == "" {
+			continue
+		}
+		if n, isNum := num(v); isNum && n == -1 {
+			continue
+		}
+		params[k] = v
+	}
+	if len(params) > 0 {
+		p.Params = params
+	}
+	p.Mode, p.Issues = RecognizeMode(p.SolarPriority, p.LoadPriority, params)
+	return p
+}
+
+// RecognizeMode 识别调度模式。params 只包含"设置了"的参数(有值且不为 -1)。
+func RecognizeMode(solar, load string, params map[string]any) (string, []string) {
+	solar, load = strings.TrimSpace(solar), strings.TrimSpace(load)
+	var issues []string
+
+	// 1. 特殊模式按原始值精确匹配
+	if solar == "9,9,9" && load == "9,9,9" {
+		for k, want := range map[string]float64{"gridDischargeMax": 0.1, "gridFeedMax": 0.1} {
+			if got, ok := num(params[k]); !ok || got != want {
+				issues = append(issues, fmt.Sprintf("Zero Import 的 %s 应为 %v,实际为 %v", k, want, params[k]))
+			}
+		}
+		sort.Strings(issues)
+		return "Zero Import", issues
+	}
+
+	// 2. 两个优先级都为空
+	if solar == "" && load == "" {
+		_, hasCharge := params["chargePower"]
+		_, hasDischarge := params["dischargePower"]
+		switch {
+		case hasCharge && !hasDischarge:
+			return "强制充电", nil
+		case hasDischarge && !hasCharge:
+			return "强制放电", nil
+		case !hasCharge && !hasDischarge:
+			return "未设置", nil
+		default:
+			return "自定义", []string{"优先级为空但同时设置了 chargePower 和 dischargePower"}
+		}
+	}
+	if solar == "" || load == "" {
+		return "自定义", []string{"光伏与负载优先级只有一个为空"}
+	}
+
+	// 3. 归一化后匹配 A~H
+	ns, ok1 := normalizePriority(solar)
+	nl, ok2 := normalizePriority(load)
+	if ok1 && ok2 {
+		for _, m := range baseModes {
+			if ns == m.Solar && nl == m.Load {
+				return m.Name, nil
+			}
+		}
+	}
+	return "自定义", nil
+}
+
+// normalizePriority 稠密排名归一化:0 保持 0,非 0 值按从小到大映射为 1、2、3,相同值映射为同一个数。
+func normalizePriority(s string) (string, bool) {
+	parts := strings.Split(s, ",")
+	if len(parts) != 3 {
+		return "", false
+	}
+	vals := make([]int, 3)
+	uniq := map[int]bool{}
+	for i, p := range parts {
+		v, err := strconv.Atoi(strings.TrimSpace(p))
+		if err != nil || v < 0 {
+			return "", false
+		}
+		vals[i] = v
+		if v != 0 {
+			uniq[v] = true
+		}
+	}
+	sorted := make([]int, 0, len(uniq))
+	for v := range uniq {
+		sorted = append(sorted, v)
+	}
+	sort.Ints(sorted)
+	rank := map[int]int{}
+	for i, v := range sorted {
+		rank[v] = i + 1
+	}
+	out := make([]string, 3)
+	for i, v := range vals {
+		if v == 0 {
+			out[i] = "0"
+		} else {
+			out[i] = strconv.Itoa(rank[v])
+		}
+	}
+	return strings.Join(out, ","), true
+}
+
+// checkCoverage 检查时段是否首尾衔接覆盖 00:00~24:00。
+func checkCoverage(ps []TouPeriod) []string {
+	if len(ps) == 0 {
+		return []string{"没有时段"}
+	}
+	var issues []string
+	if ps[0].Start != "00:00" {
+		issues = append(issues, "第一个时段不是从 00:00 开始")
+	}
+	for i := 1; i < len(ps); i++ {
+		if ps[i].Start != ps[i-1].End {
+			issues = append(issues, fmt.Sprintf("%s 与 %s 之间不衔接(%s → %s)", ps[i-1].End, ps[i].Start, ps[i-1].End, ps[i].Start))
+		}
+	}
+	if last := ps[len(ps)-1].End; last != "24:00" && last != "00:00" && last != "23:59" {
+		issues = append(issues, "最后一个时段没有到 24:00")
+	}
+	return issues
+}
+
+func str(v any) string {
+	switch t := v.(type) {
+	case nil:
+		return ""
+	case string:
+		return t
+	case float64:
+		return strconv.FormatFloat(t, 'f', -1, 64)
+	default:
+		return fmt.Sprint(t)
+	}
+}
+
+func num(v any) (float64, bool) {
+	switch t := v.(type) {
+	case float64:
+		return t, true
+	case int:
+		return float64(t), true
+	case string:
+		f, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
+		return f, err == nil
+	}
+	return 0, false
+}

+ 57 - 0
service/cmd/agent-mcp/main.go

@@ -0,0 +1,57 @@
+// fwh-agent-mcp:新 AI 助手的业务数据 MCP 服务(Streamable HTTP)。
+//
+//	fwh-agent-mcp -set=readonly -listen=127.0.0.1:8091   # 只读工具集
+//	fwh-agent-mcp -set=ops      -listen=127.0.0.1:8092   # 写工具集
+//
+// 配置读取工作目录下的 .env(与主服务相同),AGENT_MCP_TOKEN 必填。
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"go.uber.org/zap"
+
+	"fwh-toolbox-server/agentmcp"
+)
+
+func main() {
+	set := flag.String("set", agentmcp.SetReadOnly, "工具集:readonly | ops")
+	listen := flag.String("listen", "", "监听地址,默认取 AGENT_MCP_LISTEN(127.0.0.1:8091)")
+	flag.Parse()
+
+	settings := agentmcp.LoadSettings()
+	if settings.Token == "" {
+		fmt.Fprintln(os.Stderr, "[fwh-agent-mcp] AGENT_MCP_TOKEN 未配置,拒绝启动")
+		os.Exit(1)
+	}
+	addr := settings.Listen
+	if *listen != "" {
+		addr = *listen
+	}
+
+	log := agentmcp.InitLogger(settings.LogPath)
+	defer func() { _ = log.Sync() }()
+
+	if err := agentmcp.InitInfra(); err != nil {
+		log.Fatal("[agent-mcp] 初始化失败", zap.Error(err))
+	}
+
+	s, err := agentmcp.NewServer(*set, log, settings.MaxConcurrency)
+	if err != nil {
+		log.Fatal("[agent-mcp] 创建 MCP Server 失败", zap.Error(err))
+	}
+
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+
+	log.Info("[agent-mcp] 启动", zap.String("set", *set), zap.String("version", agentmcp.ServerVersion), zap.Int("maxConcurrency", settings.MaxConcurrency))
+	if err := agentmcp.Serve(ctx, addr, agentmcp.Handler(s, settings.Token), log); err != nil {
+		log.Fatal("[agent-mcp] HTTP 服务异常退出", zap.Error(err))
+	}
+	log.Info("[agent-mcp] 已退出")
+}

+ 44 - 0
service/cmd/deploy_agent_mcp.sh

@@ -0,0 +1,44 @@
+#!/bin/bash
+set -e
+
+# 部署新 AI 助手的业务数据 MCP 服务 fwh-agent-mcp(与旧 MCP 的 deploy_mcp.sh 相互独立)
+# 用法:在 service/ 目录下执行 ./cmd/deploy_agent_mcp.sh
+
+# ===== 配置部分 =====
+MCP_NAME="fwh-agent-mcp"
+APP_DIR="/var/www/fwh-toolbox/server"
+MCP_PATH="$APP_DIR/$MCP_NAME"
+UNIT_SRC="../deploy/systemd/fwh-agent-mcp.service"
+UNIT_DST="/etc/systemd/system/fwh-agent-mcp.service"
+
+# ===== 编译程序 =====
+echo "🚀 Building $MCP_NAME..."
+GOOS=linux GOARCH=amd64 go build -o $MCP_NAME ./cmd/agent-mcp/
+
+# ===== 复制程序到目标路径 =====
+echo "📦 Copying binary to $MCP_PATH..."
+sudo mv $MCP_NAME $MCP_PATH
+sudo chmod +x $MCP_PATH
+
+# ===== 安装 / 更新 systemd 单元 =====
+if [ -f "$UNIT_SRC" ]; then
+  if ! sudo cmp -s "$UNIT_SRC" "$UNIT_DST"; then
+    echo "📝 Installing systemd unit $UNIT_DST..."
+    sudo cp "$UNIT_SRC" "$UNIT_DST"
+    sudo systemctl daemon-reload
+    sudo systemctl enable fwh-agent-mcp
+  fi
+fi
+
+# ===== 检查令牌 =====
+if ! grep -q '^AGENT_MCP_TOKEN=.\+' "$APP_DIR/.env" 2>/dev/null; then
+  echo "⚠️  $APP_DIR/.env 中没有配置 AGENT_MCP_TOKEN,服务将拒绝启动"
+fi
+
+# ===== 重启 =====
+echo "🔁 Restarting fwh-agent-mcp..."
+sudo systemctl restart fwh-agent-mcp
+sleep 2
+sudo systemctl --no-pager --lines=5 status fwh-agent-mcp || true
+
+echo "✅ $MCP_NAME deployed to $MCP_PATH"

+ 28 - 0
service/config/config.go

@@ -92,6 +92,22 @@ type KiroConfig struct {
 	DebugLog bool
 }
 
+// AgentConfig 新 AI 助手(AgentServer)配置,见 docs/ai-assistant-agentserver/technical-design.md §3.2
+type AgentConfig struct {
+	// Enabled 新 AI 助手总开关(AGENT_ENABLED)
+	Enabled bool
+	// ServerURL AgentServer 地址,如 http://127.0.0.1:8787(AGENT_SERVER_URL)
+	ServerURL string
+	// ApiKey AgentServer 签发的平台 API Key,只在后端使用(AGENT_SERVER_API_KEY)
+	ApiKey string
+	// ProfileDefault 默认 Profile ID(AGENT_PROFILE_DEFAULT),为空时使用 API Key 的默认 Profile
+	ProfileDefault string
+	// ProfileByRole 角色到 Profile 的映射,格式 "role:prf_x,role2:prf_y",按顺序取第一个命中的(AGENT_PROFILE_BY_ROLE)
+	ProfileByRole string
+	// AllowedRoles 允许使用新助手的角色,逗号分隔;为空表示所有登录用户(AGENT_ALLOWED_ROLES)
+	AllowedRoles string
+}
+
 type Config struct {
 	App         AppConfig
 	FwhApi      FwhApiConfig
@@ -102,6 +118,7 @@ type Config struct {
 	PlaneApi    PlaneApiConfig
 	PlaneWebApi PlaneWebApiConfig
 	Kiro        KiroConfig
+	Agent       AgentConfig
 }
 
 // 需先执行LoadConfig后才能读取ENV
@@ -141,6 +158,9 @@ func LoadConfig(configPaths ...string) (*Config, error) {
 	// FWH ES(iotcore-stat)
 	v.SetDefault("FWH_ES_INDEX_PREFIX", "iotcore-stat")
 	v.SetDefault("FWH_ES_INSECURE_SKIP_VERIFY", true)
+	// 新 AI 助手(AgentServer)
+	v.SetDefault("AGENT_ENABLED", false)
+	v.SetDefault("AGENT_SERVER_URL", "http://127.0.0.1:8787")
 	// 多文件路径支持:优先使用调用方传入的路径,否则回退默认
 	paths := configPaths
 	if len(paths) == 0 {
@@ -255,5 +275,13 @@ func LoadConfig(configPaths ...string) (*Config, error) {
 			Home:      v.GetString("KIRO_HOME"),
 			DebugLog:  v.GetBool("KIRO_DEBUG_LOG"),
 		},
+		Agent: AgentConfig{
+			Enabled:        v.GetBool("AGENT_ENABLED"),
+			ServerURL:      v.GetString("AGENT_SERVER_URL"),
+			ApiKey:         v.GetString("AGENT_SERVER_API_KEY"),
+			ProfileDefault: v.GetString("AGENT_PROFILE_DEFAULT"),
+			ProfileByRole:  v.GetString("AGENT_PROFILE_BY_ROLE"),
+			AllowedRoles:   v.GetString("AGENT_ALLOWED_ROLES"),
+		},
 	}, nil
 }

+ 107 - 0
service/controllers/agentControllers.go

@@ -0,0 +1,107 @@
+package controllers
+
+import (
+	"errors"
+
+	"github.com/gofiber/fiber/v2"
+	"github.com/gofiber/fiber/v2/utils"
+	"go.uber.org/zap"
+
+	"fwh-toolbox-server/config"
+	"fwh-toolbox-server/middleware"
+	"fwh-toolbox-server/services"
+	"fwh-toolbox-server/utils/agentserver"
+)
+
+// 新 AI 助手(AgentServer)接口,见 docs/ai-assistant-agentserver/technical-design.md §3.4、§3.5
+
+var agentService *services.AgentService
+
+// InitAgent 初始化新 AI 助手
+func InitAgent(cfg config.AgentConfig) {
+	agentService = services.NewAgentService(cfg)
+	zap.L().Info("[Agent] 初始化", zap.Bool("enabled", cfg.Enabled), zap.String("server", cfg.ServerURL))
+}
+
+func agentEnabled(c *fiber.Ctx) bool {
+	return agentService != nil && agentService.Enabled(middleware.AuthGetRoleNameList(c))
+}
+
+// AgentGuard 未开启或角色不允许时,/agent/* 一律返回 403(/agent/config 除外)
+func AgentGuard(c *fiber.Ctx) error {
+	if !agentEnabled(c) {
+		return Forbidden(c, "AI 助手未开启或无权使用")
+	}
+	return c.Next()
+}
+
+// GetAgentConfig 新助手对当前用户是否可用
+// GET /agent/config
+func GetAgentConfig(c *fiber.Ctx) error {
+	return Success(c, fiber.Map{"enabled": agentEnabled(c)})
+}
+
+// ListAgentSessions 本人会话列表
+// GET /agent/sessions
+func ListAgentSessions(c *fiber.Ctx) error {
+	list, err := agentService.ListSessions(c.UserContext(), middleware.AuthGetUserName(c))
+	if err != nil {
+		return Error(c, "获取会话列表失败: "+err.Error())
+	}
+	return Success(c, list)
+}
+
+// CreateAgentSession 新建会话
+// POST /agent/sessions
+func CreateAgentSession(c *fiber.Ctx) error {
+	item, err := agentService.CreateSession(c.UserContext(), middleware.AuthGetUserName(c), middleware.AuthGetRoleNameList(c))
+	if err != nil {
+		zap.L().Warn("[Agent] 创建会话失败", zap.Error(err))
+		return Error(c, "创建会话失败: "+err.Error())
+	}
+	return Success(c, fiber.Map{"sessionId": item.SessionId, "title": item.Title, "profileId": item.ProfileId})
+}
+
+// UpdateAgentSession 修改会话标题
+// PATCH /agent/sessions/:id
+func UpdateAgentSession(c *fiber.Ctx) error {
+	sessionId := utils.CopyString(c.Params("id"))
+	var req struct {
+		Title string `json:"title"`
+	}
+	if err := c.BodyParser(&req); err != nil {
+		return Fail(c, "Invalid request body")
+	}
+	err := agentService.RenameSession(c.UserContext(), middleware.AuthGetUserName(c), sessionId, req.Title)
+	if errors.Is(err, services.ErrAgentSessionNotFound) {
+		return NotFound(c, err.Error())
+	}
+	if err != nil {
+		return Error(c, "修改标题失败: "+err.Error())
+	}
+	return Success[any](c, nil, "标题已更新")
+}
+
+// DeleteAgentSession 删除会话
+// DELETE /agent/sessions/:id
+func DeleteAgentSession(c *fiber.Ctx) error {
+	sessionId := utils.CopyString(c.Params("id"))
+	err := agentService.DeleteSession(c.UserContext(), middleware.AuthGetUserName(c), sessionId)
+	if errors.Is(err, services.ErrAgentSessionNotFound) {
+		return NotFound(c, err.Error())
+	}
+	if err != nil {
+		return Error(c, "删除会话失败: "+err.Error())
+	}
+	return Success[any](c, nil, "会话已删除")
+}
+
+// ProxyAgentServer AgentServer 代理,响应原样透传(不包装 Success)
+// ALL /agent/v1/*
+func ProxyAgentServer(c *fiber.Ctx) error {
+	if !agentEnabled(c) {
+		return agentserver.WriteError(c, fiber.StatusForbidden, "forbidden", "AI 助手未开启或无权使用")
+	}
+	rest := utils.CopyString(c.Params("*"))
+	return agentService.Proxy().Handle(c, middleware.AuthGetUserName(c), rest)
+}

+ 3 - 0
service/main.go

@@ -137,6 +137,9 @@ func main() {
 	// 初始化 AI Chat (ACP Client)
 	controllers.InitAIChat(".", cfg.Kiro.RunAsUser, cfg.Kiro.Home, cfg.Kiro.DebugLog)
 
+	// 初始化新 AI 助手(AgentServer)
+	controllers.InitAgent(cfg.Agent)
+
 	// 启动第一个监听
 	go func() {
 		if err := networkEvents.InitEvio(); err != nil {

+ 144 - 0
service/repositories/mongo/agentSessionRepo.go

@@ -0,0 +1,144 @@
+package mongo
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"fwh-toolbox-server/database"
+
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.mongodb.org/mongo-driver/mongo/options"
+)
+
+// 新 AI 助手(AgentServer)的会话归属与子资源索引,见 docs/ai-assistant-agentserver/technical-design.md §3.3。
+// 与旧助手的 AiChatSessions 相互独立。
+const (
+	CollectionName_AgentSessions  = "AgentSessions"
+	CollectionName_AgentResources = "AgentResources"
+)
+
+// AgentResourceTTL 子资源索引保留期(与会话保留期一致)
+const AgentResourceTTL = 180 * 24 * time.Hour
+
+// AgentSession 会话归属记录
+type AgentSession struct {
+	SessionId  string     `bson:"sessionId"`
+	UserId     string     `bson:"userId"`
+	ProfileId  string     `bson:"profileId"`
+	Title      string     `bson:"title"`
+	CreatedAt  time.Time  `bson:"createdAt"`
+	LastActive time.Time  `bson:"lastActive"`
+	DeletedAt  *time.Time `bson:"deletedAt,omitempty"`
+}
+
+// AgentResource 子资源(run / 交互 / 文件)所属会话
+type AgentResource struct {
+	ResourceId string    `bson:"resourceId"`
+	SessionId  string    `bson:"sessionId"`
+	CreatedAt  time.Time `bson:"createdAt"`
+}
+
+// InitAgentIndexes 创建索引:会话 ID 唯一、按用户列表、子资源 ID 唯一、子资源 TTL
+func InitAgentIndexes(ctx context.Context) error {
+	if _, err := database.GetCollection(CollectionName_AgentSessions).Indexes().CreateMany(ctx, []mongo.IndexModel{
+		{Keys: bson.D{{Key: "sessionId", Value: 1}}, Options: options.Index().SetUnique(true)},
+		{Keys: bson.D{{Key: "userId", Value: 1}, {Key: "deletedAt", Value: 1}, {Key: "lastActive", Value: -1}}},
+	}); err != nil {
+		return err
+	}
+	_, err := database.GetCollection(CollectionName_AgentResources).Indexes().CreateMany(ctx, []mongo.IndexModel{
+		{Keys: bson.D{{Key: "resourceId", Value: 1}}, Options: options.Index().SetUnique(true)},
+		{Keys: bson.D{{Key: "createdAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(int32(AgentResourceTTL / time.Second))},
+	})
+	return err
+}
+
+// InsertAgentSession 写入新会话
+func InsertAgentSession(ctx context.Context, s AgentSession) error {
+	_, err := database.GetCollection(CollectionName_AgentSessions).InsertOne(ctx, s)
+	return err
+}
+
+// GetAgentSession 查询会话(含已删除);不存在返回 nil
+func GetAgentSession(ctx context.Context, sessionId string) (*AgentSession, error) {
+	var s AgentSession
+	err := database.GetCollection(CollectionName_AgentSessions).FindOne(ctx, bson.M{"sessionId": sessionId}).Decode(&s)
+	if errors.Is(err, mongo.ErrNoDocuments) {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &s, nil
+}
+
+// ListAgentSessionsByUser 用户未删除的会话,按 lastActive 倒序
+func ListAgentSessionsByUser(ctx context.Context, userId string, limit int64) ([]AgentSession, error) {
+	cur, err := database.GetCollection(CollectionName_AgentSessions).Find(ctx,
+		bson.M{"userId": userId, "deletedAt": bson.M{"$exists": false}},
+		options.Find().SetSort(bson.D{{Key: "lastActive", Value: -1}}).SetLimit(limit))
+	if err != nil {
+		return nil, err
+	}
+	defer cur.Close(ctx)
+	list := make([]AgentSession, 0)
+	if err := cur.All(ctx, &list); err != nil {
+		return nil, err
+	}
+	return list, nil
+}
+
+// UpdateAgentSessionTitle 修改标题(只改本人的未删除会话)
+func UpdateAgentSessionTitle(ctx context.Context, userId, sessionId, title string) error {
+	_, err := database.GetCollection(CollectionName_AgentSessions).UpdateOne(ctx,
+		bson.M{"userId": userId, "sessionId": sessionId, "deletedAt": bson.M{"$exists": false}},
+		bson.M{"$set": bson.M{"title": title}})
+	return err
+}
+
+// TouchAgentSession 刷新 lastActive
+func TouchAgentSession(ctx context.Context, userId, sessionId string) error {
+	_, err := database.GetCollection(CollectionName_AgentSessions).UpdateOne(ctx,
+		bson.M{"userId": userId, "sessionId": sessionId, "deletedAt": bson.M{"$exists": false}},
+		bson.M{"$set": bson.M{"lastActive": time.Now()}})
+	return err
+}
+
+// SoftDeleteAgentSession 软删除
+func SoftDeleteAgentSession(ctx context.Context, userId, sessionId string) error {
+	_, err := database.GetCollection(CollectionName_AgentSessions).UpdateOne(ctx,
+		bson.M{"userId": userId, "sessionId": sessionId, "deletedAt": bson.M{"$exists": false}},
+		bson.M{"$set": bson.M{"deletedAt": time.Now()}})
+	return err
+}
+
+// GetAgentResourceSession 子资源所属会话;不存在返回空串
+func GetAgentResourceSession(ctx context.Context, resourceId string) (string, error) {
+	var r AgentResource
+	err := database.GetCollection(CollectionName_AgentResources).FindOne(ctx, bson.M{"resourceId": resourceId}).Decode(&r)
+	if errors.Is(err, mongo.ErrNoDocuments) {
+		return "", nil
+	}
+	if err != nil {
+		return "", err
+	}
+	return r.SessionId, nil
+}
+
+// UpsertAgentResources 批量写入子资源索引;已存在的不覆盖所属会话
+func UpsertAgentResources(ctx context.Context, rs []AgentResource) error {
+	if len(rs) == 0 {
+		return nil
+	}
+	models := make([]mongo.WriteModel, 0, len(rs))
+	for _, r := range rs {
+		models = append(models, mongo.NewUpdateOneModel().
+			SetFilter(bson.M{"resourceId": r.ResourceId}).
+			SetUpdate(bson.M{"$setOnInsert": r}).
+			SetUpsert(true))
+	}
+	_, err := database.GetCollection(CollectionName_AgentResources).BulkWrite(ctx, models, options.BulkWrite().SetOrdered(false))
+	return err
+}

+ 3 - 0
service/repositories/mongo/init.go

@@ -10,5 +10,8 @@ func Init(ctx context.Context) error {
 	if err := InitFaultRecordIndexes(ctx); err != nil {
 		return err
 	}
+	if err := InitAgentIndexes(ctx); err != nil {
+		return err
+	}
 	return InitSimCardStatisticsIndexes(ctx)
 }

+ 22 - 0
service/routes/agentRoutes.go

@@ -0,0 +1,22 @@
+package routes
+
+import (
+	"fwh-toolbox-server/controllers"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+// AgentSetup 新 AI 助手(AgentServer)路由,与旧助手的 /ai/* 相互独立
+func AgentSetup(api fiber.Router) {
+	// 新助手对当前用户是否可用(不受开关限制,前端据此决定是否显示菜单)
+	api.Get("/agent/config", controllers.GetAgentConfig)
+
+	// AgentServer 代理:响应原样透传,错误也按 AgentServer 格式返回
+	api.All("/agent/v1/*", controllers.ProxyAgentServer)
+
+	// 会话列表、新建、改名、删除(逐个挂开关校验,不用 Group 前缀中间件,避免影响 /agent/config)
+	api.Get("/agent/sessions", controllers.AgentGuard, controllers.ListAgentSessions)
+	api.Post("/agent/sessions", controllers.AgentGuard, controllers.CreateAgentSession)
+	api.Patch("/agent/sessions/:id", controllers.AgentGuard, controllers.UpdateAgentSession)
+	api.Delete("/agent/sessions/:id", controllers.AgentGuard, controllers.DeleteAgentSession)
+}

+ 1 - 0
service/routes/routes.go

@@ -37,6 +37,7 @@ func Setup(app *fiber.App) {
 	MqttDataManageSetup(api)
 	FeedbackSetup(api)
 	AiChatSetup(api)
+	AgentSetup(api)
 
 	app.Get("/swagger/*", swagger.HandlerDefault)
 

+ 276 - 0
service/services/agentServices.go

@@ -0,0 +1,276 @@
+package services
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/config"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	"fwh-toolbox-server/utils/agentserver"
+
+	"go.uber.org/zap"
+)
+
+// 新 AI 助手(AgentServer):会话归属、按角色选择 Profile、代理组装。
+// 设计见 docs/ai-assistant-agentserver/technical-design.md §3,与旧助手(aiChatServices.go)相互独立。
+
+// ErrAgentSessionNotFound 会话不存在、已删除或不属于当前用户
+var ErrAgentSessionNotFound = errors.New("会话不存在或无权访问")
+
+// 每个用户列出的会话上限
+const agentSessionListLimit = 50
+
+// 标题最大长度(字符)
+const agentTitleMaxLen = 100
+
+// AgentSessionItem 会话列表项(返回给前端)
+type AgentSessionItem struct {
+	SessionId  string `json:"sessionId"`
+	Title      string `json:"title"`
+	ProfileId  string `json:"profileId"`
+	CreatedAt  int64  `json:"createdAt"`
+	LastActive int64  `json:"lastActive"`
+}
+
+// AgentService 新 AI 助手服务
+type AgentService struct {
+	cfg      config.AgentConfig
+	client   *agentserver.Client
+	proxy    *agentserver.Proxy
+	recorder *agentResourceRecorder
+}
+
+// NewAgentService 创建服务并启动子资源索引的异步写入
+func NewAgentService(cfg config.AgentConfig) *AgentService {
+	client := agentserver.NewClient(cfg.ServerURL, cfg.ApiKey)
+	recorder := newAgentResourceRecorder()
+	s := &AgentService{cfg: cfg, client: client, recorder: recorder}
+	s.proxy = &agentserver.Proxy{
+		Client: client,
+		Guard:  &agentserver.Guard{Store: agentMongoStore{}, Upstream: client, Sink: recorder},
+		Sink:   recorder,
+		OnMessage: func(userId, sessionId string) {
+			go func() {
+				ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+				defer cancel()
+				if err := mongoRepo.TouchAgentSession(ctx, userId, sessionId); err != nil {
+					zap.L().Warn("[Agent] 刷新会话活跃时间失败", zap.String("sessionId", sessionId), zap.Error(err))
+				}
+			}()
+		},
+		OnTitle: func(userId, sessionId, title string) {
+			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+			defer cancel()
+			if err := mongoRepo.UpdateAgentSessionTitle(ctx, userId, sessionId, clipTitle(title)); err != nil {
+				zap.L().Warn("[Agent] 同步会话标题失败", zap.String("sessionId", sessionId), zap.Error(err))
+			}
+		},
+	}
+	if cfg.Enabled && !client.Configured() {
+		zap.L().Warn("[Agent] 已开启新 AI 助手,但 AGENT_SERVER_URL / AGENT_SERVER_API_KEY 未配置")
+	}
+	return s
+}
+
+// Enabled 新助手对该用户是否可用:总开关打开、已配置 AgentServer、角色允许
+func (s *AgentService) Enabled(roles []string) bool {
+	return s.cfg.Enabled && s.client.Configured() && agentserver.RoleAllowed(roles, s.cfg.AllowedRoles)
+}
+
+// Proxy /agent/v1/* 代理
+func (s *AgentService) Proxy() *agentserver.Proxy {
+	return s.proxy
+}
+
+// ListSessions 本人未删除的会话,按最近活跃倒序
+func (s *AgentService) ListSessions(ctx context.Context, userId string) ([]AgentSessionItem, error) {
+	list, err := mongoRepo.ListAgentSessionsByUser(ctx, userId, agentSessionListLimit)
+	if err != nil {
+		return nil, err
+	}
+	out := make([]AgentSessionItem, 0, len(list))
+	for _, r := range list {
+		out = append(out, toAgentSessionItem(r))
+	}
+	return out, nil
+}
+
+// CreateSession 按角色选择 Profile,在 AgentServer 创建会话并记录归属
+func (s *AgentService) CreateSession(ctx context.Context, userId string, roles []string) (*AgentSessionItem, error) {
+	profileId := agentserver.SelectProfile(roles, s.cfg.ProfileByRole, s.cfg.ProfileDefault)
+	dto, err := s.client.CreateSession(ctx, profileId)
+	if err != nil {
+		return nil, err
+	}
+	if dto.ProfileId != nil && *dto.ProfileId != "" {
+		profileId = *dto.ProfileId
+	}
+	now := time.Now()
+	rec := mongoRepo.AgentSession{
+		SessionId:  dto.Id,
+		UserId:     userId,
+		ProfileId:  profileId,
+		Title:      dto.Title,
+		CreatedAt:  now,
+		LastActive: now,
+	}
+	if err := mongoRepo.InsertAgentSession(ctx, rec); err != nil {
+		// 归属写入失败时删除上游会话,避免出现无主会话
+		if delErr := s.client.DeleteSession(context.Background(), dto.Id); delErr != nil {
+			zap.L().Warn("[Agent] 回滚上游会话失败", zap.String("sessionId", dto.Id), zap.Error(delErr))
+		}
+		return nil, err
+	}
+	zap.L().Info("[Agent] 创建会话", zap.String("userId", userId), zap.String("sessionId", dto.Id), zap.String("profileId", profileId))
+	item := toAgentSessionItem(rec)
+	return &item, nil
+}
+
+// RenameSession 修改标题
+func (s *AgentService) RenameSession(ctx context.Context, userId, sessionId, title string) error {
+	if err := s.ensureOwned(ctx, userId, sessionId); err != nil {
+		return err
+	}
+	title = clipTitle(title)
+	if err := s.client.PatchSession(ctx, sessionId, map[string]any{"title": title}); err != nil {
+		return err
+	}
+	return mongoRepo.UpdateAgentSessionTitle(ctx, userId, sessionId, title)
+}
+
+// DeleteSession 删除上游会话并软删除归属记录
+func (s *AgentService) DeleteSession(ctx context.Context, userId, sessionId string) error {
+	if err := s.ensureOwned(ctx, userId, sessionId); err != nil {
+		return err
+	}
+	if err := s.client.DeleteSession(ctx, sessionId); err != nil {
+		return err
+	}
+	return mongoRepo.SoftDeleteAgentSession(ctx, userId, sessionId)
+}
+
+func (s *AgentService) ensureOwned(ctx context.Context, userId, sessionId string) error {
+	rec, err := mongoRepo.GetAgentSession(ctx, sessionId)
+	if err != nil {
+		return err
+	}
+	if rec == nil || rec.DeletedAt != nil || rec.UserId != userId {
+		return ErrAgentSessionNotFound
+	}
+	return nil
+}
+
+func toAgentSessionItem(r mongoRepo.AgentSession) AgentSessionItem {
+	return AgentSessionItem{
+		SessionId:  r.SessionId,
+		Title:      r.Title,
+		ProfileId:  r.ProfileId,
+		CreatedAt:  r.CreatedAt.UnixMilli(),
+		LastActive: r.LastActive.UnixMilli(),
+	}
+}
+
+func clipTitle(t string) string {
+	t = strings.TrimSpace(t)
+	if r := []rune(t); len(r) > agentTitleMaxLen {
+		return string(r[:agentTitleMaxLen])
+	}
+	return t
+}
+
+// ============ 归属存储(Mongo) ============
+
+type agentMongoStore struct{}
+
+func (agentMongoStore) SessionOwner(ctx context.Context, sessionId string) (string, bool, error) {
+	rec, err := mongoRepo.GetAgentSession(ctx, sessionId)
+	if err != nil || rec == nil || rec.DeletedAt != nil {
+		return "", false, err
+	}
+	return rec.UserId, true, nil
+}
+
+func (agentMongoStore) ResourceSession(ctx context.Context, resourceId string) (string, bool, error) {
+	sid, err := mongoRepo.GetAgentResourceSession(ctx, resourceId)
+	if err != nil {
+		return "", false, err
+	}
+	return sid, sid != "", nil
+}
+
+func (agentMongoStore) RecentSessionIds(ctx context.Context, userId string, limit int) ([]string, error) {
+	list, err := mongoRepo.ListAgentSessionsByUser(ctx, userId, int64(limit))
+	if err != nil {
+		return nil, err
+	}
+	ids := make([]string, 0, len(list))
+	for _, r := range list {
+		ids = append(ids, r.SessionId)
+	}
+	return ids, nil
+}
+
+// ============ 子资源索引的异步批量写入 ============
+
+const (
+	agentRecorderQueue    = 2048
+	agentRecorderBatch    = 200
+	agentRecorderInterval = 500 * time.Millisecond
+)
+
+// agentResourceRecorder 代理转发时解析到的子资源 ID 先进队列,后台按批写入,不阻塞转发
+type agentResourceRecorder struct {
+	ch chan agentserver.Resource
+}
+
+func newAgentResourceRecorder() *agentResourceRecorder {
+	r := &agentResourceRecorder{ch: make(chan agentserver.Resource, agentRecorderQueue)}
+	go r.loop()
+	return r
+}
+
+// Record 实现 agentserver.ResourceSink;队列满时丢弃(之后由归属反查补写)
+func (r *agentResourceRecorder) Record(rs []agentserver.Resource) {
+	for _, res := range rs {
+		select {
+		case r.ch <- res:
+		default:
+			zap.L().Warn("[Agent] 子资源索引队列已满,丢弃", zap.String("resourceId", res.Id))
+		}
+	}
+}
+
+func (r *agentResourceRecorder) loop() {
+	ticker := time.NewTicker(agentRecorderInterval)
+	defer ticker.Stop()
+	pending := map[string]string{}
+	flush := func() {
+		if len(pending) == 0 {
+			return
+		}
+		now := time.Now()
+		batch := make([]mongoRepo.AgentResource, 0, len(pending))
+		for id, sid := range pending {
+			batch = append(batch, mongoRepo.AgentResource{ResourceId: id, SessionId: sid, CreatedAt: now})
+		}
+		pending = map[string]string{}
+		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		if err := mongoRepo.UpsertAgentResources(ctx, batch); err != nil {
+			zap.L().Warn("[Agent] 写入子资源索引失败", zap.Int("count", len(batch)), zap.Error(err))
+		}
+	}
+	for {
+		select {
+		case res := <-r.ch:
+			pending[res.Id] = res.SessionId
+			if len(pending) >= agentRecorderBatch {
+				flush()
+			}
+		case <-ticker.C:
+			flush()
+		}
+	}
+}

+ 47 - 0
service/utils/agentserver/access.go

@@ -0,0 +1,47 @@
+package agentserver
+
+import "strings"
+
+// RoleAllowed 判断用户是否可以使用新助手:allowedCSV 为空表示所有登录用户
+func RoleAllowed(userRoles []string, allowedCSV string) bool {
+	allowed := splitCSV(allowedCSV)
+	if len(allowed) == 0 {
+		return true
+	}
+	for _, a := range allowed {
+		for _, r := range userRoles {
+			if r == a {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// SelectProfile 按角色选择 Profile:byRole 形如 "ops:prf_a,admin:prf_b",
+// 按配置顺序取第一个用户拥有的角色;都不命中时返回 def(可为空,表示 API Key 的默认 Profile)
+func SelectProfile(userRoles []string, byRole, def string) string {
+	for _, item := range splitCSV(byRole) {
+		role, profile, ok := strings.Cut(item, ":")
+		role, profile = strings.TrimSpace(role), strings.TrimSpace(profile)
+		if !ok || role == "" || profile == "" {
+			continue
+		}
+		for _, r := range userRoles {
+			if r == role {
+				return profile
+			}
+		}
+	}
+	return strings.TrimSpace(def)
+}
+
+func splitCSV(s string) []string {
+	var out []string
+	for _, p := range strings.Split(s, ",") {
+		if p = strings.TrimSpace(p); p != "" {
+			out = append(out, p)
+		}
+	}
+	return out
+}

+ 43 - 0
service/utils/agentserver/access_test.go

@@ -0,0 +1,43 @@
+package agentserver
+
+import "testing"
+
+func TestRoleAllowed(t *testing.T) {
+	cases := []struct {
+		roles   []string
+		allowed string
+		want    bool
+	}{
+		{nil, "", true},
+		{[]string{"dev"}, " ", true},
+		{[]string{"dev"}, "ops,admin", false},
+		{[]string{"dev", "ops"}, "ops, admin", true},
+		{nil, "ops", false},
+	}
+	for _, c := range cases {
+		if got := RoleAllowed(c.roles, c.allowed); got != c.want {
+			t.Errorf("RoleAllowed(%v, %q)=%v, want %v", c.roles, c.allowed, got, c.want)
+		}
+	}
+}
+
+func TestSelectProfile(t *testing.T) {
+	byRole := "ops:prf_ops, admin:prf_admin,bad, :prf_x,dev:"
+	cases := []struct {
+		roles []string
+		want  string
+	}{
+		{[]string{"dev"}, "prf_default"}, // dev 映射值为空,忽略
+		{[]string{"admin"}, "prf_admin"},
+		{[]string{"admin", "ops"}, "prf_ops"}, // 按配置顺序取第一个命中
+		{nil, "prf_default"},
+	}
+	for _, c := range cases {
+		if got := SelectProfile(c.roles, byRole, " prf_default "); got != c.want {
+			t.Errorf("SelectProfile(%v)=%q, want %q", c.roles, got, c.want)
+		}
+	}
+	if got := SelectProfile([]string{"x"}, "", ""); got != "" {
+		t.Errorf("没有默认值时应返回空,得到 %q", got)
+	}
+}

+ 170 - 0
service/utils/agentserver/client.go

@@ -0,0 +1,170 @@
+package agentserver
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+// ErrNotFound 上游返回 404
+var ErrNotFound = errors.New("agentserver: not found")
+
+// Client AgentServer HTTP 客户端,使用平台 API Key 鉴权。
+// 管理类请求(会话创建/改名/删除、归属反查)使用带超时的 http.Client;
+// 代理转发(含 SSE 长连接)使用不设整体超时的 streamHTTP,由调用方的 context 控制生命周期。
+type Client struct {
+	baseURL    string
+	apiKey     string
+	http       *http.Client
+	streamHTTP *http.Client
+}
+
+// NewClient 创建客户端;baseURL 形如 http://127.0.0.1:8787(不含 /v1)
+func NewClient(baseURL, apiKey string) *Client {
+	transport := &http.Transport{
+		Proxy:               nil, // 内网服务,不走系统代理
+		MaxIdleConns:        100,
+		MaxIdleConnsPerHost: 50,
+		IdleConnTimeout:     90 * time.Second,
+	}
+	return &Client{
+		baseURL:    strings.TrimRight(baseURL, "/"),
+		apiKey:     apiKey,
+		http:       &http.Client{Transport: transport, Timeout: 30 * time.Second},
+		streamHTTP: &http.Client{Transport: transport},
+	}
+}
+
+// Configured 是否已配置地址和 API Key
+func (c *Client) Configured() bool {
+	return c != nil && c.baseURL != "" && c.apiKey != ""
+}
+
+// Forward 转发一次请求(代理用):path 以 /v1/ 开头,rawQuery 不含问号。
+// 返回的响应由调用方负责关闭 Body。
+func (c *Client) Forward(ctx context.Context, method, path, rawQuery string, header http.Header, body io.Reader) (*http.Response, error) {
+	u := c.baseURL + path
+	if rawQuery != "" {
+		u += "?" + rawQuery
+	}
+	req, err := http.NewRequestWithContext(ctx, method, u, body)
+	if err != nil {
+		return nil, err
+	}
+	for k, vs := range header {
+		for _, v := range vs {
+			req.Header.Add(k, v)
+		}
+	}
+	req.Header.Set("Authorization", "Bearer "+c.apiKey)
+	return c.streamHTTP.Do(req)
+}
+
+// CreateSession POST /v1/sessions;profileId 为空时使用 API Key 的默认 Profile
+func (c *Client) CreateSession(ctx context.Context, profileId string) (*SessionDto, error) {
+	body := map[string]any{}
+	if profileId != "" {
+		body["profileId"] = profileId
+	}
+	var out SessionDto
+	if err := c.doJSON(ctx, http.MethodPost, "/v1/sessions", body, &out); err != nil {
+		return nil, err
+	}
+	return &out, nil
+}
+
+// PatchSession PATCH /v1/sessions/:id
+func (c *Client) PatchSession(ctx context.Context, sessionId string, body map[string]any) error {
+	return c.doJSON(ctx, http.MethodPatch, "/v1/sessions/"+url.PathEscape(sessionId), body, nil)
+}
+
+// DeleteSession DELETE /v1/sessions/:id;上游已不存在时视为成功
+func (c *Client) DeleteSession(ctx context.Context, sessionId string) error {
+	err := c.doJSON(ctx, http.MethodDelete, "/v1/sessions/"+url.PathEscape(sessionId), nil, nil)
+	if errors.Is(err, ErrNotFound) {
+		return nil
+	}
+	return err
+}
+
+// GetRun GET /v1/runs/:id(归属反查用)
+func (c *Client) GetRun(ctx context.Context, runId string) (*RunDto, error) {
+	var out RunDto
+	if err := c.doJSON(ctx, http.MethodGet, "/v1/runs/"+url.PathEscape(runId), nil, &out); err != nil {
+		return nil, err
+	}
+	return &out, nil
+}
+
+// ListFiles GET /v1/sessions/:id/files(归属反查用)
+func (c *Client) ListFiles(ctx context.Context, sessionId string) ([]FileDto, error) {
+	var out []FileDto
+	if err := c.doJSON(ctx, http.MethodGet, "/v1/sessions/"+url.PathEscape(sessionId)+"/files", nil, &out); err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// PendingInteractions GET /v1/interactions?sessionId=(归属反查用)
+func (c *Client) PendingInteractions(ctx context.Context, sessionId string) ([]InteractionRefDto, error) {
+	var out []InteractionRefDto
+	if err := c.doJSON(ctx, http.MethodGet, "/v1/interactions?sessionId="+url.QueryEscape(sessionId), nil, &out); err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *Client) doJSON(ctx context.Context, method, path string, in any, out any) error {
+	var body io.Reader
+	if in != nil {
+		b, err := json.Marshal(in)
+		if err != nil {
+			return err
+		}
+		body = bytes.NewReader(b)
+	}
+	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Authorization", "Bearer "+c.apiKey)
+	if in != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+	resp, err := c.http.Do(req)
+	if err != nil {
+		return fmt.Errorf("请求 AgentServer 失败: %w", err)
+	}
+	defer resp.Body.Close()
+	data, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
+	if resp.StatusCode == http.StatusNotFound {
+		return ErrNotFound
+	}
+	if resp.StatusCode >= 300 {
+		var eb ErrorBody
+		if json.Unmarshal(data, &eb) == nil && eb.Error.Message != "" {
+			return fmt.Errorf("AgentServer %d %s: %s", resp.StatusCode, eb.Error.Code, eb.Error.Message)
+		}
+		return fmt.Errorf("AgentServer %d: %s", resp.StatusCode, truncate(string(data), 200))
+	}
+	if out != nil && len(data) > 0 {
+		if err := json.Unmarshal(data, out); err != nil {
+			return fmt.Errorf("解析 AgentServer 响应失败: %w", err)
+		}
+	}
+	return nil
+}
+
+func truncate(s string, n int) string {
+	if len(s) <= n {
+		return s
+	}
+	return s[:n] + "…"
+}

+ 154 - 0
service/utils/agentserver/guard.go

@@ -0,0 +1,154 @@
+package agentserver
+
+import (
+	"context"
+	"errors"
+)
+
+// OwnershipStore 会话归属与子资源索引的存储(Mongo 实现见 services/agentServices.go)
+type OwnershipStore interface {
+	// SessionOwner 返回未删除会话的所属用户;不存在或已删除时 found=false
+	SessionOwner(ctx context.Context, sessionId string) (owner string, found bool, err error)
+	// ResourceSession 返回子资源所属的会话
+	ResourceSession(ctx context.Context, resourceId string) (sessionId string, found bool, err error)
+	// RecentSessionIds 用户最近活跃的未删除会话(反查交互、文件时遍历)
+	RecentSessionIds(ctx context.Context, userId string, limit int) ([]string, error)
+}
+
+// ResourceSink 记录子资源索引(异步)
+type ResourceSink interface {
+	Record(rs []Resource)
+}
+
+// Upstream 归属反查用到的 AgentServer 接口(*Client 实现)
+type Upstream interface {
+	GetRun(ctx context.Context, runId string) (*RunDto, error)
+	ListFiles(ctx context.Context, sessionId string) ([]FileDto, error)
+	PendingInteractions(ctx context.Context, sessionId string) ([]InteractionRefDto, error)
+}
+
+// fallbackSessions 反查交互、文件时最多遍历的最近会话数
+const fallbackSessions = 10
+
+// Guard 代理请求的归属校验(technical-design §3.5 第 2 点)
+type Guard struct {
+	Store    OwnershipStore
+	Upstream Upstream
+	Sink     ResourceSink
+}
+
+// Check 校验 route 指向的资源属于 userId。
+// 返回资源所属的会话 ID(OwnerNone 时为空);ok=false 表示不属于本人或不存在(调用方统一返回 404)。
+// err 只在存储或上游出错时返回。
+func (g *Guard) Check(ctx context.Context, userId string, r Route) (sessionId string, ok bool, err error) {
+	switch r.Owner {
+	case OwnerNone:
+		return "", true, nil
+	case OwnerSession, OwnerQuerySession:
+		owned, err := g.ownsSession(ctx, userId, r.Id)
+		if err != nil || !owned {
+			return "", false, err
+		}
+		return r.Id, true, nil
+	case OwnerRun, OwnerInteraction, OwnerFile:
+		return g.checkResource(ctx, userId, r.Owner, r.Id)
+	}
+	return "", false, nil
+}
+
+func (g *Guard) ownsSession(ctx context.Context, userId, sessionId string) (bool, error) {
+	owner, found, err := g.Store.SessionOwner(ctx, sessionId)
+	if err != nil {
+		return false, err
+	}
+	return found && owner == userId, nil
+}
+
+func (g *Guard) checkResource(ctx context.Context, userId string, kind OwnerKind, id string) (string, bool, error) {
+	sid, found, err := g.Store.ResourceSession(ctx, id)
+	if err != nil {
+		return "", false, err
+	}
+	if found {
+		owned, err := g.ownsSession(ctx, userId, sid)
+		if err != nil || !owned {
+			return "", false, err
+		}
+		return sid, true, nil
+	}
+
+	// 索引中没有:向 AgentServer 反查,确认后补写索引
+	sid, err = g.lookup(ctx, userId, kind, id)
+	if err != nil || sid == "" {
+		return "", false, err
+	}
+	if g.Sink != nil {
+		g.Sink.Record([]Resource{{Id: id, SessionId: sid}})
+	}
+	return sid, true, nil
+}
+
+// lookup 反查子资源所属会话,返回空表示不属于本人
+func (g *Guard) lookup(ctx context.Context, userId string, kind OwnerKind, id string) (string, error) {
+	if g.Upstream == nil {
+		return "", nil
+	}
+	if kind == OwnerRun {
+		run, err := g.Upstream.GetRun(ctx, id)
+		if errors.Is(err, ErrNotFound) {
+			return "", nil
+		}
+		if err != nil {
+			return "", err
+		}
+		owned, err := g.ownsSession(ctx, userId, run.SessionId)
+		if err != nil || !owned {
+			return "", err
+		}
+		return run.SessionId, nil
+	}
+
+	sessions, err := g.Store.RecentSessionIds(ctx, userId, fallbackSessions)
+	if err != nil {
+		return "", err
+	}
+	for _, sid := range sessions {
+		hit, err := g.sessionHas(ctx, kind, sid, id)
+		if errors.Is(err, ErrNotFound) {
+			continue
+		}
+		if err != nil {
+			return "", err
+		}
+		if hit {
+			return sid, nil
+		}
+	}
+	return "", nil
+}
+
+func (g *Guard) sessionHas(ctx context.Context, kind OwnerKind, sessionId, id string) (bool, error) {
+	switch kind {
+	case OwnerFile:
+		files, err := g.Upstream.ListFiles(ctx, sessionId)
+		if err != nil {
+			return false, err
+		}
+		for _, f := range files {
+			if f.Id == id {
+				return true, nil
+			}
+		}
+	case OwnerInteraction:
+		items, err := g.Upstream.PendingInteractions(ctx, sessionId)
+		if err != nil {
+			return false, err
+		}
+		for _, it := range items {
+			if it.InteractionId == id {
+				return true, nil
+			}
+		}
+	}
+	return false, nil
+}

+ 139 - 0
service/utils/agentserver/guard_test.go

@@ -0,0 +1,139 @@
+package agentserver
+
+import (
+	"context"
+	"errors"
+	"testing"
+)
+
+type fakeStore struct {
+	owners    map[string]string // sessionId → userId(未删除)
+	resources map[string]string // resourceId → sessionId
+	err       error
+}
+
+func (f *fakeStore) SessionOwner(_ context.Context, sid string) (string, bool, error) {
+	if f.err != nil {
+		return "", false, f.err
+	}
+	o, ok := f.owners[sid]
+	return o, ok, nil
+}
+
+func (f *fakeStore) ResourceSession(_ context.Context, id string) (string, bool, error) {
+	s, ok := f.resources[id]
+	return s, ok, nil
+}
+
+func (f *fakeStore) RecentSessionIds(_ context.Context, uid string, _ int) ([]string, error) {
+	var out []string
+	for s, o := range f.owners {
+		if o == uid {
+			out = append(out, s)
+		}
+	}
+	return out, nil
+}
+
+type fakeUpstream struct {
+	runs  map[string]string // runId → sessionId
+	files map[string][]string
+	itx   map[string][]string
+}
+
+func (f *fakeUpstream) GetRun(_ context.Context, id string) (*RunDto, error) {
+	s, ok := f.runs[id]
+	if !ok {
+		return nil, ErrNotFound
+	}
+	return &RunDto{Id: id, SessionId: s}, nil
+}
+
+func (f *fakeUpstream) ListFiles(_ context.Context, sid string) ([]FileDto, error) {
+	var out []FileDto
+	for _, id := range f.files[sid] {
+		out = append(out, FileDto{Id: id, SessionId: sid})
+	}
+	return out, nil
+}
+
+func (f *fakeUpstream) PendingInteractions(_ context.Context, sid string) ([]InteractionRefDto, error) {
+	var out []InteractionRefDto
+	for _, id := range f.itx[sid] {
+		out = append(out, InteractionRefDto{InteractionId: id})
+	}
+	return out, nil
+}
+
+type fakeSink struct{ got []Resource }
+
+func (f *fakeSink) Record(rs []Resource) { f.got = append(f.got, rs...) }
+
+func newTestGuard() (*Guard, *fakeSink) {
+	sink := &fakeSink{}
+	return &Guard{
+		Store: &fakeStore{
+			owners:    map[string]string{"ses_a1": "alice", "ses_b1": "bob"},
+			resources: map[string]string{"run_a": "ses_a1", "file_b": "ses_b1"},
+		},
+		Upstream: &fakeUpstream{
+			runs:  map[string]string{"run_a2": "ses_a1", "run_b2": "ses_b1"},
+			files: map[string][]string{"ses_a1": {"file_a2"}, "ses_b1": {"file_b2"}},
+			itx:   map[string][]string{"ses_a1": {"itx_a2"}, "ses_b1": {"itx_b2"}},
+		},
+		Sink: sink,
+	}, sink
+}
+
+func TestGuardCheck(t *testing.T) {
+	g, sink := newTestGuard()
+	ctx := context.Background()
+	cases := []struct {
+		name  string
+		route Route
+		ok    bool
+		sid   string
+	}{
+		{"元数据", Route{Owner: OwnerNone}, true, ""},
+		{"本人会话", Route{Owner: OwnerSession, Id: "ses_a1"}, true, "ses_a1"},
+		{"他人会话", Route{Owner: OwnerSession, Id: "ses_b1"}, false, ""},
+		{"不存在的会话", Route{Owner: OwnerSession, Id: "ses_zz"}, false, ""},
+		{"查询参数会话(他人)", Route{Owner: OwnerQuerySession, Id: "ses_b1"}, false, ""},
+		{"索引中的本人 run", Route{Owner: OwnerRun, Id: "run_a"}, true, "ses_a1"},
+		{"索引中的他人文件", Route{Owner: OwnerFile, Id: "file_b"}, false, ""},
+		{"反查本人 run", Route{Owner: OwnerRun, Id: "run_a2"}, true, "ses_a1"},
+		{"反查他人 run", Route{Owner: OwnerRun, Id: "run_b2"}, false, ""},
+		{"反查不存在的 run", Route{Owner: OwnerRun, Id: "run_zz"}, false, ""},
+		{"反查本人文件", Route{Owner: OwnerFile, Id: "file_a2"}, true, "ses_a1"},
+		{"反查他人文件", Route{Owner: OwnerFile, Id: "file_b2"}, false, ""},
+		{"反查本人交互", Route{Owner: OwnerInteraction, Id: "itx_a2"}, true, "ses_a1"},
+		{"反查他人交互", Route{Owner: OwnerInteraction, Id: "itx_b2"}, false, ""},
+	}
+	for _, c := range cases {
+		sid, ok, err := g.Check(ctx, "alice", c.route)
+		if err != nil {
+			t.Errorf("%s: err %v", c.name, err)
+			continue
+		}
+		if ok != c.ok || sid != c.sid {
+			t.Errorf("%s: got ok=%v sid=%q, want ok=%v sid=%q", c.name, ok, sid, c.ok, c.sid)
+		}
+	}
+	// 反查成功的三项应补写索引
+	want := map[string]string{"run_a2": "ses_a1", "file_a2": "ses_a1", "itx_a2": "ses_a1"}
+	if len(sink.got) != len(want) {
+		t.Fatalf("补写索引 %v", sink.got)
+	}
+	for _, r := range sink.got {
+		if want[r.Id] != r.SessionId {
+			t.Errorf("补写了不应写入的索引 %+v", r)
+		}
+	}
+}
+
+func TestGuardStoreError(t *testing.T) {
+	g := &Guard{Store: &fakeStore{err: errors.New("mongo down")}}
+	if _, ok, err := g.Check(context.Background(), "alice", Route{Owner: OwnerSession, Id: "ses_a1"}); err == nil || ok {
+		t.Fatalf("存储出错时应返回错误且不放行,得到 ok=%v err=%v", ok, err)
+	}
+}

+ 225 - 0
service/utils/agentserver/proxy.go

@@ -0,0 +1,225 @@
+package agentserver
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/gofiber/fiber/v2"
+	"go.uber.org/zap"
+)
+
+// 需要解析的 JSON 响应最大读取字节数(事件列表最多 5000 条)
+const maxInspectBytes = 64 << 20
+
+// 透传给上游的请求头(Authorization 会被替换为 API Key,Cookie 等不转发)
+var forwardRequestHeaders = []string{"Content-Type", "Accept", "Last-Event-ID"}
+
+// 透传给浏览器的响应头
+var forwardResponseHeaders = []string{
+	"Content-Type", "Content-Disposition", "Cache-Control",
+	"X-Content-Type-Options", "Content-Security-Policy",
+}
+
+// Proxy /agent/v1/* → AgentServer /v1/*(technical-design §3.5)
+type Proxy struct {
+	Client *Client
+	Guard  *Guard
+	Sink   ResourceSink
+	// OnMessage 发送消息成功后回调(刷新会话 lastActive)
+	OnMessage func(userId, sessionId string)
+	// OnTitle 经代理改了标题后回调(同步归属表)
+	OnTitle func(userId, sessionId, title string)
+}
+
+// WriteError 以 AgentServer 的错误格式返回,SDK 可以直接解析
+func WriteError(c *fiber.Ctx, status int, code, message string) error {
+	return c.Status(status).JSON(fiber.Map{"error": fiber.Map{"code": code, "message": message}})
+}
+
+// Handle 处理一次代理请求;rest 为 /agent/v1/ 之后的路径
+func (p *Proxy) Handle(c *fiber.Ctx, userId, rest string) error {
+	method := c.Method()
+	path := "/" + strings.Trim(rest, "/")
+	rawQuery := string(c.Request().URI().QueryString())
+	query, _ := url.ParseQuery(rawQuery)
+
+	route, ok := MatchRoute(method, path, query)
+	if !ok {
+		return WriteError(c, fiber.StatusForbidden, "forbidden", "不支持的接口")
+	}
+
+	sessionId, owned, err := p.Guard.Check(c.UserContext(), userId, route)
+	if err != nil {
+		zap.L().Warn("[Agent] 归属校验失败", zap.String("path", path), zap.Error(err))
+		return WriteError(c, fiber.StatusBadGateway, "upstream_error", "AI 服务暂不可用")
+	}
+	if !owned {
+		return WriteError(c, fiber.StatusNotFound, "not_found", "会话不存在或无权访问")
+	}
+
+	// 请求体:PATCH 只保留 title、model
+	var body []byte
+	var patchedTitle *string
+	if route.Action == ActionSessionPatch {
+		body, patchedTitle, err = filterSessionPatch(c.Body())
+		if err != nil {
+			return WriteError(c, fiber.StatusBadRequest, "bad_request", "请求体不是有效的 JSON")
+		}
+	} else if len(c.Body()) > 0 {
+		body = append([]byte(nil), c.Body()...)
+	}
+
+	header := http.Header{}
+	for _, h := range forwardRequestHeaders {
+		if v := c.Get(h); v != "" {
+			header.Set(h, v)
+		}
+	}
+	if route.Action == ActionSessionPatch {
+		header.Set("Content-Type", "application/json")
+	}
+
+	// 上游请求的生命周期:流式/透传响应在处理函数返回后才读取完,由读取方在结束时取消
+	ctx, cancel := context.WithCancel(context.Background())
+	var reqBody io.Reader
+	if body != nil {
+		reqBody = bytes.NewReader(body)
+	}
+	resp, err := p.Client.Forward(ctx, method, "/v1"+path, rawQuery, header, reqBody)
+	if err != nil {
+		cancel()
+		zap.L().Warn("[Agent] 转发失败", zap.String("path", path), zap.Error(err))
+		return WriteError(c, fiber.StatusBadGateway, "upstream_error", "AI 服务暂不可用")
+	}
+
+	for _, h := range forwardResponseHeaders {
+		if v := resp.Header.Get(h); v != "" {
+			c.Set(h, v)
+		}
+	}
+	c.Status(resp.StatusCode)
+
+	switch {
+	case route.Action == ActionStream && resp.StatusCode == http.StatusOK:
+		p.stream(c, resp, cancel, sessionId)
+		return nil
+
+	case needsInspect(route.Action) && resp.StatusCode < 300:
+		defer cancel()
+		defer resp.Body.Close()
+		data, err := io.ReadAll(io.LimitReader(resp.Body, maxInspectBytes))
+		if err != nil {
+			return WriteError(c, fiber.StatusBadGateway, "upstream_error", "读取 AI 服务响应失败")
+		}
+		if rs := ResourcesFromResponse(route.Action, sessionId, data); len(rs) > 0 && p.Sink != nil {
+			p.Sink.Record(rs)
+		}
+		if route.Action == ActionSendMessage && p.OnMessage != nil {
+			p.OnMessage(userId, sessionId)
+		}
+		if route.Action == ActionSessionPatch && patchedTitle != nil && p.OnTitle != nil {
+			p.OnTitle(userId, sessionId, *patchedTitle)
+		}
+		return c.Send(data)
+
+	default:
+		// 原样透传(文件下载等),读取结束时关闭上游连接
+		c.Context().SetBodyStream(&cancelOnClose{ReadCloser: resp.Body, cancel: cancel}, int(resp.ContentLength))
+		return nil
+	}
+}
+
+func needsInspect(a Action) bool {
+	switch a {
+	case ActionEvents, ActionSessionDetail, ActionSessionPatch, ActionSendMessage,
+		ActionUploadFiles, ActionListFiles, ActionInteractions:
+		return true
+	}
+	return false
+}
+
+// stream SSE 透传:逐块写出并立即 Flush;旁路解析事件记录子资源;客户端断开时取消上游请求
+func (p *Proxy) stream(c *fiber.Ctx, resp *http.Response, cancel context.CancelFunc, sessionId string) {
+	c.Set("Content-Type", "text/event-stream; charset=utf-8")
+	c.Set("Cache-Control", "no-cache, no-transform")
+	c.Set("Connection", "keep-alive")
+	c.Set("X-Accel-Buffering", "no")
+
+	scanner := NewSSEScanner(func(data []byte) {
+		var rs []Resource
+		for _, r := range ResourcesFromSSEData(data) {
+			if r.SessionId == sessionId {
+				rs = append(rs, r)
+			}
+		}
+		if len(rs) > 0 && p.Sink != nil {
+			p.Sink.Record(rs)
+		}
+	})
+
+	c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
+		defer cancel()
+		defer resp.Body.Close()
+		buf := make([]byte, 32<<10)
+		for {
+			n, err := resp.Body.Read(buf)
+			if n > 0 {
+				chunk := buf[:n]
+				scanner.Write(chunk)
+				if _, werr := w.Write(chunk); werr != nil {
+					return
+				}
+				if ferr := w.Flush(); ferr != nil {
+					// 客户端已断开
+					return
+				}
+			}
+			if err != nil {
+				return
+			}
+		}
+	})
+}
+
+// filterSessionPatch 只保留 title、model 字段
+func filterSessionPatch(raw []byte) ([]byte, *string, error) {
+	in := map[string]json.RawMessage{}
+	if len(bytes.TrimSpace(raw)) > 0 {
+		if err := json.Unmarshal(raw, &in); err != nil {
+			return nil, nil, err
+		}
+	}
+	out := map[string]json.RawMessage{}
+	var title *string
+	for _, k := range []string{"title", "model"} {
+		if v, ok := in[k]; ok {
+			out[k] = v
+		}
+	}
+	if v, ok := out["title"]; ok {
+		var t string
+		if json.Unmarshal(v, &t) == nil {
+			title = &t
+		}
+	}
+	b, err := json.Marshal(out)
+	return b, title, err
+}
+
+// cancelOnClose 透传响应体读取结束(fasthttp 调用 Close)时取消上游请求
+type cancelOnClose struct {
+	io.ReadCloser
+	cancel context.CancelFunc
+}
+
+func (r *cancelOnClose) Close() error {
+	err := r.ReadCloser.Close()
+	r.cancel()
+	return err
+}

+ 152 - 0
service/utils/agentserver/proxy_test.go

@@ -0,0 +1,152 @@
+package agentserver
+
+import (
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+type lockedSink struct {
+	mu  sync.Mutex
+	got []Resource
+}
+
+func (s *lockedSink) Record(rs []Resource) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.got = append(s.got, rs...)
+}
+
+func (s *lockedSink) ids() []string {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	var out []string
+	for _, r := range s.got {
+		out = append(out, r.Id)
+	}
+	return out
+}
+
+// 假的 AgentServer:记录收到的鉴权头与请求体
+func fakeAgentServer(t *testing.T, seen *http.Header, lastBody *string) *httptest.Server {
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		*seen = r.Header.Clone()
+		b, _ := io.ReadAll(r.Body)
+		*lastBody = string(b)
+		switch {
+		case r.URL.Path == "/v1/sessions/ses_a1/stream":
+			w.Header().Set("Content-Type", "text/event-stream")
+			w.WriteHeader(200)
+			io.WriteString(w, "retry: 2000\n\nid: 1\nevent: run.started\ndata: "+
+				`{"seq":1,"type":"run.started","sessionId":"ses_a1","runId":"run_s1","payload":{}}`+"\n\n")
+		case r.URL.Path == "/v1/sessions/ses_a1/messages":
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(202)
+			io.WriteString(w, `{"id":"run_m1","sessionId":"ses_a1","status":"queued"}`)
+		case r.URL.Path == "/v1/sessions/ses_a1":
+			w.Header().Set("Content-Type", "application/json")
+			io.WriteString(w, `{"id":"ses_a1","title":"x"}`)
+		case r.URL.Path == "/v1/files/file_a":
+			w.Header().Set("Content-Type", "text/plain")
+			w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''a.txt")
+			io.WriteString(w, "file-content")
+		default:
+			w.WriteHeader(404)
+			io.WriteString(w, `{"error":{"code":"not_found","message":"x"}}`)
+		}
+	}))
+}
+
+func newTestApp(t *testing.T, upstreamURL string, sink *lockedSink) (*fiber.App, *[]string) {
+	var touched []string
+	guard := &Guard{
+		Store: &fakeStore{
+			owners:    map[string]string{"ses_a1": "alice", "ses_b1": "bob"},
+			resources: map[string]string{"file_a": "ses_a1"},
+		},
+		Upstream: &fakeUpstream{},
+		Sink:     sink,
+	}
+	p := &Proxy{
+		Client:    NewClient(upstreamURL, "ask_test"),
+		Guard:     guard,
+		Sink:      sink,
+		OnMessage: func(uid, sid string) { touched = append(touched, uid+"@"+sid) },
+	}
+	app := fiber.New()
+	app.All("/agent/v1/*", func(c *fiber.Ctx) error { return p.Handle(c, "alice", c.Params("*")) })
+	return app, &touched
+}
+
+func TestProxy(t *testing.T) {
+	var seen http.Header
+	var body string
+	up := fakeAgentServer(t, &seen, &body)
+	defer up.Close()
+	sink := &lockedSink{}
+	app, touched := newTestApp(t, up.URL, sink)
+
+	do := func(method, path, reqBody string) (*http.Response, string) {
+		req := httptest.NewRequest(method, path, strings.NewReader(reqBody))
+		req.Header.Set("Authorization", "Bearer toolbox-token")
+		req.Header.Set("Cookie", "secret=1")
+		if reqBody != "" {
+			req.Header.Set("Content-Type", "application/json")
+		}
+		resp, err := app.Test(req, 5000)
+		if err != nil {
+			t.Fatal(err)
+		}
+		b, _ := io.ReadAll(resp.Body)
+		return resp, string(b)
+	}
+
+	// 白名单外
+	if resp, _ := do("GET", "/agent/v1/sessions", ""); resp.StatusCode != 403 {
+		t.Errorf("GET /sessions 应为 403,得到 %d", resp.StatusCode)
+	}
+	// 他人会话
+	if resp, b := do("GET", "/agent/v1/sessions/ses_b1/events", ""); resp.StatusCode != 404 || !strings.Contains(b, "not_found") {
+		t.Errorf("他人会话应为 404,得到 %d %s", resp.StatusCode, b)
+	}
+
+	// 发送消息:替换鉴权头、不转发 Cookie、记录 runId、刷新活跃时间
+	resp, b := do("POST", "/agent/v1/sessions/ses_a1/messages", `{"text":"hi"}`)
+	if resp.StatusCode != 202 || !strings.Contains(b, "run_m1") {
+		t.Fatalf("发送消息 %d %s", resp.StatusCode, b)
+	}
+	if seen.Get("Authorization") != "Bearer ask_test" || seen.Get("Cookie") != "" {
+		t.Errorf("上游请求头 Authorization=%q Cookie=%q", seen.Get("Authorization"), seen.Get("Cookie"))
+	}
+	if len(*touched) != 1 || (*touched)[0] != "alice@ses_a1" {
+		t.Errorf("OnMessage %v", *touched)
+	}
+
+	// PATCH 只保留 title、model
+	do("PATCH", "/agent/v1/sessions/ses_a1", `{"title":"t","permissionMode":"auto"}`)
+	if body != `{"title":"t"}` {
+		t.Errorf("PATCH 转发的请求体 %s", body)
+	}
+
+	// 文件下载透传
+	resp, b = do("GET", "/agent/v1/files/file_a?inline=1", "")
+	if resp.StatusCode != 200 || b != "file-content" || resp.Header.Get("Content-Disposition") == "" {
+		t.Errorf("文件下载 %d %q %v", resp.StatusCode, b, resp.Header)
+	}
+
+	// SSE 透传并记录 runId
+	resp, b = do("GET", "/agent/v1/sessions/ses_a1/stream", "")
+	if resp.StatusCode != 200 || !strings.Contains(b, "run.started") || resp.Header.Get("X-Accel-Buffering") != "no" {
+		t.Errorf("SSE %d %q", resp.StatusCode, b)
+	}
+
+	ids := strings.Join(sink.ids(), ",")
+	if !strings.Contains(ids, "run_m1") || !strings.Contains(ids, "run_s1") {
+		t.Errorf("记录的子资源 %s", ids)
+	}
+}

+ 139 - 0
service/utils/agentserver/routes.go

@@ -0,0 +1,139 @@
+package agentserver
+
+import (
+	"net/url"
+	"strings"
+)
+
+// OwnerKind 代理请求的归属校验方式
+type OwnerKind int
+
+const (
+	// OwnerNone 只读元数据,无需归属校验(/v1/profiles、/v1/providers)
+	OwnerNone OwnerKind = iota
+	// OwnerSession 路径中的会话 ID
+	OwnerSession
+	// OwnerQuerySession 查询参数 sessionId
+	OwnerQuerySession
+	// OwnerRun 路径中的 runId
+	OwnerRun
+	// OwnerInteraction 路径中的 interactionId
+	OwnerInteraction
+	// OwnerFile 路径中的 fileId
+	OwnerFile
+)
+
+// Action 需要特殊处理的接口
+type Action int
+
+const (
+	ActionPassthrough   Action = iota
+	ActionStream               // GET /sessions/:id/stream(SSE 透传 + 解析事件)
+	ActionEvents               // GET /sessions/:id/events(解析事件)
+	ActionSessionDetail        // GET /sessions/:id(记录 activeRunId、待处理交互)
+	ActionSessionPatch         // PATCH /sessions/:id(只保留 title、model)
+	ActionSendMessage          // POST /sessions/:id/messages(记录 runId、刷新 lastActive)
+	ActionUploadFiles          // POST /sessions/:id/files(记录 fileId)
+	ActionListFiles            // GET /sessions/:id/files(记录 fileId)
+	ActionInteractions         // GET /interactions?sessionId=(记录 interactionId)
+)
+
+// Route 白名单匹配结果
+type Route struct {
+	Owner  OwnerKind
+	Id     string // 用于归属校验的 ID(会话 / run / 交互 / 文件)
+	Action Action
+}
+
+// MatchRoute 按白名单(technical-design §3.5)匹配代理请求。
+// path 为 /v1 之后的部分(如 /sessions/ses_x/stream);不在白名单内返回 false。
+func MatchRoute(method, path string, query url.Values) (Route, bool) {
+	seg := splitPath(path)
+	n := len(seg)
+	switch {
+	case method == "GET" && n == 1 && (seg[0] == "profiles" || seg[0] == "providers"):
+		return Route{Owner: OwnerNone}, true
+
+	case n >= 2 && seg[0] == "sessions" && validId(seg[1], PrefixSession):
+		id := seg[1]
+		if n == 2 {
+			switch method {
+			case "GET":
+				return Route{Owner: OwnerSession, Id: id, Action: ActionSessionDetail}, true
+			case "PATCH":
+				return Route{Owner: OwnerSession, Id: id, Action: ActionSessionPatch}, true
+			}
+			return Route{}, false
+		}
+		if n != 3 {
+			return Route{}, false
+		}
+		switch {
+		case method == "GET" && seg[2] == "events":
+			return Route{Owner: OwnerSession, Id: id, Action: ActionEvents}, true
+		case method == "GET" && seg[2] == "stream":
+			return Route{Owner: OwnerSession, Id: id, Action: ActionStream}, true
+		case method == "GET" && seg[2] == "files":
+			return Route{Owner: OwnerSession, Id: id, Action: ActionListFiles}, true
+		case method == "POST" && seg[2] == "files":
+			return Route{Owner: OwnerSession, Id: id, Action: ActionUploadFiles}, true
+		case method == "POST" && seg[2] == "messages":
+			return Route{Owner: OwnerSession, Id: id, Action: ActionSendMessage}, true
+		}
+		return Route{}, false
+
+	case n >= 2 && seg[0] == "runs" && validId(seg[1], PrefixRun):
+		if (method == "GET" && n == 2) || (method == "POST" && n == 3 && seg[2] == "interrupt") {
+			return Route{Owner: OwnerRun, Id: seg[1]}, true
+		}
+		return Route{}, false
+
+	case method == "GET" && n == 1 && seg[0] == "interactions":
+		sid := query.Get("sessionId")
+		if !validId(sid, PrefixSession) {
+			return Route{}, false
+		}
+		return Route{Owner: OwnerQuerySession, Id: sid, Action: ActionInteractions}, true
+
+	case method == "POST" && n == 2 && seg[0] == "interactions" && validId(seg[1], PrefixInteraction):
+		return Route{Owner: OwnerInteraction, Id: seg[1]}, true
+
+	case method == "GET" && n == 2 && seg[0] == "files" && validId(seg[1], PrefixFile):
+		return Route{Owner: OwnerFile, Id: seg[1]}, true
+	}
+	return Route{}, false
+}
+
+func splitPath(p string) []string {
+	p = strings.Trim(p, "/")
+	if p == "" {
+		return nil
+	}
+	return strings.Split(p, "/")
+}
+
+// validId 校验带前缀的 ID,只允许字母、数字、下划线和连字符,防止路径穿越
+func validId(id, prefix string) bool {
+	if !strings.HasPrefix(id, prefix) || len(id) <= len(prefix) || len(id) > 64 {
+		return false
+	}
+	for _, r := range id {
+		if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') {
+			return false
+		}
+	}
+	return true
+}
+
+// ResourceKindOf 按前缀判断子资源类型
+func ResourceKindOf(id string) OwnerKind {
+	switch {
+	case strings.HasPrefix(id, PrefixRun):
+		return OwnerRun
+	case strings.HasPrefix(id, PrefixInteraction):
+		return OwnerInteraction
+	case strings.HasPrefix(id, PrefixFile):
+		return OwnerFile
+	}
+	return OwnerNone
+}

+ 67 - 0
service/utils/agentserver/routes_test.go

@@ -0,0 +1,67 @@
+package agentserver
+
+import (
+	"net/url"
+	"testing"
+)
+
+func TestMatchRoute(t *testing.T) {
+	sid := "ses_01jabc"
+	cases := []struct {
+		method, path, query string
+		ok                  bool
+		owner               OwnerKind
+		id                  string
+		action              Action
+	}{
+		{"GET", "/profiles", "", true, OwnerNone, "", ActionPassthrough},
+		{"GET", "/providers", "", true, OwnerNone, "", ActionPassthrough},
+		{"GET", "/sessions/" + sid, "", true, OwnerSession, sid, ActionSessionDetail},
+		{"PATCH", "/sessions/" + sid, "", true, OwnerSession, sid, ActionSessionPatch},
+		{"GET", "/sessions/" + sid + "/events", "after=3", true, OwnerSession, sid, ActionEvents},
+		{"GET", "/sessions/" + sid + "/stream", "", true, OwnerSession, sid, ActionStream},
+		{"GET", "/sessions/" + sid + "/files", "", true, OwnerSession, sid, ActionListFiles},
+		{"POST", "/sessions/" + sid + "/files", "", true, OwnerSession, sid, ActionUploadFiles},
+		{"POST", "/sessions/" + sid + "/messages", "", true, OwnerSession, sid, ActionSendMessage},
+		{"GET", "/runs/run_01x", "", true, OwnerRun, "run_01x", ActionPassthrough},
+		{"POST", "/runs/run_01x/interrupt", "", true, OwnerRun, "run_01x", ActionPassthrough},
+		{"GET", "/interactions", "sessionId=" + sid, true, OwnerQuerySession, sid, ActionInteractions},
+		{"POST", "/interactions/itx_01x", "", true, OwnerInteraction, "itx_01x", ActionPassthrough},
+		{"GET", "/files/file_01x", "inline=1", true, OwnerFile, "file_01x", ActionPassthrough},
+
+		// 不在白名单
+		{"GET", "/sessions", "", false, 0, "", 0},
+		{"POST", "/sessions", "", false, 0, "", 0},
+		{"DELETE", "/sessions/" + sid, "", false, 0, "", 0},
+		{"GET", "/usage", "days=30", false, 0, "", 0},
+		{"POST", "/browser-tokens", "", false, 0, "", 0},
+		{"GET", "/interactions", "", false, 0, "", 0},
+		{"GET", "/interactions", "sessionId=../x", false, 0, "", 0},
+		{"POST", "/runs/run_01x", "", false, 0, "", 0},
+		{"GET", "/sessions/" + sid + "/stream/x", "", false, 0, "", 0},
+		{"GET", "/sessions/run_01x", "", false, 0, "", 0}, // 前缀不符
+		{"GET", "/files/file_..%2f", "", false, 0, "", 0}, // 非法字符
+		{"GET", "/sessions/ses_", "", false, 0, "", 0},    // 只有前缀
+		{"PUT", "/sessions/" + sid, "", false, 0, "", 0},
+	}
+	for _, c := range cases {
+		q, _ := url.ParseQuery(c.query)
+		r, ok := MatchRoute(c.method, c.path, q)
+		if ok != c.ok {
+			t.Errorf("%s %s?%s: ok=%v, want %v", c.method, c.path, c.query, ok, c.ok)
+			continue
+		}
+		if !ok {
+			continue
+		}
+		if r.Owner != c.owner || r.Id != c.id || r.Action != c.action {
+			t.Errorf("%s %s: got %+v, want owner=%v id=%s action=%v", c.method, c.path, r, c.owner, c.id, c.action)
+		}
+	}
+}
+
+func TestResourceKindOf(t *testing.T) {
+	if ResourceKindOf("run_1") != OwnerRun || ResourceKindOf("itx_1") != OwnerInteraction || ResourceKindOf("file_1") != OwnerFile || ResourceKindOf("ses_1") != OwnerNone {
+		t.Fatal("ResourceKindOf 前缀判断错误")
+	}
+}

+ 171 - 0
service/utils/agentserver/sse.go

@@ -0,0 +1,171 @@
+package agentserver
+
+import (
+	"bytes"
+	"encoding/json"
+)
+
+// Resource 会话下的子资源(run / 交互 / 文件),代理据此校验不带会话 ID 的请求
+type Resource struct {
+	Id        string
+	SessionId string
+}
+
+// ResourcesFromEvent 从一条事件中提取子资源 ID:
+//   - run.started 等带 runId 的事件 → runId
+//   - interaction.required → payload.interactionId
+//   - file.changed → payload.fileId
+func ResourcesFromEvent(e AgentEvent) []Resource {
+	if e.SessionId == "" {
+		return nil
+	}
+	var out []Resource
+	if e.Type == "run.started" && e.RunId != nil && *e.RunId != "" {
+		out = append(out, Resource{Id: *e.RunId, SessionId: e.SessionId})
+	}
+	switch e.Type {
+	case "interaction.required":
+		var p struct {
+			InteractionId string `json:"interactionId"`
+		}
+		if json.Unmarshal(e.Payload, &p) == nil && p.InteractionId != "" {
+			out = append(out, Resource{Id: p.InteractionId, SessionId: e.SessionId})
+		}
+	case "file.changed":
+		var p struct {
+			FileId string `json:"fileId"`
+		}
+		if json.Unmarshal(e.Payload, &p) == nil && p.FileId != "" {
+			out = append(out, Resource{Id: p.FileId, SessionId: e.SessionId})
+		}
+	}
+	return out
+}
+
+// SSEScanner 增量解析 SSE 字节流,对每个完整事件的 data 回调一次。
+// 只用于旁路提取资源 ID,不影响原样转发;单个事件超过 maxEventBytes 时丢弃该事件。
+type SSEScanner struct {
+	buf    []byte
+	onData func(data []byte)
+}
+
+const maxEventBytes = 4 << 20
+
+// NewSSEScanner 创建解析器
+func NewSSEScanner(onData func(data []byte)) *SSEScanner {
+	return &SSEScanner{onData: onData}
+}
+
+// Write 输入一段原始字节
+func (s *SSEScanner) Write(p []byte) {
+	s.buf = append(s.buf, p...)
+	for {
+		idx, sepLen := eventBoundary(s.buf)
+		if idx < 0 {
+			break
+		}
+		s.emit(s.buf[:idx])
+		s.buf = s.buf[idx+sepLen:]
+	}
+	if len(s.buf) > maxEventBytes {
+		s.buf = s.buf[:0]
+	}
+}
+
+// eventBoundary 查找事件分隔(空行),兼容 \n\n 与 \r\n\r\n
+func eventBoundary(b []byte) (int, int) {
+	i := bytes.Index(b, []byte("\n\n"))
+	j := bytes.Index(b, []byte("\r\n\r\n"))
+	switch {
+	case i < 0 && j < 0:
+		return -1, 0
+	case j < 0 || (i >= 0 && i < j):
+		return i, 2
+	default:
+		return j, 4
+	}
+}
+
+func (s *SSEScanner) emit(block []byte) {
+	var data []byte
+	for _, line := range bytes.Split(block, []byte("\n")) {
+		line = bytes.TrimSuffix(line, []byte("\r"))
+		if bytes.HasPrefix(line, []byte("data:")) {
+			v := bytes.TrimPrefix(line, []byte("data:"))
+			v = bytes.TrimPrefix(v, []byte(" "))
+			if data != nil {
+				data = append(data, '\n')
+			}
+			data = append(data, v...)
+		}
+	}
+	if data != nil && s.onData != nil {
+		s.onData(data)
+	}
+}
+
+// ResourcesFromSSEData 解析一条 SSE data(事件 JSON)
+func ResourcesFromSSEData(data []byte) []Resource {
+	var e AgentEvent
+	if err := json.Unmarshal(data, &e); err != nil {
+		return nil
+	}
+	return ResourcesFromEvent(e)
+}
+
+// ResourcesFromResponse 从普通 JSON 响应中提取子资源 ID,sessionId 为路径中的会话
+func ResourcesFromResponse(action Action, sessionId string, body []byte) []Resource {
+	var out []Resource
+	switch action {
+	case ActionSendMessage:
+		var r RunDto
+		if json.Unmarshal(body, &r) == nil && r.Id != "" {
+			out = append(out, Resource{Id: r.Id, SessionId: sessionId})
+		}
+	case ActionUploadFiles, ActionListFiles:
+		var fs []FileDto
+		if json.Unmarshal(body, &fs) == nil {
+			for _, f := range fs {
+				if f.Id != "" {
+					out = append(out, Resource{Id: f.Id, SessionId: sessionId})
+				}
+			}
+		}
+	case ActionEvents:
+		var page EventsPage
+		if json.Unmarshal(body, &page) == nil {
+			for _, e := range page.Items {
+				out = append(out, ResourcesFromEvent(e)...)
+			}
+		}
+	case ActionSessionDetail:
+		var d SessionDetailDto
+		if json.Unmarshal(body, &d) == nil {
+			if d.ActiveRunId != nil && *d.ActiveRunId != "" {
+				out = append(out, Resource{Id: *d.ActiveRunId, SessionId: sessionId})
+			}
+			for _, it := range d.PendingInteractions {
+				if it.InteractionId != "" {
+					out = append(out, Resource{Id: it.InteractionId, SessionId: sessionId})
+				}
+			}
+		}
+	case ActionInteractions:
+		var items []InteractionRefDto
+		if json.Unmarshal(body, &items) == nil {
+			for _, it := range items {
+				if it.InteractionId != "" {
+					out = append(out, Resource{Id: it.InteractionId, SessionId: sessionId})
+				}
+			}
+		}
+	}
+	// 只保留与路径会话一致的资源,防止伪造的事件写入他人会话的索引
+	filtered := out[:0]
+	for _, r := range out {
+		if r.SessionId == sessionId {
+			filtered = append(filtered, r)
+		}
+	}
+	return filtered
+}

+ 82 - 0
service/utils/agentserver/sse_test.go

@@ -0,0 +1,82 @@
+package agentserver
+
+import (
+	"strings"
+	"testing"
+)
+
+const sid = "ses_01a"
+
+func TestSSEScannerChunked(t *testing.T) {
+	stream := "retry: 2000\n\n" +
+		": ping\n\n" +
+		`id: 1` + "\nevent: run.started\ndata: " + `{"seq":1,"type":"run.started","sessionId":"ses_01a","runId":"run_01r","payload":{"provider":"claude","model":"sonnet"}}` + "\n\n" +
+		`id: 2` + "\r\nevent: interaction.required\r\ndata: " + `{"seq":2,"type":"interaction.required","sessionId":"ses_01a","runId":"run_01r","payload":{"interactionId":"itx_01i","kind":"approval"}}` + "\r\n\r\n" +
+		`id: 3` + "\nevent: file.changed\ndata: " + `{"seq":3,"type":"file.changed","sessionId":"ses_01a","runId":"run_01r","payload":{"fileId":"file_01f","path":"outputs/a.docx"}}` + "\n\n" +
+		`id: 4` + "\nevent: message.delta\ndata: " + `{"seq":4,"type":"message.delta","sessionId":"ses_01a","runId":"run_01r","payload":{"text":"hi"}}` + "\n\n"
+
+	var got []string
+	s := NewSSEScanner(func(data []byte) {
+		for _, r := range ResourcesFromSSEData(data) {
+			got = append(got, r.Id+"@"+r.SessionId)
+		}
+	})
+	// 按 7 字节切块喂入,覆盖事件跨块的情况
+	for i := 0; i < len(stream); i += 7 {
+		end := i + 7
+		if end > len(stream) {
+			end = len(stream)
+		}
+		s.Write([]byte(stream[i:end]))
+	}
+	want := "run_01r@ses_01a,itx_01i@ses_01a,file_01f@ses_01a"
+	if strings.Join(got, ",") != want {
+		t.Fatalf("got %v, want %s", got, want)
+	}
+}
+
+func TestResourcesFromResponse(t *testing.T) {
+	cases := []struct {
+		action Action
+		body   string
+		want   string
+	}{
+		{ActionSendMessage, `{"id":"run_01r","sessionId":"ses_01a","status":"queued"}`, "run_01r"},
+		{ActionUploadFiles, `[{"id":"file_1","sessionId":"ses_01a"},{"id":"file_2","sessionId":"ses_01a"}]`, "file_1,file_2"},
+		{ActionListFiles, `[{"id":"file_3","sessionId":"ses_01a"}]`, "file_3"},
+		{ActionEvents, `{"items":[{"seq":1,"type":"run.started","sessionId":"ses_01a","runId":"run_9","payload":{}},` +
+			`{"seq":2,"type":"file.changed","sessionId":"ses_OTHER","runId":"run_9","payload":{"fileId":"file_x"}}],"lastSeq":2}`, "run_9"},
+		{ActionSessionDetail, `{"id":"ses_01a","activeRunId":"run_7","pendingInteractions":[{"interactionId":"itx_7"}]}`, "run_7,itx_7"},
+		{ActionSessionDetail, `{"id":"ses_01a","activeRunId":null,"pendingInteractions":[]}`, ""},
+		{ActionInteractions, `[{"interactionId":"itx_8","kind":"question"}]`, "itx_8"},
+		{ActionSendMessage, `not json`, ""},
+	}
+	for _, c := range cases {
+		var ids []string
+		for _, r := range ResourcesFromResponse(c.action, sid, []byte(c.body)) {
+			if r.SessionId != sid {
+				t.Errorf("action %v: 资源 %s 的会话为 %s", c.action, r.Id, r.SessionId)
+			}
+			ids = append(ids, r.Id)
+		}
+		if strings.Join(ids, ",") != c.want {
+			t.Errorf("action %v: got %v, want %s", c.action, ids, c.want)
+		}
+	}
+}
+
+func TestFilterSessionPatch(t *testing.T) {
+	b, title, err := filterSessionPatch([]byte(`{"title":"新标题","model":"opus","permissionMode":"auto","profileId":"prf_x","systemPromptAppend":"x"}`))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(b) != `{"model":"opus","title":"新标题"}` {
+		t.Fatalf("过滤结果 %s", b)
+	}
+	if title == nil || *title != "新标题" {
+		t.Fatalf("title %v", title)
+	}
+	if _, _, err := filterSessionPatch([]byte(`[1]`)); err == nil {
+		t.Fatal("非对象应报错")
+	}
+}

+ 70 - 0
service/utils/agentserver/types.go

@@ -0,0 +1,70 @@
+// Package agentserver 新 AI 助手对接 AgentServer 的客户端与代理。
+// 接口与事件结构以 AgentServer 仓库 packages/shared/src/api.ts、events.ts 为准,这里只声明用到的字段。
+package agentserver
+
+import "encoding/json"
+
+// SessionDto AgentServer 会话(POST/PATCH /v1/sessions 的返回)
+type SessionDto struct {
+	Id        string  `json:"id"`
+	Title     string  `json:"title"`
+	Model     string  `json:"model"`
+	ProfileId *string `json:"profileId"`
+	CreatedAt string  `json:"createdAt"`
+	UpdatedAt string  `json:"updatedAt"`
+}
+
+// SessionDetailDto GET /v1/sessions/:id 的返回(只取索引子资源需要的字段)
+type SessionDetailDto struct {
+	Id                  string              `json:"id"`
+	ActiveRunId         *string             `json:"activeRunId"`
+	PendingInteractions []InteractionRefDto `json:"pendingInteractions"`
+}
+
+// RunDto Run 状态(POST /messages、GET /runs/:id 的返回)
+type RunDto struct {
+	Id        string `json:"id"`
+	SessionId string `json:"sessionId"`
+}
+
+// FileDto 工作区文件(POST/GET /sessions/:id/files 的返回元素)
+type FileDto struct {
+	Id        string `json:"id"`
+	SessionId string `json:"sessionId"`
+	Path      string `json:"path"`
+}
+
+// InteractionRefDto 交互请求(只取 ID)
+type InteractionRefDto struct {
+	InteractionId string `json:"interactionId"`
+}
+
+// AgentEvent 事件信封(SSE data 与 /events items 的元素)
+type AgentEvent struct {
+	Seq       int64           `json:"seq"`
+	Type      string          `json:"type"`
+	SessionId string          `json:"sessionId"`
+	RunId     *string         `json:"runId"`
+	Payload   json.RawMessage `json:"payload"`
+}
+
+// EventsPage GET /v1/sessions/:id/events 的返回
+type EventsPage struct {
+	Items []AgentEvent `json:"items"`
+}
+
+// ErrorBody AgentServer 的错误格式:{ "error": { "code", "message" } }
+type ErrorBody struct {
+	Error struct {
+		Code    string `json:"code"`
+		Message string `json:"message"`
+	} `json:"error"`
+}
+
+// 子资源 ID 前缀(AgentServer 使用带前缀的 ULID)
+const (
+	PrefixSession     = "ses_"
+	PrefixRun         = "run_"
+	PrefixInteraction = "itx_"
+	PrefixFile        = "file_"
+)

+ 187 - 0
service/utils/pii/pii_test.go

@@ -0,0 +1,187 @@
+package pii
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+)
+
+// ---------- 掩码函数单元测试 ----------
+
+func TestMaskName(t *testing.T) {
+	cases := map[string]string{
+		"":     "",
+		"张三":   "张***",
+		"John": "J***",
+		"李":    "李***",
+	}
+	for in, want := range cases {
+		if got := maskName(in); got != want {
+			t.Errorf("maskName(%q)=%q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestMaskEmail(t *testing.T) {
+	if got := maskEmail("john@corp.com"); got != "j***@corp.com" {
+		t.Errorf("maskEmail got %q", got)
+	}
+	// 非法邮箱退化为完全脱敏
+	if got := maskEmail("notanemail"); got != maskFull() {
+		t.Errorf("maskEmail(invalid) got %q", got)
+	}
+	if got := maskEmail(""); got != "" {
+		t.Errorf("maskEmail(empty) got %q", got)
+	}
+}
+
+func TestMaskAccount(t *testing.T) {
+	if got := maskAccount("abcdefgh"); got != "ab***gh" {
+		t.Errorf("maskAccount got %q", got)
+	}
+	// 过短完全脱敏
+	if got := maskAccount("abc"); got != maskFull() {
+		t.Errorf("maskAccount(short) got %q", got)
+	}
+}
+
+func TestMaskFullFixedLength(t *testing.T) {
+	if got := maskFull(); got != strings.Repeat(MaskChar, fullMaskLen) {
+		t.Errorf("maskFull got %q", got)
+	}
+}
+
+// ---------- 优化 B:流式 token 脱敏测试 ----------
+
+func TestMaskJSONBytes_FlatFields(t *testing.T) {
+	in := []byte(`{
+		"sn": "SN123",
+		"userName": "Alice",
+		"contactNumber": "13812345678",
+		"email": "a@b.com",
+		"longitude": 121.5,
+		"latitude": 31.2,
+		"address": "123 Main St",
+		"city": "Shanghai",
+		"province": "SH",
+		"country": "CN",
+		"iccid": "8986012345678901234",
+		"imei": "356938035643809",
+		"account": "installer01"
+	}`)
+	out, err := MaskJSONBytes(in)
+	if err != nil {
+		t.Fatalf("MaskJSONBytes err: %v", err)
+	}
+	var m map[string]any
+	if err := json.Unmarshal(out, &m); err != nil {
+		t.Fatalf("output not valid JSON: %v\n%s", err, out)
+	}
+
+	// 保留字段
+	if m["sn"] != "SN123" {
+		t.Errorf("sn should be preserved, got %v", m["sn"])
+	}
+	if m["iccid"] != "8986012345678901234" {
+		t.Errorf("iccid should be preserved, got %v", m["iccid"])
+	}
+	if m["city"] != "Shanghai" || m["province"] != "SH" || m["country"] != "CN" {
+		t.Errorf("admin region should be preserved: city=%v province=%v country=%v", m["city"], m["province"], m["country"])
+	}
+
+	// 完全脱敏字段
+	full := maskFull()
+	for _, k := range []string{"contactNumber", "longitude", "latitude", "address", "imei"} {
+		if m[k] != full {
+			t.Errorf("%s should be fully masked, got %v", k, m[k])
+		}
+	}
+	// 姓名
+	if m["userName"] != "A***" {
+		t.Errorf("userName masked wrong: %v", m["userName"])
+	}
+	// 邮箱
+	if m["email"] != "a***@b.com" {
+		t.Errorf("email masked wrong: %v", m["email"])
+	}
+	// 账号
+	if m["account"] != "in***01" {
+		t.Errorf("account masked wrong: %v", m["account"])
+	}
+}
+
+func TestMaskJSONBytes_Nested(t *testing.T) {
+	in := []byte(`{
+		"data": [
+			{"userName": "Bob", "sn": "S1", "nested": {"phone": "111", "keep": "ok"}},
+			{"userName": "Carol", "sn": "S2"}
+		],
+		"total": 2
+	}`)
+	out, err := MaskJSONBytes(in)
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	var m map[string]any
+	if err := json.Unmarshal(out, &m); err != nil {
+		t.Fatalf("invalid JSON: %v\n%s", err, out)
+	}
+	arr := m["data"].([]any)
+	first := arr[0].(map[string]any)
+	if first["userName"] != "B***" {
+		t.Errorf("nested list userName wrong: %v", first["userName"])
+	}
+	if first["sn"] != "S1" {
+		t.Errorf("sn should be preserved in list: %v", first["sn"])
+	}
+	nested := first["nested"].(map[string]any)
+	if nested["phone"] != maskFull() {
+		t.Errorf("deeply nested phone should be masked: %v", nested["phone"])
+	}
+	if nested["keep"] != "ok" {
+		t.Errorf("non-sensitive nested field should be preserved: %v", nested["keep"])
+	}
+	if m["total"].(float64) != 2 {
+		t.Errorf("total wrong: %v", m["total"])
+	}
+}
+
+func TestMaskJSONBytes_SensitiveSubtreeRedacted(t *testing.T) {
+	// 敏感 key 对应对象/数组时,整个子树被脱敏
+	in := []byte(`{"address": {"street": "secret", "no": 42}, "list_phone": ["a","b"]}`)
+	// 注意 list_phone 不在规则表,仅测 address 子树
+	out, err := MaskJSONBytes(in)
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	var m map[string]any
+	if err := json.Unmarshal(out, &m); err != nil {
+		t.Fatalf("invalid JSON: %v\n%s", err, out)
+	}
+	addr := m["address"].(map[string]any)
+	if addr["street"] != maskFull() {
+		t.Errorf("address.street should be masked: %v", addr["street"])
+	}
+	// number 子字段被替换为掩码字符串
+	if addr["no"] != maskFull() {
+		t.Errorf("address.no should be masked: %v", addr["no"])
+	}
+}
+
+func TestMaskJSONBytes_InvalidJSONFailsClosed(t *testing.T) {
+	_, err := MaskJSONBytes([]byte(`{not valid json`))
+	if err == nil {
+		t.Fatal("expected error for invalid JSON (fail-closed)")
+	}
+}
+
+func TestMaskJSONBytes_NullPreserved(t *testing.T) {
+	in := []byte(`{"userName": null, "phone": null}`)
+	out, err := MaskJSONBytes(in)
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	if !strings.Contains(string(out), `"userName":null`) {
+		t.Errorf("null should be preserved: %s", out)
+	}
+}

+ 141 - 0
service/utils/pii/rules.go

@@ -0,0 +1,141 @@
+package pii
+
+import (
+	"strings"
+)
+
+// MaskChar 是脱敏时使用的掩码字符,默认为 '*'。
+// 抽为包级变量以便在需要时全局自定义(例如改为 '•' 或 '#')。
+var MaskChar = "*"
+
+// MaskKind 表示某个字段应采用的脱敏方式。
+type MaskKind int
+
+const (
+	// MaskFull 完全脱敏:整个值替换为固定长度的掩码串(不泄露长度)。
+	// 适用于手机号、完整地址串、经纬度、IMEI/IMSI/MAC/IP 等。
+	MaskFull MaskKind = iota
+	// MaskName 姓名脱敏:保留首字符,其余以掩码填充。
+	MaskName
+	// MaskEmail 邮箱脱敏:保留本地部分首字符与完整域名。
+	MaskEmail
+	// MaskAccount 账号脱敏:保留前 2 后 2,中间掩码。
+	MaskAccount
+)
+
+// piiFieldRules 是敏感字段名到脱敏方式的映射。
+// 匹配时统一将 JSON key 转为小写后查表(大小写不敏感精确匹配)。
+//
+// 明确不脱敏(保留原值)的字段(不出现在此表中即视为保留):
+//   - iccid / giccid:SIM 卡业务主键,作为查询标识符保留
+//   - city / province / country:行政区字段,地址保留到城市级
+//   - sn / giccid 等设备/业务标识
+var piiFieldRules = map[string]MaskKind{
+	// 手机号 —— 完全脱敏
+	"contactnumber":           MaskFull,
+	"financiercontractnumber": MaskFull,
+	"msisdn":                  MaskFull,
+	"phone":                   MaskFull,
+	"mobile":                  MaskFull,
+	"tel":                     MaskFull,
+
+	// 姓名 —— 保留首字符
+	"username":      MaskName,
+	"firstname":     MaskName,
+	"lastname":      MaskName,
+	"accountname":   MaskName,
+	"financiername": MaskName,
+	"display_name":  MaskName,
+	"displayname":   MaskName,
+
+	// 邮箱
+	"email": MaskEmail,
+
+	// 完整地址串 —— 完全脱敏(行政区字段 city/province/country 不在表中,保留)
+	"address":           MaskFull,
+	"deviceaddress":     MaskFull,
+	"addresssecondlast": MaskFull,
+	"gpsaddress":        MaskFull,
+
+	// 经纬度 —— 完全脱敏
+	"longitude": MaskFull,
+	"latitude":  MaskFull,
+	"lng":       MaskFull,
+	"lat":       MaskFull,
+
+	// 账号 —— 前2后2
+	"account":          MaskAccount,
+	"installeraccount": MaskAccount,
+
+	// 设备/网络标识 —— 完全脱敏(iccid/giccid 保留,不在表中)
+	"imei":      MaskFull,
+	"imsi":      MaskFull,
+	"mac":       MaskFull,
+	"deviceip":  MaskFull,
+	"gatewayip": MaskFull,
+}
+
+// fullMask 固定长度掩码串,用于 MaskFull,避免通过长度泄露原值信息。
+const fullMaskLen = 6
+
+// maskKindFor 返回字段名对应的脱敏方式;ok 为 false 表示该字段无需脱敏。
+func maskKindFor(key string) (MaskKind, bool) {
+	k, ok := piiFieldRules[strings.ToLower(key)]
+	return k, ok
+}
+
+// applyMaskString 对字符串值按指定方式脱敏。
+func applyMaskString(kind MaskKind, s string) string {
+	switch kind {
+	case MaskName:
+		return maskName(s)
+	case MaskEmail:
+		return maskEmail(s)
+	case MaskAccount:
+		return maskAccount(s)
+	case MaskFull:
+		fallthrough
+	default:
+		return maskFull()
+	}
+}
+
+// maskFull 返回固定长度的掩码串。
+func maskFull() string {
+	return strings.Repeat(MaskChar, fullMaskLen)
+}
+
+// maskName 保留首字符(按 rune),其余固定 3 个掩码字符。
+// 空字符串返回空。
+func maskName(s string) string {
+	if s == "" {
+		return s
+	}
+	r := []rune(s)
+	return string(r[0]) + strings.Repeat(MaskChar, 3)
+}
+
+// maskEmail 保留本地部分首字符 + 掩码 + 完整域名。
+// 非法邮箱(无 @)退化为完全脱敏。
+func maskEmail(s string) string {
+	if s == "" {
+		return s
+	}
+	at := strings.LastIndex(s, "@")
+	if at <= 0 || at == len(s)-1 {
+		return maskFull()
+	}
+	local := s[:at]
+	domain := s[at:] // 含 @
+	lr := []rune(local)
+	return string(lr[0]) + strings.Repeat(MaskChar, 3) + domain
+}
+
+// maskAccount 保留前 2 后 2,中间固定掩码;长度不足 5 时完全脱敏。
+func maskAccount(s string) string {
+	r := []rune(s)
+	if len(r) < 5 {
+		return maskFull()
+	}
+	return string(r[:2]) + strings.Repeat(MaskChar, 3) + string(r[len(r)-2:])
+}

+ 251 - 0
service/utils/pii/stream.go

@@ -0,0 +1,251 @@
+// Package pii 提供字段级 PII 脱敏(规则拷贝自旧 MCP 的 helper/pii_*.go,两者独立维护)。
+package pii
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"strconv"
+)
+
+// MaskJSONBytes 对已序列化的 JSON 字节流做流式脱敏,返回脱敏后的 JSON 字节。
+//
+// 实现要点(优化 B):
+//   - 使用 json.Decoder 的 token 流式解析,不构建完整的 interface{} 对象树,
+//     内存占用为常数级(栈深度 = JSON 嵌套深度)。
+//   - 单次遍历:读到 object 的 key 命中敏感规则时,对其对应的 value 做脱敏;
+//     其余 token 原样透传。
+//   - 若敏感 key 的 value 是对象或数组,则整个子树被完全脱敏(fail-safe)。
+//   - 解析失败时返回 error(fail-closed,由上层拒绝输出,不泄露原始数据)。
+func MaskJSONBytes(in []byte) ([]byte, error) {
+	dec := json.NewDecoder(bytes.NewReader(in))
+	dec.UseNumber()
+
+	var out bytes.Buffer
+	w := &jsonWriter{buf: &out}
+
+	if err := maskValue(dec, w, nil); err != nil {
+		return nil, fmt.Errorf("PII 脱敏失败: %w", err)
+	}
+
+	// 确认没有多余内容(例如尾部垃圾),保证输入是单个合法 JSON 值。
+	if dec.More() {
+		return nil, fmt.Errorf("PII 脱敏失败: JSON 存在多余的尾部内容")
+	}
+
+	return out.Bytes(), nil
+}
+
+// maskValue 读取下一个 JSON 值并写入 w。
+// forceMask 非 nil 时,表示当前值处于某个敏感 key 之下,整个值(含子树)需完全脱敏。
+func maskValue(dec *json.Decoder, w *jsonWriter, forceMask *MaskKind) error {
+	tok, err := dec.Token()
+	if err != nil {
+		return err
+	}
+
+	switch t := tok.(type) {
+	case json.Delim:
+		switch t {
+		case '{':
+			return maskObject(dec, w, forceMask)
+		case '[':
+			return maskArray(dec, w, forceMask)
+		default:
+			return fmt.Errorf("非预期的分隔符: %v", t)
+		}
+	default:
+		// 标量值(string / json.Number / bool / nil)
+		return writeScalar(w, tok, forceMask)
+	}
+}
+
+// maskObject 处理一个 JSON 对象。forceMask 非 nil 时整个对象子树被脱敏。
+func maskObject(dec *json.Decoder, w *jsonWriter, forceMask *MaskKind) error {
+	w.openObject()
+	for dec.More() {
+		// 读 key
+		keyTok, err := dec.Token()
+		if err != nil {
+			return err
+		}
+		key, ok := keyTok.(string)
+		if !ok {
+			return fmt.Errorf("对象 key 非字符串: %T", keyTok)
+		}
+		w.writeKey(key)
+
+		// 决定该字段 value 的脱敏方式:
+		//   - 若已处于强制脱敏子树中,继续沿用父级 forceMask。
+		//   - 否则查规则表。
+		var childForce *MaskKind
+		if forceMask != nil {
+			childForce = forceMask
+		} else if kind, hit := maskKindFor(key); hit {
+			k := kind
+			childForce = &k
+		}
+
+		if err := maskValue(dec, w, childForce); err != nil {
+			return err
+		}
+	}
+	// 消费结束的 '}'
+	if _, err := dec.Token(); err != nil {
+		return err
+	}
+	w.closeObject()
+	return nil
+}
+
+// maskArray 处理一个 JSON 数组。forceMask 非 nil 时数组内每个元素都被脱敏。
+func maskArray(dec *json.Decoder, w *jsonWriter, forceMask *MaskKind) error {
+	w.openArray()
+	for dec.More() {
+		if err := maskValue(dec, w, forceMask); err != nil {
+			return err
+		}
+	}
+	// 消费结束的 ']'
+	if _, err := dec.Token(); err != nil {
+		return err
+	}
+	w.closeArray()
+	return nil
+}
+
+// writeScalar 写入一个标量值,必要时脱敏。
+func writeScalar(w *jsonWriter, tok json.Token, forceMask *MaskKind) error {
+	if forceMask != nil {
+		// 处于敏感字段之下:
+		//   - 字符串按具体 MaskKind 处理
+		//   - 其余标量(数字/bool,例如经纬度以 number 存储)统一替换为掩码字符串
+		if s, ok := tok.(string); ok {
+			return w.writeValueRaw(quote(applyMaskString(*forceMask, s)))
+		}
+		if tok == nil {
+			// null 保持 null(无信息泄露)
+			return w.writeValueRaw("null")
+		}
+		return w.writeValueRaw(quote(maskFull()))
+	}
+
+	// 非敏感字段:原样写出
+	switch v := tok.(type) {
+	case string:
+		return w.writeValueRaw(quote(v))
+	case json.Number:
+		return w.writeValueRaw(v.String())
+	case bool:
+		return w.writeValueRaw(strconv.FormatBool(v))
+	case nil:
+		return w.writeValueRaw("null")
+	default:
+		return fmt.Errorf("非预期的标量类型: %T", tok)
+	}
+}
+
+// quote 将字符串按 JSON 规则转义并加引号。
+func quote(s string) string {
+	b, _ := json.Marshal(s)
+	return string(b)
+}
+
+// jsonWriter 负责按结构写出 JSON,管理逗号与 key/value 状态。
+type jsonWriter struct {
+	buf   *bytes.Buffer
+	stack []*frame
+}
+
+type frame struct {
+	isObject   bool
+	needComma  bool // 是否需要在下一个元素/键前加逗号
+	expectKey  bool // 对象中:下一个 writeKey 之后期待 value
+	pendingKey bool // 已写入 key,等待 value(value 前不加逗号)
+}
+
+func (w *jsonWriter) top() *frame {
+	if len(w.stack) == 0 {
+		return nil
+	}
+	return w.stack[len(w.stack)-1]
+}
+
+// beforeValue 在写入一个值(标量或容器起始)之前处理逗号。
+func (w *jsonWriter) beforeValue() {
+	f := w.top()
+	if f == nil {
+		return
+	}
+	if f.isObject {
+		// 对象中值紧跟 key,key 已处理逗号,这里不加
+		if f.pendingKey {
+			f.pendingKey = false
+			return
+		}
+	}
+	if f.needComma {
+		w.buf.WriteByte(',')
+	}
+	f.needComma = true
+}
+
+func (w *jsonWriter) openObject() {
+	w.beforeValue()
+	w.buf.WriteByte('{')
+	w.stack = append(w.stack, &frame{isObject: true})
+}
+
+func (w *jsonWriter) closeObject() {
+	w.buf.WriteByte('}')
+	w.stack = w.stack[:len(w.stack)-1]
+}
+
+func (w *jsonWriter) openArray() {
+	w.beforeValue()
+	w.buf.WriteByte('[')
+	w.stack = append(w.stack, &frame{isObject: false})
+}
+
+func (w *jsonWriter) closeArray() {
+	w.buf.WriteByte(']')
+	w.stack = w.stack[:len(w.stack)-1]
+}
+
+func (w *jsonWriter) writeKey(key string) {
+	f := w.top()
+	if f != nil && f.needComma {
+		w.buf.WriteByte(',')
+	}
+	w.buf.WriteString(quote(key))
+	w.buf.WriteByte(':')
+	if f != nil {
+		f.needComma = true
+		f.pendingKey = true
+	}
+}
+
+func (w *jsonWriter) writeValueRaw(raw string) error {
+	w.beforeValue()
+	_, err := w.buf.WriteString(raw)
+	return err
+}
+
+// Marshal 将任意数据序列化为紧凑 JSON 并完成字段级脱敏。
+// 脱敏失败时返回错误(fail-closed),调用方不得输出原始数据。
+func Marshal(v any) ([]byte, error) {
+	raw, err := json.Marshal(v)
+	if err != nil {
+		return nil, fmt.Errorf("JSON 序列化失败: %w", err)
+	}
+	return MaskJSONBytes(raw)
+}
+
+// MaskField 按字段名对单个字符串值脱敏(表格/CSV 单元格使用);字段不在规则表中时原样返回。
+func MaskField(key, value string) string {
+	kind, ok := maskKindFor(key)
+	if !ok || value == "" {
+		return value
+	}
+	return applyMaskString(kind, value)
+}

+ 11 - 0
src/router/record.ts

@@ -13,6 +13,17 @@ export const routeRecordRaw: MergeMenuMixedOptions[] = [
     },
     component: 'dashboard/index',
   },
+  {
+    path: 'ai-assistant',
+    name: 'aiAssistant',
+    icon: 'i-ph:sparkle',
+    label: 'AI 助手',
+    meta: {
+      componentName: 'AiAssistant',
+      showTab: true,
+    },
+    component: 'ai-assistant/index',
+  },
   {
     path: 'site-info-manage',
     name: 'siteInfoManage',

+ 55 - 0
src/service/agent/index.ts

@@ -0,0 +1,55 @@
+// API List(新 AI 助手,见 docs/ai-assistant-agentserver/technical-design.md §3)
+// /agent/config         - GET    是否开放
+// /agent/sessions       - GET    本人的会话列表
+// /agent/sessions       - POST   创建会话(按角色选择 Profile)
+// /agent/sessions/:id   - PATCH  改标题
+// /agent/sessions/:id   - DELETE 删除会话
+// /agent/v1/*           - AgentServer 代理,由 @agent-server/client 直接调用
+
+import http from '@/service/serverReq'
+
+export interface AgentConfig {
+  enabled: boolean
+}
+
+export interface AgentSessionItem {
+  sessionId: string
+  title: string
+  profileId: string
+  createdAt: number // Unix ms
+  lastActive: number // Unix ms
+}
+
+const API = {
+  config: '/agent/config',
+  sessions: '/agent/sessions',
+  session: (id: string) => `/agent/sessions/${id}`,
+}
+
+// GET 是否开放新 AI 助手
+export const getAgentConfig = async (): Promise<AgentConfig> => {
+  return await http.get<AgentConfig>(API.config)
+}
+
+// GET 本人的会话列表(按最近活跃倒序)
+export const getAgentSessions = async (): Promise<AgentSessionItem[]> => {
+  return (await http.get<AgentSessionItem[]>(API.sessions)) ?? []
+}
+
+// POST 创建会话
+export const createAgentSession = async (): Promise<Pick<AgentSessionItem, 'sessionId' | 'title' | 'profileId'>> => {
+  return await http.post(API.sessions)
+}
+
+// PATCH 改标题
+export const renameAgentSession = async (sessionId: string, title: string) => {
+  return await http.request(API.session(sessionId), 'PATCH', { data: { title } })
+}
+
+// DELETE 删除会话
+export const deleteAgentSession = async (sessionId: string) => {
+  return await http.delete(API.session(sessionId))
+}
+
+// AgentServer 代理的基础地址(SDK 会在其后拼接 /v1/...)
+export const AGENT_PROXY_BASE = `${import.meta.env.BASE_URL || '/'}api/agent`

+ 176 - 0
src/views/ai-assistant/components/ChatHeader.vue

@@ -0,0 +1,176 @@
+<script setup lang="ts">
+import type { AgentClient } from '@agent-server/client'
+import type { ProviderStatusDto, SessionDetailDto } from '@agent-server/shared'
+import { NButton, NDropdown, NInput, NSelect, useDialog, useMessage } from 'naive-ui'
+import { computed, ref, watch } from 'vue'
+
+defineOptions({
+  name: 'AiAssistantChatHeader',
+})
+
+const props = defineProps<{
+  client: AgentClient
+  sessionId: string | null
+  title: string
+  providers: ProviderStatusDto[]
+  showSessionsButton: boolean
+  showFilesButton: boolean
+}>()
+
+const emit = defineEmits<{
+  rename: [title: string]
+  remove: []
+  openSessions: []
+  openFiles: []
+}>()
+
+const dialog = useDialog()
+const message = useMessage()
+
+const detail = ref<SessionDetailDto | null>(null)
+const modelSaving = ref(false)
+const editing = ref(false)
+const editingTitle = ref('')
+
+async function loadDetail() {
+  const id = props.sessionId
+  detail.value = null
+  if (!id) return
+  try {
+    const d = await props.client.sessions.get(id)
+    if (props.sessionId === id) detail.value = d
+  }
+  catch {
+    // 会话详情只用于显示模型,失败时不显示模型选择
+  }
+}
+watch(() => props.sessionId, () => void loadDetail(), { immediate: true })
+
+const provider = computed(() => {
+  const id = detail.value?.provider
+  return props.providers.find(p => p.id === id) ?? null
+})
+
+const modelOptions = computed(() => {
+  const p = provider.value
+  const current = detail.value?.model
+  if (!p) return current ? [{ label: current, value: current }] : []
+  const models = p.models.includes(current ?? '') || !current ? p.models : [current, ...p.models]
+  return models.map(m => ({ label: p.modelLabels?.[m] ?? m, value: m }))
+})
+
+async function changeModel(model: string) {
+  const id = props.sessionId
+  if (!id || !detail.value || model === detail.value.model) return
+  modelSaving.value = true
+  try {
+    await props.client.sessions.update(id, { model })
+    detail.value = { ...detail.value, model }
+    message.success('模型已切换,下一条消息生效')
+  }
+  catch (err) {
+    message.error(`切换模型失败:${(err as Error).message}`)
+  }
+  finally {
+    modelSaving.value = false
+  }
+}
+
+function startRename() {
+  if (!props.sessionId) return
+  editingTitle.value = props.title
+  editing.value = true
+}
+
+function commitRename() {
+  editing.value = false
+  const t = editingTitle.value.trim()
+  if (t && t !== props.title) emit('rename', t)
+}
+
+const moreOptions = computed(() => [
+  { label: '复制会话链接', key: 'copy', disabled: !props.sessionId },
+  { label: '删除会话', key: 'remove', disabled: !props.sessionId },
+])
+
+async function onMore(key: string) {
+  if (key === 'copy') {
+    try {
+      await navigator.clipboard.writeText(window.location.href)
+      message.success('已复制(只有你自己能打开)')
+    }
+    catch {
+      message.error('复制失败')
+    }
+  }
+  else if (key === 'remove') {
+    dialog.warning({
+      title: '删除会话',
+      content: '确定删除当前会话吗?会话中的消息和文件将一并删除,无法恢复。',
+      positiveText: '删除',
+      negativeText: '取消',
+      onPositiveClick: () => emit('remove'),
+    })
+  }
+}
+</script>
+
+<template>
+  <div class="px-3 py-2 border-b border-neutral-500/15 flex gap-2 items-center">
+    <NButton v-if="showSessionsButton" quaternary size="small" @click="emit('openSessions')">
+      <template #icon>
+        <span class="i-ph:list" />
+      </template>
+      会话
+    </NButton>
+
+    <div class="flex-1 min-w-0">
+      <NInput
+        v-if="editing"
+        v-model:value="editingTitle"
+        size="small"
+        maxlength="60"
+        autofocus
+        @blur="commitRename"
+        @keydown.enter.prevent="commitRename"
+        @keydown.esc="editing = false"
+      />
+      <div
+        v-else
+        class="group text-base font-medium flex gap-1 truncate items-center"
+        :class="{ 'cursor-pointer': !!sessionId }"
+        :title="sessionId ? '点击重命名' : ''"
+        @click="startRename"
+      >
+        <span class="truncate">{{ sessionId ? (title || '新对话') : '新对话' }}</span>
+        <span v-if="sessionId" class="i-ph:pencil-simple text-sm opacity-0 shrink-0 group-hover:opacity-60" />
+      </div>
+    </div>
+
+    <NSelect
+      v-if="sessionId && modelOptions.length"
+      class="w-56"
+      size="small"
+      :value="detail?.model"
+      :options="modelOptions"
+      :loading="modelSaving"
+      :consistent-menu-width="false"
+      @update:value="changeModel"
+    />
+
+    <NButton v-if="showFilesButton" quaternary size="small" @click="emit('openFiles')">
+      <template #icon>
+        <span class="i-ph:files" />
+      </template>
+      文件
+    </NButton>
+
+    <NDropdown trigger="click" :options="moreOptions" @select="onMore">
+      <NButton quaternary circle size="small">
+        <template #icon>
+          <span class="i-ph:dots-three-outline" />
+        </template>
+      </NButton>
+    </NDropdown>
+  </div>
+</template>

+ 295 - 0
src/views/ai-assistant/components/FilePanel.vue

@@ -0,0 +1,295 @@
+<script setup lang="ts">
+import { MarkdownView, downloadFile, fileIcon, formatSize, basename } from '@agent-server/chat-ui'
+import type { AgentClient } from '@agent-server/client'
+import type { FileDto } from '@agent-server/shared'
+import { NButton, NEmpty, NScrollbar, NSpin } from 'naive-ui'
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
+
+defineOptions({
+  name: 'AiAssistantFilePanel',
+})
+
+const props = defineProps<{
+  client: AgentClient
+  uploads: FileDto[]
+  outputs: FileDto[]
+  loading: boolean
+  collapsible?: boolean
+}>()
+
+const emit = defineEmits<{
+  refresh: []
+  collapse: []
+}>()
+
+/** 当前预览的文件 ID(对话中点击文件卡片时由页面设置) */
+const selectedId = defineModel<string | null>('selected', { default: null })
+
+const MAX_TEXT_PREVIEW = 2 * 1048576
+const MAX_CSV_ROWS = 200
+const TEXT_EXT_RE = /\.(?:json|log|txt|tsv|ya?ml|xml)$/
+const BOM_RE = /^\uFEFF/
+
+const groups = computed(() => [
+  { label: '产出', items: props.outputs },
+  { label: '上传', items: props.uploads },
+].filter(g => g.items.length))
+
+const selected = computed(() => [...props.outputs, ...props.uploads].find(f => f.id === selectedId.value) ?? null)
+
+type PreviewKind = 'image' | 'pdf' | 'markdown' | 'csv' | 'text' | 'none'
+
+function kindOf(f: FileDto): PreviewKind {
+  const name = f.path.toLowerCase()
+  if (f.mime.startsWith('image/')) return 'image'
+  if (f.mime === 'application/pdf' || name.endsWith('.pdf')) return 'pdf'
+  if (name.endsWith('.md') || f.mime === 'text/markdown') return 'markdown'
+  if (name.endsWith('.csv') || f.mime === 'text/csv') return 'csv'
+  if (f.mime.startsWith('text/') || TEXT_EXT_RE.test(name) || f.mime === 'application/json') return 'text'
+  return 'none'
+}
+
+const previewKind = computed<PreviewKind>(() => (selected.value ? kindOf(selected.value) : 'none'))
+const previewLoading = ref(false)
+const previewError = ref<string | null>(null)
+const objectUrl = ref<string | null>(null)
+const text = ref('')
+const csv = ref<{ header: string[], rows: string[][], more: boolean } | null>(null)
+
+function releaseUrl() {
+  if (objectUrl.value) URL.revokeObjectURL(objectUrl.value)
+  objectUrl.value = null
+}
+
+let loadToken = 0
+watch(
+  () => selected.value?.id + (selected.value?.updatedAt ?? ''),
+  async () => {
+    const f = selected.value
+    const my = ++loadToken
+    releaseUrl()
+    text.value = ''
+    csv.value = null
+    previewError.value = null
+    if (!f) return
+    const kind = kindOf(f)
+    if (kind === 'none') return
+    if ((kind === 'markdown' || kind === 'csv' || kind === 'text') && f.size > MAX_TEXT_PREVIEW) {
+      previewError.value = `文件较大(${formatSize(f.size)}),请下载后查看`
+      return
+    }
+    previewLoading.value = true
+    try {
+      const blob = await props.client.fileBlob(f.id)
+      if (my !== loadToken) return
+      if (kind === 'image' || kind === 'pdf') {
+        // PDF 用 blob 指定类型,iframe 才能内嵌显示
+        objectUrl.value = URL.createObjectURL(kind === 'pdf' ? new Blob([blob], { type: 'application/pdf' }) : blob)
+      }
+      else {
+        const content = await blob.text()
+        if (my !== loadToken) return
+        if (kind === 'csv') csv.value = parseCsv(content, MAX_CSV_ROWS)
+        else text.value = content
+      }
+    }
+    catch (err) {
+      if (my === loadToken) previewError.value = `预览失败:${(err as Error).message}`
+    }
+    finally {
+      if (my === loadToken) previewLoading.value = false
+    }
+  },
+  { immediate: true },
+)
+
+onBeforeUnmount(releaseUrl)
+
+const downloading = ref<string | null>(null)
+async function download(f: FileDto) {
+  downloading.value = f.id
+  try {
+    await downloadFile(props.client, f.id, basename(f.path))
+  }
+  finally {
+    downloading.value = null
+  }
+}
+
+/** 简单 CSV 解析:支持双引号包裹与转义,只取前 limit 行数据 */
+function parseCsv(content: string, limit: number) {
+  const rows: string[][] = []
+  let row: string[] = []
+  let field = ''
+  let quoted = false
+  let more = false
+  const src = content.replace(BOM_RE, '')
+  for (let i = 0; i < src.length; i++) {
+    const c = src[i]
+    if (quoted) {
+      if (c === '"' && src[i + 1] === '"') {
+        field += '"'
+        i++
+      }
+      else if (c === '"') {
+        quoted = false
+      }
+      else {
+        field += c
+      }
+      continue
+    }
+    if (c === '"') {
+      quoted = true
+    }
+    else if (c === ',') {
+      row.push(field)
+      field = ''
+    }
+    else if (c === '\n' || c === '\r') {
+      if (c === '\r' && src[i + 1] === '\n') i++
+      row.push(field)
+      field = ''
+      rows.push(row)
+      row = []
+      if (rows.length > limit) {
+        more = i < src.length - 1
+        break
+      }
+    }
+    else {
+      field += c
+    }
+  }
+  if ((field || row.length) && rows.length <= limit) {
+    row.push(field)
+    rows.push(row)
+  }
+  const [header = [], ...data] = rows
+  return { header, rows: data.slice(0, limit), more: more || data.length > limit }
+}
+</script>
+
+<template>
+  <div class="flex flex-col h-full">
+    <div class="px-3 py-2 flex gap-2 items-center">
+      <span class="text-sm font-medium flex-1">本会话文件</span>
+      <NButton quaternary circle size="small" title="刷新" :loading="loading" @click="emit('refresh')">
+        <template #icon>
+          <span class="i-ph:arrow-clockwise" />
+        </template>
+      </NButton>
+      <NButton v-if="collapsible" quaternary circle size="small" title="收起文件栏" @click="emit('collapse')">
+        <template #icon>
+          <span class="i-ph:sidebar-simple -scale-x-100" />
+        </template>
+      </NButton>
+    </div>
+
+    <NScrollbar class="min-h-0" :class="selected ? 'max-h-[40%] shrink-0' : 'flex-1'">
+      <div class="px-2 pb-2">
+        <NEmpty v-if="!groups.length" class="py-8" size="small" description="还没有文件" />
+        <div v-for="g in groups" :key="g.label" class="mt-1">
+          <div class="text-xs text-neutral-500 px-2 py-1">
+            {{ g.label }}
+          </div>
+          <div
+            v-for="f in g.items"
+            :key="f.id"
+            class="group text-sm px-2 py-1.5 rounded-md flex gap-2 cursor-pointer items-center hover:bg-neutral-500/10"
+            :class="{ 'file-active': f.id === selectedId }"
+            :title="f.path"
+            @click="selectedId = f.id"
+          >
+            <span class="codicon shrink-0" :class="fileIcon(f.mime, f.path)" />
+            <span class="flex-1 min-w-0 truncate">{{ basename(f.path) }}</span>
+            <span class="text-xs text-neutral-500 shrink-0">{{ formatSize(f.size) }}</span>
+            <NButton
+              quaternary
+              circle
+              size="tiny"
+              title="下载"
+              :loading="downloading === f.id"
+              @click.stop="download(f)"
+            >
+              <template #icon>
+                <span class="i-ph:download-simple" />
+              </template>
+            </NButton>
+          </div>
+        </div>
+      </div>
+    </NScrollbar>
+
+    <div v-if="selected" class="border-t border-neutral-500/15 flex flex-1 flex-col min-h-0">
+      <div class="text-xs px-3 py-2 flex gap-2 items-center">
+        <span class="font-medium flex-1 min-w-0 truncate" :title="selected.path">{{ basename(selected.path) }}</span>
+        <NButton size="tiny" quaternary @click="download(selected)">
+          下载
+        </NButton>
+        <NButton size="tiny" quaternary circle title="关闭预览" @click="selectedId = null">
+          <template #icon>
+            <span class="i-ph:x" />
+          </template>
+        </NButton>
+      </div>
+      <div class="px-3 pb-3 flex-1 min-h-0 overflow-auto">
+        <NSpin v-if="previewLoading" class="py-6 w-full" size="small" />
+        <div v-else-if="previewError" class="text-xs text-neutral-500 py-6 text-center">
+          {{ previewError }}
+        </div>
+        <template v-else>
+          <img v-if="previewKind === 'image' && objectUrl" :src="objectUrl" class="rounded max-w-full" alt="">
+          <iframe v-else-if="previewKind === 'pdf' && objectUrl" :src="objectUrl" class="border-0 h-full min-h-100 w-full" title="PDF 预览" />
+          <MarkdownView v-else-if="previewKind === 'markdown'" class="text-sm" :source="text" />
+          <div v-else-if="previewKind === 'csv' && csv" class="text-xs">
+            <table class="csv-table">
+              <thead>
+                <tr>
+                  <th v-for="(h, i) in csv.header" :key="i">
+                    {{ h }}
+                  </th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="(r, ri) in csv.rows" :key="ri">
+                  <td v-for="(c, ci) in r" :key="ci">
+                    {{ c }}
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+            <div v-if="csv.more" class="text-neutral-500 py-2">
+              仅显示前 {{ MAX_CSV_ROWS }} 行,完整内容请下载
+            </div>
+          </div>
+          <pre v-else-if="previewKind === 'text'" class="text-xs whitespace-pre-wrap break-all">{{ text }}</pre>
+          <div v-else class="text-xs text-neutral-500 py-6 text-center">
+            该类型不支持预览,请下载后查看
+          </div>
+        </template>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.file-active {
+  color: var(--primary-color);
+  background: color-mix(in srgb, var(--primary-color) 12%, transparent);
+}
+.csv-table {
+  border-collapse: collapse;
+  white-space: nowrap;
+}
+.csv-table th,
+.csv-table td {
+  padding: 2px 6px;
+  border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
+  text-align: left;
+}
+.csv-table th {
+  font-weight: 600;
+  background: color-mix(in srgb, currentColor 6%, transparent);
+}
+</style>

+ 186 - 0
src/views/ai-assistant/components/SessionSidebar.vue

@@ -0,0 +1,186 @@
+<script setup lang="ts">
+import { NButton, NDropdown, NEmpty, NInput, NScrollbar, NSpin, useDialog, useMessage } from 'naive-ui'
+import { computed, ref } from 'vue'
+
+import type { AgentSessionItem } from '@/service/agent'
+
+defineOptions({
+  name: 'AiAssistantSessionSidebar',
+})
+
+const props = defineProps<{
+  sessions: AgentSessionItem[]
+  currentId: string | null
+  runningIds: Set<string>
+  loading: boolean
+  error: string | null
+  collapsible?: boolean
+}>()
+
+const emit = defineEmits<{
+  select: [sessionId: string]
+  new: []
+  collapse: []
+  retry: []
+  rename: [sessionId: string, title: string]
+  remove: [sessionId: string]
+}>()
+
+const dialog = useDialog()
+const message = useMessage()
+
+const keyword = ref('')
+const editingId = ref<string | null>(null)
+const editingTitle = ref('')
+
+const DAY = 24 * 3600 * 1000
+
+interface Group {
+  label: string
+  items: AgentSessionItem[]
+}
+
+const groups = computed<Group[]>(() => {
+  const kw = keyword.value.trim().toLowerCase()
+  const list = props.sessions.filter(s => !kw || (s.title || '新对话').toLowerCase().includes(kw))
+  const today = new Date()
+  today.setHours(0, 0, 0, 0)
+  const t0 = today.getTime()
+  const buckets: Group[] = [
+    { label: '今天', items: [] },
+    { label: '昨天', items: [] },
+    { label: '最近 7 天', items: [] },
+    { label: '更早', items: [] },
+  ]
+  for (const s of list) {
+    const t = s.lastActive || s.createdAt
+    if (t >= t0) buckets[0].items.push(s)
+    else if (t >= t0 - DAY) buckets[1].items.push(s)
+    else if (t >= t0 - 6 * DAY) buckets[2].items.push(s)
+    else buckets[3].items.push(s)
+  }
+  return buckets.filter(g => g.items.length)
+})
+
+const menuOptions = [
+  { label: '重命名', key: 'rename' },
+  { label: '删除', key: 'remove' },
+]
+
+function onMenu(key: string, s: AgentSessionItem) {
+  if (key === 'rename') {
+    editingId.value = s.sessionId
+    editingTitle.value = s.title
+  }
+  else if (key === 'remove') {
+    dialog.warning({
+      title: '删除会话',
+      content: `确定删除“${s.title || '新对话'}”吗?会话中的消息和文件将一并删除,无法恢复。`,
+      positiveText: '删除',
+      negativeText: '取消',
+      onPositiveClick: () => emit('remove', s.sessionId),
+    })
+  }
+}
+
+function commitRename() {
+  const id = editingId.value
+  const title = editingTitle.value.trim()
+  editingId.value = null
+  if (!id) return
+  if (!title) {
+    message.warning('标题不能为空')
+    return
+  }
+  const old = props.sessions.find(s => s.sessionId === id)?.title
+  if (title !== old) emit('rename', id, title)
+}
+</script>
+
+<template>
+  <div class="flex flex-col h-full">
+    <div class="p-3 pb-2 flex gap-2 items-center">
+      <NButton class="flex-1" secondary type="primary" @click="emit('new')">
+        <template #icon>
+          <span class="i-ph:plus" />
+        </template>
+        新对话
+      </NButton>
+      <NButton v-if="collapsible" quaternary circle title="收起会话栏" @click="emit('collapse')">
+        <template #icon>
+          <span class="i-ph:sidebar-simple" />
+        </template>
+      </NButton>
+    </div>
+    <div class="px-3 pb-2">
+      <NInput v-model:value="keyword" size="small" clearable placeholder="搜索会话">
+        <template #prefix>
+          <span class="i-ph:magnifying-glass" />
+        </template>
+      </NInput>
+    </div>
+
+    <NScrollbar class="flex-1 min-h-0">
+      <div class="px-2 pb-3">
+        <NSpin v-if="loading && !sessions.length" class="py-6 w-full" size="small" />
+        <div v-else-if="error && !sessions.length" class="text-xs text-neutral-500 py-6 text-center">
+          <div>{{ error }}</div>
+          <NButton class="mt-2" size="tiny" @click="emit('retry')">
+            重试
+          </NButton>
+        </div>
+        <NEmpty v-else-if="!groups.length" class="py-8" size="small" :description="keyword ? '没有匹配的会话' : '还没有会话'" />
+
+        <div v-for="g in groups" :key="g.label" class="mt-2">
+          <div class="text-xs text-neutral-500 px-2 py-1">
+            {{ g.label }}
+          </div>
+          <div
+            v-for="s in g.items"
+            :key="s.sessionId"
+            class="group text-sm px-2 py-1.5 rounded-md flex gap-2 cursor-pointer transition-colors items-center relative hover:bg-neutral-500/10"
+            :class="{ 'session-active': s.sessionId === currentId }"
+            @click="editingId !== s.sessionId && emit('select', s.sessionId)"
+          >
+            <span
+              v-if="runningIds.has(s.sessionId)"
+              class="rounded-full bg-green-500 shrink-0 size-1.5"
+              title="进行中"
+            />
+            <NInput
+              v-if="editingId === s.sessionId"
+              v-model:value="editingTitle"
+              autofocus
+              size="tiny"
+              maxlength="60"
+              @click.stop
+              @blur="commitRename"
+              @keydown.enter.prevent="commitRename"
+              @keydown.esc="editingId = null"
+            />
+            <span v-else class="flex-1 min-w-0 truncate" :title="s.title">{{ s.title || '新对话' }}</span>
+            <NDropdown
+              v-if="editingId !== s.sessionId"
+              trigger="click"
+              :options="menuOptions"
+              @select="(key: string) => onMenu(key, s)"
+            >
+              <span
+                class="i-ph:dots-three-bold opacity-0 shrink-0 transition-opacity group-hover:opacity-70 hover:opacity-100"
+                :class="{ 'opacity-60': s.sessionId === currentId }"
+                @click.stop
+              />
+            </NDropdown>
+          </div>
+        </div>
+      </div>
+    </NScrollbar>
+  </div>
+</template>
+
+<style scoped>
+.session-active {
+  color: var(--primary-color);
+  background: color-mix(in srgb, var(--primary-color) 12%, transparent);
+}
+</style>

+ 79 - 0
src/views/ai-assistant/components/WelcomePanel.vue

@@ -0,0 +1,79 @@
+<script setup lang="ts">
+defineOptions({
+  name: 'AiAssistantWelcomePanel',
+})
+
+defineProps<{
+  disabled?: boolean
+}>()
+
+const emit = defineEmits<{
+  send: [text: string]
+  fill: [text: string]
+}>()
+
+interface Example {
+  icon: string
+  text: string
+  /** 带占位符的示例只填入输入框,由用户补全后发送 */
+  needsInput: boolean
+}
+
+const examples: Example[] = [
+  { icon: 'i-ph:warning-circle', text: '查一下站点 【SN】 最近 7 天的告警和事件', needsInput: true },
+  { icon: 'i-ph:chart-bar', text: '统计昨天停机站点按国家分布', needsInput: false },
+  { icon: 'i-ph:chart-line', text: '分析站点 【SN】 本月的功率流,生成趋势图', needsInput: true },
+  { icon: 'i-ph:book-open', text: '告警码 【告警码】 是什么意思,通常怎么处理', needsInput: true },
+  { icon: 'i-ph:calendar', text: '对比生产服和测试服的 TOU 模版 【模版 ID】', needsInput: true },
+  { icon: 'i-ph:file-doc', text: '帮我把上传的 Excel 整理成一份 Word 报告', needsInput: false },
+]
+
+function pick(e: Example) {
+  if (e.needsInput) emit('fill', e.text)
+  else emit('send', e.text)
+}
+</script>
+
+<template>
+  <div class="mx-auto px-4 py-8 text-left max-w-180">
+    <div class="text-xl font-semibold mb-2 flex gap-2 items-center">
+      <span class="i-ph:sparkle text-2xl" />
+      AI 助手
+    </div>
+    <div class="text-sm text-neutral-500 leading-6 mb-6">
+      可以查询站点、告警、停机、功率流、日志、录波、TOU 模版、SIM 卡数据,解答产品问题,分析上传的文件并生成报告。
+    </div>
+
+    <div class="gap-3 grid grid-cols-1 lg:grid-cols-3 sm:grid-cols-2">
+      <button
+        v-for="e in examples"
+        :key="e.text"
+        type="button"
+        class="example-card text-sm leading-5 p-3 text-left rounded-lg flex flex-col gap-2 transition-colors items-start disabled:opacity-50 disabled:cursor-not-allowed"
+        :disabled="disabled"
+        @click="pick(e)"
+      >
+        <span class="text-lg" :class="e.icon" />
+        <span>{{ e.text }}</span>
+        <span v-if="e.needsInput" class="text-xs text-neutral-500">填写后发送</span>
+      </button>
+    </div>
+
+    <div class="text-xs text-neutral-500 mt-6">
+      数据来自 FWH 平台,结果仅供参考;涉及修改数据的操作会先请你确认。
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.example-card {
+  border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
+  background: transparent;
+  color: inherit;
+  cursor: pointer;
+}
+.example-card:not(:disabled):hover {
+  border-color: var(--primary-color);
+  background: color-mix(in srgb, var(--primary-color) 6%, transparent);
+}
+</style>

+ 27 - 0
src/views/ai-assistant/composables/useAgentClient.ts

@@ -0,0 +1,27 @@
+import { AgentClient } from '@agent-server/client'
+
+import { AGENT_PROXY_BASE } from '@/service/agent'
+import { refreshTokenRaw } from '@/service/auth'
+import { useUserStore } from '@/stores/user'
+
+let client: AgentClient | null = null
+
+// 令牌即 ToolBox 登录令牌;请求经 ToolBox 后端代理转发到 AgentServer
+async function getToken({ refresh }: { refresh: boolean }): Promise<string> {
+  const userStore = useUserStore()
+  if (refresh) {
+    try {
+      const token = await refreshTokenRaw()
+      if (token) userStore.setToken(token)
+    }
+    catch {
+      // 刷新失败时沿用当前令牌,由 ToolBox 统一的登录流程处理
+    }
+  }
+  return userStore.token ?? ''
+}
+
+export function useAgentClient(): AgentClient {
+  client ??= new AgentClient({ baseUrl: AGENT_PROXY_BASE, getToken })
+  return client
+}

+ 203 - 0
src/views/ai-assistant/composables/useAgentSessions.ts

@@ -0,0 +1,203 @@
+import { useChatSession, type ChatSession } from '@agent-server/chat-ui'
+import type { FileDto } from '@agent-server/shared'
+import { computed, reactive, ref, shallowRef, watch } from 'vue'
+
+import {
+  createAgentSession,
+  deleteAgentSession,
+  getAgentSessions,
+  renameAgentSession,
+  type AgentSessionItem,
+} from '@/service/agent'
+
+import { useAgentClient } from './useAgentClient'
+
+const TITLE_LENGTH = 30
+const WHITESPACE_RE = /\s+/g
+const NOT_FOUND_RE = /404|not.?found|不存在/i
+
+// 当前会话写在地址的 ?session= 中。用 history.replaceState 直接改地址而不经过 vue-router:
+// 布局中 RouterView 的 key 和标签页的 key 都包含查询参数,经路由修改会导致页面重建、产生新标签
+function readSessionFromUrl(): string | null {
+  return new URLSearchParams(window.location.search).get('session')
+}
+
+function writeSessionToUrl(sessionId: string | null) {
+  const url = new URL(window.location.href)
+  if (sessionId) url.searchParams.set('session', sessionId)
+  else url.searchParams.delete('session')
+  window.history.replaceState(window.history.state, '', url.toString())
+}
+
+function makeTitle(text: string) {
+  const t = text.replace(WHITESPACE_RE, ' ').trim()
+  return [...t].length > TITLE_LENGTH ? `${[...t].slice(0, TITLE_LENGTH).join('')}...` : t
+}
+
+export function useAgentSessions() {
+  const client = useAgentClient()
+
+  const sessions = ref<AgentSessionItem[]>([])
+  const listLoading = ref(false)
+  const listError = ref<string | null>(null)
+
+  /** 当前打开的会话;为空表示新对话(欢迎页) */
+  const currentId = ref<string | null>(null)
+  const chat = shallowRef<ChatSession | null>(null)
+  /** 新对话中因上传附件而提前创建、但还没有发送消息的会话 */
+  let draftId: string | null = null
+  let draftCreating: Promise<string> | null = null
+
+  /** 离开时仍在运行的会话(左栏显示“进行中”) */
+  const runningIds = reactive(new Set<string>())
+  /** 打开的会话不存在或无权访问 */
+  const notFoundHint = ref(false)
+
+  const current = computed(() => sessions.value.find(s => s.sessionId === currentId.value) ?? null)
+
+  async function loadSessions() {
+    listLoading.value = true
+    listError.value = null
+    try {
+      sessions.value = await getAgentSessions()
+    }
+    catch (err) {
+      listError.value = (err as Error).message || '加载会话失败'
+    }
+    finally {
+      listLoading.value = false
+    }
+  }
+
+  function closeChat() {
+    const prev = chat.value
+    if (prev && currentId.value) {
+      if (prev.state.runState !== 'idle') runningIds.add(currentId.value)
+      else runningIds.delete(currentId.value)
+    }
+    prev?.dispose()
+    chat.value = null
+  }
+
+  function openSession(sessionId: string) {
+    if (currentId.value === sessionId && chat.value) return
+    closeChat()
+    draftId = null
+    currentId.value = sessionId
+    runningIds.delete(sessionId)
+    chat.value = useChatSession(client, sessionId)
+    writeSessionToUrl(sessionId)
+  }
+
+  /** 回到欢迎页;会话在发送第一条消息时才创建 */
+  function newChat() {
+    closeChat()
+    currentId.value = null
+    draftId = null
+    writeSessionToUrl(null)
+  }
+
+  /** 新对话中需要会话 ID(上传附件或发送第一条消息)时创建 */
+  async function ensureDraftSession(): Promise<string> {
+    if (draftId) return draftId
+    draftCreating ??= createAgentSession()
+      .then((s) => {
+        draftId = s.sessionId
+        return s.sessionId
+      })
+      .finally(() => {
+        draftCreating = null
+      })
+    return draftCreating
+  }
+
+  /** 新对话中上传附件 */
+  async function uploadInDraft(files: File[]): Promise<FileDto[]> {
+    const id = await ensureDraftSession()
+    return client.sessions.upload(id, files)
+  }
+
+  /** 新对话中发送第一条消息:创建会话 → 打开 → 发送 → 用前 30 字命名 */
+  async function sendFirstMessage(text: string, files: FileDto[]) {
+    const id = await ensureDraftSession()
+    const title = makeTitle(text)
+    const now = Date.now()
+    sessions.value = [{ sessionId: id, title, profileId: '', createdAt: now, lastActive: now }, ...sessions.value]
+    openSession(id)
+    await chat.value!.send(text, files)
+    try {
+      await renameAgentSession(id, title)
+    }
+    catch {
+      // 命名失败不影响对话
+    }
+    void loadSessions()
+  }
+
+  async function rename(sessionId: string, title: string) {
+    const t = title.trim()
+    if (!t) return
+    await renameAgentSession(sessionId, t)
+    const item = sessions.value.find(s => s.sessionId === sessionId)
+    if (item) item.title = t
+  }
+
+  async function remove(sessionId: string) {
+    await deleteAgentSession(sessionId)
+    sessions.value = sessions.value.filter(s => s.sessionId !== sessionId)
+    runningIds.delete(sessionId)
+    if (currentId.value === sessionId) newChat()
+  }
+
+  /** 首次进入:地址中的会话 → 最近活跃的会话 → 欢迎页 */
+  async function init() {
+    await loadSessions()
+    const fromUrl = readSessionFromUrl()
+    if (fromUrl) {
+      openSession(fromUrl)
+      return
+    }
+    if (sessions.value.length) openSession(sessions.value[0].sessionId)
+  }
+
+  // 会话被删除或无权访问时,事件流会以 404 结束;回到欢迎页
+  watch(
+    () => chat.value?.state.error,
+    (error) => {
+      if (error && NOT_FOUND_RE.test(error)) {
+        const id = currentId.value
+        newChat()
+        if (id) sessions.value = sessions.value.filter(s => s.sessionId !== id)
+        notFoundHint.value = true
+      }
+    },
+  )
+
+  function dispose() {
+    chat.value?.dispose()
+    chat.value = null
+  }
+
+  return {
+    client,
+    sessions,
+    listLoading,
+    listError,
+    currentId,
+    current,
+    chat,
+    runningIds,
+    notFoundHint,
+    init,
+    loadSessions,
+    openSession,
+    newChat,
+    uploadInDraft,
+    sendFirstMessage,
+    rename,
+    remove,
+    dispose,
+  }
+}
+
+export type AgentSessions = ReturnType<typeof useAgentSessions>

+ 62 - 0
src/views/ai-assistant/composables/useSessionFiles.ts

@@ -0,0 +1,62 @@
+import type { ChatSession } from '@agent-server/chat-ui'
+import type { AgentClient } from '@agent-server/client'
+import type { FileDto } from '@agent-server/shared'
+import { computed, ref, watch, type Ref, type ShallowRef } from 'vue'
+
+/** 本会话的上传与产出文件;收到 file.changed 或上传完成(user.message 带附件)时刷新 */
+export function useSessionFiles(client: AgentClient, sessionId: Ref<string | null>, chat: ShallowRef<ChatSession | null>) {
+  const files = ref<FileDto[]>([])
+  const loading = ref(false)
+  let timer: ReturnType<typeof setTimeout> | undefined
+  let token = 0
+
+  async function refresh() {
+    const id = sessionId.value
+    const my = ++token
+    if (!id) {
+      files.value = []
+      return
+    }
+    loading.value = true
+    try {
+      const list = await client.sessions.files(id)
+      if (my === token) files.value = list
+    }
+    catch {
+      if (my === token) files.value = []
+    }
+    finally {
+      if (my === token) loading.value = false
+    }
+  }
+
+  function refreshSoon() {
+    clearTimeout(timer)
+    timer = setTimeout(() => void refresh(), 500)
+  }
+
+  watch(sessionId, () => void refresh(), { immediate: true })
+
+  // 文件卡片数量或最新文件事件变化时刷新(含历史加载完成)
+  watch(
+    () => {
+      const items = chat.value?.state.items ?? []
+      let sig = ''
+      for (const i of items) {
+        if (i.kind === 'file') sig += `${i.fileId}:${i.seq};`
+        else if (i.kind === 'user' && i.attachments.length) sig += `u${i.seq};`
+      }
+      return sig
+    },
+    () => refreshSoon(),
+  )
+
+  const uploads = computed(() => sortNewest(files.value.filter(f => f.origin === 'upload')))
+  const outputs = computed(() => sortNewest(files.value.filter(f => f.origin === 'agent')))
+
+  return { files, uploads, outputs, loading, refresh }
+}
+
+function sortNewest(list: FileDto[]) {
+  return [...list].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
+}

+ 392 - 0
src/views/ai-assistant/index.vue

@@ -0,0 +1,392 @@
+<script setup lang="ts">
+import { AgentChat, Composer } from '@agent-server/chat-ui'
+import type { FileDto, ProviderStatusDto } from '@agent-server/shared'
+import { useElementSize } from '@vueuse/core'
+import { NAlert, NButton, NDrawer, NResult, NSpin, useMessage } from 'naive-ui'
+import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+
+import { usePersonalization } from '@/composable/usePersonalization'
+import { getAgentConfig } from '@/service/agent'
+import { useTabsStore } from '@/stores/tabs'
+
+import ChatHeader from './components/ChatHeader.vue'
+import FilePanel from './components/FilePanel.vue'
+import SessionSidebar from './components/SessionSidebar.vue'
+import WelcomePanel from './components/WelcomePanel.vue'
+import { useAgentSessions } from './composables/useAgentSessions'
+import { useSessionFiles } from './composables/useSessionFiles'
+
+import '@agent-server/chat-ui/style.css'
+
+defineOptions({
+  name: 'AiAssistant',
+})
+
+const message = useMessage()
+
+// ============ 开放状态与服务可用性 ============
+
+const configLoading = ref(true)
+const enabled = ref(false)
+const unavailable = ref<string | null>(null)
+const providers = ref<ProviderStatusDto[]>([])
+
+async function checkProviders() {
+  try {
+    providers.value = await agent.client.providers()
+    const usable = providers.value.some(p => p.enabled && p.available && p.loggedIn !== false)
+    unavailable.value = usable ? null : 'AI 服务暂不可用:引擎未就绪,请联系管理员'
+  }
+  catch (err) {
+    unavailable.value = `AI 服务暂不可用:${(err as Error).message || '无法连接'}`
+  }
+}
+
+// ============ 会话 ============
+
+const agent = useAgentSessions()
+const { sessions, listLoading, listError, currentId, current, chat, runningIds, notFoundHint } = agent
+
+const files = useSessionFiles(agent.client, currentId, chat)
+const selectedFileId = ref<string | null>(null)
+watch(currentId, () => (selectedFileId.value = null))
+
+watch(notFoundHint, (v) => {
+  if (v) {
+    message.warning('会话不存在或无权访问')
+    notFoundHint.value = false
+  }
+})
+
+const chatRef = ref<InstanceType<typeof AgentChat>>()
+const draftComposer = ref<InstanceType<typeof Composer>>()
+const draftSending = ref(false)
+
+async function onDraftSend(text: string, attachments: FileDto[]) {
+  draftSending.value = true
+  try {
+    await agent.sendFirstMessage(text, attachments)
+  }
+  catch (err) {
+    message.error(`发送失败:${(err as Error).message}`)
+    draftComposer.value?.setText(text)
+  }
+  finally {
+    draftSending.value = false
+  }
+}
+
+function sendExample(text: string) {
+  if (chat.value) void chat.value.send(text).catch(() => {})
+  else void onDraftSend(text, [])
+}
+
+function fillExample(text: string) {
+  const target = chat.value ? chatRef.value : draftComposer.value
+  target?.setText(text)
+  void nextTick(() => target?.focus())
+}
+
+async function renameCurrent(title: string) {
+  if (!currentId.value) return
+  try {
+    await agent.rename(currentId.value, title)
+  }
+  catch (err) {
+    message.error(`重命名失败:${(err as Error).message}`)
+  }
+}
+
+async function renameSession(sessionId: string, title: string) {
+  try {
+    await agent.rename(sessionId, title)
+  }
+  catch (err) {
+    message.error(`重命名失败:${(err as Error).message}`)
+  }
+}
+
+async function removeSession(sessionId: string) {
+  try {
+    await agent.remove(sessionId)
+    message.success('会话已删除')
+  }
+  catch (err) {
+    message.error(`删除失败:${(err as Error).message}`)
+  }
+}
+
+function selectSession(sessionId: string) {
+  agent.openSession(sessionId)
+  sessionsDrawer.value = false
+}
+
+function startNewChat() {
+  agent.newChat()
+  sessionsDrawer.value = false
+  void nextTick(() => draftComposer.value?.focus())
+}
+
+function onOpenFile(f: { fileId: string }) {
+  selectedFileId.value = f.fileId
+  void files.refresh()
+  if (!showFilesColumn.value) filesDrawer.value = true
+  else filesCollapsed.value = false
+}
+
+// ============ 运行状态(输入框工具栏) ============
+
+const queuePosition = computed(() => {
+  const s = chat.value?.state
+  if (!s || s.runState !== 'queued') return null
+  for (let i = s.events.length - 1; i >= 0; i--) {
+    const e = s.events[i]
+    if (e.type === 'run.queued' && e.runId === s.activeRunId) return e.payload.position
+  }
+  return null
+})
+
+function formatTokens(n: number) {
+  return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
+}
+
+const usageText = computed(() => {
+  const u = chat.value?.state.usage
+  return u ? `本轮 ${formatTokens(u.inputTokens + u.cacheReadTokens)} / ${formatTokens(u.outputTokens)} tokens` : ''
+})
+
+// ============ 布局:三栏 / 窄屏抽屉 ============
+
+const STORAGE_KEY = 'aiAssistantLayout'
+
+function readLayout(): { sessions: boolean, files: boolean } {
+  try {
+    const v = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
+    return { sessions: !!v.sessions, files: !!v.files }
+  }
+  catch {
+    return { sessions: false, files: false }
+  }
+}
+
+const saved = readLayout()
+const sessionsCollapsed = ref(saved.sessions)
+const filesCollapsed = ref(saved.files)
+watch([sessionsCollapsed, filesCollapsed], ([s, f]) => {
+  try {
+    localStorage.setItem(STORAGE_KEY, JSON.stringify({ sessions: s, files: f }))
+  }
+  catch {
+    // 无法写入时只影响下次打开的默认状态
+  }
+})
+
+const root = ref<HTMLElement>()
+const { width } = useElementSize(root)
+// 以页面内容区宽度判断(左侧菜单会占用窗口宽度);尚未测量时按宽屏处理
+const showSessionsColumn = computed(() => width.value === 0 || width.value >= 1024)
+const showFilesColumn = computed(() => width.value === 0 || width.value >= 1280)
+const sessionsDrawer = ref(false)
+const filesDrawer = ref(false)
+watch(showSessionsColumn, v => v && (sessionsDrawer.value = false))
+watch(showFilesColumn, v => v && (filesDrawer.value = false))
+
+// ============ 主题:chat-ui 读取 <html data-theme> ============
+
+const { isDark } = usePersonalization()
+watch(isDark, (dark) => {
+  document.documentElement.dataset.theme = dark ? 'dark' : 'light'
+}, { immediate: true })
+
+// ============ 生命周期 ============
+
+// 进入标签页的 KeepAlive:切换到其他标签时不销毁,进行中的回答继续接收
+const tabsStore = useTabsStore()
+function ensureKeepAlive() {
+  if (!tabsStore.keepAliveTabs.includes('AiAssistant')) tabsStore.keepAliveTabs.push('AiAssistant')
+}
+
+onMounted(async () => {
+  ensureKeepAlive()
+  try {
+    enabled.value = (await getAgentConfig())?.enabled === true
+  }
+  catch (err) {
+    enabled.value = true // 无法确认开放状态时仍显示页面,由下面的可用性检查给出提示
+    unavailable.value = `AI 服务暂不可用:${(err as Error).message || '无法连接'}`
+  }
+  finally {
+    configLoading.value = false
+  }
+  if (!enabled.value) return
+  void checkProviders()
+  await agent.init()
+})
+
+onActivated(() => {
+  if (enabled.value && !configLoading.value) void agent.loadSessions()
+})
+
+onBeforeUnmount(() => agent.dispose())
+</script>
+
+<template>
+  <div ref="root" class="ai-assistant flex h-full min-h-0 overflow-hidden">
+    <div v-if="configLoading" class="flex flex-1 items-center justify-center">
+      <NSpin />
+    </div>
+
+    <div v-else-if="!enabled" class="flex flex-1 items-center justify-center">
+      <NResult status="info" title="AI 助手暂未开放" description="如需使用,请联系管理员开通。" />
+    </div>
+
+    <template v-else>
+      <!-- 左栏:会话 -->
+      <aside
+        v-if="showSessionsColumn && !sessionsCollapsed"
+        class="border-r border-neutral-500/15 shrink-0 w-60"
+      >
+        <SessionSidebar
+          :sessions="sessions"
+          :current-id="currentId"
+          :running-ids="runningIds"
+          :loading="listLoading"
+          :error="listError"
+          collapsible
+          @select="selectSession"
+          @new="startNewChat"
+          @collapse="sessionsCollapsed = true"
+          @retry="agent.loadSessions()"
+          @rename="renameSession"
+          @remove="removeSession"
+        />
+      </aside>
+
+      <!-- 中间:对话 -->
+      <main class="flex flex-1 flex-col min-w-0">
+        <ChatHeader
+          :client="agent.client"
+          :session-id="currentId"
+          :title="current?.title ?? ''"
+          :providers="providers"
+          :show-sessions-button="!showSessionsColumn || sessionsCollapsed"
+          :show-files-button="!showFilesColumn || filesCollapsed"
+          @rename="renameCurrent"
+          @remove="currentId && removeSession(currentId)"
+          @open-sessions="showSessionsColumn ? (sessionsCollapsed = false) : (sessionsDrawer = true)"
+          @open-files="showFilesColumn ? (filesCollapsed = false) : (filesDrawer = true)"
+        />
+
+        <NAlert v-if="unavailable" type="warning" :bordered="false" class="rounded-none">
+          {{ unavailable }}
+          <NButton text type="primary" class="ml-2" @click="checkProviders">
+            重试
+          </NButton>
+        </NAlert>
+
+        <div class="chat-area flex-1 min-h-0">
+          <AgentChat
+            v-if="chat"
+            :key="currentId ?? ''"
+            ref="chatRef"
+            :client="agent.client"
+            :session="chat"
+            placeholder="输入问题,Enter 发送,Shift+Enter 换行,可粘贴截图"
+            @open-file="onOpenFile"
+          >
+            <template #empty>
+              <WelcomePanel @send="sendExample" @fill="fillExample" />
+            </template>
+            <template #toolbar>
+              <span class="toolbar-text">
+                <template v-if="queuePosition !== null">排队中(第 {{ queuePosition }} 位)</template>
+                <template v-else>{{ usageText }}</template>
+              </span>
+            </template>
+          </AgentChat>
+
+          <!-- 新对话:发送第一条消息时才创建会话 -->
+          <div v-else class="draft flex flex-col h-full">
+            <div class="flex-1 min-h-0 overflow-y-auto">
+              <WelcomePanel :disabled="!!unavailable || draftSending" @send="sendExample" @fill="fillExample" />
+            </div>
+            <div class="draft-composer">
+              <Composer
+                ref="draftComposer"
+                :running="draftSending"
+                :disabled="!!unavailable"
+                :upload="agent.uploadInDraft"
+                placeholder="输入问题,Enter 发送,Shift+Enter 换行,可粘贴截图"
+                @send="onDraftSend"
+              />
+            </div>
+          </div>
+        </div>
+      </main>
+
+      <!-- 右栏:文件 -->
+      <aside
+        v-if="showFilesColumn && !filesCollapsed"
+        class="border-l border-neutral-500/15 shrink-0 w-80"
+      >
+        <FilePanel
+          v-model:selected="selectedFileId"
+          :client="agent.client"
+          :uploads="files.uploads.value"
+          :outputs="files.outputs.value"
+          :loading="files.loading.value"
+          collapsible
+          @refresh="files.refresh()"
+          @collapse="filesCollapsed = true"
+        />
+      </aside>
+
+      <!-- 窄屏抽屉 -->
+      <NDrawer v-model:show="sessionsDrawer" placement="left" :width="280">
+        <SessionSidebar
+          :sessions="sessions"
+          :current-id="currentId"
+          :running-ids="runningIds"
+          :loading="listLoading"
+          :error="listError"
+          @select="selectSession"
+          @new="startNewChat"
+          @retry="agent.loadSessions()"
+          @rename="renameSession"
+          @remove="removeSession"
+        />
+      </NDrawer>
+      <NDrawer v-model:show="filesDrawer" placement="right" :width="360">
+        <FilePanel
+          v-model:selected="selectedFileId"
+          :client="agent.client"
+          :uploads="files.uploads.value"
+          :outputs="files.outputs.value"
+          :loading="files.loading.value"
+          @refresh="files.refresh()"
+        />
+      </NDrawer>
+    </template>
+  </div>
+</template>
+
+<style scoped>
+/* chat-ui 默认列宽 680px,大页面放宽到 880px */
+.chat-area :deep(.agent-chat .column),
+.chat-area :deep(.agent-chat .composer-wrap) {
+  max-width: 880px;
+}
+.chat-area :deep(.agent-chat) {
+  background: transparent;
+}
+.draft-composer {
+  max-width: 880px;
+  width: 100%;
+  margin: 0 auto;
+  padding: 0 16px 16px;
+}
+.toolbar-text {
+  font-size: 12px;
+  color: var(--app-secondary-foreground);
+}
+</style>