Преглед изворни кода

Merge branch 'master' of https://git.fmode.cn/18307996893/openclaw-voc-skill

# Conflicts:
#	claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js
#	claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
#	claude-code/claude-code-qiwe-assistant/package.json
#	claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js
#	claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md
gangvy пре 1 месец
родитељ
комит
fa261f694b
100 измењених фајлова са 10839 додато и 429 уклоњено
  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. 210 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. 399 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/voice-clone-service.js
  15. 22 1
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js
  16. 284 53
      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. 3 0
      claude-code/claude-code-qiwe-assistant/qiwei.runtime.config.example.mjs
  32. 6 0
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/config-loader.mjs
  33. 202 40
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/enterprise-relay-client.mjs
  34. 5 23
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/processor-bridge.mjs
  35. 78 1
      claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js
  36. 80 0
      claude-code/claude-code-qiwe-assistant/scripts/callback-relay-smoke-test.js
  37. 4 0
      claude-code/claude-code-qiwe-assistant/scripts/package-smoke-test.js
  38. 1 0
      claude-code/claude-code-qiwe-assistant/scripts/release-check.js
  39. 100 6
      claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js
  40. 154 0
      claude-code/claude-code-qiwe-assistant/scripts/voice-clone-smoke-test.js
  41. 1 1
      claude-code/claude-code-qiwe-assistant/skill-package-manifest.json
  42. 3 3
      claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md
  43. 1 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-real-estate-auto-reply/SKILL.md
  44. 49 29
      claude-code/claude-code-qiwe-assistant/skills/qiwei-voice/SKILL.md
  45. 2 2
      claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md
  46. 8 0
      claude-code/fmode-image-set/.claude-plugin/plugin.json
  47. 8 0
      claude-code/fmode-image-set/.gitignore
  48. 11 0
      claude-code/fmode-image-set/.mcp.json
  49. 99 0
      claude-code/fmode-image-set/README.md
  50. 131 0
      claude-code/fmode-image-set/bin/fmode-image-set.js
  51. 47 0
      claude-code/fmode-image-set/docs/capability-map.md
  52. 31 0
      claude-code/fmode-image-set/docs/customer-quickstart.md
  53. 44 0
      claude-code/fmode-image-set/docs/deliverables/image-set-result.md
  54. 41 0
      claude-code/fmode-image-set/docs/live-manual-acceptance-checklist.md
  55. 110 0
      claude-code/fmode-image-set/install.js
  56. 25 0
      claude-code/fmode-image-set/mcp/src/core/result-envelope.mjs
  57. 167 0
      claude-code/fmode-image-set/mcp/src/core/run-paths.mjs
  58. 68 0
      claude-code/fmode-image-set/mcp/src/core/run-paths.test.mjs
  59. 38 0
      claude-code/fmode-image-set/mcp/src/core/run-state-integrity.mjs
  60. 14 0
      claude-code/fmode-image-set/mcp/src/core/run-state-integrity.test.mjs
  61. 127 0
      claude-code/fmode-image-set/mcp/src/features/image-set/image-set-runner.mjs
  62. 24 0
      claude-code/fmode-image-set/mcp/src/features/image-set/image-set-runner.test.mjs
  63. 394 0
      claude-code/fmode-image-set/mcp/src/features/image-set/product-vision-client.mjs
  64. 523 0
      claude-code/fmode-image-set/mcp/src/features/image-set/validate-plan.mjs
  65. 187 0
      claude-code/fmode-image-set/mcp/src/features/image-set/validate-plan.test.mjs
  66. 287 0
      claude-code/fmode-image-set/mcp/src/providers/claude-attachment-client.mjs
  67. 213 0
      claude-code/fmode-image-set/mcp/src/providers/claude-attachment-client.test.mjs
  68. 147 0
      claude-code/fmode-image-set/mcp/src/providers/commerce-vision-provider.mjs
  69. 59 0
      claude-code/fmode-image-set/mcp/src/providers/commerce-vision-provider.test.mjs
  70. 764 0
      claude-code/fmode-image-set/mcp/src/providers/jimeng-v4-client.mjs
  71. 147 0
      claude-code/fmode-image-set/mcp/src/providers/jimeng-v4-client.test.mjs
  72. 169 0
      claude-code/fmode-image-set/mcp/src/providers/qiniu-upload-client.mjs
  73. 524 0
      claude-code/fmode-image-set/mcp/src/providers/qiniu-upload-client.test.mjs
  74. 91 0
      claude-code/fmode-image-set/mcp/src/server.mjs
  75. 16 0
      claude-code/fmode-image-set/mcp/src/tools/create-run.mjs
  76. 141 0
      claude-code/fmode-image-set/mcp/src/tools/image-set-run.mjs
  77. 50 0
      claude-code/fmode-image-set/mcp/src/tools/image-set-validate.mjs
  78. 16 0
      claude-code/fmode-image-set/memory-templates/image-set-profile.json
  79. 1185 0
      claude-code/fmode-image-set/package-lock.json
  80. 53 0
      claude-code/fmode-image-set/package.json
  81. 15 0
      claude-code/fmode-image-set/scripts/acceptance.mjs
  82. 75 0
      claude-code/fmode-image-set/scripts/package.test.mjs
  83. 27 0
      claude-code/fmode-image-set/scripts/run-tests.mjs
  84. 32 0
      claude-code/fmode-image-set/scripts/smoke-mcp.mjs
  85. 60 0
      claude-code/fmode-image-set/scripts/smoke-package.mjs
  86. 142 0
      claude-code/fmode-image-set/scripts/test-plan-fixture.mjs
  87. 35 0
      claude-code/fmode-image-set/skill-package-manifest.json
  88. 105 0
      claude-code/fmode-image-set/skills/fmode-image-set/SKILL.md
  89. 4 0
      claude-code/fmode-image-set/skills/fmode-image-set/agents/openai.yaml
  90. 130 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/image-set-planner.md
  91. 36 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/interaction.md
  92. 62 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/jimeng-v4.md
  93. 115 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/plan-schema.md
  94. 42 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/product-understanding.md
  95. 121 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/prompt-compiler.md
  96. 6 0
      release/npm发布管理文档/变更记录/fmode-qiwei.md
  97. 1 1
      services/qiwei-relay/lib/config.ts
  98. 3 2
      services/qiwei-relay/lib/crypto.ts
  99. 66 11
      services/qiwei-relay/routes.ts
  100. 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

@@ -177,6 +177,9 @@ Claude Code / MCP
 | 工具 | 说明 |
 | --- | --- |
 | `qiwei_transcribe_voice` | 保存语音、可选解码 silk、可选转写 |
+| `qiwei_voice_profile_status` | 查询云端 IndexTTS2 与本人声音初始化状态 |
+| `qiwei_enroll_voice` | 保存并校验当前企微账号的本人参考录音 |
+| `qiwei_send_cloned_voice` | 人工确认后编码 24kHz SILK 并真实发送企微语音 |
 
 ### Webhook 与 Relay
 
@@ -259,7 +262,7 @@ npm run wecom:status
 启动并预览企微助手
 ```
 
-Claude Code 会调用 `qiwei_agent_dashboard_start`,启动 4320 工作台,并返回产品模式、Fmode 鉴权、企微席位、企微在线、企业 Relay(仅企业版)、测试白名单和 AI 监听状态
+Claude Code 会调用 `qiwei_agent_dashboard_start`,启动 4320 工作台,并返回产品模式、Fmode 鉴权、企微席位、企微在线、企业回调(仅企业版)和测试白名单状态。企业版消息接收不依赖 Dashboard
 
 源码开发也可以执行:
 
@@ -338,25 +341,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. 恢复待审核或自动策略,演示继续处理后续回调消息
 
 ### 离线恢复
 
@@ -366,8 +376,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` 读取事件;
@@ -377,6 +387,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
@@ -428,7 +440,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 |
@@ -459,6 +471,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() {

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

@@ -0,0 +1,210 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { spawn, spawnSync } = require('child_process');
+const { categoryDir, outputsRoot, 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 esmRuntimeStatePath() {
+  return path.join(outputsRoot(), 'runtime', 'qiwei-runtime.json');
+}
+
+function esmRuntimeStopPath() {
+  return esmRuntimeStatePath().replace(/\.json$/i, '.stop.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 esmState = readJson(esmRuntimeStatePath(), {});
+  const esmComponent = esmState.components?.enterpriseRelay || {};
+  const esmPid = Number(esmState.pid) || null;
+  const esmAlive = esmState.transport === 'server_relay'
+    && ['starting', 'running'].includes(esmState.status)
+    && isProcessAlive(esmPid);
+  if (esmAlive) {
+    const heartbeatAt = esmComponent.updatedAt || esmState.updatedAt || null;
+    const heartbeatAgeMs = heartbeatAt ? Math.max(0, Date.now() - Date.parse(heartbeatAt)) : null;
+    return {
+      running: Boolean(heartbeatAgeMs !== null && heartbeatAgeMs <= HEARTBEAT_STALE_MS),
+      processAlive: true,
+      runtime: 'esm',
+      pid: esmPid,
+      heartbeatAt,
+      heartbeatAgeMs,
+      startedAt: esmState.startedAt || null,
+      lastPollAt: esmComponent.lastPollAt || null,
+      lastReceivedAt: esmComponent.received ? esmComponent.updatedAt || null : null,
+      lastAckAt: esmComponent.acked ? esmComponent.updatedAt || null : null,
+      lastError: esmComponent.lastError || esmComponent.lastConnectError || '',
+      received: Number(esmComponent.received) || 0,
+      acked: Number(esmComponent.acked) || 0,
+      failed: Number(esmComponent.failed) || 0,
+      activeDeviceGuids: Array.isArray(esmComponent.activeDeviceGuids) ? esmComponent.activeDeviceGuids : [],
+    };
+  }
+  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 };
+  if (status.runtime === 'esm') {
+    const stopPath = esmRuntimeStopPath();
+    fs.mkdirSync(path.dirname(stopPath), { recursive: true });
+    fs.writeFileSync(stopPath, `${JSON.stringify({
+      targetPid: status.pid,
+      requestedAt: new Date().toISOString(),
+    }, null, 2)}\n`, 'utf8');
+    return { stopped: true, requested: true, pid: status.pid };
+  }
+  process.kill(status.pid, 'SIGTERM');
+  return { stopped: true, pid: status.pid };
+}
+
+module.exports = {
+  HEARTBEAT_STALE_MS,
+  relayLockPath,
+  relayStatePath,
+  esmRuntimeStatePath,
+  esmRuntimeStopPath,
+  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,
   };
 }

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

@@ -0,0 +1,399 @@
+'use strict';
+
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+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;
+
+function removeFile(filePath) {
+  if (!filePath) return;
+  try {
+    fs.unlinkSync(filePath);
+  } catch (error) {
+    if (error?.code !== 'ENOENT') throw error;
+  }
+}
+
+function removePath(targetPath) {
+  let stat;
+  try {
+    stat = fs.lstatSync(targetPath);
+  } catch (error) {
+    if (error?.code === 'ENOENT') return;
+    throw error;
+  }
+  if (stat.isDirectory() && !stat.isSymbolicLink()) {
+    for (const entry of fs.readdirSync(targetPath)) {
+      removePath(path.join(targetPath, entry));
+    }
+    fs.rmdirSync(targetPath);
+    return;
+  }
+  removeFile(targetPath);
+}
+
+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`;
+      removeFile(backup);
+      if (fs.existsSync(target)) fs.renameSync(target, backup);
+      try {
+        fs.renameSync(staging, target);
+        removeFile(backup);
+      } 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 {
+      removeFile(staging);
+    }
+  }
+
+  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('声音档案路径无效');
+    removePath(dir);
+    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) {
+    const silkPath = path.join(
+      os.tmpdir(),
+      `qiwei-voice-${process.pid}-${crypto.randomBytes(8).toString('hex')}.silk`
+    );
+    const voice = new WxVoice();
+    voice.on('error', () => {});
+    try {
+      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 };
+    } catch (error) {
+      removeFile(silkPath);
+      throw error;
+    }
+  }
+
+  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);
+      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 {
+      removeFile(silk?.silkPath);
+      if (outcome !== 'sent') removeFile(result.wavPath);
+      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,
+};

+ 22 - 1
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) {
@@ -635,7 +655,7 @@ async function processWebhookEvents(envelope) {
       const autoCreateGroupEnabled = config.autoCreateGroup !== false;
 
       if (event.parsedType === ParsedWebhookEvent.NEW_MESSAGE) {
-        const msgResult = await storeGroupMessageFromWebhook(event, eventFilePath);
+        const msgResult = await routeMessageToAgent(event, eventFilePath);
         if (msgResult.success) result.processed++;
         else if (msgResult.ignored) result.ignored++;
         else result.errors++;
@@ -688,6 +708,7 @@ module.exports = {
   handleGroupCreateWebhook,
   identifyServiceGroupMembers,
   storeGroupMessageFromWebhook,
+  routeMessageToAgent,
   resolveSenderType,
   ensureRelatedContact,
   readConfirmedMapping,

+ 284 - 53
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, outputsRoot } = require('../core/output-paths');
+const { PACKAGE_ROOT, WORKSPACE_ROOT, latestPath, categoryDir, outputsRoot } = 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');
@@ -151,8 +154,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'),
@@ -176,12 +179,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;
@@ -351,35 +361,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 = {}) {
@@ -402,11 +410,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() {
@@ -752,7 +762,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;
@@ -768,7 +780,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 || '白名单测试联系人',
@@ -777,14 +793,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 处理'),
@@ -878,16 +902,35 @@ async function getAgentStatus() {
   const state = workbench.service.state(workbench.poller.status());
   const product = getProductMode();
   const listener = listenerStateWithRuntime(product, state.poller);
+  const daemonCallback = getRelayDaemonStatus();
+  const runtimeRelayRunning = product.mode === 'enterprise'
+    && listener.transport === 'server_relay'
+    && listener.running;
+  const callback = runtimeRelayRunning
+    ? {
+        ...daemonCallback,
+        running: true,
+        processAlive: true,
+        pid: listener.runtimePid || daemonCallback.pid,
+        heartbeatAt: readRuntimeState().updatedAt || daemonCallback.heartbeatAt,
+      }
+    : daemonCallback;
+  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,
+      ingress,
+      callback,
       account,
       product,
       agent: state.agent,
       knowledge: workbench.knowledge.stats(),
+      voice: workbench.voice.status(),
       config: {
         allowedSenderCount: state.qiwei.allowlistCount,
         testMode: false,
@@ -906,6 +949,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();
@@ -1342,6 +1393,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);
@@ -1400,17 +1631,11 @@ async function startListenerForWorkbench(target, account) {
 }
 
 async function ingestRuntimeMessage(message = {}, options = {}) {
-  await workbench.poller.process({
-    ...message,
-    _runtimeSource: String(options.source || 'runtime'),
-  });
-  return {
-    status: 'ok',
-    data: {
-      source: String(options.source || 'runtime'),
-      messageId: String(message.msgUniqueIdentifier || message.msgServerId || ''),
-    },
-  };
+  return ingestMessageForWorkbench({
+    config: workbench.config.qiwei,
+    db: workbench.db,
+    service: workbench.service,
+  }, message, String(options.source || 'runtime'));
 }
 
 async function startListener() {
@@ -1478,14 +1703,20 @@ module.exports = {
   regenerateDraft,
   generateLatestDraft,
   manualSend,
+  getVoiceStatus,
+  enrollVoice,
+  revokeVoiceProfile,
+  sendClonedVoice,
+  getSentVoiceAudio,
   updateCustomerTask,
   syncCustomerTaskToOfficialTodo,
   updateCustomerAlert,
   getAudit,
   ingestRuntimeMessage,
+  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",

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
claude-code/claude-code-qiwe-assistant/package.json


+ 3 - 0
claude-code/claude-code-qiwe-assistant/qiwei.runtime.config.example.mjs

@@ -19,6 +19,9 @@ export default {
       waitMs: 30000,
       retryMinMs: 1000,
       retryMaxMs: 60000,
+      upstreamReconnectMs: 300000,
+      upstreamRetryMs: 60000,
+      deviceRefreshMs: 60000,
     },
   },
 };

+ 6 - 0
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/config-loader.mjs

@@ -30,6 +30,9 @@ const DEFAULT_CONFIG = Object.freeze({
       waitMs: 30000,
       retryMinMs: 1000,
       retryMaxMs: 60000,
+      upstreamReconnectMs: 300000,
+      upstreamRetryMs: 60000,
+      deviceRefreshMs: 60000,
     }),
   }),
 });
@@ -53,6 +56,9 @@ function normalizeConfig(config = {}) {
   merged.enterprise.relay.waitMs = Math.max(1000, Math.min(60000, Number(merged.enterprise.relay.waitMs) || 30000));
   merged.enterprise.relay.retryMinMs = Math.max(250, Number(merged.enterprise.relay.retryMinMs) || 1000);
   merged.enterprise.relay.retryMaxMs = Math.max(merged.enterprise.relay.retryMinMs, Number(merged.enterprise.relay.retryMaxMs) || 60000);
+  merged.enterprise.relay.upstreamReconnectMs = Math.max(30000, Number(merged.enterprise.relay.upstreamReconnectMs) || 300000);
+  merged.enterprise.relay.upstreamRetryMs = Math.max(5000, Number(merged.enterprise.relay.upstreamRetryMs) || 60000);
+  merged.enterprise.relay.deviceRefreshMs = Math.max(10000, Number(merged.enterprise.relay.deviceRefreshMs) || 60000);
   return merged;
 }
 

+ 202 - 40
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/enterprise-relay-client.mjs

@@ -6,6 +6,13 @@ import { PACKAGE_ROOT } from './config-loader.mjs';
 const require = createRequire(import.meta.url);
 const relayConfig = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'relay-config.js'));
 const { readQiweiGuid } = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'credentials.js'));
+const { buildContext } = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'shared-gateway.js'));
+const { callFmodeWecomGateway } = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'providers', 'fmode-wecom-gateway.js'));
+
+const DEFAULT_UPSTREAM_RECONNECT_MS = 5 * 60 * 1000;
+const DEFAULT_UPSTREAM_RETRY_MS = 60 * 1000;
+const DEFAULT_DEVICE_REFRESH_MS = 60 * 1000;
+
 export function decryptPayload(encryptedPayload, privateKeyPem) {
   const key = privateKeyPem && typeof privateKeyPem === 'object' && privateKeyPem.type === 'private'
     ? privateKeyPem
@@ -57,15 +64,28 @@ async function ackEvents(context, eventIds, signal, fetchImpl = fetch) {
 
 export class EnterpriseRelayRuntime {
   constructor({ config, guid = '', onState = () => {}, processor = null, fetchImpl = fetch }) {
-    this.config = config;
+    this.config = {
+      upstreamReconnectMs: DEFAULT_UPSTREAM_RECONNECT_MS,
+      upstreamRetryMs: DEFAULT_UPSTREAM_RETRY_MS,
+      deviceRefreshMs: DEFAULT_DEVICE_REFRESH_MS,
+      ...config,
+    };
     this.guid = guid;
     this.onState = onState;
     this.processor = processor;
     this.fetchImpl = fetchImpl;
     this.running = false;
-    this.abortController = null;
+    this.abortControllers = new Set();
     this.loopPromise = null;
     this.cancelWait = null;
+    this.nextUpstreamReconnectAt = 0;
+    this.nextDeviceRefreshAt = 0;
+    this.activeDeviceGuids = [];
+    this.catchupPending = true;
+    this.contextIdentity = '';
+    this.received = 0;
+    this.acked = 0;
+    this.failed = 0;
   }
 
   wait(ms) {
@@ -82,48 +102,151 @@ export class EnterpriseRelayRuntime {
     });
   }
 
-  async pollOnce(context) {
+  newAbortController() {
+    const controller = new AbortController();
+    this.abortControllers.add(controller);
+    return controller;
+  }
+
+  releaseAbortController(controller) {
+    this.abortControllers.delete(controller);
+  }
+
+  async pollOnce(context, options = {}) {
     const processRelayEnvelope = this.processor || (await import('./processor-bridge.mjs')).processRelayEnvelope;
-    this.abortController = new AbortController();
-    const response = await this.fetchImpl(`${context.baseUrl}/api/relay/poll`, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        Authorization: `Bearer ${context.apiSecret}`,
-      },
-      body: JSON.stringify({
+    const controller = this.newAbortController();
+    try {
+      const response = await this.fetchImpl(`${context.baseUrl}/api/relay/poll`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: `Bearer ${context.apiSecret}`,
+        },
+        body: JSON.stringify({
+          guid: context.guid,
+          batchSize: this.config.batchSize,
+          waitMs: this.config.waitMs,
+        }),
+        signal: controller.signal,
+      });
+      if (!response.ok) throw new Error(`Relay poll failed: HTTP ${response.status}`);
+      const data = await response.json();
+      const events = Array.isArray(data.events) ? data.events : [];
+      const processedIds = [];
+      const failures = [];
+
+      for (const event of events) {
+        try {
+          const decrypted = decryptPayload(event.encryptedPayload, context.privateKey);
+          const payload = JSON.parse(decrypted);
+          const envelope = payload && payload.version === '2.0'
+            ? { ...payload, source: 'enterprise-relay', __rawBody: decrypted }
+            : payload && typeof payload.code === 'number' && Array.isArray(payload.data)
+              ? { ...payload, source: 'enterprise-relay', __rawBody: decrypted }
+              : {
+                  code: 0,
+                  msg: 'from-relay',
+                  data: Array.isArray(payload) ? payload : [payload],
+                  source: 'enterprise-relay',
+                  __rawBody: decrypted,
+                };
+          await processRelayEnvelope(envelope);
+          processedIds.push(event.eventId);
+        } catch (error) {
+          failures.push({ eventId: event.eventId, message: error.message });
+        }
+      }
+
+      const acked = await ackEvents(context, processedIds, controller.signal, this.fetchImpl);
+      const result = { fetched: events.length, received: events.length, acked, failed: failures.length };
+      if (failures.length && options.throwOnFailure !== false) {
+        const error = new Error(`Relay retained ${failures.length} failed event(s): ${failures[0].message}`);
+        error.result = result;
+        throw error;
+      }
+      return result;
+    } finally {
+      this.releaseAbortController(controller);
+    }
+  }
+
+  resetForContext(context) {
+    const identity = `${context.baseUrl}|${context.tenantId}|${context.guid}`;
+    if (identity === this.contextIdentity) return;
+    this.contextIdentity = identity;
+    this.nextUpstreamReconnectAt = 0;
+    this.nextDeviceRefreshAt = 0;
+    this.activeDeviceGuids = [];
+    this.catchupPending = true;
+    this.onState({
+      enterpriseRelay: {
+        status: 'starting',
+        tenantId: context.tenantId,
         guid: context.guid,
-        batchSize: this.config.batchSize,
-        waitMs: this.config.waitMs,
-      }),
-      signal: this.abortController.signal,
+        lastConfigReloadAt: new Date().toISOString(),
+      },
+    });
+  }
+
+  async reconnectUpstream(context) {
+    if (Date.now() < this.nextUpstreamReconnectAt) return;
+    const gateway = buildContext({ guid: context.guid });
+    const result = await callFmodeWecomGateway({
+      gatewayPath: '/relay/connect',
+      body: { uid: gateway.uid },
+      token: gateway.token,
+      apiBase: gateway.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');
+    this.nextUpstreamReconnectAt = Date.now() + this.config.upstreamReconnectMs;
+    this.onState({
+      enterpriseRelay: {
+        status: 'running',
+        lastConnectAt: new Date().toISOString(),
+        lastConnectError: '',
+      },
     });
-    if (!response.ok) throw new Error(`Relay poll failed: HTTP ${response.status}`);
-    const data = await response.json();
-    const events = Array.isArray(data.events) ? data.events : [];
-    const processedIds = [];
-    const failures = [];
 
-    for (const event of events) {
+    if (this.catchupPending) {
       try {
-        const decrypted = decryptPayload(event.encryptedPayload, context.privateKey);
-        const payload = JSON.parse(decrypted);
-        const envelope = {
-          code: 0,
-          msg: 'from-relay',
-          data: Array.isArray(payload) ? payload : [payload],
-          __rawBody: decrypted,
-        };
-        await processRelayEnvelope(envelope);
-        processedIds.push(event.eventId);
+        const { syncConversations } = await import('./processor-bridge.mjs');
+        const catchup = await syncConversations();
+        this.catchupPending = false;
+        this.onState({
+          enterpriseRelay: {
+            lastCatchupAt: new Date().toISOString(),
+            lastCatchupError: '',
+            lastCatchupMessage: catchup?.assistantMessage || '',
+          },
+        });
       } catch (error) {
-        failures.push({ eventId: event.eventId, message: error.message });
+        this.nextUpstreamReconnectAt = Date.now() + this.config.upstreamRetryMs;
+        this.onState({ enterpriseRelay: { lastCatchupError: error.message } });
       }
     }
+  }
 
-    const acked = await ackEvents(context, processedIds, this.abortController.signal, this.fetchImpl);
-    if (failures.length) throw new Error(`Relay retained ${failures.length} failed event(s): ${failures[0].message}`);
-    return { fetched: events.length, acked };
+  async discoverDevices(context) {
+    if (Date.now() < this.nextDeviceRefreshAt && this.activeDeviceGuids.length) return this.activeDeviceGuids;
+    const controller = this.newAbortController();
+    try {
+      const response = await this.fetchImpl(`${context.baseUrl}/api/tenant/status`, {
+        headers: { Authorization: `Bearer ${context.apiSecret}` },
+        signal: controller.signal,
+      });
+      if (!response.ok) throw new Error(`Device discovery failed: HTTP ${response.status}`);
+      const data = await response.json();
+      const discovered = Array.isArray(data.devices)
+        ? data.devices.map(device => String(device.guid || '').trim()).filter(Boolean)
+        : [];
+      this.activeDeviceGuids = [...new Set([context.guid, ...discovered].filter(Boolean))];
+      this.nextDeviceRefreshAt = Date.now() + this.config.deviceRefreshMs;
+      return this.activeDeviceGuids;
+    } finally {
+      this.releaseAbortController(controller);
+    }
   }
 
   async loop() {
@@ -138,16 +261,55 @@ export class EnterpriseRelayRuntime {
         continue;
       }
 
+      this.resetForContext(context);
       try {
-        const result = await this.pollOnce(context);
+        try {
+          await this.reconnectUpstream(context);
+        } catch (error) {
+          this.nextUpstreamReconnectAt = Date.now() + this.config.upstreamRetryMs;
+          this.onState({ enterpriseRelay: { lastConnectError: error.message } });
+        }
+
+        let deviceGuids;
+        try {
+          deviceGuids = await this.discoverDevices(context);
+        } catch (error) {
+          this.nextDeviceRefreshAt = Date.now() + Math.min(this.config.upstreamRetryMs, this.config.deviceRefreshMs);
+          deviceGuids = this.activeDeviceGuids.length ? this.activeDeviceGuids : [context.guid];
+          this.onState({ enterpriseRelay: { lastDeviceRefreshError: error.message } });
+        }
+
+        const settled = await Promise.allSettled(
+          deviceGuids.map(guid => this.pollOnce({ ...context, guid })),
+        );
+        const completed = settled.filter(item => item.status === 'fulfilled').map(item => item.value);
+        const rejected = settled.filter(item => item.status === 'rejected');
+        if (!completed.length && rejected.length) throw rejected[0].reason;
+
+        const received = completed.reduce((sum, item) => sum + item.received, 0)
+          + rejected.reduce((sum, item) => sum + Number(item.reason?.result?.received || 0), 0);
+        const acked = completed.reduce((sum, item) => sum + item.acked, 0)
+          + rejected.reduce((sum, item) => sum + Number(item.reason?.result?.acked || 0), 0);
+        const failed = completed.reduce((sum, item) => sum + item.failed, 0)
+          + rejected.reduce((sum, item) => sum + Number(item.reason?.result?.failed || 0), 0);
+        const errors = rejected.map(item => item.reason?.message || String(item.reason));
+        this.received += received;
+        this.acked += acked;
+        this.failed += failed;
+
         this.onState({
           enterpriseRelay: {
             status: 'running',
             guid: context.guid,
-            lastFetched: result.fetched,
-            lastAcked: result.acked,
+            activeDeviceGuids: deviceGuids,
+            lastFetched: received,
+            lastAcked: acked,
+            lastFailed: failed,
+            received: this.received,
+            acked: this.acked,
+            failed: this.failed,
             lastPollAt: new Date().toISOString(),
-            lastError: '',
+            lastError: errors.join('; '),
           },
         });
         backoff = this.config.retryMinMs;
@@ -170,7 +332,7 @@ export class EnterpriseRelayRuntime {
   async stop() {
     this.running = false;
     if (this.cancelWait) this.cancelWait();
-    this.abortController?.abort();
+    for (const controller of this.abortControllers) controller.abort();
     this.onState({ enterpriseRelay: { status: 'stopped' } });
     await this.loopPromise;
   }

+ 5 - 23
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/processor-bridge.mjs

@@ -8,44 +8,26 @@ const webhookTypes = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'webh
 const agentService = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'dashboard', 'agent-service.js'));
 const dashboardServer = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'dashboard', 'server.js'));
 
-function isPrivateMessage(event) {
-  if (event.parsedType !== webhookTypes.ParsedWebhookEvent.NEW_MESSAGE) return false;
-  return !String(event.raw?.fromRoomId || '').trim();
-}
-
 export async function processRelayEnvelope(envelope) {
   const events = webhookTypes.parseWebhookEnvelope(envelope);
   const webhookResult = await webhookServer.processWebhookEvents({
     ...envelope,
     source: 'enterprise-relay',
   });
-  const privateMessages = events.filter(isPrivateMessage);
-  let privateProcessed = 0;
-  const errors = [];
-
-  for (const event of privateMessages) {
-    try {
-      await agentService.ingestRuntimeMessage(event.raw, { source: 'enterprise-relay' });
-      privateProcessed += 1;
-    } catch (error) {
-      errors.push(error);
-    }
-  }
-
   if (Number(webhookResult.errors || 0) > 0) {
-    errors.push(new Error(`Webhook processor reported ${webhookResult.errors} error(s).`));
-  }
-  if (errors.length) {
-    throw new Error(errors.map(error => error.message).join('; '));
+    throw new Error(`Webhook processor reported ${webhookResult.errors} error(s).`);
   }
 
   return {
     ...webhookResult,
-    privateProcessed,
     total: events.length,
   };
 }
 
+export function syncConversations() {
+  return agentService.syncConversations();
+}
+
 export function startPersonalListener() {
   return agentService.startListener();
 }

+ 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;
+});

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

@@ -82,6 +82,8 @@ function main() {
     'install.js',
     'mcp/src/core/runtime-context.js',
     'mcp/src/core/startup-summary.js',
+    'mcp/src/core/relay-daemon.js',
+    'mcp/src/core/voice-clone-service.js',
     'mcp/src/server.js',
     'mcp/src/tools/qiwei-agent-control-run.js',
     'qiwei.runtime.config.example.mjs',
@@ -91,6 +93,8 @@ function main() {
     'runtime/callback-service/src/enterprise-relay-client.mjs',
     'scripts/start-callback-runtime.mjs',
     'scripts/callback-runtime-smoke-test.mjs',
+    'scripts/callback-relay-smoke-test.js',
+    'scripts/voice-clone-smoke-test.js',
     'scripts/agent-console-smoke-test.js',
     'scripts/preview-dashboard.js',
     'scripts/startup-preview-smoke-test.js',

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

@@ -12,6 +12,7 @@ const npmCli = process.env.npm_execpath || path.join(path.dirname(process.execPa
 const checks = [
   'check',
   'runtime:smoke',
+  'voice:smoke',
   'group-ops:check',
   'group-ops:smoke',
   'group-ops:eval',

+ 100 - 6
claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js

@@ -2,12 +2,96 @@
 
 'use strict';
 
+const crypto = require('crypto');
 const path = require('path');
 const { pathToFileURL } = require('url');
+const { readQiweiGuid } = require('../mcp/src/core/credentials');
+const {
+  getRelayBaseUrl,
+  getTenantApiSecret,
+  getRelayPrivateKey,
+  getTenantId,
+  getRelayDeviceGuid,
+} = require('../mcp/src/core/relay-config');
+
+function runtimeModuleUrl(relativePath) {
+  return pathToFileURL(path.join(__dirname, '..', 'runtime', 'callback-service', 'src', relativePath)).href;
+}
+
+function decryptPayload(encryptedPayload, privateKeyPem) {
+  const key = privateKeyPem && typeof privateKeyPem === 'object' && privateKeyPem.type === 'private'
+    ? privateKeyPem
+    : crypto.createPrivateKey(privateKeyPem);
+  if (String(encryptedPayload).startsWith('v2:')) {
+    const envelope = JSON.parse(Buffer.from(String(encryptedPayload).slice(3), 'base64').toString('utf8'));
+    const aesKey = crypto.privateDecrypt(
+      { key, oaepHash: 'sha256' },
+      Buffer.from(envelope.key, 'base64'),
+    );
+    const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(envelope.iv, 'base64'));
+    decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
+    return Buffer.concat([
+      decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
+      decipher.final(),
+    ]).toString('utf8');
+  }
+  return crypto.privateDecrypt(
+    { key, oaepHash: 'sha256' },
+    Buffer.from(encryptedPayload, 'base64'),
+  ).toString('utf8');
+}
+
+async function ackEvents(baseUrl, apiSecret, guid, eventIds) {
+  if (!eventIds.length) return 0;
+  const response = await fetch(`${String(baseUrl).replace(/\/$/, '')}/api/relay/ack`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${apiSecret}`,
+    },
+    body: JSON.stringify({ guid, eventIds }),
+  });
+  if (!response.ok) throw new Error(`ACK failed: ${response.status} ${await response.text()}`);
+  const data = await response.json();
+  return Number(data.ackedCount) || eventIds.length;
+}
+
+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 runPollOnce(baseUrl, apiSecret, guid, privateKey) {
+  const { EnterpriseRelayRuntime } = await import(runtimeModuleUrl('enterprise-relay-client.mjs'));
+  const runtime = new EnterpriseRelayRuntime({
+    config: {
+      batchSize: 100,
+      waitMs: 30000,
+      retryMinMs: 1000,
+      retryMaxMs: 60000,
+    },
+  });
+  const result = await runtime.pollOnce({ baseUrl, apiSecret, guid, privateKey, tenantId: 'compat' }, {
+    throwOnFailure: false,
+  });
+  return {
+    received: result.received,
+    acked: result.acked,
+    failed: result.failed,
+  };
+}
 
 async function main() {
-  const moduleUrl = pathToFileURL(path.join(__dirname, '..', 'runtime', 'callback-service', 'src', 'index.mjs')).href;
-  const { startRuntime } = await import(moduleUrl);
+  const { startRuntime } = await import(runtimeModuleUrl('index.mjs'));
   const runtime = await startRuntime({
     forceMode: 'enterprise',
     guid: process.argv[2] || '',
@@ -25,7 +109,17 @@ async function main() {
   process.once('SIGTERM', () => shutdown('SIGTERM'));
 }
 
-main().catch(error => {
-  process.stderr.write(`[RelayClient] ${error.message}\n`);
-  process.exit(1);
-});
+if (require.main === module) {
+  main().catch(error => {
+    process.stderr.write(`[RelayClient] ${error.message}\n`);
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  decryptPayload,
+  ackEvents,
+  runPollOnce,
+  resolveGuid,
+  resolveRuntimeConfig,
+};

+ 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 }));

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

@@ -33,7 +33,7 @@
     "qiwei-voice",
     "qiwei-webhook-relay"
   ],
-  "mcpToolCount": 93,
+  "mcpToolCount": 96,
   "apiCatalogCount": 104,
   "npmPackage": "fmode-qiwei",
   "installCommand": "npx --yes fmode-qiwei@latest workspace --smoke",

+ 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`

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

@@ -47,8 +47,8 @@ description: 企微消息运行时:个人版在客户主机主动轮询,企
    - 调用 Fmode 专用接口 `POST /relay/connect`;
    - 由 Fmode 服务端使用服务端密钥,把 Token 级全局回调设为 `{RELAY_BASE_URL}/api/webhook/ingest`;
    - 客户端不会接触或提交全局回调签名密钥;
-   - 返回配置结果,并提示启动 Relay 轮询客户端
-4. 执行 `npm run runtime` 启动 ESM Runtime。兼容命令 `npm run relay` 也会进入相同企业版运行链路
+   - 返回配置结果,并由 MCP/Skill 自动确保 Relay 消费守护进程常驻
+4. 企业版无需启动 Dashboard 或手工执行 `npm run relay`;守护进程使用 ESM Runtime,事件在服务端持久排队,本地处理成功后才 ACK
 
 ### 手动注册 Relay 租户
 

+ 8 - 0
claude-code/fmode-image-set/.claude-plugin/plugin.json

@@ -0,0 +1,8 @@
+{
+  "name": "fmode-image-set",
+  "description": "Product-truth-first ecommerce image-set planning and generation for Claude Code, with visual auditing, seven-slot prompt planning, Jimeng execution, recovery, cost control, and delivery integrity checks.",
+  "version": "0.1.0",
+  "author": {
+    "name": "Fmode"
+  }
+}

+ 8 - 0
claude-code/fmode-image-set/.gitignore

@@ -0,0 +1,8 @@
+node_modules/
+生成结果/
+memory/
+outputs/
+.env
+.env.local
+.tmp/
+*.tgz

+ 11 - 0
claude-code/fmode-image-set/.mcp.json

@@ -0,0 +1,11 @@
+{
+  "mcpServers": {
+    "fmode-image-set": {
+      "command": "node",
+      "args": [
+        "D:\\Angular\\openclaw-voc-skill\\claude-code\\fmode-image-set\\mcp\\src\\server.mjs"
+      ],
+      "cwd": "D:\\Angular\\openclaw-voc-skill\\claude-code\\fmode-image-set"
+    }
+  }
+}

+ 99 - 0
claude-code/fmode-image-set/README.md

@@ -0,0 +1,99 @@
+# Fmode Image Set
+
+面向 Claude Code 的电商商品套图 Skill。用户提供一张商品图和一句需求,系统完成商品事实审计、七图规划、逐图 Prompt 校验,并在用户明确授权后调用即梦生成。
+
+## 能力
+
+- 识别商品可见事实,分离推断与未知项;
+- 锁定商品外观、Logo、包装文字和不可新增结构;
+- 规划 1 张主图、2 张场景图、2 张卖点图、2 张购买决策图;
+- 校验卖点证据、角色配额、构图多样性和 Prompt 完整性;
+- 串行调用即梦并下载成品;
+- 对生成商品进行身份复核;
+- 支持同一运行恢复、费用上限和交付完整性审计。
+
+## 目录
+
+```text
+.claude-plugin/          Claude Code 插件元数据
+bin/                     安装与命令行入口
+docs/                    能力地图、快速开始、验收和交付物合同
+mcp/src/core/            路径、状态签名和结果信封
+mcp/src/features/        套图编排、视觉理解和计划校验
+mcp/src/providers/       Claude 附件、视觉模型、七牛、即梦适配
+mcp/src/tools/           MCP/CLI 薄入口
+memory-templates/        默认套图偏好模板
+scripts/                 smoke 与 acceptance
+skills/fmode-image-set/  Skill 入口、references 和 Agent 元数据
+```
+
+## 安装
+
+当前目录检查:
+
+```powershell
+npm install
+npm run install:check
+npm run smoke:package
+npm run mcp:smoke
+```
+
+安装到当前 Claude Code 工作区:
+
+```powershell
+node bin/fmode-image-set.js workspace --smoke
+```
+
+安装后会写入:
+
+```text
+.mcp.json
+.claude/skills/fmode-image-set/   完整交付包,同时包含根级 Skill 发现入口
+```
+
+## 使用
+
+在 Claude Code 中上传商品图并说:
+
+```text
+给这款商品做一套高级、适合电商详情页的七图。
+```
+
+只规划、不生图:
+
+```text
+先帮我规划这套图,不要实际生图。
+```
+
+## MCP 工具
+
+- `fmode_image_set_run`
+  - `start`:创建运行并完成商品视觉理解;
+  - `execute`:在计划通过且用户授权后生图;
+  - `resume`:恢复同一个运行;
+  - `status`:只读查看状态。
+- `fmode_image_set_validate`
+  - 校验 `prompt-plan.json`,不调用生图模型。
+
+## 运行产物
+
+默认写入项目目录:
+
+```text
+生成结果/电商七图/<run_id>/
+├── 商品附件/
+├── 成品图/
+├── vision-result.json
+├── prompt-plan.json
+├── reference-assets.json
+└── jimeng-result.json
+```
+
+## 安全边界
+
+- 未经用户明确授权不执行付费生图;
+- 默认费用上限为 1.54 元;
+- `submission_unknown` 不自动重提;
+- 商品身份复核失败的图片不作为合格交付;
+- 不复制其他运行的图片补齐当前运行;
+- Token 不进入计划、报告或日志。

+ 131 - 0
claude-code/fmode-image-set/bin/fmode-image-set.js

@@ -0,0 +1,131 @@
+#!/usr/bin/env node
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SOURCE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const INVOCATION_ROOT = path.resolve(process.cwd());
+const invocationRelativeToSource = path.relative(SOURCE_ROOT, INVOCATION_ROOT);
+const invokedFromSource = invocationRelativeToSource === ""
+  || (!invocationRelativeToSource.startsWith("..") && !path.isAbsolute(invocationRelativeToSource));
+const WORKSPACE_ROOT = invokedFromSource ? path.dirname(SOURCE_ROOT) : INVOCATION_ROOT;
+const DEFAULT_TARGET = path.join(os.homedir(), ".claude", "plugins", "fmode-image-set");
+const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, ".claude", "skills", "fmode-image-set");
+const EXCLUDED = new Set(["node_modules", "生成结果", "memory", "outputs", ".tmp", ".git", ".env", ".env.local"]);
+EXCLUDED.add(".claude");
+EXCLUDED.add(".codex");
+
+function parseArgs(argv) {
+  const command = argv[0] && !argv[0].startsWith("--") ? argv[0] : "install";
+  const options = {
+    command,
+    target: command === "workspace" ? WORKSPACE_TARGET : DEFAULT_TARGET,
+    workspace: command === "workspace",
+    smoke: argv.includes("--smoke"),
+    skipInstall: argv.includes("--skip-install"),
+    force: argv.includes("--force"),
+  };
+  const targetIndex = argv.indexOf("--target");
+  if (targetIndex >= 0 && argv[targetIndex + 1]) options.target = path.resolve(argv[targetIndex + 1]);
+  return options;
+}
+
+function isInside(parent, candidate) {
+  const relative = path.relative(path.resolve(parent), path.resolve(candidate));
+  return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
+}
+
+function safeTarget(target, force) {
+  return force
+    || path.resolve(target) === path.resolve(DEFAULT_TARGET)
+    || isInside(path.join(WORKSPACE_ROOT, ".claude", "plugins"), target)
+    || isInside(path.join(WORKSPACE_ROOT, ".claude", "skills"), target);
+}
+
+function copyPackage(source, target) {
+  fs.mkdirSync(target, { recursive: true });
+  for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
+    if (EXCLUDED.has(entry.name) || entry.name.endsWith(".tgz")) continue;
+    const from = path.join(source, entry.name);
+    const to = path.join(target, entry.name);
+    if (entry.isDirectory()) copyPackage(from, to);
+    else if (entry.isFile()) fs.copyFileSync(from, to);
+  }
+}
+
+function run(command, args, cwd) {
+  const useCmd = process.platform === "win32" && command === "npm";
+  const executable = useCmd ? "cmd.exe" : command;
+  const finalArgs = useCmd ? ["/d", "/s", "/c", "npm", ...args] : args;
+  const child = spawnSync(executable, finalArgs, { cwd, stdio: "inherit", encoding: "utf8" });
+  if (child.status !== 0) throw new Error(`${command} failed with exit ${child.status}`);
+}
+
+function readJsonMaybe(filePath) {
+  if (!fs.existsSync(filePath)) return {};
+  return JSON.parse(fs.readFileSync(filePath, "utf8"));
+}
+
+function writeWorkspaceActivation(target) {
+  const configPath = path.join(WORKSPACE_ROOT, ".mcp.json");
+  const existing = readJsonMaybe(configPath);
+  const config = {
+    ...existing,
+    mcpServers: {
+      ...(existing.mcpServers || {}),
+      "fmode-image-set": {
+        command: "node",
+        args: [path.join(target, "mcp", "src", "server.mjs")],
+        cwd: target,
+      },
+    },
+  };
+  fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
+
+  const packagedSkill = path.join(target, "skills", "fmode-image-set");
+  copyPackage(packagedSkill, target);
+}
+
+function install(options) {
+  if (fs.existsSync(options.target)) {
+    if (!safeTarget(options.target, options.force)) {
+      throw new Error(`Target exists; use --force only for an intentional custom replacement: ${options.target}`);
+    }
+    fs.rmSync(options.target, { recursive: true, force: true });
+  }
+  copyPackage(SOURCE_ROOT, options.target);
+  if (options.workspace) writeWorkspaceActivation(options.target);
+  if (!options.skipInstall) run("npm", ["install", "--omit=dev", "--ignore-scripts"], options.target);
+  run(process.execPath, ["install.js", options.smoke ? "--smoke" : "--check", "--skip-install"], options.target);
+  console.log(`Installed Fmode Image Set: ${options.target}`);
+  if (options.workspace) console.log("Restart the Claude Code session so it can discover the new Skill and MCP tools.");
+}
+
+function main() {
+  const options = parseArgs(process.argv.slice(2));
+  if (options.command === "install" || options.command === "workspace") return install(options);
+  if (options.command === "check") return run(process.execPath, ["install.js", "--check"], SOURCE_ROOT);
+  if (options.command === "smoke") return run(process.execPath, ["install.js", "--smoke", "--skip-install"], SOURCE_ROOT);
+  if (options.command === "path") return console.log(options.target);
+  if (options.command === "run") {
+    return run(process.execPath, ["mcp/src/tools/image-set-run.mjs", ...process.argv.slice(3)], SOURCE_ROOT);
+  }
+  console.log([
+    "Usage:",
+    "  fmode-image-set install [--smoke]",
+    "  fmode-image-set workspace [--smoke]",
+    "  fmode-image-set check",
+    "  fmode-image-set smoke",
+    "  fmode-image-set run <start|execute|resume|status> [options]",
+    "  fmode-image-set path",
+  ].join("\n"));
+}
+
+try {
+  main();
+} catch (error) {
+  console.error(`fmode-image-set failed: ${error.message}`);
+  process.exit(1);
+}

+ 47 - 0
claude-code/fmode-image-set/docs/capability-map.md

@@ -0,0 +1,47 @@
+# Fmode Image Set 能力地图
+
+## 一句话定位
+
+把一张真实商品图和一句业务需求,转成有证据、有分工、可校验、可恢复的电商七图。
+
+## 用户任务到入口映射
+
+| 用户任务 | Skill | MCP 工具 | 动作 | 输出 |
+| --- | --- | --- | --- | --- |
+| 理解商品并开始规划 | `fmode-image-set` | `fmode_image_set_run` | `start` | 运行目录、视觉理解、商品身份锁 |
+| 校验七图计划 | `fmode-image-set` | `fmode_image_set_validate` | — | 校验结果与修改项 |
+| 实际生成七图 | `fmode-image-set` | `fmode_image_set_run` | `execute` | 本地图片、状态与费用 |
+| 恢复中断任务 | `fmode-image-set` | `fmode_image_set_run` | `resume` | 同运行增量结果 |
+| 查看运行状态 | `fmode-image-set` | `fmode_image_set_run` | `status` | 账本与交付完整性 |
+
+## 能力边界
+
+- 当前固定七张,不是任意数量套图引擎。
+- 当前下游执行器为即梦 4.0。
+- 没有确定性文字排版器,重要文字和数值不交给生图模型重绘。
+- 商品图可证明可见事实,不能证明功效、成分、销量和认证。
+- 竞品图只能提供设计语法,不能作为商品资产。
+
+## 文件索引
+
+| 文件 | 用途 |
+| --- | --- |
+| `skills/fmode-image-set/SKILL.md` | 触发规则、证据边界、执行纪律和用户输出 |
+| `skills/fmode-image-set/references/*` | 商品理解、七图规划、Prompt 编译、Schema、即梦执行 |
+| `mcp/src/server.mjs` | MCP 工具注册与 Zod Schema |
+| `mcp/src/tools/*` | CLI/MCP 薄入口 |
+| `mcp/src/features/image-set/*` | 套图编排、视觉理解与计划校验 |
+| `mcp/src/providers/*` | 附件、视觉、七牛、即梦 |
+| `mcp/src/core/*` | 路径门禁、状态签名和统一结果 |
+| `docs/deliverables/image-set-result.md` | 套图交付合同 |
+| `docs/live-manual-acceptance-checklist.md` | 真实接口人工验收 |
+| `scripts/*` | 包级、MCP 和安装验收 |
+
+## 发布检查点
+
+- `package.json`、插件清单、包清单和 MCP Server 版本一致;
+- `npm run smoke:package` 通过;
+- `npm run mcp:smoke` 通过;
+- `npm run acceptance` 通过;
+- 真实生图发布前完成最小人工验收;
+- 不把真实 Token、用户附件和运行结果打入 npm 包。

+ 31 - 0
claude-code/fmode-image-set/docs/customer-quickstart.md

@@ -0,0 +1,31 @@
+# 客户快速开始
+
+## 你可以这样开始
+
+上传一张清晰商品图,然后说:
+
+```text
+给这款商品做一套高级、适合电商详情页的七图。
+```
+
+只想先看方案:
+
+```text
+先规划七张图分别讲什么,不要实际生图。
+```
+
+## 你会看到什么
+
+- 一张白底主图;
+- 两张不同任务的场景图;
+- 两张有证据的卖点图;
+- 两张解决购买疑问的决策图;
+- 商品外观和整套视觉风格保持一致。
+
+## 什么时候会产生费用
+
+只有你明确要求“开始生图”后才会调用图片模型。默认七张各生成一次,费用上限为 1.54 元;需要额外重试时会先说明。
+
+## 图片不够清楚怎么办
+
+系统会指出缺少的最短补拍清单,不会为了填满七张而虚构不可见结构、规格或功效。

+ 44 - 0
claude-code/fmode-image-set/docs/deliverables/image-set-result.md

@@ -0,0 +1,44 @@
+# 商品套图交付合同
+
+## 适用场景
+
+用户提供真实商品图,希望获得一套用于电商主图、详情页或商品传播的七张图片。
+
+## 输入字段
+
+- 商品参考图;
+- 一句话业务需求;
+- 平台/市场,可选;
+- 商品官方事实,可选;
+- VOC、竞品、关键词和趋势,可选;
+- 是否授权实际生图;
+- 费用上限。
+
+## 输出结构
+
+1. 运行编号与状态;
+2. 七张图片的业务角色;
+3. 01–07 本地成品;
+4. 成品文件夹;
+5. 完成、失败、未知、拒绝和可恢复图位;
+6. 已发生费用范围;
+7. 下一步动作。
+
+内部同时保留:
+
+- `vision-result.json`
+- `prompt-plan.json`
+- `reference-assets.json`
+- `jimeng-result.json`
+
+## 验收标准
+
+- 恰好 1 张主图、2 张场景图、2 张卖点图、2 张购买决策图;
+- 商品真实性严重错误为 0;
+- 两张卖点图使用不同证据;
+- 七张图不是同一构图换背景;
+- 商品锁、可见事实和视觉审计保持一致;
+- 重要 Logo、包装文字、规格和数量没有漂移;
+- 交付文件全部来自本次运行账本;
+- 费用不超过用户授权上限;
+- 部分完成时不伪称七张全部成功。

+ 41 - 0
claude-code/fmode-image-set/docs/live-manual-acceptance-checklist.md

@@ -0,0 +1,41 @@
+# Fmode Image Set 真实执行验收清单
+
+## 验收前准备
+
+- 使用无敏感信息的测试商品;
+- 确认视觉模型、七牛和即梦凭据已配置;
+- 确认服务端余额;
+- 使用新的测试运行,不修改历史状态文件;
+- 默认费用上限保持 1.54 元。
+
+## 最小验收
+
+1. 使用一张清晰商品图执行 `start`。
+2. 检查 `vision-result.json`:
+   - 可见事实与图片一致;
+   - 推断与未知项已分离;
+   - `identity_manifest` 没有虚构结构。
+3. 编写七图计划并执行校验。
+4. 确认校验不通过时没有调用即梦。
+5. 用户明确授权后执行 `execute`。
+6. 检查每张图完成下载后才提交下一张。
+7. 检查生成后商品身份复核。
+8. 检查成品目录与账本一致。
+9. 检查最终费用范围。
+
+## 失败场景
+
+- 连接预检失败:应标记未提交,并允许同运行恢复;
+- `submission_unknown`:不得自动重提;
+- 单图明确失败:隔离后继续后续图位;
+- `quality_rejected`:不交付、不自动补图;
+- 状态被手工修改:恢复应被签名校验拒绝;
+- 从其他运行复制图片:交付审计应阻断。
+
+## 通过标准
+
+- 商品真实性严重错误为 0;
+- 计划校验与身份复核均生效;
+- 状态和费用可追踪;
+- 同一用户请求不会创建第二个运行;
+- 所有失败都有明确状态和下一步。

+ 110 - 0
claude-code/fmode-image-set/install.js

@@ -0,0 +1,110 @@
+#!/usr/bin/env node
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ROOT = path.dirname(fileURLToPath(import.meta.url));
+const MIN_NODE_MAJOR = 20;
+const REQUIRED_FILES = [
+  "package.json",
+  ".claude-plugin/plugin.json",
+  ".mcp.json",
+  "skill-package-manifest.json",
+  "skills/fmode-image-set/SKILL.md",
+  "mcp/src/server.mjs",
+  "mcp/src/tools/image-set-run.mjs",
+  "mcp/src/tools/image-set-validate.mjs",
+  "mcp/src/features/image-set/image-set-runner.mjs",
+  "mcp/src/features/image-set/product-vision-client.mjs",
+  "mcp/src/features/image-set/validate-plan.mjs",
+  "mcp/src/providers/jimeng-v4-client.mjs",
+];
+
+function parseArgs(argv) {
+  return {
+    check: argv.includes("--check"),
+    smoke: argv.includes("--smoke"),
+    skipInstall: argv.includes("--skip-install"),
+    help: argv.includes("--help") || argv.includes("-h"),
+  };
+}
+
+function run(command, args, cwd = ROOT) {
+  const useCmd = process.platform === "win32" && command === "npm";
+  const executable = useCmd ? "cmd.exe" : command;
+  const finalArgs = useCmd ? ["/d", "/s", "/c", "npm", ...args] : args;
+  const child = spawnSync(executable, finalArgs, {
+    cwd,
+    encoding: "utf8",
+    stdio: "inherit",
+    maxBuffer: 1024 * 1024 * 100,
+  });
+  if (child.status !== 0) {
+    throw new Error(`${command} ${args.join(" ")} failed with exit ${child.status}`);
+  }
+}
+
+function readJson(relativePath) {
+  return JSON.parse(fs.readFileSync(path.join(ROOT, relativePath), "utf8"));
+}
+
+function writeMcpConfig(rootDir = ROOT) {
+  const config = {
+    mcpServers: {
+      "fmode-image-set": {
+        command: "node",
+        args: [path.join(rootDir, "mcp", "src", "server.mjs")],
+        cwd: rootDir,
+      },
+    },
+  };
+  fs.writeFileSync(path.join(rootDir, ".mcp.json"), `${JSON.stringify(config, null, 2)}\n`, "utf8");
+}
+
+export function checkPackage() {
+  const major = Number(process.versions.node.split(".")[0]);
+  if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
+    throw new Error(`Node.js ${MIN_NODE_MAJOR}+ is required. Current version: ${process.version}`);
+  }
+  const missing = REQUIRED_FILES.filter((relativePath) => !fs.existsSync(path.join(ROOT, relativePath)));
+  if (missing.length) throw new Error(`Missing required files: ${missing.join(", ")}`);
+
+  const packageJson = readJson("package.json");
+  const pluginJson = readJson(".claude-plugin/plugin.json");
+  const manifest = readJson("skill-package-manifest.json");
+  const serverText = fs.readFileSync(path.join(ROOT, "mcp", "src", "server.mjs"), "utf8");
+  const serverVersion = serverText.match(/const VERSION = "([^"]+)"/)?.[1] || "";
+  const versions = new Set([packageJson.version, pluginJson.version, manifest.version, serverVersion]);
+  if (versions.size !== 1) throw new Error("package, plugin, and skill manifest versions must match");
+  if (pluginJson.name !== "fmode-image-set" || manifest.name !== "fmode-image-set") {
+    throw new Error("plugin and skill manifest names must be fmode-image-set");
+  }
+  if (!manifest.skills.includes("fmode-image-set")) throw new Error("entry skill is missing from package manifest");
+  return { version: packageJson.version, files: REQUIRED_FILES.length };
+}
+
+async function main() {
+  const options = parseArgs(process.argv.slice(2));
+  if (options.help) {
+    console.log("Usage: node install.js [--check] [--smoke] [--skip-install]");
+    return;
+  }
+  const checked = checkPackage();
+  writeMcpConfig(ROOT);
+  console.log(`Fmode Image Set ${checked.version}`);
+  console.log(`Required files: OK (${checked.files})`);
+
+  if (!options.check && !options.skipInstall) {
+    run("npm", ["install", "--omit=dev", "--ignore-scripts"]);
+  }
+  if (options.smoke) {
+    run(process.execPath, ["scripts/smoke-package.mjs"]);
+    run(process.execPath, ["scripts/smoke-mcp.mjs"]);
+  }
+}
+
+main().catch((error) => {
+  console.error(`Install failed: ${error.message}`);
+  process.exit(1);
+});

+ 25 - 0
claude-code/fmode-image-set/mcp/src/core/result-envelope.mjs

@@ -0,0 +1,25 @@
+export function okResult(payload = {}) {
+  return {
+    status: "ok",
+    assistantMessage: payload.assistantMessage || "",
+    summary: payload.summary || {},
+    data: payload.data || {},
+    files: payload.files || [],
+    nextActions: payload.nextActions || [],
+    warnings: payload.warnings || [],
+    errors: payload.errors || [],
+  };
+}
+
+export function errorResult(status, message, payload = {}) {
+  return {
+    status: status || "error",
+    assistantMessage: message,
+    summary: payload.summary || {},
+    data: payload.data || {},
+    files: payload.files || [],
+    nextActions: payload.nextActions || [],
+    warnings: payload.warnings || [],
+    errors: payload.errors || [{ message }],
+  };
+}

+ 167 - 0
claude-code/fmode-image-set/mcp/src/core/run-paths.mjs

@@ -0,0 +1,167 @@
+import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { createHash, randomBytes } from "node:crypto";
+import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const scriptsDirectory = dirname(fileURLToPath(import.meta.url));
+export const packageRoot = resolve(scriptsDirectory, "..", "..", "..");
+
+function defaultProjectRoot() {
+  const parent = resolve(packageRoot, "..");
+  const parentName = basename(parent).toLowerCase();
+  const containerName = basename(resolve(parent, "..")).toLowerCase();
+  if (parentName === "plugins" && new Set([".claude", ".codex", ".vocmarket"]).has(containerName)) {
+    return resolve(parent, "..", "..");
+  }
+  return parent;
+}
+
+export const projectRoot = resolve(
+  process.env.FMODE_IMAGE_SET_PROJECT_ROOT
+    || process.env.CLAUDE_PROJECT_DIR
+    || defaultProjectRoot(),
+);
+export const runsRoot = join(projectRoot, "生成结果", "电商七图");
+export const requestIndexRoot = join(runsRoot, ".request-index");
+
+function compactTimestamp(date) {
+  const parts = new Intl.DateTimeFormat("en-GB", {
+    timeZone: "Asia/Shanghai",
+    year: "numeric",
+    month: "2-digit",
+    day: "2-digit",
+    hour: "2-digit",
+    minute: "2-digit",
+    second: "2-digit",
+    hourCycle: "h23",
+  }).formatToParts(date);
+  const value = (type) => parts.find((part) => part.type === type)?.value ?? "";
+  return `${value("year")}${value("month")}${value("day")}${value("hour")}${value("minute")}${value("second")}`;
+}
+
+export function createRunId(date = new Date(), suffix = randomBytes(3).toString("hex")) {
+  return `run-${compactTimestamp(date)}-${suffix}`;
+}
+
+export function isInside(parent, candidate) {
+  const normalizedParent = process.platform === "win32" ? resolve(parent).toLowerCase() : resolve(parent);
+  const normalizedCandidate = process.platform === "win32" ? resolve(candidate).toLowerCase() : resolve(candidate);
+  const pathFromParent = relative(normalizedParent, normalizedCandidate);
+  return pathFromParent === "" || (!pathFromParent.startsWith("..") && !isAbsolute(pathFromParent));
+}
+
+export function assertCanonicalRunDirectory(candidate) {
+  const directory = resolve(candidate);
+  const parent = resolve(directory, "..");
+  const sameParent = process.platform === "win32"
+    ? parent.toLowerCase() === resolve(runsRoot).toLowerCase()
+    : parent === resolve(runsRoot);
+  if (!isInside(runsRoot, directory) || !sameParent) {
+    throw new Error(`RUN_PATH_INVALID: run directory must be one direct child of ${runsRoot}`);
+  }
+  if (!/^run-\d{14}-[a-f0-9]{6}$/.test(basename(directory))) {
+    throw new Error("RUN_PATH_INVALID: run directory name must come from create-run.mjs");
+  }
+  return directory;
+}
+
+export function assertRunFile(candidate, runDirectory, expectedName = "") {
+  const filename = resolve(candidate);
+  const canonicalRunDirectory = assertCanonicalRunDirectory(runDirectory);
+  const sameDirectory = process.platform === "win32"
+    ? dirname(filename).toLowerCase() === canonicalRunDirectory.toLowerCase()
+    : dirname(filename) === canonicalRunDirectory;
+  if (!sameDirectory) {
+    throw new Error(`RUN_PATH_INVALID: ${filename} must be stored directly in ${canonicalRunDirectory}`);
+  }
+  const expectedFile = join(canonicalRunDirectory, expectedName);
+  const sameFile = process.platform === "win32"
+    ? filename.toLowerCase() === expectedFile.toLowerCase()
+    : filename === expectedFile;
+  if (expectedName && !sameFile) {
+    throw new Error(`RUN_PATH_INVALID: expected ${join(canonicalRunDirectory, expectedName)}`);
+  }
+  return filename;
+}
+
+export function pathsForRun(runId) {
+  if (!/^run-\d{14}-[a-f0-9]{6}$/.test(runId)) {
+    throw new Error("RUN_ID_INVALID: run_id must be generated by create-run.mjs");
+  }
+  const runDirectory = assertCanonicalRunDirectory(join(runsRoot, runId));
+  return {
+    project_root: projectRoot,
+    runs_root: runsRoot,
+    run_id: runId,
+    run_directory: runDirectory,
+    attachments_directory: join(runDirectory, "商品附件"),
+    images_directory: join(runDirectory, "成品图"),
+    vision_result: join(runDirectory, "vision-result.json"),
+    prompt_plan: join(runDirectory, "prompt-plan.json"),
+    asset_map: join(runDirectory, "reference-assets.json"),
+    jimeng_result: join(runDirectory, "jimeng-result.json"),
+  };
+}
+
+export function requestIndexDirectory(requestKey) {
+  const digest = createHash("sha256").update(String(requestKey)).digest("hex");
+  return join(requestIndexRoot, digest);
+}
+
+async function allocateRun() {
+  await mkdir(runsRoot, { recursive: true });
+  for (let attempt = 0; attempt < 5; attempt += 1) {
+    const paths = pathsForRun(createRunId());
+    try {
+      await mkdir(paths.run_directory, { recursive: false });
+      await mkdir(paths.attachments_directory);
+      await mkdir(paths.images_directory);
+      return paths;
+    } catch (error) {
+      if (error?.code !== "EEXIST") throw error;
+    }
+  }
+  throw new Error("RUN_CREATE_FAILED: could not allocate a unique run directory");
+}
+
+export async function createRun({ requestKey = "" } = {}) {
+  if (!requestKey) return allocateRun();
+  await mkdir(requestIndexRoot, { recursive: true });
+  const indexDirectory = requestIndexDirectory(requestKey);
+  try {
+    await mkdir(indexDirectory, { recursive: false });
+  } catch (error) {
+    if (error?.code !== "EEXIST") throw error;
+    for (let attempt = 0; attempt < 20; attempt += 1) {
+      try {
+        const indexed = JSON.parse(await readFile(join(indexDirectory, "run.json"), "utf8"));
+        const paths = pathsForRun(indexed.run_id);
+        return {
+          ...paths,
+          reused_existing_run: true,
+          request_key_hash: basename(indexDirectory),
+        };
+      } catch (readError) {
+        if (readError?.code !== "ENOENT") throw readError;
+        await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
+      }
+    }
+    throw new Error("RUN_REQUEST_LOCK_INCOMPLETE: an existing request lock has no run assignment");
+  }
+  try {
+    const paths = await allocateRun();
+    await writeFile(join(indexDirectory, "run.json"), `${JSON.stringify({
+      schema_version: "0.1",
+      run_id: paths.run_id,
+      created_at: new Date().toISOString(),
+    }, null, 2)}\n`, "utf8");
+    return {
+      ...paths,
+      reused_existing_run: false,
+      request_key_hash: basename(indexDirectory),
+    };
+  } catch (error) {
+    await rm(indexDirectory, { recursive: true, force: true });
+    throw error;
+  }
+}

+ 68 - 0
claude-code/fmode-image-set/mcp/src/core/run-paths.test.mjs

@@ -0,0 +1,68 @@
+import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
+import { rm } from "node:fs/promises";
+import { join } from "node:path";
+import test from "node:test";
+import {
+  assertCanonicalRunDirectory,
+  assertRunFile,
+  createRun,
+  createRunId,
+  pathsForRun,
+  requestIndexDirectory,
+  runsRoot,
+} from "./run-paths.mjs";
+
+test("run IDs and paths always resolve below the project run root", () => {
+  const runId = createRunId(new Date("2026-07-23T12:34:56.000Z"), "abcdef");
+  assert.equal(runId, "run-20260723203456-abcdef");
+  const paths = pathsForRun(runId);
+  assert.equal(paths.run_directory, join(runsRoot, runId));
+  assert.equal(paths.runs_root, join(paths.project_root, "生成结果", "电商七图"));
+  assert.equal(paths.attachments_directory, join(runsRoot, runId, "商品附件"));
+  assert.equal(paths.images_directory, join(runsRoot, runId, "成品图"));
+});
+
+test("run IDs use Asia/Shanghai calendar dates across the UTC midnight boundary", () => {
+  const runId = createRunId(new Date("2026-07-23T16:55:04.000Z"), "123abc");
+  assert.equal(runId, "run-20260724005504-123abc");
+});
+
+test("run path checks reject skill-source and nested output directories", () => {
+  assert.throws(
+    () => assertCanonicalRunDirectory(join(runsRoot, "run-ok", "nested")),
+    /RUN_PATH_INVALID/,
+  );
+  const runDirectory = join(runsRoot, "run-20260723123456-abcdef");
+  assert.throws(
+    () => assertRunFile(join(runDirectory, "nested", "jimeng-result.json"), runDirectory),
+    /RUN_PATH_INVALID/,
+  );
+});
+
+test("Windows run path checks ignore drive-letter casing", { skip: process.platform !== "win32" }, () => {
+  const runDirectory = join(runsRoot, "run-20260723123456-abcdef");
+  const lowerCaseDrive = `${runDirectory[0].toLowerCase()}${runDirectory.slice(1)}`;
+  assert.equal(assertCanonicalRunDirectory(lowerCaseDrive).toLowerCase(), runDirectory.toLowerCase());
+  assert.equal(
+    assertRunFile(join(lowerCaseDrive, "vision-result.json"), lowerCaseDrive, "vision-result.json").toLowerCase(),
+    join(runDirectory, "vision-result.json").toLowerCase(),
+  );
+});
+
+test("the same Claude request key always reuses one run directory", async () => {
+  const requestKey = `test-request-${randomUUID()}`;
+  const indexDirectory = requestIndexDirectory(requestKey);
+  let first;
+  try {
+    first = await createRun({ requestKey });
+    const second = await createRun({ requestKey });
+    assert.equal(first.reused_existing_run, false);
+    assert.equal(second.reused_existing_run, true);
+    assert.equal(second.run_id, first.run_id);
+    assert.equal(second.run_directory, first.run_directory);
+  } finally {
+    if (first) await rm(first.run_directory, { recursive: true, force: true });
+    await rm(indexDirectory, { recursive: true, force: true });
+  }
+});

+ 38 - 0
claude-code/fmode-image-set/mcp/src/core/run-state-integrity.mjs

@@ -0,0 +1,38 @@
+import { createHash, createHmac, timingSafeEqual } from "node:crypto";
+
+function unsignedState(state) {
+  const clone = { ...state };
+  delete clone.state_integrity;
+  return clone;
+}
+
+export function sha256(value) {
+  return createHash("sha256").update(value).digest("hex");
+}
+
+export function signState(state, secret) {
+  if (!secret) throw new Error("RUN_STATE_SECRET_MISSING");
+  const payload = JSON.stringify(unsignedState(state));
+  const signature = createHmac("sha256", secret).update(payload).digest("hex");
+  return {
+    ...unsignedState(state),
+    state_integrity: {
+      algorithm: "hmac-sha256",
+      signature,
+    },
+  };
+}
+
+export function verifyState(state, secret) {
+  const supplied = state?.state_integrity?.signature;
+  if (state?.state_integrity?.algorithm !== "hmac-sha256" || typeof supplied !== "string") {
+    throw new Error("RUN_STATE_UNSIGNED: executor state is missing its integrity signature");
+  }
+  const expected = signState(state, secret).state_integrity.signature;
+  const suppliedBuffer = Buffer.from(supplied, "hex");
+  const expectedBuffer = Buffer.from(expected, "hex");
+  if (suppliedBuffer.length !== expectedBuffer.length || !timingSafeEqual(suppliedBuffer, expectedBuffer)) {
+    throw new Error("RUN_STATE_TAMPERED: executor state was modified outside the executor");
+  }
+  return true;
+}

+ 14 - 0
claude-code/fmode-image-set/mcp/src/core/run-state-integrity.test.mjs

@@ -0,0 +1,14 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { signState, verifyState } from "./run-state-integrity.mjs";
+
+test("signed executor state verifies", () => {
+  const state = signState({ run_id: "run-1", results: [{ slot_id: "01", status: "complete" }] }, "secret");
+  assert.equal(verifyState(state, "secret"), true);
+});
+
+test("manual state edits are rejected", () => {
+  const state = signState({ run_id: "run-1", results: [{ slot_id: "01", status: "submission_unknown" }] }, "secret");
+  state.results[0].status = "not_submitted";
+  assert.throws(() => verifyState(state, "secret"), /RUN_STATE_TAMPERED/);
+});

+ 127 - 0
claude-code/fmode-image-set/mcp/src/features/image-set/image-set-runner.mjs

@@ -0,0 +1,127 @@
+import { spawn } from "node:child_process";
+import { readFile, readdir } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { inspectLatestClaudeInvocation } from "../../providers/claude-attachment-client.mjs";
+import { createRun, pathsForRun } from "../../core/run-paths.mjs";
+
+const scriptsDirectory = dirname(fileURLToPath(import.meta.url));
+const productVisionFile = resolve(scriptsDirectory, "product-vision-client.mjs");
+const jimengClientFile = resolve(scriptsDirectory, "..", "..", "providers", "jimeng-v4-client.mjs");
+const args = process.argv.slice(2);
+const command = args[0];
+const option = (name, fallback = "") => {
+  const index = args.indexOf(name);
+  return index >= 0 ? args[index + 1] : fallback;
+};
+
+function runNode(scriptFile, childArgs) {
+  return new Promise((resolvePromise, reject) => {
+    const child = spawn(process.execPath, [scriptFile, ...childArgs], {
+      cwd: process.cwd(),
+      env: process.env,
+      stdio: ["ignore", "pipe", "pipe"],
+    });
+    let stdout = "";
+    child.stdout.on("data", (chunk) => { stdout += chunk; });
+    child.stderr.on("data", (chunk) => { process.stderr.write(chunk); });
+    child.on("error", reject);
+    child.on("close", (code) => {
+      if (code !== 0) {
+        reject(new Error(`${scriptFile} exited with code ${code}`));
+        return;
+      }
+      resolvePromise(stdout);
+    });
+  });
+}
+
+export async function auditDelivery(paths, result) {
+  const tracked = new Set(
+    (result.results ?? [])
+      .flatMap((item) => item.local_images ?? [])
+      .map((file) => resolve(file).toLocaleLowerCase()),
+  );
+  const entries = await readdir(paths.images_directory, { withFileTypes: true });
+  const localFiles = entries
+    .filter((entry) => entry.isFile())
+    .map((entry) => resolve(join(paths.images_directory, entry.name)));
+  const untrackedFiles = localFiles.filter((file) => !tracked.has(file.toLocaleLowerCase()));
+  const missingFiles = [...tracked].filter((file) => !localFiles.some(
+    (candidate) => candidate.toLocaleLowerCase() === file,
+  ));
+  return {
+    status: untrackedFiles.length === 0 && missingFiles.length === 0 ? "verified" : "invalid",
+    untracked_files: untrackedFiles,
+    missing_files: missingFiles,
+  };
+}
+
+async function withDeliveryAudit(paths, summary) {
+  const state = JSON.parse(await readFile(paths.jimeng_result, "utf8"));
+  const deliveryIntegrity = await auditDelivery(paths, state);
+  if (deliveryIntegrity.status === "verified") {
+    return { ...summary, delivery_integrity: deliveryIntegrity };
+  }
+  return {
+    ...summary,
+    status: "blocked",
+    delivery_integrity: deliveryIntegrity,
+    next_action: "stop_and_report_delivery_integrity_error",
+  };
+}
+
+async function runCli() {
+if (command === "start") {
+  const explicitImage = option("--image");
+  const invocation = await inspectLatestClaudeInvocation({
+    cwd: option("--cwd", process.cwd()),
+    claudeConfigDirectory: option("--claude-config-dir", process.env.CLAUDE_CONFIG_DIR),
+    sessionId: option("--session-id", process.env.CLAUDE_CODE_SESSION_ID || ""),
+  });
+  if (!explicitImage && invocation.image_count !== 1) {
+    throw new Error(`CURRENT_REQUEST_IMAGE_COUNT_INVALID: expected one image, found ${invocation.image_count}`);
+  }
+  const paths = await createRun({ requestKey: `${invocation.session_id}:${invocation.prompt_id}` });
+  const visionStdout = await runNode(productVisionFile, explicitImage
+    ? ["--image", explicitImage, "--output", paths.vision_result]
+    : ["--from-claude-attachment", "--output", paths.vision_result]);
+  const vision = JSON.parse(visionStdout);
+  console.log(JSON.stringify({
+    status: vision.status === "complete" ? "ready_for_plan" : "blocked",
+    paths,
+    vision,
+    next_action: vision.status === "complete"
+      ? "write_prompt_plan_then_execute_same_run"
+      : "stop_and_report_error",
+  }, null, 2));
+} else if (command === "execute" || command === "resume") {
+  const runId = option("--run-id");
+  if (!runId) throw new Error("RUN_ID_REQUIRED");
+  const paths = pathsForRun(runId);
+  const maxCost = option("--max-cost", "1.54");
+  const stdout = await runNode(jimengClientFile, [
+    paths.prompt_plan,
+    "--execute",
+    "--vision-result", paths.vision_result,
+    "--asset-map", paths.asset_map,
+    "--max-cost", maxCost,
+    "--output", paths.jimeng_result,
+  ]);
+  const summary = JSON.parse(stdout);
+  console.log(JSON.stringify(await withDeliveryAudit(paths, summary), null, 2));
+} else if (command === "status") {
+  const runId = option("--run-id");
+  if (!runId) throw new Error("RUN_ID_REQUIRED");
+  const paths = pathsForRun(runId);
+  const result = JSON.parse(await readFile(paths.jimeng_result, "utf8"));
+  console.log(JSON.stringify(await withDeliveryAudit(paths, { paths, result }), null, 2));
+} else {
+  throw new Error("Usage: node image-set-runner.mjs start [--image <path-or-url>] | execute --run-id <run_id> [--max-cost 1.54] | resume --run-id <run_id> | status --run-id <run_id>");
+}
+}
+
+const invokedFile = process.argv[1] ? resolve(process.argv[1]) : "";
+if (invokedFile === fileURLToPath(import.meta.url)) {
+  await runCli();
+}

+ 24 - 0
claude-code/fmode-image-set/mcp/src/features/image-set/image-set-runner.test.mjs

@@ -0,0 +1,24 @@
+import assert from "node:assert/strict";
+import { rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import test from "node:test";
+import { auditDelivery } from "./image-set-runner.mjs";
+import { createRun } from "../../core/run-paths.mjs";
+
+test("delivery audit rejects files copied from another run", async () => {
+  const paths = await createRun();
+  try {
+    const trackedFile = join(paths.images_directory, "01-白底主图.png");
+    const copiedFile = join(paths.images_directory, "06-场景图.png");
+    await writeFile(trackedFile, "tracked");
+    await writeFile(copiedFile, "copied");
+    const audit = await auditDelivery(paths, {
+      results: [{ slot_id: "01", status: "complete", local_images: [trackedFile] }],
+    });
+    assert.equal(audit.status, "invalid");
+    assert.deepEqual(audit.untracked_files, [copiedFile]);
+    assert.deepEqual(audit.missing_files, []);
+  } finally {
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});

+ 394 - 0
claude-code/fmode-image-set/mcp/src/features/image-set/product-vision-client.mjs

@@ -0,0 +1,394 @@
+import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { dirname, isAbsolute, join, resolve } from "node:path";
+import { extractLatestClaudeAttachment } from "../../providers/claude-attachment-client.mjs";
+import { uploadLocalImage } from "../../providers/qiniu-upload-client.mjs";
+import { assertRunFile } from "../../core/run-paths.mjs";
+import {
+  compileProductLock,
+  loadVisionConfig,
+  requestVisionJson,
+} from "../../providers/commerce-vision-provider.mjs";
+
+const args = process.argv.slice(2);
+const option = (name, fallback = "") => {
+  const index = args.indexOf(name);
+  return index >= 0 ? args[index + 1] : fallback;
+};
+const imageArg = option("--image");
+const outputArg = option("--output");
+const fromClaudeAttachment = args.includes("--from-claude-attachment");
+if ((!imageArg && !fromClaudeAttachment) || (imageArg && fromClaudeAttachment) || !outputArg) {
+  throw new Error("Usage: node product-vision-client.mjs (--image <local-path-or-url> | --from-claude-attachment) --output <vision-result.json> [--dry-run]");
+}
+
+const outputFile = assertRunFile(resolve(outputArg), dirname(resolve(outputArg)), "vision-result.json");
+const publicUrlPattern = /^https?:\/\//i;
+
+async function writeResult(result) {
+  await mkdir(dirname(outputFile), { recursive: true });
+  await writeFile(outputFile, `${JSON.stringify(result, null, 2)}\n`, "utf8");
+  console.log(JSON.stringify(result, null, 2));
+}
+
+class GracefulStop extends Error {}
+
+async function fail(code, message, visionCalled = false, stage = "") {
+  await writeResult({
+    schema_version: "0.3",
+    status: "blocked",
+    vision_called: visionCalled,
+    jimeng_called: false,
+    retryable: false,
+    next_action: "stop_and_report_error",
+    error: { code, message, ...(stage ? { stage } : {}) },
+  });
+  throw new GracefulStop();
+}
+
+async function resolveImage(value) {
+  if (publicUrlPattern.test(value)) return { kind: "public", source: value, public_url: value };
+  const localPath = isAbsolute(value) ? resolve(value) : resolve(process.cwd(), value);
+  try {
+    if (!(await stat(localPath)).isFile()) throw new Error("not a file");
+  } catch {
+    await fail("REFERENCE_IMAGE_NOT_FOUND", `The user-provided product image does not exist: ${localPath}`);
+  }
+  if (args.includes("--dry-run")) return { kind: "local", source: localPath, public_url: null };
+  const uploaded = await uploadLocalImage(localPath);
+  return { kind: "local", source: localPath, public_url: uploaded.url, asset: uploaded };
+}
+
+const text = (input) => typeof input === "string" ? input.trim() : "";
+const strings = (input) => Array.isArray(input) ? input.map(text).filter(Boolean) : [];
+const confidence = (input) => ["high", "medium", "low", "unknown"].includes(input) ? input : "unknown";
+const uniqueStrings = (input) => [...new Set(strings(input))];
+
+function semanticKey(value) {
+  return text(value)
+    .toLocaleLowerCase()
+    .replace(/[,。、“”‘’;:,.!!??++/\\|()[\]{}<>《》\s_-]/g, "")
+    .replace(/搭配|配有|配|以及|及|与|和/g, "");
+}
+
+function bigrams(value) {
+  const normalized = semanticKey(value);
+  if (normalized.length < 2) return new Set([normalized]);
+  return new Set([...normalized.slice(0, -1)].map((character, index) => `${character}${normalized[index + 1]}`));
+}
+
+function textSimilarity(left, right) {
+  const a = semanticKey(left);
+  const b = semanticKey(right);
+  if (!a || !b) return 0;
+  if (a.includes(b) || b.includes(a)) return Math.min(a.length, b.length) / Math.max(a.length, b.length);
+  const aPairs = bigrams(a);
+  const bPairs = bigrams(b);
+  const intersection = [...aPairs].filter((item) => bPairs.has(item)).length;
+  const union = new Set([...aPairs, ...bPairs]).size;
+  return union ? intersection / union : 0;
+}
+
+function semanticUniqueStrings(input) {
+  const result = [];
+  for (const value of strings(input)) {
+    if (result.some((existing) => textSimilarity(existing, value) >= 0.48)) continue;
+    result.push(value);
+  }
+  return result;
+}
+
+function normalizeFactReference(value) {
+  const match = text(value).match(/visible[\s_-]*fact\s*[:#_-]?\s*(\d+)/i);
+  return match ? `visible_fact:${Number(match[1])}` : "";
+}
+
+function inferFactReferences(value, factValues, fallbackIndex) {
+  const ranked = factValues
+    .map((fact, index) => ({ reference: `visible_fact:${index + 1}`, score: textSimilarity(value, fact) }))
+    .sort((left, right) => right.score - left.score);
+  const matched = ranked.filter((item) => item.score >= 0.12).slice(0, 2).map((item) => item.reference);
+  if (matched.length > 0) return matched;
+  return factValues.length > 0 ? [`visible_fact:${Math.min(fallbackIndex + 1, factValues.length)}`] : [];
+}
+
+function normalizeSellingPoints(value, factReferences, { fallbackValues = [], factValues = [] } = {}) {
+  const validReferences = [...factReferences];
+  const parsedItems = Array.isArray(value) ? value.map((item, index) => typeof item === "string" ? {
+    id: `visible-sp-${index + 1}`,
+    value: text(item),
+    basis_refs: [],
+    confidence: "unknown",
+  } : {
+    id: text(item?.id) || `visible-sp-${index + 1}`,
+    value: text(item?.value),
+    basis_refs: strings(item?.basis_refs)
+      .map(normalizeFactReference)
+      .filter((reference) => factReferences.has(reference)),
+    confidence: confidence(item?.confidence),
+  }).filter((item) => item.value) : [];
+  const items = [];
+  const seenValues = new Set();
+  for (const item of parsedItems) {
+    const key = item.value.toLocaleLowerCase();
+    if (seenValues.has(key)) continue;
+    seenValues.add(key);
+    items.push(item);
+  }
+  for (const fallbackValue of uniqueStrings(fallbackValues)) {
+    const key = fallbackValue.toLocaleLowerCase();
+    if (items.length >= 2) break;
+    if (seenValues.has(key)) continue;
+    seenValues.add(key);
+    items.push({
+      id: "",
+      value: fallbackValue,
+      basis_refs: [],
+      confidence: "medium",
+    });
+  }
+  if (items.length < 2 && validReferences.length > 0) {
+    items.push({
+      id: "",
+      value: "商品主体外观特征清晰可辨",
+      basis_refs: [],
+      confidence: "medium",
+    });
+  }
+  return items.slice(0, 4).map((item, index) => ({
+    ...item,
+    id: `visible-sp-${index + 1}`,
+    basis_refs: factValues.length > 0
+      ? inferFactReferences(item.value, factValues, index)
+      : item.basis_refs.length > 0 || validReferences.length === 0
+        ? item.basis_refs
+        : [validReferences[Math.min(index, validReferences.length - 1)]],
+  }));
+}
+
+function normalizeDraft(value) {
+  const visibleFacts = strings(value.visible_facts);
+  if (visibleFacts.length === 0) throw new Error("Vision draft requires visible_facts");
+  const factReferences = new Set(visibleFacts.map((_, index) => `visible_fact:${index + 1}`));
+  const productName = typeof value.product_name === "string" ? {
+    value: text(value.product_name),
+    basis: "image",
+    confidence: "unknown",
+  } : {
+    value: text(value.product_name?.value),
+    basis: text(value.product_name?.basis) || "image",
+    confidence: confidence(value.product_name?.confidence),
+  };
+  const visibleText = Array.isArray(value.visible_text) ? value.visible_text.map((item) => typeof item === "string" ? {
+    text: text(item),
+    confidence: "unknown",
+  } : {
+    text: text(item?.text),
+    confidence: confidence(item?.confidence),
+  }).filter((item) => item.text) : [];
+  const visibleSpecs = value.visible_specs && typeof value.visible_specs === "object" && !Array.isArray(value.visible_specs)
+    ? Object.fromEntries(Object.entries(value.visible_specs).map(([key, item]) => [key, text(item)]).filter(([, item]) => item))
+    : {};
+  return {
+    category: text(value.category) || "unknown",
+    product_name: { ...productName, value: productName.value || text(value.category) || "unknown" },
+    visible_facts: visibleFacts,
+    visible_text: visibleText,
+    visible_selling_points: normalizeSellingPoints(value.visible_selling_points, factReferences, {
+      fallbackValues: visibleFacts,
+      factValues: visibleFacts,
+    }),
+    visible_specs: visibleSpecs,
+    creative_positioning: strings(value.creative_positioning),
+    suggested_audiences: strings(value.suggested_audiences),
+    suggested_scenes: strings(value.suggested_scenes),
+    unsupported_claims: strings(value.unsupported_claims),
+    product_invariants: strings(value.product_invariants),
+    material_and_light_behavior: strings(value.material_and_light_behavior),
+    uncertain_features: strings(value.uncertain_features),
+    generation_risks: strings(value.generation_risks),
+  };
+}
+
+function normalizeAudit(value, draft) {
+  const auditedFacts = uniqueStrings(value.verified_visible_facts);
+  const verifiedFacts = auditedFacts.length > 0 ? auditedFacts : uniqueStrings(draft.visible_facts);
+  if (verifiedFacts.length === 0) throw new Error("Vision pipeline could not identify any visible product facts");
+  const factReferences = new Set(verifiedFacts.map((_, index) => `visible_fact:${index + 1}`));
+  const rawManifest = value.identity_manifest ?? {};
+  const mustPreserve = semanticUniqueStrings([
+    ...strings(rawManifest.must_preserve),
+    ...strings(draft.product_invariants),
+    ...verifiedFacts,
+  ]).slice(0, 7);
+  for (const fallback of ["商品整体轮廓与参考图一致", "商品主体数量与参考图一致"]) {
+    if (mustPreserve.length >= 3) break;
+    if (!mustPreserve.includes(fallback)) mustPreserve.push(fallback);
+  }
+  const identityManifest = {
+    must_preserve: mustPreserve,
+    must_not_add: uniqueStrings(rawManifest.must_not_add),
+    unknown_do_not_assert: uniqueStrings(rawManifest.unknown_do_not_assert),
+  };
+  identityManifest.product_lock = compileProductLock(identityManifest);
+  const productName = typeof value.product_name === "string" ? {
+    value: text(value.product_name),
+    basis: "verified visible evidence",
+    confidence: "medium",
+  } : {
+    value: text(value.product_name?.value) || draft.product_name.value,
+    basis: text(value.product_name?.basis) || "verified visible evidence",
+    confidence: confidence(value.product_name?.confidence),
+  };
+  const visibleSpecs = value.verified_specs && typeof value.verified_specs === "object" && !Array.isArray(value.verified_specs)
+    ? Object.fromEntries(Object.entries(value.verified_specs).map(([key, item]) => [key, text(item)]).filter(([, item]) => item))
+    : {};
+  const unknownFeatures = strings(value.unknown_features);
+  const auditedSellingPoints = normalizeSellingPoints(value.verified_selling_points, factReferences, {
+    fallbackValues: [
+      ...draft.visible_selling_points.map((item) => item.value),
+      ...verifiedFacts,
+      ...identityManifest.must_preserve,
+    ],
+    factValues: verifiedFacts,
+  });
+  return {
+    ...draft,
+    category: text(value.category) || draft.category,
+    product_name: productName,
+    visible_facts: verifiedFacts,
+    verified_visible_facts: verifiedFacts,
+    inferred_attributes: strings(value.inferred_attributes),
+    unknown_features: unknownFeatures,
+    visible_selling_points: auditedSellingPoints,
+    visible_specs: visibleSpecs,
+    product_invariants: identityManifest.must_preserve,
+    uncertain_features: unknownFeatures,
+    identity_manifest: identityManifest,
+    fact_audit: {
+      status: "verified",
+      corrections: strings(value.corrections),
+    },
+  };
+}
+
+async function main() {
+if (!args.includes("--dry-run")) {
+  try {
+    const existing = JSON.parse((await readFile(outputFile, "utf8")).replace(/^\uFEFF/, ""));
+    if (["complete", "blocked"].includes(existing.status)) {
+      console.log(JSON.stringify({
+        ...existing,
+        reused_existing_result: true,
+        external_calls_made: false,
+      }, null, 2));
+      throw new GracefulStop();
+    }
+  } catch (error) {
+    if (error instanceof GracefulStop) throw error;
+    if (error?.code !== "ENOENT") {
+      await fail("VISION_RESULT_STATE_INVALID", `Cannot safely reuse the existing vision result: ${error.message}`);
+    }
+  }
+}
+
+let effectiveImage = imageArg;
+let attachment = null;
+if (fromClaudeAttachment) {
+  try {
+    const maxAgeMinutes = Number(option("--attachment-max-age-minutes", "30"));
+    attachment = await extractLatestClaudeAttachment({
+      cwd: option("--attachment-cwd", process.cwd()),
+      outputDirectory: join(dirname(outputFile), "商品附件"),
+      claudeConfigDirectory: option("--claude-config-dir", process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")),
+      sessionId: option("--session-id", process.env.CLAUDE_CODE_SESSION_ID || ""),
+      maxAgeMs: Number.isFinite(maxAgeMinutes) && maxAgeMinutes > 0 ? maxAgeMinutes * 60 * 1000 : 30 * 60 * 1000,
+    });
+    effectiveImage = attachment.file_path;
+  } catch (error) {
+    await fail(error?.code || "CLAUDE_ATTACHMENT_RESOLUTION_FAILED", error?.message || "Claude attachment resolution failed");
+  }
+}
+
+const reference = await resolveImage(effectiveImage);
+if (attachment) {
+  reference.attachment = {
+    source: attachment.source,
+    media_type: attachment.media_type,
+    sha256: attachment.sha256,
+    prompt_id: attachment.prompt_id,
+    session_id: attachment.session_id,
+    captured_at: attachment.captured_at,
+  };
+}
+const config = await loadVisionConfig();
+if (args.includes("--dry-run")) {
+  await writeResult({
+    schema_version: "0.3",
+    status: "dry-run",
+    vision_called: false,
+    reference,
+    provider: { name: config.provider, model: config.model },
+  });
+  throw new GracefulStop();
+}
+if (!config.apiKey) await fail("VISION_PROVIDER_NOT_CONFIGURED", "Qwen vision API key is not configured on this machine");
+if (!reference.public_url) await fail("REFERENCE_UPLOAD_FAILED", "The product image could not be converted to a public URL");
+
+const draftInstruction = `你是跨品类电商商品视觉分析器。只依据输入商品图片提取可见事实。不得猜测手感、气味、容量、真实尺寸、材质成分、性能、功效或图片中不可见的信息。只返回 JSON,不要 Markdown。结构:
+{"category":"品类","product_name":{"value":"名称","basis":"依据","confidence":"high|medium|low"},"visible_facts":["可见事实"],"visible_text":[{"text":"可见文字","confidence":"high|medium|low"}],"visible_selling_points":[{"id":"visible-sp-1","value":"视觉卖点","basis_refs":["visible_fact:1"],"confidence":"high|medium|low"},{"id":"visible-sp-2","value":"另一视觉卖点","basis_refs":["visible_fact:2"],"confidence":"high|medium|low"}],"visible_specs":{"可见规格":"值"},"creative_positioning":["创意方向"],"suggested_audiences":["创意人群假设"],"suggested_scenes":["创意场景"],"unsupported_claims":["图片无法证明的结论"],"product_invariants":["不得改变的身份特征"],"material_and_light_behavior":["材质布光表现"],"uncertain_features":["无法确认内容"],"generation_risks":["容易生成错误的结构"]}`;
+
+let validationStage = "draft_request";
+try {
+  let draftCall;
+  try {
+    draftCall = await requestVisionJson({
+      config,
+      imageUrls: [reference.public_url],
+      instruction: draftInstruction,
+      temperature: 0.1,
+    });
+  } catch (error) {
+    await fail("VISION_REQUEST_FAILED", error.message || "Qwen vision request failed", true, "draft_request");
+  }
+  validationStage = "draft_normalization";
+  const draft = normalizeDraft(draftCall.value);
+  validationStage = "fact_audit_request";
+  const auditInstruction = `你是商品视觉事实审计器。请对照同一张商品参考图审计下方初稿,纠正把推断当事实、错误结构术语和前后矛盾。只返回 JSON,不要 Markdown。
+初稿:${JSON.stringify(draft)}
+返回:{"category":"中性品类","product_name":{"value":"只含可验证品牌/系列/品类","basis":"verified visible evidence","confidence":"high|medium|low"},"verified_visible_facts":["直接可见事实"],"inferred_attributes":["合理但不能直接证实的属性"],"unknown_features":["未展示或不能确定的属性"],"verified_selling_points":[{"id":"visible-sp-1","value":"可见视觉卖点","basis_refs":["visible_fact:1"],"confidence":"high|medium|low"},{"id":"visible-sp-2","value":"另一可见视觉卖点","basis_refs":["visible_fact:2"],"confidence":"high|medium|low"}],"verified_specs":{"直接可见规格":"值"},"identity_manifest":{"must_preserve":["至少3项商品身份特征"],"must_not_add":["参考图不存在且不得新增的结构"],"unknown_do_not_assert":["不能确定且提示词不得断言的属性"]},"corrections":["相对初稿的修正"]}
+规则:材质成分、真实尺寸、容量、内部结构、未显示闭合方式不得列为可见事实;正常敞开的衣襟不是前中开衩;没有可见纽扣不得写单排扣或双排扣;领型必须按实际轮廓;卖点不得加入气味、触感、性能或功效。`;
+  let auditCall;
+  try {
+    auditCall = await requestVisionJson({
+      config,
+      imageUrls: [reference.public_url],
+      instruction: auditInstruction,
+      temperature: 0,
+    });
+  } catch (error) {
+    await fail("VISION_FACT_AUDIT_FAILED", error.message || "Qwen vision fact audit failed", true, "fact_audit_request");
+  }
+  validationStage = "fact_audit_normalization";
+  const productUnderstanding = normalizeAudit(auditCall.value, draft);
+  await writeResult({
+    schema_version: "0.3",
+    status: "complete",
+    vision_called: true,
+    fact_audit_called: true,
+    reference,
+    provider: { name: config.provider, model: config.model },
+    product_understanding: productUnderstanding,
+    usage: { draft: draftCall.usage ?? null, audit: auditCall.usage ?? null },
+  });
+} catch (error) {
+  if (error instanceof GracefulStop) throw error;
+  await fail("VISION_RESPONSE_INVALID", error.message, true, validationStage);
+}
+}
+
+try {
+  await main();
+} catch (error) {
+  if (!(error instanceof GracefulStop)) throw error;
+}

+ 523 - 0
claude-code/fmode-image-set/mcp/src/features/image-set/validate-plan.mjs

@@ -0,0 +1,523 @@
+import { readFile } from "node:fs/promises";
+
+const filename = process.argv[2];
+if (!filename) throw new Error("Usage: node validate-plan.mjs <prompt-plan.json>");
+
+const plan = JSON.parse(await readFile(filename, "utf8"));
+const errors = [];
+const isText = (value) => typeof value === "string" && value.trim().length > 0;
+const asArray = (value) => Array.isArray(value) ? value : [];
+const countValues = (values) => new Map(values.map((value) => [
+  value,
+  values.filter((item) => item === value).length,
+]));
+
+if (plan.schema_version !== "0.6") errors.push("schema_version must be 0.6");
+if (!isText(plan.run_id)) errors.push("run_id is required");
+if (!new Set(["ready", "needs_input"]).has(plan.status)) errors.push("status must be ready or needs_input");
+if (plan.user_output_mode !== "images_only") errors.push("user_output_mode must be images_only");
+if (!isText(plan.request?.instruction)) errors.push("request.instruction is required");
+if (!Array.isArray(plan.request?.reference_images)) errors.push("request.reference_images must be an array");
+if (!isText(plan.context?.platform)) errors.push("context.platform is required");
+if (!new Set(["available", "unavailable"]).has(plan.context?.voc?.status)) errors.push("context.voc.status is invalid");
+if (!Array.isArray(plan.context?.voc?.findings)) errors.push("context.voc.findings must be an array");
+
+const understanding = plan.product_understanding ?? {};
+if (!isText(understanding.category)) errors.push("product_understanding.category is required");
+if (!isText(understanding.product_name?.value)) errors.push("product_understanding.product_name.value is required");
+if (!isText(understanding.product_name?.basis)) errors.push("product_understanding.product_name.basis is required");
+if (!new Set(["high", "medium", "low", "unknown"]).has(understanding.product_name?.confidence)) {
+  errors.push("product_understanding.product_name.confidence is invalid");
+}
+for (const key of [
+  "visible_facts",
+  "verified_visible_facts",
+  "inferred_attributes",
+  "unknown_features",
+  "provided_facts",
+  "candidate_selling_points",
+  "creative_positioning",
+  "suggested_audiences",
+  "suggested_scenes",
+  "unsupported_claims",
+  "unknowns",
+]) {
+  if (!Array.isArray(understanding[key])) errors.push(`product_understanding.${key} must be an array`);
+}
+if (!understanding.visible_specs || typeof understanding.visible_specs !== "object" || Array.isArray(understanding.visible_specs)) {
+  errors.push("product_understanding.visible_specs must be an object");
+}
+const identityManifest = understanding.identity_manifest ?? {};
+for (const key of ["must_preserve", "must_not_add", "unknown_do_not_assert"]) {
+  if (!Array.isArray(identityManifest[key])) errors.push(`product_understanding.identity_manifest.${key} must be an array`);
+}
+if (asArray(identityManifest.must_preserve).length < 3) {
+  errors.push("product_understanding.identity_manifest.must_preserve requires at least three features");
+}
+if (!isText(identityManifest.product_lock)) {
+  errors.push("product_understanding.identity_manifest.product_lock is required");
+} else {
+  const identityLockLength = [...identityManifest.product_lock].length;
+  if (identityLockLength < 20 || identityLockLength > 160) {
+    errors.push(`product_understanding.identity_manifest.product_lock must be 20..160 characters (${identityLockLength})`);
+  }
+}
+
+const sellingPoints = asArray(understanding.candidate_selling_points);
+const validSellingPointBasisRefs = new Set(["user_instruction"]);
+asArray(understanding.visible_facts).forEach((_, index) => validSellingPointBasisRefs.add(`visible_fact:${index + 1}`));
+asArray(understanding.provided_facts).forEach((_, index) => validSellingPointBasisRefs.add(`provided_fact:${index + 1}`));
+asArray(plan.context?.voc?.findings).forEach((_, index) => validSellingPointBasisRefs.add(`voc:${index + 1}`));
+if (plan.status === "ready" && sellingPoints.length < 2) {
+  errors.push("ready plan requires at least two candidate_selling_points");
+}
+const sellingPointIds = [];
+for (const [index, point] of sellingPoints.entries()) {
+  if (!isText(point?.id)) errors.push(`candidate_selling_points[${index}].id is required`);
+  if (!isText(point?.value)) errors.push(`candidate_selling_points[${index}].value is required`);
+  if (!Array.isArray(point?.basis_refs) || point.basis_refs.length === 0) {
+    errors.push(`candidate_selling_points[${index}].basis_refs must be non-empty`);
+  } else {
+    for (const reference of point.basis_refs) {
+      if (!validSellingPointBasisRefs.has(reference)) {
+        errors.push(`candidate_selling_points[${index}].basis_refs contains unknown reference: ${reference}`);
+      }
+    }
+  }
+  if (!new Set(["high", "medium", "low", "unknown"]).has(point?.confidence)) {
+    errors.push(`candidate_selling_points[${index}].confidence is invalid`);
+  }
+  sellingPointIds.push(point?.id);
+}
+if (new Set(sellingPointIds).size !== sellingPointIds.length) {
+  errors.push("candidate_selling_points ids must be unique");
+}
+
+for (const key of ["product_invariants", "visual_system", "prohibited_changes"]) {
+  if (!Array.isArray(plan.global_consistency_lock?.[key])) errors.push(`global_consistency_lock.${key} must be an array`);
+}
+
+const requiredAllocation = { hero: 1, scene: 2, selling_point: 2, decision_support: 2 };
+for (const [key, value] of Object.entries(requiredAllocation)) {
+  if (plan.set_blueprint?.business_allocation?.[key] !== value) {
+    errors.push(`set_blueprint.business_allocation.${key} must be ${value}`);
+  }
+}
+if (!isText(plan.set_blueprint?.visual_thesis)) errors.push("set_blueprint.visual_thesis is required");
+for (const key of ["narrative_arc", "shared_visual_dna", "intentional_variations"]) {
+  if (!Array.isArray(plan.set_blueprint?.[key]) || plan.set_blueprint[key].length === 0) {
+    errors.push(`set_blueprint.${key} must be a non-empty array`);
+  }
+}
+
+if (!Array.isArray(plan.images)) errors.push("images must be an array");
+if (plan.status === "ready" && plan.images?.length !== 7) errors.push("ready plan must contain exactly seven images");
+
+const businessCategories = new Set(Object.keys(requiredAllocation));
+const renderFamiliesByCategory = {
+  hero: new Set(["isolated_product"]),
+  scene: new Set(["environmental_scene", "human_interaction", "usage_demonstration"]),
+  selling_point: new Set(["material_demonstration", "feature_demonstration", "annotated_detail"]),
+  decision_support: new Set([
+    "multi_angle",
+    "usage_sequence",
+    "package_contents",
+    "scale_context",
+    "comparison_sequence",
+    "structured_information",
+  ]),
+};
+const sceneComplexities = new Set(["simple", "medium", "complex"]);
+const moduleNames = [
+  "product_lock",
+  "image_job",
+  "scene",
+  "composition",
+  "lighting_material",
+  "camera_style",
+  "text_layout",
+  "negative_constraints",
+];
+const complexityRules = {
+  simple: { promptMin: 100, sceneMin: 12 },
+  medium: { promptMin: 220, sceneMin: 60 },
+  complex: { promptMin: 360, sceneMin: 160 },
+};
+const highRiskClaimTerms = [
+  "防水", "抗菌", "临床", "认证", "销量第一", "最畅销", "耐磨", "耐用", "续航",
+  "兼容", "承重", "保质期", "无毒", "不刺激", "留香", "香调", "成分", "浓度", "功效",
+];
+const evidenceText = JSON.stringify([
+  plan.request?.instruction,
+  ...asArray(understanding.provided_facts),
+  ...sellingPoints.map((item) => item.value),
+  understanding.visible_specs ?? {},
+  ...asArray(plan.context?.voc?.findings),
+]);
+const knownEvidenceRefs = new Set(["user_instruction"]);
+asArray(understanding.visible_facts).forEach((_, index) => knownEvidenceRefs.add(`visible_fact:${index + 1}`));
+asArray(understanding.provided_facts).forEach((_, index) => knownEvidenceRefs.add(`provided_fact:${index + 1}`));
+asArray(understanding.creative_positioning).forEach((_, index) => knownEvidenceRefs.add(`creative_positioning:${index + 1}`));
+asArray(understanding.suggested_audiences).forEach((_, index) => knownEvidenceRefs.add(`suggested_audience:${index + 1}`));
+asArray(understanding.suggested_scenes).forEach((_, index) => knownEvidenceRefs.add(`suggested_scene:${index + 1}`));
+asArray(plan.context?.voc?.findings).forEach((_, index) => knownEvidenceRefs.add(`voc:${index + 1}`));
+Object.keys(understanding.visible_specs ?? {}).forEach((key) => knownEvidenceRefs.add(`visible_spec:${key}`));
+sellingPointIds.filter(isText).forEach((id) => knownEvidenceRefs.add(id));
+
+const ids = [];
+const decisions = [];
+const shotSpecs = [];
+const setDesigns = [];
+const creativeBodies = [];
+const categoryValues = [];
+const sellingImages = [];
+const categoryRenderFamilies = new Map();
+const staticProductFamilies = new Set(["isolated_product", "material_demonstration", "annotated_detail"]);
+let staticProductCount = 0;
+const requiredShotText = [
+  "narrative_job",
+  "composition_family",
+  "shot_scale",
+  "camera_angle",
+  "product_placement",
+  "depth_structure",
+  "background_family",
+  "lighting_family",
+  "interaction_mode",
+  "information_density",
+];
+const cameraAxes = new Set(["front", "three_quarter_left", "three_quarter_right", "side", "top", "low", "macro"]);
+const framingModes = new Set(["full_product", "product_in_context", "detail", "multi_panel", "human_action"]);
+const productPoses = new Set(["upright", "laid_flat", "held", "in_use", "multi_view", "detail_crop", "arranged_sequence"]);
+
+for (const [index, image] of asArray(plan.images).entries()) {
+  ids.push(image.id);
+  decisions.push(image.decision_subject);
+  categoryValues.push(image.business_category);
+  if (!isText(image.id)) errors.push(`images[${index}].id is required`);
+  if (!isText(image.role)) errors.push(`images[${index}].role is required`);
+  if (!businessCategories.has(image.business_category)) {
+    errors.push(`images[${index}].business_category is invalid`);
+  }
+  if (!isText(image.decision_subject)) errors.push(`images[${index}].decision_subject is required`);
+  if (!Array.isArray(image.evidence_refs) || image.evidence_refs.length === 0) {
+    errors.push(`images[${index}].evidence_refs must be non-empty`);
+  } else {
+    for (const reference of image.evidence_refs) {
+      if (!knownEvidenceRefs.has(reference)) {
+        errors.push(`images[${index}].evidence_refs contains unknown reference: ${reference}`);
+      }
+    }
+  }
+  if (typeof image.selling_point_id !== "string") errors.push(`images[${index}].selling_point_id must be a string`);
+  if (!isText(image.purpose)) errors.push(`images[${index}].purpose is required`);
+  if (!Array.isArray(image.voc_refs)) errors.push(`images[${index}].voc_refs must be an array`);
+
+  const setDesign = image.set_design ?? {};
+  setDesigns.push(setDesign);
+  const allowedRenderFamilies = renderFamiliesByCategory[image.business_category];
+  if (!allowedRenderFamilies?.has(setDesign.render_family)) {
+    errors.push(`images[${index}].set_design.render_family is invalid for ${image.business_category ?? "unknown"}`);
+  }
+  if (setDesign.render_family === "structured_information") {
+    errors.push(`images[${index}] structured_information is unavailable until a deterministic text compositor is implemented`);
+  }
+  if (staticProductFamilies.has(setDesign.render_family)) staticProductCount += 1;
+  if (businessCategories.has(image.business_category) && isText(setDesign.render_family)) {
+    const values = categoryRenderFamilies.get(image.business_category) ?? [];
+    values.push(setDesign.render_family);
+    categoryRenderFamilies.set(image.business_category, values);
+  }
+  if (!sceneComplexities.has(setDesign.scene_complexity)) {
+    errors.push(`images[${index}].set_design.scene_complexity is invalid`);
+  }
+  if (!isText(setDesign.unique_visual_hook)) errors.push(`images[${index}].set_design.unique_visual_hook is required`);
+  if (!isText(setDesign.contrast_with_previous)) errors.push(`images[${index}].set_design.contrast_with_previous is required`);
+
+  if (image.business_category === "selling_point") {
+    sellingImages.push(image);
+    if (!sellingPointIds.includes(image.selling_point_id)) {
+      errors.push(`images[${index}].selling_point_id must match a candidate_selling_points id`);
+    }
+    if (!image.evidence_refs?.includes(image.selling_point_id)) {
+      errors.push(`images[${index}].evidence_refs must include its selling_point_id`);
+    }
+  } else if (isText(image.selling_point_id)) {
+    errors.push(`images[${index}].selling_point_id is only allowed for selling_point images`);
+  }
+
+  for (const key of requiredShotText) {
+    if (!isText(image.shot_spec?.[key])) errors.push(`images[${index}].shot_spec.${key} is required`);
+  }
+  if (!cameraAxes.has(image.shot_spec?.camera_axis)) {
+    errors.push(`images[${index}].shot_spec.camera_axis is invalid`);
+  }
+  if (!framingModes.has(image.shot_spec?.framing_mode)) {
+    errors.push(`images[${index}].shot_spec.framing_mode is invalid`);
+  }
+  if (!productPoses.has(image.shot_spec?.product_pose)) {
+    errors.push(`images[${index}].shot_spec.product_pose is invalid`);
+  }
+  const occupancy = image.shot_spec?.product_occupancy_pct;
+  if (!(Number.isFinite(occupancy) && occupancy >= 10 && occupancy <= 95)) {
+    errors.push(`images[${index}].shot_spec.product_occupancy_pct must be 10..95`);
+  }
+  shotSpecs.push(image.shot_spec ?? {});
+
+  const promptModules = image.prompt_modules ?? {};
+  for (const moduleName of moduleNames) {
+    if (typeof promptModules[moduleName] !== "string") {
+      errors.push(`images[${index}].prompt_modules.${moduleName} must be a string`);
+    }
+  }
+  for (const moduleName of ["product_lock", "image_job", "scene", "composition", "lighting_material", "negative_constraints"]) {
+    if (!isText(promptModules[moduleName])) errors.push(`images[${index}].prompt_modules.${moduleName} is required`);
+  }
+  const productLockLength = [...(promptModules.product_lock ?? "")].length;
+  if (productLockLength < 20 || productLockLength > 160) {
+    errors.push(`images[${index}].prompt_modules.product_lock must be 20..160 characters (${productLockLength})`);
+  }
+  if (isText(identityManifest.product_lock) && promptModules.product_lock !== identityManifest.product_lock) {
+    errors.push(`images[${index}].prompt_modules.product_lock must exactly match product_understanding.identity_manifest.product_lock`);
+  }
+  const positivePromptText = [
+    promptModules.image_job,
+    promptModules.scene,
+    promptModules.composition,
+    promptModules.lighting_material,
+    promptModules.camera_style,
+  ].filter(Boolean).join(" ");
+  for (const unknownFeature of [
+    ...asArray(understanding.unknown_features),
+    ...asArray(identityManifest.unknown_do_not_assert),
+  ].filter(isText)) {
+    if (positivePromptText.includes(unknownFeature)) {
+      errors.push(`images[${index}] positively asserts unknown product feature: ${unknownFeature}`);
+    }
+  }
+  const absentText = asArray(identityManifest.must_not_add).filter(isText).join(" ").toLowerCase();
+  const positiveLower = positivePromptText.toLowerCase();
+  const contradictionGroups = [
+    { absent: ["纽扣", "button"], positive: ["单排扣", "双排扣", "single-breasted", "double-breasted", "buttoned"] },
+    { absent: ["口袋", "pocket"], positive: ["口袋", "pocket"] },
+    { absent: ["拉链", "zipper"], positive: ["拉链", "zipper", "zip closure"] },
+    { absent: ["腰带", "belt"], positive: ["腰带", "belted", "belt"] },
+  ];
+  for (const group of contradictionGroups) {
+    if (
+      group.absent.some((term) => absentText.includes(term))
+      && group.positive.some((term) => positiveLower.includes(term))
+    ) {
+      errors.push(`images[${index}] prompt contradicts identity_manifest.must_not_add`);
+      break;
+    }
+  }
+  const usesPanelLayout = /三格|多格|分格|面板|横向序列|纵向序列|separate panels|multi[- ]?panel|\bpanel\b|\bgrid\b/i.test(positivePromptText);
+  if (usesPanelLayout && image.shot_spec?.framing_mode !== "multi_panel") {
+    errors.push(`images[${index}] uses a panel/grid layout without framing_mode=multi_panel`);
+  }
+  const complexityRule = complexityRules[setDesign.scene_complexity];
+  const sceneLength = [...(promptModules.scene ?? "")].length;
+  if (complexityRule && sceneLength < complexityRule.sceneMin) {
+    errors.push(`images[${index}].prompt_modules.scene is too short for ${setDesign.scene_complexity} complexity (${sceneLength} < ${complexityRule.sceneMin})`);
+  }
+  if (setDesign.scene_complexity === "complex") {
+    if (!isText(promptModules.camera_style)) {
+      errors.push(`images[${index}].prompt_modules.camera_style is required for complex scenes`);
+    }
+    if (sceneLength <= productLockLength * 1.25) {
+      errors.push(`images[${index}] complex scene detail must be longer than the product lock`);
+    }
+  }
+  if (isText(promptModules.text_layout)) {
+    errors.push(`images[${index}].prompt_modules.text_layout must be empty until deterministic text compositing is available`);
+  }
+  if (!isText(image.generation_prompt)) errors.push(`images[${index}].generation_prompt is required`);
+  const promptLength = [...(image.generation_prompt ?? "")].length;
+  if (complexityRule && promptLength < complexityRule.promptMin) {
+    errors.push(`images[${index}].generation_prompt is too short for ${setDesign.scene_complexity} complexity (${promptLength} < ${complexityRule.promptMin})`);
+  }
+  if (promptLength > 800) errors.push(`images[${index}].generation_prompt exceeds 800 characters (${promptLength})`);
+  const normalizedPrompt = (image.generation_prompt ?? "").replace(/\s+/g, "");
+  for (const moduleName of moduleNames) {
+    const moduleText = (promptModules[moduleName] ?? "").replace(/\s+/g, "");
+    if (moduleText && !normalizedPrompt.includes(moduleText)) {
+      errors.push(`images[${index}].generation_prompt does not contain prompt_modules.${moduleName}`);
+    }
+  }
+  if (typeof image.negative_prompt !== "string") errors.push(`images[${index}].negative_prompt must be a string`);
+  const finalRequestPrompt = image.negative_prompt?.trim()
+    ? `${image.generation_prompt}\n禁止:${image.negative_prompt}`
+    : image.generation_prompt;
+  if ([...(finalRequestPrompt ?? "")].length > 800) {
+    errors.push(`images[${index}] final request prompt exceeds 800 characters after negative_prompt is appended`);
+  }
+  const negativeTokens = (image.negative_prompt ?? "").toLowerCase().split(/[,,、;;\s]+/).filter(Boolean);
+  if (negativeTokens.includes("logo") || negativeTokens.includes("文字") || negativeTokens.includes("品牌")) {
+    errors.push(`images[${index}].negative_prompt conflicts with required product label; use 额外文字/额外品牌标识 instead`);
+  }
+  if (!Array.isArray(image.overlay_copy)) {
+    errors.push(`images[${index}].overlay_copy must be an array`);
+  } else if (image.overlay_copy.length > 0) {
+    errors.push(`images[${index}].overlay_copy must stay empty until deterministic text compositing is available`);
+  }
+  if (!Array.isArray(image.reference_images) || image.reference_images.length === 0) {
+    errors.push(`images[${index}].reference_images must contain a product reference`);
+  }
+  if (image.model_parameters?.adapter !== "jimeng_img_v4") errors.push(`images[${index}].model_parameters.adapter must be jimeng_img_v4`);
+  if (!Number.isInteger(image.model_parameters?.width) || !Number.isInteger(image.model_parameters?.height)) {
+    errors.push(`images[${index}] requires integer width and height`);
+  }
+  if (!(image.model_parameters?.scale >= 0 && image.model_parameters.scale <= 1)) {
+    errors.push(`images[${index}].model_parameters.scale must be 0..1`);
+  }
+  if (image.model_parameters?.force_single !== true) errors.push(`images[${index}].model_parameters.force_single must be true`);
+  if (!Array.isArray(image.acceptance_checks) || image.acceptance_checks.length === 0) {
+    errors.push(`images[${index}].acceptance_checks must be non-empty`);
+  }
+
+  creativeBodies.push([
+    image.decision_subject,
+    setDesign.unique_visual_hook,
+    promptModules.image_job,
+    promptModules.scene,
+    promptModules.composition,
+  ].filter(Boolean).join(" ").replace(/\s+/g, "").toLowerCase());
+
+  const claimText = [
+    promptModules.image_job,
+    promptModules.scene,
+    ...asArray(image.overlay_copy),
+  ].filter(Boolean).join(" ");
+  for (const claim of asArray(understanding.unsupported_claims).filter(isText)) {
+    if (claimText.includes(claim)) errors.push(`images[${index}] uses unsupported claim: ${claim}`);
+  }
+  for (const term of highRiskClaimTerms) {
+    if (claimText.includes(term) && !evidenceText.includes(term)) {
+      errors.push(`images[${index}] uses unsupported high-risk claim term: ${term}`);
+    }
+  }
+}
+
+if (plan.status === "ready") {
+  const expected = ["01", "02", "03", "04", "05", "06", "07"];
+  if (JSON.stringify(ids) !== JSON.stringify(expected)) errors.push("image IDs must be ordered 01 through 07");
+  if (plan.images?.[0]?.business_category !== "hero") errors.push("images[0] must be the hero image");
+  if (plan.images?.[0]?.set_design?.render_family !== "isolated_product") {
+    errors.push("images[0] must use isolated_product");
+  }
+
+  const categoryCounts = countValues(categoryValues);
+  for (const [category, required] of Object.entries(requiredAllocation)) {
+    if ((categoryCounts.get(category) ?? 0) !== required) {
+      errors.push(`business_category ${category} must appear exactly ${required} time(s)`);
+    }
+  }
+  if (new Set(decisions).size !== decisions.length) errors.push("decision_subject must be unique across all seven images");
+  if (sellingImages.length === 2 && sellingImages[0].selling_point_id === sellingImages[1].selling_point_id) {
+    errors.push("selling_point images must use different selling_point_id values");
+  }
+  for (const category of ["scene", "selling_point", "decision_support"]) {
+    const values = categoryRenderFamilies.get(category) ?? [];
+    if (values.length === 2 && new Set(values).size !== 2) {
+      errors.push(`${category} images must use different render_family values`);
+    }
+  }
+  if (staticProductCount > 2) {
+    errors.push(`static product render families may appear at most twice across the set (${staticProductCount})`);
+  }
+
+  const diversityRules = [
+    ["narrative_job", 7, 1],
+    ["composition_family", 5, 2],
+    ["shot_scale", 4, 2],
+    ["camera_angle", 4, 3],
+    ["background_family", 5, 2],
+    ["lighting_family", 4, 2],
+    ["interaction_mode", 5, 2],
+    ["camera_axis", 4, 2],
+    ["framing_mode", 4, 2],
+  ];
+  for (const [field, minimumUnique, maximumRepeats] of diversityRules) {
+    const values = shotSpecs.map((spec) => spec?.[field]).filter(isText);
+    const counts = countValues(values);
+    if (new Set(values).size < minimumUnique) errors.push(`shot_spec.${field} requires at least ${minimumUnique} distinct values`);
+    for (const [value, count] of counts) {
+      if (count > maximumRepeats) errors.push(`shot_spec.${field} repeats too often: ${value} (${count})`);
+    }
+  }
+  const framingCounts = countValues(shotSpecs.map((spec) => spec?.framing_mode).filter(isText));
+  if ((framingCounts.get("detail") ?? 0) > 1) {
+    errors.push("shot_spec.framing_mode=detail may appear at most once across the set");
+  }
+  if ((framingCounts.get("multi_panel") ?? 0) > 1) {
+    errors.push("shot_spec.framing_mode=multi_panel may appear at most once across the set");
+  }
+
+  const occupancies = shotSpecs.map((spec) => spec?.product_occupancy_pct).filter(Number.isFinite);
+  if (occupancies.length === 7 && Math.max(...occupancies) - Math.min(...occupancies) < 35) {
+    errors.push("product occupancy range must span at least 35 percentage points across the set");
+  }
+
+  const nonHeroSignatures = shotSpecs.slice(1).map((spec) => [
+    spec.camera_axis,
+    spec.framing_mode,
+    spec.product_pose,
+  ].join("|"));
+  if (new Set(nonHeroSignatures).size !== nonHeroSignatures.length) {
+    errors.push("all non-hero images require distinct camera_axis + framing_mode + product_pose visual signatures");
+  }
+  for (let left = 1; left < shotSpecs.length; left += 1) {
+    for (let right = left + 1; right < shotSpecs.length; right += 1) {
+      const a = shotSpecs[left];
+      const b = shotSpecs[right];
+      if (
+        a.camera_axis === b.camera_axis
+        && a.framing_mode === b.framing_mode
+        && Math.abs(a.product_occupancy_pct - b.product_occupancy_pct) < 15
+      ) {
+        errors.push(`images ${ids[left]} and ${ids[right]} reuse the same camera axis/framing with insufficient occupancy contrast`);
+      }
+    }
+  }
+
+  const tokens = (text) => {
+    const latin = text.match(/[a-z0-9]{3,}/g) ?? [];
+    const hanRuns = text.match(/\p{Script=Han}+/gu) ?? [];
+    const hanBigrams = hanRuns.flatMap((run) => Array.from(
+      { length: Math.max(0, run.length - 1) },
+      (_, index) => run.slice(index, index + 2),
+    ));
+    return new Set([...latin, ...hanBigrams]);
+  };
+  const similarity = (left, right) => {
+    const a = tokens(left);
+    const b = tokens(right);
+    const intersection = [...a].filter((item) => b.has(item)).length;
+    const union = new Set([...a, ...b]).size;
+    return union ? intersection / union : 0;
+  };
+  for (let left = 0; left < creativeBodies.length; left += 1) {
+    for (let right = left + 1; right < creativeBodies.length; right += 1) {
+      const score = similarity(creativeBodies[left], creativeBodies[right]);
+      if (score > 0.72) errors.push(`creative prompts ${ids[left]} and ${ids[right]} are too similar (${score.toFixed(2)})`);
+    }
+  }
+}
+
+const secretKey = /(token|api.?key|authorization|password|secret)/i;
+const scan = (value, path = "plan") => {
+  if (!value || typeof value !== "object") return;
+  for (const [key, child] of Object.entries(value)) {
+    if (secretKey.test(key)) errors.push(`${path}.${key} must not contain credentials`);
+    scan(child, `${path}.${key}`);
+  }
+};
+scan(plan);
+
+if (errors.length) {
+  console.error(JSON.stringify({ valid: false, errors }, null, 2));
+  process.exit(1);
+}
+
+console.log(JSON.stringify({ valid: true, run_id: plan.run_id, images: plan.images.length }, null, 2));

+ 187 - 0
claude-code/fmode-image-set/mcp/src/features/image-set/validate-plan.test.mjs

@@ -0,0 +1,187 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { buildValidPlan, compilePrompt } from "../../../../scripts/test-plan-fixture.mjs";
+
+const execFileAsync = promisify(execFile);
+const validatorFile = fileURLToPath(new URL("./validate-plan.mjs", import.meta.url));
+
+async function validate(filename) {
+  return execFileAsync(process.execPath, [validatorFile, filename]);
+}
+
+async function withPlan(plan, name, operation) {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-plan-test-"));
+  try {
+    const filename = join(directory, name);
+    await writeFile(filename, JSON.stringify(plan));
+    return await operation(filename);
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+}
+
+test("valid 1+2+2+2 plan passes validation", async () => {
+  await withPlan(buildValidPlan(), "valid.json", async (filename) => {
+    const { stdout } = await validate(filename);
+    const result = JSON.parse(stdout);
+    assert.equal(result.valid, true);
+    assert.equal(result.images, 7);
+  });
+});
+
+test("the same planner schema works across unrelated product categories", async () => {
+  for (const category of ["wireless keyboard", "canvas backpack", "ceramic dinner plate"]) {
+    await withPlan(buildValidPlan(category), `${category.replace(/\s+/g, "-")}.json`, async (filename) => {
+      const { stdout } = await validate(filename);
+      assert.equal(JSON.parse(stdout).valid, true);
+    });
+  }
+});
+
+test("validator rejects an incorrect business allocation", async () => {
+  const plan = buildValidPlan();
+  plan.images[6].business_category = "selling_point";
+  plan.images[6].selling_point_id = "visible-sp-1";
+  plan.images[6].evidence_refs = ["visible-sp-1"];
+  plan.images[6].set_design.render_family = "feature_demonstration";
+  await withPlan(plan, "bad-allocation.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("business_category selling_point")));
+      assert.ok(result.errors.some((item) => item.includes("business_category decision_support")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects duplicate selling points and render families", async () => {
+  const plan = buildValidPlan();
+  plan.images[3].selling_point_id = "visible-sp-1";
+  plan.images[3].evidence_refs = ["visible-sp-1"];
+  plan.images[3].set_design.render_family = "material_demonstration";
+  await withPlan(plan, "duplicate-selling-point.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("different selling_point_id")));
+      assert.ok(result.errors.some((item) => item.includes("different render_family")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects more than two static product render families", async () => {
+  const plan = buildValidPlan();
+  plan.images[3].set_design.render_family = "annotated_detail";
+  await withPlan(plan, "static-overload.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("static product render families")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects fake information layouts without a compositor", async () => {
+  const plan = buildValidPlan();
+  plan.images[4].set_design.render_family = "structured_information";
+  plan.images[4].overlay_copy = ["VISIBLE CLAIM"];
+  plan.images[4].prompt_modules.text_layout = "Reserve a copy panel.";
+  plan.images[4].generation_prompt = compilePrompt(plan.images[4].prompt_modules);
+  await withPlan(plan, "fake-information.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("deterministic text compositor")));
+      assert.ok(result.errors.some((item) => item.includes("overlay_copy must stay empty")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects a complex scene compressed to a short template", async () => {
+  const plan = buildValidPlan();
+  const image = plan.images[2];
+  image.prompt_modules.scene = "A product on a table.";
+  image.generation_prompt = compilePrompt(image.prompt_modules);
+  await withPlan(plan, "short-complex-scene.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("scene is too short for complex complexity")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects unsupported product claims", async () => {
+  const plan = buildValidPlan();
+  plan.images[1].prompt_modules.image_job += " 经过认证。";
+  plan.images[1].generation_prompt = compilePrompt(plan.images[1].prompt_modules);
+  await withPlan(plan, "unsupported-claim.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("uses unsupported claim: 经过认证")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects different business roles that reuse the same visual angle", async () => {
+  const plan = buildValidPlan();
+  plan.images[4].shot_spec.camera_axis = plan.images[2].shot_spec.camera_axis;
+  plan.images[4].shot_spec.framing_mode = plan.images[2].shot_spec.framing_mode;
+  plan.images[4].shot_spec.product_pose = plan.images[2].shot_spec.product_pose;
+  plan.images[4].shot_spec.product_occupancy_pct = plan.images[2].shot_spec.product_occupancy_pct + 5;
+  await withPlan(plan, "duplicate-visual-angle.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("distinct camera_axis")));
+      assert.ok(result.errors.some((item) => item.includes("insufficient occupancy contrast")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects a prompt that rewrites the audited product lock", async () => {
+  const plan = buildValidPlan();
+  plan.images[2].prompt_modules.product_lock = "严格保留参考商品,但允许根据场景重新设计商品结构与颜色。";
+  plan.images[2].generation_prompt = compilePrompt(plan.images[2].prompt_modules);
+  await withPlan(plan, "rewritten-lock.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("must exactly match")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects invented closure details that contradict the identity manifest", async () => {
+  const plan = buildValidPlan();
+  plan.images[1].prompt_modules.scene += " 画面清楚展示单排扣闭合结构。";
+  plan.images[1].generation_prompt = compilePrompt(plan.images[1].prompt_modules);
+  await withPlan(plan, "invented-buttons.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("contradicts identity_manifest.must_not_add")));
+      return true;
+    });
+  });
+});
+
+test("validator allows only one detail frame and one multi-panel frame", async () => {
+  const plan = buildValidPlan();
+  plan.images[3].shot_spec.framing_mode = "detail";
+  plan.images[3].shot_spec.camera_axis = "side";
+  plan.images[3].shot_spec.product_pose = "arranged_sequence";
+  await withPlan(plan, "duplicate-detail.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("framing_mode=detail may appear at most once")));
+      return true;
+    });
+  });
+});

+ 287 - 0
claude-code/fmode-image-set/mcp/src/providers/claude-attachment-client.mjs

@@ -0,0 +1,287 @@
+import { createHash } from "node:crypto";
+import { open, mkdir, readdir, stat, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { basename, dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const DEFAULT_SKILL_NAME = "fmode-image-set";
+const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
+const MAX_SESSION_TAIL_BYTES = 8 * 1024 * 1024;
+const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
+
+const mediaTypes = new Map([
+  ["image/jpeg", { extension: ".jpg", signature: (buffer) => buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff }],
+  ["image/png", { extension: ".png", signature: (buffer) => buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) }],
+  ["image/webp", { extension: ".webp", signature: (buffer) => buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP" }],
+  ["image/gif", { extension: ".gif", signature: (buffer) => ["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii")) }],
+]);
+
+export class AttachmentResolutionError extends Error {
+  constructor(code, message) {
+    super(message);
+    this.name = "AttachmentResolutionError";
+    this.code = code;
+  }
+}
+
+function normalizePath(value) {
+  return resolve(value).replaceAll("\\", "/").toLowerCase();
+}
+
+async function listSessionFiles(rootDirectory) {
+  const files = [];
+  const pending = [rootDirectory];
+  while (pending.length > 0) {
+    const directory = pending.pop();
+    let entries;
+    try {
+      entries = await readdir(directory, { withFileTypes: true });
+    } catch (error) {
+      if (error?.code === "ENOENT") return [];
+      throw error;
+    }
+    for (const entry of entries) {
+      const path = join(directory, entry.name);
+      if (entry.isDirectory()) pending.push(path);
+      else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path);
+    }
+  }
+  return files;
+}
+
+async function readSessionTail(file, knownStat = null) {
+  const fileStat = knownStat ?? await stat(file);
+  const start = Math.max(0, fileStat.size - MAX_SESSION_TAIL_BYTES);
+  const length = fileStat.size - start;
+  const handle = await open(file, "r");
+  try {
+    const buffer = Buffer.alloc(length);
+    await handle.read(buffer, 0, length, start);
+    let text = buffer.toString("utf8");
+    if (start > 0) {
+      const firstNewline = text.indexOf("\n");
+      text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
+    }
+    const records = [];
+    for (const line of text.split(/\r?\n/)) {
+      if (!line.trim()) continue;
+      try {
+        records.push(JSON.parse(line));
+      } catch {
+        // Ignore an incomplete trailing line while Claude Code is appending.
+      }
+    }
+    return { records, fileStat };
+  } finally {
+    await handle.close();
+  }
+}
+
+function contentParts(content) {
+  if (Array.isArray(content)) return content;
+  if (typeof content === "string") return [{ type: "text", text: content }];
+  return [];
+}
+
+function latestSkillInvocation(records, skillName, expectedCwd) {
+  const groups = new Map();
+  records.forEach((record, index) => {
+    if (record.type !== "user" || record.message?.role !== "user" || !record.promptId) return;
+    const group = groups.get(record.promptId) ?? {
+      promptId: record.promptId,
+      timestamp: record.timestamp,
+      cwd: record.cwd,
+      lastIndex: index,
+      texts: [],
+      images: [],
+    };
+    group.timestamp = record.timestamp || group.timestamp;
+    group.cwd = record.cwd || group.cwd;
+    group.lastIndex = index;
+    for (const part of contentParts(record.message.content)) {
+      if (part?.type === "text" && typeof part.text === "string") group.texts.push(part.text);
+      if (part?.type === "image") group.images.push(part);
+    }
+    groups.set(record.promptId, group);
+  });
+
+  const expected = normalizePath(expectedCwd);
+  const commandMarker = `<command-name>/${skillName}</command-name>`;
+  const skillPathMarkers = [
+    `/.claude/skills/${skillName}`,
+    `\\.claude\\skills\\${skillName}`,
+  ];
+  return [...groups.values()]
+    .filter((group) => group.cwd && normalizePath(group.cwd) === expected)
+    .filter((group) => {
+      const text = group.texts.join("\n");
+      return text.includes(commandMarker) || skillPathMarkers.some((marker) => text.toLowerCase().includes(marker.toLowerCase()));
+    })
+    .sort((a, b) => {
+      const timeDifference = Date.parse(b.timestamp || 0) - Date.parse(a.timestamp || 0);
+      return timeDifference || b.lastIndex - a.lastIndex;
+    })[0] ?? null;
+}
+
+function decodeImage(part) {
+  const source = part?.source;
+  if (source?.type !== "base64" || typeof source.data !== "string") {
+    throw new AttachmentResolutionError("ATTACHMENT_FORMAT_UNSUPPORTED", "The current Claude attachment is not an embedded base64 image");
+  }
+  const media = mediaTypes.get(source.media_type);
+  if (!media) {
+    throw new AttachmentResolutionError("ATTACHMENT_TYPE_UNSUPPORTED", `Unsupported attachment media type: ${source.media_type || "unknown"}`);
+  }
+  if (!/^[A-Za-z0-9+/]*={0,2}$/.test(source.data) || source.data.length % 4 !== 0) {
+    throw new AttachmentResolutionError("ATTACHMENT_DATA_INVALID", "The current Claude attachment contains invalid base64 data");
+  }
+  const buffer = Buffer.from(source.data, "base64");
+  if (buffer.length === 0 || buffer.length > MAX_IMAGE_BYTES || !media.signature(buffer)) {
+    throw new AttachmentResolutionError("ATTACHMENT_DATA_INVALID", "The current Claude attachment is empty, too large, or does not match its declared image type");
+  }
+  return { buffer, mediaType: source.media_type, extension: media.extension };
+}
+
+export async function inspectLatestClaudeInvocation({
+  cwd = process.cwd(),
+  claudeConfigDirectory = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
+  sessionId = process.env.CLAUDE_CODE_SESSION_ID || "",
+  skillName = DEFAULT_SKILL_NAME,
+  maxAgeMs = DEFAULT_MAX_AGE_MS,
+  now = Date.now(),
+} = {}) {
+  const resolvedClaudeConfigDirectory = claudeConfigDirectory || join(homedir(), ".claude");
+  const projectsDirectory = join(resolvedClaudeConfigDirectory, "projects");
+  let files = await listSessionFiles(projectsDirectory);
+  if (sessionId) {
+    files = files.filter((file) => file.toLowerCase().endsWith(`${sessionId.toLowerCase()}.jsonl`));
+    if (files.length === 0) {
+      throw new AttachmentResolutionError("CLAUDE_SESSION_NOT_FOUND", `Claude session ${sessionId} was not found`);
+    }
+  }
+  const candidates = [];
+  for (const file of files) {
+    let parsed;
+    try {
+      const fileStat = await stat(file);
+      if (!sessionId && now - fileStat.mtimeMs > maxAgeMs) continue;
+      parsed = await readSessionTail(file, fileStat);
+    } catch {
+      continue;
+    }
+    const invocation = latestSkillInvocation(parsed.records, skillName, cwd);
+    if (!invocation) continue;
+    candidates.push({
+      file,
+      sessionId: basename(file, ".jsonl"),
+      fileMtimeMs: parsed.fileStat.mtimeMs,
+      ...invocation,
+    });
+  }
+  if (candidates.length === 0) {
+    throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_NOT_FOUND", "No recent Claude Code skill invocation was found for the current project");
+  }
+  candidates.sort((a, b) => {
+    const timeDifference = Date.parse(b.timestamp || 0) - Date.parse(a.timestamp || 0);
+    return timeDifference || b.fileMtimeMs - a.fileMtimeMs;
+  });
+  const current = candidates[0];
+  const promptTime = Date.parse(current.timestamp || 0);
+  if (!Number.isFinite(promptTime) || now - promptTime > maxAgeMs) {
+    throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_EXPIRED", "The latest Claude Code skill invocation is too old");
+  }
+  return {
+    prompt_id: current.promptId,
+    session_id: current.sessionId,
+    captured_at: current.timestamp,
+    image_count: current.images.length,
+    current,
+  };
+}
+
+export async function extractLatestClaudeAttachment({
+  cwd = process.cwd(),
+  outputDirectory,
+  claudeConfigDirectory = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
+  sessionId = process.env.CLAUDE_CODE_SESSION_ID || "",
+  skillName = DEFAULT_SKILL_NAME,
+  maxAgeMs = DEFAULT_MAX_AGE_MS,
+  now = Date.now(),
+} = {}) {
+  if (!outputDirectory) throw new AttachmentResolutionError("ATTACHMENT_OUTPUT_REQUIRED", "An attachment output directory is required");
+  const invocation = await inspectLatestClaudeInvocation({
+    cwd,
+    claudeConfigDirectory,
+    sessionId,
+    skillName,
+    maxAgeMs,
+    now,
+  });
+  const current = invocation.current;
+  if (current.images.length === 0) {
+    throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_NOT_FOUND", "The current skill invocation does not contain an image attachment");
+  }
+  if (current.images.length > 1) {
+    throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_AMBIGUOUS", "The current skill invocation contains more than one image attachment");
+  }
+
+  const decoded = decodeImage(current.images[0]);
+  const sha256 = createHash("sha256").update(decoded.buffer).digest("hex");
+  const outputFile = resolve(outputDirectory, `reference-${sha256.slice(0, 16)}${decoded.extension}`);
+  await mkdir(dirname(outputFile), { recursive: true });
+  await writeFile(outputFile, decoded.buffer);
+  return {
+    schema_version: "0.1",
+    status: "complete",
+    source: "claude_code_attachment",
+    file_path: outputFile,
+    media_type: decoded.mediaType,
+    sha256,
+    prompt_id: current.promptId,
+    session_id: current.sessionId,
+    captured_at: current.timestamp,
+  };
+}
+
+function option(args, name, fallback = "") {
+  const index = args.indexOf(name);
+  return index >= 0 ? args[index + 1] : fallback;
+}
+
+async function runCli() {
+  const args = process.argv.slice(2);
+  const outputDirectory = option(args, "--output-dir");
+  const resultFile = option(args, "--result");
+  const maxAgeMinutes = Number(option(args, "--max-age-minutes", "30"));
+  let result;
+  try {
+    result = await extractLatestClaudeAttachment({
+      cwd: option(args, "--cwd", process.cwd()),
+      outputDirectory,
+      claudeConfigDirectory: option(args, "--claude-config-dir", process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")),
+      sessionId: option(args, "--session-id", process.env.CLAUDE_CODE_SESSION_ID || ""),
+      skillName: option(args, "--skill", DEFAULT_SKILL_NAME),
+      maxAgeMs: Number.isFinite(maxAgeMinutes) && maxAgeMinutes > 0 ? maxAgeMinutes * 60 * 1000 : DEFAULT_MAX_AGE_MS,
+    });
+  } catch (error) {
+    result = {
+      schema_version: "0.1",
+      status: "blocked",
+      error: {
+        code: error?.code || "CLAUDE_ATTACHMENT_RESOLUTION_FAILED",
+        message: error?.message || "Claude attachment resolution failed",
+      },
+    };
+  }
+  if (resultFile) {
+    const absoluteResult = resolve(resultFile);
+    await mkdir(dirname(absoluteResult), { recursive: true });
+    await writeFile(absoluteResult, `${JSON.stringify(result, null, 2)}\n`, "utf8");
+  }
+  console.log(JSON.stringify(result, null, 2));
+}
+
+const invokedFile = process.argv[1] ? resolve(process.argv[1]) : "";
+if (invokedFile && normalizePath(invokedFile) === normalizePath(fileURLToPath(import.meta.url))) {
+  await runCli();
+}

+ 213 - 0
claude-code/fmode-image-set/mcp/src/providers/claude-attachment-client.test.mjs

@@ -0,0 +1,213 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { AttachmentResolutionError, extractLatestClaudeAttachment } from "./claude-attachment-client.mjs";
+import { createRun } from "../core/run-paths.mjs";
+
+const skillName = "fmode-image-set";
+const cwd = "D:\\Angular\\套图设计";
+const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
+const execFileAsync = promisify(execFile);
+const visionClientFile = fileURLToPath(new URL("../features/image-set/product-vision-client.mjs", import.meta.url));
+const createRunFile = fileURLToPath(new URL("../tools/create-run.mjs", import.meta.url));
+
+function userRecord({ promptId, timestamp, content }) {
+  return {
+    type: "user",
+    promptId,
+    timestamp,
+    cwd,
+    message: { role: "user", content },
+  };
+}
+
+async function writeSession(configDirectory, sessionId, records) {
+  const projectDirectory = join(configDirectory, "projects", "demo-project");
+  await mkdir(projectDirectory, { recursive: true });
+  const file = join(projectDirectory, `${sessionId}.jsonl`);
+  await writeFile(file, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, "utf8");
+  return file;
+}
+
+function invocationRecords(promptId, timestamp, images = [jpeg]) {
+  return [
+    userRecord({
+      promptId,
+      timestamp,
+      content: `<command-message>${skillName}</command-message>\n<command-name>/${skillName}</command-name>`,
+    }),
+    userRecord({
+      promptId,
+      timestamp,
+      content: [
+        ...images.map((image) => ({
+          type: "image",
+          source: { type: "base64", media_type: "image/jpeg", data: image.toString("base64") },
+        })),
+        { type: "text", text: `Base directory for this skill: ${cwd}\\.claude\\skills\\${skillName}` },
+      ],
+    }),
+  ];
+}
+
+test("extracts the unique image from the latest matching Claude skill invocation", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-attachment-test-"));
+  try {
+    const configDirectory = join(directory, ".claude");
+    const outputDirectory = join(directory, "run", "attachments");
+    const timestamp = "2026-07-23T06:31:35.973Z";
+    await writeSession(configDirectory, "11111111-1111-1111-1111-111111111111", invocationRecords("prompt-current", timestamp));
+    const result = await extractLatestClaudeAttachment({
+      cwd,
+      outputDirectory,
+      claudeConfigDirectory: configDirectory,
+      now: Date.parse(timestamp) + 1_000,
+    });
+    assert.equal(result.status, "complete");
+    assert.equal(result.prompt_id, "prompt-current");
+    assert.equal(result.media_type, "image/jpeg");
+    assert.deepEqual(await readFile(result.file_path), jpeg);
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("does not reuse an older attachment when the latest invocation has no image", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-attachment-stale-test-"));
+  try {
+    const configDirectory = join(directory, ".claude");
+    const oldRecords = invocationRecords("prompt-old", "2026-07-23T06:30:00.000Z");
+    const currentRecords = invocationRecords("prompt-current", "2026-07-23T06:31:00.000Z", []);
+    await writeSession(configDirectory, "22222222-2222-2222-2222-222222222222", [...oldRecords, ...currentRecords]);
+    await assert.rejects(
+      extractLatestClaudeAttachment({
+        cwd,
+        outputDirectory: join(directory, "run"),
+        claudeConfigDirectory: configDirectory,
+        now: Date.parse("2026-07-23T06:31:01.000Z"),
+      }),
+      (error) => error instanceof AttachmentResolutionError && error.code === "CLAUDE_ATTACHMENT_NOT_FOUND",
+    );
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("fails closed when the current invocation contains multiple images", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-attachment-ambiguous-test-"));
+  try {
+    const configDirectory = join(directory, ".claude");
+    const timestamp = "2026-07-23T06:31:00.000Z";
+    await writeSession(configDirectory, "33333333-3333-3333-3333-333333333333", invocationRecords("prompt-current", timestamp, [jpeg, jpeg]));
+    await assert.rejects(
+      extractLatestClaudeAttachment({
+        cwd,
+        outputDirectory: join(directory, "run"),
+        claudeConfigDirectory: configDirectory,
+        now: Date.parse(timestamp) + 1_000,
+      }),
+      (error) => error instanceof AttachmentResolutionError && error.code === "CLAUDE_ATTACHMENT_AMBIGUOUS",
+    );
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("product vision client accepts the Claude attachment bridge without a manual image path", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-attachment-integration-test-"));
+  const paths = await createRun();
+  try {
+    const configDirectory = join(directory, ".claude");
+    const timestamp = new Date(Date.now() - 60_000).toISOString();
+    await writeSession(configDirectory, "44444444-4444-4444-4444-444444444444", invocationRecords("prompt-current", timestamp));
+    const outputFile = paths.vision_result;
+    const { stdout } = await execFileAsync(process.execPath, [
+      visionClientFile,
+      "--from-claude-attachment",
+      "--output", outputFile,
+      "--dry-run",
+      "--attachment-cwd", cwd,
+      "--claude-config-dir", configDirectory,
+      "--attachment-max-age-minutes", "30",
+    ], {
+      env: { ...process.env },
+    });
+    const result = JSON.parse(stdout);
+    assert.equal(result.status, "dry-run");
+    assert.equal(result.vision_called, false);
+    assert.equal(result.reference.kind, "local");
+    assert.equal(result.reference.attachment.prompt_id, "prompt-current");
+    assert.ok(result.reference.source.startsWith(paths.attachments_directory));
+    assert.deepEqual(await readFile(result.reference.source), jpeg);
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("create-run CLI reuses the run assigned to the same Claude prompt", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-create-run-lock-test-"));
+  let paths;
+  let indexDirectory;
+  try {
+    const configDirectory = join(directory, ".claude");
+    const sessionId = "55555555-5555-5555-5555-555555555555";
+    const timestamp = new Date().toISOString();
+    await writeSession(configDirectory, sessionId, invocationRecords("prompt-locked", timestamp));
+    const command = () => execFileAsync(process.execPath, [
+      createRunFile,
+      "--cwd", cwd,
+      "--claude-config-dir", configDirectory,
+      "--session-id", sessionId,
+    ]);
+    const first = JSON.parse((await command()).stdout);
+    const second = JSON.parse((await command()).stdout);
+    paths = first;
+    indexDirectory = join(first.runs_root, ".request-index", first.request_key_hash);
+    assert.equal(first.reused_existing_run, false);
+    assert.equal(second.reused_existing_run, true);
+    assert.equal(second.run_id, first.run_id);
+  } finally {
+    if (paths) await rm(paths.run_directory, { recursive: true, force: true });
+    if (indexDirectory) await rm(indexDirectory, { recursive: true, force: true });
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("empty CLAUDE_CONFIG_DIR falls back to the user Claude directory", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-empty-claude-config-test-"));
+  let paths;
+  let indexDirectory;
+  try {
+    const configDirectory = join(directory, ".claude");
+    const sessionId = "66666666-6666-6666-6666-666666666666";
+    const timestamp = new Date().toISOString();
+    await writeSession(configDirectory, sessionId, invocationRecords("prompt-empty-config", timestamp));
+    const { stdout } = await execFileAsync(process.execPath, [
+      createRunFile,
+      "--cwd", cwd,
+    ], {
+      env: {
+        ...process.env,
+        CLAUDE_CONFIG_DIR: "",
+        CLAUDE_CODE_SESSION_ID: sessionId,
+        HOME: directory,
+        USERPROFILE: directory,
+        FMODE_IMAGE_SET_PROJECT_ROOT: directory,
+      },
+    });
+    paths = JSON.parse(stdout);
+    indexDirectory = join(paths.runs_root, ".request-index", paths.request_key_hash);
+    assert.equal(paths.reused_existing_run, false);
+    assert.equal(paths.request_key_hash.length, 64);
+  } finally {
+    if (paths) await rm(paths.run_directory, { recursive: true, force: true });
+    if (indexDirectory) await rm(indexDirectory, { recursive: true, force: true });
+    await rm(directory, { recursive: true, force: true });
+  }
+});

+ 147 - 0
claude-code/fmode-image-set/mcp/src/providers/commerce-vision-provider.mjs

@@ -0,0 +1,147 @@
+import { readFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { resolve } from "node:path";
+
+const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
+
+export function parseJsonContent(content) {
+  if (typeof content !== "string") throw new Error("Vision model returned no text content");
+  const cleaned = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
+  const start = cleaned.indexOf("{");
+  const end = cleaned.lastIndexOf("}");
+  if (start < 0 || end <= start) throw new Error("Vision model did not return a JSON object");
+  return JSON.parse(cleaned.slice(start, end + 1));
+}
+
+export async function loadVisionConfig() {
+  let fileConfig = {};
+  try {
+    const raw = await readFile(resolve(homedir(), ".openclaw", "commerce-vision.json"), "utf8");
+    fileConfig = JSON.parse(raw.replace(/^\uFEFF/, ""));
+  } catch (error) {
+    if (error?.code !== "ENOENT") throw new Error(`Cannot read vision credentials: ${error.message}`);
+  }
+  return {
+    provider: process.env.COMMERCE_VISION_PROVIDER || fileConfig.provider || "qwen",
+    baseUrl: process.env.COMMERCE_VISION_BASE_URL || fileConfig.baseUrl || "https://dashscope.aliyuncs.com/compatible-mode/v1",
+    model: process.env.COMMERCE_VISION_MODEL || fileConfig.model || "qwen3-vl-plus",
+    apiKey: process.env.COMMERCE_VISION_API_KEY || fileConfig.apiKey,
+  };
+}
+
+export async function requestVisionJson({ config, imageUrls, instruction, temperature = 0.1 }) {
+  if (!config?.apiKey) throw new Error("VISION_PROVIDER_NOT_CONFIGURED");
+  const endpoint = `${config.baseUrl.replace(/\/$/, "")}/chat/completions`;
+  let lastError;
+  for (let attempt = 1; attempt <= 3; attempt += 1) {
+    try {
+      const response = await fetch(endpoint, {
+        method: "POST",
+        headers: {
+          Authorization: `Bearer ${config.apiKey}`,
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({
+          model: config.model,
+          temperature,
+          messages: [{
+            role: "user",
+            content: [
+              ...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })),
+              { type: "text", text: instruction },
+            ],
+          }],
+        }),
+        signal: AbortSignal.timeout(120_000),
+      });
+      const body = await response.text();
+      let parsed;
+      try { parsed = body ? JSON.parse(body) : {}; } catch { parsed = {}; }
+      if (!response.ok) {
+        const error = new Error(parsed?.error?.message || parsed?.message || `HTTP ${response.status}`);
+        error.status = response.status;
+        throw error;
+      }
+      return {
+        value: parseJsonContent(parsed.choices?.[0]?.message?.content),
+        usage: parsed.usage ?? null,
+      };
+    } catch (error) {
+      lastError = error;
+      const retryable = error?.status === 429 || (error?.status >= 500 && error?.status <= 599);
+      if (!retryable || attempt === 3) break;
+      await sleep(attempt * 750);
+    }
+  }
+  throw lastError ?? new Error("VISION_REQUEST_FAILED");
+}
+
+export function compileProductLock(identityManifest) {
+  const preserve = [...new Set(identityManifest.must_preserve ?? [])].filter(Boolean).slice(0, 7);
+  const absent = [...new Set(identityManifest.must_not_add ?? [])].filter(Boolean).slice(0, 5);
+  const unknown = [...new Set(identityManifest.unknown_do_not_assert ?? [])].filter(Boolean).slice(0, 4);
+  const build = () => {
+    let result = `严格保留参考商品的${preserve.join("、")}。`;
+    if (absent.length) result += `不得增加${absent.join("、")}。`;
+    if (unknown.length) result += `不得推断${unknown.join("、")}。`;
+    return result;
+  };
+  let result = build();
+  while ([...result].length > 160 && unknown.length) {
+    unknown.pop();
+    result = build();
+  }
+  while ([...result].length > 160 && absent.length > 2) {
+    absent.pop();
+    result = build();
+  }
+  while ([...result].length > 160 && preserve.length > 3) {
+    preserve.pop();
+    result = build();
+  }
+  return result;
+}
+
+export async function reviewGeneratedProduct({
+  referenceUrl,
+  generatedUrl,
+  identityManifest,
+  config = null,
+}) {
+  const effectiveConfig = config ?? await loadVisionConfig();
+  const instruction = `你是商品身份一致性审核器。第一张图是原始商品参考图,第二张图是生成图。只检查商品是否仍为同一款,不评价摄影美感。商品身份约束:
+${JSON.stringify(identityManifest)}
+返回严格 JSON:
+{
+  "same_product": true,
+  "confidence": "high|medium|low",
+  "preserved_features": ["已保持的关键特征"],
+  "critical_violations": [{"code":"ADDED_STRUCTURE|REMOVED_STRUCTURE|SHAPE_CHANGED|COLOR_CHANGED|MATERIAL_APPEARANCE_CHANGED|TEXT_CHANGED|MULTI_PRODUCT_INCONSISTENT","detail":"具体问题"}],
+  "notes": "简短说明"
+}
+只要新增或删除结构、颜色或轮廓明显变化、多格图中的商品互不一致,就必须 same_product=false。`;
+  const { value, usage } = await requestVisionJson({
+    config: effectiveConfig,
+    imageUrls: [referenceUrl, generatedUrl],
+    instruction,
+    temperature: 0,
+  });
+  const violations = Array.isArray(value.critical_violations)
+    ? value.critical_violations.map((item) => ({
+      code: String(item?.code ?? "").trim(),
+      detail: String(item?.detail ?? "").trim(),
+    })).filter((item) => item.code && item.detail)
+    : [];
+  const sameProduct = value.same_product === true && violations.length === 0;
+  return {
+    accepted: sameProduct,
+    same_product: sameProduct,
+    confidence: ["high", "medium", "low"].includes(value.confidence) ? value.confidence : "low",
+    preserved_features: Array.isArray(value.preserved_features)
+      ? value.preserved_features.map((item) => String(item).trim()).filter(Boolean)
+      : [],
+    critical_violations: violations,
+    notes: String(value.notes ?? "").trim(),
+    usage,
+  };
+}

+ 59 - 0
claude-code/fmode-image-set/mcp/src/providers/commerce-vision-provider.test.mjs

@@ -0,0 +1,59 @@
+import assert from "node:assert/strict";
+import { createServer } from "node:http";
+import test from "node:test";
+import { compileProductLock, reviewGeneratedProduct } from "./commerce-vision-provider.mjs";
+
+test("compileProductLock creates one deterministic identity constraint", () => {
+  const manifest = {
+    must_preserve: ["方正轮廓", "浅蓝主体", "黑色圆润顶部", "正面标签位置"],
+    must_not_add: ["纽扣", "额外配件"],
+    unknown_do_not_assert: ["内部结构", "真实尺寸"],
+  };
+  const first = compileProductLock(manifest);
+  const second = compileProductLock(manifest);
+  assert.equal(first, second);
+  assert.match(first, /严格保留/);
+  assert.match(first, /不得增加纽扣/);
+  assert.ok([...first].length <= 160);
+});
+
+test("generated-product review rejects structural drift", async () => {
+  const server = createServer(async (request, response) => {
+    let body = "";
+    for await (const chunk of request) body += chunk;
+    const parsed = JSON.parse(body);
+    assert.equal(parsed.messages[0].content.filter((item) => item.type === "image_url").length, 2);
+    response.writeHead(200, { "Content-Type": "application/json" });
+    response.end(JSON.stringify({
+      choices: [{ message: { content: JSON.stringify({
+        same_product: false,
+        confidence: "high",
+        preserved_features: ["主体颜色"],
+        critical_violations: [{ code: "ADDED_STRUCTURE", detail: "生成图新增了参考图不存在的纽扣" }],
+        notes: "reject",
+      }) } }],
+    }));
+  });
+  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+  try {
+    const result = await reviewGeneratedProduct({
+      referenceUrl: "https://images.example.test/reference.png",
+      generatedUrl: "https://images.example.test/generated.png",
+      identityManifest: {
+        must_preserve: ["主体颜色", "完整轮廓", "可见结构"],
+        must_not_add: ["纽扣"],
+        unknown_do_not_assert: [],
+        product_lock: "严格保留主体颜色、完整轮廓、可见结构,不得增加纽扣。",
+      },
+      config: {
+        apiKey: "test-key",
+        model: "test-vl",
+        baseUrl: `http://127.0.0.1:${server.address().port}/v1`,
+      },
+    });
+    assert.equal(result.accepted, false);
+    assert.equal(result.critical_violations[0].code, "ADDED_STRUCTURE");
+  } finally {
+    await new Promise((resolve) => server.close(resolve));
+  }
+});

+ 764 - 0
claude-code/fmode-image-set/mcp/src/providers/jimeng-v4-client.mjs

@@ -0,0 +1,764 @@
+import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
+import { execFile } from "node:child_process";
+import { homedir } from "node:os";
+import { dirname, extname, isAbsolute, join, resolve } from "node:path";
+import { promisify } from "node:util";
+import { fileURLToPath } from "node:url";
+import { uploadLocalImage } from "./qiniu-upload-client.mjs";
+import { assertRunFile, pathsForRun } from "../core/run-paths.mjs";
+import { sha256, signState, verifyState } from "../core/run-state-integrity.mjs";
+import { loadVisionConfig, reviewGeneratedProduct } from "./commerce-vision-provider.mjs";
+
+const args = process.argv.slice(2);
+const planFileArg = args[0];
+if (!planFileArg || planFileArg.startsWith("--")) {
+  throw new Error("Usage: node jimeng-v4-client.mjs <prompt-plan.json> --dry-run|--prepare-assets|--execute|--download-only [--vision-result vision-result.json] [--max-cost 1.54] [--asset-map assets.json] [--output result.json] [--download-timeout-seconds 180]");
+}
+
+const flag = (name) => args.includes(name);
+const option = (name, fallback) => {
+  const index = args.indexOf(name);
+  return index >= 0 ? args[index + 1] : fallback;
+};
+async function readConfiguredToken() {
+  if (process.env.JIMENG_TOKEN) return process.env.JIMENG_TOKEN;
+  try {
+    const serialized = await readFile(join(homedir(), ".openclaw", "voc-credentials.json"), "utf8");
+    const credentials = JSON.parse(serialized.replace(/^\uFEFF/, ""));
+    return credentials.jimengToken ?? credentials.jimeng?.token ?? credentials.token;
+  } catch {
+    return undefined;
+  }
+}
+const requestedModes = ["--dry-run", "--prepare-assets", "--execute", "--download-only"].filter(flag);
+if (requestedModes.length > 1) throw new Error("Choose only one mode: --dry-run, --prepare-assets, --execute, or --download-only");
+const mode = flag("--execute") ? "execute" : flag("--prepare-assets") ? "prepare-assets" : flag("--download-only") ? "download-only" : "dry-run";
+if (requestedModes.length === 0) console.warn("No mode supplied; defaulting to dry-run.");
+
+const planFile = resolve(planFileArg);
+const planText = await readFile(planFile, "utf8");
+const plan = JSON.parse(planText);
+if (plan.status !== "ready" || !Array.isArray(plan.images) || plan.images.length !== 7) {
+  throw new Error("Prompt plan must be ready and contain seven images");
+}
+const canonicalPaths = pathsForRun(plan.run_id);
+assertRunFile(planFile, canonicalPaths.run_directory, "prompt-plan.json");
+const execFileAsync = promisify(execFile);
+const validatorFile = fileURLToPath(new URL("../features/image-set/validate-plan.mjs", import.meta.url));
+try {
+  await execFileAsync(process.execPath, [validatorFile, planFile]);
+} catch (error) {
+  let validation = null;
+  try {
+    validation = JSON.parse(error?.stderr || "");
+  } catch {
+    validation = null;
+  }
+  console.log(JSON.stringify({
+    status: "blocked",
+    jimeng_called: false,
+    error: {
+      code: "PLAN_VALIDATION_FAILED",
+      message: validation?.errors?.join("; ") || error?.message || "Prompt plan validation failed",
+    },
+  }, null, 2));
+  process.exit(0);
+}
+
+let visionResult = null;
+let visionResultFile = null;
+let visionResultText = "";
+if (mode === "execute") {
+  const visionResultArg = option("--vision-result", "");
+  if (!visionResultArg) {
+    console.log(JSON.stringify({
+      status: "blocked",
+      jimeng_called: false,
+      error: { code: "VISION_PIPELINE_REQUIRED", message: "Run the internal product vision adapter before Jimeng execution" },
+    }, null, 2));
+    process.exit(0);
+  }
+  visionResultFile = resolve(visionResultArg);
+  try {
+    assertRunFile(visionResultFile, canonicalPaths.run_directory, "vision-result.json");
+    visionResultText = (await readFile(visionResultFile, "utf8")).replace(/^\uFEFF/, "");
+    visionResult = JSON.parse(visionResultText);
+  } catch (error) {
+    console.log(JSON.stringify({
+      status: "blocked",
+      jimeng_called: false,
+      error: { code: "VISION_RESULT_INVALID", message: `Cannot read vision result: ${error.message}` },
+    }, null, 2));
+    process.exit(0);
+  }
+  if (visionResult.status !== "complete" || !visionResult.product_understanding || !visionResult.reference?.public_url) {
+    console.log(JSON.stringify({
+      status: "blocked",
+      jimeng_called: false,
+      error: { code: "VISION_RESULT_INVALID", message: "Vision result is incomplete and cannot authorize Jimeng execution" },
+    }, null, 2));
+    process.exit(0);
+  }
+  const planIdentity = plan.product_understanding?.identity_manifest;
+  const visionIdentity = visionResult.product_understanding?.identity_manifest;
+  const planFacts = plan.product_understanding?.verified_visible_facts;
+  const visionFacts = visionResult.product_understanding?.verified_visible_facts;
+  if (
+    JSON.stringify(planIdentity) !== JSON.stringify(visionIdentity)
+    || JSON.stringify(planFacts) !== JSON.stringify(visionFacts)
+  ) {
+    console.log(JSON.stringify({
+      status: "blocked",
+      jimeng_called: false,
+      error: {
+        code: "VISION_PLAN_IDENTITY_MISMATCH",
+        message: "The prompt plan changed the audited product identity or verified visible facts",
+      },
+    }, null, 2));
+    process.exit(0);
+  }
+}
+
+const publicUrlPattern = /^https?:\/\//i;
+const costPerImage = 0.22;
+const estimatedCost = Number((plan.images.length * costPerImage).toFixed(2));
+const assetMapFile = assertRunFile(
+  resolve(option("--asset-map", canonicalPaths.asset_map)),
+  canonicalPaths.run_directory,
+  "reference-assets.json",
+);
+const resultOutputFile = assertRunFile(
+  resolve(option("--output", canonicalPaths.jimeng_result)),
+  canonicalPaths.run_directory,
+  "jimeng-result.json",
+);
+const downloadDir = resolve(option("--download-dir", canonicalPaths.images_directory));
+const samePath = (left, right) => process.platform === "win32"
+  ? resolve(left).toLowerCase() === resolve(right).toLowerCase()
+  : resolve(left) === resolve(right);
+if (!samePath(downloadDir, canonicalPaths.images_directory)) {
+  throw new Error(`RUN_PATH_INVALID: images must be downloaded to ${canonicalPaths.images_directory}`);
+}
+const downloadTimeoutSeconds = Math.max(10, Number(option("--download-timeout-seconds", "180")) || 180);
+const forwardPath = (value) => resolve(value).replace(/\\/g, "/");
+const deliveryMetadata = (items) => ({
+  download_dir: downloadDir,
+  local_images: items.map((item) => item.file),
+  image_markdown_links: items.map((item) => `[${item.label}](<${forwardPath(item.file)}>)`),
+  open_folder_command: `explorer "${downloadDir}"`,
+});
+const deliveryItems = (results, acceptedOnly = true) => results.flatMap((entry) => {
+  if (acceptedOnly && entry.status !== "complete") return [];
+  const files = entry.local_images ?? [];
+  return files.map((file, index) => ({
+    slot_id: entry.slot_id,
+    file,
+    label: files.length === 1 ? entry.slot_id : `${entry.slot_id}-${index + 1}`,
+  }));
+});
+
+function imageExtension(url, contentType) {
+  const fromUrl = extname(new URL(url).pathname).toLowerCase();
+  if ([".jpg", ".jpeg", ".png", ".webp"].includes(fromUrl)) return fromUrl;
+  if (contentType?.includes("webp")) return ".webp";
+  if (contentType?.includes("jpeg")) return ".jpg";
+  return ".png";
+}
+
+const localRoleNames = {
+  hero: "白底主图",
+  scene: "场景图",
+  selling_point: "卖点图",
+  decision_support: "购买决策图",
+};
+function localImageStem(slotId) {
+  const image = plan.images.find((item) => item.id === slotId);
+  return `${slotId}-${localRoleNames[image?.business_category] ?? "商品图"}`;
+}
+
+async function downloadImagesForSlot(slotId, imageUrls) {
+  if (!Array.isArray(imageUrls) || imageUrls.length === 0) return [];
+  await mkdir(downloadDir, { recursive: true });
+  const files = [];
+  for (const [index, imageUrl] of imageUrls.entries()) {
+    const response = await fetch(imageUrl, {
+      signal: AbortSignal.timeout(downloadTimeoutSeconds * 1000),
+    });
+    if (!response.ok) throw new Error(`Image download failed: HTTP ${response.status}`);
+    const extension = imageExtension(imageUrl, response.headers.get("content-type"));
+    const suffix = imageUrls.length > 1 ? `-${index + 1}` : "";
+    const target = join(downloadDir, `${localImageStem(slotId)}${suffix}${extension}`);
+    await writeFile(target, Buffer.from(await response.arrayBuffer()));
+    files.push(target);
+  }
+  return files;
+}
+
+if (mode === "download-only") {
+  const rawToken = await readConfiguredToken();
+  if (!rawToken) throw new Error("Jimeng credential is required to verify executor state");
+  const prior = JSON.parse((await readFile(resultOutputFile, "utf8")).replace(/^\uFEFF/, ""));
+  verifyState(prior, rawToken);
+  let downloaded = 0;
+  let failed = 0;
+  for (const entry of prior.results ?? []) {
+    if (!Array.isArray(entry.images) || entry.images.length === 0) continue;
+    try {
+      entry.local_images = await downloadImagesForSlot(entry.slot_id, entry.images);
+      delete entry.download_error;
+      downloaded += entry.local_images.length;
+    } catch (error) {
+      entry.download_error = error.message;
+      failed += 1;
+    }
+  }
+  await writeFile(resultOutputFile, `${JSON.stringify(signState(prior, rawToken), null, 2)}\n`, "utf8");
+  console.log(JSON.stringify({
+    mode,
+    output: resultOutputFile,
+    ...deliveryMetadata(deliveryItems(prior.results ?? [])),
+    downloaded,
+    failed,
+  }, null, 2));
+} else {
+async function existingFile(candidate) {
+  try {
+    const fileStat = await stat(candidate);
+    return fileStat.isFile();
+  } catch {
+    return false;
+  }
+}
+
+async function resolveReference(reference) {
+  if (typeof reference !== "string" || !reference.trim()) throw new Error("Reference image entries must be non-empty strings");
+  const value = reference.trim();
+  if (publicUrlPattern.test(value)) return { kind: "public", source: value, url: value };
+
+  const candidates = isAbsolute(value)
+    ? [resolve(value)]
+    : [resolve(process.cwd(), value), resolve(dirname(planFile), value)];
+  for (const candidate of [...new Set(candidates)]) {
+    if (await existingFile(candidate)) return { kind: "local", source: candidate };
+  }
+  throw new Error(`Reference image was not found locally and is not a public URL: ${value}`);
+}
+
+async function loadAssetMap() {
+  try {
+    const parsed = JSON.parse(await readFile(assetMapFile, "utf8"));
+    return parsed && typeof parsed.assets === "object" ? parsed : { schema_version: "0.1", run_id: plan.run_id, assets: {} };
+  } catch (error) {
+    if (error?.code !== "ENOENT") throw new Error(`Cannot read asset map: ${error.message}`);
+    return { schema_version: "0.1", run_id: plan.run_id, assets: {} };
+  }
+}
+
+async function saveAssetMap(assetMap) {
+  await writeFile(assetMapFile, `${JSON.stringify(assetMap, null, 2)}\n`, "utf8");
+}
+
+const referenceSets = [];
+const uniqueReferences = new Map();
+for (const image of plan.images) {
+  const rawReferences = [...new Set([...(image.reference_images ?? []), ...(plan.request?.reference_images ?? [])])];
+  const resolvedReferences = [];
+  for (const rawReference of rawReferences) {
+    const reference = await resolveReference(rawReference);
+    const key = reference.kind === "public" ? reference.url : reference.source;
+    if (!uniqueReferences.has(key)) uniqueReferences.set(key, reference);
+    resolvedReferences.push(uniqueReferences.get(key));
+  }
+  referenceSets.push(resolvedReferences);
+}
+
+const assetMap = await loadAssetMap();
+if (visionResult?.reference?.kind === "local" && visionResult.reference.source && visionResult.reference.public_url) {
+  const source = resolve(visionResult.reference.source);
+  assetMap.assets[source] = {
+    ...(visionResult.reference.asset ?? {}),
+    source,
+    url: visionResult.reference.public_url,
+  };
+  await saveAssetMap(assetMap);
+}
+const assetUploads = [];
+const urlBySource = new Map();
+for (const reference of uniqueReferences.values()) {
+  if (reference.kind === "public") {
+    urlBySource.set(reference.source, reference.url);
+    continue;
+  }
+  const cached = assetMap.assets[reference.source];
+  if (cached && publicUrlPattern.test(cached.url ?? "")) {
+    urlBySource.set(reference.source, cached.url);
+    assetUploads.push({ ...cached, reused: true });
+  }
+}
+
+async function prepareLocalAssets() {
+  for (const reference of uniqueReferences.values()) {
+    if (reference.kind !== "local" || urlBySource.has(reference.source)) continue;
+    const uploaded = await uploadLocalImage(reference.source);
+    const safeRecord = { ...uploaded, reused: false };
+    assetMap.assets[reference.source] = uploaded;
+    urlBySource.set(reference.source, uploaded.url);
+    assetUploads.push(safeRecord);
+    await saveAssetMap(assetMap);
+  }
+}
+
+function buildTasks() {
+  return plan.images.map((image, index) => {
+    const references = referenceSets[index];
+    const imageUrls = [...new Set(references.map((reference) => urlBySource.get(reference.source)).filter(Boolean))];
+    const pendingLocal = references.some((reference) => reference.kind === "local" && !urlBySource.has(reference.source));
+    const negative = image.negative_prompt?.trim();
+    const prompt = negative ? `${image.generation_prompt}\n禁止:${negative}` : image.generation_prompt;
+    if ([...prompt].length > 800) throw new Error(`Slot ${image.id} final prompt exceeds 800 characters`);
+    return {
+      slot_id: image.id,
+      endpoint: "https://server.fmode.cn/api/volcengine/jimeng/getImgV4",
+      router_name: "getImgV4",
+      reference_status: imageUrls.length ? "ready" : pendingLocal ? "local_path_pending_qiniu_upload" : "missing_reference_image",
+      local_reference_images: references.filter((reference) => reference.kind === "local").map((reference) => reference.source),
+      payload: {
+        prompt,
+        image_urls: imageUrls,
+        sizeDate: { width: image.model_parameters.width, height: image.model_parameters.height },
+        scale: image.model_parameters.scale,
+        force_single: true,
+        client_request_id: `${plan.run_id}:${image.id}`,
+      },
+      estimated_cost_cny: costPerImage,
+    };
+  });
+}
+
+if (mode === "dry-run") {
+  console.log(JSON.stringify({ mode, run_id: plan.run_id, estimated_cost_cny: estimatedCost, will_upload_local_assets: true, tasks: buildTasks() }, null, 2));
+  process.exit(0);
+}
+
+let token;
+let stateSecret;
+if (mode === "execute") {
+  const maxCost = Number(option("--max-cost", "0"));
+  if (!Number.isFinite(maxCost) || maxCost < estimatedCost) throw new Error(`--max-cost must be at least ${estimatedCost}`);
+  const rawToken = await readConfiguredToken();
+  if (!rawToken) throw new Error("Jimeng credential is missing; set JIMENG_TOKEN or configure ~/.openclaw/voc-credentials.json");
+  stateSecret = rawToken;
+  token = rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
+}
+
+await prepareLocalAssets();
+const tasks = buildTasks();
+if (tasks.some((task) => task.payload.image_urls.length === 0)) {
+  throw new Error("All seven slots require at least one product reference image; local uploads must succeed before Jimeng is called");
+}
+
+if (mode === "prepare-assets") {
+  const summary = {
+    mode,
+    run_id: plan.run_id,
+    asset_map: assetMapFile,
+    unique_local_assets: [...uniqueReferences.values()].filter((reference) => reference.kind === "local").length,
+    uploaded: assetUploads.filter((item) => !item.reused).length,
+    reused: assetUploads.filter((item) => item.reused).length,
+    assets: assetUploads,
+    jimeng_called: false,
+  };
+  const outputFile = option("--output", "");
+  if (outputFile) await writeFile(resolve(outputFile), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
+  console.log(JSON.stringify(summary, null, 2));
+}
+
+if (mode === "execute") {
+  const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
+  const pollSeconds = Math.max(0.05, Number(option("--poll-seconds", "4")) || 4);
+  const timeoutSeconds = Math.max(0.1, Number(option("--timeout-seconds", "240")) || 240);
+  const safeRequestAttempts = Math.max(1, Number(option("--request-attempts", "5")) || 5);
+  const submitResponseAttempts = Math.max(1, Number(option("--submit-attempts", "3")) || 3);
+  const unknownCooldownSeconds = Math.max(0, Number(option("--unknown-cooldown-seconds", "30")) || 0);
+  const preflightAttempts = Math.max(1, Number(option("--preflight-attempts", "5")) || 5);
+  const outputFile = resolve(option("--output", `jimeng-result-${plan.run_id}.json`));
+  const submitUrl = process.env.JIMENG_SUBMIT_URL || "https://server.fmode.cn/api/volcengine/jimeng/getImgV4";
+  const queryUrl = process.env.JIMENG_QUERY_URL || "https://server.fmode.cn/api/volcengine/jimeng/getDataByTask02";
+  const resultBaseUrl = process.env.JIMENG_RESULT_BASE_URL || "https://server.fmode.cn/parse/classes/ImagineWork";
+  const preflightUrl = option(
+    "--preflight-url",
+    process.env.JIMENG_PREFLIGHT_URL
+      || (new URL(submitUrl).hostname === "server.fmode.cn"
+        ? `${new URL(submitUrl).origin}/parse/classes/ImagineWork/connection-preflight`
+        : ""),
+  );
+  const qualityConfig = await loadVisionConfig();
+  if (!qualityConfig.apiKey) {
+    console.log(JSON.stringify({
+      status: "blocked",
+      jimeng_called: false,
+      error: {
+        code: "QUALITY_REVIEW_NOT_CONFIGURED",
+        message: "The configured vision provider is required for generated-product identity review",
+      },
+    }, null, 2));
+    process.exit(0);
+  }
+
+  const describeError = (error) => [error?.message, error?.cause?.code].filter(Boolean).join(" ") || "unknown error";
+
+  async function readJsonResponse(response) {
+    const text = await response.text();
+    let result;
+    try {
+      result = text ? JSON.parse(text) : {};
+    } catch {
+      result = {};
+    }
+    if (!response.ok || (result.code !== undefined && result.code !== 200)) {
+      const error = new Error(result.msg ?? result.message ?? `HTTP ${response.status}`);
+      error.status = response.status;
+      throw error;
+    }
+    return result;
+  }
+
+  async function postJson(url, body) {
+    const response = await fetch(url, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(body),
+      signal: AbortSignal.timeout(30_000),
+    });
+    return readJsonResponse(response);
+  }
+
+  async function getResult(workId) {
+    const response = await fetch(`${resultBaseUrl}/${encodeURIComponent(workId)}`, {
+      headers: { "X-Parse-Application-Id": "ncloudmaster" },
+      signal: AbortSignal.timeout(30_000),
+    });
+    return readJsonResponse(response);
+  }
+
+  async function retrySafe(operation) {
+    let lastError;
+    for (let attempt = 1; attempt <= safeRequestAttempts; attempt += 1) {
+      try {
+        return await operation();
+      } catch (error) {
+        lastError = error;
+        if (attempt < safeRequestAttempts) await sleep(Math.min(8_000, 1_000 * (2 ** (attempt - 1))));
+      }
+    }
+    throw lastError;
+  }
+
+  async function loadRunState() {
+    let existing;
+    try {
+      existing = JSON.parse((await readFile(outputFile, "utf8")).replace(/^\uFEFF/, ""));
+      verifyState(existing, stateSecret);
+      if (existing.run_id !== plan.run_id) throw new Error("RUN_STATE_PLAN_MISMATCH: run_id changed");
+      if (existing.plan_sha256 !== sha256(planText)) throw new Error("RUN_STATE_PLAN_MISMATCH: prompt-plan.json changed after execution started");
+      if (existing.vision_sha256 !== sha256(visionResultText)) throw new Error("RUN_STATE_VISION_MISMATCH: vision-result.json changed after execution started");
+    } catch (error) {
+      if (error?.code !== "ENOENT") throw new Error(`Cannot read run state: ${error.message}`);
+    }
+    const previous = existing?.run_id === plan.run_id && Array.isArray(existing.results)
+      ? new Map(existing.results.map((item) => [item.slot_id, item]))
+      : new Map();
+    return {
+      schema_version: "0.3",
+      run_id: plan.run_id,
+      adapter: "jimeng_img_v4",
+      status: "running",
+      estimated_cost_cny: estimatedCost,
+      asset_uploads: assetUploads,
+      vision_result: visionResultFile,
+      plan_sha256: sha256(planText),
+      vision_sha256: sha256(visionResultText),
+      started_at: existing?.started_at || new Date().toISOString(),
+      updated_at: new Date().toISOString(),
+      issues: Array.isArray(existing?.issues) ? existing.issues : [],
+      results: tasks.map((task) => ({
+        slot_id: task.slot_id,
+        client_request_id: `${plan.run_id}:${task.slot_id}`,
+        status: "not_submitted",
+        work_id: null,
+        images: [],
+        cost_cny: task.estimated_cost_cny,
+        error: null,
+        ...(previous.get(task.slot_id) ?? {}),
+      })),
+    };
+  }
+
+  const runState = await loadRunState();
+  const entryBySlot = new Map(runState.results.map((item) => [item.slot_id, item]));
+  async function saveRunState() {
+    runState.updated_at = new Date().toISOString();
+    const signed = signState(runState, stateSecret);
+    runState.state_integrity = signed.state_integrity;
+    await writeFile(outputFile, `${JSON.stringify(signed, null, 2)}\n`, "utf8");
+  }
+  async function materializeCompletedImages(entry) {
+    if (!Array.isArray(entry.images) || entry.images.length === 0 || entry.local_images?.length === entry.images.length) return;
+    entry.local_images = await retrySafe(() => downloadImagesForSlot(entry.slot_id, entry.images));
+    delete entry.download_error;
+  }
+  await saveRunState();
+
+  async function recordIssue(entry, status, error) {
+    entry.status = status;
+    entry.error = describeError(error);
+    const issue = { slot_id: entry.slot_id, code: status, message: entry.error, at: new Date().toISOString() };
+    runState.issues.push(issue);
+    await saveRunState();
+    console.error(`[${entry.slot_id}] ${status}: ${entry.error}`);
+    return issue;
+  }
+
+  function isRetryableSubmit(error) {
+    return error?.status === 429 || (error?.status >= 500 && error?.status <= 599);
+  }
+
+  async function ensureSubmitTransportReady(entry) {
+    if (!preflightUrl) return true;
+    let lastError;
+    for (let attempt = 1; attempt <= preflightAttempts; attempt += 1) {
+      entry.preflight_attempts = (entry.preflight_attempts ?? 0) + 1;
+      await saveRunState();
+      try {
+        const response = await fetch(preflightUrl, {
+          method: "GET",
+          headers: { "X-Parse-Application-Id": "ncloudmaster" },
+          signal: AbortSignal.timeout(15_000),
+        });
+        await response.arrayBuffer();
+        entry.last_preflight_at = new Date().toISOString();
+        delete entry.preflight_error;
+        await saveRunState();
+        return true;
+      } catch (error) {
+        lastError = error;
+        entry.preflight_error = describeError(error);
+        await saveRunState();
+        if (attempt < preflightAttempts) {
+          await sleep(Math.min(8_000, 500 * (2 ** (attempt - 1))));
+        }
+      }
+    }
+    await recordIssue(entry, "transport_unavailable", lastError ?? new Error("Jimeng transport preflight failed"));
+    return false;
+  }
+
+  async function submitOne(task, entry) {
+    if (!(await ensureSubmitTransportReady(entry))) return false;
+    for (let attempt = 1; attempt <= submitResponseAttempts; attempt += 1) {
+      entry.submit_attempts = (entry.submit_attempts ?? 0) + 1;
+      await saveRunState();
+      try {
+        const submitted = await postJson(submitUrl, { ...task.payload, token });
+        const workId = submitted.data?.workId;
+        if (!workId) throw new Error(`Slot ${task.slot_id} returned no workId; submission acceptance is unknown`);
+        entry.work_id = workId;
+        entry.status = "submitted";
+        entry.error = null;
+        await saveRunState();
+        console.error(`[${task.slot_id}] submitted workId=${workId}`);
+        return true;
+      } catch (error) {
+        if (!error?.status) {
+          entry.uncertain_submit_attempts = (entry.uncertain_submit_attempts ?? 0) + 1;
+          await saveRunState();
+          await recordIssue(entry, "submission_unknown", error);
+          if (unknownCooldownSeconds > 0) {
+            console.error(`[${entry.slot_id}] acceptance is unknown; waiting ${unknownCooldownSeconds}s before skipping this slot`);
+            await sleep(unknownCooldownSeconds * 1000);
+          }
+          return false;
+        }
+        if (!isRetryableSubmit(error) || attempt === submitResponseAttempts) {
+          await recordIssue(entry, "submit_failed", error);
+          return false;
+        }
+        console.error(`[${task.slot_id}] submit attempt ${attempt} failed with HTTP ${error.status}; retrying`);
+        await sleep(Math.min(8_000, 1_000 * (2 ** (attempt - 1))));
+      }
+    }
+    return false;
+  }
+
+  async function waitAndDownload(entry) {
+    const deadline = Date.now() + timeoutSeconds * 1000;
+    let transientFailures = 0;
+    while (Date.now() < deadline) {
+      try {
+        const direct = await retrySafe(() => getResult(entry.work_id));
+        if (Array.isArray(direct.images) && direct.images.length > 0) {
+          entry.images = direct.images;
+          entry.status = "downloading";
+          entry.error = null;
+          await saveRunState();
+          try {
+            await materializeCompletedImages(entry);
+          } catch (error) {
+            await recordIssue(entry, "download_failed", error);
+            return false;
+          }
+          entry.status = "quality_review";
+          await saveRunState();
+          try {
+            const reviews = [];
+            for (const generatedUrl of entry.images) {
+              reviews.push(await retrySafe(() => reviewGeneratedProduct({
+                referenceUrl: visionResult.reference.public_url,
+                generatedUrl,
+                identityManifest: visionResult.product_understanding.identity_manifest,
+                config: qualityConfig,
+              })));
+            }
+            entry.quality_review = {
+              accepted: reviews.every((review) => review.accepted),
+              reviews,
+              reviewed_at: new Date().toISOString(),
+            };
+            if (!entry.quality_review.accepted) {
+              const violations = reviews.flatMap((review) => review.critical_violations ?? []);
+              await recordIssue(entry, "quality_rejected", new Error(
+                violations.map((item) => `${item.code}: ${item.detail}`).join("; ") || "Generated product no longer matches the reference",
+              ));
+              return false;
+            }
+          } catch (error) {
+            await recordIssue(entry, "quality_review_failed", error);
+            return false;
+          }
+          entry.status = "complete";
+          await saveRunState();
+          console.error(`[${entry.slot_id}] complete and downloaded`);
+          return true;
+        }
+        const state = await retrySafe(() => postJson(queryUrl, {
+          workId: entry.work_id,
+          routerName: "getImgV4",
+          token,
+        }));
+        entry.status = "submitted";
+        entry.error = null;
+        transientFailures = 0;
+        await saveRunState();
+        console.error(`[${entry.slot_id}] ${state.data?.tip ?? "pending"}`);
+      } catch (error) {
+        transientFailures += 1;
+        entry.transient_query_failures = (entry.transient_query_failures ?? 0) + 1;
+        entry.last_transient_error = describeError(error);
+        await saveRunState();
+        const delay = Math.min(15_000, 1_000 * (2 ** Math.min(transientFailures - 1, 4)));
+        console.error(`[${entry.slot_id}] transient query failure; retrying within slot timeout: ${entry.last_transient_error}`);
+        await sleep(delay);
+        continue;
+      }
+      await sleep(pollSeconds * 1000);
+    }
+    await recordIssue(entry, "poll_timeout", new Error(`Slot ${entry.slot_id} did not complete within ${timeoutSeconds}s; workId preserved`));
+    return false;
+  }
+
+  // Normal path is strictly serial: submit, wait, download, then move to the next slot.
+  // A failed/unknown/timed-out slot is isolated and skipped so later slots can still finish.
+  // Unknown submissions are never resubmitted because the remote service has no idempotency key.
+  for (const task of tasks) {
+    const entry = entryBySlot.get(task.slot_id);
+    if (entry.status === "complete" && entry.quality_review?.accepted) {
+      try {
+        await materializeCompletedImages(entry);
+        await saveRunState();
+      } catch (error) {
+        await recordIssue(entry, "download_failed", error);
+      }
+      continue;
+    }
+    if (entry.status === "submission_unknown" || entry.status === "quality_rejected") {
+      console.error(entry.status === "submission_unknown"
+        ? `[${entry.slot_id}] skipped: prior submission acceptance is unknown and will not be resubmitted`
+        : `[${entry.slot_id}] skipped: generated image failed product-identity review and is not auto-regenerated`);
+      continue;
+    }
+    if (!entry.work_id && !(await submitOne(task, entry))) continue;
+    await waitAndDownload(entry);
+  }
+
+  const completed = runState.results.filter((item) => item.status === "complete").length;
+  const pending = runState.results.filter((item) => item.work_id
+    && !["complete", "quality_rejected"].includes(item.status)).length;
+  const failedStatuses = new Set([
+    "submission_unknown",
+    "submit_failed",
+    "download_failed",
+    "poll_timeout",
+    "quality_review_failed",
+    "quality_rejected",
+    "transport_unavailable",
+  ]);
+  const failedEntries = runState.results.filter((item) => failedStatuses.has(item.status));
+  const failed = failedEntries.length;
+  const unknownSlots = runState.results.filter((item) => item.status === "submission_unknown").map((item) => item.slot_id);
+  const resumableSlots = runState.results
+    .filter((item) => !["complete", "submission_unknown", "quality_rejected"].includes(item.status))
+    .map((item) => item.slot_id);
+  const rejectedSlots = runState.results
+    .filter((item) => item.status === "quality_rejected")
+    .map((item) => item.slot_id);
+  const confirmedSubmitted = runState.results.filter((item) => item.work_id).length;
+  const potentialUnknown = unknownSlots.length;
+  const confirmedMinimumCost = Number((confirmedSubmitted * costPerImage).toFixed(2));
+  const potentialAdditionalCost = Number((potentialUnknown * costPerImage).toFixed(2));
+  const costRange = {
+    min: confirmedMinimumCost,
+    max: Number((confirmedMinimumCost + potentialAdditionalCost).toFixed(2)),
+  };
+  runState.status = completed === 7 ? "complete" : "partial";
+  runState.summary = {
+    completed,
+    failed,
+    unknown_slots: unknownSlots,
+    rejected_slots: rejectedSlots,
+    resumable_slots: resumableSlots,
+    cost_range_cny: costRange,
+  };
+  await saveRunState();
+  const resumable = resumableSlots.length > 0;
+  const nextAction = runState.status === "complete"
+    ? "deliver_local_images"
+    : resumable
+      ? "deliver_partial_then_resume_same_plan_vision_and_output"
+      : rejectedSlots.length
+        ? "deliver_accepted_images_and_report_quality_rejections_without_auto_regeneration"
+        : "deliver_partial_and_report_unknown_do_not_resubmit";
+  const completedItems = deliveryItems(runState.results);
+  const rejectedItems = deliveryItems(
+    runState.results.filter((item) => item.status === "quality_rejected"),
+    false,
+  );
+  console.log(JSON.stringify({
+    output: outputFile,
+    ...deliveryMetadata(completedItems),
+    status: runState.status,
+    completed,
+    pending,
+    failed,
+    failed_slots: failedEntries.map((item) => ({ slot_id: item.slot_id, code: item.status, message: item.error })),
+    unknown_slots: unknownSlots,
+    rejected_slots: rejectedSlots,
+    rejected_local_images: rejectedItems.map((item) => item.file),
+    resumable_slots: resumableSlots,
+    resumable,
+    confirmed_minimum_cost_cny: confirmedMinimumCost,
+    potential_additional_cost_cny: potentialAdditionalCost,
+    cost_range_cny: costRange,
+    next_action: nextAction,
+  }, null, 2));
+}
+}

+ 147 - 0
claude-code/fmode-image-set/mcp/src/providers/jimeng-v4-client.test.mjs

@@ -0,0 +1,147 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { createServer } from "node:http";
+import { readFile, rm, writeFile } from "node:fs/promises";
+import { promisify } from "node:util";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { buildValidPlan } from "../../../scripts/test-plan-fixture.mjs";
+import { createRun } from "../core/run-paths.mjs";
+import { verifyState } from "../core/run-state-integrity.mjs";
+
+const execFileAsync = promisify(execFile);
+const executorFile = fileURLToPath(new URL("./jimeng-v4-client.mjs", import.meta.url));
+
+function slotFromPayload(payload) {
+  return payload.client_request_id?.split(":").pop()
+    ?? payload.prompt.match(/Slot\s+(\d+)/i)?.[1]?.padStart(2, "0")
+    ?? "unknown";
+}
+
+test("a definite slot failure is isolated and later slots still complete", async () => {
+  const submissions = new Map();
+  let preflightCalls = 0;
+  const server = createServer(async (request, response) => {
+    const base = `http://127.0.0.1:${server.address().port}`;
+    if (request.method === "GET" && request.url === "/preflight") {
+      preflightCalls += 1;
+      if (preflightCalls <= 2) {
+        request.socket.destroy();
+        return;
+      }
+      response.writeHead(204);
+      response.end();
+      return;
+    }
+    if (request.method === "POST" && request.url === "/submit") {
+      let body = "";
+      for await (const chunk of request) body += chunk;
+      const slot = slotFromPayload(JSON.parse(body));
+      submissions.set(slot, (submissions.get(slot) ?? 0) + 1);
+      if (slot === "02" && submissions.get(slot) === 1) {
+        response.writeHead(400, { "Content-Type": "application/json" });
+        response.end(JSON.stringify({ code: 400, msg: "definite test rejection" }));
+        return;
+      }
+      response.writeHead(200, { "Content-Type": "application/json" });
+      response.end(JSON.stringify({ code: 200, data: { workId: `work-${slot}` } }));
+      return;
+    }
+    if (request.method === "GET" && request.url?.startsWith("/result/work-")) {
+      const slot = request.url.split("work-")[1];
+      response.writeHead(200, { "Content-Type": "application/json" });
+      response.end(JSON.stringify({ images: [`${base}/image/${slot}.png`] }));
+      return;
+    }
+    if (request.method === "GET" && request.url?.startsWith("/image/")) {
+      response.writeHead(200, { "Content-Type": "image/png" });
+      response.end(Buffer.from("test-image"));
+      return;
+    }
+    if (request.method === "POST" && request.url === "/vision/chat/completions") {
+      response.writeHead(200, { "Content-Type": "application/json" });
+      response.end(JSON.stringify({
+        choices: [{ message: { content: JSON.stringify({
+          same_product: true,
+          confidence: "high",
+          preserved_features: ["shape", "color", "visible structure"],
+          critical_violations: [],
+          notes: "accepted",
+        }) } }],
+      }));
+      return;
+    }
+    response.writeHead(404);
+    response.end();
+  });
+
+  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+  const paths = await createRun();
+  const secret = "test-jimeng-token";
+  try {
+    const reference = "https://images.example.test/product.jpg";
+    const plan = buildValidPlan("generic consumer product", reference, paths.run_id);
+    await writeFile(paths.prompt_plan, `${JSON.stringify(plan, null, 2)}\n`);
+    await writeFile(paths.vision_result, `${JSON.stringify({
+      status: "complete",
+      product_understanding: plan.product_understanding,
+      reference: { kind: "public", source: reference, public_url: reference },
+    }, null, 2)}\n`);
+
+    const base = `http://127.0.0.1:${server.address().port}`;
+    const run = () => execFileAsync(process.execPath, [
+      executorFile,
+      paths.prompt_plan,
+      "--execute",
+      "--vision-result", paths.vision_result,
+      "--asset-map", paths.asset_map,
+      "--output", paths.jimeng_result,
+      "--max-cost", "1.54",
+      "--poll-seconds", "0.05",
+      "--timeout-seconds", "1",
+      "--unknown-cooldown-seconds", "0",
+    ], {
+      env: {
+        ...process.env,
+        JIMENG_TOKEN: secret,
+        JIMENG_SUBMIT_URL: `${base}/submit`,
+        JIMENG_QUERY_URL: `${base}/query`,
+        JIMENG_RESULT_BASE_URL: `${base}/result`,
+        JIMENG_PREFLIGHT_URL: `${base}/preflight`,
+        COMMERCE_VISION_API_KEY: "test-key",
+        COMMERCE_VISION_BASE_URL: `${base}/vision`,
+      },
+    });
+
+    const first = JSON.parse((await run()).stdout);
+    assert.equal(first.status, "partial");
+    assert.equal(first.completed, 6);
+    assert.deepEqual(first.failed_slots.map((item) => item.slot_id), ["02"]);
+    assert.equal(submissions.get("03"), 1, "slot 03 must run after slot 02 fails");
+    assert.equal(preflightCalls >= 9, true, "network resets must be absorbed by the non-billing preflight");
+    assert.equal(first.unknown_slots.length, 0, "preflight resets must not create unknown paid submissions");
+    assert.equal(first.download_dir, paths.images_directory);
+    assert.ok(first.image_markdown_links.some((item) => item.startsWith("[03]")));
+    assert.equal(first.image_markdown_links.some((item) => item.startsWith("[02]")), false);
+
+    const second = JSON.parse((await run()).stdout);
+    assert.equal(second.status, "complete");
+    assert.equal(second.completed, 7);
+    assert.equal(submissions.get("01"), 1, "completed slots must not be resubmitted");
+    assert.equal(submissions.get("02"), 2, "only the definitely failed slot is retried");
+
+    const state = JSON.parse(await readFile(paths.jimeng_result, "utf8"));
+    assert.equal(verifyState(state, secret), true);
+    assert.equal(state.results[0].client_request_id, `${paths.run_id}:01`);
+    state.results[0].submit_attempts = 0;
+    await writeFile(paths.jimeng_result, `${JSON.stringify(state, null, 2)}\n`);
+    await assert.rejects(run(), (error) => {
+      assert.match(error.stderr, /RUN_STATE_TAMPERED/);
+      return true;
+    });
+  } finally {
+    await new Promise((resolve) => server.close(resolve));
+    assert.ok(paths.run_directory.startsWith(paths.runs_root));
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});

+ 169 - 0
claude-code/fmode-image-set/mcp/src/providers/qiniu-upload-client.mjs

@@ -0,0 +1,169 @@
+import { createHmac, randomBytes } from "node:crypto";
+import { readFile, stat, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { basename, extname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const DEFAULT_CREDENTIALS_FILE = resolve(homedir(), ".openclaw", "voc-credentials.json");
+const MAX_IMAGE_BYTES = 30 * 1024 * 1024;
+const MIME_BY_EXTENSION = new Map([
+  [".jpg", "image/jpeg"],
+  [".jpeg", "image/jpeg"],
+  [".png", "image/png"],
+  [".webp", "image/webp"],
+  [".gif", "image/gif"],
+]);
+
+const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
+const stripTrailingSlash = (value) => String(value || "").replace(/\/+$/, "");
+const stripBoundarySlashes = (value) => String(value || "").replace(/^\/+|\/+$/g, "");
+
+function requireValue(value, label) {
+  if (!String(value || "").trim()) throw new Error(`${label} is not configured`);
+  return String(value).trim();
+}
+
+export async function loadQiniuConfig({ env = process.env, credentialsFile = DEFAULT_CREDENTIALS_FILE } = {}) {
+  let stored = {};
+  try {
+    const serialized = await readFile(credentialsFile, "utf8");
+    const parsed = JSON.parse(serialized.replace(/^\uFEFF/, ""));
+    stored = parsed.qiniu ?? {};
+  } catch (error) {
+    if (error?.code !== "ENOENT") throw new Error(`Cannot read Qiniu credentials: ${error.message}`);
+  }
+
+  return {
+    accessKey: requireValue(env.QINIU_AK || env.QINIU_ACCESS_KEY || stored.accessKey, "Qiniu access key"),
+    secretKey: requireValue(env.QINIU_SK || env.QINIU_SECRET_KEY || stored.secretKey, "Qiniu secret key"),
+    bucket: requireValue(env.QINIU_BUCKET || stored.bucket, "Qiniu bucket"),
+    cdnDomain: stripTrailingSlash(requireValue(env.QINIU_CDN_DOMAIN || stored.cdnDomain, "Qiniu CDN domain")),
+    prefix: stripBoundarySlashes(env.QINIU_CDN_PREFIX || stored.prefix || "x/openclaw-skills/commerce-image-prompts"),
+    uploadUrl: stripTrailingSlash(requireValue(env.QINIU_UPLOAD_URL || stored.uploadUrl, "Qiniu upload URL")),
+  };
+}
+
+function base64Url(value) {
+  return Buffer.from(value).toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
+}
+
+export function createUploadToken(config, key, deadline = Math.floor(Date.now() / 1000) + 3600) {
+  const encodedPolicy = base64Url(JSON.stringify({ scope: `${config.bucket}:${key}`, deadline }));
+  const signature = createHmac("sha1", config.secretKey).update(encodedPolicy).digest("base64").replace(/\+/g, "-").replace(/\//g, "_");
+  return `${config.accessKey}:${signature}:${encodedPolicy}`;
+}
+
+function buildObjectKey(config, localPath, now = new Date()) {
+  const extension = extname(localPath).toLowerCase();
+  const originalBase = basename(localPath, extension);
+  const safeBase = originalBase.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/g, "_").slice(0, 80) || "product";
+  const yyyy = String(now.getFullYear());
+  const mm = String(now.getMonth() + 1).padStart(2, "0");
+  const dd = String(now.getDate()).padStart(2, "0");
+  const unique = `${now.getTime()}-${randomBytes(4).toString("hex")}`;
+  return `${config.prefix}/${yyyy}/${mm}/${yyyy}${mm}${dd}-${unique}-${safeBase}${extension}`;
+}
+
+async function parseUploadResponse(response) {
+  const text = await response.text();
+  let payload = {};
+  try {
+    payload = text ? JSON.parse(text) : {};
+  } catch {
+    payload = {};
+  }
+  if (!response.ok) {
+    throw new Error(payload.error || payload.message || text || `Qiniu upload failed: HTTP ${response.status}`);
+  }
+  return payload;
+}
+
+export async function uploadLocalImage(localPath, {
+  config,
+  fetchImpl = globalThis.fetch,
+  maxAttempts = 3,
+  now = new Date(),
+} = {}) {
+  if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this Node runtime");
+  const absolutePath = resolve(localPath);
+  const fileStat = await stat(absolutePath);
+  if (!fileStat.isFile()) throw new Error(`Reference image is not a file: ${absolutePath}`);
+  if (fileStat.size <= 0) throw new Error(`Reference image is empty: ${absolutePath}`);
+  if (fileStat.size > MAX_IMAGE_BYTES) throw new Error(`Reference image exceeds 30 MB: ${absolutePath}`);
+
+  const extension = extname(absolutePath).toLowerCase();
+  const mimeType = MIME_BY_EXTENSION.get(extension);
+  if (!mimeType) throw new Error(`Unsupported reference image type: ${extension || "unknown"}`);
+
+  const resolvedConfig = config ?? await loadQiniuConfig();
+  const key = buildObjectKey(resolvedConfig, absolutePath, now);
+  const uploadToken = createUploadToken(resolvedConfig, key);
+  const bytes = await readFile(absolutePath);
+  let lastError;
+
+  for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
+    try {
+      const form = new FormData();
+      form.append("token", uploadToken);
+      form.append("key", key);
+      form.append("file", new Blob([bytes], { type: mimeType }), basename(absolutePath));
+      const response = await fetchImpl(resolvedConfig.uploadUrl, { method: "POST", body: form });
+      if (response.status >= 500 && attempt < maxAttempts) {
+        await response.body?.cancel?.();
+        await sleep(500 * attempt);
+        continue;
+      }
+      const payload = await parseUploadResponse(response);
+      const uploadedKey = String(payload.key || key);
+      return {
+        source: absolutePath,
+        url: `${resolvedConfig.cdnDomain}/${uploadedKey}`,
+        key: uploadedKey,
+        mime_type: mimeType,
+        size_bytes: fileStat.size,
+      };
+    } catch (error) {
+      lastError = error;
+      if (attempt >= maxAttempts) break;
+      await sleep(500 * attempt);
+    }
+  }
+
+  throw new Error(`Qiniu upload failed for ${basename(absolutePath)}: ${lastError?.message || "unknown error"}`);
+}
+
+async function cli() {
+  const args = process.argv.slice(2);
+  const localPath = args.find((value) => !value.startsWith("--"));
+  if (!localPath) throw new Error("Usage: node qiniu-upload-client.mjs <local-image> [--dry-run] [--output result.json]");
+  const outputIndex = args.indexOf("--output");
+  const outputFile = outputIndex >= 0 ? args[outputIndex + 1] : "";
+  const absolutePath = resolve(localPath);
+  const fileStat = await stat(absolutePath);
+
+  let result;
+  if (args.includes("--dry-run")) {
+    const extension = extname(absolutePath).toLowerCase();
+    result = {
+      mode: "dry-run",
+      source: absolutePath,
+      size_bytes: fileStat.size,
+      mime_type: MIME_BY_EXTENSION.get(extension) || "unsupported",
+      would_upload: true,
+    };
+  } else {
+    result = { mode: "uploaded", ...(await uploadLocalImage(absolutePath)) };
+  }
+
+  const serialized = `${JSON.stringify(result, null, 2)}\n`;
+  if (outputFile) await writeFile(outputFile, serialized, "utf8");
+  process.stdout.write(serialized);
+}
+
+const isDirectExecution = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
+if (isDirectExecution) {
+  cli().catch((error) => {
+    console.error(error.message);
+    process.exitCode = 1;
+  });
+}

+ 524 - 0
claude-code/fmode-image-set/mcp/src/providers/qiniu-upload-client.test.mjs

@@ -0,0 +1,524 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { once } from "node:events";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { createServer } from "node:http";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { createUploadToken, loadQiniuConfig, uploadLocalImage } from "./qiniu-upload-client.mjs";
+import { buildValidPlan } from "../../../scripts/test-plan-fixture.mjs";
+import { createRun } from "../core/run-paths.mjs";
+
+const execFileAsync = promisify(execFile);
+const clientFile = fileURLToPath(new URL("./jimeng-v4-client.mjs", import.meta.url));
+const visionClientFile = fileURLToPath(new URL("../features/image-set/product-vision-client.mjs", import.meta.url));
+const visionResultForPlan = (plan, reference) => ({
+  status: "complete",
+  reference: { kind: "public", source: reference, public_url: reference },
+  product_understanding: {
+    category: plan.product_understanding.category,
+    verified_visible_facts: plan.product_understanding.verified_visible_facts,
+    identity_manifest: plan.product_understanding.identity_manifest,
+  },
+});
+
+test("createUploadToken creates a scoped Qiniu token without exposing the secret", () => {
+  const token = createUploadToken({ accessKey: "demo-ak", secretKey: "demo-sk", bucket: "demo-bucket" }, "demo/product.png", 2_000_000_000);
+  const [accessKey, signature, policy] = token.split(":");
+  assert.equal(accessKey, "demo-ak");
+  assert.ok(signature.length > 10);
+  const decoded = JSON.parse(Buffer.from(policy, "base64url").toString("utf8"));
+  assert.deepEqual(decoded, { scope: "demo-bucket:demo/product.png", deadline: 2_000_000_000 });
+  assert.equal(token.includes("demo-sk"), false);
+  assert.equal(signature.endsWith("="), true);
+});
+
+test("loadQiniuConfig accepts a Windows UTF-8 BOM credentials file", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-credentials-test-"));
+  try {
+    const credentialsFile = join(directory, "credentials.json");
+    await writeFile(credentialsFile, `\uFEFF${JSON.stringify({ qiniu: {
+      accessKey: "demo-ak",
+      secretKey: "demo-sk",
+      bucket: "demo-bucket",
+      cdnDomain: "https://cdn.example.test",
+      prefix: "demo",
+      uploadUrl: "https://upload.example.test",
+    } })}`);
+    const config = await loadQiniuConfig({ env: {}, credentialsFile });
+    assert.equal(config.bucket, "demo-bucket");
+    assert.equal(config.prefix, "demo");
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("uploadLocalImage posts one file and returns the configured CDN URL", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-qiniu-test-"));
+  try {
+    const localImage = join(directory, "product.png");
+    await writeFile(localImage, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
+    let calls = 0;
+    const result = await uploadLocalImage(localImage, {
+      config: {
+        accessKey: "demo-ak",
+        secretKey: "demo-sk",
+        bucket: "demo-bucket",
+        cdnDomain: "https://cdn.example.test",
+        prefix: "demo",
+        uploadUrl: "https://upload.example.test",
+      },
+      now: new Date("2026-07-22T00:00:00Z"),
+      fetchImpl: async (url, options) => {
+        calls += 1;
+        assert.equal(url, "https://upload.example.test");
+        assert.equal(options.method, "POST");
+        assert.ok(options.body.get("token"));
+        assert.match(options.body.get("key"), /^demo\/2026\/07\//);
+        assert.equal(options.body.get("file").type, "image/png");
+        return new Response(JSON.stringify({ key: options.body.get("key") }), {
+          status: 200,
+          headers: { "Content-Type": "application/json" },
+        });
+      },
+    });
+    assert.equal(calls, 1);
+    assert.match(result.url, /^https:\/\/cdn\.example\.test\/demo\/2026\/07\//);
+    assert.equal(result.source, localImage);
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("jimeng dry-run marks local references pending and performs no upload", async () => {
+  const paths = await createRun();
+  try {
+    const localImage = join(paths.run_directory, "product.jpg");
+    const planFile = paths.prompt_plan;
+    const assetMap = paths.asset_map;
+    await writeFile(localImage, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
+    await writeFile(planFile, JSON.stringify(buildValidPlan("test product", localImage, paths.run_id)));
+
+    const { stdout } = await execFileAsync(process.execPath, [clientFile, planFile, "--dry-run", "--asset-map", assetMap]);
+    const result = JSON.parse(stdout);
+    assert.equal(result.mode, "dry-run");
+    assert.equal(result.tasks.length, 7);
+    assert.ok(result.tasks.every((task) => task.reference_status === "local_path_pending_qiniu_upload"));
+    assert.ok(result.tasks.every((task) => task.payload.image_urls.length === 0));
+    await assert.rejects(readFile(assetMap), (error) => error.code === "ENOENT");
+  } finally {
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("jimeng refuses an invalid business plan even when Claude skips the explicit validator step", async () => {
+  const paths = await createRun();
+  try {
+    const planFile = paths.prompt_plan;
+    const plan = buildValidPlan("test product", "https://images.example.test/product.jpg", paths.run_id);
+    plan.images[6].business_category = "selling_point";
+    plan.images[6].selling_point_id = "visible-sp-1";
+    plan.images[6].evidence_refs = ["visible-sp-1"];
+    plan.images[6].set_design.render_family = "feature_demonstration";
+    await writeFile(planFile, JSON.stringify(plan));
+    const { stdout } = await execFileAsync(process.execPath, [clientFile, planFile, "--dry-run"]);
+    const result = JSON.parse(stdout);
+    assert.equal(result.status, "blocked");
+    assert.equal(result.jimeng_called, false);
+    assert.equal(result.error.code, "PLAN_VALIDATION_FAILED");
+  } finally {
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("jimeng requires the internal vision pipeline result before generation", async () => {
+  const paths = await createRun();
+  try {
+    const planFile = paths.prompt_plan;
+    await writeFile(planFile, JSON.stringify(buildValidPlan(
+      "test product",
+      "https://images.example.test/product.jpg",
+      paths.run_id,
+    )));
+    const { stdout } = await execFileAsync(process.execPath, [clientFile, planFile, "--execute", "--max-cost", "1.54"], {
+      env: { ...process.env, JIMENG_TOKEN: "test-token" },
+    });
+    const result = JSON.parse(stdout);
+    assert.equal(result.status, "blocked");
+    assert.equal(result.jimeng_called, false);
+    assert.equal(result.error.code, "VISION_PIPELINE_REQUIRED");
+  } finally {
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("product vision adapter sends the image to an OpenAI-compatible vision model and normalizes JSON", async () => {
+  const paths = await createRun();
+  let receivedBody;
+  const receivedBodies = [];
+  const server = createServer((request, response) => {
+    let body = "";
+    request.on("data", (chunk) => { body += chunk; });
+    request.on("end", () => {
+      receivedBody = JSON.parse(body);
+      receivedBodies.push(receivedBody);
+      if (receivedBodies.length === 2) {
+        response.setHeader("Content-Type", "application/json");
+        response.end(JSON.stringify({
+          choices: [{ message: { content: JSON.stringify({
+            category: "portable consumer product",
+            product_name: { value: "DEMO portable product", basis: "verified visible evidence", confidence: "high" },
+            verified_visible_facts: ["rectangular main silhouette", "black rounded top component"],
+            inferred_attributes: [],
+            unknown_features: ["internal structure", "real dimensions"],
+            verified_selling_points: [
+              { id: "visible-sp-1", value: "black rounded top component contrasts with the body", basis_refs: ["visible_fact:1"], confidence: "high" },
+              { id: "visible-sp-2", value: "rectangular main silhouette is visually clear", basis_refs: ["visible_fact:2"], confidence: "high" },
+            ],
+            verified_specs: { visible_quantity: "1" },
+            identity_manifest: {
+              must_preserve: [
+                "方形磨砂半透明瓶身轮廓",
+                "方形磨砂瓶身轮廓",
+                "白色按压泵头配短直管嘴",
+                "白色按压泵头+短直管嘴",
+                "拉丝银色金属连接环",
+                "银色拉丝金属连接环",
+                "正面中央空白矩形标签位",
+              ],
+              must_not_add: ["extra controls", "extra accessories"],
+              unknown_do_not_assert: ["internal structure", "real dimensions"],
+            },
+            corrections: ["removed unsupported inference"],
+          }) } }],
+          usage: { prompt_tokens: 11, completion_tokens: 21 },
+        }));
+        return;
+      }
+      response.setHeader("Content-Type", "application/json");
+      response.end(JSON.stringify({
+        choices: [{ message: { content: JSON.stringify({
+          category: "便携消费品",
+          product_name: { value: "DEMO便携产品", basis: "可见文字和外观", confidence: "high" },
+          visible_facts: ["矩形主体轮廓", "黑色圆润部件"],
+          visible_text: [{ text: "DEMO", confidence: "high" }],
+          visible_selling_points: [
+            { id: "duplicate-id", value: "矩形轮廓让外观边界清晰", basis_refs: ["visible_fact_99"], confidence: "high" },
+            { id: "duplicate-id", value: "圆润部件与主体形成形态对比", basis_refs: [], confidence: "high" },
+          ],
+          visible_specs: { visible_quantity: "1" },
+          creative_positioning: ["清晰、克制的商业视觉"],
+          suggested_audiences: ["重视外观设计的消费者"],
+          suggested_scenes: ["整洁日常环境"],
+          unsupported_claims: ["真实性能"],
+          product_invariants: ["保持主体和圆润部件结构"],
+          material_and_light_behavior: ["表面具有可见高光"],
+          uncertain_features: ["内部结构不可见"],
+          generation_risks: ["标签文字容易变形"],
+        }) } }],
+        usage: { prompt_tokens: 10, completion_tokens: 20 },
+      }));
+    });
+  });
+  try {
+    server.listen(0, "127.0.0.1");
+    await once(server, "listening");
+    const { port } = server.address();
+    const outputFile = paths.vision_result;
+    const env = {
+      ...process.env,
+      COMMERCE_VISION_API_KEY: "test-key",
+      COMMERCE_VISION_BASE_URL: `http://127.0.0.1:${port}/v1`,
+      COMMERCE_VISION_MODEL: "test-vl",
+    };
+    const { stdout } = await execFileAsync(process.execPath, [
+      visionClientFile,
+      "--image", "https://images.example.test/product.jpg",
+      "--output", outputFile,
+    ], { env });
+    const result = JSON.parse(stdout);
+    assert.equal(result.status, "complete");
+    assert.equal(result.product_understanding.category, "portable consumer product");
+    assert.equal(result.product_understanding.visible_facts.length, 2);
+    assert.equal(result.product_understanding.visible_selling_points.length, 2);
+    assert.deepEqual(result.product_understanding.visible_selling_points[0].basis_refs, ["visible_fact:2"]);
+    assert.deepEqual(result.product_understanding.visible_selling_points[1].basis_refs, ["visible_fact:1"]);
+    assert.equal(receivedBody.model, "test-vl");
+    assert.equal(receivedBody.messages[0].content[0].image_url.url, "https://images.example.test/product.jpg");
+    assert.equal(receivedBodies.length, 2);
+    assert.equal(result.fact_audit_called, true);
+    assert.ok(result.product_understanding.identity_manifest.product_lock.includes("方形磨砂半透明瓶身轮廓"));
+    assert.equal(
+      result.product_understanding.identity_manifest.must_preserve.filter((item) => item.includes("方形磨砂")).length,
+      1,
+    );
+    assert.equal(
+      result.product_understanding.identity_manifest.must_preserve.filter((item) => item.includes("白色按压泵头")).length,
+      1,
+    );
+    const auditInstruction = receivedBodies[1].messages[0].content.at(-1).text;
+    assert.match(auditInstruction, /"id":"visible-sp-1"/);
+    assert.match(auditInstruction, /"basis_refs":\["visible_fact:1"\]/);
+    const repeated = await execFileAsync(process.execPath, [
+      visionClientFile,
+      "--image", "https://images.example.test/product.jpg",
+      "--output", outputFile,
+    ], { env });
+    const repeatedResult = JSON.parse(repeated.stdout);
+    assert.equal(repeatedResult.reused_existing_result, true);
+    assert.equal(repeatedResult.external_calls_made, false);
+    assert.equal(receivedBodies.length, 2, "a completed result must not trigger additional vision calls");
+  } finally {
+    server.close();
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("a sparse final vision audit is deterministically repaired without repeating external calls", async () => {
+  const paths = await createRun();
+  let calls = 0;
+  const server = createServer(async (request, response) => {
+    for await (const _chunk of request) {
+      // Drain the request before replying.
+    }
+    calls += 1;
+    const content = calls === 1 ? {
+      category: "test product",
+      product_name: { value: "test product", basis: "visible shape", confidence: "high" },
+      visible_facts: ["fact one", "fact two"],
+      visible_text: [],
+      visible_selling_points: [],
+      visible_specs: {},
+      creative_positioning: [],
+      suggested_audiences: [],
+      suggested_scenes: [],
+      unsupported_claims: [],
+      product_invariants: ["shape", "color", "quantity"],
+      material_and_light_behavior: [],
+      uncertain_features: [],
+      generation_risks: [],
+    } : {
+      category: "test product",
+      product_name: { value: "test product", basis: "verified visible evidence", confidence: "high" },
+      verified_visible_facts: ["fact one"],
+      inferred_attributes: [],
+      unknown_features: [],
+      verified_selling_points: [
+        { id: "duplicate-id", value: "only one final point", basis_refs: ["visible_fact:99"], confidence: "high" },
+      ],
+      verified_specs: {},
+      identity_manifest: {
+        must_preserve: ["shape"],
+        must_not_add: [],
+        unknown_do_not_assert: [],
+      },
+      corrections: [],
+    };
+    response.writeHead(200, { "Content-Type": "application/json" });
+    response.end(JSON.stringify({ choices: [{ message: { content: JSON.stringify(content) } }] }));
+  });
+  try {
+    server.listen(0, "127.0.0.1");
+    await once(server, "listening");
+    const env = {
+      ...process.env,
+      COMMERCE_VISION_API_KEY: "test-key",
+      COMMERCE_VISION_BASE_URL: `http://127.0.0.1:${server.address().port}/v1`,
+    };
+    const command = () => execFileAsync(process.execPath, [
+      visionClientFile,
+      "--image", "https://images.example.test/product.jpg",
+      "--output", paths.vision_result,
+    ], { env });
+    const first = JSON.parse((await command()).stdout);
+    assert.equal(first.status, "complete");
+    assert.equal(first.product_understanding.visible_selling_points.length >= 2, true);
+    assert.deepEqual(
+      first.product_understanding.visible_selling_points.map((point) => point.id),
+      first.product_understanding.visible_selling_points.map((_, index) => `visible-sp-${index + 1}`),
+    );
+    assert.equal(
+      first.product_understanding.visible_selling_points.every((point) => point.basis_refs.length > 0),
+      true,
+    );
+    assert.equal(first.product_understanding.identity_manifest.must_preserve.length >= 3, true);
+    assert.equal(calls, 2);
+    const second = JSON.parse((await command()).stdout);
+    assert.equal(second.reused_existing_result, true);
+    assert.equal(second.external_calls_made, false);
+    assert.equal(calls, 2);
+  } finally {
+    server.close();
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("jimeng execution completes and downloads each slot before submitting the next", async () => {
+  const paths = await createRun();
+  let submitCalls = 0;
+  let activeJobs = 0;
+  let maxActiveJobs = 0;
+  let baseUrl = "";
+  const ready = new Set();
+  const completed = new Set();
+  const server = createServer((request, response) => {
+    if (request.method === "GET" && request.url.startsWith("/image/")) {
+      response.setHeader("Content-Type", "image/png");
+      response.end(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
+      return;
+    }
+    response.setHeader("Content-Type", "application/json");
+    if (request.method === "POST" && request.url === "/vision/chat/completions") {
+      response.end(JSON.stringify({
+        choices: [{ message: { content: JSON.stringify({
+          same_product: true,
+          confidence: "high",
+          preserved_features: ["main silhouette", "color", "visible structure"],
+          critical_violations: [],
+          notes: "accepted",
+        }) } }],
+      }));
+      return;
+    }
+    if (request.method === "POST" && request.url === "/submit") {
+      submitCalls += 1;
+      activeJobs += 1;
+      maxActiveJobs = Math.max(maxActiveJobs, activeJobs);
+      response.end(JSON.stringify({ code: 200, data: { workId: `work-${submitCalls}` } }));
+      return;
+    }
+    if (request.method === "POST" && request.url === "/query") {
+      let body = "";
+      request.on("data", (chunk) => { body += chunk; });
+      request.on("end", () => {
+        const { workId } = JSON.parse(body);
+        ready.add(workId);
+        response.end(JSON.stringify({ code: 200, data: { isFinish: true, tip: "complete" } }));
+      });
+      return;
+    }
+    if (request.method === "GET" && request.url.startsWith("/result/")) {
+      const workId = request.url.split("/").pop();
+      if (ready.has(workId) && !completed.has(workId)) {
+        completed.add(workId);
+        activeJobs -= 1;
+      }
+      response.end(JSON.stringify({ images: ready.has(workId) ? [`${baseUrl}/image/${workId}.png`] : [] }));
+      return;
+    }
+    response.statusCode = 404;
+    response.end(JSON.stringify({ message: "not found" }));
+  });
+
+  try {
+    server.listen(0, "127.0.0.1");
+    await once(server, "listening");
+    const { port } = server.address();
+    baseUrl = `http://127.0.0.1:${port}`;
+    const planFile = paths.prompt_plan;
+    const outputFile = paths.jimeng_result;
+    const visionFile = paths.vision_result;
+    const plan = buildValidPlan(
+      "test product",
+      "https://images.example.test/product.jpg",
+      paths.run_id,
+    );
+    await writeFile(planFile, JSON.stringify(plan));
+    await writeFile(visionFile, JSON.stringify(visionResultForPlan(plan, "https://images.example.test/product.jpg")));
+    const env = {
+      ...process.env,
+      JIMENG_TOKEN: "test-token",
+      JIMENG_SUBMIT_URL: `http://127.0.0.1:${port}/submit`,
+      JIMENG_QUERY_URL: `http://127.0.0.1:${port}/query`,
+      JIMENG_RESULT_BASE_URL: `http://127.0.0.1:${port}/result`,
+      COMMERCE_VISION_API_KEY: "test-key",
+      COMMERCE_VISION_BASE_URL: `http://127.0.0.1:${port}/vision`,
+    };
+    const args = [clientFile, planFile, "--execute", "--vision-result", visionFile, "--max-cost", "1.54", "--output", outputFile, "--poll-seconds", "0.05", "--timeout-seconds", "2"];
+    const execution = await execFileAsync(process.execPath, args, { env });
+    const summary = JSON.parse(execution.stdout);
+    assert.equal(summary.status, "complete");
+    assert.equal(summary.local_images.length, 7);
+    assert.ok(summary.local_images.every((item) => item.startsWith(paths.images_directory)));
+    assert.ok(summary.local_images.some((item) => item.endsWith("01-白底主图.png")));
+    assert.ok(summary.local_images.some((item) => item.endsWith("02-卖点图.png")));
+    assert.ok(summary.local_images.some((item) => item.endsWith("03-场景图.png")));
+    assert.ok(summary.local_images.some((item) => item.endsWith("05-购买决策图.png")));
+    assert.equal(summary.download_dir, paths.images_directory);
+    const saved = JSON.parse(await readFile(outputFile, "utf8"));
+    assert.equal(saved.results.filter((item) => item.status === "complete").length, 7);
+    assert.equal(saved.results.filter((item) => item.local_images?.length === 1).length, 7);
+    assert.equal(submitCalls, 7);
+    assert.equal(maxActiveJobs, 1);
+  } finally {
+    server.close();
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("jimeng retries explicit HTTP failure, never resubmits unknown slots, and continues later slots", async () => {
+  const paths = await createRun();
+  let submitCalls = 0;
+  const server = createServer((request, response) => {
+    if (request.method === "POST" && request.url === "/submit") {
+      submitCalls += 1;
+      if (submitCalls === 1) {
+        response.statusCode = 500;
+        response.setHeader("Content-Type", "application/json");
+        response.end(JSON.stringify({ message: "temporary failure" }));
+      } else {
+        request.socket.destroy();
+      }
+      return;
+    }
+    response.statusCode = 404;
+    response.end();
+  });
+
+  try {
+    server.listen(0, "127.0.0.1");
+    await once(server, "listening");
+    const { port } = server.address();
+    const planFile = paths.prompt_plan;
+    const outputFile = paths.jimeng_result;
+    const visionFile = paths.vision_result;
+    const plan = buildValidPlan(
+      "test product",
+      "https://images.example.test/product.jpg",
+      paths.run_id,
+    );
+    await writeFile(planFile, JSON.stringify(plan));
+    await writeFile(visionFile, JSON.stringify(visionResultForPlan(plan, "https://images.example.test/product.jpg")));
+    const env = {
+      ...process.env,
+      JIMENG_TOKEN: "test-token",
+      JIMENG_SUBMIT_URL: `http://127.0.0.1:${port}/submit`,
+      JIMENG_QUERY_URL: `http://127.0.0.1:${port}/query`,
+      JIMENG_RESULT_BASE_URL: `http://127.0.0.1:${port}/result`,
+      COMMERCE_VISION_API_KEY: "test-key",
+      COMMERCE_VISION_BASE_URL: `http://127.0.0.1:${port}/vision`,
+    };
+    const execution = await execFileAsync(process.execPath, [
+      clientFile, planFile, "--execute", "--vision-result", visionFile, "--max-cost", "1.54", "--output", outputFile,
+      "--request-attempts", "3", "--poll-seconds", "0.05", "--timeout-seconds", "1", "--unknown-cooldown-seconds", "0",
+    ], { env });
+    const summary = JSON.parse(execution.stdout);
+    assert.equal(summary.status, "partial");
+    assert.deepEqual(summary.unknown_slots, ["01", "02", "03", "04", "05", "06", "07"]);
+    assert.equal(summary.resumable, false);
+    assert.equal(summary.next_action, "deliver_partial_and_report_unknown_do_not_resubmit");
+    assert.equal(submitCalls, 8);
+    const saved = JSON.parse(await readFile(outputFile, "utf8"));
+    assert.equal(saved.results[0].status, "submission_unknown");
+    assert.equal(saved.results[0].submit_attempts, 2);
+    assert.equal(saved.results[0].uncertain_submit_attempts, 1);
+    assert.ok(saved.results.slice(1).every((item) => item.status === "submission_unknown"));
+    assert.ok(saved.results.slice(1).every((item) => item.submit_attempts === 1));
+  } finally {
+    server.close();
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});

+ 91 - 0
claude-code/fmode-image-set/mcp/src/server.mjs

@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { z } from "zod";
+import { runImageSet } from "./tools/image-set-run.mjs";
+import { validateImageSetPlan } from "./tools/image-set-validate.mjs";
+
+const VERSION = "0.1.0";
+
+function asToolResult(result = {}) {
+  return {
+    content: [{
+      type: "text",
+      text: result.assistantMessage || JSON.stringify(result, null, 2),
+    }],
+    structuredContent: result,
+    isError: !new Set(["ok", "partial"]).has(result.status),
+  };
+}
+
+export function createServer() {
+  const server = new McpServer({
+    name: "fmode-image-set",
+    version: VERSION,
+  });
+
+  const outputSchema = {
+    status: z.string(),
+    assistantMessage: z.string(),
+    summary: z.object({}).passthrough().optional(),
+    data: z.object({}).passthrough().optional(),
+    files: z.array(z.string()).optional(),
+    nextActions: z.array(z.string()).optional(),
+    warnings: z.array(z.any()).optional(),
+    errors: z.array(z.any()).optional(),
+  };
+
+  server.registerTool(
+    "fmode_image_set_run",
+    {
+      title: "Run Fmode Image Set",
+      description: [
+        "Create, execute, resume, or inspect a product-truth-first ecommerce image-set run.",
+        "Use start for visual understanding and run allocation.",
+        "Use execute only after a valid prompt-plan exists and the user explicitly authorizes paid generation.",
+        "Use resume only for the same runId; use status for read-only inspection.",
+      ].join(" "),
+      inputSchema: {
+        action: z.enum(["start", "execute", "resume", "status"]),
+        image: z.string().optional(),
+        runId: z.string().optional(),
+        maxCost: z.union([z.number().nonnegative(), z.string()]).optional(),
+        workspaceRoot: z.string().optional(),
+        cwd: z.string().optional(),
+        claudeConfigDir: z.string().optional(),
+        sessionId: z.string().optional(),
+      },
+      outputSchema,
+    },
+    async (input) => asToolResult(await runImageSet(input)),
+  );
+
+  server.registerTool(
+    "fmode_image_set_validate",
+    {
+      title: "Validate Fmode Image Set Plan",
+      description: "Validate a prompt-plan.json before any paid image generation. Checks schema, product identity, evidence, seven-slot allocation, prompt completeness, and cross-image diversity.",
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: false,
+      },
+      inputSchema: {
+        planPath: z.string(),
+      },
+      outputSchema,
+    },
+    async (input) => asToolResult(await validateImageSetPlan(input)),
+  );
+
+  return server;
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  const server = createServer();
+  const transport = new StdioServerTransport();
+  await server.connect(transport);
+}

+ 16 - 0
claude-code/fmode-image-set/mcp/src/tools/create-run.mjs

@@ -0,0 +1,16 @@
+import { createRun } from "../core/run-paths.mjs";
+import { inspectLatestClaudeInvocation } from "../providers/claude-attachment-client.mjs";
+
+const args = process.argv.slice(2);
+const option = (name, fallback = "") => {
+  const index = args.indexOf(name);
+  return index >= 0 ? args[index + 1] : fallback;
+};
+const explicitRequestKey = option("--request-key");
+const invocation = explicitRequestKey ? null : await inspectLatestClaudeInvocation({
+  cwd: option("--cwd", process.cwd()),
+  claudeConfigDirectory: option("--claude-config-dir", process.env.CLAUDE_CONFIG_DIR),
+  sessionId: option("--session-id", process.env.CLAUDE_CODE_SESSION_ID || ""),
+});
+const requestKey = explicitRequestKey || `${invocation.session_id}:${invocation.prompt_id}`;
+console.log(JSON.stringify(await createRun({ requestKey }), null, 2));

+ 141 - 0
claude-code/fmode-image-set/mcp/src/tools/image-set-run.mjs

@@ -0,0 +1,141 @@
+#!/usr/bin/env node
+import { execFile } from "node:child_process";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { errorResult, okResult } from "../core/result-envelope.mjs";
+
+const execFileAsync = promisify(execFile);
+const toolsDirectory = dirname(fileURLToPath(import.meta.url));
+const runnerFile = resolve(toolsDirectory, "..", "features", "image-set", "image-set-runner.mjs");
+
+function parseJson(value) {
+  const text = String(value || "").trim();
+  if (!text) return null;
+  try {
+    return JSON.parse(text);
+  } catch {
+    const start = text.lastIndexOf("\n{");
+    if (start >= 0) return JSON.parse(text.slice(start + 1));
+    throw new Error("IMAGE_SET_RESULT_INVALID_JSON");
+  }
+}
+
+function collectFiles(result) {
+  const direct = Array.isArray(result?.files) ? result.files : [];
+  const fromResults = Array.isArray(result?.results)
+    ? result.results.flatMap((item) => item?.local_images || [])
+    : [];
+  const nested = Array.isArray(result?.result?.results)
+    ? result.result.results.flatMap((item) => item?.local_images || [])
+    : [];
+  return [...new Set([...direct, ...fromResults, ...nested].filter(Boolean))];
+}
+
+function buildAssistantMessage(action, result) {
+  const runId = result?.paths?.run_id || result?.result?.run_id || result?.run_id || "";
+  if (action === "start" && result?.status === "ready_for_plan") {
+    return [
+      "商品视觉理解已完成,可以编写七图计划。",
+      runId ? `运行编号:${runId}` : "",
+      result?.paths?.prompt_plan ? `计划文件:${result.paths.prompt_plan}` : "",
+      "下一步:依据视觉结果生成 prompt-plan.json,并调用 fmode_image_set_validate。",
+    ].filter(Boolean).join("\n");
+  }
+  if (result?.status === "complete") {
+    return `商品套图已完成${runId ? `,运行编号:${runId}` : ""}。`;
+  }
+  if (result?.status === "partial") {
+    return [
+      `商品套图部分完成${runId ? `,运行编号:${runId}` : ""}。`,
+      result?.summary ? `完成 ${result.summary.completed || 0} 张,失败 ${result.summary.failed || 0} 张。` : "",
+      "请根据返回的失败、未知、质量拒绝和可恢复图位继续处理。",
+    ].filter(Boolean).join("\n");
+  }
+  if (action === "status") {
+    return `已读取套图运行状态${runId ? `:${runId}` : ""}。`;
+  }
+  return result?.error?.message || "套图运行被阻断,请按结构化结果中的下一步处理。";
+}
+
+export async function runImageSet(input = {}) {
+  const action = String(input.action || "start");
+  if (!new Set(["start", "execute", "resume", "status"]).has(action)) {
+    return errorResult("needs_input", "action 必须是 start、execute、resume 或 status。");
+  }
+
+  const args = [runnerFile, action];
+  if (input.image) args.push("--image", String(input.image));
+  if (input.runId) args.push("--run-id", String(input.runId));
+  if (input.maxCost !== undefined) args.push("--max-cost", String(input.maxCost));
+  if (input.cwd) args.push("--cwd", String(input.cwd));
+  if (input.claudeConfigDir) args.push("--claude-config-dir", String(input.claudeConfigDir));
+  if (input.sessionId) args.push("--session-id", String(input.sessionId));
+
+  const env = {
+    ...process.env,
+    ...(input.workspaceRoot ? { FMODE_IMAGE_SET_PROJECT_ROOT: resolve(String(input.workspaceRoot)) } : {}),
+  };
+
+  try {
+    const { stdout } = await execFileAsync(process.execPath, args, {
+      cwd: input.workspaceRoot ? resolve(String(input.workspaceRoot)) : process.cwd(),
+      env,
+      maxBuffer: 1024 * 1024 * 20,
+    });
+    const result = parseJson(stdout);
+    const files = collectFiles(result);
+    const payload = {
+      assistantMessage: buildAssistantMessage(action, result),
+      summary: {
+        action,
+        runId: result?.paths?.run_id || result?.result?.run_id || result?.run_id || input.runId || "",
+        runtimeStatus: result?.status || "unknown",
+      },
+      data: result,
+      files,
+      nextActions: result?.next_action ? [result.next_action] : [],
+      warnings: result?.issues || [],
+    };
+    if (new Set(["blocked", "error"]).has(result?.status)) {
+      return errorResult(result.status, payload.assistantMessage, payload);
+    }
+    return okResult(payload);
+  } catch (error) {
+    let detail = null;
+    try {
+      detail = parseJson(error?.stdout || error?.stderr);
+    } catch {
+      detail = null;
+    }
+    const message = detail?.error?.message || detail?.message || error?.message || "套图运行失败";
+    return errorResult("error", message, {
+      data: detail || {},
+      nextActions: ["检查输入、运行编号和本地配置后,在同一运行中重试。"],
+    });
+  }
+}
+
+function parseArgs(argv) {
+  const input = { action: argv[0] || "start" };
+  for (let index = 1; index < argv.length; index += 1) {
+    const token = argv[index];
+    if (!token.startsWith("--")) continue;
+    const key = token.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
+    const next = argv[index + 1];
+    input[key] = next && !next.startsWith("--") ? next : true;
+    if (input[key] === next) index += 1;
+  }
+  return input;
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  const input = parseArgs(process.argv.slice(2));
+  const result = await runImageSet(input);
+  if (input.assistantMessageOnly) {
+    console.log(result.assistantMessage);
+  } else {
+    console.log(JSON.stringify(result, null, 2));
+  }
+  process.exitCode = new Set(["ok", "partial"]).has(result.status) ? 0 : 1;
+}

+ 50 - 0
claude-code/fmode-image-set/mcp/src/tools/image-set-validate.mjs

@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+import { execFile } from "node:child_process";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { errorResult, okResult } from "../core/result-envelope.mjs";
+
+const execFileAsync = promisify(execFile);
+const toolsDirectory = dirname(fileURLToPath(import.meta.url));
+const validatorFile = resolve(toolsDirectory, "..", "features", "image-set", "validate-plan.mjs");
+
+export async function validateImageSetPlan(input = {}) {
+  const planPath = input.planPath || input.path;
+  if (!planPath) {
+    return errorResult("needs_input", "planPath 是必填项。");
+  }
+  try {
+    const { stdout } = await execFileAsync(process.execPath, [validatorFile, resolve(String(planPath))], {
+      maxBuffer: 1024 * 1024 * 10,
+    });
+    const validation = JSON.parse(stdout);
+    return okResult({
+      assistantMessage: `套图计划校验通过,共 ${validation.images} 张。`,
+      summary: validation,
+      data: validation,
+      files: [resolve(String(planPath))],
+      nextActions: ["用户已授权实际生图时,调用 fmode_image_set_run,action=execute。"],
+    });
+  } catch (error) {
+    let validation = { valid: false, errors: [error?.message || "计划校验失败"] };
+    try {
+      validation = JSON.parse(error?.stderr || error?.stdout || "");
+    } catch {}
+    return errorResult("plan_invalid", [
+      "套图计划校验未通过。",
+      ...((validation.errors || []).slice(0, 12).map((item) => `- ${item}`)),
+    ].join("\n"), {
+      data: validation,
+      files: [resolve(String(planPath))],
+      nextActions: ["只修改不合格的计划字段,重新校验;校验通过前不要调用生图模型。"],
+    });
+  }
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  const planPath = process.argv[2];
+  const result = await validateImageSetPlan({ planPath });
+  console.log(JSON.stringify(result, null, 2));
+  process.exitCode = result.status === "ok" ? 0 : 1;
+}

+ 16 - 0
claude-code/fmode-image-set/memory-templates/image-set-profile.json

@@ -0,0 +1,16 @@
+{
+  "project": "Fmode Image Set",
+  "platform": "general_ecommerce",
+  "market": "",
+  "locale": "zh-CN",
+  "defaultImageCount": 7,
+  "businessAllocation": {
+    "hero": 1,
+    "scene": 2,
+    "selling_point": 2,
+    "decision_support": 2
+  },
+  "visualPreferences": [],
+  "blockedDirections": [],
+  "updatedAt": ""
+}

+ 1185 - 0
claude-code/fmode-image-set/package-lock.json

@@ -0,0 +1,1185 @@
+{
+  "name": "@fmode/image-set",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "@fmode/image-set",
+      "version": "0.1.0",
+      "dependencies": {
+        "@modelcontextprotocol/sdk": "^1.29.0",
+        "zod": "^4.4.3"
+      },
+      "bin": {
+        "fmode-image-set": "bin/fmode-image-set.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/@hono/node-server": {
+      "version": "2.0.12",
+      "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz",
+      "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      },
+      "peerDependencies": {
+        "hono": "^4"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk": {
+      "version": "1.30.0",
+      "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
+      "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
+      "license": "MIT",
+      "dependencies": {
+        "@hono/node-server": "^1.19.9 || ^2.0.5",
+        "ajv": "^8.17.1",
+        "ajv-formats": "^3.0.1",
+        "content-type": "^1.0.5",
+        "cors": "^2.8.5",
+        "cross-spawn": "^7.0.5",
+        "eventsource": "^3.0.2",
+        "eventsource-parser": "^3.0.0",
+        "express": "^5.2.1",
+        "express-rate-limit": "^8.2.1",
+        "hono": "^4.11.4",
+        "jose": "^6.1.3",
+        "json-schema-typed": "^8.0.2",
+        "pkce-challenge": "^5.0.0",
+        "raw-body": "^3.0.0",
+        "zod": "^3.25 || ^4.0",
+        "zod-to-json-schema": "^3.25.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@cfworker/json-schema": "^4.1.1",
+        "zod": "^3.25 || ^4.0"
+      },
+      "peerDependenciesMeta": {
+        "@cfworker/json-schema": {
+          "optional": true
+        },
+        "zod": {
+          "optional": false
+        }
+      }
+    },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+      "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ajv-formats": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+      "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "ajv": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+      "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^2.0.0",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.1",
+        "iconv-lite": "^0.7.2",
+        "on-finished": "^2.4.1",
+        "qs": "^6.15.2",
+        "raw-body": "^3.0.2",
+        "type-is": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/body-parser/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+      "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/cors": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/eventsource": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+      "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+      "license": "MIT",
+      "dependencies": {
+        "eventsource-parser": "^3.0.1"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/eventsource-parser": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
+      "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/express-rate-limit": {
+      "version": "8.6.1",
+      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz",
+      "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "ip-address": "^10.2.0"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/express-rate-limit"
+      },
+      "peerDependencies": {
+        "express": ">= 4.11"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "license": "MIT"
+    },
+    "node_modules/fast-uri": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
+      "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/hono": {
+      "version": "4.12.32",
+      "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz",
+      "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.9.0"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.7.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+      "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ip-address": {
+      "version": "10.3.1",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
+      "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "license": "ISC"
+    },
+    "node_modules/jose": {
+      "version": "6.2.4",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz",
+      "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "license": "MIT"
+    },
+    "node_modules/json-schema-typed": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+      "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+      "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.4.2",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/pkce-challenge": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+      "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+      "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+      "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^2.0.0",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/type-is/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    },
+    "node_modules/zod": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+      "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
+    "node_modules/zod-to-json-schema": {
+      "version": "3.25.2",
+      "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+      "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+      "license": "ISC",
+      "peerDependencies": {
+        "zod": "^3.25.28 || ^4"
+      }
+    }
+  }
+}

+ 53 - 0
claude-code/fmode-image-set/package.json

@@ -0,0 +1,53 @@
+{
+  "name": "@fmode/image-set",
+  "version": "0.1.0",
+  "description": "Claude Code ecommerce image-set skill with product vision auditing, seven-slot planning, Jimeng generation, recovery, cost control, and delivery integrity checks.",
+  "type": "module",
+  "bin": {
+    "fmode-image-set": "bin/fmode-image-set.js"
+  },
+  "scripts": {
+    "install:check": "node install.js --check",
+    "install:smoke": "node install.js --smoke",
+    "mcp:start": "node mcp/src/server.mjs",
+    "mcp:smoke": "node scripts/smoke-mcp.mjs",
+    "smoke:package": "node scripts/smoke-package.mjs",
+    "acceptance": "node scripts/acceptance.mjs",
+    "test": "node scripts/run-tests.mjs",
+    "validate:plan": "node mcp/src/tools/image-set-validate.mjs"
+  },
+  "files": [
+    ".claude-plugin/",
+    ".mcp.json",
+    "bin/",
+    "docs/",
+    "install.js",
+    "mcp/",
+    "memory-templates/",
+    "package.json",
+    "package-lock.json",
+    "README.md",
+    "scripts/",
+    "skill-package-manifest.json",
+    "skills/"
+  ],
+  "publishConfig": {
+    "access": "public"
+  },
+  "keywords": [
+    "claude-code",
+    "ecommerce",
+    "product-image",
+    "image-set",
+    "jimeng",
+    "mcp",
+    "fmode"
+  ],
+  "engines": {
+    "node": ">=20"
+  },
+  "dependencies": {
+    "@modelcontextprotocol/sdk": "^1.29.0",
+    "zod": "^4.4.3"
+  }
+}

+ 15 - 0
claude-code/fmode-image-set/scripts/acceptance.mjs

@@ -0,0 +1,15 @@
+#!/usr/bin/env node
+import { execFileSync } from "node:child_process";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const run = (file, args = []) => execFileSync(process.execPath, [resolve(root, file), ...args], {
+  cwd: root,
+  stdio: "inherit",
+});
+
+run("install.js", ["--check"]);
+run("scripts/smoke-package.mjs");
+run("scripts/smoke-mcp.mjs");
+console.log(JSON.stringify({ status: "ok", acceptance: "fmode-image-set" }, null, 2));

+ 75 - 0
claude-code/fmode-image-set/scripts/package.test.mjs

@@ -0,0 +1,75 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { access, mkdtemp, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { smokePackage } from "./smoke-package.mjs";
+import { smokeMcp } from "./smoke-mcp.mjs";
+
+const execFileAsync = promisify(execFile);
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+
+test("package structure and plan validation smoke", async () => {
+  const result = await smokePackage();
+  assert.equal(result.status, "ok");
+});
+
+test("MCP exposes the image-set tools", async () => {
+  const result = await smokeMcp();
+  assert.equal(result.status, "ok");
+  assert.deepEqual(result.tools.sort(), ["fmode_image_set_run", "fmode_image_set_validate"].sort());
+  const validator = result.toolDefinitions.find((tool) => tool.name === "fmode_image_set_validate");
+  assert.deepEqual(validator.annotations, {
+    readOnlyHint: true,
+    destructiveHint: false,
+    idempotentHint: true,
+    openWorldHint: false,
+  });
+});
+
+test("skill can call its MCP tools and delegates unsupported images to the attachment bridge", async () => {
+  const skill = await readFile(join(root, "skills", "fmode-image-set", "SKILL.md"), "utf8");
+  assert.doesNotMatch(skill, /^allowed-tools:/m);
+  assert.match(skill, /\[Unsupported Image\]/);
+  assert.match(skill, /`fmode_image_set_run` 的 `action=start`/);
+  assert.match(skill, /不得先用 Glob 搜图/);
+});
+
+test("workspace install places the complete package under the discoverable skill root", async () => {
+  const workspace = await mkdtemp(join(tmpdir(), "fmode-image-set-workspace-"));
+  try {
+    await execFileAsync(
+      process.execPath,
+      [join(root, "bin", "fmode-image-set.js"), "workspace", "--skip-install"],
+      { cwd: workspace },
+    );
+
+    const target = join(workspace, ".claude", "skills", "fmode-image-set");
+    for (const relativePath of [
+      ".claude-plugin/plugin.json",
+      "bin/fmode-image-set.js",
+      "docs/capability-map.md",
+      "mcp/src/server.mjs",
+      "memory-templates/image-set-profile.json",
+      "scripts/smoke-package.mjs",
+      "skills/fmode-image-set/SKILL.md",
+      "skill-package-manifest.json",
+      "SKILL.md",
+      "references/image-set-planner.md",
+      "agents/openai.yaml",
+    ]) {
+      await access(join(target, relativePath));
+    }
+
+    const config = JSON.parse(await readFile(join(workspace, ".mcp.json"), "utf8"));
+    assert.equal(
+      resolve(config.mcpServers["fmode-image-set"].cwd),
+      resolve(target),
+    );
+  } finally {
+    await rm(workspace, { recursive: true, force: true });
+  }
+});

+ 27 - 0
claude-code/fmode-image-set/scripts/run-tests.mjs

@@ -0,0 +1,27 @@
+#!/usr/bin/env node
+import { spawnSync } from "node:child_process";
+import { readdirSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+
+function collectTests(directory) {
+  const result = [];
+  for (const entry of readdirSync(directory, { withFileTypes: true })) {
+    if (entry.name === "node_modules") continue;
+    const fullPath = join(directory, entry.name);
+    if (entry.isDirectory()) result.push(...collectTests(fullPath));
+    else if (entry.isFile() && entry.name.endsWith(".test.mjs")) result.push(fullPath);
+  }
+  return result;
+}
+
+const tests = collectTests(root);
+const child = spawnSync(process.execPath, ["--test", ...tests], {
+  cwd: root,
+  stdio: "inherit",
+  encoding: "utf8",
+  maxBuffer: 1024 * 1024 * 100,
+});
+process.exit(child.status ?? 1);

+ 32 - 0
claude-code/fmode-image-set/scripts/smoke-mcp.mjs

@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+
+export async function smokeMcp() {
+  const client = new Client({ name: "fmode-image-set-smoke", version: "0.1.0" });
+  const transport = new StdioClientTransport({
+    command: process.execPath,
+    args: [resolve(root, "mcp", "src", "server.mjs")],
+    cwd: root,
+    stderr: "pipe",
+  });
+  await client.connect(transport);
+  try {
+    const result = await client.listTools();
+    const names = result.tools.map((tool) => tool.name);
+    assert.ok(names.includes("fmode_image_set_run"));
+    assert.ok(names.includes("fmode_image_set_validate"));
+    return { status: "ok", tools: names, toolDefinitions: result.tools };
+  } finally {
+    await client.close();
+  }
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  console.log(JSON.stringify(await smokeMcp(), null, 2));
+}

+ 60 - 0
claude-code/fmode-image-set/scripts/smoke-package.mjs

@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { okResult } from "../mcp/src/core/result-envelope.mjs";
+import { sha256, signState, verifyState } from "../mcp/src/core/run-state-integrity.mjs";
+import { buildValidPlan } from "./test-plan-fixture.mjs";
+
+const execFileAsync = promisify(execFile);
+
+export async function smokePackage() {
+  const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+  const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
+  const pluginJson = JSON.parse(await readFile(join(root, ".claude-plugin", "plugin.json"), "utf8"));
+  const manifest = JSON.parse(await readFile(join(root, "skill-package-manifest.json"), "utf8"));
+  assert.equal(packageJson.version, pluginJson.version);
+  assert.equal(pluginJson.version, manifest.version);
+  assert.equal(manifest.entrySkill, "fmode-image-set");
+
+  const envelope = okResult({ assistantMessage: "ok" });
+  assert.equal(envelope.status, "ok");
+  assert.equal(envelope.assistantMessage, "ok");
+
+  const state = { run_id: "run-20260729000000-abcdef", status: "running" };
+  const secret = "package-smoke-secret";
+  const signed = signState(state, secret);
+  assert.equal(verifyState(signed, secret), true);
+  assert.equal(sha256("image-set").length, 64);
+
+  const temp = await mkdtemp(join(tmpdir(), "fmode-image-set-"));
+  try {
+    const planPath = join(temp, "prompt-plan.json");
+    await writeFile(planPath, `${JSON.stringify(buildValidPlan(
+      "generic consumer product",
+      "https://images.example.test/product.jpg",
+      "run-20260729000000-abcdef",
+    ), null, 2)}\n`, "utf8");
+    const validator = join(root, "mcp", "src", "features", "image-set", "validate-plan.mjs");
+    const { stdout } = await execFileAsync(process.execPath, [validator, planPath]);
+    const validation = JSON.parse(stdout);
+    assert.equal(validation.valid, true);
+    assert.equal(validation.images, 7);
+  } finally {
+    await rm(temp, { recursive: true, force: true });
+  }
+
+  return {
+    status: "ok",
+    version: packageJson.version,
+    checks: ["version-sync", "result-envelope", "state-integrity", "plan-validation"],
+  };
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  console.log(JSON.stringify(await smokePackage(), null, 2));
+}

+ 142 - 0
claude-code/fmode-image-set/scripts/test-plan-fixture.mjs

@@ -0,0 +1,142 @@
+import { compileProductLock } from "../mcp/src/providers/commerce-vision-provider.mjs";
+
+function repeatToLength(seed, minimum) {
+  let result = seed;
+  while ([...result].length < minimum) result += ` ${seed}`;
+  return result;
+}
+
+const identityManifest = {
+  must_preserve: ["完整外形轮廓", "商品数量", "主体颜色", "可见结构", "标识位置"],
+  must_not_add: ["额外配件", "额外文字", "不存在的纽扣"],
+  unknown_do_not_assert: ["内部结构", "真实尺寸", "材质成分"],
+};
+identityManifest.product_lock = compileProductLock(identityManifest);
+
+const slotConfigs = [
+  ["hero", "isolated_product", "simple", "看清完整商品外观与数量", ["visible_fact:1"], "", "纯净无缝背景中只放置一个完整商品,保留充分边距、自然接触阴影与清楚轮廓。"],
+  ["selling_point", "material_demonstration", "medium", "理解第一项可见材质价值", ["visible-sp-1"], "visible-sp-1", "以单一方向的掠射光划过商品表面,并用明暗渐变和局部微观纹理展示第一项可见材质特征;画面仍保留足够完整外形作为识别锚点,不加入说明文字或虚构性能。"],
+  ["scene", "environmental_scene", "complex", "理解商品与完整生活空间的关系", ["suggested_scene:1"], "", "建立具有前景家具、中景商品与远景建筑层次的宽幅生活空间。商品自然位于真实承托面上,附近道具只用于交代尺度与情境,左右留出呼吸空间;环境细节、材质反射与光影方向互相呼应,不宣称未经证实的适用性。"],
+  ["selling_point", "feature_demonstration", "medium", "理解第二项可见结构价值", ["visible-sp-2"], "visible-sp-2", "用侧向中近景展示与第二项卖点相关的可见结构,并让完整商品处于同一连续空间的后景作为关系参照;通过景深和连续空间层次区分重点,禁止虚构内部构造、功能或性能。"],
+  ["decision_support", "multi_angle", "medium", "从多个真实可见角度完成外观判断", ["visible_fact:2"], "", "在唯一一张多格构图中,以等比例、等光色的三个外观视角建立横向阅读顺序;所有视角都只展示参考图支持的外部表面,间距清楚,不生成未知背面、内部、附件或说明文字。"],
+  ["scene", "human_interaction", "complex", "理解人物动作与商品之间的关系", ["suggested_audience:1"], "", "人物从画面侧边进入,以明确、自然且符合商品可见结构的手部动作与商品发生联系。前景动作、中景商品和背景环境形成三层空间,服装、道具与光线克制统一;商品仍是视觉焦点,不遮挡身份特征,不添加未证实用途。"],
+  ["decision_support", "usage_sequence", "medium", "理解商品在单一使用时刻中的状态关系", ["visible_fact:3"], "", "在一个连续、真实的桌面或生活空间中呈现商品与必要使用动作的前后关系,以视线方向、手部位置和物体间距传达顺序,保持单一连续画面,不加入步骤文字、测量值、营销结论或未知附件。"],
+].map(([category, render, complexity, decision, evidence, sellingPointId, scene]) => ({
+  category, render, complexity, decision, evidence, sellingPointId, scene,
+}));
+
+function buildModules(index, config) {
+  const sceneMinimum = config.complexity === "complex" ? 175 : config.complexity === "medium" ? 75 : 25;
+  return {
+    product_lock: identityManifest.product_lock,
+    image_job: `第${index + 1}张图只承担一个决策任务:${config.decision}。`,
+    scene: repeatToLength(config.scene, sceneMinimum),
+    composition: `第${index + 1}张使用独立的裁切、占比、边距、空间深度与阅读顺序,不能复用其他槽位的机位关系。`,
+    lighting_material: `第${index + 1}张使用独立光线方向、光源面积、软硬度、色温、阴影和真实材质响应。`,
+    camera_style: config.complexity === "complex"
+      ? `为第${index + 1}张指定独立镜头焦段、焦平面和景深过渡。`
+      : `第${index + 1}张采用独立的商业摄影机位与成像方式。`,
+    text_layout: "",
+    negative_constraints: `禁止商品漂移、无关物体、重复商品、虚构卖点、水印和额外品牌;禁止断言内部结构、真实尺寸或材质成分。`,
+  };
+}
+
+export function compilePrompt(modules) {
+  return Object.values(modules).filter(Boolean).join(" ");
+}
+
+export function buildValidPlan(
+  category = "generic consumer product",
+  reference = "https://images.example.test/product.jpg",
+  runId = `cross-category-${category.replace(/\s+/g, "-")}`,
+) {
+  return {
+    schema_version: "0.6",
+    run_id: runId,
+    status: "ready",
+    user_output_mode: "images_only",
+    request: {
+      instruction: "这是我的产品,帮我生成一套通用电商七图。",
+      reference_images: [reference],
+    },
+    context: {
+      platform: "general_ecommerce",
+      market: "unspecified",
+      voc: { status: "unavailable", findings: [] },
+    },
+    product_understanding: {
+      category,
+      product_name: { value: category, basis: "verified visible evidence", confidence: "medium" },
+      visible_facts: ["完整外形可见", "正面与侧面轮廓可见", "可见操作结构", "主色和表面材质观感可见"],
+      verified_visible_facts: ["完整外形可见", "正面与侧面轮廓可见", "可见操作结构", "主色和表面材质观感可见"],
+      inferred_attributes: [],
+      unknown_features: ["内部结构", "真实尺寸", "材质成分"],
+      identity_manifest: identityManifest,
+      provided_facts: [],
+      candidate_selling_points: [
+        { id: "visible-sp-1", value: "可见材质与主色形成清晰的视觉识别", basis_refs: ["visible_fact:4"], confidence: "high" },
+        { id: "visible-sp-2", value: "可见结构关系让商品外观层次容易理解", basis_refs: ["visible_fact:2", "visible_fact:3"], confidence: "high" },
+      ],
+      visible_specs: { visible_quantity: "1", exterior_state: "complete" },
+      creative_positioning: ["克制、清晰的商业视觉"],
+      suggested_audiences: ["重视清晰商品信息的消费者"],
+      suggested_scenes: ["整洁、可信的日常使用环境"],
+      unsupported_claims: ["经过认证", "超长耐用"],
+      unknowns: ["内部结构", "真实尺寸", "材质成分"],
+    },
+    global_consistency_lock: {
+      product_invariants: identityManifest.must_preserve,
+      visual_system: ["统一商品身份与真实材质响应"],
+      prohibited_changes: identityManifest.must_not_add,
+    },
+    set_blueprint: {
+      business_allocation: { hero: 1, scene: 2, selling_point: 2, decision_support: 2 },
+      visual_thesis: "用七个不可互换的购买任务让商品可识别、可理解、可代入并可判断。",
+      narrative_arc: ["识别", "理解价值", "代入", "决策"],
+      shared_visual_dna: ["商品身份", "核心色彩", "真实材质"],
+      intentional_variations: ["业务任务", "空间", "人物", "景别", "信息关系"],
+    },
+    images: slotConfigs.map((config, index) => {
+      const modules = buildModules(index, config);
+      return {
+        id: String(index + 1).padStart(2, "0"),
+        role: config.category,
+        business_category: config.category,
+        decision_subject: config.decision,
+        evidence_refs: config.evidence,
+        selling_point_id: config.sellingPointId,
+        purpose: config.decision,
+        voc_refs: [],
+        set_design: {
+          render_family: config.render,
+          scene_complexity: config.complexity,
+          unique_visual_hook: `槽位${index + 1}使用${config.render}形成不可互换的视觉任务。`,
+          contrast_with_previous: index === 0 ? "开场" : `槽位${index + 1}改变景别、空间、动作和信息关系。`,
+        },
+        shot_spec: {
+          narrative_job: `job-${index + 1}`,
+          composition_family: ["centered hero", "diagonal material field", "wide room depth", "single-space feature relation", "multi-angle grid", "human action frame", "continuous usage moment"][index],
+          shot_scale: ["full product", "medium product", "wide environment", "detail and whole", "multi-view", "human medium-wide", "context close-medium"][index],
+          camera_angle: ["eye level", "low oblique", "high wide", "side close", "three-quarter grid", "shoulder height", "top-oblique context"][index],
+          camera_axis: ["front", "low", "three_quarter_left", "side", "three_quarter_right", "front", "top"][index],
+          framing_mode: ["full_product", "detail", "product_in_context", "full_product", "multi_panel", "human_action", "product_in_context"][index],
+          product_pose: ["upright", "detail_crop", "upright", "arranged_sequence", "multi_view", "held", "laid_flat"][index],
+          product_placement: `distinct placement ${index + 1}`,
+          product_occupancy_pct: [70, 45, 25, 50, 60, 20, 55][index],
+          depth_structure: `distinct depth structure ${index + 1}`,
+          background_family: ["pure seamless", "material field", "lived-in room", "feature stage", "neutral grid", "human environment", "continuous context"][index],
+          lighting_family: ["even studio", "directional material", "window ambient", "edge definition", "flat comparative", "motivated environmental", "clear contextual"][index],
+          interaction_mode: ["single accurate product", "material relation", "environment placement", "feature relation", "multiple exterior views", "human action", "single moment relation"][index],
+          information_density: ["low", "medium", "medium", "medium", "high", "medium", "medium"][index],
+        },
+        prompt_modules: modules,
+        generation_prompt: compilePrompt(modules),
+        negative_prompt: "deformation, watermark",
+        overlay_copy: [],
+        reference_images: [reference],
+        model_parameters: { adapter: "jimeng_img_v4", width: 2048, height: 2048, scale: 0.6, force_single: true },
+        acceptance_checks: ["product identity preserved", "decision task is visible"],
+      };
+    }),
+  };
+}

+ 35 - 0
claude-code/fmode-image-set/skill-package-manifest.json

@@ -0,0 +1,35 @@
+{
+  "name": "fmode-image-set",
+  "version": "0.1.0",
+  "description": "Claude Code native ecommerce image-set package with evidence-bounded planning and traceable generation.",
+  "plugin": "fmode-image-set",
+  "skills": [
+    "fmode-image-set"
+  ],
+  "mcpTools": [
+    "fmode_image_set_run",
+    "fmode_image_set_validate"
+  ],
+  "entrySkill": "fmode-image-set",
+  "capabilityMap": "docs/capability-map.md",
+  "customerQuickstart": "docs/customer-quickstart.md",
+  "liveAcceptanceChecklist": "docs/live-manual-acceptance-checklist.md",
+  "deliverableContract": "docs/deliverables/image-set-result.md",
+  "primaryUseCases": [
+    "Understand a user-provided product image without inventing hidden product facts",
+    "Plan one coherent seven-image ecommerce set with distinct business roles",
+    "Compile and validate per-slot generation prompts",
+    "Generate through Jimeng with cost caps, resumable state, and delivery integrity checks",
+    "Reject generated images that drift from the audited product identity"
+  ],
+  "recommendedUserPrompts": [
+    "给这款商品做一套高级、适合电商详情页的七图。",
+    "先帮我规划这套图,不要实际生图。",
+    "继续恢复刚才那套图,只处理允许恢复的图位。"
+  ],
+  "installCommand": "node install.js",
+  "workspaceInstallCommand": "fmode-image-set workspace --smoke",
+  "workspaceInstallTarget": ".claude/skills/fmode-image-set",
+  "mcpSmokeCommand": "npm run mcp:smoke",
+  "packageSmokeCommand": "npm run smoke:package"
+}

+ 105 - 0
claude-code/fmode-image-set/skills/fmode-image-set/SKILL.md

@@ -0,0 +1,105 @@
+---
+name: fmode-image-set
+description: Understand one product image and a short business request, plan a coherent seven-image ecommerce set, validate product truth and non-repetition, and optionally generate the approved set through Jimeng. Use when the user asks for 商品套图、电商七图、白底主图、卖点图、场景图、购买决策图、整套商品图 or wants one product image turned into a consistent image set.
+---
+
+# Fmode Image Set
+
+你是面向电商运营、品牌方和设计人员的商品套图工作台。用户只需要提供一张商品图和一句业务需求;不要要求用户编写生图 Prompt、镜头、灯光、七图角色或负面词。
+
+## 必须执行
+
+- 用户要求商品套图、电商七图、主图、卖点图或场景图时,优先运行 `fmode_image_set_run`,不要只凭经验给七条提示词。
+- 即使当前对话模型把附件显示为 `[Unsupported Image]`,也必须先运行 `fmode_image_set_run` 的 `action=start`,让 MCP 从当前 Claude 会话提取原始图片;不得先用 Glob 搜图,也不得要求用户重复提供路径。
+- 首次处理使用 `action=start`,由工具创建唯一运行目录并完成商品视觉理解。
+- 根据视觉结果和用户需求编写 `prompt-plan.json`,然后运行 `fmode_image_set_validate`。
+- 只有用户明确要求实际生图时,才运行 `action=execute`;仅规划、分析、评审或调试请求不得消费生图费用。
+- 中断后只能对同一个 `runId` 使用 `action=resume`,不得创建新运行绕过状态、费用或失败记录。
+- 第一轮没有指定平台时使用 `general_ecommerce`;没有 VOC 时记录 `voc.status=unavailable` 并继续,不得伪造 VOC。
+
+## 输入与证据
+
+- 商品图片可证明外形、颜色、可见结构、Logo 和可见文字,不能证明功效、成分、销量、认证或不可见结构。
+- 信息优先级:用户明确事实 > 官方资料 > 图片可见事实 > VOC/竞品/趋势 > 默认设计规则。
+- VOC 用来决定用户关心什么,不自动证明商品具备某项能力。
+- 竞品素材只能用于抽象构图和信息顺序,不能作为我方商品参考图或复制品牌资产。
+- 无法确认的关键事实必须进入未知项或禁止断言项。
+
+需要理解证据分层时读取 `references/product-understanding.md`。
+
+## 套图规划
+
+当前版本固定生成七张,业务配额为:
+
+```text
+1 张 hero
+2 张 scene
+2 张 selling_point
+2 张 decision_support
+```
+
+每张只承担一个主要决策任务,整套遵循:
+
+```text
+看清商品 → 理解价值 → 代入场景 → 补足判断
+```
+
+两张卖点图必须引用不同、可追溯的候选卖点;两张场景图必须使用不同空间或人物关系;两张购买决策图必须解决不同购买疑问。不要用“同一商品换背景”伪装成七个不同任务。
+
+规划时读取:
+
+- `references/image-set-planner.md`
+- `references/prompt-compiler.md`
+- `references/plan-schema.md`
+
+## 商品一致性
+
+- `identity_manifest`、`verified_visible_facts` 和每张图的 `product_lock` 必须来自工具返回的视觉审计结果,不得改写。
+- 未知属性不得写进正向 Prompt。
+- 生成后出现结构漂移、新增配件、Logo/包装文字变化或商品数量错误时,必须拒绝该图。
+- 不得从其他运行复制图片补齐当前运行。
+
+## 生成与费用
+
+- 实际执行前必须先通过计划校验。
+- 默认费用上限为 `1.54` 元,对应七张各生成一次;需要提高预算时必须得到用户明确授权。
+- 正常执行顺序是:连接预检 → 单张提交 → 等待完成 → 下载 → 商品身份复核 → 下一张。
+- `submission_unknown` 不得自动重提,避免重复扣费。
+- `quality_rejected` 不自动补图。
+- 部分完成时如实返回成功、失败、未知、拒绝和可恢复图位。
+
+需要调用即梦时再读取 `references/jimeng-v4.md`。
+
+## 用户输出
+
+成功时默认只返回:
+
+- 01–07 七张本地图片;
+- 每张图片的绝对路径;
+- 成品文件夹绝对路径;
+- 一句简短完成状态。
+
+部分完成或阻断时返回:
+
+- 当前状态;
+- 已成功图位;
+- 失败、未知或质量拒绝图位;
+- 已发生费用范围;
+- 是否允许在同一运行恢复;
+- 用户下一步需要做什么。
+
+不要默认展示内部 Prompt、商品理解 JSON、模型参数、状态签名或调试日志。只有用户明确要求诊断信息时再展开。
+
+## 典型用户话术
+
+```text
+给这款商品做一套高级、适合电商详情页的七图。
+```
+
+```text
+先帮我规划这套图,不要实际生图。
+```
+
+```text
+继续恢复刚才那套图,只处理允许恢复的图位。
+```

+ 4 - 0
claude-code/fmode-image-set/skills/fmode-image-set/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "Fmode Image Set"
+  short_description: "Plan and generate consistent ecommerce image sets."
+  default_prompt: "Use $fmode-image-set to turn this product image into a coherent ecommerce image set."

+ 130 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/image-set-planner.md

@@ -0,0 +1,130 @@
+# 通用七图规划器
+
+七张是 v0 的固定交付数量,使用智能匹配,不要求用户手动配置。业务结构固定为:
+
+- `hero` × 1:标准商品白底图;
+- `scene` × 2:环境场景与人物/使用关系;
+- `selling_point` × 2:两个不同且有证据的商品价值;
+- `decision_support` × 2:帮助完成规格、结构、步骤、包装、尺度、多角度或比较判断。
+
+这四类回答“图片讲什么”。`render_family`只回答“如何表达”,不能代替业务任务。
+
+## 先做商品理解
+
+读取 [product-understanding.md](product-understanding.md)。先建立商品名称、可见事实、候选卖点、可见规格、创意定位、建议人群/场景和禁止声明,再规划图片。
+
+卖点图只能引用:
+
+1. 用户明确事实;
+2. 图片可见事实形成的 `visible_selling_points`;
+3. 有具体证据的VOC结论。
+
+视觉氛围、人群和场景建议可以控制创意,但不能成为功能、性能或适用性声明。
+
+## 业务结构
+
+### 1. 白底图
+
+固定一张,通常为01。只负责完整、准确、移动端清晰地展示商品。不得承担第二个氛围广告任务。
+
+### 2. 两张场景图
+
+两张必须解决不同问题:
+
+- 一张强调商品与完整环境的关系;
+- 另一张强调人物、动作、步骤或不同使用时刻。
+
+无可靠人群信息时,人物只是创意角色,不在画面文字中声称商品“适合”该人群。两张不得只是更换房间背景。
+
+### 3. 两张卖点图
+
+分别引用不同 `selling_point_id`。每张必须明确:
+
+- 具体可见特征;
+- 该特征带来的可见价值;
+- 采用什么画面关系证明,而不是只把商品重新拍一遍。
+
+允许的渲染家族:
+
+- `material_demonstration`:用真实物理光线、纹理或形态关系解释可见材质;
+- `feature_demonstration`:通过动作、结构关系或局部与整体的并置解释特征;
+- `annotated_detail`:只有确定性后期标注可用时才选择;当前Demo默认不用。
+
+两张卖点图不得使用同一渲染家族,不得都表现成大比例单品棚拍。
+
+### 4. 两张购买决策图
+
+根据现有证据智能选择:
+
+- `multi_angle`:多角度外观关系;
+- `usage_sequence`:有依据的使用步骤;
+- `package_contents`:参考图或资料中确实存在的包装/配件;
+- `scale_context`:有可靠尺寸或真实参照关系;
+- `comparison_sequence`:有可靠对比证据;
+- `structured_information`:只有确定性文字合成器可用时使用。
+
+两张必须回答不同购买问题。没有可靠尺寸、包装、步骤或比较信息时,优先使用多角度外观和基于可见结构的顺序展示,不得编造数据,也不得用“商品加留白”冒充信息图。
+
+## 渲染家族
+
+标准值:
+
+- `isolated_product`
+- `environmental_scene`
+- `human_interaction`
+- `usage_demonstration`
+- `material_demonstration`
+- `feature_demonstration`
+- `annotated_detail`
+- `multi_angle`
+- `usage_sequence`
+- `package_contents`
+- `scale_context`
+- `comparison_sequence`
+- `structured_information`
+
+业务分类与渲染家族必须匹配。`brand_editorial`不属于通用默认七图的独立业务任务;需要品牌氛围时,将其作为场景图的视觉方向,而不是额外增加一张静态商品照。
+
+## 证据与去重
+
+每张填写:
+
+- `business_category`
+- `decision_subject`
+- `evidence_refs`
+- 卖点图的 `selling_point_id`
+- `render_family`
+- `unique_visual_hook`
+- `contrast_with_previous`
+
+要求:
+
+- 七张 `decision_subject` 不重复;
+- 两张卖点图引用两个不同卖点ID;
+- 两张场景图使用不同渲染家族;
+- 两张购买决策图使用不同渲染家族;
+- `isolated_product`只能用于白底图;
+- 静态单品类画面(白底、材质单品、纯局部)全套最多两张;
+- 不能靠改写字段名称绕过去重。如果删除背景名词后仍是“大商品 + 台面 + 单一光源”,视为同一画面逻辑;
+- 不允许把卖点图、细节图、信息图和品牌图全部实现成商品单独陈列。
+
+## 镜头节奏
+
+先确定业务任务和证据,再填写 `shot_spec`。除自然语言机位说明外,必须填写规范化的 `camera_axis`、`framing_mode` 与 `product_pose`。至少覆盖五种构图、四种景别、四种机位轴、四种画面类型、五种背景结构和四种布光;最大与最小商品占比相差至少35个百分点。
+
+除白底图外,任何两图不得复用同一个“机位轴 + 画面类型 + 商品姿态”组合。若两图机位轴和画面类型相同,商品占比还必须至少相差15个百分点。尤其不能让场景图与购买决策图仅因业务名称不同而使用相同拍摄角度。
+
+统一视觉只锁定商品身份、核心色彩、材质物理表现和后期质感。空间深度、主体占比、动静关系、信息密度和画面关系必须变化。
+
+推荐顺序是:
+
+`白底识别 → 卖点解释 → 环境代入 → 第二卖点 → 决策辅助 → 人物/动作 → 第二决策辅助`
+
+这是节奏建议,不是品类模板。允许根据商品与证据调整顺序,但01必须是白底图,且业务配额不能改变。
+
+## 缺少上下文
+
+- VOC为空:继续执行,`voc_refs`为空,不写消费者普遍结论。
+- 平台/市场为空:使用 `general_ecommerce`,不声称符合某平台硬规则。
+- 商品资料为空:使用可见卖点和可见规格,不编造功能卖点。
+- 目标人群为空:使用视觉服务给出的创意假设,不写产品适用性结论。

+ 36 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/interaction.md

@@ -0,0 +1,36 @@
+# 极简交互规则
+
+## 默认输入
+
+用户通常只提供:
+
+- 一张或多张商品参考图;
+- 一句自然语言,例如“这是我的产品,帮我生成一套图”。
+
+以下内容均为可选机器上下文,不要求用户填写:商品资料、商品品类、目标平台、市场、VOC、用户人群、品牌风格和具体场景想法。
+
+## 自动补全
+
+- 从商品图识别可见商品信息和品类。
+- 从可见事实自动生成带证据的候选卖点、可见规格、创意定位、建议人群和建议场景;不要求用户填写。
+- 没有平台或市场时使用 `general_ecommerce`。
+- 没有 VOC 时使用 `unavailable`,依据商品与通用购买决策规划。
+- 没有风格时,从商品的颜色、材质、形态和价格感知选择克制的商业视觉方向。
+- 没有具体图位想法时,自动完成 `1张白底 + 2张场景 + 2张卖点 + 2张购买决策` 的七图设计。
+
+## 何时提问
+
+只在以下情况提一个最短问题:
+
+1. 没有可访问的商品图;
+2. 图片中存在多个商品,无法判断哪个是目标商品;
+3. 多张参考图明显属于不同 SKU;
+4. 用户要求的创意会改变商品本身,且无法判断是否允许。
+
+不要为了确认平台、市场、品类、风格、镜头、道具、图位顺序或 Prompt 而提问。
+
+## 输出纪律
+
+普通模式只输出七张结果和完成状态。内部分析、计划和 Prompt 写入运行记录,不作为用户操作步骤。
+
+只通过 `fmode_image_set_run` 启动、执行和恢复。同一用户请求重复启动时,程序必须返回原 `run_id`。视觉适配器或计划执行器返回 `blocked` 时,原样返回其 `error`、`retryable` 和 `next_action` 并停止。执行结果为 `partial` 且存在 `resumable_slots` 时,只恢复同一运行;不存在可恢复图位时直接交付部分结果。禁止创建新运行、改写结果、扫描旧运行、跨目录复制图片或让用户选择绕过方式。相同 `vision_result` 的重复命令由程序直接返回已有结果,不产生新的外部调用。

+ 62 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/jimeng-v4.md

@@ -0,0 +1,62 @@
+# 即梦4.0执行适配器
+
+本 Skill 只使用即梦图片生成4.0接口,不使用即梦4.6样图或4.6流程。
+
+## 接口
+
+- 提交:`POST https://server.fmode.cn/api/volcengine/jimeng/getImgV4`
+- 轮询:`POST https://server.fmode.cn/api/volcengine/jimeng/getDataByTask02`
+- `routerName`: `getImgV4`
+- 结果:`GET https://server.fmode.cn/parse/classes/ImagineWork/{workId}`
+- 结果头:`X-Parse-Application-Id: ncloudmaster`
+- 参考成本:0.22元/张,七张一次成功约1.54元。
+
+## 输入限制
+
+- `prompt` 必填;
+- `image_urls` 支持0–10张公网URL;商品套图必须至少有一个真实商品参考URL;
+- 默认 `sizeDate={width:2048,height:2048}`;
+- 默认 `scale=0.6`、`force_single=true`;
+- 单条Prompt不超过800字符;
+- 本地文件路径不能直接传给远端接口;适配器会先自动上传七牛,再把所得公网URL传给即梦。
+
+## 凭证
+
+优先读取环境变量 `JIMENG_TOKEN`,也可读取用户目录下 `.openclaw/voc-credentials.json`。绝不把Token写入计划、日志、运行结果或HTML。
+
+不要复制接口文档中的 `currentToken` 作为Skill默认值。
+
+七牛配置优先读取环境变量 `QINIU_ACCESS_KEY`、`QINIU_SECRET_KEY`、`QINIU_BUCKET`、`QINIU_CDN_DOMAIN`、`QINIU_UPLOAD_URL`,也可读取用户目录下 `.openclaw/voc-credentials.json` 的 `qiniu` 对象。密钥只存在用户级配置中,不得写入 Skill、计划、示例、日志或结果文件。
+
+## 执行
+
+执行前必须先运行 `product-vision-client.mjs`,并使用其 `status=complete` 的结果生成 Prompt Plan。该步骤由 Skill 内部自动完成,当前对话模型即使是纯文本 DeepSeek 也不需要直接读取图片。
+
+先调用统一 MCP 入口 `fmode_image_set_run`,并使用 `action=start`。它按 Claude 当前
+`session_id + prompt_id`为本次用户请求分配唯一运行,并把所有产物固定在:
+
+`生成结果/电商七图/<run_id>/`
+
+`run_id` 的日期和时间使用 Asia/Shanghai。目录内只使用 `商品附件/`、`成品图/`、`vision-result.json`、`prompt-plan.json`、`reference-assets.json` 和 `jimeng-result.json`。中文只用于本地交付目录和图片文件名;API字段、远程URL、run_id和状态字段继续使用英文。执行器拒绝 Skill 源码目录、临时目录或其他自定义位置。
+
+底层 `jimeng-v4-client.mjs` 的 dry-run 与素材准备模式仅供执行器测试使用,Claude不得直接调用。统一入口会在实际执行前完成计划校验、素材去重、七牛上传和费用检查;上传失败时不会调用即梦。
+
+只有用户明确要求实际生图、即梦凭证存在且费用上限足够时才执行:
+
+```powershell
+node "<插件目录>/mcp/src/tools/image-set-run.mjs" execute --run-id <run_id> --max-cost 1.54
+```
+
+正常路径严格串行:先用不计费的结果GET请求预热同域名连接,再提交当前图位并立即持久化 `workId`,等待该图位完成、下载本地并通过商品身份复核后,才提交下一图位,禁止先连续提交七张再统一查询。预热失败时不得POST,该图位保留为可恢复的 `transport_unavailable`。单个图位在程序内重试和等待耗尽后会被隔离,执行器继续处理后续图位。
+
+明确收到响应的 HTTP 429/5xx 提交错误由执行器有限重试;没有收到任何响应的提交错误记为 `submission_unknown`,因为现有服务没有客户端幂等ID和按客户端ID查询能力,后台可能已经受理,禁止再次POST。执行器安全冷却后跳过该图位并继续后续图位。查询网络错误会在该图位的总等待时间内持续退避重试;结果已生成但下载失败时保留图片URL;超时或失败不会清空 `workId`。
+
+生成图会再次与原始参考图进行视觉身份复核。新增或删除结构、明显改变轮廓/颜色/材质观感、标签变化或多格图内商品互不一致时记为 `quality_rejected`:保留本地文件供调试,但不作为合格结果交付,也不自动补图。
+
+未完成七张时返回 `partial`,同时给出 `failed_slots`、`unknown_slots`、`rejected_slots`、`resumable_slots`、费用区间和通过复核的本地图片。再次使用完全相同的计划、视觉结果与状态文件可恢复确定失败或已有 `workId` 的图位;`submission_unknown` 在任何恢复中都不会重提,`quality_rejected` 不会自动重生。
+
+`jimeng-result.json` 带 HMAC 完整性签名,并记录计划与视觉结果哈希。手工编辑状态、替换计划或替换视觉结果会被执行器拒绝,避免重复扣费和不可追溯重提。
+
+成功结果会自动下载到本轮固定的 `成品图/` 目录,并以 `01-白底主图.png`、`02-卖点图.png` 等名称写入 `local_images`。任务中断后使用 `fmode_image_set_run` 的 `action=resume`;它只恢复状态账本允许的图位。
+
+执行器同时返回使用真实槽位号标记的 `image_markdown_links` 和可直接运行的 `open_folder_command`。Claude必须原样使用这些字段交付结果,不得按成功顺序重新编号,也不得自行拼接反斜杠Markdown文件夹链接。

+ 115 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/plan-schema.md

@@ -0,0 +1,115 @@
+# 内部 Prompt Plan 结构
+
+计划仅供 Skill、调试和下游模型调用,不是普通用户界面。
+
+```json
+{
+  "schema_version": "0.6",
+  "run_id": "string",
+  "status": "ready|needs_input",
+  "user_output_mode": "images_only",
+  "request": {
+    "instruction": "string",
+    "reference_images": ["path-or-url"]
+  },
+  "context": {
+    "platform": "general_ecommerce|string",
+    "market": "unspecified|string",
+    "voc": {"status": "unavailable|available", "findings": []}
+  },
+  "product_understanding": {
+    "category": "string",
+    "product_name": {"value":"string","basis":"string","confidence":"high|medium|low|unknown"},
+    "visible_facts": [],
+    "verified_visible_facts": [],
+    "inferred_attributes": [],
+    "unknown_features": [],
+    "identity_manifest": {
+      "must_preserve": ["至少3项商品身份特征"],
+      "must_not_add": ["参考图不存在且不得增加的结构"],
+      "unknown_do_not_assert": ["不能确定且不得写入正向Prompt的属性"],
+      "product_lock": "由程序确定性编译的20—160字符商品身份锁"
+    },
+    "provided_facts": [],
+    "candidate_selling_points": [
+      {"id":"visible-sp-1","value":"string","basis_refs":["visible_fact:1"],"confidence":"high|medium|low|unknown"}
+    ],
+    "visible_specs": {},
+    "creative_positioning": [],
+    "suggested_audiences": [],
+    "suggested_scenes": [],
+    "unsupported_claims": [],
+    "unknowns": []
+  },
+  "global_consistency_lock": {
+    "product_invariants": [],
+    "visual_system": [],
+    "prohibited_changes": []
+  },
+  "set_blueprint": {
+    "business_allocation": {"hero":1,"scene":2,"selling_point":2,"decision_support":2},
+    "visual_thesis": "整套图共同表达的视觉命题",
+    "narrative_arc": ["识别", "理解价值", "代入", "决策"],
+    "shared_visual_dna": ["只描述跨图必须统一的内容"],
+    "intentional_variations": ["空间", "人物", "信息任务", "景别", "画面关系"]
+  },
+  "images": [
+    {
+      "id": "01",
+      "role": "hero",
+      "business_category": "hero|scene|selling_point|decision_support",
+      "decision_subject": "本图唯一购买问题",
+      "evidence_refs": ["visible_fact:1"],
+      "selling_point_id": "",
+      "purpose": "string",
+      "voc_refs": [],
+      "set_design": {
+        "render_family": "isolated_product|environmental_scene|human_interaction|usage_demonstration|material_demonstration|feature_demonstration|annotated_detail|multi_angle|usage_sequence|package_contents|scale_context|comparison_sequence|structured_information",
+        "scene_complexity": "simple|medium|complex",
+        "unique_visual_hook": "本图不可被其他图位替代的可见设计",
+        "contrast_with_previous": "与上一张的变化;01写opening"
+      },
+      "shot_spec": {
+        "narrative_job": "本图唯一销售任务",
+        "composition_family": "构图家族",
+        "shot_scale": "景别",
+        "camera_angle": "机位",
+        "camera_axis": "front|three_quarter_left|three_quarter_right|side|top|low|macro",
+        "framing_mode": "full_product|product_in_context|detail|multi_panel|human_action",
+        "product_pose": "upright|laid_flat|held|in_use|multi_view|detail_crop|arranged_sequence",
+        "product_placement": "商品位置",
+        "product_occupancy_pct": 60,
+        "depth_structure": "前中后景关系",
+        "background_family": "背景结构",
+        "lighting_family": "布光家族",
+        "interaction_mode": "商品与环境/人物/信息的关系",
+        "information_density": "low|medium|high"
+      },
+      "prompt_modules": {
+        "product_lock": "必须逐字等于product_understanding.identity_manifest.product_lock",
+        "image_job": "本图唯一任务",
+        "scene": "空间、人物、道具及相互关系",
+        "composition": "机位、景别、位置、占比、裁切和留白",
+        "lighting_material": "光源及材料响应",
+        "camera_style": "镜头、景深和真实度",
+        "text_layout": "",
+        "negative_constraints": "本图专属禁止项"
+      },
+      "generation_prompt": "string",
+      "negative_prompt": "string",
+      "overlay_copy": [],
+      "reference_images": [],
+      "model_parameters": {
+        "adapter": "jimeng_img_v4",
+        "width": 2048,
+        "height": 2048,
+        "scale": 0.6,
+        "force_single": true
+      },
+      "acceptance_checks": []
+    }
+  ]
+}
+```
+
+`status=ready`时必须恰好七项,01为白底图,业务分类严格为1+2+2+2。卖点图必须引用不同的 `candidate_selling_points.id`。整套最多一张 `detail`、一张 `multi_panel`;非多格槽位不得使用分格语义。计划中的 `identity_manifest` 与 `verified_visible_facts` 必须和视觉审计结果逐字一致。当前执行链没有确定性文字合成器,因此 `overlay_copy`保持空数组,不得选择依赖长文字的假信息图。`needs_input`只用于无法识别目标商品。

+ 42 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/product-understanding.md

@@ -0,0 +1,42 @@
+# 通用商品理解
+
+商品理解必须先于七图规划,并且适用于任意品类。视觉适配器固定执行初次提取与同图事实审计两步,不把审美联想升级为商品事实。卖点ID和事实引用属于执行器内部字段,始终由程序重新编号、过滤和补齐;证据引用按卖点与可见事实的文本相似度匹配,不按数组位置盲目绑定。商品身份特征执行近似语义去重,避免同一瓶身、泵头或包装结构以不同措辞重复进入商品锁。外部视觉模型只负责输出商品语义,不得因为模型少写卖点、重复ID或引用格式波动而阻断整条流程。只有无法取得任何商品可见事实时,才判定视觉理解不可用。
+
+## 输出层级
+
+1. `product_name`:优先使用图片中可辨认的品牌、系列、型号或品类;无法确认时使用不带品牌的品类名称。
+2. `visible_facts`:外形、数量、颜色、材质观感、结构、包装、标签位置和可见文字。
+3. `visible_selling_points`:把可见事实转写成2–4条消费者能理解的视觉购买理由。最终结果中的每条都由程序附加一个或多个 `visible_fact:N`;视觉模型遗漏内部编号时由程序修复,只描述图片能够证明的价值。
+4. `visible_specs`:仅记录图片可见的规格属性;容量、尺寸、成分、性能、兼容性等无法确认时不得补写。
+5. `creative_positioning`:由颜色、形态和材质得到的视觉方向,不是商品事实。
+6. `suggested_audiences`、`suggested_scenes`:用于套图创意的假设,不得写成“适合”“专为”等产品结论。
+7. `unsupported_claims`:图片无法证明、禁止进入Prompt和画面文案的功能、体验或合规结论。
+8. `verified_visible_facts`:二次审计后确认的可见事实;计划必须逐字复制。
+9. `inferred_attributes`、`unknown_features`:可推测项与不可确认项;二者都不能冒充商品事实。
+10. `identity_manifest`:包含 `must_preserve`、`must_not_add`、`unknown_do_not_assert` 和程序生成的 `product_lock`,用于锁定跨图商品身份。
+
+## 可见卖点写法
+
+可见卖点必须同时包含“特征”和“可见价值”,例如:
+
+- “折叠结构收起后占用空间较小”只有在折叠状态或结构确实可见时才能使用;
+- “大面积网布形成轻透、通风的视觉感受”可以作为视觉表达,但不能改写成经过测试的透气性能;
+- “分区口袋让收纳关系一目了然”只有口袋及分区真实可见时才能使用。
+
+禁止只写抽象形容词,也禁止从图片推断手感、气味、口感、耐用性、防护能力、功效、认证、真实尺寸、容量、成分或性能。
+
+## 证据引用
+
+- 可见事实:`visible_fact:1`
+- 用户明确事实:`provided_fact:1`
+- 用户原始想法:`user_instruction`
+- VOC:`voc:1`
+- 视觉定位:`creative_positioning:1`
+- 建议人群:`suggested_audience:1`
+- 建议场景:`suggested_scene:1`
+- 可见规格:`visible_spec:<key>`
+- 候选卖点:使用其稳定 `id`
+
+七图计划中的卖点图必须引用不同候选卖点。场景建议只负责创意方向,不能成为商品功能或适用性声明的证据。
+
+计划不得改写视觉审计生成的 `identity_manifest` 和 `verified_visible_facts`。例如参考图没有可见纽扣,就不能在后续计划中用“单排扣”补齐服装术语;正常敞开的衣襟也不能被改写成“前中开衩”。

+ 121 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/prompt-compiler.md

@@ -0,0 +1,121 @@
+# 逐图 Prompt 编译器
+
+## 原则
+
+Prompt 长度由图位复杂度决定。只加入会改变生成结果的控制信息,不以字数为目标。
+
+先分别写入 `prompt_modules`,再按模块顺序编译 `generation_prompt`。禁止先写一条约 200 字的固定模板,再把内容拆回模块。
+
+## 可选模块
+
+按顺序选择需要的模块:
+
+1. `product_lock`:商品结构、颜色、Logo、标签和包装不可改变项;
+2. `image_job`:本图唯一任务;
+3. `scene`:空间、人物、道具和相互关系;
+4. `composition`:画幅、机位、景别、位置、占比、裁切和留白;
+5. `lighting_material`:光源、方向、软硬、色温、反射、折射、纹理;
+6. `camera_style`:镜头、景深、商业摄影类型和真实度;
+7. `text_layout`:必须出现的文字、位置、层级和安全区;
+8. `negative_constraints`:变形、错误文字、额外物件、水印和不需要的风格。
+
+## 复杂度路由
+
+### 简单图
+
+适用:白底主图、单一角度商品图。
+
+使用商品锁、背景、构图、光线和禁止项即可。通常无需人物、道具、情绪和镜头故事。
+
+指导区间:完整 Prompt 通常 100–320 字符。`scene` 可以很短,但必须明确背景与商品的关系。
+
+### 中等图
+
+适用:材质特写、结构解析、规格、用法和简洁信息图。
+
+补充局部范围、版式、引导线、文字层级、材质表现和安全区。
+
+指导区间:完整 Prompt 通常 220–560 字符。`scene` 至少写清局部范围、其他元素及其关系。
+
+### 复杂图
+
+适用:生活场景、人物、目标人群、氛围广告和多物件平铺。
+
+完整描述场景、人物、道具、空间关系、商品占比、光线、镜头和情绪,但仍只服务一个核心任务。
+
+指导区间:完整 Prompt 通常 360–780 字符。`scene` 必须足以锁定前中后景、人物或道具关系和动作状态;不能因为其他图较短而主动压缩到相同篇幅。
+
+区间用于发现明显欠描述,不要求每条接近上限。复杂度不同的七条 Prompt 不应自然收敛到几乎相同的总长度。
+
+## 商品一致性锁
+
+七条 Prompt 的高权重位置保留必要商品锁,但商品锁只负责身份一致,不承担画面创意。`product_lock` 由视觉适配器根据 `identity_manifest` 确定性生成,七张必须逐字复制,Claude不得自由改写。只锁定来源可靠的信息,不编造背面、接口、尺寸、成分或功效。
+
+`product_lock` 控制在 20–160 字符。复杂图中,场景正文应明显长于商品锁;不得用反复描述商品占满复杂场景的内容预算。`unknown_features` 和 `identity_manifest.unknown_do_not_assert` 中的内容不得进入正向模块。
+
+推荐句式:
+
+```text
+严格保留参考商品的{外形、比例、结构、颜色、Logo、标签和包装细节},不得重新设计商品,不改变文字,不增加或删除结构。
+```
+
+## 用户想法和 VOC
+
+- 用户想法是创意意图,可直接改变相关图位的场景、情绪、道具和人群;
+- VOC 是内部决策依据,只在 `available` 且有具体 finding 时使用;
+- VOC 关注点可转译为视觉场景,不能自动写成商品功效;
+- 无证据的“持久、抗菌、安全、临床、第一、销量”等不得进入 Prompt 或画面文字。
+- 用户给出一个目标人群或场合时,不自动扩展到新的场合;场景道具可以创作,商品适用性结论不能创作。
+- “清冷、轻盈、坚固感、科技感”等用户创意词只描述色彩、道具、空间和情绪,除非资料确认,否则不得改写为真实气味、成分、性能、耐用性或功效结论。
+- `unknowns` 中的包装、配件、容量、结构和文字不得被Prompt补入画面。
+- “细节/工艺图”不等于允许展示未拍到的内部结构;只放大真实可见区域,若需要开盖、背面或内部必须有对应参考图。
+
+## 文字
+
+商品标签必须要求逐字保持。当前Demo不生成额外营销文字,也不依赖生图模型完成信息排版;`overlay_copy`保持空数组。
+
+## 即梦约束
+
+- 最终发送给即梦的单条 Prompt 不超过 800 字符;
+- 不因接口文档的简短建议把复杂场景强行压缩到 120 字;
+- 如果完整 Prompt 超限,先删除重复形容词和低影响修饰,再压缩场景细节;商品锁和构图约束最后删除;
+- 负面词需要合并进最终请求时,保持简短并放在末尾。
+
+## 差异化编译
+
+先读取每张图的 `shot_spec`,再编译场景正文。复杂图至少写清以下关系:
+
+- 商品相对画面边界、前中后景和其他元素的位置;
+- 镜头高度、角度、景别、商品占比、裁切和留白;
+- 主光源的方向、面积、软硬、色温,以及玻璃、金属、织物等材料如何响应;
+- 本图与前后图不同的动作、空间或信息任务。
+
+复杂图还必须明确:
+
+- 前景、中景、后景分别出现什么,哪些区域必须留空;
+- 人物存在时的身份线索、服装、姿态、视线、手部动作及其与商品的距离;
+- 道具的数量、位置、遮挡关系和主次,禁止只列道具名;
+- 环境光与主光的来源、方向、软硬、色温,以及商品和环境材料分别如何响应;
+- 画面最终落点:消费者先看到什么,再看到什么。
+
+当前Demo没有确定性文字合成器,不选择 `structured_information`,也不写“商品加右侧留白”来冒充信息图。购买决策图优先选择多角度、步骤、包装内容、尺度关系或比较序列;没有可靠证据时使用多角度外观或可见结构顺序,不编造数据。
+
+整套最多一张细节特写和一张多格图。其余卖点图、场景图与购买决策图必须使用连续空间、人物动作、完整商品关系或其他不同视觉语法,不能再用近景局部或多面板换皮。
+
+避免只写“高级、奢华、克制、极简、商业摄影”等抽象形容词。每个抽象词必须由可见设计决定来实现,例如非对称留白、低机位、硬边轮廓光、长阴影或材质对比。
+
+白底图可以短;复杂场景应写到足以锁定空间关系,不以统一字数模板截断。统一视觉不等于统一台面、统一居中构图或统一柔光。
+
+商品标签不得与新增文字冲突。需要保留商品标签时,负面词写“额外文字、额外品牌标识”,不要笼统写“文字、Logo”。营销文案和信息排版留待确定性合成器实现。
+
+## 自检问题
+
+- 删除某一句是否会让模型失去重要控制?若不会,删除;
+- 本图是否只有一个主要任务?
+- 商品占比、位置和裁切是否明确?
+- 场景中每个道具是否服务主题?
+- 光线是否符合材质物理特性?
+- 是否把用户愿望或 VOC 当成商品事实?
+- 是否与另外六张图明显重复?
+- 本图删除场景名词后,是否仍能与另一张互换?若能,重写构图和镜头任务;
+- 七张是否形成远近、明暗、动静、疏密变化,而不只是背景换色?

+ 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 });
 }

Неке датотеке нису приказане због велике количине промена