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

feat: harden qiwei account switching and generic runtime

gangvy 1 месяц назад
Родитель
Сommit
208247169f
26 измененных файлов с 1180 добавлено и 112 удалено
  1. 6 0
      claude-code/claude-code-qiwe-assistant/.env.example
  2. 120 0
      claude-code/claude-code-qiwe-assistant/docs/ACCOUNT-SWITCH-DEMO.md
  3. 33 5
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js
  4. 32 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js
  5. 41 11
      claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js
  6. 11 7
      claude-code/claude-code-qiwe-assistant/mcp/src/core/login-fallback-server.js
  7. 14 10
      claude-code/claude-code-qiwe-assistant/mcp/src/core/login-flow-server.js
  8. 201 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/message-archive.js
  9. 49 13
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  10. 89 16
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  11. 32 4
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/group-agent-service.js
  12. 9 1
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  13. 76 1
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-agent-transport.js
  14. 11 5
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js
  15. 17 6
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js
  16. 3 2
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-subscription-run.js
  17. 2 0
      claude-code/claude-code-qiwe-assistant/package.json
  18. 93 23
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/personal-polling.mjs
  19. 7 2
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/processor-bridge.mjs
  20. 151 0
      claude-code/claude-code-qiwe-assistant/scripts/account-switch-smoke-test.js
  21. 56 3
      claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js
  22. 36 1
      claude-code/claude-code-qiwe-assistant/scripts/callback-runtime-smoke-test.mjs
  23. 66 0
      claude-code/claude-code-qiwe-assistant/scripts/message-archive-smoke-test.js
  24. 3 2
      claude-code/claude-code-qiwe-assistant/scripts/start-login-flow-4200.js
  25. 10 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-login/SKILL.md
  26. 12 0
      release/npm发布管理文档/变更记录/fmode-qiwei.md

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

@@ -53,6 +53,12 @@ QIWEI_AGENT_INITIAL_SYNC_LIMIT=5000
 QIWEI_AGENT_INITIAL_SYNC_MAX_PAGES=200
 QIWEI_AGENT_STARTUP_GRACE_SECONDS=10
 
+# 可选:额外生成便于检索的 JSONL 消息流水。默认关闭;Workbench 数据库归档不受影响。
+QIWEI_MESSAGE_ARCHIVE_ENABLED=0
+
+# 可选:为每位联系人生成并允许 Agent 维护通用五文件记忆。默认关闭。
+QIWEI_AGENT_MEMORY_ENABLED=0
+
 # 客服回复时效监控(仅本地提醒,不自动发送企微消息)
 QIWEI_RESPONSE_REMINDER_MINUTES=15
 QIWEI_RESPONSE_URGENT_MINUTES=60

+ 120 - 0
claude-code/claude-code-qiwe-assistant/docs/ACCOUNT-SWITCH-DEMO.md

@@ -0,0 +1,120 @@
+# 企业微信新账号扫码与多账号切换演示手册
+
+## 1. 本次演示目标
+
+在一个新克隆的项目目录中完成:
+
+1. 安装通用版 `fmode-qiwei`;
+2. 使用新设备 UID 生成企业微信二维码;
+3. 扫码、必要时提交 6 位验证码;
+4. 登录成功后保存新账号并切换到该账号;
+5. 重启工作台后仍恢复正确账号;
+6. 在两个已保存账号之间往返切换,客户 Workbench 与 Claude Session 不串号。
+
+本流程不包含房源、看房、购房画像或房源推荐 Skill。
+
+## 2. 演示前准备
+
+- Node.js `22.5.0+`;
+- 已登录 Fmode Studio,或当前项目能读取有效 Fmode 鉴权;
+- 手机企业微信可扫码;
+- 新项目目录可写;
+- 端口 `4320` 未被其他项目占用。
+
+克隆项目后,先备份项目根目录的 `.env.local`。若本次必须演示“全新账号首次绑定”,可删除克隆文件中的 `QIWEI_UID` 与 `QIWEI_GUID` 两行;保留现有 Fmode 鉴权配置。不要把 `.env.local` 发到聊天或提交到 Git。
+
+## 3. 从本地迁移版安装
+
+在 PowerShell 中执行:
+
+```powershell
+$Source = 'E:\workspace\openclaw-voc-skill\claude-code\claude-code-qiwe-assistant'
+$Project = 'E:\你的新克隆项目'
+node "$Source\install.js" workspace "$Project" --smoke
+Set-Location $Project
+node .claude\plugins\qiwei-assistant\install.js preview .
+```
+
+浏览器应打开:
+
+```text
+http://127.0.0.1:4320/
+```
+
+> 当前迁移版尚未发布到 npm;现场演示优先使用上述本地源码安装命令,避免误装线上 `0.4.4`。
+
+## 4. 页面操作步骤
+
+1. 打开右上角账号菜单,点击 **添加账号**;或进入 **账号状态 → 添加账号**。
+2. 页面会生成一个新的设备 UID,并请求新的登录二维码;旧账号仍保留在已保存账号列表中。
+3. 用待演示的企业微信扫码,并在手机端确认。
+4. 若页面出现验证码输入框,输入手机端显示的 6 位数字。
+5. 等待页面显示“登录成功”和新账号名称。
+6. 进入 **账号状态**,确认新账号为当前账号且状态为在线。
+7. 在已保存账号中选择旧账号,点击 **切换**;再切回新账号。
+8. 打开 **智能会话**,确认切换后显示的是对应账号的独立会话数据。
+9. 选择测试联系人加入白名单,将全局模式保持为 **待审核**,再启动监听。
+
+## 5. 必做验收
+
+### 扫码链路
+
+- 二维码能展示;
+- 状态能从等待扫码进入已确认或验证码,再进入登录成功;
+- 新 UID 的请求没有携带旧账号 GUID;
+- 扫码取消后原账号仍可继续使用。
+
+### 切换与重启
+
+- 切换接口成功后页面才更新当前账号;
+- 项目 `.env.local` 中的 UID/GUID 属于同一个当前账号;
+- 关闭工作台并重新执行 preview,当前账号仍正确;
+- A → B → A 往返切换后,会话、草稿和 Claude Session 不串号;
+- 监听恢复不把原来的审核/自动/人工模式重置。
+
+### 工作区隔离
+
+- 同一浏览器打开另一个克隆项目时,账号列表使用新的 `workspaceId`;
+- 不会因为同样使用 `127.0.0.1:4320` 而自动写入上一个项目的账号。
+
+## 6. 快速自检命令
+
+在技能包目录执行:
+
+```powershell
+npm run check
+npm run account:switch-smoke
+npm run runtime:smoke
+npm run agent:smoke
+npm run connection:smoke
+npm run install:check
+npm run package:smoke
+```
+
+专项测试不访问真实账号,也不输出任何凭据。
+
+## 7. 刘总交给 Agent 的一键配置提示词
+
+```text
+请在当前克隆项目中完成 fmode-qiwei 通用版的安装、启动和新企业微信账号绑定,并把每一步结果简洁汇报给我。
+
+执行要求:
+1. 先确认 Node.js >= 22.5,并识别当前项目根目录;不要读取或输出任何 Token、GUID、sessionToken、Authorization 或 .env.local 全文。
+2. 优先使用已经安装在当前项目的 .claude/plugins/qiwei-assistant;若未安装,使用我提供的本地 fmode-qiwei 源码目录执行 workspace --smoke 安装。不要安装或启用任何房源、看房、购房画像、房源推荐 Skill。
+3. 检查 4320 端口;若被其他项目占用,先确认对应进程,停止旧项目服务后再启动当前项目,不要让两个项目共用同一个工作台进程。
+4. 启动 preview,读取 /api/health,确认返回的 workspaceId 属于当前项目。
+5. 本次目标是“添加新企微账号”:生成全新的 uid;调用登录开始、状态检查和验证码时始终使用该 uid,禁止复用旧账号 guid;扫码成功前 persistConfig=false。
+6. 展示二维码并等待我扫码。状态为 10 时只向我询问手机端 6 位验证码;状态为 2 后保存该 uid 与本次返回的 guid,并切换为当前账号。
+7. 若扫码取消或切换失败,恢复原当前账号并保留已保存账号列表,然后报告失败步骤和可重试动作。
+8. 登录成功后验证账号在线;重启一次工作台验证账号能恢复;如已有两个账号,再执行 A→B→A 切换并确认 Workbench/Claude 客户 Session 隔离。
+9. 打开智能会话,把测试联系人加入白名单;默认保持待审核模式,启动监听。不要自动向任何非白名单联系人发送消息。
+10. 最终只汇报:安装状态、workspaceId 后 6 位、扫码状态、当前账号脱敏名称、重启恢复、账号切换、白名单、监听状态和未完成项。不要回显任何凭据或完整设备标识。
+```
+
+## 8. 回滚
+
+1. 停止当前项目工作台与监听;
+2. 恢复演示前备份的 `.env.local`;
+3. 重新启动工作台;
+4. 在账号状态页切回原账号;
+5. 确认白名单与 Agent 模式符合演示前状态。

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

@@ -2,6 +2,7 @@ const crypto = require('crypto');
 const fs = require('fs');
 const path = require('path');
 const { spawn } = require('child_process');
+const { safeDirName, memoryEnabled, memoryIndexText, memoryRelBase } = require('./message-archive');
 
 const RISK_PATTERN = /合同|签约|产权|学区资格|保证|承诺|最低价|贷款|利率|首付|投诉|退款|发票|身份证|银行卡|法律|违约/;
 const NO_REPLY_NEEDED_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
@@ -557,6 +558,9 @@ class ClaudeCodeClient {
 
   buildPrompt(messages, context = {}) {
     if (context.directPrompt) return String(context.directPrompt);
+    const wxid = String(context.conversation?.contact_id || '').trim();
+    const useMemory = memoryEnabled();
+    const memoryDir = wxid ? `messages/memory/${safeDirName(wxid)}/` : 'messages/memory/{当前用户}/';
     const history = selectAuthoritativeHistory(messages, 10)
       .map(message => `${message.role === 'assistant' ? '客服' : message.role === 'tool' ? '工具' : '客户'}:${String(message.content || '').slice(0, 1200)}`)
       .join('\n\n');
@@ -571,9 +575,13 @@ class ClaudeCodeClient {
       status: item.status,
       feedbackReason: item.feedback_reason || item.feedbackReason || '',
     }));
-    return [
-      '请处理下面的企业微信客户会话。你可以使用只读工具检索当前工作区中的知识库、规则和房源数据。',
-      '不要修改文件,不要发送消息,不要编造业务事实。只生成供 Dashboard 审核的回复草稿。',
+    const lines = [
+      useMemory
+        ? `请处理下面的企业微信客户会话。你可以使用只读工具检索当前工作区,并且只可维护当前用户记忆目录 ${memoryDir}。`
+        : '请处理下面的企业微信客户会话。你可以使用只读工具检索当前工作区中的知识库和规则。',
+      useMemory
+        ? `除 ${memoryDir} 外不要修改文件,不要发送消息,不要编造业务事实。只生成供 Dashboard 审核的回复草稿。`
+        : '不要修改文件,不要发送消息,不要编造业务事实。只生成供 Dashboard 审核的回复草稿。',
       '严格按照 JSON Schema 输出;reply 面向客户,reason 仅供内部审核。',
       '【上下文边界】下面的“本轮有效会话”是本轮唯一可信的客户对话。即使当前 Claude Code Session 曾经出现过其他客户原话,也不得引用未在本轮有效会话中重复出现的内容。',
       '把语音转写重复、语义残缺、与当前业务无关的测试/技术消息视为未确认噪声;不得据此推断购买数量、预算用途、投资意图等高影响需求,必须先向客户确认。',
@@ -590,7 +598,10 @@ class ClaudeCodeClient {
       '',
       '本轮有效会话:',
       history || `客户:${String(context.inboundContent || '')}`,
-    ].join('\n');
+    ];
+    const memoryDigest = wxid ? memoryIndexText(wxid) : '';
+    if (memoryDigest) lines.push('', '【用户记忆索引】', memoryDigest);
+    return lines.join('\n');
   }
 
   runProcess(args, input = '') {
@@ -647,13 +658,17 @@ class ClaudeCodeClient {
     const system = messages.find(message => message.role === 'system')?.content || '';
     const prompt = `${system}\n\n${this.buildPrompt(messages, context)}`;
     const budgetLimitUsd = Number(options.maxBudgetUsd || this.config.claudeMaxBudgetUsd || 1);
+    const baseTools = String(this.config.claudeTools || 'Read,Glob,Grep')
+      .split(',').map(item => item.trim()).filter(Boolean);
+    const useMemory = memoryEnabled();
+    const toolNames = useMemory ? [...new Set([...baseTools, 'Write', 'Edit'])] : baseTools;
     const args = [
       '--print',
       '--output-format', 'json',
       '--permission-mode', 'dontAsk',
       ...(this.config.claudeBare !== false ? ['--bare'] : []),
       ...(this.config.claudeEffort ? ['--effort', String(this.config.claudeEffort)] : []),
-      '--tools', String(this.config.claudeTools || 'Read,Glob,Grep'),
+      '--tools', toolNames.join(','),
       '--model', String(this.config.model || 'sonnet'),
       '--max-budget-usd', String(budgetLimitUsd),
       '--json-schema', JSON.stringify(this.outputSchema()),
@@ -665,6 +680,19 @@ class ClaudeCodeClient {
     if (session.initialized) args.push('--resume', session.id);
     else args.push('--session-id', session.id);
 
+    if (useMemory) {
+      const wxid = String(context.conversation?.contact_id || '').trim();
+      if (wxid) {
+        const relBase = memoryRelBase(wxid);
+        const absBase = path.resolve(this.workdir, relBase).split('\\').join('/');
+        const allowedTools = [...baseTools];
+        for (const base of [relBase, absBase]) {
+          allowedTools.push(`Write(${base}/**)`, `Edit(${base}/**)`);
+        }
+        args.push('--allowedTools', allowedTools.join(','));
+      }
+    }
+
     const payload = await this.runProcess(args, prompt);
     if (payload.is_error) {
       const subtype = String(payload.subtype || 'unknown');

+ 32 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js

@@ -2,6 +2,7 @@ const { EventEmitter } = require('events');
 const { AgentNotConfiguredError, isNoReplyNeededMessage } = require('./agent-runtime');
 const { isGroupMessage } = require('./agent-poller-policy');
 const { friendlyAgentError } = require('./agent-error-message');
+const { appendChatRecord, ensureMemory } = require('./message-archive');
 
 function looksLikeGroupId(value) {
   return /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(String(value || '').trim());
@@ -110,6 +111,7 @@ class AgentWorkbenchService extends EventEmitter {
     if (isGroupMessage(raw || {}) || looksLikeGroupId(contactId)) return { status: 'ignored_group_message' };
     if (!this.isAllowed(contactId)) return { status: 'ignored_not_allowlisted' };
     const conversation = this.db.ensureConversation(contactId, contactName);
+    ensureMemory(contactId, contactName);
     const contentDuplicate = this.db.findRecentInboundDuplicate(conversation.id, content, timestamp, 60);
     if (contentDuplicate) {
       this.db.audit({
@@ -132,6 +134,16 @@ class AgentWorkbenchService extends EventEmitter {
       raw,
     });
     if (!inserted.created) return { status: 'duplicate', conversation, message: inserted.message };
+    appendChatRecord({
+      wxid: contactId,
+      messageId: inserted.message.id,
+      externalId,
+      dir: 'in',
+      senderType: 'customer',
+      content,
+      createdAt: timestamp,
+      source: raw?.source || 'ingest_inbound',
+    });
 
     this.db.audit({ actor: 'qiwei', action: 'message_received', conversationId: conversation.id, entityId: inserted.message.id });
     this.emit('change', { type: 'message', conversationId: conversation.id });
@@ -309,6 +321,16 @@ class AgentWorkbenchService extends EventEmitter {
         content: finalContent,
         status: result.isSendSuccess === false ? 'submitted' : 'sent',
       }).message;
+      appendChatRecord({
+        wxid: conversation.contact_id,
+        messageId: outbound.id,
+        externalId: null,
+        dir: 'out',
+        senderType: actor === 'agent:auto' ? 'agent' : 'human',
+        content: finalContent,
+        createdAt: outbound.created_at,
+        source: 'draft_approved',
+      });
       const sentRecommendations = propertyRecommendationsFromTrace(draft.tool_trace);
       if (sentRecommendations.length) {
         this.db.upsertCustomerRecommendations(conversation.id, sentRecommendations, {
@@ -398,6 +420,16 @@ class AgentWorkbenchService extends EventEmitter {
       content: text,
       status: result.isSendSuccess === false ? 'submitted' : 'sent',
     }).message;
+    appendChatRecord({
+      wxid: conversation.contact_id,
+      messageId: message.id,
+      externalId: null,
+      dir: 'out',
+      senderType: 'human',
+      content: text,
+      createdAt: message.created_at,
+      source: 'manual',
+    });
     if (/(房源|方案|重点|推荐).{0,20}(套|房)|(套|房).{0,20}(房源|方案|推荐)/.test(text)) {
       this.db.completeCustomerTaskByBusinessKey(conversationId, 'recommendation:shortlist', 'manual_recommendation_sent');
     }

+ 41 - 11
claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js

@@ -74,7 +74,7 @@ function readEnvFiles() {
   return envCandidates().reduce((merged, filePath) => {
     const next = readEnvFileMaybe(filePath);
     for (const [key, value] of Object.entries(next)) {
-      if (!merged[key]) merged[key] = value;
+      if (!Object.prototype.hasOwnProperty.call(merged, key)) merged[key] = value;
     }
     return merged;
   }, {});
@@ -202,7 +202,13 @@ function readQiweiApiBase(input = {}) {
   ]) || DEFAULT_API_BASE).replace(/\/$/, '');
 }
 
-function saveQiweiClientConfig({ uid, apiBase, guid, authToken } = {}) {
+function resolveQiweiEnvPath(envRoot) {
+  return path.join(envRoot ? path.resolve(envRoot) : process.cwd(), '.env.local');
+}
+
+function saveQiweiClientConfig(input = {}) {
+  const { uid, apiBase, guid, authToken, envRoot } = input;
+  const hasGuid = Object.prototype.hasOwnProperty.call(input, 'guid');
   const saved = [];
   try {
     const dir = path.dirname(CREDENTIALS_FILE);
@@ -211,7 +217,10 @@ function saveQiweiClientConfig({ uid, apiBase, guid, authToken } = {}) {
     const next = { ...current };
     if (uid) next.uid = uid;
     if (apiBase) next.apiBase = apiBase;
-    if (guid) next.guid = guid;
+    if (hasGuid) {
+      if (guid) next.guid = guid;
+      else delete next.guid;
+    }
     next.updatedAt = new Date().toISOString();
     fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(next, null, 2), 'utf8');
     saved.push(CREDENTIALS_FILE);
@@ -219,11 +228,11 @@ function saveQiweiClientConfig({ uid, apiBase, guid, authToken } = {}) {
     // ignore, fall through to env file
   }
   try {
-    const envPath = path.join(process.cwd(), '.env.local');
+    const envPath = resolveQiweiEnvPath(envRoot);
     const pairs = [];
     if (uid) pairs.push(['QIWEI_UID', uid]);
     if (apiBase) pairs.push(['QIWEI_API_BASE', apiBase]);
-    if (guid) pairs.push(['QIWEI_GUID', guid]);
+    if (hasGuid) pairs.push(['QIWEI_GUID', String(guid || '').trim()]);
     if (authToken) pairs.push(['QIWEI_AUTH_TOKEN', normalizeToken(authToken)]);
     if (pairs.length) {
       let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
@@ -240,22 +249,42 @@ function saveQiweiClientConfig({ uid, apiBase, guid, authToken } = {}) {
   } catch {
     // ignore
   }
+  if (uid) process.env.QIWEI_UID = String(uid).trim();
+  if (apiBase) process.env.QIWEI_API_BASE = String(apiBase).trim().replace(/\/$/, '');
+  if (hasGuid) {
+    if (guid) process.env.QIWEI_GUID = String(guid).trim();
+    else process.env.QIWEI_GUID = '';
+  }
   return saved;
 }
 
 function readQiweiGuid(input = {}) {
   const fileEnv = readEnvFiles();
   const creds = readCredentialsFile();
+  const processGuid = Object.prototype.hasOwnProperty.call(process.env, 'QIWEI_GUID')
+    ? process.env.QIWEI_GUID
+    : process.env.QIWE_GUID;
+  const fileGuid = Object.prototype.hasOwnProperty.call(fileEnv, 'QIWEI_GUID')
+    ? fileEnv.QIWEI_GUID
+    : fileEnv.QIWE_GUID;
+  const requestedUid = firstNonEmpty([input.uid, input.qiweiUid]);
+  const storedUid = firstNonEmpty([
+    process.env.QIWEI_UID,
+    process.env.QIWE_UID,
+    fileEnv.QIWEI_UID,
+    fileEnv.QIWE_UID,
+    creds.uid,
+    activeQiweiContext.uid
+  ]);
+  const contextMatchesUid = !requestedUid || !storedUid || requestedUid === storedUid;
   return firstNonEmpty([
     input.guid,
     input.qiweiGuid,
     input.deviceGuid,
-    activeQiweiContext.guid,
-    process.env.QIWEI_GUID,
-    process.env.QIWE_GUID,
-    fileEnv.QIWEI_GUID,
-    fileEnv.QIWE_GUID,
-    creds.guid
+    contextMatchesUid ? activeQiweiContext.guid : '',
+    contextMatchesUid ? processGuid : '',
+    contextMatchesUid ? fileGuid : '',
+    contextMatchesUid ? creds.guid : ''
   ]);
 }
 
@@ -309,6 +338,7 @@ module.exports = {
   readFmodeLlmBase,
   ensureQiweiUid,
   readQiweiApiBase,
+  resolveQiweiEnvPath,
   saveQiweiClientConfig,
   setActiveQiweiContext,
   getActiveQiweiContext,

+ 11 - 7
claude-code/claude-code-qiwe-assistant/mcp/src/core/login-fallback-server.js

@@ -9,6 +9,10 @@ let currentFallbackServer = null;
 let currentFallbackContext = null;
 let fallbackIdleTimer = null;
 
+function loginBody(uid, guid, extra = {}) {
+  return { uid, ...(guid ? { guid } : {}), ...extra };
+}
+
 function resetIdleTimer() {
   if (fallbackIdleTimer) clearTimeout(fallbackIdleTimer);
   fallbackIdleTimer = setTimeout(() => {
@@ -36,7 +40,7 @@ function createFallbackHandler(ctx) {
         return;
       }
       if (url.pathname === '/flow/check') {
-        const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: loginBody(ctx.uid, ctx.guid), token: ctx.token, apiBase: ctx.apiBase });
         const data = result.data || {};
         json(res, 200, { status: data.status, detail: data.detail || {} });
         return;
@@ -48,12 +52,12 @@ function createFallbackHandler(ctx) {
           json(res, 400, { errorCode: 'QW-UP-502', message: '请输入 6 位数字验证码' });
           return;
         }
-        await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: { uid: ctx.uid, code }, token: ctx.token, apiBase: ctx.apiBase });
+        await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: loginBody(ctx.uid, ctx.guid, { code }), token: ctx.token, apiBase: ctx.apiBase });
         json(res, 200, { ok: true });
         return;
       }
       if (url.pathname === '/flow/refresh' && req.method === 'POST') {
-        const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: loginBody(ctx.uid, ctx.guid), token: ctx.token, apiBase: ctx.apiBase });
         const base64 = String((result.data && result.data.loginQrcodeBase64Data) || '').replace(/^data:image\/\w+;base64,/, '');
         if (!base64) {
           json(res, 502, { errorCode: 'QW-UP-502', message: '网关未返回二维码' });
@@ -70,20 +74,20 @@ function createFallbackHandler(ctx) {
       const safeStatus = httpStatus >= 400 && httpStatus < 600 ? httpStatus : 502;
       json(res, safeStatus, {
         errorCode: 'QW-UP-' + safeStatus,
-        message: String((error && error.message) || '请求失败')
+        message: String((error && (error.bizMessage || error.message)) || '请求失败')
       });
     }
   };
 }
 
-function startLoginFallbackServer({ token, apiBase, uid, qrcodeBuffer, port, onQrcode } = {}) {
+function startLoginFallbackServer({ token, apiBase, uid, guid, qrcodeBuffer, port, onQrcode } = {}) {
   const listenPort = Number(port || process.env.QIWEI_FALLBACK_PORT) || DEFAULT_FALLBACK_PORT;
   if (currentFallbackServer && currentFallbackContext) {
-    Object.assign(currentFallbackContext, { token, apiBase, uid, qrcodeBuffer, onQrcode });
+    Object.assign(currentFallbackContext, { token, apiBase, uid, guid, qrcodeBuffer, onQrcode });
     resetIdleTimer();
     return Promise.resolve({ url: `http://127.0.0.1:${currentFallbackContext.port}/`, alreadyRunning: true });
   }
-  const ctx = { token, apiBase, uid, qrcodeBuffer, onQrcode };
+  const ctx = { token, apiBase, uid, guid, qrcodeBuffer, onQrcode };
   const server = http.createServer(createFallbackHandler(ctx));
   return new Promise((resolve, reject) => {
     server.once('error', reject);

+ 14 - 10
claude-code/claude-code-qiwe-assistant/mcp/src/core/login-flow-server.js

@@ -42,6 +42,10 @@ const FLOW_PAGE_STYLE = `
 let currentServer = null;
 let currentContext = null;
 
+function loginBody(uid, guid, extra = {}) {
+  return { uid, ...(guid ? { guid } : {}), ...extra };
+}
+
 function buildFlowPageHtml({ monthlyPrice, checkoutToken }) {
   return `<!DOCTYPE html>
 <html lang="zh-CN">
@@ -423,18 +427,18 @@ function createFlowHandler(ctx) {
         let stateError = false;
         let monthlyPrice = QIWEI_MONTHLY_PRICE;
         try {
-          const sub = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase });
+          const sub = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase, cacheBust: true });
           subscribed = Boolean(sub.data && sub.data.subscribed);
           const livePrice = Number(sub.data && sub.data.price);
           if (Number.isFinite(livePrice) && livePrice > 0) monthlyPrice = livePrice;
           if (!subscribed) subscribeDetail = '尚未开通包月订阅';
         } catch (error) {
           stateError = true;
-          subscribeDetail = String((error && error.message) || '订阅状态查询失败');
+          subscribeDetail = String((error && (error.bizMessage || error.message)) || '订阅状态查询失败');
         }
         if (subscribed) {
           try {
-            const status = await callFmodeWecomGateway({ gatewayPath: '/login/status', httpMethod: 'GET', query: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+            const status = await callFmodeWecomGateway({ gatewayPath: '/login/status', httpMethod: 'GET', query: { uid: ctx.uid, ...(ctx.guid ? { guid: ctx.guid } : {}) }, token: ctx.token, apiBase: ctx.apiBase });
             online = Boolean(status.data && status.data.online);
             detail = (status.data && status.data.detail) || null;
           } catch {
@@ -484,7 +488,7 @@ function createFlowHandler(ctx) {
         return;
       }
       if (url.pathname === '/flow/start-login' && req.method === 'POST') {
-        const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: loginBody(ctx.uid, ctx.guid), token: ctx.token, apiBase: ctx.apiBase });
         const base64 = String((result.data && result.data.loginQrcodeBase64Data) || '').replace(/^data:image\/\w+;base64,/, '');
         if (!base64) {
           json(res, 502, subscribeErrorBody(502, '网关未返回二维码'));
@@ -505,7 +509,7 @@ function createFlowHandler(ctx) {
         return;
       }
       if (url.pathname === '/flow/check') {
-        const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: loginBody(ctx.uid, ctx.guid), token: ctx.token, apiBase: ctx.apiBase });
         const data = result.data || {};
         json(res, 200, { status: data.status, detail: data.detail || {} });
         return;
@@ -517,25 +521,25 @@ function createFlowHandler(ctx) {
           json(res, 400, { errorCode: 'QW-UP-502', message: '请输入 6 位数字验证码' });
           return;
         }
-        await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: { uid: ctx.uid, code }, token: ctx.token, apiBase: ctx.apiBase });
+        await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: loginBody(ctx.uid, ctx.guid, { code }), token: ctx.token, apiBase: ctx.apiBase });
         json(res, 200, { ok: true });
         return;
       }
       json(res, 404, { message: 'not found' });
     } catch (error) {
       const httpStatus = Number((error && error.httpStatus) || 502);
-      json(res, httpStatus >= 400 && httpStatus < 600 ? httpStatus : 502, subscribeErrorBody(httpStatus, error && error.message));
+      json(res, httpStatus >= 400 && httpStatus < 600 ? httpStatus : 502, subscribeErrorBody(httpStatus, error && (error.bizMessage || error.message)));
     }
   };
 }
 
-function startLoginFlowServer({ token, apiBase, uid, port, onQrcode } = {}) {
+function startLoginFlowServer({ token, apiBase, uid, guid, port, onQrcode } = {}) {
   const listenPort = Number(port || process.env.QIWEI_FLOW_PORT) || DEFAULT_FLOW_PORT;
   if (currentServer && currentContext) {
-    Object.assign(currentContext, { token, apiBase, uid, onQrcode, checkoutToken: createCheckoutToken() });
+    Object.assign(currentContext, { token, apiBase, uid, guid, onQrcode, checkoutToken: createCheckoutToken() });
     return Promise.resolve({ url: `http://127.0.0.1:${currentContext.port}/`, alreadyRunning: true });
   }
-  const ctx = { token, apiBase, uid, port: listenPort, qrcodeBuffer: null, checkoutToken: createCheckoutToken(), onQrcode };
+  const ctx = { token, apiBase, uid, guid, port: listenPort, qrcodeBuffer: null, checkoutToken: createCheckoutToken(), onQrcode };
   const server = http.createServer(createFlowHandler(ctx));
   return new Promise((resolve, reject) => {
     server.once('error', reject);

+ 201 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/message-archive.js

@@ -0,0 +1,201 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { WORKSPACE_ROOT } = require('./output-paths');
+
+const MESSAGES_ROOT = process.env.QIWEI_MESSAGES_DIR
+  ? path.resolve(process.env.QIWEI_MESSAGES_DIR)
+  : path.join(WORKSPACE_ROOT, 'messages');
+const MEMORY_FILES = Object.freeze(['indexes.md', 'profile.md', 'facts.md', 'tasks.md', 'relationship.md']);
+const writeQueues = new Map();
+
+function safeDirName(raw) {
+  return String(raw || '')
+    .replace(/[\\/:*?"<>|\[\]{}]+/g, '_')
+    .replace(/[\s\r\n\t]+/g, '_')
+    .slice(0, 64) || '_default';
+}
+
+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);
+}
+
+function memoryEnabled(options = {}) {
+  if (typeof options.enabled === 'boolean') return options.enabled;
+  return String(process.env.QIWEI_AGENT_MEMORY_ENABLED || '').trim() === '1';
+}
+
+function archiveEnabled(options = {}) {
+  if (typeof options.enabled === 'boolean') return options.enabled;
+  return String(process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED || '').trim() === '1';
+}
+
+function enqueueAppend(filePath, line, options = {}) {
+  if (!archiveEnabled(options)) return Promise.resolve({ skipped: true });
+  const previous = writeQueues.get(filePath) || Promise.resolve();
+  const next = previous.then(() => {
+    try {
+      fs.mkdirSync(path.dirname(filePath), { recursive: true });
+      fs.appendFileSync(filePath, `${line}\n`, 'utf8');
+      return { written: true, filePath };
+    } catch (error) {
+      return { error: error.message };
+    }
+  }).catch(error => ({ error: error.message }));
+  writeQueues.set(filePath, next.catch(() => {}));
+  return next;
+}
+
+function drainQueues() {
+  return Promise.all([...writeQueues.values()]);
+}
+
+function appendChatRecord({ wxid, messageId, externalId, dir, senderType, content, createdAt, source }, options = {}) {
+  const outbound = String(dir) === 'out';
+  const row = {
+    id: messageId || '',
+    externalId: externalId || null,
+    ts: createdAt || new Date().toISOString(),
+    dir: outbound ? 'out' : 'in',
+    sender: wxid || '',
+    senderType: senderType || (outbound ? 'human' : 'customer'),
+    content: String(content ?? ''),
+    source: source || '',
+  };
+  const filePath = path.join(MESSAGES_ROOT, 'chat', safeDirName(wxid), `${dateOf(row.ts)}.json`);
+  return enqueueAppend(filePath, JSON.stringify(row), options);
+}
+
+function appendGroupRecord({ roomId, messageId, seq, msgType, senderId, senderName, self, content, timestamp }, options = {}) {
+  const row = {
+    id: messageId || '',
+    ts: timestamp || new Date().toISOString(),
+    roomId: roomId || '',
+    dir: self ? 'out' : 'in',
+    sender: senderId || '',
+    senderName: senderName || '',
+    self: Boolean(self),
+    seq: seq ?? null,
+    msgType: String(msgType ?? ''),
+    content: String(content ?? ''),
+  };
+  const filePath = path.join(MESSAGES_ROOT, 'group', safeDirName(roomId), `${dateOf(row.ts)}.json`);
+  return enqueueAppend(filePath, JSON.stringify(row), options);
+}
+
+function memoryTemplate(name, contactName = '') {
+  if (name === 'indexes.md') return `# 用户记忆索引
+> 仅记录长期有效、与服务相关且有依据的信息;普通聊天保留在消息流水中。
+
+## 一句话认知
+(角色、当前目标、服务阶段、最重要的约束或风险)
+
+## 各文件摘要
+| 文件 | 摘要 | 最后更新 |
+|---|---|---|
+| profile.md | 身份、目标、需求与偏好 | - |
+| facts.md | 已确认事实、待确认推断与冲突 | - |
+| tasks.md | 待办、承诺与进展 | - |
+| relationship.md | 沟通偏好、雷区与关系脉络 | - |
+
+## 待确认 / 存疑
+(未确认问题、矛盾点、下一步要问的关键变量)
+`;
+  if (name === 'profile.md') return `# 用户画像
+## 基本信息
+- 称呼:${contactName || '(待确认)'}
+- 身份 / 角色:
+- 所属组织 / 场景:
+
+## 当前目标与需求
+- 核心目标:
+- 必要条件:
+- 偏好:
+- 时间要求:
+- 决策角色:
+
+## 约束与风险
+- 预算 / 资源约束:
+- 合规 / 权限边界:
+- 待确认事项:
+
+## 证据
+- [YYYY-MM-DD] 来源:…
+`;
+  if (name === 'facts.md') return `# 用户关键事实
+> A=用户明确表达或工具核验;B=合理推断但待确认;C=矛盾或弱线索。
+
+## A 级(高置信)
+- [YYYY-MM-DD][A] …
+
+## B 级(待确认)
+- [YYYY-MM-DD][B] …
+
+## C 级(冲突 / 弱线索)
+- [YYYY-MM-DD][C] …
+`;
+  if (name === 'tasks.md') return `# 用户待办与承诺
+## 待办
+- [ ] YYYY-MM-DD …
+
+## 已作承诺
+- [ ] YYYY-MM-DD …
+
+## 进展时间线
+- YYYY-MM-DD …
+`;
+  if (name === 'relationship.md') return `# 用户沟通偏好
+## 沟通方式与时段
+
+## 回复风格与节奏
+
+## 近期话题脉络
+
+## 雷区 / 注意事项
+`;
+  return '';
+}
+
+function ensureMemory(wxid, contactName = '', options = {}) {
+  if (!memoryEnabled(options)) return { enabled: false, dir: '', created: [] };
+  const dir = path.join(MESSAGES_ROOT, 'memory', safeDirName(wxid));
+  fs.mkdirSync(dir, { recursive: true });
+  const created = [];
+  for (const name of MEMORY_FILES) {
+    const filePath = path.join(dir, name);
+    if (fs.existsSync(filePath)) continue;
+    fs.writeFileSync(filePath, memoryTemplate(name, contactName), 'utf8');
+    created.push(name);
+  }
+  return { enabled: true, dir, created };
+}
+
+function memoryIndexText(wxid, options = {}) {
+  if (!memoryEnabled(options)) return '';
+  try {
+    const filePath = path.join(MESSAGES_ROOT, 'memory', safeDirName(wxid), 'indexes.md');
+    return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
+  } catch {
+    return '';
+  }
+}
+
+function memoryRelBase(wxid) {
+  return path.posix.join('messages', 'memory', safeDirName(wxid));
+}
+
+module.exports = {
+  MESSAGES_ROOT,
+  MEMORY_FILES,
+  safeDirName,
+  memoryEnabled,
+  archiveEnabled,
+  appendChatRecord,
+  appendGroupRecord,
+  ensureMemory,
+  memoryIndexText,
+  memoryRelBase,
+  drainQueues,
+};

+ 49 - 13
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -16,7 +16,7 @@ const {
 } = require('./official-office-knowledge-service');
 const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
 const { messageTimestamp, roomIdOf, isGroupMessage, evaluatePolledMessage } = require('../core/agent-poller-policy');
-const { setActiveQiweiContext } = require('../core/credentials');
+const { saveQiweiClientConfig, setActiveQiweiContext } = require('../core/credentials');
 const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
 const { responseMonitor } = require('./response-monitor-service');
 const { GroupAgentService } = require('./group-agent-service');
@@ -45,6 +45,18 @@ function readEnvFile(filePath) {
   }
 }
 
+function refreshAllowedSendersFromEnv(config = {}, envFile = ENV_FILE) {
+  const env = readEnvFile(envFile);
+  if (!Object.prototype.hasOwnProperty.call(env, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS')) {
+    return { changed: false, count: Array.isArray(config.allowedSenders) ? config.allowedSenders.length : 0 };
+  }
+  const next = normalizeAllowlistIds(env.QIWEI_AUTO_REPLY_ALLOWED_SENDERS);
+  const current = Array.isArray(config.allowedSenders) ? config.allowedSenders.map(String) : [];
+  const changed = next.length !== current.length || next.some((id, index) => id !== current[index]);
+  if (changed) config.allowedSenders = next;
+  return { changed, count: next.length };
+}
+
 function readRuntimeState() {
   try {
     const filePath = path.join(outputsRoot(), 'runtime', 'qiwei-runtime.json');
@@ -361,6 +373,10 @@ class QiweiAgentPoller {
   }
 
   async process(message) {
+    const allowlist = refreshAllowedSendersFromEnv(this.config);
+    if (allowlist.changed) {
+      this.db.audit({ actor: 'runtime', action: 'poller_allowlist_reloaded', detail: { count: allowlist.count } });
+    }
     return ingestMessageForWorkbench({ config: this.config, db: this.db, service: this.service }, message, 'polling');
   }
 }
@@ -545,9 +561,15 @@ async function switchActiveAccount(input = {}) {
   const accountPatch = {
     ...account,
     apiBase: account.apiBase || currentAccount.apiBase,
+    guid: account.guid || (accountChanged ? '' : currentAccount.guid),
   };
-  if (!accountPatch.guid) delete accountPatch.guid;
   applyActiveAccountContext({ ...currentAccount, ...accountPatch });
+  saveQiweiClientConfig({
+    uid: accountPatch.uid,
+    guid: accountPatch.guid,
+    apiBase: accountPatch.apiBase,
+    envRoot: PROJECT_ROOT,
+  });
   const status = provisionalAccountStatus();
   accountStatusCache = { checkedAt: Date.now(), value: status };
   void refreshAccountStatus();
@@ -1654,25 +1676,39 @@ async function startListener() {
   return startListenerForWorkbench(workbench, account);
 }
 
-function stopListener() {
+function applyManualTakeover(target) {
+  target.service.setGlobal({ paused: false, defaultMode: 'review' });
+  for (const conversation of target.db.listConversations()) {
+    target.service.setConversationMode(conversation.id, 'human');
+  }
+}
+
+function stopListener({ preserveAgentState = false } = {}) {
   const product = getProductMode();
   if (product.mode === 'enterprise') {
-    workbench.service.setGlobal({ paused: true, defaultMode: 'review' });
-    for (const conversation of workbench.db.listConversations()) {
-      workbench.service.setConversationMode(conversation.id, 'human');
+    if (!preserveAgentState) {
+      workbench.service.setGlobal({ paused: true, defaultMode: 'review' });
+      for (const conversation of workbench.db.listConversations()) {
+        workbench.service.setConversationMode(conversation.id, 'human');
+      }
     }
     return {
       status: 'ok',
-      assistantMessage: 'Enterprise Relay continues collecting messages; AI replies are paused and conversations are in human mode.',
+      assistantMessage: preserveAgentState
+        ? 'Enterprise Relay runtime stopped; Agent modes were preserved.'
+        : 'Enterprise Relay continues collecting messages; AI replies are paused and conversations are in human mode.',
       data: { running: false, relayRunning: true, transport: 'server_relay' },
     };
   }
   const status = workbench.poller.stop();
-  workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
-  for (const conversation of workbench.db.listConversations()) {
-    workbench.service.setConversationMode(conversation.id, 'human');
-  }
-  return { status: 'ok', assistantMessage: 'AI 监听已关闭,现有会话已切换为人工接管', data: status };
+  if (!preserveAgentState) applyManualTakeover(workbench);
+  return {
+    status: 'ok',
+    assistantMessage: preserveAgentState
+      ? 'Qiwei polling runtime stopped; Agent modes were preserved.'
+      : 'AI 监听已关闭,现有会话已切换为人工接管',
+    data: status,
+  };
 }
 
 function getAgentRuntimeConfig() {
@@ -1718,5 +1754,5 @@ module.exports = {
   stopListener,
   getAgentRuntimeConfig,
   createWorkbench,
-  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, detectedPropertiesInMessage, groupAgentService, startListenerForWorkbench },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, applyManualTakeover, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, detectedPropertiesInMessage, groupAgentService, startListenerForWorkbench },
 };

+ 89 - 16
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js

@@ -2,8 +2,10 @@
   'use strict';
 
   const API_BASE = '';
-  const ACCOUNTS_KEY = 'qiwei-accounts';
-  const CURRENT_ACCOUNT_KEY = 'qiwei-current-account';
+  const LEGACY_ACCOUNTS_KEY = 'qiwei-accounts';
+  const LEGACY_CURRENT_ACCOUNT_KEY = 'qiwei-current-account';
+  let ACCOUNTS_KEY = LEGACY_ACCOUNTS_KEY;
+  let CURRENT_ACCOUNT_KEY = LEGACY_CURRENT_ACCOUNT_KEY;
 
   const CO_FILTERS_KEY = 'qiwei-co-filters';
   const PT_FILTERS_KEY = 'qiwei-pt-filters';
@@ -43,8 +45,8 @@
 
   const state = {
     page: 'overview',
-    accounts: JSON.parse(localStorage.getItem(ACCOUNTS_KEY) || '[]'),
-    currentAccountId: localStorage.getItem(CURRENT_ACCOUNT_KEY) || null,
+    accounts: [],
+    currentAccountId: null,
     status: null,
     groups: [],
     groupSync: {},
@@ -61,6 +63,7 @@
     loginTimer: null,
     loginPhase: 'idle',
     loginUid: null,
+    loginPreviousAccountId: null,
     autoRecoverAttempted: false,
     preRecoveryPage: null,
     selectedGroupIds: new Set(),
@@ -143,25 +146,85 @@
     localStorage.setItem(CURRENT_ACCOUNT_KEY, state.currentAccountId || '');
   }
 
+  function parseStoredAccounts(value) {
+    try {
+      const parsed = JSON.parse(value || '[]');
+      return Array.isArray(parsed) ? parsed.filter(item => item && typeof item === 'object') : [];
+    } catch {
+      return [];
+    }
+  }
+
+  async function initializeWorkspaceAccountStorage() {
+    let workspaceId = '';
+    let activeAccountUid = '';
+    try {
+      const health = await api('GET', '/api/health', undefined, 4000);
+      workspaceId = String(health.data?.workspaceId || '').trim();
+      activeAccountUid = String(health.data?.activeAccountUid || '').trim();
+    } catch {
+      // 旧版服务端或短暂启动延迟时继续使用原键,避免阻断工作台。
+    }
+
+    if (workspaceId) {
+      ACCOUNTS_KEY = `qiwei-${workspaceId}-accounts`;
+      CURRENT_ACCOUNT_KEY = `qiwei-${workspaceId}-current-account`;
+    }
+
+    let accounts = parseStoredAccounts(localStorage.getItem(ACCOUNTS_KEY));
+    let currentId = localStorage.getItem(CURRENT_ACCOUNT_KEY) || null;
+    if (workspaceId && accounts.length === 0 && activeAccountUid) {
+      const legacy = parseStoredAccounts(localStorage.getItem(LEGACY_ACCOUNTS_KEY));
+      const activeLegacy = legacy.find(item => String(item.uid || '').trim() === activeAccountUid);
+      if (activeLegacy) {
+        accounts = legacy;
+        currentId = activeLegacy.id;
+      }
+    }
+    if (!accounts.some(item => item.id === currentId)) currentId = null;
+    state.accounts = accounts;
+    state.currentAccountId = currentId;
+    saveAccounts();
+  }
+
   function isGroupSynced(roomId) {
     return !!(state.groupSync[roomId] && state.groupSync[roomId].lastSyncAt);
   }
 
-  function addAccount(account) {
+  function addAccount(account, options = {}) {
+    const activate = options.activate !== false;
     const existing = state.accounts.find(a =>
       (account.uid && a.uid === account.uid) ||
       ((account.guid || account.userId) && a.guid === account.guid && a.userId === account.userId)
     );
     if (existing) {
       Object.assign(existing, account, { id: existing.id });
-      state.currentAccountId = existing.id;
+      if (activate) state.currentAccountId = existing.id;
     } else {
       account.id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
       state.accounts.push(account);
-      state.currentAccountId = account.id;
+      if (activate) state.currentAccountId = account.id;
     }
     saveAccounts();
     updateAccountSwitcher();
+    return existing?.id || account.id;
+  }
+
+  function beginAddAccountLogin() {
+    state.loginPreviousAccountId = state.currentAccountId;
+    state.loginUid = createDeviceUid();
+    state.currentAccountId = null;
+    saveAccounts();
+  }
+
+  function restorePreviousAccountSelection() {
+    const previousId = state.loginPreviousAccountId;
+    if (previousId && state.accounts.some(item => item.id === previousId)) {
+      state.currentAccountId = previousId;
+      saveAccounts();
+      updateAccountSwitcher();
+    }
+    state.loginPreviousAccountId = null;
   }
 
   async function switchAccount(id, options = {}) {
@@ -3991,6 +4054,7 @@
 
         if (statusCode === 4) {
           clearInterval(state.loginTimer);
+          restorePreviousAccountSelection();
           if (statusText) statusText.textContent = '登录已取消,请重新生成二维码';
           return;
         }
@@ -4067,10 +4131,19 @@
         toast('登录成功,但未获取到 Fmode 设备标识,请重新扫码', 'error');
         return;
       }
-      addAccount(account);
-      const switched = await switchAccount(state.currentAccountId, { silent: true, deferRender: state.bootstrapping });
-      if (!switched) return;
+      const previousAccountId = state.loginPreviousAccountId;
+      const accountId = addAccount(account, { activate: false });
+      const switched = await switchAccount(accountId, { silent: true, deferRender: state.bootstrapping });
+      if (!switched) {
+        state.currentAccountId = previousAccountId && state.accounts.some(item => item.id === previousAccountId)
+          ? previousAccountId
+          : null;
+        saveAccounts();
+        updateAccountSwitcher();
+        return;
+      }
       state.loginUid = null;
+      state.loginPreviousAccountId = null;
       toast(`登录成功:${account.nickname || account.userId}`);
       if (state.preRecoveryPage) {
         location.hash = `#${state.preRecoveryPage}`;
@@ -4131,7 +4204,9 @@
       if (res.status === 'needs_verify_code') {
         state.loginPhase = 'verify';
         state.preRecoveryPage = location.hash.slice(1) || 'groups';
+        state.loginPreviousAccountId ||= state.currentAccountId;
         state.currentAccountId = null;
+        saveAccounts();
         renderPage();
         setTimeout(() => showVerifyCodeInput(), 100);
         return false;
@@ -4157,6 +4232,7 @@
 
   async function recoverLogin() {
     state.autoRecoverAttempted = true;
+    state.loginPreviousAccountId = state.currentAccountId;
     state.loginUid = currentAccount()?.uid || null;
     const recovered = await attemptAutoRecover(false);
     if (!recovered) {
@@ -4292,9 +4368,7 @@
       `;
 
       page.querySelector('#btn-add-account-page').addEventListener('click', () => {
-        state.loginUid = createDeviceUid();
-        state.currentAccountId = null;
-        saveAccounts();
+        beginAddAccountLogin();
         renderPage();
       });
 
@@ -7375,6 +7449,7 @@
   }
 
   async function init() {
+    await initializeWorkspaceAccountStorage();
     setActiveNav(location.hash.slice(1) || 'overview');
     updateAccountSwitcher();
     const hadAccountAtStart = Boolean(currentAccount());
@@ -7392,9 +7467,7 @@
     });
 
     document.getElementById('btn-add-account').addEventListener('click', () => {
-      state.loginUid = createDeviceUid();
-      state.currentAccountId = null;
-      saveAccounts();
+      beginAddAccountLogin();
       location.hash = '#groups';
       renderPage();
     });

+ 32 - 4
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/group-agent-service.js

@@ -2,6 +2,7 @@ const fs = require('fs');
 const path = require('path');
 const crypto = require('crypto');
 const { roomIdOf } = require('../core/agent-poller-policy');
+const { appendGroupRecord, appendChatRecord, ensureMemory } = require('../core/message-archive');
 const { isStaffMessage, timestampOf } = require('./response-monitor-service');
 const {
   appendWebhookMessage,
@@ -328,9 +329,10 @@ class GroupAgentService {
     const content = messageContent(message);
     if (!content || ![0, 1, 2].includes(Number(message.msgType))) return { status: 'ignored_group_message_type' };
     const timestamp = message.timestamp ? new Date(Number(message.timestamp) > 1e12 ? Number(message.timestamp) : Number(message.timestamp) * 1000).toISOString() : new Date().toISOString();
+    const msgId = message.msgUniqueIdentifier || message.msgServerId || `${roomId}-${message.seq}`;
     this.appendMessage(roomId, {
-      msgId: message.msgUniqueIdentifier || message.msgServerId || `${roomId}-${message.seq}`,
-      msgUniqueIdentifier: message.msgUniqueIdentifier || message.msgServerId || `${roomId}-${message.seq}`,
+      msgId,
+      msgUniqueIdentifier: msgId,
       seq: message.seq,
       senderId: message.senderId || '',
       senderName: message.senderName || '',
@@ -341,8 +343,34 @@ class GroupAgentService {
       timestamp,
       rawData: message,
     });
-    if (String(message.senderId || '') === String(config.selfUserId || '')) return { status: 'group_staff_message_saved' };
-    return this.generate(roomId, { sourceMessageId: message.msgUniqueIdentifier || message.msgServerId || `${roomId}-${message.seq}` });
+    const self = String(message.senderId || '') === String(config.selfUserId || '');
+    appendGroupRecord({
+      roomId,
+      messageId: msgId,
+      seq: message.seq,
+      msgType: String(message.msgType),
+      senderId: message.senderId || '',
+      senderName: message.senderName || '',
+      self,
+      content,
+      timestamp,
+    });
+    const memberId = String(message.senderId || '').trim();
+    if (memberId && !self) {
+      ensureMemory(memberId, message.senderName || '群成员');
+      appendChatRecord({
+        wxid: memberId,
+        messageId: msgId,
+        externalId: null,
+        dir: 'in',
+        senderType: 'customer',
+        content,
+        createdAt: timestamp,
+        source: 'group',
+      });
+    }
+    if (self) return { status: 'group_staff_message_saved' };
+    return this.generate(roomId, { sourceMessageId: msgId });
   }
 }
 

+ 9 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js

@@ -4,6 +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 {
   qiweiSyncExternalGroups,
   qiweiListExternalGroups,
@@ -484,7 +485,14 @@ async function handleRequest(req, res) {
       return;
     }
     if (pathname === '/api/health') {
-      json(res, 200, { status: 'ok', data: { workspaceId: WORKSPACE_ID, port: Number(DASHBOARD_PORT) } });
+      json(res, 200, {
+        status: 'ok',
+        data: {
+          workspaceId: WORKSPACE_ID,
+          port: Number(DASHBOARD_PORT),
+          activeAccountUid: readQiweiUid(),
+        },
+      });
       return;
     }
     if (pathname === '/api/status' && req.method === 'GET') {

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

@@ -20,7 +20,7 @@ class FmodeQiweiClient {
     return {
       token: readQiweiAuthToken({ authToken: this.config.authToken }),
       uid: readQiweiUid({ uid: this.config.uid }),
-      guid: readQiweiGuid({ guid: this.config.guid }),
+      guid: readQiweiGuid({ guid: this.config.guid, uid: this.config.uid }),
       apiBase: readQiweiApiBase({ apiBase: this.config.apiBase }),
     };
   }
@@ -156,6 +156,81 @@ class FmodeQiweiClient {
     return this.call('/msg/sendText', { toId, content, isNoNeedRead: false });
   }
 
+  sendLocation(toId, { title, address, latitude, longitude } = {}) {
+    if (!String(toId || '').trim()) throw new Error('位置消息缺少接收人');
+    if (!String(title || address || '').trim()) throw new Error('位置消息缺少名称或地址');
+    if (!Number.isFinite(Number(latitude)) || !Number.isFinite(Number(longitude))) {
+      throw new Error('位置消息需要有效经纬度');
+    }
+    return this.call('/msg/sendLocation', {
+      toId,
+      title: String(title || ''),
+      address: String(address || ''),
+      latitude: Number(latitude),
+      longitude: Number(longitude),
+    });
+  }
+
+  async uploadImageByUrl(fileUrl, filename = 'cover.jpg') {
+    if (!String(fileUrl || '').trim()) throw new Error('待上传的图片 URL 不能为空');
+    const data = await this.call('/cloud/cdnBigUploadByUrl', {
+      filename: String(filename || 'cover.jpg'),
+      fileUrl: String(fileUrl),
+      fileType: 1,
+    });
+    if (!data.fileId) throw new Error('企微图片 URL 上传未返回有效 fileId');
+    return data;
+  }
+
+  async sendWeapp(toId, {
+    appId,
+    username,
+    title,
+    desc = '',
+    pagePath,
+    coverUrl = '',
+    coverFileId = '',
+    coverFileAesKey = '',
+    coverFileSize = 0,
+  } = {}) {
+    if (!String(toId || '').trim()) throw new Error('小程序卡片缺少接收人');
+    if (!String(appId || '').trim()) throw new Error('小程序 appId 不能为空');
+    if (!String(username || '').trim()) throw new Error('小程序 username(原始 id,gh_ 开头)未配置');
+    if (!String(title || '').trim()) throw new Error('小程序卡片标题不能为空');
+    const rawUsername = String(username).trim();
+    const normalizedUsername = /@app$/.test(rawUsername) ? rawUsername : `${rawUsername}@app`;
+    let file = { coverFileId, coverFileAesKey, coverFileSize };
+    let coverUploadWarning = '';
+    if (!file.coverFileId && coverUrl) {
+      try {
+        const uploaded = await this.uploadImageByUrl(coverUrl, `cover_${Date.now()}.jpg`);
+        file = {
+          coverFileId: uploaded.fileId,
+          coverFileAesKey: uploaded.fileAesKey || '',
+          coverFileSize: uploaded.fileSize || 0,
+        };
+      } catch (error) {
+        coverUploadWarning = `封面上传失败:${error.message}`;
+        file = { coverFileId: '', coverFileAesKey: '', coverFileSize: 0 };
+      }
+    }
+    const result = await this.call('/msg/sendWeapp', {
+      toId,
+      appId: String(appId),
+      username: normalizedUsername,
+      title: String(title),
+      desc: String(desc || ''),
+      pagePath: String(pagePath || ''),
+      thumbUrl: String(coverUrl || ''),
+      coverFileId: file.coverFileId,
+      coverFileAesKey: file.coverFileAesKey,
+      coverFileSize: file.coverFileSize,
+    });
+    return result && typeof result === 'object'
+      ? { ...result, coverUploadWarning: coverUploadWarning || undefined }
+      : result;
+  }
+
   async uploadVoiceFile(filePath) {
     const ctx = this.requireContext();
     const resolved = path.resolve(String(filePath || ''));

+ 11 - 5
claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js

@@ -11,8 +11,8 @@ function redactSecret(value) {
     .replace(/\bguid\b/gi, '设备标识');
 }
 
-function sanitizePayload(value) {
-  if (Array.isArray(value)) return value.map(sanitizePayload);
+function sanitizePayload(value, fieldName = '') {
+  if (Array.isArray(value)) return value.map(item => sanitizePayload(item, fieldName));
   if (value && typeof value === 'object') {
     const output = {};
     for (const [key, item] of Object.entries(value)) {
@@ -23,10 +23,11 @@ function sanitizePayload(value) {
       ) {
         continue;
       }
-      output[key] = sanitizePayload(item);
+      output[key] = sanitizePayload(item, key);
     }
     return output;
   }
+  if (typeof value === 'string' && /^(uid|guid|userId|corpId)$/i.test(fieldName)) return value;
   return typeof value === 'string' ? redactSecret(value) : value;
 }
 
@@ -107,7 +108,8 @@ async function callFmodeWecomGateway({
   token,
   apiBase,
   timeoutMs = 60000,
-  networkAttempts = 4
+  networkAttempts = 4,
+  cacheBust = false
 }) {
   if (!gatewayPath) {
     const err = new Error('missing gatewayPath');
@@ -121,7 +123,10 @@ async function callFmodeWecomGateway({
     throw err;
   }
 
-  const url = buildGatewayUrl(apiBase, gatewayPath, query);
+  const requestQuery = cacheBust
+    ? { ...(query || {}), _ts: Date.now() }
+    : query;
+  const url = buildGatewayUrl(apiBase, gatewayPath, requestQuery);
   const method = String(httpMethod || 'POST').toUpperCase();
   if (body !== undefined && formData !== undefined) {
     const err = new Error('body and formData are mutually exclusive');
@@ -135,6 +140,7 @@ async function callFmodeWecomGateway({
       headers: {
         Authorization: `Bearer ${token}`,
         Accept: 'application/json',
+        ...(cacheBust ? { 'Cache-Control': 'no-cache', Pragma: 'no-cache' } : {}),
         ...(body !== undefined ? { 'Content-Type': 'application/json' } : {})
       },
       body: formData !== undefined

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

@@ -122,7 +122,7 @@ function openInBrowser(target) {
 }
 
 function gatewayErrorResult(error, stage, fallbackMessage) {
-  const safeMessage = redactSecret(error && error.message);
+  const safeMessage = redactSecret(error && (error.bizMessage || error.message));
   const kind = String((error && error.kind) || 'upstream');
   if (kind === 'auth') {
     return {
@@ -161,16 +161,21 @@ function gatewayErrorResult(error, stage, fallbackMessage) {
   });
 }
 
+function loginBody(uid, guid, extra = {}) {
+  return { uid, ...(guid ? { guid } : {}), ...extra };
+}
+
 async function qiweiLoginStatus(input = {}) {
   const token = readQiweiAuthToken(input);
   if (!token) return authRequiredResult();
   const uid = ensureQiweiUid(input);
+  const guid = readQiweiGuid(input);
   const apiBase = readQiweiApiBase(input);
   try {
     const result = await callFmodeWecomGateway({
       gatewayPath: '/login/status',
       httpMethod: 'GET',
-      query: { uid },
+      query: { uid, ...(guid ? { guid } : {}) },
       token,
       apiBase
     });
@@ -224,6 +229,7 @@ async function qiweiLoginStart(input = {}) {
   const token = readQiweiAuthToken(input);
   if (!token) return authRequiredResult();
   const uid = ensureQiweiUid(input);
+  const guid = readQiweiGuid(input);
   const apiBase = readQiweiApiBase(input);
   if (input.persistConfig !== false) saveQiweiClientConfig({ uid, apiBase });
   if (input.flowUi !== false) {
@@ -232,6 +238,7 @@ async function qiweiLoginStart(input = {}) {
         token,
         apiBase,
         uid,
+        guid,
         port: input.flowPort,
         onQrcode: (buffer) => {
           try {
@@ -260,7 +267,8 @@ async function qiweiLoginStart(input = {}) {
         gatewayPath: '/subscribe/status',
         httpMethod: 'GET',
         token,
-        apiBase
+        apiBase,
+        cacheBust: true
       });
       const sub = statusResult.data || {};
       if (!sub.subscribed) {
@@ -277,7 +285,7 @@ async function qiweiLoginStart(input = {}) {
   try {
     const result = await callFmodeWecomGateway({
       gatewayPath: '/login/start',
-      body: { uid },
+      body: loginBody(uid, guid),
       token,
       apiBase
     });
@@ -292,6 +300,7 @@ async function qiweiLoginStart(input = {}) {
           token,
           apiBase,
           uid,
+          guid,
           qrcodeBuffer,
           port: 0,
           onQrcode: (buffer) => {
@@ -372,11 +381,12 @@ async function qiweiLoginCheck(input = {}) {
   const token = readQiweiAuthToken(input);
   if (!token) return authRequiredResult();
   const uid = ensureQiweiUid(input);
+  const guid = readQiweiGuid(input);
   const apiBase = readQiweiApiBase(input);
   try {
     const result = await callFmodeWecomGateway({
       gatewayPath: '/login/check',
-      body: { uid, manual: Boolean(input.manual) },
+      body: loginBody(uid, guid, { manual: Boolean(input.manual) }),
       token,
       apiBase
     });
@@ -471,13 +481,14 @@ async function qiweiLoginVerify(input = {}) {
   const token = readQiweiAuthToken(input);
   if (!token) return authRequiredResult();
   const uid = ensureQiweiUid(input);
+  const guid = readQiweiGuid(input);
   const code = String(input.code || '').trim();
   if (!/^\d{6}$/.test(code)) return errorResult('请提供手机端显示的 6 位数字验证码(入参 code)。');
   const apiBase = readQiweiApiBase(input);
   try {
     await callFmodeWecomGateway({
       gatewayPath: '/login/verify',
-      body: { uid, code },
+      body: loginBody(uid, guid, { code }),
       token,
       apiBase
     });

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

@@ -16,7 +16,7 @@ function authRequiredResult() {
 }
 
 function subscriptionError(error, stage) {
-  const safeMessage = redactSecret(error && error.message);
+  const safeMessage = redactSecret(error && (error.bizMessage || error.message));
   const kind = String((error && error.kind) || 'upstream');
   if (kind === 'auth') return authRequiredResult();
   if (kind === 'billing') {
@@ -45,7 +45,8 @@ async function qiweiSubscriptionStatus(input = {}) {
       gatewayPath: '/subscribe/status',
       httpMethod: 'GET',
       token,
-      apiBase: readQiweiApiBase(input)
+      apiBase: readQiweiApiBase(input),
+      cacheBust: true
     });
     const data = result.data || {};
     return okResult({

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


+ 93 - 23
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/personal-polling.mjs

@@ -1,22 +1,39 @@
 import path from 'node:path';
 import { spawn } from 'node:child_process';
 import { PACKAGE_ROOT } from './config-loader.mjs';
-import {
-  getPersonalListenerStatus,
-  startPersonalListener,
-  stopPersonalListener,
-} from './processor-bridge.mjs';
+
+let processorBridgePromise = null;
+
+function loadProcessorBridge() {
+  if (!processorBridgePromise) processorBridgePromise = import('./processor-bridge.mjs');
+  return processorBridgePromise;
+}
+
+const defaultListenerApi = {
+  getStatus: async () => (await loadProcessorBridge()).getPersonalListenerStatus(),
+  recoverLogin: async () => (await loadProcessorBridge()).recoverPersonalLogin(),
+  start: async () => (await loadProcessorBridge()).startPersonalListener(),
+  stop: async options => (await loadProcessorBridge()).stopPersonalListener(options),
+};
 
 export class PersonalPollingRuntime {
-  constructor({ config, workspaceRoot, onState = () => {} }) {
+  constructor({ config, workspaceRoot, onState = () => {}, listenerApi = {} }) {
     this.config = config;
     this.workspaceRoot = workspaceRoot;
     this.onState = onState;
+    this.listenerApi = {
+      getStatus: listenerApi.getStatus || defaultListenerApi.getStatus,
+      recoverLogin: listenerApi.recoverLogin || defaultListenerApi.recoverLogin,
+      start: listenerApi.start || defaultListenerApi.start,
+      stop: listenerApi.stop || defaultListenerApi.stop,
+    };
     this.running = false;
     this.listenerStarted = false;
     this.friendWorker = null;
     this.loopPromise = null;
     this.cancelWait = null;
+    this.lastRecoveryAttemptAt = 0;
+    this.lastRecoveryError = '';
   }
 
   wait(ms) {
@@ -49,25 +66,78 @@ export class PersonalPollingRuntime {
     });
   }
 
+  recoveryDue() {
+    const cooldownMs = Math.max(30000, Number(this.config.retryMs) || 10000);
+    return Date.now() - this.lastRecoveryAttemptAt >= cooldownMs;
+  }
+
+  async recoverAndRestart(listener = {}) {
+    if (!this.recoveryDue()) return null;
+    this.lastRecoveryAttemptAt = Date.now();
+    this.onState({ personalPolling: { status: 'reconnecting' } });
+    const recovered = await this.listenerApi.recoverLogin();
+    const recoveredCode = Number(recovered?.summary?.statusCode);
+    if (!recovered?.summary?.loggedIn && recoveredCode !== 2) {
+      this.lastRecoveryError = String(
+        recovered?.errors?.[0]?.message
+        || recovered?.assistantMessage
+        || 'Automatic login recovery is pending.',
+      );
+      return null;
+    }
+
+    this.lastRecoveryError = '';
+    if (listener.running) await this.listenerApi.stop({ preserveAgentState: true });
+    this.listenerStarted = false;
+    const started = await this.listenerApi.start();
+    this.listenerStarted = Boolean(started?.data?.running);
+    return started;
+  }
+
+  async pollOnce() {
+    let result = await this.listenerApi.getStatus();
+    let listener = result?.data?.listener || {};
+    let account = result?.data?.account || {};
+
+    if (account.online === false) {
+      await this.recoverAndRestart(listener);
+      result = await this.listenerApi.getStatus();
+      listener = result?.data?.listener || {};
+      account = result?.data?.account || {};
+    }
+
+    if (!listener.running && account.online !== false) {
+      try {
+        const started = await this.listenerApi.start();
+        this.listenerStarted = Boolean(started?.data?.running);
+      } catch (error) {
+        const recovered = await this.recoverAndRestart(listener);
+        if (!recovered) throw error;
+      }
+      result = await this.listenerApi.getStatus();
+      listener = result?.data?.listener || {};
+      account = result?.data?.account || {};
+    }
+
+    this.listenerStarted = Boolean(listener.running);
+    if (this.listenerStarted) this.startFriendWorker();
+    const listenerHealthy = listener.running && account.online !== false;
+    if (listenerHealthy) this.lastRecoveryError = '';
+    this.onState({
+      personalPolling: {
+        status: listenerHealthy ? 'running' : 'waiting',
+        syncKey: Number(listener.syncKey || 0),
+        startedAt: listener.startedAt || null,
+        lastError: listenerHealthy ? '' : (listener.lastError || this.lastRecoveryError),
+      },
+    });
+    return listener;
+  }
+
   async loop() {
     while (this.running) {
       try {
-        if (!this.listenerStarted) {
-          const result = await startPersonalListener();
-          this.listenerStarted = Boolean(result?.data?.running);
-          if (this.listenerStarted) this.startFriendWorker();
-        }
-        const result = await getPersonalListenerStatus();
-        const listener = result?.data?.listener || {};
-        this.listenerStarted = Boolean(listener.running);
-        this.onState({
-          personalPolling: {
-            status: listener.running ? 'running' : 'waiting',
-            syncKey: Number(listener.syncKey || 0),
-            startedAt: listener.startedAt || null,
-            lastError: listener.lastError || '',
-          },
-        });
+        await this.pollOnce();
       } catch (error) {
         this.listenerStarted = false;
         this.onState({ personalPolling: { status: 'waiting', lastError: error.message } });
@@ -87,7 +157,7 @@ export class PersonalPollingRuntime {
     this.running = false;
     if (this.cancelWait) this.cancelWait();
     if (this.listenerStarted) {
-      try { stopPersonalListener(); } catch {}
+      try { await this.listenerApi.stop({ preserveAgentState: true }); } catch {}
     }
     this.listenerStarted = false;
     if (this.friendWorker) {

+ 7 - 2
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/processor-bridge.mjs

@@ -7,6 +7,7 @@ const webhookServer = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'web
 const webhookTypes = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'core', 'webhook-types.js'));
 const agentService = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'dashboard', 'agent-service.js'));
 const dashboardServer = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'dashboard', 'server.js'));
+const loginTools = require(path.join(PACKAGE_ROOT, 'mcp', 'src', 'tools', 'qiwei-login-run.js'));
 
 export async function processRelayEnvelope(envelope) {
   const events = webhookTypes.parseWebhookEnvelope(envelope);
@@ -32,14 +33,18 @@ export function startPersonalListener() {
   return agentService.startListener();
 }
 
-export function stopPersonalListener() {
-  return agentService.stopListener();
+export function stopPersonalListener(options = {}) {
+  return agentService.stopListener(options);
 }
 
 export function getPersonalListenerStatus() {
   return agentService.getAgentStatus();
 }
 
+export function recoverPersonalLogin() {
+  return loginTools.qiweiLoginCheck({ manual: true, persistConfig: false });
+}
+
 export function startDashboard(port) {
   return dashboardServer.startServer(port);
 }

+ 151 - 0
claude-code/claude-code-qiwe-assistant/scripts/account-switch-smoke-test.js

@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+function envValue(filePath, key) {
+  const content = fs.readFileSync(filePath, 'utf8');
+  const match = content.match(new RegExp(`^${key}=(.*)$`, 'm'));
+  return match ? match[1].trim() : undefined;
+}
+
+async function main() {
+  const packageRoot = path.resolve(__dirname, '..');
+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-account-switch-'));
+  const previousCwd = process.cwd();
+  const previousEnv = { ...process.env };
+  const originalFetch = global.fetch;
+  const requests = [];
+
+  try {
+    process.env.USERPROFILE = tempRoot;
+    process.env.HOME = tempRoot;
+    process.env.QIWEI_WORKSPACE_ROOT = tempRoot;
+    process.env.QIWEI_OUTPUTS_DIR = path.join(tempRoot, 'outputs');
+    process.env.CLAUDE_CODE_WORKDIR = tempRoot;
+    process.env.QIWEI_AUTH_TOKEN = 'test-account-switch-token';
+    delete process.env.QIWEI_UID;
+    delete process.env.QIWE_UID;
+    delete process.env.QIWEI_GUID;
+    delete process.env.QIWE_GUID;
+    process.chdir(tempRoot);
+
+    const credentials = require(path.join(packageRoot, 'mcp', 'src', 'core', 'credentials'));
+    const login = require(path.join(packageRoot, 'mcp', 'src', 'tools', 'qiwei-login-run'));
+    assert.equal(path.dirname(credentials.CREDENTIALS_FILE), path.join(tempRoot, '.claude'));
+
+    const envFile = path.join(tempRoot, '.env.local');
+    credentials.saveQiweiClientConfig({
+      uid: 'uid-account-a',
+      guid: 'guid-account-a',
+      apiBase: 'https://gateway.example/api/qiwei',
+      envRoot: tempRoot,
+    });
+    assert.equal(envValue(envFile, 'QIWEI_UID'), 'uid-account-a');
+    assert.equal(envValue(envFile, 'QIWEI_GUID'), 'guid-account-a');
+    assert.equal(credentials.readQiweiGuid({ uid: 'uid-account-b' }), '');
+
+    let checkLoggedIn = false;
+    global.fetch = async (rawUrl, options = {}) => {
+      const url = new URL(String(rawUrl));
+      const body = options.body ? JSON.parse(options.body) : {};
+      requests.push({ path: url.pathname, body });
+      let data = {};
+      if (url.pathname.endsWith('/login/start')) data = { uid: body.uid };
+      else if (url.pathname.endsWith('/login/check')) {
+        data = checkLoggedIn
+          ? { uid: body.uid, status: 2, detail: { userId: 'user-b', nickname: '账号 B', guid: 'guid-account-b' } }
+          : { uid: body.uid, status: 10, detail: {} };
+      } else if (url.pathname.endsWith('/login/verify')) data = { accepted: true };
+      else if (url.pathname.endsWith('/login/status')) data = { configured: true, online: true, statusCode: 2, detail: { userId: 'user-b', nickname: '账号 B' } };
+      return new Response(JSON.stringify({ code: 0, data }), {
+        status: 200,
+        headers: { 'Content-Type': 'application/json' },
+      });
+    };
+
+    const common = {
+      uid: 'uid-account-b',
+      authToken: 'test-account-switch-token',
+      apiBase: 'https://gateway.example/api/qiwei',
+      flowUi: false,
+      openBrowser: false,
+      persistConfig: false,
+      skipSubscriptionCheck: true,
+    };
+    await login.qiweiLoginStart(common);
+    const pending = await login.qiweiLoginCheck(common);
+    assert.equal(pending.status, 'needs_verify_code');
+    await login.qiweiLoginVerify({ ...common, code: '123456' });
+    for (const request of requests.slice(0, 3)) {
+      assert.equal(request.body.uid, 'uid-account-b');
+      assert.equal(Object.prototype.hasOwnProperty.call(request.body, 'guid'), false);
+    }
+
+    checkLoggedIn = true;
+    const loggedIn = await login.qiweiLoginCheck(common);
+    assert.equal(loggedIn.summary.loggedIn, true);
+    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'));
+    await switchActiveAccount({
+      uid: 'uid-account-b',
+      guid: loggedIn.data.guid,
+      apiBase: common.apiBase,
+      userId: 'user-b',
+      nickname: '账号 B',
+    });
+    assert.equal(envValue(envFile, 'QIWEI_UID'), 'uid-account-b');
+    assert.equal(envValue(envFile, 'QIWEI_GUID'), 'guid-account-b');
+    assert.equal(credentials.readQiweiGuid({ uid: 'uid-account-b' }), 'guid-account-b');
+
+    await switchActiveAccount({
+      uid: 'uid-account-c',
+      guid: '',
+      apiBase: common.apiBase,
+      userId: 'user-c',
+      nickname: '账号 C',
+    });
+    assert.equal(envValue(envFile, 'QIWEI_UID'), 'uid-account-c');
+    assert.equal(envValue(envFile, 'QIWEI_GUID'), '');
+    assert.equal(credentials.readQiweiGuid({ uid: 'uid-account-c' }), '');
+    assert.equal(JSON.parse(fs.readFileSync(credentials.CREDENTIALS_FILE, 'utf8')).guid, undefined);
+
+    const appSource = fs.readFileSync(path.join(packageRoot, 'mcp', 'src', 'dashboard', 'app.js'), 'utf8');
+    assert.match(appSource, /qiwei-\$\{workspaceId\}-accounts/);
+    assert.match(appSource, /await initializeWorkspaceAccountStorage\(\);[\s\S]*if \(currentAccount\(\)\?\.uid\)/);
+    assert.match(appSource, /addAccount\(account, \{ activate: false \}\)[\s\S]*await switchAccount\(accountId/);
+    assert.match(appSource, /if \(!switched\)[\s\S]*state\.currentAccountId = previousAccountId/);
+    assert.match(appSource, /statusCode === 4[\s\S]*restorePreviousAccountSelection\(\)/);
+
+    process.stdout.write(`${JSON.stringify({
+      status: 'ok',
+      checks: 20,
+      coverage: [
+        'new_uid_does_not_reuse_old_guid',
+        'verify_and_check_keep_uid_isolation',
+        'successful_switch_persists_uid_guid',
+        'guidless_switch_clears_stale_guid',
+        'workspace_scoped_browser_accounts',
+        'failed_or_cancelled_login_restores_previous_account',
+      ],
+    }, null, 2)}\n`);
+  } finally {
+    global.fetch = originalFetch;
+    process.chdir(previousCwd);
+    for (const key of Object.keys(process.env)) {
+      if (!(key in previousEnv)) delete process.env[key];
+    }
+    for (const [key, value] of Object.entries(previousEnv)) process.env[key] = value;
+    fs.rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exitCode = 1;
+});

+ 56 - 3
claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js

@@ -147,6 +147,13 @@ async function main() {
       await client.syncMessages(8, 50);
       const contacts = await client.listExternalContacts();
       await client.sendText('external-contact-1', '测试回复');
+      await client.sendLocation('external-contact-1', {
+        title: '会面地点', address: '示例路 1 号', latitude: 31.23, longitude: 121.47,
+      });
+      await client.sendWeapp('external-contact-1', {
+        appId: 'wx-demo-app', username: 'gh_demo', title: '服务入口', pagePath: '/pages/home',
+        coverFileId: 'cover-file', coverFileAesKey: 'cover-key', coverFileSize: 64,
+      });
       const voiceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-voice-transport-'));
       const voicePath = path.join(voiceDir, 'voice.silk');
       fs.writeFileSync(voicePath, Buffer.from('#!SILK_V3'));
@@ -165,9 +172,13 @@ async function main() {
       assert.equal(calls[3].body.method, '/contact/batchGetUserinfo');
       assert.equal(calls[4].body.method, '/msg/sendText');
       assert.equal(calls[4].options.headers.Authorization, 'Bearer test-fmode-token');
-      assert.match(calls[5].url, /\/doFileApi$/);
-      assert.equal(calls[6].body.method, '/msg/sendVoice');
-      assert.equal(calls[6].body.params.voiceTime, 2);
+      assert.equal(calls[5].body.method, '/msg/sendLocation');
+      assert.equal(calls[5].body.params.latitude, 31.23);
+      assert.equal(calls[6].body.method, '/msg/sendWeapp');
+      assert.equal(calls[6].body.params.username, 'gh_demo@app');
+      assert.match(calls[7].url, /\/doFileApi$/);
+      assert.equal(calls[8].body.method, '/msg/sendVoice');
+      assert.equal(calls[8].body.params.voiceTime, 2);
 
       let failedSendAttempts = 0;
       global.fetch = async () => {
@@ -196,6 +207,48 @@ async function main() {
     assert.equal(configB.qiwei.guid, accountB.guid);
   });
 
+  await check('白名单文件变更会在监听期间热加载', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-allowlist-reload-'));
+    try {
+      const envFile = path.join(dir, '.env.local');
+      fs.writeFileSync(envFile, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS=contact-1,contact-2\n', 'utf8');
+      const config = { selfUserId: 'self', allowedSenders: ['contact-1'] };
+      const { __testing } = require('../mcp/src/dashboard/agent-service');
+      const refreshed = __testing.refreshAllowedSendersFromEnv(config, envFile);
+      assert.deepEqual(refreshed, { changed: true, count: 2 });
+      assert.deepEqual(config.allowedSenders, ['contact-1', 'contact-2']);
+      const candidate = evaluatePolledMessage({
+        msgType: 1,
+        senderId: 'contact-2',
+        receiverId: 'self',
+        timestamp: Math.floor(Date.now() / 1000),
+        msgData: { content: '新加入白名单后的首条消息' },
+      }, config);
+      assert.equal(candidate.eligible, true);
+    } finally {
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
+  await check('监听恢复可保留现有 Agent 模式', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const calls = [];
+    const target = {
+      service: {
+        setGlobal(input) { calls.push(['global', input]); },
+        setConversationMode(id, mode) { calls.push(['conversation', id, mode]); },
+      },
+      db: { listConversations: () => [{ id: 'conversation-a' }] },
+    };
+    // preserveAgentState=true 时 stopListener 不调用该接管逻辑;普通人工停止仍调用。
+    assert.equal(calls.length, 0);
+    __testing.applyManualTakeover(target);
+    assert.deepEqual(calls, [
+      ['global', { paused: false, defaultMode: 'review' }],
+      ['conversation', 'conversation-a', 'human'],
+    ]);
+  });
+
   await check('发送语音后会结算当前待审核草稿并关联语音消息', async () => {
     const { __testing } = require('../mcp/src/dashboard/agent-service');
     const draft = {

+ 36 - 1
claude-code/claude-code-qiwe-assistant/scripts/callback-runtime-smoke-test.mjs

@@ -5,6 +5,7 @@ import os from 'node:os';
 import path from 'node:path';
 import { pathToFileURL } from 'node:url';
 import { decryptPayload, EnterpriseRelayRuntime } from '../runtime/callback-service/src/enterprise-relay-client.mjs';
+import { PersonalPollingRuntime } from '../runtime/callback-service/src/personal-polling.mjs';
 import { loadRuntimeConfig, resolveRuntimeMode } from '../runtime/callback-service/src/config-loader.mjs';
 import {
   clearRuntimeStopRequest,
@@ -102,7 +103,41 @@ try {
   clearRuntimeStopRequest(statePath);
   assert.deepEqual(readRuntimeStopRequest(statePath), {});
 
-  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 19 }, null, 2)}\n`);
+  let listener = { running: true, syncKey: 7, lastError: 'offline', startedAt: 1 };
+  let stopOptions = null;
+  let recoveryCount = 0;
+  const personalStates = [];
+  const personal = new PersonalPollingRuntime({
+    config: { retryMs: 3000, friendPollingEnabled: false },
+    workspaceRoot: tempRoot,
+    onState: statePatch => personalStates.push(statePatch),
+    listenerApi: {
+      getStatus: async () => ({
+        data: {
+          account: { online: recoveryCount > 0 },
+          listener,
+        },
+      }),
+      recoverLogin: async () => {
+        recoveryCount += 1;
+        return { summary: { loggedIn: true, statusCode: 2 } };
+      },
+      stop: async options => { stopOptions = options; listener = { ...listener, running: false }; },
+      start: async () => {
+        listener = { running: true, syncKey: 7, lastError: '', startedAt: 2 };
+        return { data: listener };
+      },
+    },
+  });
+  const recoveredListener = await personal.pollOnce();
+  assert.equal(recoveryCount, 1);
+  assert.deepEqual(stopOptions, { preserveAgentState: true });
+  assert.equal(recoveredListener.running, true);
+  assert.equal(recoveredListener.lastError, '');
+  assert.equal(personalStates.at(-1).personalPolling.status, 'running');
+  assert.equal(personalStates.at(-1).personalPolling.lastError, '');
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 25 }, null, 2)}\n`);
 } finally {
   fs.rmSync(tempRoot, { recursive: true, force: true });
 }

+ 66 - 0
claude-code/claude-code-qiwe-assistant/scripts/message-archive-smoke-test.js

@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+async function main() {
+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-message-archive-'));
+  const previousArchive = process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED;
+  const previousMemory = process.env.QIWEI_AGENT_MEMORY_ENABLED;
+  const previousRoot = process.env.QIWEI_MESSAGES_DIR;
+  try {
+    process.env.QIWEI_MESSAGES_DIR = tempRoot;
+    delete process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED;
+    delete process.env.QIWEI_AGENT_MEMORY_ENABLED;
+    const archive = require('../mcp/src/core/message-archive');
+
+    assert.equal(archive.archiveEnabled(), false);
+    assert.equal(archive.memoryEnabled(), false);
+    await archive.appendChatRecord({ wxid: 'contact/unsafe', content: '默认不落盘', dir: 'in' });
+    assert.equal(fs.existsSync(path.join(tempRoot, 'chat')), false);
+
+    process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED = '1';
+    await archive.appendChatRecord({
+      wxid: 'contact/unsafe', messageId: 'm-1', externalId: 'e-1', dir: 'in', senderType: 'customer',
+      content: '需要下周安排一次产品演示', createdAt: '2026-08-03T10:00:00.000Z', source: 'smoke',
+    });
+    await archive.appendGroupRecord({
+      roomId: 'room:demo', messageId: 'g-1', seq: 1, msgType: 1, senderId: 'member-1',
+      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');
+    assert.equal(JSON.parse(fs.readFileSync(chatFile, 'utf8')).content, '需要下周安排一次产品演示');
+    assert.equal(JSON.parse(fs.readFileSync(groupFile, 'utf8')).roomId, 'room:demo');
+
+    assert.deepEqual(archive.ensureMemory('contact-1', '刘总'), { enabled: false, dir: '', created: [] });
+    process.env.QIWEI_AGENT_MEMORY_ENABLED = '1';
+    const memory = archive.ensureMemory('contact-1', '刘总');
+    assert.equal(memory.enabled, true);
+    assert.deepEqual(memory.created.sort(), [...archive.MEMORY_FILES].sort());
+    const templates = archive.MEMORY_FILES.map(name => fs.readFileSync(path.join(memory.dir, name), 'utf8')).join('\n');
+    assert.match(templates, /当前目标与需求/);
+    assert.doesNotMatch(templates, /房源|购房|看房|小牛/);
+    assert.match(archive.memoryIndexText('contact-1'), /用户记忆索引/);
+    assert.equal(archive.memoryRelBase('contact/1'), 'messages/memory/contact_1');
+
+    process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 14, archiveDefault: 'off', memoryDefault: 'off' }, null, 2)}\n`);
+  } finally {
+    if (previousArchive === undefined) delete process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED;
+    else process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED = previousArchive;
+    if (previousMemory === undefined) delete process.env.QIWEI_AGENT_MEMORY_ENABLED;
+    else process.env.QIWEI_AGENT_MEMORY_ENABLED = previousMemory;
+    if (previousRoot === undefined) delete process.env.QIWEI_MESSAGES_DIR;
+    else process.env.QIWEI_MESSAGES_DIR = previousRoot;
+    fs.rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exitCode = 1;
+});

+ 3 - 2
claude-code/claude-code-qiwe-assistant/scripts/start-login-flow-4200.js

@@ -1,11 +1,12 @@
-const { readQiweiAuthToken, ensureQiweiUid, readQiweiApiBase } = require('../mcp/src/core/credentials');
+const { readQiweiAuthToken, ensureQiweiUid, readQiweiApiBase, readQiweiGuid } = require('../mcp/src/core/credentials');
 const { startLoginFlowServer } = require('../mcp/src/core/login-flow-server');
 
 const token = readQiweiAuthToken({});
 const uid = ensureQiweiUid({});
+const guid = readQiweiGuid({});
 const apiBase = readQiweiApiBase({});
 
-startLoginFlowServer({ token, apiBase, uid, port: Number(process.env.QIWEI_FLOW_PORT) || 4200 })
+startLoginFlowServer({ token, apiBase, uid, guid, port: Number(process.env.QIWEI_FLOW_PORT) || 4200 })
   .then(({ url, alreadyRunning }) => {
     console.log(JSON.stringify({ url, alreadyRunning, uid, apiBase, tokenConfigured: Boolean(token) }));
   })

+ 10 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-login/SKILL.md

@@ -27,6 +27,15 @@ description: 通过 Fmode 网关转发的企业微信接口完成扫码登录和
 5. **验证码**:状态 `10` 时向用户索要手机端显示的 6 位验证码,调用 `qiwei_login_verify`,然后再次检查状态。
 6. **验证业务链路**:登录成功后调用 `qiwei_api_call`(例如 `user.getProfile`)。
 
+## 新账号与多账号切换
+
+- “添加账号”必须为新账号生成新的 `uid`,启动、轮询和验证码请求始终携带同一个新 `uid`;新 `uid` 不复用当前账号的 `guid`。
+- 扫码完成前使用 `persistConfig=false`,不提前覆盖当前项目配置;状态为 `2` 后再保存新账号并调用 Dashboard 的账号切换接口。
+- 切换成功后同时持久化 `QIWEI_UID` 与对应 `QIWEI_GUID`;新账号没有 GUID 时必须清除旧 GUID,不得形成跨账号组合。
+- 浏览器账号列表按 `workspaceId` 隔离。同一浏览器访问多个克隆项目时,不得把其他项目的当前账号自动写入本项目。
+- 用户取消扫码或切换接口失败时恢复原当前账号;已经保存的其他账号继续保留,可再次选择。
+- 切换账号会切换独立 Workbench 数据库和 Claude 客户 Session;监听重启只恢复连接,不改变原有审核/自动/人工模式。
+
 ## 配置
 
 - `QIWEI_API_BASE` 默认 `https://server.fmode.cn/api/qiwei`;
@@ -40,3 +49,4 @@ description: 通过 Fmode 网关转发的企业微信接口完成扫码登录和
 - 不向用户暴露接口服务提供方名称、相关域名或控制台;
 - 不绕过 Fmode 网关直连内部服务;
 - 不通过 `qiwei_api_call` 调用 `/login/*` 或 `/client/*`。
+- 不在对话、日志或测试报告中回显 Token、GUID 或完整账号标识。

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

@@ -2,6 +2,18 @@
 
 > 当前版本号见 [`../NPM技能包发布管理.md`](../NPM技能包发布管理.md) 的「发布状态总览」。本文按版本号倒序记录 `fmode-qiwei` 的更新内容。
 
+## 0.5.0(开发中,未发布)
+
+- 从业务项目选择性回迁通用能力;未加入 `qiwei-property-match`、房源 Provider、购房画像、房源推荐规则、小牛业务默认值或房源云函数。
+- 修复新企微账号扫码隔离:新 UID 不继承旧 GUID,登录开始/检查/验证码保持同一 UID;网关脱敏不再改写 GUID 业务值。
+- 修复账号切换持久化:切换成功同步项目 `.env.local` 和用户级活动账号;无 GUID 账号会显式清除旧 GUID,标准变量空值可覆盖旧别名变量。
+- Dashboard 账号列表按 `workspaceId` 隔离,防止不同克隆项目在同一浏览器和 4320 端口下串号;扫码取消或后端切换失败恢复原账号。
+- 监听恢复支持 `preserveAgentState`,不会因运行时重连重置审核/自动/人工模式;个人轮询在处理消息前热加载白名单。
+- 增加通用 JSONL 消息归档和五文件用户记忆框架,二者默认关闭;模板不包含房产字段,启用记忆时只开放当前用户记忆目录的 Write/Edit 权限。
+- 增加通用位置消息和小程序卡片底层传输,未绑定任何行业默认小程序或业务自动发送逻辑。
+- 新增账号切换 20 项、Runtime 25 项、消息归档 14 项专项检查,并补充克隆项目现场演示步骤与一键配置提示词。
+- 当前仅在 `codex/fmode-qiwei-0.5.0-upstream` 分支开发,尚未执行 npm publish。
+
 ## 0.4.4(2026-08-01)
 
 - 新增本人声音初始化、自动客服语气识别、24kHz SILK 编码和企微原生语音发送;试听默认禁用以避免额外合成费用。

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