Ver Fonte

feat: 声音克隆(TTS)、仪表盘增强、Relay守护进程及多项优化

- 新增声音克隆服务 (voice-clone-service),集成 IndexTTS2 云端 TTS,支持企微语音直传
- 新增 Relay 守护进程 (relay-daemon),支持回调模式持久化运行与自动重连
- 新增 Agent 错误消息标准化处理模块 (agent-error-message)
- 仪表盘大幅增强:Agent 生命周期管理、群组操作面板、UI/UX 重构
- 完善冒烟测试套件:voice-clone、callback-relay、agent-console
- 更新企微语音工具,TTS 通过 Fmode /doFileApi 直传,无公网回源
- fmode-agent-transport 与 webhook-processor 健壮性改进
- qiwei-relay 路由/crypto/config 多项修复
- 更新所有技能文档 (SKILL.md) 与集成指南
- 新增 dist/ 到 .gitignore,排除编译产物
ETO-kai há 1 mês atrás
pai
commit
d2944bb027
43 ficheiros alterados com 3726 adições e 399 exclusões
  1. 1 0
      .gitignore
  2. 11 1
      claude-code/claude-code-qiwe-assistant/.env.example
  3. 25 13
      claude-code/claude-code-qiwe-assistant/README.md
  4. 2 2
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-local-integration-guide.md
  5. 104 0
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-voice-clone.md
  6. 33 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-error-message.js
  7. 11 2
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-poller-policy.js
  8. 2 2
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js
  9. 29 4
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-db.js
  10. 4 2
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js
  11. 1 1
      claude-code/claude-code-qiwe-assistant/mcp/src/core/relay-config.js
  12. 164 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/relay-daemon.js
  13. 5 3
      claude-code/claude-code-qiwe-assistant/mcp/src/core/startup-summary.js
  14. 363 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/voice-clone-service.js
  15. 34 10
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js
  16. 267 42
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  17. 643 130
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  18. 5 1
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/group-agent-service.js
  19. 2 2
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html
  20. 128 8
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  21. 75 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css
  22. 59 1
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-agent-transport.js
  23. 3 2
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js
  24. 75 5
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  25. 32 22
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-agent-control-run.js
  26. 1 1
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js
  27. 193 11
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-voice-run.js
  28. 57 40
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-webhook-relay-run.js
  29. 554 0
      claude-code/claude-code-qiwe-assistant/package-lock.json
  30. 0 0
      claude-code/claude-code-qiwe-assistant/package.json
  31. 78 1
      claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js
  32. 80 0
      claude-code/claude-code-qiwe-assistant/scripts/callback-relay-smoke-test.js
  33. 255 41
      claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js
  34. 154 0
      claude-code/claude-code-qiwe-assistant/scripts/voice-clone-smoke-test.js
  35. 3 3
      claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md
  36. 1 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-real-estate-auto-reply/SKILL.md
  37. 49 29
      claude-code/claude-code-qiwe-assistant/skills/qiwei-voice/SKILL.md
  38. 3 3
      claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md
  39. 6 0
      release/npm发布管理文档/变更记录/fmode-qiwei.md
  40. 1 1
      services/qiwei-relay/lib/config.ts
  41. 3 2
      services/qiwei-relay/lib/crypto.ts
  42. 66 11
      services/qiwei-relay/routes.ts
  43. 144 2
      services/qiwei-relay/scripts/global-ingest-smoke-test.mjs

+ 1 - 0
.gitignore

@@ -5,6 +5,7 @@
 
 # Generated outputs
 outputs/
+dist/
 
 # Scratch / tooling run dirs
 .codex-run/

+ 11 - 1
claude-code/claude-code-qiwe-assistant/.env.example

@@ -57,6 +57,15 @@ QIWEI_AGENT_STARTUP_GRACE_SECONDS=10
 QIWEI_RESPONSE_REMINDER_MINUTES=15
 QIWEI_RESPONSE_URGENT_MINUTES=60
 
+# ---- 云端声音克隆与企微语音 ----
+# IndexTTS2 服务密钥只写入 .env.local,禁止提交到仓库。
+QIWEI_TTS_API_KEY=
+QIWEI_TTS_ENDPOINT=https://www.yuntts.com/api/v1/indextts2_infer
+QIWEI_TTS_MODEL=IndexTeam/IndexTTS-2
+QIWEI_TTS_TIMEOUT_MS=180000
+
+# 企微语音固定通过 Fmode /doFileApi 直传,无公网 URL 回源模式。
+
 # Agent 模型。密钥只放 .env.local 或用户级模型配置,禁止提交到仓库。
 AGENT_PROVIDER=claude-code
 AGENT_API_KEY=
@@ -69,7 +78,8 @@ CLAUDE_CODE_EXECUTABLE=
 CLAUDE_CODE_WORKDIR=.
 CLAUDE_CODE_SESSION_FILE=
 CLAUDE_CODE_TIMEOUT_MS=120000
-CLAUDE_CODE_MAX_BUDGET_USD=0.35
+CLAUDE_CODE_MAX_BUDGET_USD=1
+CLAUDE_CODE_RETRY_MAX_BUDGET_USD=3
 CLAUDE_CODE_ALLOWED_TOOLS=Read,Glob,Grep
 
 # 可选:绑定项目主控 Claude Code Session。未配置时仍会生成项目级控制器标识,客户 Session 保持独立。

+ 25 - 13
claude-code/claude-code-qiwe-assistant/README.md

@@ -175,6 +175,9 @@ Claude Code / MCP
 | 工具 | 说明 |
 | --- | --- |
 | `qiwei_transcribe_voice` | 保存语音、可选解码 silk、可选转写 |
+| `qiwei_voice_profile_status` | 查询云端 IndexTTS2 与本人声音初始化状态 |
+| `qiwei_enroll_voice` | 保存并校验当前企微账号的本人参考录音 |
+| `qiwei_send_cloned_voice` | 人工确认后编码 24kHz SILK 并真实发送企微语音 |
 
 ### Webhook 与 Relay
 
@@ -247,7 +250,7 @@ npm run wecom:status
 启动并预览企微助手
 ```
 
-Claude Code 会调用 `qiwei_agent_dashboard_start`,启动 4320 工作台,并返回产品模式、Fmode 鉴权、企微席位、企微在线、企业 Relay(仅企业版)、测试白名单和 AI 监听状态
+Claude Code 会调用 `qiwei_agent_dashboard_start`,启动 4320 工作台,并返回产品模式、Fmode 鉴权、企微席位、企微在线、企业回调(仅企业版)和测试白名单状态。企业版消息接收不依赖 Dashboard
 
 源码开发也可以执行:
 
@@ -326,25 +329,32 @@ Dashboard 优先使用启动进程或 MCP 请求中注入的 `QIWEI_AUTH_TOKEN`
 
 ### 智能会话演示
 
-Dashboard 的「智能会话」页把真实消息监听、意图识别、需求画像、业务匹配、自动回复和人工协同放在同一页面。启停逻辑如下
+Dashboard 的「智能会话」页把回调消息、意图识别、需求画像、业务匹配、自动回复和人工协同放在同一页面:
 
 - 点击「管理白名单」,从当前企微外部联系人中搜索并勾选允许 Agent 处理的客户;
-- 点击「启动 AI 监听」后,AI 仅处理已选白名单联系人;
+- 企业版由 MCP/Skill 自动连接公网回调并启动独立 Relay 守护进程;Dashboard 关闭后仍持续接收,离线期间的消息保留在服务端队列;
+- 个人版仍可点击「启动 AI 监听」,仅处理已选白名单联系人;
 - 已确认的客户群会进入独立的「群聊智能回复」列表,Agent 自动生成草稿,但必须人工编辑或审核后才能发送到群;
-- 点击「关闭 AI 监听」后,轮询器停止,切换为人工回复
+- 企业版通过全局 `paused` 或单会话人工接管停止 Agent 处理,但消息仍持续接收并保存
 - 真实发送只允许命中 `QIWEI_AUTO_REPLY_ALLOWED_SENDERS` 白名单的联系人;
+- 客服输入文本后可自动识别「自然、友好、真诚致歉、温和关怀、明确提醒」,人工确认后直接生成并发送本人音色的原生企微语音;试听默认禁用,避免额外产生一次云端合成费用;
+- 语音媒体只通过已验证的 Fmode `/doFileApi` multipart 路由直接上传,路由不可用时直接报错,不暴露公网音频回源地址;
+- 「本人声音」支持浏览器录音或音频上传,参考录音按企微账号隔离,注销后删除;
+- 已发送语音在消息流中显示为语音气泡,点击气泡播放或暂停;左侧「转文字」直接展开合成时保存的准确原文,不会再次调用语音识别服务;
+- 使用待审核草稿发送语音成功后,该草稿会同步结算为已发送并关联语音消息,文字操作区恢复为「让 Agent 处理」,避免同一回复再次以文字发送;
 - 可对单个客户切换「人工接管 / 恢复 Agent」;
+- 未初始化的客户 Claude Code Session 可在工作台一键首次运行并生成待审核草稿;已初始化 Session 仍通过现有说明和命令打开隔离审阅副本;
 - 首次启动会同步并跳过历史消息,避免把历史消息当成新消息重复处理。
 
 推荐的现场顺序:
 
 1. 打开 `http://127.0.0.1:4320/#agent`,确认测试账号显示在线;
 2. 点击「管理白名单」,搜索并选择一位测试联系人;
-3. 点击「启动 AI 监听」;
-4. 用已选白名单联系人发送一条新消息;
+3. 确认页面显示「公网回调常驻」;
+4. 用已选白名单联系人发送一条新消息,无需启动监听
 5. 查看意图、需求画像、匹配结果和建议回复;
-6. 点击「关闭 AI 监听」,演示切换为人工回复;
-7. 如需展示单客户协同,可重新启动后演示「人工接管 / 恢复 Agent」
+6. 切换全局暂停或单客户「人工接管」,验证消息继续入库但 Agent 不再生成回复;
+7. 恢复待审核或自动策略,演示继续处理后续回调消息
 
 ### 离线恢复
 
@@ -354,8 +364,8 @@ Dashboard 的「智能会话」页把真实消息监听、意图识别、需求
 
 客户把技能包安装到独立项目目录后,在该目录中的 Claude Code 会话作为项目主控入口:
 
-1. 调用 `qiwei_agent_dashboard_start` 启动 4320 工作台
-2. 完成企微登录并用 `qiwei_agent_listener` 控制监听
+1. 完成企微登录;企业版 MCP 启动时会自动连接回调并确保 Relay 守护进程常驻
+2. Dashboard 仅用于可视化管理,`qiwei_agent_status`、`qiwei_agent_list_conversations` 和 `qiwei_agent_inbox` 不依赖 Dashboard
 3. 用 `qiwei_agent_set_global` 设置暂停、待审核、自动或人工策略;
 4. 每个白名单客户绑定独立 Claude Code Session,通过 Fmode 模型配置生成草稿;
 5. 前端和客户 Session 的动作统一写入 Workbench,主控会话通过 `qiwei_agent_inbox` 读取事件;
@@ -365,6 +375,8 @@ Dashboard 的「智能会话」页把真实消息监听、意图识别、需求
 
 Dashboard 会在每个客户会话标题下主动显示 Session 状态、客户可识别会话名和打开入口;主控 Claude Code 也可以调用 `qiwei_agent_session_guide` 获取同样说明。两处都不会暴露原始 Session ID。
 
+Dashboard 默认给 Claude Code 单次生成设置 `$1` 预算,遇到 `error_max_budget_usd` 时会重建 Session 并以 `$3` 预算重试一次;可分别通过 `CLAUDE_CODE_MAX_BUDGET_USD` 和 `CLAUDE_CODE_RETRY_MAX_BUDGET_USD` 调整,最高 `$5`。这里限制的是单次任务开销,不是 Claude 账户余额。
+
 查看某个客户对应的 Claude Code 历史时,不需要查找或复制原始 Session ID:
 
 ```bash
@@ -416,7 +428,7 @@ Agent 可按业务场景直接定位到对应 Skill 文档,每个 Skill 内部
 | qiwei-portrait-tags | skills/qiwei-portrait-tags/SKILL.md | 客户画像分析、标签管理、企微个人标签 | qiwei_update_customer_portrait、qiwei_save_customer_portrait、qiwei_add_customer_tags、qiwei_apply_personal_labels |
 | qiwei-broker-playbook | skills/qiwei-broker-playbook/SKILL.md | 顾问 playbook 蒸馏、保存、导出 | qiwei_distill_broker、qiwei_save_broker_playbook、qiwei_export_broker_playbooks |
 | qiwei-customer-transfer | skills/qiwei-customer-transfer/SKILL.md | 客户交接预览与执行 | qiwei_preview_transfer_package、qiwei_execute_transfer |
-| qiwei-voice | skills/qiwei-voice/SKILL.md | 语音保存、解码、转写 | qiwei_transcribe_voice |
+| qiwei-voice | skills/qiwei-voice/SKILL.md | 语音转写、本人声音初始化、受控情绪合成与企微语音发送 | qiwei_transcribe_voice、qiwei_voice_profile_status、qiwei_enroll_voice、qiwei_send_cloned_voice |
 | qiwei-webhook-relay | skills/qiwei-webhook-relay/SKILL.md | 本地 webhook 接收、企微回调配置、Relay 自动注册与配置 | qiwei_webhook_server_start、qiwei_webhook_auto_setup、qiwei_relay_register、qiwei_relay_connect、qiwei_relay_save_config |
 | qiwei-capability-router | skills/qiwei-capability-router/SKILL.md | 选择 Fmode 网关还是官方 CLI 通道 | 按 Skill 内部规则路由 |
 | qiwei-official-meeting | skills/qiwei-official-meeting/SKILL.md | 官方会议能力 | qiwei_official_call |
@@ -447,6 +459,6 @@ QIWEI_AUTO_REPLY_ALLOWED_SENDERS=<测试联系人 userId,多个用逗号分隔
 AGENT_PROVIDER=claude-code
 ```
 
-然后运行 `npm run dashboard`,在 Dashboard「智能会话」页点击「启动 AI 监听」。登录状态通过 Fmode 专用端点读取,消息同步和发送通过 Fmode 网关执行;技能包只保存 Fmode 设备绑定信息,不接触上游企业微信接口凭据,也不保留本地直连接口
+企业版无需运行 Dashboard 或点击监听。MCP/Skill 会自动连接 Fmode 服务端全局回调并启动独立 Relay 守护进程;公网 Relay 持久排队,本地处理成功后才 ACK。登录状态和发送通过 Fmode 专用网关执行,技能包不接触上游企业微信接口凭据
 
-白名单为空时监听器会拒绝启动。默认使用审核模式,关闭监听后自动切回人工模式;状态、客户画像、待办、预警和审计记录写入 `outputs/messages/`。
+白名单为空时私聊回调仍会安全落盘,但不会进入 Agent 客户会话。默认使用审核模式;全局暂停和人工接管只停止 Agent 处理,不停止消息接收。状态、客户画像、待办、预警和审计记录写入 `outputs/messages/`。

+ 2 - 2
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-local-integration-guide.md

@@ -44,7 +44,7 @@ Content-Type: application/json
 ```
 
 5. Fmode 服务端校验订阅和设备后,使用服务端保存的回调地址与签名密钥调用企微 `/client/setCallback`。
-6. 启动本地 Relay Client
+6. MCP/Skill 自动确保本地 Relay 消费守护进程运行;无需启动 Dashboard。手工诊断时仍可执行
 
 ```bash
 npm run relay
@@ -80,7 +80,7 @@ RELAY_DEVICE_GUID
 
 ## 五、验证
 
-1. `npm run relay` 保持运行
+1. `qiwei_webhook_status` 显示 `relayRunning=true`;默认服务端队列保留 7 天,处理成功后才 ACK
 2. 让另一个企微账号给当前登录账号发送一条新的真实消息。
 3. Relay 服务日志应出现全局入口的接收记录和对应 `guid` 路由。
 4. 本地客户端应完成 poll、解密、业务处理和 ACK。

+ 104 - 0
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-voice-clone.md

@@ -0,0 +1,104 @@
+# 企微声音克隆与语音发送
+
+## 范围
+
+客服为当前企微账号初始化一次本人录音,之后在客服工作台输入文本、自动选择受控语气,并在人工确认后生成和发送原生企微语音。第一版只允许人工触发,不接入全自动回复。试听功能禁用,避免为同一条消息额外调用一次付费合成。
+
+## 链路
+
+```text
+本人参考录音
+  -> 16kHz 单声道 WAV 档案
+  -> 云端 IndexTTS2
+  -> 24kHz 单声道 SILK
+  -> Fmode /doFileApi 直传
+  -> /msg/sendVoice
+```
+
+参考录音限制为 5~30 秒、最大 20MB。档案存放在 `outputs/voice/profiles/<account-key>/`,不同企微账号相互隔离。
+
+## Payload 策略
+
+默认参数:
+
+```json
+{
+  "input": "客服回复文本",
+  "emo_control_method": 0,
+  "use_random": false
+}
+```
+
+识别到特殊客服场景时使用文本情绪控制:
+
+```json
+{
+  "input": "非常抱歉给您带来了不好的体验。",
+  "emo_control_method": 3,
+  "emo_alpha": 0.5,
+  "emo_text": "真诚、耐心、克制,带有适度歉意,语速稍慢,不夸张",
+  "use_random": false
+}
+```
+
+| 语气 | 场景 | `emo_alpha` |
+| --- | --- | ---: |
+| 自然 | 普通说明或无法判断 | 不传 |
+| 友好 | 欢迎、感谢、成功、好消息 | 0.40 |
+| 真诚致歉 | 投诉、道歉、服务失误 | 0.50 |
+| 温和关怀 | 遗憾、安慰、身体不适 | 0.45 |
+| 明确提醒 | 截止、到期、时间安排 | 0.40 |
+
+不启用情绪音频和八维情绪向量,不开放愤怒、厌恶、恐惧或强烈悲伤。`use_random` 固定为 `false`。
+
+## Dashboard API
+
+| 路由 | 用途 |
+| --- | --- |
+| `GET /api/agent/voice/status` | 查询服务和声音档案状态 |
+| `POST /api/agent/voice/profile` | 上传 Base64 音频并初始化声音 |
+| `DELETE /api/agent/voice/profile` | 删除当前账号声音档案 |
+| `POST /api/agent/conversations/:id/voice-send` | 合成并真实发送企微语音;可传当前 `draftId` |
+| `GET /api/agent/messages/:id/voice-audio` | 回放指定已发送语音,支持 HTTP Range |
+
+真实发送继续执行私聊、白名单和人工确认校验,并写入工作台消息与审计表。
+
+### 草稿与消息状态
+
+Dashboard 从待审核草稿发送语音时,在 `voice-send` 请求中携带该草稿的 `draftId`。服务端在合成和企微发送成功后执行以下状态更新:
+
+1. 插入 `msgType=16` 的 outbound 语音消息并保存 WAV 路径、时长和发送结果;
+2. 将对应草稿内容更新为实际发送文本,状态更新为 `sent`;
+3. 写入 `reviewed_at`、`reviewer=human:voice` 和 `sent_message_id`;
+4. 前端刷新后不再显示「批准并发送」,恢复「让 Agent 处理」。
+
+显式传入的 `draftId` 不存在、已处理或属于其他会话时,在合成和外发前拒绝请求。没有待审核草稿时仍允许作为人工语音发送。
+
+### 消息展示与回放
+
+- 已发送语音显示为带时长和播放状态的语音气泡,点击气泡播放或暂停;同一时间只播放一条语音。
+- 气泡左侧提供「转文字」按钮,默认收起,点击后展开合成时保存的原文;该操作不调用 ASR。
+- 播放接口只返回数据库已关联且位于 `outputs/voice/<date>/<time>-clone-*/speech.wav` 的文件,支持 HTTP Range、`audio/wav` 和私有无缓存响应。
+- Dashboard 轮询检测到语音正在播放时不重绘客服工作区,避免播放被轮询中断;播放结束后恢复正常更新。
+
+## 配置
+
+```dotenv
+QIWEI_TTS_API_KEY=
+QIWEI_TTS_ENDPOINT=https://www.yuntts.com/api/v1/indextts2_infer
+QIWEI_TTS_MODEL=IndexTeam/IndexTTS-2
+QIWEI_TTS_TIMEOUT_MS=180000
+```
+
+语音媒体只使用 Fmode 网关的 `/doFileApi` multipart 代理。该路由已于 2026-07-28 完成真实 SILK 上传验证,可以直接返回企微发送所需的 `fileId`、`fileAesKey` 和 `fileSize`。路由不可用或上传失败时直接报错,不使用公网 URL 回源。系统会在合成前检查 Fmode 鉴权和企微账号配置,避免已知无法上传时产生合成费用。
+
+## 安全
+
+- 只录制和克隆当前员工本人声音;
+- API Key 只保存在 `.env.local` 或安全环境变量;
+- 真实发送前显示联系人、文本与语气并二次确认;
+- MCP 发送工具必须显式传入 `confirmed=true`,并标记为破坏性外部操作;
+- 固定关闭随机采样;
+- 声音档案支持按账号删除;
+- 发送成功的 WAV 会保留供工作台按消息回放;失败发送的 WAV 和所有 SILK 会立即删除,运行清单不保存完整话术;
+- 记录初始化、发送、失败和删除审计。

+ 33 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-error-message.js

@@ -0,0 +1,33 @@
+'use strict';
+
+function friendlyAgentError(error) {
+  const rawMessage = String(error?.message || error || '').trim();
+  const text = rawMessage.toLowerCase();
+  if (/usage limit|quota|billing cycle|insufficient (?:credit|balance)|额度不足|余额不足/.test(text)) {
+    return { code: 'quota_exhausted', message: 'Claude Code 模型额度不足,请补充额度或等待额度恢复后重试。' };
+  }
+  if (/error_max_budget_usd|max budget|budget exceeded|预算上限/.test(text)) {
+    return { code: 'task_budget_exceeded', message: '本次 Claude Code 任务预算上限不足,请提高单次任务预算后重试。' };
+  }
+  if (/failed to authenticate|unauthorized|invalid api key|authentication|鉴权|认证失败|登录失效/.test(text)) {
+    return { code: 'authentication_failed', message: 'Claude Code 鉴权失败,请重新登录或检查模型凭据。' };
+  }
+  if (/\b403\b|forbidden|permission denied|无权限/.test(text)) {
+    return { code: 'permission_denied', message: '当前 Claude Code 账号没有执行权限,请检查账号或模型权限。' };
+  }
+  if (/not configured|尚未配置|missing.*(?:provider|model|api)/.test(text)) {
+    return { code: 'not_configured', message: 'Claude Code 模型尚未配置,请先在连接与设置中完成配置。' };
+  }
+  if (/enoent|command not found|not recognized|找不到.*(?:claude|命令)/.test(text)) {
+    return { code: 'cli_not_found', message: '本机没有找到 Claude Code CLI,请先完成安装或配置可执行文件路径。' };
+  }
+  if (/timed? ?out|timeout|超时/.test(text)) {
+    return { code: 'timeout', message: 'Claude Code 本次运行超时,请稍后重试或缩短会话上下文。' };
+  }
+  if (/network|fetch failed|econnreset|enotfound|网络/.test(text)) {
+    return { code: 'network_error', message: 'Claude Code 网络连接失败,请检查网络后重试。' };
+  }
+  return { code: 'unknown', message: 'Claude Code 暂时无法完成本次运行,请稍后重试。详细原因已记录在审计日志中。' };
+}
+
+module.exports = { friendlyAgentError };

+ 11 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-poller-policy.js

@@ -28,10 +28,19 @@ function isGroupMessage(message = {}) {
   return /^(?:group|room)$/i.test(String(message.chatType || '').trim());
 }
 
+function messageContent(message = {}) {
+  const data = message.msgData || {};
+  const candidates = [message.content, data.content, data.text, data.value, data.content?.text, data.content?.content];
+  for (const value of candidates) {
+    if (typeof value === 'string' && value.trim()) return value.trim();
+  }
+  return '';
+}
+
 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 content = messageContent(message);
   const senderId = String(message.senderId || '');
   if (!content || !senderId) return { eligible: false, reason: 'empty_message' };
   if (senderId === String(config.selfUserId || '')) return { eligible: false, reason: 'self_message' };
@@ -42,4 +51,4 @@ function evaluatePolledMessage(message = {}, config = {}) {
   return { eligible: true, content, senderId, timestamp };
 }
 
-module.exports = { messageTimestamp, roomIdOf, isGroupMessage, evaluatePolledMessage };
+module.exports = { messageTimestamp, roomIdOf, isGroupMessage, messageContent, evaluatePolledMessage };

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

@@ -646,7 +646,7 @@ class ClaudeCodeClient {
   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 budgetLimitUsd = Number(options.maxBudgetUsd || this.config.claudeMaxBudgetUsd || 1);
     const args = [
       '--print',
       '--output-format', 'json',
@@ -708,7 +708,7 @@ class ClaudeCodeClient {
         if (resetReason) {
           session = this.sessionStore.reset(key, metadata);
           const retryMaxBudgetUsd = resetReason === 'budget_exceeded'
-            ? Number(this.config.claudeRetryMaxBudgetUsd || Math.max(Number(this.config.claudeMaxBudgetUsd || 0.35) * 2, 1))
+            ? Number(this.config.claudeRetryMaxBudgetUsd || Math.max(Number(this.config.claudeMaxBudgetUsd || 1) * 2, 3))
             : undefined;
           const result = await this.invoke(messages, context, session, { maxBudgetUsd: retryMaxBudgetUsd });
           this.sessionStore.markInitialized(key);

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

@@ -10,6 +10,23 @@ const parse = (value, fallback) => {
   try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
 };
 
+const CONTACT_NAME_PLACEHOLDERS = new Set([
+  '企微客户',
+  '白名单企微联系人',
+  '未知客户',
+  '未命名客户',
+]);
+
+function normalizeContactName(value) {
+  return String(value || '').trim().slice(0, 160);
+}
+
+function isMeaningfulContactName(value) {
+  const name = normalizeContactName(value);
+  if (!name || CONTACT_NAME_PLACEHOLDERS.has(name) || name.includes('\uFFFD')) return false;
+  return !/^[??\s\p{P}\p{S}]+$/u.test(name);
+}
+
 const normalizeKeyPart = value => String(value || '')
   .trim()
   .toLowerCase()
@@ -282,16 +299,18 @@ class AgentWorkbenchDb {
 
   ensureConversation(contactId, contactName = '') {
     const existing = this.db.prepare('SELECT * FROM conversations WHERE contact_id=?').get(String(contactId));
+    const normalizedName = normalizeContactName(contactName);
+    const safeName = isMeaningfulContactName(normalizedName) ? normalizedName : '';
     if (existing) {
-      if (contactName && existing.contact_name !== contactName) {
-        this.db.prepare('UPDATE conversations SET contact_name=?,updated_at=? WHERE id=?').run(contactName, now(), existing.id);
+      if (safeName && existing.contact_name !== safeName) {
+        this.db.prepare('UPDATE conversations SET contact_name=?,updated_at=? WHERE id=?').run(safeName, now(), existing.id);
       }
       return this.getConversation(existing.id);
     }
     const conversationId = makeId('conv');
     const timestamp = now();
     this.db.prepare(`INSERT INTO conversations(id,contact_id,contact_name,mode,created_at,updated_at)
-      VALUES(?,?,?,?,?,?)`).run(conversationId, String(contactId), contactName, this.getSetting('default_mode', 'review'), timestamp, timestamp);
+      VALUES(?,?,?,?,?,?)`).run(conversationId, String(contactId), safeName, this.getSetting('default_mode', 'review'), timestamp, timestamp);
     this.db.prepare('INSERT INTO customer_profiles(conversation_id,updated_at) VALUES(?,?)').run(conversationId, timestamp);
     return this.getConversation(conversationId);
   }
@@ -332,6 +351,12 @@ class AgentWorkbenchDb {
 
   getMessage(messageId) { return this.db.prepare('SELECT * FROM messages WHERE id=?').get(messageId) || null; }
 
+  updateMessageRaw(messageId, raw) {
+    const result = this.db.prepare('UPDATE messages SET raw_json=? WHERE id=?').run(json(raw || {}), messageId);
+    if (!result.changes) throw new Error('消息不存在');
+    return this.getMessage(messageId);
+  }
+
   getLatestInbound(conversationId) {
     return this.db.prepare("SELECT * FROM messages WHERE conversation_id=? AND direction='inbound' ORDER BY created_at DESC LIMIT 1").get(conversationId) || null;
   }
@@ -726,4 +751,4 @@ class AgentWorkbenchDb {
   }
 }
 
-module.exports = { AgentWorkbenchDb };
+module.exports = { AgentWorkbenchDb, isMeaningfulContactName, normalizeContactName };

+ 4 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js

@@ -1,6 +1,7 @@
 const { EventEmitter } = require('events');
 const { AgentNotConfiguredError, isNoReplyNeededMessage } = require('./agent-runtime');
 const { isGroupMessage } = require('./agent-poller-policy');
+const { friendlyAgentError } = require('./agent-error-message');
 
 function looksLikeGroupId(value) {
   return /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(String(value || '').trim());
@@ -261,15 +262,16 @@ class AgentWorkbenchService extends EventEmitter {
       };
     } catch (error) {
       const action = error instanceof AgentNotConfiguredError ? 'agent_not_configured' : 'agent_failed';
+      const friendly = friendlyAgentError(error);
       this.db.audit({
         actor: 'agent',
         action,
         conversationId: conversation.id,
         entityId: inboundMessage.id,
-        detail: { message: error.message },
+        detail: { message: friendly.message, errorCode: friendly.code, rawMessage: error.message },
       });
       this.emit('change', { type: 'agent_error', conversationId: conversation.id });
-      return { status: action, error: error.message, conversation, message: inboundMessage };
+      return { status: action, error: friendly.message, errorCode: friendly.code, conversation, message: inboundMessage };
     }
   }
 

+ 1 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/core/relay-config.js

@@ -73,7 +73,7 @@ function isRelayEnabled() {
 
 function getRelayBaseUrl() {
   const env = readEnvLocal();
-  return (process.env.RELAY_BASE_URL || env.RELAY_BASE_URL || readRelayConfigFile().relayBaseUrl || 'http://8.138.37.248:4000').replace(/\/$/, '');
+  return (process.env.RELAY_BASE_URL || env.RELAY_BASE_URL || readRelayConfigFile().relayBaseUrl || 'https://8.138.37.248').replace(/\/$/, '');
 }
 
 function getTenantApiKey() {

+ 164 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/relay-daemon.js

@@ -0,0 +1,164 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { spawn, spawnSync } = require('child_process');
+const { categoryDir, PACKAGE_ROOT } = require('./output-paths');
+const { getProductMode } = require('./product-mode');
+const { isRelayEnabled } = require('./relay-config');
+
+const HEARTBEAT_STALE_MS = 90000;
+
+function relayRuntimeDir() {
+  const dir = categoryDir('webhook');
+  fs.mkdirSync(dir, { recursive: true });
+  return dir;
+}
+
+function relayLockPath() {
+  return path.join(relayRuntimeDir(), 'relay-consumer.lock.json');
+}
+
+function relayStatePath() {
+  return path.join(relayRuntimeDir(), 'relay-consumer-state.json');
+}
+
+function relayLauncherPath() {
+  return path.join(relayRuntimeDir(), 'start-relay-consumer.cmd');
+}
+
+function relayTaskName() {
+  const suffix = crypto.createHash('sha256').update(PACKAGE_ROOT).digest('hex').slice(0, 10);
+  return `FmodeQiweiRelay-${suffix}`;
+}
+
+function ensureRelayAutostart() {
+  if (process.platform !== 'win32') return { configured: false, reason: 'not_windows' };
+  const launcher = relayLauncherPath();
+  const scriptPath = path.join(PACKAGE_ROOT, 'scripts', 'start-relay-client.js');
+  const stdoutPath = path.join(relayRuntimeDir(), 'relay-consumer.stdout.log');
+  const stderrPath = path.join(relayRuntimeDir(), 'relay-consumer.stderr.log');
+  const content = [
+    '@echo off',
+    `cd /d "${PACKAGE_ROOT}"`,
+    `"${process.execPath}" "${scriptPath}" 1>>"${stdoutPath}" 2>>"${stderrPath}"`,
+    '',
+  ].join('\r\n');
+  fs.writeFileSync(launcher, content, 'utf8');
+  const result = spawnSync('schtasks.exe', [
+    '/Create', '/TN', relayTaskName(), '/TR', launcher,
+    '/SC', 'MINUTE', '/MO', '5', '/F'
+  ], { windowsHide: true, encoding: 'utf8' });
+  const settingsCommand = [
+    '$settings = New-ScheduledTaskSettingsSet',
+    '-AllowStartIfOnBatteries',
+    '-DontStopIfGoingOnBatteries',
+    '-ExecutionTimeLimit ([TimeSpan]::Zero)',
+    '-MultipleInstances IgnoreNew;',
+    `Set-ScheduledTask -TaskName '${relayTaskName()}' -Settings $settings | Out-Null`,
+  ].join(' ');
+  const settingsResult = result.status === 0
+    ? spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', settingsCommand], { windowsHide: true, encoding: 'utf8' })
+    : null;
+  return {
+    configured: result.status === 0 && settingsResult?.status === 0,
+    taskName: relayTaskName(),
+    launcher,
+    error: result.status !== 0
+      ? String(result.stderr || result.stdout || '').trim()
+      : settingsResult?.status === 0
+        ? ''
+        : String(settingsResult?.stderr || settingsResult?.stdout || '').trim(),
+  };
+}
+
+function readJson(filePath, fallback = null) {
+  try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
+  catch { return fallback; }
+}
+
+function isProcessAlive(pid) {
+  const value = Number(pid) || 0;
+  if (!value) return false;
+  try {
+    process.kill(value, 0);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function getRelayDaemonStatus() {
+  const lock = readJson(relayLockPath(), {});
+  const state = readJson(relayStatePath(), {});
+  const pid = Number(lock.pid || state.pid) || null;
+  const processAlive = isProcessAlive(pid);
+  const heartbeatAt = state.heartbeatAt || null;
+  const heartbeatAgeMs = heartbeatAt ? Math.max(0, Date.now() - Date.parse(heartbeatAt)) : null;
+  return {
+    running: Boolean(processAlive && heartbeatAgeMs !== null && heartbeatAgeMs <= HEARTBEAT_STALE_MS),
+    processAlive,
+    pid,
+    heartbeatAt,
+    heartbeatAgeMs,
+    startedAt: state.startedAt || null,
+    lastPollAt: state.lastPollAt || null,
+    lastReceivedAt: state.lastReceivedAt || null,
+    lastAckAt: state.lastAckAt || null,
+    lastError: state.lastError || '',
+    received: Number(state.received) || 0,
+    acked: Number(state.acked) || 0,
+    failed: Number(state.failed) || 0,
+  };
+}
+
+function ensureRelayDaemon(input = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== 'enterprise') return { started: false, reason: 'personal_mode', ...getRelayDaemonStatus() };
+  if (!isRelayEnabled()) return { started: false, reason: 'relay_not_configured', ...getRelayDaemonStatus() };
+
+  const autostart = ensureRelayAutostart();
+  const current = getRelayDaemonStatus();
+  if (current.processAlive) return { started: false, reason: current.running ? 'already_running' : 'process_starting', autostart, ...current };
+
+  const scriptPath = path.join(PACKAGE_ROOT, 'scripts', 'start-relay-client.js');
+  const logDir = relayRuntimeDir();
+  const stdoutFd = fs.openSync(path.join(logDir, 'relay-consumer.stdout.log'), 'a');
+  const stderrFd = fs.openSync(path.join(logDir, 'relay-consumer.stderr.log'), 'a');
+  try {
+    const child = spawn(process.execPath, [scriptPath], {
+      cwd: PACKAGE_ROOT,
+      detached: true,
+      windowsHide: true,
+      stdio: ['ignore', stdoutFd, stderrFd],
+      env: { ...process.env, QIWEI_RELAY_DAEMON: '1' },
+    });
+    child.unref();
+    return { started: true, reason: 'started', pid: child.pid, running: false, autostart };
+  } finally {
+    fs.closeSync(stdoutFd);
+    fs.closeSync(stderrFd);
+  }
+}
+
+function stopRelayDaemon() {
+  const status = getRelayDaemonStatus();
+  if (!status.processAlive) return { stopped: false, reason: 'not_running', ...status };
+  process.kill(status.pid, 'SIGTERM');
+  return { stopped: true, pid: status.pid };
+}
+
+module.exports = {
+  HEARTBEAT_STALE_MS,
+  relayLockPath,
+  relayStatePath,
+  relayLauncherPath,
+  relayTaskName,
+  readJson,
+  isProcessAlive,
+  getRelayDaemonStatus,
+  ensureRelayAutostart,
+  ensureRelayDaemon,
+  stopRelayDaemon,
+};

+ 5 - 3
claude-code/claude-code-qiwe-assistant/mcp/src/core/startup-summary.js

@@ -3,15 +3,16 @@ function buildStartupSummary(status = {}, agent = {}) {
   const data = agent.data || {};
   const account = data.account || {};
   const listener = data.listener || {};
+  const ingress = data.ingress || {};
   const product = data.product || { mode: 'personal', label: '个人版', collectionLabel: '本地主动监听', relayReady: false };
   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 ? '在工作台点击“恢复登录”' : '在工作台完成企微扫码登录' },
-    ...(product.mode === 'enterprise' ? [{ key: 'relay', label: '企业 Relay', ready: Boolean(product.relayReady), action: '完成企业 Relay 注册并连接服务端统一回调' }] : []),
+    ...(product.mode === 'enterprise' ? [{ key: 'relay', label: '企业回调', ready: Boolean(ingress.running), action: '等待 MCP 自动连接服务端回调并启动 Relay 消费守护进程' }] : []),
     { key: 'allowlist', label: '测试白名单', ready: allowedSenderCount > 0, action: '配置至少一个测试联系人白名单' },
-    { key: 'listener', label: 'AI 监听', ready: Boolean(listener.running), action: '账号在线后,在智能会话页点击“启动 AI 监听”' },
+    ...(product.mode === 'personal' ? [{ key: 'listener', label: 'AI 监听', ready: Boolean(listener.running), action: '账号在线后,在智能会话页点击“启动 AI 监听”' }] : []),
   ];
   const next = checks.find(item => !item.ready);
   return {
@@ -23,7 +24,8 @@ function buildStartupSummary(status = {}, agent = {}) {
     accountConfigured: Boolean(account.configured),
     online: Boolean(account.online),
     allowedSenderCount,
-    listenerRunning: Boolean(listener.running),
+    listenerRunning: product.mode === 'personal' ? Boolean(listener.running) : false,
+    callbackRunning: product.mode === 'enterprise' ? Boolean(ingress.running) : false,
     product,
   };
 }

+ 363 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/voice-clone-service.js

@@ -0,0 +1,363 @@
+'use strict';
+
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+const Ffmpeg = require('@ffmpeg-installer/ffmpeg');
+const Ffprobe = require('@ffprobe-installer/ffprobe');
+const WxVoiceModule = require('@binsee/wx-voice');
+const WxVoice = WxVoiceModule.WxVoice || WxVoiceModule.default || WxVoiceModule;
+const { categoryDir, createRunDir, writeRunManifest } = require('./output-paths');
+
+const TONE_PRESETS = Object.freeze({
+  natural: Object.freeze({ id: 'natural', label: '自然', method: 0, alpha: null, text: '' }),
+  friendly: Object.freeze({ id: 'friendly', label: '友好', method: 3, alpha: 0.4, text: '自然、友好、轻松,略带愉快和肯定,表达清晰' }),
+  apology: Object.freeze({ id: 'apology', label: '真诚致歉', method: 3, alpha: 0.5, text: '真诚、耐心、克制,带有适度歉意,语速稍慢,不夸张' }),
+  empathetic: Object.freeze({ id: 'empathetic', label: '温和关怀', method: 3, alpha: 0.45, text: '温和、关怀、耐心,语气柔和沉稳,不过度悲伤' }),
+  reminder: Object.freeze({ id: 'reminder', label: '明确提醒', method: 3, alpha: 0.4, text: '明确、专注、专业,稍微强调时间但不焦虑、不催促' }),
+});
+
+const TONE_RULES = Object.freeze([
+  ['apology', /抱歉|对不起|歉意|给您带来|不好的体验|未能及时|处理不周|投诉|不满意/],
+  ['empathetic', /遗憾|难过|理解您的心情|辛苦了|保重|安慰|担心|不容易|身体不适/],
+  ['reminder', /请于|截止|到期|超时|尽快|尽早|抓紧|马上|立即|提醒您|别忘了|时间安排/],
+  ['friendly', /恭喜|好消息|审核通过|申请成功|已经成功|感谢|欢迎|很高兴|祝您|顺利完成/],
+]);
+
+const MAX_SYNTH_AUDIO_BYTES = 50 * 1024 * 1024;
+const MAX_ERROR_BODY_BYTES = 1024 * 1024;
+
+async function readResponseBuffer(response, maxBytes) {
+  const declared = Number(response.headers.get('content-length')) || 0;
+  if (declared > maxBytes) throw new Error('声音合成响应过大');
+  if (!response.body) return Buffer.alloc(0);
+  const reader = response.body.getReader();
+  const chunks = [];
+  let size = 0;
+  try {
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      size += value.byteLength;
+      if (size > maxBytes) {
+        await reader.cancel();
+        throw new Error('声音合成响应过大');
+      }
+      chunks.push(Buffer.from(value));
+    }
+  } finally {
+    reader.releaseLock();
+  }
+  return Buffer.concat(chunks, size);
+}
+
+function isExplicitSendSuccess(result) {
+  return Boolean(result && [true, 1, '1'].includes(result.isSendSuccess));
+}
+
+function inferVoiceTone(text, context = '') {
+  const primary = String(text || '').trim();
+  const combined = `${primary}\n${String(context || '').trim()}`;
+  const match = TONE_RULES.find(([, pattern]) => pattern.test(combined));
+  const preset = TONE_PRESETS[match ? match[0] : 'natural'];
+  return {
+    ...preset,
+    automatic: true,
+    reason: match ? `命中文本场景:${preset.label}` : '未识别到特殊情绪,使用自然语气',
+  };
+}
+
+function resolveTone(input, text, context) {
+  const requested = String(input || 'auto').trim().toLowerCase();
+  if (!requested || requested === 'auto') return inferVoiceTone(text, context);
+  const preset = TONE_PRESETS[requested];
+  if (!preset) throw new Error('不支持的语音语气');
+  return { ...preset, automatic: false, reason: `人工选择:${preset.label}` };
+}
+
+function runBinary(executable, args) {
+  return new Promise((resolve, reject) => {
+    const child = spawn(executable, args, { windowsHide: true });
+    let stdout = '';
+    let stderr = '';
+    child.stdout.on('data', chunk => { stdout += chunk; });
+    child.stderr.on('data', chunk => { stderr += chunk; });
+    child.on('error', reject);
+    child.on('close', code => {
+      if (code === 0) resolve({ stdout, stderr });
+      else reject(new Error(`音频处理失败(code ${code}):${stderr.slice(-500)}`));
+    });
+  });
+}
+
+async function probeAudio(filePath) {
+  const result = await runBinary(Ffprobe.path, [
+    '-v', 'error', '-show_entries', 'format=duration,size,format_name',
+    '-show_entries', 'stream=codec_name,sample_rate,channels', '-of', 'json', filePath,
+  ]);
+  const parsed = JSON.parse(result.stdout || '{}');
+  const stream = (parsed.streams || [])[0] || {};
+  return {
+    duration: Number(parsed.format?.duration) || 0,
+    size: Number(parsed.format?.size) || fs.statSync(filePath).size,
+    format: parsed.format?.format_name || '',
+    codec: stream.codec_name || '',
+    sampleRate: Number(stream.sample_rate) || 0,
+    channels: Number(stream.channels) || 0,
+  };
+}
+
+function safeAccountKey(qiwei) {
+  const context = qiwei?.context?.() || {};
+  const source = String(context.uid || context.guid || 'default');
+  return crypto.createHash('sha256').update(source).digest('hex').slice(0, 16);
+}
+
+function extensionFor(name, mime = '') {
+  const ext = path.extname(String(name || '')).toLowerCase();
+  if (['.wav', '.mp3', '.m4a', '.webm', '.ogg', '.aac'].includes(ext)) return ext;
+  if (/wav/i.test(mime)) return '.wav';
+  if (/mpeg|mp3/i.test(mime)) return '.mp3';
+  if (/mp4|m4a/i.test(mime)) return '.m4a';
+  if (/webm/i.test(mime)) return '.webm';
+  if (/ogg/i.test(mime)) return '.ogg';
+  return '.audio';
+}
+
+class VoiceCloneService {
+  constructor({ config = {}, qiwei }) {
+    this.config = {
+      endpoint: String(config.endpoint || 'https://www.yuntts.com/api/v1/indextts2_infer').trim(),
+      apiKey: String(config.apiKey || '').trim(),
+      model: String(config.model || 'IndexTeam/IndexTTS-2').trim(),
+      requestTimeoutMs: Math.max(15000, Math.min(300000, Number(config.requestTimeoutMs) || 180000)),
+    };
+    this.qiwei = qiwei;
+  }
+
+  accountKey() {
+    return safeAccountKey(this.qiwei);
+  }
+
+  profileDir() {
+    return path.join(categoryDir('voice'), 'profiles', this.accountKey());
+  }
+
+  profilePath() {
+    return path.join(this.profileDir(), 'reference.wav');
+  }
+
+  metadataPath() {
+    return path.join(this.profileDir(), 'profile.json');
+  }
+
+  readMetadata() {
+    try {
+      return JSON.parse(fs.readFileSync(this.metadataPath(), 'utf8'));
+    } catch {
+      return null;
+    }
+  }
+
+  status() {
+    const metadata = this.readMetadata();
+    return {
+      configured: Boolean(this.config.apiKey && this.config.endpoint),
+      provider: 'yuntts-indextts2',
+      model: this.config.model,
+      enrolled: Boolean(metadata && fs.existsSync(this.profilePath())),
+      profile: metadata ? {
+        createdAt: metadata.createdAt,
+        duration: metadata.duration,
+        sampleRate: metadata.sampleRate,
+        originalName: metadata.originalName,
+      } : null,
+      tones: Object.values(TONE_PRESETS).map(item => ({ id: item.id, label: item.label })),
+      automaticTone: true,
+      previewEnabled: false,
+      uploadMode: 'doFileApi',
+      sendConfigured: Boolean(this.qiwei?.isConfigured?.()),
+    };
+  }
+
+  async enroll({ filePath, originalName = '', mime = '' }) {
+    if (!filePath || !fs.existsSync(filePath)) throw new Error('缺少声音参考文件');
+    const sourceSize = fs.statSync(filePath).size;
+    if (sourceSize > 20 * 1024 * 1024) throw new Error('声音参考文件不能超过 20MB');
+    const dir = this.profileDir();
+    fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
+    const target = this.profilePath();
+    const staging = path.join(dir, `reference-${crypto.randomUUID()}.wav`);
+    try {
+      await runBinary(Ffmpeg.path, ['-y', '-i', filePath, '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', staging]);
+      const info = await probeAudio(staging);
+      if (info.duration < 5 || info.duration > 30) throw new Error('参考录音时长需在 5~30 秒之间');
+      const backup = `${target}.bak`;
+      fs.rmSync(backup, { force: true });
+      if (fs.existsSync(target)) fs.renameSync(target, backup);
+      try {
+        fs.renameSync(staging, target);
+        fs.rmSync(backup, { force: true });
+      } catch (error) {
+        if (!fs.existsSync(target) && fs.existsSync(backup)) fs.renameSync(backup, target);
+        throw error;
+      }
+      try { fs.chmodSync(target, 0o600); } catch {}
+      const metadata = {
+        version: 1,
+        createdAt: new Date().toISOString(),
+        originalName: String(originalName || `reference${extensionFor(originalName, mime)}`).slice(0, 160),
+        mime: String(mime || '').slice(0, 100),
+        duration: Number(info.duration.toFixed(3)),
+        sampleRate: info.sampleRate,
+        channels: info.channels,
+        size: info.size,
+      };
+      fs.writeFileSync(this.metadataPath(), JSON.stringify(metadata, null, 2), 'utf8');
+      try { fs.chmodSync(this.metadataPath(), 0o600); } catch {}
+      return { status: 'ok', profile: this.status().profile };
+    } finally {
+      if (fs.existsSync(staging)) fs.rmSync(staging, { force: true });
+    }
+  }
+
+  revoke() {
+    const dir = path.resolve(this.profileDir());
+    const root = path.resolve(path.join(categoryDir('voice'), 'profiles'));
+    if (!dir.startsWith(`${root}${path.sep}`)) throw new Error('声音档案路径无效');
+    fs.rmSync(dir, { recursive: true, force: true });
+    return { status: 'ok', revoked: true };
+  }
+
+  requireReady() {
+    if (!this.config.apiKey) throw new Error('缺少 QIWEI_TTS_API_KEY,请先配置云端语音服务');
+    if (!fs.existsSync(this.profilePath())) throw new Error('尚未初始化本人声音,请先录制或上传参考音频');
+  }
+
+  async synthesize({ text, context = '', tone = 'auto' }) {
+    this.requireReady();
+    const input = String(text || '').trim();
+    if (!input) throw new Error('语音文本不能为空');
+    if (input.length > 600) throw new Error('语音文本不能超过 600 字');
+    const selectedTone = resolveTone(tone, input, context);
+    const payload = {
+      input,
+      emo_control_method: selectedTone.method,
+      use_random: false,
+    };
+    if (selectedTone.method === 3) {
+      payload.emo_alpha = selectedTone.alpha;
+      payload.emo_text = selectedTone.text;
+    }
+    const form = new FormData();
+    const reference = fs.readFileSync(this.profilePath());
+    form.append('spk_audio_file', new Blob([reference], { type: 'audio/wav' }), 'reference.wav');
+    form.append('payload', JSON.stringify(payload));
+    form.append('model', this.config.model);
+    form.append('stream_mode', 'true');
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
+    let response;
+    try {
+      response = await fetch(this.config.endpoint, {
+        method: 'POST',
+        headers: { Authorization: `Bearer ${this.config.apiKey}` },
+        body: form,
+        signal: controller.signal,
+      });
+    } catch (error) {
+      if (error.name === 'AbortError') throw new Error('声音合成超时,请稍后重试');
+      throw new Error(`声音合成服务不可用:${error.message}`);
+    } finally {
+      clearTimeout(timeout);
+    }
+    const contentType = String(response.headers.get('content-type') || '').toLowerCase();
+    if (!response.ok || contentType.includes('json')) {
+      const raw = (await readResponseBuffer(response, MAX_ERROR_BODY_BYTES)).toString('utf8');
+      let parsed;
+      try { parsed = JSON.parse(raw); } catch { parsed = {}; }
+      throw new Error(parsed.message || parsed.error || `声音合成失败(HTTP ${response.status})`);
+    }
+    const runDir = createRunDir('voice', `clone-${crypto.randomBytes(4).toString('hex')}`);
+    const wavPath = path.join(runDir, 'speech.wav');
+    fs.writeFileSync(wavPath, await readResponseBuffer(response, MAX_SYNTH_AUDIO_BYTES), { mode: 0o600 });
+    const wavInfo = await probeAudio(wavPath);
+    if (!wavInfo.duration || wavInfo.duration > 180) throw new Error('声音合成结果无效');
+    const manifestPath = writeRunManifest(runDir, {
+      type: 'voice-clone',
+      provider: 'yuntts-indextts2',
+      model: this.config.model,
+      textLength: input.length,
+      textSha256: crypto.createHash('sha256').update(input).digest('hex'),
+      tone: selectedTone,
+      audio: wavInfo,
+      files: ['speech.wav'],
+    });
+    return { text: input, tone: selectedTone, wavPath, wavInfo, runDir, manifestPath };
+  }
+
+  async encodeSilk(wavPath, runDir) {
+    const silkPath = path.join(runDir, 'speech.silk');
+    const voice = new WxVoice();
+    voice.on('error', () => {});
+    await voice.encode(wavPath, silkPath, { format: 'silk', frequency: 24000, channels: 1 });
+    const duration = await voice.duration(silkPath);
+    if (!duration) throw new Error('SILK 编码结果无效');
+    return { silkPath, duration, size: fs.statSync(silkPath).size };
+  }
+
+  async synthesizeAndSend({ text, context = '', tone = 'auto', toId, confirmed = false }) {
+    if (confirmed !== true) throw new Error('发送克隆语音前必须获得人工确认');
+    if (!this.qiwei?.isConfigured?.()) throw new Error('企微语音媒体上传尚未配置,请先完成 Fmode 鉴权和企微登录');
+    if (!String(toId || '').trim()) throw new Error('企微语音接收人不能为空');
+    const result = await this.synthesize({ text, context, tone });
+    let silk;
+    let outcome = 'failed';
+    try {
+      silk = await this.encodeSilk(result.wavPath, result.runDir);
+      const uploaded = await this.qiwei.uploadVoiceFile(silk.silkPath);
+      if (!uploaded?.fileId || !uploaded?.fileAesKey) throw new Error('企微语音媒体上传未返回有效文件信息');
+      const sent = await this.qiwei.sendVoice(toId, {
+        fileAesKey: uploaded.fileAesKey,
+        fileId: uploaded.fileId,
+        fileSize: uploaded.fileSize || silk.size,
+        voiceTime: Math.max(1, Math.round(silk.duration)),
+      });
+      if (!isExplicitSendSuccess(sent)) throw new Error('企微未明确确认语音发送成功');
+      outcome = 'sent';
+      return {
+        text: result.text,
+        tone: result.tone,
+        duration: silk.duration,
+        fileSize: silk.size,
+        sendResult: sent,
+        runDir: result.runDir,
+        audioPath: result.wavPath,
+      };
+    } finally {
+      if (silk?.silkPath) fs.rmSync(silk.silkPath, { force: true });
+      if (outcome !== 'sent') fs.rmSync(result.wavPath, { force: true });
+      writeRunManifest(result.runDir, {
+        type: 'voice-clone',
+        provider: 'yuntts-indextts2',
+        model: this.config.model,
+        textLength: result.text.length,
+        textSha256: crypto.createHash('sha256').update(result.text).digest('hex'),
+        tone: result.tone,
+        outcome,
+        files: outcome === 'sent' ? ['speech.wav'] : [],
+      });
+    }
+  }
+}
+
+module.exports = {
+  VoiceCloneService,
+  TONE_PRESETS,
+  inferVoiceTone,
+  resolveTone,
+  probeAudio,
+  isExplicitSendSuccess,
+  readResponseBuffer,
+};

+ 34 - 10
claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js

@@ -611,6 +611,26 @@ async function storeGroupMessageFromWebhook(event, eventFilePath = null) {
   return { success: true };
 }
 
+async function routeMessageToAgent(event, eventFilePath = null) {
+  const raw = event.raw || {};
+  const isGroup = Boolean(raw.fromRoomId && String(raw.fromRoomId) !== '0');
+  const archived = isGroup
+    ? await storeGroupMessageFromWebhook(event, eventFilePath)
+    : { success: false, ignored: true, reason: 'private_message' };
+  const agentResult = await require('../dashboard/agent-service').ingestWebhookMessage(raw);
+  const agentStatus = String(agentResult?.status || '');
+  const agentHandled = Boolean(agentStatus && !agentStatus.startsWith('ignored_'));
+  if (!isGroup && agentHandled && eventFilePath) {
+    updateWebhookEventStatus(eventFilePath, 'PROCESSED', `私聊消息已进入 Agent 工作台: ${agentStatus}`);
+  }
+  return {
+    success: Boolean(archived.success || agentHandled),
+    ignored: Boolean(!archived.success && !agentHandled),
+    archived,
+    agent: agentResult,
+  };
+}
+
 async function processWebhookEvents(envelope) {
   const events = parseWebhookEnvelope(envelope);
   if (!events.length) {
@@ -628,24 +648,26 @@ async function processWebhookEvents(envelope) {
     }
 
     const eventFilePath = saveWebhookEventStructured(event, envelope.source || 'callback', envelope.__rawBody);
-    markEventProcessed(event.eventId);
-
     try {
       const config = readWebhookConfig();
       const autoCreateGroupEnabled = config.autoCreateGroup !== false;
 
       if (event.parsedType === ParsedWebhookEvent.NEW_MESSAGE) {
-        const msgResult = await storeGroupMessageFromWebhook(event, eventFilePath);
-        if (msgResult.success) result.processed++;
-        else if (msgResult.ignored) result.ignored++;
-        else result.errors++;
+        const msgResult = await routeMessageToAgent(event, eventFilePath);
+        if (msgResult.success) {
+          result.processed++;
+          markEventProcessed(event.eventId);
+        } else if (msgResult.ignored) {
+          result.ignored++;
+          markEventProcessed(event.eventId);
+        } else result.errors++;
         continue;
       }
 
       if (event.parsedType === ParsedWebhookEvent.GROUP_CREATED) {
         const groupResult = await handleGroupCreateWebhook(event, eventFilePath);
-        if (groupResult.success) result.processed++;
-        else if (groupResult.ignored) result.ignored++;
+        if (groupResult.success) { result.processed++; markEventProcessed(event.eventId); }
+        else if (groupResult.ignored) { result.ignored++; markEventProcessed(event.eventId); }
         else result.errors++;
         continue;
       }
@@ -655,14 +677,15 @@ async function processWebhookEvents(envelope) {
         event.parsedType === ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED
       )) {
         const autoResult = await triggerAutoCreateGroup(event, eventFilePath);
-        if (autoResult.success) result.processed++;
-        else if (autoResult.ignored) result.ignored++;
+        if (autoResult.success) { result.processed++; markEventProcessed(event.eventId); }
+        else if (autoResult.ignored) { result.ignored++; markEventProcessed(event.eventId); }
         else result.errors++;
         continue;
       }
 
       updateWebhookEventStatus(eventFilePath, 'IGNORED', `事件类型 ${event.parsedType} 不需要自动处理`);
       result.ignored++;
+      markEventProcessed(event.eventId);
     } catch (err) {
       console.error('[Webhook] 事件处理异常:', err && err.message ? err.message : err);
       updateWebhookEventStatus(eventFilePath, 'ERROR', String(err && err.message ? err.message : err));
@@ -682,6 +705,7 @@ module.exports = {
   handleGroupCreateWebhook,
   identifyServiceGroupMembers,
   storeGroupMessageFromWebhook,
+  routeMessageToAgent,
   resolveSenderType,
   ensureRelatedContact,
   readConfirmedMapping,

+ 267 - 42
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -1,12 +1,14 @@
 const fs = require('fs');
 const path = require('path');
 const crypto = require('crypto');
-const { PACKAGE_ROOT, WORKSPACE_ROOT, latestPath } = require('../core/output-paths');
+const { PACKAGE_ROOT, WORKSPACE_ROOT, latestPath, categoryDir } = require('../core/output-paths');
 const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
 const { AgentKnowledgeStore } = require('../core/agent-knowledge');
 const { QiweiAgentRuntime, extractExplicitCustomerIntelligence } = require('../core/agent-runtime');
 const { getCustomerSessionGuide } = require('../core/agent-session-guide');
 const { AgentWorkbenchService } = require('../core/agent-workbench-service');
+const { friendlyAgentError } = require('../core/agent-error-message');
+const { VoiceCloneService } = require('../core/voice-clone-service');
 const {
   searchTodoUsers,
   createTodoKnowledge,
@@ -20,6 +22,7 @@ const { responseMonitor } = require('./response-monitor-service');
 const { GroupAgentService } = require('./group-agent-service');
 const { normalizeAllowlistIds, normalizeAllowlistContact, writeEnvValue } = require('../core/allowlist-config');
 const { getProductMode } = require('../core/product-mode');
+const { getRelayDaemonStatus } = require('../core/relay-daemon');
 
 const PROJECT_ROOT = WORKSPACE_ROOT;
 const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
@@ -114,8 +117,8 @@ function loadAgentConfig(overrides = {}) {
       claudeProjectId: value('QIWEI_AGENT_PROJECT_ID') || crypto.createHash('sha256').update(PROJECT_ROOT).digest('hex').slice(0, 16),
       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),
+      claudeMaxBudgetUsd: number('CLAUDE_CODE_MAX_BUDGET_USD', 1, 0.05, 5),
+      claudeRetryMaxBudgetUsd: number('CLAUDE_CODE_RETRY_MAX_BUDGET_USD', 3, 0.1, 5),
       claudeBare: bool('CLAUDE_CODE_BARE', true),
       claudeEffort: value('CLAUDE_CODE_EFFORT', 'low'),
       claudeTools: value('CLAUDE_CODE_ALLOWED_TOOLS', 'Read,Glob,Grep'),
@@ -139,12 +142,19 @@ function loadAgentConfig(overrides = {}) {
       responseReminderMinutes: number('QIWEI_RESPONSE_REMINDER_MINUTES', 15, 1, 1440),
       responseUrgentMinutes: number('QIWEI_RESPONSE_URGENT_MINUTES', 60, 1, 10080),
     },
+    voice: {
+      endpoint: value('QIWEI_TTS_ENDPOINT', 'https://www.yuntts.com/api/v1/indextts2_infer'),
+      apiKey: value('QIWEI_TTS_API_KEY'),
+      model: value('QIWEI_TTS_MODEL', 'IndexTeam/IndexTTS-2'),
+      requestTimeoutMs: number('QIWEI_TTS_TIMEOUT_MS', 180000, 15000, 300000),
+    },
   };
   const config = {
     ...baseConfig,
     ...overrides,
     agent: { ...baseConfig.agent, ...(overrides.agent || {}) },
     qiwei: { ...baseConfig.qiwei, ...(overrides.qiwei || {}) },
+    voice: { ...baseConfig.voice, ...(overrides.voice || {}) },
   };
   config.agent.claudeAddDirs = [config.knowledgeDir, config.propertyDataFile ? path.dirname(config.propertyDataFile) : ''].filter(Boolean);
   return config;
@@ -298,35 +308,33 @@ 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;
+    return ingestMessageForWorkbench({ config: this.config, db: this.db, service: this.service }, message, 'polling');
+  }
+}
+
+async function ingestMessageForWorkbench(target, message = {}, source = 'callback') {
+  if (isGroupMessage(message)) {
+    try { return await groupAgentService.ingestPolledMessage(message, target.config); }
+    catch (error) {
+      target.db.audit({ actor: 'agent', action: 'group_agent_failed', detail: { roomId: roomIdOf(message), source, message: error.message } });
+      throw error;
     }
-    const candidate = evaluatePolledMessage(message, this.config);
-    if (!candidate.eligible) return;
-    const { content, senderId, timestamp } = candidate;
-    await this.service.ingestInbound({
-      externalId: String(message.msgServerId || message.msgUniqueIdentifier || `${senderId}:${message.seq}`),
-      contactId: senderId,
-      contactName: message.senderName || '王刚',
-      content,
-      timestamp: new Date(timestamp * 1000).toISOString(),
-      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,
-      },
-    });
   }
+  const candidate = evaluatePolledMessage(message, target.config);
+  if (!candidate.eligible) return { status: `ignored_${candidate.reason || 'ineligible'}` };
+  const { content, senderId, timestamp } = candidate;
+  return target.service.ingestInbound({
+    externalId: String(message.msgServerId || message.msgUniqueIdentifier || `${senderId}:${message.seq}`),
+    contactId: senderId,
+    contactName: message.senderName || '企微客户',
+    content,
+    timestamp: new Date(timestamp * 1000).toISOString(),
+    raw: {
+      ...message,
+      source,
+      fromRoomId: message.fromRoomId || null,
+    },
+  });
 }
 
 function createWorkbench(overrides = {}) {
@@ -349,11 +357,13 @@ function createWorkbench(overrides = {}) {
     propertyDataFile: config.propertyDataFile,
   });
   backfillPropertyRecommendations(db, knowledge);
+  backfillSentVoiceAudioPaths(db);
   const qiwei = overrides.qiwei || new FmodeQiweiClient(config.qiwei);
+  const voice = overrides.voice || new VoiceCloneService({ config: config.voice, qiwei });
   const agent = overrides.agent || new QiweiAgentRuntime({ config: config.agent, knowledge });
   const service = overrides.service || new AgentWorkbenchService({ db, agent, qiwei, config });
   const poller = overrides.poller || new QiweiAgentPoller({ config: config.qiwei, db, qiwei, service });
-  return { config, db, knowledge, qiwei, agent, service, poller };
+  return { config, db, knowledge, qiwei, voice, agent, service, poller };
 }
 
 function configuredStartupAccount() {
@@ -699,7 +709,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 agentError = ['agent_failed', 'agent_not_configured'].includes(currentAgentOutcome?.action)
+    ? { ...currentAgentOutcome, ...friendlyAgentError(currentAgentOutcome.message) }
+    : null;
   const agentNotice = currentAgentOutcome?.action === 'agent_no_reply_needed' ? currentAgentOutcome : null;
   const rawProfile = detail.profile?.profile || {};
   const { __evidence: profileEvidence = {}, ...profile } = rawProfile;
@@ -715,7 +727,11 @@ function publicConversation(row) {
   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)
-  ).slice(0, 40);
+  ).slice(0, 40).map(item => {
+    const publicDetail = { ...(item.detail || {}) };
+    delete publicDetail.rawMessage;
+    return { ...item, detail_json: undefined, detail: publicDetail };
+  });
   return {
     id: row.id,
     displayName: row.contact_name || '白名单测试联系人',
@@ -724,14 +740,22 @@ function publicConversation(row) {
     mode: row.mode,
     source: 'live',
     claudeSession,
-    messages: displayMessages.map(message => ({
-      id: message.id,
-      role: message.direction === 'inbound' ? 'customer' : message.sender_type,
-      content: message.content,
-      timestamp: message.created_at,
-      status: message.status,
-      source: message.direction === 'inbound' ? 'live' : message.sender_type,
-    })),
+    messages: displayMessages.map(message => {
+      const raw = parseJson(message.raw_json, {});
+      if (Number(raw.msgType) === 16 && raw.audioPath) {
+        raw.audioAvailable = true;
+        delete raw.audioPath;
+      }
+      return {
+        id: message.id,
+        role: message.direction === 'inbound' ? 'customer' : message.sender_type,
+        content: message.content,
+        timestamp: message.created_at,
+        status: message.status,
+        source: message.direction === 'inbound' ? 'live' : message.sender_type,
+        raw,
+      };
+    }),
     analysis: {
       intent: latestDraft?.intent || '',
       intentLabel: latestDraft?.intent || (agentError ? 'Agent 上游不可用' : agentNotice ? '无需回复' : '待 Agent 处理'),
@@ -824,16 +848,23 @@ async function getAgentStatus() {
   const account = await detectAccountStatus();
   const state = workbench.service.state(workbench.poller.status());
   const product = getProductMode();
+  const callback = getRelayDaemonStatus();
+  const ingress = product.mode === 'enterprise'
+    ? { mode: 'server_callback', running: callback.running, durableQueue: true, ...callback }
+    : { mode: 'local_polling', running: state.poller.running, durableQueue: false };
   return {
     status: 'ok',
     data: {
       globalMode: state.global.paused ? 'paused' : state.global.defaultMode,
       global: state.global,
       listener: state.poller,
+      ingress,
+      callback,
       account,
       product,
       agent: state.agent,
       knowledge: workbench.knowledge.stats(),
+      voice: workbench.voice.status(),
       config: {
         allowedSenderCount: state.qiwei.allowlistCount,
         testMode: false,
@@ -852,6 +883,14 @@ async function getAgentStatus() {
   };
 }
 
+async function ingestWebhookMessage(message = {}) {
+  return ingestMessageForWorkbench({
+    config: workbench.config.qiwei,
+    db: workbench.db,
+    service: workbench.service,
+  }, message, 'relay_callback');
+}
+
 async function getAllowlistCandidates() {
   const selectedIds = [...workbench.config.qiwei.allowedSenders];
   const candidates = new Map();
@@ -1288,6 +1327,186 @@ async function manualSend(conversationId, content) {
   return { status: 'ok', assistantMessage: '人工回复已真实发送给白名单测试联系人', data: { message } };
 }
 
+function getVoiceStatus() {
+  return { status: 'ok', data: workbench.voice.status() };
+}
+
+async function enrollVoice(input = {}) {
+  const result = await workbench.voice.enroll({
+    filePath: input.filePath,
+    originalName: input.originalName,
+    mime: input.mime,
+  });
+  workbench.db.audit({
+    actor: 'human',
+    action: 'voice_profile_enrolled',
+    detail: { duration: result.profile?.duration, sampleRate: result.profile?.sampleRate },
+  });
+  return {
+    status: 'ok',
+    assistantMessage: '本人声音已初始化,可以生成企微语音',
+    data: workbench.voice.status(),
+  };
+}
+
+function revokeVoiceProfile() {
+  const result = workbench.voice.revoke();
+  workbench.db.audit({ actor: 'human', action: 'voice_profile_revoked', detail: {} });
+  return { status: 'ok', assistantMessage: '声音档案已删除', data: result };
+}
+
+function voiceConversationContext(conversationId) {
+  const conversation = workbench.db.getConversation(conversationId);
+  if (!conversation) throw new Error('会话不存在');
+  workbench.service.requireAllowed(conversation.contact_id);
+  workbench.service.requirePrivateConversation(conversation.id);
+  const inbound = workbench.db.getLatestInbound(conversationId);
+  return { conversation, context: inbound?.content || '' };
+}
+
+function pendingVoiceDraft(db, conversationId, draftId = '') {
+  const selectedId = String(draftId || '').trim();
+  const draft = selectedId
+    ? db.getDraft(selectedId)
+    : db.listDrafts({ conversationId, status: 'pending', limit: 1 })[0];
+  if (!draft && selectedId) throw new Error('待审核草稿不存在或已被处理,请刷新后重试');
+  if (!draft) return null;
+  if (draft.conversation_id !== conversationId) throw new Error('待审核草稿与当前会话不匹配');
+  if (draft.status !== 'pending') throw new Error('待审核草稿已被处理,请刷新后重试');
+  return draft;
+}
+
+function markVoiceDraftSent(db, draft, { content, messageId, actor = 'human:voice' }) {
+  if (!draft) return null;
+  return db.updateDraft(draft.id, {
+    content,
+    status: 'sent',
+    reviewed_at: new Date().toISOString(),
+    reviewer: actor,
+    sent_message_id: messageId,
+    error: null,
+  });
+}
+
+async function sendClonedVoice(conversationId, input = {}) {
+  const { conversation, context } = voiceConversationContext(conversationId);
+  const content = String(input.content || '').trim();
+  const draft = pendingVoiceDraft(workbench.db, conversationId, input.draftId);
+  const result = await workbench.voice.synthesizeAndSend({
+    text: content,
+    context,
+    tone: input.tone || 'auto',
+    toId: conversation.contact_id,
+    confirmed: input.confirmed,
+  });
+  if (result.sendResult?.isSendSuccess === false || Number(result.sendResult?.isSendSuccess) === 0) {
+    throw new Error('企微未确认语音发送成功');
+  }
+  const message = workbench.db.insertMessage({
+    conversationId,
+    direction: 'outbound',
+    senderType: 'human',
+    content: `[语音 · ${result.tone.label}] ${content}`,
+    status: 'sent',
+    raw: {
+      msgType: 16,
+      tone: result.tone,
+      duration: result.duration,
+      fileSize: result.fileSize,
+      audioPath: result.audioPath,
+      sendResult: result.sendResult,
+    },
+  }).message;
+  const resolvedDraft = markVoiceDraftSent(workbench.db, draft, { content, messageId: message.id });
+  workbench.db.audit({
+    actor: 'human',
+    action: 'cloned_voice_sent',
+    conversationId,
+    entityId: message.id,
+    detail: {
+      tone: result.tone.id,
+      automatic: result.tone.automatic,
+      duration: result.duration,
+      msgServerId: result.sendResult?.msgServerId,
+      draftId: resolvedDraft?.id || null,
+    },
+  });
+  const publicMessage = { ...message };
+  delete publicMessage.raw_json;
+  return {
+    status: 'ok',
+    assistantMessage: `已用${result.tone.label}语气发送企微语音`,
+    data: { message: publicMessage, draft: resolvedDraft, tone: result.tone, duration: result.duration, sendResult: result.sendResult },
+  };
+}
+
+function sentVoiceRuns(voiceRoot = categoryDir('voice')) {
+  const runs = [];
+  if (!fs.existsSync(voiceRoot)) return runs;
+  for (const dateEntry of fs.readdirSync(voiceRoot, { withFileTypes: true })) {
+    if (!dateEntry.isDirectory() || !/^\d{4}-\d{2}-\d{2}$/.test(dateEntry.name)) continue;
+    const dateDir = path.join(voiceRoot, dateEntry.name);
+    for (const runEntry of fs.readdirSync(dateDir, { withFileTypes: true })) {
+      if (!runEntry.isDirectory() || !runEntry.name.includes('-clone')) continue;
+      const runDir = path.join(dateDir, runEntry.name);
+      const manifestPath = path.join(runDir, 'manifest.json');
+      const audioPath = path.join(runDir, 'speech.wav');
+      if (!fs.existsSync(manifestPath) || !fs.existsSync(audioPath)) continue;
+      const manifest = parseJson(fs.readFileSync(manifestPath, 'utf8'), {});
+      if (manifest.type !== 'voice-clone' || manifest.outcome === 'failed') continue;
+      const createdAt = Date.parse(manifest.createdAt);
+      if (!Number.isFinite(createdAt)) continue;
+      const textHash = String(manifest.textSha256 || (manifest.text
+        ? crypto.createHash('sha256').update(String(manifest.text)).digest('hex')
+        : ''));
+      if (!textHash) continue;
+      runs.push({
+        audioPath,
+        createdAt,
+        duration: Number(manifest.audio?.duration || manifest.duration) || 0,
+        textHash,
+      });
+    }
+  }
+  return runs;
+}
+
+function backfillSentVoiceAudioPaths(db, options = {}) {
+  const available = sentVoiceRuns(options.voiceRoot).map(item => ({ ...item, used: false }));
+  if (!available.length) return 0;
+  let updated = 0;
+  for (const conversation of db.listConversations()) {
+    for (const message of db.listMessages(conversation.id, 1000)) {
+      const raw = parseJson(message.raw_json, {});
+      if (message.direction !== 'outbound' || message.status !== 'sent'
+        || Number(raw.msgType) !== 16 || raw.audioPath) continue;
+      const spokenText = String(message.content || '').replace(/^\[语音[^\]]*\]\s*/, '');
+      const textHash = crypto.createHash('sha256').update(spokenText).digest('hex');
+      const sentAt = Date.parse(message.created_at);
+      const match = available
+        .filter(item => !item.used && item.textHash === textHash
+          && Math.abs(item.createdAt - sentAt) <= 10 * 60 * 1000
+          && (!item.duration || !raw.duration || Math.abs(item.duration - Number(raw.duration)) <= 1.5))
+        .sort((a, b) => Math.abs(a.createdAt - sentAt) - Math.abs(b.createdAt - sentAt))[0];
+      if (!match) continue;
+      match.used = true;
+      db.updateMessageRaw(message.id, { ...raw, audioPath: match.audioPath });
+      updated += 1;
+    }
+  }
+  return updated;
+}
+
+function getSentVoiceAudio(messageId) {
+  const message = workbench.db.getMessage(String(messageId || ''));
+  const raw = parseJson(message?.raw_json, {});
+  if (!message || message.direction !== 'outbound' || message.status !== 'sent'
+    || Number(raw.msgType) !== 16 || !raw.audioPath) {
+    throw new Error('已发送语音不存在或不可播放');
+  }
+  return { filePath: raw.audioPath, duration: Number(raw.duration) || 0 };
+}
+
 async function updateCustomerTask(taskId, input = {}) {
   if (!['open', 'in_progress', 'done', 'dismissed'].includes(String(input.status || ''))) throw new Error('不支持的客户待办状态');
   const existing = workbench.db.getCustomerTask(taskId);
@@ -1387,13 +1606,19 @@ module.exports = {
   regenerateDraft,
   generateLatestDraft,
   manualSend,
+  getVoiceStatus,
+  enrollVoice,
+  revokeVoiceProfile,
+  sendClonedVoice,
+  getSentVoiceAudio,
   updateCustomerTask,
   syncCustomerTaskToOfficialTodo,
   updateCustomerAlert,
   getAudit,
+  ingestWebhookMessage,
   startListener,
   stopListener,
   getAgentRuntimeConfig,
   createWorkbench,
-  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage, groupAgentService, startListenerForWorkbench },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, detectedPropertiesInMessage, groupAgentService, startListenerForWorkbench },
 };

+ 643 - 130
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js

@@ -65,7 +65,7 @@
     preRecoveryPage: null,
     selectedGroupIds: new Set(),
     customerOps: { customers: [], defaultGreeting: '您好 {{name}},我是您的专属顾问,方便加您企业微信沟通。', rateLimit: 12, maxAttempts: 2, lastResult: null, master: { hub: null, selectedId: null, keyword: '', loading: false }, ...loadFilters(CO_FILTERS_KEY, { tablePage: 1, keyword: '', status: '', portrait: '', tag: '' }) },
-    portraits: { lastPortrait: null, lastTags: [], personalLabels: [], lastTransferPreview: null, ...loadFilters(PT_FILTERS_KEY, { tablePage: 1, keyword: '', source: '', tag: '' }) },
+    portraits: { lastPortrait: null, lastTags: [], personalLabels: [], personalLabelsLoaded: false, directoryCustomers: [], dashboardSnapshot: null, loadingPromise: null, lastTransferPreview: null, ...loadFilters(PT_FILTERS_KEY, { tablePage: 1, keyword: '', source: '', tag: '' }) },
     agent: {
       status: initialAgentSnapshot.status || null,
       conversations: Array.isArray(initialAgentSnapshot.conversations) ? initialAgentSnapshot.conversations : [],
@@ -79,11 +79,14 @@
       signature: initialAgentSnapshot.signature || null,
       replyEdits: {},
       groupReplyEdits: {},
+      voiceTone: 'auto',
+      renderPending: false,
+      insightInteractionUntil: 0,
       cachedAccountKey: initialAgentSnapshot.accountKey || null
     },
     skills: { registry: null, selectedPackageId: null, selectedSkillId: null, keyword: '' },
     knowledge: { tree: null, selectedNodeId: null, file: null, properties: null, selectedProperty: null, expandedFolderIds: new Set(), foldersInitialized: false, filters: { q: '', district: '', layout: '', decoration: '', maxPrice: '' }, meetings: { hub: null, selectedId: null, loading: false }, documents: { hub: null, selectedId: null, loading: false }, todos: { hub: null, selectedId: null, loading: false, userResults: [] }, tasks: { hub: null, loading: false, filters: { q: '', source: '', status: 'active' } } },
-    transfers: { lastTransferPreview: null, candidates: [], groups: [], ...loadFilters(TF_FILTERS_KEY, { tablePage: 1, status: '', user: '' }) }
+    transfers: { lastTransferPreview: null, candidates: [], groups: [], dashboardSnapshot: null, loadingPromise: null, ...loadFilters(TF_FILTERS_KEY, { tablePage: 1, status: '', user: '' }) }
   };
 
   const els = {
@@ -192,6 +195,9 @@
         state.agent.signature = null;
         state.agent.replyEdits = {};
         state.agent.groupReplyEdits = {};
+        state.agent.voiceTone = 'auto';
+        state.agent.renderPending = false;
+        state.agent.insightInteractionUntil = 0;
         state.agent.cachedAccountKey = null;
         try { sessionStorage.removeItem(AGENT_SNAPSHOT_KEY); } catch {}
       }
@@ -382,6 +388,7 @@
         <div class="business-modal-fields">${fields.map(businessModalField).join('')}</div>
         ${shortcuts.length ? `<div class="business-modal-shortcuts"><span>${escapeHtml(options.shortcutLabel || '快捷选择')}</span><div>${shortcuts.map((shortcut, index) => `<button type="button" data-business-shortcut="${index}">${escapeHtml(shortcut.label)}</button>`).join('')}</div></div>` : ''}
         ${options.warning ? `<div class="business-modal-live-warning"><i>!</i><div><strong>${escapeHtml(options.warningTitle || '真实业务操作')}</strong><span>${escapeHtml(options.warning)}</span></div></div>` : ''}
+        ${options.dontAskAgainLabel ? `<label class="business-modal-dont-ask"><input type="checkbox" name="dontAskAgain" value="1" /><span>${escapeHtml(options.dontAskAgainLabel)}</span></label>` : ''}
         <p class="business-modal-error" data-business-error role="alert" hidden></p>
         <footer class="business-modal-actions">${options.cancelText !== null ? `<button type="button" class="btn btn-secondary" data-business-cancel>${escapeHtml(options.cancelText || '取消')}</button>` : ''}<button type="submit" class="btn business-modal-confirm ${options.danger ? 'danger' : ''}" data-business-primary>${escapeHtml(options.confirmText || '确认')}</button></footer>
       </form>
@@ -471,7 +478,19 @@
     });
   }
 
+  const CONFIRM_DISMISS_PREFIX = 'qiwei:confirm-dismiss:';
+
+  function isConfirmDismissed(key) {
+    try { return localStorage.getItem(`${CONFIRM_DISMISS_PREFIX}${key}`) === '1'; } catch { return false; }
+  }
+
+  function dismissConfirm(key) {
+    try { localStorage.setItem(`${CONFIRM_DISMISS_PREFIX}${key}`, '1'); } catch {}
+  }
+
   function confirmModal(message, options = {}) {
+    const dismissKey = String(options.dismissKey || '').trim();
+    if (dismissKey && isConfirmDismissed(dismissKey)) return Promise.resolve(true);
     return showBusinessModal({
       title: options.title || '确认操作',
       description: message,
@@ -480,7 +499,12 @@
       warning: options.warning,
       warningTitle: options.warningTitle,
       danger: options.danger,
-    }).then(result => result !== null);
+      dontAskAgainLabel: dismissKey ? (options.dismissLabel || '以后不再提示') : null,
+    }).then(result => {
+      if (result === null) return false;
+      if (dismissKey && result.dontAskAgain) dismissConfirm(dismissKey);
+      return true;
+    });
   }
 
   function alertModal(message, options = {}) {
@@ -1328,7 +1352,7 @@
     const highAlerts = conversations.reduce((sum, item) => sum + Number(item.customerIntelligence?.summary?.highAlerts || 0), 0);
     const actionCount = pendingReplies + Number(group.pendingReview || 0) + Number(group.highRisk || 0) + openCustomerTasks;
     const connectionOnline = Boolean(agent.account?.online || currentAccount()?.online);
-    const listenerRunning = Boolean(agent.listener?.running);
+    const listenerRunning = Boolean(agent.ingress?.running ?? agent.listener?.running);
     const healthScore = group.averageHealthScore;
     const query = state.overview.query || '';
 
@@ -2387,9 +2411,23 @@
   function renderAgentMessage(message) {
     const roleLabel = message.senderName || (message.role === 'customer' ? '客户' : message.role === 'human' ? '人工客服' : 'Agent 建议');
     const statusLabel = message.status === 'pending' ? '待确认' : message.status === 'sending' ? '发送中' : message.status === 'sent' ? '已发送' : message.status === 'submitted' ? '已提交企微' : '';
-    return `<div class="agent-message ${escapeHtml(message.role)}">
+    const voiceMeta = message.raw?.msgType === 16 ? message.raw : null;
+    const voiceAudio = voiceMeta?.audioAvailable && message.id ? `/api/agent/messages/${encodeURIComponent(message.id)}/voice-audio` : null;
+    const voiceTranscript = voiceMeta ? String(message.content || '').replace(/^\[语音[^\]]*\]\s*/, '') : '';
+    const transcriptId = voiceMeta && message.id ? `voice-transcript-${String(message.id).replace(/[^a-zA-Z0-9_-]/g, '')}` : '';
+    const voiceDuration = Math.max(1, Number(voiceMeta?.duration) || 1);
+    const voiceBubbleWidth = Math.min(232, Math.max(118, Math.round(112 + voiceDuration * 2)));
+    return `<div class="agent-message ${escapeHtml(message.role)}" data-message-id="${escapeHtml(message.id || '')}">
       <div class="agent-message-meta"><span>${roleLabel}</span><span>${formatAgentTime(message.timestamp)}${statusLabel ? ` · ${statusLabel}` : ''}</span></div>
-      <div class="agent-message-body">${escapeHtml(message.content).replace(/\n/g, '<br>')}</div>
+      ${voiceAudio ? `<div class="agent-message-voice-row">
+        <button type="button" class="agent-voice-transcript-toggle" data-voice-transcript-toggle aria-expanded="false" aria-controls="${escapeHtml(transcriptId)}">${voiceIcon('text')}<span>转文字</span></button>
+        <button type="button" class="agent-voice-bubble" data-voice-play aria-pressed="false" style="--voice-bubble-width:${voiceBubbleWidth}px" aria-label="播放语音,时长 ${Math.ceil(voiceDuration)} 秒">
+          <span class="agent-voice-play-icon" aria-hidden="true">${voiceIcon('volume')}</span>
+          <span class="agent-voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
+          <span class="agent-voice-duration">${Math.ceil(voiceDuration)}&Prime;</span>
+          <audio class="agent-voice-audio" src="${escapeHtml(voiceAudio)}" preload="metadata"></audio>
+        </button>
+      </div><div class="agent-voice-transcript" id="${escapeHtml(transcriptId)}" hidden>${escapeHtml(voiceTranscript).replace(/\n/g, '<br>')}</div>` : `<div class="agent-message-body">${escapeHtml(message.content).replace(/\n/g, '<br>')}</div>`}
     </div>`;
   }
 
@@ -2586,6 +2624,109 @@
     });
   }
 
+  function captureAgentWorkspaceScroll(page) {
+    const list = page.querySelector('.agent-list-scroll');
+    const stream = page.querySelector('.agent-message-stream');
+    return {
+      viewKey: page.dataset.agentViewKey || '',
+      listTop: list?.scrollTop || 0,
+      messageTop: stream?.scrollTop || 0,
+      messageAtBottom: stream
+        ? stream.scrollHeight - stream.scrollTop - stream.clientHeight <= 48
+        : true,
+    };
+  }
+
+  function restoreAgentWorkspaceScroll(page, snapshot, viewKey, incremental) {
+    if (!incremental || !snapshot || snapshot.viewKey !== viewKey) {
+      scrollAgentMessagesToLatest(page);
+      return;
+    }
+    requestAnimationFrame(() => {
+      const list = page.querySelector('.agent-list-scroll');
+      const stream = page.querySelector('.agent-message-stream');
+      if (list) list.scrollTop = Math.min(snapshot.listTop, Math.max(0, list.scrollHeight - list.clientHeight));
+      if (!stream) return;
+      stream.scrollTop = snapshot.messageAtBottom
+        ? stream.scrollHeight
+        : Math.min(snapshot.messageTop, Math.max(0, stream.scrollHeight - stream.clientHeight));
+    });
+  }
+
+  function agentDomNodeKey(node) {
+    if (!node || node.nodeType !== Node.ELEMENT_NODE) return '';
+    for (const attribute of ['id', 'data-message-id', 'data-conversation-id', 'data-group-monitor-id']) {
+      const value = node.getAttribute(attribute);
+      if (value) return `${node.tagName}:${attribute}:${value}`;
+    }
+    return '';
+  }
+
+  function agentDomNodesCompatible(current, next) {
+    return current?.nodeType === next?.nodeType
+      && (current.nodeType !== Node.ELEMENT_NODE || current.tagName === next.tagName);
+  }
+
+  function syncAgentDomAttributes(current, next) {
+    for (const attribute of [...current.attributes]) {
+      if (!next.hasAttribute(attribute.name)) current.removeAttribute(attribute.name);
+    }
+    for (const attribute of [...next.attributes]) {
+      if (current.getAttribute(attribute.name) !== attribute.value) current.setAttribute(attribute.name, attribute.value);
+    }
+    if (current instanceof HTMLTextAreaElement && document.activeElement !== current && current.value !== next.value) {
+      current.value = next.value;
+    }
+    if (current instanceof HTMLInputElement && document.activeElement !== current && current.value !== next.value) {
+      current.value = next.value;
+    }
+  }
+
+  function patchAgentDomNode(current, next) {
+    if (!agentDomNodesCompatible(current, next)) {
+      current.replaceWith(next.cloneNode(true));
+      return;
+    }
+    if (current.nodeType === Node.TEXT_NODE || current.nodeType === Node.COMMENT_NODE) {
+      if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
+      return;
+    }
+
+    syncAgentDomAttributes(current, next);
+    let cursor = current.firstChild;
+    for (const nextChild of [...next.childNodes]) {
+      const nextKey = agentDomNodeKey(nextChild);
+      let match = cursor;
+      if (nextKey) {
+        if (agentDomNodeKey(match) !== nextKey) {
+          match = [...current.childNodes].find(candidate => agentDomNodeKey(candidate) === nextKey) || null;
+        }
+      } else if (!agentDomNodesCompatible(match, nextChild) || agentDomNodeKey(match)) {
+        match = null;
+      }
+
+      if (!match) {
+        match = nextChild.cloneNode(true);
+        current.insertBefore(match, cursor);
+      } else if (match !== cursor) {
+        current.insertBefore(match, cursor);
+      }
+      patchAgentDomNode(match, nextChild);
+      cursor = match.nextSibling;
+    }
+    while (cursor) {
+      const nextSibling = cursor.nextSibling;
+      cursor.remove();
+      cursor = nextSibling;
+    }
+  }
+
+  function patchAgentPage(page, html) {
+    const nextPage = page.cloneNode(false);
+    nextPage.innerHTML = html;
+    patchAgentDomNode(page, nextPage);
+  }
+
   function captureAgentInsightScroll(page) {
     const panel = page.querySelector('.agent-insight-panel');
     if (!panel) return null;
@@ -2728,6 +2869,7 @@
   function renderAgentWorkspace(page) {
     const status = state.agent.status || {};
     const listener = status.listener || {};
+    const ingress = status.ingress || {};
     const account = status.account || {};
     const config = status.config || {};
     const accountName = currentAccount()?.nickname || account.nickname || account.userId || '当前企微账号';
@@ -2832,10 +2974,188 @@
     scrollAgentMessagesToLatest(page);
   }
 
+  function voiceIcon(name) {
+    const paths = {
+      mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3"/>',
+      play: '<path d="m6 3 14 9-14 9V3Z"/>',
+      volume: '<path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18 6a8.5 8.5 0 0 1 0 12"/>',
+      text: '<path d="M4 5h16M8 10h8M8 15h8M6 20h12"/>',
+      upload: '<path d="M12 16V4m0 0L7 9m5-5 5 5"/><path d="M20 16v4H4v-4"/>',
+      trash: '<path d="M3 6h18M8 6V4h8v2m-9 0 1 15h8l1-15M10 11v5m4-5v5"/>',
+      stop: '<rect x="6" y="6" width="12" height="12" rx="1"/>',
+    };
+    return `<svg class="voice-icon" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths[name] || paths.mic}</svg>`;
+  }
+
+  function fileToBase64(file) {
+    return new Promise((resolve, reject) => {
+      const reader = new FileReader();
+      reader.onload = () => resolve(String(reader.result || '').split(',').pop() || '');
+      reader.onerror = () => reject(reader.error || new Error('读取音频失败'));
+      reader.readAsDataURL(file);
+    });
+  }
+
+  async function openVoiceProfileModal(page) {
+    const voice = state.agent.status?.voice || {};
+    const profile = voice.profile || {};
+    openModal('本人声音', `
+      <div class="voice-profile-modal-body">
+        <div class="voice-profile-status ${voice.enrolled ? 'ready' : ''}">
+          <span class="voice-profile-dot"></span>
+          <div><strong>${voice.enrolled ? '声音已初始化' : '尚未初始化声音'}</strong><small>${voice.enrolled ? `${Number(profile.duration || 0).toFixed(1)} 秒 · ${Number(profile.sampleRate || 0) / 1000}kHz · ${escapeHtml(profile.originalName || '参考录音')}` : '支持 5~30 秒音频'}</small></div>
+        </div>
+        <div class="voice-capture-row">
+          <button type="button" class="voice-capture-button" data-voice-record>${voiceIcon('mic')}<span>开始录音</span></button>
+          <button type="button" class="voice-capture-button" data-voice-stop disabled>${voiceIcon('stop')}<span>停止</span></button>
+          <button type="button" class="voice-capture-button" data-voice-upload>${voiceIcon('upload')}<span>选择音频</span></button>
+          <input type="file" accept="audio/*,.m4a" data-voice-file hidden />
+        </div>
+        <div class="voice-selected-file" data-voice-selected>未选择新录音</div>
+        <audio class="voice-profile-preview" data-voice-audio controls hidden></audio>
+      </div>
+    `, `
+      <button type="button" class="btn btn-secondary" data-voice-cancel>取消</button>
+      ${voice.enrolled ? `<button type="button" class="btn btn-secondary voice-delete-button" data-voice-delete>${voiceIcon('trash')}删除声音</button>` : ''}
+      <button type="button" class="btn" data-voice-save disabled>保存声音</button>
+    `, 'voice-profile-modal');
+    const modal = state.modal;
+    const fileInput = modal.querySelector('[data-voice-file]');
+    const recordButton = modal.querySelector('[data-voice-record]');
+    const stopButton = modal.querySelector('[data-voice-stop]');
+    const uploadButton = modal.querySelector('[data-voice-upload]');
+    const saveButton = modal.querySelector('[data-voice-save]');
+    const selectedLabel = modal.querySelector('[data-voice-selected]');
+    const audio = modal.querySelector('[data-voice-audio]');
+    let selectedFile = null;
+    let stream = null;
+    let recorder = null;
+    let chunks = [];
+    let previewUrl = '';
+
+    const cleanupMedia = () => {
+      if (recorder?.state === 'recording') recorder.stop();
+      stream?.getTracks().forEach(track => track.stop());
+      if (previewUrl) URL.revokeObjectURL(previewUrl);
+    };
+    const originalCleanup = modal.__modalCleanup;
+    modal.__modalCleanup = () => { originalCleanup?.(); cleanupMedia(); };
+
+    const selectFile = file => {
+      if (!file) return;
+      selectedFile = file;
+      if (previewUrl) URL.revokeObjectURL(previewUrl);
+      previewUrl = URL.createObjectURL(file);
+      audio.src = previewUrl;
+      audio.hidden = false;
+      selectedLabel.textContent = `${file.name} · ${(file.size / 1024).toFixed(0)}KB`;
+      saveButton.disabled = false;
+    };
+
+    uploadButton.addEventListener('click', () => fileInput.click());
+    fileInput.addEventListener('change', () => selectFile(fileInput.files?.[0]));
+    if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
+      recordButton.disabled = true;
+      recordButton.title = '当前浏览器不支持录音';
+    }
+    recordButton.addEventListener('click', async () => {
+      try {
+        stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
+        const preferred = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'].find(type => MediaRecorder.isTypeSupported(type));
+        recorder = preferred ? new MediaRecorder(stream, { mimeType: preferred }) : new MediaRecorder(stream);
+        chunks = [];
+        recorder.ondataavailable = event => { if (event.data.size) chunks.push(event.data); };
+        recorder.onstop = () => {
+          const type = recorder.mimeType || 'audio/webm';
+          const ext = type.includes('mp4') ? 'm4a' : 'webm';
+          selectFile(new File(chunks, `voice-reference.${ext}`, { type }));
+          stream?.getTracks().forEach(track => track.stop());
+          stream = null;
+          recordButton.disabled = false;
+          stopButton.disabled = true;
+          recordButton.classList.remove('recording');
+        };
+        recorder.start();
+        recordButton.disabled = true;
+        stopButton.disabled = false;
+        recordButton.classList.add('recording');
+        selectedLabel.textContent = '正在录音…';
+      } catch (error) {
+        toast(error.message || '无法使用麦克风', 'error');
+      }
+    });
+    stopButton.addEventListener('click', () => recorder?.state === 'recording' && recorder.stop());
+    modal.querySelector('[data-voice-cancel]').addEventListener('click', closeModal);
+    modal.querySelector('[data-voice-delete]')?.addEventListener('click', async event => {
+      if (!await confirmModal('确认删除当前账号保存的声音档案?', { title: '删除声音', confirmText: '确认删除', warning: '删除后无法继续生成克隆语音。', danger: true })) return;
+      event.currentTarget.disabled = true;
+      try {
+        const result = await api('DELETE', '/api/agent/voice/profile');
+        state.agent.status.voice = { ...voice, ...(result.data || {}), enrolled: false, profile: null };
+        closeModal();
+        toast(result.assistantMessage || '声音档案已删除');
+        renderAgentWorkspaceV2(page);
+      } catch (error) {
+        event.currentTarget.disabled = false;
+        toast(error.message || '删除声音失败', 'error');
+      }
+    });
+    saveButton.addEventListener('click', async () => {
+      if (!selectedFile) return;
+      saveButton.disabled = true;
+      saveButton.textContent = '正在处理…';
+      try {
+        const result = await api('POST', '/api/agent/voice/profile', {
+          name: selectedFile.name,
+          type: selectedFile.type,
+          data: await fileToBase64(selectedFile),
+        }, 120000);
+        state.agent.status.voice = result.data || {};
+        closeModal();
+        toast(result.assistantMessage || '声音已初始化');
+        renderAgentWorkspaceV2(page);
+      } catch (error) {
+        saveButton.disabled = false;
+        saveButton.textContent = '保存声音';
+        toast(error.message || '声音初始化失败', 'error');
+      }
+    });
+  }
+
+  function voiceComposerToolbar({ selected, content, draftId, disabled }) {
+    const voice = state.agent.status?.voice || {};
+    const tones = voice.tones || [];
+    const profileLabel = voice.enrolled ? '声音设置' : '初始化声音';
+    const voiceContentDisabled = disabled || !voice.enrolled || !content;
+    const sendDisabled = voiceContentDisabled || !voice.sendConfigured;
+    return `<div class="voice-composer-toolbar">
+      <button type="button" class="voice-profile-button" data-agent-action="voice-profile" data-id="${escapeHtml(selected?.id || '')}">${voiceIcon('mic')}<span>${profileLabel}</span><i class="${voice.enrolled ? 'ready' : ''}"></i></button>
+      <label class="voice-tone-select"><span>语气</span><select id="agent-voice-tone" aria-label="语音语气" ${disabled ? 'disabled' : ''}><option value="auto" ${state.agent.voiceTone === 'auto' ? 'selected' : ''}>自动识别</option>${tones.map(item => `<option value="${escapeHtml(item.id)}" ${state.agent.voiceTone === item.id ? 'selected' : ''}>${escapeHtml(item.label)}</option>`).join('')}</select></label>
+      <div class="voice-composer-actions">
+        <button type="button" class="btn voice-action-button" data-agent-action="voice-send" data-id="${escapeHtml(selected?.id || '')}" data-draft-id="${escapeHtml(draftId || '')}" ${sendDisabled ? 'disabled' : ''} ${!voice.sendConfigured ? 'title="媒体上传未配置"' : ''}>${voiceIcon('mic')}语音发送</button>
+      </div>
+    </div>`;
+  }
+
+  function agentErrorPresentation(error = {}) {
+    return ({
+      quota_exhausted: { title: '模型额度不足', next: '恢复额度后,可点击“初始化 Session”或“让 Agent 处理”重试。' },
+      task_budget_exceeded: { title: '单次任务预算不足', next: '请提高 Claude Code 单次任务预算后重试。' },
+      authentication_failed: { title: 'Claude Code 登录失效', next: '请重新登录或检查模型凭据后重试。' },
+      permission_denied: { title: 'Claude Code 权限不足', next: '请检查当前账号与模型权限。' },
+      not_configured: { title: 'Claude Code 尚未配置', next: '请先在“连接与设置”中完成模型配置。' },
+      cli_not_found: { title: '未找到 Claude Code CLI', next: '请先完成 CLI 安装或配置可执行文件路径。' },
+      timeout: { title: 'Claude Code 运行超时', next: '请稍后重试,必要时缩短当前会话上下文。' },
+      network_error: { title: 'Claude Code 网络异常', next: '请检查网络连接后重试。' },
+    })[error.code] || { title: 'Claude Code 暂时不可用', next: '请稍后重试;详细原因已记录在审计日志中。' };
+  }
+
   function renderAgentWorkspaceV2(page) {
     const insightScroll = captureAgentInsightScroll(page);
+    const workspaceScroll = captureAgentWorkspaceScroll(page);
     const status = state.agent.status || {};
     const listener = status.listener || {};
+    const ingress = status.ingress || {};
     const account = status.account || {};
     const config = status.config || {};
     const product = status.product || { mode: 'personal', label: '个人版', collectionLabel: '本地主动监听', relayReady: false };
@@ -2844,6 +3164,7 @@
     const globalPaused = global.paused !== undefined ? Boolean(global.paused) : status.globalMode === 'paused';
     const globalMode = global.defaultMode || (status.globalMode === 'auto' ? 'auto' : 'review');
     const listenerRunning = Boolean(listener.running);
+    const messageIngressRunning = product.mode === 'enterprise' ? Boolean(ingress.running) : listenerRunning;
     const allowlistCount = Number(config.allowedSenderCount) || 0;
     const allowlistReady = allowlistCount > 0;
     const agent = status.agent || {};
@@ -2891,7 +3212,7 @@
     els.accountName.textContent = accountName;
     els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
 
-    page.innerHTML = `
+    const nextHtml = `
       <section class="agent-hero">
         <div>
           <div class="agent-eyebrow">WECOM · REAL AGENT · HUMAN IN THE LOOP</div>
@@ -2900,17 +3221,19 @@
         </div>
         <div class="agent-safety-card">
           <span class="agent-live-dot ${account.online ? 'online' : ''}"></span>
-          <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(product.label)} · ${escapeHtml(product.collectionLabel)} · ${listenerRunning ? '消息处理中' : '处理已停止'}</small></div>
+          <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(product.label)} · ${escapeHtml(product.collectionLabel)} · ${messageIngressRunning ? '回调接收中' : '回调待恢复'}</small></div>
           <div class="agent-safety-actions">
             <button class="btn btn-secondary" data-agent-action="manage-allowlist">管理白名单</button>
             <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采最近消息</button>
-            <button class="btn ${listenerRunning ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (listenerRunning ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (listenerRunning ? '关闭 AI 监听(转人工)' : '启动 AI 监听') : '先选择白名单'}</button>
+            ${product.mode === 'personal'
+              ? `<button class="btn ${listenerRunning ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (listenerRunning ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (listenerRunning ? '关闭 AI 监听(转人工)' : '启动 AI 监听') : '先选择白名单'}</button>`
+              : ''}
           </div>
         </div>
       </section>
 
       <section class="agent-modebar">
-        <div class="agent-mode-copy"><strong>全局 Agent 策略</strong><span>${escapeHtml(product.label)} · ${product.mode === 'enterprise' ? (product.relayReady ? '企业 Relay 已接入' : '企业 Relay 待接入') : '数据保存在当前项目'} · 暂停时仍可收消息</span></div>
+        <div class="agent-mode-copy"><strong>全局 Agent 策略</strong><span>${escapeHtml(product.label)} · ${product.mode === 'enterprise' ? (messageIngressRunning ? '公网回调常驻' : '公网回调正在恢复') : '数据保存在当前项目'} · 暂停时仍接收并保存消息</span></div>
         <div class="agent-mode-switch">
           <button class="agent-mode-btn ${!globalPaused && globalMode === 'review' ? 'active' : ''}" data-agent-mode="review">待审核</button>
           <button class="agent-mode-btn ${!globalPaused && globalMode === 'auto' ? 'active' : ''}" data-agent-mode="auto">高置信自动</button>
@@ -2922,7 +3245,7 @@
       ${renderResponseMonitor(state.agent.responseMonitor || {})}
 
       ${selected?.agentError
-        ? `<div class="agent-runtime-alert"><strong>Agent 上游不可用</strong><span>${escapeHtml(selected.agentError.message)}。真实消息已保存,系统没有生成或发送 Mock 回复。</span></div>`
+        ? (() => { const errorUi = agentErrorPresentation(selected.agentError); return `<div class="agent-runtime-alert"><strong>${escapeHtml(errorUi.title)}</strong><span>${escapeHtml(selected.agentError.message)} ${escapeHtml(errorUi.next)} 真实消息已保存,系统没有生成或发送 Mock 回复。</span></div>`; })()
         : noReplyNotice
           ? `<div class="agent-runtime-alert notice"><strong>Agent 已处理</strong><span>${escapeHtml(noReplyNotice.message)},系统没有向客户发送消息。</span></div>`
           : ''}
@@ -2946,7 +3269,7 @@
                 <span class="agent-avatar group">群</span>
                 <span class="agent-conversation-copy"><span class="agent-conversation-name"><strong>${escapeHtml(item.name || '客户群')}</strong><i class="agent-channel-badge group">群聊</i></span><small>${escapeHtml(item.lastMessagePreview || '等待群消息同步')}</small><em class="${escapeHtml(item.status || '')}">${item.mode === 'auto' ? '全自动' : item.pendingReply ? '草稿待审核' : item.agentError ? 'Agent 生成失败' : escapeHtml(agentMonitorStatusLabel(item.status))}${item.waitingMinutes ? ` · ${formatReplyWaiting(item.waitingMinutes)}` : ''}</em></span>
                 <time>${formatAgentTime(item.lastMessageAt)}</time>
-              </button>`).join('') : `<div class="agent-empty"><strong>${listMode === 'group' ? '还没有已确认客户群' : allowlistReady ? '等待第一条真实消息' : '还没有选择白名单'}</strong><span>${listMode === 'group' ? '先在客户群管理中确认需要智能回复的客户群。' : allowlistReady ? '启动监听后,请从已选企微客户发送测试内容。' : '点击上方“管理白名单”,直接从企微联系人中选择。'}</span></div>`}
+              </button>`).join('') : `<div class="agent-empty"><strong>${listMode === 'group' ? '还没有已确认客户群' : allowlistReady ? '等待第一条真实消息' : '还没有选择白名单'}</strong><span>${listMode === 'group' ? '先在客户群管理中确认需要智能回复的客户群。' : allowlistReady ? (product.mode === 'enterprise' ? '公网回调会持续接收消息,请从已选企微客户发送测试内容。' : '启动监听后,请从已选企微客户发送测试内容。') : '点击上方“管理白名单”,直接从企微联系人中选择。'}</span></div>`}
           </div>
         </aside>
 
@@ -2985,12 +3308,13 @@
             <div class="agent-session-strip ${sessionGuide?.ready ? 'ready' : ''}">
               <span class="agent-session-dot"></span>
               <div><strong>${sessionGuide?.ready ? 'Claude Code 客户 Session 已识别' : 'Claude Code 客户 Session 等待首次运行'}</strong><small>${escapeHtml(sessionGuide?.displayName || 'Agent 首次生成草稿后自动创建')} · ${sessionGuide?.ready ? '可安全打开审阅副本' : '不会与其他客户共用上下文'}</small></div>
-              <button class="btn btn-sm btn-secondary" data-agent-action="session-guide" data-id="${selected.id}">${sessionGuide?.ready ? '查看会话与打开方式' : '查看说明'}</button>
+              <button class="btn btn-sm btn-secondary agent-session-action" data-agent-action="session-guide" data-id="${selected.id}">${sessionGuide?.ready ? '查看会话与打开方式' : '初始化 Session'}</button>
             </div>
             <div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
             <div class="agent-composer ${manualActive ? 'manual' : ''}">
               <div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span data-agent-edit-status>${pending ? (hasReplyEdit ? '已人工修改 · 批准后发送当前内容' : `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核,可直接编辑' : '可直接编辑后发送'}`) : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
               <textarea id="agent-reply-editor" data-agent-edit-key="${escapeHtml(replyEditKey)}" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
+              ${voiceComposerToolbar({ selected, content: pendingText, draftId: pending?.id, disabled: conversationPaused })}
               <div class="agent-composer-actions">
                 <span>${pending ? escapeHtml(pending.reason || '请核对内容与依据后再发送') : `模型:${escapeHtml(agent.model || '未配置')} · ${agent.configured ? '已配置' : '未配置'}`}</span>
                 <div class="agent-review-actions">
@@ -3053,8 +3377,13 @@
         </aside>
       </section>
     `;
+    const viewKey = `${listMode}:${selected?.id || selectedGroup?.id || 'empty'}`;
+    const incremental = page.dataset.agentViewKey === viewKey && Boolean(page.querySelector('.agent-workspace'));
+    if (incremental) patchAgentPage(page, nextHtml);
+    else page.innerHTML = nextHtml;
+    page.dataset.agentViewKey = viewKey;
     restoreAgentInsightScroll(page, insightScroll, selected?.id || selectedGroup?.id);
-    scrollAgentMessagesToLatest(page);
+    restoreAgentWorkspaceScroll(page, workspaceScroll, viewKey, incremental);
   }
 
   function responseMonitorRenderSignature(monitor = {}) {
@@ -3119,8 +3448,10 @@
       const nextSignature = JSON.stringify({
         globalMode: nextStatus.globalMode,
         listenerRunning: Boolean(nextStatus.listener?.running),
+        ingressRunning: Boolean(nextStatus.ingress?.running),
         account: nextStatus.account,
         config: nextStatus.config,
+        voice: nextStatus.voice,
         conversations: nextConversations,
         responseMonitor: responseMonitorRenderSignature(nextResponseMonitor),
         groupAgents: nextGroupAgents,
@@ -3145,9 +3476,15 @@
       });
       const waitingForFirstRender = Boolean(page.querySelector('.agent-loading'));
       const editorFocused = document.activeElement === page.querySelector('#agent-reply-editor') || document.activeElement === page.querySelector('#agent-group-reply-editor');
-      if (!editorFocused) {
+      const insightPanel = page.querySelector('.agent-insight-panel');
+      const insightActive = Date.now() < Number(state.agent.insightInteractionUntil || 0) || Boolean(insightPanel?.matches(':hover'));
+      const voicePlaying = [...page.querySelectorAll('.agent-voice-audio')].some(audio => !audio.paused && !audio.ended);
+      if (!editorFocused && !insightActive && !voicePlaying) {
         state.agent.signature = nextSignature;
-        if ((changed || waitingForFirstRender) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
+        if ((changed || waitingForFirstRender || state.agent.renderPending) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
+        state.agent.renderPending = false;
+      } else if (changed || waitingForFirstRender) {
+        state.agent.renderPending = true;
       }
     } catch (error) {
       if (!silent) toast(error.message || '智能会话加载失败', 'error');
@@ -3185,10 +3522,18 @@
     els.content.appendChild(page);
     if (state.agent.status) renderAgentWorkspaceV2(page);
 
+    const markInsightInteraction = event => {
+      if (event.target?.closest?.('.agent-insight-panel')) state.agent.insightInteractionUntil = Date.now() + 2000;
+    };
+    page.addEventListener('pointerdown', markInsightInteraction, true);
+    page.addEventListener('scroll', markInsightInteraction, true);
+
     page.addEventListener('input', event => {
       const editor = event.target.closest('#agent-reply-editor');
       if (editor?.dataset.agentEditKey) {
         state.agent.replyEdits[editor.dataset.agentEditKey] = editor.value;
+        const voiceReady = Boolean(state.agent.status?.voice?.enrolled && editor.value.trim() && !editor.disabled);
+        page.querySelectorAll('[data-agent-action="voice-send"]').forEach(button => { button.disabled = !voiceReady || !state.agent.status?.voice?.sendConfigured; });
         const status = page.querySelector('[data-agent-edit-status]');
         if (status) status.textContent = '已人工修改 · 批准后发送当前内容';
         return;
@@ -3200,7 +3545,60 @@
       if (status) status.textContent = '已人工修改 · 批准后发送当前内容';
     });
 
+    page.addEventListener('change', event => {
+      if (event.target.id !== 'agent-voice-tone') return;
+      state.agent.voiceTone = event.target.value || 'auto';
+    });
+
+    const updateVoicePlaybackUi = audio => {
+      const bubble = audio?.closest('.agent-voice-bubble');
+      if (!bubble) return;
+      const playing = !audio.paused && !audio.ended;
+      bubble.classList.toggle('playing', playing);
+      bubble.setAttribute('aria-pressed', String(playing));
+      const duration = Math.max(1, Number(audio.duration) || Number(bubble.closest('.agent-message')?.querySelector('.agent-voice-duration')?.textContent) || 1);
+      bubble.setAttribute('aria-label', `${playing ? '暂停' : '播放'}语音,时长 ${Math.ceil(duration)} 秒`);
+    };
+    page.addEventListener('play', event => {
+      if (!event.target.matches?.('.agent-voice-audio')) return;
+      page.querySelectorAll('.agent-voice-audio').forEach(audio => {
+        if (audio !== event.target && !audio.paused) audio.pause();
+      });
+      updateVoicePlaybackUi(event.target);
+    }, true);
+    page.addEventListener('pause', event => {
+      if (event.target.matches?.('.agent-voice-audio')) updateVoicePlaybackUi(event.target);
+    }, true);
+    page.addEventListener('ended', event => {
+      if (event.target.matches?.('.agent-voice-audio')) updateVoicePlaybackUi(event.target);
+    }, true);
+
     page.addEventListener('click', async event => {
+      const transcriptButton = event.target.closest('[data-voice-transcript-toggle]');
+      if (transcriptButton) {
+        const transcript = document.getElementById(transcriptButton.getAttribute('aria-controls'));
+        const expanded = transcriptButton.getAttribute('aria-expanded') === 'true';
+        transcriptButton.setAttribute('aria-expanded', String(!expanded));
+        transcriptButton.querySelector('span').textContent = expanded ? '转文字' : '收起';
+        if (transcript) transcript.hidden = expanded;
+        return;
+      }
+      const voicePlayButton = event.target.closest('[data-voice-play]');
+      if (voicePlayButton) {
+        const audio = voicePlayButton.querySelector('.agent-voice-audio');
+        if (!audio) return;
+        if (audio.paused || audio.ended) {
+          if (audio.ended) audio.currentTime = 0;
+          try {
+            await audio.play();
+          } catch (error) {
+            toast(error.message || '语音播放失败', 'error');
+          }
+        } else {
+          audio.pause();
+        }
+        return;
+      }
       const responseMonitorButton = event.target.closest('[data-response-monitor-target]');
       if (responseMonitorButton) {
         if (responseMonitorButton.dataset.responseMonitorTarget === 'customer') {
@@ -3319,7 +3717,35 @@
       }
       if (action === 'session-guide') {
         const conversation = (state.agent.conversations || []).find(item => item.id === id);
-        openAgentSessionGuide(conversation?.claudeSession || {});
+        const guide = conversation?.claudeSession || {};
+        if (guide.ready) {
+          openAgentSessionGuide(guide);
+          return;
+        }
+        actionButton.disabled = true;
+        actionButton.setAttribute('aria-busy', 'true');
+        actionButton.innerHTML = '<span class="agent-session-spinner" aria-hidden="true"></span>初始化中…';
+        try {
+          const result = await api('POST', `/api/agent/conversations/${id}/generate`, {}, 240000);
+          await loadAgentData(page);
+          const updated = (state.agent.conversations || []).find(item => item.id === id);
+          if (!updated?.claudeSession?.ready) {
+            throw new Error(result.assistantMessage || result.data?.error || '客户 Session 初始化未完成');
+          }
+          toast('客户 Session 已初始化,生成内容已进入人工审核流程');
+        } catch (error) {
+          toast(error.message || '客户 Session 初始化失败', 'error');
+        } finally {
+          if (actionButton.isConnected) {
+            actionButton.disabled = false;
+            actionButton.removeAttribute('aria-busy');
+            actionButton.textContent = '初始化 Session';
+          }
+        }
+        return;
+      }
+      if (action === 'voice-profile') {
+        await openVoiceProfileModal(page);
         return;
       }
       actionButton.disabled = true;
@@ -3396,9 +3822,19 @@
           const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId);
           if (!await confirmModal(`确认由人工真实发送给白名单联系人“${selected?.displayName || '当前客户'}”?`, { title: '人工发送客户回复', confirmText: '确认发送', warning: '该操作会立即发送真实私聊消息。', danger: true })) return;
           result = await api('POST', `/api/agent/conversations/${id}/manual-send`, { content });
+        } else if (action === 'voice-send') {
+          const content = page.querySelector('#agent-reply-editor')?.value.trim();
+          if (!content) throw new Error('请先输入语音内容');
+          const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId);
+          const toneLabel = state.agent.voiceTone === 'auto'
+            ? '自动识别语气'
+            : (state.agent.status?.voice?.tones || []).find(item => item.id === state.agent.voiceTone)?.label || '当前语气';
+          const voiceAccountKey = agentAccountKey() || state.agent.cachedAccountKey || 'default';
+          if (!await confirmModal(`确认使用“${toneLabel}”生成语音,并真实发送给“${selected?.displayName || '当前客户'}”?`, { title: '发送克隆语音', confirmText: '生成并发送', warning: '该操作会调用云端声音克隆服务并立即发送真实企微语音。', danger: true, dismissKey: `voice-send:${voiceAccountKey}` })) return;
+          result = await api('POST', `/api/agent/conversations/${id}/voice-send`, { content, draftId: actionButton.dataset.draftId || undefined, tone: state.agent.voiceTone, confirmed: true }, 240000);
         }
         if (result) toast(result.assistantMessage || '操作成功');
-        if (result && replyEditKey && ['approve', 'reject', 'regenerate', 'generate', 'manual-send'].includes(action)) {
+        if (result && replyEditKey && ['approve', 'reject', 'regenerate', 'generate', 'manual-send', 'voice-send'].includes(action)) {
           delete state.agent.replyEdits[replyEditKey];
         }
         if (result && groupReplyEditKey && ['group-approve', 'group-reject', 'group-regenerate', 'group-generate'].includes(action)) {
@@ -5715,17 +6151,18 @@
     `;
     els.content.appendChild(page);
 
-    await loadPortraitsDashboard(page);
+    if (renderPortraitsDashboardSnapshot(page)) void loadPortraitsDashboard(page, true);
+    else await loadPortraitsDashboard(page);
     dashboardRefreshTimer = setInterval(() => {
       if (state.page === 'portraits') loadPortraitsDashboard(page, true);
     }, 30000);
 
-    page.querySelector('#btn-pt-refresh').addEventListener('click', () => loadPortraitsDashboard(page));
+    page.querySelector('#btn-pt-refresh').addEventListener('click', () => loadPortraitsDashboard(page, false, true));
     page.querySelector('#btn-pt-rebuild').addEventListener('click', async () => {
       try {
         await api('POST', '/api/dashboard/rebuild');
         toast('索引已重建');
-        loadPortraitsDashboard(page);
+        loadPortraitsDashboard(page, false, true);
       } catch (err) {
         toast(err.message || '重建失败', 'error');
       }
@@ -5759,103 +6196,140 @@
     });
   }
 
-  async function loadPortraitsDashboard(page, silent = false) {
+  function renderPortraitsDashboardSnapshot(page) {
+    const snapshot = state.portraits.dashboardSnapshot;
+    if (!snapshot || !page?.isConnected) return false;
+    const signature = JSON.stringify({
+      summary: snapshot.summary,
+      portraitIds: Object.entries(snapshot.portraits || {}).map(([id, item]) => [id, item.updatedAt || item.profileUpdatedAt || '']),
+      customerIds: Object.keys(snapshot.customers || {}),
+      tags: snapshot.tags || [],
+    });
+    if (page.dataset.portraitSignature === signature && page.querySelector('#pt-portrait-table-wrap')?.children.length) return true;
+    state.portraits.allPortraits = snapshot.portraits;
+    state.portraits.allCustomers = snapshot.customers;
+    state.portraits.personalLabels = snapshot.personalLabels || [];
+    hideLoading(page.querySelector('#pt-portrait-table-wrap'));
+    renderPortraitsKpi(page, snapshot.summary);
+    renderPortraitsCharts(page, snapshot.summary, snapshot.portraits, snapshot.tags);
+    renderPortraitsTagFilter(page, snapshot.tags);
+    loadPortraitsTable(page);
+    page.dataset.portraitSignature = signature;
+    return true;
+  }
+
+  function buildPortraitsDashboardSnapshot({ summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes, directoryCustomers, personalLabels }) {
+    const summary = structuredClone(summaryRes.data || {});
+    const legacyPortraits = portraitsRes.data?.portraits || {};
+    const legacyCustomers = customersRes.data?.customers || {};
+    const customerMaster = customerMasterRes.data?.customers || [];
+    const canonicalPortraits = {};
+    const canonicalCustomers = {};
+    for (const customer of directoryCustomers || []) {
+      const externalUserId = String(customer.externalUserId || '').trim();
+      if (!externalUserId) continue;
+      canonicalCustomers[externalUserId] = {
+        externalUserId,
+        name: customer.name || customer.displayName || '',
+        phone: customer.phone || '',
+        tags: customer.tags || [],
+        messageCount: customer.messageCount || 0,
+        remote: customer.source === 'qiwei-contact',
+      };
+    }
+    for (const customer of customerMaster) {
+      const externalUserId = String(customer.externalUserId || customer.id || '').trim();
+      if (!externalUserId) continue;
+      const hasProfile = customer.profile && Object.keys(customer.profile).length > 0;
+      if (hasProfile) {
+        canonicalPortraits[externalUserId] = {
+          externalUserId,
+          displayId: customer.maskedId,
+          fields: customer.fields || [],
+          profile: customer.profile || {},
+          source: 'agent',
+          canonical: true,
+          messageCount: customer.messageCount || 0,
+          updatedAt: customer.profileUpdatedAt || customer.lastMessageAt,
+        };
+      }
+      canonicalCustomers[externalUserId] = {
+        ...(canonicalCustomers[externalUserId] || {}),
+        externalUserId,
+        name: customer.displayName,
+        phone: customer.maskedId,
+        tags: customer.tags || [],
+        messageCount: customer.messageCount || 0,
+      };
+    }
+    const portraits = { ...legacyPortraits, ...canonicalPortraits };
+    const customers = { ...legacyCustomers, ...canonicalCustomers };
+    const tags = [...new Set([
+      ...(tagsRes.data?.allTags || []),
+      ...customerMaster.flatMap(customer => customer.tags || []),
+      ...(personalLabels || []).map(label => label.name).filter(Boolean),
+    ])];
+    const portraitItems = Object.values(portraits);
+    const customerTotal = Math.max(Number(summary.customers?.total || 0), customerMaster.length, Object.keys(customers).length);
+    const bySource = portraitItems.reduce((counts, portrait) => {
+      const source = portrait.source === 'agent' ? 'agent' : portrait.source === 'keyword' ? 'keyword' : 'other';
+      counts[source] += 1;
+      return counts;
+    }, { agent: 0, keyword: 0, other: 0 });
+    summary.portraits = {
+      ...(summary.portraits || {}),
+      total: portraitItems.length,
+      coverage: customerTotal ? Math.round((portraitItems.length / customerTotal) * 100) : 0,
+      bySource,
+    };
+    summary.tags = {
+      ...(summary.tags || {}),
+      total: tags.length,
+      avgPerCustomer: customerTotal ? Number((tags.length / customerTotal).toFixed(1)) : 0,
+    };
+    return { summary, portraits, customers, tags, personalLabels: personalLabels || [] };
+  }
+
+  async function loadPortraitsDashboard(page, silent = false, force = false) {
+    if (state.portraits.loadingPromise) return state.portraits.loadingPromise;
     const tableWrap = page.querySelector('#pt-portrait-table-wrap');
-    if (!silent) showLoading(tableWrap, 'table');
-    try {
-      const [summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes, directoryRes, personalLabelsRes] = await Promise.all([
+    if (!silent && !state.portraits.dashboardSnapshot) showLoading(tableWrap, 'table');
+    const run = (async () => {
+      const fastPromise = Promise.all([
         api('GET', '/api/dashboard/summary'),
         api('GET', '/api/dashboard/portraits'),
         api('GET', '/api/dashboard/tags'),
         api('GET', '/api/dashboard/customers'),
         api('GET', '/api/customers'),
-        api('GET', '/api/dashboard/customers/search?scope=portrait&limit=100&sortBy=nameAsc'),
-        getGuid()
-          ? api('POST', '/api/personal-labels/sync', { guid: getGuid() }).catch(() => ({ data: { labels: [] } }))
-          : Promise.resolve({ data: { labels: [] } })
       ]);
-      const summary = summaryRes.data || {};
-      const legacyPortraits = portraitsRes.data?.portraits || {};
-      const legacyCustomers = customersRes.data?.customers || {};
-      const customerMaster = customerMasterRes.data?.customers || [];
-      const directoryCustomers = directoryRes.data?.customers || [];
-      const personalLabels = (personalLabelsRes.data?.labels || []).filter(label => label && label.isDeleted !== 1 && label.dataType !== 2);
-      const canonicalPortraits = {};
-      const canonicalCustomers = {};
-      for (const customer of directoryCustomers) {
-        const externalUserId = String(customer.externalUserId || '').trim();
-        if (!externalUserId) continue;
-        canonicalCustomers[externalUserId] = {
-          externalUserId,
-          name: customer.name || customer.displayName || '',
-          phone: customer.phone || '',
-          tags: customer.tags || [],
-          messageCount: customer.messageCount || 0,
-          remote: customer.source === 'qiwei-contact',
-        };
-      }
-      for (const customer of customerMaster) {
-        const externalUserId = String(customer.externalUserId || customer.id || '').trim();
-        if (!externalUserId) continue;
-        const hasProfile = customer.profile && Object.keys(customer.profile).length > 0;
-        if (hasProfile) {
-          canonicalPortraits[externalUserId] = {
-            externalUserId,
-            displayId: customer.maskedId,
-            fields: customer.fields || [],
-            profile: customer.profile || {},
-            source: 'agent',
-            canonical: true,
-            messageCount: customer.messageCount || 0,
-            updatedAt: customer.profileUpdatedAt || customer.lastMessageAt,
-          };
-        }
-        canonicalCustomers[externalUserId] = {
-          ...(canonicalCustomers[externalUserId] || {}),
-          externalUserId,
-          name: customer.displayName,
-          phone: customer.maskedId,
-          tags: customer.tags || [],
-          messageCount: customer.messageCount || 0,
-        };
-      }
-      const portraits = { ...legacyPortraits, ...canonicalPortraits };
-      const customers = { ...legacyCustomers, ...canonicalCustomers };
-      const tags = [...new Set([
-        ...(tagsRes.data?.allTags || []),
-        ...customerMaster.flatMap(customer => customer.tags || []),
-        ...personalLabels.map(label => label.name).filter(Boolean),
-      ])];
-      const portraitItems = Object.values(portraits);
-      const customerTotal = Math.max(Number(summary.customers?.total || 0), customerMaster.length, Object.keys(customers).length);
-      const bySource = portraitItems.reduce((counts, portrait) => {
-        const source = portrait.source === 'agent' ? 'agent' : portrait.source === 'keyword' ? 'keyword' : 'other';
-        counts[source] += 1;
-        return counts;
-      }, { agent: 0, keyword: 0, other: 0 });
-      summary.portraits = {
-        ...(summary.portraits || {}),
-        total: portraitItems.length,
-        coverage: customerTotal ? Math.round((portraitItems.length / customerTotal) * 100) : 0,
-        bySource,
-      };
-      summary.tags = {
-        ...(summary.tags || {}),
-        total: tags.length,
-        avgPerCustomer: customerTotal ? Number((tags.length / customerTotal).toFixed(1)) : 0,
-      };
-
-      state.portraits.allPortraits = portraits;
-      state.portraits.allCustomers = customers;
-      state.portraits.personalLabels = personalLabels;
-      hideLoading(tableWrap);
-      renderPortraitsKpi(page, summary);
-      renderPortraitsCharts(page, summary, portraits, tags);
-      renderPortraitsTagFilter(page, tags);
-      loadPortraitsTable(page);
-    } catch (err) {
-      if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadPortraitsDashboard(page));
-    }
+      const directoryPromise = api('GET', `/api/dashboard/customers/search?scope=portrait&limit=100&sortBy=nameAsc${force ? '&refresh=1' : ''}`);
+      const labelsPromise = getGuid() && (force || !state.portraits.personalLabelsLoaded)
+          ? api('POST', '/api/personal-labels/sync', { guid: getGuid() }).catch(() => ({ data: { labels: [] } }))
+          : Promise.resolve({ data: { labels: state.portraits.personalLabels || [] } });
+      const [summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes] = await fastPromise;
+      const baseInput = { summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes };
+      state.portraits.dashboardSnapshot = buildPortraitsDashboardSnapshot({
+        ...baseInput,
+        directoryCustomers: state.portraits.directoryCustomers || [],
+        personalLabels: state.portraits.personalLabels || [],
+      });
+      renderPortraitsDashboardSnapshot(page);
+
+      const [directoryRes, personalLabelsRes] = await Promise.all([directoryPromise, labelsPromise]);
+      state.portraits.directoryCustomers = directoryRes.data?.customers || [];
+      state.portraits.personalLabels = (personalLabelsRes.data?.labels || []).filter(label => label && label.isDeleted !== 1 && label.dataType !== 2);
+      state.portraits.personalLabelsLoaded = true;
+      state.portraits.dashboardSnapshot = buildPortraitsDashboardSnapshot({
+        ...baseInput,
+        directoryCustomers: state.portraits.directoryCustomers,
+        personalLabels: state.portraits.personalLabels,
+      });
+      renderPortraitsDashboardSnapshot(page);
+    })().catch(err => {
+      if (!silent && page?.isConnected) showError(tableWrap, err.message || '加载看板失败', () => loadPortraitsDashboard(page, false, true));
+    }).finally(() => { state.portraits.loadingPromise = null; });
+    state.portraits.loadingPromise = run;
+    return run;
   }
 
   function renderPortraitsKpi(page, summary) {
@@ -6419,17 +6893,18 @@
     `;
     els.content.appendChild(page);
 
-    await loadTransfersDashboard(page);
+    if (renderTransfersDashboardSnapshot(page)) void loadTransfersDashboard(page, true);
+    else await loadTransfersDashboard(page);
     dashboardRefreshTimer = setInterval(() => {
       if (state.page === 'transfers') loadTransfersDashboard(page, true);
     }, 30000);
 
-    page.querySelector('#btn-tf-refresh').addEventListener('click', () => loadTransfersDashboard(page));
+    page.querySelector('#btn-tf-refresh').addEventListener('click', () => loadTransfersDashboard(page, false, true));
     page.querySelector('#btn-tf-rebuild').addEventListener('click', async () => {
       try {
         await api('POST', '/api/dashboard/rebuild');
         toast('索引已重建');
-        loadTransfersDashboard(page);
+        loadTransfersDashboard(page, false, true);
       } catch (err) {
         toast(err.message || '重建失败', 'error');
       }
@@ -6439,30 +6914,68 @@
     page.querySelector('#tf-filter-user').addEventListener('input', () => loadTransfersTable(page));
   }
 
-  async function loadTransfersDashboard(page, silent = false) {
+  function renderTransfersDashboardSnapshot(page) {
+    const snapshot = state.transfers.dashboardSnapshot;
+    if (!snapshot || !page?.isConnected) return false;
+    const signature = JSON.stringify({
+      summary: snapshot.summary?.transfers || {},
+      previews: (snapshot.transfers?.previews || []).map(item => [item.filePath, item.status, item.createdAt]),
+      executions: (snapshot.transfers?.executions || []).map(item => [item.filePath, item.status, item.executedAt]),
+      candidateIds: (snapshot.candidates || []).map(item => item.externalUserId),
+      groupIds: (snapshot.groups || []).map(item => item.roomId),
+    });
+    if (page.dataset.transferSignature === signature && page.querySelector('#tf-transfer-table-wrap')?.children.length) return true;
+    renderTransfersKpi(page, snapshot.summary, { candidates: snapshot.candidates, groups: snapshot.groups });
+    renderTransfersCharts(page, snapshot.summary, snapshot.transfers);
+    renderTransferSourceSummary(page, snapshot.candidates, snapshot.groups);
+    state.transfers = {
+      ...state.transfers,
+      ...snapshot.transfers,
+      candidates: snapshot.candidates,
+      groups: snapshot.groups,
+      dashboardSnapshot: snapshot,
+    };
+    hideLoading(page.querySelector('#tf-transfer-table-wrap'));
+    loadTransfersTable(page);
+    page.dataset.transferSignature = signature;
+    return true;
+  }
+
+  async function loadTransfersDashboard(page, silent = false, force = false) {
+    if (state.transfers.loadingPromise) return state.transfers.loadingPromise;
     const tableWrap = page.querySelector('#tf-transfer-table-wrap');
-    if (!silent) showLoading(tableWrap, 'table');
-    try {
-      const [summaryRes, transfersRes, customersRes, groupsRes] = await Promise.all([
+    if (!silent && !state.transfers.dashboardSnapshot) showLoading(tableWrap, 'table');
+    const run = (async () => {
+      const candidatesPromise = api('GET', `/api/dashboard/customers/search?limit=100&sortBy=nameAsc${force ? '&refresh=1' : ''}`);
+      const [summaryRes, transfersRes, groupsRes] = await Promise.all([
         api('GET', '/api/dashboard/summary'),
         api('GET', '/api/dashboard/transfers'),
-        api('GET', '/api/dashboard/customers/search?limit=100&sortBy=nameAsc'),
         api('GET', '/api/groups/list')
       ]);
       const summary = summaryRes.data || {};
       const transfers = transfersRes.data || {};
-      const candidates = customersRes.data?.customers || [];
       const groups = groupsRes.data?.groups || [];
-
-      hideLoading(tableWrap);
-      renderTransfersKpi(page, summary, { candidates, groups });
-      renderTransfersCharts(page, summary, transfers);
-      renderTransferSourceSummary(page, candidates, groups);
-      state.transfers = { ...state.transfers, ...transfers, candidates, groups };
-      loadTransfersTable(page);
-    } catch (err) {
-      if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadTransfersDashboard(page));
-    }
+      state.transfers.dashboardSnapshot = {
+        summary,
+        transfers,
+        candidates: state.transfers.candidates || [],
+        groups,
+      };
+      renderTransfersDashboardSnapshot(page);
+
+      const customersRes = await candidatesPromise;
+      state.transfers.dashboardSnapshot = {
+        summary,
+        transfers,
+        candidates: customersRes.data?.customers || [],
+        groups,
+      };
+      renderTransfersDashboardSnapshot(page);
+    })().catch(err => {
+      if (!silent && page?.isConnected) showError(tableWrap, err.message || '加载看板失败', () => loadTransfersDashboard(page, false, true));
+    }).finally(() => { state.transfers.loadingPromise = null; });
+    state.transfers.loadingPromise = run;
+    return run;
   }
 
   function renderTransfersKpi(page, summary, source = {}) {

+ 5 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/group-agent-service.js

@@ -15,7 +15,11 @@ function readJson(filePath, fallback) {
 }
 
 function messageContent(message = {}) {
-  return String(message.content || message.msgData?.content || '').trim();
+  const data = message.msgData || {};
+  for (const value of [message.content, data.content, data.text, data.value, data.content?.text, data.content?.content]) {
+    if (typeof value === 'string' && value.trim()) return value.trim();
+  }
+  return '';
 }
 
 function messageId(message = {}) {

+ 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=20260725-dialogs">
+<link rel="stylesheet" href="/dashboard/styles.css?v=20260726-voice">
 </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=20260725-dialogs"></script>
+<script src="/dashboard/app.js?v=20260726-voice"></script>
 </body>
 </html>

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

@@ -2,7 +2,7 @@ const http = require('http');
 const fs = require('fs');
 const path = require('path');
 const { URL } = require('url');
-const { outputsRoot } = require('../core/output-paths');
+const { outputsRoot, categoryDir } = require('../core/output-paths');
 const { resolveWorkspaceRoot, workspaceIdentity } = require('../core/runtime-context');
 const {
   qiweiSyncExternalGroups,
@@ -86,6 +86,11 @@ const {
   regenerateDraft,
   generateLatestDraft,
   manualSend,
+  getVoiceStatus,
+  enrollVoice,
+  revokeVoiceProfile,
+  sendClonedVoice,
+  getSentVoiceAudio,
   updateCustomerTask,
   syncCustomerTaskToOfficialTodo,
   updateCustomerAlert,
@@ -173,7 +178,12 @@ async function getCachedSubscriptionStatus({ force = false } = {}) {
 // Dashboard 启动后立即预热较慢的远程订阅状态,用户进入账号页时通常可直接命中缓存。
 void getCachedSubscriptionStatus().catch(() => {});
 
-async function getDashboardCustomerDirectory() {
+const CUSTOMER_DIRECTORY_CACHE_TTL_MS = 60 * 1000;
+let customerDirectoryCache = null;
+let customerDirectoryCachedAt = 0;
+let customerDirectoryInFlight = null;
+
+async function buildDashboardCustomerDirectory() {
   const merged = new Map();
   const mergeCustomer = customer => {
     const externalUserId = String(customer.externalUserId || customer.customerId || '').trim();
@@ -223,15 +233,44 @@ async function getDashboardCustomerDirectory() {
   return Array.from(merged.values());
 }
 
+async function getDashboardCustomerDirectory({ force = false } = {}) {
+  const fresh = customerDirectoryCache && Date.now() - customerDirectoryCachedAt < CUSTOMER_DIRECTORY_CACHE_TTL_MS;
+  if (!force && fresh) return customerDirectoryCache;
+  if (customerDirectoryInFlight) return customerDirectoryInFlight;
+  customerDirectoryInFlight = buildDashboardCustomerDirectory()
+    .then(customers => {
+      customerDirectoryCache = customers;
+      customerDirectoryCachedAt = Date.now();
+      return customers;
+    })
+    .finally(() => { customerDirectoryInFlight = null; });
+  return customerDirectoryInFlight;
+}
+
+// 预热共享客户目录,画像、交接和客户选择器进入时可直接复用。
+void getDashboardCustomerDirectory().catch(() => {});
+
 function ensureTmpDir() {
   fs.mkdirSync(TMP_DIR, { recursive: true });
 }
 
-function readBody(req) {
+function readBody(req, maxBytes = 30 * 1024 * 1024) {
   return new Promise((resolve, reject) => {
     const chunks = [];
-    req.on('data', chunk => chunks.push(chunk));
+    let size = 0;
+    let exceeded = false;
+    req.on('data', chunk => {
+      if (exceeded) return;
+      size += chunk.length;
+      if (size > maxBytes) {
+        exceeded = true;
+        reject(new Error('请求内容过大'));
+        return;
+      }
+      chunks.push(chunk);
+    });
     req.on('end', () => {
+      if (exceeded) return;
       try {
         resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
       } catch {
@@ -282,18 +321,25 @@ function serveStatic(req, res, filePath, contentType) {
   });
 }
 
-async function handleUpload(req) {
-  const body = await readBody(req);
+function saveUploadBody(body) {
   const base64 = String(body.data || '');
-  const name = String(body.name || `upload-${Date.now()}`).replace(/[\\/]/g, '_');
+  const requestedName = path.basename(String(body.name || `upload-${Date.now()}`)).replace(/[^A-Za-z0-9._\-\u4e00-\u9fa5]/g, '_');
+  const name = !requestedName || requestedName === '.' || requestedName === '..' ? `upload-${Date.now()}` : requestedName;
   if (!base64) throw new Error('缺少文件数据');
+  const maxBytes = 20 * 1024 * 1024;
+  if (base64.length > Math.ceil(maxBytes / 3) * 4 + 16) throw new Error('上传文件不能超过 20MB');
   ensureTmpDir();
   const buffer = Buffer.from(base64, 'base64');
+  if (buffer.length > maxBytes) throw new Error('上传文件不能超过 20MB');
   const filePath = path.join(TMP_DIR, name);
   fs.writeFileSync(filePath, buffer);
   return { path: filePath };
 }
 
+async function handleUpload(req) {
+  return saveUploadBody(await readBody(req));
+}
+
 function handleOutputsDownload(req, res, query) {
   const requested = String(query.path || '');
   if (!requested) {
@@ -329,6 +375,44 @@ function handleOutputsDownload(req, res, query) {
   res.end(data);
 }
 
+function serveSentVoiceAudio(req, res, requested) {
+  const resolved = path.resolve(String(requested || ''));
+  const root = path.resolve(categoryDir('voice'));
+  const relative = path.relative(root, resolved);
+  if (!relative || relative.startsWith('..') || path.isAbsolute(relative)
+    || path.basename(resolved).toLowerCase() !== 'speech.wav'
+    || !relative.split(path.sep).some(part => /^\d{6}-clone-[a-z0-9-]+$/i.test(part))
+    || !fs.existsSync(resolved)) {
+    json(res, 404, { status: 'error', message: '已发送语音文件不存在' });
+    return;
+  }
+  const size = fs.statSync(resolved).size;
+  const range = String(req.headers.range || '').match(/^bytes=(\d*)-(\d*)$/);
+  let start = 0;
+  let end = size - 1;
+  if (range) {
+    if (range[1]) start = Number(range[1]);
+    if (range[2]) end = Number(range[2]);
+    if (!range[1] && range[2]) start = Math.max(0, size - Number(range[2]));
+    if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= size) {
+      res.writeHead(416, { 'Content-Range': `bytes */${size}` });
+      res.end();
+      return;
+    }
+    end = Math.min(end, size - 1);
+  }
+  const headers = {
+    'Content-Type': 'audio/wav',
+    'Content-Length': end - start + 1,
+    'Accept-Ranges': 'bytes',
+    'Cache-Control': 'private, no-store',
+    'Content-Disposition': 'inline; filename="speech.wav"',
+  };
+  if (range) headers['Content-Range'] = `bytes ${start}-${end}/${size}`;
+  res.writeHead(range ? 206 : 200, headers);
+  fs.createReadStream(resolved, { start, end }).pipe(res);
+}
+
 function getContentType(filePath) {
   const ext = path.extname(filePath).toLowerCase();
   const map = {
@@ -527,6 +611,28 @@ async function handleRequest(req, res) {
       json(res, 200, await getAgentStatus());
       return;
     }
+    if (pathname === '/api/agent/voice/status' && req.method === 'GET') {
+      json(res, 200, getVoiceStatus());
+      return;
+    }
+    if (pathname === '/api/agent/voice/profile' && req.method === 'POST') {
+      const body = await readBody(req);
+      const upload = saveUploadBody(body);
+      try {
+        json(res, 200, await enrollVoice({
+          filePath: upload.path,
+          originalName: body.name,
+          mime: body.type,
+        }));
+      } finally {
+        fs.rmSync(upload.path, { force: true });
+      }
+      return;
+    }
+    if (pathname === '/api/agent/voice/profile' && req.method === 'DELETE') {
+      json(res, 200, revokeVoiceProfile());
+      return;
+    }
     if (pathname === '/api/agent/allowlist' && req.method === 'GET') {
       json(res, 200, await getAllowlistCandidates());
       return;
@@ -623,6 +729,19 @@ async function handleRequest(req, res) {
       else json(res, 200, await approveReply(conversationId, body.content));
       return;
     }
+    const voiceConversationRoute = pathname.match(/^\/api\/agent\/conversations\/([^/]+)\/voice-send$/);
+    if (voiceConversationRoute && req.method === 'POST') {
+      const [, conversationId] = voiceConversationRoute;
+      const body = await readBody(req);
+      json(res, 200, await sendClonedVoice(conversationId, body));
+      return;
+    }
+    const voiceAudioRoute = pathname.match(/^\/api\/agent\/messages\/([^/]+)\/voice-audio$/);
+    if (voiceAudioRoute && req.method === 'GET') {
+      const audio = getSentVoiceAudio(decodeURIComponent(voiceAudioRoute[1]));
+      serveSentVoiceAudio(req, res, audio.filePath);
+      return;
+    }
     const agentDraftRoute = pathname.match(/^\/api\/agent\/drafts\/([^/]+)\/(approve|reject|regenerate)$/);
     if (agentDraftRoute && req.method === 'POST') {
       const [, draftId, action] = agentDraftRoute;
@@ -644,7 +763,8 @@ async function handleRequest(req, res) {
       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 => {
+      const forceRefresh = url.searchParams.get('refresh') === '1';
+      let customers = (await getDashboardCustomerDirectory({ force: forceRefresh })).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();

+ 75 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css

@@ -1012,6 +1012,9 @@ html, body {
 .agent-session-strip small { margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #89918e; font-size: 9px; }
 .agent-session-dot { width: 8px; height: 8px; border-radius: 50%; background: #b3bbb8; box-shadow: 0 0 0 4px rgba(137,145,142,.1); }
 .agent-session-strip.ready .agent-session-dot { background: #22b981; box-shadow: 0 0 0 4px rgba(34,185,129,.12); }
+.agent-session-action { min-width: 112px; }
+.agent-session-action[aria-busy="true"] { cursor: wait; opacity: .72; }
+.agent-session-spinner { width: 12px; height: 12px; flex: 0 0 auto; border: 2px solid #c9d8d1; border-top-color: var(--agent-green); border-radius: 50%; animation: spin .75s linear infinite; }
 
 .agent-message-stream {
   max-height: 440px;
@@ -1034,6 +1037,39 @@ html, body {
 .agent-message-body { padding: 11px 13px; border: 1px solid #e3e8e5; border-radius: 4px 14px 14px 14px; color: #34413c; background: #fff; box-shadow: 0 5px 12px rgba(24,54,45,.05); font-size: 12px; line-height: 1.7; }
 .agent-message.agent .agent-message-body { color: #164e3d; border-color: #cfe8de; border-radius: 14px 4px 14px 14px; background: #eef8f4; }
 .agent-message.human .agent-message-body { color: #6d431b; border-color: #f3d9bd; border-radius: 14px 4px 14px 14px; background: #fff6eb; }
+.agent-message-voice-row { display: flex; align-items: center; justify-content: flex-start; gap: 7px; max-width: 100%; }
+.agent-message.agent .agent-message-voice-row,
+.agent-message.human .agent-message-voice-row { justify-content: flex-end; }
+.agent-voice-transcript-toggle,
+.agent-voice-bubble { min-height: 44px; border: 1px solid transparent; font: inherit; cursor: pointer; transition: border-color .18s ease, background-color .18s ease, box-shadow .18s ease; }
+.agent-voice-transcript-toggle { display: inline-flex; align-items: center; justify-content: center; gap: 4px; width: 68px; flex: 0 0 68px; padding: 0 7px; border-color: #d8e2dd; border-radius: 7px; color: #53635c; background: #fff; font-size: 10px; font-weight: 650; }
+.agent-voice-transcript-toggle:hover { color: var(--agent-green); border-color: #92c9b7; background: #f6fbf9; }
+.agent-voice-transcript-toggle[aria-expanded="true"] { color: #166b50; border-color: #b9dacc; background: #edf7f3; }
+.agent-voice-bubble { display: inline-flex; align-items: center; gap: 9px; width: min(var(--voice-bubble-width), calc(100% - 75px)); min-width: 118px; max-width: 232px; padding: 0 13px; border-color: #c9e3d8; border-radius: 5px 15px 15px 15px; color: #175a45; background: #dcf4e9; box-shadow: 0 4px 10px rgba(24, 84, 64, .08); }
+.agent-message.agent .agent-voice-bubble,
+.agent-message.human .agent-voice-bubble { border-radius: 15px 5px 15px 15px; }
+.agent-message.human .agent-voice-bubble { color: #6b451f; border-color: #edd2b4; background: #ffedda; box-shadow: 0 4px 10px rgba(123, 77, 31, .08); }
+.agent-voice-bubble:hover { border-color: #84c7af; background: #d2efdf; }
+.agent-message.human .agent-voice-bubble:hover { border-color: #dfb989; background: #ffe5c8; }
+.agent-voice-transcript-toggle:focus-visible,
+.agent-voice-bubble:focus-visible { outline: 3px solid rgba(19, 122, 91, .2); outline-offset: 2px; }
+.agent-voice-play-icon { display: inline-flex; flex: 0 0 auto; }
+.agent-voice-play-icon .voice-icon { width: 18px; height: 18px; }
+.agent-voice-wave { display: inline-flex; align-items: center; gap: 2px; height: 18px; flex: 1 1 auto; }
+.agent-voice-wave i { width: 2px; height: 6px; border-radius: 1px; background: currentColor; opacity: .38; transform-origin: center; }
+.agent-voice-wave i:nth-child(2) { height: 11px; opacity: .52; }
+.agent-voice-wave i:nth-child(3) { height: 15px; opacity: .7; }
+.agent-voice-wave i:nth-child(4) { height: 9px; opacity: .48; }
+.agent-voice-bubble.playing .agent-voice-wave i { animation: agentVoiceWave .72s ease-in-out infinite alternate; }
+.agent-voice-bubble.playing .agent-voice-wave i:nth-child(2) { animation-delay: .12s; }
+.agent-voice-bubble.playing .agent-voice-wave i:nth-child(3) { animation-delay: .24s; }
+.agent-voice-bubble.playing .agent-voice-wave i:nth-child(4) { animation-delay: .36s; }
+.agent-voice-duration { flex: 0 0 auto; min-width: 25px; text-align: right; font-size: 11px; font-weight: 700; }
+.agent-voice-audio { display: none; }
+.agent-voice-transcript { margin-top: 7px; padding: 10px 12px; border: 1px solid #e1e8e5; border-radius: 8px; color: #45534d; background: #fff; font-size: 11px; line-height: 1.65; box-shadow: 0 3px 8px rgba(24, 54, 45, .04); }
+.agent-voice-transcript[hidden] { display: none; }
+@keyframes agentVoiceWave { from { transform: scaleY(.55); opacity: .38; } to { transform: scaleY(1.15); opacity: .9; } }
+@media (prefers-reduced-motion: reduce) { .agent-voice-bubble.playing .agent-voice-wave i { animation: none; opacity: .85; } }
 
 .agent-composer {
   padding: 14px 18px 16px;
@@ -1053,6 +1089,36 @@ html, body {
 .agent-composer-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
 .agent-composer-actions span { color: #9aa29f; font-size: 9px; }
 .agent-composer-actions .btn { padding: 8px 14px; background: var(--agent-green); border-color: var(--agent-green); font-size: 11px; }
+.voice-icon { width: 15px; height: 15px; flex: 0 0 auto; }
+.voice-composer-toolbar { display: grid; grid-template-columns: auto auto minmax(180px, 1fr); align-items: center; gap: 8px; min-height: 40px; margin-top: 8px; padding-top: 8px; border-top: 1px solid #e8eeeb; }
+.voice-profile-button { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 6px 9px; border: 1px solid #d8e2dd; border-radius: 7px; color: #53635c; background: #fff; cursor: pointer; font: inherit; font-size: 10px; font-weight: 650; transition: border-color .18s ease, color .18s ease, background .18s ease; }
+.voice-profile-button:hover { border-color: var(--agent-green); color: var(--agent-green); background: #f3faf7; }
+.voice-profile-button:focus-visible, .voice-capture-button:focus-visible, .voice-tone-select select:focus-visible { outline: 3px solid rgba(19,122,91,.18); outline-offset: 1px; }
+.voice-profile-button i { width: 6px; height: 6px; border-radius: 50%; background: #c9d1cd; }
+.voice-profile-button i.ready { background: #22a06b; box-shadow: 0 0 0 3px rgba(34,160,107,.12); }
+.voice-tone-select { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 8px; border: 1px solid #d8e2dd; border-radius: 7px; color: #7b8882; background: #fff; font-size: 9px; }
+.voice-tone-select select { max-width: 110px; border: 0; outline: 0; color: #3f5048; background: transparent; cursor: pointer; font: inherit; font-size: 10px; font-weight: 650; }
+.voice-composer-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; }
+.agent-composer .voice-action-button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-width: 76px; min-height: 34px; padding: 7px 10px; }
+.agent-composer .voice-action-button.btn-secondary { color: #4d6158; background: #fff; border-color: #d7e0dc; }
+
+.voice-profile-modal { width: min(92vw, 520px); }
+.voice-profile-modal-body { display: grid; gap: 14px; padding: 4px 0 2px; }
+.voice-profile-status { display: flex; align-items: center; gap: 11px; padding: 12px; border: 1px solid #e1e7e4; border-radius: 8px; background: #f7f9f8; }
+.voice-profile-status.ready { border-color: #cce5d8; background: #f1f9f5; }
+.voice-profile-dot { width: 10px; height: 10px; flex: 0 0 auto; border-radius: 50%; background: #aeb9b4; }
+.voice-profile-status.ready .voice-profile-dot { background: #22a06b; box-shadow: 0 0 0 4px rgba(34,160,107,.12); }
+.voice-profile-status div { display: grid; gap: 3px; min-width: 0; }
+.voice-profile-status strong { color: #2f4038; font-size: 13px; }
+.voice-profile-status small { overflow: hidden; color: #75827c; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+.voice-capture-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
+.voice-capture-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 44px; padding: 8px 10px; border: 1px solid #d7e0dc; border-radius: 8px; color: #4c5d55; background: #fff; cursor: pointer; font: inherit; font-size: 11px; font-weight: 650; transition: border-color .18s ease, color .18s ease, background .18s ease; }
+.voice-capture-button:hover:not(:disabled) { border-color: var(--agent-green); color: var(--agent-green); background: #f3faf7; }
+.voice-capture-button:disabled { cursor: not-allowed; opacity: .45; }
+.voice-capture-button.recording { border-color: #d25b4b; color: #b64034; background: #fff4f2; }
+.voice-selected-file { min-height: 34px; padding: 9px 11px; overflow: hidden; border: 1px dashed #d7e0dc; border-radius: 8px; color: #78857f; background: #fbfcfb; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+.voice-profile-preview { width: 100%; height: 36px; }
+.voice-delete-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; color: #a44338; }
 
 .agent-review-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
 .agent-review-actions .btn-secondary { color: #5f6d68; border-color: #d7e0dc; background: #fff; }
@@ -2236,6 +2302,8 @@ body.business-modal-open { overflow: hidden; }
 .business-modal-live-warning strong { font-size: 12px; }
 .business-modal-live-warning span { margin-top: 3px; font-size: 11px; line-height: 1.55; }
 .business-modal-error { margin: 13px 0 0; color: #c3443d; font-size: 12px; }
+.business-modal-dont-ask { display: flex; align-items: center; gap: 7px; margin-top: 14px; color: #5c6f66; font-size: 12px; cursor: pointer; user-select: none; }
+.business-modal-dont-ask input { width: 14px; height: 14px; margin: 0; accent-color: #3f9c7c; cursor: pointer; }
 .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; }
@@ -3630,4 +3698,11 @@ body.business-modal-open { overflow: hidden; }
   .transfer-stage-connector { grid-template-columns: 42px 1fr; justify-items: stretch; align-items: center; padding: 0 12px; text-align: left; }
   .transfer-stage-connector span { display: none; }
   .transfer-stage-connector i { height: 3px; }
+  .voice-composer-toolbar { grid-template-columns: 1fr 1fr; }
+  .voice-profile-button, .voice-tone-select { min-height: 44px; }
+  .voice-composer-actions { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; }
+  .agent-composer .voice-action-button { min-height: 44px; }
+  .voice-capture-row { grid-template-columns: 1fr; }
+  .voice-profile-modal > .modal-actions { flex-wrap: wrap; }
+  .voice-profile-modal > .modal-actions .btn { min-height: 44px; flex: 1 1 120px; }
 }

+ 59 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-agent-transport.js

@@ -1,5 +1,8 @@
 'use strict';
 
+const fs = require('fs');
+const path = require('path');
+
 const {
   readQiweiAuthToken,
   readQiweiUid,
@@ -34,7 +37,7 @@ class FmodeQiweiClient {
     return ctx;
   }
 
-  async call(method, params = {}) {
+  async call(method, params = {}, options = {}) {
     const ctx = this.requireContext();
     const result = await callFmodeWecomGateway({
       gatewayPath: '/doApi',
@@ -46,6 +49,7 @@ class FmodeQiweiClient {
       token: ctx.token,
       apiBase: ctx.apiBase,
       timeoutMs: 30000,
+      networkAttempts: options.networkAttempts,
     });
     return result.data && result.data.data !== undefined ? result.data.data : (result.data || {});
   }
@@ -151,6 +155,60 @@ class FmodeQiweiClient {
   sendText(toId, content) {
     return this.call('/msg/sendText', { toId, content, isNoNeedRead: false });
   }
+
+  async uploadVoiceFile(filePath) {
+    const ctx = this.requireContext();
+    const resolved = path.resolve(String(filePath || ''));
+    if (!fs.existsSync(resolved)) throw new Error('待上传的 SILK 文件不存在');
+
+    // Retry transient gateway/network failures before falling back to URL upload.
+    let directError = null;
+    for (let attempt = 1; attempt <= 3; attempt += 1) {
+      const form = new FormData();
+      form.append('uid', ctx.uid);
+      form.append('method', '/cloud/cdnBigUpload');
+      if (ctx.guid) form.append('guid', ctx.guid);
+      form.append('fileType', '5');
+      form.append('file', new Blob([fs.readFileSync(resolved)], { type: 'application/octet-stream' }), path.basename(resolved));
+      try {
+        const response = await fetch(`${ctx.apiBase.replace(/\/$/, '')}/doFileApi`, {
+          method: 'POST',
+          headers: { Authorization: `Bearer ${ctx.token}`, Accept: 'application/json' },
+          body: form,
+          signal: AbortSignal.timeout(30000),
+        });
+        const raw = await response.text();
+        let parsed;
+        try { parsed = JSON.parse(raw); } catch { parsed = {}; }
+        if (response.ok && [0, 200].includes(Number(parsed.code ?? 200))) {
+          const data = parsed.data?.data ?? parsed.data ?? parsed;
+          if (data.fileId && data.fileAesKey) return data;
+        }
+        if (response.status === 404) {
+          directError = new Error('Fmode 网关尚未开放 /doFileApi');
+          break;
+        }
+        directError = new Error(parsed.message || parsed.msg || `媒体上传失败(HTTP ${response.status})`);
+        const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
+        if (!retryable) break;
+      } catch (error) {
+        directError = error;
+      }
+      if (attempt < 3) await new Promise(resolve => setTimeout(resolve, attempt * 500));
+    }
+
+    throw new Error(`Fmode /doFileApi 媒体上传失败:${directError?.message || '未返回有效媒体信息'}`);
+  }
+
+  sendVoice(toId, media) {
+    return this.call('/msg/sendVoice', {
+      toId,
+      fileAesKey: media.fileAesKey,
+      fileId: media.fileId,
+      fileSize: media.fileSize,
+      voiceTime: media.voiceTime,
+    }, { networkAttempts: 1 });
+  }
 }
 
 module.exports = { FmodeQiweiClient };

+ 3 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js

@@ -106,7 +106,8 @@ async function callFmodeWecomGateway({
   formData,
   token,
   apiBase,
-  timeoutMs = 60000
+  timeoutMs = 60000,
+  networkAttempts = 4
 }) {
   if (!gatewayPath) {
     const err = new Error('missing gatewayPath');
@@ -141,7 +142,7 @@ async function callFmodeWecomGateway({
         : body === undefined
           ? undefined
           : JSON.stringify(body)
-    }, timeoutMs);
+    }, timeoutMs, Math.max(1, Math.min(4, Number(networkAttempts) || 1)));
   } catch (error) {
     const err = new Error('网络请求失败');
     err.kind = 'upstream';

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

@@ -90,7 +90,12 @@ const {
   qiweiGoalUpdateTask,
   qiweiGoalImportMeetingActions
 } = require('./tools/qiwei-goal-management-run');
-const { qiweiTranscribeVoice } = require('./tools/qiwei-voice-run');
+const {
+  qiweiTranscribeVoice,
+  qiweiVoiceProfileStatus,
+  qiweiEnrollVoice,
+  qiweiSendClonedVoice
+} = require('./tools/qiwei-voice-run');
 const {
   qiweiWebhookStatus,
   qiweiWebhookDiscover,
@@ -101,7 +106,8 @@ const {
   qiweiRelayConfig,
   qiweiRelaySaveConfig,
   qiweiRelayRegister,
-  qiweiRelayConnect
+  qiweiRelayConnect,
+  qiweiRelayStart
 } = require('./tools/qiwei-webhook-relay-run');
 const {
   qiweiAgentDashboardStart,
@@ -1118,6 +1124,61 @@ function createServer() {
     wrap(qiweiTranscribeVoice)
   );
 
+  server.registerTool(
+    'qiwei_voice_profile_status',
+    {
+      title: '查询声音克隆状态',
+      description: '查询当前企微账号是否已配置云端 IndexTTS2,以及是否完成本人声音初始化。',
+      inputSchema: {
+        uid: z.string().optional(),
+        guid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiVoiceProfileStatus)
+  );
+
+  server.registerTool(
+    'qiwei_enroll_voice',
+    {
+      title: '初始化本人声音',
+      description: '保存并校验 5~30 秒本人参考录音,转换为账号隔离的 16kHz 单声道声音档案。',
+      inputSchema: {
+        filePath: z.string().optional(),
+        base64Audio: z.string().optional(),
+        originalName: z.string().optional(),
+        mime: z.string().optional(),
+        uid: z.string().optional(),
+        guid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiEnrollVoice)
+  );
+
+  server.registerTool(
+    'qiwei_send_cloned_voice',
+    {
+      title: '发送克隆企微语音',
+      description: '真实发送操作。合成本人音色、自动选择受控客服语气、编码 24kHz SILK 并发送给指定企微联系人。调用前必须获得人工确认。',
+      inputSchema: {
+        toId: z.string().min(1),
+        text: z.string().min(1).max(600),
+        context: z.string().optional(),
+        tone: z.enum(['auto', 'natural', 'friendly', 'apology', 'empathetic', 'reminder']).optional(),
+        confirmed: z.literal(true),
+        uid: z.string().optional(),
+        guid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      },
+      annotations: { destructiveHint: true, openWorldHint: true }
+    },
+    wrap(qiweiSendClonedVoice)
+  );
+
   server.registerTool(
     'qiwei_webhook_status',
     {
@@ -1251,7 +1312,7 @@ function createServer() {
     'qiwei_agent_dashboard_start',
     {
       title: '启动企微智能工作台',
-      description: '启动并预览当前项目的企微 Dashboard(默认 4320 端口),同时返回 Fmode 鉴权、企微席位、账号在线、测试白名单和 AI 监听五项状态与下一步动作。主控 Claude Code 会话首次使用时调用。',
+      description: '启动并预览当前项目的企微 Dashboard(默认 4320 端口),同时返回 Fmode 鉴权、企微席位、账号在线、测试白名单和企业回调状态。Dashboard 仅用于管理,不承担企业版消息接收。',
       inputSchema: {}
     },
     wrap(qiweiAgentDashboardStart)
@@ -1388,8 +1449,8 @@ function createServer() {
   server.registerTool(
     'qiwei_agent_listener',
     {
-      title: '启动或停止企微监听',
-      description: '控制真实企微消息监听。running=true 启动,false 停止。启动监听不等于自动发送,发送仍受全局策略、会话模式和白名单约束。',
+      title: '查看或控制企微消息接收',
+      description: '企业版公网回调自动常驻,不需要监听开关;个人版可用 running 控制本地主动监听。停止 Agent 处理请使用全局 paused,消息仍会接收和保存。',
       inputSchema: { running: z.boolean() }
     },
     wrap(qiweiAgentListener)
@@ -1422,6 +1483,15 @@ async function main() {
   const server = createServer();
   const transport = new StdioServerTransport();
   await server.connect(transport);
+  void (async () => {
+    const connected = await qiweiRelayConnect({});
+    if (connected.status !== 'ok') {
+      console.error(`[RelayBootstrap] ${connected.assistantMessage || '企业回调连接失败'}`);
+      return;
+    }
+    const started = await qiweiRelayStart({});
+    if (started.status !== 'ok') console.error(`[RelayBootstrap] ${started.assistantMessage || 'Relay 消费守护进程启动失败'}`);
+  })().catch(error => console.error('[RelayBootstrap] 企业回调启动异常:', error.message));
 }
 
 main().catch(error => {

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

@@ -5,6 +5,8 @@ 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 { getProductMode } = require('../core/product-mode');
+const { ensureRelayDaemon, getRelayDaemonStatus } = require('../core/relay-daemon');
 
 const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
 const DASHBOARD_PORT = Number(process.env.QIWEI_DASHBOARD_PORT || 4320);
@@ -68,6 +70,14 @@ async function dashboardAvailable() {
   return (await dashboardState()).available;
 }
 
+function agentService() {
+  return require('../dashboard/agent-service');
+}
+
+function localConversations() {
+  return agentService().getConversations();
+}
+
 const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
 
 async function qiweiAgentDashboardStart() {
@@ -123,18 +133,16 @@ async function qiweiAgentBindController(input = {}) {
 }
 
 async function qiweiAgentStatus() {
-  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动,请先调用 qiwei_agent_dashboard_start');
-  const [status, conversations] = await Promise.all([
-    requestJson('GET', '/api/agent/status'),
-    requestJson('GET', '/api/agent/conversations'),
-  ]);
+  const [status, conversations] = await Promise.all([agentService().getAgentStatus(), localConversations()]);
   const items = conversations.data?.conversations || [];
   const readySessions = items.filter(item => item.claudeSession?.ready).length;
+  const ingress = status.data?.ingress || {};
   return okResult({
-    assistantMessage: `企微 Agent ${status.data?.listener?.running ? '监听中' : '监听已停止'},${items.length} 个客户会话,已识别 ${readySessions} 个可审阅的客户 Claude Code Session。`,
+    assistantMessage: `企微 Agent ${ingress.running ? '回调接收中' : '回调接收未运行'},${items.length} 个客户会话,已识别 ${readySessions} 个可审阅的客户 Claude Code Session。`,
     summary: {
       online: Boolean(status.data?.account?.online),
-      listenerRunning: Boolean(status.data?.listener?.running),
+      callbackRunning: Boolean(ingress.running),
+      ingressMode: ingress.mode || null,
       global: status.data?.global || null,
       provider: status.data?.agent?.provider || null,
       model: status.data?.agent?.model || null,
@@ -146,8 +154,7 @@ async function qiweiAgentStatus() {
 }
 
 async function qiweiAgentListConversations() {
-  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动');
-  const result = await requestJson('GET', '/api/agent/conversations');
+  const result = localConversations();
   const conversations = (result.data?.conversations || []).map(item => ({
     id: item.id,
     displayName: item.displayName,
@@ -173,8 +180,7 @@ async function qiweiAgentListConversations() {
 }
 
 async function qiweiAgentSessionGuide(input = {}) {
-  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动,请先调用 qiwei_agent_dashboard_start');
-  const result = await requestJson('GET', '/api/agent/conversations');
+  const result = localConversations();
   const conversations = result.data?.conversations || [];
   let matches = conversations;
   if (input.conversationId) matches = matches.filter(item => item.id === input.conversationId);
@@ -205,8 +211,7 @@ async function qiweiAgentSessionGuide(input = {}) {
 }
 
 async function qiweiAgentCustomerIntelligence(input = {}) {
-  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动,请先调用 qiwei_agent_dashboard_start');
-  const result = await requestJson('GET', '/api/agent/conversations');
+  const result = localConversations();
   let matches = result.data?.conversations || [];
   if (input.conversationId) matches = matches.filter(item => item.id === input.conversationId);
   if (input.customerName) matches = matches.filter(item => String(item.displayName || '').includes(String(input.customerName)));
@@ -230,9 +235,8 @@ async function qiweiAgentCustomerIntelligence(input = {}) {
 }
 
 async function qiweiAgentInbox(input = {}) {
-  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动');
   const limit = Math.max(1, Math.min(200, Number(input.limit || 50)));
-  const result = await requestJson('GET', `/api/agent/audit?limit=${limit}`);
+  const result = agentService().getAudit(limit);
   const audit = result.data?.audit || [];
   return okResult({
     assistantMessage: `主控事件箱读取完成:最近 ${audit.length} 条事件。`,
@@ -242,22 +246,28 @@ async function qiweiAgentInbox(input = {}) {
 }
 
 async function qiweiAgentListener(input = {}) {
-  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动');
-  const action = input.running === false ? 'stop' : 'start';
-  const result = await requestJson('POST', `/api/agent/listener/${action}`, {});
+  const product = getProductMode();
+  if (product.mode === 'enterprise') {
+    const status = input.running === false ? getRelayDaemonStatus() : ensureRelayDaemon();
+    return okResult({
+      assistantMessage: input.running === false
+        ? '企业版使用常驻公网回调,不能通过 AI 监听开关关闭;如需停止处理请设置全局策略为 paused'
+        : '企业版公网回调会自动常驻,不需要启动 AI 监听',
+      data: { ingress: status, productMode: product.mode }
+    });
+  }
+  const result = input.running === false ? agentService().stopListener() : await agentService().startListener();
   return okResult({ assistantMessage: result.assistantMessage, data: result.data });
 }
 
 async function qiweiAgentSetGlobal(input = {}) {
-  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动');
-  const result = await requestJson('POST', '/api/agent/mode', { mode: input.mode });
+  const result = agentService().changeGlobalMode(input.mode);
   return okResult({ assistantMessage: result.assistantMessage, data: result.data });
 }
 
 async function qiweiAgentGenerateDraft(input = {}) {
-  if (!(await dashboardAvailable())) return errorResult('企微智能工作台尚未启动');
   if (!input.conversationId) return errorResult('缺少 conversationId,请先调用 qiwei_agent_list_conversations');
-  const result = await requestJson('POST', `/api/agent/conversations/${encodeURIComponent(input.conversationId)}/generate`, {}, 180000);
+  const result = await agentService().generateLatestDraft(input.conversationId);
   const guideResult = await qiweiAgentSessionGuide({ conversationId: input.conversationId });
   const guide = guideResult.data?.session || null;
   return okResult({

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

@@ -404,7 +404,7 @@ async function qiweiLoginCheck(input = {}) {
 
       const assistantMessage = product.mode === 'enterprise'
         ? relaySetup?.success
-          ? `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。企业版 Relay 回调已自动配置:${relaySetup.callbackUrl}。请保持 npm run relay 运行。`
+          ? `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。企业版 Relay 回调已自动配置:${relaySetup.callbackUrl}。Relay 消费守护进程会由 MCP/Skill 自动保持运行。`
           : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。当前为企业版,但 Relay 接入尚未完成,请检查企业 Relay 配置。`
         : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。当前为个人版,消息由本地主动监听处理并保存在当前项目。`;
 

+ 193 - 11
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-voice-run.js

@@ -1,7 +1,12 @@
 const fs = require('fs');
 const path = require('path');
+const dns = require('dns');
+const https = require('https');
+const net = require('net');
 const { okResult, errorResult } = require('../core/result-envelope');
-const { createRunDir, outputsRoot } = require('../core/output-paths');
+const { createRunDir, outputsRoot, PACKAGE_ROOT } = require('../core/output-paths');
+const { VoiceCloneService } = require('../core/voice-clone-service');
+const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
 const {
   buildContext,
   gatewayCall,
@@ -16,36 +21,112 @@ function ensureVoiceDir() {
   fs.mkdirSync(voiceDir(), { recursive: true });
 }
 
+const MAX_VOICE_BYTES = 20 * 1024 * 1024;
+const VOICE_DOWNLOAD_TIMEOUT_MS = 30000;
+const MAX_VOICE_REDIRECTS = 3;
+
+function isPrivateAddress(address) {
+  const normalized = String(address || '').toLowerCase().split('%')[0];
+  const family = net.isIP(normalized);
+  if (family === 4) {
+    const [a, b] = normalized.split('.').map(Number);
+    return a === 0 || a === 10 || a === 127 || a >= 224
+      || (a === 100 && b >= 64 && b <= 127)
+      || (a === 169 && b === 254)
+      || (a === 172 && b >= 16 && b <= 31)
+      || (a === 192 && b === 168)
+      || (a === 198 && (b === 18 || b === 19));
+  }
+  if (family === 6) {
+    if (normalized.startsWith('::ffff:')) return isPrivateAddress(normalized.slice(7));
+    return normalized === '::' || normalized === '::1'
+      || normalized.startsWith('fc') || normalized.startsWith('fd')
+      || /^fe[89ab]/.test(normalized);
+  }
+  return true;
+}
+
+async function resolvePublicAddress(hostname) {
+  const addresses = await dns.promises.lookup(hostname, { all: true, verbatim: true });
+  if (!addresses.length || addresses.some(item => isPrivateAddress(item.address))) {
+    throw new Error('语音 URL 不允许访问本机或内网地址');
+  }
+  return addresses[0];
+}
+
 function extensionFromBuffer(buffer) {
   if (!buffer || buffer.length < 4) return 'bin';
   const header = buffer.slice(0, 4).toString('hex');
   if (header.startsWith('23414d52') || header.startsWith('02152353')) return 'silk';
   if (header.startsWith('52494646')) return 'wav';
   if (header.startsWith('494433') || header.startsWith('fffb') || header.startsWith('fff3')) return 'mp3';
+  if (buffer.slice(4, 12).toString('ascii').includes('ftyp')) return 'm4a';
   return 'bin';
 }
 
-function decodeSilk(inputPath, outputPath) {
+async function decodeSilk(inputPath, outputPath) {
   let wxVoice;
   try {
-    wxVoice = require('@binsee/wx-voice');
+    const module = require('@binsee/wx-voice');
+    wxVoice = module.WxVoice || module.default || module;
   } catch {
     return { ok: false, error: '未安装 @binsee/wx-voice,无法解码 silk;请运行 npm install @binsee/wx-voice' };
   }
   try {
     const voice = new wxVoice();
-    voice.decode(inputPath, outputPath, { format: 'mp3' });
+    voice.on('error', () => {});
+    await voice.decode(inputPath, outputPath, { format: 'mp3' });
     return { ok: true };
   } catch (error) {
     return { ok: false, error: error && error.message ? error.message : String(error) };
   }
 }
 
-async function fetchVoice(url) {
-  const response = await fetch(url);
-  if (!response.ok) throw new Error(`下载语音失败: HTTP ${response.status}`);
-  const arrayBuffer = await response.arrayBuffer();
-  return Buffer.from(arrayBuffer);
+async function fetchVoice(url, redirects = 0) {
+  const target = new URL(String(url || ''));
+  if (target.protocol !== 'https:' || target.username || target.password) {
+    throw new Error('语音 URL 只允许使用不含凭据的 HTTPS 地址');
+  }
+  if (redirects > MAX_VOICE_REDIRECTS) throw new Error('语音 URL 重定向次数过多');
+  const resolved = await resolvePublicAddress(target.hostname);
+  return new Promise((resolve, reject) => {
+    const request = https.get(target, {
+      lookup: (_hostname, _options, callback) => callback(null, resolved.address, resolved.family),
+      servername: target.hostname,
+      headers: { Accept: 'audio/*,application/octet-stream;q=0.8' },
+    }, response => {
+      if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
+        response.resume();
+        fetchVoice(new URL(response.headers.location, target).toString(), redirects + 1).then(resolve, reject);
+        return;
+      }
+      if (response.statusCode < 200 || response.statusCode >= 300) {
+        response.resume();
+        reject(new Error(`下载语音失败: HTTP ${response.statusCode}`));
+        return;
+      }
+      const declared = Number(response.headers['content-length']) || 0;
+      if (declared > MAX_VOICE_BYTES) {
+        response.destroy();
+        reject(new Error('语音文件不能超过 20MB'));
+        return;
+      }
+      const chunks = [];
+      let size = 0;
+      response.on('data', chunk => {
+        size += chunk.length;
+        if (size > MAX_VOICE_BYTES) {
+          response.destroy(new Error('语音文件不能超过 20MB'));
+          return;
+        }
+        chunks.push(chunk);
+      });
+      response.on('end', () => resolve(Buffer.concat(chunks, size)));
+      response.on('error', reject);
+    });
+    request.setTimeout(VOICE_DOWNLOAD_TIMEOUT_MS, () => request.destroy(new Error('下载语音超时')));
+    request.on('error', reject);
+  });
 }
 
 async function transcribeWithFmodeListen(audioPath, apiBase, token) {
@@ -80,12 +161,15 @@ const qiweiTranscribeVoice = safeResult(async function qiweiTranscribeVoice(inpu
 
   if (filePath) {
     if (!fs.existsSync(filePath)) return errorResult(`语音文件不存在: ${filePath}`);
+    if (fs.statSync(filePath).size > MAX_VOICE_BYTES) return errorResult('语音文件不能超过 20MB');
     sourceBuffer = fs.readFileSync(filePath);
     sourceExt = path.extname(filePath).replace(/^\./, '') || extensionFromBuffer(sourceBuffer);
     sourcePath = path.join(runDir, `source.${sourceExt}`);
     fs.copyFileSync(filePath, sourcePath);
   } else if (base64Audio) {
+    if (base64Audio.length > Math.ceil(MAX_VOICE_BYTES / 3) * 4 + 16) return errorResult('语音文件不能超过 20MB');
     sourceBuffer = Buffer.from(base64Audio, 'base64');
+    if (sourceBuffer.length > MAX_VOICE_BYTES) return errorResult('语音文件不能超过 20MB');
     sourceExt = extensionFromBuffer(sourceBuffer);
     sourcePath = path.join(runDir, `source.${sourceExt}`);
     fs.writeFileSync(sourcePath, sourceBuffer);
@@ -100,7 +184,7 @@ const qiweiTranscribeVoice = safeResult(async function qiweiTranscribeVoice(inpu
   let decoded = false;
   if (sourceExt === 'silk') {
     const decodedPath = path.join(runDir, 'audio.mp3');
-    const result = decodeSilk(sourcePath, decodedPath);
+    const result = await decodeSilk(sourcePath, decodedPath);
     if (result.ok) {
       audioPath = decodedPath;
       decoded = true;
@@ -146,6 +230,104 @@ const qiweiTranscribeVoice = safeResult(async function qiweiTranscribeVoice(inpu
   });
 });
 
+function readVoiceEnv() {
+  const env = {};
+  for (const filePath of [path.join(PACKAGE_ROOT, '.env.local'), path.join(process.cwd(), '.env.local')]) {
+    try {
+      for (const rawLine of fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/)) {
+        const match = rawLine.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
+        if (!match || env[match[1]]) continue;
+        let value = match[2].trim();
+        if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
+        env[match[1]] = value;
+      }
+    } catch {}
+  }
+  return env;
+}
+
+function voiceRuntime(input = {}) {
+  const env = readVoiceEnv();
+  const qiwei = new FmodeQiweiClient({
+    authToken: input.authToken,
+    uid: input.uid,
+    guid: input.guid,
+    apiBase: input.apiBase,
+  });
+  return new VoiceCloneService({
+    qiwei,
+    config: {
+      endpoint: process.env.QIWEI_TTS_ENDPOINT || env.QIWEI_TTS_ENDPOINT,
+      apiKey: process.env.QIWEI_TTS_API_KEY || env.QIWEI_TTS_API_KEY,
+      model: process.env.QIWEI_TTS_MODEL || env.QIWEI_TTS_MODEL,
+      requestTimeoutMs: process.env.QIWEI_TTS_TIMEOUT_MS || env.QIWEI_TTS_TIMEOUT_MS,
+    },
+  });
+}
+
+const qiweiVoiceProfileStatus = safeResult(async function qiweiVoiceProfileStatus(input = {}) {
+  const status = voiceRuntime(input).status();
+  return okResult({
+    assistantMessage: status.enrolled ? '本人声音已初始化' : '尚未初始化本人声音',
+    summary: { configured: status.configured, enrolled: status.enrolled, model: status.model },
+    data: status,
+  });
+});
+
+const qiweiEnrollVoice = safeResult(async function qiweiEnrollVoice(input = {}) {
+  let filePath = input.filePath ? path.resolve(String(input.filePath)) : '';
+  let temporary = false;
+  if (!filePath && input.base64Audio) {
+    const encoded = String(input.base64Audio);
+    if (encoded.length > Math.ceil(MAX_VOICE_BYTES / 3) * 4 + 16) return errorResult('声音参考文件不能超过 20MB');
+    const buffer = Buffer.from(encoded, 'base64');
+    if (buffer.length > MAX_VOICE_BYTES) return errorResult('声音参考文件不能超过 20MB');
+    const ext = extensionFromBuffer(buffer);
+    const runDir = createRunDir('voice', 'enroll-source');
+    filePath = path.join(runDir, `source.${ext}`);
+    fs.writeFileSync(filePath, buffer);
+    temporary = true;
+  }
+  if (!filePath) return errorResult('缺少 filePath 或 base64Audio');
+  const runtime = voiceRuntime(input);
+  try {
+    const result = await runtime.enroll({
+      filePath,
+      originalName: input.originalName || path.basename(filePath),
+      mime: input.mime,
+    });
+    return okResult({
+      assistantMessage: '本人声音已初始化,可以生成企微语音',
+      summary: result.profile,
+      data: runtime.status(),
+      files: [runtime.profilePath(), runtime.metadataPath()],
+    });
+  } finally {
+    if (temporary) fs.rmSync(filePath, { force: true });
+  }
+});
+
+const qiweiSendClonedVoice = safeResult(async function qiweiSendClonedVoice(input = {}) {
+  const runtime = voiceRuntime(input);
+  const result = await runtime.synthesizeAndSend({
+    text: input.text,
+    context: input.context,
+    tone: input.tone || 'auto',
+    toId: input.toId,
+    confirmed: input.confirmed,
+  });
+  return okResult({
+    assistantMessage: `已用${result.tone.label}语气发送企微语音`,
+    summary: { tone: result.tone, duration: result.duration, msgServerId: result.sendResult?.msgServerId },
+    data: result,
+    files: [result.runDir],
+  });
+});
+
 module.exports = {
-  qiweiTranscribeVoice
+  qiweiTranscribeVoice,
+  qiweiVoiceProfileStatus,
+  qiweiEnrollVoice,
+  qiweiSendClonedVoice,
+  __testing: { extensionFromBuffer, readVoiceEnv, voiceRuntime, isPrivateAddress, resolvePublicAddress, fetchVoice }
 };

+ 57 - 40
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-webhook-relay-run.js

@@ -1,6 +1,5 @@
 const fs = require('fs');
 const path = require('path');
-const { fork } = require('child_process');
 const { okResult, errorResult } = require('../core/result-envelope');
 const {
   buildContext,
@@ -32,6 +31,7 @@ const {
 const { recordDeviceGuid } = require('../core/device-broker-mapping');
 const { isRelayEnabled, getRelayBaseUrl, getTenantApiKey, getTenantApiSecret, getTenantId, getRelayPrivateKey, getRelayCredentials, saveRelayCredentials, writeRelayConfigFile, readRelayConfigFile } = require('../core/relay-config');
 const { getProductMode } = require('../core/product-mode');
+const { getRelayDaemonStatus, ensureRelayDaemon, stopRelayDaemon } = require('../core/relay-daemon');
 
 const REQUIRED_METHODS = {
   setCallback: '/client/setCallback'
@@ -44,12 +44,13 @@ function generateRelaySecret() {
   return result;
 }
 
-async function fetchRelay(baseUrl, path, apiSecret, body) {
+async function fetchRelay(baseUrl, path, apiSecret, body, headers = {}) {
   const res = await fetch(`${baseUrl}${path}`, {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
-      Authorization: `Bearer ${apiSecret}`
+      Authorization: `Bearer ${apiSecret}`,
+      ...headers
     },
     body: JSON.stringify(body || {})
   });
@@ -68,6 +69,20 @@ async function fetchRelay(baseUrl, path, apiSecret, body) {
   return json;
 }
 
+async function relayTenantHasDevice(baseUrl, apiSecret, guid) {
+  try {
+    const response = await fetch(`${baseUrl}/api/tenant/status`, {
+      headers: { Authorization: `Bearer ${apiSecret}` },
+      signal: AbortSignal.timeout(15000)
+    });
+    if (!response.ok) return false;
+    const data = await response.json();
+    return Array.isArray(data.devices) && data.devices.some(device => String(device.guid) === String(guid));
+  } catch {
+    return false;
+  }
+}
+
 async function connectRelayThroughFmode(input = {}) {
   const ctx = buildContext(input);
   const result = await callFmodeWecomGateway({
@@ -150,6 +165,7 @@ async function ensureRelayWebhookConfigured(input = {}, options = {}) {
   const apiSecret = getTenantApiSecret();
   const tenantId = getTenantId();
   const guid = String(input.guid || readQiweiGuid(input) || '').trim();
+  const fmodeToken = readQiweiAuthToken(input);
 
   if (!guid) {
     return { success: false, error: '缺少 guid,请先完成企微设备登录' };
@@ -161,9 +177,18 @@ async function ensureRelayWebhookConfigured(input = {}, options = {}) {
   // Register the device before enabling the global callback so routing is ready
   // when the first event arrives.
   try {
-    await fetchRelay(baseUrl, '/api/tenant/device', apiSecret, { guid });
+    await fetchRelay(
+      baseUrl,
+      '/api/tenant/device',
+      apiSecret,
+      { guid },
+      fmodeToken ? { 'X-Fmode-Token': fmodeToken } : {}
+    );
   } catch (err) {
-    return { success: false, error: `Relay device registration failed: ${err.message}` };
+    const alreadyOwned = /assigned to multiple tenants|already assigned/i.test(String(err.message))
+      && await relayTenantHasDevice(baseUrl, apiSecret, guid);
+    if (!alreadyOwned) return { success: false, error: `Relay device registration failed: ${err.message}` };
+    console.warn('[Relay] 当前租户已包含该设备,忽略历史重复租户注册冲突并继续连接');
   }
 
   const callbackUrl = `${baseUrl}/api/webhook/ingest`;
@@ -181,6 +206,8 @@ async function ensureRelayWebhookConfigured(input = {}, options = {}) {
 
   writeConfig({ callbackUrl, secret: null });
   writeRelayConfigFile({ deviceGuid: guid, tenantId });
+  saveQiweiClientConfig({ guid, apiBase: readQiweiApiBase(input) });
+  recordDeviceGuid(guid);
 
   return { success: true, callbackUrl, source: 'fmode_relay' };
 }
@@ -326,7 +353,7 @@ const qiweiWebhookAutoSetup = safeResult(async function qiweiWebhookAutoSetup(in
     });
   }
   return okResult({
-    assistantMessage: `已自动配置 Relay 回调:${result.callbackUrl}。请在服务器上执行 npm run relay 启动轮询客户端。`,
+    assistantMessage: `已自动配置 Relay 回调:${result.callbackUrl}。Relay 消费守护进程会自动启动和恢复。`,
     summary: { callbackUrl: result.callbackUrl, source: result.source },
     data: { callbackUrl: result.callbackUrl, source: result.source }
   });
@@ -487,10 +514,10 @@ const qiweiRelayConnect = safeResult(async function qiweiRelayConnect(input = {}
   }
 
   return okResult({
-    assistantMessage: `Relay 回调已配置:${result.callbackUrl}。请在服务器上执行 npm run relay 启动轮询客户端(或使用 systemd/pm2 持久化运行)。`,
+    assistantMessage: `Relay 回调已配置:${result.callbackUrl}。Relay 消费守护进程会自动启动和恢复,无需 Dashboard。`,
     summary: { callbackUrl: result.callbackUrl, source: result.source },
     data: { callbackUrl: result.callbackUrl, source: result.source },
-    nextActions: ['执行 npm run relay 启动轮询客户端', '或配置 systemd 服务持久化运行']
+    nextActions: ['Relay 消费守护进程会由 MCP/Skill 自动启动,无需启动 Dashboard']
   });
 });
 
@@ -498,8 +525,6 @@ const qiweiRelayConnect = safeResult(async function qiweiRelayConnect(input = {}
 // 阶段 6:webhook 状态 / 重放 / 清理 / Relay 子进程控制
 // ============================================================
 
-let relayChildProcess = null;
-
 function webhookDir() {
   return path.join(outputsRoot(), 'webhook');
 }
@@ -562,13 +587,19 @@ function getWebhookEventStats() {
       counts.OTHER = (counts.OTHER || 0) + 1;
     }
   }
+  const daemon = getRelayDaemonStatus();
   return {
     total: files.length,
     pendingCount: counts.PENDING + counts.AUTO_GROUP_CREATED,
     counts,
     lastReceivedAt,
     lastProcessedType,
-    relayRunning: !!(relayChildProcess && !relayChildProcess.killed && relayChildProcess.exitCode === null)
+    relayRunning: daemon.running,
+    relayProcessAlive: daemon.processAlive,
+    relayPid: daemon.pid,
+    relayHeartbeatAt: daemon.heartbeatAt,
+    relayLastPollAt: daemon.lastPollAt,
+    relayLastError: daemon.lastError
   };
 }
 
@@ -659,18 +690,15 @@ const qiweiWebhookPurge = safeResult(async function qiweiWebhookPurge(input = {}
 });
 
 function getRelayChildStatus() {
-  return {
-    running: !!(relayChildProcess && !relayChildProcess.killed && relayChildProcess.exitCode === null),
-    pid: relayChildProcess ? relayChildProcess.pid : null
-  };
+  return getRelayDaemonStatus();
 }
 
 const qiweiRelayStart = safeResult(async function qiweiRelayStart(input = {}) {
   const product = getProductMode(input);
   if (product.mode !== 'enterprise') return errorResult('当前为个人版,不启动企业 Relay Client');
-  if (getRelayChildStatus().running) {
+  if (getRelayChildStatus().processAlive) {
     return okResult({
-      assistantMessage: `Relay 子进程已在运行中,pid=${relayChildProcess.pid}`,
+      assistantMessage: `Relay 消费守护进程已在运行中,pid=${getRelayChildStatus().pid}`,
       summary: getRelayChildStatus()
     });
   }
@@ -678,36 +706,24 @@ const qiweiRelayStart = safeResult(async function qiweiRelayStart(input = {}) {
   const guid = String(input.guid || readQiweiGuid(input) || '').trim();
   if (!guid) return errorResult('缺少 guid,请先完成企微设备登录或显式传入 guid');
 
-  const scriptPath = path.join(process.cwd(), 'scripts', 'start-relay-client.js');
-  relayChildProcess = fork(scriptPath, [guid], {
-    detached: false,
-    stdio: ['ignore', 'pipe', 'pipe', 'ipc']
-  });
-
-  relayChildProcess.on('exit', (code) => {
-    console.log(`[MCP] Relay 子进程退出,code=${code}`);
-    relayChildProcess = null;
-  });
-  relayChildProcess.on('error', (err) => {
-    console.error('[MCP] Relay 子进程错误:', err.message);
-  });
+  const started = ensureRelayDaemon({ ...input, guid });
 
   return okResult({
-    assistantMessage: `Relay 子进程已启动,pid=${relayChildProcess.pid}`,
-    summary: getRelayChildStatus(),
-    nextActions: ['调用 qiwei_relay_stop 停止子进程']
+    assistantMessage: started.started
+      ? `Relay 消费守护进程已启动,pid=${started.pid}`
+      : `Relay 消费守护进程状态:${started.reason}`,
+    summary: started
   });
 });
 
 const qiweiRelayStop = safeResult(async function qiweiRelayStop() {
-  if (!getRelayChildStatus().running) {
-    return okResult({ assistantMessage: 'Relay 进程未运行', summary: getRelayChildStatus() });
+  if (!getRelayChildStatus().processAlive) {
+    return okResult({ assistantMessage: 'Relay 消费守护进程未运行', summary: getRelayChildStatus() });
   }
-  const pid = relayChildProcess.pid;
-  relayChildProcess.kill('SIGTERM');
+  const result = stopRelayDaemon();
   return okResult({
-    assistantMessage: `已发送停止信号给 Relay 子进程 pid=${pid}`,
-    summary: { stopped: true, pid }
+    assistantMessage: `已发送停止信号给 Relay 消费守护进程 pid=${result.pid}`,
+    summary: result
   });
 });
 
@@ -729,5 +745,6 @@ module.exports = {
   ensureRelayWebhookConfigured,
   ensureLocalWebhookConfigured,
   connectRelayThroughFmode,
-  registerRelayTenant
+  registerRelayTenant,
+  relayTenantHasDevice
 };

+ 554 - 0
claude-code/claude-code-qiwe-assistant/package-lock.json

@@ -8,6 +8,9 @@
       "name": "fmode-qiwei",
       "version": "0.4.3",
       "dependencies": {
+        "@binsee/wx-voice": "^0.3.2",
+        "@ffmpeg-installer/ffmpeg": "^1.1.0",
+        "@ffprobe-installer/ffprobe": "^2.1.2",
         "@modelcontextprotocol/sdk": "^1.12.1",
         "cross-spawn": "7.0.6",
         "read-excel-file": "^9.3.2",
@@ -21,6 +24,365 @@
         "node": ">=22.5.0"
       }
     },
+    "node_modules/@binsee/wx-voice": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmmirror.com/@binsee/wx-voice/-/wx-voice-0.3.2.tgz",
+      "integrity": "sha512-N21+Cxck47JuNLNdQj27VPmEbEOBL7TbWfSi/AJc7P+0Nj8T08+qJ5VfMSXr1bXIz9jYvsZsSFqmocciM2S+kQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@ffmpeg-installer/ffmpeg": "1.1.0",
+        "@ffprobe-installer/ffprobe": "2.1.2",
+        "cli-spinner": "^0.2.6",
+        "command-line-args": "^4.0.7",
+        "randomatic": "^1.1.7",
+        "read-chunk": "^2.1.0",
+        "strong-data-uri": "^1.0.4"
+      },
+      "bin": {
+        "wx-voice": "dist/bin.js"
+      },
+      "optionalDependencies": {
+        "@binsee/wx-voice-silk-darwin-arm64": "0.3.2",
+        "@binsee/wx-voice-silk-darwin-x64": "0.3.2",
+        "@binsee/wx-voice-silk-linux-arm64": "0.3.2",
+        "@binsee/wx-voice-silk-linux-x64": "0.3.2",
+        "@binsee/wx-voice-silk-win32-arm64": "0.3.2",
+        "@binsee/wx-voice-silk-win32-x64": "0.3.2"
+      }
+    },
+    "node_modules/@binsee/wx-voice-silk-darwin-arm64": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmmirror.com/@binsee/wx-voice-silk-darwin-arm64/-/wx-voice-silk-darwin-arm64-0.3.2.tgz",
+      "integrity": "sha512-CozhQwdD5+Cvu1bc01QBEssF49wxJIRbq7vKxMAhg8n1NwpMNmFE6B41a7DH8Y/KZgZTXbbO6N1PLFOY2vgM7Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@binsee/wx-voice-silk-darwin-x64": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmmirror.com/@binsee/wx-voice-silk-darwin-x64/-/wx-voice-silk-darwin-x64-0.3.2.tgz",
+      "integrity": "sha512-RAS9N2uRY38Y8RDZzIB1MbZIugoNxDycMluaI7y8cFO/iP4vLEf7jdRqIuhvWCVAWHC1gcAvJYXLkbUx2bhx2A==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@binsee/wx-voice-silk-linux-arm64": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmmirror.com/@binsee/wx-voice-silk-linux-arm64/-/wx-voice-silk-linux-arm64-0.3.2.tgz",
+      "integrity": "sha512-4rHM6UpZegutkfUso0sCjyWkXahNSiM4nJuR4wMzqIHsmfXtTQ0q0PgcvZFKSG0AfxgTdekpGnUh+dZHFPuU4w==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@binsee/wx-voice-silk-linux-x64": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmmirror.com/@binsee/wx-voice-silk-linux-x64/-/wx-voice-silk-linux-x64-0.3.2.tgz",
+      "integrity": "sha512-b33F1aXYd8HskYVANdO6VaJ7Nt2ObrGstZ3JuZyoMcTsISp8p0Qsb+84mK/qK/A5Ol8XkeMcQVTLwwqJCne9mA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@binsee/wx-voice-silk-win32-arm64": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmmirror.com/@binsee/wx-voice-silk-win32-arm64/-/wx-voice-silk-win32-arm64-0.3.2.tgz",
+      "integrity": "sha512-ij3+NBTgVFeIHlAEYjdlvBA9FacQo8h82J1dLdZjOmhm8c5pqrZqlNn1RU81Dx9yvdKlv+NBwvI3Mo855zq5ww==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@binsee/wx-voice-silk-win32-x64": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmmirror.com/@binsee/wx-voice-silk-win32-x64/-/wx-voice-silk-win32-x64-0.3.2.tgz",
+      "integrity": "sha512-f9r6kiOLqd8cfpb+2snbAlFxIcF0pfJSlzx6bAYcO5qYgj3w8E+a8o45AGGIva2dNp/9Zrr+Spdf++QTnIPB8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@ffmpeg-installer/darwin-arm64": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz",
+      "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==",
+      "cpu": [
+        "arm64"
+      ],
+      "hasInstallScript": true,
+      "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@ffmpeg-installer/darwin-x64": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz",
+      "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==",
+      "cpu": [
+        "x64"
+      ],
+      "hasInstallScript": true,
+      "license": "LGPL-2.1",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@ffmpeg-installer/ffmpeg": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz",
+      "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==",
+      "license": "LGPL-2.1",
+      "optionalDependencies": {
+        "@ffmpeg-installer/darwin-arm64": "4.1.5",
+        "@ffmpeg-installer/darwin-x64": "4.1.0",
+        "@ffmpeg-installer/linux-arm": "4.1.3",
+        "@ffmpeg-installer/linux-arm64": "4.1.4",
+        "@ffmpeg-installer/linux-ia32": "4.1.0",
+        "@ffmpeg-installer/linux-x64": "4.1.0",
+        "@ffmpeg-installer/win32-ia32": "4.1.0",
+        "@ffmpeg-installer/win32-x64": "4.1.0"
+      }
+    },
+    "node_modules/@ffmpeg-installer/linux-arm": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz",
+      "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==",
+      "cpu": [
+        "arm"
+      ],
+      "hasInstallScript": true,
+      "license": "GPLv3",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@ffmpeg-installer/linux-arm64": {
+      "version": "4.1.4",
+      "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz",
+      "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "hasInstallScript": true,
+      "license": "GPLv3",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@ffmpeg-installer/linux-ia32": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz",
+      "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "hasInstallScript": true,
+      "license": "GPLv3",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@ffmpeg-installer/linux-x64": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz",
+      "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==",
+      "cpu": [
+        "x64"
+      ],
+      "hasInstallScript": true,
+      "license": "GPLv3",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@ffmpeg-installer/win32-ia32": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz",
+      "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "GPLv3",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@ffmpeg-installer/win32-x64": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz",
+      "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "GPLv3",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@ffprobe-installer/darwin-arm64": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/@ffprobe-installer/darwin-arm64/-/darwin-arm64-5.0.1.tgz",
+      "integrity": "sha512-vwNCNjokH8hfkbl6m95zICHwkSzhEvDC3GVBcUp5HX8+4wsX10SP3B+bGur7XUzTIZ4cQpgJmEIAx6TUwRepMg==",
+      "cpu": [
+        "arm64"
+      ],
+      "hasInstallScript": true,
+      "license": "LGPL-2.1",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@ffprobe-installer/darwin-x64": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmmirror.com/@ffprobe-installer/darwin-x64/-/darwin-x64-5.1.0.tgz",
+      "integrity": "sha512-J+YGscZMpQclFg31O4cfVRGmDpkVsQ2fZujoUdMAAYcP0NtqpC49Hs3SWJpBdsGB4VeqOt5TTm1vSZQzs1NkhA==",
+      "cpu": [
+        "x64"
+      ],
+      "hasInstallScript": true,
+      "license": "GPL-3.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@ffprobe-installer/ffprobe": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/@ffprobe-installer/ffprobe/-/ffprobe-2.1.2.tgz",
+      "integrity": "sha512-ZNvwk4f2magF42Zji2Ese16SMj9BS7Fui4kRjg6gTYTxY3gWZNpg85n4MIfQyI9nimHg4x/gT6FVkp/bBDuBwg==",
+      "license": "LGPL-2.1",
+      "engines": {
+        "node": ">=14.21.2"
+      },
+      "optionalDependencies": {
+        "@ffprobe-installer/darwin-arm64": "5.0.1",
+        "@ffprobe-installer/darwin-x64": "5.1.0",
+        "@ffprobe-installer/linux-arm": "5.2.0",
+        "@ffprobe-installer/linux-arm64": "5.2.0",
+        "@ffprobe-installer/linux-ia32": "5.2.0",
+        "@ffprobe-installer/linux-x64": "5.2.0",
+        "@ffprobe-installer/win32-ia32": "5.1.0",
+        "@ffprobe-installer/win32-x64": "5.1.0"
+      }
+    },
+    "node_modules/@ffprobe-installer/linux-arm": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmmirror.com/@ffprobe-installer/linux-arm/-/linux-arm-5.2.0.tgz",
+      "integrity": "sha512-PF5HqEhCY7WTWHtLDYbA/+rLS+rhslWvyBlAG1Fk8VzVlnRdl93o6hy7DE2kJgxWQbFaR3ZktPQGEzfkrmQHvQ==",
+      "cpu": [
+        "arm"
+      ],
+      "hasInstallScript": true,
+      "license": "GPL-3.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@ffprobe-installer/linux-arm64": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmmirror.com/@ffprobe-installer/linux-arm64/-/linux-arm64-5.2.0.tgz",
+      "integrity": "sha512-X1VvWtlLs6ScP73biVLuHD5ohKJKsMTa0vafCESOen4mOoNeLAYbxOVxDWAdFz9cpZgRiloFj5QD6nDj8E28yQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "hasInstallScript": true,
+      "license": "GPL-3.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@ffprobe-installer/linux-ia32": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmmirror.com/@ffprobe-installer/linux-ia32/-/linux-ia32-5.2.0.tgz",
+      "integrity": "sha512-TFVK5sasXyXhbIG7LtPRDmtkrkOsInwKcL43iEvEw+D9vCS2rc//mn9/0Q+BR0UoJEiMK4+ApYr/3LLVUBPOCQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "hasInstallScript": true,
+      "license": "GPL-3.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@ffprobe-installer/linux-x64": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmmirror.com/@ffprobe-installer/linux-x64/-/linux-x64-5.2.0.tgz",
+      "integrity": "sha512-D3UeqTLYPNs7pBWPLUYGehPdRVqU8eACox4OZy3pZUZatxye2YKlvBwEfaLdL1v2Z4FOAlLUhms0kY8m8kqSRA==",
+      "cpu": [
+        "x64"
+      ],
+      "hasInstallScript": true,
+      "license": "GPL-3.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@ffprobe-installer/win32-ia32": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmmirror.com/@ffprobe-installer/win32-ia32/-/win32-ia32-5.1.0.tgz",
+      "integrity": "sha512-5O3vOoNRxmut0/Nu9vSazTdSHasrr+zPT2B3Hm7kjmO3QVFcIfVImS6ReQnZeSy8JPJOqXts5kX5x/3KOX54XQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "GPL-3.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@ffprobe-installer/win32-x64": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmmirror.com/@ffprobe-installer/win32-x64/-/win32-x64-5.1.0.tgz",
+      "integrity": "sha512-jMGYeAgkrdn4e2vvYt/qakgHRE3CPju4bn5TmdPfoAm1BlX1mY9cyMd8gf5vSzI8gH8Zq5WQAyAkmekX/8TSTg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "GPL-3.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
     "node_modules/@hono/node-server": {
       "version": "2.0.11",
       "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.11.tgz",
@@ -111,6 +473,18 @@
         }
       }
     },
+    "node_modules/array-back": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/array-back/-/array-back-2.0.0.tgz",
+      "integrity": "sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==",
+      "license": "MIT",
+      "dependencies": {
+        "typical": "^2.6.1"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/body-parser": {
       "version": "2.3.0",
       "license": "MIT",
@@ -176,6 +550,29 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/cli-spinner": {
+      "version": "0.2.10",
+      "resolved": "https://registry.npmmirror.com/cli-spinner/-/cli-spinner-0.2.10.tgz",
+      "integrity": "sha512-U0sSQ+JJvSLi1pAYuJykwiA8Dsr15uHEy85iCJ6A+0DjVxivr3d+N2Wjvodeg89uP5K6TswFkKBfAD7B3YSn/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/command-line-args": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmmirror.com/command-line-args/-/command-line-args-4.0.7.tgz",
+      "integrity": "sha512-aUdPvQRAyBvQd2n7jXcsMDz68ckBJELXNzBybCHOibUWEg0mWTnaYCSRU8h9R+aNRSvDihJtssSRCiDRpLaezA==",
+      "license": "MIT",
+      "dependencies": {
+        "array-back": "^2.0.0",
+        "find-replace": "^1.0.3",
+        "typical": "^2.6.1"
+      },
+      "bin": {
+        "command-line-args": "bin/cli.js"
+      }
+    },
     "node_modules/content-disposition": {
       "version": "1.1.0",
       "license": "MIT",
@@ -434,6 +831,31 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/find-replace": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/find-replace/-/find-replace-1.0.3.tgz",
+      "integrity": "sha512-KrUnjzDCD9426YnCP56zGYy/eieTnhtK6Vn++j+JJzmlsWWwEkDnsyVF575spT6HJ6Ow9tlbT3TQTDsa+O4UWA==",
+      "license": "MIT",
+      "dependencies": {
+        "array-back": "^1.0.4",
+        "test-value": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/find-replace/node_modules/array-back": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmmirror.com/array-back/-/array-back-1.0.4.tgz",
+      "integrity": "sha512-1WxbZvrmyhkNoeYcizokbmh5oiOCIfyvGtcqbK3Ls1v1fKcquzxnQSceOx6tzq7jmai2kFLWIpGND2cLhH6TPw==",
+      "license": "MIT",
+      "dependencies": {
+        "typical": "^2.6.0"
+      },
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
     "node_modules/forwarded": {
       "version": "0.2.0",
       "license": "MIT",
@@ -581,6 +1003,15 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/is-number": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz",
+      "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/is-promise": {
       "version": "4.0.0",
       "license": "MIT"
@@ -604,6 +1035,15 @@
       "version": "8.0.2",
       "license": "BSD-2-Clause"
     },
+    "node_modules/kind-of": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+      "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
       "license": "MIT",
@@ -611,6 +1051,12 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/math-random": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz",
+      "integrity": "sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==",
+      "license": "MIT"
+    },
     "node_modules/media-typer": {
       "version": "1.1.0",
       "license": "MIT",
@@ -722,6 +1168,15 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/pify": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/pify/-/pify-3.0.0.tgz",
+      "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/pkce-challenge": {
       "version": "5.0.1",
       "license": "MIT",
@@ -754,6 +1209,20 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/randomatic": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz",
+      "integrity": "sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==",
+      "license": "MIT",
+      "dependencies": {
+        "is-number": "^4.0.0",
+        "kind-of": "^6.0.0",
+        "math-random": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      }
+    },
     "node_modules/range-parser": {
       "version": "1.3.0",
       "license": "MIT",
@@ -778,6 +1247,19 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/read-chunk": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/read-chunk/-/read-chunk-2.1.0.tgz",
+      "integrity": "sha512-QQqB2O9KX/BnztP1xkTRsBxABcWHCXTqQYmEm/DdNId1nw+leKkZvV/g5oCKUrXolGtU3zouoIxMLToAYyDgDw==",
+      "license": "MIT",
+      "dependencies": {
+        "pify": "^3.0.0",
+        "safe-buffer": "^5.1.1"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/read-excel-file": {
       "version": "9.3.2",
       "resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.3.2.tgz",
@@ -813,6 +1295,26 @@
         "node": ">= 18"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
     "node_modules/safer-buffer": {
       "version": "2.1.2",
       "license": "MIT"
@@ -959,6 +1461,43 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/strong-data-uri": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmmirror.com/strong-data-uri/-/strong-data-uri-1.0.6.tgz",
+      "integrity": "sha512-zhzBZev0uhT2IrFUerenXhfaE0vFUYwAZsnG0gIKGpfM/Gi6jOUQ3cmcvyTsXeDLIPiTubHESeO7EbD6FoPmzw==",
+      "license": "Artistic-2.0",
+      "dependencies": {
+        "truncate": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/test-value": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/test-value/-/test-value-2.1.0.tgz",
+      "integrity": "sha512-+1epbAxtKeXttkGFMTX9H42oqzOTufR1ceCF+GYA5aOmvaPq9wd4PUS8329fn2RRLGNeUkgRLnVpycjx8DsO2w==",
+      "license": "MIT",
+      "dependencies": {
+        "array-back": "^1.0.3",
+        "typical": "^2.6.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/test-value/node_modules/array-back": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmmirror.com/array-back/-/array-back-1.0.4.tgz",
+      "integrity": "sha512-1WxbZvrmyhkNoeYcizokbmh5oiOCIfyvGtcqbK3Ls1v1fKcquzxnQSceOx6tzq7jmai2kFLWIpGND2cLhH6TPw==",
+      "license": "MIT",
+      "dependencies": {
+        "typical": "^2.6.0"
+      },
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
     "node_modules/toidentifier": {
       "version": "1.0.1",
       "license": "MIT",
@@ -966,6 +1505,15 @@
         "node": ">=0.6"
       }
     },
+    "node_modules/truncate": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/truncate/-/truncate-2.1.0.tgz",
+      "integrity": "sha512-em3E3SUDONOjTBcZ36DTm3RvDded3IRU9rX32oHwwXNt3rJD5MVaFlJTQvs8tJoHRoeYP36OuQ1eL/Q7bNEWIQ==",
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/type-is": {
       "version": "2.1.0",
       "license": "MIT",
@@ -993,6 +1541,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/typical": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmmirror.com/typical/-/typical-2.6.1.tgz",
+      "integrity": "sha512-ofhi8kjIje6npGozTip9Fr8iecmYfEbS06i0JnIg+rh51KakryWF4+jX8lLKZVhy6N+ID45WYSFCxPOdTWCzNg==",
+      "license": "MIT"
+    },
     "node_modules/unpipe": {
       "version": "1.0.0",
       "license": "MIT",

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
claude-code/claude-code-qiwe-assistant/package.json


+ 78 - 1
claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js

@@ -23,6 +23,7 @@ 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 { friendlyAgentError } = require('../mcp/src/core/agent-error-message');
 
 const results = [];
 
@@ -79,6 +80,28 @@ async function check(name, fn) {
 }
 
 async function main() {
+  await check('invalid contact names cannot overwrite a real customer name', async () => {
+    const ctx = setup();
+    try {
+      const original = ctx.db.ensureConversation('contact-name-test', 'Valid Customer');
+      const corrupted = ctx.db.ensureConversation('contact-name-test', '??????');
+      assert.equal(corrupted.id, original.id);
+      assert.equal(corrupted.contact_name, 'Valid Customer');
+      const renamed = ctx.db.ensureConversation('contact-name-test', 'Renamed Customer');
+      assert.equal(renamed.contact_name, 'Renamed Customer');
+      const unnamed = ctx.db.ensureConversation('contact-unnamed-test', '????');
+      assert.equal(unnamed.contact_name, '');
+    } finally { ctx.close(); }
+  });
+
+  await check('Claude Code 原始错误会转换为可操作的用户提示', async () => {
+    assert.equal(friendlyAgentError('403 reached your usage limit for this billing cycle').code, 'quota_exhausted');
+    assert.match(friendlyAgentError('403 reached your usage limit for this billing cycle').message, /额度不足/);
+    assert.equal(friendlyAgentError('Failed to authenticate: invalid API key').code, 'authentication_failed');
+    assert.equal(friendlyAgentError('spawn claude ENOENT').code, 'cli_not_found');
+    assert.equal(friendlyAgentError('request timed out').code, 'timeout');
+  });
+
   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 格式无效/);
@@ -91,8 +114,11 @@ async function main() {
     const calls = [];
     const originalFetch = global.fetch;
     global.fetch = async (url, options = {}) => {
-      const parsedBody = options.body ? JSON.parse(options.body) : null;
+      const parsedBody = options.body && typeof options.body === 'string' ? JSON.parse(options.body) : null;
       calls.push({ url: String(url), options, body: parsedBody });
+      if (String(url).endsWith('/doFileApi')) {
+        return { ok: true, status: 200, async text() { return JSON.stringify({ code: 0, data: { data: { fileId: 'file-voice', fileAesKey: 'aes-voice', fileSize: 128 } } }); } };
+      }
       const loginStatus = String(url).includes('/login/status');
       let payload;
       if (loginStatus) {
@@ -121,6 +147,12 @@ async function main() {
       await client.syncMessages(8, 50);
       const contacts = await client.listExternalContacts();
       await client.sendText('external-contact-1', '测试回复');
+      const voiceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-voice-transport-'));
+      const voicePath = path.join(voiceDir, 'voice.silk');
+      fs.writeFileSync(voicePath, Buffer.from('#!SILK_V3'));
+      const uploaded = await client.uploadVoiceFile(voicePath);
+      await client.sendVoice('external-contact-1', { ...uploaded, voiceTime: 2 });
+      fs.rmSync(voiceDir, { recursive: true, force: true });
       assert.equal(account.online, true);
       assert.equal(account.nickname, '演示账号');
       assert.match(calls[0].url, /\/login\/status\?uid=uid-smoke$/);
@@ -133,6 +165,17 @@ async function main() {
       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');
+      assert.match(calls[5].url, /\/doFileApi$/);
+      assert.equal(calls[6].body.method, '/msg/sendVoice');
+      assert.equal(calls[6].body.params.voiceTime, 2);
+
+      let failedSendAttempts = 0;
+      global.fetch = async () => {
+        failedSendAttempts += 1;
+        throw new Error('ambiguous network failure');
+      };
+      await assert.rejects(() => client.sendVoice('external-contact-1', { ...uploaded, voiceTime: 2 }), /网络请求失败/);
+      assert.equal(failedSendAttempts, 1);
     } finally {
       global.fetch = originalFetch;
     }
@@ -153,6 +196,36 @@ async function main() {
     assert.equal(configB.qiwei.guid, accountB.guid);
   });
 
+  await check('发送语音后会结算当前待审核草稿并关联语音消息', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const draft = {
+      id: 'draft-voice-1',
+      conversation_id: 'conversation-1',
+      status: 'pending',
+      content: '原草稿',
+    };
+    const updates = [];
+    const db = {
+      getDraft(id) { return id === draft.id ? draft : null; },
+      listDrafts() { return [draft]; },
+      updateDraft(id, fields) {
+        updates.push({ id, fields });
+        return { ...draft, ...fields };
+      },
+    };
+    const selected = __testing.pendingVoiceDraft(db, 'conversation-1', draft.id);
+    const resolved = __testing.markVoiceDraftSent(db, selected, {
+      content: '实际发送的语音内容',
+      messageId: 'message-voice-1',
+    });
+    assert.equal(resolved.status, 'sent');
+    assert.equal(resolved.content, '实际发送的语音内容');
+    assert.equal(resolved.sent_message_id, 'message-voice-1');
+    assert.equal(resolved.reviewer, 'human:voice');
+    assert.equal(updates.length, 1);
+    assert.throws(() => __testing.pendingVoiceDraft(db, 'another-conversation', draft.id), /当前会话不匹配/);
+  });
+
   await check('启动监听保留当前审核策略,不自动切换会话模式', async () => {
     const { __testing } = require('../mcp/src/dashboard/agent-service');
     const calls = [];
@@ -243,6 +316,10 @@ async function main() {
       assert.equal(ctx.db.listDrafts().length, 0);
       assert.equal(ctx.sent.length, 0);
       assert.equal(ctx.db.latestAgentState(result.conversation.id).action, 'agent_failed');
+      assert.match(result.error, /暂时无法完成/);
+      const audit = ctx.db.listAudit(20, result.conversation.id).find(item => item.action === 'agent_failed');
+      assert.equal(audit.detail.message, result.error);
+      assert.equal(audit.detail.rawMessage, 'Agent 上游暂时不可用(HTTP 522)');
     } finally { ctx.close(); }
   });
 

+ 80 - 0
claude-code/claude-code-qiwe-assistant/scripts/callback-relay-smoke-test.js

@@ -0,0 +1,80 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-callback-relay-smoke-'));
+process.env.QIWEI_OUTPUTS_DIR = root;
+
+const agentServicePath = require.resolve('../mcp/src/dashboard/agent-service');
+const bridged = [];
+require.cache[agentServicePath] = {
+  id: agentServicePath,
+  filename: agentServicePath,
+  loaded: true,
+  exports: {
+    ingestWebhookMessage: async message => {
+      bridged.push(message);
+      return { status: 'pending_review' };
+    },
+  },
+};
+
+const { processWebhookEvents } = require('../mcp/src/core/webhook-processor');
+const { readProcessedEventIds } = require('../mcp/src/core/webhook-store');
+const { runPollOnce } = require('./start-relay-client');
+
+async function main() {
+  const messageId = `callback-private-${Date.now()}`;
+  const result = await processWebhookEvents({
+    code: 0,
+    msg: 'test',
+    source: 'relay',
+    data: [{
+      guid: 'device-1', cmd: 15000, msgType: 1, msgUniqueIdentifier: messageId,
+      senderId: 'customer-1', senderName: '测试客户', receiverId: 'staff-1',
+      timestamp: Math.floor(Date.now() / 1000), seq: 1,
+      msgData: { content: '回调消息测试' },
+    }],
+  });
+  assert.strictEqual(result.processed, 1);
+  assert.strictEqual(result.errors, 0);
+  assert.strictEqual(bridged.length, 1);
+  assert.strictEqual(bridged[0].msgData.content, '回调消息测试');
+  assert.strictEqual(readProcessedEventIds().has(messageId), true);
+
+  const originalFetch = global.fetch;
+  let ackRequests = 0;
+  global.fetch = async url => {
+    if (String(url).endsWith('/api/relay/poll')) {
+      return new Response(JSON.stringify({ success: true, events: [
+        { eventId: 'broken-event', encryptedPayload: 'not-valid-base64' },
+      ] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+    }
+    if (String(url).endsWith('/api/relay/ack')) {
+      ackRequests += 1;
+      return new Response(JSON.stringify({ success: true, ackedCount: 1 }), { status: 200 });
+    }
+    throw new Error(`unexpected URL: ${url}`);
+  };
+  try {
+    const poll = await runPollOnce('https://relay.example.test', 'secret', 'device-1', 'invalid-private-key');
+    assert.strictEqual(poll.failed, 1);
+    assert.strictEqual(poll.acked, 0);
+    assert.strictEqual(ackRequests, 0);
+  } finally {
+    global.fetch = originalFetch;
+  }
+
+  console.log('[ok] callback messages bridge to Agent and failed Relay events remain unacked');
+}
+
+main().finally(() => {
+  delete require.cache[agentServicePath];
+  fs.rmSync(root, { recursive: true, force: true });
+}).catch(error => {
+  console.error(error);
+  process.exitCode = 1;
+});

+ 255 - 41
claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js

@@ -11,9 +11,12 @@
  */
 
 const crypto = require('crypto');
+const fs = require('fs');
 const { processWebhookEvents } = require('../mcp/src/core/webhook-server');
 const { readQiweiGuid } = require('../mcp/src/core/credentials');
 const { getProductMode } = require('../mcp/src/core/product-mode');
+const { buildContext } = require('../mcp/src/core/shared-gateway');
+const { callFmodeWecomGateway } = require('../mcp/src/providers/fmode-wecom-gateway');
 const {
   getRelayBaseUrl,
   getTenantApiSecret,
@@ -21,10 +24,76 @@ const {
   getTenantId,
   getRelayDeviceGuid
 } = require('../mcp/src/core/relay-config');
+const {
+  relayLockPath,
+  relayStatePath,
+  readJson,
+  isProcessAlive
+} = require('../mcp/src/core/relay-daemon');
 
 const POLL_WAIT_MS = 30000;
 const INITIAL_BACKOFF_MS = 1000;
 const MAX_BACKOFF_MS = 60000;
+const UPSTREAM_RECONNECT_INTERVAL_MS = 5 * 60 * 1000;
+const UPSTREAM_RECONNECT_RETRY_MS = 60 * 1000;
+const DEVICE_REFRESH_INTERVAL_MS = 60 * 1000;
+
+let lockOwned = false;
+let runtimeState = {
+  pid: process.pid,
+  startedAt: new Date().toISOString(),
+  heartbeatAt: new Date().toISOString(),
+  lastPollAt: null,
+  lastReceivedAt: null,
+  lastAckAt: null,
+  lastError: '',
+  received: 0,
+  acked: 0,
+  failed: 0
+};
+
+let nextUpstreamReconnectAt = 0;
+let startupCatchupPending = true;
+let nextDeviceRefreshAt = 0;
+let activeDeviceGuids = [];
+
+function writeJsonAtomic(filePath, value) {
+  const tempPath = `${filePath}.${process.pid}.tmp`;
+  fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8');
+  fs.renameSync(tempPath, filePath);
+}
+
+function writeRuntimeState(patch = {}) {
+  runtimeState = { ...runtimeState, ...patch, pid: process.pid, heartbeatAt: new Date().toISOString() };
+  writeJsonAtomic(relayStatePath(), runtimeState);
+}
+
+function acquireLock() {
+  const lockPath = relayLockPath();
+  for (let attempt = 0; attempt < 2; attempt += 1) {
+    try {
+      const fd = fs.openSync(lockPath, 'wx');
+      fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: runtimeState.startedAt }, null, 2));
+      fs.closeSync(fd);
+      lockOwned = true;
+      return true;
+    } catch (error) {
+      if (error.code !== 'EEXIST') throw error;
+      const existing = readJson(lockPath, {});
+      if (isProcessAlive(existing.pid)) return false;
+      try { fs.rmSync(lockPath, { force: true }); } catch {}
+    }
+  }
+  return false;
+}
+
+function releaseLock() {
+  if (!lockOwned) return;
+  const existing = readJson(relayLockPath(), {});
+  if (Number(existing.pid) === process.pid) fs.rmSync(relayLockPath(), { force: true });
+  lockOwned = false;
+  writeRuntimeState({ running: false, stoppedAt: new Date().toISOString() });
+}
 
 function decryptPayload(encryptedPayload, privateKeyPem) {
   const key = crypto.createPrivateKey(privateKeyPem);
@@ -51,30 +120,28 @@ function decryptPayload(encryptedPayload, privateKeyPem) {
 }
 
 async function ackEvents(baseUrl, apiSecret, guid, eventIds) {
-  if (!eventIds.length) return;
-  try {
-    const res = await fetch(`${baseUrl}/api/relay/ack`, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        Authorization: `Bearer ${apiSecret}`
-      },
-      body: JSON.stringify({ guid, eventIds })
-    });
-
-    if (!res.ok) {
-      console.warn('[RelayClient] ACK 失败:', res.status, await res.text());
-      return;
-    }
+  if (!eventIds.length) return 0;
+  const res = await fetch(`${baseUrl}/api/relay/ack`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${apiSecret}`
+    },
+    body: JSON.stringify({ guid, eventIds })
+  });
 
-    const data = await res.json();
-    console.log(`[RelayClient] ACK ${data.ackedCount || eventIds.length} 条事件`);
-  } catch (err) {
-    console.warn('[RelayClient] ACK 请求异常:', err.message);
+  if (!res.ok) {
+    throw new Error(`ACK failed: ${res.status} ${await res.text()}`);
   }
+
+  const data = await res.json();
+  const ackedCount = Number(data.ackedCount) || eventIds.length;
+  console.log(`[RelayClient] ACK ${ackedCount} 条事件`);
+  return ackedCount;
 }
 
-async function runPollOnce(baseUrl, apiSecret, guid, privateKey) {
+async function runPollOnce(baseUrl, apiSecret, guid, privateKey, options = {}) {
+  const trackState = options.trackState !== false;
   const res = await fetch(`${baseUrl}/api/relay/poll`, {
     method: 'POST',
     headers: {
@@ -89,27 +156,43 @@ async function runPollOnce(baseUrl, apiSecret, guid, privateKey) {
   }
 
   const data = await res.json();
-  if (!data.events || !data.events.length) return { acked: 0 };
+  if (trackState) writeRuntimeState({ lastPollAt: new Date().toISOString(), lastError: '' });
+  if (!data.events || !data.events.length) return { received: 0, acked: 0, failed: 0 };
 
   console.log(`[RelayClient] 取回 ${data.events.length} 条事件`);
   const eventIds = [];
+  let failed = 0;
 
   for (const event of data.events) {
     try {
       const decrypted = decryptPayload(event.encryptedPayload, privateKey);
       const payload = JSON.parse(decrypted);
-      const envelope = { code: 0, msg: 'from-relay', data: Array.isArray(payload) ? payload : [payload], __rawBody: decrypted };
-      await processWebhookEvents(envelope);
+      const envelope = payload && payload.version === '2.0'
+        ? { ...payload, source: 'relay', __rawBody: decrypted }
+        : payload && typeof payload.code === 'number' && Array.isArray(payload.data)
+          ? { ...payload, source: 'relay', __rawBody: decrypted }
+          : { code: 0, msg: 'from-relay', data: Array.isArray(payload) ? payload : [payload], source: 'relay', __rawBody: decrypted };
+      const processed = await processWebhookEvents(envelope);
+      if (processed.errors) throw new Error(`webhook processing failed for ${processed.errors} event(s)`);
       eventIds.push(event.eventId);
     } catch (err) {
       console.error(`[RelayClient] 解密/处理失败 eventId=${event.eventId}:`, err.message);
-      // 解密失败也要 ACK,避免 Relay 重复投递
-      eventIds.push(event.eventId);
+      failed += 1;
     }
   }
 
-  await ackEvents(baseUrl, apiSecret, guid, eventIds);
-  return { acked: eventIds.length };
+  const acked = await ackEvents(baseUrl, apiSecret, guid, eventIds);
+  if (trackState) {
+    writeRuntimeState({
+      lastReceivedAt: new Date().toISOString(),
+      lastAckAt: acked ? new Date().toISOString() : runtimeState.lastAckAt,
+      received: runtimeState.received + data.events.length,
+      acked: runtimeState.acked + acked,
+      failed: runtimeState.failed + failed,
+      lastError: failed ? `${failed} event(s) failed and remain pending` : ''
+    });
+  }
+  return { received: data.events.length, acked, failed };
 }
 
 function resolveGuid() {
@@ -117,38 +200,160 @@ function resolveGuid() {
   return process.argv[2] || process.env.RELAY_DEVICE_GUID || getRelayDeviceGuid() || readQiweiGuid() || '';
 }
 
+function resolveRuntimeConfig() {
+  return {
+    baseUrl: getRelayBaseUrl(),
+    apiSecret: getTenantApiSecret(),
+    privateKey: getRelayPrivateKey(),
+    tenantId: getTenantId(),
+    guid: resolveGuid()
+  };
+}
+
+async function refreshTenantDevices(baseUrl, apiSecret, primaryGuid) {
+  if (Date.now() < nextDeviceRefreshAt && activeDeviceGuids.length) return activeDeviceGuids;
+  const response = await fetch(`${baseUrl}/api/tenant/status`, {
+    headers: { Authorization: `Bearer ${apiSecret}` },
+    signal: AbortSignal.timeout(15000)
+  });
+  if (!response.ok) throw new Error(`device discovery failed: ${response.status} ${await response.text()}`);
+  const data = await response.json();
+  const discovered = Array.isArray(data.devices)
+    ? data.devices.map(device => String(device.guid || '').trim()).filter(Boolean)
+    : [];
+  activeDeviceGuids = [...new Set([primaryGuid, ...discovered].filter(Boolean))];
+  nextDeviceRefreshAt = Date.now() + DEVICE_REFRESH_INTERVAL_MS;
+  writeRuntimeState({ activeDeviceGuids });
+  return activeDeviceGuids;
+}
+
+async function reconnectUpstreamCallback() {
+  const ctx = buildContext({});
+  const result = await callFmodeWecomGateway({
+    gatewayPath: '/relay/connect',
+    body: { uid: ctx.uid },
+    token: ctx.token,
+    apiBase: ctx.apiBase,
+    timeoutMs: 60000
+  });
+  const connected = result.data && result.data.data !== undefined ? result.data.data : result.data;
+  if (connected && connected.connected === false) throw new Error('Fmode Relay connection was not accepted');
+  nextUpstreamReconnectAt = Date.now() + UPSTREAM_RECONNECT_INTERVAL_MS;
+  writeRuntimeState({ lastConnectAt: new Date().toISOString(), lastConnectError: '' });
+  console.log('[RelayClient] Fmode Relay 回调已重新连接');
+  if (startupCatchupPending) {
+    try {
+      const { syncConversations } = require('../mcp/src/dashboard/agent-service');
+      const catchup = await syncConversations();
+      startupCatchupPending = false;
+      writeRuntimeState({
+        lastCatchupAt: new Date().toISOString(),
+        lastCatchupError: '',
+        lastCatchupMessage: catchup.assistantMessage || ''
+      });
+      console.log('[RelayClient] 启动补采完成:', catchup.assistantMessage || 'ok');
+    } catch (error) {
+      nextUpstreamReconnectAt = Date.now() + UPSTREAM_RECONNECT_RETRY_MS;
+      writeRuntimeState({ lastCatchupError: error.message });
+      console.warn('[RelayClient] 启动补采失败:', error.message);
+    }
+  }
+  return connected;
+}
+
+async function maintainUpstreamCallback() {
+  if (Date.now() < nextUpstreamReconnectAt) return;
+  try {
+    await reconnectUpstreamCallback();
+  } catch (error) {
+    nextUpstreamReconnectAt = Date.now() + UPSTREAM_RECONNECT_RETRY_MS;
+    writeRuntimeState({ lastConnectError: error.message });
+    console.warn('[RelayClient] Fmode Relay 回调重连失败:', error.message);
+  }
+}
+
 async function main() {
+  if (!acquireLock()) {
+    console.log('[RelayClient] 已有 Relay 消费进程运行,本进程退出');
+    return;
+  }
+  writeRuntimeState({ running: true });
   const product = getProductMode();
   if (product.mode !== 'enterprise') {
     console.error('[RelayClient] 当前为个人版,请使用工作台本地监听;企业 Relay Client 未启动');
     process.exit(1);
   }
-  const baseUrl = getRelayBaseUrl();
-  const apiSecret = getTenantApiSecret();
-  const privateKey = getRelayPrivateKey();
-  const tenantId = getTenantId();
-  const guid = resolveGuid();
+  let config = resolveRuntimeConfig();
 
-  if (!apiSecret || !privateKey || !tenantId) {
+  if (!config.apiSecret || !config.privateKey || !config.tenantId) {
     console.error('[RelayClient] 缺少配置:请检查 .env.local 中的 TENANT_API_SECRET、RELAY_PRIVATE_KEY、TENANT_ID');
     process.exit(1);
   }
-  if (!guid) {
+  if (!config.guid) {
     console.error('[RelayClient] 缺少 deviceGuid:请通过命令行传入,或配置 RELAY_DEVICE_GUID / relay-config.json / 完成企微登录');
     process.exit(1);
   }
 
-  console.log(`[RelayClient] 启动 Relay 轮询: ${baseUrl}`);
-  console.log(`[RelayClient] tenantId=${tenantId}, guid=${guid}`);
+  console.log(`[RelayClient] 启动 Relay 轮询: ${config.baseUrl}`);
+  console.log(`[RelayClient] tenantId=${config.tenantId}, guid=${config.guid}`);
 
   let backoff = INITIAL_BACKOFF_MS;
+  let configIdentity = `${config.baseUrl}|${config.tenantId}|${config.guid}`;
 
   while (true) {
     try {
-      await runPollOnce(baseUrl, apiSecret, guid, privateKey);
+      const latestConfig = resolveRuntimeConfig();
+      if (!latestConfig.apiSecret || !latestConfig.privateKey || !latestConfig.tenantId || !latestConfig.guid) {
+        throw new Error('Relay runtime configuration became incomplete');
+      }
+      const latestIdentity = `${latestConfig.baseUrl}|${latestConfig.tenantId}|${latestConfig.guid}`;
+      if (latestIdentity !== configIdentity) {
+        config = latestConfig;
+        configIdentity = latestIdentity;
+        nextUpstreamReconnectAt = 0;
+        nextDeviceRefreshAt = 0;
+        activeDeviceGuids = [];
+        startupCatchupPending = true;
+        writeRuntimeState({
+          tenantId: config.tenantId,
+          deviceGuid: config.guid,
+          lastConfigReloadAt: new Date().toISOString()
+        });
+        console.log(`[RelayClient] 已切换 Relay 配置: tenantId=${config.tenantId}, guid=${config.guid}`);
+      } else {
+        config = latestConfig;
+      }
+      await maintainUpstreamCallback();
+      let deviceGuids;
+      try {
+        deviceGuids = await refreshTenantDevices(config.baseUrl, config.apiSecret, config.guid);
+      } catch (error) {
+        deviceGuids = activeDeviceGuids.length ? activeDeviceGuids : [config.guid];
+        nextDeviceRefreshAt = Date.now() + Math.min(UPSTREAM_RECONNECT_RETRY_MS, DEVICE_REFRESH_INTERVAL_MS);
+        console.warn('[RelayClient] 租户设备发现失败,继续轮询已知设备:', error.message);
+      }
+      const settled = await Promise.allSettled(
+        deviceGuids.map(guid => runPollOnce(config.baseUrl, config.apiSecret, guid, config.privateKey, { trackState: false }))
+      );
+      const results = settled.filter(item => item.status === 'fulfilled').map(item => item.value);
+      const errors = settled.filter(item => item.status === 'rejected').map(item => item.reason?.message || String(item.reason));
+      if (!results.length && errors.length) throw new Error(errors.join('; '));
+      const received = results.reduce((sum, item) => sum + item.received, 0);
+      const acked = results.reduce((sum, item) => sum + item.acked, 0);
+      const failed = results.reduce((sum, item) => sum + item.failed, 0);
+      writeRuntimeState({
+        lastPollAt: new Date().toISOString(),
+        lastReceivedAt: received ? new Date().toISOString() : runtimeState.lastReceivedAt,
+        lastAckAt: acked ? new Date().toISOString() : runtimeState.lastAckAt,
+        received: runtimeState.received + received,
+        acked: runtimeState.acked + acked,
+        failed: runtimeState.failed + failed,
+        lastError: [...errors, ...(failed ? [`${failed} event(s) failed and remain pending`] : [])].join('; ')
+      });
       backoff = INITIAL_BACKOFF_MS;
     } catch (err) {
       console.error('[RelayClient] 轮询异常:', err.message);
+      writeRuntimeState({ lastError: err.message });
       console.log(`[RelayClient] ${backoff}ms 后重试...`);
       await new Promise((resolve) => setTimeout(resolve, backoff));
       backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
@@ -156,7 +361,16 @@ async function main() {
   }
 }
 
-main().catch((err) => {
-  console.error('[RelayClient] 致命错误:', err);
-  process.exit(1);
-});
+if (require.main === module) {
+  process.once('SIGINT', () => { releaseLock(); process.exit(0); });
+  process.once('SIGTERM', () => { releaseLock(); process.exit(0); });
+  process.once('exit', releaseLock);
+  main().catch((err) => {
+    console.error('[RelayClient] 致命错误:', err);
+    writeRuntimeState({ running: false, lastError: err.message });
+    releaseLock();
+    process.exit(1);
+  });
+}
+
+module.exports = { decryptPayload, ackEvents, runPollOnce, resolveGuid, resolveRuntimeConfig, refreshTenantDevices, reconnectUpstreamCallback, maintainUpstreamCallback, acquireLock, releaseLock };

+ 154 - 0
claude-code/claude-code-qiwe-assistant/scripts/voice-clone-smoke-test.js

@@ -0,0 +1,154 @@
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-voice-clone-smoke-'));
+process.env.QIWEI_OUTPUTS_DIR = path.join(root, 'outputs');
+
+const {
+  VoiceCloneService,
+  inferVoiceTone,
+  resolveTone,
+  readResponseBuffer,
+} = require('../mcp/src/core/voice-clone-service');
+const { __testing: voiceToolTesting } = require('../mcp/src/tools/qiwei-voice-run');
+
+function wavBuffer({ duration = 1, sampleRate = 16000, frequency = 220 } = {}) {
+  const samples = Math.floor(duration * sampleRate);
+  const dataSize = samples * 2;
+  const buffer = Buffer.alloc(44 + dataSize);
+  buffer.write('RIFF', 0);
+  buffer.writeUInt32LE(36 + dataSize, 4);
+  buffer.write('WAVEfmt ', 8);
+  buffer.writeUInt32LE(16, 16);
+  buffer.writeUInt16LE(1, 20);
+  buffer.writeUInt16LE(1, 22);
+  buffer.writeUInt32LE(sampleRate, 24);
+  buffer.writeUInt32LE(sampleRate * 2, 28);
+  buffer.writeUInt16LE(2, 32);
+  buffer.writeUInt16LE(16, 34);
+  buffer.write('data', 36);
+  buffer.writeUInt32LE(dataSize, 40);
+  for (let index = 0; index < samples; index += 1) {
+    const value = Math.round(Math.sin((2 * Math.PI * frequency * index) / sampleRate) * 4000);
+    buffer.writeInt16LE(value, 44 + index * 2);
+  }
+  return buffer;
+}
+
+async function main() {
+  assert.equal(inferVoiceTone('非常抱歉给您带来了不好的体验').id, 'apology');
+  assert.equal(inferVoiceTone('恭喜,您的申请已经审核通过').id, 'friendly');
+  assert.equal(inferVoiceTone('提醒您,请于明天下午前提交资料').id, 'reminder');
+  assert.equal(inferVoiceTone('理解您的心情,这段时间辛苦了').id, 'empathetic');
+  assert.equal(inferVoiceTone('资料已经收到,我稍后回复您').id, 'natural');
+  assert.equal(resolveTone('natural', '恭喜').automatic, false);
+  assert.throws(() => resolveTone('angry', '测试'), /不支持/);
+  assert.equal(voiceToolTesting.isPrivateAddress('127.0.0.1'), true);
+  assert.equal(voiceToolTesting.isPrivateAddress('10.0.0.8'), true);
+  assert.equal(voiceToolTesting.isPrivateAddress('8.8.8.8'), false);
+  await assert.rejects(() => voiceToolTesting.fetchVoice('http://example.com/voice.wav'), /只允许使用/);
+  await assert.rejects(
+    () => readResponseBuffer(new Response('small', { headers: { 'Content-Length': String(51 * 1024 * 1024) } }), 50 * 1024 * 1024),
+    /响应过大/
+  );
+
+  const referencePath = path.join(root, 'reference.wav');
+  fs.writeFileSync(referencePath, wavBuffer({ duration: 6 }));
+  const calls = [];
+  let sendSuccess = true;
+  const qiwei = {
+    context: () => ({ uid: 'account-smoke', guid: 'guid-smoke' }),
+    isConfigured: () => true,
+    async uploadVoiceFile(filePath) {
+      calls.push({ type: 'upload', filePath });
+      assert.equal(fs.existsSync(filePath), true);
+      return { fileId: 'file-smoke', fileAesKey: 'aes-smoke', fileSize: fs.statSync(filePath).size };
+    },
+    async sendVoice(toId, media) {
+      calls.push({ type: 'send', toId, media });
+      return sendSuccess ? { isSendSuccess: 1, msgType: 16, msgServerId: 1001 } : { isSendSuccess: 0 };
+    },
+  };
+  const service = new VoiceCloneService({
+    qiwei,
+    config: {
+      endpoint: 'https://tts.example/indextts2',
+      apiKey: 'test-key',
+      model: 'IndexTeam/IndexTTS-2',
+    },
+  });
+
+  await service.enroll({ filePath: referencePath, originalName: '本人录音.wav', mime: 'audio/wav' });
+  const status = service.status();
+  assert.equal(status.enrolled, true);
+  assert.equal(status.previewEnabled, false);
+  assert.equal(status.profile.sampleRate, 16000);
+  assert.ok(status.profile.duration >= 5.9 && status.profile.duration <= 6.1);
+  await service.enroll({ filePath: referencePath, originalName: '本人录音-更新.wav', mime: 'audio/wav' });
+  assert.equal(service.status().enrolled, true);
+
+  const originalFetch = global.fetch;
+  const generated = wavBuffer({ duration: 1.2, sampleRate: 22050, frequency: 330 });
+  global.fetch = async (_url, options = {}) => {
+    const payload = JSON.parse(options.body.get('payload'));
+    assert.equal(payload.use_random, false);
+    assert.equal([0, 3].includes(payload.emo_control_method), true);
+    if (payload.emo_control_method === 3) assert.equal(payload.emo_text.includes('歉意'), true);
+    assert.equal(options.body.get('stream_mode'), 'true');
+    return new Response(generated, { status: 200, headers: { 'Content-Type': 'audio/wav' } });
+  };
+
+  try {
+    await assert.rejects(
+      service.synthesizeAndSend({ text: '未经确认', tone: 'natural', toId: 'contact-smoke' }),
+      /必须获得人工确认/
+    );
+
+    const result = await service.synthesizeAndSend({
+      text: '非常抱歉,我们马上为您处理。',
+      tone: 'auto',
+      toId: 'contact-smoke',
+      confirmed: true,
+    });
+    assert.equal(result.tone.id, 'apology');
+    assert.ok(result.duration >= 1.1 && result.duration <= 1.3);
+    assert.equal(calls[0].type, 'upload');
+    assert.equal(calls[1].type, 'send');
+    assert.equal(calls[1].toId, 'contact-smoke');
+    assert.equal(calls[1].media.voiceTime, 1);
+    assert.equal(fs.existsSync(path.join(result.runDir, 'speech.wav')), true);
+    assert.equal(fs.existsSync(path.join(result.runDir, 'speech.silk')), false);
+    assert.match(path.basename(result.runDir), /-clone-[a-f0-9]{8}$/);
+    const manifest = JSON.parse(fs.readFileSync(path.join(result.runDir, 'manifest.json'), 'utf8'));
+    assert.equal(manifest.outcome, 'sent');
+    assert.equal(Object.hasOwn(manifest, 'text'), false);
+
+    sendSuccess = false;
+    await assert.rejects(
+      service.synthesizeAndSend({ text: '这次发送应失败', tone: 'natural', toId: 'contact-smoke', confirmed: true }),
+      /未明确确认/
+    );
+    const retainedWavs = fs.readdirSync(path.dirname(result.runDir), { withFileTypes: true })
+      .filter(item => item.isDirectory())
+      .map(item => path.join(path.dirname(result.runDir), item.name, 'speech.wav'))
+      .filter(filePath => fs.existsSync(filePath));
+    assert.deepEqual(retainedWavs, [result.audioPath]);
+  } finally {
+    global.fetch = originalFetch;
+  }
+
+  service.revoke();
+  assert.equal(service.status().enrolled, false);
+  console.log('[ok] voice clone tone, enrollment, retained sent WAV, SILK and send orchestration');
+}
+
+main()
+  .catch(error => {
+    console.error(error);
+    process.exitCode = 1;
+  })
+  .finally(() => fs.rmSync(root, { recursive: true, force: true }));

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

@@ -53,7 +53,7 @@ node .claude/plugins/qiwei-assistant/install.js preview .
 
 1. `qiwei_agent_bind_controller`:自动检测失败时显式绑定项目主控 Session;
 2. `qiwei_agent_status`:读取账号、监听、Agent 和会话状态;
-3. `qiwei_agent_listener`:启动或停止真实消息监听
+3. `qiwei_agent_listener`:个人版控制本地监听;企业版回调自动常驻,该工具只返回回调状态
 4. `qiwei_agent_set_global`:设置 paused、review、auto 或 human;
 5. `qiwei_agent_list_conversations`:读取客户会话和待审核草稿;
 6. `qiwei_agent_inbox`:读取前端、监听器、Agent 与人工操作事件;
@@ -68,9 +68,9 @@ node .claude/plugins/qiwei-assistant/install.js preview .
 2. 企微席位是否有效;
 3. 企微账号是否在线;
 4. 测试联系人白名单是否非空;
-5. AI 监听是否已经启动
+5. 企业版公网回调是否运行(个人版检查 AI 监听)
 
-只提示第一项需要处理的动作,完成后重新检查。页面打开不代表企微已经在线,也不代表监听已经启动
+只提示第一项需要处理的动作,完成后重新检查。Dashboard 只是管理界面;企业版回调和 Skill 会话读取均不依赖页面是否打开。
 
 ## 项目主控与客户会话
 

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

@@ -22,7 +22,7 @@ description: 管理企业微信房产 Agent、客户私聊与客户群消息监
 npm run dashboard
 ```
 
-然后打开 `http://127.0.0.1:4320/#agent`,点击“启动 AI 监听”;也可以由项目主控会话调用 `qiwei_agent_listener` 并传入 `running=true`。关闭监听或切换“人工接管”后,Agent 不再自动处理该会话,由人工回复
+企业版无需打开 Dashboard 或启动 AI 监听。MCP/Skill 启动后会自动连接公网 Relay,回调消息进入共享 Workbench;全局暂停或切换“人工接管”后 Agent 不再处理,但消息仍持续接收和保存。个人版仍可用 `qiwei_agent_listener` 控制主动监听
 
 客户消息同步和发送统一通过 Fmode 网关执行 `/msg/syncMsg`、`/msg/sendText`;登录状态使用 Fmode 专用 `/login/status`,不读取、不保存底层企微 Token,也不访问本地直连接口。
 

+ 49 - 29
claude-code/claude-code-qiwe-assistant/skills/qiwei-voice/SKILL.md

@@ -1,50 +1,70 @@
 ---
 name: qiwei-voice
-description: 迁移自 Qiwei 项目的语音转写能力:接收语音 URL、本地文件或 base64,保存到 outputs/voice/,可选解码 silk 并转写
+description: 管理企微语音转写与本人声音克隆。用于接收、解码和转写企微语音,初始化本人参考录音,通过云端 IndexTTS2 生成受控客服语气,并在人工确认后发送原生企微语音消息
 ---
 
-# 语音转写
+# 企微语音
 
 ## 使用边界
 
-本 skill 负责
+执行以下能力
 
-- 下载或接收语音文件;
-- 保存到 `outputs/voice/`;
-- 解码 silk 为 mp3(需可选依赖 `@binsee/wx-voice`);
-- 调用 fmode-listen 转写服务(如配置)。
+- 下载、保存、解码和转写企微语音;
+- 保存当前企微账号的本人参考录音;
+- 调用云端 IndexTTS2 生成 WAV;
+- 将 WAV 编码为 24kHz 单声道 SILK;
+- 上传媒体并发送原生企微语音。
+
+禁止克隆未明确授权的第三方声音。调用真实发送工具前,先向用户确认接收人、文本和语气。
 
 ## 数据存储
 
-- 源文件:`outputs/voice/<timestamp>-transcribe/source.<ext>`
-- 解码后音频:`outputs/voice/<timestamp>-transcribe/audio.mp3`
-- 清单:`outputs/voice/<timestamp>-transcribe/manifest.json`
+- 转写运行:`outputs/voice/<date>/<time>-transcribe/`
+- 声音档案:`outputs/voice/profiles/<account-key>/reference.wav`
+- 克隆运行:`outputs/voice/<date>/<time>-clone/`
+
+不同企微账号使用独立声音档案。声音注销时删除当前账号档案。
+
+## 转写流程
+
+调用 `qiwei_transcribe_voice`,传入 `voiceUrl`、`filePath` 或 `base64Audio`。Silk 文件先解码,再按配置调用 fmode-listen。`voiceUrl` 只接受公网 HTTPS 地址,最多重定向 3 次、30 秒超时且音频最大 20MB;本机和内网地址会被拒绝。
+
+## 声音克隆流程
+
+1. 调用 `qiwei_voice_profile_status` 检查配置。
+2. 调用 `qiwei_enroll_voice` 保存 5~30 秒本人录音。
+3. 确认接收人、文本和语气。
+4. 调用 `qiwei_send_cloned_voice`,同时传入 `confirmed=true`,生成并真实发送。发送成功后可在工作台消息流中回放该语音;不提供单独试听,避免额外合成费用。
+
+从 Dashboard 的待审核草稿发送语音时,传入当前 `draftId`。仅在企微确认发送成功后,将该草稿结算为 `sent` 并关联语音消息;不要保留可再次批准发送的 pending 草稿。
+
+工作台「转文字」直接展示合成时已保存的原文。不要为已发送的克隆语音再次调用 ASR,也不要因此产生额外模型费用。
 
-## 标准流程
+`tone` 默认使用 `auto`。只使用以下受控语气:
 
-```json
-{
-  "filePath": "C:/temp/voice.silk",
-  "transcribe": true
-}
-```
+- `natural`:普通说明;
+- `friendly`:欢迎、感谢、成功和好消息;
+- `apology`:投诉、道歉和服务失误;
+- `empathetic`:遗憾、安慰和关怀;
+- `reminder`:截止、到期和时间提醒。
 
-或:
+保持 `use_random=false`。自动判断不明确时回退 `natural`。不要开放愤怒、厌恶、恐惧或强烈悲伤。
 
-```json
-{
-  "base64Audio": "...",
-  "transcribe": false
-}
-```
+## 上传策略
 
-## 依赖
+只通过 Fmode `/doFileApi` 上传本地 SILK。网关未开放或上传失败时直接报错,不使用公网 URL 回源。语音发送请求发生网络结果不确定时不自动重试,避免重复发送。
 
-- `@binsee/wx-voice`:可选,用于 silk 解码;
-- fmode-listen 服务:通过 `FMODE_LISTEN_URL` 或从 `QIWEI_API_BASE` 推断。
+## 配置与依赖
 
-## 迁移说明
+- `QIWEI_TTS_API_KEY`:云端 IndexTTS2 密钥;
+- `QIWEI_TTS_ENDPOINT`:默认 `https://www.yuntts.com/api/v1/indextts2_infer`;
+- `QIWEI_TTS_MODEL`:默认 `IndexTeam/IndexTTS-2`;
+- `@binsee/wx-voice`:SILK 编解码;
+- `@ffmpeg-installer/ffmpeg`、`@ffprobe-installer/ffprobe`:音频标准化和校验。
 
-原 Qiwei 项目中的语音转写 worker 移除,改为显式调用。MCP 工具:
+## MCP 工具
 
 - `qiwei_transcribe_voice`
+- `qiwei_voice_profile_status`
+- `qiwei_enroll_voice`
+- `qiwei_send_cloned_voice`

+ 3 - 3
claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md

@@ -22,7 +22,7 @@ description: 企微 Webhook 与 Relay:支持企业版中央 Relay 自动接入
 
 ### 中央 Relay 模式(默认)
 
-企微平台把事件推送到 Fmode 中央 Relay 服务器(默认 `http://8.138.37.248:4000`),本地 Skill 通过长轮询主动取回属于自己的事件。适合 Skill 运行在本地电脑、内网或无固定公网 IP 的场景。
+企微平台把事件推送到 Fmode 中央 Relay 服务器,本地 Skill 默认通过 `https://8.138.37.248` 加密连接并长轮询取回属于自己的事件。适合 Skill 运行在本地电脑、内网或无固定公网 IP 的场景。
 
 ## 数据存储
 
@@ -43,8 +43,8 @@ description: 企微 Webhook 与 Relay:支持企业版中央 Relay 自动接入
    - 调用 Fmode 专用接口 `POST /relay/connect`;
    - 由 Fmode 服务端使用服务端密钥,把 Token 级全局回调设为 `{RELAY_BASE_URL}/api/webhook/ingest`;
    - 客户端不会接触或提交全局回调签名密钥;
-   - 返回配置结果,并提示启动 Relay 轮询客户端
-4. 在服务器上执行 `npm run relay` 启动长轮询(或使用 systemd/pm2 持久化运行)
+   - 返回配置结果,并由 MCP/Skill 自动确保 Relay 消费守护进程常驻
+4. 无需启动 Dashboard 或手工执行 `npm run relay`;事件在服务端持久排队,本地处理成功后才 ACK
 
 ### 手动注册 Relay 租户
 

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

@@ -2,6 +2,12 @@
 
 > 当前版本号见 [`../NPM技能包发布管理.md`](../NPM技能包发布管理.md) 的「发布状态总览」。本文按版本号倒序记录 `fmode-qiwei` 的更新内容。
 
+## 未发布(开发中)
+
+- 新增云端 IndexTTS2 本人声音初始化、自动客服语气识别、24kHz SILK 编码和企微原生语音发送;试听默认禁用以避免额外合成费用。
+- Dashboard 客服编辑器增加声音设置、语气选择和真实语音发送;录音、上传、删除、加载、失败和移动端状态完整。
+- 新增声音档案账号隔离、短期媒体令牌、白名单与人工确认校验,以及声音初始化、发送和注销审计。
+
 ## 0.4.3(2026-07-24)
 
 - 企微订阅价格调整为 500 元/席位/月,与 Future Server 实际扣费规则保持一致。

+ 1 - 1
services/qiwei-relay/lib/config.ts

@@ -24,7 +24,7 @@ export function getLogLevel(): string {
 }
 
 export function getEventTtlSeconds(): number {
-  return process.env.EVENT_TTL_SECONDS ? parseInt(process.env.EVENT_TTL_SECONDS, 10) : 86400;
+  return process.env.EVENT_TTL_SECONDS ? parseInt(process.env.EVENT_TTL_SECONDS, 10) : 604800;
 }
 
 export function getMaxPendingEventsPerTenant(): number {

+ 3 - 2
services/qiwei-relay/lib/crypto.ts

@@ -71,8 +71,9 @@ export function verifyHmacSignature(secret: string, rawBody: string, signature:
 export function verifyBearerToken(secret: string, authHeader: string | undefined): boolean {
   if (!authHeader) return false;
   const match = authHeader.match(/^Bearer\s+(.+)$/i);
-  if (!match) return false;
-  return timingSafeEqual(match[1], secret);
+  const provided = (match ? match[1] : authHeader).trim();
+  if (!provided) return false;
+  return timingSafeEqual(provided, secret);
 }
 
 function timingSafeEqual(a: string, b: string): boolean {

+ 66 - 11
services/qiwei-relay/routes.ts

@@ -392,14 +392,46 @@ relayRoutes.post('/tenant/device', tenantAuth, async (req: AuthenticatedRequest,
       return;
     }
 
-    const assigned = await findDeviceByGuid(guid);
-    if (assigned && assigned.tenantId !== tenantId) {
-      res.status(409).json({ error: 'Device is already assigned to another tenant' });
-      return;
-    }
-
     let device: TenantDevice | null = await findDevice(tenantId, guid);
 
+    if (!device) {
+      const fmodeToken = getHeader(req.headers as Record<string, string | string[] | undefined>, 'x-fmode-token');
+      const tenant = req.tenant!;
+      const assignedDevices = await findDevicesByGuid(guid);
+      const foreignDevices = assignedDevices.filter((item) => item.tenantId !== tenantId);
+
+      // 已绑定 Fmode 身份的租户注册新设备时必须重新证明账号身份。
+      // 历史管理租户仍可注册未被占用的设备,以保持向后兼容。
+      if ((tenant.fmodeUserId || foreignDevices.length > 0) && !fmodeToken) {
+        res.status(401).json({ error: 'Fmode token is required to register or transfer this device' });
+        return;
+      }
+
+      if (fmodeToken) {
+        const fmodeUser = await verifyFmodeToken(fmodeToken, guid);
+        if (tenant.fmodeUserId && tenant.fmodeUserId !== fmodeUser.userId) {
+          res.status(403).json({ error: 'Fmode account does not match this tenant' });
+          return;
+        }
+
+        for (const assigned of foreignDevices) {
+          const owner = await findTenantById(assigned.tenantId);
+          if (!owner?.fmodeUserId || owner.fmodeUserId !== fmodeUser.userId) {
+            res.status(409).json({ error: 'Device is already assigned to another Fmode account' });
+            return;
+          }
+        }
+
+        if (!tenant.fmodeUserId) {
+          updateObject('Tenant', tenantId, { fmodeUserId: fmodeUser.userId });
+        }
+        for (const assigned of foreignDevices) {
+          updateObject('TenantDevice', assigned.objectId, { status: 'inactive' });
+          console.log(`[Relay] 同账号设备迁移: guid=${guid}, from=${assigned.tenantId}, to=${tenantId}`);
+        }
+      }
+    }
+
     const now = new Date().toISOString();
     if (!device) {
       device = createObject('TenantDevice', {
@@ -462,6 +494,11 @@ relayRoutes.post('/webhook/ingest', async (req: Request, res: Response) => {
       }
     }
 
+    if (isCallbackSetupProbe(req.body)) {
+      res.json({ success: true, probe: true, queued: 0 });
+      return;
+    }
+
     const groups = splitGlobalPayload(req.body);
     if (groups.length === 0) {
       console.warn('[Relay] 全局回调缺少可路由的 guid');
@@ -675,16 +712,26 @@ async function findDevice(tenantId: string, guid: string): Promise<TenantDevice
 }
 
 async function findDeviceByGuid(guid: string): Promise<TenantDevice | null> {
-  const devices = await new Query<TenantDevice>('TenantDevice')
-    .equalTo('guid', guid)
-    .equalTo('status', 'active')
-    .find();
+  const devices = await findDevicesByGuid(guid);
   if (devices.length > 1) {
-    throw new Error(`Device guid is assigned to multiple tenants: ${guid}`);
+    const tenants = await Promise.all(devices.map((device) => findTenantById(device.tenantId)));
+    const owners = new Set(tenants.filter(Boolean).map((tenant) => tenant!.fmodeUserId).filter(Boolean));
+    if (tenants.some((tenant) => !tenant) || owners.size !== 1) {
+      throw new Error(`Device guid is assigned to multiple tenants: ${guid}`);
+    }
+    devices.sort((left, right) => String(right.lastOnlineAt || right.updatedAt || '').localeCompare(String(left.lastOnlineAt || left.updatedAt || '')));
+    console.warn(`[Relay] 收敛同一 Fmode 用户的重复设备路由: guid=${guid}, selectedTenant=${devices[0].tenantId}`);
   }
   return devices[0] || null;
 }
 
+async function findDevicesByGuid(guid: string): Promise<TenantDevice[]> {
+  return new Query<TenantDevice>('TenantDevice')
+    .equalTo('guid', guid)
+    .equalTo('status', 'active')
+    .find();
+}
+
 function extractGuid(value: unknown): string {
   if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
   const item = value as Record<string, unknown>;
@@ -696,6 +743,14 @@ function extractGuid(value: unknown): string {
   return '';
 }
 
+function isCallbackSetupProbe(body: unknown): boolean {
+  if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
+  const value = body as Record<string, unknown>;
+  return typeof value.callBackUrl === 'string'
+    && typeof value.msg === 'string'
+    && value.msg.includes('设置订阅成功');
+}
+
 function splitGlobalPayload(body: unknown): Array<{ guid: string; body: unknown; rawBody: string }> {
   if (Array.isArray(body)) {
     const groups = new Map<string, unknown[]>();

+ 144 - 2
services/qiwei-relay/scripts/global-ingest-smoke-test.mjs

@@ -4,11 +4,23 @@ import fs from 'node:fs';
 import os from 'node:os';
 import path from 'node:path';
 import { spawn } from 'node:child_process';
+import { createServer } from 'node:http';
 
 const PORT = 4100;
 const BASE = `http://127.0.0.1:${PORT}`;
 const ADMIN_KEY = 'relay-smoke-admin';
 const CALLBACK_SECRET = 'relay-smoke-callback';
+const FMODE_PORT = 4101;
+const fmodeServer = createServer((req, res) => {
+  const authorization = String(req.headers.authorization || '');
+  const userId = authorization.includes('account-b') ? 'fmode-account-b' : 'fmode-account-a';
+  res.writeHead(200, { 'Content-Type': 'application/json' });
+  res.end(JSON.stringify({ data: { userId } }));
+});
+await new Promise((resolve, reject) => {
+  fmodeServer.once('error', reject);
+  fmodeServer.listen(FMODE_PORT, '127.0.0.1', resolve);
+});
 const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-relay-smoke-'));
 const child = spawn(process.execPath, ['dist/server.js'], {
   cwd: path.resolve(import.meta.dirname, '..'),
@@ -19,6 +31,7 @@ const child = spawn(process.execPath, ['dist/server.js'], {
     ADMIN_KEY,
     UPSTREAM_CALLBACK_SECRET: CALLBACK_SECRET,
     RELAY_PUBLIC_URL: BASE,
+    FMODE_GATEWAY_URL: `http://127.0.0.1:${FMODE_PORT}`,
   },
   stdio: ['ignore', 'pipe', 'pipe'],
 });
@@ -107,6 +120,23 @@ try {
   assert.equal(duplicate.response.status, 200);
   assert.equal(duplicate.data.duplicates, 1);
 
+  const rawAuthorization = await request('/api/webhook/ingest', {
+    method: 'POST',
+    headers: { Authorization: CALLBACK_SECRET },
+    body: callbackBody,
+  });
+  assert.equal(rawAuthorization.response.status, 200);
+  assert.equal(rawAuthorization.data.duplicates, 1);
+
+  const setupProbe = await request('/api/webhook/ingest', {
+    method: 'POST',
+    headers: { Authorization: CALLBACK_SECRET },
+    body: { msg: '设置订阅成功!!', callBackUrl: 'http://relay.example/api/webhook/ingest' },
+  });
+  assert.equal(setupProbe.response.status, 200);
+  assert.equal(setupProbe.data.probe, true);
+  assert.equal(setupProbe.data.queued, 0);
+
   const poll = await request('/api/relay/poll', {
     method: 'POST',
     headers: { Authorization: `Bearer ${tenant.apiSecret}` },
@@ -142,6 +172,109 @@ try {
   });
   assert.equal(invalidSignature.response.status, 401);
 
+  const transferGuid = `transfer-guid-${crypto.randomUUID()}`;
+  const accountTenantResponse = await request('/api/tenant/register', {
+    method: 'POST',
+    headers: { Authorization: 'Bearer account-a-token' },
+    body: { description: 'account-a-owner', deviceGuid: transferGuid },
+  });
+  assert.equal(accountTenantResponse.response.status, 200);
+
+  const replacementTenantResponse = await request('/api/admin/tenant', {
+    method: 'POST',
+    headers: { 'X-Admin-Key': ADMIN_KEY },
+    body: { name: 'account-a-replacement' },
+  });
+  assert.equal(replacementTenantResponse.response.status, 200);
+  const replacementTenant = replacementTenantResponse.data;
+  const transfer = await request('/api/tenant/device', {
+    method: 'POST',
+    headers: {
+      Authorization: `Bearer ${replacementTenant.apiSecret}`,
+      'X-Fmode-Token': 'account-a-token',
+    },
+    body: { guid: transferGuid },
+  });
+  assert.equal(transfer.response.status, 200);
+
+  const oldTenantStatus = await request('/api/tenant/status', {
+    headers: { Authorization: `Bearer ${accountTenantResponse.data.apiSecret}` },
+  });
+  const replacementStatus = await request('/api/tenant/status', {
+    headers: { Authorization: `Bearer ${replacementTenant.apiSecret}` },
+  });
+  assert.equal(oldTenantStatus.data.devices.some(item => item.guid === transferGuid), false);
+  assert.equal(replacementStatus.data.devices.some(item => item.guid === transferGuid), true);
+
+  const foreignTenantResponse = await request('/api/admin/tenant', {
+    method: 'POST',
+    headers: { 'X-Admin-Key': ADMIN_KEY },
+    body: { name: 'account-b' },
+  });
+  const crossAccountTransfer = await request('/api/tenant/device', {
+    method: 'POST',
+    headers: {
+      Authorization: `Bearer ${foreignTenantResponse.data.apiSecret}`,
+      'X-Fmode-Token': 'account-b-token',
+    },
+    body: { guid: transferGuid },
+  });
+  assert.equal(crossAccountTransfer.response.status, 409);
+
+  const secondAccountAGuid = `account-a-second-${crypto.randomUUID()}`;
+  const accountBGuid = `account-b-device-${crypto.randomUUID()}`;
+  const secondAccountADevice = await request('/api/tenant/device', {
+    method: 'POST',
+    headers: {
+      Authorization: `Bearer ${replacementTenant.apiSecret}`,
+      'X-Fmode-Token': 'account-a-token',
+    },
+    body: { guid: secondAccountAGuid },
+  });
+  const accountBDevice = await request('/api/tenant/device', {
+    method: 'POST',
+    headers: {
+      Authorization: `Bearer ${foreignTenantResponse.data.apiSecret}`,
+      'X-Fmode-Token': 'account-b-token',
+    },
+    body: { guid: accountBGuid },
+  });
+  assert.equal(secondAccountADevice.response.status, 200);
+  assert.equal(accountBDevice.response.status, 200);
+
+  const multiDeviceBody = {
+    code: 0,
+    data: [
+      { guid: transferGuid, cmd: 15000, msgType: 2, msgUniqueIdentifier: `multi-${crypto.randomUUID()}`, msgData: { content: 'account-a-primary' } },
+      { guid: secondAccountAGuid, cmd: 15000, msgType: 2, msgUniqueIdentifier: `multi-${crypto.randomUUID()}`, msgData: { content: 'account-a-secondary' } },
+      { guid: accountBGuid, cmd: 15000, msgType: 2, msgUniqueIdentifier: `multi-${crypto.randomUUID()}`, msgData: { content: 'account-b-primary' } },
+    ],
+  };
+  const multiDeviceRaw = JSON.stringify(multiDeviceBody);
+  const multiDeviceIngest = await request('/api/webhook/ingest', {
+    method: 'POST',
+    headers: { 'X-Signature': crypto.createHmac('sha256', CALLBACK_SECRET).update(multiDeviceRaw).digest('hex') },
+    body: multiDeviceBody,
+  });
+  assert.equal(multiDeviceIngest.response.status, 200);
+  assert.equal(multiDeviceIngest.data.queued, 3);
+
+  for (const [deviceGuid, apiSecret, privateKey, expectedContent] of [
+    [transferGuid, replacementTenant.apiSecret, replacementTenant.privateKey, 'account-a-primary'],
+    [secondAccountAGuid, replacementTenant.apiSecret, replacementTenant.privateKey, 'account-a-secondary'],
+    [accountBGuid, foreignTenantResponse.data.apiSecret, foreignTenantResponse.data.privateKey, 'account-b-primary'],
+  ]) {
+    const devicePoll = await request('/api/relay/poll', {
+      method: 'POST',
+      headers: { Authorization: `Bearer ${apiSecret}` },
+      body: { guid: deviceGuid, batchSize: 10, waitMs: 1000 },
+    });
+    assert.equal(devicePoll.response.status, 200);
+    assert.equal(devicePoll.data.events.length, 1);
+    const devicePayload = JSON.parse(decrypt(devicePoll.data.events[0].encryptedPayload, privateKey));
+    assert.equal(devicePayload.data[0].msgData.content, expectedContent);
+  }
+
   process.stdout.write(JSON.stringify({
     status: 'ok',
     globalRouting: true,
@@ -149,8 +282,17 @@ try {
     duplicateProtected: true,
     missingGuidRejected: true,
     invalidSignatureRejected: true,
+    sameAccountDeviceTransfer: true,
+    crossAccountDeviceTransferRejected: true,
+    multipleDevicesPerAccount: true,
+    multipleAccountsIsolated: true,
   }, null, 2));
 } finally {
-  child.kill('SIGTERM');
-  fs.rmSync(tempRoot, { recursive: true, force: true });
+  if (child.exitCode === null) {
+    const exited = new Promise(resolve => child.once('exit', resolve));
+    child.kill('SIGTERM');
+    await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 3000))]);
+  }
+  await new Promise(resolve => fmodeServer.close(resolve));
+  fs.rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
 }

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff