Explorar o código

release: publish fmode-qiwei 0.5.2

gangvy hai 1 mes
pai
achega
6cf56b6092
Modificáronse 20 ficheiros con 372 adicións e 55 borrados
  1. 1 1
      claude-code/claude-code-qiwe-assistant/.claude-plugin/plugin.json
  2. 1 1
      claude-code/claude-code-qiwe-assistant/README.md
  3. 3 3
      claude-code/claude-code-qiwe-assistant/docs/RELEASE.md
  4. 80 18
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  5. 22 7
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  6. 1 1
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  7. 2 1
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  8. 25 2
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js
  9. 2 2
      claude-code/claude-code-qiwe-assistant/package-lock.json
  10. 1 1
      claude-code/claude-code-qiwe-assistant/package.json
  11. 1 1
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/processor-bridge.mjs
  12. 82 2
      claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js
  13. 67 10
      claude-code/claude-code-qiwe-assistant/scripts/preview-dashboard.js
  14. 9 1
      claude-code/claude-code-qiwe-assistant/scripts/startup-preview-smoke-test.js
  15. 1 1
      claude-code/claude-code-qiwe-assistant/skill-package-manifest.json
  16. 4 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-ops/SKILL.md
  17. 3 2
      claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md
  18. 51 0
      release/npm/fmode-qiwei/publish.ps1
  19. 7 1
      release/npm发布管理文档/NPM技能包发布管理.md
  20. 9 0
      release/npm发布管理文档/变更记录/fmode-qiwei.md

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

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

+ 1 - 1
claude-code/claude-code-qiwe-assistant/README.md

@@ -422,7 +422,7 @@ node install.js workspace <客户项目目录> --smoke
 <客户项目>/.gitignore
 ```
 
-安装器会幂等补全项目根目录 `.gitignore`,保留项目已有规则,并忽略本地凭据、运行数据库、Session、日志和依赖目录。安装过程不会复制 `.env`、`.env.local`、`.npmrc`、真实运行输出、客户 Session、Playwright 会话、压缩包或源码目录中的 `node_modules`。当前公开版本为 `fmode-qiwei@0.5.1`;版本与验证记录见 `docs/RELEASE.md`。
+安装器会幂等补全项目根目录 `.gitignore`,保留项目已有规则,并忽略本地凭据、运行数据库、Session、日志和依赖目录。安装过程不会复制 `.env`、`.env.local`、`.npmrc`、真实运行输出、客户 Session、Playwright 会话、压缩包或源码目录中的 `node_modules`。当前版本为 `fmode-qiwei@0.5.2`;版本与验证记录见 `docs/RELEASE.md`。
 
 ## 子 Skill 索引
 

+ 3 - 3
claude-code/claude-code-qiwe-assistant/docs/RELEASE.md

@@ -3,8 +3,8 @@
 ## 当前状态
 
 - npm 包名:`fmode-qiwei`
-- 当前版本:`0.5.1`
-- 状态:已发布到 npm,`latest=0.5.1`
+- 当前版本:`0.5.2`
+- 状态:已发布到 npm,`latest=0.5.2`
 - 运行要求:Node.js `22.5.0+`(使用内置 `node:sqlite`)
 - Dashboard:`http://127.0.0.1:4320/`
 
@@ -30,7 +30,7 @@ node install.js workspace <客户项目目录> --smoke
 本地 tgz:
 
 ```powershell
-npm install -g .\fmode-qiwei-0.5.1.tgz
+npm install -g .\fmode-qiwei-0.5.2.tgz
 fmode-qiwei workspace <客户项目目录> --smoke
 ```
 

+ 80 - 18
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -146,7 +146,7 @@ function loadAgentConfig(overrides = {}) {
   const baseConfig = {
     dbPath: resolvePath(value('QIWEI_AGENT_DB_PATH'), latestPath('messages', 'agent-workbench.db')),
     legacyDbPath: path.resolve(PACKAGE_ROOT, '..', '..', 'qiwei-agent-workbench', 'data', 'workbench.db'),
-    globalDefaultPaused: bool('QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED', true),
+    globalDefaultPaused: bool('QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED', false),
     conversationDefaultMode: value('QIWEI_AGENT_DEFAULT_MODE', 'review'),
     autoSendConfidence: number('QIWEI_AGENT_AUTO_SEND_CONFIDENCE', 0.88, 0, 1),
     knowledgeDir: resolvePath(
@@ -1023,6 +1023,8 @@ async function getAllowlistCandidates() {
 }
 
 function updateAllowlist(input = {}) {
+  refreshAllowedSendersFromEnv(workbench.config.qiwei);
+  const previousIds = new Set(workbench.config.qiwei.allowedSenders.map(String));
   const ids = normalizeAllowlistIds(input.contactIds || input.allowedSenders || []);
   const serialized = ids.join(',');
   writeEnvValue(ENV_FILE, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS', serialized);
@@ -1030,32 +1032,54 @@ function updateAllowlist(input = {}) {
   process.env.QIWEI_AUTO_REPLY_ALLOWED_SENDERS = serialized;
 
   let listenerStopped = false;
+  const contactNames = input.contactNames || {};
+  const addedIds = ids.filter(id => !previousIds.has(id));
   for (const target of workbenches.values()) {
     target.config.qiwei.allowedSenders = [...ids];
+    for (const contactId of addedIds) {
+      target.db.ensureConversation(contactId, String(contactNames[contactId] || ''));
+    }
+    if (ids.length && input.autoStart !== false) {
+      target.db.setSetting('listener_enabled', 'true');
+      target.service.setGlobal({ paused: false }, input.actor || 'allowlist');
+    } else if (!ids.length) {
+      target.db.setSetting('listener_enabled', 'false');
+    }
     if (!ids.length && target.poller.status().running) {
       target.poller.stop();
       listenerStopped = true;
     }
   }
   workbench.db.audit({
-    actor: 'human',
+    actor: input.actor || 'human',
     action: 'allowlist_updated',
-    detail: { count: ids.length, listenerStopped },
+    detail: { count: ids.length, addedCount: addedIds.length, listenerStopped, autoStart: ids.length > 0 && input.autoStart !== false },
   });
   return {
     status: 'ok',
     assistantMessage: ids.length
-      ? `白名单已保存,共 ${ids.length} 位联系人,立即生效`
+      ? `白名单已保存,共 ${ids.length} 位联系人;AI 监听将自动保持开启并按当前审核策略处理`
       : '白名单已清空,AI 监听已停止',
-    summary: { selectedCount: ids.length, listenerStopped },
-    data: { selectedIds: ids, selectedCount: ids.length },
+    summary: { selectedCount: ids.length, addedCount: addedIds.length, listenerStopped, autoStart: ids.length > 0 && input.autoStart !== false },
+    data: { selectedIds: ids, selectedCount: ids.length, addedIds },
     warnings: [],
     errors: [],
   };
 }
 
+function addAllowlistContacts(input = {}) {
+  refreshAllowedSendersFromEnv(workbench.config.qiwei);
+  const contactIds = normalizeAllowlistIds(input.contactIds || []);
+  return updateAllowlist({
+    ...input,
+    contactIds: [...workbench.config.qiwei.allowedSenders, ...contactIds],
+    autoStart: input.autoStart !== false,
+    actor: input.actor || 'agent:test-contact',
+  });
+}
+
 function displayableConversations() {
-  return workbench.db.listConversations().filter(item => item.last_message_at || item.last_content || Number(item.pending_count || 0) > 0);
+  return workbench.db.listConversations();
 }
 
 function getConversations() {
@@ -1249,9 +1273,30 @@ function updateCustomerRecommendation(conversationId, recommendationId, input =
   };
 }
 
-async function syncConversations() {
+function resolveConversationSyncScope(input = {}, allowlist = new Set(), db = workbench.db) {
+  const conversationId = String(input.conversationId || '').trim();
+  let contactId = String(input.contactId || '').trim();
+  if (conversationId) {
+    const conversation = db.getConversation(conversationId);
+    if (!conversation) throw new Error('当前会话不存在,请刷新后重试');
+    if (conversationChannelInfo(conversation, db.listMessages(conversation.id, 100)).channelType !== 'private') {
+      throw new Error('当前采集仅支持个人聊天会话');
+    }
+    contactId = String(conversation.contact_id || '').trim();
+  }
+  if (contactId && !allowlist.has(contactId)) throw new Error('当前联系人不在个人消息白名单中');
+  return {
+    conversationId,
+    contactId,
+    contacts: contactId ? new Set([contactId]) : allowlist,
+    scope: contactId ? 'conversation' : 'allowlist',
+  };
+}
+
+async function syncConversations(input = {}) {
   const allowlist = new Set(workbench.config.qiwei.allowedSenders.map(String));
   if (!allowlist.size) throw new Error('测试联系人白名单为空,无法同步会话');
+  const syncScope = resolveConversationSyncScope(input, allowlist);
   const account = await detectAccountStatus(true);
   if (!account.online) throw new Error('测试账号当前不在线,无法同步企微会话');
 
@@ -1271,7 +1316,7 @@ async function syncConversations() {
       if (isGroupMessage(message)) continue;
       const senderId = String(message.senderId || '');
       const receiverId = String(message.receiverId || '');
-      const contactId = allowlist.has(senderId) ? senderId : allowlist.has(receiverId) ? receiverId : '';
+      const contactId = syncScope.contacts.has(senderId) ? senderId : syncScope.contacts.has(receiverId) ? receiverId : '';
       const content = String(message.msgData?.content || '').trim();
       if (!contactId || !content || ![0, 1, 2].includes(Number(message.msgType))) continue;
 
@@ -1332,15 +1377,21 @@ async function syncConversations() {
   workbench.db.audit({
     actor: 'human',
     action: 'conversation_history_synced',
-    detail: { conversations: grouped.size, insertedMessages: syncedMessages, removedImportedMessages, scannedMessages, duplicatesRemoved },
+    detail: { scope: syncScope.scope, contactId: syncScope.contactId || null, conversations: grouped.size, insertedMessages: syncedMessages, removedImportedMessages, scannedMessages, duplicatesRemoved },
   });
 
   return {
     status: 'ok',
     assistantMessage: grouped.size
-      ? `已补采 ${grouped.size} 个白名单真实会话的最近消息;只入库,不运行 Agent、不发送回复`
-      : '未读取到白名单联系人的文字会话,请先在企微中与该联系人收发一条消息',
+      ? syncScope.scope === 'conversation'
+        ? '已采集当前个人会话的最近消息;只入库,不运行 Agent、不发送回复'
+        : `已补采 ${grouped.size} 个白名单真实会话的最近消息;只入库,不运行 Agent、不发送回复`
+      : syncScope.scope === 'conversation'
+        ? '当前个人会话暂无可采集的文字消息,请先在企微中收发一条消息'
+        : '未读取到白名单联系人的文字会话,请先在企微中与该联系人收发一条消息',
     data: {
+      scope: syncScope.scope,
+      conversationId: syncScope.conversationId || null,
       syncedConversationCount: grouped.size,
       syncedMessageCount: syncedMessages,
       scannedMessageCount: scannedMessages,
@@ -1357,6 +1408,7 @@ function changeGlobalMode(mode) {
   else if (selected === 'auto') update = { paused: false, defaultMode: 'auto' };
   else if (selected === 'human') update = { paused: false, defaultMode: 'human' };
   else throw new Error('不支持的 Agent 模式');
+  workbench.db.setSetting('listener_enabled', selected === 'paused' ? 'false' : 'true');
   const global = workbench.service.setGlobal(update);
   return {
     status: 'ok',
@@ -1642,8 +1694,14 @@ function getAudit(limit = 200) {
   return { status: 'ok', data: { audit: workbench.db.listAudit(Math.max(1, Math.min(500, Number(limit) || 200))) } };
 }
 
-async function startListenerForWorkbench(target, account) {
+async function startListenerForWorkbench(target, account, options = {}) {
   if (!account.online) throw new Error(`${account.nickname || '当前账号'}不在线,无法启动真实消息监听`);
+  refreshAllowedSendersFromEnv(target.config.qiwei, options.envFile || ENV_FILE);
+  if (options.automatic === true && target.db?.getSetting?.('listener_enabled', 'true') === 'false') {
+    return { status: 'ok', assistantMessage: 'AI 监听保持人工关闭状态', data: { running: false, disabled: true } };
+  }
+  target.db?.setSetting?.('listener_enabled', 'true');
+  target.service?.setGlobal?.({ paused: false }, options.automatic ? 'runtime:auto-start' : 'human');
   const status = await target.poller.start();
   return {
     status: 'ok',
@@ -1660,7 +1718,7 @@ async function ingestRuntimeMessage(message = {}, options = {}) {
   }, message, String(options.source || 'runtime'));
 }
 
-async function startListener() {
+async function startListener(options = {}) {
   const product = getProductMode();
   if (product.mode === 'enterprise') {
     const runtime = listenerStateWithRuntime(product, workbench.poller.status());
@@ -1673,11 +1731,11 @@ async function startListener() {
     };
   }
   const account = await detectAccountStatus(true);
-  return startListenerForWorkbench(workbench, account);
+  return startListenerForWorkbench(workbench, account, options);
 }
 
 function applyManualTakeover(target) {
-  target.service.setGlobal({ paused: false, defaultMode: 'review' });
+  target.service.setGlobal({ paused: true, defaultMode: 'review' });
   for (const conversation of target.db.listConversations()) {
     target.service.setConversationMode(conversation.id, 'human');
   }
@@ -1701,7 +1759,10 @@ function stopListener({ preserveAgentState = false } = {}) {
     };
   }
   const status = workbench.poller.stop();
-  if (!preserveAgentState) applyManualTakeover(workbench);
+  if (!preserveAgentState) {
+    workbench.db.setSetting('listener_enabled', 'false');
+    applyManualTakeover(workbench);
+  }
   return {
     status: 'ok',
     assistantMessage: preserveAgentState
@@ -1720,6 +1781,7 @@ module.exports = {
   getAgentStatus,
   getAllowlistCandidates,
   updateAllowlist,
+  addAllowlistContacts,
   getConversations,
   getResponseMonitor,
   getGroupAgents,
@@ -1754,5 +1816,5 @@ module.exports = {
   stopListener,
   getAgentRuntimeConfig,
   createWorkbench,
-  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, applyManualTakeover, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, detectedPropertiesInMessage, groupAgentService, startListenerForWorkbench },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, resolveConversationSyncScope, applyManualTakeover, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, detectedPropertiesInMessage, groupAgentService, startListenerForWorkbench },
 };

+ 22 - 7
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js

@@ -2917,7 +2917,12 @@
       })) return;
       saveButton.disabled = true;
       try {
-        const result = await api('POST', '/api/agent/allowlist', { contactIds: [...selected] });
+        const contactIds = [...selected];
+        const contactNames = {};
+        for (const contact of contacts) {
+          if (selected.has(contact.id) && String(contact.displayName || '').trim()) contactNames[contact.id] = contact.displayName;
+        }
+        const result = await api('POST', '/api/agent/allowlist', { contactIds, contactNames, autoStart: true });
         toast(result.assistantMessage || '白名单已保存');
         closeModal();
         await loadAgentData(page);
@@ -3227,6 +3232,7 @@
     const globalPaused = global.paused !== undefined ? Boolean(global.paused) : status.globalMode === 'paused';
     const globalMode = global.defaultMode || (status.globalMode === 'auto' ? 'auto' : 'review');
     const listenerRunning = Boolean(listener.running);
+    const aiListenerActive = listenerRunning && !globalPaused;
     const messageIngressRunning = product.mode === 'enterprise' ? Boolean(ingress.running) : listenerRunning;
     const allowlistCount = Number(config.allowedSenderCount) || 0;
     const allowlistReady = allowlistCount > 0;
@@ -3287,9 +3293,9 @@
           <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(product.label)} · ${escapeHtml(product.collectionLabel)} · ${messageIngressRunning ? '回调接收中' : '回调待恢复'}</small></div>
           <div class="agent-safety-actions">
             <button class="btn btn-secondary" data-agent-action="manage-allowlist">管理白名单</button>
-            <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采最近消息</button>
+            <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采全部白名单</button>
             ${product.mode === 'personal'
-              ? `<button class="btn ${listenerRunning ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (listenerRunning ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (listenerRunning ? '关闭 AI 监听(转人工)' : '启动 AI 监听') : '先选择白名单'}</button>`
+              ? `<button class="btn ${aiListenerActive ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (aiListenerActive ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (aiListenerActive ? '关闭 AI 监听(转人工)' : '启动 AI 监听') : '先选择白名单'}</button>`
               : ''}
           </div>
         </div>
@@ -3361,6 +3367,7 @@
             <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">
+                <button class="btn btn-sm btn-secondary" data-agent-action="sync-current-conversation" data-id="${selected.id}" ${account.online ? '' : 'disabled'}>采集当前会话</button>
                 <button class="btn btn-sm ${selected.mode === 'review' ? '' : 'btn-secondary'}" data-agent-conversation-mode="review" data-id="${selected.id}">待审核</button>
                 <button class="btn btn-sm ${selected.mode === 'auto' ? '' : 'btn-secondary'}" data-agent-conversation-mode="auto" data-id="${selected.id}">自动</button>
                 <button class="btn btn-sm ${selected.mode === 'human' ? '' : 'btn-secondary'}" data-agent-conversation-mode="human" data-id="${selected.id}">人工接管</button>
@@ -3814,14 +3821,22 @@
       actionButton.disabled = true;
       try {
         let result;
-        if (action === 'sync-conversations') {
-          if (!await confirmModal('只补采白名单联系人最近的真实消息,不运行 Agent,也不发送任何回复。', { title: '补采最近消息', confirmText: '开始补采' })) return;
-          result = await api('POST', '/api/agent/conversations/sync', {});
+        if (action === 'sync-conversations' || action === 'sync-current-conversation') {
+          const current = (state.agent.conversations || []).find(item => item.id === (id || state.agent.selectedId));
+          const single = action === 'sync-current-conversation';
+          if (single && !current) throw new Error('当前会话不存在,请刷新后重试');
+          const prompt = single
+            ? `只采集“${current.displayName || '当前客户'}”最近的真实消息,不运行 Agent,也不发送回复。`
+            : '补采全部白名单联系人的最近真实消息,不运行 Agent,也不发送回复。';
+          if (!await confirmModal(prompt, { title: single ? '采集当前会话' : '补采全部白名单', confirmText: '开始采集' })) return;
+          result = await api('POST', '/api/agent/conversations/sync', single ? { conversationId: current.id } : {});
           const synced = result.data?.conversations || [];
           if (synced.length) {
             state.agent.conversations = synced;
             state.agent.listMode = 'private';
-            state.agent.selectedId = synced.find(item => item.channelType !== 'group')?.id || null;
+            state.agent.selectedId = single && synced.some(item => item.id === current.id)
+              ? current.id
+              : 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];

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

@@ -686,7 +686,7 @@ async function handleRequest(req, res) {
       return;
     }
     if (pathname === '/api/agent/conversations/sync' && req.method === 'POST') {
-      json(res, 200, await syncConversations());
+      json(res, 200, await syncConversations(await readBody(req)));
       return;
     }
     if (pathname === '/api/agent/audit' && req.method === 'GET') {

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

@@ -174,7 +174,7 @@ function wrap(handler) {
 function createServer() {
   const server = new McpServer({
     name: 'enterprise-wechat-assistant',
-    version: '0.5.1'
+    version: '0.5.2'
   });
 
   server.registerTool(
@@ -432,6 +432,7 @@ function createServer() {
         defaultGreeting: z.string().optional(),
         rateLimitPerMinute: z.number().int().min(1).max(60).optional(),
         maxAttempts: z.number().int().min(1).max(5).optional(),
+        addToAllowlist: z.boolean().optional().describe('仅添加测试好友时设为 true;成功后自动加入个人消息白名单并启用待审核监听'),
         uid: z.string().optional(),
         authToken: z.string().optional(),
         apiBase: z.string().optional()

+ 25 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js

@@ -251,10 +251,33 @@ const qiweiBatchAddFriends = safeResult(async function qiweiBatchAddFriends(inpu
     });
   }
 
+  let allowlistResult = null;
+  let allowlistWarning = '';
+  if (input.addToAllowlist === true) {
+    const contactIds = [...new Set(details
+      .filter(item => item.status === 'SUCCESS' && item.externalUserId)
+      .map(item => String(item.externalUserId)))];
+    if (contactIds.length) {
+      const contactNames = Object.fromEntries(details
+        .filter(item => item.status === 'SUCCESS' && item.externalUserId && item.name)
+        .map(item => [String(item.externalUserId), String(item.name)]));
+      try {
+        const { addAllowlistContacts } = require('../dashboard/agent-service');
+        allowlistResult = addAllowlistContacts({ contactIds, contactNames, autoStart: true, actor: 'agent:test-contact' });
+        summary.allowlistAdded = Number(allowlistResult.summary?.addedCount) || 0;
+      } catch (error) {
+        allowlistWarning = `好友已添加,但测试白名单同步待重试:${String(error.message || error)}`;
+      }
+    } else {
+      allowlistWarning = '好友操作已完成,但返回结果中没有可加入测试白名单的联系人 ID';
+    }
+  }
+
   return okResult({
-    assistantMessage: `批量加好友完成:成功 ${success},失败 ${failed}。`,
+    assistantMessage: `批量加好友完成:成功 ${success},失败 ${failed}。${allowlistResult ? ' 测试联系人已自动加入白名单,AI 监听将保持开启。' : ''}`,
     summary,
-    data: { details, invalid }
+    data: { details, invalid, allowlist: allowlistResult?.data || null },
+    warnings: allowlistWarning ? [allowlistWarning] : []
   });
 });
 

+ 2 - 2
claude-code/claude-code-qiwe-assistant/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "fmode-qiwei",
-  "version": "0.5.1",
+  "version": "0.5.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "fmode-qiwei",
-      "version": "0.5.1",
+      "version": "0.5.2",
       "dependencies": {
         "@binsee/wx-voice": "^0.3.2",
         "@ffmpeg-installer/ffmpeg": "^1.1.0",

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

@@ -1,6 +1,6 @@
 {
   "name": "fmode-qiwei",
-  "version": "0.5.1",
+  "version": "0.5.2",
   "description": "Fmode 企业微信智能办公技能包:客户运营、智能客服、Dashboard、Webhook/Relay、官方 CLI 与目标管理",
   "author": "fmode",
   "main": "mcp/src/server.js",

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

@@ -30,7 +30,7 @@ export function syncConversations() {
 }
 
 export function startPersonalListener() {
-  return agentService.startListener();
+  return agentService.startListener({ automatic: true });
 }
 
 export function stopPersonalListener(options = {}) {

+ 82 - 2
claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js

@@ -26,6 +26,7 @@ const { GroupAgentService } = require('../mcp/src/dashboard/group-agent-service'
 const { friendlyAgentError } = require('../mcp/src/core/agent-error-message');
 
 const results = [];
+const PACKAGE_ROOT = path.resolve(__dirname, '..');
 
 function setup({ paused = false, defaultMode = 'review', agentRun } = {}) {
   const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-smoke-'));
@@ -207,6 +208,79 @@ async function main() {
     assert.equal(configB.qiwei.guid, accountB.guid);
   });
 
+  await check('自动监听热加载白名单并尊重人工关闭状态', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-listener-default-'));
+    const envFile = path.join(dir, '.env.local');
+    fs.writeFileSync(envFile, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS=contact-new\n', 'utf8');
+    const settings = new Map();
+    const calls = [];
+    let starts = 0;
+    const target = {
+      config: { qiwei: { allowedSenders: ['contact-old'] } },
+      db: {
+        getSetting(name, fallback) { return settings.has(name) ? settings.get(name) : fallback; },
+        setSetting(name, value) { settings.set(name, value); },
+        globalState() { return { defaultMode: 'review' }; },
+      },
+      service: { setGlobal(input, actor) { calls.push({ input, actor }); } },
+      poller: {
+        async start() {
+          starts += 1;
+          assert.deepEqual(target.config.qiwei.allowedSenders, ['contact-new']);
+          return { running: true, syncKey: starts };
+        },
+      },
+    };
+    try {
+      const started = await __testing.startListenerForWorkbench(target, { online: true }, { automatic: true, envFile });
+      assert.equal(started.data.running, true);
+      assert.equal(settings.get('listener_enabled'), 'true');
+      assert.deepEqual(calls.at(-1).input, { paused: false });
+
+      settings.set('listener_enabled', 'false');
+      const disabled = await __testing.startListenerForWorkbench(target, { online: true }, { automatic: true, envFile });
+      assert.equal(disabled.data.disabled, true);
+      assert.equal(starts, 1);
+
+      const manual = await __testing.startListenerForWorkbench(target, { online: true }, { envFile });
+      assert.equal(manual.data.running, true);
+      assert.equal(starts, 2);
+      assert.equal(settings.get('listener_enabled'), 'true');
+    } finally {
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
+  await check('当前会话采集仅允许白名单个人聊天', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const conversations = new Map([
+      ['private-1', { id: 'private-1', contact_id: 'contact-1' }],
+      ['private-2', { id: 'private-2', contact_id: 'contact-2' }],
+      ['group-1', { id: 'group-1', contact_id: 'room-1@chatroom' }],
+    ]);
+    const db = {
+      getConversation(id) { return conversations.get(id) || null; },
+      listMessages() { return []; },
+    };
+    const allowlist = new Set(['contact-1']);
+    const scope = __testing.resolveConversationSyncScope({ conversationId: 'private-1' }, allowlist, db);
+    assert.equal(scope.scope, 'conversation');
+    assert.deepEqual([...scope.contacts], ['contact-1']);
+    assert.throws(() => __testing.resolveConversationSyncScope({ conversationId: 'private-2' }, allowlist, db), /白名单/);
+    assert.throws(() => __testing.resolveConversationSyncScope({ conversationId: 'group-1' }, allowlist, db), /个人聊天/);
+  });
+
+  await check('Dashboard 和工具公开精准采集及测试好友白名单契约', async () => {
+    const appSource = fs.readFileSync(path.join(PACKAGE_ROOT, 'mcp', 'src', 'dashboard', 'app.js'), 'utf8');
+    const serverSource = fs.readFileSync(path.join(PACKAGE_ROOT, 'mcp', 'src', 'server.js'), 'utf8');
+    const bridgeSource = fs.readFileSync(path.join(PACKAGE_ROOT, 'runtime', 'callback-service', 'src', 'processor-bridge.mjs'), 'utf8');
+    assert.match(appSource, /data-agent-action="sync-current-conversation"/);
+    assert.match(appSource, /补采全部白名单/);
+    assert.match(serverSource, /addToAllowlist:\s*z\.boolean\(\)/);
+    assert.match(bridgeSource, /startListener\(\{ automatic: true \}\)/);
+  });
+
   await check('白名单文件变更会在监听期间热加载', async () => {
     const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-allowlist-reload-'));
     try {
@@ -244,7 +318,7 @@ async function main() {
     assert.equal(calls.length, 0);
     __testing.applyManualTakeover(target);
     assert.deepEqual(calls, [
-      ['global', { paused: false, defaultMode: 'review' }],
+      ['global', { paused: true, defaultMode: 'review' }],
       ['conversation', 'conversation-a', 'human'],
     ]);
   });
@@ -283,6 +357,12 @@ async function main() {
     const { __testing } = require('../mcp/src/dashboard/agent-service');
     const calls = [];
     const target = {
+      config: { qiwei: { allowedSenders: ['contact-1'] } },
+      db: {
+        getSetting(_name, fallback) { return fallback; },
+        setSetting() {},
+      },
+      service: { setGlobal() { calls.push('service.setGlobal'); } },
       poller: {
         async start() {
           calls.push('poller.start');
@@ -291,7 +371,7 @@ async function main() {
       }
     };
     const result = await __testing.startListenerForWorkbench(target, { online: true, nickname: '测试账号' });
-    assert.deepEqual(calls, ['poller.start']);
+    assert.deepEqual(calls, ['service.setGlobal', 'poller.start']);
     assert.equal(result.status, 'ok');
     assert.equal(result.data.running, true);
   });

+ 67 - 10
claude-code/claude-code-qiwe-assistant/scripts/preview-dashboard.js

@@ -49,6 +49,60 @@ function openUrl(url) {
   child.unref();
 }
 
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+function runtimeIsRunning(state, isAlive) {
+  return state?.status === 'running' && isAlive(state.pid);
+}
+
+async function ensureCallbackRuntime({ workspaceRoot, timeoutMs = 15000 } = {}) {
+  const stateModuleUrl = pathToFileURL(path.join(
+    __dirname,
+    '..',
+    'runtime',
+    'callback-service',
+    'src',
+    'runtime-state.mjs',
+  )).href;
+  const { isProcessAlive, readRuntimeState } = await import(stateModuleUrl);
+  let state = readRuntimeState();
+  if (runtimeIsRunning(state, isProcessAlive)) {
+    return { ...state, reused: true };
+  }
+
+  const deadline = Date.now() + timeoutMs;
+  while (state.pid && isProcessAlive(state.pid) && state.status !== 'stopped' && Date.now() < deadline) {
+    await delay(100);
+    state = readRuntimeState();
+    if (runtimeIsRunning(state, isProcessAlive)) return { ...state, reused: true };
+  }
+  if (state.pid && isProcessAlive(state.pid) && state.status !== 'stopped') {
+    throw new Error(`Qiwei runtime pid=${state.pid} is still ${state.status || 'unknown'}.`);
+  }
+
+  const runtimeScript = path.join(__dirname, 'start-callback-runtime.mjs');
+  const child = spawn(process.execPath, [runtimeScript, 'start', '--no-dashboard'], {
+    cwd: workspaceRoot,
+    env: { ...process.env, QIWEI_WORKSPACE_ROOT: workspaceRoot },
+    detached: true,
+    stdio: 'ignore',
+    windowsHide: true,
+  });
+  child.unref();
+
+  while (Date.now() < deadline) {
+    await delay(100);
+    state = readRuntimeState();
+    if (runtimeIsRunning(state, isProcessAlive)) {
+      return { ...state, reused: false };
+    }
+    if (state.pid === child.pid && state.status === 'stopped') {
+      throw new Error(`Qiwei runtime stopped during startup: ${state.stopReason || 'unknown reason'}`);
+    }
+  }
+  throw new Error(`Qiwei runtime did not become ready within ${timeoutMs}ms.`);
+}
+
 function printStartupSummary(baseUrl, summary, workspaceRoot, alreadyRunning) {
   process.stdout.write([
     '',
@@ -76,17 +130,16 @@ async function main() {
   }
 
   let server = null;
-  let runtime = null;
   if (!current.running) {
     const { startServer } = require('../mcp/src/dashboard/server');
     ({ server } = await startServer(options.port));
-    try {
-      const runtimeModuleUrl = pathToFileURL(path.join(__dirname, '..', 'runtime', 'callback-service', 'src', 'index.mjs')).href;
-      const runtimeModule = await import(runtimeModuleUrl);
-      runtime = await runtimeModule.startRuntime({ embedded: true, dashboard: false });
-    } catch (error) {
-      process.stderr.write(`Qiwei runtime startup is pending: ${error.message}\n`);
-    }
+  }
+
+  try {
+    const runtime = await ensureCallbackRuntime({ workspaceRoot: context.workspaceRoot });
+    process.stdout.write(`Qiwei runtime ${runtime.reused ? 'reused' : 'started'} independently (pid=${runtime.pid}).\n`);
+  } catch (error) {
+    process.stderr.write(`Qiwei runtime startup is pending: ${error.message}\n`);
   }
 
   let status = {};
@@ -104,12 +157,14 @@ async function main() {
   if (options.openBrowser) openUrl(`${baseUrl}/#agent`);
   if (current.running && !server) return;
 
-  if (runtime) {
+  if (server) {
     let shuttingDown = false;
     const shutdown = signal => {
       if (shuttingDown) return;
       shuttingDown = true;
-      runtime.stop(signal).finally(() => process.exit(0));
+      process.stdout.write(`Closing dashboard after ${signal}; callback runtime remains active.\n`);
+      server.close(() => process.exit(0));
+      setTimeout(() => process.exit(0), 3000).unref();
     };
     process.once('SIGINT', () => shutdown('SIGINT'));
     process.once('SIGTERM', () => shutdown('SIGTERM'));
@@ -126,4 +181,6 @@ if (require.main === module) {
 module.exports = {
   parseArgs,
   inspectRunningServer,
+  runtimeIsRunning,
+  ensureCallbackRuntime,
 };

+ 9 - 1
claude-code/claude-code-qiwe-assistant/scripts/startup-preview-smoke-test.js

@@ -7,6 +7,7 @@ const path = require('path');
 const { spawnSync } = require('child_process');
 const { resolveWorkspaceRoot, workspaceIdentity } = require('../mcp/src/core/runtime-context');
 const { buildStartupSummary, startupAssistantMessage } = require('../mcp/src/core/startup-summary');
+const { runtimeIsRunning } = require('./preview-dashboard');
 
 const ROOT = path.resolve(__dirname, '..');
 
@@ -71,16 +72,23 @@ function testWorkspaceInstall(tempRoot) {
   assert.match(secondGitignore, /# Customer rules\ncustomer-cache\//);
 }
 
+function testIndependentRuntimeDetection() {
+  assert.equal(runtimeIsRunning({ pid: 123, status: 'running' }, pid => pid === 123), true);
+  assert.equal(runtimeIsRunning({ pid: 123, status: 'stopped' }, () => true), false);
+  assert.equal(runtimeIsRunning({ pid: 123, status: 'running' }, () => false), false);
+}
+
 function main() {
   const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-preview-smoke-'));
   try {
     testRuntimeContext(tempRoot);
     testStartupSummary();
     testWorkspaceInstall(tempRoot);
+    testIndependentRuntimeDetection();
   } finally {
     fs.rmSync(tempRoot, { recursive: true, force: true });
   }
-  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 3 }, null, 2)}\n`);
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 4 }, null, 2)}\n`);
 }
 
 try { main(); }

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

@@ -1,6 +1,6 @@
 {
   "name": "fmode-qiwei",
-  "version": "0.5.1",
+  "version": "0.5.2",
   "status": "published",
   "plugin": "qiwei-assistant",
   "description": "企业微信智能办公技能包:日常办公、目标推进、智能客服、CRM 客户管理和底层 Skills/API。",

+ 4 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-ops/SKILL.md

@@ -71,12 +71,16 @@ description: 迁移自 Qiwei 项目的客户群运营能力:批量搜索并添
   "customers": [
     { "phone": "13800000000", "name": "张三", "greeting": "你好,方便加您企业微信沟通。" }
   ],
+  "addToAllowlist": true,
   "rateLimitPerMinute": 12
 }
 ```
 
 也可以传 `phones`,或传 `.xlsx` 格式的 `filePath` 读取 Excel。Excel 至少需要手机号列;旧版 `.xls` 请先另存为 `.xlsx`。
 
+- 用户明确说“测试好友”“演示联系人”时传 `addToAllowlist=true`,成功后自动加入个人消息白名单并开启待审核监听。
+- 普通批量拓客时省略该字段,避免扩大 AI 监听范围。
+
 ### 检查好友申请状态
 
 批量加好友后,可调用 `qiwei_check_friend_status` 查看哪些客户已通过、待通过或未找到:

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

@@ -67,8 +67,8 @@ node .claude/plugins/qiwei-assistant/install.js preview .
 1. Fmode 鉴权是否可用;
 2. 企微席位是否有效;
 3. 企微账号是否在线;
-4. 测试联系人白名单是否非空;
-5. 企业版公网回调是否运行(个人版检查 AI 监听)。
+4. 测试联系人白名单是否非空;添加测试好友时自动写入白名单,普通批量加好友不自动扩大监听范围;
+5. 企业版公网回调是否运行(个人版默认保持 AI 待审核监听;人工关闭时尊重关闭状态)。
 
 只提示第一项需要处理的动作,完成后重新检查。Dashboard 只是管理界面;企业版回调和 Skill 会话读取均不依赖页面是否打开。
 
@@ -78,6 +78,7 @@ node .claude/plugins/qiwei-assistant/install.js preview .
 - 项目主控 Claude Code 会话负责服务启动、登录、策略、审核和事件汇总,不直接承载所有客户对话上下文。
 - 每个白名单客户绑定独立 Claude Code Session;Session 记录项目 ID 和主控 Session 关联,防止客户上下文互相污染。
 - Dashboard、监听器、MCP 管理工具和客户 Session 共同读写 Workbench 数据库与审计事件。
+- 私聊会话标题栏提供“采集当前会话”,只补采当前白名单联系人;顶部“补采全部白名单”用于批量补录。
 - Claude Code 交互会话不能被浏览器页面直接异步注入消息。新事件先写入事件箱,主控会话调用 `qiwei_agent_inbox` 读取;需要自动回灌时必须由串行桥接器 resume 主控 Session,禁止并发写同一 Session。
 - 客户 Session 默认只允许 Read、Glob、Grep,只生成草稿。真实发送必须继续经过白名单、模式、置信度和人工审核规则。
 - Dashboard 客户标题下方必须显示 Session 状态条;识别到客户 Session 后,展示可识别名称和「查看会话与打开方式」按钮。按钮只复制安全命令,不展示原始 Session ID。

+ 51 - 0
release/npm/fmode-qiwei/publish.ps1

@@ -0,0 +1,51 @@
+param(
+  [string]$Tarball,
+  [switch]$AuthOnly
+)
+
+$ErrorActionPreference = 'Stop'
+$Root = Resolve-Path (Join-Path $PSScriptRoot '..\..\..')
+$PackageRoot = Join-Path $Root 'claude-code\claude-code-qiwe-assistant'
+$SharedEnvPath = Join-Path $PSScriptRoot '..\claude-code-voc-intelligence\.env.local'
+
+function Read-LocalToken([string]$FilePath) {
+  if (-not (Test-Path -LiteralPath $FilePath)) { return '' }
+  foreach ($raw in Get-Content -LiteralPath $FilePath) {
+    $line = $raw.Trim()
+    if (-not $line -or $line.StartsWith('#') -or -not $line.Contains('=')) { continue }
+    $parts = $line.Split('=', 2)
+    if ($parts[0].Trim() -in @('NPM_TOKEN', 'NODE_AUTH_TOKEN')) {
+      return $parts[1].Trim().Trim('"').Trim("'")
+    }
+  }
+  return ''
+}
+
+$token = if ($env:NPM_TOKEN) {
+  $env:NPM_TOKEN
+} elseif ($env:NODE_AUTH_TOKEN) {
+  $env:NODE_AUTH_TOKEN
+} else {
+  Read-LocalToken $SharedEnvPath
+}
+if (-not $token) { throw 'npm token is not configured in the local release environment.' }
+
+$npmrc = Join-Path $env:TEMP ('fmode-qiwei-' + [guid]::NewGuid().ToString('N') + '.npmrc')
+try {
+  Set-Content -LiteralPath $npmrc -Value "//registry.npmjs.org/:_authToken=$token" -Encoding ascii
+  $env:NPM_CONFIG_USERCONFIG = $npmrc
+
+  $account = npm whoami --registry=https://registry.npmjs.org/
+  if ($LASTEXITCODE -ne 0) { throw 'npm authentication check failed.' }
+  Write-Host "[auth] npm account: $account"
+  if ($AuthOnly) { return }
+
+  if (-not $Tarball) { throw 'Tarball is required unless -AuthOnly is used.' }
+  $resolvedTarball = Resolve-Path -LiteralPath $Tarball
+  npm publish $resolvedTarball --access public --registry=https://registry.npmjs.org/
+  if ($LASTEXITCODE -ne 0) { throw 'npm publish failed.' }
+} finally {
+  Remove-Item Env:NPM_CONFIG_USERCONFIG -ErrorAction SilentlyContinue
+  Remove-Item -LiteralPath $npmrc -Force -ErrorAction SilentlyContinue
+  $token = $null
+}

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

@@ -30,7 +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.5.1` | 已发布 | `claude-code/claude-code-qiwe-assistant/` | https://www.npmjs.com/package/fmode-qiwei |
+| fmode 企业微信智能办公 | `fmode-qiwei` | `0.5.2` | 已发布 | `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 |
 
 ## 安装入口
@@ -138,6 +138,12 @@ npm view fmode-qiwei name version dist-tags.latest --registry=https://registry.n
 npm exec --yes --package fmode-qiwei@latest -- fmode-qiwei workspace . --smoke
 ```
 
+本地发布脚本会读取现有发布环境中的 npm token,并使用临时 `.npmrc`,结束后自动删除:
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass -File release/npm/fmode-qiwei/publish.ps1 -Tarball <fmode-qiwei-版本.tgz>
+```
+
 工作区安装会写入:
 
 ```text

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

@@ -2,6 +2,15 @@
 
 > 当前版本号见 [`../NPM技能包发布管理.md`](../NPM技能包发布管理.md) 的「发布状态总览」。本文按版本号倒序记录 `fmode-qiwei` 的更新内容。
 
+## 0.5.2(2026-08-04)
+
+- 个人消息继续强制受白名单约束;`qiwei_batch_add_friends` 新增显式 `addToAllowlist`,仅测试/演示好友按需自动加入白名单,普通批量拓客不扩大监听范围。
+- Dashboard 新增“采集当前会话”,只补采当前白名单私聊;顶部入口改为“补采全部白名单”,群聊和非白名单联系人会被拒绝。
+- 个人版默认保持 AI 监听和 `review` 待审核模式;监听启动前热加载项目白名单,人工关闭后独立 Runtime 尊重关闭状态,不会自动外发。
+- Dashboard 预览改为启动独立 Runtime,关闭页面或 Dashboard 进程后监听仍可常驻;工作区 Skill 文档与插件契约同步更新。
+- 发布前 18 组检查、37 项 Agent 回归、25 项 Runtime 回归、账号切换 20 项、npm 包 190 项及生产依赖审计全部通过,违规文件和依赖漏洞均为 0。
+- 已发布并验证 npm `latest=0.5.2`;registry 版本、线上 tarball shasum `2f14817331ee36cace1f33257327f1b6cb85dbc8` 和隔离环境 `workspace --smoke` 均通过。
+
 ## 0.5.1(2026-08-04)
 
 - workspace 初始化新增项目根目录 `.gitignore`:通过随包发布的 `templates/workspace.gitignore` 规避 npm 默认省略源码 `.gitignore` 的行为。