gangvy пре 1 месец
родитељ
комит
d5f8f43c5b
28 измењених фајлова са 1745 додато и 113 уклоњено
  1. 2 2
      claude-code/claude-code-qiwe-assistant/.claude-plugin/plugin.json
  2. 22 10
      claude-code/claude-code-qiwe-assistant/README.md
  3. 10 8
      claude-code/claude-code-qiwe-assistant/docs/RELEASE.md
  4. 4 4
      claude-code/claude-code-qiwe-assistant/install.js
  5. 1 8
      claude-code/claude-code-qiwe-assistant/mcp/catalog/qiwei-endpoints.json
  6. 7 2
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js
  7. 22 1
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js
  8. 44 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/allowlist-config.js
  9. 203 7
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  10. 416 31
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  11. 345 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/group-agent-service.js
  12. 12 7
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/response-monitor-service.js
  13. 40 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  14. 228 1
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css
  15. 57 0
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-agent-transport.js
  16. 1 1
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  17. 2 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js
  18. 12 8
      claude-code/claude-code-qiwe-assistant/package-lock.json
  19. 20 7
      claude-code/claude-code-qiwe-assistant/package.json
  20. 184 5
      claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js
  21. 10 1
      claude-code/claude-code-qiwe-assistant/scripts/build-catalog.py
  22. 24 0
      claude-code/claude-code-qiwe-assistant/scripts/package-smoke-test.js
  23. 1 0
      claude-code/claude-code-qiwe-assistant/scripts/release-check.js
  24. 24 1
      claude-code/claude-code-qiwe-assistant/scripts/response-monitor-smoke-test.js
  25. 6 5
      claude-code/claude-code-qiwe-assistant/skill-package-manifest.json
  26. 4 3
      claude-code/claude-code-qiwe-assistant/skills/qiwei-real-estate-auto-reply/SKILL.md
  27. 24 1
      release/npm发布管理文档/NPM技能包发布管理.md
  28. 20 0
      release/npm发布管理文档/变更记录/fmode-qiwei.md

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

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

+ 22 - 10
claude-code/claude-code-qiwe-assistant/README.md

@@ -1,4 +1,4 @@
-# 企业微信助手技能包
+# fmode-qiwei 企业微信助手技能包
 
 提供两套相互独立的企业微信能力:
 
@@ -311,7 +311,9 @@ Dashboard 优先使用启动进程或 MCP 请求中注入的 `QIWEI_AUTH_TOKEN`
 
 Dashboard 的「智能会话」页把真实消息监听、意图识别、需求画像、业务匹配、自动回复和人工协同放在同一页面。启停逻辑如下:
 
-- 点击「启动 AI 监听」后,AI 自动回复白名单测试联系人;
+- 点击「管理白名单」,从当前企微外部联系人中搜索并勾选允许 Agent 处理的客户;
+- 点击「启动 AI 监听」后,AI 仅处理已选白名单联系人;
+- 已确认的客户群会进入独立的「群聊智能回复」列表,Agent 自动生成草稿,但必须人工编辑或审核后才能发送到群;
 - 点击「关闭 AI 监听」后,轮询器停止,切换为人工回复;
 - 真实发送只允许命中 `QIWEI_AUTO_REPLY_ALLOWED_SENDERS` 白名单的联系人;
 - 可对单个客户切换「人工接管 / 恢复 Agent」;
@@ -320,11 +322,12 @@ Dashboard 的「智能会话」页把真实消息监听、意图识别、需求
 推荐的现场顺序:
 
 1. 打开 `http://127.0.0.1:4320/#agent`,确认测试账号显示在线;
-2. 点击「启动 AI 监听」;
-3. 用唯一白名单测试联系人发送一条新消息;
-4. 查看意图、需求画像、匹配结果和建议回复;
-5. 点击「关闭 AI 监听」,演示切换为人工回复;
-6. 如需展示单客户协同,可重新启动后演示「人工接管 / 恢复 Agent」。
+2. 点击「管理白名单」,搜索并选择一位测试联系人;
+3. 点击「启动 AI 监听」;
+4. 用已选白名单联系人发送一条新消息;
+5. 查看意图、需求画像、匹配结果和建议回复;
+6. 点击「关闭 AI 监听」,演示切换为人工回复;
+7. 如需展示单客户协同,可重新启动后演示「人工接管 / 恢复 Agent」。
 
 ### 离线恢复
 
@@ -356,7 +359,14 @@ npm run agent:session -- --customer 王刚
 
 ## 安装到客户项目
 
-本地源码包已经支持 workspace 安装:
+npm 发布版推荐直接安装到当前客户项目:
+
+```bash
+npx --yes fmode-qiwei@latest workspace --smoke
+npx --yes fmode-qiwei@latest preview
+```
+
+本地源码调试也可以执行:
 
 ```bash
 node install.js workspace <客户项目目录> --smoke
@@ -370,7 +380,7 @@ node install.js workspace <客户项目目录> --smoke
 <客户项目>/.mcp.json
 ```
 
-安装过程不会复制 `.env`、`.env.local`、`.npmrc`、真实运行输出、客户 Session、Playwright 会话、压缩包或源码目录中的 `node_modules`。当前版本已通过发布检查,尚未发布到 npm;发布状态与上传检查见 `docs/RELEASE.md`。
+安装过程不会复制 `.env`、`.env.local`、`.npmrc`、真实运行输出、客户 Session、Playwright 会话、压缩包或源码目录中的 `node_modules`。当前公开版本为 `fmode-qiwei@0.4.1`;版本与验证记录见 `docs/RELEASE.md`。
 
 ## 子 Skill 索引
 
@@ -409,7 +419,9 @@ Agent 可按业务场景直接定位到对应 Skill 文档,每个 Skill 内部
 
 技能包内置 30 套脱敏房源以及客户、标签和匹配规则,可直接用于演示;正式客户数据可通过 `QIWEI_AGENT_PROPERTY_DATA_FILE` 覆盖。
 
-先在 `.env.local` 配置测试联系人白名单,并推荐复用 Fmode Studio 当前项目的 Claude Code 模型能力:
+启动 Dashboard 后,在「智能会话」页点击「管理白名单」,直接从企微联系人中搜索并勾选客户。保存后立即生效并写入客户项目 `.env.local`,无需手工查找联系人 ID 或重启技能包。
+
+只有在页面暂时无法读取联系人时,才需要使用下面的手工配置作为兜底;同时推荐复用 Fmode Studio 当前项目的 Claude Code 模型能力:
 
 ```text
 QIWEI_AUTO_REPLY_ALLOWED_SENDERS=<测试联系人 userId,多个用逗号分隔>

+ 10 - 8
claude-code/claude-code-qiwe-assistant/docs/RELEASE.md

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

+ 4 - 4
claude-code/claude-code-qiwe-assistant/install.js

@@ -14,10 +14,10 @@ function usage() {
     '企业微信 Claude Code 技能包安装器',
     '',
     '用法:',
-    '  node install.js --check',
-    '  node install.js workspace <客户项目目录>',
-    '  node install.js workspace <客户项目目录> --smoke',
-    '  node install.js preview [客户项目目录] [--no-open] [--port 4320]',
+    '  fmode-qiwei --check',
+    '  fmode-qiwei workspace [客户项目目录]',
+    '  fmode-qiwei workspace [客户项目目录] --smoke',
+    '  fmode-qiwei preview [客户项目目录] [--no-open] [--port 4320]',
     '',
     'workspace 模式会写入 .claude/plugins、.claude/skills 和项目级 .mcp.json。',
     'preview 模式会启动工作台、检查当前状态并打开浏览器。',

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

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

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

@@ -791,14 +791,17 @@ class QiweiAgentRuntime {
     return { error: `未知工具 ${name}` };
   }
 
-  async run({ conversation, messages, profile, customerIntelligence = {}, inboundContent }) {
+  async run({ conversation, messages, profile, customerIntelligence = {}, inboundContent, channelType = 'private', directPrompt = '' }) {
     const history = messages.slice(-16).map(message => ({
       role: message.direction === 'inbound' ? 'user' : 'assistant',
       content: message.content,
     }));
     const recommendationContext = (customerIntelligence.recommendations || []).map(item => ({ propertyId: item.property_id || item.propertyId, community: item.property_snapshot?.community || item.property?.community, totalPrice: item.property_snapshot?.totalPrice || item.property?.totalPrice, status: item.status, feedbackReason: item.feedback_reason || item.feedbackReason || '' }));
+    const groupChat = channelType === 'group';
     const system = [
-      '你是企业微信客户服务 Agent。你需要自主判断是否检索知识、客户画像或房源工具,再生成回复草稿。',
+      groupChat
+        ? '你是企业微信群聊客服 Agent。你需要理解不同群成员的发言,再生成一条供人工审核的群内回复草稿。'
+        : '你是企业微信客户服务 Agent。你需要自主判断是否检索知识、客户画像或房源工具,再生成回复草稿。',
       '严禁编造业务事实。工具没有证据时明确说需要确认。敏感或低置信内容标记 requiresHuman=true。',
       '最终必须只输出 JSON,包含 reply、confidence、intent、reason、requiresHuman、profileUpdates、tasks 和 alerts。',
       'reason 用于内部监管台,简要说明依据和不确定性,不要发送给客户。',
@@ -818,6 +821,8 @@ class QiweiAgentRuntime {
         profile,
         customerIntelligence,
         inboundContent,
+        channelType,
+        directPrompt,
       });
       if (assistant.claudeCode) {
         toolTrace.push({

+ 22 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js

@@ -1,5 +1,10 @@
 const { EventEmitter } = require('events');
 const { AgentNotConfiguredError, isNoReplyNeededMessage } = require('./agent-runtime');
+const { isGroupMessage } = require('./agent-poller-policy');
+
+function looksLikeGroupId(value) {
+  return /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(String(value || '').trim());
+}
 
 function propertyRecommendationsFromTrace(toolTrace = []) {
   const items = [];
@@ -48,6 +53,17 @@ class AgentWorkbenchService extends EventEmitter {
     if (!this.isAllowed(contactId)) throw new Error('该联系人不在测试白名单,禁止处理或发送');
   }
 
+  requirePrivateConversation(conversationId) {
+    const conversation = this.db.getConversation(conversationId);
+    if (looksLikeGroupId(conversation?.contact_id)) {
+      throw new Error('群聊仅用于监控与人工处理,不能进入客户私聊 Agent 发送链路');
+    }
+    const groupMessage = this.db.listMessages(conversationId, 500).some(message => {
+      try { return isGroupMessage(JSON.parse(message.raw_json || '{}')); } catch { return false; }
+    });
+    if (groupMessage) throw new Error('群聊仅用于监控与人工处理,不能进入客户私聊 Agent 发送链路');
+  }
+
   conversationDetail(conversationId) {
     const conversation = this.db.getConversation(conversationId);
     if (!conversation) return null;
@@ -90,6 +106,7 @@ class AgentWorkbenchService extends EventEmitter {
   }
 
   async ingestInbound({ externalId, contactId, contactName = '', content, timestamp = new Date().toISOString(), raw = null }) {
+    if (isGroupMessage(raw || {}) || looksLikeGroupId(contactId)) return { status: 'ignored_group_message' };
     if (!this.isAllowed(contactId)) return { status: 'ignored_not_allowlisted' };
     const conversation = this.db.ensureConversation(contactId, contactName);
     const contentDuplicate = this.db.findRecentInboundDuplicate(conversation.id, content, timestamp, 60);
@@ -263,6 +280,7 @@ class AgentWorkbenchService extends EventEmitter {
     const conversation = this.db.getConversation(draft.conversation_id);
     if (!conversation) throw new Error('会话不存在');
     this.requireAllowed(conversation.contact_id);
+    this.requirePrivateConversation(conversation.id);
     const finalContent = String(content || draft.content).trim();
     if (!finalContent) throw new Error('回复内容不能为空');
     if (finalContent.length > 2000) throw new Error('回复内容过长');
@@ -341,9 +359,10 @@ class AgentWorkbenchService extends EventEmitter {
   async regenerateDraft(draftId, actor = 'human') {
     const draft = this.db.getDraft(draftId);
     if (!draft) throw new Error('回复草稿不存在');
-    if (draft.status === 'pending') this.rejectDraft(draftId, { actor, reason: 'regenerated' });
     const conversation = this.db.getConversation(draft.conversation_id);
     const inbound = this.db.getMessage(draft.inbound_message_id);
+    this.requirePrivateConversation(conversation.id);
+    if (draft.status === 'pending') this.rejectDraft(draftId, { actor, reason: 'regenerated' });
     this.db.audit({ actor, action: 'draft_regenerate_requested', conversationId: conversation.id, entityId: draftId });
     return this.generateDraftForMessage(conversation, inbound);
   }
@@ -352,6 +371,7 @@ class AgentWorkbenchService extends EventEmitter {
     const conversation = this.db.getConversation(conversationId);
     if (!conversation) throw new Error('会话不存在');
     this.requireAllowed(conversation.contact_id);
+    this.requirePrivateConversation(conversation.id);
     const inbound = this.db.getLatestInbound(conversationId);
     if (!inbound) throw new Error('没有可处理的客户消息');
     const existing = this.db.listDrafts({ conversationId }).find(item => item.inbound_message_id === inbound.id && item.status === 'pending');
@@ -364,6 +384,7 @@ class AgentWorkbenchService extends EventEmitter {
     const conversation = this.db.getConversation(conversationId);
     if (!conversation) throw new Error('会话不存在');
     this.requireAllowed(conversation.contact_id);
+    this.requirePrivateConversation(conversation.id);
     const text = String(content || '').trim();
     if (!text) throw new Error('回复内容不能为空');
     if (text.length > 2000) throw new Error('回复内容过长');

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

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

+ 203 - 7
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -13,10 +13,12 @@ const {
   completeTodoKnowledge,
 } = require('./official-office-knowledge-service');
 const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
-const { messageTimestamp, isGroupMessage, evaluatePolledMessage } = require('../core/agent-poller-policy');
+const { messageTimestamp, roomIdOf, isGroupMessage, evaluatePolledMessage } = require('../core/agent-poller-policy');
 const { setActiveQiweiContext } = require('../core/credentials');
 const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
 const { responseMonitor } = require('./response-monitor-service');
+const { GroupAgentService } = require('./group-agent-service');
+const { normalizeAllowlistIds, normalizeAllowlistContact, writeEnvValue } = require('../core/allowlist-config');
 
 const PROJECT_ROOT = WORKSPACE_ROOT;
 const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
@@ -295,6 +297,13 @@ class QiweiAgentPoller {
   }
 
   async process(message) {
+    if (isGroupMessage(message)) {
+      try { await groupAgentService.ingestPolledMessage(message, this.config); }
+      catch (error) {
+        this.db.audit({ actor: 'agent', action: 'group_agent_failed', detail: { roomId: roomIdOf(message), message: error.message } });
+      }
+      return;
+    }
     const candidate = evaluatePolledMessage(message, this.config);
     if (!candidate.eligible) return;
     const { content, senderId, timestamp } = candidate;
@@ -411,6 +420,12 @@ function activeAccountMetadata() {
   };
 }
 
+const groupAgentService = new GroupAgentService({
+  projectRoot: PROJECT_ROOT,
+  getRuntime: () => workbench,
+  getAccount: () => activeAccountMetadata(),
+});
+
 function applyActiveAccountContext(account) {
   setActiveQiweiContext(account);
   Object.assign(workbench.config.qiwei, account);
@@ -652,6 +667,24 @@ function backfillPropertyRecommendations(db, knowledge) {
   return count;
 }
 
+function conversationChannelInfo(row, messages = []) {
+  const contactId = String(row?.contact_id || '').trim();
+  let roomId = '';
+  for (const message of messages) {
+    const raw = parseJson(message.raw_json, {});
+    if (!isGroupMessage(raw)) continue;
+    roomId = roomIdOf(raw) || contactId;
+    break;
+  }
+  if (!roomId && /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(contactId)) roomId = contactId;
+  const channelType = roomId ? 'group' : 'private';
+  return {
+    channelType,
+    channelLabel: channelType === 'group' ? '客户群聊' : '客户私聊',
+    channelId: roomId || contactId,
+  };
+}
+
 function publicConversation(row) {
   const detail = workbench.service.conversationDetail(row.id);
   const claudeSession = getCustomerSessionGuide(row, {
@@ -677,6 +710,7 @@ function publicConversation(row) {
   const displayMessages = [...new Map((detail.messages || []).map(message => [message.id, message])).values()]
     .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))
     .slice(-60);
+  const channel = conversationChannelInfo(row, displayMessages);
   const visibleEntityIds = new Set(displayMessages.map(message => message.id));
   const visibleAudit = (detail.audit || []).filter(item =>
     Date.parse(item.created_at) >= cutoverAt || visibleEntityIds.has(item.entity_id)
@@ -685,6 +719,7 @@ function publicConversation(row) {
     id: row.id,
     displayName: row.contact_name || '白名单测试联系人',
     maskedId: maskedId(row.contact_id),
+    ...channel,
     mode: row.mode,
     source: 'live',
     claudeSession,
@@ -814,27 +849,113 @@ async function getAgentStatus() {
   };
 }
 
+async function getAllowlistCandidates() {
+  const selectedIds = [...workbench.config.qiwei.allowedSenders];
+  const candidates = new Map();
+  let remoteCount = 0;
+  let warning = '';
+
+  try {
+    const account = await detectAccountStatus(true);
+    if (account.online) {
+      const result = await workbench.qiwei.listExternalContacts({ limit: 500, maxPages: 10 });
+      remoteCount = Number(result.contactCount) || result.contacts.length;
+      for (const item of result.contacts) {
+        const contact = normalizeAllowlistContact(item);
+        if (contact) candidates.set(contact.id, contact);
+      }
+    } else {
+      warning = '当前企微账号离线,只显示已保存或已有会话中的联系人';
+    }
+  } catch {
+    warning = '联系人列表暂时读取失败,仍可管理已保存联系人或手工添加联系人 ID';
+  }
+
+  for (const row of workbench.db.listConversations()) {
+    const contact = normalizeAllowlistContact({ contactId: row.contact_id }, row.contact_name);
+    if (contact && !candidates.has(contact.id)) candidates.set(contact.id, contact);
+  }
+  for (const id of selectedIds) {
+    if (!candidates.has(id)) candidates.set(id, normalizeAllowlistContact({ id }, '已保存联系人'));
+  }
+
+  const contacts = [...candidates.values()]
+    .map(contact => ({ ...contact, selected: selectedIds.includes(contact.id) }))
+    .sort((a, b) => Number(b.selected) - Number(a.selected) || a.displayName.localeCompare(b.displayName, 'zh-CN'));
+  return {
+    status: 'ok',
+    assistantMessage: warning || `已读取 ${contacts.length} 位可选联系人`,
+    summary: { selectedCount: selectedIds.length, candidateCount: contacts.length, remoteCount },
+    data: { selectedIds, contacts },
+    warnings: warning ? [warning] : [],
+    errors: [],
+  };
+}
+
+function updateAllowlist(input = {}) {
+  const ids = normalizeAllowlistIds(input.contactIds || input.allowedSenders || []);
+  const serialized = ids.join(',');
+  writeEnvValue(ENV_FILE, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS', serialized);
+  fileEnv.QIWEI_AUTO_REPLY_ALLOWED_SENDERS = serialized;
+  process.env.QIWEI_AUTO_REPLY_ALLOWED_SENDERS = serialized;
+
+  let listenerStopped = false;
+  for (const target of workbenches.values()) {
+    target.config.qiwei.allowedSenders = [...ids];
+    if (!ids.length && target.poller.status().running) {
+      target.poller.stop();
+      listenerStopped = true;
+    }
+  }
+  workbench.db.audit({
+    actor: 'human',
+    action: 'allowlist_updated',
+    detail: { count: ids.length, listenerStopped },
+  });
+  return {
+    status: 'ok',
+    assistantMessage: ids.length
+      ? `白名单已保存,共 ${ids.length} 位联系人,立即生效`
+      : '白名单已清空,AI 监听已停止',
+    summary: { selectedCount: ids.length, listenerStopped },
+    data: { selectedIds: ids, selectedCount: ids.length },
+    warnings: [],
+    errors: [],
+  };
+}
+
 function displayableConversations() {
   return workbench.db.listConversations().filter(item => item.last_message_at || item.last_content || Number(item.pending_count || 0) > 0);
 }
 
 function getConversations() {
-  return { status: 'ok', data: { conversations: displayableConversations().map(publicConversation) } };
+  const conversations = displayableConversations().map(publicConversation);
+  return {
+    status: 'ok',
+    data: {
+      conversations,
+      summary: {
+        privateCount: conversations.filter(item => item.channelType === 'private').length,
+        groupCount: conversations.filter(item => item.channelType === 'group').length,
+      },
+    },
+  };
 }
 
 function getResponseMonitor() {
   const allowlist = new Set(workbench.config.qiwei.allowedSenders.map(String));
   const conversations = displayableConversations()
     .filter(row => allowlist.has(String(row.contact_id || '')))
-    .map(row => {
+    .flatMap(row => {
       const conversation = publicConversation(row);
-      return {
+      if (conversation.channelType === 'group') return [];
+      return [{
         id: conversation.id,
         contactId: row.contact_id,
         displayName: conversation.displayName,
         messages: conversation.messages,
         handledMessageId: conversation.agentNotice?.entityId || '',
-      };
+      }];
     });
   const account = activeAccountMetadata();
   const data = responseMonitor({
@@ -857,6 +978,73 @@ 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'
+      ? '群聊全自动模式已开启:后续 Agent 非空回复将忽略风险并直接发送'
+      : '群聊已切回人工审核模式',
+    data: { group },
+  };
+}
+
+async function approveGroupDraft(roomId, draftId, content) {
+  try {
+    const result = await groupAgentService.approve(roomId, draftId, content);
+    return { status: 'ok', assistantMessage: '群聊回复已真实发送', data: result };
+  } catch (error) {
+    if (/不存在|已经是|不能为空|过长|尚未确认为/.test(error.message)) throw error;
+    throw new Error('群聊发送失败,请确认企微账号在线后重试');
+  }
+}
+
+function rejectGroupDraft(roomId, draftId, reason) {
+  const result = groupAgentService.reject(roomId, draftId, reason);
+  return { status: 'ok', assistantMessage: '群聊草稿已驳回', data: result };
+}
+
+async function regenerateGroupDraft(roomId, draftId) {
+  try {
+    const result = await groupAgentService.regenerate(roomId, draftId);
+    return { status: 'ok', assistantMessage: '群聊 Agent 已重新生成待审核草稿', data: result };
+  } catch {
+    throw new Error('群聊 Agent 暂时不可用,原草稿已保留');
+  }
+}
+
 function updateCustomerProfile(conversationId, input = {}) {
   const conversation = workbench.db.getConversation(conversationId);
   if (!conversation) throw new Error('客户会话不存在');
@@ -1163,7 +1351,7 @@ async function startListener() {
   }
   return {
     status: 'ok',
-    assistantMessage: 'AI 监听已启动:仅处理白名单联系人,高置信回复可自动发送,人工可随时接管',
+    assistantMessage: 'AI 监听已启动:白名单私聊按当前策略处理;已确认客户群自动生成待审核草稿,不会自动群发',
     data: status,
   };
 }
@@ -1184,8 +1372,16 @@ function getAgentRuntimeConfig() {
 module.exports = {
   switchActiveAccount,
   getAgentStatus,
+  getAllowlistCandidates,
+  updateAllowlist,
   getConversations,
   getResponseMonitor,
+  getGroupAgents,
+  changeGroupMode,
+  generateGroupReply,
+  approveGroupDraft,
+  rejectGroupDraft,
+  regenerateGroupDraft,
   updateCustomerProfile,
   updateCustomerRecommendation,
   syncConversations,
@@ -1205,5 +1401,5 @@ module.exports = {
   stopListener,
   getAgentRuntimeConfig,
   createWorkbench,
-  __testing: { loadAgentConfig, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage, groupAgentService },
 };

+ 416 - 31
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js

@@ -42,7 +42,7 @@
     selectedGroupIds: new Set(),
     customerOps: { customers: [], defaultGreeting: '您好 {{name}},我是您的专属顾问,方便加您企业微信沟通。', rateLimit: 12, maxAttempts: 2, lastResult: null, master: { hub: null, selectedId: null, keyword: '', loading: false }, ...loadFilters(CO_FILTERS_KEY, { tablePage: 1, keyword: '', status: '', portrait: '', tag: '' }) },
     portraits: { lastPortrait: null, lastTags: [], personalLabels: [], lastTransferPreview: null, ...loadFilters(PT_FILTERS_KEY, { tablePage: 1, keyword: '', source: '', tag: '' }) },
-    agent: { status: null, conversations: [], responseMonitor: null, selectedId: null, timer: null, loading: false, signature: null },
+    agent: { status: null, conversations: [], responseMonitor: null, groupAgents: [], selectedId: null, selectedGroupId: null, listMode: 'private', timer: null, loading: false, signature: null, replyEdits: {}, groupReplyEdits: {} },
     skills: { registry: null, selectedPackageId: null, selectedSkillId: null, keyword: '' },
     knowledge: { tree: null, selectedNodeId: null, file: null, properties: null, selectedProperty: null, expandedFolderIds: new Set(), foldersInitialized: false, filters: { q: '', district: '', layout: '', decoration: '', maxPrice: '' }, meetings: { hub: null, selectedId: null, loading: false }, documents: { hub: null, selectedId: null, loading: false }, todos: { hub: null, selectedId: null, loading: false, userResults: [] }, tasks: { hub: null, loading: false, filters: { q: '', source: '', status: 'active' } } },
     transfers: { lastTransferPreview: null, ...loadFilters(TF_FILTERS_KEY, { tablePage: 1, status: '', user: '' }) }
@@ -145,6 +145,7 @@
       state.agent.conversations = [];
       state.agent.selectedId = null;
       state.agent.signature = null;
+      state.agent.replyEdits = {};
       saveAccounts();
       updateAccountSwitcher();
       renderPage();
@@ -1913,7 +1914,7 @@
   }
 
   function renderAgentMessage(message) {
-    const roleLabel = message.role === 'customer' ? '客户' : message.role === 'human' ? '人工客服' : 'Agent 建议';
+    const roleLabel = message.senderName || (message.role === 'customer' ? '客户' : message.role === 'human' ? '人工客服' : 'Agent 建议');
     const statusLabel = message.status === 'pending' ? '待确认' : message.status === 'sending' ? '发送中' : message.status === 'sent' ? '已发送' : message.status === 'submitted' ? '已提交企微' : '';
     return `<div class="agent-message ${escapeHtml(message.role)}">
       <div class="agent-message-meta"><span>${roleLabel}</span><span>${formatAgentTime(message.timestamp)}${statusLabel ? ` · ${statusLabel}` : ''}</span></div>
@@ -1976,6 +1977,110 @@
       </section>`;
   }
 
+  function agentGroupMonitorItems(conversations = [], monitor = {}, groupAgents = []) {
+    const groups = new Map();
+    for (const item of monitor.scope?.groups || []) {
+      groups.set(String(item.id), { ...item, id: String(item.id), channelType: 'group', channelLabel: '客户群聊' });
+    }
+    for (const conversation of conversations.filter(item => item.channelType === 'group')) {
+      const id = String(conversation.channelId || conversation.id);
+      const latest = conversation.messages?.[conversation.messages.length - 1] || null;
+      const current = groups.get(id) || {};
+      groups.set(id, {
+        ...current,
+        id,
+        name: current.name || conversation.displayName,
+        conversationId: conversation.id,
+        messages: conversation.messages || [],
+        lastMessageAt: current.lastMessageAt || conversation.lastMessageAt,
+        lastMessagePreview: current.lastMessagePreview || latest?.content || '',
+        status: current.status || 'monitoring',
+        channelType: 'group',
+        channelLabel: '客户群聊',
+      });
+    }
+    for (const groupAgent of groupAgents || []) {
+      const id = String(groupAgent.roomId || '');
+      if (!id) continue;
+      const latest = groupAgent.messages?.[groupAgent.messages.length - 1] || null;
+      const current = groups.get(id) || {};
+      groups.set(id, {
+        ...current,
+        id,
+        name: current.name || groupAgent.roomName || id,
+        messages: groupAgent.messages || [],
+        lastMessageAt: current.lastMessageAt || latest?.timestamp || null,
+        lastMessagePreview: current.lastMessagePreview || latest?.content || '',
+        lastSenderName: current.lastSenderName || latest?.senderName || '',
+        pendingReply: groupAgent.pendingReply || null,
+        mode: groupAgent.mode === 'auto' ? 'auto' : 'review',
+        agentError: groupAgent.agentError || null,
+        sendError: groupAgent.sendError || null,
+        agentNotice: groupAgent.agentNotice || null,
+        lastOutcome: groupAgent.lastOutcome || null,
+        audit: groupAgent.audit || [],
+        channelType: 'group',
+        channelLabel: '客户群聊',
+      });
+    }
+    return [...groups.values()].sort((a, b) => Date.parse(b.lastMessageAt || 0) - Date.parse(a.lastMessageAt || 0));
+  }
+
+  function agentMonitorStatusLabel(status) {
+    return ({
+      overdue: '待客服回复',
+      warning: '即将超时',
+      replied: '已回复',
+      no_reply_needed: '无需回复',
+      awaiting_sync: '等待消息同步',
+      monitoring: '监控中',
+    })[status] || '监控中';
+  }
+
+  function privateConversationFlow(conversation) {
+    const messages = conversation?.messages || [];
+    const inboundIndex = messages.findLastIndex(message => message.role === 'customer');
+    const outboundAfterInbound = inboundIndex >= 0 && messages.slice(inboundIndex + 1).some(message => ['agent', 'human'].includes(message.role));
+    const hasInbound = inboundIndex >= 0;
+    const hasDraft = Boolean(conversation?.pendingReply);
+    const failed = Boolean(conversation?.agentError);
+    const noReply = Boolean(conversation?.agentNotice);
+    const human = conversation?.mode === 'human';
+    const paused = conversation?.mode === 'paused';
+    return [
+      { label: '消息接入', detail: hasInbound ? '已收到企微消息' : '等待客户消息', state: hasInbound ? 'done' : 'current' },
+      { label: '范围校验', detail: hasInbound ? '白名单私聊' : '等待校验', state: hasInbound ? 'done' : 'waiting' },
+      { label: 'Agent 分析', detail: failed ? '处理失败' : noReply ? '判断无需回复' : hasDraft || outboundAfterInbound ? '分析完成' : human ? '人工路径' : paused ? '已暂停' : '等待处理', state: failed ? 'error' : noReply || hasDraft || outboundAfterInbound ? 'done' : hasInbound && !human && !paused ? 'current' : 'waiting' },
+      { label: '审核 / 接管', detail: hasDraft ? '草稿待审核' : human ? '人工处理中' : outboundAfterInbound || noReply ? '已完成' : '等待上一步', state: hasDraft || human ? 'current' : outboundAfterInbound || noReply ? 'done' : 'waiting' },
+      { label: '处理完成', detail: outboundAfterInbound ? '回复已发送' : noReply ? '无需外发' : '尚未完成', state: outboundAfterInbound || noReply ? 'done' : 'waiting' },
+    ];
+  }
+
+  function groupConversationFlow(group) {
+    const synced = group?.status !== 'awaiting_sync';
+    const hasMessage = Boolean(group?.messages?.length || group?.lastMessageAt);
+    const pending = Boolean(group?.pendingReply);
+    const failed = Boolean(group?.agentError);
+    const sendFailed = Boolean(group?.sendError);
+    const noReply = Boolean(group?.agentNotice);
+    const auto = group?.mode === 'auto';
+    const sent = ['group_message_sent', 'group_message_auto_sent'].includes(group?.lastOutcome?.action) || group?.status === 'replied';
+    return [
+      { label: '群消息接入', detail: hasMessage ? '消息已同步' : '等待同步', state: hasMessage ? 'done' : 'current' },
+      { label: '范围识别', detail: synced ? '已确认客户群' : '等待确认', state: synced ? 'done' : 'waiting' },
+      { label: 'Agent 分析', detail: failed ? '生成失败' : pending || sent || noReply ? '分析完成' : '等待生成', state: failed ? 'error' : pending || sent || noReply ? 'done' : hasMessage ? 'current' : 'waiting' },
+      { label: auto ? '自动放行' : '人工审核', detail: auto ? (sendFailed ? '发送失败待处理' : sent ? '已自动放行' : noReply ? '无需放行' : '等待 Agent 分析') : pending ? '草稿待审核' : sent || noReply ? '已完成' : '等待草稿', state: sendFailed ? 'error' : auto ? (sent || noReply ? 'done' : 'waiting') : pending ? 'current' : sent || noReply ? 'done' : 'waiting' },
+      { label: auto ? '自动发送' : '群内发送', detail: sent ? '回复已发送' : noReply ? '无需回复' : sendFailed ? '发送失败' : '尚未发送', state: sent || noReply ? 'done' : sendFailed ? 'error' : 'waiting' },
+    ];
+  }
+
+  function renderAgentFlow(steps, type) {
+    return `<section class="agent-flow ${escapeHtml(type)}" aria-label="${type === 'group' ? '群聊监控流程' : '客户私聊处理流程'}">
+      <div class="agent-flow-heading"><strong>当前处理流程</strong><span>${type === 'group' ? '群聊按当前群策略处理,审核与全自动互不混用' : '客户私聊进入 Agent 与人工协作链路'}</span></div>
+      <ol>${steps.map((step, index) => `<li class="${escapeHtml(step.state)}"><i>${index + 1}</i><div><strong>${escapeHtml(step.label)}</strong><small>${escapeHtml(step.detail)}</small></div></li>`).join('')}</ol>
+    </section>`;
+  }
+
   function agentAuditLabel(action) {
     return ({
       message_received: '收到真实企微消息',
@@ -2029,6 +2134,121 @@
     });
   }
 
+  function maskAllowlistId(value) {
+    const id = String(value || '');
+    if (id.length <= 8) return id;
+    return `${id.slice(0, 4)}…${id.slice(-4)}`;
+  }
+
+  async function openAllowlistModal(page) {
+    openModal(
+      '管理 AI 白名单',
+      `<div class="allowlist-intro"><strong>选择允许 AI 处理的客户</strong><span>未选中的联系人不会进入 Agent,也不会收到自动回复。</span></div>
+       <div class="allowlist-toolbar">
+         <input id="allowlist-search" type="search" placeholder="搜索客户姓名、备注或企业" autocomplete="off">
+         <button class="btn btn-secondary btn-sm" id="allowlist-refresh" type="button">刷新联系人</button>
+       </div>
+       <div class="allowlist-summary"><span id="allowlist-count">正在读取联系人…</span><em id="allowlist-warning"></em></div>
+       <div class="allowlist-contact-list" id="allowlist-contact-list"><div class="allowlist-loading">正在从企微读取联系人…</div></div>
+       <details class="allowlist-manual"><summary>找不到联系人?手工添加联系人 ID</summary><div><input id="allowlist-manual-id" placeholder="输入联系人 ID"><button class="btn btn-secondary btn-sm" id="allowlist-manual-add" type="button">添加</button></div><small id="allowlist-manual-error"></small></details>`,
+      `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button><button class="btn" id="allowlist-save" disabled>保存白名单</button>`,
+      'allowlist-modal'
+    );
+    const modal = state.modal;
+    const previousCleanup = modal.__modalCleanup;
+    document.body.classList.add('allowlist-modal-open');
+    modal.__modalCleanup = () => {
+      previousCleanup?.();
+      document.body.classList.remove('allowlist-modal-open');
+    };
+    const listEl = modal.querySelector('#allowlist-contact-list');
+    const searchEl = modal.querySelector('#allowlist-search');
+    const countEl = modal.querySelector('#allowlist-count');
+    const warningEl = modal.querySelector('#allowlist-warning');
+    const saveButton = modal.querySelector('#allowlist-save');
+    let contacts = [];
+    let selected = new Set();
+
+    const updateCount = () => {
+      countEl.textContent = `已选择 ${selected.size} 位联系人`;
+      saveButton.textContent = `保存白名单${selected.size ? `(${selected.size})` : ''}`;
+    };
+    const renderContacts = () => {
+      const keyword = searchEl.value.trim().toLowerCase();
+      const visible = contacts.filter(contact => !keyword || [contact.displayName, contact.remark, contact.company, contact.id]
+        .some(value => String(value || '').toLowerCase().includes(keyword)));
+      listEl.innerHTML = visible.length ? visible.map(contact => {
+        const checked = selected.has(contact.id);
+        const meta = [contact.remark && contact.remark !== contact.displayName ? `备注:${contact.remark}` : '', contact.company, maskAllowlistId(contact.id)].filter(Boolean).join(' · ');
+        return `<label class="allowlist-contact ${checked ? 'selected' : ''}">
+          <input type="checkbox" data-allowlist-id="${escapeHtml(contact.id)}" ${checked ? 'checked' : ''}>
+          <span class="allowlist-avatar">${escapeHtml((contact.displayName || '客').slice(0, 1))}</span>
+          <span class="allowlist-contact-copy"><strong>${escapeHtml(contact.displayName || '企微客户')}</strong><small>${escapeHtml(meta)}</small></span>
+          <em>${checked ? '已选择' : '未选择'}</em>
+        </label>`;
+      }).join('') : `<div class="allowlist-empty"><strong>${contacts.length ? '没有匹配的联系人' : '暂未读取到联系人'}</strong><span>${contacts.length ? '换一个姓名或备注试试。' : '请确认企微在线,或使用下方联系人 ID 添加。'}</span></div>`;
+      updateCount();
+    };
+    const loadContacts = async () => {
+      listEl.innerHTML = '<div class="allowlist-loading">正在从企微读取联系人…</div>';
+      warningEl.textContent = '';
+      saveButton.disabled = true;
+      try {
+        const result = await api('GET', '/api/agent/allowlist', undefined, 35000);
+        if (!modal.isConnected) return;
+        contacts = result.data?.contacts || [];
+        selected = new Set(result.data?.selectedIds || []);
+        warningEl.textContent = result.warnings?.[0] || '';
+        renderContacts();
+      } catch (error) {
+        if (!modal.isConnected) return;
+        listEl.innerHTML = `<div class="allowlist-empty error"><strong>联系人读取失败</strong><span>${escapeHtml(error.message || '请稍后重试,或使用联系人 ID 添加。')}</span></div>`;
+        countEl.textContent = '尚未加载白名单';
+      } finally {
+        if (modal.isConnected) saveButton.disabled = false;
+      }
+    };
+
+    searchEl.addEventListener('input', renderContacts);
+    listEl.addEventListener('change', event => {
+      const checkbox = event.target.closest('[data-allowlist-id]');
+      if (!checkbox) return;
+      if (checkbox.checked) selected.add(checkbox.dataset.allowlistId);
+      else selected.delete(checkbox.dataset.allowlistId);
+      renderContacts();
+    });
+    modal.querySelector('#allowlist-refresh').addEventListener('click', loadContacts);
+    modal.querySelector('#allowlist-manual-add').addEventListener('click', () => {
+      const input = modal.querySelector('#allowlist-manual-id');
+      const errorEl = modal.querySelector('#allowlist-manual-error');
+      const id = input.value.trim();
+      if (!/^[A-Za-z0-9._:@-]{1,128}$/.test(id)) {
+        errorEl.textContent = '请输入有效的联系人 ID';
+        return;
+      }
+      errorEl.textContent = '';
+      if (!contacts.some(contact => contact.id === id)) contacts.unshift({ id, displayName: '手工添加联系人', remark: '', company: '' });
+      selected.add(id);
+      input.value = '';
+      searchEl.value = '';
+      renderContacts();
+    });
+    saveButton.addEventListener('click', async () => {
+      if (!selected.size && !window.confirm('清空白名单会立即停止 AI 监听。确认清空吗?')) return;
+      saveButton.disabled = true;
+      try {
+        const result = await api('POST', '/api/agent/allowlist', { contactIds: [...selected] });
+        toast(result.assistantMessage || '白名单已保存');
+        closeModal();
+        await loadAgentData(page);
+      } catch (error) {
+        toast(error.message || '白名单保存失败', 'error');
+        saveButton.disabled = false;
+      }
+    });
+    await loadContacts();
+  }
+
   function renderAgentWorkspace(page) {
     const status = state.agent.status || {};
     const listener = status.listener || {};
@@ -2077,7 +2297,7 @@
           <span class="agent-mode-btn ${!aiActive ? 'active' : ''}">人工回复</span>
           <span class="agent-mode-btn ${aiActive ? 'active' : ''}">AI 自动回复</span>
         </div>
-        <div class="agent-guard"><span>安全边界</span>AI 只回复唯一白名单测试联系人</div>
+        <div class="agent-guard"><span>安全边界</span>仅处理 ${Number(config.allowedSenderCount) || 0} 位白名单联系人</div>
       </section>
 
       <section class="agent-workspace">
@@ -2147,12 +2367,28 @@
     const globalPaused = global.paused !== undefined ? Boolean(global.paused) : status.globalMode === 'paused';
     const globalMode = global.defaultMode || (status.globalMode === 'auto' ? 'auto' : 'review');
     const listenerRunning = Boolean(listener.running);
+    const allowlistCount = Number(config.allowedSenderCount) || 0;
+    const allowlistReady = allowlistCount > 0;
     const agent = status.agent || {};
     const knowledge = status.knowledge || {};
     const conversations = state.agent.conversations || [];
-    const selected = conversations.find(item => item.id === state.agent.selectedId) || conversations[0] || null;
+    const groupAgents = state.agent.groupAgents || [];
+    const privateConversations = conversations.filter(item => item.channelType !== 'group');
+    const groupConversations = agentGroupMonitorItems(conversations, state.agent.responseMonitor || {}, groupAgents);
+    const listMode = state.agent.listMode === 'group' ? 'group' : 'private';
+    const selected = listMode === 'private'
+      ? privateConversations.find(item => item.id === state.agent.selectedId) || privateConversations[0] || null
+      : null;
+    const selectedGroup = listMode === 'group'
+      ? groupConversations.find(item => item.id === state.agent.selectedGroupId) || groupConversations[0] || null
+      : null;
     if (selected) state.agent.selectedId = selected.id;
+    if (selectedGroup) state.agent.selectedGroupId = selectedGroup.id;
     const sessionGuide = selected?.claudeSession || null;
+    const groupPending = selectedGroup?.pendingReply || null;
+    const groupReplyEditKey = selectedGroup && groupPending ? `${selectedGroup.id}:${groupPending.id}` : '';
+    const hasGroupReplyEdit = Boolean(groupReplyEditKey && Object.prototype.hasOwnProperty.call(state.agent.groupReplyEdits, groupReplyEditKey));
+    const groupReplyText = hasGroupReplyEdit ? state.agent.groupReplyEdits[groupReplyEditKey] : (groupPending?.content || '');
 
     const analysis = selected?.analysis || {};
     const customerIntelligence = selected?.customerIntelligence || {};
@@ -2164,9 +2400,13 @@
     const pending = selected?.pendingReply || null;
     const noReplyNotice = selected?.agentNotice || null;
     const activeDraft = pending;
-    const pendingText = pending?.content || '';
     const manualActive = selected?.mode === 'human';
     const conversationPaused = selected?.mode === 'paused';
+    const replyEditKey = selected ? `${selected.id}:${pending?.id || (manualActive ? 'manual' : 'composer')}` : '';
+    const hasReplyEdit = Boolean(replyEditKey && Object.prototype.hasOwnProperty.call(state.agent.replyEdits, replyEditKey));
+    const pendingText = hasReplyEdit
+      ? state.agent.replyEdits[replyEditKey]
+      : (pending?.content || '');
     const audit = selected?.audit || [];
     const toolTrace = activeDraft?.toolTrace || activeDraft?.tool_trace || [];
     const citations = activeDraft?.citations || [];
@@ -2185,8 +2425,9 @@
           <span class="agent-live-dot ${account.online ? 'online' : ''}"></span>
           <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(account.corpName || config.transport || '企微通道')} · ${listenerRunning ? '真实消息监听中' : '监听已停止'}</small></div>
           <div class="agent-safety-actions">
-            <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online ? '' : 'disabled'}>补采最近消息</button>
-            <button class="btn ${listenerRunning ? 'btn-secondary' : ''}" data-agent-action="${listenerRunning ? 'stop-listener' : 'start-listener'}" ${account.online ? '' : 'disabled'}>${listenerRunning ? '关闭 AI 监听(转人工)' : '启动 AI 监听'}</button>
+            <button class="btn btn-secondary" data-agent-action="manage-allowlist">管理白名单</button>
+            <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采最近消息</button>
+            <button class="btn ${listenerRunning ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (listenerRunning ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (listenerRunning ? '关闭 AI 监听(转人工)' : '启动 AI 监听') : '先选择白名单'}</button>
           </div>
         </div>
       </section>
@@ -2198,7 +2439,7 @@
           <button class="agent-mode-btn ${!globalPaused && globalMode === 'auto' ? 'active' : ''}" data-agent-mode="auto">高置信自动</button>
           <button class="agent-mode-btn ${globalPaused ? 'active danger' : ''}" data-agent-mode="paused">全局暂停</button>
         </div>
-        <div class="agent-guard"><span>安全边界</span>仅处理 1 个白名单联系人 · 演示回复已禁用</div>
+        <div class="agent-guard"><span>发送边界</span>私聊限 ${allowlistCount} 位白名单联系人 · 客户群按各群独立模式处理${groupAgents.some(item => item.mode === 'auto') ? ` · ${groupAgents.filter(item => item.mode === 'auto').length} 个群已开启全自动` : ''}</div>
       </section>
 
       ${renderResponseMonitor(state.agent.responseMonitor || {})}
@@ -2211,21 +2452,49 @@
 
       <section class="agent-workspace">
         <aside class="agent-conversation-list">
-          <div class="agent-panel-heading"><div><strong>真实客户会话</strong><small>${conversations.length} 个白名单会话</small></div><span class="agent-count">${conversations.length}</span></div>
+          <div class="agent-panel-heading"><div><strong>消息入口</strong><small>私聊与群聊智能回复已分开</small></div><span class="agent-count">${privateConversations.length + groupConversations.length}</span></div>
+          <div class="agent-list-tabs" role="tablist" aria-label="消息类型">
+            <button class="${listMode === 'private' ? 'active' : ''}" role="tab" aria-selected="${listMode === 'private'}" data-agent-list-mode="private"><span>客户私聊</span><b>${privateConversations.length}</b></button>
+            <button class="${listMode === 'group' ? 'active' : ''}" role="tab" aria-selected="${listMode === 'group'}" data-agent-list-mode="group"><span>群聊智能回复</span><b>${groupConversations.length}</b></button>
+          </div>
           <div class="agent-list-scroll">
-            ${conversations.length ? conversations.map(item => {
+            ${listMode === 'private' && privateConversations.length ? privateConversations.map(item => {
               const last = [...(item.messages || [])].reverse().find(message => message.role === 'customer') || item.messages?.[item.messages.length - 1];
               return `<button class="agent-conversation-item ${item.id === selected?.id ? 'active' : ''}" data-conversation-id="${item.id}">
                 <span class="agent-avatar">${escapeHtml((item.displayName || '客').slice(0, 1))}</span>
-                <span class="agent-conversation-copy"><strong>${escapeHtml(item.displayName)}</strong><small>${escapeHtml(last?.content || '等待新消息')}</small><em>${agentModeLabel(item.mode)}${item.pendingReply ? ' · 有待审草稿' : ''}${item.customerIntelligence?.summary?.openTasks ? ` · ${item.customerIntelligence.summary.openTasks}待办` : ''}${item.customerIntelligence?.summary?.highAlerts ? ` · ${item.customerIntelligence.summary.highAlerts}预警` : ''}</em></span>
+                <span class="agent-conversation-copy"><span class="agent-conversation-name"><strong>${escapeHtml(item.displayName)}</strong><i class="agent-channel-badge private">私聊</i></span><small>${escapeHtml(last?.content || '等待新消息')}</small><em>${agentModeLabel(item.mode)}${item.pendingReply ? ' · 有待审草稿' : ''}${item.customerIntelligence?.summary?.openTasks ? ` · ${item.customerIntelligence.summary.openTasks}待办` : ''}${item.customerIntelligence?.summary?.highAlerts ? ` · ${item.customerIntelligence.summary.highAlerts}预警` : ''}</em></span>
                 <time>${formatAgentTime(item.lastMessageAt)}</time>
               </button>`;
-            }).join('') : `<div class="agent-empty"><strong>等待第一条真实消息</strong><span>开始接收后,用“王刚”的企微发送测试内容。</span></div>`}
+            }).join('') : listMode === 'group' && groupConversations.length ? groupConversations.map(item => `<button class="agent-conversation-item group ${item.id === selectedGroup?.id ? 'active' : ''}" data-group-monitor-id="${escapeHtml(item.id)}">
+                <span class="agent-avatar group">群</span>
+                <span class="agent-conversation-copy"><span class="agent-conversation-name"><strong>${escapeHtml(item.name || '客户群')}</strong><i class="agent-channel-badge group">群聊</i></span><small>${escapeHtml(item.lastMessagePreview || '等待群消息同步')}</small><em class="${escapeHtml(item.status || '')}">${item.mode === 'auto' ? '全自动' : item.pendingReply ? '草稿待审核' : item.agentError ? 'Agent 生成失败' : escapeHtml(agentMonitorStatusLabel(item.status))}${item.waitingMinutes ? ` · ${formatReplyWaiting(item.waitingMinutes)}` : ''}</em></span>
+                <time>${formatAgentTime(item.lastMessageAt)}</time>
+              </button>`).join('') : `<div class="agent-empty"><strong>${listMode === 'group' ? '还没有已确认客户群' : allowlistReady ? '等待第一条真实消息' : '还没有选择白名单'}</strong><span>${listMode === 'group' ? '先在客户群管理中确认需要智能回复的客户群。' : allowlistReady ? '启动监听后,请从已选企微客户发送测试内容。' : '点击上方“管理白名单”,直接从企微联系人中选择。'}</span></div>`}
           </div>
         </aside>
 
-        <main class="agent-chat-panel">
-          ${selected ? `
+        <main class="agent-chat-panel ${selectedGroup ? 'group-mode' : ''}">
+          ${selectedGroup ? `
+            <header class="agent-chat-header group">
+              <div><strong>${escapeHtml(selectedGroup.name || '客户群')}</strong><span>客户群聊 · ${selectedGroup.mode === 'auto' ? '全自动(忽略风险)' : 'Agent 草稿 · 人工审核发送'}</span></div>
+              <div class="agent-conversation-modes group-modes">
+                <button class="btn btn-sm ${selectedGroup.mode === 'auto' ? 'btn-secondary' : ''}" data-agent-group-mode="review" data-room-id="${escapeHtml(selectedGroup.id)}">人工审核</button>
+                <button class="btn btn-sm agent-group-mode-danger ${selectedGroup.mode === 'auto' ? 'active' : 'btn-secondary'}" data-agent-group-mode="auto" data-room-id="${escapeHtml(selectedGroup.id)}">无视风险 · 全自动</button>
+              </div>
+            </header>
+            ${renderAgentFlow(groupConversationFlow(selectedGroup), 'group')}
+            <div class="agent-message-stream group">${selectedGroup.messages?.length ? selectedGroup.messages.map(renderAgentMessage).join('') : '<div class="agent-empty"><strong>等待群消息</strong><span>同步群消息后,Agent 可以生成待审核回复。</span></div>'}</div>
+            <div class="agent-composer group">
+              <div class="agent-composer-label"><strong>${groupPending ? (selectedGroup.sendError ? '全自动发送失败 · 草稿已保留' : '群聊 Agent 待审核草稿') : selectedGroup.agentNotice ? 'Agent 已处理' : selectedGroup.agentError ? 'Agent 生成失败' : selectedGroup.mode === 'auto' ? '群聊全自动回复' : '群聊智能回复'}</strong><span data-agent-group-edit-status>${groupPending ? (hasGroupReplyEdit ? '已人工修改 · 批准后发送当前内容' : `置信度 ${Math.round((groupPending.confidence || 0) * 100)}% · ${selectedGroup.mode === 'auto' ? '自动发送未完成,可人工处理' : '必须人工审核'}`) : selectedGroup.agentNotice ? '最新群消息无需回复' : selectedGroup.agentError ? '可重新生成或直接在企微回复' : selectedGroup.mode === 'auto' ? '忽略置信度、敏感判断和人工风险标记,生成后直接发送' : 'Agent 只生成草稿,不会自动外发'}</span></div>
+              <textarea id="agent-group-reply-editor" data-agent-group-edit-key="${escapeHtml(groupReplyEditKey)}" placeholder="${groupPending ? '可先编辑 Agent 群聊草稿…' : '点击下方按钮,让 Agent 分析最近一条群消息'}">${escapeHtml(groupReplyText)}</textarea>
+              <div class="agent-composer-actions">
+                <span>${groupPending ? escapeHtml(groupPending.reason || '请核对群成员、事实与表达后再发送') : `回复时效:${selectedGroup.waitingMinutes ? formatReplyWaiting(selectedGroup.waitingMinutes) : agentMonitorStatusLabel(selectedGroup.status)}`}</span>
+                <div class="agent-review-actions">
+                  ${groupPending ? `<button class="btn btn-secondary" data-agent-action="group-reject" data-room-id="${escapeHtml(selectedGroup.id)}" data-draft-id="${escapeHtml(groupPending.id)}">驳回</button><button class="btn btn-secondary" data-agent-action="group-regenerate" data-room-id="${escapeHtml(selectedGroup.id)}" data-draft-id="${escapeHtml(groupPending.id)}">重新生成</button><button class="btn" data-agent-action="group-approve" data-room-id="${escapeHtml(selectedGroup.id)}" data-draft-id="${escapeHtml(groupPending.id)}">批准并发送到群</button>` : `<button class="btn ${selectedGroup.mode === 'auto' ? 'agent-group-auto-send' : ''}" data-agent-action="group-generate" data-room-id="${escapeHtml(selectedGroup.id)}">${selectedGroup.mode === 'auto' ? '立即生成并自动发送' : '让 Agent 生成草稿'}</button>`}
+                </div>
+              </div>
+            </div>
+          ` : selected ? `
             <header class="agent-chat-header">
               <div><strong>${escapeHtml(selected.displayName)}</strong><span>${escapeHtml(selected.maskedId)} · 真实企微会话 · ${agentModeLabel(selected.mode)}</span></div>
               <div class="agent-conversation-modes">
@@ -2235,6 +2504,7 @@
                 <button class="btn btn-sm ${selected.mode === 'paused' ? '' : 'btn-secondary'}" data-agent-conversation-mode="paused" data-id="${selected.id}">暂停</button>
               </div>
             </header>
+            ${renderAgentFlow(privateConversationFlow(selected), 'private')}
             <div class="agent-session-strip ${sessionGuide?.ready ? 'ready' : ''}">
               <span class="agent-session-dot"></span>
               <div><strong>${sessionGuide?.ready ? 'Claude Code 客户 Session 已识别' : 'Claude Code 客户 Session 等待首次运行'}</strong><small>${escapeHtml(sessionGuide?.displayName || 'Agent 首次生成草稿后自动创建')} · ${sessionGuide?.ready ? '可安全打开审阅副本' : '不会与其他客户共用上下文'}</small></div>
@@ -2242,8 +2512,8 @@
             </div>
             <div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
             <div class="agent-composer ${manualActive ? 'manual' : ''}">
-              <div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span>${pending ? `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核' : '可审核发送'}` : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
-              <textarea id="agent-reply-editor" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
+              <div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span data-agent-edit-status>${pending ? (hasReplyEdit ? '已人工修改 · 批准后发送当前内容' : `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核,可直接编辑' : '可直接编辑后发送'}`) : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
+              <textarea id="agent-reply-editor" data-agent-edit-key="${escapeHtml(replyEditKey)}" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
               <div class="agent-composer-actions">
                 <span>${pending ? escapeHtml(pending.reason || '请核对内容与依据后再发送') : `模型:${escapeHtml(agent.model || '未配置')} · ${agent.configured ? '已配置' : '未配置'}`}</span>
                 <div class="agent-review-actions">
@@ -2251,11 +2521,24 @@
                 </div>
               </div>
             </div>
-          ` : `<div class="agent-empty large"><strong>真实 Agent 会话准备就绪</strong><span>开始接收,然后从“王刚”的企微发送:“我想在新北区买婚房,预算150万,三室,最好精装”</span></div>`}
+          ` : `<div class="agent-empty large"><strong>真实 Agent 会话准备就绪</strong><span>开始接收后,请从已选白名单客户的企微发送测试消息。</span></div>`}
         </main>
 
-        <aside class="agent-insight-panel" data-conversation-id="${escapeHtml(selected?.id || '')}">
-          ${selected ? `
+        <aside class="agent-insight-panel" data-conversation-id="${escapeHtml(selected?.id || selectedGroup?.id || '')}">
+          ${selectedGroup ? `
+            <div class="agent-insight-section">
+              <div class="agent-section-title"><span>01</span><div><strong>智能回复状态</strong><small>群聊独立上下文</small></div></div>
+              <div class="agent-intent-card group ${selectedGroup.mode === 'auto' ? 'auto' : ''}"><strong>${selectedGroup.mode === 'auto' ? '全自动 · 忽略风险' : groupPending ? '草稿待审核' : selectedGroup.agentError ? '生成失败' : selectedGroup.agentNotice ? '无需回复' : '等待 Agent'}</strong><span>${escapeHtml(agentMonitorStatusLabel(selectedGroup.status))}</span></div>
+            </div>
+            <div class="agent-insight-section">
+              <div class="agent-section-title"><span>02</span><div><strong>回复时效</strong><small>本地计时与提醒</small></div></div>
+              <div class="agent-group-sla"><strong>${selectedGroup.waitingMinutes ? formatReplyWaiting(selectedGroup.waitingMinutes) : '未开始计时'}</strong><span>${selectedGroup.status === 'overdue' ? '已超过紧急提醒阈值' : selectedGroup.status === 'warning' ? '已进入提醒区间' : '当前未超时'}</span></div>
+            </div>
+            <div class="agent-insight-section">
+              <div class="agent-section-title"><span>03</span><div><strong>处理边界</strong><small>不会串入客户私聊</small></div></div>
+              <ul class="agent-boundary-list ${selectedGroup.mode === 'auto' ? 'danger' : ''}">${selectedGroup.mode === 'auto' ? '<li>忽略置信度、敏感判断和人工风险标记</li><li>Agent 返回非空内容后立即真实群发</li><li>模型或发送失败时停止并保留草稿</li><li>不读取其他客户私聊与画像</li>' : '<li>已确认客户群自动生成回复草稿</li><li>每条草稿必须人工编辑或审核</li><li>不读取其他客户私聊与画像</li><li>确认后才通过 Fmode 网关真实群发</li>'}</ul>
+            </div>
+          ` : selected ? `
             <div class="agent-insight-section">
               <div class="agent-section-title"><span>01</span><div><strong>意图判断</strong><small>Agent 结构化理解</small></div></div>
               <div class="agent-intent-card"><strong>${escapeHtml(analysis.intentLabel || analysis.intent || '待识别')}</strong><span>画像完整度 ${Math.round(analysis.completenessScore || 0)}%</span><div><i style="width:${Math.min(100, analysis.completenessScore || 0)}%"></i></div></div>
@@ -2293,7 +2576,7 @@
         </aside>
       </section>
     `;
-    restoreAgentInsightScroll(page, insightScroll, selected?.id);
+    restoreAgentInsightScroll(page, insightScroll, selected?.id || selectedGroup?.id);
     scrollAgentMessagesToLatest(page);
   }
 
@@ -2301,14 +2584,16 @@
     if (state.agent.loading) return;
     state.agent.loading = true;
     try {
-      const [statusRes, conversationsRes, responseMonitorRes] = await Promise.all([
+      const [statusRes, conversationsRes, responseMonitorRes, groupAgentsRes] = await Promise.all([
         api('GET', '/api/agent/status'),
         api('GET', '/api/agent/conversations'),
         api('GET', '/api/agent/response-monitor'),
+        api('GET', '/api/agent/groups'),
       ]);
       const nextStatus = statusRes.data || {};
       const nextConversations = conversationsRes.data?.conversations || [];
       const nextResponseMonitor = responseMonitorRes.data || {};
+      const nextGroupAgents = groupAgentsRes.data?.groups || [];
       const localAccount = currentAccount();
       const activeAccount = nextStatus.account || {};
       if (localAccount && localAccount.uid && activeAccount.uid === localAccount.uid) {
@@ -2343,15 +2628,22 @@
         config: nextStatus.config,
         conversations: nextConversations,
         responseMonitor: nextResponseMonitor,
+        groupAgents: nextGroupAgents,
       });
       const changed = nextSignature !== state.agent.signature;
       state.agent.status = nextStatus;
       state.agent.conversations = nextConversations;
       state.agent.responseMonitor = nextResponseMonitor;
-      state.agent.signature = nextSignature;
-      if (!state.agent.selectedId && state.agent.conversations.length) state.agent.selectedId = state.agent.conversations[0].id;
+      state.agent.groupAgents = nextGroupAgents;
+      if (!state.agent.selectedId && state.agent.conversations.length) {
+        state.agent.selectedId = state.agent.conversations.find(item => item.channelType !== 'group')?.id || null;
+      }
       const waitingForFirstRender = Boolean(page.querySelector('.agent-loading'));
-      if ((changed || waitingForFirstRender) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
+      const editorFocused = document.activeElement === page.querySelector('#agent-reply-editor') || document.activeElement === page.querySelector('#agent-group-reply-editor');
+      if (!editorFocused) {
+        state.agent.signature = nextSignature;
+        if ((changed || waitingForFirstRender) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
+      }
     } catch (error) {
       if (!silent) toast(error.message || '智能会话加载失败', 'error');
     } finally {
@@ -2371,19 +2663,51 @@
     page.innerHTML = '<div class="agent-loading">正在读取企微智能会话…</div>';
     els.content.appendChild(page);
 
+    page.addEventListener('input', event => {
+      const editor = event.target.closest('#agent-reply-editor');
+      if (editor?.dataset.agentEditKey) {
+        state.agent.replyEdits[editor.dataset.agentEditKey] = editor.value;
+        const status = page.querySelector('[data-agent-edit-status]');
+        if (status) status.textContent = '已人工修改 · 批准后发送当前内容';
+        return;
+      }
+      const groupEditor = event.target.closest('#agent-group-reply-editor');
+      if (!groupEditor?.dataset.agentGroupEditKey) return;
+      state.agent.groupReplyEdits[groupEditor.dataset.agentGroupEditKey] = groupEditor.value;
+      const status = page.querySelector('[data-agent-group-edit-status]');
+      if (status) status.textContent = '已人工修改 · 批准后发送当前内容';
+    });
+
     page.addEventListener('click', async event => {
       const responseMonitorButton = event.target.closest('[data-response-monitor-target]');
       if (responseMonitorButton) {
         if (responseMonitorButton.dataset.responseMonitorTarget === 'customer') {
+          state.agent.listMode = 'private';
           state.agent.selectedId = responseMonitorButton.dataset.id;
-          renderAgentWorkspaceV2(page);
         } else {
-          location.hash = '#groups';
+          state.agent.listMode = 'group';
+          state.agent.selectedGroupId = responseMonitorButton.dataset.id;
         }
+        renderAgentWorkspaceV2(page);
+        page.querySelector('.agent-workspace')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+        return;
+      }
+      const listModeButton = event.target.closest('[data-agent-list-mode]');
+      if (listModeButton) {
+        state.agent.listMode = listModeButton.dataset.agentListMode === 'group' ? 'group' : 'private';
+        renderAgentWorkspaceV2(page);
+        return;
+      }
+      const groupMonitorButton = event.target.closest('[data-group-monitor-id]');
+      if (groupMonitorButton) {
+        state.agent.listMode = 'group';
+        state.agent.selectedGroupId = groupMonitorButton.dataset.groupMonitorId;
+        renderAgentWorkspaceV2(page);
         return;
       }
       const conversationButton = event.target.closest('[data-conversation-id]');
       if (conversationButton) {
+        state.agent.listMode = 'private';
         state.agent.selectedId = conversationButton.dataset.conversationId;
         renderAgentWorkspaceV2(page);
         return;
@@ -2392,7 +2716,8 @@
       const modeButton = event.target.closest('[data-agent-mode]');
       if (modeButton) {
         const mode = modeButton.dataset.agentMode;
-        if (mode === 'auto' && !window.confirm('高置信自动模式会真实发送回复,仅对白名单联系人“王刚”生效。确认开启吗?')) return;
+        const allowlistCount = Number(state.agent.status?.config?.allowedSenderCount) || 0;
+        if (mode === 'auto' && !window.confirm(`高置信自动模式会向白名单中的 ${allowlistCount} 位联系人真实发送回复。确认开启吗?`)) return;
         try {
           const result = await api('POST', '/api/agent/mode', { mode });
           toast(result.assistantMessage || '模式已更新');
@@ -2422,10 +2747,39 @@
         return;
       }
 
+      const groupModeButton = event.target.closest('[data-agent-group-mode]');
+      if (groupModeButton) {
+        const roomId = groupModeButton.dataset.roomId;
+        const mode = groupModeButton.dataset.agentGroupMode;
+        if (mode === 'auto' && !window.confirm('全自动模式会忽略置信度、敏感判断和人工风险标记。Agent 只要返回非空内容,就会直接真实发送到该客户群。确认开启吗?')) return;
+        groupModeButton.disabled = true;
+        try {
+          const result = await api('POST', `/api/agent/groups/${encodeURIComponent(roomId)}/mode`, {
+            mode,
+            confirmation: mode === 'auto' ? 'AUTO_SEND_GROUP_MESSAGES' : '',
+          });
+          toast(result.assistantMessage || '群聊模式已更新');
+          await loadAgentData(page);
+        } catch (error) {
+          toast(error.message || '群聊模式切换失败', 'error');
+        } finally {
+          if (groupModeButton.isConnected) groupModeButton.disabled = false;
+        }
+        return;
+      }
+
       const actionButton = event.target.closest('[data-agent-action]');
       if (!actionButton) return;
       const action = actionButton.dataset.agentAction;
       const id = actionButton.dataset.id;
+      const editor = page.querySelector('#agent-reply-editor');
+      const replyEditKey = editor?.dataset.agentEditKey || '';
+      const groupEditor = page.querySelector('#agent-group-reply-editor');
+      const groupReplyEditKey = groupEditor?.dataset.agentGroupEditKey || '';
+      if (action === 'manage-allowlist') {
+        await openAllowlistModal(page);
+        return;
+      }
       if (action === 'session-guide') {
         const conversation = (state.agent.conversations || []).find(item => item.id === id);
         openAgentSessionGuide(conversation?.claudeSession || {});
@@ -2440,7 +2794,8 @@
           const synced = result.data?.conversations || [];
           if (synced.length) {
             state.agent.conversations = synced;
-            state.agent.selectedId = synced[0].id;
+            state.agent.listMode = 'private';
+            state.agent.selectedId = synced.find(item => item.channelType !== 'group')?.id || null;
           }
         } else if (action === 'customer-task-sync-official') {
           const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId) || (state.agent.conversations || [])[0];
@@ -2460,12 +2815,35 @@
         } else if (action === 'customer-alert-ack' || action === 'customer-alert-dismiss') {
           result = await api('POST', `/api/agent/alerts/${id}`, { status: action === 'customer-alert-ack' ? 'acknowledged' : 'dismissed' });
         } else if (action === 'start-listener' || action === 'stop-listener') {
-          if (action === 'start-listener' && !window.confirm('启动后,白名单客户的新消息会由 AI 处理;高置信回复可能自动真实发送。人工可随时关闭监听或接管会话。确认启动吗?')) return;
+          if (action === 'start-listener') {
+            const autoGroups = (state.agent.groupAgents || []).filter(item => item.mode === 'auto');
+            const groupNotice = autoGroups.length
+              ? `其中 ${autoGroups.length} 个客户群已开启“无视风险 · 全自动”,Agent 非空回复会直接真实群发。`
+              : '当前所有客户群均为人工审核模式,不会自动群发。';
+            if (!window.confirm(`启动后,白名单私聊会按当前策略处理。${groupNotice}确认启动吗?`)) return;
+          }
           result = await api('POST', `/api/agent/listener/${action === 'start-listener' ? 'start' : 'stop'}`, {});
+        } else if (action === 'group-generate') {
+          const group = (state.agent.groupAgents || []).find(item => item.roomId === actionButton.dataset.roomId);
+          if (group?.mode === 'auto' && !window.confirm(`当前为全自动模式。继续后,Agent 生成的非空内容会立即真实发送到“${group.roomName || group.roomId}”。确认继续吗?`)) return;
+          result = await api('POST', `/api/agent/groups/${encodeURIComponent(actionButton.dataset.roomId)}/generate`, {});
+        } else if (action === 'group-approve') {
+          const content = groupEditor?.value.trim();
+          if (!content) throw new Error('请先输入群聊回复内容');
+          const selectedGroup = (state.agent.groupAgents || []).find(item => item.roomId === actionButton.dataset.roomId);
+          if (!window.confirm(`确认审核通过,并真实发送到客户群“${selectedGroup?.roomName || actionButton.dataset.roomId}”?`)) return;
+          result = await api('POST', `/api/agent/groups/${encodeURIComponent(actionButton.dataset.roomId)}/drafts/${encodeURIComponent(actionButton.dataset.draftId)}/approve`, { content });
+        } else if (action === 'group-reject') {
+          const reason = window.prompt('驳回原因(可选)', '需要人工调整');
+          if (reason === null) return;
+          result = await api('POST', `/api/agent/groups/${encodeURIComponent(actionButton.dataset.roomId)}/drafts/${encodeURIComponent(actionButton.dataset.draftId)}/reject`, { reason });
+        } else if (action === 'group-regenerate') {
+          result = await api('POST', `/api/agent/groups/${encodeURIComponent(actionButton.dataset.roomId)}/drafts/${encodeURIComponent(actionButton.dataset.draftId)}/regenerate`, {});
         } else if (action === 'approve') {
           const content = page.querySelector('#agent-reply-editor')?.value.trim();
           if (!content) throw new Error('请先输入回复内容');
-          if (!window.confirm('确认审核通过,并真实发送给白名单联系人“王刚”?')) return;
+          const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId);
+          if (!window.confirm(`确认审核通过,并真实发送给白名单联系人“${selected?.displayName || '当前客户'}”?`)) return;
           result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/approve`, { content });
         } else if (action === 'reject') {
           const reason = window.prompt('驳回原因(可选)', '需要人工调整');
@@ -2478,10 +2856,17 @@
         } else if (action === 'manual-send') {
           const content = page.querySelector('#agent-reply-editor')?.value.trim();
           if (!content) throw new Error('请先输入人工回复内容');
-          if (!window.confirm('确认由人工真实发送给白名单联系人“王刚”?')) return;
+          const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId);
+          if (!window.confirm(`确认由人工真实发送给白名单联系人“${selected?.displayName || '当前客户'}”?`)) return;
           result = await api('POST', `/api/agent/conversations/${id}/manual-send`, { content });
         }
         if (result) toast(result.assistantMessage || '操作成功');
+        if (result && replyEditKey && ['approve', 'reject', 'regenerate', 'generate', 'manual-send'].includes(action)) {
+          delete state.agent.replyEdits[replyEditKey];
+        }
+        if (result && groupReplyEditKey && ['group-approve', 'group-reject', 'group-regenerate', 'group-generate'].includes(action)) {
+          delete state.agent.groupReplyEdits[groupReplyEditKey];
+        }
         await loadAgentData(page);
       } catch (error) {
         toast(error.message || '操作失败', 'error');

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

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

+ 12 - 7
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/response-monitor-service.js

@@ -95,13 +95,18 @@ function roomMessages(messagesDir, roomId) {
   const prefix = `room-${roomId}-`;
   const seen = new Set();
   const result = [];
-  const files = fs.readdirSync(messagesDir)
-    .filter(name => name.startsWith(prefix) && name.endsWith('.json'))
-    .sort()
-    .reverse();
-  for (const fileName of files) {
-    const messages = readJson(path.join(messagesDir, fileName), []);
-    for (const message of Array.isArray(messages) ? messages : []) {
+  const files = [
+    ...fs.readdirSync(messagesDir)
+      .filter(name => name.startsWith(prefix) && name.endsWith('.json'))
+      .map(name => path.join(messagesDir, name)),
+    ...[path.join(messagesDir, 'rooms', roomId), path.join(messagesDir, roomId)]
+      .filter(dir => fs.existsSync(dir))
+      .flatMap(dir => fs.readdirSync(dir).filter(name => name.endsWith('.json')).map(name => path.join(dir, name))),
+  ].sort().reverse();
+  for (const filePath of files) {
+    const parsed = readJson(filePath, []);
+    const messages = Array.isArray(parsed) ? parsed : [parsed];
+    for (const message of messages) {
       const key = String(message.msgId || message.msgServerId || message.seq || `${timestampOf(message)}:${previewOf(message)}`);
       if (seen.has(key)) continue;
       seen.add(key);

+ 40 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js

@@ -55,8 +55,16 @@ const {
 const {
   switchActiveAccount,
   getAgentStatus,
+  getAllowlistCandidates,
+  updateAllowlist,
   getConversations,
   getResponseMonitor,
+  getGroupAgents,
+  changeGroupMode,
+  generateGroupReply,
+  approveGroupDraft,
+  rejectGroupDraft,
+  regenerateGroupDraft,
   syncConversations,
   changeGlobalMode,
   changeConversationMode,
@@ -397,6 +405,14 @@ async function handleRequest(req, res) {
       json(res, 200, await getAgentStatus());
       return;
     }
+    if (pathname === '/api/agent/allowlist' && req.method === 'GET') {
+      json(res, 200, await getAllowlistCandidates());
+      return;
+    }
+    if (pathname === '/api/agent/allowlist' && req.method === 'POST') {
+      json(res, 200, updateAllowlist(await readBody(req)));
+      return;
+    }
     if (pathname === '/api/accounts/switch' && req.method === 'POST') {
       json(res, 200, await switchActiveAccount(await readBody(req)));
       return;
@@ -409,6 +425,30 @@ async function handleRequest(req, res) {
       json(res, 200, getResponseMonitor());
       return;
     }
+    if (pathname === '/api/agent/groups' && req.method === 'GET') {
+      json(res, 200, getGroupAgents());
+      return;
+    }
+    const groupAgentModeRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/mode$/);
+    if (groupAgentModeRoute && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, changeGroupMode(decodeURIComponent(groupAgentModeRoute[1]), body.mode, body.confirmation));
+      return;
+    }
+    const groupAgentGenerateRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/generate$/);
+    if (groupAgentGenerateRoute && req.method === 'POST') {
+      json(res, 200, await generateGroupReply(decodeURIComponent(groupAgentGenerateRoute[1]), await readBody(req)));
+      return;
+    }
+    const groupAgentDraftRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/drafts\/([^/]+)\/(approve|reject|regenerate)$/);
+    if (groupAgentDraftRoute && req.method === 'POST') {
+      const [, roomId, draftId, action] = groupAgentDraftRoute;
+      const body = await readBody(req);
+      if (action === 'approve') json(res, 200, await approveGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId), body.content));
+      else if (action === 'reject') json(res, 200, rejectGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId), body.reason));
+      else json(res, 200, await regenerateGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId)));
+      return;
+    }
     if (pathname === '/api/agent/conversations/sync' && req.method === 'POST') {
       json(res, 200, await syncConversations());
       return;

+ 228 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css

@@ -776,6 +776,36 @@ html, body {
 .agent-panel-heading small { margin-top: 3px; color: var(--text-muted); font-size: 10px; }
 .agent-count { min-width: 24px; padding: 4px 7px; border-radius: 12px; text-align: center; color: var(--agent-green); background: var(--agent-green-soft); font-size: 11px; font-weight: 800; }
 
+.agent-list-tabs {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 5px;
+  padding: 8px;
+  border-bottom: 1px solid var(--agent-line);
+  background: #f5f8f6;
+}
+
+.agent-list-tabs button {
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 5px;
+  min-height: 34px;
+  padding: 6px;
+  border: 1px solid transparent;
+  border-radius: 7px;
+  color: #69756f;
+  background: transparent;
+  font: inherit;
+  font-size: 10px;
+  cursor: pointer;
+}
+
+.agent-list-tabs button.active { color: #245f4d; border-color: #cfe1d9; background: #fff; box-shadow: 0 2px 7px rgba(30,65,54,.07); font-weight: 700; }
+.agent-list-tabs b { min-width: 18px; padding: 2px 4px; border-radius: 8px; color: #68746f; background: #e5ebe8; font-size: 8px; }
+.agent-list-tabs button.active b { color: #fff; background: var(--agent-green); }
+
 .agent-list-scroll { padding: 8px; }
 
 .agent-conversation-item {
@@ -796,6 +826,7 @@ html, body {
 
 .agent-conversation-item:hover { background: #f1f6f3; }
 .agent-conversation-item.active { background: var(--agent-green-soft); box-shadow: inset 3px 0 0 var(--agent-green); }
+.agent-conversation-item.group.active { background: #fff5ea; box-shadow: inset 3px 0 0 #d97b2f; }
 
 .agent-avatar {
   width: 34px;
@@ -808,8 +839,14 @@ html, body {
   font-size: 13px;
   font-weight: 800;
 }
+.agent-avatar.group { color: #8a4f20; background: #f4d8bd; }
 
 .agent-conversation-copy { min-width: 0; }
+.agent-conversation-name { min-width: 0; display: flex; align-items: center; gap: 5px; }
+.agent-conversation-name strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.agent-channel-badge { flex: 0 0 auto; padding: 2px 4px; border-radius: 4px; font-size: 7px; font-style: normal; font-weight: 800; }
+.agent-channel-badge.private { color: #267057; background: #e6f3ed; }
+.agent-channel-badge.group { color: #9a571f; background: #fff0df; }
 .agent-conversation-copy strong,
 .agent-conversation-copy small,
 .agent-conversation-copy em { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -821,9 +858,10 @@ html, body {
 .agent-chat-panel {
   min-width: 0;
   display: grid;
-  grid-template-rows: auto auto minmax(320px, 1fr) auto;
+  grid-template-rows: auto auto auto minmax(320px, 1fr) auto;
   background: #fff;
 }
+.agent-chat-panel.group-mode { grid-template-rows: auto auto minmax(320px, 1fr) auto; }
 
 .agent-chat-header {
   min-height: 72px;
@@ -840,6 +878,67 @@ html, body {
 .agent-chat-header strong { color: var(--agent-ink); font-size: 14px; }
 .agent-chat-header span { margin-top: 3px; color: var(--text-muted); font-size: 10px; }
 .agent-chat-header .btn { padding: 7px 11px; font-size: 11px; }
+.agent-chat-header.group { background: #fffaf4; }
+.agent-group-mode-danger.active { color: #fff; border-color: #b94b3c; background: #b94b3c; }
+.agent-group-mode-danger.active:hover { background: #9f3d31; }
+.agent-group-auto-send { border-color: #b94b3c !important; background: #b94b3c !important; }
+.agent-group-auto-send:hover { background: #9f3d31 !important; }
+
+.agent-flow {
+  padding: 12px 20px 13px;
+  border-bottom: 1px solid var(--agent-line);
+  background: #fbfcfb;
+}
+
+.agent-flow-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
+.agent-flow-heading strong { color: #45554f; font-size: 10px; }
+.agent-flow-heading span { color: #88938e; font-size: 8px; }
+.agent-flow ol { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; margin: 0; padding: 0; list-style: none; }
+.agent-flow.group ol { grid-template-columns: repeat(5, minmax(0, 1fr)); }
+.agent-flow li { position: relative; min-width: 0; display: flex; align-items: center; gap: 7px; padding-right: 14px; }
+.agent-flow li:not(:last-child)::after { content: ''; position: absolute; z-index: 0; top: 11px; right: 2px; width: 10px; height: 1px; background: #d8dfdc; }
+.agent-flow li i { position: relative; z-index: 1; width: 22px; height: 22px; display: grid; flex: 0 0 auto; place-items: center; border: 1px solid #d7dedb; border-radius: 50%; color: #89928f; background: #fff; font-size: 8px; font-style: normal; font-weight: 800; }
+.agent-flow li strong,
+.agent-flow li small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.agent-flow li strong { color: #69736f; font-size: 9px; }
+.agent-flow li small { margin-top: 2px; color: #9aa19e; font-size: 7px; }
+.agent-flow li.done i { color: #fff; border-color: var(--agent-green); background: var(--agent-green); }
+.agent-flow li.done strong { color: #2c6f59; }
+.agent-flow li.current i { color: #fff; border-color: #d47a32; background: #d47a32; box-shadow: 0 0 0 4px rgba(212,122,50,.1); }
+.agent-flow li.current strong { color: #98501b; }
+.agent-flow li.error i { color: #fff; border-color: #c65043; background: #c65043; }
+.agent-flow li.error strong { color: #a13d33; }
+
+.agent-group-monitor-view { min-height: 430px; display: grid; align-content: start; gap: 14px; padding: 22px; background: #fbfcfb; }
+.agent-group-status,
+.agent-group-latest,
+.agent-group-boundary { padding: 16px; border: 1px solid #e2e7e4; border-radius: 8px; background: #fff; }
+.agent-group-status { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 16px; border-left: 4px solid #5f8c79; }
+.agent-group-status.overdue { border-left-color: #cf603c; }
+.agent-group-status.warning { border-left-color: #d49b37; }
+.agent-group-status span,
+.agent-group-latest > span { color: #8b9490; font-size: 8px; }
+.agent-group-status strong { color: #405049; font-size: 15px; }
+.agent-group-status small { grid-column: 2; grid-row: 1 / 3; color: #8c5a37; font-size: 9px; }
+.agent-group-latest blockquote { margin: 9px 0 7px; color: #34413c; font-size: 13px; line-height: 1.65; }
+.agent-group-latest small { color: #929a97; font-size: 8px; }
+.agent-group-boundary { display: flex; align-items: center; gap: 12px; color: #796248; background: #fffaf3; }
+.agent-group-boundary strong { flex: 0 0 auto; font-size: 10px; }
+.agent-group-boundary span { font-size: 9px; line-height: 1.5; }
+.agent-intent-card.group { border-color: #f0d8bd; background: #fff7ed; }
+.agent-intent-card.group > strong { color: #965521; }
+.agent-intent-card.group.auto { border-color: #efbeb6; background: #fff1ef; }
+.agent-intent-card.group.auto > strong { color: #a13d33; }
+.agent-group-sla { padding: 12px; border: 1px solid #e5e9e7; border-radius: 8px; background: #fff; }
+.agent-group-sla strong,
+.agent-group-sla span { display: block; }
+.agent-group-sla strong { color: #46534e; font-size: 15px; }
+.agent-group-sla span { margin-top: 4px; color: #8e9693; font-size: 8px; }
+.agent-boundary-list { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
+.agent-boundary-list li { padding-left: 13px; position: relative; color: #69746f; font-size: 9px; line-height: 1.45; }
+.agent-boundary-list li::before { content: ''; position: absolute; left: 1px; top: 5px; width: 5px; height: 5px; border-radius: 50%; background: #d47a32; }
+.agent-boundary-list.danger li { color: #8f4036; }
+.agent-boundary-list.danger li::before { background: #bd4d3f; }
 
 .agent-conversation-modes {
   display: flex;
@@ -876,6 +975,7 @@ html, body {
     radial-gradient(circle at 1px 1px, rgba(20,87,66,.1) 1px, transparent 0);
   background-size: auto, 18px 18px;
 }
+.agent-message-stream.group { background-color: #fbfaf8; }
 
 .agent-message { max-width: 86%; margin-bottom: 18px; }
 .agent-message.customer { margin-right: auto; }
@@ -895,6 +995,8 @@ html, body {
 }
 
 .agent-composer.manual { background: #fffaf4; }
+.agent-composer.group { background: #fffaf4; }
+.agent-composer.group .agent-composer-label strong { color: #9a571f; }
 .agent-composer-label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
 .agent-composer-label strong { color: var(--agent-green); font-size: 11px; }
 .agent-composer.manual .agent-composer-label strong { color: #b06c2a; }
@@ -1040,6 +1142,10 @@ html, body {
   .response-reminder footer { justify-content: space-between; }
   .agent-workspace { display: block; }
   .agent-conversation-list { border-right: 0; border-bottom: 1px solid var(--agent-line); }
+  .agent-flow { padding: 12px; overflow-x: auto; }
+  .agent-flow-heading { align-items: flex-start; flex-direction: column; gap: 3px; }
+  .agent-flow ol,
+  .agent-flow.group ol { min-width: 560px; }
   .agent-chat-panel { min-height: 620px; }
   .agent-insight-panel { display: block; }
 }
@@ -1442,6 +1548,127 @@ tr:hover td { background: var(--brand-bg); }
   margin-top: 24px;
 }
 
+.modal.allowlist-modal {
+  width: min(92vw, 700px);
+  max-width: 700px;
+  max-height: min(760px, calc(100vh - 40px));
+  overflow: hidden;
+  padding: 0;
+}
+
+body.allowlist-modal-open { overflow: hidden; }
+
+.allowlist-modal .modal-title {
+  margin: 0;
+  padding: 22px 24px 16px;
+  border-bottom: 1px solid #e8eeeb;
+}
+
+.allowlist-intro {
+  padding: 16px 24px 12px;
+}
+
+.allowlist-intro strong,
+.allowlist-intro span { display: block; }
+.allowlist-intro strong { color: #31433c; font-size: 14px; }
+.allowlist-intro span { margin-top: 4px; color: #7f8b86; font-size: 12px; line-height: 1.5; }
+
+.allowlist-toolbar {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 10px;
+  padding: 0 24px;
+}
+
+.allowlist-toolbar input,
+.allowlist-manual input {
+  width: 100%;
+  height: 38px;
+  padding: 0 12px;
+  border: 1px solid #dce5e1;
+  border-radius: 7px;
+  outline: none;
+  color: #34443e;
+  background: #fff;
+  font: inherit;
+  font-size: 12px;
+}
+
+.allowlist-toolbar input:focus,
+.allowlist-manual input:focus { border-color: #4c9277; box-shadow: 0 0 0 3px rgba(76,146,119,.12); }
+
+.allowlist-summary {
+  min-height: 32px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 8px 24px 6px;
+  color: #4b685d;
+  font-size: 11px;
+}
+
+.allowlist-summary em { color: #aa6f24; font-style: normal; text-align: right; }
+
+.allowlist-contact-list {
+  height: 330px;
+  overflow-y: auto;
+  margin: 0 24px;
+  border: 1px solid #e2e9e6;
+  border-radius: 7px;
+  background: #fafcfb;
+}
+
+.allowlist-contact {
+  min-height: 62px;
+  display: grid;
+  grid-template-columns: auto auto minmax(0, 1fr) auto;
+  align-items: center;
+  gap: 11px;
+  padding: 10px 12px;
+  border-bottom: 1px solid #e7ecea;
+  cursor: pointer;
+  transition: background .15s ease;
+}
+
+.allowlist-contact:last-child { border-bottom: 0; }
+.allowlist-contact:hover { background: #f1f7f4; }
+.allowlist-contact.selected { background: #edf7f2; }
+.allowlist-contact input { width: 16px; height: 16px; accent-color: #347b61; }
+.allowlist-avatar { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%; color: #23624d; background: #dcefe7; font-size: 13px; font-weight: 800; }
+.allowlist-contact-copy { min-width: 0; }
+.allowlist-contact-copy strong,
+.allowlist-contact-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.allowlist-contact-copy strong { color: #35443e; font-size: 13px; }
+.allowlist-contact-copy small { margin-top: 4px; color: #89938f; font-size: 10px; }
+.allowlist-contact > em { color: #929c98; font-size: 10px; font-style: normal; }
+.allowlist-contact.selected > em { color: #2f765d; font-weight: 700; }
+
+.allowlist-loading,
+.allowlist-empty { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; padding: 24px; color: #87928d; font-size: 12px; text-align: center; }
+.allowlist-empty strong { color: #54615c; font-size: 13px; }
+.allowlist-empty.error strong { color: #a24536; }
+
+.allowlist-manual { margin: 12px 24px 0; color: #78847f; font-size: 11px; }
+.allowlist-manual summary { cursor: pointer; }
+.allowlist-manual > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; margin-top: 9px; }
+.allowlist-manual small { display: block; min-height: 18px; padding-top: 4px; color: #b14535; }
+.allowlist-modal .modal-actions { margin: 14px 0 0; padding: 14px 24px 20px; border-top: 1px solid #e8eeeb; }
+
+@media (max-width: 620px) {
+  .modal.allowlist-modal { width: calc(100vw - 20px); max-height: calc(100vh - 20px); }
+  .allowlist-modal .modal-title,
+  .allowlist-intro { padding-left: 16px; padding-right: 16px; }
+  .allowlist-toolbar { grid-template-columns: 1fr; padding: 0 16px; }
+  .allowlist-summary { align-items: flex-start; flex-direction: column; gap: 3px; padding-left: 16px; padding-right: 16px; }
+  .allowlist-summary em { text-align: left; }
+  .allowlist-contact-list { height: 300px; margin: 0 16px; }
+  .allowlist-contact { grid-template-columns: auto auto minmax(0, 1fr); }
+  .allowlist-contact > em { display: none; }
+  .allowlist-manual { margin-left: 16px; margin-right: 16px; }
+  .allowlist-modal .modal-actions { padding-left: 16px; padding-right: 16px; }
+}
+
 body.business-modal-open { overflow: hidden; }
 
 .business-modal-backdrop {

+ 57 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-agent-transport.js

@@ -91,6 +91,63 @@ class FmodeQiweiClient {
     return this.call('/msg/syncMsg', { msgSeq, limit });
   }
 
+  async listExternalContacts({ limit = 500, maxPages = 10 } = {}) {
+    const pageSize = Math.max(20, Math.min(1000, Number(limit) || 500));
+    const pageLimit = Math.max(1, Math.min(20, Number(maxPages) || 10));
+    const contacts = [];
+    const seen = new Set();
+    let currentSeq = 0;
+    let pages = 0;
+    let reportedCount = 0;
+
+    while (pages < pageLimit) {
+      const data = await this.call('/contact/getWxContactList', {
+        currentSeq,
+        limit: pageSize,
+        bizType: 1,
+      });
+      const list = Array.isArray(data.contactList) ? data.contactList : [];
+      reportedCount = Math.max(reportedCount, Number(data.contactCount) || 0);
+      for (const item of list) {
+        const id = String(item?.userId || item?.externalUserId || item?.id || '').trim();
+        if (!id || seen.has(id)) continue;
+        seen.add(id);
+        contacts.push(item);
+      }
+
+      pages += 1;
+      const nextSeq = Number(data.currentSeq);
+      if (!data.hasMore || !list.length || !Number.isFinite(nextSeq) || nextSeq === currentSeq) break;
+      currentSeq = nextSeq;
+    }
+
+    const missingNames = contacts
+      .filter(item => !String(item.remark || item.nickname || item.nickName || item.userName || item.name || '').trim())
+      .map(item => String(item.userId || item.externalUserId || item.id || '').trim())
+      .filter(Boolean);
+    const details = new Map();
+    for (let index = 0; index < missingNames.length; index += 100) {
+      try {
+        const data = await this.call('/contact/batchGetUserinfo', { userIdList: missingNames.slice(index, index + 100) });
+        for (const item of Array.isArray(data.contactList) ? data.contactList : []) {
+          const id = String(item?.userId || item?.externalUserId || item?.id || '').trim();
+          if (id) details.set(id, item);
+        }
+      } catch {
+        break;
+      }
+    }
+
+    return {
+      contacts: contacts.map(item => {
+        const id = String(item?.userId || item?.externalUserId || item?.id || '').trim();
+        return { ...item, ...(details.get(id) || {}) };
+      }),
+      contactCount: Math.max(reportedCount, contacts.length),
+      pages,
+    };
+  }
+
   sendText(toId, content) {
     return this.call('/msg/sendText', { toId, content, isNoNeedRead: false });
   }

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

@@ -151,7 +151,7 @@ function wrap(handler) {
 function createServer() {
   const server = new McpServer({
     name: 'enterprise-wechat-assistant',
-    version: '0.4.0'
+    version: '0.4.1'
   });
 
   server.registerTool(

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

@@ -1188,6 +1188,8 @@ module.exports = {
   qiweiConfigureGroupKeywords,
   qiweiRejectExternalGroup,
   qiweiSyncGroupMessages,
+  appendWebhookMessage,
+  readRoomMessages,
   readConfirmedMapping,
   writeConfirmedMapping,
   updateConfirmedMapping,

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

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

+ 20 - 7
claude-code/claude-code-qiwe-assistant/package.json

@@ -1,11 +1,11 @@
 {
-  "name": "claude-code-qiwei-assistant",
-  "version": "0.4.0",
-  "description": "企业微信技能包:Fmode 网关、客户运营、Dashboard、Webhook/Relay、官方 CLI 与房产 AI 自动回复",
-  "author": "BrainHack",
+  "name": "fmode-qiwei",
+  "version": "0.4.1",
+  "description": "Fmode 企业微信智能办公技能包:客户运营、智能客服、Dashboard、Webhook/Relay、官方 CLI 与目标管理",
+  "author": "fmode",
   "main": "mcp/src/server.js",
   "bin": {
-    "qiwei-assistant": "install.js"
+    "fmode-qiwei": "install.js"
   },
   "files": [
     ".claude-plugin/",
@@ -19,7 +19,7 @@
     "mcp/",
     "package.json",
     "README.md",
-    "scripts/",
+    "scripts/*.js",
     "skill-package-manifest.json",
     "skills/",
     "THIRD_PARTY_NOTICES.md",
@@ -49,6 +49,7 @@
     "excel:smoke": "node scripts/excel-smoke-test.js",
     "smoke": "node scripts/startup-preview-smoke-test.js && node scripts/deadline-parser-smoke-test.js && node scripts/response-monitor-smoke-test.js && node scripts/smoke-test.js && node scripts/wecom-cli-smoke-test.js && node scripts/goal-management-smoke-test.js && node scripts/meeting-knowledge-smoke-test.js && node scripts/official-office-knowledge-smoke-test.js && node scripts/unified-task-smoke-test.js && node scripts/customer-master-smoke-test.js && node scripts/customer-journey-smoke-test.js",
     "package:smoke": "node scripts/package-smoke-test.js",
+    "audit:prod": "npm audit --omit=dev --audit-level=moderate",
     "release:check": "node scripts/release-check.js",
     "outputs:validate": "node scripts/validate-output-standard.js",
     "wecom:install": "node scripts/install-wecom-cli.js",
@@ -62,8 +63,20 @@
     "write-excel-file": "^4.1.1",
     "zod": "^3.24.1"
   },
+  "overrides": {
+    "@hono/node-server": "2.0.11"
+  },
   "engines": {
     "node": ">=22.5.0"
   },
-  "keywords": ["claude-code", "fmode", "wecom", "enterprise-wechat", "mcp", "crm", "customer-service", "goal-management"]
+  "keywords": [
+    "claude-code",
+    "fmode",
+    "wecom",
+    "enterprise-wechat",
+    "mcp",
+    "crm",
+    "customer-service",
+    "goal-management"
+  ]
 }

+ 184 - 5
claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js

@@ -20,6 +20,8 @@ const {
 } = require('../mcp/src/core/agent-runtime');
 const { getCustomerSessionGuide } = require('../mcp/src/core/agent-session-guide');
 const { FmodeQiweiClient } = require('../mcp/src/providers/fmode-agent-transport');
+const { normalizeAllowlistIds, normalizeAllowlistContact } = require('../mcp/src/core/allowlist-config');
+const { GroupAgentService } = require('../mcp/src/dashboard/group-agent-service');
 
 const results = [];
 
@@ -76,6 +78,14 @@ async function check(name, fn) {
 }
 
 async function main() {
+  await check('白名单选择会去重并拒绝不安全的联系人 ID', async () => {
+    assert.deepEqual(normalizeAllowlistIds(['contact-1', ' contact-1 ', 'wm_test:2']), ['contact-1', 'wm_test:2']);
+    assert.throws(() => normalizeAllowlistIds(['contact-1\nINJECTED=true']), /联系人 ID 格式无效/);
+    assert.deepEqual(normalizeAllowlistContact({ userId: 'contact-1', remark: '刘总', corpName: '示例公司' }), {
+      id: 'contact-1', displayName: '刘总', remark: '刘总', company: '示例公司'
+    });
+  });
+
   await check('Agent 企微传输统一走 Fmode 网关与登录专用端点', async () => {
     const calls = [];
     const originalFetch = global.fetch;
@@ -83,9 +93,16 @@ async function main() {
       const parsedBody = options.body ? JSON.parse(options.body) : null;
       calls.push({ url: String(url), options, body: parsedBody });
       const loginStatus = String(url).includes('/login/status');
-      const payload = loginStatus
-        ? { code: 0, data: { configured: true, online: true, statusCode: 2, detail: { nickname: '演示账号' } } }
-        : { code: 0, data: { data: { isSendSuccess: true, syncMsgList: [], travelSyncKey: 9 } } };
+      let payload;
+      if (loginStatus) {
+        payload = { code: 0, data: { configured: true, online: true, statusCode: 2, detail: { nickname: '演示账号' } } };
+      } else if (parsedBody?.method === '/contact/getWxContactList') {
+        payload = { code: 0, data: { data: { currentSeq: 9, contactCount: 1, hasMore: false, contactList: [{ userId: 'contact-1' }] } } };
+      } else if (parsedBody?.method === '/contact/batchGetUserinfo') {
+        payload = { code: 0, data: { data: { contactList: [{ userId: 'contact-1', nickname: '测试客户' }] } } };
+      } else {
+        payload = { code: 0, data: { data: { isSendSuccess: true, syncMsgList: [], travelSyncKey: 9 } } };
+      }
       return {
         ok: true,
         status: 200,
@@ -101,6 +118,7 @@ async function main() {
       });
       const account = await client.checkLogin();
       await client.syncMessages(8, 50);
+      const contacts = await client.listExternalContacts();
       await client.sendText('external-contact-1', '测试回复');
       assert.equal(account.online, true);
       assert.equal(account.nickname, '演示账号');
@@ -109,8 +127,11 @@ async function main() {
       assert.equal(calls[1].body.uid, 'uid-smoke');
       assert.equal(calls[1].body.method, '/msg/syncMsg');
       assert.equal(calls[1].body.params.guid, 'guid-smoke');
-      assert.equal(calls[2].body.method, '/msg/sendText');
-      assert.equal(calls[2].options.headers.Authorization, 'Bearer test-fmode-token');
+      assert.equal(contacts.contacts[0].nickname, '测试客户');
+      assert.equal(calls[2].body.method, '/contact/getWxContactList');
+      assert.equal(calls[3].body.method, '/contact/batchGetUserinfo');
+      assert.equal(calls[4].body.method, '/msg/sendText');
+      assert.equal(calls[4].options.headers.Authorization, 'Bearer test-fmode-token');
     } finally {
       global.fetch = originalFetch;
     }
@@ -461,6 +482,164 @@ async function main() {
     }, { selfUserId: 'self', allowedSenders: ['contact-1'] });
     assert.equal(self.eligible, false);
     assert.equal(self.reason, 'self_message');
+
+    const ctx = setup();
+    try {
+      const ignored = await ctx.service.ingestInbound({
+        externalId: 'group-direct-entry',
+        contactId: 'contact-1',
+        contactName: '王刚',
+        content: '群里的消息',
+        raw: { fromRoomId: 'room-123', senderId: 'contact-1' },
+      });
+      assert.equal(ignored.status, 'ignored_group_message');
+      assert.equal(ctx.db.listConversations().length, 0);
+      const legacyGroup = ctx.db.ensureConversation('contact-1', '历史群聊');
+      ctx.db.insertMessage({
+        conversationId: legacyGroup.id,
+        externalId: 'legacy-group-message',
+        direction: 'inbound',
+        senderType: 'customer',
+        content: '旧数据中的群消息',
+        raw: { fromRoomId: 'room-legacy', senderId: 'contact-1' },
+      });
+      await assert.rejects(() => ctx.service.manualSend(legacyGroup.id, '不应发送'), /群聊仅用于监控/);
+      assert.equal(ctx.sent.length, 0);
+    } finally { ctx.close(); }
+  });
+
+  await check('客户群支持人工审核与无视风险全自动两种独立模式', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-group-agent-smoke-'));
+    const messages = [{
+      msgId: 'group-message-1',
+      senderId: 'customer-1',
+      senderName: '张三',
+      fromRoomId: 'r-1',
+      content: '明天下午可以看房吗',
+      timestamp: '2026-07-23T08:00:00.000Z',
+    }];
+    const sent = [];
+    const sendAttempts = [];
+    const agentInputs = [];
+    let sendShouldFail = false;
+    let agentResponse = {
+      content: '可以的,请问您明天下午几点方便?',
+      confidence: 0.88,
+      intent: '预约看房',
+      reason: '客户明确询问看房时间',
+      requiresHuman: false,
+      citations: [],
+      toolTrace: [],
+    };
+    const runtime = {
+      config: { qiwei: { selfUserId: 'self-1', nickname: '王顾问' } },
+      agent: {
+        async run(input) {
+          agentInputs.push(input);
+          return agentResponse;
+        },
+      },
+      qiwei: {
+        async sendText(toId, content) {
+          sendAttempts.push({ toId, content });
+          if (sendShouldFail) return { isSendSuccess: false };
+          sent.push({ toId, content });
+          return { isSendSuccess: true };
+        },
+      },
+    };
+    const service = new GroupAgentService({
+      projectRoot: dir,
+      statePath: path.join(dir, 'group-agent.json'),
+      runtime,
+      getAccount: () => ({ uid: 'account-1', userId: 'self-1', nickname: '王顾问' }),
+      loadGroups: () => ({ 'r-1': { roomName: '张三买房服务群', customerName: '张三' } }),
+      loadMessages: () => messages,
+      appendMessage: (roomId, message) => { messages.push({ ...message, fromRoomId: roomId }); return 'memory'; },
+    });
+    try {
+      assert.equal(service.publicState('r-1').mode, 'review');
+      const generated = await service.generate('r-1');
+      assert.equal(generated.status, 'pending_review');
+      assert.equal(generated.draft.requiresHuman, true);
+      assert.equal(sent.length, 0);
+      assert.equal(agentInputs[0].channelType, 'group');
+      assert.match(agentInputs[0].directPrompt, /企业微信群聊客服 Agent/);
+
+      const approved = await service.approve('r-1', generated.draft.id, '可以的,张三,请问您明天下午几点方便?');
+      assert.equal(approved.status, 'sent');
+      assert.deepEqual(sent, [{ toId: 'r-1', content: '可以的,张三,请问您明天下午几点方便?' }]);
+      assert.equal(service.publicState('r-1').pendingReply, null);
+      assert.equal(service.publicState('r-1').messages.at(-1).role, 'human');
+      await assert.rejects(() => service.approve('r-1', generated.draft.id, '重复发送'), /已经是 sent/);
+      await assert.rejects(() => service.generate('unconfirmed-room'), /尚未确认为客户群/);
+
+      assert.throws(() => service.setMode('r-1', 'auto'), /需要明确确认/);
+      assert.equal(service.setMode('r-1', 'auto', 'AUTO_SEND_GROUP_MESSAGES').mode, 'auto');
+      agentResponse = {
+        content: '三点可以,我先为您登记。',
+        confidence: 0.1,
+        intent: '预约看房',
+        reason: '低置信回复仍由全自动模式放行',
+        requiresHuman: true,
+        citations: [],
+        toolTrace: [],
+      };
+
+      const ingested = await service.ingestPolledMessage({
+        msgType: 1,
+        msgServerId: 'group-message-2',
+        seq: 2,
+        senderId: 'customer-1',
+        senderName: '张三',
+        fromRoomId: 'r-1',
+        msgData: { content: '三点可以吗' },
+        timestamp: Math.floor(Date.now() / 1000),
+      }, { selfUserId: 'self-1' });
+      assert.equal(ingested.status, 'auto_sent');
+      assert.equal(agentInputs.length, 2);
+      assert.equal(sent.length, 2);
+      assert.deepEqual(sent.at(-1), { toId: 'r-1', content: '三点可以,我先为您登记。' });
+      assert.equal(service.publicState('r-1').pendingReply, null);
+      assert.equal(service.publicState('r-1').lastOutcome.action, 'group_message_auto_sent');
+      assert.equal(messages.at(-1).rawData.source, 'group_agent_auto');
+
+      sendShouldFail = true;
+      const failed = await service.ingestPolledMessage({
+        msgType: 1,
+        msgServerId: 'group-message-3',
+        seq: 3,
+        senderId: 'customer-1',
+        senderName: '张三',
+        fromRoomId: 'r-1',
+        msgData: { content: '能发个定位吗' },
+        timestamp: Math.floor(Date.now() / 1000) + 1,
+      }, { selfUserId: 'self-1' });
+      assert.equal(failed.status, 'pending_review');
+      assert.equal(failed.autoSendFailed, true);
+      assert.equal(sent.length, 2);
+      assert.equal(sendAttempts.length, 3);
+      assert(service.publicState('r-1').pendingReply);
+      assert(service.publicState('r-1').sendError);
+
+      sendShouldFail = false;
+      assert.equal(service.setMode('r-1', 'review').mode, 'review');
+      const reviewed = await service.ingestPolledMessage({
+        msgType: 1,
+        msgServerId: 'group-message-4',
+        seq: 4,
+        senderId: 'customer-1',
+        senderName: '张三',
+        fromRoomId: 'r-1',
+        msgData: { content: '四点也可以' },
+        timestamp: Math.floor(Date.now() / 1000) + 2,
+      }, { selfUserId: 'self-1' });
+      assert.equal(reviewed.status, 'pending_review');
+      assert.equal(sent.length, 2);
+      assert.equal(service.publicState('r-1').mode, 'review');
+    } finally {
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
   });
 
   await check('同一业务待办只保留一张卡并聚合多条依据', async () => {

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

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

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

@@ -26,6 +26,8 @@ function main() {
   const lock = readJson('package-lock.json');
   const manifest = readJson('skill-package-manifest.json');
   const plugin = readJson('.claude-plugin/plugin.json');
+  const catalog = readJson('mcp/catalog/qiwei-endpoints.json');
+  const catalogSource = fs.readFileSync(path.join(ROOT, 'mcp', 'catalog', 'qiwei-endpoints.json'), 'utf8');
   const serverSource = fs.readFileSync(path.join(ROOT, 'mcp', 'src', 'server.js'), 'utf8');
   const serverVersion = serverSource.match(/new McpServer\([\s\S]*?version:\s*['"]([^'"]+)/)?.[1];
   const toolNames = [...serverSource.matchAll(/registerTool\(\s*['"]([^'"]+)['"]/g)].map(match => match[1]);
@@ -34,15 +36,37 @@ function main() {
     .map(entry => entry.name)
     .sort();
 
+  assert.equal(pkg.name, 'fmode-qiwei');
+  assert.equal(pkg.bin?.['fmode-qiwei'], 'install.js');
   assert.equal(pkg.version, lock.version);
   assert.equal(pkg.version, lock.packages?.['']?.version);
   assert.equal(pkg.version, manifest.version);
   assert.equal(pkg.version, plugin.version);
   assert.equal(pkg.version, serverVersion);
+  assert.equal(manifest.name, pkg.name);
+  assert.equal(manifest.npmPackage, pkg.name);
+  assert.equal(manifest.status, 'published');
+  assert.match(manifest.installCommand, /fmode-qiwei@latest/);
   assert.equal(pkg.engines?.node, '>=22.5.0');
   assert.equal(new Set(toolNames).size, toolNames.length, 'MCP 工具名不能重复');
   assert.equal(toolNames.length, manifest.mcpToolCount, 'MCP 工具数量与 manifest 不一致');
   assert.deepEqual(skillDirs, [...manifest.skills].sort(), 'Skill 目录与 manifest 不一致');
+  assert.equal(
+    (catalogSource.match(/q-[a-z-]+=/gi) || []).length,
+    0,
+    '公开接口清单不能包含外部签名参数'
+  );
+  const createClient = catalog.endpoints.find(endpoint => endpoint.id === 'client.createClient');
+  assert(createClient, '接口清单缺少 client.createClient');
+  const hiddenNetworkParam = ['a', 'i', 'd'].join('');
+  assert(
+    !createClient.params.some(param => String(param.name).toLowerCase() === hiddenNetworkParam),
+    '公开接口清单不能暴露内部网络组件参数'
+  );
+  assert.equal(
+    createClient.params.find(param => param.name === 'clientVersion')?.desc,
+    '客户端版本由 Fmode 服务自动选择,通常无需填写。'
+  );
 
   const pack = runNpmPackDryRun();
   const packedPaths = new Set(pack.files.map(item => item.path.replace(/\\/g, '/')));

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

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

+ 24 - 1
claude-code/claude-code-qiwe-assistant/scripts/response-monitor-smoke-test.js

@@ -70,10 +70,33 @@ assert.strictEqual(result.scope.groups[1].status, 'awaiting_sync');
 assert.strictEqual(isStaffMessage({ senderId: 'staff-1' }, ['staff-1'], []), true);
 assert.strictEqual(isStaffMessage({ senderName: '客服小夏' }, [], ['客服小夏']), true);
 
+const roomMessagesDir = path.join(messagesDir, 'rooms', 'room-1');
+fs.mkdirSync(roomMessagesDir, { recursive: true });
+fs.writeFileSync(path.join(roomMessagesDir, '2-group-agent-reply.json'), JSON.stringify({
+  msgId: 'group-agent-reply',
+  senderId: 'staff-1',
+  senderName: '客服小夏',
+  fromRoomId: 'room-1',
+  content: '已经收到,我们马上处理。',
+  timestamp: '2026-07-20T09:00:00.000Z',
+  direction: 'outbound',
+}), 'utf8');
+const repliedResult = responseMonitor({
+  projectRoot: root,
+  now: Date.parse('2026-07-20T10:00:00.000Z'),
+  warningMinutes: 15,
+  urgentMinutes: 60,
+  selfUserIds: ['staff-1'],
+  selfNames: ['客服小夏'],
+  conversations: [],
+});
+assert.strictEqual(repliedResult.scope.groups[0].status, 'replied');
+assert.strictEqual(repliedResult.summary.overdue, 0);
+
 fs.rmSync(root, { recursive: true, force: true });
 console.log(JSON.stringify({
   status: 'ok',
-  checks: 15,
+  checks: 17,
   monitoredCustomers: result.summary.monitoredCustomers,
   monitoredGroups: result.summary.monitoredGroups,
   overdue: result.summary.overdue,

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

@@ -1,7 +1,7 @@
 {
-  "name": "claude-code-qiwei-assistant",
-  "version": "0.4.0",
-  "status": "release-ready-not-published",
+  "name": "fmode-qiwei",
+  "version": "0.4.1",
+  "status": "published",
   "plugin": "qiwei-assistant",
   "description": "企业微信智能办公技能包:日常办公、目标推进、智能客服、CRM 客户管理和底层 Skills/API。",
   "capabilities": {
@@ -32,8 +32,9 @@
   ],
   "mcpToolCount": 75,
   "apiCatalogCount": 104,
-  "installCommand": "node install.js workspace <客户项目目录> --smoke",
-  "previewCommand": "node install.js preview <客户项目目录>",
+  "npmPackage": "fmode-qiwei",
+  "installCommand": "npx --yes fmode-qiwei@latest workspace --smoke",
+  "previewCommand": "npx --yes fmode-qiwei@latest preview",
   "workspaceInstallTarget": ".claude/plugins/qiwei-assistant",
   "dashboardUrl": "http://127.0.0.1:4320/",
   "startupChecks": ["Fmode 鉴权", "企微席位", "企微账号在线", "测试白名单", "AI 监听"],

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

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

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

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

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

@@ -0,0 +1,20 @@
+# `fmode-qiwei` 变更记录
+
+> 当前版本号见 [`../NPM技能包发布管理.md`](../NPM技能包发布管理.md) 的「发布状态总览」。本文按版本号倒序记录 `fmode-qiwei` 的更新内容。
+
+## 0.4.1(2026-07-23)
+
+- 已发布并验证 npm `latest=0.4.1`(140 个文件,shasum `6785d804…`)。
+- 清理公开接口清单中的上游供应商标识、外部签名下载地址、内部网络组件参数和具体客户端版本,对外统一为 Fmode 服务说明。
+- 清单生成器增加源头过滤;内部 Python 生成器不再进入 npm 包;发布检查增加结构性防回归校验。
+- 通过 9 组发布检查、实际 tarball 扫描和线上 workspace `--smoke`;17 个 Skills、75 个 MCP 工具、25 条 Agent 检查均通过,生产依赖漏洞为 0。
+
+## 0.4.0(2026-07-23)
+
+- 首次发布为 fmode 无 scope npm 包,已验证 npm `latest=0.4.0`(141 个文件,shasum `c42bc56c…`);安装命令:`npx --yes fmode-qiwei@latest workspace --smoke`。
+- 包含 17 个企业微信 Skills、75 个 MCP 工具和 104 个接口清单,覆盖企微登录与席位、客户运营、群管理、画像标签、客户交接、目标推进、官方会议/文档/日程/待办、Webhook/Relay 和智能客服工作台。
+- workspace 安装生成 `.claude/plugins/qiwei-assistant`、项目级 Skills 和绝对路径 MCP 配置;`preview` 启动本地 4320 工作台。
+- 发布包排除 `.env.local`、npm/模型 Token、客户 Session、真实运行输出、数据库、日志、Playwright 会话和 `node_modules`。
+- 发布前通过语法、MCP mock、Agent、知识库、目标/待办、客户主档/旅程、输出规范、workspace 安装及 npm 包内容检查;生产依赖审计为 0 个漏洞。
+- 固定 `@hono/node-server@2.0.11` 并更新 `fast-uri@3.1.4`,消除发布前依赖审计发现的路径穿越和 URI 主机混淆告警;发布后验证 npm `latest` 和在线 workspace 安装。
+- 已授权 fmode 组织 `read-write`;`gangvy` 与 `fmode001` 均可维护,与 `fmode-listen` 权限级别一致。