Просмотр исходного кода

merge: reconcile upstream updates and qiwei dashboard fixes

ETO-kai 1 месяц назад
Родитель
Сommit
cff367f1ce
79 измененных файлов с 4778 добавлено и 724 удалено
  1. 2 2
      claude-code/claude-code-qiwe-assistant/.claude-plugin/plugin.json
  2. 4 0
      claude-code/claude-code-qiwe-assistant/.env.example
  3. 50 14
      claude-code/claude-code-qiwe-assistant/README.md
  4. 12 9
      claude-code/claude-code-qiwe-assistant/docs/RELEASE.md
  5. 12 17
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-subscription-flow.md
  6. 37 7
      claude-code/claude-code-qiwe-assistant/install.js
  7. 1 8
      claude-code/claude-code-qiwe-assistant/mcp/catalog/qiwei-endpoints.json
  8. 24 1
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-poller-policy.js
  9. 95 23
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js
  10. 16 4
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-db.js
  11. 43 3
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js
  12. 44 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/allowlist-config.js
  13. 56 18
      claude-code/claude-code-qiwe-assistant/mcp/src/core/login-flow-server.js
  14. 5 3
      claude-code/claude-code-qiwe-assistant/mcp/src/core/output-paths.js
  15. 52 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/runtime-context.js
  16. 33 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/startup-summary.js
  17. 2 160
      claude-code/claude-code-qiwe-assistant/mcp/src/core/subscribe-page.js
  18. 296 38
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  19. 617 63
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  20. 1 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/customer-master-service.js
  21. 345 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/group-agent-service.js
  22. 2 2
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html
  23. 196 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/response-monitor-service.js
  24. 114 8
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  25. 390 5
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css
  26. 22 8
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/workspace-library-service.js
  27. 79 8
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-agent-transport.js
  28. 5 4
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  29. 38 6
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-agent-control-run.js
  30. 19 12
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-broker-playbook-run.js
  31. 10 2
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-transfer-run.js
  32. 30 9
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js
  33. 26 1
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-operations-run.js
  34. 9 15
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js
  35. 31 16
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js
  36. 8 2
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-subscription-run.js
  37. 12 8
      claude-code/claude-code-qiwe-assistant/package-lock.json
  38. 6 8
      claude-code/claude-code-qiwe-assistant/package.json
  39. 334 6
      claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js
  40. 10 1
      claude-code/claude-code-qiwe-assistant/scripts/build-catalog.py
  41. 3 1
      claude-code/claude-code-qiwe-assistant/scripts/customer-master-smoke-test.js
  42. 18 0
      claude-code/claude-code-qiwe-assistant/scripts/group-operations-smoke-test.js
  43. 3 1
      claude-code/claude-code-qiwe-assistant/scripts/open-customer-session.js
  44. 34 0
      claude-code/claude-code-qiwe-assistant/scripts/package-smoke-test.js
  45. 108 0
      claude-code/claude-code-qiwe-assistant/scripts/preview-dashboard.js
  46. 1 0
      claude-code/claude-code-qiwe-assistant/scripts/release-check.js
  47. 103 0
      claude-code/claude-code-qiwe-assistant/scripts/response-monitor-smoke-test.js
  48. 0 38
      claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals.py
  49. 0 30
      claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals2.py
  50. 0 25
      claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals3.py
  51. 0 22
      claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals4.py
  52. 0 12
      claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops.py
  53. 75 10
      claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js
  54. 4 3
      claude-code/claude-code-qiwe-assistant/scripts/start-dashboard.js
  55. 73 0
      claude-code/claude-code-qiwe-assistant/scripts/startup-preview-smoke-test.js
  56. 8 5
      claude-code/claude-code-qiwe-assistant/skill-package-manifest.json
  57. 47 28
      claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md
  58. 4 3
      claude-code/claude-code-qiwe-assistant/skills/qiwei-real-estate-auto-reply/SKILL.md
  59. 1 1
      claude-code/claude-code-voc-intelligence/.claude-plugin/plugin.json
  60. 233 34
      claude-code/claude-code-voc-intelligence/bin/claude-voc.js
  61. 4 0
      claude-code/claude-code-voc-intelligence/mcp/src/core/credentials.js
  62. 2 2
      claude-code/claude-code-voc-intelligence/package-lock.json
  63. 3 2
      claude-code/claude-code-voc-intelligence/package.json
  64. 1 0
      claude-code/claude-code-voc-intelligence/scripts/acceptance.js
  65. 79 0
      claude-code/claude-code-voc-intelligence/scripts/installer-acceptance.js
  66. 2 2
      claude-code/claude-code-voc-intelligence/skill-package-manifest.json
  67. 4 4
      dist/claude-code-voc-intelligence-suite-manifest.json
  68. BIN
      dist/claude-code-voc-intelligence.zip
  69. 6 6
      dist/npm/claude-code-voc-npm-package-manifest.json
  70. BIN
      docs/企微技能包/01-小牛资源准备清单.docx
  71. 56 0
      docs/企微技能包/01-小牛资源准备清单.md
  72. BIN
      docs/企微技能包/02-小牛二次开发计划.docx
  73. 234 0
      docs/企微技能包/02-小牛二次开发计划.md
  74. BIN
      docs/企微技能包/03-企微技能包产品化架构与功能.docx
  75. 401 0
      docs/企微技能包/03-企微技能包产品化架构与功能.md
  76. 122 0
      docs/企微技能包/04-企微技能包启动与预览.md
  77. 25 3
      release/npm发布管理文档/NPM技能包发布管理.md
  78. 28 0
      release/npm发布管理文档/变更记录/fmode-qiwei.md
  79. 8 1
      release/npm发布管理文档/变更记录/voc-skill.md

+ 2 - 2
claude-code/claude-code-qiwe-assistant/.claude-plugin/plugin.json

@@ -1,8 +1,8 @@
 {
   "name": "qiwei-assistant",
   "description": "Enterprise WeChat intelligent office, goal management, customer service, CRM and API skills for Claude Code and Fmode Studio.",
-  "version": "0.4.0",
+  "version": "0.4.2",
   "author": {
-    "name": "BrainHack"
+    "name": "fmode"
   }
 }

+ 4 - 0
claude-code/claude-code-qiwe-assistant/.env.example

@@ -46,6 +46,10 @@ QIWEI_AGENT_INITIAL_SYNC_LIMIT=5000
 QIWEI_AGENT_INITIAL_SYNC_MAX_PAGES=200
 QIWEI_AGENT_STARTUP_GRACE_SECONDS=10
 
+# 客服回复时效监控(仅本地提醒,不自动发送企微消息)
+QIWEI_RESPONSE_REMINDER_MINUTES=15
+QIWEI_RESPONSE_URGENT_MINUTES=60
+
 # Agent 模型。密钥只放 .env.local 或用户级模型配置,禁止提交到仓库。
 AGENT_PROVIDER=claude-code
 AGENT_API_KEY=

+ 50 - 14
claude-code/claude-code-qiwe-assistant/README.md

@@ -1,4 +1,4 @@
-# 企业微信助手技能包
+# fmode-qiwei 企业微信助手技能包
 
 提供两套相互独立的企业微信能力:
 
@@ -230,7 +230,31 @@ npm run wecom:status
 - `QIWEI_UID` 是客户端设备别名;未配置时会生成随机稳定 uid,并写入 `.env.local` 和 `~/.claude/qiwei-credentials.json`。
 - 企业微信接口访问凭据和设备上下文由 Fmode 网关管理,不会出现在技能配置或返回结果中。
 
-## 首次使用
+## 快速启动与预览
+
+在 Fmode Studio 中打开项目后,直接对 Claude Code 说:
+
+```text
+启动并预览企微助手
+```
+
+Claude Code 会调用 `qiwei_agent_dashboard_start`,启动 4320 工作台,并依次返回:Fmode 鉴权、企微席位、企微在线、测试白名单和 AI 监听五项状态。
+
+源码开发也可以执行:
+
+```bash
+npm run preview
+```
+
+安装到客户 workspace 后可以执行:
+
+```bash
+node .claude/plugins/qiwei-assistant/install.js preview .
+```
+
+启动器会自动打开浏览器并告诉用户当前第一项需要处理的动作。使用 `--no-open` 可禁止自动打开浏览器,使用 `--port 4321` 可切换端口。
+
+## 底层登录顺序
 
 1. `qiwei_subscription_status` 检查订阅;
 2. 未订阅时调用 `qiwei_subscribe`,`seats` 表示企微账号席位数;
@@ -271,8 +295,7 @@ node install.js --check
 本项目包含一个独立的本地 Web Dashboard,用于在浏览器中管理智能会话、客户群和账号状态等:
 
 ```bash
-cd claude-code/claude-code-qiwe-assistant
-npm run dashboard
+npm run preview
 ```
 
 启动后访问:
@@ -283,19 +306,22 @@ http://127.0.0.1:4320/
 
 ### 为什么建议在项目目录下启动
 
-Dashboard 优先使用启动进程或 MCP 请求中注入的 `QIWEI_AUTH_TOKEN`、`QIWEI_UID` 和 `QIWEI_API_BASE`,其次读取项目根目录的 `.env.local`、Fmode/Claude Code 用户配置。进程级凭据优先于本机文件,避免 Fmode Studio 当前账号被旧的本机 token 覆盖。仍建议从项目目录启动,以便知识库、账号工作台和输出路径落在正确项目中
+Dashboard 优先使用启动进程或 MCP 请求中注入的 `QIWEI_AUTH_TOKEN`、`QIWEI_UID` 和 `QIWEI_API_BASE`,其次读取客户项目根目录的 `.env.local`、Fmode/Claude Code 用户配置。workspace 安装会将运行目录、输出目录和 Claude Code 工作目录绑定到客户项目根目录,避免客户数据落入隐藏插件目录
 
 ### 端口与状态
 
 - 默认端口 `4320`,可通过 `QIWEI_DASHBOARD_PORT` 覆盖;
 - 健康检查:`curl http://127.0.0.1:4320/api/health`;
 - 状态汇总:`curl http://127.0.0.1:4320/api/status`。
+- 健康检查包含当前项目的 `workspaceId`,用于阻止不同项目误用同一个 4320 服务。
 
 ### 智能会话演示
 
 Dashboard 的「智能会话」页把真实消息监听、意图识别、需求画像、业务匹配、自动回复和人工协同放在同一页面。启停逻辑如下:
 
-- 点击「启动 AI 监听」后,AI 自动回复白名单测试联系人;
+- 点击「管理白名单」,从当前企微外部联系人中搜索并勾选允许 Agent 处理的客户;
+- 点击「启动 AI 监听」后,AI 仅处理已选白名单联系人;
+- 已确认的客户群会进入独立的「群聊智能回复」列表,Agent 自动生成草稿,但必须人工编辑或审核后才能发送到群;
 - 点击「关闭 AI 监听」后,轮询器停止,切换为人工回复;
 - 真实发送只允许命中 `QIWEI_AUTO_REPLY_ALLOWED_SENDERS` 白名单的联系人;
 - 可对单个客户切换「人工接管 / 恢复 Agent」;
@@ -304,11 +330,12 @@ Dashboard 的「智能会话」页把真实消息监听、意图识别、需求
 推荐的现场顺序:
 
 1. 打开 `http://127.0.0.1:4320/#agent`,确认测试账号显示在线;
-2. 点击「启动 AI 监听」;
-3. 用唯一白名单测试联系人发送一条新消息;
-4. 查看意图、需求画像、匹配结果和建议回复;
-5. 点击「关闭 AI 监听」,演示切换为人工回复;
-6. 如需展示单客户协同,可重新启动后演示「人工接管 / 恢复 Agent」。
+2. 点击「管理白名单」,搜索并选择一位测试联系人;
+3. 点击「启动 AI 监听」;
+4. 用已选白名单联系人发送一条新消息;
+5. 查看意图、需求画像、匹配结果和建议回复;
+6. 点击「关闭 AI 监听」,演示切换为人工回复;
+7. 如需展示单客户协同,可重新启动后演示「人工接管 / 恢复 Agent」。
 
 ### 离线恢复
 
@@ -340,7 +367,14 @@ npm run agent:session -- --customer 王刚
 
 ## 安装到客户项目
 
-本地源码包已经支持 workspace 安装:
+npm 发布版推荐直接安装到当前客户项目:
+
+```bash
+npx --yes fmode-qiwei@latest workspace --smoke
+npx --yes fmode-qiwei@latest preview
+```
+
+本地源码调试也可以执行:
 
 ```bash
 node install.js workspace <客户项目目录> --smoke
@@ -354,7 +388,7 @@ node install.js workspace <客户项目目录> --smoke
 <客户项目>/.mcp.json
 ```
 
-安装过程不会复制 `.env`、`.env.local`、`.npmrc`、真实运行输出、客户 Session、Playwright 会话、压缩包或源码目录中的 `node_modules`。当前版本已通过发布检查,尚未发布到 npm;发布状态与上传检查见 `docs/RELEASE.md`。
+安装过程不会复制 `.env`、`.env.local`、`.npmrc`、真实运行输出、客户 Session、Playwright 会话、压缩包或源码目录中的 `node_modules`。当前公开版本为 `fmode-qiwei@0.4.2`;版本与验证记录见 `docs/RELEASE.md`。
 
 ## 子 Skill 索引
 
@@ -395,7 +429,9 @@ Agent 可按业务场景直接定位到对应 Skill 文档,每个 Skill 内部
 
 技能包内置 30 套脱敏房源以及客户、标签和匹配规则,可直接用于演示;正式客户数据可通过 `QIWEI_AGENT_PROPERTY_DATA_FILE` 覆盖。
 
-先在 `.env.local` 配置测试联系人白名单,并推荐复用 Fmode Studio 当前项目的 Claude Code 模型能力:
+启动 Dashboard 后,在「智能会话」页点击「管理白名单」,直接从企微联系人中搜索并勾选客户。保存后立即生效并写入客户项目 `.env.local`,无需手工查找联系人 ID 或重启技能包。
+
+只有在页面暂时无法读取联系人时,才需要使用下面的手工配置作为兜底;同时推荐复用 Fmode Studio 当前项目的 Claude Code 模型能力:
 
 ```text
 QIWEI_AUTO_REPLY_ALLOWED_SENDERS=<测试联系人 userId,多个用逗号分隔>

+ 12 - 9
claude-code/claude-code-qiwe-assistant/docs/RELEASE.md

@@ -2,9 +2,9 @@
 
 ## 当前状态
 
-- npm 包名:`claude-code-qiwei-assistant`
-- 当前版本:`0.4.0`
-- 状态:已通过发布检查,可上传,尚未发布到 npm
+- npm 包名:`fmode-qiwei`
+- 当前版本:`0.4.2`
+- 状态:已发布到 npm,`latest=0.4.2`
 - 运行要求:Node.js `22.5.0+`(使用内置 `node:sqlite`)
 - Dashboard:`http://127.0.0.1:4320/`
 
@@ -17,6 +17,7 @@ npm pack --dry-run --json
 ```
 
 `release:check` 会覆盖语法、MCP mock 网关、Agent 工作台、知识库、会议/文档/待办、目标管理、客户主档、客户旅程、输出规范、安装结构和实际 npm 包内容。
+同时执行生产依赖审计;当前生产依赖漏洞数为 0。
 
 ## 安装方式
 
@@ -29,14 +30,14 @@ node install.js workspace <客户项目目录> --smoke
 本地 tgz:
 
 ```powershell
-npm install -g .\claude-code-qiwei-assistant-0.4.0.tgz
-qiwei-assistant workspace <客户项目目录> --smoke
+npm install -g .\fmode-qiwei-0.4.2.tgz
+fmode-qiwei workspace <客户项目目录> --smoke
 ```
 
 npm 发布后:
 
 ```powershell
-npx --yes claude-code-qiwei-assistant@latest workspace <客户项目目录> --smoke
+npx --yes fmode-qiwei@latest workspace <客户项目目录> --smoke
 ```
 
 workspace 安装会生成:
@@ -60,7 +61,9 @@ workspace 安装会生成:
 ## 已知边界
 
 - 白名单为空时拒绝启动真实监听。
-- 高置信自动回复仍受白名单和置信度阈值约束,低置信内容进入人工审核。
+- Dashboard 可直接读取企微外部联系人并搜索、多选白名单;保存后写入客户项目 `.env.local` 并立即更新运行时安全边界。
+- 私聊高置信自动回复仍受白名单和置信度阈值约束,低置信内容进入人工审核。
+- 客户群默认使用人工审核;“无视风险 · 全自动”必须按群单独开启并二次确认,非空 Agent 回复会直接群发,模型或发送失败时停止并保留草稿。
 - 官方会议 CLI 是否可用取决于企业微信向当前企业开放的机器人权限;不可用时页面会明确降级,不生成 Mock 会议。
 - Fmode 登录/设备操作使用专用端点;业务消息通过 `/doApi`,不允许从通用转发口调用 `/client/*`。
 
@@ -69,8 +72,8 @@ workspace 安装会生成:
 仅在版本、账号和仓库信息确认后执行:
 
 ```powershell
-npm publish
-npm view claude-code-qiwei-assistant version
+npm publish --access public
+npm view fmode-qiwei name version dist-tags.latest --registry=https://registry.npmjs.org/
 ```
 
 只有 `npm publish` 成功且 registry 查询验证通过后,才能将状态改为“已发布”。

+ 12 - 17
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-subscription-flow.md

@@ -7,29 +7,23 @@
 
 - 页面加载时请求 `/flow/state` 自动判断当前所处步骤
   (已登录→完成页;已订阅→直接出码;未订阅→套餐页);
-- 套餐页点「开通服务」→ `/flow/subscribe` 扣费成功后自动进入出码步骤;
+- 套餐页点「开通服务」→ `/flow/subscribe` 使用一次性付款确认码提交;
+- 本地服务一次调用正式 `POST /subscribe`,由 Future Server 按席位与月数完成整笔扣费和续期;
 - 出码后每 3 秒自动轮询 `/flow/check`,无需手动刷新;
 - 状态 10 自动切到验证码输入页,提交 `/flow/verify` 后继续轮询;
 - 状态 2 显示登录成功页。
 
-鉴权 token 仅保存在流程页服务进程内存中,不写入 HTML。
-传 `flowUi=false` 回退到静态文件模式(下述两步流程)
+鉴权 token 仅保存在流程页服务进程内存中,不写入 HTML。HTML 只携带当前本地页面使用的
+一次性付款确认码,开通成功后立即失效
 
-## 流程总览(静态模式)
+## `flowUi=false` 回退模式
 
-1. **第 1 步:开通订阅(付费)**
-   - `qiwei_login_start` 会先查询 `GET /subscribe/status`;
-   - 未开通/已到期时,不直接报错,而是生成套餐选择页
-     `outputs/subscription/qiwei-subscribe.html` 并自动打开浏览器;
-   - 页面上选择席位(1/3/10/20/50 个号)与时长(1/6/12 个月),
-     点击「开通服务」后调用 `POST /subscribe`,按
-     `席位数 × ¥200/月 × 月数` 从飞马余额一次性扣费;
-   - 开通成功后重新调用 `qiwei_login_start` 进入第 2 步。
-
-2. **第 2 步:扫码登录**
+- 已开通订阅时,可以使用扫码回退服务:
    - `qiwei_login_start` 生成二维码(`outputs/login/qiwei-login-qrcode.png` + 预览页);
    - 手机企业微信扫码确认后,`qiwei_login_check` 轮询状态;
    - 状态 10 时用 `qiwei_login_verify` 提交 6 位验证码。
+- 未开通/已到期时返回 `needs_subscription`,提示重新使用默认动态流程页或调用
+  `qiwei_subscribe`;不再生成会把鉴权信息写入 HTML 的静态付费页。
 
 ## 价格
 
@@ -44,16 +38,17 @@
 | QW-AUTH-401 | 401 | 鉴权失败 | token 无效或过期,检查 QIWEI_AUTH_TOKEN 后重试 |
 | QW-PAY-402 | 402 | 飞马余额不足 | 余额不足以完成扣费,请先充值飞马余额 |
 | QW-SUB-402 | 402 | 订阅未开通或已到期 | 在套餐页选择席位并点击开通 |
+| QW-PAY-403 | 403 | 本地付款确认无效或已使用 | 刷新本地流程页后重新选择套餐 |
 | QW-SEAT-403 | 403 | 席位已满 | 增购席位或停用闲置设备 |
 | QW-UP-502 | 502/503 | 网关或企微服务不可用 | 稍后重试 |
 
-对应模块:`mcp/src/core/subscribe-page.js`(`ERROR_CODES` /
-`classifySubscribeError` / `saveSubscribePage`)。
+对应模块:`mcp/src/core/subscribe-page.js`(套餐、价格、`ERROR_CODES`、
+`classifySubscribeError`)与 `mcp/src/core/login-flow-server.js`(动态页面和一次性付款确认)。
 
 ## MCP 工具侧状态
 
 - `needs_subscription`:`qiwei_login_start` 检测到未订阅时返回,
-  `summary.subscribePage` 指向套餐页路径
+  默认动态流程页直接展示套餐;`flowUi=false` 时提示改用动态流程
 - `needs_seat`:席位不足;
 - `needs_auth`:鉴权失败;
 - `needs_verify_code`:扫码后需要 6 位验证码。

+ 37 - 7
claude-code/claude-code-qiwe-assistant/install.js

@@ -14,11 +14,13 @@ function usage() {
     '企业微信 Claude Code 技能包安装器',
     '',
     '用法:',
-    '  node install.js --check',
-    '  node install.js workspace <客户项目目录>',
-    '  node install.js workspace <客户项目目录> --smoke',
+    '  fmode-qiwei --check',
+    '  fmode-qiwei workspace [客户项目目录]',
+    '  fmode-qiwei workspace [客户项目目录] --smoke',
+    '  fmode-qiwei preview [客户项目目录] [--no-open] [--port 4320]',
     '',
     'workspace 模式会写入 .claude/plugins、.claude/skills 和项目级 .mcp.json。',
+    'preview 模式会启动工作台、检查当前状态并打开浏览器。',
   ].join('\n');
 }
 
@@ -123,7 +125,12 @@ function installWorkspace(targetInput, options = {}) {
   mcp.mcpServers['qiwei-assistant'] = {
     command: process.execPath,
     args: [path.join(pluginDir, 'mcp', 'src', 'server.js')],
-    cwd: pluginDir,
+    cwd: target,
+    env: {
+      QIWEI_WORKSPACE_ROOT: target,
+      QIWEI_OUTPUTS_DIR: path.join(target, 'outputs'),
+      CLAUDE_CODE_WORKDIR: target,
+    },
   };
   writeJson(mcpPath, mcp);
 
@@ -151,12 +158,29 @@ function installWorkspace(targetInput, options = {}) {
     '',
     '下一步:',
     '  1. 在 Fmode Studio 中打开该项目。',
-    '  2. 启动 Claude Code,调用 qiwei_agent_dashboard_start。',
-    '  3. 在项目终端运行 npm --prefix ".claude/plugins/qiwei-assistant" run agent:session:list 查看客户 Session。',
+    '  2. 在 Claude Code 中说“启动并预览企微助手”。',
+    `  3. 也可以在项目终端运行:node "${path.join(pluginDir, 'install.js')}" preview "${target}"`,
+    '  4. 页面会依次提示登录、席位、企微在线、白名单和监听状态。',
+    '  5. 需要审阅客户会话时,在项目终端运行 npm --prefix ".claude/plugins/qiwei-assistant" run agent:session:list。',
     '',
   ].join('\n'));
 }
 
+function previewWorkspace(targetInput, args = []) {
+  const target = path.resolve(targetInput || process.cwd());
+  const installedRoot = path.join(target, '.claude', 'plugins', 'qiwei-assistant');
+  const runtimeRoot = fs.existsSync(path.join(installedRoot, 'scripts', 'preview-dashboard.js')) ? installedRoot : ROOT;
+  const previewScript = path.join(runtimeRoot, 'scripts', 'preview-dashboard.js');
+  if (!fs.existsSync(previewScript)) throw new Error('技能包缺少启动预览脚本,请重新安装或升级技能包');
+  run(process.execPath, [previewScript, ...args], target, {
+    env: {
+      QIWEI_WORKSPACE_ROOT: target,
+      QIWEI_OUTPUTS_DIR: path.join(target, 'outputs'),
+      CLAUDE_CODE_WORKDIR: target,
+    },
+  });
+}
+
 function main() {
   const args = process.argv.slice(2);
   if (args.includes('--help') || args.includes('-h')) {
@@ -169,7 +193,13 @@ function main() {
     if (!args.length) process.stdout.write(`${usage()}\n`);
     return;
   }
-  if (args[0] !== 'workspace') throw new Error('仅支持 workspace 安装模式');
+  if (args[0] === 'preview') {
+    const target = args[1] && !args[1].startsWith('--') ? args[1] : process.cwd();
+    const previewArgs = args.slice(args[1] && !args[1].startsWith('--') ? 2 : 1);
+    previewWorkspace(target, previewArgs);
+    return;
+  }
+  if (args[0] !== 'workspace') throw new Error('支持 workspace 安装或 preview 启动预览模式');
   const target = args[1] && !args[1].startsWith('--') ? args[1] : process.cwd();
   installWorkspace(target, {
     smoke: args.includes('--smoke'),

+ 1 - 8
claude-code/claude-code-qiwe-assistant/mcp/catalog/qiwei-endpoints.json

@@ -1430,14 +1430,7 @@
      "in": "body",
      "type": "string",
      "required": true,
-     "desc": "iPad 最新版5.0.2.206376 = ,Windows 最新版5.0.2.6011。如无特殊需求置空即可"
-    },
-    {
-     "name": "aid",
-     "in": "body",
-     "type": "string",
-     "required": false,
-     "desc": "- **网络方式3:本地代理** 需配合 Aid 客户端工具使用:[Win Aid 工具下载](https://download-1308498490.cos.ap-guangzhou.myqcloud.com/gewe-AidNet-win64.exe_20260425_200627.zip?q-sign-algorithm=sha1&q-ak=AKIDmOkqfDUUDfqjMincBSSAbleGaeQv96mB&q-sign-time=1777118830;10417032430&q-key-time=1777118830;10417032430&q-header-list=host&q-url-param-list=&q-signature=3a7cabdea6463d09f51936f1f5fdad111dc70e9c) 需在本地电脑运行 Aid 客户端 24小时在线 提供最稳定、最纯净的网络环境"
+     "desc": "客户端版本由 Fmode 服务自动选择,通常无需填写。"
     }
    ],
    "responseHint": "data 字段:guid(参数 guid)"

+ 24 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-poller-policy.js

@@ -6,8 +6,31 @@ function messageTimestamp(raw) {
   return parsed > 1e12 ? Math.floor(parsed / 1000) : parsed;
 }
 
+function roomIdOf(message = {}) {
+  const candidates = [
+    message.fromRoomId,
+    message.roomId,
+    message.chatRoomId,
+    message.room?.id,
+    message.msgData?.fromRoomId,
+    message.msgData?.roomId,
+  ];
+  for (const value of candidates) {
+    const normalized = String(value ?? '').trim();
+    if (normalized && !/^(?:0|null|undefined)$/i.test(normalized)) return normalized;
+  }
+  return '';
+}
+
+function isGroupMessage(message = {}) {
+  if (roomIdOf(message)) return true;
+  if (message.isRoom === true || Number(message.isRoomNotice) === 1) return true;
+  return /^(?:group|room)$/i.test(String(message.chatType || '').trim());
+}
+
 function evaluatePolledMessage(message = {}, config = {}) {
   if (![0, 1, 2].includes(Number(message.msgType))) return { eligible: false, reason: 'unsupported_type' };
+  if (isGroupMessage(message)) return { eligible: false, reason: 'group_message', roomId: roomIdOf(message) };
   const content = String(message.msgData?.content || '').trim();
   const senderId = String(message.senderId || '');
   if (!content || !senderId) return { eligible: false, reason: 'empty_message' };
@@ -19,4 +42,4 @@ function evaluatePolledMessage(message = {}, config = {}) {
   return { eligible: true, content, senderId, timestamp };
 }
 
-module.exports = { messageTimestamp, evaluatePolledMessage };
+module.exports = { messageTimestamp, roomIdOf, isGroupMessage, evaluatePolledMessage };

+ 95 - 23
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js

@@ -4,6 +4,54 @@ const path = require('path');
 const { spawn } = require('child_process');
 
 const RISK_PATTERN = /合同|签约|产权|学区资格|保证|承诺|最低价|贷款|利率|首付|投诉|退款|发票|身份证|银行卡|法律|违约/;
+const NO_REPLY_NEEDED_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
+
+function isNoReplyNeededMessage(input = '') {
+  const normalized = String(input || '').trim().replace(/[,。!?!?,.~~]+$/g, '').trim();
+  return Boolean(normalized && NO_REPLY_NEEDED_PATTERN.test(normalized));
+}
+
+function claudeSessionResetReason(error) {
+  const detail = `${error?.message || ''} ${error?.subtype || ''}`;
+  if (/error_max_budget_usd|max[-_\s]?budget[-_\s]?usd/i.test(detail)) return 'budget_exceeded';
+  if (/session|conversation|resume/i.test(detail)) return 'session_invalid';
+  return '';
+}
+
+function parseClaudeProcessResult(stdout = '', stderr = '', code = 0) {
+  let payload = null;
+  try { payload = JSON.parse(String(stdout || '').trim()); } catch {}
+  if (code === 0) {
+    return payload
+      ? { payload }
+      : { error: `Claude Code 未返回有效 JSON${stderr ? ',请检查 Fmode 配置' : ''}` };
+  }
+
+  const structured = payload?.structured_output;
+  const usableStructuredOutput = structured
+    && typeof structured === 'object'
+    && !Array.isArray(structured)
+    && Object.prototype.hasOwnProperty.call(structured, 'reply')
+    && Object.prototype.hasOwnProperty.call(structured, 'confidence')
+    && Object.prototype.hasOwnProperty.call(structured, 'intent');
+  const safeError = String(stderr || '')
+    .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
+    .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
+    .trim()
+    .slice(0, 300);
+  const structuredError = String(payload?.error || payload?.message || payload?.result || payload?.subtype || '').trim().slice(0, 300);
+  const detail = safeError || structuredError || `退出码 ${code}`;
+  if (usableStructuredOutput) {
+    return {
+      payload: {
+        ...payload,
+        is_error: false,
+        process_warning: { exitCode: code, detail },
+      },
+    };
+  }
+  return { error: `Claude Code 调用失败(退出码 ${code})${detail ? `:${detail}` : ''}` };
+}
 
 class AgentNotConfiguredError extends Error {
   constructor() {
@@ -117,9 +165,20 @@ function writeJsonAtomic(filePath, value) {
 }
 
 function resolveClaudeExecutable(config = {}) {
+  const pathCandidates = String(process.env.PATH || '')
+    .split(path.delimiter)
+    .map(item => item.trim().replace(/^"|"$/g, ''))
+    .filter(Boolean)
+    .flatMap(dir => process.platform === 'win32'
+      ? [
+          path.join(dir, 'claude.exe'),
+          path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
+        ]
+      : [path.join(dir, 'claude')]);
   const candidates = [
     config.claudeExecutable,
     process.env.CLAUDE_CODE_EXECUTABLE,
+    ...pathCandidates,
     path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
     path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
   ].filter(Boolean);
@@ -519,6 +578,7 @@ class ClaudeCodeClient {
       '【上下文边界】下面的“本轮有效会话”是本轮唯一可信的客户对话。即使当前 Claude Code Session 曾经出现过其他客户原话,也不得引用未在本轮有效会话中重复出现的内容。',
       '把语音转写重复、语义残缺、与当前业务无关的测试/技术消息视为未确认噪声;不得据此推断购买数量、预算用途、投资意图等高影响需求,必须先向客户确认。',
       'reply 使用适合企微的简洁纯文本,不输出 Markdown 星号、标题语法或内部推理。客户给出明确事实时,在 profileUpdates 中同步记录;不明确的字段标记待确认。',
+      '如果最新消息只是“收到、好的、明白了、谢谢”等纯确认或致谢,且没有新的问题或待确认动作,reply 可以返回空字符串。',
       '同时输出内部 tasks 和 alerts:tasks 只记录明确可执行的跟进动作,alerts 只记录有证据的高意向、时效、矛盾、投诉或人工接管风险;每项 evidence 必须来自本轮有效会话。没有就输出空数组。',
       '',
       `当前客户画像:${JSON.stringify(profile)}`,
@@ -536,9 +596,13 @@ class ClaudeCodeClient {
   runProcess(args, input = '') {
     if (!this.isConfigured()) throw new AgentNotConfiguredError();
     return new Promise((resolve, reject) => {
+      const childEnv = { ...process.env, NO_COLOR: '1' };
+      if (this.config.claudeBare !== false && childEnv.ANTHROPIC_AUTH_TOKEN) {
+        childEnv.ANTHROPIC_API_KEY = childEnv.ANTHROPIC_AUTH_TOKEN;
+      }
       const child = spawn(this.executable, args, {
         cwd: this.workdir,
-        env: { ...process.env, NO_COLOR: '1' },
+        env: childEnv,
         windowsHide: true,
         stdio: ['pipe', 'pipe', 'pipe'],
       });
@@ -572,36 +636,26 @@ class ClaudeCodeClient {
         if (finished) return;
         finished = true;
         clearTimeout(timer);
-        if (code !== 0) {
-          const safeError = stderr
-            .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
-            .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
-            .trim()
-            .slice(0, 300);
-          let structuredError = '';
-          try {
-            const failedPayload = JSON.parse(stdout.trim());
-            structuredError = String(failedPayload.error || failedPayload.message || failedPayload.result || failedPayload.subtype || '').trim().slice(0, 300);
-          } catch {}
-          const detail = safeError || structuredError;
-          return reject(new Error(`Claude Code 调用失败(退出码 ${code})${detail ? `:${detail}` : ''}`));
-        }
-        try { resolve(JSON.parse(stdout.trim())); }
-        catch { reject(new Error(`Claude Code 未返回有效 JSON${stderr ? ',请检查 Fmode 配置' : ''}`)); }
+        const parsed = parseClaudeProcessResult(stdout, stderr, code);
+        if (parsed.error) reject(new Error(parsed.error));
+        else resolve(parsed.payload);
       });
     });
   }
 
-  async invoke(messages, context, session) {
+  async invoke(messages, context, session, options = {}) {
     const system = messages.find(message => message.role === 'system')?.content || '';
     const prompt = `${system}\n\n${this.buildPrompt(messages, context)}`;
+    const budgetLimitUsd = Number(options.maxBudgetUsd || this.config.claudeMaxBudgetUsd || 0.25);
     const args = [
       '--print',
       '--output-format', 'json',
       '--permission-mode', 'dontAsk',
+      ...(this.config.claudeBare !== false ? ['--bare'] : []),
+      ...(this.config.claudeEffort ? ['--effort', String(this.config.claudeEffort)] : []),
       '--tools', String(this.config.claudeTools || 'Read,Glob,Grep'),
       '--model', String(this.config.model || 'sonnet'),
-      '--max-budget-usd', String(this.config.claudeMaxBudgetUsd || 0.25),
+      '--max-budget-usd', String(budgetLimitUsd),
       '--json-schema', JSON.stringify(this.outputSchema()),
       '--name', session.displayName || buildClaudeSessionName(context, this.sessionKey(context)),
     ];
@@ -629,7 +683,9 @@ class ClaudeCodeClient {
         sessionName: session.displayName || buildClaudeSessionName(context, this.sessionKey(context)),
         durationMs: Number(payload.duration_ms || 0),
         costUsd: Number(payload.total_cost_usd || 0),
+        budgetLimitUsd,
         resumed: Boolean(session.initialized),
+        ...(payload.process_warning ? { processWarning: payload.process_warning } : {}),
       },
     };
   }
@@ -648,10 +704,15 @@ class ClaudeCodeClient {
         this.sessionStore.markInitialized(key);
         return result;
       } catch (error) {
-        if (/session|conversation|resume/i.test(`${error.message} ${error.subtype || ''}`)) {
+        const resetReason = claudeSessionResetReason(error);
+        if (resetReason) {
           session = this.sessionStore.reset(key, metadata);
-          const result = await this.invoke(messages, context, session);
+          const retryMaxBudgetUsd = resetReason === 'budget_exceeded'
+            ? Number(this.config.claudeRetryMaxBudgetUsd || Math.max(Number(this.config.claudeMaxBudgetUsd || 0.35) * 2, 1))
+            : undefined;
+          const result = await this.invoke(messages, context, session, { maxBudgetUsd: retryMaxBudgetUsd });
           this.sessionStore.markInitialized(key);
+          if (result.claudeCode) result.claudeCode.sessionResetReason = resetReason;
           return result;
         }
         throw error;
@@ -741,14 +802,17 @@ class QiweiAgentRuntime {
     return { error: `未知工具 ${name}` };
   }
 
-  async run({ conversation, messages, profile, customerIntelligence = {}, inboundContent }) {
+  async run({ conversation, messages, profile, customerIntelligence = {}, inboundContent, channelType = 'private', directPrompt = '' }) {
     const history = messages.slice(-16).map(message => ({
       role: message.direction === 'inbound' ? 'user' : 'assistant',
       content: message.content,
     }));
     const recommendationContext = (customerIntelligence.recommendations || []).map(item => ({ propertyId: item.property_id || item.propertyId, community: item.property_snapshot?.community || item.property?.community, totalPrice: item.property_snapshot?.totalPrice || item.property?.totalPrice, status: item.status, feedbackReason: item.feedback_reason || item.feedbackReason || '' }));
+    const groupChat = channelType === 'group';
     const system = [
-      '你是企业微信客户服务 Agent。你需要自主判断是否检索知识、客户画像或房源工具,再生成回复草稿。',
+      groupChat
+        ? '你是企业微信群聊客服 Agent。你需要理解不同群成员的发言,再生成一条供人工审核的群内回复草稿。'
+        : '你是企业微信客户服务 Agent。你需要自主判断是否检索知识、客户画像或房源工具,再生成回复草稿。',
       '严禁编造业务事实。工具没有证据时明确说需要确认。敏感或低置信内容标记 requiresHuman=true。',
       '最终必须只输出 JSON,包含 reply、confidence、intent、reason、requiresHuman、profileUpdates、tasks 和 alerts。',
       'reason 用于内部监管台,简要说明依据和不确定性,不要发送给客户。',
@@ -768,6 +832,8 @@ class QiweiAgentRuntime {
         profile,
         customerIntelligence,
         inboundContent,
+        channelType,
+        directPrompt,
       });
       if (assistant.claudeCode) {
         toolTrace.push({
@@ -777,7 +843,10 @@ class QiweiAgentRuntime {
             sessionName: assistant.claudeCode.sessionName,
             durationMs: assistant.claudeCode.durationMs,
             costUsd: assistant.claudeCode.costUsd,
+            budgetLimitUsd: assistant.claudeCode.budgetLimitUsd,
             resumed: assistant.claudeCode.resumed,
+            ...(assistant.claudeCode.processWarning ? { processWarning: assistant.claudeCode.processWarning } : {}),
+            ...(assistant.claudeCode.sessionResetReason ? { sessionResetReason: assistant.claudeCode.sessionResetReason } : {}),
           },
         });
       }
@@ -834,9 +903,12 @@ module.exports = {
   ClaudeCodeClient,
   ClaudeCodeSessionStore,
   buildClaudeSessionName,
+  claudeSessionResetReason,
+  parseClaudeProcessResult,
   selectAuthoritativeHistory,
   enforceAuthoritativeGrounding,
   extractExplicitCustomerIntelligence,
+  isNoReplyNeededMessage,
   resolveClaudeExecutable,
   QiweiAgentRuntime,
 };

+ 16 - 4
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-db.js

@@ -653,12 +653,24 @@ class AgentWorkbenchDb {
     return rows.map(row => ({ ...row, detail: parse(row.detail_json, {}) }));
   }
 
-  latestAgentState(conversationId) {
+  latestAgentOutcome(conversationId) {
     const row = this.db.prepare(`SELECT * FROM audit_logs
-      WHERE conversation_id=? AND action IN ('draft_created','agent_failed','agent_not_configured')
+      WHERE conversation_id=? AND action IN ('draft_created','agent_failed','agent_not_configured','agent_no_reply_needed')
       ORDER BY created_at DESC LIMIT 1`).get(conversationId);
-    if (!row || row.action === 'draft_created') return null;
-    return { action: row.action, message: parse(row.detail_json, {}).message || 'Agent 上游不可用', createdAt: row.created_at };
+    if (!row) return null;
+    const detail = parse(row.detail_json, {});
+    return {
+      action: row.action,
+      message: detail.message || (row.action === 'agent_no_reply_needed' ? '客户消息无需回复' : 'Agent 上游不可用'),
+      entityId: row.entity_id,
+      createdAt: row.created_at,
+    };
+  }
+
+  latestAgentState(conversationId) {
+    const outcome = this.latestAgentOutcome(conversationId);
+    if (!outcome || ['draft_created', 'agent_no_reply_needed'].includes(outcome.action)) return null;
+    return outcome;
   }
 
   getPollState(key, fallback = '') {

+ 43 - 3
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js

@@ -1,5 +1,10 @@
 const { EventEmitter } = require('events');
-const { AgentNotConfiguredError } = require('./agent-runtime');
+const { AgentNotConfiguredError, isNoReplyNeededMessage } = require('./agent-runtime');
+const { isGroupMessage } = require('./agent-poller-policy');
+
+function looksLikeGroupId(value) {
+  return /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(String(value || '').trim());
+}
 
 function propertyRecommendationsFromTrace(toolTrace = []) {
   const items = [];
@@ -48,9 +53,21 @@ class AgentWorkbenchService extends EventEmitter {
     if (!this.isAllowed(contactId)) throw new Error('该联系人不在测试白名单,禁止处理或发送');
   }
 
+  requirePrivateConversation(conversationId) {
+    const conversation = this.db.getConversation(conversationId);
+    if (looksLikeGroupId(conversation?.contact_id)) {
+      throw new Error('群聊仅用于监控与人工处理,不能进入客户私聊 Agent 发送链路');
+    }
+    const groupMessage = this.db.listMessages(conversationId, 500).some(message => {
+      try { return isGroupMessage(JSON.parse(message.raw_json || '{}')); } catch { return false; }
+    });
+    if (groupMessage) throw new Error('群聊仅用于监控与人工处理,不能进入客户私聊 Agent 发送链路');
+  }
+
   conversationDetail(conversationId) {
     const conversation = this.db.getConversation(conversationId);
     if (!conversation) return null;
+    const agentOutcome = this.db.latestAgentOutcome(conversationId);
     return {
       conversation,
       messages: this.db.listMessages(conversationId),
@@ -60,7 +77,8 @@ class AgentWorkbenchService extends EventEmitter {
       recommendations: this.db.listCustomerRecommendations(conversationId),
       drafts: this.db.listDrafts({ conversationId }),
       audit: this.db.listAudit(200, conversationId),
-      agentError: this.db.latestAgentState(conversationId),
+      agentOutcome,
+      agentError: ['agent_failed', 'agent_not_configured'].includes(agentOutcome?.action) ? agentOutcome : null,
     };
   }
 
@@ -88,6 +106,7 @@ class AgentWorkbenchService extends EventEmitter {
   }
 
   async ingestInbound({ externalId, contactId, contactName = '', content, timestamp = new Date().toISOString(), raw = null }) {
+    if (isGroupMessage(raw || {}) || looksLikeGroupId(contactId)) return { status: 'ignored_group_message' };
     if (!this.isAllowed(contactId)) return { status: 'ignored_not_allowlisted' };
     const conversation = this.db.ensureConversation(contactId, contactName);
     const contentDuplicate = this.db.findRecentInboundDuplicate(conversation.id, content, timestamp, 60);
@@ -132,6 +151,23 @@ class AgentWorkbenchService extends EventEmitter {
 
   async generateDraftForMessage(conversation, inboundMessage) {
     try {
+      if (isNoReplyNeededMessage(inboundMessage.content)) {
+        const message = '客户消息属于确认或致谢,无需继续回复';
+        this.db.audit({
+          actor: 'agent',
+          action: 'agent_no_reply_needed',
+          conversationId: conversation.id,
+          entityId: inboundMessage.id,
+          detail: { message },
+        });
+        this.emit('change', { type: 'agent_no_reply_needed', conversationId: conversation.id });
+        return {
+          status: 'no_reply_needed',
+          assistantMessage: 'Agent 已处理:该消息无需回复,没有向客户发送消息',
+          conversation,
+          message: inboundMessage,
+        };
+      }
       const profile = this.db.getProfile(conversation.id);
       const currentTasks = this.db.listCustomerTasks(conversation.id);
       const currentAlerts = this.db.listCustomerAlerts(conversation.id);
@@ -244,6 +280,7 @@ class AgentWorkbenchService extends EventEmitter {
     const conversation = this.db.getConversation(draft.conversation_id);
     if (!conversation) throw new Error('会话不存在');
     this.requireAllowed(conversation.contact_id);
+    this.requirePrivateConversation(conversation.id);
     const finalContent = String(content || draft.content).trim();
     if (!finalContent) throw new Error('回复内容不能为空');
     if (finalContent.length > 2000) throw new Error('回复内容过长');
@@ -322,9 +359,10 @@ class AgentWorkbenchService extends EventEmitter {
   async regenerateDraft(draftId, actor = 'human') {
     const draft = this.db.getDraft(draftId);
     if (!draft) throw new Error('回复草稿不存在');
-    if (draft.status === 'pending') this.rejectDraft(draftId, { actor, reason: 'regenerated' });
     const conversation = this.db.getConversation(draft.conversation_id);
     const inbound = this.db.getMessage(draft.inbound_message_id);
+    this.requirePrivateConversation(conversation.id);
+    if (draft.status === 'pending') this.rejectDraft(draftId, { actor, reason: 'regenerated' });
     this.db.audit({ actor, action: 'draft_regenerate_requested', conversationId: conversation.id, entityId: draftId });
     return this.generateDraftForMessage(conversation, inbound);
   }
@@ -333,6 +371,7 @@ class AgentWorkbenchService extends EventEmitter {
     const conversation = this.db.getConversation(conversationId);
     if (!conversation) throw new Error('会话不存在');
     this.requireAllowed(conversation.contact_id);
+    this.requirePrivateConversation(conversation.id);
     const inbound = this.db.getLatestInbound(conversationId);
     if (!inbound) throw new Error('没有可处理的客户消息');
     const existing = this.db.listDrafts({ conversationId }).find(item => item.inbound_message_id === inbound.id && item.status === 'pending');
@@ -345,6 +384,7 @@ class AgentWorkbenchService extends EventEmitter {
     const conversation = this.db.getConversation(conversationId);
     if (!conversation) throw new Error('会话不存在');
     this.requireAllowed(conversation.contact_id);
+    this.requirePrivateConversation(conversation.id);
     const text = String(content || '').trim();
     if (!text) throw new Error('回复内容不能为空');
     if (text.length > 2000) throw new Error('回复内容过长');

+ 44 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/allowlist-config.js

@@ -0,0 +1,44 @@
+const fs = require('fs');
+const path = require('path');
+
+function normalizeAllowlistIds(input) {
+  const values = Array.isArray(input) ? input : String(input || '').split(',');
+  const unique = [];
+  const seen = new Set();
+  for (const raw of values) {
+    const id = String(raw || '').trim();
+    if (!id || seen.has(id)) continue;
+    if (!/^[A-Za-z0-9._:@-]{1,128}$/.test(id)) throw new Error('联系人 ID 格式无效,请重新选择或检查手工输入');
+    seen.add(id);
+    unique.push(id);
+  }
+  if (unique.length > 200) throw new Error('单个项目最多选择 200 个白名单联系人');
+  return unique;
+}
+
+function normalizeAllowlistContact(contact = {}, fallbackName = '') {
+  const id = String(contact.userId || contact.externalUserId || contact.contactId || contact.id || '').trim();
+  if (!id) return null;
+  const displayName = String(
+    contact.remark || contact.nickname || contact.nickName || contact.userName || contact.name || fallbackName || '企微客户'
+  ).trim();
+  return {
+    id,
+    displayName: displayName || '企微客户',
+    remark: String(contact.remark || '').trim(),
+    company: String(contact.corpName || contact.companyName || contact.corpFullName || '').trim(),
+  };
+}
+
+function writeEnvValue(filePath, key, value) {
+  fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  const line = `${key}=${value}`;
+  const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '') : '';
+  const matcher = new RegExp(`^${key}\\s*=.*$`, 'm');
+  const next = matcher.test(content)
+    ? content.replace(matcher, line)
+    : `${content.replace(/\n*$/, '')}${content ? '\n' : ''}${line}\n`;
+  fs.writeFileSync(filePath, next, 'utf8');
+}
+
+module.exports = { normalizeAllowlistIds, normalizeAllowlistContact, writeEnvValue };

+ 56 - 18
claude-code/claude-code-qiwe-assistant/mcp/src/core/login-flow-server.js

@@ -1,4 +1,5 @@
 const http = require('http');
+const { randomBytes } = require('crypto');
 const { callFmodeWecomGateway } = require('../providers/fmode-wecom-gateway');
 const { SEAT_PLANS, DURATION_PLANS, QIWEI_MONTHLY_PRICE, classifySubscribeError, ERROR_CODES } = require('./subscribe-page');
 
@@ -41,7 +42,7 @@ const FLOW_PAGE_STYLE = `
 let currentServer = null;
 let currentContext = null;
 
-function buildFlowPageHtml({ monthlyPrice }) {
+function buildFlowPageHtml({ monthlyPrice, checkoutToken }) {
   return `<!DOCTYPE html>
 <html lang="zh-CN">
 <head>
@@ -62,6 +63,7 @@ function buildFlowPageHtml({ monthlyPrice }) {
 </div>
 <script>
 const PRICE = ${monthlyPrice};
+const CHECKOUT_TOKEN = ${JSON.stringify(String(checkoutToken || ''))};
 const SEATS = ${JSON.stringify(SEAT_PLANS)};
 const DURATIONS = ${JSON.stringify(DURATION_PLANS)};
 let seats = 1, months = 1, pollTimer = null, currentStep = 0, codeSubmitted = false, verifyWaits = 0, checkErrors = 0;
@@ -123,7 +125,7 @@ function renderSubscribe(detail) {
     const btn = document.getElementById('buy');
     btn.disabled = true;
     showMsg('ok', '正在开通扣费,请稍候…');
-    const r = await api('/flow/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ seats, months, idempotencyKey: subscribeKey }) });
+    const r = await api('/flow/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ seats, months, idempotencyKey: subscribeKey, checkoutToken: CHECKOUT_TOKEN }) });
     if (!r.ok) {
       showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + '] ' + (r.data.errorTitle || '开通失败') + '</b><br>' + (r.data.message || '') + '<br>' + (r.data.tip || ''));
       btn.disabled = false;
@@ -377,6 +379,20 @@ function subscribeErrorBody(httpStatus, message) {
   return { errorCode: codeKey, errorTitle: info.title || '操作失败', tip: info.tip || '', message: String(message || '') };
 }
 
+function createCheckoutToken() {
+  return randomBytes(24).toString('base64url');
+}
+
+function validCheckoutRequest(req, ctx, body) {
+  const contentType = String(req.headers['content-type'] || '').toLowerCase();
+  const origin = String(req.headers.origin || '');
+  const expectedOrigin = `http://127.0.0.1:${ctx.port}`;
+  return contentType.startsWith('application/json') &&
+    origin === expectedOrigin &&
+    typeof body.checkoutToken === 'string' &&
+    body.checkoutToken === ctx.checkoutToken;
+}
+
 async function readBody(req) {
   const chunks = [];
   for await (const chunk of req) chunks.push(chunk);
@@ -392,8 +408,9 @@ function createFlowHandler(ctx) {
     const url = new URL(req.url, 'http://localhost');
     try {
       if (url.pathname === '/' || url.pathname === '/index.html') {
-        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
-        res.end(buildFlowPageHtml({ monthlyPrice: QIWEI_MONTHLY_PRICE }));
+        ctx.checkoutToken = createCheckoutToken();
+        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
+        res.end(buildFlowPageHtml({ monthlyPrice: QIWEI_MONTHLY_PRICE, checkoutToken: ctx.checkoutToken }));
         return;
       }
       if (url.pathname === '/flow/state') {
@@ -424,20 +441,41 @@ function createFlowHandler(ctx) {
       }
       if (url.pathname === '/flow/subscribe' && req.method === 'POST') {
         const body = await readBody(req);
-        const seats = Math.max(1, Number(body.seats) || 1);
-        const months = Math.max(1, Math.min(12, Number(body.months) || 1));
-        const idempotencyKey = typeof body.idempotencyKey === 'string' && body.idempotencyKey ? body.idempotencyKey : '';
-        let last = null;
-        for (let i = 0; i < months; i++) {
-          last = await callFmodeWecomGateway({
-            gatewayPath: '/subscribe',
-            body: idempotencyKey ? { seats, idempotencyKey: `${idempotencyKey}-m${i + 1}` } : { seats },
-            token: ctx.token,
-            apiBase: ctx.apiBase
+        if (!validCheckoutRequest(req, ctx, body)) {
+          json(res, 403, {
+            errorCode: 'QW-PAY-403',
+            errorTitle: '付款确认已失效',
+            message: '本次付款确认无效或已使用。',
+            tip: '请刷新本地流程页后重新选择套餐。'
           });
+          return;
+        }
+        const seats = Number(body.seats);
+        const months = Number(body.months);
+        const idempotencyKey = String(body.idempotencyKey || '');
+        if (!Number.isInteger(seats) || seats < 1 || !Number.isInteger(months) || months < 1 || months > 12 || !/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) {
+          json(res, 400, {
+            errorCode: 'QW-PAY-400',
+            errorTitle: '套餐参数无效',
+            message: '席位、购买时长或订单编号格式不正确。',
+            tip: '请刷新本地流程页后重新选择套餐。'
+          });
+          return;
         }
-        const data = (last && last.data) || {};
-        json(res, 200, { seats: data.seats || seats, expireAt: data.expireAt || '', months });
+        const result = await callFmodeWecomGateway({
+          gatewayPath: '/subscribe',
+          body: { seats, months, idempotencyKey },
+          token: ctx.token,
+          apiBase: ctx.apiBase
+        });
+        const data = (result && result.data) || {};
+        ctx.checkoutToken = createCheckoutToken();
+        json(res, 200, {
+          seats: data.seats || seats,
+          expireAt: data.expireAt || '',
+          months: data.months || months,
+          amount: data.amount
+        });
         return;
       }
       if (url.pathname === '/flow/start-login' && req.method === 'POST') {
@@ -489,10 +527,10 @@ function createFlowHandler(ctx) {
 function startLoginFlowServer({ token, apiBase, uid, port, onQrcode } = {}) {
   const listenPort = Number(port || process.env.QIWEI_FLOW_PORT) || DEFAULT_FLOW_PORT;
   if (currentServer && currentContext) {
-    Object.assign(currentContext, { token, apiBase, uid, onQrcode });
+    Object.assign(currentContext, { token, apiBase, uid, onQrcode, checkoutToken: createCheckoutToken() });
     return Promise.resolve({ url: `http://127.0.0.1:${currentContext.port}/`, alreadyRunning: true });
   }
-  const ctx = { token, apiBase, uid, port: listenPort, qrcodeBuffer: null, onQrcode };
+  const ctx = { token, apiBase, uid, port: listenPort, qrcodeBuffer: null, checkoutToken: createCheckoutToken(), onQrcode };
   const server = http.createServer(createFlowHandler(ctx));
   return new Promise((resolve, reject) => {
     server.once('error', reject);

+ 5 - 3
claude-code/claude-code-qiwe-assistant/mcp/src/core/output-paths.js

@@ -1,7 +1,8 @@
 const fs = require('fs');
 const path = require('path');
+const { PACKAGE_ROOT, resolveWorkspaceRoot } = require('./runtime-context');
 
-const PACKAGE_ROOT = path.resolve(__dirname, '..', '..', '..');
+const WORKSPACE_ROOT = resolveWorkspaceRoot({ packageRoot: PACKAGE_ROOT });
 
 const OUTPUT_CATEGORIES = Object.freeze([
   'login',
@@ -32,13 +33,13 @@ const OUTPUT_CATEGORIES = Object.freeze([
 
 const OUTPUT_PERSISTENT_STORES = Object.freeze({
   knowledge: Object.freeze(['meetings', 'docs', 'todos', 'tasks']),
-  messages: Object.freeze(['*'])
+  messages: Object.freeze(['rooms'])
 });
 
 function outputsRoot() {
   const override = process.env.QIWEI_OUTPUTS_DIR;
   if (override && String(override).trim()) return path.resolve(String(override).trim());
-  return path.join(PACKAGE_ROOT, 'outputs');
+  return path.join(WORKSPACE_ROOT, 'outputs');
 }
 
 function ensureDir(dirPath) {
@@ -101,6 +102,7 @@ function writeRunManifest(runDir, manifest = {}) {
 
 module.exports = {
   PACKAGE_ROOT,
+  WORKSPACE_ROOT,
   OUTPUT_CATEGORIES,
   OUTPUT_PERSISTENT_STORES,
   outputsRoot,

+ 52 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/runtime-context.js

@@ -0,0 +1,52 @@
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+
+const PACKAGE_ROOT = path.resolve(__dirname, '..', '..', '..');
+
+function installedWorkspaceRoot(packageRoot = PACKAGE_ROOT) {
+  const normalized = path.resolve(packageRoot);
+  const marker = `${path.sep}.claude${path.sep}plugins${path.sep}`;
+  const index = normalized.toLowerCase().indexOf(marker.toLowerCase());
+  return index >= 0 ? normalized.slice(0, index) : '';
+}
+
+function resolveWorkspaceRoot(options = {}) {
+  const explicit = String(options.workspaceRoot || process.env.QIWEI_WORKSPACE_ROOT || '').trim();
+  if (explicit) return path.resolve(explicit);
+
+  const packageRoot = path.resolve(options.packageRoot || PACKAGE_ROOT);
+  const installedRoot = installedWorkspaceRoot(packageRoot);
+  if (installedRoot) return installedRoot;
+
+  const cwd = path.resolve(options.cwd || process.cwd());
+  const installedPlugin = path.join(cwd, '.claude', 'plugins', 'qiwei-assistant', 'package.json');
+  if (fs.existsSync(installedPlugin)) return cwd;
+  return packageRoot;
+}
+
+function workspaceIdentity(workspaceRoot = resolveWorkspaceRoot()) {
+  return crypto.createHash('sha256').update(path.resolve(workspaceRoot).toLowerCase()).digest('hex').slice(0, 12);
+}
+
+function applyWorkspaceContext(options = {}) {
+  const workspaceRoot = resolveWorkspaceRoot(options);
+  process.env.QIWEI_WORKSPACE_ROOT ||= workspaceRoot;
+  process.env.QIWEI_OUTPUTS_DIR ||= path.join(workspaceRoot, 'outputs');
+  process.env.CLAUDE_CODE_WORKDIR ||= workspaceRoot;
+  if (options.changeCwd !== false) process.chdir(workspaceRoot);
+  return {
+    packageRoot: path.resolve(options.packageRoot || PACKAGE_ROOT),
+    workspaceRoot,
+    workspaceId: workspaceIdentity(workspaceRoot),
+    outputsRoot: path.resolve(process.env.QIWEI_OUTPUTS_DIR),
+  };
+}
+
+module.exports = {
+  PACKAGE_ROOT,
+  installedWorkspaceRoot,
+  resolveWorkspaceRoot,
+  workspaceIdentity,
+  applyWorkspaceContext,
+};

+ 33 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/startup-summary.js

@@ -0,0 +1,33 @@
+function buildStartupSummary(status = {}, agent = {}) {
+  const summary = status.summary || {};
+  const data = agent.data || {};
+  const account = data.account || {};
+  const listener = data.listener || {};
+  const allowedSenderCount = Number(data.config?.allowedSenderCount || 0);
+  const checks = [
+    { key: 'auth', label: 'Fmode 鉴权', ready: Boolean(summary.authConfigured), action: '在 Fmode Studio 登录当前账号后重新启动' },
+    { key: 'subscription', label: '企微席位', ready: Boolean(summary.subscribed), action: '在工作台打开订阅与登录流程,先开通席位' },
+    { key: 'account', label: '企微账号', ready: Boolean(account.online), action: account.configured ? '在工作台点击“恢复登录”' : '在工作台完成企微扫码登录' },
+    { key: 'allowlist', label: '测试白名单', ready: allowedSenderCount > 0, action: '配置至少一个测试联系人白名单' },
+    { key: 'listener', label: 'AI 监听', ready: Boolean(listener.running), action: '账号在线后,在智能会话页点击“启动 AI 监听”' },
+  ];
+  const next = checks.find(item => !item.ready);
+  return {
+    ready: checks.every(item => item.ready),
+    checks,
+    nextAction: next?.action || '工作台已就绪,可以进入智能会话进行测试',
+    authConfigured: Boolean(summary.authConfigured),
+    subscribed: Boolean(summary.subscribed),
+    accountConfigured: Boolean(account.configured),
+    online: Boolean(account.online),
+    allowedSenderCount,
+    listenerRunning: Boolean(listener.running),
+  };
+}
+
+function startupAssistantMessage(startup = {}) {
+  const stateText = (startup.checks || []).map(item => `${item.label}${item.ready ? '完成' : '待处理'}`).join(',');
+  return `企微智能工作台已启动。${stateText}。下一步:${startup.nextAction}`;
+}
+
+module.exports = { buildStartupSummary, startupAssistantMessage };

+ 2 - 160
claude-code/claude-code-qiwe-assistant/mcp/src/core/subscribe-page.js

@@ -1,6 +1,3 @@
-const fs = require('fs');
-const { latestPath } = require('./output-paths');
-
 const QIWEI_MONTHLY_PRICE = 200;
 
 const SEAT_PLANS = [
@@ -20,7 +17,7 @@ const DURATION_PLANS = [
 const ERROR_CODES = {
   'QW-AUTH-401': { http: 401, title: '认证失败', tip: '鉴权 token 无效或已过期,请检查 QIWEI_AUTH_TOKEN 后重试。' },
   'QW-PAY-402': { http: 402, title: '飞马余额不足', tip: '账户余额不足以完成本次订阅扣费,请先充值飞马余额。' },
-  'QW-SUB-402': { http: 402, title: '订阅未开通或已到期', tip: '请在套餐页面选择席位并点击开通,扣费成功后即可继续登录。' },
+  'QW-SUB-402': { http: 402, title: '订阅未开通或已到期', tip: '请在动态流程页选择席位并完成开通,扣费成功后即可继续登录。' },
   'QW-SEAT-403': { http: 403, title: '席位已满', tip: '当前订阅席位已全部占用,请增购席位或停用闲置设备。' },
   'QW-UP-502': { http: 502, title: '企业微信服务暂时不可用', tip: '网关或上游服务异常,请稍后重试。' }
 };
@@ -34,165 +31,10 @@ function classifySubscribeError(httpStatus, message) {
   return 'QW-UP-502';
 }
 
-function buildSubscribePageHtml({ apiBase, token, monthlyPrice = QIWEI_MONTHLY_PRICE }) {
-  const plansJson = JSON.stringify(SEAT_PLANS);
-  const durationsJson = JSON.stringify(DURATION_PLANS);
-  const errorsJson = JSON.stringify(ERROR_CODES);
-  return `<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-<meta charset="utf-8">
-<title>企微助手 · 套餐与服务</title>
-<style>
-  :root { --brand:#fa8c16; --brand-bg:#fff7e6; --border:#f0f0f0; }
-  * { box-sizing:border-box; }
-  body { font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif; margin:0; background:#f7f8fa; color:#333; }
-  .wrap { max-width:1080px; margin:0 auto; padding:32px 24px; }
-  h1 { font-size:22px; margin:0 0 4px; }
-  .sub { color:#999; font-size:13px; margin-bottom:24px; }
-  .layout { display:flex; gap:24px; align-items:flex-start; }
-  .main { flex:1; background:#fff; border:1px solid var(--border); border-radius:12px; padding:24px; }
-  .section-title { font-size:14px; color:#666; margin:18px 0 10px; }
-  .cards { display:flex; flex-wrap:wrap; gap:12px; }
-  .card { position:relative; flex:1; min-width:120px; border:1px solid var(--border); border-radius:10px; padding:14px 16px; cursor:pointer; text-align:center; background:#fff; transition:all .15s; }
-  .card:hover { border-color:var(--brand); }
-  .card.active { border-color:var(--brand); background:var(--brand-bg); box-shadow:0 2px 8px rgba(250,140,22,.12); }
-  .card .t { font-size:15px; font-weight:600; }
-  .card .p { color:var(--brand); font-size:14px; margin-top:6px; }
-  .card .n { color:#999; font-size:12px; margin-top:4px; }
-  .badge { position:absolute; top:-9px; right:10px; background:var(--brand); color:#fff; font-size:11px; border-radius:8px; padding:1px 8px; }
-  .side { width:300px; background:#fff; border:1px solid var(--border); border-radius:12px; padding:22px; }
-  .side h2 { font-size:14px; color:#666; margin:0 0 12px; font-weight:normal; }
-  .total { font-size:30px; color:#e64545; font-weight:700; }
-  .total small { font-size:13px; color:#999; font-weight:normal; }
-  .detail { color:#999; font-size:12px; margin:8px 0 18px; line-height:1.8; }
-  button.buy { width:100%; border:none; background:var(--brand); color:#fff; font-size:16px; padding:12px 0; border-radius:8px; cursor:pointer; }
-  button.buy:disabled { opacity:.6; cursor:not-allowed; }
-  .msg { margin-top:14px; font-size:13px; line-height:1.7; border-radius:8px; padding:10px 12px; display:none; }
-  .msg.ok { display:block; background:#f0fff4; border:1px solid #b7eb8f; color:#237804; }
-  .msg.err { display:block; background:#fff1f0; border:1px solid #ffa39e; color:#a8071a; }
-  .tips { background:var(--brand-bg); border:1px solid #ffe7ba; border-radius:10px; padding:12px 16px; font-size:13px; color:#ad6800; margin-bottom:18px; }
-</style>
-</head>
-<body>
-<div class="wrap">
-  <h1>套餐与服务</h1>
-  <div class="sub">开通企微账号:每个账号 ¥${monthlyPrice}/月,从飞马余额扣费,开通后立即生效</div>
-  <div class="layout">
-    <div class="main">
-      <div class="tips">首次使用需先开通包月订阅,开通成功后再进行扫码登录(第 2 步)。订阅按「席位数 × ¥${monthlyPrice} × 月数」一次性扣除飞马余额。</div>
-      <div class="section-title">选择账号数量(席位)</div>
-      <div class="cards" id="seat-cards"></div>
-      <div class="section-title">购买时长</div>
-      <div class="cards" id="duration-cards" style="max-width:420px"></div>
-    </div>
-    <div class="side">
-      <h2>订单预览</h2>
-      <div class="total" id="total">¥0 <small>/ 一次性扣费</small></div>
-      <div class="detail" id="detail"></div>
-      <button class="buy" id="buy">开通服务(立即扣费)</button>
-      <div class="msg" id="msg"></div>
-    </div>
-  </div>
-</div>
-<script>
-const API_BASE = ${JSON.stringify(String(apiBase || '').replace(/\/$/, ''))};
-const TOKEN = ${JSON.stringify(String(token || ''))};
-const PRICE = ${monthlyPrice};
-const SEATS = ${plansJson};
-const DURATIONS = ${durationsJson};
-const ERRORS = ${errorsJson};
-let seats = 1, months = 1;
-
-function renderCards(el, items, key, current, onPick) {
-  el.innerHTML = '';
-  items.forEach(item => {
-    const div = document.createElement('div');
-    div.className = 'card' + (item[key] === current ? ' active' : '');
-    div.innerHTML = (item.recommended ? '<span class="badge">推荐</span>' : '') +
-      '<div class="t">' + item.label + '</div>' +
-      '<div class="p">¥' + (key === 'seats' ? item.seats * PRICE + '/月' : (item.months * PRICE) + '/号') + '</div>' +
-      (item.note ? '<div class="n">' + item.note + '</div>' : '');
-    div.onclick = () => onPick(item[key]);
-    el.appendChild(div);
-  });
-}
-
-function render() {
-  renderCards(document.getElementById('seat-cards'), SEATS, 'seats', seats, v => { seats = v; render(); });
-  renderCards(document.getElementById('duration-cards'), DURATIONS, 'months', months, v => { months = v; render(); });
-  const total = seats * months * PRICE;
-  document.getElementById('total').innerHTML = '¥' + total.toLocaleString() + ' <small>/ 一次性扣费</small>';
-  document.getElementById('detail').textContent = seats + ' 个号 × ¥' + PRICE + '/月 × ' + months + ' 个月,从飞马余额扣除';
-}
-
-function showMsg(type, html) {
-  const el = document.getElementById('msg');
-  el.className = 'msg ' + type;
-  el.innerHTML = html;
-}
-
-document.getElementById('buy').onclick = async () => {
-  const btn = document.getElementById('buy');
-  btn.disabled = true;
-  showMsg('ok', '正在开通,请稍候…');
-  try {
-    let lastData = null;
-    for (let i = 0; i < months; i++) {
-      const res = await fetch(API_BASE + '/subscribe', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN },
-        body: JSON.stringify({ seats })
-      });
-      lastData = await res.json().catch(() => ({}));
-      if (!res.ok || (lastData.code !== undefined && ![0, 200].includes(Number(lastData.code)))) {
-        const message = lastData.mess || lastData.msg || lastData.message || ('HTTP ' + res.status);
-        const codeKey = classify(res.status, message);
-        const info = ERRORS[codeKey];
-        showMsg('err', '<b>[' + codeKey + '] ' + info.title + '</b><br>' + message + '<br>' + info.tip);
-        btn.disabled = false;
-        return;
-      }
-    }
-    const d = (lastData && lastData.data) || {};
-    showMsg('ok', '<b>开通成功!</b><br>席位:' + (d.seats || seats) + ',到期时间:' + (d.expireAt || '已延长 ' + months + ' 个月') + '。<br>请回到对话中重新执行 qiwei_login_start 继续扫码登录。');
-  } catch (e) {
-    showMsg('err', '<b>[QW-UP-502] 网络请求失败</b><br>' + e.message + '<br>请确认网关服务可用后重试。');
-    btn.disabled = false;
-  }
-};
-
-function classify(status, message) {
-  const text = String(message || '');
-  if (status === 401 || /认证失败/.test(text)) return 'QW-AUTH-401';
-  if (/余额不足/.test(text)) return 'QW-PAY-402';
-  if (status === 402) return 'QW-SUB-402';
-  if (status === 403 || /席位/.test(text)) return 'QW-SEAT-403';
-  return 'QW-UP-502';
-}
-
-render();
-</script>
-</body>
-</html>`;
-}
-
-function saveSubscribePage({ apiBase, token, monthlyPrice } = {}) {
-  try {
-    const htmlPath = latestPath('subscription', 'qiwei-subscribe.html');
-    fs.writeFileSync(htmlPath, buildSubscribePageHtml({ apiBase, token, monthlyPrice }), 'utf8');
-    return htmlPath;
-  } catch {
-    return '';
-  }
-}
-
 module.exports = {
   QIWEI_MONTHLY_PRICE,
   SEAT_PLANS,
   DURATION_PLANS,
   ERROR_CODES,
-  classifySubscribeError,
-  buildSubscribePageHtml,
-  saveSubscribePage
+  classifySubscribeError
 };

+ 296 - 38
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -1,7 +1,7 @@
 const fs = require('fs');
 const path = require('path');
 const crypto = require('crypto');
-const { latestPath } = require('../core/output-paths');
+const { PACKAGE_ROOT, WORKSPACE_ROOT, latestPath } = require('../core/output-paths');
 const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
 const { AgentKnowledgeStore } = require('../core/agent-knowledge');
 const { QiweiAgentRuntime, extractExplicitCustomerIntelligence } = require('../core/agent-runtime');
@@ -13,11 +13,14 @@ const {
   completeTodoKnowledge,
 } = require('./official-office-knowledge-service');
 const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
-const { messageTimestamp, evaluatePolledMessage } = require('../core/agent-poller-policy');
+const { messageTimestamp, roomIdOf, isGroupMessage, evaluatePolledMessage } = require('../core/agent-poller-policy');
 const { setActiveQiweiContext } = require('../core/credentials');
 const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
+const { responseMonitor } = require('./response-monitor-service');
+const { GroupAgentService } = require('./group-agent-service');
+const { normalizeAllowlistIds, normalizeAllowlistContact, writeEnvValue } = require('../core/allowlist-config');
 
-const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
+const PROJECT_ROOT = WORKSPACE_ROOT;
 const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
 
 function readEnvFile(filePath) {
@@ -78,19 +81,25 @@ function loadAgentConfig(overrides = {}) {
   const provider = value('AGENT_PROVIDER', 'claude-code');
   const anthropic = provider === 'anthropic';
   const claudeCode = provider === 'claude-code';
-  const bundledPropertyFile = path.join(PROJECT_ROOT, 'knowledge-base', 'property-data', 'properties.json');
-  const workspacePropertyFile = path.resolve(PROJECT_ROOT, '..', '..', 'huaxiangpipei', 'src', 'assets', 'data', 'properties.json');
+  const projectPropertyFile = path.join(PROJECT_ROOT, 'knowledge-base', 'property-data', 'properties.json');
+  const bundledPropertyFile = path.join(PACKAGE_ROOT, 'knowledge-base', 'property-data', 'properties.json');
+  const workspacePropertyFile = path.resolve(PACKAGE_ROOT, '..', '..', 'huaxiangpipei', 'src', 'assets', 'data', 'properties.json');
   const configuredPropertyFile = value('QIWEI_AGENT_PROPERTY_DATA_FILE');
   const propertyDataFile = configuredPropertyFile
     ? resolvePath(configuredPropertyFile)
-    : (fs.existsSync(bundledPropertyFile) ? bundledPropertyFile : (fs.existsSync(workspacePropertyFile) ? workspacePropertyFile : ''));
+    : (fs.existsSync(projectPropertyFile)
+      ? projectPropertyFile
+      : (fs.existsSync(bundledPropertyFile) ? bundledPropertyFile : (fs.existsSync(workspacePropertyFile) ? workspacePropertyFile : '')));
   const baseConfig = {
     dbPath: resolvePath(value('QIWEI_AGENT_DB_PATH'), latestPath('messages', 'agent-workbench.db')),
-    legacyDbPath: path.resolve(PROJECT_ROOT, '..', '..', 'qiwei-agent-workbench', 'data', 'workbench.db'),
+    legacyDbPath: path.resolve(PACKAGE_ROOT, '..', '..', 'qiwei-agent-workbench', 'data', 'workbench.db'),
     globalDefaultPaused: bool('QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED', true),
     conversationDefaultMode: value('QIWEI_AGENT_DEFAULT_MODE', 'review'),
     autoSendConfidence: number('QIWEI_AGENT_AUTO_SEND_CONFIDENCE', 0.88, 0, 1),
-    knowledgeDir: resolvePath(value('QIWEI_AGENT_KNOWLEDGE_DIR'), path.join(PROJECT_ROOT, 'knowledge')),
+    knowledgeDir: resolvePath(
+      value('QIWEI_AGENT_KNOWLEDGE_DIR'),
+      fs.existsSync(path.join(PROJECT_ROOT, 'knowledge')) ? path.join(PROJECT_ROOT, 'knowledge') : path.join(PACKAGE_ROOT, 'knowledge')
+    ),
     propertyDataFile,
     agent: {
       provider,
@@ -105,6 +114,9 @@ function loadAgentConfig(overrides = {}) {
       claudeMainSessionId: value('QIWEI_AGENT_MAIN_SESSION_ID'),
       claudeTimeoutMs: number('CLAUDE_CODE_TIMEOUT_MS', 120000, 15000, 300000),
       claudeMaxBudgetUsd: number('CLAUDE_CODE_MAX_BUDGET_USD', 0.35, 0.05, 5),
+      claudeRetryMaxBudgetUsd: number('CLAUDE_CODE_RETRY_MAX_BUDGET_USD', 1, 0.1, 5),
+      claudeBare: bool('CLAUDE_CODE_BARE', true),
+      claudeEffort: value('CLAUDE_CODE_EFFORT', 'low'),
       claudeTools: value('CLAUDE_CODE_ALLOWED_TOOLS', 'Read,Glob,Grep'),
     },
     qiwei: {
@@ -122,6 +134,9 @@ function loadAgentConfig(overrides = {}) {
       initialSyncLimit: number('QIWEI_AGENT_INITIAL_SYNC_LIMIT', 5000, 100, 5000),
       initialSyncMaxPages: number('QIWEI_AGENT_INITIAL_SYNC_MAX_PAGES', 200, 10, 500),
       startupGraceSeconds: number('QIWEI_AGENT_STARTUP_GRACE_SECONDS', 10, 0, 60),
+      trustConfiguredOnStatusError: bool('QIWEI_TRUST_CONFIGURED_ON_STATUS_ERROR', false),
+      responseReminderMinutes: number('QIWEI_RESPONSE_REMINDER_MINUTES', 15, 1, 1440),
+      responseUrgentMinutes: number('QIWEI_RESPONSE_URGENT_MINUTES', 60, 1, 10080),
     },
   };
   const config = {
@@ -282,6 +297,13 @@ class QiweiAgentPoller {
   }
 
   async process(message) {
+    if (isGroupMessage(message)) {
+      try { await groupAgentService.ingestPolledMessage(message, this.config); }
+      catch (error) {
+        this.db.audit({ actor: 'agent', action: 'group_agent_failed', detail: { roomId: roomIdOf(message), message: error.message } });
+      }
+      return;
+    }
     const candidate = evaluatePolledMessage(message, this.config);
     if (!candidate.eligible) return;
     const { content, senderId, timestamp } = candidate;
@@ -291,7 +313,17 @@ class QiweiAgentPoller {
       contactName: message.senderName || '王刚',
       content,
       timestamp: new Date(timestamp * 1000).toISOString(),
-      raw: { seq: message.seq, msgType: message.msgType, timestamp: message.timestamp },
+      raw: {
+        seq: message.seq,
+        msgType: message.msgType,
+        timestamp: message.timestamp,
+        senderId: message.senderId,
+        receiverId: message.receiverId,
+        senderName: message.senderName,
+        fromRoomId: message.fromRoomId || null,
+        isRoomNotice: message.isRoomNotice,
+        IsWWWXOutRoom: message.IsWWWXOutRoom,
+      },
     });
   }
 }
@@ -388,6 +420,12 @@ function activeAccountMetadata() {
   };
 }
 
+const groupAgentService = new GroupAgentService({
+  projectRoot: PROJECT_ROOT,
+  getRuntime: () => workbench,
+  getAccount: () => activeAccountMetadata(),
+});
+
 function applyActiveAccountContext(account) {
   setActiveQiweiContext(account);
   Object.assign(workbench.config.qiwei, account);
@@ -413,9 +451,10 @@ workbenches.set(accountRuntimeKey(initialAccount), workbench);
 applyActiveAccountContext(initialAccount);
 
 async function switchActiveAccount(input = {}) {
+  const requestedGuid = String(input.guid || '').trim();
   const account = {
     uid: String(input.uid || '').trim(),
-    guid: String(input.guid || '').trim(),
+    guid: requestedGuid === 'server-managed' ? '' : requestedGuid,
     apiBase: String(input.apiBase || '').trim(),
     userId: String(input.userId || '').trim(),
     nickname: String(input.nickname || input.userId || '').trim(),
@@ -438,11 +477,13 @@ async function switchActiveAccount(input = {}) {
     workbenches.set(nextKey, nextWorkbench);
   }
   workbench = nextWorkbench;
-  applyActiveAccountContext({
-    ...activeAccountMetadata(),
+  const currentAccount = activeAccountMetadata();
+  const accountPatch = {
     ...account,
-    apiBase: account.apiBase || activeAccountMetadata().apiBase,
-  });
+    apiBase: account.apiBase || currentAccount.apiBase,
+  };
+  if (!accountPatch.guid) delete accountPatch.guid;
+  applyActiveAccountContext({ ...currentAccount, ...accountPatch });
   const status = provisionalAccountStatus();
   accountStatusCache = { checkedAt: Date.now(), value: status };
   void refreshAccountStatus();
@@ -479,7 +520,7 @@ async function refreshAccountStatus() {
         nickname: data.nickname || selected.nickname || selected.userId || '当前企微账号',
         corpName: data.corpName || selected.corpName || '',
         statusCode: data.userOnlineStatus ?? null,
-        statusText: online ? '账号在线' : '账号离线',
+        statusText: online ? (data.statusFallback ? '账号在线(状态接口待复核)' : '账号在线') : '账号离线',
       };
       if (online) {
         accountLastOnlineAt.set(refreshKey, Date.now());
@@ -509,6 +550,10 @@ async function refreshAccountStatus() {
     if (accountRuntimeKey(activeAccountMetadata()) === refreshKey) {
       accountStatusCache = { checkedAt: Date.now(), value: next };
     }
+    if (next.userId) {
+      targetWorkbench.config.qiwei.userId = String(next.userId);
+      targetWorkbench.config.qiwei.selfUserId = String(next.userId);
+    }
     return next;
   })().finally(() => {
     if (accountStatusRefreshKey === refreshKey) {
@@ -622,6 +667,24 @@ function backfillPropertyRecommendations(db, knowledge) {
   return count;
 }
 
+function conversationChannelInfo(row, messages = []) {
+  const contactId = String(row?.contact_id || '').trim();
+  let roomId = '';
+  for (const message of messages) {
+    const raw = parseJson(message.raw_json, {});
+    if (!isGroupMessage(raw)) continue;
+    roomId = roomIdOf(raw) || contactId;
+    break;
+  }
+  if (!roomId && /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(contactId)) roomId = contactId;
+  const channelType = roomId ? 'group' : 'private';
+  return {
+    channelType,
+    channelLabel: channelType === 'group' ? '客户群聊' : '客户私聊',
+    channelId: roomId || contactId,
+  };
+}
+
 function publicConversation(row) {
   const detail = workbench.service.conversationDetail(row.id);
   const claudeSession = getCustomerSessionGuide(row, {
@@ -634,6 +697,9 @@ function publicConversation(row) {
     : [];
   const pending = currentDrafts.find(item => item.status === 'pending') || null;
   const latestDraft = pending || currentDrafts.find(item => ['sent', 'approved'].includes(item.status)) || null;
+  const currentAgentOutcome = latestInbound && detail.agentOutcome?.entityId === latestInbound.id ? detail.agentOutcome : null;
+  const agentError = ['agent_failed', 'agent_not_configured'].includes(currentAgentOutcome?.action) ? currentAgentOutcome : null;
+  const agentNotice = currentAgentOutcome?.action === 'agent_no_reply_needed' ? currentAgentOutcome : null;
   const rawProfile = detail.profile?.profile || {};
   const { __evidence: profileEvidence = {}, ...profile } = rawProfile;
   const customerTasks = detail.tasks || [];
@@ -644,6 +710,7 @@ function publicConversation(row) {
   const displayMessages = [...new Map((detail.messages || []).map(message => [message.id, message])).values()]
     .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))
     .slice(-60);
+  const channel = conversationChannelInfo(row, displayMessages);
   const visibleEntityIds = new Set(displayMessages.map(message => message.id));
   const visibleAudit = (detail.audit || []).filter(item =>
     Date.parse(item.created_at) >= cutoverAt || visibleEntityIds.has(item.entity_id)
@@ -652,6 +719,7 @@ function publicConversation(row) {
     id: row.id,
     displayName: row.contact_name || '白名单测试联系人',
     maskedId: maskedId(row.contact_id),
+    ...channel,
     mode: row.mode,
     source: 'live',
     claudeSession,
@@ -665,14 +733,14 @@ function publicConversation(row) {
     })),
     analysis: {
       intent: latestDraft?.intent || '',
-      intentLabel: latestDraft?.intent || (detail.agentError ? 'Agent 上游不可用' : '待 Agent 处理'),
+      intentLabel: latestDraft?.intent || (agentError ? 'Agent 上游不可用' : agentNotice ? '无需回复' : '待 Agent 处理'),
       demand: profile,
       completenessScore: completeness(profile),
       matches: propertyMatches(latestDraft?.tool_trace || []),
       knowledgeSources: citations.length
         ? citations.map(item => `${item.heading || item.source}${item.source ? ` · ${item.source}` : ''}`)
         : ['真实企微消息', '客户画像', '企业规则库与知识库'],
-      reasoning: latestDraft?.reason || detail.agentError?.message || '消息已进入真实企微链路,等待 Agent 生成可审核草稿。',
+      reasoning: latestDraft?.reason || agentError?.message || agentNotice?.message || '消息已进入真实企微链路,等待 Agent 生成可审核草稿。',
     },
     customerIntelligence: {
       profile,
@@ -744,7 +812,8 @@ function publicConversation(row) {
     } : null,
     drafts,
     audit: visibleAudit,
-    agentError: detail.agentError,
+    agentError,
+    agentNotice,
     lastMessageAt: displayMessages.at(-1)?.created_at || row.last_message_at,
     updatedAt: row.updated_at,
   };
@@ -780,12 +849,200 @@ async function getAgentStatus() {
   };
 }
 
+async function getAllowlistCandidates() {
+  const selectedIds = [...workbench.config.qiwei.allowedSenders];
+  const candidates = new Map();
+  let remoteCount = 0;
+  let warning = '';
+
+  try {
+    const account = await detectAccountStatus(true);
+    if (account.online) {
+      const result = await workbench.qiwei.listExternalContacts({ limit: 500, maxPages: 10 });
+      remoteCount = Number(result.contactCount) || result.contacts.length;
+      for (const item of result.contacts) {
+        const contact = normalizeAllowlistContact(item);
+        if (contact) candidates.set(contact.id, contact);
+      }
+    } else {
+      warning = '当前企微账号离线,只显示已保存或已有会话中的联系人';
+    }
+  } catch {
+    warning = '联系人列表暂时读取失败,仍可管理已保存联系人或手工添加联系人 ID';
+  }
+
+  for (const row of workbench.db.listConversations()) {
+    const contact = normalizeAllowlistContact({ contactId: row.contact_id }, row.contact_name);
+    if (contact && !candidates.has(contact.id)) candidates.set(contact.id, contact);
+  }
+  for (const id of selectedIds) {
+    if (!candidates.has(id)) candidates.set(id, normalizeAllowlistContact({ id }, '已保存联系人'));
+  }
+
+  const contacts = [...candidates.values()]
+    .map(contact => ({ ...contact, selected: selectedIds.includes(contact.id) }))
+    .sort((a, b) => Number(b.selected) - Number(a.selected) || a.displayName.localeCompare(b.displayName, 'zh-CN'));
+  return {
+    status: 'ok',
+    assistantMessage: warning || `已读取 ${contacts.length} 位可选联系人`,
+    summary: { selectedCount: selectedIds.length, candidateCount: contacts.length, remoteCount },
+    data: { selectedIds, contacts },
+    warnings: warning ? [warning] : [],
+    errors: [],
+  };
+}
+
+function updateAllowlist(input = {}) {
+  const ids = normalizeAllowlistIds(input.contactIds || input.allowedSenders || []);
+  const serialized = ids.join(',');
+  writeEnvValue(ENV_FILE, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS', serialized);
+  fileEnv.QIWEI_AUTO_REPLY_ALLOWED_SENDERS = serialized;
+  process.env.QIWEI_AUTO_REPLY_ALLOWED_SENDERS = serialized;
+
+  let listenerStopped = false;
+  for (const target of workbenches.values()) {
+    target.config.qiwei.allowedSenders = [...ids];
+    if (!ids.length && target.poller.status().running) {
+      target.poller.stop();
+      listenerStopped = true;
+    }
+  }
+  workbench.db.audit({
+    actor: 'human',
+    action: 'allowlist_updated',
+    detail: { count: ids.length, listenerStopped },
+  });
+  return {
+    status: 'ok',
+    assistantMessage: ids.length
+      ? `白名单已保存,共 ${ids.length} 位联系人,立即生效`
+      : '白名单已清空,AI 监听已停止',
+    summary: { selectedCount: ids.length, listenerStopped },
+    data: { selectedIds: ids, selectedCount: ids.length },
+    warnings: [],
+    errors: [],
+  };
+}
+
 function displayableConversations() {
   return workbench.db.listConversations().filter(item => item.last_message_at || item.last_content || Number(item.pending_count || 0) > 0);
 }
 
 function getConversations() {
-  return { status: 'ok', data: { conversations: displayableConversations().map(publicConversation) } };
+  const conversations = displayableConversations().map(publicConversation);
+  return {
+    status: 'ok',
+    data: {
+      conversations,
+      summary: {
+        privateCount: conversations.filter(item => item.channelType === 'private').length,
+        groupCount: conversations.filter(item => item.channelType === 'group').length,
+      },
+    },
+  };
+}
+
+function getResponseMonitor() {
+  const allowlist = new Set(workbench.config.qiwei.allowedSenders.map(String));
+  const conversations = displayableConversations()
+    .filter(row => allowlist.has(String(row.contact_id || '')))
+    .flatMap(row => {
+      const conversation = publicConversation(row);
+      if (conversation.channelType === 'group') return [];
+      return [{
+        id: conversation.id,
+        contactId: row.contact_id,
+        displayName: conversation.displayName,
+        messages: conversation.messages,
+        handledMessageId: conversation.agentNotice?.entityId || '',
+      }];
+    });
+  const account = activeAccountMetadata();
+  const data = responseMonitor({
+    projectRoot: PROJECT_ROOT,
+    conversations,
+    selfUserIds: [workbench.config.qiwei.selfUserId, account.userId],
+    selfNames: [account.nickname],
+    warningMinutes: workbench.config.qiwei.responseReminderMinutes,
+    urgentMinutes: workbench.config.qiwei.responseUrgentMinutes,
+  });
+  return {
+    status: 'ok',
+    assistantMessage: data.summary.overdue
+      ? `当前有 ${data.summary.overdue} 个会话超过回复时效,请客服优先处理。`
+      : '当前监听范围内没有超时未回复会话。',
+    summary: data.summary,
+    data,
+    warnings: [],
+    errors: [],
+  };
+}
+
+function getGroupAgents() {
+  const groups = groupAgentService.list();
+  return {
+    status: 'ok',
+    assistantMessage: `已读取 ${groups.length} 个客户群的智能回复状态`,
+    summary: {
+      groupCount: groups.length,
+      pendingCount: groups.filter(item => item.pendingReply).length,
+      errorCount: groups.filter(item => item.agentError || item.sendError).length,
+      autoCount: groups.filter(item => item.mode === 'auto').length,
+    },
+    data: { groups },
+    warnings: [],
+    errors: [],
+  };
+}
+
+async function generateGroupReply(roomId, options = {}) {
+  try {
+    const result = await groupAgentService.generate(roomId, options);
+    return {
+      status: 'ok',
+      assistantMessage: result.status === 'no_reply_needed'
+        ? 'Agent 判断这条群消息无需回复'
+        : result.status === 'auto_sent' ? '群聊 Agent 已忽略风险并自动发送回复' : '群聊 Agent 已生成待审核草稿',
+      data: result,
+    };
+  } catch {
+    throw new Error('群聊 Agent 暂时不可用,消息已保留,可稍后重试或人工回复');
+  }
+}
+
+function changeGroupMode(roomId, mode, confirmation) {
+  const group = groupAgentService.setMode(roomId, mode, confirmation);
+  return {
+    status: 'ok',
+    assistantMessage: group.mode === 'auto'
+      ? '群聊全自动模式已开启:后续 Agent 非空回复将忽略风险并直接发送'
+      : '群聊已切回人工审核模式',
+    data: { group },
+  };
+}
+
+async function approveGroupDraft(roomId, draftId, content) {
+  try {
+    const result = await groupAgentService.approve(roomId, draftId, content);
+    return { status: 'ok', assistantMessage: '群聊回复已真实发送', data: result };
+  } catch (error) {
+    if (/不存在|已经是|不能为空|过长|尚未确认为/.test(error.message)) throw error;
+    throw new Error('群聊发送失败,请确认企微账号在线后重试');
+  }
+}
+
+function rejectGroupDraft(roomId, draftId, reason) {
+  const result = groupAgentService.reject(roomId, draftId, reason);
+  return { status: 'ok', assistantMessage: '群聊草稿已驳回', data: result };
+}
+
+async function regenerateGroupDraft(roomId, draftId) {
+  try {
+    const result = await groupAgentService.regenerate(roomId, draftId);
+    return { status: 'ok', assistantMessage: '群聊 Agent 已重新生成待审核草稿', data: result };
+  } catch {
+    throw new Error('群聊 Agent 暂时不可用,原草稿已保留');
+  }
 }
 
 function updateCustomerProfile(conversationId, input = {}) {
@@ -881,6 +1138,7 @@ async function syncConversations() {
     scannedMessages += list.length;
 
     for (const message of list) {
+      if (isGroupMessage(message)) continue;
       const senderId = String(message.senderId || '');
       const receiverId = String(message.receiverId || '');
       const contactId = allowlist.has(senderId) ? senderId : allowlist.has(receiverId) ? receiverId : '';
@@ -1017,7 +1275,7 @@ async function generateLatestDraft(conversationId) {
   const result = await workbench.service.generateLatestDraft(conversationId, 'human');
   return {
     status: 'ok',
-    assistantMessage: result.status === 'pending_review' ? 'Agent 已生成待审核草稿' : (result.error || 'Agent 未生成草稿'),
+    assistantMessage: result.assistantMessage || (result.status === 'pending_review' ? 'Agent 已生成待审核草稿' : (result.error || 'Agent 未生成草稿')),
     data: result,
   };
 }
@@ -1074,30 +1332,21 @@ function getAudit(limit = 200) {
   return { status: 'ok', data: { audit: workbench.db.listAudit(Math.max(1, Math.min(500, Number(limit) || 200))) } };
 }
 
-async function startListener() {
-  const account = await detectAccountStatus(true);
+async function startListenerForWorkbench(target, account) {
   if (!account.online) throw new Error(`${account.nickname || '当前账号'}不在线,无法启动真实消息监听`);
-  workbench.service.setGlobal({ paused: false, defaultMode: 'auto' });
-  for (const conversation of workbench.db.listConversations()) {
-    workbench.service.setConversationMode(conversation.id, 'auto');
-  }
-  let status;
-  try {
-    status = await workbench.poller.start();
-  } catch (error) {
-    workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
-    for (const conversation of workbench.db.listConversations()) {
-      workbench.service.setConversationMode(conversation.id, 'human');
-    }
-    throw error;
-  }
+  const status = await target.poller.start();
   return {
     status: 'ok',
-    assistantMessage: 'AI 监听已启动:仅处理白名单联系人,高置信回复可自动发送,人工可随时接管',
+    assistantMessage: 'AI 监听已启动:白名单私聊按当前策略处理;已确认客户群自动生成待审核草稿,不会自动群发',
     data: status,
   };
 }
 
+async function startListener() {
+  const account = await detectAccountStatus(true);
+  return startListenerForWorkbench(workbench, account);
+}
+
 function stopListener() {
   const status = workbench.poller.stop();
   workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
@@ -1114,7 +1363,16 @@ function getAgentRuntimeConfig() {
 module.exports = {
   switchActiveAccount,
   getAgentStatus,
+  getAllowlistCandidates,
+  updateAllowlist,
   getConversations,
+  getResponseMonitor,
+  getGroupAgents,
+  changeGroupMode,
+  generateGroupReply,
+  approveGroupDraft,
+  rejectGroupDraft,
+  regenerateGroupDraft,
   updateCustomerProfile,
   updateCustomerRecommendation,
   syncConversations,
@@ -1134,5 +1392,5 @@ module.exports = {
   stopListener,
   getAgentRuntimeConfig,
   createWorkbench,
-  __testing: { loadAgentConfig, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage, groupAgentService, startListenerForWorkbench },
 };

Разница между файлами не показана из-за своего большого размера
+ 617 - 63
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js


+ 1 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/customer-master-service.js

@@ -175,6 +175,7 @@ function normalizeCustomer(conversation) {
   const evidenced = fields.filter(item => item.evidence?.text).length;
   const customer = {
     id: conversation.id,
+    externalUserId: conversation.channelId || conversation.contactId || '',
     displayName: conversation.displayName,
     maskedId: conversation.maskedId,
     source: conversation.source === 'live' ? '真实企微会话' : conversation.source,

+ 345 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/group-agent-service.js

@@ -0,0 +1,345 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { roomIdOf } = require('../core/agent-poller-policy');
+const { isStaffMessage, timestampOf } = require('./response-monitor-service');
+const {
+  appendWebhookMessage,
+  readRoomMessages,
+  readConfirmedMapping,
+} = require('../tools/qiwei-group-management-run');
+
+function readJson(filePath, fallback) {
+  try { return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); }
+  catch { return fallback; }
+}
+
+function messageContent(message = {}) {
+  return String(message.content || message.msgData?.content || '').trim();
+}
+
+function messageId(message = {}) {
+  return String(message.msgId || message.msgUniqueIdentifier || message.msgServerId || message.id || message.seq || '').trim();
+}
+
+function groupPrompt(roomName, messages, latest) {
+  const history = messages.slice(-12).map(message => {
+    const speaker = message.direction === 'outbound' ? '客服' : (message.senderName || '群成员');
+    return `${speaker}:${message.content}`;
+  }).join('\n');
+  return [
+    '你是企业微信群聊客服 Agent。请根据群聊最近消息,为客服生成一条待人工审核的群内回复草稿。',
+    '只能依据本轮群聊和可检索的企业知识,不得引用其他客户私聊,不得编造价格、承诺、库存或个人信息。',
+    '回复对象是整个客户群:语气简洁自然,必要时称呼最新发言人;不要输出 Markdown,不要暴露内部推理。',
+    '如果最新消息只是致谢、确认或明显无需回复,reply 返回空字符串。',
+    '最终严格输出 JSON:reply、confidence、intent、reason、requiresHuman、profileUpdates、tasks、alerts。',
+    '群聊场景必须设置 requiresHuman=true;profileUpdates 使用空对象,tasks 和 alerts 使用空数组。',
+    '',
+    `群名称:${roomName}`,
+    `最新发言:${latest.senderName || '群成员'}:${latest.content}`,
+    '本轮群聊:',
+    history,
+  ].join('\n');
+}
+
+class GroupAgentService {
+  constructor(options = {}) {
+    this.projectRoot = path.resolve(options.projectRoot || process.cwd());
+    this.getRuntime = options.getRuntime || (() => options.runtime || {});
+    this.getAccount = options.getAccount || (() => ({}));
+    this.loadGroups = options.loadGroups || readConfirmedMapping;
+    this.loadMessages = options.loadMessages || readRoomMessages;
+    this.appendMessage = options.appendMessage || appendWebhookMessage;
+    this.fixedStatePath = options.statePath || '';
+    this.generating = new Map();
+  }
+
+  statePath() {
+    if (this.fixedStatePath) return this.fixedStatePath;
+    const account = this.getAccount() || {};
+    const key = crypto.createHash('sha256').update(String(account.uid || account.guid || 'default')).digest('hex').slice(0, 12);
+    return path.join(this.projectRoot, 'outputs', 'groups', `agent-replies-${key}.json`);
+  }
+
+  loadState() {
+    const parsed = readJson(this.statePath(), null);
+    return parsed && parsed.version === 1 && parsed.rooms ? parsed : { version: 1, rooms: {} };
+  }
+
+  saveState(state) {
+    const filePath = this.statePath();
+    fs.mkdirSync(path.dirname(filePath), { recursive: true });
+    const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
+    fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), 'utf8');
+    fs.renameSync(tempPath, filePath);
+  }
+
+  confirmedRoom(roomId) {
+    const id = String(roomId || '').trim();
+    if (!id || id.length > 200) throw new Error('群聊 roomId 无效');
+    const mapping = this.loadGroups() || {};
+    if (!mapping[id]) throw new Error('该群尚未确认为客户群,不能启用智能回复');
+    return { roomId: id, ...mapping[id] };
+  }
+
+  normalizedMessages(roomId) {
+    const account = this.getAccount() || {};
+    const runtime = this.getRuntime() || {};
+    const selfUserIds = [account.userId, runtime.config?.qiwei?.selfUserId].map(String).filter(Boolean);
+    const selfNames = [account.nickname, runtime.config?.qiwei?.nickname].map(String).filter(Boolean);
+    const messages = (this.loadMessages(roomId) || []).map(message => {
+      const staff = isStaffMessage(message, selfUserIds, selfNames);
+      return {
+        id: messageId(message) || crypto.createHash('sha1').update(JSON.stringify(message)).digest('hex').slice(0, 16),
+        direction: staff ? 'outbound' : 'inbound',
+        role: staff ? 'human' : 'customer',
+        senderName: String(message.senderName || (staff ? account.nickname || '客服' : '群成员')).trim(),
+        content: messageContent(message),
+        timestamp: new Date(timestampOf(message) || Date.now()).toISOString(),
+        status: staff ? 'sent' : 'received',
+      };
+    }).filter(message => message.content);
+    return messages.sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp));
+  }
+
+  roomState(state, roomId) {
+    state.rooms[roomId] ||= { drafts: [], audit: [] };
+    state.rooms[roomId].drafts ||= [];
+    state.rooms[roomId].audit ||= [];
+    state.rooms[roomId].mode = state.rooms[roomId].mode === 'auto' ? 'auto' : 'review';
+    return state.rooms[roomId];
+  }
+
+  publicState(roomId) {
+    const room = this.confirmedRoom(roomId);
+    const state = this.loadState();
+    const stored = this.roomState(state, room.roomId);
+    const messages = this.normalizedMessages(room.roomId).slice(-40);
+    const pendingReply = [...stored.drafts].reverse().find(draft => draft.status === 'pending') || null;
+    const publicAudit = stored.audit.map(item => {
+      if (item.action === 'group_agent_failed') return { ...item, message: '群聊 Agent 暂时不可用,消息已保留' };
+      if (item.action === 'group_auto_send_failed') return { ...item, message: '全自动发送失败,草稿已保留' };
+      return item;
+    });
+    const latestAudit = publicAudit.at(-1) || null;
+    return {
+      roomId: room.roomId,
+      roomName: room.roomName || room.name || room.roomId,
+      customerName: room.customerName || '',
+      mode: stored.mode,
+      messages,
+      pendingReply,
+      agentError: latestAudit?.action === 'group_agent_failed' ? latestAudit : null,
+      sendError: latestAudit?.action === 'group_auto_send_failed' ? latestAudit : null,
+      agentNotice: latestAudit?.action === 'group_agent_no_reply_needed' ? latestAudit : null,
+      lastOutcome: latestAudit,
+      audit: publicAudit.slice(-30).reverse(),
+    };
+  }
+
+  list() {
+    return Object.keys(this.loadGroups() || {}).map(roomId => this.publicState(roomId));
+  }
+
+  setMode(roomId, mode, confirmation = '') {
+    const room = this.confirmedRoom(roomId);
+    const selected = String(mode || '').trim();
+    if (!['review', 'auto'].includes(selected)) throw new Error('不支持的群聊 Agent 模式');
+    if (selected === 'auto' && confirmation !== 'AUTO_SEND_GROUP_MESSAGES') {
+      throw new Error('开启群聊全自动模式需要明确确认');
+    }
+    const state = this.loadState();
+    const stored = this.roomState(state, room.roomId);
+    stored.mode = selected;
+    stored.audit.push({
+      id: crypto.randomUUID(),
+      action: 'group_mode_changed',
+      mode: selected,
+      message: selected === 'auto' ? '全自动发送已开启,忽略置信度和风险标记' : '已切回人工审核',
+      createdAt: new Date().toISOString(),
+    });
+    this.saveState(state);
+    return this.publicState(room.roomId);
+  }
+
+  async generate(roomId, options = {}) {
+    const id = String(roomId || '').trim();
+    if (this.generating.has(id)) return this.generating.get(id);
+    const task = this.generateUnlocked(id, options);
+    this.generating.set(id, task);
+    try { return await task; }
+    finally { if (this.generating.get(id) === task) this.generating.delete(id); }
+  }
+
+  async generateUnlocked(roomId, options = {}) {
+    const room = this.confirmedRoom(roomId);
+    const messages = this.normalizedMessages(room.roomId);
+    const latest = options.sourceMessageId
+      ? messages.find(message => message.id === String(options.sourceMessageId)) || messages.at(-1)
+      : messages.at(-1);
+    if (!latest) throw new Error('该群还没有可用于生成回复的消息');
+    if (latest.direction === 'outbound') throw new Error('最新一条已经是客服消息,无需再次生成回复');
+
+    const state = this.loadState();
+    const stored = this.roomState(state, room.roomId);
+    const existing = [...stored.drafts].reverse().find(draft => draft.status === 'pending' && draft.sourceMessageId === latest.id);
+    if (existing && !options.force) return { status: 'pending_review', draft: existing, group: this.publicState(room.roomId) };
+    const runtime = this.getRuntime() || {};
+    if (!runtime.agent) throw new Error('群聊 Agent 尚未配置');
+    const inboundContent = `${latest.senderName || '群成员'}:${latest.content}`;
+    try {
+      const result = await runtime.agent.run({
+        conversation: {
+          id: `group:${room.roomId}`,
+          contact_id: room.roomId,
+          contact_name: room.roomName || room.roomId,
+          displayName: `群聊-${room.roomName || room.roomId}`,
+        },
+        messages: messages.map(message => ({
+          direction: message.direction,
+          content: message.direction === 'inbound' ? `${message.senderName || '群成员'}:${message.content}` : message.content,
+        })),
+        profile: { profile: {}, tags: [] },
+        customerIntelligence: { tasks: [], alerts: [], recommendations: [] },
+        inboundContent,
+        channelType: 'group',
+        directPrompt: groupPrompt(room.roomName || room.roomId, messages, latest),
+      });
+      const freshState = this.loadState();
+      const freshRoom = this.roomState(freshState, room.roomId);
+      if (!String(result.content || '').trim()) {
+        for (const draft of freshRoom.drafts) if (draft.status === 'pending') draft.status = 'superseded';
+        const audit = { id: crypto.randomUUID(), action: 'group_agent_no_reply_needed', sourceMessageId: latest.id, message: 'Agent 判断本条群消息无需回复', createdAt: new Date().toISOString() };
+        freshRoom.audit.push(audit);
+        this.saveState(freshState);
+        return { status: 'no_reply_needed', notice: audit, group: this.publicState(room.roomId) };
+      }
+      const draft = {
+        id: crypto.randomUUID(),
+        roomId: room.roomId,
+        sourceMessageId: latest.id,
+        content: String(result.content).trim(),
+        confidence: Number(result.confidence) || 0,
+        intent: String(result.intent || ''),
+        reason: String(result.reason || ''),
+        requiresHuman: true,
+        citations: result.citations || [],
+        toolTrace: result.toolTrace || [],
+        status: 'pending',
+        createdAt: new Date().toISOString(),
+      };
+      for (const item of freshRoom.drafts) if (item.status === 'pending') item.status = 'superseded';
+      freshRoom.drafts.push(draft);
+      freshRoom.audit.push({ id: crypto.randomUUID(), action: 'group_draft_created', draftId: draft.id, sourceMessageId: latest.id, createdAt: draft.createdAt });
+      this.saveState(freshState);
+      if (freshRoom.mode === 'auto') {
+        try {
+          const sent = await this.approve(room.roomId, draft.id, draft.content, { actor: 'agent:auto' });
+          return { status: 'auto_sent', draft: sent.draft, sendResult: sent.sendResult, group: sent.group };
+        } catch (error) {
+          const failedState = this.loadState();
+          const failedRoom = this.roomState(failedState, room.roomId);
+          failedRoom.audit.push({ id: crypto.randomUUID(), action: 'group_auto_send_failed', draftId: draft.id, message: error.message, createdAt: new Date().toISOString() });
+          this.saveState(failedState);
+          return { status: 'pending_review', draft, autoSendFailed: true, group: this.publicState(room.roomId) };
+        }
+      }
+      return { status: 'pending_review', draft, group: this.publicState(room.roomId) };
+    } catch (error) {
+      const freshState = this.loadState();
+      const freshRoom = this.roomState(freshState, room.roomId);
+      const audit = { id: crypto.randomUUID(), action: 'group_agent_failed', sourceMessageId: latest.id, message: error.message, createdAt: new Date().toISOString() };
+      freshRoom.audit.push(audit);
+      this.saveState(freshState);
+      throw error;
+    }
+  }
+
+  async approve(roomId, draftId, content, options = {}) {
+    const room = this.confirmedRoom(roomId);
+    const state = this.loadState();
+    const stored = this.roomState(state, room.roomId);
+    const draft = stored.drafts.find(item => item.id === draftId);
+    if (!draft) throw new Error('群聊回复草稿不存在');
+    if (draft.status !== 'pending') throw new Error(`该群聊草稿已经是 ${draft.status} 状态`);
+    const finalContent = String(content || draft.content || '').trim();
+    if (!finalContent) throw new Error('群聊回复内容不能为空');
+    if (finalContent.length > 2000) throw new Error('群聊回复内容过长');
+    const runtime = this.getRuntime() || {};
+    if (!runtime.qiwei) throw new Error('企微发送通道尚未配置');
+    const originalContent = String(draft.content || '');
+    const result = await runtime.qiwei.sendText(room.roomId, finalContent);
+    if (result?.isSendSuccess === false) throw new Error('企微未确认群消息发送成功');
+    draft.content = finalContent;
+    draft.status = 'sent';
+    draft.reviewedAt = new Date().toISOString();
+    draft.sentAt = draft.reviewedAt;
+    draft.edited = finalContent !== originalContent;
+    const auto = options.actor === 'agent:auto';
+    stored.audit.push({ id: crypto.randomUUID(), action: auto ? 'group_message_auto_sent' : 'group_message_sent', draftId: draft.id, createdAt: draft.sentAt });
+    this.saveState(state);
+
+    const account = this.getAccount() || {};
+    try {
+      this.appendMessage(room.roomId, {
+        msgId: `group-agent-${draft.id}`,
+        msgUniqueIdentifier: `group-agent-${draft.id}`,
+        seq: Date.now(),
+        senderId: account.userId || runtime.config?.qiwei?.selfUserId || '',
+        senderName: account.nickname || runtime.config?.qiwei?.nickname || '客服',
+        fromRoomId: room.roomId,
+        content: finalContent,
+        timestamp: draft.sentAt,
+        isSelf: true,
+        direction: 'outbound',
+        rawData: { source: auto ? 'group_agent_auto' : 'group_agent_review' },
+      });
+    } catch {}
+    return { status: 'sent', draft, sendResult: result, group: this.publicState(room.roomId) };
+  }
+
+  reject(roomId, draftId, reason = '') {
+    const room = this.confirmedRoom(roomId);
+    const state = this.loadState();
+    const stored = this.roomState(state, room.roomId);
+    const draft = stored.drafts.find(item => item.id === draftId);
+    if (!draft) throw new Error('群聊回复草稿不存在');
+    if (draft.status !== 'pending') throw new Error(`该群聊草稿已经是 ${draft.status} 状态`);
+    draft.status = 'rejected';
+    draft.rejectedAt = new Date().toISOString();
+    draft.rejectionReason = String(reason || '人工驳回');
+    stored.audit.push({ id: crypto.randomUUID(), action: 'group_draft_rejected', draftId: draft.id, message: draft.rejectionReason, createdAt: draft.rejectedAt });
+    this.saveState(state);
+    return { status: 'rejected', draft, group: this.publicState(room.roomId) };
+  }
+
+  async regenerate(roomId, draftId) {
+    this.reject(roomId, draftId, '重新生成');
+    return this.generate(roomId, { force: true });
+  }
+
+  async ingestPolledMessage(message = {}, config = {}) {
+    const roomId = roomIdOf(message);
+    if (!roomId || !(this.loadGroups() || {})[roomId]) return { status: 'ignored_unconfirmed_group' };
+    const content = messageContent(message);
+    if (!content || ![0, 1, 2].includes(Number(message.msgType))) return { status: 'ignored_group_message_type' };
+    const timestamp = message.timestamp ? new Date(Number(message.timestamp) > 1e12 ? Number(message.timestamp) : Number(message.timestamp) * 1000).toISOString() : new Date().toISOString();
+    this.appendMessage(roomId, {
+      msgId: message.msgUniqueIdentifier || message.msgServerId || `${roomId}-${message.seq}`,
+      msgUniqueIdentifier: message.msgUniqueIdentifier || message.msgServerId || `${roomId}-${message.seq}`,
+      seq: message.seq,
+      senderId: message.senderId || '',
+      senderName: message.senderName || '',
+      receiverId: message.receiverId || '',
+      fromRoomId: roomId,
+      msgType: String(message.msgType),
+      content,
+      timestamp,
+      rawData: message,
+    });
+    if (String(message.senderId || '') === String(config.selfUserId || '')) return { status: 'group_staff_message_saved' };
+    return this.generate(roomId, { sourceMessageId: message.msgUniqueIdentifier || message.msgServerId || `${roomId}-${message.seq}` });
+  }
+}
+
+module.exports = { GroupAgentService, groupPrompt };

+ 2 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html

@@ -5,7 +5,7 @@
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>企微智能办公助手</title>
 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23fa8c16'/%3E%3Cpath d='M18 20h28v20H34l-8 7v-7h-8z' fill='white'/%3E%3C/svg%3E">
-<link rel="stylesheet" href="/dashboard/styles.css?v=20260724-feedback3">
+<link rel="stylesheet" href="/dashboard/styles.css?v=20260725-dialogs">
 </head>
 <body>
 <div class="app" id="app">
@@ -100,6 +100,6 @@
   <div class="toast-container" id="toast-container" aria-live="polite"></div>
 </div>
 <script src="/dashboard/echarts.min.js"></script>
-<script src="/dashboard/app.js?v=20260724-feedback3"></script>
+<script src="/dashboard/app.js?v=20260725-dialogs"></script>
 </body>
 </html>

+ 196 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/response-monitor-service.js

@@ -0,0 +1,196 @@
+const fs = require('fs');
+const path = require('path');
+
+function readJson(filePath, fallback) {
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
+  } catch {
+    return fallback;
+  }
+}
+
+function timestampOf(message = {}) {
+  const value = message.timestamp || message.createdAt || message.created_at || message.time;
+  const parsed = Date.parse(value || '');
+  if (Number.isFinite(parsed)) return parsed;
+  const numeric = Number(value);
+  if (!Number.isFinite(numeric) || numeric <= 0) return 0;
+  return numeric > 1e12 ? numeric : numeric * 1000;
+}
+
+function latestMessage(messages = []) {
+  return [...messages]
+    .filter(message => timestampOf(message) > 0)
+    .sort((left, right) => timestampOf(left) - timestampOf(right))
+    .at(-1) || null;
+}
+
+function previewOf(message = {}) {
+  return String(message.content || message.msgData?.content || '').replace(/\s+/g, ' ').trim().slice(0, 120);
+}
+
+function isStaffMessage(message = {}, selfUserIds = [], selfNames = []) {
+  const role = String(message.role || message.senderType || '').toLowerCase();
+  const direction = String(message.direction || '').toLowerCase();
+  if (message.isSelf === true || direction === 'outbound' || ['human', 'agent', 'staff', 'service'].includes(role)) return true;
+  const senderId = String(message.senderId || message.fromUserId || '').trim();
+  const senderName = String(message.senderName || message.fromName || '').trim();
+  return (senderId && selfUserIds.includes(senderId)) || (senderName && selfNames.includes(senderName));
+}
+
+function buildState({
+  kind,
+  id,
+  name,
+  messages,
+  conversationId = '',
+  warningMinutes,
+  urgentMinutes,
+  now,
+  selfUserIds,
+  selfNames,
+  handledMessageId = '',
+}) {
+  const latest = latestMessage(messages);
+  if (!latest) {
+    return {
+      kind,
+      id,
+      name,
+      conversationId,
+      status: 'awaiting_sync',
+      severity: 'none',
+      waitingMinutes: 0,
+      lastMessageAt: null,
+      lastMessagePreview: '',
+    };
+  }
+
+  const lastMessageAt = new Date(timestampOf(latest)).toISOString();
+  const waitingMinutes = Math.max(0, Math.floor((now - timestampOf(latest)) / 60000));
+  const latestId = String(latest.id || latest.messageId || latest.msgId || '').trim();
+  const handledWithoutReply = Boolean(handledMessageId && latestId && String(handledMessageId) === latestId);
+  const replied = isStaffMessage(latest, selfUserIds, selfNames) || handledWithoutReply;
+  const severity = replied || waitingMinutes < warningMinutes
+    ? 'none'
+    : waitingMinutes >= urgentMinutes ? 'urgent' : 'warning';
+
+  return {
+    kind,
+    id,
+    name,
+    conversationId,
+    status: handledWithoutReply ? 'no_reply_needed' : replied ? 'replied' : severity === 'none' ? 'waiting' : 'overdue',
+    severity,
+    waitingMinutes: replied ? 0 : waitingMinutes,
+    lastMessageAt,
+    lastMessagePreview: previewOf(latest),
+    lastSenderName: String(latest.senderName || (latest.role === 'customer' ? name : '') || '').trim(),
+    handledWithoutReply,
+  };
+}
+
+function roomMessages(messagesDir, roomId) {
+  if (!fs.existsSync(messagesDir)) return [];
+  const prefix = `room-${roomId}-`;
+  const seen = new Set();
+  const result = [];
+  const files = [
+    ...fs.readdirSync(messagesDir)
+      .filter(name => name.startsWith(prefix) && name.endsWith('.json'))
+      .map(name => path.join(messagesDir, name)),
+    ...[path.join(messagesDir, 'rooms', roomId), path.join(messagesDir, roomId)]
+      .filter(dir => fs.existsSync(dir))
+      .flatMap(dir => fs.readdirSync(dir).filter(name => name.endsWith('.json')).map(name => path.join(dir, name))),
+  ].sort().reverse();
+  for (const filePath of files) {
+    const parsed = readJson(filePath, []);
+    const messages = Array.isArray(parsed) ? parsed : [parsed];
+    for (const message of messages) {
+      const key = String(message.msgId || message.msgServerId || message.seq || `${timestampOf(message)}:${previewOf(message)}`);
+      if (seen.has(key)) continue;
+      seen.add(key);
+      result.push(message);
+    }
+  }
+  return result;
+}
+
+function responseMonitor({
+  projectRoot,
+  conversations = [],
+  selfUserIds = [],
+  selfNames = [],
+  warningMinutes = 15,
+  urgentMinutes = 60,
+  now = Date.now(),
+} = {}) {
+  const warning = Math.max(1, Number(warningMinutes) || 15);
+  const urgent = Math.max(warning, Number(urgentMinutes) || 60);
+  const normalizedSelfUserIds = [...new Set(selfUserIds.map(String).map(value => value.trim()).filter(Boolean))];
+  const normalizedSelfNames = [...new Set(selfNames.map(String).map(value => value.trim()).filter(Boolean))];
+
+  const customerStates = conversations.map(conversation => buildState({
+    kind: 'customer',
+    id: String(conversation.contactId || conversation.id || ''),
+    name: String(conversation.displayName || conversation.contactName || '未命名客户'),
+    conversationId: String(conversation.id || ''),
+    messages: conversation.messages || [],
+    warningMinutes: warning,
+    urgentMinutes: urgent,
+    now,
+    selfUserIds: normalizedSelfUserIds,
+    selfNames: normalizedSelfNames,
+    handledMessageId: conversation.handledMessageId,
+  }));
+
+  const outputsDir = path.join(projectRoot, 'outputs');
+  const confirmedGroups = readJson(path.join(outputsDir, 'groups', 'confirmed-mapping.json'), {});
+  const latestRooms = readJson(path.join(outputsDir, 'groups', 'rooms-latest.json'), []);
+  const roomById = new Map((Array.isArray(latestRooms) ? latestRooms : []).map(room => [String(room.roomId || ''), room]));
+  const groupStates = Object.entries(confirmedGroups || {}).map(([roomId, mapping]) => {
+    const room = roomById.get(String(roomId)) || {};
+    return buildState({
+      kind: 'group',
+      id: String(roomId),
+      name: String(mapping?.roomName || room.roomName || roomId),
+      messages: roomMessages(path.join(outputsDir, 'messages'), String(roomId)),
+      warningMinutes: warning,
+      urgentMinutes: urgent,
+      now,
+      selfUserIds: normalizedSelfUserIds,
+      selfNames: normalizedSelfNames,
+    });
+  });
+
+  const scope = { customers: customerStates, groups: groupStates };
+  const reminders = [...customerStates, ...groupStates]
+    .filter(item => item.status === 'overdue')
+    .sort((left, right) => right.waitingMinutes - left.waitingMinutes);
+  const allStates = [...customerStates, ...groupStates];
+
+  return {
+    summary: {
+      monitoredCustomers: customerStates.length,
+      monitoredGroups: groupStates.length,
+      overdue: reminders.length,
+      urgent: reminders.filter(item => item.severity === 'urgent').length,
+      warning: reminders.filter(item => item.severity === 'warning').length,
+      awaitingSync: allStates.filter(item => item.status === 'awaiting_sync').length,
+    },
+    config: {
+      warningMinutes: warning,
+      urgentMinutes: urgent,
+      notificationMode: 'local-only',
+    },
+    scope,
+    reminders,
+    checkedAt: new Date(now).toISOString(),
+  };
+}
+
+module.exports = {
+  responseMonitor,
+  timestampOf,
+  isStaffMessage,
+};

+ 114 - 8
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js

@@ -3,6 +3,7 @@ const fs = require('fs');
 const path = require('path');
 const { URL } = require('url');
 const { outputsRoot } = require('../core/output-paths');
+const { resolveWorkspaceRoot, workspaceIdentity } = require('../core/runtime-context');
 const {
   qiweiSyncExternalGroups,
   qiweiListExternalGroups,
@@ -66,7 +67,16 @@ const {
 const {
   switchActiveAccount,
   getAgentStatus,
+  getAllowlistCandidates,
+  updateAllowlist,
   getConversations,
+  getResponseMonitor,
+  getGroupAgents,
+  changeGroupMode,
+  generateGroupReply,
+  approveGroupDraft,
+  rejectGroupDraft,
+  regenerateGroupDraft,
   syncConversations,
   changeGlobalMode,
   changeConversationMode,
@@ -128,6 +138,7 @@ const { qiweiBusinessDiagnosis } = require('../tools/qiwei-business-diagnosis-ru
 const DASHBOARD_PORT = process.env.QIWEI_DASHBOARD_PORT || 4320;
 const STATIC_DIR = path.join(__dirname);
 const TMP_DIR = path.join(outputsRoot(), 'tmp');
+const WORKSPACE_ID = workspaceIdentity(resolveWorkspaceRoot());
 
 const jobs = new Map();
 
@@ -162,6 +173,56 @@ async function getCachedSubscriptionStatus({ force = false } = {}) {
 // Dashboard 启动后立即预热较慢的远程订阅状态,用户进入账号页时通常可直接命中缓存。
 void getCachedSubscriptionStatus().catch(() => {});
 
+async function getDashboardCustomerDirectory() {
+  const merged = new Map();
+  const mergeCustomer = customer => {
+    const externalUserId = String(customer.externalUserId || customer.customerId || '').trim();
+    if (!externalUserId) return;
+    const current = merged.get(externalUserId) || {};
+    merged.set(externalUserId, {
+      ...current,
+      ...customer,
+      externalUserId,
+      customerId: customer.customerId || current.customerId || externalUserId,
+      name: customer.name || customer.displayName || current.name || '',
+      phone: customer.phone || current.phone || '',
+      tags: Array.isArray(customer.tags) ? customer.tags : (current.tags || []),
+      hasPortrait: customer.hasPortrait !== undefined
+        ? Boolean(customer.hasPortrait)
+        : Boolean((customer.fields || current.fields || []).length || current.hasPortrait),
+    });
+  };
+
+  try {
+    const allowlist = await getAllowlistCandidates();
+    for (const contact of allowlist.data?.contacts || []) {
+      mergeCustomer({
+        externalUserId: contact.id,
+        name: contact.displayName,
+        selected: contact.selected === true,
+        friendRequestStatus: 'ACCEPTED',
+        source: 'qiwei-contact',
+      });
+    }
+  } catch {
+    // Local and conversation-backed customers remain available during a remote outage.
+  }
+
+  const local = getStateSection('customers');
+  for (const customer of Object.values(local.customers || {})) mergeCustomer(customer);
+
+  const master = getCustomerMasterHub();
+  for (const customer of master.data?.customers || []) {
+    mergeCustomer({
+      ...customer,
+      name: customer.displayName,
+      hasPortrait: Array.isArray(customer.fields) && customer.fields.length > 0,
+    });
+  }
+
+  return Array.from(merged.values());
+}
+
 function ensureTmpDir() {
   fs.mkdirSync(TMP_DIR, { recursive: true });
 }
@@ -213,7 +274,10 @@ function serveStatic(req, res, filePath, contentType) {
       res.end('not found');
       return;
     }
-    res.writeHead(200, { 'Content-Type': contentType });
+    res.writeHead(200, {
+      'Content-Type': contentType,
+      'Cache-Control': 'no-store'
+    });
     res.end(data);
   });
 }
@@ -336,7 +400,7 @@ async function handleRequest(req, res) {
       return;
     }
     if (pathname === '/api/health') {
-      json(res, 200, { status: 'ok' });
+      json(res, 200, { status: 'ok', data: { workspaceId: WORKSPACE_ID, port: Number(DASHBOARD_PORT) } });
       return;
     }
     if (pathname === '/api/status' && req.method === 'GET') {
@@ -463,6 +527,14 @@ async function handleRequest(req, res) {
       json(res, 200, await getAgentStatus());
       return;
     }
+    if (pathname === '/api/agent/allowlist' && req.method === 'GET') {
+      json(res, 200, await getAllowlistCandidates());
+      return;
+    }
+    if (pathname === '/api/agent/allowlist' && req.method === 'POST') {
+      json(res, 200, updateAllowlist(await readBody(req)));
+      return;
+    }
     if (pathname === '/api/accounts/switch' && req.method === 'POST') {
       json(res, 200, await switchActiveAccount(await readBody(req)));
       return;
@@ -471,6 +543,34 @@ async function handleRequest(req, res) {
       json(res, 200, getConversations());
       return;
     }
+    if (pathname === '/api/agent/response-monitor' && req.method === 'GET') {
+      json(res, 200, getResponseMonitor());
+      return;
+    }
+    if (pathname === '/api/agent/groups' && req.method === 'GET') {
+      json(res, 200, getGroupAgents());
+      return;
+    }
+    const groupAgentModeRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/mode$/);
+    if (groupAgentModeRoute && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, changeGroupMode(decodeURIComponent(groupAgentModeRoute[1]), body.mode, body.confirmation));
+      return;
+    }
+    const groupAgentGenerateRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/generate$/);
+    if (groupAgentGenerateRoute && req.method === 'POST') {
+      json(res, 200, await generateGroupReply(decodeURIComponent(groupAgentGenerateRoute[1]), await readBody(req)));
+      return;
+    }
+    const groupAgentDraftRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/drafts\/([^/]+)\/(approve|reject|regenerate)$/);
+    if (groupAgentDraftRoute && req.method === 'POST') {
+      const [, roomId, draftId, action] = groupAgentDraftRoute;
+      const body = await readBody(req);
+      if (action === 'approve') json(res, 200, await approveGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId), body.content));
+      else if (action === 'reject') json(res, 200, rejectGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId), body.reason));
+      else json(res, 200, await regenerateGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId)));
+      return;
+    }
     if (pathname === '/api/agent/conversations/sync' && req.method === 'POST') {
       json(res, 200, await syncConversations());
       return;
@@ -541,12 +641,18 @@ async function handleRequest(req, res) {
       return;
     }
     if (pathname === '/api/dashboard/customers/search' && req.method === 'GET') {
-      const filter = {
-        keyword: url.searchParams.get('keyword') || '',
-        hasPortrait: url.searchParams.has('hasPortrait') ? url.searchParams.get('hasPortrait') === 'true' : undefined
-      };
-      const stateSection = getStateSection('customers', { filter });
-      let customers = Object.values(stateSection.customers || {});
+      const keyword = String(url.searchParams.get('keyword') || '').trim().toLowerCase();
+      const hasPortrait = url.searchParams.has('hasPortrait') ? url.searchParams.get('hasPortrait') === 'true' : undefined;
+      const scope = String(url.searchParams.get('scope') || '').trim();
+      let customers = (await getDashboardCustomerDirectory()).filter(customer => {
+        if (scope === 'portrait' && customer.selected !== true && customer.discoveredFromGroups !== true && customer.groupStatus !== 'IN_GROUP') return false;
+        if (keyword) {
+          const text = `${customer.name || ''} ${customer.phone || ''} ${customer.externalUserId || ''}`.toLowerCase();
+          if (!text.includes(keyword)) return false;
+        }
+        if (hasPortrait !== undefined && Boolean(customer.hasPortrait) !== hasPortrait) return false;
+        return true;
+      });
       const sortBy = url.searchParams.get('sortBy') || 'default';
       if (sortBy === 'lastActive') {
         customers.sort((a, b) => String(b.lastSeenInGroupAt || b.updatedAt || '').localeCompare(String(a.lastSeenInGroupAt || a.updatedAt || '')));

+ 390 - 5
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css

@@ -377,6 +377,7 @@ html, body {
 
 .main {
   flex: 1;
+  min-width: 0;
   margin-left: 220px;
   display: flex;
   flex-direction: column;
@@ -721,6 +722,55 @@ html, body {
   font-weight: 700;
 }
 
+.response-monitor {
+  margin: 0 0 16px;
+  overflow: hidden;
+  border: 1px solid #dfe8e4;
+  border-radius: 16px;
+  background: #fff;
+  box-shadow: 0 6px 20px rgba(27,49,42,.06);
+}
+
+.response-monitor.has-urgent { border-color: #efc1ad; }
+.response-monitor-header { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 15px 18px; border-bottom: 1px solid #e8eeeb; background: linear-gradient(135deg, #f8fbfa, #fff); }
+.response-monitor.has-urgent .response-monitor-header { background: linear-gradient(135deg, #fff7f2, #fff); }
+.response-monitor-header > div:first-child span,
+.response-monitor-header > div:first-child strong,
+.response-monitor-header > div:first-child small { display: block; }
+.response-monitor-header > div:first-child span { color: #44816b; font-size: 8px; font-weight: 800; letter-spacing: .12em; }
+.response-monitor-header > div:first-child strong { margin-top: 4px; color: #33443e; font-size: 15px; }
+.response-monitor-header > div:first-child small { margin-top: 3px; color: #89958f; font-size: 9px; }
+.response-monitor-kpis { display: flex; gap: 7px; }
+.response-monitor-kpis span { min-width: 70px; padding: 8px 10px; border-radius: 10px; color: #708079; background: #eef4f1; font-size: 8px; text-align: center; }
+.response-monitor-kpis b { display: block; color: #2f745c; font-size: 15px; }
+.response-monitor-kpis span.danger { color: #9b4c2e; background: #fff0e9; }
+.response-monitor-kpis span.danger b { color: #c85e39; }
+.response-monitor-body { display: grid; grid-template-columns: minmax(260px, .85fr) minmax(360px, 1.15fr); gap: 16px; padding: 15px 18px 18px; }
+.response-monitor-section-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 9px; }
+.response-monitor-section-title strong { color: #4a5b54; font-size: 10px; }
+.response-monitor-section-title span { color: #96a19c; font-size: 8px; }
+.response-scope-list { display: flex; flex-wrap: wrap; gap: 6px; }
+.response-scope-chip { display: inline-flex; align-items: center; gap: 5px; padding: 6px 8px; border: 1px solid #e1e9e5; border-radius: 9px; color: #5e6e67; background: #fafcfb; font-size: 8px; }
+.response-scope-chip i { display: grid; place-items: center; min-width: 24px; height: 19px; padding: 0 5px; border-radius: 6px; color: #2f795f; background: #e7f3ed; font-size: 7px; font-style: normal; font-weight: 800; }
+.response-scope-chip em { color: #98a39e; font-size: 7px; font-style: normal; }
+.response-scope-chip.overdue { border-color: #f0c8b6; background: #fff8f4; }
+.response-scope-chip.awaiting_sync { border-style: dashed; }
+.response-reminder-list { min-width: 0; }
+.response-reminder { display: grid; grid-template-columns: minmax(150px, .65fr) minmax(160px, 1fr) auto; align-items: center; gap: 12px; margin-top: 6px; padding: 9px 10px; border: 1px solid #ecd6a7; border-left: 3px solid #d4a548; border-radius: 10px; background: #fffaf0; }
+.response-reminder.urgent { border-color: #efc0ab; border-left-color: #d9633c; background: #fff5f0; }
+.response-reminder > div { display: flex; align-items: center; gap: 6px; min-width: 0; }
+.response-reminder > div i { width: 22px; height: 22px; display: grid; flex: 0 0 auto; place-items: center; border-radius: 7px; color: #996c25; background: #f5e5bc; font-size: 8px; font-style: normal; font-weight: 800; }
+.response-reminder.urgent > div i { color: #a84c2d; background: #f8d9cb; }
+.response-reminder > div strong { overflow: hidden; color: #536059; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.response-reminder > div em { padding: 2px 5px; border-radius: 6px; color: #9b712c; background: rgba(255,255,255,.8); font-size: 7px; font-style: normal; }
+.response-reminder.urgent > div em { color: #b34e2c; }
+.response-reminder p { margin: 0; overflow: hidden; color: #79847f; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
+.response-reminder footer { display: flex; align-items: center; gap: 8px; }
+.response-reminder footer span { color: #a1603d; font-size: 8px; font-weight: 700; white-space: nowrap; }
+.response-reminder footer .btn { padding: 5px 8px; font-size: 8px; }
+.response-monitor-empty { display: block; padding: 12px; border-radius: 9px; color: #8d9993; background: #f5f8f6; font-size: 9px; text-align: center; }
+.response-monitor-empty.success { color: #3c8067; background: #edf7f2; }
+
 .agent-runtime-alert {
   display: flex;
   align-items: center;
@@ -736,6 +786,7 @@ html, body {
 
 .agent-runtime-alert strong { flex: 0 0 auto; }
 .agent-runtime-alert span { line-height: 1.5; }
+.agent-runtime-alert.notice { border-color: #b9dfcf; color: #256b54; background: #eff9f4; }
 
 .agent-workspace {
   min-height: 650px;
@@ -772,6 +823,36 @@ html, body {
 .agent-panel-heading small { margin-top: 3px; color: var(--text-muted); font-size: 10px; }
 .agent-count { min-width: 24px; padding: 4px 7px; border-radius: 12px; text-align: center; color: var(--agent-green); background: var(--agent-green-soft); font-size: 11px; font-weight: 800; }
 
+.agent-list-tabs {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 5px;
+  padding: 8px;
+  border-bottom: 1px solid var(--agent-line);
+  background: #f5f8f6;
+}
+
+.agent-list-tabs button {
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 5px;
+  min-height: 34px;
+  padding: 6px;
+  border: 1px solid transparent;
+  border-radius: 7px;
+  color: #69756f;
+  background: transparent;
+  font: inherit;
+  font-size: 10px;
+  cursor: pointer;
+}
+
+.agent-list-tabs button.active { color: #245f4d; border-color: #cfe1d9; background: #fff; box-shadow: 0 2px 7px rgba(30,65,54,.07); font-weight: 700; }
+.agent-list-tabs b { min-width: 18px; padding: 2px 4px; border-radius: 8px; color: #68746f; background: #e5ebe8; font-size: 8px; }
+.agent-list-tabs button.active b { color: #fff; background: var(--agent-green); }
+
 .agent-list-scroll { padding: 8px; }
 
 .agent-conversation-item {
@@ -792,6 +873,7 @@ html, body {
 
 .agent-conversation-item:hover { background: #f1f6f3; }
 .agent-conversation-item.active { background: var(--agent-green-soft); box-shadow: inset 3px 0 0 var(--agent-green); }
+.agent-conversation-item.group.active { background: #fff5ea; box-shadow: inset 3px 0 0 #d97b2f; }
 
 .agent-avatar {
   width: 34px;
@@ -804,8 +886,14 @@ html, body {
   font-size: 13px;
   font-weight: 800;
 }
+.agent-avatar.group { color: #8a4f20; background: #f4d8bd; }
 
 .agent-conversation-copy { min-width: 0; }
+.agent-conversation-name { min-width: 0; display: flex; align-items: center; gap: 5px; }
+.agent-conversation-name strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.agent-channel-badge { flex: 0 0 auto; padding: 2px 4px; border-radius: 4px; font-size: 7px; font-style: normal; font-weight: 800; }
+.agent-channel-badge.private { color: #267057; background: #e6f3ed; }
+.agent-channel-badge.group { color: #9a571f; background: #fff0df; }
 .agent-conversation-copy strong,
 .agent-conversation-copy small,
 .agent-conversation-copy em { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -817,9 +905,10 @@ html, body {
 .agent-chat-panel {
   min-width: 0;
   display: grid;
-  grid-template-rows: auto auto minmax(320px, 1fr) auto;
+  grid-template-rows: auto auto auto minmax(320px, 1fr) auto;
   background: #fff;
 }
+.agent-chat-panel.group-mode { grid-template-rows: auto auto minmax(320px, 1fr) auto; }
 
 .agent-chat-header {
   min-height: 72px;
@@ -836,6 +925,67 @@ html, body {
 .agent-chat-header strong { color: var(--agent-ink); font-size: 14px; }
 .agent-chat-header span { margin-top: 3px; color: var(--text-muted); font-size: 10px; }
 .agent-chat-header .btn { padding: 7px 11px; font-size: 11px; }
+.agent-chat-header.group { background: #fffaf4; }
+.agent-group-mode-danger.active { color: #fff; border-color: #b94b3c; background: #b94b3c; }
+.agent-group-mode-danger.active:hover { background: #9f3d31; }
+.agent-group-auto-send { border-color: #b94b3c !important; background: #b94b3c !important; }
+.agent-group-auto-send:hover { background: #9f3d31 !important; }
+
+.agent-flow {
+  padding: 12px 20px 13px;
+  border-bottom: 1px solid var(--agent-line);
+  background: #fbfcfb;
+}
+
+.agent-flow-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
+.agent-flow-heading strong { color: #45554f; font-size: 10px; }
+.agent-flow-heading span { color: #88938e; font-size: 8px; }
+.agent-flow ol { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; margin: 0; padding: 0; list-style: none; }
+.agent-flow.group ol { grid-template-columns: repeat(5, minmax(0, 1fr)); }
+.agent-flow li { position: relative; min-width: 0; display: flex; align-items: center; gap: 7px; padding-right: 14px; }
+.agent-flow li:not(:last-child)::after { content: ''; position: absolute; z-index: 0; top: 11px; right: 2px; width: 10px; height: 1px; background: #d8dfdc; }
+.agent-flow li i { position: relative; z-index: 1; width: 22px; height: 22px; display: grid; flex: 0 0 auto; place-items: center; border: 1px solid #d7dedb; border-radius: 50%; color: #89928f; background: #fff; font-size: 8px; font-style: normal; font-weight: 800; }
+.agent-flow li strong,
+.agent-flow li small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.agent-flow li strong { color: #69736f; font-size: 9px; }
+.agent-flow li small { margin-top: 2px; color: #9aa19e; font-size: 7px; }
+.agent-flow li.done i { color: #fff; border-color: var(--agent-green); background: var(--agent-green); }
+.agent-flow li.done strong { color: #2c6f59; }
+.agent-flow li.current i { color: #fff; border-color: #d47a32; background: #d47a32; box-shadow: 0 0 0 4px rgba(212,122,50,.1); }
+.agent-flow li.current strong { color: #98501b; }
+.agent-flow li.error i { color: #fff; border-color: #c65043; background: #c65043; }
+.agent-flow li.error strong { color: #a13d33; }
+
+.agent-group-monitor-view { min-height: 430px; display: grid; align-content: start; gap: 14px; padding: 22px; background: #fbfcfb; }
+.agent-group-status,
+.agent-group-latest,
+.agent-group-boundary { padding: 16px; border: 1px solid #e2e7e4; border-radius: 8px; background: #fff; }
+.agent-group-status { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 16px; border-left: 4px solid #5f8c79; }
+.agent-group-status.overdue { border-left-color: #cf603c; }
+.agent-group-status.warning { border-left-color: #d49b37; }
+.agent-group-status span,
+.agent-group-latest > span { color: #8b9490; font-size: 8px; }
+.agent-group-status strong { color: #405049; font-size: 15px; }
+.agent-group-status small { grid-column: 2; grid-row: 1 / 3; color: #8c5a37; font-size: 9px; }
+.agent-group-latest blockquote { margin: 9px 0 7px; color: #34413c; font-size: 13px; line-height: 1.65; }
+.agent-group-latest small { color: #929a97; font-size: 8px; }
+.agent-group-boundary { display: flex; align-items: center; gap: 12px; color: #796248; background: #fffaf3; }
+.agent-group-boundary strong { flex: 0 0 auto; font-size: 10px; }
+.agent-group-boundary span { font-size: 9px; line-height: 1.5; }
+.agent-intent-card.group { border-color: #f0d8bd; background: #fff7ed; }
+.agent-intent-card.group > strong { color: #965521; }
+.agent-intent-card.group.auto { border-color: #efbeb6; background: #fff1ef; }
+.agent-intent-card.group.auto > strong { color: #a13d33; }
+.agent-group-sla { padding: 12px; border: 1px solid #e5e9e7; border-radius: 8px; background: #fff; }
+.agent-group-sla strong,
+.agent-group-sla span { display: block; }
+.agent-group-sla strong { color: #46534e; font-size: 15px; }
+.agent-group-sla span { margin-top: 4px; color: #8e9693; font-size: 8px; }
+.agent-boundary-list { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
+.agent-boundary-list li { padding-left: 13px; position: relative; color: #69746f; font-size: 9px; line-height: 1.45; }
+.agent-boundary-list li::before { content: ''; position: absolute; left: 1px; top: 5px; width: 5px; height: 5px; border-radius: 50%; background: #d47a32; }
+.agent-boundary-list.danger li { color: #8f4036; }
+.agent-boundary-list.danger li::before { background: #bd4d3f; }
 
 .agent-conversation-modes {
   display: flex;
@@ -872,6 +1022,7 @@ html, body {
     radial-gradient(circle at 1px 1px, rgba(20,87,66,.1) 1px, transparent 0);
   background-size: auto, 18px 18px;
 }
+.agent-message-stream.group { background-color: #fbfaf8; }
 
 .agent-message { max-width: 86%; margin-bottom: 18px; }
 .agent-message.customer { margin-right: auto; }
@@ -891,6 +1042,8 @@ html, body {
 }
 
 .agent-composer.manual { background: #fffaf4; }
+.agent-composer.group { background: #fffaf4; }
+.agent-composer.group .agent-composer-label strong { color: #9a571f; }
 .agent-composer-label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
 .agent-composer-label strong { color: var(--agent-green); font-size: 11px; }
 .agent-composer.manual .agent-composer-label strong { color: #b06c2a; }
@@ -1143,6 +1296,7 @@ html, body {
 @media (max-width: 1050px) {
   .agent-modebar { grid-template-columns: 1fr auto; }
   .agent-guard { display: none; }
+  .response-monitor-body { grid-template-columns: 1fr; }
   .agent-workspace { grid-template-columns: 210px minmax(0, 1fr); }
   .agent-insight-panel { grid-column: 1 / -1; max-height: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--agent-line); border-left: 0; }
 }
@@ -1153,8 +1307,18 @@ html, body {
   .agent-modebar { grid-template-columns: 1fr; }
   .agent-mode-switch { justify-self: stretch; }
   .agent-mode-btn { flex: 1; }
+  .response-monitor-header { align-items: stretch; flex-direction: column; }
+  .response-monitor-kpis { width: 100%; }
+  .response-monitor-kpis span { flex: 1; }
+  .response-monitor-body { padding: 12px; }
+  .response-reminder { grid-template-columns: 1fr; }
+  .response-reminder footer { justify-content: space-between; }
   .agent-workspace { display: block; }
   .agent-conversation-list { border-right: 0; border-bottom: 1px solid var(--agent-line); }
+  .agent-flow { padding: 12px; overflow-x: auto; }
+  .agent-flow-heading { align-items: flex-start; flex-direction: column; gap: 3px; }
+  .agent-flow ol,
+  .agent-flow.group ol { min-width: 560px; }
   .agent-chat-panel { min-height: 620px; }
   .agent-insight-panel { display: block; }
 }
@@ -1822,6 +1986,126 @@ body.modal-open { overflow: hidden; }
   .customer-selector-modal > .modal-actions { padding: 12px 14px; }
   .customer-selector-modal > .modal-actions .btn { flex: 1; min-height: 44px; }
 }
+.modal.allowlist-modal {
+  width: min(92vw, 700px);
+  max-width: 700px;
+  max-height: min(760px, calc(100vh - 40px));
+  overflow: hidden;
+  padding: 0;
+}
+
+body.allowlist-modal-open { overflow: hidden; }
+
+.allowlist-modal .modal-title {
+  margin: 0;
+  padding: 22px 24px 16px;
+  border-bottom: 1px solid #e8eeeb;
+}
+
+.allowlist-intro {
+  padding: 16px 24px 12px;
+}
+
+.allowlist-intro strong,
+.allowlist-intro span { display: block; }
+.allowlist-intro strong { color: #31433c; font-size: 14px; }
+.allowlist-intro span { margin-top: 4px; color: #7f8b86; font-size: 12px; line-height: 1.5; }
+
+.allowlist-toolbar {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 10px;
+  padding: 0 24px;
+}
+
+.allowlist-toolbar input,
+.allowlist-manual input {
+  width: 100%;
+  height: 38px;
+  padding: 0 12px;
+  border: 1px solid #dce5e1;
+  border-radius: 7px;
+  outline: none;
+  color: #34443e;
+  background: #fff;
+  font: inherit;
+  font-size: 12px;
+}
+
+.allowlist-toolbar input:focus,
+.allowlist-manual input:focus { border-color: #4c9277; box-shadow: 0 0 0 3px rgba(76,146,119,.12); }
+
+.allowlist-summary {
+  min-height: 32px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 8px 24px 6px;
+  color: #4b685d;
+  font-size: 11px;
+}
+
+.allowlist-summary em { color: #aa6f24; font-style: normal; text-align: right; }
+
+.allowlist-contact-list {
+  height: 330px;
+  overflow-y: auto;
+  margin: 0 24px;
+  border: 1px solid #e2e9e6;
+  border-radius: 7px;
+  background: #fafcfb;
+}
+
+.allowlist-contact {
+  min-height: 62px;
+  display: grid;
+  grid-template-columns: auto auto minmax(0, 1fr) auto;
+  align-items: center;
+  gap: 11px;
+  padding: 10px 12px;
+  border-bottom: 1px solid #e7ecea;
+  cursor: pointer;
+  transition: background .15s ease;
+}
+
+.allowlist-contact:last-child { border-bottom: 0; }
+.allowlist-contact:hover { background: #f1f7f4; }
+.allowlist-contact.selected { background: #edf7f2; }
+.allowlist-contact input { width: 16px; height: 16px; accent-color: #347b61; }
+.allowlist-avatar { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%; color: #23624d; background: #dcefe7; font-size: 13px; font-weight: 800; }
+.allowlist-contact-copy { min-width: 0; }
+.allowlist-contact-copy strong,
+.allowlist-contact-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.allowlist-contact-copy strong { color: #35443e; font-size: 13px; }
+.allowlist-contact-copy small { margin-top: 4px; color: #89938f; font-size: 10px; }
+.allowlist-contact > em { color: #929c98; font-size: 10px; font-style: normal; }
+.allowlist-contact.selected > em { color: #2f765d; font-weight: 700; }
+
+.allowlist-loading,
+.allowlist-empty { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; padding: 24px; color: #87928d; font-size: 12px; text-align: center; }
+.allowlist-empty strong { color: #54615c; font-size: 13px; }
+.allowlist-empty.error strong { color: #a24536; }
+
+.allowlist-manual { margin: 12px 24px 0; color: #78847f; font-size: 11px; }
+.allowlist-manual summary { cursor: pointer; }
+.allowlist-manual > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; margin-top: 9px; }
+.allowlist-manual small { display: block; min-height: 18px; padding-top: 4px; color: #b14535; }
+.allowlist-modal .modal-actions { margin: 14px 0 0; padding: 14px 24px 20px; border-top: 1px solid #e8eeeb; }
+
+@media (max-width: 620px) {
+  .modal.allowlist-modal { width: calc(100vw - 20px); max-height: calc(100vh - 20px); }
+  .allowlist-modal .modal-title,
+  .allowlist-intro { padding-left: 16px; padding-right: 16px; }
+  .allowlist-toolbar { grid-template-columns: 1fr; padding: 0 16px; }
+  .allowlist-summary { align-items: flex-start; flex-direction: column; gap: 3px; padding-left: 16px; padding-right: 16px; }
+  .allowlist-summary em { text-align: left; }
+  .allowlist-contact-list { height: 300px; margin: 0 16px; }
+  .allowlist-contact { grid-template-columns: auto auto minmax(0, 1fr); }
+  .allowlist-contact > em { display: none; }
+  .allowlist-manual { margin-left: 16px; margin-right: 16px; }
+  .allowlist-modal .modal-actions { padding-left: 16px; padding-right: 16px; }
+}
 
 body.business-modal-open { overflow: hidden; }
 
@@ -1873,7 +2157,7 @@ body.business-modal-open { overflow: hidden; }
 }
 
 .business-modal-header h3 { margin: 0; color: #1e2a32; font-size: 21px; line-height: 1.3; }
-.business-modal-header p { margin: 7px 0 0; color: #74808a; font-size: 13px; line-height: 1.65; }
+.business-modal-header p { margin: 7px 0 0; color: #68757d; font-size: 13px; line-height: 1.65; white-space: pre-line; }
 
 .business-modal-close {
   flex: 0 0 34px;
@@ -1889,6 +2173,8 @@ body.business-modal-open { overflow: hidden; }
 }
 
 .business-modal-close:hover { color: #25333b; background: #e7ecea; }
+.business-modal-close:focus-visible,
+.business-modal-actions .btn:focus-visible { outline: 3px solid rgba(42, 154, 119, .22); outline-offset: 2px; }
 .business-modal-form { padding: 22px 27px 25px; }
 .business-modal-fields { display: grid; gap: 16px; }
 .business-modal-field { display: grid; gap: 7px; }
@@ -1952,6 +2238,8 @@ body.business-modal-open { overflow: hidden; }
 .business-modal-error { margin: 13px 0 0; color: #c3443d; font-size: 12px; }
 .business-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
 .business-modal-confirm { min-width: 128px; }
+.business-modal-confirm.danger { border-color: #c94b43; color: #fff; background: #c94b43; }
+.business-modal-confirm.danger:hover { border-color: #b33f38; background: #b33f38; }
 
 @media (max-width: 600px) {
   .business-modal-backdrop { align-items: end; padding: 0; }
@@ -1961,6 +2249,11 @@ body.business-modal-open { overflow: hidden; }
   .business-modal-actions .btn { flex: 1; }
 }
 
+@media (prefers-reduced-motion: reduce) {
+  .business-modal-backdrop,
+  .business-modal { animation: none; }
+}
+
 .agent-session-modal-status { padding: 12px 14px; border-radius: 10px; color: #6d7673; background: #f4f6f5; }
 .agent-session-modal-status.ready { color: #146a4f; background: #eaf7f2; }
 .agent-session-modal-status strong,
@@ -2650,6 +2943,91 @@ body.business-modal-open { overflow: hidden; }
 .overview-data-note button svg { width: 14px; height: 14px; fill: currentColor; }
 .overview-query-form button:focus-visible, .overview-kpi:focus-visible, .overview-action:focus-visible, .overview-diagnosis-actions button:focus-visible, .overview-text-action:focus-visible, .overview-data-note button:focus-visible { outline: 3px solid rgba(35, 120, 88, .22); outline-offset: 2px; }
 
+/* Business overview visual refresh */
+.overview-page { gap: 18px; }
+.overview-command {
+  position: relative;
+  overflow: hidden;
+  min-height: 224px;
+  padding: 32px 34px;
+  border: 0;
+  border-radius: 8px;
+  color: #fff;
+  background: #153c32;
+  box-shadow: 0 18px 42px rgba(21, 60, 50, .17);
+}
+.overview-command::after {
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  width: 38%;
+  height: 3px;
+  background: #e5a33f;
+  content: '';
+}
+.overview-command-copy { gap: 10px; }
+.overview-command-copy h2 { color: #fff; font-size: 34px; line-height: 1.2; }
+.overview-command-copy p { color: rgba(255, 255, 255, .72); font-size: 14px; line-height: 1.65; }
+.overview-kicker { color: #b9d8cd; font-size: 12px; }
+.overview-live { background: #91a89f; box-shadow: 0 0 0 4px rgba(255, 255, 255, .08); }
+.overview-live.online { background: #55d794; box-shadow: 0 0 0 4px rgba(85, 215, 148, .15); }
+.overview-command-status { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 5px; }
+.overview-command-status span { min-height: 32px; display: inline-flex; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid rgba(255, 255, 255, .14); border-radius: 6px; color: rgba(255, 255, 255, .76); background: rgba(255, 255, 255, .07); font-size: 12px; }
+.overview-command-status strong { color: #fff; font-size: 14px; }
+.overview-command-status .is-risk { border-color: rgba(255, 193, 181, .3); color: #ffd5cd; background: rgba(154, 54, 42, .26); }
+.overview-query-form { position: relative; z-index: 1; gap: 11px; padding: 0; background: transparent; box-shadow: none; }
+.overview-query-form > label { color: #c6ded5; font-size: 13px; }
+.overview-query-form input { min-height: 48px; padding: 0 15px; border-color: rgba(255, 255, 255, .7); border-radius: 6px; color: #172b23; background: rgba(255, 255, 255, .96); font-size: 14px; box-shadow: 0 7px 20px rgba(6, 27, 21, .13); }
+.overview-query-form input::placeholder { color: #75837d; }
+.overview-query-form input:focus { border-color: #f1b252; outline-color: rgba(241, 178, 82, .28); }
+.overview-query-form .btn { min-height: 48px; border-color: #e7a03a; border-radius: 6px; color: #17372e; background: #f0aa43; box-shadow: 0 7px 18px rgba(6, 27, 21, .18); }
+.overview-query-form .btn:hover { border-color: #f3b75b; color: #102e26; background: #f3b75b; }
+.overview-query-chips { gap: 7px; }
+.overview-query-chips button { min-height: 34px; padding: 6px 11px; border-color: rgba(255, 255, 255, .18); border-radius: 6px; color: rgba(255, 255, 255, .78); background: rgba(255, 255, 255, .06); font-size: 12px; }
+.overview-query-chips button:hover { border-color: rgba(255, 255, 255, .36); color: #fff; background: rgba(255, 255, 255, .12); }
+.overview-kpi-grid { gap: 12px; }
+.overview-kpi { min-height: 118px; padding: 18px; border-color: #dce4e0; border-radius: 8px; box-shadow: 0 6px 20px rgba(29, 54, 44, .045); }
+.overview-kpi:hover { border-color: #9fc3b3; box-shadow: 0 12px 28px rgba(28, 69, 52, .1); }
+.overview-kpi-icon { width: 44px; height: 44px; border-radius: 7px; }
+.overview-kpi small { color: #65766e; font-size: 13px; font-weight: 650; }
+.overview-kpi strong { font-size: 30px; }
+.overview-kpi em { color: #738179; font-size: 12px; }
+.overview-main-grid { grid-template-columns: minmax(0, 1.58fr) minmax(320px, .72fr); gap: 14px; }
+.overview-primary-column, .overview-secondary-column { gap: 14px; }
+.overview-panel { padding: 21px; border-color: #dce4e0; border-radius: 8px; box-shadow: 0 7px 24px rgba(24, 48, 38, .045); }
+.overview-panel-header { margin-bottom: 17px; }
+.overview-panel-header span { color: #477664; font-size: 10px; letter-spacing: 1px; }
+.overview-panel-header h3 { font-size: 18px; }
+.overview-panel-header > small { color: #74827b; font-size: 12px; }
+.overview-panel-header > strong { font-size: 24px; }
+.overview-diagnosis-empty { min-height: 200px; gap: 9px; }
+.overview-diagnosis-empty strong { font-size: 16px; }
+.overview-diagnosis-empty span { color: #697870; font-size: 13px; }
+.overview-diagnosis-result > header span { font-size: 11px; }
+.overview-diagnosis-result > header strong { font-size: 18px; }
+.overview-diagnosis-result > p { font-size: 14px; }
+.overview-evidence span, .overview-data-gaps > strong, .overview-data-gaps span { font-size: 12px; }
+.overview-data-gaps small { font-size: 11px; }
+.overview-diagnosis-go { min-height: 82px; padding: 13px 36px 10px 14px; }
+.overview-diagnosis-go > span { font-size: 14px; }
+.overview-diagnosis-go > small { font-size: 12px; }
+.overview-candidate-btn { min-height: 38px; font-size: 12px; }
+.overview-action { min-height: 72px; padding: 10px 13px; border-radius: 7px; }
+.overview-action > strong { font-size: 23px; }
+.overview-action b { font-size: 14px; }
+.overview-action small { color: #718078; font-size: 12px; }
+.overview-health-track { height: 8px; }
+.overview-health-panel dl { gap: 12px; }
+.overview-health-panel dl div, .overview-text-action { font-size: 13px; }
+.overview-text-action { min-height: 44px; }
+.overview-worker { grid-template-columns: 42px minmax(0, 1fr) auto; min-height: 68px; }
+.overview-worker-icon { width: 40px; height: 40px; }
+.overview-worker strong { font-size: 14px; }
+.overview-worker small { color: #6f7d76; font-size: 12px; }
+.overview-worker em { min-height: 26px; font-size: 11px; }
+.overview-data-note { min-height: 44px; color: #6e7c75; font-size: 12px; }
+.overview-data-note button { min-height: 44px; }
+
 @media (max-width: 1180px) {
   .overview-command { grid-template-columns: 1fr; gap: 18px; }
   .overview-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -2662,13 +3040,20 @@ body.business-modal-open { overflow: hidden; }
 }
 
 @media (max-width: 640px) {
-  .overview-command { min-height: 0; padding: 19px 16px; }
-  .overview-command-copy h2 { font-size: 24px; }
+  .overview-page { gap: 12px; }
+  .overview-command { min-height: 0; padding: 22px 16px; }
+  .overview-command-copy h2 { font-size: 28px; }
+  .overview-command-copy p { font-size: 15px; }
+  .overview-command-status { display: grid; grid-template-columns: 1fr; }
+  .overview-command-status span { width: 100%; font-size: 13px; }
+  .overview-query-form { padding: 0; }
   .overview-query-form > div:first-of-type { grid-template-columns: 1fr; }
   .overview-query-form input { font-size: 16px; }
   .overview-query-form .btn { width: 100%; }
+  .overview-query-chips button { min-height: 44px; }
   .overview-kpi-grid, .overview-secondary-column { grid-template-columns: 1fr; }
-  .overview-kpi { min-height: 88px; }
+  .overview-kpi { min-height: 104px; }
+  .overview-panel { padding: 16px; }
   .overview-diagnosis-actions { grid-template-columns: 1fr; }
   .overview-diagnosis-action { border-right: 0; border-bottom: 1px solid #edf0ee; }
   .overview-diagnosis-action:last-child { border-bottom: 0; }

+ 22 - 8
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/workspace-library-service.js

@@ -1,10 +1,15 @@
 const fs = require('fs');
 const path = require('path');
+const { PACKAGE_ROOT, resolveWorkspaceRoot } = require('../core/runtime-context');
 
-const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
-const KNOWLEDGE_BASE_DIR = path.join(PROJECT_ROOT, 'knowledge-base');
+const PROJECT_ROOT = resolveWorkspaceRoot({ packageRoot: PACKAGE_ROOT });
+const PACKAGE_KNOWLEDGE_BASE_DIR = path.join(PACKAGE_ROOT, 'knowledge-base');
+const PROJECT_KNOWLEDGE_BASE_DIR = path.join(PROJECT_ROOT, 'knowledge-base');
+const KNOWLEDGE_BASE_DIR = fs.existsSync(path.join(PROJECT_KNOWLEDGE_BASE_DIR, 'catalog.json'))
+  ? PROJECT_KNOWLEDGE_BASE_DIR
+  : PACKAGE_KNOWLEDGE_BASE_DIR;
 const CATALOG_FILE = path.join(KNOWLEDGE_BASE_DIR, 'catalog.json');
-const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '..', '..');
+const SOURCE_WORKSPACE_ROOT = path.resolve(PACKAGE_ROOT, '..', '..');
 const ALLOWED_EXTENSIONS = new Set(['.md', '.json', '.csv', '.txt', '.js']);
 const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
 
@@ -52,21 +57,29 @@ function packageDefinitions() {
       id: 'unified-source',
       label: '统一源码版',
       description: '当前 4320 Dashboard 的主运行包,承载页面、MCP 工具和真实企微 Agent。',
-      root: PROJECT_ROOT,
+      root: PACKAGE_ROOT,
       status: 'running',
     },
+    ...(PROJECT_ROOT.toLowerCase() !== PACKAGE_ROOT.toLowerCase() ? [{
+      id: 'customer-project',
+      label: '当前客户项目',
+      description: '客户项目中的自定义 Skills、知识库和业务扩展。',
+      root: PROJECT_ROOT,
+      skillsRoot: path.join(PROJECT_ROOT, '.claude', 'skills'),
+      status: 'active',
+    }] : []),
     {
       id: 'openclaw',
       label: 'OpenClaw 企微技能包',
       description: 'OpenClaw 版本的企微技能与独立 Agent 工具实现。',
-      root: path.resolve(WORKSPACE_ROOT, '..', 'openclaw-voc-skill', 'claude-code', 'claude-code-qiwe-assistant'),
+      root: path.resolve(SOURCE_WORKSPACE_ROOT, '..', 'openclaw-voc-skill', 'claude-code', 'claude-code-qiwe-assistant'),
       status: 'connected',
     },
     {
       id: 'agent-workbench',
       label: '企微 Agent Workbench',
       description: 'SQLite 状态机、人工审核、暂停、审计和工具循环的来源实现。',
-      root: path.join(WORKSPACE_ROOT, 'qiwei-agent-workbench'),
+      root: path.join(SOURCE_WORKSPACE_ROOT, 'qiwei-agent-workbench'),
       status: 'merged',
     },
   ];
@@ -99,7 +112,7 @@ function loadCatalog() {
         : 'document-library',
   })).filter(item => item.id !== 'property-data' || fs.existsSync(item.root));
   for (const pkg of packageDefinitions()) {
-    const skillsRoot = path.join(pkg.root, 'skills');
+    const skillsRoot = pkg.skillsRoot || path.join(pkg.root, 'skills');
     if (!fs.existsSync(skillsRoot)) continue;
     libraries.push({
       id: `skills-${pkg.id}`,
@@ -452,7 +465,8 @@ function getSkillDetail(skillId) {
 }
 
 module.exports = {
-  PROJECT_ROOT,
+    PROJECT_ROOT,
+    PACKAGE_ROOT,
   KNOWLEDGE_BASE_DIR,
   listKnowledgeTree,
   readKnowledgeFile,

+ 79 - 8
claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-agent-transport.js

@@ -52,14 +52,28 @@ class FmodeQiweiClient {
 
   async checkLogin() {
     const ctx = this.requireContext();
-    const result = await callFmodeWecomGateway({
-      gatewayPath: '/login/status',
-      httpMethod: 'GET',
-      query: { uid: ctx.uid },
-      token: ctx.token,
-      apiBase: ctx.apiBase,
-      timeoutMs: 30000,
-    });
+    let result;
+    try {
+      result = await callFmodeWecomGateway({
+        gatewayPath: '/login/status',
+        httpMethod: 'GET',
+        query: { uid: ctx.uid },
+        token: ctx.token,
+        apiBase: ctx.apiBase,
+        timeoutMs: 30000,
+      });
+    } catch (error) {
+      if (!this.config.trustConfiguredOnStatusError) throw error;
+      return {
+        configured: true,
+        online: true,
+        userOnlineStatus: 2,
+        errorCode: 0,
+        nickname: this.config.nickname || '',
+        corpName: this.config.corpName || '',
+        statusFallback: true,
+      };
+    }
     const data = result.data || {};
     const detail = data.detail || {};
     return {
@@ -77,6 +91,63 @@ class FmodeQiweiClient {
     return this.call('/msg/syncMsg', { msgSeq, limit });
   }
 
+  async listExternalContacts({ limit = 500, maxPages = 10 } = {}) {
+    const pageSize = Math.max(20, Math.min(1000, Number(limit) || 500));
+    const pageLimit = Math.max(1, Math.min(20, Number(maxPages) || 10));
+    const contacts = [];
+    const seen = new Set();
+    let currentSeq = 0;
+    let pages = 0;
+    let reportedCount = 0;
+
+    while (pages < pageLimit) {
+      const data = await this.call('/contact/getWxContactList', {
+        currentSeq,
+        limit: pageSize,
+        bizType: 1,
+      });
+      const list = Array.isArray(data.contactList) ? data.contactList : [];
+      reportedCount = Math.max(reportedCount, Number(data.contactCount) || 0);
+      for (const item of list) {
+        const id = String(item?.userId || item?.externalUserId || item?.id || '').trim();
+        if (!id || seen.has(id)) continue;
+        seen.add(id);
+        contacts.push(item);
+      }
+
+      pages += 1;
+      const nextSeq = Number(data.currentSeq);
+      if (!data.hasMore || !list.length || !Number.isFinite(nextSeq) || nextSeq === currentSeq) break;
+      currentSeq = nextSeq;
+    }
+
+    const missingNames = contacts
+      .filter(item => !String(item.remark || item.nickname || item.nickName || item.userName || item.name || '').trim())
+      .map(item => String(item.userId || item.externalUserId || item.id || '').trim())
+      .filter(Boolean);
+    const details = new Map();
+    for (let index = 0; index < missingNames.length; index += 100) {
+      try {
+        const data = await this.call('/contact/batchGetUserinfo', { userIdList: missingNames.slice(index, index + 100) });
+        for (const item of Array.isArray(data.contactList) ? data.contactList : []) {
+          const id = String(item?.userId || item?.externalUserId || item?.id || '').trim();
+          if (id) details.set(id, item);
+        }
+      } catch {
+        break;
+      }
+    }
+
+    return {
+      contacts: contacts.map(item => {
+        const id = String(item?.userId || item?.externalUserId || item?.id || '').trim();
+        return { ...item, ...(details.get(id) || {}) };
+      }),
+      contactCount: Math.max(reportedCount, contacts.length),
+      pages,
+    };
+  }
+
   sendText(toId, content) {
     return this.call('/msg/sendText', { toId, content, isNoNeedRead: false });
   }

+ 5 - 4
claude-code/claude-code-qiwe-assistant/mcp/src/server.js

@@ -167,7 +167,7 @@ function wrap(handler) {
 function createServer() {
   const server = new McpServer({
     name: 'enterprise-wechat-assistant',
-    version: '0.4.0'
+    version: '0.4.2'
   });
 
   server.registerTool(
@@ -243,7 +243,7 @@ function createServer() {
       title: '开始企业微信扫码登录',
       description: [
         '登录两步流程入口:默认在本地端口(默认 4310)启动流程页服务并打开浏览器,页面自动推进:未开通订阅时先展示套餐页(每号 ¥200/月,飞马余额扣费),开通后自动生成二维码并每 3 秒轮询扫码状态,需要验证码时页面提示输入。',
-        '传 flowUi=false 回退到静态模式:校验订阅(未开通返回 needs_subscription 并生成套餐页)后生成二维码保存到 outputs/login/qiwei-login-qrcode.png(openBrowser=false 可关闭自动打开)。',
+        '传 flowUi=false 仅用于已开通订阅后的扫码回退模式;未开通时返回 needs_subscription,不生成包含请求凭据的静态付费页。',
         '需要有效企微包月订阅和可用席位;无需提供企业微信接口访问凭据、代理或设备参数。'
       ].join(' '),
       inputSchema: {
@@ -305,9 +305,10 @@ function createServer() {
     'qiwei_subscribe',
     {
       title: '开通、续费或增购企微席位',
-      description: '使用飞马余额开通或续费企微包月订阅。1 个席位对应 1 个企业微信账号;seats 不传时默认 1。',
+      description: '使用飞马余额一次开通或续费 1-12 个月企微订阅。1 个席位对应 1 个企业微信账号;seats 和 months 不传时默认 1。',
       inputSchema: {
         seats: z.number().int().min(1).optional(),
+        months: z.number().int().min(1).max(12).optional(),
         authToken: z.string().optional(),
         apiBase: z.string().optional()
       }
@@ -1228,7 +1229,7 @@ function createServer() {
     'qiwei_agent_dashboard_start',
     {
       title: '启动企微智能工作台',
-      description: '在当前技能包项目中启动企微 Dashboard(默认 4320 端口),返回智能会话页面地址。主控 Claude Code 会话首次使用时调用。',
+      description: '启动并预览当前项目的企微 Dashboard(默认 4320 端口),同时返回 Fmode 鉴权、企微席位、账号在线、测试白名单和 AI 监听五项状态与下一步动作。主控 Claude Code 会话首次使用时调用。',
       inputSchema: {}
     },
     wrap(qiweiAgentDashboardStart)

+ 38 - 6
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-agent-control-run.js

@@ -3,11 +3,14 @@ const path = require('path');
 const { spawn } = require('child_process');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { latestPath } = require('../core/output-paths');
+const { resolveWorkspaceRoot, workspaceIdentity } = require('../core/runtime-context');
+const { buildStartupSummary, startupAssistantMessage } = require('../core/startup-summary');
 
 const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
 const DASHBOARD_PORT = Number(process.env.QIWEI_DASHBOARD_PORT || 4320);
 const DASHBOARD_URL = `http://127.0.0.1:${DASHBOARD_PORT}`;
 const SESSION_FILE = latestPath('messages', 'claude-code-sessions.json');
+const WORKSPACE_ID = workspaceIdentity(resolveWorkspaceRoot());
 
 function writeJsonAtomic(filePath, value) {
   fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -50,16 +53,32 @@ async function requestJson(method, pathname, body, timeoutMs = 15000) {
   return payload;
 }
 
+async function dashboardState() {
+  try {
+    const health = await requestJson('GET', '/api/health', undefined, 2500);
+    const actualWorkspaceId = health.data?.workspaceId || '';
+    if (!actualWorkspaceId || actualWorkspaceId !== WORKSPACE_ID) return { available: false, occupied: true };
+    return { available: true, occupied: false };
+  } catch {
+    return { available: false, occupied: false };
+  }
+}
+
 async function dashboardAvailable() {
-  try { await requestJson('GET', '/api/agent/status', undefined, 2500); return true; }
-  catch { return false; }
+  return (await dashboardState()).available;
 }
 
 const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
 
 async function qiweiAgentDashboardStart() {
   bindControllerSession();
-  if (!(await dashboardAvailable())) {
+  let state = await dashboardState();
+  if (state.occupied) {
+    return errorResult(`端口 ${DASHBOARD_PORT} 正被另一个企微项目或旧版工作台使用`, {
+      nextActions: [`关闭占用 ${DASHBOARD_PORT} 的旧工作台,或为当前项目设置其他 QIWEI_DASHBOARD_PORT`],
+    });
+  }
+  if (!state.available) {
     const child = spawn(process.execPath, [path.join(PROJECT_ROOT, 'scripts', 'start-dashboard.js')], {
       cwd: PROJECT_ROOT,
       detached: true,
@@ -71,10 +90,23 @@ async function qiweiAgentDashboardStart() {
     for (let attempt = 0; attempt < 40 && !(await dashboardAvailable()); attempt += 1) await delay(250);
   }
   if (!(await dashboardAvailable())) return errorResult('企微智能工作台启动失败,请检查端口是否被占用');
+  let startup = null;
+  const warnings = [];
+  try {
+    const [status, agent] = await Promise.all([
+      requestJson('GET', '/api/status'),
+      requestJson('GET', '/api/agent/status'),
+    ]);
+    startup = buildStartupSummary(status, agent);
+  } catch {
+    warnings.push('工作台已启动,但暂时无法读取完整启动状态,请打开页面后点击状态检查');
+  }
   return okResult({
-    assistantMessage: `企微智能工作台已启动:${DASHBOARD_URL}/#agent`,
-    summary: { running: true, port: DASHBOARD_PORT },
-    data: { dashboardUrl: `${DASHBOARD_URL}/#agent` },
+    assistantMessage: startup ? startupAssistantMessage(startup) : `企微智能工作台已启动:${DASHBOARD_URL}/#agent`,
+    summary: { running: true, port: DASHBOARD_PORT, ready: Boolean(startup?.ready), ...(startup || {}) },
+    data: { dashboardUrl: `${DASHBOARD_URL}/#agent`, startup },
+    nextActions: startup ? [startup.nextAction, `打开 ${DASHBOARD_URL}/#agent`] : [`打开 ${DASHBOARD_URL}/#agent`],
+    warnings,
   });
 }
 

+ 19 - 12
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-broker-playbook-run.js

@@ -71,22 +71,29 @@ function extractRoomIdFromFileName(fileName) {
 }
 
 function listRoomIdsFromSubdirectories() {
-  const dir = path.join(outputsRoot(), 'messages');
-  if (!fs.existsSync(dir)) return [];
-  return fs.readdirSync(dir)
-    .filter(f => {
-      const fullPath = path.join(dir, f);
-      return fs.statSync(fullPath).isDirectory() && /^[a-zA-Z0-9_-]+$/.test(f);
-    });
+  const messagesRoot = path.join(outputsRoot(), 'messages');
+  const roots = [path.join(messagesRoot, 'rooms'), messagesRoot];
+  const result = new Set();
+  for (const dir of roots) {
+    if (!fs.existsSync(dir)) continue;
+    for (const entry of fs.readdirSync(dir)) {
+      if (dir === messagesRoot && (entry === 'rooms' || /^\d{4}-\d{2}-\d{2}$/.test(entry))) continue;
+      const fullPath = path.join(dir, entry);
+      if (fs.statSync(fullPath).isDirectory() && /^[a-zA-Z0-9_-]+$/.test(entry)) result.add(entry);
+    }
+  }
+  return [...result];
 }
 
 function listRoomMessageFiles(roomId) {
-  const dir = path.join(outputsRoot(), 'messages', roomId);
-  if (!fs.existsSync(dir)) return [];
-  return fs.readdirSync(dir)
+  const messagesRoot = path.join(outputsRoot(), 'messages');
+  const dirs = [path.join(messagesRoot, 'rooms', roomId), path.join(messagesRoot, roomId)];
+  const files = dirs.flatMap(dir => !fs.existsSync(dir) ? [] : fs.readdirSync(dir)
     .filter(f => f.endsWith('.json'))
-    .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
-    .sort((a, b) => a.name.localeCompare(b.name));
+    .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime })));
+  const unique = new Map();
+  for (const file of files) if (!unique.has(file.name)) unique.set(file.name, file);
+  return [...unique.values()].sort((a, b) => a.name.localeCompare(b.name));
 }
 
 function collectMessagesForBroker(brokerUserId, roomIds = null) {

+ 10 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-transfer-run.js

@@ -107,7 +107,11 @@ const qiweiPreviewTransferPackage = safeResult(async function qiweiPreviewTransf
   const fileName = `preview-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
   const filePath = path.join(runDir, fileName);
   fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
-  writeRunManifest(runDir, { operation: 'preview-customer-transfer', fromUserId, toUserId, files: [filePath] });
+  writeRunManifest(runDir, {
+    tool: 'qiwei_preview_transfer_package',
+    summary: { fromUserId, toUserId, itemCount: items.length, status: 'DRAFT' },
+    files: [filePath]
+  });
 
   // 同时写入 latest
   const latestFile = path.join(transfersDir(), fileName);
@@ -196,7 +200,11 @@ const qiweiExecuteTransfer = safeResult(async function qiweiExecuteTransfer(inpu
   const fileName = `execution-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
   const filePath = path.join(runDir, fileName);
   fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
-  writeRunManifest(runDir, { operation: 'execute-customer-transfer', fromUserId: preview.fromUserId, toUserId: preview.toUserId, files: [filePath] });
+  writeRunManifest(runDir, {
+    tool: 'qiwei_execute_transfer',
+    summary: { success, failed, total: preview.items.length, removeOldBroker },
+    files: [filePath]
+  });
   recordTransferExecution({ ...preview, filePath: path.relative(outputsRoot(), filePath) }, results);
 
   return okResult({

+ 30 - 9
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js

@@ -49,10 +49,22 @@ function messagesDir() {
   return path.join(outputsRoot(), 'messages');
 }
 
+function roomMessagesRoot() {
+  return path.join(messagesDir(), 'rooms');
+}
+
 function roomMessagesDir(roomId) {
+  return path.join(roomMessagesRoot(), roomId);
+}
+
+function legacyRoomMessagesDir(roomId) {
   return path.join(messagesDir(), roomId);
 }
 
+function roomMessageDirs(roomId) {
+  return [roomMessagesDir(roomId), legacyRoomMessagesDir(roomId)].filter((dir, index, items) => items.indexOf(dir) === index);
+}
+
 function ensureGroupsDir() {
   fs.mkdirSync(groupsDir(), { recursive: true });
 }
@@ -189,20 +201,17 @@ function appendWebhookMessage(roomId, message) {
 
 function messageExists(roomId, msgUniqueId) {
   if (!roomId || !msgUniqueId) return false;
-  const dir = roomMessagesDir(roomId);
-  if (!fs.existsSync(dir)) return false;
   const safeUniqueId = String(msgUniqueId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
-  const files = fs.readdirSync(dir);
-  return files.some(f => f.endsWith(`-${safeUniqueId}.json`));
+  return roomMessageDirs(roomId).some(dir => fs.existsSync(dir) && fs.readdirSync(dir).some(f => f.endsWith(`-${safeUniqueId}.json`)));
 }
 
 function listRoomMessageFiles(roomId) {
-  const dir = roomMessagesDir(roomId);
-  if (!fs.existsSync(dir)) return [];
-  return fs.readdirSync(dir)
+  const files = roomMessageDirs(roomId).flatMap(dir => !fs.existsSync(dir) ? [] : fs.readdirSync(dir)
     .filter(f => f.endsWith('.json'))
-    .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
-    .sort((a, b) => a.name.localeCompare(b.name));
+    .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime })));
+  const unique = new Map();
+  for (const file of files) if (!unique.has(file.name)) unique.set(file.name, file);
+  return [...unique.values()].sort((a, b) => a.name.localeCompare(b.name));
 }
 
 function readRoomMessages(roomId) {
@@ -1067,6 +1076,16 @@ const qiweiConfirmExternalGroup = safeResult(async function qiweiConfirmExternal
       }
     : item);
   const discoveredCustomers = recordCustomersFromGroups(roomsForDiscovery, { source: 'group-confirm' });
+  const operationAccountKey = String(input.accountKey || input.guid || input.uid || '').trim();
+  if (operationAccountKey) {
+    getGroupOperationsStore().upsertGroup(operationAccountKey, {
+      roomId,
+      roomName: room.roomName || roomId,
+      groupType: 'customer',
+      lifecycleStage: 'active',
+      status: 'active'
+    }, 'group-confirm');
+  }
 
   return okResult({
     assistantMessage: `已确认外部群为客户群:${room.roomName || roomId}。`,
@@ -1272,6 +1291,8 @@ module.exports = {
   qiweiConfigureGroupKeywords,
   qiweiRejectExternalGroup,
   qiweiSyncGroupMessages,
+  appendWebhookMessage,
+  readRoomMessages,
   readConfirmedMapping,
   writeConfirmedMapping,
   updateConfirmedMapping,

+ 26 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-operations-run.js

@@ -2,6 +2,7 @@ const { okResult, errorResult } = require('../core/result-envelope');
 const { safeResult, buildContext, gatewayCall, requireGuid, assertMethodsInCatalog } = require('../core/shared-gateway');
 const { getGroupOperationsStore, accountKey } = require('../core/group-operations-store');
 const { ROLE_CAPABILITIES, authorizeGroupOps } = require('../core/group-operations-access');
+const { qiweiListExternalGroups } = require('./qiwei-group-management-run');
 
 function context(input = {}) {
   return {
@@ -21,9 +22,32 @@ function isAmbiguousSendFailure(error) {
   return /timeout|timed out|etimedout|econnreset|socket hang up|network|连接重置|超时|回执/.test(message);
 }
 
+function syncCustomerGroupsToOperations(store, key, groups = [], actor = 'group-directory-sync') {
+  let synced = 0;
+  for (const group of groups) {
+    if (!['CONFIRMED', 'AUTO_CONFIRMED'].includes(group.reviewStatus)) continue;
+    const roomId = String(group.roomId || '').trim();
+    if (!roomId) continue;
+    const roomName = String(group.roomName || '').trim();
+    const existing = store.getGroup(key, roomId);
+    if (existing && existing.room_name === roomName && existing.status === 'active') continue;
+    store.upsertGroup(key, {
+      roomId,
+      roomName,
+      groupType: 'customer',
+      lifecycleStage: existing?.lifecycle_stage || 'active',
+      status: 'active',
+    }, actor);
+    synced++;
+  }
+  return synced;
+}
+
 const qiweiGroupOpsGetContext = safeResult(async function qiweiGroupOpsGetContext(input = {}) {
   const ctx = context(input);
   const store = getGroupOperationsStore();
+  const directory = await qiweiListExternalGroups({ includeRejected: false });
+  syncCustomerGroupsToOperations(store, ctx.key, directory.data?.groups || []);
   if (input.roomId && input.group) store.upsertGroup(ctx.key, { ...input.group, roomId: input.roomId }, ctx.actor);
   const data = input.roomId
     ? { group: store.getGroup(ctx.key, input.roomId), playbooks: store.listPlaybooks(ctx.key), overview: store.overview(ctx.key, input.date) }
@@ -291,5 +315,6 @@ module.exports = {
   qiweiGroupOpsInsights,
   qiweiGroupOpsManageTask,
   qiweiGroupOpsAutomation,
-  qiweiGroupOpsManageAccess
+  qiweiGroupOpsManageAccess,
+  __testing: { syncCustomerGroupsToOperations }
 };

+ 9 - 15
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js

@@ -11,7 +11,7 @@ const {
 const { callFmodeWecomGateway, redactSecret } = require('../providers/fmode-wecom-gateway');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { latestPath } = require('../core/output-paths');
-const { saveSubscribePage, QIWEI_MONTHLY_PRICE } = require('../core/subscribe-page');
+const { QIWEI_MONTHLY_PRICE } = require('../core/subscribe-page');
 const { startLoginFlowServer } = require('../core/login-flow-server');
 const { startLoginFallbackServer } = require('../core/login-fallback-server');
 const { isRelayEnabled } = require('../core/relay-config');
@@ -202,25 +202,19 @@ async function qiweiLoginStatus(input = {}) {
   }
 }
 
-function subscriptionRequiredResult({ apiBase, token, uid, detail }) {
-  const pagePath = saveSubscribePage({ apiBase, token });
-  const opened = Boolean(pagePath && openInBrowser(pagePath));
+function subscriptionRequiredResult({ uid, detail }) {
   return {
     status: 'needs_subscription',
     assistantMessage: [
       `登录前需要先开通企微包月订阅:每个账号 ¥${QIWEI_MONTHLY_PRICE}/月,从飞马余额扣费。`,
-      opened
-        ? `已自动打开套餐选择页:${pagePath},请在页面上选择席位与时长并点击「开通服务」。`
-        : pagePath
-          ? `请打开套餐选择页完成开通:${pagePath}`
-          : '套餐页生成失败,可直接调用 qiwei_subscribe 开通。',
+      '为保护鉴权凭据,不再生成包含请求凭据的静态付费页面。请重新调用 qiwei_login_start 使用默认动态流程页,或直接调用 qiwei_subscribe 开通。',
       '开通成功后重新调用 qiwei_login_start 进入扫码登录(第 2 步)。',
       detail ? `订阅状态:${detail}` : ''
     ].filter(Boolean).join('\n'),
-    summary: { stage: 'loginStart', errorKind: 'subscription_required', uid, subscribePage: pagePath || null, browserOpened: opened, monthlyPrice: QIWEI_MONTHLY_PRICE },
-    data: { uid, subscribePage: pagePath || null },
-    files: pagePath ? [pagePath] : [],
-    nextActions: ['在套餐页开通订阅(或调用 qiwei_subscribe)', '开通后重新调用 qiwei_login_start'],
+    summary: { stage: 'loginStart', errorKind: 'subscription_required', uid, dynamicFlowRequired: true, monthlyPrice: QIWEI_MONTHLY_PRICE },
+    data: { uid },
+    files: [],
+    nextActions: ['重新调用 qiwei_login_start 使用默认动态流程页(或调用 qiwei_subscribe)', '开通后继续扫码登录'],
     warnings: [],
     errors: []
   };
@@ -270,11 +264,11 @@ async function qiweiLoginStart(input = {}) {
       });
       const sub = statusResult.data || {};
       if (!sub.subscribed) {
-        return subscriptionRequiredResult({ apiBase, token, uid, detail: '尚未开通包月订阅' });
+        return subscriptionRequiredResult({ uid, detail: '尚未开通包月订阅' });
       }
     } catch (error) {
       if (error && (error.kind === 'billing' || error.httpStatus === 402)) {
-        return subscriptionRequiredResult({ apiBase, token, uid, detail: redactSecret(error.message) });
+        return subscriptionRequiredResult({ uid, detail: redactSecret(error.message) });
       }
       if (error && error.kind === 'auth') return gatewayErrorResult(error, 'loginStart', '生成登录二维码失败');
       // 订阅状态接口不可用时不阻断登录,由 /login/start 自身订阅校验兑底

+ 31 - 16
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js

@@ -2,9 +2,9 @@ const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { createRunDir, outputsRoot, writeRunManifest } = require('../core/output-paths');
+const { writeObjectRows } = require('../core/xlsx-io');
 const { readState, recordPortrait, removePortrait, recordTags, recordOperation } = require('../core/dashboard-state');
 const { analyzePortraitWithLlm } = require('../core/llm-client');
-const { writeObjectRows } = require('../core/xlsx-io');
 const {
   buildContext,
   gatewayCall,
@@ -173,22 +173,29 @@ function extractRoomIdFromFileName(fileName) {
 }
 
 function listRoomIdsFromSubdirectories() {
-  const dir = path.join(outputsRoot(), 'messages');
-  if (!fs.existsSync(dir)) return [];
-  return fs.readdirSync(dir)
-    .filter(f => {
-      const fullPath = path.join(dir, f);
-      return fs.statSync(fullPath).isDirectory() && /^[a-zA-Z0-9_-]+$/.test(f);
-    });
+  const messagesRoot = path.join(outputsRoot(), 'messages');
+  const roots = [path.join(messagesRoot, 'rooms'), messagesRoot];
+  const result = new Set();
+  for (const dir of roots) {
+    if (!fs.existsSync(dir)) continue;
+    for (const entry of fs.readdirSync(dir)) {
+      if (dir === messagesRoot && (entry === 'rooms' || /^\d{4}-\d{2}-\d{2}$/.test(entry))) continue;
+      const fullPath = path.join(dir, entry);
+      if (fs.statSync(fullPath).isDirectory() && /^[a-zA-Z0-9_-]+$/.test(entry)) result.add(entry);
+    }
+  }
+  return [...result];
 }
 
 function listRoomMessageFiles(roomId) {
-  const dir = path.join(outputsRoot(), 'messages', roomId);
-  if (!fs.existsSync(dir)) return [];
-  return fs.readdirSync(dir)
+  const messagesRoot = path.join(outputsRoot(), 'messages');
+  const dirs = [path.join(messagesRoot, 'rooms', roomId), path.join(messagesRoot, roomId)];
+  const files = dirs.flatMap(dir => !fs.existsSync(dir) ? [] : fs.readdirSync(dir)
     .filter(f => f.endsWith('.json'))
-    .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
-    .sort((a, b) => a.name.localeCompare(b.name));
+    .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime })));
+  const unique = new Map();
+  for (const file of files) if (!unique.has(file.name)) unique.set(file.name, file);
+  return [...unique.values()].sort((a, b) => a.name.localeCompare(b.name));
 }
 
 function textOfMessage(message = {}) {
@@ -228,7 +235,7 @@ function inferRoomIdsForExternalUserId(externalUserId) {
 function collectMessagesForExternalUserId(externalUserId, roomIds = null) {
   const messages = [];
 
-  // 优先:新单条文件格式 outputs/messages/{roomId}/{seq}-{msgUniqueId}.json
+  // 优先:新单条文件格式 outputs/messages/rooms/{roomId}/{seq}-{msgUniqueId}.json
   const roomIdList = roomIds || listRoomIdsFromSubdirectories();
   for (const roomId of roomIdList) {
     if (roomIds && !roomIds.includes(roomId)) continue;
@@ -306,7 +313,11 @@ const qiweiPrepareCustomerPortrait = safeResult(async function qiweiPrepareCusto
   const runDir = createRunDir('portraits', `context-${externalUserId}`);
   const filePath = path.join(runDir, `context-${externalUserId}.json`);
   fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
-  writeRunManifest(runDir, { operation: 'prepare-customer-portrait', externalUserId, files: [filePath] });
+  writeRunManifest(runDir, {
+    tool: 'qiwei_prepare_customer_portrait',
+    summary: { externalUserId, messageCount: messages.length },
+    files: [filePath]
+  });
 
   return okResult({
     assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文:共 ${messages.length} 条消息。`,
@@ -364,7 +375,11 @@ const qiweiUpdateCustomerPortrait = safeResult(async function qiweiUpdateCustome
   const runDir = createRunDir('portraits', `context-${externalUserId}`);
   const contextFilePath = path.join(runDir, `context-${externalUserId}.json`);
   fs.writeFileSync(contextFilePath, JSON.stringify(context, null, 2), 'utf8');
-  writeRunManifest(runDir, { operation: 'update-customer-portrait', externalUserId, files: [contextFilePath] });
+  writeRunManifest(runDir, {
+    tool: 'qiwei_update_customer_portrait',
+    summary: { externalUserId, messageCount: messages.length, aiMode: aiMode || 'llm' },
+    files: [contextFilePath]
+  });
 
   try {
     const { portrait, usage } = await analyzePortraitWithLlm(context, {

+ 8 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-subscription-run.js

@@ -72,22 +72,28 @@ async function qiweiSubscribe(input = {}) {
   const token = readQiweiAuthToken(input);
   if (!token) return authRequiredResult();
   const seats = input.seats === undefined ? undefined : Number(input.seats);
+  const months = input.months === undefined ? 1 : Number(input.months);
   if (seats !== undefined && (!Number.isInteger(seats) || seats < 1)) {
     return errorResult('seats 必须是大于等于 1 的整数。');
   }
+  if (!Number.isInteger(months) || months < 1 || months > 12) {
+    return errorResult('months 必须是 1-12 的整数。');
+  }
   try {
     const result = await callFmodeWecomGateway({
       gatewayPath: '/subscribe',
-      body: seats === undefined ? {} : { seats },
+      body: { ...(seats === undefined ? {} : { seats }), months },
       token,
       apiBase: readQiweiApiBase(input)
     });
     const data = result.data || {};
     return okResult({
-      assistantMessage: `企微包月订阅已开通或续费:${data.seats ?? seats ?? 1} 个席位,到期时间 ${data.expireAt || '未知'}。`,
+      assistantMessage: `企微包月订阅已开通或续费:${data.seats ?? seats ?? 1} 个席位,购买 ${data.months ?? months} 个月,到期时间 ${data.expireAt || '未知'}。`,
       summary: {
         subscribed: true,
         seats: data.seats ?? seats ?? 1,
+        months: data.months ?? months,
+        amount: data.amount ?? null,
         expireAt: data.expireAt || null,
         autoRenew: data.autoRenew ?? null
       },

+ 12 - 8
claude-code/claude-code-qiwe-assistant/package-lock.json

@@ -1,12 +1,12 @@
 {
-  "name": "claude-code-qiwei-assistant",
-  "version": "0.4.0",
+  "name": "fmode-qiwei",
+  "version": "0.4.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "claude-code-qiwei-assistant",
-      "version": "0.4.0",
+      "name": "fmode-qiwei",
+      "version": "0.4.2",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.12.1",
         "cross-spawn": "7.0.6",
@@ -15,17 +15,19 @@
         "zod": "^3.24.1"
       },
       "bin": {
-        "qiwei-assistant": "install.js"
+        "fmode-qiwei": "install.js"
       },
       "engines": {
         "node": ">=22.5.0"
       }
     },
     "node_modules/@hono/node-server": {
-      "version": "1.19.14",
+      "version": "2.0.11",
+      "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.11.tgz",
+      "integrity": "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==",
       "license": "MIT",
       "engines": {
-        "node": ">=18.14.1"
+        "node": ">=20"
       },
       "peerDependencies": {
         "hono": "^4"
@@ -392,7 +394,9 @@
       "license": "MIT"
     },
     "node_modules/fast-uri": {
-      "version": "3.1.3",
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
+      "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
       "funding": [
         {
           "type": "github",

Разница между файлами не показана из-за своего большого размера
+ 6 - 8
claude-code/claude-code-qiwe-assistant/package.json


+ 334 - 6
claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js

@@ -6,17 +6,23 @@ const { DatabaseSync } = require('node:sqlite');
 const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
 const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
 const { createCustomerTaskOfficialSync } = require('../mcp/src/core/customer-task-official-sync');
-const { evaluatePolledMessage } = require('../mcp/src/core/agent-poller-policy');
+const { evaluatePolledMessage, roomIdOf } = require('../mcp/src/core/agent-poller-policy');
 const {
   ClaudeCodeClient,
   ClaudeCodeSessionStore,
   buildClaudeSessionName,
+  claudeSessionResetReason,
   enforceAuthoritativeGrounding,
   extractExplicitCustomerIntelligence,
+  isNoReplyNeededMessage,
+  parseClaudeProcessResult,
+  resolveClaudeExecutable,
   selectAuthoritativeHistory,
 } = require('../mcp/src/core/agent-runtime');
 const { getCustomerSessionGuide } = require('../mcp/src/core/agent-session-guide');
 const { FmodeQiweiClient } = require('../mcp/src/providers/fmode-agent-transport');
+const { normalizeAllowlistIds, normalizeAllowlistContact } = require('../mcp/src/core/allowlist-config');
+const { GroupAgentService } = require('../mcp/src/dashboard/group-agent-service');
 
 const results = [];
 
@@ -73,6 +79,14 @@ async function check(name, fn) {
 }
 
 async function main() {
+  await check('白名单选择会去重并拒绝不安全的联系人 ID', async () => {
+    assert.deepEqual(normalizeAllowlistIds(['contact-1', ' contact-1 ', 'wm_test:2']), ['contact-1', 'wm_test:2']);
+    assert.throws(() => normalizeAllowlistIds(['contact-1\nINJECTED=true']), /联系人 ID 格式无效/);
+    assert.deepEqual(normalizeAllowlistContact({ userId: 'contact-1', remark: '刘总', corpName: '示例公司' }), {
+      id: 'contact-1', displayName: '刘总', remark: '刘总', company: '示例公司'
+    });
+  });
+
   await check('Agent 企微传输统一走 Fmode 网关与登录专用端点', async () => {
     const calls = [];
     const originalFetch = global.fetch;
@@ -80,9 +94,16 @@ async function main() {
       const parsedBody = options.body ? JSON.parse(options.body) : null;
       calls.push({ url: String(url), options, body: parsedBody });
       const loginStatus = String(url).includes('/login/status');
-      const payload = loginStatus
-        ? { code: 0, data: { configured: true, online: true, statusCode: 2, detail: { nickname: '演示账号' } } }
-        : { code: 0, data: { data: { isSendSuccess: true, syncMsgList: [], travelSyncKey: 9 } } };
+      let payload;
+      if (loginStatus) {
+        payload = { code: 0, data: { configured: true, online: true, statusCode: 2, detail: { nickname: '演示账号' } } };
+      } else if (parsedBody?.method === '/contact/getWxContactList') {
+        payload = { code: 0, data: { data: { currentSeq: 9, contactCount: 1, hasMore: false, contactList: [{ userId: 'contact-1' }] } } };
+      } else if (parsedBody?.method === '/contact/batchGetUserinfo') {
+        payload = { code: 0, data: { data: { contactList: [{ userId: 'contact-1', nickname: '测试客户' }] } } };
+      } else {
+        payload = { code: 0, data: { data: { isSendSuccess: true, syncMsgList: [], travelSyncKey: 9 } } };
+      }
       return {
         ok: true,
         status: 200,
@@ -98,6 +119,7 @@ async function main() {
       });
       const account = await client.checkLogin();
       await client.syncMessages(8, 50);
+      const contacts = await client.listExternalContacts();
       await client.sendText('external-contact-1', '测试回复');
       assert.equal(account.online, true);
       assert.equal(account.nickname, '演示账号');
@@ -106,8 +128,11 @@ async function main() {
       assert.equal(calls[1].body.uid, 'uid-smoke');
       assert.equal(calls[1].body.method, '/msg/syncMsg');
       assert.equal(calls[1].body.params.guid, 'guid-smoke');
-      assert.equal(calls[2].body.method, '/msg/sendText');
-      assert.equal(calls[2].options.headers.Authorization, 'Bearer test-fmode-token');
+      assert.equal(contacts.contacts[0].nickname, '测试客户');
+      assert.equal(calls[2].body.method, '/contact/getWxContactList');
+      assert.equal(calls[3].body.method, '/contact/batchGetUserinfo');
+      assert.equal(calls[4].body.method, '/msg/sendText');
+      assert.equal(calls[4].options.headers.Authorization, 'Bearer test-fmode-token');
     } finally {
       global.fetch = originalFetch;
     }
@@ -128,6 +153,40 @@ async function main() {
     assert.equal(configB.qiwei.guid, accountB.guid);
   });
 
+  await check('启动监听保留当前审核策略,不自动切换会话模式', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const calls = [];
+    const target = {
+      poller: {
+        async start() {
+          calls.push('poller.start');
+          return { running: true, syncKey: 7 };
+        }
+      }
+    };
+    const result = await __testing.startListenerForWorkbench(target, { online: true, nickname: '测试账号' });
+    assert.deepEqual(calls, ['poller.start']);
+    assert.equal(result.status, 'ok');
+    assert.equal(result.data.running, true);
+  });
+
+  await check('Claude Code 可从 Fmode Studio npm-global PATH 中发现', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-claude-path-'));
+    const executable = process.platform === 'win32'
+      ? path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe')
+      : path.join(dir, 'claude');
+    fs.mkdirSync(path.dirname(executable), { recursive: true });
+    fs.writeFileSync(executable, 'smoke');
+    const previousPath = process.env.PATH;
+    try {
+      process.env.PATH = `${dir}${path.delimiter}${previousPath || ''}`;
+      assert.equal(resolveClaudeExecutable({}), executable);
+    } finally {
+      process.env.PATH = previousPath;
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
   await check('不同消息 ID 的同内容在 60 秒内只入库一次', async () => {
     const ctx = setup();
     try {
@@ -187,6 +246,92 @@ async function main() {
     } finally { ctx.close(); }
   });
 
+  await check('确认消息无需调用模型、无需回复且会清除旧错误状态', async () => {
+    let agentCalls = 0;
+    const ctx = setup({ agentRun: async () => { agentCalls += 1; throw new Error('不应调用模型'); } });
+    try {
+      const conversation = ctx.db.ensureConversation('contact-1', '王刚');
+      ctx.db.audit({ actor: 'agent', action: 'agent_failed', conversationId: conversation.id, detail: { message: '历史上游失败' } });
+      const result = await ctx.service.ingestInbound({ externalId: 'm-ack', contactId: 'contact-1', contactName: '王刚', content: '收到' });
+      assert.equal(result.status, 'no_reply_needed');
+      assert.equal(agentCalls, 0);
+      assert.equal(ctx.sent.length, 0);
+      assert.equal(ctx.db.latestAgentState(conversation.id), null);
+      assert.equal(ctx.db.latestAgentOutcome(conversation.id).action, 'agent_no_reply_needed');
+      assert.equal(ctx.db.latestAgentOutcome(conversation.id).entityId, result.message.id);
+      assert.equal(isNoReplyNeededMessage('好的。'), true);
+      assert.equal(isNoReplyNeededMessage('地址确认好了吗'), false);
+    } finally { ctx.close(); }
+  });
+
+  await check('Claude Code 预算超限时轮换客户 Session 并只重试一次', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-budget-reset-'));
+    try {
+      const client = new ClaudeCodeClient({
+        claudeWorkdir: dir,
+        claudeSessionFile: path.join(dir, 'sessions.json'),
+        claudeMaxBudgetUsd: 0.35,
+        claudeRetryMaxBudgetUsd: 1,
+      });
+      const sessionIds = [];
+      const invokeOptions = [];
+      client.invoke = async (_messages, _context, session, options = {}) => {
+        sessionIds.push(session.id);
+        invokeOptions.push(options);
+        if (sessionIds.length === 1) throw new Error('Claude Code 调用失败(退出码 1):error_max_budget_usd');
+        return { content: '{}', claudeCode: { resumed: false } };
+      };
+      const result = await client.complete([{ role: 'user', content: '请推荐房源' }], [], { conversation: { id: 'conversation-budget', contact_name: '王刚' } });
+      assert.equal(sessionIds.length, 2);
+      assert.notEqual(sessionIds[0], sessionIds[1]);
+      assert.equal(invokeOptions[1].maxBudgetUsd, 1);
+      assert.equal(result.claudeCode.sessionResetReason, 'budget_exceeded');
+      assert.equal(claudeSessionResetReason(new Error('error_max_budget_usd')), 'budget_exceeded');
+    } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+  });
+
+  await check('Claude Code 客服调用使用精简模式与低推理强度', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-claude-bare-'));
+    try {
+      let capturedArgs = [];
+      const client = new ClaudeCodeClient({
+        claudeWorkdir: dir,
+        claudeSessionFile: path.join(dir, 'sessions.json'),
+        claudeBare: true,
+        claudeEffort: 'low',
+        claudeTools: 'Read,Glob,Grep',
+        model: 'deepseek-v4-pro',
+      });
+      client.runProcess = async args => {
+        capturedArgs = args;
+        return { structured_output: { reply: '测试草稿' }, duration_ms: 1, total_cost_usd: 0.01 };
+      };
+      await client.invoke([{ role: 'system', content: '测试' }, { role: 'user', content: '推荐房源' }], {}, { id: '33333333-3333-4333-8333-333333333333', initialized: false });
+      assert(capturedArgs.includes('--bare'));
+      assert.equal(capturedArgs[capturedArgs.indexOf('--effort') + 1], 'low');
+      assert.equal(capturedArgs[capturedArgs.indexOf('--tools') + 1], 'Read,Glob,Grep');
+    } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+  });
+
+  await check('Claude Code 已完成结构化输出时不因末尾预算退出码丢弃草稿', async () => {
+    const recovered = parseClaudeProcessResult(JSON.stringify({
+      is_error: true,
+      subtype: 'error_max_budget_usd',
+      structured_output: {
+        reply: '已经生成的客服草稿',
+        confidence: 0.75,
+        intent: '继续推荐',
+      },
+    }), '', 1);
+    assert.equal(recovered.error, undefined);
+    assert.equal(recovered.payload.is_error, false);
+    assert.equal(recovered.payload.structured_output.reply, '已经生成的客服草稿');
+    assert.equal(recovered.payload.process_warning.detail, 'error_max_budget_usd');
+
+    const failed = parseClaudeProcessResult(JSON.stringify({ is_error: true, subtype: 'error_max_budget_usd' }), '', 1);
+    assert.match(failed.error, /error_max_budget_usd/);
+  });
+
   await check('非白名单联系人被忽略且不能人工发送', async () => {
     const ctx = setup();
     try {
@@ -349,6 +494,189 @@ async function main() {
     assert.equal(candidate.content, '自己住吧');
   });
 
+  await check('群聊与本账号消息不会串入白名单客户私聊', async () => {
+    const group = evaluatePolledMessage({
+      msgType: 2,
+      senderId: 'contact-1',
+      receiverId: 'self',
+      fromRoomId: 'room-123',
+      timestamp: Math.floor(Date.now() / 1000),
+      msgData: { content: '@同事 请发会议录屏' },
+    }, { selfUserId: 'self', allowedSenders: ['contact-1'] });
+    assert.equal(group.eligible, false);
+    assert.equal(group.reason, 'group_message');
+    assert.equal(group.roomId, 'room-123');
+    assert.equal(roomIdOf({ fromRoomId: 0, roomId: '' }), '');
+
+    const self = evaluatePolledMessage({
+      msgType: 1,
+      senderId: 'self',
+      receiverId: 'contact-1',
+      timestamp: Math.floor(Date.now() / 1000),
+      msgData: { content: '我发出的私聊' },
+    }, { selfUserId: 'self', allowedSenders: ['contact-1'] });
+    assert.equal(self.eligible, false);
+    assert.equal(self.reason, 'self_message');
+
+    const ctx = setup();
+    try {
+      const ignored = await ctx.service.ingestInbound({
+        externalId: 'group-direct-entry',
+        contactId: 'contact-1',
+        contactName: '王刚',
+        content: '群里的消息',
+        raw: { fromRoomId: 'room-123', senderId: 'contact-1' },
+      });
+      assert.equal(ignored.status, 'ignored_group_message');
+      assert.equal(ctx.db.listConversations().length, 0);
+      const legacyGroup = ctx.db.ensureConversation('contact-1', '历史群聊');
+      ctx.db.insertMessage({
+        conversationId: legacyGroup.id,
+        externalId: 'legacy-group-message',
+        direction: 'inbound',
+        senderType: 'customer',
+        content: '旧数据中的群消息',
+        raw: { fromRoomId: 'room-legacy', senderId: 'contact-1' },
+      });
+      await assert.rejects(() => ctx.service.manualSend(legacyGroup.id, '不应发送'), /群聊仅用于监控/);
+      assert.equal(ctx.sent.length, 0);
+    } finally { ctx.close(); }
+  });
+
+  await check('客户群支持人工审核与无视风险全自动两种独立模式', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-group-agent-smoke-'));
+    const messages = [{
+      msgId: 'group-message-1',
+      senderId: 'customer-1',
+      senderName: '张三',
+      fromRoomId: 'r-1',
+      content: '明天下午可以看房吗',
+      timestamp: '2026-07-23T08:00:00.000Z',
+    }];
+    const sent = [];
+    const sendAttempts = [];
+    const agentInputs = [];
+    let sendShouldFail = false;
+    let agentResponse = {
+      content: '可以的,请问您明天下午几点方便?',
+      confidence: 0.88,
+      intent: '预约看房',
+      reason: '客户明确询问看房时间',
+      requiresHuman: false,
+      citations: [],
+      toolTrace: [],
+    };
+    const runtime = {
+      config: { qiwei: { selfUserId: 'self-1', nickname: '王顾问' } },
+      agent: {
+        async run(input) {
+          agentInputs.push(input);
+          return agentResponse;
+        },
+      },
+      qiwei: {
+        async sendText(toId, content) {
+          sendAttempts.push({ toId, content });
+          if (sendShouldFail) return { isSendSuccess: false };
+          sent.push({ toId, content });
+          return { isSendSuccess: true };
+        },
+      },
+    };
+    const service = new GroupAgentService({
+      projectRoot: dir,
+      statePath: path.join(dir, 'group-agent.json'),
+      runtime,
+      getAccount: () => ({ uid: 'account-1', userId: 'self-1', nickname: '王顾问' }),
+      loadGroups: () => ({ 'r-1': { roomName: '张三买房服务群', customerName: '张三' } }),
+      loadMessages: () => messages,
+      appendMessage: (roomId, message) => { messages.push({ ...message, fromRoomId: roomId }); return 'memory'; },
+    });
+    try {
+      assert.equal(service.publicState('r-1').mode, 'review');
+      const generated = await service.generate('r-1');
+      assert.equal(generated.status, 'pending_review');
+      assert.equal(generated.draft.requiresHuman, true);
+      assert.equal(sent.length, 0);
+      assert.equal(agentInputs[0].channelType, 'group');
+      assert.match(agentInputs[0].directPrompt, /企业微信群聊客服 Agent/);
+
+      const approved = await service.approve('r-1', generated.draft.id, '可以的,张三,请问您明天下午几点方便?');
+      assert.equal(approved.status, 'sent');
+      assert.deepEqual(sent, [{ toId: 'r-1', content: '可以的,张三,请问您明天下午几点方便?' }]);
+      assert.equal(service.publicState('r-1').pendingReply, null);
+      assert.equal(service.publicState('r-1').messages.at(-1).role, 'human');
+      await assert.rejects(() => service.approve('r-1', generated.draft.id, '重复发送'), /已经是 sent/);
+      await assert.rejects(() => service.generate('unconfirmed-room'), /尚未确认为客户群/);
+
+      assert.throws(() => service.setMode('r-1', 'auto'), /需要明确确认/);
+      assert.equal(service.setMode('r-1', 'auto', 'AUTO_SEND_GROUP_MESSAGES').mode, 'auto');
+      agentResponse = {
+        content: '三点可以,我先为您登记。',
+        confidence: 0.1,
+        intent: '预约看房',
+        reason: '低置信回复仍由全自动模式放行',
+        requiresHuman: true,
+        citations: [],
+        toolTrace: [],
+      };
+
+      const ingested = await service.ingestPolledMessage({
+        msgType: 1,
+        msgServerId: 'group-message-2',
+        seq: 2,
+        senderId: 'customer-1',
+        senderName: '张三',
+        fromRoomId: 'r-1',
+        msgData: { content: '三点可以吗' },
+        timestamp: Math.floor(Date.now() / 1000),
+      }, { selfUserId: 'self-1' });
+      assert.equal(ingested.status, 'auto_sent');
+      assert.equal(agentInputs.length, 2);
+      assert.equal(sent.length, 2);
+      assert.deepEqual(sent.at(-1), { toId: 'r-1', content: '三点可以,我先为您登记。' });
+      assert.equal(service.publicState('r-1').pendingReply, null);
+      assert.equal(service.publicState('r-1').lastOutcome.action, 'group_message_auto_sent');
+      assert.equal(messages.at(-1).rawData.source, 'group_agent_auto');
+
+      sendShouldFail = true;
+      const failed = await service.ingestPolledMessage({
+        msgType: 1,
+        msgServerId: 'group-message-3',
+        seq: 3,
+        senderId: 'customer-1',
+        senderName: '张三',
+        fromRoomId: 'r-1',
+        msgData: { content: '能发个定位吗' },
+        timestamp: Math.floor(Date.now() / 1000) + 1,
+      }, { selfUserId: 'self-1' });
+      assert.equal(failed.status, 'pending_review');
+      assert.equal(failed.autoSendFailed, true);
+      assert.equal(sent.length, 2);
+      assert.equal(sendAttempts.length, 3);
+      assert(service.publicState('r-1').pendingReply);
+      assert(service.publicState('r-1').sendError);
+
+      sendShouldFail = false;
+      assert.equal(service.setMode('r-1', 'review').mode, 'review');
+      const reviewed = await service.ingestPolledMessage({
+        msgType: 1,
+        msgServerId: 'group-message-4',
+        seq: 4,
+        senderId: 'customer-1',
+        senderName: '张三',
+        fromRoomId: 'r-1',
+        msgData: { content: '四点也可以' },
+        timestamp: Math.floor(Date.now() / 1000) + 2,
+      }, { selfUserId: 'self-1' });
+      assert.equal(reviewed.status, 'pending_review');
+      assert.equal(sent.length, 2);
+      assert.equal(service.publicState('r-1').mode, 'review');
+    } finally {
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
   await check('同一业务待办只保留一张卡并聚合多条依据', async () => {
     const ctx = setup();
     try {

+ 10 - 1
claude-code/claude-code-qiwe-assistant/scripts/build-catalog.py

@@ -13,13 +13,22 @@ def strip_html(s):
     s = html.unescape(s)
     return re.sub(r'\s+', ' ', s).strip()
 
+PUBLIC_HIDDEN_PARAMS = {'aid'}
+
+def public_param_description(name, description):
+    if name == 'clientVersion' and re.search(r'iPad\s*最新版.*Windows\s*最新版', description, re.I):
+        return '客户端版本由 Fmode 服务自动选择,通常无需填写。'
+    return description
+
 def simplify_props(schema, required=None, prefix=''):
     out = []
     props = (schema or {}).get('properties') or {}
     req = set((schema or {}).get('required') or [])
     for name, spec in props.items():
         name = str(name).strip()
-        desc = strip_html(spec.get('description') or spec.get('title') or '')
+        if name.lower() in PUBLIC_HIDDEN_PARAMS:
+            continue
+        desc = public_param_description(name, strip_html(spec.get('description') or spec.get('title') or ''))
         typ = spec.get('type', 'string')
         entry = {'name': prefix + name, 'in': 'body', 'type': typ, 'required': name in req}
         if desc: entry['desc'] = desc[:600]

+ 3 - 1
claude-code/claude-code-qiwe-assistant/scripts/customer-master-smoke-test.js

@@ -11,6 +11,7 @@ async function main() {
   let updateCall = null;
   const conversation = {
     id: 'conversation-live-1',
+    channelId: 'external-contact-1',
     displayName: '测试客户',
     maskedId: '16***34',
     source: 'live',
@@ -47,6 +48,7 @@ async function main() {
   assert.equal(hub.summary.priorityCount, 1);
   assert.equal(hub.data.customers[0].source, '真实企微会话');
   assert.equal(hub.data.customers[0].evidenceCoverage, 33);
+  assert.equal(hub.data.customers[0].externalUserId, 'external-contact-1');
   assert.equal(hub.data.customers[0].openTaskCount, 1);
   assert(fs.existsSync(projectionPath));
   const projection = fs.readFileSync(projectionPath, 'utf8');
@@ -59,7 +61,7 @@ async function main() {
   assert.equal(updateCall.input.profile.intent, '自住');
   assert.equal(updateCall.input.reason, '人工确认');
 
-  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 13, customers: hub.summary.customerCount, evidenceProjectionSafe: true }, null, 2)}\n`);
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 14, customers: hub.summary.customerCount, evidenceProjectionSafe: true }, null, 2)}\n`);
 }
 
 main().catch(error => {

+ 18 - 0
claude-code/claude-code-qiwe-assistant/scripts/group-operations-smoke-test.js

@@ -4,6 +4,7 @@ const fs = require('fs');
 const os = require('os');
 const path = require('path');
 const { GroupOperationsStore } = require('../mcp/src/core/group-operations-store');
+const { __testing: groupOpsTesting } = require('../mcp/src/tools/qiwei-group-operations-run');
 
 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-group-ops-'));
 const store = new GroupOperationsStore(path.join(tempDir, 'test.db'));
@@ -11,6 +12,17 @@ const store = new GroupOperationsStore(path.join(tempDir, 'test.db'));
 try {
   const account = 'account-a';
   const otherAccount = 'account-b';
+  const directoryAccount = 'directory-account';
+  assert.equal(groupOpsTesting.syncCustomerGroupsToOperations(store, directoryAccount, [
+    { roomId: 'confirmed-room', roomName: 'Confirmed room', reviewStatus: 'CONFIRMED' },
+    { roomId: 'auto-room', roomName: 'Auto room', reviewStatus: 'AUTO_CONFIRMED' },
+    { roomId: 'suggested-room', roomName: 'Suggested room', reviewStatus: 'SUGGESTED' },
+  ]), 2);
+  assert.deepEqual(store.listGroups(directoryAccount).map(group => group.room_id).sort(), ['auto-room', 'confirmed-room']);
+  assert.equal(groupOpsTesting.syncCustomerGroupsToOperations(store, directoryAccount, [
+    { roomId: 'confirmed-room', roomName: 'Confirmed room', reviewStatus: 'CONFIRMED' },
+    { roomId: 'auto-room', roomName: 'Auto room', reviewStatus: 'AUTO_CONFIRMED' },
+  ]), 0, 'directory reconciliation must be idempotent');
   const playbook = store.createPlaybook(account, {
     name: '新客群服务 SOP',
     groupType: 'new_customer',
@@ -107,6 +119,12 @@ try {
   assert.equal(task.status, 'open');
   assert.equal(store.updateTask(account, task.id, { status: 'done' }, 'test').status, 'done');
   assert.equal(store.storeBreakdown(account, '2026-07-18').length, 2);
+  store.reviewMessages(account, {
+    roomId: 'room-1',
+    messages: [
+      { messageId: 'recent-activity', senderId: 'customer-1', senderRole: 'customer', content: '收到,谢谢', sentAt: new Date().toISOString() }
+    ]
+  }, 'test');
   assert.equal(store.groupInsights(account, 'room-1')[0].activity.currentMessages >= 1, true);
   assert.equal(store.excellentScripts(account).length, 1);
   assert.equal(store.overview(otherAccount, '2026-07-18').metrics.groups, 0);

+ 3 - 1
claude-code/claude-code-qiwe-assistant/scripts/open-customer-session.js

@@ -5,8 +5,10 @@ const os = require('os');
 const path = require('path');
 const { DatabaseSync } = require('node:sqlite');
 const spawn = require('cross-spawn');
+const { applyWorkspaceContext } = require('../mcp/src/core/runtime-context');
 
-const PROJECT_ROOT = path.resolve(__dirname, '..');
+const PACKAGE_ROOT = path.resolve(__dirname, '..');
+const { workspaceRoot: PROJECT_ROOT } = applyWorkspaceContext({ packageRoot: PACKAGE_ROOT, changeCwd: false });
 
 function loadEnvFile(filePath) {
   if (!fs.existsSync(filePath)) return;

+ 34 - 0
claude-code/claude-code-qiwe-assistant/scripts/package-smoke-test.js

@@ -26,7 +26,10 @@ function main() {
   const lock = readJson('package-lock.json');
   const manifest = readJson('skill-package-manifest.json');
   const plugin = readJson('.claude-plugin/plugin.json');
+  const catalog = readJson('mcp/catalog/qiwei-endpoints.json');
+  const catalogSource = fs.readFileSync(path.join(ROOT, 'mcp', 'catalog', 'qiwei-endpoints.json'), 'utf8');
   const serverSource = fs.readFileSync(path.join(ROOT, 'mcp', 'src', 'server.js'), 'utf8');
+  const dashboardSource = fs.readFileSync(path.join(ROOT, 'mcp', 'src', 'dashboard', 'app.js'), 'utf8');
   const serverVersion = serverSource.match(/new McpServer\([\s\S]*?version:\s*['"]([^'"]+)/)?.[1];
   const toolNames = [...serverSource.matchAll(/registerTool\(\s*['"]([^'"]+)['"]/g)].map(match => match[1]);
   const skillDirs = fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true })
@@ -34,15 +37,42 @@ function main() {
     .map(entry => entry.name)
     .sort();
 
+  assert.equal(pkg.name, 'fmode-qiwei');
+  assert.equal(pkg.bin?.['fmode-qiwei'], 'install.js');
   assert.equal(pkg.version, lock.version);
   assert.equal(pkg.version, lock.packages?.['']?.version);
   assert.equal(pkg.version, manifest.version);
   assert.equal(pkg.version, plugin.version);
   assert.equal(pkg.version, serverVersion);
+  assert.equal(manifest.name, pkg.name);
+  assert.equal(manifest.npmPackage, pkg.name);
+  assert.equal(manifest.status, 'published');
+  assert.match(manifest.installCommand, /fmode-qiwei@latest/);
   assert.equal(pkg.engines?.node, '>=22.5.0');
   assert.equal(new Set(toolNames).size, toolNames.length, 'MCP 工具名不能重复');
   assert.equal(toolNames.length, manifest.mcpToolCount, 'MCP 工具数量与 manifest 不一致');
   assert.deepEqual(skillDirs, [...manifest.skills].sort(), 'Skill 目录与 manifest 不一致');
+  assert.equal(
+    (catalogSource.match(/q-[a-z-]+=/gi) || []).length,
+    0,
+    '公开接口清单不能包含外部签名参数'
+  );
+  const createClient = catalog.endpoints.find(endpoint => endpoint.id === 'client.createClient');
+  assert(createClient, '接口清单缺少 client.createClient');
+  const hiddenNetworkParam = ['a', 'i', 'd'].join('');
+  assert(
+    !createClient.params.some(param => String(param.name).toLowerCase() === hiddenNetworkParam),
+    '公开接口清单不能暴露内部网络组件参数'
+  );
+  assert.equal(
+    createClient.params.find(param => param.name === 'clientVersion')?.desc,
+    '客户端版本由 Fmode 服务自动选择,通常无需填写。'
+  );
+  assert.doesNotMatch(
+    dashboardSource,
+    /(?:window\s*\.\s*)?(?:alert|confirm|prompt)\s*\(/,
+    'Dashboard 不能调用浏览器原生 alert、confirm 或 prompt'
+  );
 
   const pack = runNpmPackDryRun();
   const packedPaths = new Set(pack.files.map(item => item.path.replace(/\\/g, '/')));
@@ -50,9 +80,13 @@ function main() {
     '.claude-plugin/plugin.json',
     '.env.example',
     'install.js',
+    'mcp/src/core/runtime-context.js',
+    'mcp/src/core/startup-summary.js',
     'mcp/src/server.js',
     'mcp/src/tools/qiwei-agent-control-run.js',
     'scripts/agent-console-smoke-test.js',
+    'scripts/preview-dashboard.js',
+    'scripts/startup-preview-smoke-test.js',
     'skill-package-manifest.json',
     'THIRD_PARTY_NOTICES.md',
   ]) {

+ 108 - 0
claude-code/claude-code-qiwe-assistant/scripts/preview-dashboard.js

@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+
+const path = require('path');
+const { spawn } = require('child_process');
+const { applyWorkspaceContext, workspaceIdentity } = require('../mcp/src/core/runtime-context');
+const { buildStartupSummary } = require('../mcp/src/core/startup-summary');
+
+function parseArgs(argv = process.argv.slice(2)) {
+  const portIndex = argv.indexOf('--port');
+  const port = portIndex >= 0 ? Number(argv[portIndex + 1]) : Number(process.env.QIWEI_DASHBOARD_PORT || 4320);
+  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('端口无效,请使用 --port 4320 这类有效端口');
+  return { port, openBrowser: !argv.includes('--no-open') };
+}
+
+async function requestJson(url, timeoutMs = 15000) {
+  const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
+  if (!response.ok) throw new Error(`HTTP ${response.status}`);
+  return response.json();
+}
+
+async function inspectRunningServer(baseUrl, expectedWorkspaceId) {
+  try {
+    const health = await requestJson(`${baseUrl}/api/health`, 2500);
+    const actualWorkspaceId = health.data?.workspaceId || '';
+    if (actualWorkspaceId && actualWorkspaceId !== expectedWorkspaceId) {
+      return { running: false, occupied: true, actualWorkspaceId };
+    }
+    return { running: health.status === 'ok', occupied: false, health };
+  } catch {
+    return { running: false, occupied: false };
+  }
+}
+
+function openUrl(url) {
+  let command;
+  let args;
+  if (process.platform === 'win32') {
+    command = 'cmd.exe';
+    args = ['/d', '/s', '/c', 'start', '', url];
+  } else if (process.platform === 'darwin') {
+    command = 'open';
+    args = [url];
+  } else {
+    command = 'xdg-open';
+    args = [url];
+  }
+  const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
+  child.unref();
+}
+
+function printStartupSummary(baseUrl, summary, workspaceRoot, alreadyRunning) {
+  process.stdout.write([
+    '',
+    `企微工作台${alreadyRunning ? '已在运行' : '启动成功'}:${baseUrl}/#agent`,
+    `项目目录:${workspaceRoot}`,
+    '',
+    '启动检查:',
+    ...summary.checks.map(item => `  ${item.ready ? '[完成]' : '[待处理]'} ${item.label}${item.ready ? '' : ` — ${item.action}`}`),
+    '',
+    `下一步:${summary.nextAction}`,
+    '',
+  ].join('\n'));
+}
+
+async function main() {
+  const options = parseArgs();
+  process.env.QIWEI_DASHBOARD_PORT = String(options.port);
+  const context = applyWorkspaceContext({ packageRoot: path.resolve(__dirname, '..') });
+  const expectedWorkspaceId = workspaceIdentity(context.workspaceRoot);
+  const baseUrl = `http://127.0.0.1:${options.port}`;
+  const current = await inspectRunningServer(baseUrl, expectedWorkspaceId);
+  if (current.occupied) {
+    throw new Error(`端口 ${options.port} 正被另一个企微项目使用。请关闭旧工作台,或使用 npm run preview -- --port ${options.port + 1}`);
+  }
+
+  let server = null;
+  if (!current.running) {
+    const { startServer } = require('../mcp/src/dashboard/server');
+    ({ server } = await startServer(options.port));
+  }
+
+  let status = {};
+  let agent = {};
+  try {
+    [status, agent] = await Promise.all([
+      requestJson(`${baseUrl}/api/status`),
+      requestJson(`${baseUrl}/api/agent/status`),
+    ]);
+  } catch (error) {
+    process.stderr.write(`工作台已启动,但启动状态读取失败:${error.message}\n`);
+  }
+  const summary = buildStartupSummary(status, agent);
+  printStartupSummary(baseUrl, summary, context.workspaceRoot, current.running);
+  if (options.openBrowser) openUrl(`${baseUrl}/#agent`);
+  if (current.running && !server) return;
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    process.stderr.write(`启动预览失败:${error.message}\n`);
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  parseArgs,
+  inspectRunningServer,
+};

+ 1 - 0
claude-code/claude-code-qiwe-assistant/scripts/release-check.js

@@ -25,6 +25,7 @@ const checks = [
   'outputs:validate',
   'install:check',
   'package:smoke',
+  'audit:prod',
 ];
 
 try {

+ 103 - 0
claude-code/claude-code-qiwe-assistant/scripts/response-monitor-smoke-test.js

@@ -0,0 +1,103 @@
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { responseMonitor, isStaffMessage } = require('../mcp/src/dashboard/response-monitor-service');
+
+const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-response-monitor-'));
+const groupsDir = path.join(root, 'outputs', 'groups');
+const messagesDir = path.join(root, 'outputs', 'messages');
+fs.mkdirSync(groupsDir, { recursive: true });
+fs.mkdirSync(messagesDir, { recursive: true });
+
+fs.writeFileSync(path.join(groupsDir, 'confirmed-mapping.json'), JSON.stringify({
+  'room-1': { roomName: '重点客户群' },
+  'room-2': { roomName: '待同步客户群' },
+}), 'utf8');
+fs.writeFileSync(path.join(groupsDir, 'rooms-latest.json'), JSON.stringify([
+  { roomId: 'room-1', roomName: '重点客户群' },
+]), 'utf8');
+fs.writeFileSync(path.join(messagesDir, 'room-room-1-2026-07-20T10-00-00.json'), JSON.stringify([
+  { msgId: 'group-1', senderId: 'customer-1', senderName: '客户甲', content: '麻烦尽快回复', timestamp: '2026-07-20T08:00:00.000Z' },
+]), 'utf8');
+
+const result = responseMonitor({
+  projectRoot: root,
+  now: Date.parse('2026-07-20T10:00:00.000Z'),
+  warningMinutes: 15,
+  urgentMinutes: 60,
+  selfUserIds: ['staff-1'],
+  selfNames: ['客服小夏'],
+  conversations: [
+    {
+      id: 'conversation-1',
+      contactId: 'customer-1',
+      displayName: '王刚',
+      messages: [{ role: 'customer', content: '在吗', timestamp: '2026-07-20T09:40:00.000Z' }],
+    },
+    {
+      id: 'conversation-2',
+      contactId: 'customer-2',
+      displayName: '已回复客户',
+      messages: [
+        { role: 'customer', content: '你好', timestamp: '2026-07-20T09:20:00.000Z' },
+        { role: 'human', content: '您好', timestamp: '2026-07-20T09:22:00.000Z' },
+      ],
+    },
+    {
+      id: 'conversation-3',
+      contactId: 'customer-3',
+      displayName: '无需回复客户',
+      handledMessageId: 'ack-1',
+      messages: [{ id: 'ack-1', role: 'customer', content: '收到', timestamp: '2026-07-20T09:00:00.000Z' }],
+    },
+  ],
+});
+
+assert.strictEqual(result.summary.monitoredCustomers, 3);
+assert.strictEqual(result.summary.monitoredGroups, 2);
+assert.strictEqual(result.summary.overdue, 2);
+assert.strictEqual(result.summary.urgent, 1);
+assert.strictEqual(result.summary.warning, 1);
+assert.strictEqual(result.summary.awaitingSync, 1);
+assert.strictEqual(result.reminders[0].kind, 'group');
+assert.strictEqual(result.reminders[0].severity, 'urgent');
+assert.strictEqual(result.reminders[1].conversationId, 'conversation-1');
+assert.strictEqual(result.scope.customers[1].status, 'replied');
+assert.strictEqual(result.scope.customers[2].status, 'no_reply_needed');
+assert.strictEqual(result.scope.customers[2].waitingMinutes, 0);
+assert.strictEqual(result.scope.groups[1].status, 'awaiting_sync');
+assert.strictEqual(isStaffMessage({ senderId: 'staff-1' }, ['staff-1'], []), true);
+assert.strictEqual(isStaffMessage({ senderName: '客服小夏' }, [], ['客服小夏']), true);
+
+const roomMessagesDir = path.join(messagesDir, 'rooms', 'room-1');
+fs.mkdirSync(roomMessagesDir, { recursive: true });
+fs.writeFileSync(path.join(roomMessagesDir, '2-group-agent-reply.json'), JSON.stringify({
+  msgId: 'group-agent-reply',
+  senderId: 'staff-1',
+  senderName: '客服小夏',
+  fromRoomId: 'room-1',
+  content: '已经收到,我们马上处理。',
+  timestamp: '2026-07-20T09:00:00.000Z',
+  direction: 'outbound',
+}), 'utf8');
+const repliedResult = responseMonitor({
+  projectRoot: root,
+  now: Date.parse('2026-07-20T10:00:00.000Z'),
+  warningMinutes: 15,
+  urgentMinutes: 60,
+  selfUserIds: ['staff-1'],
+  selfNames: ['客服小夏'],
+  conversations: [],
+});
+assert.strictEqual(repliedResult.scope.groups[0].status, 'replied');
+assert.strictEqual(repliedResult.summary.overdue, 0);
+
+fs.rmSync(root, { recursive: true, force: true });
+console.log(JSON.stringify({
+  status: 'ok',
+  checks: 17,
+  monitoredCustomers: result.summary.monitoredCustomers,
+  monitoredGroups: result.summary.monitoredGroups,
+  overdue: result.summary.overdue,
+}, null, 2));

+ 0 - 38
claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals.py

@@ -1,38 +0,0 @@
-from playwright.sync_api import sync_playwright
-import time
-with sync_playwright() as p:
-    browser = p.chromium.launch(headless=True)
-    page = browser.new_page(viewport={'width': 1440, 'height': 900})
-    page.goto('http://127.0.0.1:4320/')
-    time.sleep(2)
-    page.click('text=社群运营')
-    time.sleep(2)
-    page.click('text=SOP 版本')
-    time.sleep(1)
-    page.screenshot(path='/tmp/qiwei-group-ops-sop.png', full_page=False)
-    # Create SOP
-    page.fill('#group-ops-playbook-name', '测试SOP')
-    page.fill('#group-ops-template', '欢迎加入 {{room_name}}')
-    page.click('#group-ops-playbook-form button[type="submit"]')
-    time.sleep(2)
-    page.screenshot(path='/tmp/qiwei-group-ops-sop-created.png', full_page=False)
-    # Click 影响预览
-    page.click('text=影响预览')
-    time.sleep(1)
-    page.screenshot(path='/tmp/qiwei-group-ops-alert-modal.png', full_page=False)
-    # Close alert
-    page.click('button:has-text("知道了")')
-    time.sleep(0.5)
-    # Click 发布
-    page.click('text=发布')
-    time.sleep(1)
-    page.screenshot(path='/tmp/qiwei-group-ops-confirm-modal.png', full_page=False)
-    # Cancel
-    page.click('button:has-text("取消")')
-    time.sleep(0.5)
-    # Click 新版本
-    page.click('text=新版本')
-    time.sleep(1)
-    page.screenshot(path='/tmp/qiwei-group-ops-prompt-modal.png', full_page=False)
-    browser.close()
-print('screenshots saved')

+ 0 - 30
claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals2.py

@@ -1,30 +0,0 @@
-from playwright.sync_api import sync_playwright
-import time
-with sync_playwright() as p:
-    browser = p.chromium.launch(headless=True)
-    page = browser.new_page(viewport={'width': 1440, 'height': 900})
-    page.goto('http://127.0.0.1:4320/')
-    time.sleep(2)
-    page.click('text=社群运营')
-    time.sleep(2)
-    page.click('text=SOP 版本')
-    time.sleep(1)
-
-    # Test confirm modal by clicking 发布
-    page.click('text=发布')
-    time.sleep(1)
-    page.screenshot(path='/tmp/qiwei-group-ops-confirm-modal2.png', full_page=False)
-    # Close with cancel
-    page.click('button:has-text("取消")')
-    time.sleep(0.5)
-
-    # Test prompt modal by clicking 新版本
-    page.click('text=新版本')
-    time.sleep(1)
-    page.screenshot(path='/tmp/qiwei-group-ops-prompt-modal2.png', full_page=False)
-    # Close with cancel
-    page.click('button:has-text("取消")')
-    time.sleep(0.5)
-
-    browser.close()
-print('screenshots saved')

+ 0 - 25
claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals3.py

@@ -1,25 +0,0 @@
-from playwright.sync_api import sync_playwright
-import time
-with sync_playwright() as p:
-    browser = p.chromium.launch(headless=True)
-    page = browser.new_page(viewport={'width': 1440, 'height': 900})
-    page.goto('http://127.0.0.1:4320/')
-    time.sleep(2)
-    page.click('text=社群运营')
-    time.sleep(2)
-    page.click('text=SOP 版本')
-    time.sleep(1)
-
-    # Find publish button
-    btn = page.query_selector('[data-group-ops-publish]')
-    print('publish button found:', btn is not None)
-    if btn:
-        print('dataset:', btn.evaluate('el => el.dataset.groupOpsPublish'))
-        btn.click()
-        time.sleep(2)
-        page.screenshot(path='/tmp/qiwei-group-ops-confirm-modal3.png', full_page=False)
-        # Try to find any modal
-        modal = page.query_selector('.business-modal, .modal')
-        print('modal found:', modal is not None)
-    browser.close()
-print('done')

+ 0 - 22
claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops-modals4.py

@@ -1,22 +0,0 @@
-import os
-from playwright.sync_api import sync_playwright
-import time
-out = os.path.join(os.environ.get('TEMP', 'C:/tmp'), 'qiwei-group-ops-prompt-modal3.png')
-with sync_playwright() as p:
-    browser = p.chromium.launch(headless=True)
-    page = browser.new_page(viewport={'width': 1440, 'height': 900})
-    page.goto('http://127.0.0.1:4320/')
-    time.sleep(2)
-    page.click('text=社群运营')
-    time.sleep(2)
-    page.click('text=SOP 版本')
-    time.sleep(1)
-    btn = page.query_selector('[data-group-ops-new-version]')
-    print('new version button found:', btn is not None)
-    if btn:
-        btn.click()
-        time.sleep(1)
-        page.screenshot(path=out, full_page=False)
-        print('screenshot saved to', out)
-    browser.close()
-print('done')

+ 0 - 12
claude-code/claude-code-qiwe-assistant/scripts/screenshot-group-ops.py

@@ -1,12 +0,0 @@
-from playwright.sync_api import sync_playwright
-import time
-with sync_playwright() as p:
-    browser = p.chromium.launch(headless=True)
-    page = browser.new_page(viewport={'width': 1440, 'height': 900})
-    page.goto('http://127.0.0.1:4320/')
-    time.sleep(2)
-    page.click('text=社群运营')
-    time.sleep(2)
-    page.screenshot(path='/tmp/qiwei-group-ops-overview.png', full_page=False)
-    browser.close()
-print('screenshot saved')

+ 75 - 10
claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js

@@ -80,8 +80,9 @@ function readBody(req) {
   });
 }
 
-async function startMockGateway() {
+async function startMockGateway({ subscribed = true } = {}) {
   const requests = [];
+  let subscriptionActive = subscribed;
   const server = http.createServer(async (req, res) => {
     const body = await readBody(req);
     const url = new URL(req.url, 'http://localhost');
@@ -172,7 +173,7 @@ async function startMockGateway() {
       data = { accepted: true };
     } else if (url.pathname === '/subscribe/status') {
       data = {
-        subscribed: true,
+        subscribed: subscriptionActive,
         seats: 2,
         usedSeats: 1,
         expireAt: '2026-08-10T00:00:00.000Z',
@@ -180,7 +181,14 @@ async function startMockGateway() {
         balance: 1000
       };
     } else if (url.pathname === '/subscribe') {
-      data = { seats: body.seats || 1, expireAt: '2026-08-10T00:00:00.000Z', autoRenew: true };
+      subscriptionActive = true;
+      data = {
+        seats: body.seats || 1,
+        months: body.months || 1,
+        amount: (body.seats || 1) * (body.months || 1) * 200,
+        expireAt: '2026-08-10T00:00:00.000Z',
+        autoRenew: true
+      };
     } else if (url.pathname === '/subscribe/autoRenew') {
       data = { autoRenew: body.autoRenew };
     } else {
@@ -299,9 +307,10 @@ async function main() {
     const subscription = await qiweiSubscriptionStatus(common);
     assert.strictEqual(subscription.status, 'ok');
     assert.strictEqual(subscription.summary.subscribed, true);
-    const subscribed = await qiweiSubscribe({ ...common, seats: 3 });
+    const subscribed = await qiweiSubscribe({ ...common, seats: 3, months: 6 });
     assert.strictEqual(subscribed.status, 'ok');
     assert.strictEqual(gateway.requests.at(-1).body.seats, 3);
+    assert.strictEqual(gateway.requests.at(-1).body.months, 6);
     const autoRenew = await qiweiSubscriptionAutoRenew({ ...common, autoRenew: false });
     assert.strictEqual(autoRenew.status, 'ok');
     assert.strictEqual(gateway.requests.at(-1).body.autoRenew, false);
@@ -322,6 +331,62 @@ async function main() {
     assert.deepStrictEqual(gateway.requests.at(-1).body, { uid: common.uid, code: '123456' });
     console.log('[ok] login tools use uid-based Fmode gateway routes and hide internal identifiers');
 
+    stopLoginFlowServer();
+    const checkoutGateway = await startMockGateway({ subscribed: false });
+    try {
+      const checkoutStart = await qiweiLoginStart({
+        ...common,
+        apiBase: checkoutGateway.apiBase,
+        flowPort: 4312,
+        openBrowser: false
+      });
+      assert.strictEqual(checkoutStart.status, 'ok');
+      const checkoutUrl = checkoutStart.summary.flowUrl;
+      const checkoutPageRes = await fetch(checkoutUrl);
+      assert.strictEqual(checkoutPageRes.status, 200);
+      const checkoutPage = await checkoutPageRes.text();
+      assert.ok(!checkoutPage.includes(common.authToken), 'dynamic checkout page must not expose the gateway token');
+      assert.ok(!checkoutPage.includes(checkoutGateway.apiBase), 'dynamic checkout page must not expose the gateway URL');
+      const tokenMatch = checkoutPage.match(/const CHECKOUT_TOKEN = "([^"]+)";/);
+      assert.ok(tokenMatch && tokenMatch[1], 'dynamic checkout page should carry a local one-time confirmation');
+      const checkoutBody = {
+        seats: 3,
+        months: 6,
+        idempotencyKey: 'flow-smoke-order-1',
+        checkoutToken: tokenMatch[1]
+      };
+
+      const rejectedCheckout = await fetch(`${checkoutUrl}flow/subscribe`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(checkoutBody)
+      });
+      assert.strictEqual(rejectedCheckout.status, 403);
+
+      const acceptedCheckout = await fetch(`${checkoutUrl}flow/subscribe`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', Origin: new URL(checkoutUrl).origin },
+        body: JSON.stringify(checkoutBody)
+      });
+      assert.strictEqual(acceptedCheckout.status, 200);
+      assert.deepStrictEqual(checkoutGateway.requests.at(-1).body, {
+        seats: 3,
+        months: 6,
+        idempotencyKey: 'flow-smoke-order-1'
+      });
+
+      const replayedCheckout = await fetch(`${checkoutUrl}flow/subscribe`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', Origin: new URL(checkoutUrl).origin },
+        body: JSON.stringify(checkoutBody)
+      });
+      assert.strictEqual(replayedCheckout.status, 403);
+      console.log('[ok] dynamic checkout hides gateway credentials, submits months once and rejects cross-origin/replayed payment');
+    } finally {
+      stopLoginFlowServer();
+      await checkoutGateway.close();
+    }
+
     const verifyGateway = await startMockVerifyGateway();
     try {
       const verifyCommon = {
@@ -547,12 +612,12 @@ async function main() {
     const statusBefore = await qiweiWebhookStatus({});
     assert.strictEqual(statusBefore.status, 'ok');
     assert.strictEqual(statusBefore.summary.localMode, 'enabled');
-    assert.strictEqual(statusBefore.summary.relayEnabled, false);
-
-    const autoSetupWithoutRelay = await qiweiWebhookAutoSetup({ ...common, guid: 'guid-1' });
-    assert.strictEqual(autoSetupWithoutRelay.status, 'error');
-    assert.strictEqual(autoSetupWithoutRelay.assistantMessage.includes('Relay is not configured'), true);
-    console.log('[ok] webhook-relay tools expose local mode and require relay for auto setup');
+    if (!statusBefore.summary.relayEnabled) {
+      const autoSetupWithoutRelay = await qiweiWebhookAutoSetup({ ...common, guid: 'guid-1' });
+      assert.strictEqual(autoSetupWithoutRelay.status, 'error');
+      assert.strictEqual(autoSetupWithoutRelay.assistantMessage.includes('Relay is not configured'), true);
+    }
+    console.log('[ok] webhook status reflects local mode and avoids external relay setup during smoke tests');
 
     for (const request of gateway.requests) {
       assert.strictEqual(request.authorization, 'Bearer sk-test-relay-token');

+ 4 - 3
claude-code/claude-code-qiwe-assistant/scripts/start-dashboard.js

@@ -1,8 +1,9 @@
 const path = require('path');
-const { startServer } = require('../mcp/src/dashboard/server');
+const { applyWorkspaceContext } = require('../mcp/src/core/runtime-context');
 
-// 确保工作目录为项目根目录,以便正确读取 .env / .claude/settings.json 等配置
-process.chdir(path.resolve(__dirname, '..'));
+const packageRoot = path.resolve(__dirname, '..');
+applyWorkspaceContext({ packageRoot });
+const { startServer } = require('../mcp/src/dashboard/server');
 
 startServer().catch(error => {
   console.error('启动 Dashboard 失败:', error);

+ 73 - 0
claude-code/claude-code-qiwe-assistant/scripts/startup-preview-smoke-test.js

@@ -0,0 +1,73 @@
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { resolveWorkspaceRoot, workspaceIdentity } = require('../mcp/src/core/runtime-context');
+const { buildStartupSummary, startupAssistantMessage } = require('../mcp/src/core/startup-summary');
+
+const ROOT = path.resolve(__dirname, '..');
+
+function testRuntimeContext(tempRoot) {
+  const installedPackage = path.join(tempRoot, '.claude', 'plugins', 'qiwei-assistant');
+  fs.mkdirSync(installedPackage, { recursive: true });
+  assert.equal(resolveWorkspaceRoot({ packageRoot: installedPackage, cwd: installedPackage }), tempRoot);
+  assert.equal(resolveWorkspaceRoot({ packageRoot: ROOT, workspaceRoot: tempRoot }), tempRoot);
+  assert.match(workspaceIdentity(tempRoot), /^[0-9a-f]{12}$/);
+}
+
+function testStartupSummary() {
+  const offline = buildStartupSummary(
+    { summary: { authConfigured: true, subscribed: true } },
+    { data: { account: { configured: true, online: false }, listener: { running: false }, config: { allowedSenderCount: 1 } } },
+  );
+  assert.equal(offline.ready, false);
+  assert.equal(offline.nextAction, '在工作台点击“恢复登录”');
+  assert.match(startupAssistantMessage(offline), /企微账号待处理/);
+
+  const ready = buildStartupSummary(
+    { summary: { authConfigured: true, subscribed: true } },
+    { data: { account: { configured: true, online: true }, listener: { running: true }, config: { allowedSenderCount: 1 } } },
+  );
+  assert.equal(ready.ready, true);
+  assert.match(ready.nextAction, /可以进入智能会话/);
+}
+
+function testWorkspaceInstall(tempRoot) {
+  const target = path.join(tempRoot, 'customer-project');
+  fs.mkdirSync(target, { recursive: true });
+  const result = spawnSync(process.execPath, [path.join(ROOT, 'install.js'), 'workspace', target, '--skip-install'], {
+    cwd: ROOT,
+    encoding: 'utf8',
+    windowsHide: true,
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  const mcp = JSON.parse(fs.readFileSync(path.join(target, '.mcp.json'), 'utf8'));
+  const config = mcp.mcpServers?.['qiwei-assistant'];
+  assert.equal(config.cwd, target);
+  assert.equal(config.env?.QIWEI_WORKSPACE_ROOT, target);
+  assert.equal(config.env?.QIWEI_OUTPUTS_DIR, path.join(target, 'outputs'));
+  assert.equal(config.env?.CLAUDE_CODE_WORKDIR, target);
+  assert(fs.existsSync(path.join(target, '.claude', 'plugins', 'qiwei-assistant', 'scripts', 'preview-dashboard.js')));
+  assert(!fs.existsSync(path.join(target, '.claude', 'plugins', 'qiwei-assistant', '.env.local')));
+}
+
+function main() {
+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-preview-smoke-'));
+  try {
+    testRuntimeContext(tempRoot);
+    testStartupSummary();
+    testWorkspaceInstall(tempRoot);
+  } finally {
+    fs.rmSync(tempRoot, { recursive: true, force: true });
+  }
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 3 }, null, 2)}\n`);
+}
+
+try { main(); }
+catch (error) {
+  process.stderr.write(`${error.message}\n`);
+  process.exit(1);
+}

+ 8 - 5
claude-code/claude-code-qiwe-assistant/skill-package-manifest.json

@@ -1,7 +1,7 @@
 {
-  "name": "claude-code-qiwei-assistant",
-  "version": "0.4.0",
-  "status": "release-ready-not-published",
+  "name": "fmode-qiwei",
+  "version": "0.4.2",
+  "status": "published",
   "plugin": "qiwei-assistant",
   "description": "企业微信智能办公技能包:日常办公、目标推进、智能客服、CRM 客户管理和底层 Skills/API。",
   "capabilities": {
@@ -35,13 +35,16 @@
   ],
   "mcpToolCount": 91,
   "apiCatalogCount": 104,
-  "installCommand": "node install.js workspace <客户项目目录> --smoke",
+  "npmPackage": "fmode-qiwei",
+  "installCommand": "npx --yes fmode-qiwei@latest workspace --smoke",
+  "previewCommand": "npx --yes fmode-qiwei@latest preview",
   "workspaceInstallTarget": ".claude/plugins/qiwei-assistant",
   "dashboardUrl": "http://127.0.0.1:4320/",
+  "startupChecks": ["Fmode 鉴权", "企微席位", "企微账号在线", "测试白名单", "AI 监听"],
   "sessionReviewCommand": "npm run agent:session -- --customer <客户名称>",
   "security": [
     "安装包不复制 .env.local、真实 outputs、Playwright 会话或 node_modules",
     "客户 Session 原始 ID 不进入用户提示或 Dashboard",
-    "默认 review 模式,真实发送继续受白名单和人工审核约束"
+    "默认 review 模式;群聊全自动模式必须按群明确开启并二次确认,发送失败保留草稿"
   ]
 }

+ 47 - 28
claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: qiwei-dashboard
-description: 启动并管理 Qiwei Dashboard(本地 Web 操作界面),包括经营驾驶舱、真实企微 Agent、社群与客户运营、经营诊断、统一任务中心、行动效果闭环、技能中心和本地知识库。用于启动 4320 工作台、检查运行状态、查看经营总览、诊断业务问题、管理内部任务、浏览技能包和知识文件;必须从项目根目录运行 npm run dashboard
+description: 启动并预览 Qiwei Dashboard(本地 Web 操作界面),包括经营驾驶舱、真实企微 Agent、社群与客户运营、经营诊断、统一任务中心、行动效果闭环、技能中心和本地知识库。用户说“启动企微助手”“打开企微工作台”“启动并预览”时,优先调用 qiwei_agent_dashboard_start,并一次返回鉴权、席位、企微在线、白名单和监听状态;同时支持查看经营总览、诊断业务问题、管理内部任务、浏览技能包和知识文件。
 ---
 
 # Qiwei Dashboard
@@ -23,30 +23,54 @@ description: 启动并管理 Qiwei Dashboard(本地 Web 操作界面),包
 
 ## 前置条件
 
-1. 必须位于项目目录 `claude-code/claude-code-qiwe-assistant`
-2. 必须已经配置 Fmode 鉴权 token(`.env.local` 或环境变量 `QIWEI_AUTH_TOKEN` / `FMODE_API_KEY`)
-3. 可选但推荐配置 `QIWEI_UID`;未配置时会自动生成并写入 `.env.local`
+1. 在 Fmode Studio 中打开已经安装企微技能包的项目
+2. Fmode Studio 当前账号应处于登录状态,技能包会自动读取当前项目可用的 Fmode 凭据
+3. 不要求普通用户手工填写 Token、uid、guid 或接口地址
 
 ## 启动流程
 
-**必须**先切换工作目录到项目根目录,再执行启动命令:
+在 Claude Code 项目主控会话中,优先直接调用:
+
+1. `qiwei_agent_dashboard_start`:启动工作台,并返回五项启动检查和下一步;
+2. 根据返回结果依次处理 Fmode 鉴权、席位、企微登录、白名单和监听;
+3. 打开返回的 `http://127.0.0.1:4320/#agent`。
+
+源码开发时也可以在技能包根目录执行:
 
 ```bash
-cd claude-code/claude-code-qiwe-assistant
-npm run dashboard
+npm run preview
 ```
 
-在 Claude Code 项目主控会话中,优先直接调用:
+安装到客户 workspace 后,可以在项目终端执行:
+
+```bash
+node .claude/plugins/qiwei-assistant/install.js preview .
+```
+
+`preview` 会自动打开浏览器;自动打开不方便时使用 `--no-open`。端口冲突时使用 `--port 4321`。
 
-1. `qiwei_agent_dashboard_start`:启动或确认 Dashboard;
-2. `qiwei_agent_bind_controller`:自动检测失败时显式绑定项目主控 Session;
-3. `qiwei_agent_status`:读取账号、监听、Agent 和会话状态;
-4. `qiwei_agent_listener`:启动或停止真实消息监听;
-5. `qiwei_agent_set_global`:设置 paused、review、auto 或 human;
-6. `qiwei_agent_list_conversations`:读取客户会话和待审核草稿;
-7. `qiwei_agent_inbox`:读取前端、监听器、Agent 与人工操作事件;
-8. `qiwei_agent_generate_draft`:让客户专属 Claude Code Session 生成待审核草稿,不发送。
-9. `qiwei_agent_session_guide`:主动说明某客户对应的 Claude Code 会话名、所在位置和安全打开命令。
+工作台启动后的管理工具:
+
+1. `qiwei_agent_bind_controller`:自动检测失败时显式绑定项目主控 Session;
+2. `qiwei_agent_status`:读取账号、监听、Agent 和会话状态;
+3. `qiwei_agent_listener`:启动或停止真实消息监听;
+4. `qiwei_agent_set_global`:设置 paused、review、auto 或 human;
+5. `qiwei_agent_list_conversations`:读取客户会话和待审核草稿;
+6. `qiwei_agent_inbox`:读取前端、监听器、Agent 与人工操作事件;
+7. `qiwei_agent_generate_draft`:让客户专属 Claude Code Session 生成待审核草稿,不发送;
+8. `qiwei_agent_session_guide`:主动说明某客户对应的 Claude Code 会话名、所在位置和安全打开命令。
+
+## 五项启动检查
+
+`qiwei_agent_dashboard_start` 和 `npm run preview` 必须按顺序返回:
+
+1. Fmode 鉴权是否可用;
+2. 企微席位是否有效;
+3. 企微账号是否在线;
+4. 测试联系人白名单是否非空;
+5. AI 监听是否已经启动。
+
+只提示第一项需要处理的动作,完成后重新检查。页面打开不代表企微已经在线,也不代表监听已经启动。
 
 ## 项目主控与客户会话
 
@@ -155,29 +179,24 @@ curl -s http://127.0.0.1:4320/api/status
 
 ## 常见错误
 
-### Dashboard 显示「网络请求失败」但浏览器能访问 server.fmode.cn
+### Dashboard 显示「网络请求失败」
 
-原因:Dashboard 启动时的工作目录不对,没有读取到 `.env.local` 中的 token
+先确认是在 Fmode Studio 当前项目中启动。客户 workspace 安装后的运行目录、输出目录和 Claude Code 工作目录应自动指向客户项目根目录,不应落入 `.claude/plugins/qiwei-assistant`
 
 解决:
 
 ```bash
-cd claude-code/claude-code-qiwe-assistant
-npm run dashboard
+node .claude/plugins/qiwei-assistant/install.js preview .
 ```
 
 ### 端口被占用
 
 ```bash
-# Windows
-netstat -ano | findstr 4320
-taskkill /PID <PID> /F
-
-# macOS / Linux
-lsof -i :4320
-kill -9 <PID>
+npm run preview -- --port 4321
 ```
 
+启动器会校验 4320 对应的项目身份,避免误连到另一个企微项目。
+
 ## 文件位置
 
 - 服务入口:`scripts/start-dashboard.js`

+ 4 - 3
claude-code/claude-code-qiwe-assistant/skills/qiwei-real-estate-auto-reply/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: qiwei-real-estate-auto-reply
-description: 管理企业微信房产 Agent、客户消息监听、需求画像、房源匹配、回复草稿和人工接管。适用于小牛看房演示、白名单测试、启动或停止 4320 智能会话;企微收发统一通过 Fmode 网关。
+description: 管理企业微信房产 Agent、客户私聊与客户群消息监听、需求画像、房源匹配、回复草稿和人工接管。适用于小牛看房演示、白名单测试、群聊智能回复、启动或停止 4320 智能会话;企微收发统一通过 Fmode 网关。
 ---
 
 # 房产 AI 智能会话
@@ -8,10 +8,11 @@ description: 管理企业微信房产 Agent、客户消息监听、需求画像
 ## 前置检查
 
 1. 调用 `qiwei_subscription_status` 和 `qiwei_login_status`,确认订阅有效且设备在线。
-2. 配置 `QIWEI_AUTO_REPLY_ALLOWED_SENDERS` 测试联系人白名单;白名单为空时必须拒绝监听。
+2. 优先在 Dashboard「管理白名单」中搜索并勾选测试联系人;页面不可用时才手工配置 `QIWEI_AUTO_REPLY_ALLOWED_SENDERS`。白名单为空时必须拒绝监听。
 3. 默认使用技能包内置的脱敏房源案例;客户数据可通过 `QIWEI_AGENT_PROPERTY_DATA_FILE` 覆盖。
 4. 推荐设置 `AGENT_PROVIDER=claude-code`,复用客户项目中的 Claude Code/Fmode 模型配置。
 5. 默认使用 `review`:生成待审核草稿,不自动发送。
+6. 已确认客户群的新消息会生成独立群聊草稿;群聊始终要求人工审核,不参与私聊高置信自动发送。
 
 ## 启动与接管
 
@@ -49,8 +50,8 @@ npm run agent:session -- --customer <客户名称>
 ## 安全约束
 
 - 不处理或发送给非白名单联系人。
+- 不为未确认的群生成或发送回复;已确认群也必须人工批准草稿后才能真实群发。
 - 全局暂停、人工模式或审核未通过时不得自动外发。
 - 不在代码、日志、回复或 Skill 文件中输出 Fmode token、AI 密钥、设备标识或 Relay 私钥。
 - 先用测试企微与脱敏案例验收,再扩大联系人范围。
 - 同一账号只运行一个消息消费者,避免轮询与 Relay 重复处理。
-

+ 1 - 1
claude-code/claude-code-voc-intelligence/.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "voc-intelligence",
   "description": "VOC intelligence skills for Claude Code: end-to-end VOC business workflows, Xiaohongshu and Douyin trend reports, live social VOC collection, Fmode Doubao image analysis, token/recharge checks, evidence-card outputs, prioritized issue pools, single-problem deep dives, competitor maps, 7-day content plans, VOC-backed speaking script co-creation, boss/operator action plans, and multi-turn memory.",
-  "version": "0.3.30",
+  "version": "0.3.31",
   "author": {
     "name": "nkkj-BrainHack"
   }

+ 233 - 34
claude-code/claude-code-voc-intelligence/bin/claude-voc.js

@@ -4,12 +4,17 @@ const os = require('os');
 const path = require('path');
 const { spawnSync } = require('child_process');
 const { ensureActivated } = require('../mcp/src/core/activation');
+const { readNewApiToken } = require('../mcp/src/core/credentials');
 
 const SOURCE_ROOT = path.resolve(__dirname, '..');
 const WORKSPACE_ROOT = process.cwd();
 const DEFAULT_TARGET = path.join(os.homedir(), '.claude', 'plugins', 'voc-intelligence');
 const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'plugins', 'voc-intelligence');
 const WORKSPACE_PLUGINS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'plugins');
+const CODEX_PROJECT_TARGET = path.join(WORKSPACE_ROOT, '.codex', 'plugins', 'voc-intelligence');
+const CODEX_GLOBAL_TARGET = path.join(os.homedir(), '.codex', 'plugins', 'voc-intelligence');
+const PORTABLE_WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.vocmarket', 'plugins', 'voc-intelligence');
+const VOCMARKET_CREDENTIALS = path.join(os.homedir(), '.vocmarket', 'credentials.json');
 const MIN_NODE_MAJOR = 18;
 
 const EXCLUDED_DIRS = new Set([
@@ -35,7 +40,7 @@ function usage() {
     'Usage:',
     '  claude-voc install',
     '  claude-voc workspace',
-    '  claude-voc workspace --activate',
+    '  claude-voc workspace --activate --channel <name>',
     '  claude-voc install --workspace --smoke',
     '  claude-voc check',
     '  claude-voc smoke',
@@ -54,7 +59,8 @@ function usage() {
     '  --skip-install   Skip npm install after copying files',
     '  --smoke          Run sample/preference/MCP smoke checks after install',
     '  --activate       安装后按 token 状态激活:无 token 弹付费/登录页,有 token 直接放行(外部 AI IDE 专用,不影响 --smoke)',
-    '  --channel <name> 付费归因渠道名(claude-code/codex/workbuddy)',
+    '  --channel <name> 安装渠道(claude-code/codex/workbuddy/fmode-studio)',
+    '  --scope <name>   Codex 安装范围(project/global,默认 project)',
     '  --help, -h       Show help'
   ].join('\n');
 }
@@ -72,6 +78,9 @@ function parseArgs(argv) {
     smoke: false,
     activate: false,
     channel: 'external',
+    scope: 'project',
+    targetExplicit: false,
+    workspaceRequested: first === 'workspace' || first === 'install-workspace',
     force: false,
     help: false
   };
@@ -85,10 +94,13 @@ function parseArgs(argv) {
     const token = argv[i];
     if (token === '--target') {
       opts.target = argv[++i];
+      opts.targetExplicit = true;
     } else if (token.startsWith('--target=')) {
       opts.target = token.slice('--target='.length);
+      opts.targetExplicit = true;
     } else if (token === '--workspace') {
       opts.target = WORKSPACE_TARGET;
+      opts.workspaceRequested = true;
     } else if (token === '--force') {
       opts.force = true;
     } else if (token === '--skip-install') {
@@ -101,15 +113,48 @@ function parseArgs(argv) {
       opts.channel = argv[++i];
     } else if (token.startsWith('--channel=')) {
       opts.channel = token.slice('--channel='.length);
+    } else if (token === '--scope') {
+      opts.scope = argv[++i];
+    } else if (token.startsWith('--scope=')) {
+      opts.scope = token.slice('--scope='.length);
     } else if (token === '--help' || token === '-h') {
       opts.help = true;
     }
   }
 
+  opts.channel = normalizeInstallChannel(opts.channel);
+  opts.scope = normalizeInstallScope(opts.channel, opts.scope);
+  if (opts.workspaceRequested && !opts.targetExplicit) {
+    opts.target = defaultTargetFor(opts.channel, opts.scope);
+  }
   opts.target = path.resolve(expandHome(opts.target));
   return opts;
 }
 
+function normalizeInstallChannel(value) {
+  const channel = String(value || 'claude-code').trim().toLowerCase();
+  if (channel === 'external') return 'claude-code';
+  if (!['claude-code', 'codex', 'workbuddy', 'fmode-studio'].includes(channel)) {
+    throw new Error(`Unsupported install channel: ${channel}`);
+  }
+  return channel;
+}
+
+function normalizeInstallScope(channel, value) {
+  if (channel !== 'codex') return 'project';
+  const scope = String(value || 'project').trim().toLowerCase();
+  if (!['project', 'global'].includes(scope)) {
+    throw new Error(`Unsupported Codex install scope: ${scope}`);
+  }
+  return scope;
+}
+
+function defaultTargetFor(channel, scope) {
+  if (channel === 'codex') return scope === 'global' ? CODEX_GLOBAL_TARGET : CODEX_PROJECT_TARGET;
+  if (channel === 'workbuddy') return PORTABLE_WORKSPACE_TARGET;
+  return WORKSPACE_TARGET;
+}
+
 function run(command, args, cwd) {
   const useCmd = process.platform === 'win32' && command === 'npm';
   const executable = useCmd ? 'cmd.exe' : command;
@@ -184,8 +229,13 @@ function isSafeWorkspaceTarget(targetDir) {
   return isInside(WORKSPACE_PLUGINS_ROOT, targetDir);
 }
 
+function isSafeManagedTarget(targetDir) {
+  return [WORKSPACE_TARGET, CODEX_PROJECT_TARGET, CODEX_GLOBAL_TARGET, PORTABLE_WORKSPACE_TARGET]
+    .some(candidate => path.resolve(candidate) === path.resolve(targetDir));
+}
+
 function canOverwriteTarget(targetDir, opts) {
-  return opts.force || isSafeDefaultTarget(targetDir) || isSafeWorkspaceTarget(targetDir);
+  return opts.force || isSafeDefaultTarget(targetDir) || isSafeWorkspaceTarget(targetDir) || isSafeManagedTarget(targetDir);
 }
 
 function shouldCopyEntry(entry) {
@@ -262,9 +312,8 @@ function readJsonIfExists(filePath) {
   return JSON.parse(fs.readFileSync(filePath, 'utf8'));
 }
 
-function writeWorkspaceMcpConfig(targetDir) {
-  const mcpConfigPath = path.join(WORKSPACE_ROOT, '.mcp.json');
-  const existing = readJsonIfExists(mcpConfigPath);
+function writeJsonMcpConfig(configPath, targetDir) {
+  const existing = readJsonIfExists(configPath);
   const mcpConfig = {
     ...existing,
     mcpServers: {
@@ -276,7 +325,99 @@ function writeWorkspaceMcpConfig(targetDir) {
       }
     }
   };
-  fs.writeFileSync(mcpConfigPath, `${JSON.stringify(mcpConfig, null, 2)}\n`, 'utf8');
+  ensureDir(path.dirname(configPath));
+  fs.writeFileSync(configPath, `${JSON.stringify(mcpConfig, null, 2)}\n`, 'utf8');
+}
+
+function writeWorkspaceMcpConfig(targetDir) {
+  const mcpConfigPath = path.join(WORKSPACE_ROOT, '.mcp.json');
+  writeJsonMcpConfig(mcpConfigPath, targetDir);
+}
+
+function tomlString(value) {
+  return JSON.stringify(String(value));
+}
+
+function upsertCodexProjectMcp(targetDir, workspaceRoot = WORKSPACE_ROOT) {
+  const configPath = path.join(workspaceRoot, '.codex', 'config.toml');
+  const original = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
+  const lines = original.replace(/\r\n/g, '\n').split('\n');
+  const kept = [];
+  let skipping = false;
+  for (const line of lines) {
+    const table = line.match(/^\s*\[([^\]]+)]\s*(?:#.*)?$/);
+    if (table) {
+      skipping = table[1] === 'mcp_servers.voc' || table[1].startsWith('mcp_servers.voc.');
+    }
+    if (!skipping) kept.push(line);
+  }
+  while (kept.length && !kept[kept.length - 1].trim()) kept.pop();
+  const serverPath = path.join(targetDir, 'mcp', 'src', 'server.js');
+  const block = [
+    '[mcp_servers.voc]',
+    'command = "node"',
+    `args = [${tomlString(serverPath)}]`,
+    `cwd = ${tomlString(targetDir)}`
+  ];
+  ensureDir(path.dirname(configPath));
+  fs.writeFileSync(configPath, `${kept.concat(kept.length ? [''] : [], block).join('\n')}\n`, 'utf8');
+  return configPath;
+}
+
+function runCapture(command, args, cwd = WORKSPACE_ROOT) {
+  const useCmd = process.platform === 'win32';
+  const executable = useCmd ? 'cmd.exe' : command;
+  const finalArgs = useCmd ? ['/d', '/s', '/c', command, ...args] : args;
+  return spawnSync(executable, finalArgs, {
+    cwd,
+    encoding: 'utf8',
+    stdio: ['ignore', 'pipe', 'pipe'],
+    maxBuffer: 1024 * 1024 * 10
+  });
+}
+
+function registerCodexGlobalMcp(targetDir) {
+  const getResult = runCapture('codex', ['mcp', 'get', 'voc']);
+  if (!getResult.error && getResult.status === 0) {
+    const removeResult = runCapture('codex', ['mcp', 'remove', 'voc']);
+    if (removeResult.error || removeResult.status !== 0) {
+      throw new Error('Unable to update the existing Codex VOC MCP registration');
+    }
+  }
+  const serverPath = path.join(targetDir, 'mcp', 'src', 'server.js');
+  const addResult = runCapture('codex', ['mcp', 'add', 'voc', '--', 'node', serverPath]);
+  if (addResult.error || addResult.status !== 0) {
+    const detail = String(addResult.stderr || addResult.error?.message || '').trim();
+    throw new Error(`Codex MCP registration failed${detail ? `: ${detail}` : ''}`);
+  }
+}
+
+function copyPackagedSkills(targetDir, destinationRoot) {
+  const sourceRoot = path.join(targetDir, 'skills');
+  ensureDir(destinationRoot);
+  let copied = 0;
+  for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
+    if (!entry.isDirectory()) continue;
+    const destination = path.join(destinationRoot, entry.name);
+    removeDirResilient(destination);
+    copied += copyDirRecursive(path.join(sourceRoot, entry.name), destination);
+  }
+  return copied;
+}
+
+function persistNeutralCredential(credentialsPath = VOCMARKET_CREDENTIALS) {
+  const token = readNewApiToken();
+  if (!token) return false;
+  const existing = readJsonIfExists(credentialsPath);
+  ensureDir(path.dirname(credentialsPath));
+  fs.writeFileSync(credentialsPath, `${JSON.stringify({ ...existing, fmodeApiKey: token }, null, 2)}\n`, {
+    encoding: 'utf8',
+    mode: 0o600
+  });
+  try {
+    fs.chmodSync(credentialsPath, 0o600);
+  } catch {}
+  return true;
 }
 
 function asForwardSlash(filePath) {
@@ -617,11 +758,10 @@ function writeWorkspaceSkillEntries(targetDir) {
   fs.copyFileSync(path.join(targetDir, 'skills', 'voc-api-catalog', 'SKILL.md'), path.join(apiCatalogSkillDir, 'SKILL.md'));
 }
 
-function writeWorkspaceActivation(targetDir) {
-  if (!isSafeWorkspaceTarget(targetDir)) return;
+function writeClaudeWorkspaceActivation(targetDir) {
   writeWorkspaceMcpConfig(targetDir);
   writeWorkspaceSkillEntries(targetDir);
-  console.log('Wrote workspace MCP config: .\\.mcp.json');
+  console.log('Wrote Claude/Fmode workspace MCP config: .\\.mcp.json');
   console.log('Wrote workspace skill entry: .\\.claude\\skills\\xiaohongshu-trend-intelligence\\SKILL.md');
   console.log('Wrote workspace skill entry: .\\.claude\\skills\\douyin-trend-intelligence\\SKILL.md');
   console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-issue-pool\\SKILL.md');
@@ -634,26 +774,71 @@ function writeWorkspaceActivation(targetDir) {
   console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-api-catalog\\SKILL.md');
 }
 
-function printNextSteps(targetDir) {
-  const workspaceMode = isSafeWorkspaceTarget(targetDir);
+function writeWorkspaceActivation(targetDir, opts) {
+  if (!opts.workspaceRequested) return;
+  if (opts.channel === 'codex') {
+    const skillRoot = opts.scope === 'global'
+      ? path.join(os.homedir(), '.codex', 'skills')
+      : path.join(WORKSPACE_ROOT, '.codex', 'skills');
+    copyPackagedSkills(targetDir, skillRoot);
+    if (opts.scope === 'global') {
+      registerCodexGlobalMcp(targetDir);
+      console.log('Registered VOC MCP with Codex user config.');
+      console.log(`Installed Codex user skills: ${skillRoot}`);
+    } else {
+      const configPath = upsertCodexProjectMcp(targetDir);
+      console.log(`Registered VOC MCP with Codex project config: ${configPath}`);
+      console.log(`Installed Codex project skills: ${skillRoot}`);
+    }
+    return;
+  }
+  if (opts.channel === 'workbuddy') {
+    writeWorkspaceMcpConfig(targetDir);
+    const skillRoot = path.join(WORKSPACE_ROOT, '.workbuddy', 'skills');
+    copyPackagedSkills(targetDir, skillRoot);
+    console.log('Wrote portable WorkBuddy MCP registration: .\\.mcp.json');
+    console.log('Installed WorkBuddy workspace skills: .\\.workbuddy\\skills');
+    return;
+  }
+  writeClaudeWorkspaceActivation(targetDir);
+}
+
+async function verifyVocToolDiscovery(targetDir) {
+  const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
+  const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
+  const { version } = require('../package.json');
+  const client = new Client({ name: 'voc-installer-verification', version });
+  const transport = new StdioClientTransport({
+    command: process.execPath,
+    args: [path.join(targetDir, 'mcp', 'src', 'server.js')],
+    cwd: targetDir,
+    stderr: 'pipe'
+  });
+  await client.connect(transport);
+  try {
+    const result = await client.listTools();
+    const names = (result.tools || []).map(tool => tool.name);
+    const discovered = names.filter(name => name.startsWith('voc_api_'));
+    if (!discovered.length) {
+      throw new Error('MCP started, but no voc_api_* tool was discovered');
+    }
+    console.log(`Verified MCP tool discovery (no data request): ${discovered.join(', ')}`);
+    return discovered;
+  } finally {
+    await client.close();
+  }
+}
+
+function printNextSteps(targetDir, opts) {
+  const workspaceMode = opts.workspaceRequested;
   console.log('');
   console.log('Install complete.');
   console.log('');
   if (workspaceMode) {
-    console.log('VSCode workspace mode is ready.');
+    console.log(`${opts.channel} ${opts.scope} mode is ready.`);
     console.log('');
     console.log('Generated project files:');
-    console.log('  .\\.mcp.json');
-    console.log('  .\\.claude\\skills\\xiaohongshu-trend-intelligence\\SKILL.md');
-    console.log('  .\\.claude\\skills\\douyin-trend-intelligence\\SKILL.md');
-    console.log('  .\\.claude\\skills\\voc-issue-pool\\SKILL.md');
-    console.log('  .\\.claude\\skills\\voc-problem-deep-dive\\SKILL.md');
-    console.log('  .\\.claude\\skills\\voc-content-plan\\SKILL.md');
-    console.log('  .\\.claude\\skills\\voc-speaking-script\\SKILL.md');
-    console.log('  .\\.claude\\skills\\voc-competitor-map\\SKILL.md');
-    console.log('  .\\.claude\\skills\\voc-business-workflow\\SKILL.md');
-    console.log('  .\\.claude\\skills\\fmode-image-analysis\\SKILL.md');
-    console.log('  .\\.claude\\plugins\\voc-intelligence');
+    console.log(`  ${targetDir}`);
     console.log('');
     console.log('Restart the VSCode Claude Code session if it was already open.');
   } else {
@@ -677,7 +862,7 @@ function printNextSteps(targetDir) {
   console.log(`Install target: ${targetDir}`);
 }
 
-function install(opts) {
+async function install(opts) {
   checkNodeVersion();
   if (path.resolve(SOURCE_ROOT) !== path.resolve(opts.target)) {
     if (fs.existsSync(opts.target)) {
@@ -693,7 +878,7 @@ function install(opts) {
 
   assertInstalled(opts.target);
   writeMcpConfig(opts.target);
-  writeWorkspaceActivation(opts.target);
+  writeWorkspaceActivation(opts.target, opts);
 
   if (!opts.skipInstall) {
     console.log('Installing runtime dependencies...');
@@ -707,7 +892,11 @@ function install(opts) {
     run(process.execPath, ['install.js', '--check', '--no-next'], opts.target);
   }
 
-  printNextSteps(opts.target);
+  if (opts.activate) {
+    persistNeutralCredential();
+    await verifyVocToolDiscovery(opts.target);
+  }
+  printNextSteps(opts.target, opts);
 }
 
 function check(opts) {
@@ -742,7 +931,7 @@ async function main() {
     return;
   }
   if (opts.command === 'install') {
-    install(opts);
+    await install(opts);
     if (opts.activate) {
       await ensureActivated({ channel: opts.channel });
     }
@@ -754,10 +943,20 @@ async function main() {
   throw new Error(`Unknown command: ${opts.command}\n\n${usage()}`);
 }
 
-main().catch((error) => {
-  console.error('');
-  console.error(`claude-voc failed: ${error.message}`);
-  process.exit(1);
-});
-
+if (require.main === module) {
+  main().catch((error) => {
+    console.error('');
+    console.error(`claude-voc failed: ${error.message}`);
+    process.exit(1);
+  });
+}
 
+module.exports = {
+  parseArgs,
+  normalizeInstallChannel,
+  normalizeInstallScope,
+  defaultTargetFor,
+  upsertCodexProjectMcp,
+  verifyVocToolDiscovery,
+  persistNeutralCredential
+};

+ 4 - 0
claude-code/claude-code-voc-intelligence/mcp/src/core/credentials.js

@@ -149,6 +149,7 @@ function readXiaohongshuToken(input = {}) {
 //   → Claude Code env(进程注入 + ~/.claude/settings.json)的 ANTHROPIC_AUTH_TOKEN(sk-、且 base 指向 fmode)。
 function readNewApiToken(input = {}) {
   const workspaceEnv = readEnvLocalFiles();
+  const vocMarketCreds = readJsonMaybe(path.join(os.homedir(), '.vocmarket', 'credentials.json'));
   const claudeCreds = readJsonMaybe(path.join(os.homedir(), '.claude', 'voc-credentials.json'));
   const fmodeConfig = readFmodeConfig();
   const claudeEnv = readClaudeSettingsEnv();
@@ -165,6 +166,9 @@ function readNewApiToken(input = {}) {
     process.env.NEW_API_TOKEN,
     process.env.FMODE_API_KEY,
     process.env.FMODE_API_TOKEN,
+    vocMarketCreds.fmodeApiKey,
+    vocMarketCreds.fmodeApiToken,
+    vocMarketCreds.newApiToken,
     claudeCreds.fmodeApiKey,
     claudeCreds.fmodeApiToken,
     fmodeConfig.newapiToken,

+ 2 - 2
claude-code/claude-code-voc-intelligence/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "@vocmarket/voc-skill",
-  "version": "0.3.30",
+  "version": "0.3.31",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@vocmarket/voc-skill",
-      "version": "0.3.30",
+      "version": "0.3.31",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.29.0",
         "zod": "^4.4.3"

+ 3 - 2
claude-code/claude-code-voc-intelligence/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vocmarket/voc-skill",
-  "version": "0.3.30",
+  "version": "0.3.31",
   "description": "Claude Code VOC intelligence skills for Xiaohongshu and Douyin trend reports, live social VOC collection, Fmode image analysis, single-issue deep dives, boss/operator action plans, and multi-turn memory.",
   "type": "commonjs",
   "bin": {
@@ -29,7 +29,8 @@
     "voc:business-workflow": "node mcp/src/tools/voc-business-workflow-run.js --brand 示例品牌A --industry 家居定制 --platform douyin --collection-mode sample --keywords \"家居定制怎么选,第一次买怎么对比,售后\" --assistant-message-only",
     "mcp:start": "node mcp/src/server.js",
     "mcp:smoke": "node scripts/smoke-mcp.js",
-    "acceptance": "node scripts/acceptance.js"
+    "acceptance": "node scripts/acceptance.js",
+    "acceptance:installer": "node scripts/installer-acceptance.js"
   },
   "files": [
     ".claude-plugin/",

+ 1 - 0
claude-code/claude-code-voc-intelligence/scripts/acceptance.js

@@ -13,6 +13,7 @@ function main() {
   run('npm', ['run', 'fmode:image:smoke'], ROOT);
   run('npm', ['run', 'smoke:package'], ROOT);
   run('npm', ['run', 'mcp:smoke'], ROOT);
+  run('npm', ['run', 'acceptance:installer'], ROOT);
   run('npm', ['pack', '--dry-run'], ROOT);
 
   const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'voc-workspace-install-'));

+ 79 - 0
claude-code/claude-code-voc-intelligence/scripts/installer-acceptance.js

@@ -0,0 +1,79 @@
+#!/usr/bin/env node
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const {
+  parseArgs,
+  normalizeInstallChannel,
+  normalizeInstallScope,
+  upsertCodexProjectMcp,
+  verifyVocToolDiscovery,
+  persistNeutralCredential
+} = require('../bin/claude-voc');
+
+async function main() {
+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'voc-installer-acceptance-'));
+  const targetDir = path.join(tempRoot, '.codex', 'plugins', 'voc-intelligence');
+  const configDir = path.join(tempRoot, '.codex');
+  const configPath = path.join(configDir, 'config.toml');
+  const credentialsPath = path.join(tempRoot, '.vocmarket', 'credentials.json');
+  const previousToken = process.env.FMODE_API_KEY;
+
+  try {
+    assert.strictEqual(normalizeInstallChannel('CODEX'), 'codex');
+    assert.strictEqual(normalizeInstallScope('codex', 'global'), 'global');
+    assert.strictEqual(normalizeInstallScope('workbuddy', 'global'), 'project');
+
+    const options = parseArgs(['workspace', '--activate', '--channel=codex', '--scope=global']);
+    assert.strictEqual(options.activate, true);
+    assert.strictEqual(options.smoke, false);
+    assert.strictEqual(options.channel, 'codex');
+    assert.strictEqual(options.scope, 'global');
+
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(configPath, [
+      'model = "gpt-test"',
+      '',
+      '[mcp_servers.existing]',
+      'command = "existing-command"',
+      '',
+      '[mcp_servers.voc]',
+      'command = "old-command"',
+      ''
+    ].join('\n'));
+    upsertCodexProjectMcp(targetDir, tempRoot);
+    const config = fs.readFileSync(configPath, 'utf8');
+    assert.match(config, /model = "gpt-test"/);
+    assert.match(config, /\[mcp_servers\.existing]/);
+    assert.strictEqual((config.match(/\[mcp_servers\.voc]/g) || []).length, 1);
+    assert.ok(config.includes('mcp\\\\src\\\\server.js') || config.includes('mcp/src/server.js'));
+
+    process.env.FMODE_API_KEY = 'sk-installer_acceptance-123456789';
+    assert.strictEqual(persistNeutralCredential(credentialsPath), true);
+    const credentials = JSON.parse(fs.readFileSync(credentialsPath, 'utf8'));
+    assert.strictEqual(credentials.fmodeApiKey, process.env.FMODE_API_KEY);
+
+    const tools = await verifyVocToolDiscovery(path.resolve(__dirname, '..'));
+    assert.ok(tools.includes('voc_api_search'));
+    assert.ok(tools.includes('voc_api_doc'));
+    assert.ok(tools.includes('voc_api_call'));
+
+    console.log(JSON.stringify({
+      status: 'ok',
+      checks: ['channels', 'no-smoke activation', 'Codex TOML merge', 'neutral credentials', 'MCP ListTools'],
+      discoveredTools: tools
+    }, null, 2));
+  } finally {
+    if (previousToken === undefined) delete process.env.FMODE_API_KEY;
+    else process.env.FMODE_API_KEY = previousToken;
+    fs.rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+main().catch(error => {
+  console.error(error.message);
+  process.exit(1);
+});

+ 2 - 2
claude-code/claude-code-voc-intelligence/skill-package-manifest.json

@@ -1,6 +1,6 @@
 {
   "name": "claude-code-voc-intelligence",
-  "version": "0.3.30",
+  "version": "0.3.31",
   "capabilities": {
     "summary": "三类情报源同源接入:①社媒内容 ②国内电商+创作者情报 ③海外选品。统一 search→doc→call、鉴权计费一致。",
     "channels": [
@@ -123,4 +123,4 @@
   "workspaceInstallCommand": "npx @vocmarket/voc-skill workspace --smoke",
   "workspaceInstallTarget": ".claude/plugins/voc-intelligence",
   "installHint": "Supports three delivery modes: unzip and run node install.js, install globally into ~/.claude/plugins/voc-intelligence, or install into the current workspace. Workspace mode writes .mcp.json plus .claude/skills/* entries for trend intelligence, issue pool, deep dive, competitor map, content plan, speaking script, VOC business workflow, and Fmode image analysis so VSCode Claude Code can discover the workflows without relying only on --plugin-dir."
-}
+}

+ 4 - 4
dist/claude-code-voc-intelligence-suite-manifest.json

@@ -1,9 +1,9 @@
 {
   "name": "claude-code-voc-intelligence",
-  "version": "0.3.27",
-  "generatedAt": "2026-06-27T16:33:26.605Z",
+  "version": "0.3.31",
+  "generatedAt": "2026-07-23T02:40:15.266Z",
   "packageZip": "dist/claude-code-voc-intelligence.zip",
-  "packageZipBytes": 407142,
+  "packageZipBytes": 666693,
   "plugin": "voc-intelligence",
   "skills": [
     "xiaohongshu-trend-intelligence",
@@ -44,5 +44,5 @@
   "buildCommand": "node scripts/deploy/claude-code-voc-intelligence-suite.js --validate --build --test-package",
   "uploadCommand": "node scripts/deploy/claude-code-voc-intelligence-suite.js --validate --build --test-package --upload",
   "uploaded": false,
-  "copiedFiles": 117
+  "copiedFiles": 142
 }

BIN
dist/claude-code-voc-intelligence.zip


+ 6 - 6
dist/npm/claude-code-voc-npm-package-manifest.json

@@ -1,11 +1,11 @@
 {
   "name": "@vocmarket/voc-skill",
-  "version": "0.3.30",
-  "generatedAt": "2026-06-29T16:42:36.776Z",
-  "tarball": "dist/npm/vocmarket-voc-skill-0.3.30.tgz",
-  "tarballBytes": 576620,
-  "unpackedBytes": 4041253,
-  "entryCount": 139,
+  "version": "0.3.31",
+  "generatedAt": "2026-07-23T02:43:00.912Z",
+  "tarball": "dist/npm/vocmarket-voc-skill-0.3.31.tgz",
+  "tarballBytes": 579738,
+  "unpackedBytes": 4052076,
+  "entryCount": 140,
   "installCommands": [
     "npm install -g @vocmarket/voc-skill",
     "claude-voc install",

BIN
docs/企微技能包/01-小牛资源准备清单.docx


+ 56 - 0
docs/企微技能包/01-小牛资源准备清单.md

@@ -0,0 +1,56 @@
+# 小牛企微技能包启动准备清单
+
+**开工判断:小牛完成下列 3 项后,我方即可通过 ToDesk 远程开始部署和联调。其余技术环境由我方处理,不需要小牛提前配置。**
+
+准备完成后,小牛只需将 Fmode 账号、企微试点账号和 ToDesk 设备代码告知我方,并约定远程时间;密码和验证码在远程操作时由小牛自行输入。
+
+## 一、启动前必须完成的 3 项
+
+| 序号 | 必须事项 | 小牛现在要做 | 完成标准 |
+| ---: | --- | --- | --- |
+| 1 | Fmode 账号和余额 | 使用现有 Fmode Studio;确认 1 个 Fmode 账号可以正常登录,并将账户可用余额充值至不低于 ¥300 | 账号可登录;可用余额 ≥ ¥300,其中 ¥200 用于 1 个企微自动化席位,约 ¥100 作为首轮模型测试 Token 额度 |
+| 2 | 企业微信试点账号 | 指定 1 个真实、可控的企业微信账号,能够正常登录企业微信桌面端 | 账号可以配合扫码、验证码和授权;用于消息监听、AI 回复测试和人工接管 |
+| 3 | Windows 电脑和 ToDesk | 准备 1 台可以持续使用的 Windows 电脑,安装并登录 ToDesk,约定远程时间 | ToDesk 保持在线;电脑网络、电源稳定;远程部署时安排 1 位联系人配合扫码、验证码和权限确认 |
+
+**完成以上 3 项即可安排远程时间,开始搭建。**
+
+## 二、¥300 启动余额的用途
+
+| 项目 | 金额 | 用途 | 后续说明 |
+| --- | ---: | --- | --- |
+| 企微自动化席位 | ¥200/账号/月 | 为 1 个企微账号提供登录、消息监听、消息发送和设备状态能力 | 席位按月保持有效;后续继续使用时按月续费 |
+| 模型测试 Token | 首轮预留约 ¥100 | 用于意图识别、客户画像、回复草稿、待办提炼、房源匹配解释和知识检索 | 首轮联调先使用约 ¥100;后续根据实际 Token 消耗补充 |
+| 启动前账户余额 | 不低于 ¥300 | 覆盖 1 个月席位和首轮模型测试 | 开通席位前确认余额即可 |
+
+## 三、我方通过 ToDesk 远程完成
+
+| 远程工作 | 我方处理内容 |
+| --- | --- |
+| 技术环境 | 安装和配置 Node.js、Git、项目依赖、项目目录及运行环境 |
+| 技能包部署 | 部署企微技能包,配置项目插件、Skills、MCP 工具和基础知识库 |
+| 企微接入 | 协助完成席位开通、企微扫码登录、账号绑定、白名单和连接状态检查 |
+| 本地工作台 | 启动本地 4320 工作台,检查账号、客户会话、画像、待办和运行状态 |
+| 链路测试 | 跑通消息进入、AI 草稿、审核发送、自动回复和人工接管 |
+| 本地数据 | 配置 SQLite、知识库、日志、运行输出和基础备份目录 |
+
+小牛只需要保持电脑和 ToDesk 在线,并在扫码、验证码、授权确认时配合;不需要提前安装或配置上述技术环境。
+
+## 四、搭建过程中逐步准备
+
+以下内容不阻塞初始环境搭建,可以在部署和联调过程中逐步提供。
+
+| 准备时间 | 后续提供内容 | 首批建议 | 用途 |
+| --- | --- | --- | --- |
+| 消息联调前 | 测试联系人和测试客户群 | 1–2 个安全测试联系人、至少 1 个测试群 | 验证私聊、客户群、白名单和人工接管 |
+| AI 效果联调时 | 测试客户和房源数据 | 10–30 位客户、30–100 套房源 | 验证客户画像、意图判断和真实房源匹配 |
+| 业务规则校准时 | 客户阶段、画像字段、话术、跟进规则和 SOP | 各 1 套基础版本 | 调整 AI 判断、建议追问、待办和预警 |
+| 系统接口联调时 | 客户、经纪人、门店、房源等接口说明和测试凭据 | 测试环境资料 1 套 | 接入小牛真实业务数据;正式密钥不写入文档或代码 |
+| 试点优化时 | 标杆案例逐字稿和错误案例 | 10–20 份案例 | 校准回复方式、业务动作和验收标准 |
+
+## 五、启动确认
+
+- [ ] Fmode 账号可以正常登录,账户可用余额不低于 ¥300;
+- [ ] 已确定 1 个真实、可控的企业微信试点账号;
+- [ ] 已准备 1 台 Windows 电脑,ToDesk 已安装并保持在线。
+
+**以上 3 项确认后,我方即可安排远程时间开始搭建。**

BIN
docs/企微技能包/02-小牛二次开发计划.docx


+ 234 - 0
docs/企微技能包/02-小牛二次开发计划.md

@@ -0,0 +1,234 @@
+# 小牛企微智能助手二次开发计划
+
+## 一、开发目标
+
+小牛 IT 以现有企微技能包代码为基础,开发适合小牛房产业务的企微销售助手,优先跑通以下业务闭环:
+
+1. 接入小牛真实客户、经纪人、门店和房源数据;
+2. 获取客户私聊和客户群消息;
+3. 判断客户阶段、表层意图和深层需求;
+4. 持续更新客户画像、标签、待办和预警;
+5. 调用真实房源接口完成房源匹配;
+6. 支持 AI 草稿、审核发送、自动回复和人工接管;
+7. 让经纪人和管理人员通过工作台查看和推进客户;
+8. 通过真实门店试点持续修正业务规则和 AI 效果。
+
+## 二、现有代码与小牛二开内容
+
+| 模块 | 现有代码可复用内容 | 小牛需要开发或确认的内容 |
+| --- | --- | --- |
+| 登录与企微通道 | Fmode 登录、订阅、席位、企微设备和接口中转 | 试点账号配置、运行环境和异常处理流程 |
+| 消息与客服 | 私聊和客户群消息同步、AI 草稿、审核、自动、人工接管和暂停 | 小牛业务下的模式切换规则、人工接管条件和客服流程 |
+| 客户管理 | 客户列表、详情、时间线、画像、标签、待办和预警基础页面 | 小牛客户字段、负责人、门店、阶段、筛选项和管理视图 |
+| Agent 会话 | 项目主控会话、每客户独立 Session、知识和工具调用 | 小牛提示词、业务 Skills、知识库和模型评测样本 |
+| 房源匹配 | 本地演示房源、匹配结果和推荐记录 | 小牛实时房源 API、字段映射、权限、匹配规则和反馈闭环 |
+| 客户群运营 | 客户群识别、确认、消息同步和群运营基础 | 小牛群 SOP、总部规则、门店执行方式和群任务 |
+| 客户交接 | 客户交接和交接包基础能力 | 小牛客户归属、经纪人通讯录和交接审批规则 |
+| 任务推进 | 对话待办、回复提醒和统一任务中心基础能力 | 与小牛内部任务系统或企微待办的连接方式 |
+| 本地工作台 | 4320 工作台、账号状态和基础业务页面 | 小牛页面布局、字段、权限、统计和运营看板 |
+
+## 三、双方分工
+
+### 1. 我方提供
+
+- 现有企微技能包代码和基础使用说明;
+- Fmode 登录、订阅、席位、企微自动化和模型接入能力;
+- 现有 Skills、MCP 工具、本地工作台和房产演示数据;
+- 技能包目录、数据结构、扩展接口和安全边界说明;
+- 底层通道及技能框架相关问题的技术建议。
+
+### 2. 小牛 IT 负责
+
+- 建立并维护小牛自己的代码仓库和版本分支;
+- 在现有代码上完成小牛业务二次开发;
+- 对接客户、经纪人、门店、通讯录、房源和任务系统;
+- 修改业务页面、字段、权限、工作流和数据适配器;
+- 将小牛业务规则封装为 Skills、知识库和配置;
+- 完成测试、部署、日志、备份、问题处理和后续维护。
+
+### 3. 小牛业务团队负责
+
+- 确定试点门店、企微账号和经纪人;
+- 定义客户阶段、画像字段、深层意图和下一步动作;
+- 提供真实业务案例、逐字稿、SOP 和禁用规则;
+- 审核房源匹配、AI 判断、回复建议和待办结果;
+- 决定自动回复、待审核和人工接管的使用边界。
+
+## 四、建议人员配置
+
+| 角色 | 建议人数 | 主要工作 |
+| --- | ---: | --- |
+| 小牛全栈/集成开发 | 1 人 | 数据接口、工作台页面、权限、部署和系统集成 |
+| 小牛 AI/业务开发 | 1 人 | Skills、知识库、客户画像、意图、房源匹配和评测 |
+| 小牛业务负责人 | 1 人 | 确认范围、业务规则、优先级和验收结果 |
+| 房源接口负责人 | 1 人,可由开发兼任 | 提供房源接口、字段口径、测试数据和问题协调 |
+| 试点经纪人 | 1–3 人 | 使用真实客户场景测试并反馈 |
+
+建议两名开发在首轮开发和试点期内保持稳定投入。房源匹配应有明确负责人,不作为普通页面修改附带处理。
+
+## 五、开发阶段
+
+以下周期用于排期参考。小牛 IT 完成接口盘点后,再确认正式工期。
+
+| 阶段 | 建议周期 | 主要任务 | 阶段结果 |
+| --- | --- | --- | --- |
+| 1. 代码交接与环境跑通 | 1 周 | 安装依赖、启动 4320 工作台、绑定测试企微账号、配置白名单、跑通消息和人工接管 | 小牛 IT 可以独立启动、停止和排查项目 |
+| 2. 小牛基础数据接入 | 1–2 周 | 接入客户、经纪人、门店和通讯录,建立身份映射和数据同步 | 企微消息能够归属到正确客户、经纪人和门店 |
+| 3. 真实房源与 AI 配房 | 2–3 周 | 接入房源 API、建立字段适配器、实现查询、匹配、解释和反馈 | 根据客户真实需求返回可用房源和推荐依据 |
+| 4. 阶段、画像、意图与 SOP | 2–3 周,可与阶段 3 并行 | 定义客户阶段、画像字段、深层意图、追问方式和下一步动作 | AI 判断能够引用对话和小牛业务规则 |
+| 5. 任务闭环与工作台 | 1–2 周 | 完善待办、预警、回复提醒、负责人、客户详情和管理视图 | 经纪人能够在工作台中查看并完成客户任务 |
+| 6. 真实门店试点 | 1–2 个月 | 真实客户运行、每日抽查、每周复盘、问题集回归和规则更新 | 形成可复制到下一门店的配置、知识和操作流程 |
+
+阶段 3 和阶段 4 可以并行推进。正式试点前,建议先完成 6–8 周的基础开发和联调。
+
+## 六、各阶段开发要求
+
+### 1. 代码交接与环境跑通
+
+- 建立小牛自己的 Git 仓库、分支和发布方式;
+- 安装 Node.js、Fmode Studio 和项目依赖;
+- 配置开发、测试和正式环境;
+- 绑定一个真实企微测试账号;
+- 配置测试联系人和测试客户群白名单;
+- 跑通消息进入、AI 草稿、审核发送和人工接管;
+- 明确配置、SQLite、知识库、日志和备份目录。
+
+验收结果:小牛开发人员可以不依赖我方现场操作,独立重启项目并完成一次真实消息闭环。
+
+### 2. 小牛基础数据接入
+
+- 对接客户、经纪人、门店和通讯录接口;
+- 建立企微身份、手机号、员工 ID 和客户 ID 的映射;
+- 设计独立的数据适配层,避免将小牛字段写入企微底层代码;
+- 明确增量同步、全量同步和数据冲突处理方式;
+- 按小牛要求调整客户列表和客户详情字段;
+- 建立客户归属、经纪人归属和门店归属关系。
+
+验收结果:真实企微消息可以准确落到小牛系统中的客户和负责人。
+
+### 3. 真实房源与 AI 配房
+
+- 明确房源 API 的鉴权、查询条件、状态字段和调用限制;
+- 建立房源字段映射和数据适配器;
+- 将房源查询与匹配封装为独立 Skill/工具;
+- 根据客户画像和实时对话查询房源;
+- 输出匹配房源、匹配理由和待补充条件;
+- 推荐前检查房源有效状态和查看权限;
+- 记录推荐历史、经纪人反馈和客户反馈;
+- 建立固定测试集,持续检查匹配结果。
+
+验收结果:使用标准客户需求,可以稳定返回真实有效的房源,并说明推荐依据。
+
+### 4. 阶段、画像、意图与 SOP
+
+- 定义从首次接触、需求挖掘、房源匹配、约看、带看、谈判到签约的阶段;
+- 定义每个阶段的进入条件、退出条件、关键事件和下一步动作;
+- 确定客户画像字段及字段更新规则;
+- 将客户表层表达与潜在需求、建议追问和行动建议关联;
+- 整理标杆经纪人的真实成功案例和逐字稿;
+- 清洗不鼓励、不合规或不可复制的经验;
+- 将稳定、高频的处理动作封装为 Skills;
+- 为知识和规则记录来源、版本和责任人。
+
+验收结果:阶段、画像和深层意图的判断均可查看对话依据,业务负责人可以修改规则和知识内容。
+
+### 5. 任务闭环与工作台
+
+- 统一约看、回访、地址确认、资料补充等客户待办;
+- 明确任务负责人、截止时间、状态和处理结果;
+- 防止同一客户、同一事项重复生成待办;
+- 增加客户长时间未回复、客服未及时回复和高意向客户提醒;
+- 将任务状态同步到客户时间线;
+- 完善客户阶段、消息数、最后互动和负责人等列表字段;
+- 提供客户详情、待办、预警、推荐和处理依据;
+- 根据小牛选择,对接企微待办或小牛内部任务系统。
+
+验收结果:经纪人能够收到、处理并关闭任务,管理人员能够查看未处理和逾期事项。
+
+### 6. 真实门店试点
+
+- 选择一个受控门店或业务小组;
+- 首先使用待审核模式,稳定后再逐步开放自动回复;
+- 每日抽查回复、画像、意图、待办和房源推荐;
+- 每周由小牛业务负责人、IT 和试点经纪人共同复盘;
+- 将错误案例加入固定问题集并重复测试;
+- 记录人工接管、异常、模型用量和业务结果;
+- 固化安装、配置、业务知识和使用流程。
+
+验收结果:同一套项目配置可以复制给下一门店或下一试点账号。
+
+## 七、小牛需要提供的接口
+
+| 接口 | 首批字段要求 |
+| --- | --- |
+| 客户/客源 | 客户 ID、姓名或脱敏名称、手机号、负责人、门店、来源、当前状态 |
+| 经纪人 | 员工 ID、姓名、手机号、企微身份、门店、在职状态 |
+| 门店/组织 | 门店 ID、名称、区域、负责人和组织关系 |
+| 房源 | 房源 ID、有效状态、价格、户型、面积、区域、楼层、装修、卖点和更新时间 |
+| 任务 | 任务 ID、客户、负责人、截止时间、状态和处理结果;如首期不对接可暂用本地任务中心 |
+| 权限 | 员工、客户、门店和房源之间的查看与操作权限 |
+
+每个接口需要同时提供字段说明、测试环境、测试凭据、错误码和调用限制。
+
+## 八、小牛需要提供的业务资料
+
+- 客户阶段和阶段转换条件;
+- 客户画像字段及判断依据;
+- 常见表层表达、潜在需求、建议追问和下一步动作;
+- 标杆经纪人的真实案例逐字稿;
+- 约看、带看、回访、谈判和签约 SOP;
+- 常用话术、禁用话术和人工接管条件;
+- 一批标准客户需求和期望房源结果;
+- 一批错误案例和不可接受结果。
+
+逐字稿需区分说话人并保留原始表达,不能只提供总结版。
+
+## 九、测试与验收重点
+
+### 1. 系统链路
+
+- 企微账号可以稳定登录和恢复;
+- 私聊和客户群消息可以正确进入;
+- 审核、自动和人工接管切换有效;
+- 客户、经纪人、门店和房源映射正确;
+- 待办不会重复生成;
+- 非白名单误发送数量为 0;
+- 日志能够定位失败环节。
+
+### 2. AI 与业务效果
+
+- 客户阶段判断符合小牛规则;
+- 画像字段有原始对话依据;
+- 深层意图和建议追问可被经纪人理解和采用;
+- 房源推荐真实有效且理由清楚;
+- 回复草稿符合小牛话术和人工接管要求;
+- 待办和预警能够推动实际跟进。
+
+### 3. 运维与数据
+
+- 开发、测试和正式配置相互隔离;
+- Token 和正式凭据不进入代码仓库;
+- SQLite、知识库和关键配置可以备份恢复;
+- 小牛 IT 可以独立完成部署、升级、回滚和常见故障处理。
+
+## 十、阶段交付物
+
+- 小牛二开代码仓库和版本分支;
+- 环境配置、启动、备份和恢复说明;
+- 小牛客户、经纪人、门店和房源数据适配器;
+- 房源匹配 Skill、测试数据和评测结果;
+- 客户阶段、画像、深层意图和 SOP 知识库;
+- 小牛业务工作台页面;
+- 测试问题集和回归记录;
+- 试点复盘记录和下一轮优化清单。
+
+## 十一、首轮完成标准
+
+首轮开发完成时,应满足:
+
+1. 一个真实企微账号可以完成消息监听、回复审核、自动回复和人工接管;
+2. 真实客户、经纪人、门店和房源数据已经接通;
+3. 客户阶段、画像、意图、待办和推荐结果均有业务依据;
+4. 小牛业务负责人能够对 AI 结果进行评分和调整;
+5. 小牛 IT 可以独立维护和继续二次开发;
+6. 真实门店试点形成了可复制的配置、知识和操作流程。

BIN
docs/企微技能包/03-企微技能包产品化架构与功能.docx


+ 401 - 0
docs/企微技能包/03-企微技能包产品化架构与功能.md

@@ -0,0 +1,401 @@
+# 企微技能包产品化架构与功能
+
+## 一、产品目标
+
+将现有企微能力整理为一套可安装、可运行、可配置、可二次开发的 npm 技能包,为使用企业微信开展客户沟通和内部协作的企业提供通用基础能力。
+
+产品由两部分组成:
+
+1. **本地企微技能包**:安装在客户自己的项目目录中,包含 Skills、MCP 工具、本地工作台、Agent、知识库和业务扩展代码;
+2. **Fmode 服务底座**:提供用户登录、席位订阅、企微设备、自动化通道和模型调用能力。
+
+客户取得技能包代码后,可以根据自己的行业和业务修改页面、Skills、知识库、数据适配器和业务规则。
+
+## 二、目标用户
+
+| 用户角色 | 主要需求 |
+| --- | --- |
+| 企业管理者 | 查看客户推进、客服质量、任务执行和团队运营情况 |
+| 客服/销售/顾问 | 使用 AI 草稿、自动回复、客户画像、待办和人工接管 |
+| 运营人员 | 配置客户阶段、群 SOP、知识库、话术、标签和预警规则 |
+| 企业 IT | 安装技能包、接入业务系统、修改页面和维护客户定制代码 |
+| 行业服务商 | 在通用核心上制作房产、教育、招聘、汽车等行业模板 |
+
+## 三、产品交付和启动流程
+
+1. 用户安装并登录 Fmode Studio;
+2. 在本地创建一个独立项目目录;
+3. 通过 npm/npx 或源码安装企微技能包;
+4. 安装器写入项目插件、Skills、MCP 配置和基础目录;
+5. 用户在 Claude Code 中说“启动企微助手”;
+6. 技能包打开 Fmode 登录、席位开通和企微扫码流程;
+7. 企微账号连接成功后,启动本地 4320 工作台;
+8. 用户配置测试联系人和客户群白名单;
+9. 用户导入业务资料、选择行业模板或连接业务系统;
+10. 先使用待审核模式验证,再按业务规则开放自动回复。
+
+安装和首次启动过程需要直接告诉用户当前状态、下一步动作和工作台地址。普通用户不需要填写底层设备标识、网关密钥或接口路径。
+
+## 四、总体架构
+
+```mermaid
+flowchart TB
+    U["用户:Fmode Studio / Claude Code"] --> W["客户本地项目"]
+    W --> I["npm 安装器与项目插件"]
+    W --> D["本地 4320 工作台"]
+    W --> S["Skills 与 MCP 工具"]
+    W --> A["Agent 运行时"]
+    W --> L["SQLite、知识库与本地文件"]
+
+    A --> M["项目主控 Session"]
+    A --> C["每位企微客户独立 Session"]
+    C --> Q["事件与处理结果回到项目主控"]
+
+    S --> F["Fmode 服务底座"]
+    F --> AUTH["登录、订阅与席位"]
+    F --> GW["企微设备与自动化通道"]
+    F --> MODEL["模型网关与用量"]
+
+    S --> O["企业微信官方办公能力"]
+    S --> R["客户业务系统适配器"]
+    R --> B["CRM、商品、房源、订单与组织数据"]
+```
+
+## 五、产品分层
+
+### 1. npm 分发与安装层
+
+负责将技能包可靠地安装到客户项目:
+
+- 支持 npm、npx、源码和本地安装包;
+- 支持项目级 workspace 安装;
+- 自动安装插件、Skills 和 MCP 配置;
+- 自动检测 Node.js、端口、目录权限和依赖;
+- 安装完成后执行基础 smoke test;
+- 支持版本检查、升级、指定版本安装和回滚;
+- 安装包不包含真实 Token、客户数据、本地数据库和运行日志。
+
+### 2. 客户本地项目层
+
+每个采购企业或独立业务项目使用单独的项目目录,保存:
+
+- 项目配置和客户自定义代码;
+- 本地 4320 工作台;
+- 客户主档、消息、画像、待办、预警和审计数据;
+- 项目主控 Session 和客户 Session 映射;
+- 客户知识库、SOP 和行业 Skills;
+- 客户自己的业务数据适配器;
+- SQLite 数据库和运行输出。
+
+不同项目之间的配置、凭据、Session、数据库和输出文件相互隔离。
+
+### 3. Fmode 服务层
+
+集中提供:
+
+- Fmode 用户认证;
+- 企微席位开通、续费和状态查询;
+- 企微设备创建、扫码登录和在线状态;
+- 企业微信自动化接口中转;
+- Webhook/Relay 回调连接;
+- 模型访问、余额和用量管理;
+- 服务端账号和设备隔离;
+- 底层敏感凭据管理。
+
+本地技能包只使用 Fmode 提供的用户级凭据,不保存企微上游接口密钥。
+
+### 4. Agent 与会话层
+
+会话层采用“一个业务项目主控 + 每位企微客户独立会话”的结构:
+
+- 项目主控 Session 负责配置、服务状态、全局任务和运营管理;
+- 每位企微外部联系人绑定独立 Agent Session,避免不同客户上下文混用;
+- 客户 Session 读取客户画像、知识库、业务规则和只读工具;
+- 客户消息、Agent 结果、异常和人工操作统一写入事件队列;
+- 项目主控可以查看客户事件和打开客户会话审阅副本;
+- 客户 Session 默认不能修改项目代码;
+- 会话标题和打开入口使用客户可识别名称,不向普通用户展示原始 Session ID。
+
+### 5. 通用私域业务层
+
+通用核心不写死具体行业字段,提供:
+
+- 客户主档和客户归属;
+- 私聊和客户群消息;
+- 客户阶段和自定义字段;
+- 客户画像、标签、意图和证据;
+- AI 草稿、审核、自动回复和人工接管;
+- 待办、预警、回复时效和客户时间线;
+- 客户群运营和群 SOP;
+- 客户交接和内部负责人;
+- 知识库、业务规则和建议动作;
+- 推荐记录、处理结果和反馈;
+- 操作审计和数据导出。
+
+### 6. 企业协作能力层
+
+以独立模块连接企业微信官方能力:
+
+- 通讯录;
+- 会议;
+- 文档;
+- 日程;
+- 待办;
+- 从会议提取行动项;
+- 目标拆解、负责人、截止时间和进度汇总。
+
+客户可以只安装私域客户运营模块,也可以按需启用企业协作模块。
+
+### 7. 行业扩展层
+
+行业能力由“行业 Skills + 知识模板 + 数据适配器 + 页面配置”组成,不直接修改通用底层。
+
+房产行业模板包括:
+
+- 求购和售房客户阶段;
+- 预算、区域、户型、面积、购房目的和时间等画像;
+- 表层表达、深层需求和建议追问;
+- 房源 API 适配和 AI 配房;
+- 约看、带看、谈判和签约待办;
+- 经纪人话术和 SOP;
+- 门店、区域和总部运营视图。
+
+其他行业只需要替换自己的行业对象、客户阶段、画像、SOP、推荐工具和业务页面。
+
+## 六、核心功能要求
+
+### 1. 安装与项目管理
+
+- 一条命令完成项目安装;
+- 自动检查运行环境和依赖;
+- 安装失败时给出可以直接执行的修复步骤;
+- 支持启动、停止、健康检查和服务恢复;
+- 自动显示工作台地址和当前企微账号;
+- 支持升级、回滚和版本锁定;
+- 支持使用自然语言完成常用启动和检查操作。
+
+### 2. 登录、订阅与企微账号
+
+- 完成 Fmode 登录和当前用户识别;
+- 查询席位、到期时间和续费状态;
+- 一个席位绑定一个企微账号;
+- 支持企微扫码、验证码、在线状态和重新连接;
+- 清楚展示未登录、未开通、未授权、离线和已连接状态;
+- 切换企微账号后,客户数据、配置和 Session 同步切换;
+- 凭据只从安全配置或运行环境中读取。
+
+### 3. 消息与智能客服
+
+- 同步真实客户私聊和已确认客户群消息;
+- 支持测试联系人和客户群白名单;
+- 支持待审核、自动、人工接管和暂停四种模式;
+- 支持草稿编辑、批准、驳回和重新生成;
+- 根据置信度、业务阶段和风险规则决定审核或自动;
+- 人工接管后停止 Agent 自动处理;
+- 支持客服长时间未回复提醒;
+- 消息页面进入后定位到最新消息;
+- 消息发送失败时显示真实状态并支持重试。
+
+### 4. 客户 CRM
+
+- 客户列表展示负责人、来源、阶段、消息数和最后互动时间;
+- 客户详情展示消息、画像、标签、待办、预警、推荐和时间线;
+- 支持客户与员工、门店、部门和企微账号的归属关系;
+- 支持客户批量导入、历史消息分析、导出和备份;
+- 支持客户交接、负责人变化和交接记录;
+- 支持客户自定义字段和行业对象关联。
+
+### 5. 客户画像、意图与标签
+
+- 从对话持续补充客户画像;
+- 每个画像字段保留原始消息或业务数据依据;
+- 区分客户表层表达、潜在需求、情绪和建议追问;
+- 支持字段置信度、人工修正和更新时间;
+- 支持通用标签、行业标签和企业自定义标签;
+- 支持按画像、标签、阶段和意图筛选客户。
+
+### 6. 待办、预警与客户推进
+
+- 从对话和业务事件生成客户待办;
+- 待办包含客户、负责人、截止时间、状态和来源;
+- 同一客户的同一事项不重复生成;
+- 支持客服未回复、客户沉默、高意向、投诉和逾期预警;
+- 任务状态与客户时间线联动;
+- 支持同步企微待办或客户内部任务系统;
+- 管理人员可以查看未处理、逾期和阻塞事项。
+
+### 7. 客户群运营
+
+- 识别、确认和拒绝客户群;
+- 同步已确认客户群消息;
+- 配置客户群负责人、门店和运营状态;
+- 配置按天或阶段执行的群 SOP;
+- 生成群运营建议、待办和异常提醒;
+- 支持总部规则、门店执行和完成情况查看;
+- 支持群画像、成员标签和运营时间线。
+
+### 8. 客户交接
+
+- 选择原负责人、新负责人和待交接客户;
+- 生成交接预览和客户资料包;
+- 转移客户归属、画像、待办和跟进记录;
+- 记录交接时间、操作人和执行结果;
+- 根据企业规则执行加好友、建群或通知动作;
+- 交接失败可以定位到具体客户和具体步骤。
+
+### 9. 知识库、SOP 与 Skills
+
+- 支持文档、会议记录、结构化规则和业务数据;
+- 区分通用知识、企业知识和行业知识;
+- 知识条目记录来源、版本、责任人和更新时间;
+- 支持人工审核和发布业务规则;
+- 将高频、稳定的业务动作封装为 Skills;
+- Skills 使用自然业务语言接收任务;
+- 工具输出采用统一结构,便于工作台、Agent 和其他技能复用;
+- 支持知识更新后的固定案例回归测试。
+
+### 10. 数据适配器
+
+标准适配器用于连接:
+
+- 客户和线索;
+- 员工、顾问、经纪人和组织;
+- 商品、房源、课程、车辆等行业对象;
+- 订单、预约、任务和交易状态;
+- 客户归属和数据权限。
+
+适配器统一处理字段转换、鉴权、分页、重试、限流、错误说明和数据同步。行业代码通过适配器使用客户系统,不直接修改 Fmode 企微网关。
+
+### 11. 企业办公与目标管理
+
+- 查询和管理会议、文档、日程和待办;
+- 从会议记录中提取行动项;
+- 将大目标拆分为里程碑和任务;
+- 设置负责人、截止时间、依赖和优先级;
+- 汇总完成、阻塞、逾期和整体进度;
+- 将任务与相关客户、项目和会议资料关联。
+
+## 七、客户二次开发范围
+
+客户可以修改:
+
+- 工作台页面、导航和品牌样式;
+- 客户、员工、组织和行业字段;
+- 客户阶段、画像、标签、意图和预警规则;
+- 企业知识库、SOP、提示词和 Skills;
+- 业务数据适配器;
+- 推荐、匹配和评分逻辑;
+- 内部任务、审批、报表和运营看板。
+
+以下底层能力由产品统一维护:
+
+- Fmode 用户认证和席位协议;
+- 企微设备、自动化通道和上游凭据;
+- 模型网关和用量校验;
+- 项目、账号、数据和 Session 隔离;
+- 安装结构、工具协议和版本兼容;
+- 白名单、人工接管和审计等安全机制。
+
+通过明确扩展接口保持业务代码与底层代码分离,使客户二开代码可以跟随新版本升级。
+
+## 八、数据与安全要求
+
+### 1. 凭据安全
+
+- 真实 Token、密钥和账号凭据不写入代码、HTML、日志和文档;
+- 安装包不携带开发者本机配置和测试凭据;
+- 登录、订阅、设备和模型请求使用用户级身份;
+- 敏感上游地址、接口密钥和设备上下文只在服务端管理;
+- 开发、测试和正式环境使用不同配置。
+
+### 2. 数据隔离
+
+- 不同项目的数据目录相互独立;
+- 不同企微账号的客户、群、Session 和数据库相互隔离;
+- npm 包不包含任何真实客户数据;
+- 支持备份和恢复 SQLite、知识库和关键配置;
+- 客户数据导出、清理和保留周期可以配置。
+
+### 3. 自动化安全
+
+- 首次启用默认使用待审核模式;
+- 白名单为空时不启动真实自动监听;
+- 非白名单对象不允许自动发送;
+- 人工接管立即停止对应客户的自动处理;
+- 所有发送、审批、模式切换和数据更新保留审计;
+- 自动化通道异常时保留人工处理入口。
+
+## 九、运行与运维要求
+
+- 工作台提供服务健康、企微在线、订阅和模型状态;
+- 关键进程支持自动恢复和手动重启;
+- 日志区分登录、消息、Agent、工具、发送和数据错误;
+- 错误提示说明影响范围和恢复动作;
+- 支持配置、SQLite 和知识库备份恢复;
+- 支持安装前检查、升级前检查和升级后 smoke test;
+- 支持固定版本运行和回滚;
+- 多账号阶段提供账号级健康和用量视图。
+
+## 十、版本与发布要求
+
+- `package.json`、锁文件、插件清单和技能包清单版本保持一致;
+- 每个版本提供变更说明、升级步骤和兼容说明;
+- 发布前执行语法检查、smoke test、安装测试和打包检查;
+- 发布后验证 npm 安装和 npx workspace 安装;
+- npm 包排除本机配置、真实输出、Session、数据库和依赖目录;
+- 客户可以锁定版本并在测试环境验证后升级;
+- 底层接口变化时提供兼容层或迁移说明。
+
+## 十一、产品化路线
+
+### 阶段 1:单席位标准产品
+
+- 完成 npm 安装和项目初始化;
+- 打磨 Fmode 登录、席位开通、企微扫码和启动体验;
+- 稳定私聊、AI 草稿、待审核、自动回复和人工接管;
+- 完善客户画像、待办、预警、时间线和数据备份;
+- 使用一个真实客户完成单账号试点。
+
+### 阶段 2:通用核心与房产模板分离
+
+- 从通用核心移除房产专用字段;
+- 建立统一的数据适配器和行业模板接口;
+- 将房源匹配、房产画像、阶段和 SOP 独立成房产模板;
+- 完成小牛真实房源和门店试点;
+- 验证客户二开代码可以跟随核心版本升级。
+
+### 阶段 3:群运营、交接和企业协作
+
+- 完善客户群识别、群 SOP、异常和运营看板;
+- 完善客户交接、通讯录和组织关系;
+- 打通会议、文档、日程、待办和目标管理;
+- 建立企业级知识和任务中心。
+
+### 阶段 4:多席位和组织管理
+
+- 支持多个企微账号统一管理;
+- 建立账号、员工、门店和客户权限;
+- 提供多账号数据隔离与汇总;
+- 提供席位、用量、运行健康和批量升级视图。
+
+### 阶段 5:行业模板体系
+
+- 提供行业模板脚手架;
+- 标准化行业 Skills、知识包、适配器和页面配置;
+- 建立模板安装、版本和兼容机制;
+- 逐步形成可复制的行业模板目录。
+
+## 十二、产品完成标准
+
+企微技能包面向其他客户交付前,应满足:
+
+1. 新用户可以根据页面提示完成安装、登录、席位开通和企微连接;
+2. 单账号能够稳定完成消息监听、AI 草稿、审核发送和人工接管;
+3. 客户可以通过标准适配器接入自己的业务数据;
+4. 通用核心不依赖房产字段,房产能力可以独立安装和升级;
+5. 客户 IT 可以在现有代码上修改页面、Skills、知识库和适配器;
+6. Token、客户数据、Session 和数据库不会进入 npm 安装包;
+7. 安装、升级、回滚、备份和故障恢复流程已经验证;
+8. 项目、账号和客户 Session 之间的数据隔离已经验证;
+9. 至少完成一个真实行业客户的连续试点;
+10. npm 发布版本和 workspace 安装通过实际验证。

+ 122 - 0
docs/企微技能包/04-企微技能包启动与预览.md

@@ -0,0 +1,122 @@
+# 企微技能包启动与预览
+
+## 一、客户使用方式
+
+客户在 Fmode Studio 中打开已经安装技能包的项目,然后对 Claude Code 说:
+
+```text
+启动并预览企微助手
+```
+
+Claude Code 启动本地工作台并返回页面地址:
+
+```text
+http://127.0.0.1:4320/#agent
+```
+
+## 二、启动检查顺序
+
+工作台启动后按以下顺序检查:
+
+| 顺序 | 检查项 | 正常状态 | 未完成时的处理 |
+| ---: | --- | --- | --- |
+| 1 | Fmode 鉴权 | 当前 Fmode Studio 账号可以访问服务 | 先登录 Fmode Studio,再重新启动 |
+| 2 | 企微席位 | 当前账号已开通有效席位 | 在工作台打开订阅与登录流程 |
+| 3 | 企微账号 | 企微设备显示在线 | 首次使用扫码登录;已绑定账号点击恢复登录 |
+| 4 | 测试白名单 | 至少配置 1 个测试联系人 | 添加测试联系人后重新检查 |
+| 5 | AI 监听 | 显示监听中 | 企微在线后点击“启动 AI 监听” |
+
+页面可以打开,只表示本地工作台已经启动。五项检查全部完成后,才表示企微智能客服链路已经可以接收和处理消息。
+
+## 三、源码开发预览
+
+在技能包源码目录执行:
+
+```bash
+npm install
+npm run preview
+```
+
+启动器会:
+
+1. 确认当前项目目录;
+2. 检查 4320 是否属于当前项目;
+3. 启动本地工作台;
+4. 读取五项启动状态;
+5. 打印当前第一项待处理动作;
+6. 自动打开浏览器。
+
+不自动打开浏览器:
+
+```bash
+npm run preview -- --no-open
+```
+
+使用其他端口:
+
+```bash
+npm run preview -- --port 4321
+```
+
+## 四、客户项目预览
+
+安装技能包:
+
+```bash
+node install.js workspace <客户项目目录> --smoke
+```
+
+进入客户项目后启动:
+
+```bash
+node .claude/plugins/qiwei-assistant/install.js preview .
+```
+
+workspace 安装会将以下路径绑定到客户项目根目录:
+
+- MCP 运行目录;
+- `outputs/` 运行数据;
+- Claude Code 客户 Session 工作目录;
+- 客户自己的 `.env.local` 和业务配置。
+
+技能包程序仍位于 `.claude/plugins/qiwei-assistant`,客户业务数据不写入插件目录。
+
+## 五、消息演示流程
+
+1. 打开“智能会话”;
+2. 确认企微账号在线;
+3. 确认测试联系人已进入白名单;
+4. 保持默认“待审核”模式;
+5. 点击“启动 AI 监听”;
+6. 使用白名单联系人向测试企微发送消息;
+7. 在工作台查看新消息、客户画像、待办和预警;
+8. 查看并编辑 AI 草稿;
+9. 点击批准发送;
+10. 点击“人工接管”后,继续在企业微信客户端人工回复。
+
+## 六、查看客户 Agent 会话
+
+列出已经识别的客户会话:
+
+```bash
+npm --prefix .claude/plugins/qiwei-assistant run agent:session:list
+```
+
+按客户名称打开审阅副本:
+
+```bash
+npm --prefix .claude/plugins/qiwei-assistant run agent:session -- --customer <客户名称>
+```
+
+打开的是客户 Session 的审阅副本,不会修改生产客户会话。
+
+## 七、常见卡点
+
+| 现象 | 原因 | 处理 |
+| --- | --- | --- |
+| 页面能打开,但显示企微离线 | 工作台已启动,企微设备尚未在线 | 点击恢复登录或重新扫码 |
+| 订阅有效,但不能启动监听 | 企微离线或白名单为空 | 先完成企微登录,再配置测试联系人 |
+| 4320 已被占用 | 另一个项目或旧工作台正在运行 | 关闭旧工作台,或改用 `--port 4321` |
+| 打开的页面属于另一个项目 | 多项目共用端口 | 新启动器会校验 `workspaceId` 并拒绝误连 |
+| 数据出现在隐藏插件目录 | 旧版 workspace 运行目录指向插件 | 重新执行 workspace 安装,运行目录会绑定到客户项目根目录 |
+| 不知道下一步做什么 | 旧启动命令只返回页面地址 | 使用 `npm run preview` 或让 Claude Code“启动并预览企微助手” |

+ 25 - 3
release/npm发布管理文档/NPM技能包发布管理.md

@@ -18,17 +18,19 @@
 - [`变更记录/voc-skill.md`](变更记录/voc-skill.md) — `@vocmarket/voc-skill`
 - [`变更记录/tihao.md`](变更记录/tihao.md) — `@vocmarket/tihao`(含 legacy `@vocmarket/tihao-sop`)
 - [`变更记录/fmode-vision-ffmpeg-listen.md`](变更记录/fmode-vision-ffmpeg-listen.md) — `fmode-vision` / `fmode-ffmpeg` / `fmode-listen`
+- [`变更记录/fmode-qiwei.md`](变更记录/fmode-qiwei.md) — `fmode-qiwei`
 - [`变更记录/measurement-marker-check.md`](变更记录/measurement-marker-check.md) — `@gangvy/claude-code-measurement-marker-check`
 
 ## 发布状态总览(版本号唯一真源)
 
 | 技能包 | npm 包名 | 当前版本 | 状态 | 本地目录 | npm 链接 |
 |---|---|---:|---|---|---|
-| Claude Code VOC Intelligence | `@vocmarket/voc-skill` | `0.3.30` | 已发布 | `claude-code/claude-code-voc-intelligence/` | https://www.npmjs.com/package/@vocmarket/voc-skill |
+| Claude Code VOC Intelligence | `@vocmarket/voc-skill` | `0.3.31` | 已发布 | `claude-code/claude-code-voc-intelligence/` | https://www.npmjs.com/package/@vocmarket/voc-skill |
 | 提号 SOP 博主名单 | `@vocmarket/tihao` | `1.0.18` | 已发布 | `E:\workspace\tihao-ai\claude-code-tihao-sourcing\` | https://www.npmjs.com/package/@vocmarket/tihao |
 | fmode 视觉分析 | `fmode-vision` | `0.1.1` | 已发布 | `claude-code/fmode-vision/`(如有) | https://www.npmjs.com/package/fmode-vision |
 | fmode 音视频处理 | `fmode-ffmpeg` | `0.1.1` | 已发布 | `claude-code/fmode-ffmpeg/` | https://www.npmjs.com/package/fmode-ffmpeg |
 | fmode 录音转写 | `fmode-listen` | `0.1.1` | 已发布 | `claude-code/fmode-listen/`(如有) | https://www.npmjs.com/package/fmode-listen |
+| fmode 企业微信智能办公 | `fmode-qiwei` | `0.4.2` | 已发布 | `claude-code/claude-code-qiwe-assistant/` | https://www.npmjs.com/package/fmode-qiwei |
 | 装修量尺图缺标记检查 | `@gangvy/claude-code-measurement-marker-check` | `0.1.2` | 已发布 | `claude-code-measurement-marker-check/` | https://www.npmjs.com/package/@gangvy/claude-code-measurement-marker-check |
 
 ## 安装入口
@@ -115,6 +117,28 @@ npx --yes fmode-ffmpeg@latest workspace      # 或 install
 npx --yes fmode-listen@latest workspace      # 或 install
 ```
 
+### fmode 企业微信智能办公(`fmode-qiwei`)
+
+安装到当前客户项目并运行发布级冒烟检查:
+
+```powershell
+npx --yes fmode-qiwei@latest workspace --smoke
+```
+
+启动本地 4320 工作台:
+
+```powershell
+npx --yes fmode-qiwei@latest preview
+```
+
+工作区安装会写入:
+
+```text
+.\.mcp.json
+.\.claude\plugins\qiwei-assistant
+.\.claude\skills\<qiwei-skill>\SKILL.md
+```
+
 ## 认证方式
 
 发布脚本支持临时读取 `NPM_TOKEN` 或 `NODE_AUTH_TOKEN`。本地建议放在:
@@ -192,5 +216,3 @@ Pop-Location
 ```
 
 > 缺 token(`needs_token`)时**先引导自救**(读 `~/.claude/settings.json` 等来源的 `sk-` token),不直接甩充值链接;只有真正 `402 余额不足` 才返回充值/开通链接(`sk-` 计费链 → `studio/?balance=fmodeapi`,`r:` 会话 token 链 → `apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF`)。任何情况下都不允许把底层 403、权限或余额接口报错直接展示给用户。
-
-

+ 28 - 0
release/npm发布管理文档/变更记录/fmode-qiwei.md

@@ -0,0 +1,28 @@
+# `fmode-qiwei` 变更记录
+
+> 当前版本号见 [`../NPM技能包发布管理.md`](../NPM技能包发布管理.md) 的「发布状态总览」。本文按版本号倒序记录 `fmode-qiwei` 的更新内容。
+
+## 0.4.2(2026-07-23)
+
+- 已发布并验证 npm `latest=0.4.2`(142 个文件,shasum `cbccf719…`)。
+- 新增客户群独立智能回复链路,群聊与客户私聊分栏展示、独立上下文、独立草稿和审计,不再串入私聊画像、待办或客户会话。
+- 每个已确认客户群可在“人工审核”和“无视风险 · 全自动”之间单独切换;全自动必须二次确认,Agent 返回非空内容后直接群发,模型、空回复或企微发送失败时停止并保留草稿。
+- 增加群聊处理流程可视化、发送失败状态、可编辑待审草稿、动态监听风险提示和桌面/移动端适配。
+- 补充默认审核、强确认、低置信自动发送、发送失败保留草稿、切回审核不自动发送等回归测试;9 组发布检查、线上 `npx workspace --smoke` 和生产依赖审计均通过,发布包继续排除本地凭据、运行输出与客户数据。
+
+## 0.4.1(2026-07-23)
+
+- 已发布并验证 npm `latest=0.4.1`(140 个文件,shasum `6785d804…`)。
+- 清理公开接口清单中的上游供应商标识、外部签名下载地址、内部网络组件参数和具体客户端版本,对外统一为 Fmode 服务说明。
+- 清单生成器增加源头过滤;内部 Python 生成器不再进入 npm 包;发布检查增加结构性防回归校验。
+- 通过 9 组发布检查、实际 tarball 扫描和线上 workspace `--smoke`;17 个 Skills、75 个 MCP 工具、25 条 Agent 检查均通过,生产依赖漏洞为 0。
+
+## 0.4.0(2026-07-23)
+
+- 首次发布为 fmode 无 scope npm 包,已验证 npm `latest=0.4.0`(141 个文件,shasum `c42bc56c…`);安装命令:`npx --yes fmode-qiwei@latest workspace --smoke`。
+- 包含 17 个企业微信 Skills、75 个 MCP 工具和 104 个接口清单,覆盖企微登录与席位、客户运营、群管理、画像标签、客户交接、目标推进、官方会议/文档/日程/待办、Webhook/Relay 和智能客服工作台。
+- workspace 安装生成 `.claude/plugins/qiwei-assistant`、项目级 Skills 和绝对路径 MCP 配置;`preview` 启动本地 4320 工作台。
+- 发布包排除 `.env.local`、npm/模型 Token、客户 Session、真实运行输出、数据库、日志、Playwright 会话和 `node_modules`。
+- 发布前通过语法、MCP mock、Agent、知识库、目标/待办、客户主档/旅程、输出规范、workspace 安装及 npm 包内容检查;生产依赖审计为 0 个漏洞。
+- 固定 `@hono/node-server@2.0.11` 并更新 `fast-uri@3.1.4`,消除发布前依赖审计发现的路径穿越和 URI 主机混淆告警;发布后验证 npm `latest` 和在线 workspace 安装。
+- 已授权 fmode 组织 `read-write`;`gangvy` 与 `fmode001` 均可维护,与 `fmode-listen` 权限级别一致。

+ 8 - 1
release/npm发布管理文档/变更记录/voc-skill.md

@@ -3,6 +3,14 @@
 > 当前版本号见 [`../NPM技能包发布管理.md`](../NPM技能包发布管理.md) 的「发布状态总览」。本文按版本号倒序记录每次更新的简要内容。
 > 本地目录:`claude-code/claude-code-voc-intelligence/`。
 
+## 0.3.31(2026-07-23)
+
+- 已发布并验证 npm `latest=0.3.31`(140 文件,package 579.7 kB,shasum `cf9be1d8…`)。
+- 安装渠道补齐 Claude Code、Codex、WorkBuddy、FmodeStudio,并支持 Codex 项目级与用户全局 MCP 注册。
+- 用户凭证统一写入中立路径 `~/.vocmarket/credentials.json`;安装完成后通过 MCP `ListTools` 验证 `voc_api_search`、`voc_api_doc`、`voc_api_call` 可发现。
+- 外部 AI IDE 安装统一使用 `npx --yes @vocmarket/voc-skill@latest workspace --activate`,禁止使用 smoke 模式;安装与激活提示不回显 token。
+- 验证通过:主 acceptance(含 installer acceptance)、package/MCP smoke、XHS/Douyin sample、build、zip、npm pack、global/npx/workspace 安装与 registry verify。
+
 ## 0.3.30(2026-06-29)
 
 - 已发布并验证 npm `latest=0.3.30`(139 文件,package 576.6 kB,shasum `e0986267…`)。本版新增**外部 AI IDE 渠道「安装即激活」入口**,面向现场教练用 Claude Code / Codex / Workbuddy 安装本技能的场景,避免进入取数流程、空耗 token 后才发现需付费:
@@ -77,4 +85,3 @@
 - 已发布并验证 `latest=0.3.0`。新增抖音趋势情报:`douyin-trend-intelligence` skill、`voc_douyin_trend_run`、`voc_douyin_token_check`、`voc_douyin_preference_update`;工作区安装同时生成小红书+抖音两个 skill 入口。
 - 统一 token/充值体验:无 token 返回开通链接,余额不足/业务 403 返回充值链接,不暴露底层报错;新增 `business-status recharge guard` smoke(防 HTTP 200 但业务码 403 误判成功)。
 
-

Некоторые файлы не были показаны из-за большого количества измененных файлов