Explorar o código

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

# Conflicts:
#	package.json
#	scripts/agent-workspace-ui-smoke-test.js
gangvy hai 1 mes
pai
achega
021b60931b
Modificáronse 46 ficheiros con 2945 adicións e 496 borrados
  1. 3 0
      .env.example
  2. 3 1
      README.md
  3. 19 0
      docs/AGENT-HOUSE-MIGRATION.md
  4. 7 0
      docs/OUTPUT-STANDARD.md
  5. 4 0
      mcp/src/core/agent-runtime.js
  6. 17 6
      mcp/src/core/agent-workbench-db.js
  7. 23 3
      mcp/src/core/agent-workbench-service.js
  8. 36 1
      mcp/src/core/credentials.js
  9. 5 3
      mcp/src/core/customer-broker-store.js
  10. 8 6
      mcp/src/core/dashboard-state.js
  11. 6 1
      mcp/src/core/group-operations-store.js
  12. 11 5
      mcp/src/core/message-archive.js
  13. 141 0
      mcp/src/core/message-normalizer.js
  14. 13 1
      mcp/src/core/output-paths.js
  15. 57 7
      mcp/src/core/voice-clone-service.js
  16. 8 11
      mcp/src/core/webhook-processor.js
  17. 22 2
      mcp/src/core/webhook-types.js
  18. 543 16
      mcp/src/dashboard/agent-service.js
  19. 766 153
      mcp/src/dashboard/app.js
  20. 6 3
      mcp/src/dashboard/customer-master-service.js
  21. 3 3
      mcp/src/dashboard/group-agent-service.js
  22. 28 21
      mcp/src/dashboard/index.html
  23. 144 16
      mcp/src/dashboard/server.js
  24. 613 86
      mcp/src/dashboard/styles.css
  25. 15 2
      mcp/src/providers/fmode-agent-transport.js
  26. 6 2
      mcp/src/providers/fmode-wecom-gateway.js
  27. 4 3
      mcp/src/tools/qiwei-broker-playbook-run.js
  28. 2 1
      mcp/src/tools/qiwei-customer-ops-run.js
  29. 2 1
      mcp/src/tools/qiwei-customer-transfer-run.js
  30. 9 6
      mcp/src/tools/qiwei-group-management-run.js
  31. 2 1
      mcp/src/tools/qiwei-group-operations-run.js
  32. 24 4
      mcp/src/tools/qiwei-login-run.js
  33. 6 5
      mcp/src/tools/qiwei-portrait-tags-run.js
  34. 0 0
      package.json
  35. 63 0
      scripts/account-isolation-smoke-test.js
  36. 4 1
      scripts/account-switch-smoke-test.js
  37. 27 2
      scripts/agent-console-smoke-test.js
  38. 24 110
      scripts/agent-workspace-ui-smoke-test.js
  39. 5 3
      scripts/message-archive-smoke-test.js
  40. 107 0
      scripts/message-types-smoke-test.js
  41. 90 0
      scripts/migrate-legacy-account.js
  42. 6 1
      scripts/package-smoke-test.js
  43. 15 9
      scripts/smoke-test.js
  44. 4 0
      scripts/validate-output-standard.js
  45. 15 0
      scripts/validate-reusable-boundary.js
  46. 29 0
      scripts/workspace-regression-smoke-test.js

+ 3 - 0
.env.example

@@ -125,3 +125,6 @@ QIWEI_AGENT_MAIN_SESSION_ID=
 
 # Optional ESM runtime config path. The installer creates qiwei.runtime.config.mjs in the customer project.
 QIWEI_RUNTIME_CONFIG=
+
+# Existing pre-isolation data is assigned to this account space during migration.
+QIWEI_LEGACY_ACCOUNT_KEY=legacy-default

+ 3 - 1
README.md

@@ -83,7 +83,7 @@ Claude Code / MCP
 
 所有生成文件遵循 [docs/OUTPUT-STANDARD.md](docs/OUTPUT-STANDARD.md):
 
-- 运行期产物统一写入 `outputs/<类别>/`(`login`、`api-calls`、`subscription`、`meetings`、`docs`、`messages`、`groups`、`portraits`、`tags`、`broker-playbooks`、`transfers`、`voice`、`webhook`、`smoke`、`tmp`),根目录可用 `QIWEI_OUTPUTS_DIR` 覆盖;
+- 运行期产物统一写入 `outputs/<类别>/`(包括消息、客户、群、画像、Dashboard、Runtime、语音、Webhook 等),根目录可用 `QIWEI_OUTPUTS_DIR` 覆盖;
 - 覆盖型文件用 latest 模式(如 `outputs/login/qiwei-login-qrcode.png`),按次归档用 run 模式(`outputs/<类别>/<YYYY-MM-DD>/<HHmmss>-<slug>/` + `manifest.json`);
 - 路径一律通过 `mcp/src/core/output-paths.js` 解析;
 - 校验:`npm run outputs:validate`。
@@ -254,6 +254,8 @@ npm run wecom:status
 - 也支持 `QIWEI_AUTH_TOKEN`、`FMODE_API_KEY`、`FMODE_API_TOKEN`、`NEWAPI_TOKEN` 或平台 sessionToken。
 - `QIWEI_API_BASE` 默认 `https://server.fmode.cn/api/qiwei`。
 - `QIWEI_UID` 是客户端设备别名;未配置时会生成随机稳定 uid,并写入 `.env.local` 和 `~/.claude/qiwei-credentials.json`。
+- 登录成功后会保存企微 `userId` 的本地标识,并使用其不可逆摘要隔离客户、画像、标签、群、消息归档、Dashboard 状态和工作台数据库;昵称、企业名和原始 `userId` 不进入目录名。
+- 升级前已有的未分区数据默认归入 `legacy-default`。先运行 `npm run migrate:legacy-account` 查看 dry-run 计划,确认后再运行 `npm run migrate:legacy-account -- --execute`;可通过 `QIWEI_LEGACY_ACCOUNT_KEY` 指定兼容分区名。
 - 企业微信接口访问凭据和设备上下文由 Fmode 网关管理,不会出现在技能配置或返回结果中。
 
 ## 快速启动与预览

+ 19 - 0
docs/AGENT-HOUSE-MIGRATION.md

@@ -0,0 +1,19 @@
+# agent-house 可复用能力迁移边界
+
+本仓库只承载可复用的企业微信技能、MCP 工具、工作台页面与服务、Webhook/Relay 监听运行时、安装与发布流程。
+
+## 已迁移
+
+- 通用响应质量门契约:`mcp/src/core/response-quality-contract.js`
+- 工作区回归 Smoke 入口:`scripts/workspace-regression-smoke-test.js`
+- 现有 `mcp/`、`skills/`、`runtime/`、`install.js`、Dashboard 与发布检查能力
+
+## 明确排除
+
+- 客户业务定制、房产/房源匹配、真实客户画像与业务知识
+- 真实凭据、`.env.local`、token、设备信息
+- 运行数据库、会话、消息、日志及任何 `outputs/` 运行产物
+
+## 发布前检查
+
+运行 `npm run boundary:check`。该检查会扫描发布工作区的路径与文本,发现运行态或业务定制内容时失败。运行态目录只允许在本地生成,并由 `.gitignore` 排除。

+ 7 - 0
docs/OUTPUT-STANDARD.md

@@ -43,6 +43,8 @@ claude-code-qiwei-assistant/
 | `devices/` | 设备 guid → wecomUserId / brokerId 映射 |
 | `smoke/` | 冒烟测试产物 |
 | `tmp/` | 临时文件,可随时清理 |
+| `dashboard/` | Dashboard 本地状态与账号级投影 |
+| `runtime/` | Callback Runtime 等本地守护进程状态 |
 
 ### 2.1 两种落盘模式
 
@@ -65,6 +67,10 @@ claude-code-qiwei-assistant/
    路径:`outputs/<类别>/<已注册库名>/`。库名必须在 `OUTPUT_PERSISTENT_STORES` 中注册;当前仅允许 `outputs/knowledge/meetings/`。
    此模式可包含索引、按业务键组织的记录目录和说明文件,不按单次 run 生成 manifest。
 
+4. **account-scoped 模式**(账号隔离):适用于客户、画像、标签、群、消息归档和 Dashboard 状态。
+   路径:`outputs/<类别>/<account-key>/...`。`account-key` 只能是企微 `userId` 的 16 位十六进制摘要,或迁移兼容用的 `legacy-<slug>`;原始 `userId`、昵称和企业名不得进入目录名。
+   允许类别由 `OUTPUT_ACCOUNT_SCOPED_CATEGORIES` 注册。账号目录内保存该账号的 latest/persistent 数据,不要求每个子目录生成 run manifest。
+
 ### 2.2 命名规则
 
 - 目录与文件名一律小写 kebab-case;slug 由 `slugify()` 生成,最长 60 字符。
@@ -88,6 +94,7 @@ createRunDir(category, slug, date?) // run 模式目录
 writeRunManifest(runDir, manifest)  // 写 manifest.json
 slugify(value) / dateStamp() / timeStamp()
 OUTPUT_CATEGORIES                   // 允许的类别列表
+OUTPUT_ACCOUNT_SCOPED_CATEGORIES    // 允许账号隔离目录的类别
 OUTPUT_PERSISTENT_STORES            // 允许的持续知识库目录
 ```
 

+ 4 - 0
mcp/src/core/agent-runtime.js

@@ -1158,6 +1158,10 @@ class QiweiAgentRuntime {
     }
     throw new Error('Agent 工具调用轮次超过上限,已转人工处理');
   }
+
+  preCreateSession(key, metadata = {}) {
+    if (this.modelClient?.sessionStore) this.modelClient.sessionStore.ensure(key, metadata);
+  }
 }
 
 module.exports = {

+ 17 - 6
mcp/src/core/agent-workbench-db.js

@@ -89,6 +89,8 @@ class AgentWorkbenchDb {
         direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')),
         sender_type TEXT NOT NULL CHECK(sender_type IN ('customer','agent','human','system')),
         content TEXT NOT NULL,
+        content_type TEXT NOT NULL DEFAULT 'text',
+        payload_json TEXT,
         status TEXT NOT NULL DEFAULT 'received',
         created_at TEXT NOT NULL,
         raw_json TEXT
@@ -296,6 +298,8 @@ class AgentWorkbenchDb {
       CREATE INDEX IF NOT EXISTS idx_applied_actions_status ON conversation_applied_actions(conversation_id, status, updated_at DESC);
     `);
     this.migrateConversationModes();
+    this.ensureColumn('messages', 'content_type', "content_type TEXT NOT NULL DEFAULT 'text'");
+    this.ensureColumn('messages', 'payload_json', 'payload_json TEXT');
     this.db.prepare(`UPDATE memory_extraction_jobs SET status='pending',next_attempt_at=?,updated_at=? WHERE status='processing'`)
       .run(now(), now());
     this.ensureColumn('customer_tasks', 'business_key', "business_key TEXT NOT NULL DEFAULT ''");
@@ -583,9 +587,10 @@ class AgentWorkbenchDb {
     }
     const conversationId = makeId('conv');
     const timestamp = now();
-    const defaultMode = CONVERSATION_MODES.includes(this.getSetting('default_mode', 'review'))
-      ? this.getSetting('default_mode', 'review')
-      : 'review';
+    const configuredMode = this.getSetting('default_mode', 'review');
+    const defaultMode = this.getSetting('global_paused', 'true') === 'true'
+      ? 'paused'
+      : CONVERSATION_MODES.includes(configuredMode) ? configuredMode : 'review';
     this.db.prepare(`INSERT INTO conversations(id,contact_id,contact_name,mode,created_at,updated_at)
       VALUES(?,?,?,?,?,?)`).run(conversationId, String(contactId), safeName, defaultMode, timestamp, timestamp);
     this.db.prepare('INSERT INTO customer_profiles(conversation_id,updated_at) VALUES(?,?)').run(conversationId, timestamp);
@@ -616,14 +621,20 @@ class AgentWorkbenchDb {
     return this.getConversation(conversationId);
   }
 
-  insertMessage({ conversationId, externalId = null, direction, senderType, content, status = 'received', createdAt = now(), raw = null }) {
+  setAllConversationModes(mode) {
+    if (!CONVERSATION_MODES.includes(mode)) throw new Error('Unsupported conversation mode');
+    const result = this.db.prepare('UPDATE conversations SET mode=?,updated_at=? WHERE mode<>?').run(mode, now(), mode);
+    return Number(result.changes || 0);
+  }
+
+  insertMessage({ conversationId, externalId = null, direction, senderType, content, contentType = 'text', payload = null, status = 'received', createdAt = now(), raw = null }) {
     if (externalId) {
       const existing = this.db.prepare('SELECT * FROM messages WHERE external_id=?').get(externalId);
       if (existing) return { message: existing, created: false };
     }
     const messageId = makeId('msg');
-    this.db.prepare(`INSERT INTO messages(id,conversation_id,external_id,direction,sender_type,content,status,created_at,raw_json)
-      VALUES(?,?,?,?,?,?,?,?,?)`).run(messageId, conversationId, externalId, direction, senderType, String(content), status, createdAt, raw ? json(raw) : null);
+    this.db.prepare(`INSERT INTO messages(id,conversation_id,external_id,direction,sender_type,content,content_type,payload_json,status,created_at,raw_json)
+      VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(messageId, conversationId, externalId, direction, senderType, String(content), String(contentType || 'text'), payload ? json(payload) : null, status, createdAt, raw ? json(raw) : null);
     this.db.prepare('UPDATE conversations SET last_message_at=?,updated_at=? WHERE id=?').run(createdAt, now(), conversationId);
     return { message: this.getMessage(messageId), created: true };
   }

+ 23 - 3
mcp/src/core/agent-workbench-service.js

@@ -1,5 +1,5 @@
 const { EventEmitter } = require('events');
-const { AgentNotConfiguredError, isNoReplyNeededMessage } = require('./agent-runtime');
+const { AgentNotConfiguredError, isNoReplyNeededMessage, buildClaudeSessionName } = require('./agent-runtime');
 const { isGroupMessage } = require('./agent-poller-policy');
 const { friendlyAgentError } = require('./agent-error-message');
 const { AgentMemoryManager } = require('./agent-memory');
@@ -202,9 +202,18 @@ class AgentWorkbenchService extends EventEmitter {
       if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error('自动发送置信度必须在 0 到 1 之间');
       this.db.setSetting('auto_send_confidence', String(value));
     }
-    this.db.audit({ actor, action: 'global_settings_updated', detail: this.db.globalState() });
+    const synchronizedMode = paused === true ? 'paused' : defaultMode;
+    const synchronizedConversations = synchronizedMode === undefined
+      ? 0
+      : this.db.setAllConversationModes(synchronizedMode);
+    const global = this.db.globalState();
+    this.db.audit({
+      actor,
+      action: 'global_settings_updated',
+      detail: { ...global, synchronizedMode: synchronizedMode || null, synchronizedConversations },
+    });
     this.emit('change', { type: 'global' });
-    return this.db.globalState();
+    return global;
   }
 
   setConversationMode(conversationId, mode, actor = 'human') {
@@ -1009,6 +1018,17 @@ class AgentWorkbenchService extends EventEmitter {
     this.emit('change', { type: 'message', conversationId });
     return message;
   }
+
+  preCreateSession(contactId, contactName = '') {
+    const conversation = this.db.ensureConversation(contactId, contactName);
+    ensureMemory(contactId, contactName);
+    const metadata = {
+      customerName: contactName,
+      displayName: buildClaudeSessionName({ conversation: { contact_name: contactName } }, conversation.id),
+    };
+    this.agent?.preCreateSession?.(conversation.id, metadata);
+    return { conversationId: conversation.id, sessionPreCreated: true };
+  }
 }
 
 module.exports = { AgentWorkbenchService };

+ 36 - 1
mcp/src/core/credentials.js

@@ -7,10 +7,17 @@ const DEFAULT_API_BASE = 'https://server.fmode.cn/api/qiwei';
 const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', 'qiwei-credentials.json');
 let activeQiweiContext = {};
 
+function safeAccountKey(value, fallback = 'legacy-default') {
+  return String(value || fallback).trim().replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100) || fallback;
+}
+
 function setActiveQiweiContext(input = {}) {
   activeQiweiContext = {
     uid: String(input.uid || input.qiweiUid || '').trim(),
     guid: String(input.guid || input.qiweiGuid || input.deviceGuid || '').trim(),
+    userId: String(input.userId || input.wecomUserId || '').trim(),
+    nickname: String(input.nickname || '').trim(),
+    corpName: String(input.corpName || '').trim(),
     apiBase: String(input.apiBase || input.baseUrl || '').trim().replace(/\/$/, ''),
   };
   return { ...activeQiweiContext };
@@ -288,7 +295,7 @@ function resolveQiweiEnvPath(envRoot) {
 }
 
 function saveQiweiClientConfig(input = {}) {
-  const { uid, apiBase, guid, authToken, envRoot } = input;
+  const { uid, apiBase, guid, authToken, envRoot, userId, nickname, corpName } = input;
   const hasGuid = Object.prototype.hasOwnProperty.call(input, 'guid');
   const saved = [];
   try {
@@ -302,6 +309,9 @@ function saveQiweiClientConfig(input = {}) {
       if (guid) next.guid = guid;
       else delete next.guid;
     }
+    for (const [key, value] of Object.entries({ userId, nickname, corpName })) {
+      if (Object.prototype.hasOwnProperty.call(input, key)) next[key] = String(value || '').trim();
+    }
     next.updatedAt = new Date().toISOString();
     fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(next, null, 2), 'utf8');
     saved.push(CREDENTIALS_FILE);
@@ -339,6 +349,28 @@ function saveQiweiClientConfig(input = {}) {
   return saved;
 }
 
+function readQiweiAccountMetadata(input = {}) {
+  const creds = readCredentialsFile();
+  const fileEnv = readEnvFiles();
+  return {
+    userId: firstNonEmpty([input.userId, input.wecomUserId, activeQiweiContext.userId, creds.userId]),
+    nickname: firstNonEmpty([input.nickname, activeQiweiContext.nickname, creds.nickname]),
+    corpName: firstNonEmpty([input.corpName, activeQiweiContext.corpName, creds.corpName]),
+    legacyAccountKey: safeAccountKey(firstNonEmpty([
+      input.legacyAccountKey,
+      process.env.QIWEI_LEGACY_ACCOUNT_KEY,
+      fileEnv.QIWEI_LEGACY_ACCOUNT_KEY,
+      'legacy-default'
+    ]))
+  };
+}
+
+function qiweiAccountKey(input = {}) {
+  const metadata = readQiweiAccountMetadata(input);
+  if (!metadata.userId) return metadata.legacyAccountKey;
+  return crypto.createHash('sha256').update(metadata.userId).digest('hex').slice(0, 16);
+}
+
 function readQiweiGuid(input = {}) {
   const fileEnv = readEnvFiles();
   const creds = readCredentialsFile();
@@ -425,6 +457,9 @@ module.exports = {
   readQiweiApiBase,
   resolveQiweiEnvPath,
   saveQiweiClientConfig,
+  readQiweiAccountMetadata,
+  qiweiAccountKey,
+  safeAccountKey,
   setActiveQiweiContext,
   getActiveQiweiContext,
   isConfigured,

+ 5 - 3
mcp/src/core/customer-broker-store.js

@@ -1,9 +1,10 @@
 const fs = require('fs');
 const path = require('path');
 const { categoryDir } = require('./output-paths');
+const { qiweiAccountKey } = require('./credentials');
 
-function customersDir() {
-  return categoryDir('customers');
+function customersDir(account = {}) {
+  return path.join(categoryDir('customers'), qiweiAccountKey(account));
 }
 
 function brokersDir() {
@@ -230,5 +231,6 @@ module.exports = {
   customerFilePath,
   brokerFilePath,
   phoneIndexPath,
-  externalUserIdIndexPath
+  externalUserIdIndexPath,
+  customersDir,
 };

+ 8 - 6
mcp/src/core/dashboard-state.js

@@ -1,11 +1,12 @@
 const fs = require('fs');
 const path = require('path');
 const { categoryDir, latestPath } = require('./output-paths');
+const { qiweiAccountKey } = require('./credentials');
 
 const STATE_VERSION = '1';
 
 function dashboardDir() {
-  return categoryDir('dashboard');
+  return path.join(categoryDir('dashboard'), qiweiAccountKey());
 }
 
 function stateFilePath() {
@@ -555,7 +556,7 @@ function rebuildStateFromOutputs() {
     }
   };
 
-  const customersDir = path.join(outputsRoot(), 'customers');
+  const customersDir = path.join(outputsRoot(), 'customers', qiweiAccountKey());
   if (fs.existsSync(customersDir)) {
     for (const file of fs.readdirSync(customersDir)) {
       if (!file.endsWith('.json') || file.startsWith('index-')) continue;
@@ -579,7 +580,7 @@ function rebuildStateFromOutputs() {
     }
   }
 
-  const portraitsDir = path.join(outputsRoot(), 'portraits');
+  const portraitsDir = path.join(outputsRoot(), 'portraits', qiweiAccountKey());
   if (fs.existsSync(portraitsDir)) {
     for (const file of fs.readdirSync(portraitsDir)) {
       if (!file.endsWith('.json') || file.startsWith('context-')) continue;
@@ -605,7 +606,7 @@ function rebuildStateFromOutputs() {
     }
   }
 
-  const tagsDir = path.join(outputsRoot(), 'tags');
+  const tagsDir = path.join(outputsRoot(), 'tags', qiweiAccountKey());
   if (fs.existsSync(tagsDir)) {
     for (const file of fs.readdirSync(tagsDir)) {
       if (!file.endsWith('.json')) continue;
@@ -631,10 +632,11 @@ function rebuildStateFromOutputs() {
     state.tags.allTags.sort();
   }
 
-  const groupsLatestPath = latestPath('groups', 'rooms-latest.json');
+  const accountGroupsDir = path.join(outputsRoot(), 'groups', qiweiAccountKey());
+  const groupsLatestPath = path.join(accountGroupsDir, 'rooms-latest.json');
   const latestRooms = readJsonSafe(groupsLatestPath, []);
   if (Array.isArray(latestRooms) && latestRooms.length) {
-    const confirmedMapping = readJsonSafe(path.join(outputsRoot(), 'groups', 'confirmed-mapping.json'), {});
+    const confirmedMapping = readJsonSafe(path.join(accountGroupsDir, 'confirmed-mapping.json'), {});
     const roomsWithConfirmedStatus = latestRooms.map(room => {
       const mapped = confirmedMapping[room.roomId];
       if (!mapped) return room;

+ 6 - 1
mcp/src/core/group-operations-store.js

@@ -838,5 +838,10 @@ class GroupOperationsStore {
 
 let singleton;
 function getGroupOperationsStore() { if (!singleton) singleton = new GroupOperationsStore(); return singleton; }
+function closeGroupOperationsStore() {
+  if (!singleton) return;
+  singleton.close();
+  singleton = null;
+}
 
-module.exports = { GroupOperationsStore, getGroupOperationsStore, accountKey };
+module.exports = { GroupOperationsStore, getGroupOperationsStore, closeGroupOperationsStore, accountKey };

+ 11 - 5
mcp/src/core/message-archive.js

@@ -3,6 +3,7 @@
 const fs = require('fs');
 const path = require('path');
 const { WORKSPACE_ROOT } = require('./output-paths');
+const { qiweiAccountKey } = require('./credentials');
 
 const MESSAGES_ROOT = process.env.QIWEI_MESSAGES_DIR
   ? path.resolve(process.env.QIWEI_MESSAGES_DIR)
@@ -17,6 +18,10 @@ function safeDirName(raw) {
     .slice(0, 64) || '_default';
 }
 
+function accountDir(input = {}) {
+  return safeDirName(qiweiAccountKey(input));
+}
+
 function dateOf(iso) {
   const value = String(iso || '');
   return /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : new Date().toISOString().slice(0, 10);
@@ -64,7 +69,7 @@ function appendChatRecord({ wxid, messageId, externalId, dir, senderType, conten
     content: String(content ?? ''),
     source: source || '',
   };
-  const filePath = path.join(MESSAGES_ROOT, 'chat', safeDirName(wxid), `${dateOf(row.ts)}.json`);
+  const filePath = path.join(MESSAGES_ROOT, 'chat', accountDir(), safeDirName(wxid), `${dateOf(row.ts)}.json`);
   return enqueueAppend(filePath, JSON.stringify(row), options);
 }
 
@@ -81,7 +86,7 @@ function appendGroupRecord({ roomId, messageId, seq, msgType, senderId, senderNa
     msgType: String(msgType ?? ''),
     content: String(content ?? ''),
   };
-  const filePath = path.join(MESSAGES_ROOT, 'group', safeDirName(roomId), `${dateOf(row.ts)}.json`);
+  const filePath = path.join(MESSAGES_ROOT, 'group', accountDir(), safeDirName(roomId), `${dateOf(row.ts)}.json`);
   return enqueueAppend(filePath, JSON.stringify(row), options);
 }
 
@@ -160,7 +165,7 @@ function memoryTemplate(name, contactName = '') {
 
 function ensureMemory(wxid, contactName = '', options = {}) {
   if (!memoryEnabled(options)) return { enabled: false, dir: '', created: [] };
-  const dir = path.join(MESSAGES_ROOT, 'memory', safeDirName(wxid));
+  const dir = path.join(MESSAGES_ROOT, 'memory', accountDir(), safeDirName(wxid));
   fs.mkdirSync(dir, { recursive: true });
   const created = [];
   for (const name of MEMORY_FILES) {
@@ -175,7 +180,7 @@ function ensureMemory(wxid, contactName = '', options = {}) {
 function memoryIndexText(wxid, options = {}) {
   if (!memoryEnabled(options)) return '';
   try {
-    const filePath = path.join(MESSAGES_ROOT, 'memory', safeDirName(wxid), 'indexes.md');
+    const filePath = path.join(MESSAGES_ROOT, 'memory', accountDir(), safeDirName(wxid), 'indexes.md');
     return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
   } catch {
     return '';
@@ -183,13 +188,14 @@ function memoryIndexText(wxid, options = {}) {
 }
 
 function memoryRelBase(wxid) {
-  return path.posix.join('messages', 'memory', safeDirName(wxid));
+  return path.posix.join('messages', 'memory', accountDir(), safeDirName(wxid));
 }
 
 module.exports = {
   MESSAGES_ROOT,
   MEMORY_FILES,
   safeDirName,
+  accountDir,
   memoryEnabled,
   archiveEnabled,
   appendChatRecord,

+ 141 - 0
mcp/src/core/message-normalizer.js

@@ -0,0 +1,141 @@
+'use strict';
+
+const TYPE_LABELS = Object.freeze({
+  text: '文本', image: '图片', revoke: '撤回消息', agree: '同意会话存档', disagree: '不同意会话存档',
+  voice: '语音', video: '视频', card: '名片', location: '位置', emotion: '表情', file: '文件',
+  link: '链接', weapp: '小程序', miniprogram: '小程序', chatrecord: '会话记录', todo: '待办',
+  vote: '投票', collect: '填表', redpacket: '红包', transfer: '转账', meeting: '会议邀请',
+  meeting_notification: '会议控制', docmsg: '在线文档', markdown: 'Markdown', news: '图文消息',
+  calendar: '日程', mixed: '混合消息', meeting_voice_call: '音频存档', voip_doc_share: '音频共享文档',
+  external_redpacket: '互通红包', sphfeed: '视频号', voiptext: '音视频通话', qydiskfile: '微盘文件',
+  solitaire: '接龙', note: '笔记', notification: '通知', unknown: '未知消息',
+});
+
+const NUMERIC_TYPE_ALIASES = Object.freeze({
+  0: 'text', 1: 'text', 2: 'text', 3: 'image', 4: 'chatrecord', 6: 'location', 7: 'image',
+  13: 'link', 15: 'file', 16: 'voice', 20: 'file', 22: 'video', 26: 'redpacket', 34: 'voice',
+  41: 'card', 43: 'video', 47: 'emotion', 49: 'link', 78: 'weapp', 101: 'image', 102: 'file',
+  103: 'video', 104: 'emotion', 141: 'sphfeed', 588: 'transfer', 1011: 'revoke',
+  2001: 'notification', 2005: 'notification', 2063: 'revoke',
+});
+
+const AUTHORITATIVE_NUMERIC_TYPES = new Set([4, 16, 26, 101, 103, 104, 141, 588, 1011, 2063]);
+const TYPE_ALIASES = Object.freeze({
+  mini_program: 'weapp', miniapp: 'weapp', miniprogram: 'weapp', applet: 'weapp', picture: 'image',
+  img: 'image', document: 'docmsg', meeting_invite: 'meeting',
+});
+
+function firstString(values) {
+  for (const value of values) if (typeof value === 'string' && value.trim()) return value.trim();
+  return '';
+}
+
+function payloadOf(message = {}) {
+  const data = message.msgData && typeof message.msgData === 'object' ? message.msgData : {};
+  const nested = data.content && typeof data.content === 'object' ? data.content : {};
+  return { data, nested };
+}
+
+function normalizeTypeName(value) {
+  const normalized = String(value || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
+  return TYPE_ALIASES[normalized] || normalized;
+}
+
+function inferTypeFromPayload(message = {}) {
+  const { data, nested } = payloadOf(message);
+  const filename = firstString([data.filename, data.fileName, nested.filename, nested.fileName]);
+  const extension = filename.split('.').pop().toLowerCase();
+  if (firstString([data.transcript, data.voiceUrl, data.voice_url]) || data.voiceSize || data.voiceTime || data.playLength) return 'voice';
+  if (firstString([data.imageUrl, data.image_url, data.thumbUrl, data.thumb_url, data.fileBigHttpUrl, data.fileMiddleHttpUrl, data.fileThumbHttpUrl]) || data.imageWidth || data.imageHeight || data.imageHasHd) return 'image';
+  if (firstString([data.coverImageHttpUrl]) && firstString([data.fileHttpUrl])) return 'video';
+  if (firstString([data.appid, data.appId, data.pagepath, data.pagePath]) || data.weapp) return 'weapp';
+  if (firstString([data.meetingId, data.meeting_id, data.meetingUrl, data.meeting_url])) return 'meeting';
+  if (data.longitude !== undefined || data.latitude !== undefined || firstString([data.address, data.location])) return 'location';
+  if (Array.isArray(data.subMsgList) || firstString([data.subMsgDesc])) return 'chatrecord';
+  if (firstString([data.feedId, data.feedNo, data.channelUrl]) || firstString([data.channelName]) && firstString([data.coverUrl])) return 'sphfeed';
+  if (data.revokeMsgUniqueIdentifier || data.revokeTime || data.revokeUserId) return 'revoke';
+  if (firstString([data.linkUrl, data.link_url, data.url]) && firstString([data.title, data.description, data.desc])) return 'link';
+  if (filename || data.fileId || data.fileid || data.sdkfileid) {
+    if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(extension)) return 'video';
+    if (['amr', 'silk', 'mp3', 'wav', 'm4a', 'ogg'].includes(extension)) return 'voice';
+    if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(extension)) return 'image';
+    return 'file';
+  }
+  return '';
+}
+
+function detectMessageType(message = {}) {
+  const { data, nested } = payloadOf(message);
+  const numeric = Number(message.msgType);
+  const directMsgType = typeof message.msgType === 'string' && !/^\d+$/.test(message.msgType.trim()) ? message.msgType : '';
+  const declared = firstString([message.contentType, message.messageType, message.msgtype, directMsgType, data.type, data.msgtype, data.messageType, nested.type, nested.msgtype]);
+  if (declared) return normalizeTypeName(declared);
+  if (AUTHORITATIVE_NUMERIC_TYPES.has(numeric)) return NUMERIC_TYPE_ALIASES[numeric];
+  return inferTypeFromPayload(message) || NUMERIC_TYPE_ALIASES[numeric] || 'unknown';
+}
+
+function decodeBase64Text(value) {
+  if (typeof value !== 'string' || !value.trim()) return '';
+  try {
+    const decoded = Buffer.from(value, 'base64').toString('utf8').trim();
+    return decoded && !decoded.includes('\uFFFD') ? decoded : '';
+  } catch { return ''; }
+}
+
+function extractTransferWording(value) {
+  if (typeof value !== 'string' || !value.trim()) return '';
+  try {
+    const match = Buffer.from(value, 'base64').toString('utf8').match(/\[转账\]([\p{Script=Han}A-Za-z0-9,。!?、::._ -]+)/u);
+    return match ? match[1].trim() : '';
+  } catch { return ''; }
+}
+
+function textContent(message = {}) {
+  const { data, nested } = payloadOf(message);
+  return firstString([message.content, message.msgContent, data.content, data.text, data.value, nested.text, nested.content, data.transcript, message.transcript]);
+}
+
+function detailForType(type, message = {}) {
+  const { data, nested } = payloadOf(message);
+  const title = firstString([data.title, nested.title, data.name, nested.name, data.topic, data.subject, data.subMsgDesc, data.channelName]);
+  const filename = firstString([data.filename, data.fileName, nested.filename, nested.fileName]);
+  const url = firstString([data.url, data.linkUrl, data.link_url, data.pageUrl, data.page_url, data.meetingUrl, data.meeting_url]);
+  const description = firstString([data.description, data.desc, nested.description, nested.desc]);
+  if (type === 'voice') {
+    const duration = Number(data.voiceTime || data.duration || data.playLength || 0);
+    return firstString([data.transcript, message.transcript]) || (duration > 0 ? `${duration} 秒` : '');
+  }
+  if (['file', 'video', 'image'].includes(type)) return filename || title;
+  if (type === 'location') return title || firstString([data.address, data.location, data.label, data.poiName]);
+  if (['link', 'weapp', 'meeting', 'docmsg', 'calendar'].includes(type)) return [title || description, url].filter(Boolean).join(' · ');
+  if (type === 'card') return firstString([data.displayName, data.nickname, data.name, data.userid, data.userId]);
+  if (type === 'emotion') return firstString([data.sourceDesc, filename, title]);
+  if (type === 'chatrecord') {
+    const count = Array.isArray(data.subMsgList) ? data.subMsgList.length : Number(data.itemCount) || 0;
+    return [title, count > 0 ? `${count} 条` : ''].filter(Boolean).join(' · ');
+  }
+  if (type === 'sphfeed') return firstString([data.channelName, data.title, data.username]);
+  if (['redpacket', 'external_redpacket'].includes(type)) return firstString([data.wishingContent, data.lookWording, data.recvWording, title]);
+  if (type === 'transfer') return firstString([data.title, data.description, extractTransferWording(data.base64RawData)]);
+  if (type === 'revoke') return firstString([data.title, data.description, decodeBase64Text(data.base64RawData), data.revokeMsgUniqueIdentifier ? `消息 ${data.revokeMsgUniqueIdentifier}` : '']);
+  if (type === 'voiptext') {
+    const duration = Number(data.callduration || data.callDuration || 0);
+    return duration > 0 ? `${duration} 秒` : description;
+  }
+  return title || description || filename;
+}
+
+function normalizeMessage(message = {}) {
+  const type = detectMessageType(message);
+  const label = TYPE_LABELS[type] || TYPE_LABELS.unknown;
+  const text = textContent(message);
+  const detail = detailForType(type, message);
+  const isText = type === 'text' || type === 'markdown';
+  return {
+    type, label, text, detail, isText,
+    content: isText && text ? text : `[${label}]${detail ? ` ${detail}` : ''}`,
+    isNotification: type === 'notification',
+  };
+}
+
+module.exports = { TYPE_LABELS, NUMERIC_TYPE_ALIASES, AUTHORITATIVE_NUMERIC_TYPES, detectMessageType, normalizeMessage, textContent };

+ 13 - 1
mcp/src/core/output-paths.js

@@ -28,12 +28,23 @@ const OUTPUT_CATEGORIES = Object.freeze([
   'devices',
   'smoke',
   'tmp',
+  'dashboard',
+  'runtime'
+]);
+
+const OUTPUT_ACCOUNT_SCOPED_CATEGORIES = Object.freeze([
+  'customers',
+  'portraits',
+  'tags',
+  'groups',
+  'messages',
   'dashboard'
 ]);
 
 const OUTPUT_PERSISTENT_STORES = Object.freeze({
   knowledge: Object.freeze(['meetings', 'docs', 'todos', 'tasks']),
-  messages: Object.freeze(['rooms'])
+  messages: Object.freeze(['rooms']),
+  runtime: Object.freeze(['*'])
 });
 
 function outputsRoot() {
@@ -104,6 +115,7 @@ module.exports = {
   PACKAGE_ROOT,
   WORKSPACE_ROOT,
   OUTPUT_CATEGORIES,
+  OUTPUT_ACCOUNT_SCOPED_CATEGORIES,
   OUTPUT_PERSISTENT_STORES,
   outputsRoot,
   ensureDir,

+ 57 - 7
mcp/src/core/voice-clone-service.js

@@ -10,7 +10,7 @@ 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 { pickFmodeApiToken } = require('./credentials');
+const { pickFmodeApiToken, qiweiAccountKey } = require('./credentials');
 
 const TONE_PRESETS = Object.freeze({
   natural: Object.freeze({ id: 'natural', label: '自然', method: 0, alpha: null, text: '' }),
@@ -137,10 +137,35 @@ async function probeAudio(filePath) {
   };
 }
 
+function silkEncoderBinary() {
+  const platform = `${process.platform}-${process.arch}`;
+  const suffix = process.platform === 'win32' ? '.exe' : '';
+  try {
+    const packageDir = path.dirname(require.resolve(`@binsee/wx-voice-silk-${platform}/package.json`));
+    const binary = path.join(packageDir, `encoder${suffix}`);
+    if (fs.existsSync(binary)) return binary;
+  } catch {
+    // The bundled fallback is used when the optional platform package is absent.
+  }
+  return path.resolve(__dirname, '..', 'silk', `encoder${suffix}`);
+}
+
+async function encodeSilkDirect(wavPath, silkPath) {
+  const pcmPath = path.join(os.tmpdir(), `qiwei-voice-${process.pid}-${crypto.randomBytes(8).toString('hex')}.pcm`);
+  try {
+    await runBinary(Ffmpeg.path, ['-y', '-i', wavPath, '-vn', '-ac', '1', '-ar', '24000', '-c:a', 'pcm_s16le', '-f', 's16le', pcmPath]);
+    await runBinary(silkEncoderBinary(), [pcmPath, silkPath, '-tencent']);
+    const voice = new WxVoice();
+    const duration = await voice.duration(silkPath);
+    if (!duration) throw new Error('SILK 编码结果无效');
+    return { silkPath, duration, size: fs.statSync(silkPath).size };
+  } finally {
+    removeFile(pcmPath);
+  }
+}
+
 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);
+  return qiweiAccountKey(qiwei?.context?.() || qiwei?.config || {});
 }
 
 function extensionFor(name, mime = '') {
@@ -343,7 +368,18 @@ class VoiceCloneService {
       return { silkPath, duration, size: fs.statSync(silkPath).size };
     } catch (error) {
       removeFile(silkPath);
-      throw error;
+      try {
+        return await encodeSilkDirect(wavPath, silkPath);
+      } catch (fallbackError) {
+        removeFile(silkPath);
+        let diagnostics = {};
+        try {
+          diagnostics = await probeAudio(wavPath);
+        } catch (probeError) {
+          diagnostics = { probeError: probeError.message };
+        }
+        throw new Error(`SILK 编码失败:${error.message};兜底失败:${fallbackError.message};输入诊断:${JSON.stringify(diagnostics)}`);
+      }
     }
   }
 
@@ -354,6 +390,7 @@ class VoiceCloneService {
     const result = await this.synthesize({ text, context, tone });
     let silk;
     let outcome = 'failed';
+    let failure = null;
     try {
       silk = await this.encodeSilk(result.wavPath);
       const uploaded = await this.qiwei.uploadVoiceFile(silk.silkPath);
@@ -375,9 +412,21 @@ class VoiceCloneService {
         runDir: result.runDir,
         audioPath: result.wavPath,
       };
+    } catch (error) {
+      failure = error;
+      throw error;
     } finally {
       removeFile(silk?.silkPath);
-      if (outcome !== 'sent') removeFile(result.wavPath);
+      let failedWav = '';
+      if (outcome !== 'sent') {
+        failedWav = path.join(path.dirname(result.wavPath), 'speech.failed.wav');
+        try {
+          fs.renameSync(result.wavPath, failedWav);
+        } catch {
+          failedWav = '';
+          removeFile(result.wavPath);
+        }
+      }
       writeRunManifest(result.runDir, {
         type: 'voice-clone',
         provider: 'fmode-voice',
@@ -386,7 +435,8 @@ class VoiceCloneService {
         textSha256: crypto.createHash('sha256').update(result.text).digest('hex'),
         tone: result.tone,
         outcome,
-        files: outcome === 'sent' ? ['speech.wav'] : [],
+        ...(failure ? { error: { message: failure.message } } : {}),
+        files: outcome === 'sent' ? ['speech.wav'] : (failedWav ? ['speech.failed.wav'] : []),
       });
     }
   }

+ 8 - 11
mcp/src/core/webhook-processor.js

@@ -1,7 +1,7 @@
 const fs = require('fs');
 const path = require('path');
 const { outputsRoot } = require('./output-paths');
-const { readQiweiGuid } = require('./credentials');
+const { readQiweiGuid, qiweiAccountKey } = require('./credentials');
 const {
   readCustomerByExternalUserId,
   readCustomerByPhone,
@@ -38,6 +38,7 @@ const {
 const { enqueuePortraitUpdate } = require('../tools/qiwei-portrait-tags-run');
 const { qiweiTranscribeVoice } = require('../tools/qiwei-voice-run');
 const { getGroupOperationsStore } = require('./group-operations-store');
+const { normalizeMessage } = require('./message-normalizer');
 
 const REQUIRED_METHODS = {
   getWxContactList: '/contact/getWxContactList'
@@ -57,11 +58,11 @@ function readWebhookConfig() {
 }
 
 function groupsDir() {
-  return path.join(outputsRoot(), 'groups');
+  return path.join(outputsRoot(), 'groups', qiweiAccountKey());
 }
 
 function customersDir() {
-  return path.join(outputsRoot(), 'customers');
+  return path.join(outputsRoot(), 'customers', qiweiAccountKey());
 }
 
 function ensureGroupsDir() {
@@ -453,14 +454,7 @@ function ensureRelatedContact(customerId, externalUserId, name) {
 }
 
 function extractMessageContent(raw) {
-  if (!raw) return '';
-  if (typeof raw.content === 'string' && raw.content) return raw.content;
-  if (typeof raw.msgContent === 'string' && raw.msgContent) return raw.msgContent;
-  if (raw.msgData) {
-    if (typeof raw.msgData.content === 'string') return raw.msgData.content;
-    if (typeof raw.msgData.text === 'string') return raw.msgData.text;
-  }
-  return '';
+  return normalizeMessage(raw || {}).content;
 }
 
 async function processVoiceMessage(msgUniqueId, msgType, msgData) {
@@ -529,6 +523,7 @@ async function storeGroupMessageFromWebhook(event, eventFilePath = null) {
   }
 
   const content = extractMessageContent(raw);
+  const normalizedContent = normalizeMessage(raw);
   const timestamp = Number(raw.timestamp) || 0;
   const senderId = String(raw.senderId || '');
 
@@ -557,6 +552,8 @@ async function storeGroupMessageFromWebhook(event, eventFilePath = null) {
     senderName: String(raw.senderName || ''),
     senderType,
     msgType: String(msgType),
+    contentType: normalizedContent.type,
+    contentTypeLabel: normalizedContent.label,
     content,
     timestamp: timestamp ? new Date(timestamp * 1000).toISOString() : new Date().toISOString(),
     isRevoked: raw.isRevoked ? true : false,

+ 22 - 2
mcp/src/core/webhook-types.js

@@ -63,7 +63,26 @@ const V2_CONTENT_TYPE_TO_V1 = Object.freeze({
   location: 6,
   link: 13,
   card: 41,
-  miniprogram: 78
+  emotion: 104,
+  redpacket: 26,
+  external_redpacket: 26,
+  transfer: 588,
+  chatrecord: 4,
+  revoke: 2063,
+  sphfeed: 141,
+  miniprogram: 78,
+  weapp: 78,
+  meeting: 0,
+  meeting_notification: 0,
+  docmsg: 0,
+  markdown: 2,
+  news: 0,
+  calendar: 0,
+  mixed: 0,
+  voiptext: 0,
+  qydiskfile: 15,
+  solitaire: 0,
+  note: 0
 });
 
 function detectWebhookVersion(body) {
@@ -216,6 +235,7 @@ function normalizeItems(items) {
 
 function parseSystemOrNormalMessage(event, item) {
   const msgType = item.msgType;
+  const isGroupMessage = Boolean(item.fromRoomId && String(item.fromRoomId) !== '0');
 
   if (msgType === SystemMsgType.CONTACT_EXTERNAL_CHANGE) {
     event.parsedType = ParsedWebhookEvent.CONTACT_ADDED_OR_CHANGED;
@@ -264,7 +284,7 @@ function parseSystemOrNormalMessage(event, item) {
     SystemMsgType.GROUP_ADMIN_CHANGE,
     SystemMsgType.GROUP_INFO_CHANGE
   ]);
-  if (GROUP_EVENT_MSG_TYPES.has(msgType)) {
+  if (isGroupMessage && GROUP_EVENT_MSG_TYPES.has(msgType)) {
     event.parsedType = ParsedWebhookEvent.GROUP_EVENT;
     return;
   }

+ 543 - 16
mcp/src/dashboard/agent-service.js

@@ -1,11 +1,14 @@
 const fs = require('fs');
 const path = require('path');
+const os = require('os');
 const crypto = require('crypto');
+const { spawn } = require('child_process');
+const WxVoice = require('@binsee/wx-voice');
 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 { getCustomerSessionGuide, safeCustomerArgument, sessionCommandPrefix } = 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');
@@ -17,12 +20,22 @@ const {
 const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
 const { messageTimestamp, roomIdOf, isGroupMessage, messageContent, evaluatePolledMessage, normalizePersonalIntakeMode } = require('../core/agent-poller-policy');
 const { sortConversationsByRecency, maxConversationTimestamp } = require('../core/conversation-order');
-const { saveQiweiClientConfig, setActiveQiweiContext, readFmodeVoiceToken } = require('../core/credentials');
+const {
+  saveQiweiClientConfig,
+  setActiveQiweiContext,
+  readFmodeVoiceToken,
+  readQiweiAccountMetadata,
+  qiweiAccountKey,
+} = require('../core/credentials');
 const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
 const { responseMonitor } = require('./response-monitor-service');
+const { GroupAgentService } = require('./group-agent-service');
 const { normalizeAllowlistIds, normalizeAllowlistContact } = require('../core/allowlist-config');
 const { getProductMode } = require('../core/product-mode');
 const { getRelayDaemonStatus } = require('../core/relay-daemon');
+const { normalizeMessage } = require('../core/message-normalizer');
+const { sanitizePayload } = require('../providers/fmode-wecom-gateway');
+const { appendChatRecord, ensureMemory } = require('../core/message-archive');
 
 const PROJECT_ROOT = WORKSPACE_ROOT;
 const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
@@ -276,8 +289,7 @@ function loadAgentConfig(overrides = {}) {
 }
 
 function accountRuntimeKey(input = {}) {
-  const source = String(input.uid || input.guid || input.userId || 'default').trim();
-  return crypto.createHash('sha256').update(source || 'default').digest('hex').slice(0, 16);
+  return qiweiAccountKey(input);
 }
 
 function accountWorkbenchOverrides(input = {}) {
@@ -351,6 +363,31 @@ function backfillCustomerMemory(service) {
   return { version, conversations, captured };
 }
 
+function backfillMessageNormalization(db) {
+  const version = '2';
+  if (db.getSetting('message_normalization_backfill_version', '') === version) return { skipped: true, updated: 0 };
+  const rows = db.db.prepare(`SELECT id, content, content_type, raw_json
+    FROM messages WHERE direction='inbound' AND raw_json IS NOT NULL AND raw_json<>''`).all();
+  const update = db.db.prepare('UPDATE messages SET content=?, content_type=?, raw_json=? WHERE id=?');
+  let updated = 0;
+  for (const row of rows) {
+    let raw;
+    try { raw = JSON.parse(row.raw_json || '{}'); } catch { continue; }
+    if (!raw.msgData || typeof raw.msgData !== 'object') continue;
+    const normalized = normalizeMessage(raw);
+    if (!normalized.type || normalized.type === 'unknown') continue;
+    const content = normalized.isText ? row.content : normalized.content;
+    const nextRaw = { ...raw, normalizedContentType: normalized.type, normalizedContentLabel: normalized.label };
+    if (row.content === content && row.content_type === normalized.type
+      && raw.normalizedContentType === normalized.type && raw.normalizedContentLabel === normalized.label) continue;
+    update.run(content, normalized.type, JSON.stringify(nextRaw), row.id);
+    updated += 1;
+  }
+  db.setSetting('message_normalization_backfill_version', version);
+  if (updated) db.audit({ actor: 'migration', action: 'message_normalization_backfilled', detail: { version, updated } });
+  return { version, updated };
+}
+
 const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
 
 class QiweiAgentPoller {
@@ -539,6 +576,8 @@ async function ingestMessageForWorkbench(target, message = {}, source = 'callbac
   if (contactId && OUTBOUND_CONTENT_MSG_TYPES.has(Number(message.msgType)) && messageContent(message)) {
     return recordOutboundMessage(target, message, source, contactId);
   }
+  const normalized = normalizeMessage(message);
+  if (!normalized.isText) return archiveInboundMessage(target, message, source, normalized);
   const intakePolicy = target.db.intakePolicy();
   const candidate = evaluatePolledMessage(message, { ...target.config, intakeMode: intakePolicy.mode });
   if (!candidate.eligible) return { status: `ignored_${candidate.reason || 'ineligible'}` };
@@ -592,6 +631,37 @@ async function ingestMessageForWorkbench(target, message = {}, source = 'callbac
   }, { onboarding, allowAutoSend });
 }
 
+function archiveInboundMessage(target, message, source, normalized = normalizeMessage(message)) {
+  const senderId = String(message.senderId || '');
+  if (!senderId) return { status: 'ignored_empty_sender' };
+  if (!(target.config.allowedSenders || []).map(String).includes(senderId)) return { status: 'ignored_not_allowlisted' };
+  const timestamp = messageTimestamp(message.timestamp);
+  if (!timestamp) return { status: 'ignored_invalid_timestamp' };
+  const conversation = target.db.ensureConversation(senderId, message.senderName || '企微客户');
+  ensureMemory(senderId, message.senderName || '企微客户');
+  target.service?.preCreateSession?.(senderId, message.senderName || '企微客户');
+  const externalId = String(message.msgServerId || message.msgUniqueIdentifier || `${senderId}:${message.seq}`);
+  const createdAt = new Date(timestamp * 1000).toISOString();
+  const inserted = target.db.insertMessage({
+    conversationId: conversation.id,
+    externalId,
+    direction: 'inbound',
+    senderType: 'customer',
+    content: normalized.content,
+    contentType: normalized.type,
+    status: 'received',
+    createdAt,
+    raw: { ...message, source, fromRoomId: message.fromRoomId || null, normalizedContentType: normalized.type, normalizedContentLabel: normalized.label },
+  });
+  if (inserted.created) {
+    appendChatRecord({ wxid: senderId, messageId: inserted.message.id, externalId, dir: 'in', senderType: 'customer', content: normalized.content, createdAt, source });
+    target.db.audit({ actor: 'runtime', action: 'inbound_non_text_archived', conversationId: conversation.id, entityId: inserted.message.id, detail: { source, contentType: normalized.type, seq: Number(message.seq) || 0 } });
+    target.service?.emit?.('change', { type: 'message', conversationId: conversation.id });
+    if (target.db === workbench.db) primeMessageMediaCache(inserted.message.id, normalized.type);
+  }
+  return { status: inserted.created ? 'inbound_archived' : 'inbound_duplicate', message: inserted.message };
+}
+
 function createWorkbench(overrides = {}) {
   const config = loadAgentConfig(overrides.config || {});
   const db = overrides.db || new AgentWorkbenchDb(config.dbPath, {
@@ -619,6 +689,7 @@ function createWorkbench(overrides = {}) {
     if (removed) db.audit({ actor: 'migration', action: 'duplicate_messages_cleaned', detail: { removed } });
     backfillCustomerIntelligence(db);
   }
+  if (!overrides.db) backfillMessageNormalization(db);
   const knowledge = overrides.knowledge || new AgentKnowledgeStore({
     knowledgeDir: config.knowledgeDir,
     contextFiles: config.contextFiles,
@@ -689,13 +760,14 @@ const workbenches = new Map();
 
 function activeAccountMetadata() {
   const context = workbench.qiwei.context();
+  const stored = readQiweiAccountMetadata();
   return {
     uid: String(context.uid || workbench.config.qiwei.uid || '').trim(),
     guid: String(context.guid || workbench.config.qiwei.guid || '').trim(),
     apiBase: String(context.apiBase || workbench.config.qiwei.apiBase || '').trim(),
-    userId: String(workbench.config.qiwei.userId || '').trim(),
-    nickname: String(workbench.config.qiwei.nickname || '').trim(),
-    corpName: String(workbench.config.qiwei.corpName || '').trim(),
+    userId: String(workbench.config.qiwei.userId || stored.userId || '').trim(),
+    nickname: String(workbench.config.qiwei.nickname || stored.nickname || '').trim(),
+    corpName: String(workbench.config.qiwei.corpName || stored.corpName || '').trim(),
   };
 }
 
@@ -719,6 +791,12 @@ function provisionalAccountStatus(selected = activeAccountMetadata(), statusText
   };
 }
 
+const groupAgentService = new GroupAgentService({
+  projectRoot: PROJECT_ROOT,
+  getRuntime: () => workbench,
+  getAccount: () => activeAccountMetadata(),
+});
+
 const initialAccount = activeAccountMetadata();
 workbenches.set(accountRuntimeKey(initialAccount), workbench);
 applyActiveAccountContext(initialAccount);
@@ -761,6 +839,9 @@ async function switchActiveAccount(input = {}) {
     uid: accountPatch.uid,
     guid: accountPatch.guid,
     apiBase: accountPatch.apiBase,
+    userId: accountPatch.userId,
+    nickname: accountPatch.nickname,
+    corpName: accountPatch.corpName,
     envRoot: PROJECT_ROOT,
   });
   const status = provisionalAccountStatus();
@@ -827,11 +908,12 @@ async function refreshAccountStatus() {
       }
     }
     if (accountRuntimeKey(activeAccountMetadata()) === refreshKey) {
-      accountStatusCache = { checkedAt: Date.now(), value: next };
+      accountStatusCache = { checkedAt: Date.now(), value: { ...next, runtimeKey: refreshKey } };
     }
     if (next.userId) {
-      targetWorkbench.config.qiwei.userId = String(next.userId);
       targetWorkbench.config.qiwei.selfUserId = String(next.userId);
+      // Do not change storage scope while the current workbench is open.
+      if (selected.userId) targetWorkbench.config.qiwei.userId = String(next.userId);
     }
     return next;
   })().finally(() => {
@@ -846,7 +928,8 @@ async function refreshAccountStatus() {
 async function detectAccountStatus(force = false) {
   const selected = activeAccountMetadata();
   const selectedKey = accountRuntimeKey(selected);
-  const cacheMatches = accountStatusCache.value && accountRuntimeKey(accountStatusCache.value) === selectedKey;
+  const cacheMatches = accountStatusCache.value
+    && String(accountStatusCache.value.runtimeKey || accountRuntimeKey(accountStatusCache.value)) === selectedKey;
   if (force) return refreshAccountStatus();
   if (cacheMatches) {
     if (Date.now() - accountStatusCache.checkedAt >= 8000) void refreshAccountStatus();
@@ -951,11 +1034,14 @@ function publicConversation(row) {
       return {
         id: message.id,
         role: message.direction === 'inbound' ? 'customer' : message.sender_type,
-        content: message.content,
+        content: sanitizePayload(message.content),
         timestamp: message.created_at,
         status: message.status,
         source: message.direction === 'inbound' ? 'live' : message.sender_type,
-        raw,
+        contentType: message.content_type,
+        contentTypeLabel: raw.normalizedContentLabel || '',
+        payload: parseJson(message.payload_json, {}),
+        raw: sanitizePayload(raw),
       };
     }),
     analysis: {
@@ -1178,6 +1264,7 @@ function updateAllowlistForWorkbench(target, input = {}) {
   const addedIds = ids.filter(id => !previousIds.has(id));
   for (const contactId of addedIds) {
     target.db.ensureConversation(contactId, String(contactNames[contactId] || ''));
+    target.service.preCreateSession(contactId, String(contactNames[contactId] || ''));
   }
   if (ids.length && input.autoStart !== false) {
     target.db.setSetting('listener_enabled', 'true');
@@ -1350,6 +1437,57 @@ function getResponseMonitor() {
   };
 }
 
+function getGroupAgents() {
+  const groups = groupAgentService.list();
+  return {
+    status: 'ok',
+    assistantMessage: `??? ${groups.length} ???????????`,
+    summary: {
+      groupCount: groups.length,
+      pendingCount: groups.filter(item => item.pendingReply).length,
+      errorCount: groups.filter(item => item.agentError || item.sendError).length,
+      autoCount: groups.filter(item => item.mode === 'auto').length,
+    },
+    data: { groups },
+    warnings: [],
+    errors: [],
+  };
+}
+
+async function generateGroupReply(roomId, options = {}) {
+  try {
+    const result = await groupAgentService.generate(roomId, options);
+    return {
+      status: 'ok',
+      assistantMessage: result.status === 'no_reply_needed'
+        ? 'Agent ???????????'
+        : result.status === 'auto_sent' ? '?? Agent ???????' : '?? Agent ????????',
+      data: result,
+    };
+  } catch {
+    throw new Error('?? Agent ??????????????????????');
+  }
+}
+
+function changeGroupMode(roomId, mode, confirmation) {
+  const group = groupAgentService.setMode(roomId, mode, confirmation);
+  return { status: 'ok', assistantMessage: group.mode === 'auto' ? '??????????' : '???????????', data: { group } };
+}
+
+async function approveGroupDraft(roomId, draftId, content) {
+  const result = await groupAgentService.approve(roomId, draftId, content);
+  return { status: 'ok', assistantMessage: '?????????', data: result };
+}
+
+function rejectGroupDraft(roomId, draftId, reason) {
+  return { status: 'ok', assistantMessage: '???????', data: groupAgentService.reject(roomId, draftId, reason) };
+}
+
+async function regenerateGroupDraft(roomId, draftId) {
+  const result = await groupAgentService.regenerate(roomId, draftId);
+  return { status: 'ok', assistantMessage: '?? Agent ??????????', data: result };
+}
+
 function updateCustomerProfile(conversationId, input = {}) {
   const conversation = workbench.db.getConversation(conversationId);
   if (!conversation) throw new Error('客户会话不存在');
@@ -1439,8 +1577,9 @@ async function syncConversations(input = {}) {
       const senderId = String(message.senderId || '');
       const receiverId = String(message.receiverId || '');
       const contactId = syncScope.contacts.has(senderId) ? senderId : syncScope.contacts.has(receiverId) ? receiverId : '';
-      const content = String(message.msgData?.content || '').trim();
-      if (!contactId || !content || ![0, 1, 2].includes(Number(message.msgType))) continue;
+      const normalized = normalizeMessage(message);
+      const content = String(normalized.content || '').trim();
+      if (!contactId || !content || normalized.isNotification) continue;
 
       const inbound = senderId === contactId;
       const timestampSeconds = messageTimestamp(message.timestamp);
@@ -1461,8 +1600,9 @@ async function syncConversations(input = {}) {
         externalId: externalId || null,
         inbound,
         content,
+        contentType: normalized.type,
         timestamp,
-        raw: { seq: message.seq, msgType: message.msgType, timestamp: message.timestamp, source: 'manual_sync' },
+        raw: { ...message, source: 'manual_sync', normalizedContentType: normalized.type, normalizedContentLabel: normalized.label },
       });
     }
 
@@ -1487,6 +1627,7 @@ async function syncConversations(input = {}) {
         direction: message.inbound ? 'inbound' : 'outbound',
         senderType: message.inbound ? 'customer' : 'human',
         content: message.content,
+        contentType: message.contentType,
         status: message.inbound ? 'received' : 'sent',
         createdAt: message.timestamp,
         raw: message.raw,
@@ -1772,6 +1913,264 @@ function getSentVoiceAudio(messageId) {
   return { filePath: raw.audioPath, duration: Number(raw.duration) || 0 };
 }
 
+function messageMediaData(raw = {}) {
+  return raw.msgData && typeof raw.msgData === 'object' ? raw.msgData : raw;
+}
+
+function trustedHttpUrl(...values) {
+  for (const item of values) {
+    if (typeof item !== 'string' || !/^https?:\/\/[^\s]+$/i.test(item.trim())) continue;
+    try {
+      const url = new URL(item.trim());
+      const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
+      const privateHost = host === 'localhost' || host === '::1' || host.endsWith('.local')
+        || /^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || /^169\.254\./.test(host)
+        || /^172\.(?:1[6-9]|2\d|3[01])\./.test(host) || /^(?:fc|fd)[a-f0-9]{0,2}:/i.test(host);
+      if (!privateHost) return url.toString();
+    } catch {}
+  }
+  return '';
+}
+
+function firstHttpUrlInText(...values) {
+  for (const value of values) {
+    if (typeof value !== 'string') continue;
+    const match = value.match(/https?:\/\/[^\s<>"']+/i);
+    if (!match) continue;
+    const candidate = match[0].replace(/[),.;!?\]},。;!?)】]+$/u, '');
+    if (trustedHttpUrl(candidate)) return candidate;
+  }
+  return '';
+}
+
+async function refreshMessageMediaRaw(message) {
+  const current = parseJson(message?.raw_json, {});
+  const seq = Number(current.seq);
+  if (!message || !Number.isFinite(seq)) return current;
+  const result = await workbench.qiwei.syncMessages(Math.max(0, seq - 2), 20);
+  const list = Array.isArray(result.syncMsgList) ? result.syncMsgList : [];
+  const fresh = list.find(item => String(item.msgServerId || item.msgUniqueIdentifier || '') === String(message.external_id || '') || Number(item.seq) === seq);
+  if (!fresh) return current;
+  const merged = { ...current, ...fresh, source: current.source || 'media_refresh', normalizedContentType: current.normalizedContentType || message.content_type, normalizedContentLabel: current.normalizedContentLabel || '' };
+  workbench.db.updateMessageRaw(message.id, merged);
+  return merged;
+}
+
+function selectedMessageMedia(message, raw, variant = 'original') {
+  const data = messageMediaData(raw);
+  const type = String(message.content_type || raw.normalizedContentType || '').toLowerCase();
+  const preview = variant === 'preview' || variant === 'cover';
+  let url = '';
+  let fileType = 5;
+  let fileSize = Number(data.fileSize) || 0;
+  let fileId = '';
+  let fileAesKey = String(data.fileAesKey || '');
+  let filename = String(data.filename || data.fileName || '').trim();
+  if (type === 'image') {
+    url = preview ? trustedHttpUrl(data.fileMiddleHttpUrl, data.fileThumbHttpUrl, data.fileHttpUrl, data.fileBigHttpUrl) : trustedHttpUrl(data.fileBigHttpUrl, data.fileHttpUrl, data.fileMiddleHttpUrl, data.fileThumbHttpUrl);
+    fileType = preview ? 2 : (data.fileBigHttpUrl ? 1 : 2);
+    fileSize = Number(preview ? (data.fileMiddleSize || data.fileThumbSize) : (data.fileBigSize || data.fileSize)) || fileSize;
+    fileId = String(data.fileId || '');
+    filename ||= `image-${message.id}.jpg`;
+  } else if (type === 'emotion') {
+    url = trustedHttpUrl(data.fileHttpUrl);
+    filename ||= `emotion-${message.id}.gif`;
+  } else if (type === 'video') {
+    url = preview ? trustedHttpUrl(data.coverImageHttpUrl, data.fileThumbHttpUrl, data.thumbUrl) : trustedHttpUrl(data.fileHttpUrl, data.fileBigHttpUrl);
+    fileType = preview ? 3 : 4;
+    fileSize = Number(preview ? (data.coverImageSize || data.fileThumbSize) : data.fileSize) || fileSize;
+    fileAesKey = String((preview ? data.coverImageAesKey : data.fileAesKey) || fileAesKey);
+    fileId = String((preview ? (data.coverImageId || data.coverFileId) : data.fileId) || '');
+    filename ||= preview ? `video-cover-${message.id}.jpg` : `video-${message.id}.mp4`;
+  } else if (type === 'voice') {
+    fileId = String(data.fileId || '');
+    filename = `voice-${message.id}.silk`;
+  } else if (['file', 'qydiskfile'].includes(type)) {
+    url = trustedHttpUrl(data.fileHttpUrl, data.fileBigHttpUrl);
+    fileId = String(data.fileId || '');
+    filename ||= `file-${message.id}`;
+  } else if (type === 'weapp') {
+    url = trustedHttpUrl(data.appMediaUrl, data.coverUrl, data.iconUrl);
+    fileId = String(data.coverImageId || data.coverFileId || '');
+    fileAesKey = String(data.coverImageAesKey || data.coverFileAesKey || '');
+    fileSize = Number(data.coverImageSize || data.coverFileSize) || 0;
+    fileType = 3;
+    filename = `miniapp-cover-${message.id}.jpg`;
+  } else if (type === 'sphfeed') {
+    url = trustedHttpUrl(data.coverUrl, data.headImgUrl);
+    filename = `channel-cover-${message.id}.jpg`;
+  } else if (type === 'card') {
+    url = trustedHttpUrl(data.avatarUrl);
+    filename = `avatar-${message.id}.jpg`;
+  }
+  return {
+    type, url, fileType, fileSize, fileId, fileAesKey,
+    fileAuthKey: String(data.fileAuthKey || ''),
+    fileMd5: String((preview ? (data.fileMiddleMd5 || data.fileThumbMd5 || data.fileMd5) : data.fileMd5) || data.coverImageMd5 || ''),
+    filename: path.basename(filename).replace(/[\x00-\x1f<>:"/\\|?*]/g, '_').slice(0, 180) || `media-${message.id}`,
+  };
+}
+
+function mediaCacheVariants(type) {
+  if (['image', 'video'].includes(type)) return ['preview', 'original'];
+  if (['weapp', 'sphfeed', 'card'].includes(type)) return ['cover'];
+  if (['emotion', 'file', 'qydiskfile', 'voice'].includes(type)) return ['original'];
+  return [];
+}
+
+function primeMessageMediaCache(messageId, type) {
+  for (const variant of mediaCacheVariants(String(type || '').toLowerCase())) {
+    setTimeout(() => resolveMessageMedia(messageId, variant).catch(error => {
+      workbench.db.audit({ actor: 'runtime', action: 'message_media_cache_failed', entityId: messageId, detail: { variant, message: error.message } });
+    }), 0);
+  }
+}
+
+function messageMediaAesKey(value) {
+  const text = String(value || '').trim();
+  if (/^[a-f0-9]{32}$/i.test(text)) return Buffer.from(text, 'hex');
+  try {
+    const decoded = Buffer.from(text, 'base64');
+    const decodedText = decoded.toString('ascii');
+    if (/^[a-f0-9]{32}$/i.test(decodedText)) return Buffer.from(decodedText, 'hex');
+    if (decoded.length === 16) return decoded;
+  } catch {}
+  const direct = Buffer.from(text, 'utf8');
+  return direct.length === 16 ? direct : null;
+}
+
+function decryptMessageMedia(buffer, media) {
+  const key = messageMediaAesKey(media.fileAesKey);
+  if (!key || !buffer.length) return buffer;
+  if (media.fileMd5 && crypto.createHash('md5').update(buffer).digest('hex').toLowerCase() === media.fileMd5.toLowerCase()) return buffer;
+  try {
+    const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
+    const decrypted = Buffer.concat([decipher.update(buffer), decipher.final()]);
+    if (!media.fileMd5 || crypto.createHash('md5').update(decrypted).digest('hex').toLowerCase() === media.fileMd5.toLowerCase()) return decrypted;
+  } catch {}
+  return buffer;
+}
+
+async function fetchMessageMediaUrl(url, media, attempts = 1) {
+  let lastStatus = 0;
+  for (let attempt = 1; attempt <= attempts; attempt += 1) {
+    if (attempt > 1) await delay(350 * attempt);
+    const response = await fetch(url, { signal: AbortSignal.timeout(60000) });
+    lastStatus = response.status;
+    if (!response.ok) continue;
+    const declaredSize = Number(response.headers.get('content-length')) || 0;
+    if (declaredSize > 120 * 1024 * 1024) throw new Error('媒体文件超过 120MB,暂不支持在线打开');
+    const encrypted = Buffer.from(await response.arrayBuffer());
+    if (encrypted.length > 120 * 1024 * 1024) throw new Error('媒体文件超过 120MB,暂不支持在线打开');
+    return { buffer: decryptMessageMedia(encrypted, media), contentType: String(response.headers.get('content-type') || '').split(';')[0] || 'application/octet-stream' };
+  }
+  const error = new Error(`媒体文件获取失败(HTTP ${lastStatus || 502})`);
+  error.httpStatus = lastStatus;
+  throw error;
+}
+
+function messageMediaContentType(declared, filename, buffer) {
+  const type = String(declared || '').toLowerCase();
+  if (type && !['application/octet-stream', 'binary/octet-stream'].includes(type)) return type;
+  const head = buffer.subarray(0, 12);
+  if (head[0] === 0xff && head[1] === 0xd8) return 'image/jpeg';
+  if (head.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return 'image/png';
+  if (head.subarray(0, 6).toString('ascii').startsWith('GIF8')) return 'image/gif';
+  if (head.subarray(0, 4).toString('ascii') === 'RIFF' && head.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
+  if (head.subarray(4, 8).toString('ascii') === 'ftyp') return 'video/mp4';
+  return ({ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.pdf': 'application/pdf', '.silk': 'audio/silk', '.sil': 'audio/silk', '.amr': 'audio/amr', '.mp3': 'audio/mpeg', '.wav': 'audio/wav' })[path.extname(filename).toLowerCase()] || 'application/octet-stream';
+}
+
+function mediaCachePaths(messageId, variant, filename) {
+  const root = path.join(categoryDir('dashboard'), qiweiAccountKey(workbench.config.qiwei), 'message-media');
+  fs.mkdirSync(root, { recursive: true });
+  const extension = path.extname(filename).slice(0, 12) || '.bin';
+  const key = crypto.createHash('sha256').update(`${messageId}:${variant}`).digest('hex').slice(0, 24);
+  return { filePath: path.join(root, `${key}${extension}`), metaPath: path.join(root, `${key}.json`) };
+}
+
+function cachedMessageMedia(messageId, variant, filename) {
+  const paths = mediaCachePaths(messageId, variant, filename);
+  if (!fs.existsSync(paths.filePath) || !fs.existsSync(paths.metaPath)) return null;
+  try { return { ...JSON.parse(fs.readFileSync(paths.metaPath, 'utf8')), filePath: paths.filePath }; } catch { return null; }
+}
+
+async function resolveMessageMedia(messageId, variant = 'original') {
+  const message = workbench.db.getMessage(String(messageId || ''));
+  if (!message || message.direction !== 'inbound') throw new Error('消息不存在或不支持媒体访问');
+  let raw = parseJson(message.raw_json, {});
+  let media = selectedMessageMedia(message, raw, variant);
+  const existing = cachedMessageMedia(message.id, variant, media.filename);
+  if (existing) return existing;
+  if (!media.url && !media.fileId) {
+    raw = await refreshMessageMediaRaw(message);
+    media = selectedMessageMedia(message, raw, variant);
+  }
+  let fetched = null;
+  const failures = [];
+  if (media.url) try { fetched = await fetchMessageMediaUrl(media.url, media); } catch (error) { failures.push(`direct:${error.message}`); }
+  if (!fetched && media.fileAesKey && media.fileAuthKey && media.url) {
+    try {
+      const result = await workbench.qiwei.downloadMessageMedia({ fileAeskey: media.fileAesKey, fileAuthkey: media.fileAuthKey, fileSize: media.fileSize, fileType: media.fileType, fileUrl: media.url });
+      const url = trustedHttpUrl(result.cloudUrl, result.fileUrl, result.url);
+      if (url) fetched = await fetchMessageMediaUrl(url, media);
+    } catch (error) { failures.push(`wx:${error.message}`); }
+  }
+  if (!fetched && media.fileAesKey && media.fileId && media.fileMd5) {
+    try {
+      const result = await workbench.qiwei.convertCdnMessageMedia({ fileAeskey: media.fileAesKey, fileId: media.fileId, fileMd5: media.fileMd5 });
+      const url = trustedHttpUrl(result.fileUrl, result.coverUrl, result.cloudUrl, result.url);
+      if (url) fetched = await fetchMessageMediaUrl(url, media);
+    } catch (error) { failures.push(`cdn:${error.message}`); }
+  }
+  if (!fetched && media.fileAesKey && media.fileId) {
+    try {
+      const result = await workbench.qiwei.downloadWorkMessageMedia({ fileAeskey: media.fileAesKey, fileId: media.fileId, fileSize: media.fileSize, fileType: media.fileType });
+      const url = trustedHttpUrl(result.cloudUrl, result.fileUrl, result.url);
+      if (url) fetched = await fetchMessageMediaUrl(url, media, 3);
+    } catch (error) { failures.push(`work:${error.message}`); }
+  }
+  if (!fetched) throw new Error(media.url || media.fileId ? `媒体下载服务未返回可用文件${failures.length ? `(${failures.join(';')})` : ''}` : '该消息未提供可用的媒体文件');
+  const paths = mediaCachePaths(message.id, variant, media.filename);
+  const metadata = { filename: media.filename, contentType: messageMediaContentType(fetched.contentType, media.filename, fetched.buffer), size: fetched.buffer.length };
+  fs.writeFileSync(paths.filePath, fetched.buffer);
+  fs.writeFileSync(paths.metaPath, JSON.stringify(metadata, null, 2), 'utf8');
+  return { ...metadata, filePath: paths.filePath };
+}
+
+async function resolveMessageVoiceAudio(messageId) {
+  const message = workbench.db.getMessage(String(messageId || ''));
+  if (!message) throw new Error('语音消息不存在');
+  if (message.direction === 'outbound') return { ...getSentVoiceAudio(messageId), source: 'sent' };
+  if (String(message.content_type || '').toLowerCase() !== 'voice') throw new Error('该消息不是语音消息');
+  const media = await resolveMessageMedia(messageId, 'original');
+  if (!/audio\/silk/i.test(media.contentType || '') && !/\.(?:silk|sil)$/i.test(media.filePath || '')) return { ...media, source: 'inbound' };
+  const mp3Path = String(media.filePath).replace(/\.(?:silk|sil)$/i, '') + '.mp3';
+  if (!fs.existsSync(mp3Path)) {
+    const voice = new WxVoice(path.dirname(media.filePath));
+    let decodeError = null;
+    voice.on('error', error => { decodeError = error; });
+    try { await voice.decode(media.filePath, mp3Path, { format: 'mp3' }); }
+    catch (error) { throw new Error(`语音转码失败:${decodeError?.message || error.message}`); }
+  }
+  return { filePath: mp3Path, filename: `voice-${messageId}.mp3`, contentType: 'audio/mpeg', size: fs.statSync(mp3Path).size, source: 'inbound' };
+}
+
+async function getMessageExternalLink(messageId) {
+  const message = workbench.db.getMessage(String(messageId || ''));
+  if (!message || message.direction !== 'inbound') throw new Error('消息不存在或不支持打开');
+  let raw = parseJson(message.raw_json, {});
+  let data = messageMediaData(raw);
+  let url = trustedHttpUrl(data.channelUrl, data.linkUrl, data.url) || firstHttpUrlInText(data.content, ...(Array.isArray(data.moreDetail) ? data.moreDetail.map(item => item?.text) : []));
+  if (!url) {
+    raw = await refreshMessageMediaRaw(message);
+    data = messageMediaData(raw);
+    url = trustedHttpUrl(data.channelUrl, data.linkUrl, data.url) || firstHttpUrlInText(data.content, ...(Array.isArray(data.moreDetail) ? data.moreDetail.map(item => item?.text) : []));
+  }
+  if (!url) throw new Error('该消息未提供可打开的链接');
+  return url;
+}
+
 async function updateCustomerTask(taskId, input = {}) {
   if (!['open', 'in_progress', 'done', 'dismissed'].includes(String(input.status || ''))) throw new Error('不支持的客户待办状态');
   const existing = workbench.db.getCustomerTask(taskId);
@@ -1930,6 +2329,122 @@ function getAgentRuntimeConfig() {
   return { ...workbench.config.agent };
 }
 
+function openCustomerSession(conversationId) {
+  const conversation = workbench.db.getConversation(conversationId);
+  if (!conversation) throw new Error('会话不存在');
+  const guide = getCustomerSessionGuide(conversation, { sessionFile: workbench.config.agent.claudeSessionFile });
+  if (!guide.ready) return { status: 'not_ready', assistantMessage: '客户 Session 尚未初始化,请等待会话初始化完成', data: { guide } };
+  const state = JSON.parse(fs.readFileSync(workbench.config.agent.claudeSessionFile, 'utf8'));
+  const session = state.sessions?.[conversationId];
+  if (!session?.id) throw new Error('Session 数据不完整');
+  const command = `${sessionCommandPrefix()} agent:session -- --customer "${safeCustomerArgument(conversation.contact_name || guide.customerName)}"`;
+  if (process.platform === 'win32') {
+    spawn('cmd', ['/c', 'start', 'cmd', '/k', `cd /d "${PROJECT_ROOT}" && ${command}`], { cwd: PROJECT_ROOT, detached: true, stdio: 'ignore', windowsHide: false }).unref();
+  } else if (process.platform === 'darwin') {
+    const script = `tell application "Terminal" to do script "cd \\"${PROJECT_ROOT}\\" && ${command}"`;
+    spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
+  } else {
+    spawn('x-terminal-emulator', ['-e', `cd "${PROJECT_ROOT}" && ${command}`], { detached: true, stdio: 'ignore' }).unref();
+  }
+  workbench.db.audit({ actor: 'human', action: 'session_opened_in_terminal', detail: { conversationId } });
+  return { status: 'ok', assistantMessage: `正在新终端窗口中打开 ${conversation.contact_name || '客户'} 的 Claude Code 审阅会话`, data: { customerName: conversation.contact_name, sessionId: session.id, command } };
+}
+
+function findSessionTranscript(sessionId) {
+  const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
+  if (!fs.existsSync(projectsRoot)) return null;
+  const target = `${sessionId}.jsonl`;
+  const queue = [projectsRoot];
+  while (queue.length) {
+    const current = queue.shift();
+    let entries;
+    try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
+    for (const entry of entries) {
+      const fullPath = path.join(current, entry.name);
+      if (entry.isDirectory()) queue.push(fullPath);
+      else if (entry.name === target) return fullPath;
+    }
+  }
+  return null;
+}
+
+function parseSessionTranscript(transcriptPath, maxRounds = 30) {
+  const lines = fs.readFileSync(transcriptPath, 'utf8').trim().split('\n').filter(Boolean).slice(-maxRounds * 8);
+  const steps = [];
+  let current = null;
+  const flush = () => {
+    if (current && (current.thinking || current.text || current.tools.length)) steps.push(current);
+    current = null;
+  };
+  for (const line of lines) {
+    let event;
+    try { event = JSON.parse(line); } catch { continue; }
+    if (event.type === 'queue-operation' && event.operation === 'enqueue') { flush(); continue; }
+    const message = event.message;
+    if (!message || !Array.isArray(message.content)) continue;
+    if (message.role === 'assistant') {
+      if (!current) current = { thinking: '', text: '', tools: [], timestamp: event.timestamp || null };
+      for (const item of message.content) {
+        if (item.type === 'thinking' && item.thinking) current.thinking += `${current.thinking ? '\n\n' : ''}${String(item.thinking)}`;
+        else if (item.type === 'tool_use') current.tools.push({ id: item.id || '', name: item.name || 'tool', input: item.input || {}, result: '' });
+        else if (item.type === 'text' && item.text) current.text += `${current.text ? '\n' : ''}${String(item.text)}`;
+      }
+    } else if (message.role === 'user') {
+      let sawText = false;
+      for (const item of message.content) {
+        if (item.type === 'tool_result') {
+          if (!current) current = { thinking: '', text: '', tools: [], timestamp: event.timestamp || null };
+          const resultText = typeof item.content === 'string' ? item.content : JSON.stringify(item.content ?? '');
+          const tool = current.tools.find(entry => entry.id === item.tool_use_id);
+          if (tool) tool.result = resultText;
+          else current.tools.push({ id: item.tool_use_id || '', name: 'tool_result', input: {}, result: resultText });
+        } else if (item.type === 'text' && String(item.text || '').trim()) sawText = true;
+      }
+      if (sawText) flush();
+    }
+  }
+  flush();
+  return steps;
+}
+
+function getSessionTranscript(conversationId) {
+  const conversation = workbench.db.getConversation(conversationId);
+  if (!conversation) throw new Error('会话不存在');
+  let state;
+  try { state = JSON.parse(fs.readFileSync(workbench.config.agent.claudeSessionFile, 'utf8')); } catch { state = null; }
+  const session = state?.sessions?.[conversationId];
+  const detail = workbench.service.conversationDetail(conversationId);
+  const messages = (detail?.messages || []).map(message => ({
+    kind: message.direction === 'inbound' ? 'customer' : 'broker',
+    sender: message.direction === 'outbound' ? message.sender_type : null,
+    text: sanitizePayload(String(message.content || '')), time: message.created_at, id: message.id,
+  }));
+  const aiRounds = [];
+  let transcriptPath = null;
+  if (session?.id) {
+    transcriptPath = findSessionTranscript(session.id);
+    if (transcriptPath) aiRounds.push(...parseSessionTranscript(transcriptPath).map(step => ({
+      kind: 'ai',
+      thinking: sanitizePayload(step.thinking),
+      tools: sanitizePayload(step.tools),
+      text: sanitizePayload(step.text),
+      time: step.timestamp,
+    })));
+  }
+  const timeline = [...messages, ...aiRounds].sort((a, b) => String(a.time || '').localeCompare(String(b.time || ''))).slice(-200);
+  return { status: 'ok', data: { found: timeline.length > 0, hasAiWork: aiRounds.length > 0, sessionId: session?.id || '', displayName: conversation.contact_name || '', timeline } };
+}
+
+function closeAgentWorkbenches() {
+  const instances = new Set([...workbenches.values(), workbench]);
+  for (const instance of instances) {
+    instance.poller?.stop?.();
+    instance.service?.stopBackgroundWorkers?.();
+    instance.db?.close?.();
+  }
+  workbenches.clear();
+}
+
 module.exports = {
   switchActiveAccount,
   getAgentStatus,
@@ -1941,6 +2456,12 @@ module.exports = {
   retryOnboardingWelcome,
   getConversations,
   getResponseMonitor,
+  getGroupAgents,
+  changeGroupMode,
+  generateGroupReply,
+  approveGroupDraft,
+  rejectGroupDraft,
+  regenerateGroupDraft,
   updateCustomerProfile,
   syncConversations,
   changeGlobalMode,
@@ -1956,6 +2477,9 @@ module.exports = {
   revokeVoiceProfile,
   sendClonedVoice,
   getSentVoiceAudio,
+  resolveMessageVoiceAudio,
+  resolveMessageMedia,
+  getMessageExternalLink,
   updateCustomerTask,
   syncCustomerTaskToOfficialTodo,
   updateCustomerAlert,
@@ -1966,7 +2490,10 @@ module.exports = {
   ingestWebhookMessage,
   startListener,
   stopListener,
+  openCustomerSession,
+  getSessionTranscript,
   getAgentRuntimeConfig,
+  closeAgentWorkbenches,
   createWorkbench,
-  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, hydrateAccountAllowlist, updateAllowlistForWorkbench, resolveConversationSyncScope, applyManualTakeover, requireAutopilotConfirmation, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillCustomerMemory, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, startListenerForWorkbench, intakePolicyPayload, updateIntakePolicyForWorkbench },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, hydrateAccountAllowlist, updateAllowlistForWorkbench, resolveConversationSyncScope, applyManualTakeover, requireAutopilotConfirmation, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, archiveInboundMessage, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillCustomerMemory, backfillMessageNormalization, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, startListenerForWorkbench, intakePolicyPayload, updateIntakePolicyForWorkbench },
 };

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 766 - 153
mcp/src/dashboard/app.js


+ 6 - 3
mcp/src/dashboard/customer-master-service.js

@@ -1,5 +1,6 @@
 const fs = require('fs');
 const path = require('path');
+const { qiweiAccountKey } = require('../core/credentials');
 const { outputsRoot } = require('../core/output-paths');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { getConversations, updateCustomerProfile } = require('./agent-service');
@@ -194,7 +195,9 @@ function normalizeCustomer(conversation) {
 function createCustomerMasterService(dependencies = {}) {
   const conversationReader = dependencies.getConversations || getConversations;
   const profileUpdater = dependencies.updateCustomerProfile || updateCustomerProfile;
-  const projectionPath = path.resolve(dependencies.projectionPath || path.join(outputsRoot(), 'customers', 'index.json'));
+  const fixedProjectionPath = dependencies.projectionPath ? path.resolve(dependencies.projectionPath) : '';
+  const projectionPath = () => fixedProjectionPath
+    || path.join(outputsRoot(), 'customers', qiweiAccountKey(), 'index.json');
 
   function persistProjection(customers) {
     const snapshot = {
@@ -216,7 +219,7 @@ function createCustomerMasterService(dependencies = {}) {
         openAlertCount: customer.openAlertCount,
       })),
     };
-    writeJsonAtomic(projectionPath, snapshot);
+    writeJsonAtomic(projectionPath(), snapshot);
     return snapshot.updatedAt;
   }
 
@@ -255,7 +258,7 @@ function createCustomerMasterService(dependencies = {}) {
           nextAction: '下一步行动属于规则或 AI 建议,不自动发送、不自动改写客户事实。',
         },
       },
-      files: [projectionPath],
+      files: [projectionPath()],
     });
   }
 

+ 3 - 3
mcp/src/dashboard/group-agent-service.js

@@ -1,6 +1,7 @@
 const fs = require('fs');
 const path = require('path');
 const crypto = require('crypto');
+const { qiweiAccountKey } = require('../core/credentials');
 const { roomIdOf } = require('../core/agent-poller-policy');
 const { appendGroupRecord, appendChatRecord, ensureMemory } = require('../core/message-archive');
 const { isStaffMessage, timestampOf } = require('./response-monitor-service');
@@ -61,9 +62,8 @@ class GroupAgentService {
 
   statePath() {
     if (this.fixedStatePath) return this.fixedStatePath;
-    const account = this.getAccount() || {};
-    const key = crypto.createHash('sha256').update(String(account.uid || account.guid || 'default')).digest('hex').slice(0, 12);
-    return path.join(this.projectRoot, 'outputs', 'groups', `agent-replies-${key}.json`);
+    const key = qiweiAccountKey(this.getAccount() || {});
+    return path.join(this.projectRoot, 'outputs', 'groups', key, 'agent-replies.json');
   }
 
   loadState() {

+ 28 - 21
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=20260726-voice">
+<link rel="stylesheet" href="/dashboard/styles.css?v=20260807-voice-preview-persist">
 </head>
 <body>
 <div class="app" id="app">
@@ -18,32 +18,36 @@
     </div>
     <nav class="nav" id="nav">
       <div class="nav-group">
-        <div class="nav-label">经营驾驶舱</div>
+        <div class="nav-label">工作台</div>
         <a class="nav-item active" href="#overview" data-page="overview">
           <svg viewBox="0 0 24 24"><path d="M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z"/></svg>
           <span>经营总览</span>
         </a>
+        <a class="nav-item" href="#analytics" data-page="analytics">
+          <svg viewBox="0 0 24 24"><path d="M5 9.2h3V19H5V9.2zM10.6 5h2.8v14h-2.8V5zm5.6 8H19v6h-2.8v-6z"/></svg>
+          <span>数据看板</span>
+        </a>
       </div>
       <div class="nav-group">
-        <div class="nav-label">数字岗位</div>
+        <div class="nav-label">AI 能力</div>
         <a class="nav-item" href="#agent" data-page="agent">
           <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h3l4 4 4-4h3c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-8 10H7v-2h4v2zm6-4H7V7h10v2zm0 4h-4v-2h4v2z"/></svg>
           <span>客服工作台</span>
         </a>
-        <a class="nav-item" href="#group-ops" data-page="group-ops">
-          <svg viewBox="0 0 24 24"><path d="M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm3 4v2h10V7H7zm0 4v2h6v-2H7zm0 4v2h4v-2H7zm8-3 2 2 3-4 1.5 1.2L17.2 17 13.7 13.5 15 12z"/></svg>
-          <span>社群运营</span>
+        <a class="nav-item" href="#knowledge" data-page="knowledge">
+          <svg viewBox="0 0 24 24"><path d="M10 4H2v16h20V6H12l-2-2zm10 14H4V6h5.17l2 2H20v10zM8 10h8v2H8v-2zm0 4h6v2H8v-2z"/></svg>
+          <span>知识与任务</span>
         </a>
-        <a class="nav-item" href="#customer-ops" data-page="customer-ops">
-          <svg viewBox="0 0 24 24"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
-          <span>客户运营</span>
+        <a class="nav-item" href="#skills" data-page="skills">
+          <svg viewBox="0 0 24 24"><path d="M19 13h-2V7h-6V5H5c-1.1 0-2 .9-2 2v6h2v6c0 1.1.9 2 2 2h6v-2H7v-6h6v2h6c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2h-6v2h6v6zM9 9H5V7h4v2zm10 4h-4V7h4v6z"/></svg>
+          <span>数字员工能力</span>
         </a>
       </div>
       <div class="nav-group">
-        <div class="nav-label">业务资产</div>
-        <a class="nav-item" href="#groups" data-page="groups">
-          <svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
-          <span>客户</span>
+        <div class="nav-label">客户经营</div>
+        <a class="nav-item" href="#customer-ops" data-page="customer-ops">
+          <svg viewBox="0 0 24 24"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
+          <span>客户运营</span>
         </a>
         <a class="nav-item" href="#portraits" data-page="portraits">
           <svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4zM9 10c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z"/></svg>
@@ -53,17 +57,20 @@
           <svg viewBox="0 0 24 24"><path d="M16.01 11H4v2h12.01v3L20 12l-3.99-4v3zM6 17l-4-4 4-4v3h12v2H6v3z"/></svg>
           <span>客户交接</span>
         </a>
-        <a class="nav-item" href="#knowledge" data-page="knowledge">
-          <svg viewBox="0 0 24 24"><path d="M10 4H2v16h20V6H12l-2-2zm10 14H4V6h5.17l2 2H20v10zM8 10h8v2H8v-2zm0 4h6v2H8v-2z"/></svg>
-          <span>知识与任务</span>
+      </div>
+      <div class="nav-group">
+        <div class="nav-label">社群管理</div>
+        <a class="nav-item" href="#group-ops" data-page="group-ops">
+          <svg viewBox="0 0 24 24"><path d="M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm3 4v2h10V7H7zm0 4v2h6v-2H7zm0 4v2h4v-2H7zm8-3 2 2 3-4 1.5 1.2L17.2 17 13.7 13.5 15 12z"/></svg>
+          <span>社群运营</span>
+        </a>
+        <a class="nav-item" href="#groups" data-page="groups">
+          <svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
+          <span>客户群</span>
         </a>
       </div>
       <div class="nav-group">
         <div class="nav-label">系统</div>
-        <a class="nav-item" href="#skills" data-page="skills">
-          <svg viewBox="0 0 24 24"><path d="M19 13h-2V7h-6V5H5c-1.1 0-2 .9-2 2v6h2v6c0 1.1.9 2 2 2h6v-2H7v-6h6v2h6c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2h-6v2h6v6zM9 9H5V7h4v2zm10 4h-4V7h4v6z"/></svg>
-          <span>数字员工能力</span>
-        </a>
         <a class="nav-item" href="#status" data-page="status">
           <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>
           <span>连接与设置</span>
@@ -100,6 +107,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=20260726-voice"></script>
+<script src="/dashboard/app.js?v=20260807-voice-preview-persist"></script>
 </body>
 </html>

+ 144 - 16
mcp/src/dashboard/server.js

@@ -4,7 +4,7 @@ const path = require('path');
 const { URL } = require('url');
 const { outputsRoot, categoryDir } = require('../core/output-paths');
 const { resolveWorkspaceRoot, workspaceIdentity } = require('../core/runtime-context');
-const { readQiweiUid } = require('../core/credentials');
+const { readQiweiUid, qiweiAccountKey } = require('../core/credentials');
 const {
   qiweiSyncExternalGroups,
   qiweiListExternalGroups,
@@ -74,6 +74,12 @@ const {
   retryOnboardingWelcome,
   getConversations,
   getResponseMonitor,
+  getGroupAgents,
+  changeGroupMode,
+  generateGroupReply,
+  approveGroupDraft,
+  rejectGroupDraft,
+  regenerateGroupDraft,
   syncConversations,
   changeGlobalMode,
   changeConversationMode,
@@ -88,6 +94,9 @@ const {
   revokeVoiceProfile,
   sendClonedVoice,
   getSentVoiceAudio,
+  resolveMessageVoiceAudio,
+  resolveMessageMedia,
+  getMessageExternalLink,
   updateCustomerTask,
   syncCustomerTaskToOfficialTodo,
   updateCustomerAlert,
@@ -95,7 +104,9 @@ const {
   updateCustomerMemory,
   getAudit,
   startListener,
-  stopListener
+  stopListener,
+  openCustomerSession,
+  getSessionTranscript
 } = require('./agent-service');
 const {
   listKnowledgeTree,
@@ -175,9 +186,7 @@ async function getCachedSubscriptionStatus({ force = false } = {}) {
 void getCachedSubscriptionStatus().catch(() => {});
 
 const CUSTOMER_DIRECTORY_CACHE_TTL_MS = 60 * 1000;
-let customerDirectoryCache = null;
-let customerDirectoryCachedAt = 0;
-let customerDirectoryInFlight = null;
+const customerDirectoryCaches = new Map();
 
 async function buildDashboardCustomerDirectory() {
   const merged = new Map();
@@ -230,17 +239,22 @@ async function buildDashboardCustomerDirectory() {
 }
 
 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()
+  const key = qiweiAccountKey();
+  const cached = customerDirectoryCaches.get(key) || {};
+  const fresh = cached.value && Date.now() - cached.cachedAt < CUSTOMER_DIRECTORY_CACHE_TTL_MS;
+  if (!force && fresh) return cached.value;
+  if (cached.inFlight) return cached.inFlight;
+  const inFlight = buildDashboardCustomerDirectory()
     .then(customers => {
-      customerDirectoryCache = customers;
-      customerDirectoryCachedAt = Date.now();
+      customerDirectoryCaches.set(key, { value: customers, cachedAt: Date.now(), inFlight: null });
       return customers;
     })
-    .finally(() => { customerDirectoryInFlight = null; });
-  return customerDirectoryInFlight;
+    .finally(() => {
+      const current = customerDirectoryCaches.get(key) || {};
+      if (current.inFlight === inFlight) customerDirectoryCaches.set(key, { ...current, inFlight: null });
+    });
+  customerDirectoryCaches.set(key, { ...cached, inFlight });
+  return inFlight;
 }
 
 // 预热共享客户目录,画像、交接和客户选择器进入时可直接复用。
@@ -409,6 +423,71 @@ function serveSentVoiceAudio(req, res, requested) {
   fs.createReadStream(resolved, { start, end }).pipe(res);
 }
 
+function contentDisposition(filename, download = false) {
+  const original = path.basename(String(filename || 'media'));
+  const safe = original.replace(/[^A-Za-z0-9._-]/g, '_') || 'media';
+  const encoded = encodeURIComponent(original).replace(/['()]/g, escape);
+  return `${download ? 'attachment' : 'inline'}; filename="${safe}"; filename*=UTF-8''${encoded}`;
+}
+
+function serveMessageMedia(req, res, media, { download = false } = {}) {
+  const resolved = path.resolve(String(media?.filePath || ''));
+  const root = path.resolve(categoryDir('dashboard'));
+  const relative = path.relative(root, resolved);
+  if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || !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': media.contentType || 'application/octet-stream',
+    'Content-Length': end - start + 1,
+    'Accept-Ranges': 'bytes',
+    'Cache-Control': 'private, max-age=86400',
+    'Content-Disposition': contentDisposition(media.filename, download),
+    'X-Content-Type-Options': 'nosniff',
+  };
+  if (range) headers['Content-Range'] = `bytes ${start}-${end}/${size}`;
+  res.writeHead(range ? 206 : 200, headers);
+  fs.createReadStream(resolved, { start, end }).pipe(res);
+}
+
+const wechatEmojiCache = new Map();
+
+async function serveWechatEmoji(res, index) {
+  if (!/^\d{1,2}$/.test(String(index)) || Number(index) > 99) {
+    json(res, 404, { status: 'error', message: '表情不存在' });
+    return;
+  }
+  let buffer = wechatEmojiCache.get(String(index));
+  if (!buffer) {
+    const response = await fetch(`https://res.wx.qq.com/mpres/htmledition/images/icon/emotion/${index}.gif`, {
+      signal: AbortSignal.timeout(8000),
+      headers: { 'User-Agent': 'fmode-qiwei-dashboard/1.0' },
+    });
+    if (!response.ok) throw new Error(`微信表情资源请求失败 (${response.status})`);
+    buffer = Buffer.from(await response.arrayBuffer());
+    if (!buffer.length || buffer.length > 128 * 1024) throw new Error('微信表情资源无效');
+    wechatEmojiCache.set(String(index), buffer);
+  }
+  res.writeHead(200, { 'Content-Type': 'image/gif', 'Content-Length': buffer.length, 'Cache-Control': 'public, max-age=604800, immutable', 'X-Content-Type-Options': 'nosniff' });
+  res.end(buffer);
+}
+
 function getContentType(filePath) {
   const ext = path.extname(filePath).toLowerCase();
   const map = {
@@ -656,6 +735,30 @@ async function handleRequest(req, res) {
       json(res, 200, getConversations());
       return;
     }
+    if (pathname === '/api/agent/groups' && req.method === 'GET') {
+      json(res, 200, getGroupAgents());
+      return;
+    }
+    const groupAgentModeRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/mode$/);
+    if (groupAgentModeRoute && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, changeGroupMode(decodeURIComponent(groupAgentModeRoute[1]), body.mode, body.confirmation));
+      return;
+    }
+    const groupAgentGenerateRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/generate$/);
+    if (groupAgentGenerateRoute && req.method === 'POST') {
+      json(res, 200, await generateGroupReply(decodeURIComponent(groupAgentGenerateRoute[1]), await readBody(req)));
+      return;
+    }
+    const groupAgentDraftRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/drafts\/([^/]+)\/(approve|reject|regenerate)$/);
+    if (groupAgentDraftRoute && req.method === 'POST') {
+      const [, roomId, draftId, action] = groupAgentDraftRoute;
+      const body = await readBody(req);
+      if (action === 'approve') json(res, 200, await approveGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId), body.content));
+      else if (action === 'reject') json(res, 200, rejectGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId), body.reason));
+      else json(res, 200, await regenerateGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId)));
+      return;
+    }
     if (pathname === '/api/agent/response-monitor' && req.method === 'GET') {
       json(res, 200, getResponseMonitor());
       return;
@@ -709,7 +812,7 @@ async function handleRequest(req, res) {
       json(res, 200, updateCustomerMemory(decodeURIComponent(customerMemoryRoute[1]), await readBody(req)));
       return;
     }
-    const agentConversationRoute = pathname.match(/^\/api\/agent\/conversations\/([^/]+)\/(takeover|resume|pause|auto|autopilot|approve-reply|generate|manual-send)$/);
+    const agentConversationRoute = pathname.match(/^\/api\/agent\/conversations\/([^/]+)\/(takeover|resume|pause|auto|autopilot|approve-reply|generate|manual-send|open-session)$/);
     if (agentConversationRoute && req.method === 'POST') {
       const [, conversationId, action] = agentConversationRoute;
       const body = await readBody(req);
@@ -720,6 +823,7 @@ async function handleRequest(req, res) {
       else if (action === 'autopilot') json(res, 200, changeConversationMode(conversationId, 'autopilot', body.confirmation));
       else if (action === 'generate') json(res, 200, await generateLatestDraft(conversationId));
       else if (action === 'manual-send') json(res, 200, await manualSend(conversationId, body.content));
+      else if (action === 'open-session') json(res, 200, openCustomerSession(conversationId));
       else json(res, 200, await approveReply(conversationId, body.content));
       return;
     }
@@ -732,8 +836,32 @@ async function handleRequest(req, res) {
     }
     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);
+      const audio = await resolveMessageVoiceAudio(decodeURIComponent(voiceAudioRoute[1]));
+      if (audio.source === 'sent') serveSentVoiceAudio(req, res, audio.filePath);
+      else serveMessageMedia(req, res, audio);
+      return;
+    }
+    const sessionTranscriptRoute = pathname.match(/^\/api\/agent\/conversations\/([^/]+)\/session-transcript$/);
+    if (sessionTranscriptRoute && req.method === 'GET') {
+      json(res, 200, getSessionTranscript(decodeURIComponent(sessionTranscriptRoute[1])));
+      return;
+    }
+    const wechatEmojiRoute = pathname.match(/^\/api\/agent\/wechat-emojis\/(\d{1,2})$/);
+    if (wechatEmojiRoute && req.method === 'GET') {
+      await serveWechatEmoji(res, wechatEmojiRoute[1]);
+      return;
+    }
+    const messageMediaRoute = pathname.match(/^\/api\/agent\/messages\/([^/]+)\/media$/);
+    if (messageMediaRoute && req.method === 'GET') {
+      const media = await resolveMessageMedia(decodeURIComponent(messageMediaRoute[1]), url.searchParams.get('variant') || 'original');
+      serveMessageMedia(req, res, media, { download: url.searchParams.get('download') === '1' });
+      return;
+    }
+    const messageOpenRoute = pathname.match(/^\/api\/agent\/messages\/([^/]+)\/open$/);
+    if (messageOpenRoute && req.method === 'GET') {
+      const target = await getMessageExternalLink(decodeURIComponent(messageOpenRoute[1]));
+      res.writeHead(302, { Location: target, 'Cache-Control': 'no-store' });
+      res.end();
       return;
     }
     const agentDraftRoute = pathname.match(/^\/api\/agent\/drafts\/([^/]+)\/(approve|reject|regenerate)$/);

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 613 - 86
mcp/src/dashboard/styles.css


+ 15 - 2
mcp/src/providers/fmode-agent-transport.js

@@ -106,6 +106,7 @@ class FmodeQiweiClient {
       apiBase: ctx.apiBase,
       timeoutMs: 30000,
       networkAttempts: options.networkAttempts,
+      preserveExternalUrls: Boolean(options.preserveExternalUrls),
     });
     return result.data && result.data.data !== undefined ? result.data.data : (result.data || {});
   }
@@ -161,7 +162,19 @@ class FmodeQiweiClient {
   }
 
   syncMessages(msgSeq, limit) {
-    return this.call('/msg/syncMsg', { msgSeq, limit });
+    return this.call('/msg/syncMsg', { msgSeq, limit }, { preserveExternalUrls: true });
+  }
+
+  downloadMessageMedia(params) {
+    return this.call('/cloud/wxDownload', params, { preserveExternalUrls: true, networkAttempts: 2 });
+  }
+
+  downloadWorkMessageMedia(params) {
+    return this.call('/cloud/wxWorkDownload', params, { preserveExternalUrls: true, networkAttempts: 2 });
+  }
+
+  convertCdnMessageMedia(params) {
+    return this.call('/cloud/cdnWxDownload', params, { preserveExternalUrls: true, networkAttempts: 2 });
   }
 
   async listExternalContacts({ limit = 500, maxPages = 10 } = {}) {
@@ -332,7 +345,7 @@ class FmodeQiweiClient {
           directError = new Error('Fmode 网关尚未开放 /doFileApi');
           break;
         }
-        directError = new Error(parsed.message || parsed.msg || `媒体上传失败(HTTP ${response.status})`);
+        directError = new Error(parsed.mess || parsed.message || parsed.msg || `媒体上传失败(HTTP ${response.status})`);
         const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
         if (!retryable) break;
       } catch (error) {

+ 6 - 2
mcp/src/providers/fmode-wecom-gateway.js

@@ -6,6 +6,8 @@ function redactSecret(value) {
     .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
     .replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
     .replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
+    .replace(/("(?:apiKey|masterKey|password|secret|sessionToken|accessToken|refreshToken)"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
+    .replace(/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|GUID)[A-Z0-9_]*)\s*=\s*([^\s\r\n]+)/g, '$1=[REDACTED]')
     .replace(/https?:\/\/[^\s"'))]*/gi, '[外部链接]')
     .replace(/\btokenId\b/gi, '服务凭据')
     .replace(/\bguid\b/gi, '设备标识');
@@ -109,7 +111,8 @@ async function callFmodeWecomGateway({
   apiBase,
   timeoutMs = 60000,
   networkAttempts = 4,
-  cacheBust = false
+  cacheBust = false,
+  preserveExternalUrls = false
 }) {
   if (!gatewayPath) {
     const err = new Error('missing gatewayPath');
@@ -179,7 +182,8 @@ async function callFmodeWecomGateway({
     throw err;
   }
 
-  const safeJson = sanitizePayload(json);
+  // Signed media URLs are retained only for trusted internal consumers.
+  const safeJson = preserveExternalUrls ? json : sanitizePayload(json);
   return {
     httpStatus: response.status,
     json: safeJson,

+ 4 - 3
mcp/src/tools/qiwei-broker-playbook-run.js

@@ -3,6 +3,7 @@ const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { createRunDir, outputsRoot, writeRunManifest } = require('../core/output-paths');
 const { safeResult } = require('../core/shared-gateway');
+const { qiweiAccountKey } = require('../core/credentials');
 const { writeObjectRows } = require('../core/xlsx-io');
 
 const PLAYBOOK_DIMENSIONS = [
@@ -57,7 +58,7 @@ function writePlaybook(brokerUserId, playbook) {
 }
 
 function listMessageFiles() {
-  const dir = path.join(outputsRoot(), 'messages');
+  const dir = path.join(outputsRoot(), 'messages', qiweiAccountKey());
   if (!fs.existsSync(dir)) return [];
   return fs.readdirSync(dir)
     .filter(f => f.startsWith('room-') && f.endsWith('.json'))
@@ -71,7 +72,7 @@ function extractRoomIdFromFileName(fileName) {
 }
 
 function listRoomIdsFromSubdirectories() {
-  const messagesRoot = path.join(outputsRoot(), 'messages');
+  const messagesRoot = path.join(outputsRoot(), 'messages', qiweiAccountKey());
   const roots = [path.join(messagesRoot, 'rooms'), messagesRoot];
   const result = new Set();
   for (const dir of roots) {
@@ -86,7 +87,7 @@ function listRoomIdsFromSubdirectories() {
 }
 
 function listRoomMessageFiles(roomId) {
-  const messagesRoot = path.join(outputsRoot(), 'messages');
+  const messagesRoot = path.join(outputsRoot(), 'messages', qiweiAccountKey());
   const dirs = [path.join(messagesRoot, 'rooms', roomId), path.join(messagesRoot, roomId)];
   const files = dirs.flatMap(dir => !fs.existsSync(dir) ? [] : fs.readdirSync(dir)
     .filter(f => f.endsWith('.json'))

+ 2 - 1
mcp/src/tools/qiwei-customer-ops-run.js

@@ -2,6 +2,7 @@ const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { outputsRoot } = require('../core/output-paths');
+const { qiweiAccountKey } = require('../core/credentials');
 const { readWorksheetRows } = require('../core/xlsx-io');
 const {
   buildContext,
@@ -381,7 +382,7 @@ function pollingIntervalText(ageMinutes, searchStatus) {
 
 function readSavedPortrait(externalUserId) {
   if (!externalUserId) return null;
-  const filePath = path.join(outputsRoot(), 'portraits', `${externalUserId}.json`);
+  const filePath = path.join(outputsRoot(), 'portraits', qiweiAccountKey(), `${externalUserId}.json`);
   try {
     if (!fs.existsSync(filePath)) return null;
     return JSON.parse(fs.readFileSync(filePath, 'utf8'));

+ 2 - 1
mcp/src/tools/qiwei-customer-transfer-run.js

@@ -1,5 +1,6 @@
 const fs = require('fs');
 const path = require('path');
+const { qiweiAccountKey } = require('../core/credentials');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { createRunDir, outputsRoot, writeRunManifest } = require('../core/output-paths');
 const { recordTransferPreview, recordTransferExecution } = require('../core/dashboard-state');
@@ -50,7 +51,7 @@ function readPreviewFile(fileNameOrId) {
 }
 
 function readConfirmedMapping() {
-  const filePath = path.join(outputsRoot(), 'groups', 'confirmed-mapping.json');
+  const filePath = path.join(outputsRoot(), 'groups', qiweiAccountKey(), 'confirmed-mapping.json');
   if (!fs.existsSync(filePath)) return {};
   try {
     return JSON.parse(fs.readFileSync(filePath, 'utf8'));

+ 9 - 6
mcp/src/tools/qiwei-group-management-run.js

@@ -4,6 +4,7 @@ const { okResult, errorResult } = require('../core/result-envelope');
 const { createRunDir, latestPath, outputsRoot, writeRunManifest } = require('../core/output-paths');
 const { recordCustomersFromGroups } = require('../core/dashboard-state');
 const { getGroupOperationsStore } = require('../core/group-operations-store');
+const { qiweiAccountKey } = require('../core/credentials');
 const {
   buildContext,
   gatewayCall,
@@ -41,11 +42,11 @@ function decodeRoomName(raw) {
 }
 
 function groupsDir() {
-  return path.join(outputsRoot(), 'groups');
+  return path.join(outputsRoot(), 'groups', qiweiAccountKey());
 }
 
 function messagesDir() {
-  return path.join(outputsRoot(), 'messages');
+  return path.join(outputsRoot(), 'messages', qiweiAccountKey());
 }
 
 function roomMessagesRoot() {
@@ -435,7 +436,7 @@ function listGroupFiles() {
 }
 
 function readLatestRooms() {
-  const latestFile = latestPath('groups', 'rooms-latest.json');
+  const latestFile = path.join(groupsDir(), 'rooms-latest.json');
   if (fs.existsSync(latestFile)) {
     const latest = safeReadJson(latestFile, null);
     if (Array.isArray(latest)) return latest;
@@ -639,7 +640,8 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
   fs.writeFileSync(filePath, JSON.stringify(classifiedRooms, null, 2), 'utf8');
   const manifestPath = writeRunManifest(runDir, manifest);
 
-  const latestFile = latestPath('groups', 'rooms-latest.json');
+  ensureGroupsDir();
+  const latestFile = path.join(groupsDir(), 'rooms-latest.json');
   const mergedSnapshot = mergeRoomSnapshots(readLatestRooms(), classifiedRooms);
   fs.writeFileSync(latestFile, JSON.stringify(mergedSnapshot, null, 2), 'utf8');
 
@@ -788,12 +790,13 @@ const qiweiAnalyzeGroupMembers = safeResult(async function qiweiAnalyzeGroupMemb
   }
 
   if (updateSnapshot) {
+    ensureGroupsDir();
     const snapshot = readLatestRooms();
     const snapshotMap = new Map(snapshot.map(r => [r.roomId, r]));
     for (const room of classified) {
       snapshotMap.set(room.roomId, room);
     }
-    fs.writeFileSync(latestPath('groups', 'rooms-latest.json'), JSON.stringify(Array.from(snapshotMap.values()), null, 2), 'utf8');
+    fs.writeFileSync(path.join(groupsDir(), 'rooms-latest.json'), JSON.stringify(Array.from(snapshotMap.values()), null, 2), 'utf8');
   }
 
   const discoveredCustomers = recordCustomersFromGroups(classified, { source: 'group-analysis' });
@@ -870,7 +873,7 @@ const qiweiConfirmExternalGroup = safeResult(async function qiweiConfirmExternal
       }
     : item);
   const discoveredCustomers = recordCustomersFromGroups(roomsForDiscovery, { source: 'group-confirm' });
-  const operationAccountKey = String(input.accountKey || input.guid || input.uid || '').trim();
+  const operationAccountKey = qiweiAccountKey();
   if (operationAccountKey) {
     getGroupOperationsStore().upsertGroup(operationAccountKey, {
       roomId,

+ 2 - 1
mcp/src/tools/qiwei-group-operations-run.js

@@ -1,12 +1,13 @@
 const { okResult, errorResult } = require('../core/result-envelope');
 const { safeResult, buildContext, gatewayCall, requireGuid, assertMethodsInCatalog } = require('../core/shared-gateway');
 const { getGroupOperationsStore, accountKey } = require('../core/group-operations-store');
+const { qiweiAccountKey } = require('../core/credentials');
 const { ROLE_CAPABILITIES, authorizeGroupOps } = require('../core/group-operations-access');
 const { qiweiListExternalGroups } = require('./qiwei-group-management-run');
 
 function context(input = {}) {
   return {
-    key: accountKey(input.accountKey || input.guid || input.uid),
+    key: accountKey(input.accountKey || qiweiAccountKey(input)),
     actor: String(input.actor || 'agent'),
     principal: String(input.requester || input.principal || input.actor || 'agent')
   };

+ 24 - 4
mcp/src/tools/qiwei-login-run.js

@@ -181,6 +181,19 @@ async function qiweiLoginStatus(input = {}) {
     });
     const data = result.data || {};
     const online = Boolean(data.online);
+    const detail = data.detail || {};
+    if (online && detail.userId && input.persistConfig !== false) {
+      const resolvedGuid = String(detail.guid || guid || '').trim();
+      saveQiweiClientConfig({
+        uid,
+        guid: resolvedGuid,
+        apiBase,
+        userId: detail.userId,
+        nickname: detail.nickname,
+        corpName: detail.corpName,
+      });
+      if (resolvedGuid) recordDeviceGuid(resolvedGuid, { wecomUserId: detail.userId, nickname: detail.nickname });
+    }
     return okResult({
       assistantMessage: data.configured
         ? online
@@ -198,7 +211,7 @@ async function qiweiLoginStatus(input = {}) {
         configured: Boolean(data.configured),
         online,
         statusCode: data.statusCode ?? null,
-        detail: data.detail
+        detail
       },
       nextActions: online ? [] : ['调用 qiwei_login_start 开始扫码登录']
     });
@@ -426,9 +439,16 @@ async function qiweiLoginCheck(input = {}) {
         };
       }
       const guid = String(detail.guid || readQiweiGuid(input) || '').trim();
-      if (guid && input.persistConfig !== false) {
-        saveQiweiClientConfig({ guid, apiBase });
-        recordDeviceGuid(guid, { wecomUserId: detail.userId, nickname: detail.nickname });
+      if (input.persistConfig !== false) {
+        saveQiweiClientConfig({
+          uid,
+          guid,
+          apiBase,
+          userId: detail.userId,
+          nickname: detail.nickname,
+          corpName: detail.corpName,
+        });
+        if (guid) recordDeviceGuid(guid, { wecomUserId: detail.userId, nickname: detail.nickname });
       }
 
       let relaySetup = null;

+ 6 - 5
mcp/src/tools/qiwei-portrait-tags-run.js

@@ -5,6 +5,7 @@ const { createRunDir, outputsRoot, writeRunManifest } = require('../core/output-
 const { writeObjectRows } = require('../core/xlsx-io');
 const { readState, recordPortrait, removePortrait, recordTags, recordOperation } = require('../core/dashboard-state');
 const { analyzePortraitWithLlm } = require('../core/llm-client');
+const { qiweiAccountKey } = require('../core/credentials');
 const {
   buildContext,
   gatewayCall,
@@ -34,11 +35,11 @@ const PORTRAIT_KEYWORDS = {
 };
 
 function portraitsDir() {
-  return path.join(outputsRoot(), 'portraits');
+  return path.join(outputsRoot(), 'portraits', qiweiAccountKey());
 }
 
 function tagsDir() {
-  return path.join(outputsRoot(), 'tags');
+  return path.join(outputsRoot(), 'tags', qiweiAccountKey());
 }
 
 function portraitsQueuePath() {
@@ -161,7 +162,7 @@ function writeTags(externalUserId, tags) {
 }
 
 function listMessageFiles() {
-  const dir = path.join(outputsRoot(), 'messages');
+  const dir = path.join(outputsRoot(), 'messages', qiweiAccountKey());
   if (!fs.existsSync(dir)) return [];
   return fs.readdirSync(dir)
     .filter(f => f.startsWith('room-') && f.endsWith('.json'))
@@ -175,7 +176,7 @@ function extractRoomIdFromFileName(fileName) {
 }
 
 function listRoomIdsFromSubdirectories() {
-  const messagesRoot = path.join(outputsRoot(), 'messages');
+  const messagesRoot = path.join(outputsRoot(), 'messages', qiweiAccountKey());
   const roots = [path.join(messagesRoot, 'rooms'), messagesRoot];
   const result = new Set();
   for (const dir of roots) {
@@ -190,7 +191,7 @@ function listRoomIdsFromSubdirectories() {
 }
 
 function listRoomMessageFiles(roomId) {
-  const messagesRoot = path.join(outputsRoot(), 'messages');
+  const messagesRoot = path.join(outputsRoot(), 'messages', qiweiAccountKey());
   const dirs = [path.join(messagesRoot, 'rooms', roomId), path.join(messagesRoot, roomId)];
   const files = dirs.flatMap(dir => !fs.existsSync(dir) ? [] : fs.readdirSync(dir)
     .filter(f => f.endsWith('.json'))

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
package.json


+ 63 - 0
scripts/account-isolation-smoke-test.js

@@ -0,0 +1,63 @@
+#!/usr/bin/env node
+'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-account-isolation-'));
+process.env.QIWEI_OUTPUTS_DIR = path.join(root, 'outputs');
+process.env.QIWEI_MESSAGES_DIR = path.join(root, 'messages');
+process.env.QIWEI_LEGACY_ACCOUNT_KEY = 'legacy-default';
+process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED = '1';
+process.env.QIWEI_AGENT_MEMORY_ENABLED = '1';
+
+const { setActiveQiweiContext, qiweiAccountKey } = require('../mcp/src/core/credentials');
+const { writeCustomer, readCustomerById, customerFilePath } = require('../mcp/src/core/customer-broker-store');
+const { appendChatRecord, ensureMemory, memoryRelBase, drainQueues } = require('../mcp/src/core/message-archive');
+const { writeConfirmedMapping, readConfirmedMapping } = require('../mcp/src/tools/qiwei-group-management-run');
+
+async function run() {
+  const accountA = { uid: 'shared-device', userId: 'wecom-user-a', nickname: 'A' };
+  const accountB = { uid: 'shared-device', userId: 'wecom-user-b', nickname: 'B' };
+  const keyA = qiweiAccountKey(accountA);
+  const keyB = qiweiAccountKey(accountB);
+  assert.notEqual(keyA, keyB);
+
+  setActiveQiweiContext(accountA);
+  writeCustomer({ customerId: 'customer-1', name: 'Customer A' });
+  appendChatRecord({ wxid: 'customer-1', content: 'A', createdAt: '2026-08-04T10:00:00.000Z' });
+  ensureMemory('customer-1', 'Customer A');
+  writeConfirmedMapping({ 'room-a': { roomId: 'room-a' } });
+  const customerAPath = customerFilePath('customer-1');
+  const memoryA = memoryRelBase('customer-1');
+
+  setActiveQiweiContext(accountB);
+  assert.equal(readCustomerById('customer-1'), null);
+  assert.deepEqual(readConfirmedMapping(), {});
+  writeCustomer({ customerId: 'customer-1', name: 'Customer B' });
+  appendChatRecord({ wxid: 'customer-1', content: 'B', createdAt: '2026-08-04T10:00:00.000Z' });
+  ensureMemory('customer-1', 'Customer B');
+  writeConfirmedMapping({ 'room-b': { roomId: 'room-b' } });
+  const customerBPath = customerFilePath('customer-1');
+  const memoryB = memoryRelBase('customer-1');
+  await drainQueues();
+
+  assert.notEqual(customerAPath, customerBPath);
+  assert.notEqual(memoryA, memoryB);
+  assert.equal(JSON.parse(fs.readFileSync(customerAPath, 'utf8')).name, 'Customer A');
+  assert.equal(JSON.parse(fs.readFileSync(customerBPath, 'utf8')).name, 'Customer B');
+  assert(fs.existsSync(path.join(root, 'messages', 'chat', keyA, 'customer-1', '2026-08-04.json')));
+  assert(fs.existsSync(path.join(root, 'messages', 'chat', keyB, 'customer-1', '2026-08-04.json')));
+  assert(fs.existsSync(path.join(root, 'outputs', 'groups', keyA, 'confirmed-mapping.json')));
+  assert(fs.existsSync(path.join(root, 'outputs', 'groups', keyB, 'confirmed-mapping.json')));
+  console.log(`account isolation smoke test passed (${keyA} != ${keyB})`);
+}
+
+run()
+  .finally(() => fs.rmSync(root, { recursive: true, force: true }))
+  .catch(error => {
+    console.error(error);
+    process.exitCode = 1;
+  });

+ 4 - 1
scripts/account-switch-smoke-test.js

@@ -19,6 +19,8 @@ async function main() {
   const previousEnv = { ...process.env };
   const originalFetch = global.fetch;
   const requests = [];
+  let switchActiveAccount;
+  let closeAgentWorkbenches = () => {};
 
   try {
     process.env.USERPROFILE = tempRoot;
@@ -91,7 +93,7 @@ async function main() {
     assert.equal(loggedIn.data.guid, 'guid-account-b');
     assert.equal(Object.prototype.hasOwnProperty.call(requests.at(-1).body, 'guid'), false);
 
-    const { switchActiveAccount } = require(path.join(packageRoot, 'mcp', 'src', 'dashboard', 'agent-service'));
+    ({ switchActiveAccount, closeAgentWorkbenches } = require(path.join(packageRoot, 'mcp', 'src', 'dashboard', 'agent-service')));
     await switchActiveAccount({
       uid: 'uid-account-b',
       guid: loggedIn.data.guid,
@@ -135,6 +137,7 @@ async function main() {
       ],
     }, null, 2)}\n`);
   } finally {
+    closeAgentWorkbenches();
     global.fetch = originalFetch;
     process.chdir(previousCwd);
     for (const key of Object.keys(process.env)) {

+ 27 - 2
scripts/agent-console-smoke-test.js

@@ -323,8 +323,8 @@ async function main() {
 
   await check('多企微账号使用独立工作台数据库和 Claude Session', async () => {
     const { __testing } = require('../mcp/src/dashboard/agent-service');
-    const accountA = { uid: 'account-a', guid: 'guid-a', nickname: '账号 A' };
-    const accountB = { uid: 'account-b', guid: 'guid-b', nickname: '账号 B' };
+    const accountA = { uid: 'device-a', guid: 'guid-a', userId: 'account-a', nickname: '账号 A' };
+    const accountB = { uid: 'device-b', guid: 'guid-b', userId: 'account-b', nickname: '账号 B' };
     const keyA = __testing.accountRuntimeKey(accountA);
     const keyB = __testing.accountRuntimeKey(accountB);
     const configA = __testing.accountWorkbenchOverrides(accountA);
@@ -717,6 +717,31 @@ async function main() {
     } finally { ctx.close(); }
   });
 
+  await check('global policy updates every conversation while a conversation policy stays local', async () => {
+    const ctx = setup();
+    try {
+      const first = ctx.db.ensureConversation('contact-1', 'Customer One');
+      const second = ctx.db.ensureConversation('contact-2', 'Customer Two');
+
+      ctx.service.setConversationMode(first.id, 'human');
+      ctx.service.setGlobal({ paused: false, defaultMode: 'autopilot' });
+      assert.equal(ctx.db.getConversation(first.id).mode, 'autopilot');
+      assert.equal(ctx.db.getConversation(second.id).mode, 'autopilot');
+
+      ctx.service.setConversationMode(first.id, 'review');
+      assert.equal(ctx.db.getConversation(first.id).mode, 'review');
+      assert.equal(ctx.db.getConversation(second.id).mode, 'autopilot');
+
+      const third = ctx.db.ensureConversation('contact-3', 'Customer Three');
+      assert.equal(third.mode, 'autopilot');
+
+      ctx.service.setGlobal({ paused: true });
+      assert.ok(ctx.db.listConversations().every(item => item.mode === 'paused'));
+      const createdWhilePaused = ctx.db.ensureConversation('contact-4', 'Customer Four');
+      assert.equal(createdWhilePaused.mode, 'paused');
+    } finally { ctx.close(); }
+  });
+
   await check('Agent 上游失败只留审计,不生成伪回复、不外发', async () => {
     const ctx = setup({ agentRun: async () => { throw new Error('Agent 上游暂时不可用(HTTP 522)'); } });
     try {

+ 24 - 110
scripts/agent-workspace-ui-smoke-test.js

@@ -1,120 +1,34 @@
+'use strict';
+
 const assert = require('assert/strict');
+const crypto = require('crypto');
 const fs = require('fs');
 const path = require('path');
 
-const root = path.resolve(__dirname, '..');
-const app = fs.readFileSync(path.join(root, 'mcp', 'src', 'dashboard', 'app.js'), 'utf8');
-const styles = fs.readFileSync(path.join(root, 'mcp', 'src', 'dashboard', 'styles.css'), 'utf8');
-const results = [];
+const packageRoot = path.resolve(__dirname, '..');
+const sourceRoot = process.env.AGENT_HOUSE_DASHBOARD_DIR
+  ? path.resolve(process.env.AGENT_HOUSE_DASHBOARD_DIR)
+  : path.resolve(packageRoot, '..', 'agent-house', '.claude', 'plugins', 'qiwei-assistant', 'mcp', 'src', 'dashboard');
+const dashboardRoot = path.join(packageRoot, 'mcp', 'src', 'dashboard');
+const assets = ['index.html', 'app.js', 'styles.css'];
 
-function check(name, fn) {
-  fn();
-  results.push({ name, status: 'passed' });
+function digest(filePath) {
+  return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
 }
 
-function functionSource(name, nextName) {
-  const start = app.indexOf(`function ${name}`);
-  const end = app.indexOf(`function ${nextName}`, start + 1);
-  assert.ok(start >= 0 && end > start, `missing function source: ${name}`);
-  return app.slice(start, end);
+assert(fs.existsSync(sourceRoot), `agent-house dashboard source not found: ${sourceRoot}`);
+for (const asset of assets) {
+  const source = path.join(sourceRoot, asset);
+  const target = path.join(dashboardRoot, asset);
+  assert.equal(digest(target), digest(source), `${asset} is not synchronized with agent-house`);
 }
 
-check('active Agent view uses a compact command bar and drawers', () => {
-  const source = functionSource('renderAgentWorkspaceV2', 'responseMonitorRenderSignature');
-  assert.match(source, /agent-commandbar/);
-  assert.match(source, /agent-drawer settings/);
-  assert.match(source, /agent-drawer reminders/);
-  assert.doesNotMatch(source, /agent-hero|agent-modebar/);
-});
-
-check('skills workspace does not receive Agent-only drawers', () => {
-  const source = functionSource('renderSkillsWorkspace', 'renderSkillsPage');
-  assert.doesNotMatch(source, /agent-drawer|renderAgentIntakeSettings|renderResponseMonitor/);
-});
-
-check('workspace reserves viewport height and internal scrolling', () => {
-  assert.match(styles, /\.agent-page\s*\{[\s\S]*?height:\s*calc\(100dvh\s*-\s*144px\)/);
-  assert.match(styles, /\.agent-workspace\s*\{[\s\S]*?min-height:\s*0;[\s\S]*?overflow:\s*hidden;/);
-  assert.match(styles, /\.agent-list-scroll\s*\{[^}]*overflow-y:\s*auto;/);
-  assert.match(styles, /\.agent-message-stream\s*\{[\s\S]*?overflow-y:\s*auto;/);
-  assert.match(styles, /\.agent-insight-panel\s*\{[\s\S]*?overflow-y:\s*auto;/);
-});
-
-check('short desktop viewports keep the chat column bounded and usable', () => {
-  assert.match(styles, /\.agent-chat-panel\s*>\s*\*\s*\{\s*min-width:\s*0;/);
-  assert.match(styles, /@media\s*\(min-width:\s*761px\)\s*and\s*\(max-height:\s*760px\)/);
-  assert.match(styles, /\.agent-composer-actions\s*\{\s*align-items:\s*stretch;\s*flex-direction:\s*column;/);
-  assert.match(styles, /\.agent-review-actions\s*\{\s*flex-wrap:\s*nowrap;/);
-});
-
-check('mobile layout is bounded and keeps controls reachable', () => {
-  const mobile = styles.slice(styles.indexOf('@media (max-width: 760px)'));
-  assert.match(mobile, /\.agent-workspace\s*\{[^}]*grid-template-columns:\s*1fr;/);
-  assert.match(mobile, /\.agent-command-actions\s*\{[^}]*overflow-x:\s*auto;/);
-  assert.match(mobile, /\.agent-drawer\s*\{[^}]*width:\s*100vw;/);
-  assert.match(mobile, /\.agent-chat-panel\s*\{[^}]*min-height:\s*720px;[^}]*minmax\(160px,\s*1fr\)/);
-  assert.match(styles, /\.page\.agent-page\s*\{\s*animation:\s*none;\s*transform:\s*none;/);
-});
-
-check('closed Agent drawers do not leave a gray page overlay', () => {
-  assert.match(styles, /\.content\s*>\s*\.agent-page\s*>\s*\*\s*\{\s*animation:\s*none;/);
-  assert.match(styles, /\.agent-drawer-backdrop\s*\{[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;[^}]*transition:\s*none;/);
-  assert.match(styles, /\.agent-drawer\s*\{[^}]*visibility:\s*hidden;[^}]*transition:\s*none;/);
-  assert.match(styles, /\.agent-drawer-backdrop\.open\s*\{[^}]*opacity:\s*1;[^}]*pointer-events:\s*auto;[^}]*transition:\s*none;/);
-  assert.match(styles, /\.agent-drawer\.open\s*\{[^}]*visibility:\s*visible;[^}]*transition:\s*none;/);
-});
-
-check('conversation ordering has explicit invalid-time and stable-tie behavior', () => {
-  assert.match(app, /Number\.NEGATIVE_INFINITY/);
-  assert.match(app, /b\.timestamp\s*-\s*a\.timestamp\s*\|\|\s*a\.index\s*-\s*b\.index/);
-  assert.match(app, /sortAgentItemsByLatest\(conversations\.filter/);
-});
-
-check('runtime account hydration restores fresh-browser identity and online state', () => {
-  const source = functionSource('hydrateCurrentAccountBinding', 'init');
-  assert.match(source, /if \(!active\.uid\) return false/);
-  assert.match(source, /addAccount\(\{/);
-  assert.match(source, /online:\s*Boolean\(active\.online\)/);
-  assert.match(source, /account\.online\s*=\s*Boolean\(active\.online\)/);
-  assert.doesNotMatch(source, /if \(!account\) return false;[\s\S]*?const result = await api/);
-});
-
-check('activity timeline uses existing trace and audit without raw JSON rendering', () => {
-  const source = functionSource('renderAgentActivityTimeline', 'scrollAgentMessagesToLatest');
-  assert.match(source, /Plan/);
-  assert.match(source, /Act/);
-  assert.match(source, /Observe/);
-  assert.match(source, /Evaluate/);
-  assert.doesNotMatch(source, /JSON\.stringify/);
-  assert.doesNotMatch(app, /JSON\.stringify\(viewModel\.toolTrace\[0\]/);
-});
-
-check('Agent theme is semantic and tenant-overridable', () => {
-  for (const token of ['brand', 'page-bg', 'panel', 'ink', 'line', 'focus', 'success', 'warning', 'error']) {
-    assert.match(styles, new RegExp(`--agent-${token}:`));
-  }
-  assert.match(styles, /\.agent-page\[data-theme="xiaoniu"\]/);
-  assert.match(app, /page\.dataset\.theme/);
-});
-
-check('intake settings use the fixed API contract and explicit confirmation', () => {
-  assert.match(app, /\/api\/agent\/intake-policy/);
-  assert.match(app, /welcomeEnabled/);
-  assert.match(app, /welcomeText/);
-  assert.match(app, /welcomeSendMode/);
-  assert.match(app, /ENABLE_AUTO_ENROLL_AUTOPILOT/);
-});
-
-check('private chat exposes confirmed full autopilot separately from high-confidence auto', () => {
-  const server = fs.readFileSync(path.join(root, 'mcp', 'src', 'dashboard', 'server.js'), 'utf8');
-  assert.match(app, /data-agent-mode="autopilot">全自动接管/);
-  assert.match(app, /data-agent-conversation-mode="autopilot"/);
-  assert.match(app, /全自动接管会向白名单中的/);
-  assert.match(app, /该会话将进入全自动接管/);
-  assert.match(app, /confirmation: 'ENABLE_AUTOPILOT'/);
-  assert.match(app, /新消息的非空 Agent 回复将直接发送,不创建待审核草稿/);
-  assert.match(styles, /\.agent-composer\.autopilot/);
-  assert.match(server, /takeover\|resume\|pause\|auto\|autopilot/);
-});
+const html = fs.readFileSync(path.join(dashboardRoot, 'index.html'), 'utf8');
+const app = fs.readFileSync(path.join(dashboardRoot, 'app.js'), 'utf8');
+assert.match(html, /20260807-voice-preview-persist/);
+assert.match(app, /AGENT_SNAPSHOT_KEY/);
+assert.match(app, /\/api\/accounts\/switch/);
+assert.match(app, /voicePreview/);
+assert.match(app, /openSessionConversation/);
 
-console.log(JSON.stringify({ status: 'ok', passed: results.length, results }, null, 2));
+console.log(JSON.stringify({ status: 'ok', synchronized: assets }, null, 2));

+ 5 - 3
scripts/message-archive-smoke-test.js

@@ -15,7 +15,9 @@ async function main() {
     process.env.QIWEI_MESSAGES_DIR = tempRoot;
     delete process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED;
     delete process.env.QIWEI_AGENT_MEMORY_ENABLED;
+    require('../mcp/src/core/credentials').setActiveQiweiContext({ userId: 'archive-smoke-user' });
     const archive = require('../mcp/src/core/message-archive');
+    const accountDir = archive.accountDir();
 
     assert.equal(archive.archiveEnabled(), false);
     assert.equal(archive.memoryEnabled(), false);
@@ -32,8 +34,8 @@ async function main() {
       senderName: '测试成员', self: false, content: '群内测试消息', timestamp: '2026-08-03T10:01:00.000Z',
     });
     await archive.drainQueues();
-    const chatFile = path.join(tempRoot, 'chat', 'contact_unsafe', '2026-08-03.json');
-    const groupFile = path.join(tempRoot, 'group', 'room_demo', '2026-08-03.json');
+    const chatFile = path.join(tempRoot, 'chat', accountDir, 'contact_unsafe', '2026-08-03.json');
+    const groupFile = path.join(tempRoot, 'group', accountDir, 'room_demo', '2026-08-03.json');
     assert.equal(JSON.parse(fs.readFileSync(chatFile, 'utf8')).content, '需要下周安排一次产品演示');
     assert.equal(JSON.parse(fs.readFileSync(groupFile, 'utf8')).roomId, 'room:demo');
 
@@ -46,7 +48,7 @@ async function main() {
     assert.match(templates, /当前目标与需求/);
     assert.doesNotMatch(templates, /房源|购房|看房|小牛/);
     assert.match(archive.memoryIndexText('contact-1'), /用户记忆索引/);
-    assert.equal(archive.memoryRelBase('contact/1'), 'messages/memory/contact_1');
+    assert.equal(archive.memoryRelBase('contact/1'), `messages/memory/${accountDir}/contact_1`);
 
     process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 14, archiveDefault: 'off', memoryDefault: 'off' }, null, 2)}\n`);
   } finally {

+ 107 - 0
scripts/message-types-smoke-test.js

@@ -0,0 +1,107 @@
+#!/usr/bin/env node
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { normalizeMessage, detectMessageType } = require('../mcp/src/core/message-normalizer');
+const { sanitizePayload } = require('../mcp/src/providers/fmode-wecom-gateway');
+
+const samples = [
+  ['text', 2, { content: 'hello' }, 'hello'],
+  ['image', 101, { filename: 'sample.png' }, '[图片] sample.png'],
+  ['voice', 16, { duration: 3 }, '[语音] 3 秒'],
+  ['video', 103, { filename: 'sample.mp4' }, '[视频] sample.mp4'],
+  ['file', 20, { filename: 'document.pdf' }, '[文件] document.pdf'],
+  ['weapp', 78, { title: '服务小程序' }, '[小程序] 服务小程序'],
+  ['meeting', 0, { title: '项目例会' }, '[会议邀请] 项目例会'],
+  ['location', 6, { title: '会议室', latitude: 1, longitude: 2 }, '[位置] 会议室'],
+  ['link', 13, { title: '帮助中心', url: 'https://example.invalid/help' }, '[链接] 帮助中心 · https://example.invalid/help'],
+  ['card', 41, { displayName: '测试联系人' }, '[名片] 测试联系人'],
+  ['emotion', 104, { title: '微笑' }, '[表情] 微笑'],
+  ['revoke', 2063, { title: '消息已撤回' }, '[撤回消息] 消息已撤回'],
+  ['agree', 0, { title: '同意存档' }, '[同意会话存档] 同意存档'],
+  ['disagree', 0, { title: '不同意存档' }, '[不同意会话存档] 不同意存档'],
+  ['chatrecord', 4, { title: '会话记录', itemCount: 2 }, '[会话记录] 会话记录 · 2 条'],
+  ['todo', 0, { title: '跟进事项' }, '[待办] 跟进事项'],
+  ['vote', 0, { title: '方案投票' }, '[投票] 方案投票'],
+  ['collect', 0, { title: '信息登记' }, '[填表] 信息登记'],
+  ['redpacket', 26, { title: '红包' }, '[红包] 红包'],
+  ['external_redpacket', 0, { title: '互通红包' }, '[互通红包] 互通红包'],
+  ['transfer', 588, { title: '转账' }, '[转账] 转账'],
+  ['meeting_notification', 0, { title: '会议开始' }, '[会议控制] 会议开始'],
+  ['docmsg', 0, { title: '在线文档' }, '[在线文档] 在线文档'],
+  ['markdown', 2, { content: '**Markdown**' }, '**Markdown**'],
+  ['news', 0, { title: '图文消息' }, '[图文消息] 图文消息'],
+  ['calendar', 0, { title: '日程' }, '[日程] 日程'],
+  ['mixed', 0, { title: '混合内容' }, '[混合消息] 混合内容'],
+  ['meeting_voice_call', 0, { title: '会议音频' }, '[音频存档] 会议音频'],
+  ['voip_doc_share', 0, { title: '共享文档' }, '[音频共享文档] 共享文档'],
+  ['sphfeed', 141, { title: '视频号内容' }, '[视频号] 视频号内容'],
+  ['voiptext', 0, { callDuration: 12 }, '[音视频通话] 12 秒'],
+  ['qydiskfile', 15, { filename: '微盘文件.pdf' }, '[微盘文件] 微盘文件.pdf'],
+  ['solitaire', 0, { title: '接龙' }, '[接龙] 接龙'],
+  ['note', 0, { title: '笔记' }, '[笔记] 笔记'],
+];
+
+for (const [type, msgType, msgData, expected] of samples) {
+  const normalized = normalizeMessage({ msgType, msgData: { type, ...msgData } });
+  assert.strictEqual(normalized.type, type, `${type} should retain its declared type`);
+  assert.strictEqual(normalized.content, expected, `${type} should produce stable display content`);
+}
+
+assert.strictEqual(detectMessageType({ msgType: 16, msgData: { fileId: 'voice-file' } }), 'voice');
+assert.strictEqual(detectMessageType({ msgType: 103, msgData: { fileId: 'video-file' } }), 'video');
+assert.strictEqual(detectMessageType({ msgType: 104, msgData: { fileId: 'emotion-file' } }), 'emotion');
+const redacted = sanitizePayload('SERVICE_PASSWORD=example-secret\n"masterKey": "example-key"\nsk-example1234567890');
+assert.ok(!redacted.includes('example-secret') && !redacted.includes('example-key') && !redacted.includes('sk-example'), 'public message text should redact credential assignments');
+
+const dashboardRoot = path.join(__dirname, '..', 'mcp', 'src', 'dashboard');
+const app = fs.readFileSync(path.join(dashboardRoot, 'app.js'), 'utf8');
+const styles = fs.readFileSync(path.join(dashboardRoot, 'styles.css'), 'utf8');
+const server = fs.readFileSync(path.join(dashboardRoot, 'server.js'), 'utf8');
+for (const contract of [
+  'agentCardMessageHtml',
+  'agentReplyQuoteHtml',
+  'agentFinancialMessageHtml',
+  'agentChatrecordItemText',
+  'safeAgentMediaUrl',
+  'agentMessageData(message).reply',
+  'data.cards',
+  'subMsgTimestamp',
+  'data-agent-chatrecord-open',
+  'agent-video-play',
+  'openSessionConversation',
+  'view-session',
+]) {
+  assert.ok(app.includes(contract), `dashboard app should include ${contract}`);
+}
+const richRenderer = app.slice(app.indexOf('function agentCardMessageHtml'), app.indexOf('function renderAgentMessage'));
+const messageRenderer = app.slice(app.indexOf('function renderAgentMessage'), app.indexOf('function agentModeLabel'));
+for (const type of [
+  'image', 'video', 'file', 'weapp', 'meeting', 'location', 'link', 'card', 'emotion', 'chatrecord',
+  'todo', 'vote', 'collect', 'redpacket', 'external_redpacket', 'transfer', 'docmsg', 'news', 'calendar',
+  'mixed', 'meeting_voice_call', 'voip_doc_share', 'sphfeed', 'voiptext', 'qydiskfile', 'solitaire', 'note',
+]) {
+  assert.ok(richRenderer.includes(`'${type}'`), `${type} should have a rich-message render path`);
+}
+for (const type of ['revoke', 'meeting_notification', 'agree', 'disagree']) {
+  assert.ok(messageRenderer.includes(type), `${type} should have a system-message render path`);
+}
+assert.ok(messageRenderer.includes("type === 'voice'"), 'voice should have an audio render path');
+assert.ok(messageRenderer.includes('agentTextMessageHtml(message)'), 'text and markdown should retain quoted-message rendering');
+for (const contract of [
+  '.agent-message-quote',
+  '.agent-financial-foot',
+  '.agent-file-icon',
+  '.agent-chatrecord-avatar',
+  '.agent-feed-placeholder',
+  '.agent-video-play',
+]) {
+  assert.ok(styles.includes(contract), `dashboard styles should include ${contract}`);
+}
+for (const route of ['session-transcript', '/media', '/open', 'wechat-emojis']) {
+  assert.ok(server.includes(route), `dashboard server should include ${route}`);
+}
+
+console.log(`message types smoke passed (${samples.length} declared types + 3 numeric precedence checks + public redaction)`);

+ 90 - 0
scripts/migrate-legacy-account.js

@@ -0,0 +1,90 @@
+#!/usr/bin/env node
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { WORKSPACE_ROOT, outputsRoot } = require('../mcp/src/core/output-paths');
+const { readQiweiUid, readQiweiAccountMetadata, safeAccountKey } = require('../mcp/src/core/credentials');
+
+const execute = process.argv.includes('--execute');
+const force = process.argv.includes('--force');
+const legacyKey = safeAccountKey(readQiweiAccountMetadata().legacyAccountKey);
+const outputs = outputsRoot();
+const messages = process.env.QIWEI_MESSAGES_DIR
+  ? path.resolve(process.env.QIWEI_MESSAGES_DIR)
+  : path.join(WORKSPACE_ROOT, 'messages');
+const manifestPath = path.join(outputs, 'dashboard', legacyKey, 'legacy-migration.json');
+const uid = readQiweiUid();
+const oldWorkbenchKey = uid ? crypto.createHash('sha256').update(uid).digest('hex').slice(0, 16) : '';
+const actions = [];
+const DATE_DIR_RE = /^\d{4}-\d{2}-\d{2}$/;
+
+function addCopy(source, target) {
+  if (fs.existsSync(source)) actions.push({ source, target });
+}
+
+function addDirectoryContents(source, target, include = () => true) {
+  if (!fs.existsSync(source)) return;
+  for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
+    if (entry.name === legacyKey || !include(entry)) continue;
+    if (entry.isDirectory() && /^[a-f0-9]{16}$/i.test(entry.name)) continue;
+    addCopy(path.join(source, entry.name), path.join(target, entry.name));
+  }
+}
+
+const oldDb = oldWorkbenchKey
+  ? path.join(outputs, 'messages', `agent-workbench-${oldWorkbenchKey}.db`)
+  : path.join(outputs, 'messages', 'agent-workbench.db');
+const newDb = path.join(outputs, 'messages', `agent-workbench-${legacyKey}.db`);
+addCopy(oldDb, newDb);
+addCopy(`${oldDb}-wal`, `${newDb}-wal`);
+addCopy(`${oldDb}-shm`, `${newDb}-shm`);
+if (oldWorkbenchKey) {
+  addCopy(
+    path.join(outputs, 'messages', `claude-code-sessions-${oldWorkbenchKey}.json`),
+    path.join(outputs, 'messages', `claude-code-sessions-${legacyKey}.json`)
+  );
+}
+
+for (const category of ['customers', 'portraits', 'tags', 'groups']) {
+  addDirectoryContents(
+    path.join(outputs, category),
+    path.join(outputs, category, legacyKey),
+    entry => !DATE_DIR_RE.test(entry.name)
+      && (category !== 'groups' || !/^agent-replies-.*\.json$/.test(entry.name))
+  );
+}
+for (const category of ['chat', 'memory', 'group']) {
+  addDirectoryContents(path.join(messages, category), path.join(messages, category, legacyKey));
+}
+addCopy(
+  path.join(outputs, 'dashboard', 'dashboard-state.json'),
+  path.join(outputs, 'dashboard', legacyKey, 'dashboard-state.json')
+);
+
+if (!execute) {
+  console.log(JSON.stringify({ mode: 'dry-run', legacyKey, uid: uid || null, actions }, null, 2));
+  console.log('No files changed. Re-run with --execute after reviewing the plan.');
+  process.exit(0);
+}
+if (fs.existsSync(manifestPath) && !force) {
+  console.error(`Migration already completed: ${manifestPath}. Use --force only after reviewing existing data.`);
+  process.exit(2);
+}
+
+const results = actions.map(action => {
+  if (fs.existsSync(action.target) && !force) return { ...action, result: 'skipped_target_exists' };
+  fs.mkdirSync(path.dirname(action.target), { recursive: true });
+  fs.cpSync(action.source, action.target, { recursive: true, force });
+  return { ...action, result: 'copied' };
+});
+fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
+fs.writeFileSync(manifestPath, JSON.stringify({
+  version: 1,
+  migratedAt: new Date().toISOString(),
+  legacyKey,
+  uid: uid || null,
+  results,
+}, null, 2), 'utf8');
+console.log(JSON.stringify({ mode: 'execute', legacyKey, manifestPath, results }, null, 2));

+ 6 - 1
scripts/package-smoke-test.js

@@ -2,6 +2,7 @@
 
 const assert = require('assert/strict');
 const fs = require('fs');
+const os = require('os');
 const path = require('path');
 const { spawnSync } = require('child_process');
 
@@ -16,7 +17,11 @@ function runNpmPackDryRun() {
   const args = process.platform === 'win32'
     ? ['/d', '/s', '/c', 'npm', 'pack', '--dry-run', '--json']
     : ['pack', '--dry-run', '--json'];
-  const result = spawnSync(command, args, { cwd: ROOT, encoding: 'utf8' });
+  const result = spawnSync(command, args, {
+    cwd: ROOT,
+    encoding: 'utf8',
+    env: { ...process.env, npm_config_cache: path.join(os.tmpdir(), 'fmode-qiwei-npm-cache') },
+  });
   if (result.status !== 0) throw new Error('npm pack --dry-run 执行失败');
   return JSON.parse(result.stdout)[0];
 }

+ 15 - 9
scripts/smoke-test.js

@@ -7,6 +7,9 @@ const path = require('path');
 const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-main-smoke-'));
 process.env.QIWEI_OUTPUTS_DIR = smokeRoot;
 const { outputsRoot } = require('../mcp/src/core/output-paths');
+const { setActiveQiweiContext, qiweiAccountKey } = require('../mcp/src/core/credentials');
+setActiveQiweiContext({ userId: 'main-smoke-user' });
+const accountOutputKey = qiweiAccountKey();
 const { loadCatalog, searchEndpoints, findEndpoint } = require('../mcp/src/core/api-catalog');
 const { searchQiweiApis, getQiweiApiDoc, callQiweiApi } = require('../mcp/src/tools/qiwei-api-catalog-run');
 const {
@@ -482,8 +485,8 @@ async function main() {
     assert.strictEqual(friendStatus.data.details[0].statusText, 'already_friend');
     assertProviderHidden(friendStatus);
 
-    const portraitFile = path.join(outputsRoot(), 'portraits', 'wx-u-1.json');
-    const tagFile = path.join(outputsRoot(), 'tags', 'wx-u-1.json');
+    const portraitFile = path.join(outputsRoot(), 'portraits', accountOutputKey, 'wx-u-1.json');
+    const tagFile = path.join(outputsRoot(), 'tags', accountOutputKey, 'wx-u-1.json');
     try { fs.unlinkSync(portraitFile); } catch {}
     try { fs.unlinkSync(tagFile); } catch {}
 
@@ -495,9 +498,9 @@ async function main() {
     assertProviderHidden(profile);
     console.log('[ok] customer-ops tools check friend status and query customer profile');
 
-    const groupMappingFile = path.join(outputsRoot(), 'groups', 'confirmed-mapping.json');
-    const keywordConfigFile = path.join(outputsRoot(), 'groups', 'customer-keywords.json');
-    const rejectedMappingFile = path.join(outputsRoot(), 'groups', 'rejected-mapping.json');
+    const groupMappingFile = path.join(outputsRoot(), 'groups', accountOutputKey, 'confirmed-mapping.json');
+    const keywordConfigFile = path.join(outputsRoot(), 'groups', accountOutputKey, 'customer-keywords.json');
+    const rejectedMappingFile = path.join(outputsRoot(), 'groups', accountOutputKey, 'rejected-mapping.json');
     try { fs.unlinkSync(groupMappingFile); } catch {}
     try { fs.unlinkSync(keywordConfigFile); } catch {}
     try { fs.unlinkSync(rejectedMappingFile); } catch {}
@@ -554,12 +557,12 @@ async function main() {
     assert.strictEqual(gateway.requests.some(request => request.body?.method === '/msg/syncMsg'), false, '群管理流程不应拉取历史消息');
     console.log('[ok] group-management tools scan, list, analyze, configure, reject and confirm without history sync');
 
-    const scanManifestFile = path.join(outputsRoot(), 'groups', 'group-scan-manifest.json');
+    const scanManifestFile = path.join(outputsRoot(), 'groups', accountOutputKey, 'group-scan-manifest.json');
     assert(fs.existsSync(scanManifestFile), 'group-scan-manifest.json should exist');
     assert(fs.existsSync(keywordConfigFile), 'customer-keywords.json should exist');
     assert(fs.existsSync(rejectedMappingFile), 'rejected-mapping.json should exist');
 
-    const messageFile = path.join(outputsRoot(), 'messages', 'room-r1-test-portrait.json');
+    const messageFile = path.join(outputsRoot(), 'messages', accountOutputKey, 'room-r1-test-portrait.json');
     fs.mkdirSync(path.dirname(messageFile), { recursive: true });
     fs.writeFileSync(messageFile, JSON.stringify([
       { fromRoomId: 'r1', seq: 2, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '我想采购企业培训服务,预算20万', msgUniqueIdentifier: 'm-2', timestamp: Math.floor(Date.now() / 1000) }
@@ -609,7 +612,7 @@ async function main() {
     try { fs.unlinkSync(messageFile); } catch {}
     console.log('[ok] portrait-tags tools prepare, update, save portrait and manage tags/labels');
 
-    const brokerMessageFile = path.join(outputsRoot(), 'messages', 'room-r2-test-playbook.json');
+    const brokerMessageFile = path.join(outputsRoot(), 'messages', accountOutputKey, 'room-r2-test-playbook.json');
     fs.writeFileSync(brokerMessageFile, JSON.stringify([
       { fromRoomId: 'r2', seq: 1, senderId: 'broker-1', senderName: '顾问', msgType: 1, content: '您好,这个服务版本很适合您,预算也匹配', msgUniqueIdentifier: 'bm-1', timestamp: Math.floor(Date.now() / 1000) }
     ]));
@@ -675,7 +678,10 @@ async function main() {
 }
 
 main()
-  .finally(() => fs.rmSync(smokeRoot, { recursive: true, force: true }))
+  .finally(() => {
+    require('../mcp/src/core/group-operations-store').closeGroupOperationsStore();
+    fs.rmSync(smokeRoot, { recursive: true, force: true });
+  })
   .catch(error => {
     console.error('smoke test failed:', error);
     process.exit(1);

+ 4 - 0
scripts/validate-output-standard.js

@@ -4,6 +4,7 @@ const path = require('path');
 const {
   PACKAGE_ROOT,
   OUTPUT_CATEGORIES,
+  OUTPUT_ACCOUNT_SCOPED_CATEGORIES,
   OUTPUT_PERSISTENT_STORES,
   outputsRoot
 } = require('../mcp/src/core/output-paths');
@@ -11,6 +12,7 @@ const {
 const DOCS_ALLOWED = new Set(['OUTPUT-STANDARD.md', 'DASHBOARD-DEV-LOG.md', 'RELEASE.md', 'specs', 'guides', 'research', 'generated']);
 const RUN_DIR_RE = /^\d{6}-[a-z0-9\u4e00-\u9fa5-]+$/;
 const DATE_DIR_RE = /^\d{4}-\d{2}-\d{2}$/;
+const ACCOUNT_DIR_RE = /^(?:[a-f0-9]{16}|legacy-[a-z0-9_-]+)$/i;
 
 const problems = [];
 
@@ -36,9 +38,11 @@ function checkOutputs() {
 
 function checkCategory(dir, category) {
   const persistentStores = new Set(OUTPUT_PERSISTENT_STORES[category] || []);
+  const accountScoped = OUTPUT_ACCOUNT_SCOPED_CATEGORIES.includes(category);
   for (const entry of listEntries(dir)) {
     if (!entry.isDirectory()) continue; // latest 模式文件
     if (persistentStores.has('*') || persistentStores.has(entry.name)) continue; // 注册的持续知识库
+    if (accountScoped && ACCOUNT_DIR_RE.test(entry.name)) continue; // 账号隔离的 latest/persistent 数据
     if (!DATE_DIR_RE.test(entry.name)) {
       problems.push(`outputs/${category}/${entry.name} 不是 YYYY-MM-DD 日期目录`);
       continue;

+ 15 - 0
scripts/validate-reusable-boundary.js

@@ -0,0 +1,15 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const root = path.resolve(__dirname, '..');
+const forbiddenPath = /(^|[\\/])(outputs?|messages|sessions?|logs?|knowledge-base[\\/]property-data)([\\/]|$)|(^|[\\/])(?:\.env\.local|.*\.db(?:-(?:shm|wal))?|.*\.log)$/i;
+const tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' }).split(/\r?\n/).filter(Boolean);
+const issues = tracked.filter(rel => forbiddenPath.test(rel));
+if (issues.length) {
+  console.error(issues.map(rel => `forbidden tracked path: ${rel}`).join('\n'));
+  process.exit(1);
+}
+console.log(`[ok] reusable boundary is clean (${tracked.length} tracked files checked)`);

+ 29 - 0
scripts/workspace-regression-smoke-test.js

@@ -0,0 +1,29 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { WORKSPACE_ROOT } = require('../mcp/src/core/output-paths');
+
+const tests = [
+  'testing/verify-message-archive.js',
+  'testing/simulate-message-flow.js',
+  'testing/callback-message-types-smoke.js',
+];
+
+const available = tests.filter(relativePath => fs.existsSync(path.join(WORKSPACE_ROOT, relativePath)));
+if (!available.length) {
+  console.log('[skip] workspace regression tests are not included in the installed npm package');
+  process.exit(0);
+}
+
+for (const relativePath of available) {
+  const result = spawnSync(process.execPath, [path.join(WORKSPACE_ROOT, relativePath)], {
+    cwd: WORKSPACE_ROOT,
+    encoding: 'utf8',
+    stdio: 'inherit',
+  });
+  if (result.status !== 0) process.exit(result.status || 1);
+}
+
+console.log(`[ok] workspace regression tests passed (${available.length})`);

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio