Sfoglia il codice sorgente

feat: restore private chat autopilot mode

gangvy 1 mese fa
parent
commit
bef85a0f00

+ 3 - 0
.env.example

@@ -8,6 +8,9 @@ QIWEI_UID=
 # 也可以显式配置平台 sessionToken 或 Fmode API token。
 QIWEI_AUTH_TOKEN=
 
+# 语音合成只接受 Fmode API 控制台 /keys/ 创建的 sk- Token,不复用平台 sessionToken。
+QIWEI_VOICE_AUTH_TOKEN=
+
 # 企业微信接口访问凭据和设备上下文仅由 Fmode 网关管理,客户端无需配置。
 
 # 产品运行模式:personal(个人版,本地主动监听)或 enterprise(企业版,服务端统一回调)。

+ 4 - 1
mcp/src/core/agent-context-builder.js

@@ -35,6 +35,7 @@ class AgentContextBuilder {
 
   buildClaudePrompt(messages, context = {}) {
     if (context.directPrompt) return clip(String(context.directPrompt), this.promptCharLimit);
+    const autopilot = context.conversation?.mode === 'autopilot';
     const history = selectAuthoritativeHistory(messages, 10)
       .map(message => `${message.role === 'assistant' ? '客服' : message.role === 'tool' ? '工具' : '客户'}:${String(message.content || '').slice(0, 1200)}`)
       .join('\n\n');
@@ -47,7 +48,9 @@ class AgentContextBuilder {
     const historyBudget = this.promptCharLimit - instructionBudget - stateBudget - 32;
     const instructions = clip([
       '请处理下面的企业微信客户会话。你可以使用只读工具检索当前工作区中的知识库和规则。',
-      '不要修改文件,不要发送消息,不要编造业务事实。只生成供 Dashboard 审核的回复草稿。',
+      autopilot
+        ? '不要修改文件或自行调用发送工具,不要编造业务事实。只返回结构化 JSON,系统会通过全自动接管链路直接发送 reply。'
+        : '不要修改文件,不要发送消息,不要编造业务事实。只生成供 Dashboard 审核的回复草稿。',
       '严格按照 JSON Schema 输出;reply 面向客户,reason 仅供内部审核。',
       '【上下文边界】下面的“本轮有效会话”是本轮唯一可信的客户对话。即使当前 Claude Code Session 曾经出现过其他客户原话,也不得引用未在本轮有效会话中重复出现的内容。',
       '把语音转写重复、语义残缺、与当前业务无关的测试/技术消息视为未确认噪声;不得据此推断数量、预算用途或决策意图等高影响需求,必须先向客户确认。',

+ 44 - 7
mcp/src/core/agent-workbench-db.js

@@ -9,6 +9,7 @@ const json = value => JSON.stringify(value ?? null);
 const parse = (value, fallback) => {
   try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
 };
+const CONVERSATION_MODES = ['review', 'auto', 'autopilot', 'human', 'paused'];
 const PERSONAL_INTAKE_MODES = ['allowlist_only', 'auto_enroll_review', 'auto_enroll_autopilot'];
 const WELCOME_SEND_MODES = ['draft', 'send'];
 const WELCOME_STATES = ['skipped', 'draft_pending', 'send_pending', 'sending', 'sent', 'failed', 'delivery_unknown'];
@@ -76,7 +77,7 @@ class AgentWorkbenchDb {
         id TEXT PRIMARY KEY,
         contact_id TEXT NOT NULL UNIQUE,
         contact_name TEXT NOT NULL DEFAULT '',
-        mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','human','paused')),
+        mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','autopilot','human','paused')),
         last_message_at TEXT,
         created_at TEXT NOT NULL,
         updated_at TEXT NOT NULL
@@ -263,6 +264,7 @@ class AgentWorkbenchDb {
       CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at DESC);
       CREATE INDEX IF NOT EXISTS idx_contact_onboarding_state ON contact_onboarding(account_key, welcome_state, updated_at DESC);
     `);
+    this.migrateConversationModes();
     this.db.prepare(`UPDATE memory_extraction_jobs SET status='pending',next_attempt_at=?,updated_at=? WHERE status='processing'`)
       .run(now(), now());
     this.ensureColumn('customer_tasks', 'business_key', "business_key TEXT NOT NULL DEFAULT ''");
@@ -282,7 +284,7 @@ class AgentWorkbenchDb {
       CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_alerts_business_key ON customer_alerts(conversation_id, business_key);
     `);
     this.setDefault('global_paused', defaults.globalPaused ? 'true' : 'false');
-    this.setDefault('default_mode', defaults.defaultMode || 'review');
+    this.setDefault('default_mode', CONVERSATION_MODES.includes(defaults.defaultMode) ? defaults.defaultMode : 'review');
     this.setDefault('auto_send_confidence', String(defaults.autoSendConfidence ?? 0.88));
     this.setDefault('agent_cutover_at', defaults.cutoverAt || now());
     this.setDefault('personal_intake_mode', PERSONAL_INTAKE_MODES.includes(defaults.personalIntakeMode) ? defaults.personalIntakeMode : 'allowlist_only');
@@ -380,6 +382,36 @@ class AgentWorkbenchDb {
       ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`).run(key, String(value), now());
   }
 
+  migrateConversationModes() {
+    const schema = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='conversations'").get()?.sql || '';
+    if (schema.includes("'autopilot'")) return;
+    this.db.exec('PRAGMA foreign_keys=OFF;');
+    try {
+      this.db.exec(`
+        BEGIN IMMEDIATE;
+        CREATE TABLE conversations_mode_migration (
+          id TEXT PRIMARY KEY,
+          contact_id TEXT NOT NULL UNIQUE,
+          contact_name TEXT NOT NULL DEFAULT '',
+          mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','autopilot','human','paused')),
+          last_message_at TEXT,
+          created_at TEXT NOT NULL,
+          updated_at TEXT NOT NULL
+        );
+        INSERT INTO conversations_mode_migration(id,contact_id,contact_name,mode,last_message_at,created_at,updated_at)
+          SELECT id,contact_id,contact_name,mode,last_message_at,created_at,updated_at FROM conversations;
+        DROP TABLE conversations;
+        ALTER TABLE conversations_mode_migration RENAME TO conversations;
+        COMMIT;
+      `);
+    } catch (error) {
+      try { this.db.exec('ROLLBACK;'); } catch {}
+      throw error;
+    } finally {
+      this.db.exec('PRAGMA foreign_keys=ON;');
+    }
+  }
+
   intakePolicy() {
     const mode = this.getSetting('personal_intake_mode', 'allowlist_only');
     const welcomeSendMode = this.getSetting('welcome_send_mode', 'draft');
@@ -520,8 +552,11 @@ class AgentWorkbenchDb {
     }
     const conversationId = makeId('conv');
     const timestamp = now();
+    const defaultMode = CONVERSATION_MODES.includes(this.getSetting('default_mode', 'review'))
+      ? this.getSetting('default_mode', 'review')
+      : 'review';
     this.db.prepare(`INSERT INTO conversations(id,contact_id,contact_name,mode,created_at,updated_at)
-      VALUES(?,?,?,?,?,?)`).run(conversationId, String(contactId), safeName, this.getSetting('default_mode', 'review'), timestamp, timestamp);
+      VALUES(?,?,?,?,?,?)`).run(conversationId, String(contactId), safeName, defaultMode, timestamp, timestamp);
     this.db.prepare('INSERT INTO customer_profiles(conversation_id,updated_at) VALUES(?,?)').run(conversationId, timestamp);
     return this.getConversation(conversationId);
   }
@@ -544,7 +579,7 @@ class AgentWorkbenchDb {
   }
 
   setConversationMode(conversationId, mode) {
-    if (!['review', 'auto', 'human', 'paused'].includes(mode)) throw new Error('不支持的会话模式');
+    if (!CONVERSATION_MODES.includes(mode)) throw new Error('不支持的会话模式');
     const result = this.db.prepare('UPDATE conversations SET mode=?,updated_at=? WHERE id=?').run(mode, now(), conversationId);
     if (!result.changes) throw new Error('会话不存在');
     return this.getConversation(conversationId);
@@ -1101,13 +1136,15 @@ class AgentWorkbenchDb {
 
   latestAgentOutcome(conversationId) {
     const row = this.db.prepare(`SELECT * FROM audit_logs
-      WHERE conversation_id=? AND action IN ('draft_created','agent_failed','agent_not_configured','agent_no_reply_needed')
+      WHERE conversation_id=? AND action IN ('draft_created','autopilot_message_sent','autopilot_send_failed','agent_failed','agent_not_configured','agent_no_reply_needed')
       ORDER BY created_at DESC LIMIT 1`).get(conversationId);
     if (!row) return null;
     const detail = parse(row.detail_json, {});
     return {
       action: row.action,
-      message: detail.message || (row.action === 'agent_no_reply_needed' ? '客户消息无需回复' : 'Agent 上游不可用'),
+      message: detail.message || (row.action === 'agent_no_reply_needed'
+        ? '客户消息无需回复'
+        : row.action === 'autopilot_message_sent' ? '全自动接管已直接发送 Agent 回复' : 'Agent 上游不可用'),
       entityId: row.entity_id,
       createdAt: row.created_at,
     };
@@ -1115,7 +1152,7 @@ class AgentWorkbenchDb {
 
   latestAgentState(conversationId) {
     const outcome = this.latestAgentOutcome(conversationId);
-    if (!outcome || ['draft_created', 'agent_no_reply_needed'].includes(outcome.action)) return null;
+    if (!outcome || ['draft_created', 'autopilot_message_sent', 'agent_no_reply_needed'].includes(outcome.action)) return null;
     return outcome;
   }
 

+ 71 - 4
mcp/src/core/agent-workbench-service.js

@@ -164,14 +164,14 @@ class AgentWorkbenchService extends EventEmitter {
       audit: this.db.listAudit(200, conversationId),
       onboarding: this.db.getOnboarding(this.intakeAccountKey(), conversation.contact_id),
       agentOutcome,
-      agentError: ['agent_failed', 'agent_not_configured'].includes(agentOutcome?.action) ? agentOutcome : null,
+      agentError: ['agent_failed', 'agent_not_configured', 'autopilot_send_failed'].includes(agentOutcome?.action) ? agentOutcome : null,
     };
   }
 
   setGlobal({ paused, defaultMode, autoSendConfidence }, actor = 'human') {
     if (paused !== undefined) this.db.setSetting('global_paused', paused ? 'true' : 'false');
     if (defaultMode !== undefined) {
-      if (!['review', 'auto', 'human', 'paused'].includes(defaultMode)) throw new Error('不支持的默认模式');
+      if (!['review', 'auto', 'autopilot', 'human', 'paused'].includes(defaultMode)) throw new Error('不支持的默认模式');
       this.db.setSetting('default_mode', defaultMode);
     }
     if (autoSendConfidence !== undefined) {
@@ -416,6 +416,13 @@ class AgentWorkbenchService extends EventEmitter {
           },
         });
       }
+      if (options.allowAutoSend !== false && conversation.mode === 'autopilot') {
+        return await this.sendAutopilotReply(conversation, inboundMessage, output, {
+          profile: updatedProfile,
+          tasks: customerTasks,
+          alerts: customerAlerts,
+        });
+      }
       const draft = this.db.createDraft({
         conversationId: conversation.id,
         inboundMessageId: inboundMessage.id,
@@ -455,7 +462,7 @@ class AgentWorkbenchService extends EventEmitter {
       };
     } catch (error) {
       this.queueMemoryExtraction(conversation, inboundMessage);
-      const action = error instanceof AgentNotConfiguredError ? 'agent_not_configured' : 'agent_failed';
+      const action = error.agentAction || (error instanceof AgentNotConfiguredError ? 'agent_not_configured' : 'agent_failed');
       const friendly = friendlyAgentError(error);
       this.db.audit({
         actor: 'agent',
@@ -464,11 +471,71 @@ class AgentWorkbenchService extends EventEmitter {
         entityId: inboundMessage.id,
         detail: { message: friendly.message, errorCode: friendly.code, rawMessage: error.message },
       });
-      this.emit('change', { type: 'agent_error', conversationId: conversation.id });
+      this.emit('change', { type: action === 'autopilot_send_failed' ? 'send_error' : 'agent_error', conversationId: conversation.id });
       return { status: action, error: friendly.message, errorCode: friendly.code, conversation, message: inboundMessage };
     }
   }
 
+  async sendAutopilotReply(conversation, inboundMessage, output, intelligence = {}) {
+    this.requireAllowed(conversation.contact_id);
+    this.requirePrivateConversation(conversation.id);
+    const content = String(output.content || '').trim();
+    if (!content) throw new Error('Agent 没有生成可发送的回复内容');
+    if (content.length > 2000) throw new Error('回复内容过长');
+    try {
+      const result = await this.qiwei.sendText(conversation.contact_id, content);
+      const outbound = this.db.insertMessage({
+        conversationId: conversation.id,
+        direction: 'outbound',
+        senderType: 'agent',
+        content,
+        status: result.isSendSuccess === false ? 'submitted' : 'sent',
+        raw: {
+          automationMode: 'autopilot',
+          inboundMessageId: inboundMessage.id,
+          confidence: Number(output.confidence) || 0,
+          requiresHuman: Boolean(output.requiresHuman),
+          citations: output.citations || [],
+          toolTrace: output.toolTrace || [],
+        },
+      }).message;
+      appendChatRecord({
+        wxid: conversation.contact_id,
+        messageId: outbound.id,
+        externalId: null,
+        dir: 'out',
+        senderType: 'agent',
+        content,
+        createdAt: outbound.created_at,
+        source: 'autopilot',
+      });
+      this.db.audit({
+        actor: 'agent:autopilot',
+        action: 'autopilot_message_sent',
+        conversationId: conversation.id,
+        entityId: inboundMessage.id,
+        detail: {
+          inboundMessageId: inboundMessage.id,
+          outboundMessageId: outbound.id,
+          confidence: Number(output.confidence) || 0,
+          requiresHuman: Boolean(output.requiresHuman),
+          draftCreated: false,
+        },
+      });
+      this.emit('change', { type: 'message', conversationId: conversation.id });
+      return {
+        status: 'autopilot_sent',
+        conversation,
+        message: inboundMessage,
+        reply: outbound,
+        intelligence,
+      };
+    } catch (error) {
+      error.agentAction = 'autopilot_send_failed';
+      throw error;
+    }
+  }
+
   async approveDraft(draftId, { content = '', actor = 'human' } = {}) {
     const draft = this.db.getDraft(draftId);
     if (!draft) throw new Error('回复草稿不存在');

+ 55 - 4
mcp/src/core/credentials.js

@@ -116,16 +116,65 @@ function readFmodeConfig() {
 
 function pickFmodeAnthropicToken(env) {
   const token = env && typeof env.ANTHROPIC_AUTH_TOKEN === 'string' ? env.ANTHROPIC_AUTH_TOKEN.trim() : '';
-  if (!token || !/^sk-/i.test(token) || /^sk-ant-/i.test(token)) return '';
   const base = String((env && (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_BASE)) || '').toLowerCase();
-  if (base && !base.includes('fmode')) return '';
-  return token;
+  return pickFmodeApiToken(token, base);
 }
 
 function normalizeToken(value) {
   return String(value || '').trim().replace(/^Bearer\s+/i, '');
 }
 
+function pickFmodeApiToken(value, apiBase = '') {
+  const token = normalizeToken(value);
+  if (!/^sk-/i.test(token) || /^sk-ant-/i.test(token)) return '';
+  const base = String(apiBase || '').trim().toLowerCase();
+  if (base && !base.includes('fmode')) return '';
+  return token;
+}
+
+function readFmodeVoiceToken(input = {}, sourceOverrides = {}) {
+  const processEnv = sourceOverrides.processEnv || process.env;
+  const fileEnv = sourceOverrides.fileEnv || readEnvFiles();
+  const claudeEnv = sourceOverrides.claudeEnv || readClaudeSettingsEnv();
+  const fmodeConfig = sourceOverrides.fmodeConfig || readFmodeConfig();
+  const voiceBase = firstNonEmpty([
+    input.voiceApiBase,
+    input.endpoint,
+    processEnv.QIWEI_VOICE_ENDPOINT,
+    fileEnv.QIWEI_VOICE_ENDPOINT,
+    'https://server.fmode.cn/api/voice/indextts2'
+  ]);
+  const fmodeBase = firstNonEmpty([
+    input.fmodeApiBase,
+    processEnv.FMODE_API_BASE,
+    fileEnv.FMODE_API_BASE,
+    fmodeConfig.apiBase,
+    fmodeConfig.llmBaseUrl,
+    'https://api.fmode.cn'
+  ]);
+  return firstNonEmpty([
+    pickFmodeApiToken(input.voiceAuthToken || input.authToken, voiceBase),
+    pickFmodeApiToken(processEnv.QIWEI_VOICE_AUTH_TOKEN, voiceBase),
+    pickFmodeApiToken(fileEnv.QIWEI_VOICE_AUTH_TOKEN, voiceBase),
+    pickFmodeApiToken(input.fmodeApiToken || input.fmodeApiKey || input.newapiToken, fmodeBase),
+    pickFmodeApiToken(processEnv.FMODE_API_TOKEN, fmodeBase),
+    pickFmodeApiToken(processEnv.FMODE_API_KEY, fmodeBase),
+    pickFmodeApiToken(processEnv.NEWAPI_TOKEN, fmodeBase),
+    pickFmodeApiToken(fileEnv.FMODE_API_TOKEN, fmodeBase),
+    pickFmodeApiToken(fileEnv.FMODE_API_KEY, fmodeBase),
+    pickFmodeApiToken(fileEnv.NEWAPI_TOKEN, fmodeBase),
+    pickFmodeApiToken(fmodeConfig.fmodeApiToken, fmodeBase),
+    pickFmodeApiToken(fmodeConfig.fmodeApiKey, fmodeBase),
+    pickFmodeApiToken(fmodeConfig.newapiToken, fmodeBase),
+    pickFmodeApiToken(fmodeConfig.newApiToken, fmodeBase),
+    pickFmodeApiToken(claudeEnv.FMODE_API_TOKEN, fmodeBase),
+    pickFmodeApiToken(claudeEnv.FMODE_API_KEY, fmodeBase),
+    pickFmodeApiToken(claudeEnv.NEWAPI_TOKEN, fmodeBase),
+    pickFmodeAnthropicToken(processEnv),
+    pickFmodeAnthropicToken(claudeEnv)
+  ]);
+}
+
 function readQiweiAuthToken(input = {}) {
   const fileEnv = readEnvFiles();
   const claudeEnv = readClaudeSettingsEnv();
@@ -332,6 +381,7 @@ module.exports = {
   DEFAULT_API_BASE,
   CREDENTIALS_FILE,
   readQiweiAuthToken,
+  readFmodeVoiceToken,
   readQiweiUid,
   readQiweiGuid,
   readFmodeApiKey,
@@ -346,5 +396,6 @@ module.exports = {
   readEnvFiles,
   readClaudeSettingsEnv,
   readFmodeConfig,
-  pickFmodeAnthropicToken
+  pickFmodeAnthropicToken,
+  pickFmodeApiToken
 };

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

@@ -10,6 +10,7 @@ const Ffprobe = require('@ffprobe-installer/ffprobe');
 const WxVoiceModule = require('@binsee/wx-voice');
 const WxVoice = WxVoiceModule.WxVoice || WxVoiceModule.default || WxVoiceModule;
 const { categoryDir, createRunDir, writeRunManifest } = require('./output-paths');
+const { pickFmodeApiToken } = require('./credentials');
 
 const TONE_PRESETS = Object.freeze({
   natural: Object.freeze({ id: 'natural', label: '自然', method: 0, alpha: null, text: '' }),
@@ -155,9 +156,10 @@ function extensionFor(name, mime = '') {
 
 class VoiceCloneService {
   constructor({ config = {}, qiwei }) {
+    const endpoint = String(config.endpoint || 'https://server.fmode.cn/api/voice/indextts2').trim();
     this.config = {
-      endpoint: String(config.endpoint || 'https://server.fmode.cn/api/voice/indextts2').trim(),
-      authToken: String(config.authToken || '').trim().replace(/^Bearer\s+/i, ''),
+      endpoint,
+      authToken: pickFmodeApiToken(config.authToken, endpoint),
       model: String(config.model || 'fmode-voice').trim(),
       requestTimeoutMs: Math.max(15000, Math.min(300000, Number(config.requestTimeoutMs) || 180000)),
     };
@@ -190,9 +192,8 @@ class VoiceCloneService {
 
   status() {
     const metadata = this.readMetadata();
-    const authToken = this.config.authToken || String(this.qiwei?.context?.()?.token || '').trim();
     return {
-      configured: Boolean(authToken && this.config.endpoint),
+      configured: Boolean(this.config.authToken && this.config.endpoint),
       provider: 'fmode-voice',
       model: this.config.model,
       enrolled: Boolean(metadata && fs.existsSync(this.profilePath())),
@@ -260,8 +261,8 @@ class VoiceCloneService {
   }
 
   requireReady() {
-    const authToken = this.config.authToken || String(this.qiwei?.context?.()?.token || '').trim();
-    if (!authToken) throw new Error('缺少 Fmode 鉴权,请先在 Fmode Studio 中完成登录');
+    const authToken = this.config.authToken;
+    if (!authToken) throw new Error('缺少 Fmode API Token,请前往 https://api.fmode.cn/keys/ 创建并配置 QIWEI_VOICE_AUTH_TOKEN');
     if (!fs.existsSync(this.profilePath())) throw new Error('尚未初始化本人声音,请先录制或上传参考音频');
     return authToken;
   }

+ 24 - 12
mcp/src/dashboard/agent-service.js

@@ -17,7 +17,7 @@ const {
 const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
 const { messageTimestamp, roomIdOf, isGroupMessage, messageContent, evaluatePolledMessage, normalizePersonalIntakeMode } = require('../core/agent-poller-policy');
 const { sortConversationsByRecency, maxConversationTimestamp } = require('../core/conversation-order');
-const { saveQiweiClientConfig, setActiveQiweiContext } = require('../core/credentials');
+const { saveQiweiClientConfig, setActiveQiweiContext, readFmodeVoiceToken } = require('../core/credentials');
 const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
 const { responseMonitor } = require('./response-monitor-service');
 const { normalizeAllowlistIds, normalizeAllowlistContact } = require('../core/allowlist-config');
@@ -28,6 +28,12 @@ const PROJECT_ROOT = WORKSPACE_ROOT;
 const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
 const INTAKE_AUTOPILOT_CONFIRMATION = 'ENABLE_AUTO_ENROLL_AUTOPILOT';
 const INTAKE_AUTOPILOT_CONFIRMATION_VERSION = 'v1';
+const AUTOPILOT_CONFIRMATION = 'ENABLE_AUTOPILOT';
+
+function requireAutopilotConfirmation(mode, confirmation, scope = 'global') {
+  if (mode !== 'autopilot' || confirmation === AUTOPILOT_CONFIRMATION) return;
+  throw new Error(scope === 'conversation' ? '开启会话全自动接管需要二次确认' : '开启全自动接管需要二次确认');
+}
 
 function readEnvFile(filePath) {
   try {
@@ -178,7 +184,7 @@ function loadAgentConfig(overrides = {}) {
       welcomeEnabled: bool('QIWEI_WELCOME_ENABLED', false),
       welcomeText: value('QIWEI_WELCOME_TEXT', '您好,已经收到您的消息,我会尽快为您处理。'),
       welcomeSendMode: value('QIWEI_WELCOME_SEND_MODE', 'draft') === 'send' ? 'send' : 'draft',
-      effectiveAutopilotMode: 'auto',
+      effectiveAutopilotMode: 'autopilot',
     },
     knowledgeDir: resolvePath(
       value('QIWEI_AGENT_KNOWLEDGE_DIR'),
@@ -231,7 +237,7 @@ function loadAgentConfig(overrides = {}) {
       manualAllowedSenders: [...manualAllowedSenders],
       autoEnrolledSenders: [],
       accountKey: '',
-      intakeAutopilotConversationMode: 'auto',
+      intakeAutopilotConversationMode: 'autopilot',
       selfUserId: value('QIWEI_AUTO_REPLY_SELF_USER_ID'),
       intervalMs: number('QIWEI_AUTO_REPLY_INTERVAL_MS', 10000, 3000, 60000),
       initialSyncLimit: number('QIWEI_AGENT_INITIAL_SYNC_LIMIT', 5000, 100, 5000),
@@ -243,7 +249,10 @@ function loadAgentConfig(overrides = {}) {
     },
     voice: {
       endpoint: value('QIWEI_VOICE_ENDPOINT', 'https://server.fmode.cn/api/voice/indextts2'),
-      authToken: value('QIWEI_VOICE_AUTH_TOKEN'),
+      authToken: readFmodeVoiceToken({
+        voiceAuthToken: value('QIWEI_VOICE_AUTH_TOKEN'),
+        endpoint: value('QIWEI_VOICE_ENDPOINT', 'https://server.fmode.cn/api/voice/indextts2'),
+      }),
       model: 'fmode-voice',
       requestTimeoutMs: number('QIWEI_TTS_TIMEOUT_MS', 180000, 15000, 300000),
     },
@@ -543,7 +552,7 @@ async function ingestMessageForWorkbench(target, message = {}, source = 'callbac
     target.config.autoEnrolledSenders = autoIds;
     target.config.allowedSenders = normalizeAllowlistIds([...(target.config.manualAllowedSenders || []), ...autoIds]);
     const conversation = target.db.ensureConversation(senderId, message.senderName || '企微客户');
-    const effectiveMode = intakePolicy.mode === 'auto_enroll_review' ? 'review' : 'auto';
+    const effectiveMode = intakePolicy.mode === 'auto_enroll_review' ? 'review' : 'autopilot';
     target.service.setConversationMode(conversation.id, effectiveMode, 'policy:auto-enroll');
     const welcomeState = !intakePolicy.welcomeEnabled
       ? 'skipped'
@@ -900,7 +909,7 @@ function publicConversation(row) {
   const pending = currentDrafts.find(item => item.status === 'pending') || null;
   const latestDraft = pending || currentDrafts.find(item => ['sent', 'approved'].includes(item.status)) || null;
   const currentAgentOutcome = latestInbound && detail.agentOutcome?.entityId === latestInbound.id ? detail.agentOutcome : null;
-  const agentError = ['agent_failed', 'agent_not_configured'].includes(currentAgentOutcome?.action)
+  const agentError = ['agent_failed', 'agent_not_configured', 'autopilot_send_failed'].includes(currentAgentOutcome?.action)
     ? { ...currentAgentOutcome, ...friendlyAgentError(currentAgentOutcome.message) }
     : null;
   const agentNotice = currentAgentOutcome?.action === 'agent_no_reply_needed' ? currentAgentOutcome : null;
@@ -1213,7 +1222,7 @@ function intakePolicyPayload(target = workbench) {
     welcomeEnabled: policy.welcomeEnabled,
     welcomeText: policy.welcomeText,
     welcomeSendMode: policy.welcomeSendMode,
-    effectiveConversationMode: policy.mode === 'auto_enroll_autopilot' ? 'auto' : 'review',
+    effectiveConversationMode: policy.mode === 'auto_enroll_autopilot' ? 'autopilot' : 'review',
     autopilotConfirmed: policy.autopilotConfirmation === INTAKE_AUTOPILOT_CONFIRMATION_VERSION,
     onboarding: {
       total: onboardings.length,
@@ -1257,7 +1266,7 @@ function updateIntakePolicyForWorkbench(target, input = {}, actor = 'human') {
   target.db.audit({
     actor,
     action: 'personal_intake_policy_updated',
-    detail: { mode, welcomeEnabled, welcomeSendMode, effectiveConversationMode: 'auto' },
+    detail: { mode, welcomeEnabled, welcomeSendMode, effectiveConversationMode: mode === 'auto_enroll_autopilot' ? 'autopilot' : 'review' },
   });
   return intakePolicyPayload(target);
 }
@@ -1510,12 +1519,14 @@ async function syncConversations(input = {}) {
   };
 }
 
-function changeGlobalMode(mode) {
+function changeGlobalMode(mode, confirmation = '') {
   const selected = String(mode || '');
+  requireAutopilotConfirmation(selected, confirmation);
   let update;
   if (['paused', 'monitor'].includes(selected)) update = { paused: true };
   else if (['review', 'suggest'].includes(selected)) update = { paused: false, defaultMode: 'review' };
   else if (selected === 'auto') update = { paused: false, defaultMode: 'auto' };
+  else if (selected === 'autopilot') update = { paused: false, defaultMode: 'autopilot' };
   else if (selected === 'human') update = { paused: false, defaultMode: 'human' };
   else throw new Error('不支持的 Agent 模式');
   workbench.db.setSetting('listener_enabled', selected === 'paused' ? 'false' : 'true');
@@ -1527,10 +1538,11 @@ function changeGlobalMode(mode) {
   };
 }
 
-function changeConversationMode(id, mode) {
+function changeConversationMode(id, mode, confirmation = '') {
   const mapped = mode === 'agent' ? 'review' : mode === 'manual' ? 'human' : mode;
+  requireAutopilotConfirmation(mapped, confirmation, 'conversation');
   const conversation = workbench.service.setConversationMode(id, mapped);
-  const labels = { review: '待审核', auto: '高置信自动', human: '人工接管', paused: '会话暂停' };
+  const labels = { review: '待审核', auto: '高置信自动', autopilot: '全自动接管', human: '人工接管', paused: '会话暂停' };
   return { status: 'ok', assistantMessage: `会话已切换为${labels[mapped]}`, data: { conversation } };
 }
 
@@ -1953,5 +1965,5 @@ module.exports = {
   stopListener,
   getAgentRuntimeConfig,
   createWorkbench,
-  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, hydrateAccountAllowlist, updateAllowlistForWorkbench, resolveConversationSyncScope, applyManualTakeover, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillCustomerMemory, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, startListenerForWorkbench, intakePolicyPayload, updateIntakePolicyForWorkbench },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, hydrateAccountAllowlist, updateAllowlistForWorkbench, resolveConversationSyncScope, applyManualTakeover, requireAutopilotConfirmation, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillCustomerMemory, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, startListenerForWorkbench, intakePolicyPayload, updateIntakePolicyForWorkbench },
 };

+ 34 - 19
mcp/src/dashboard/app.js

@@ -2463,7 +2463,7 @@
   }
 
   function agentModeLabel(mode) {
-    return ({ review: '待审核', auto: '高置信自动', human: '人工接管', paused: '会话暂停' })[mode] || mode || '待审核';
+    return ({ review: '待审核', auto: '高置信自动', autopilot: '全自动接管', human: '人工接管', paused: '会话暂停' })[mode] || mode || '待审核';
   }
 
   function formatReplyWaiting(minutes) {
@@ -3269,15 +3269,15 @@
         <label class="agent-field"><span>接入策略</span><select name="mode">
           <option value="allowlist_only" ${mode === 'allowlist_only' ? 'selected' : ''}>仅白名单(默认)</option>
           <option value="auto_enroll_review" ${mode === 'auto_enroll_review' ? 'selected' : ''}>新私聊自动纳入 · 强制待审核</option>
-          <option value="auto_enroll_autopilot" ${mode === 'auto_enroll_autopilot' ? 'selected' : ''}>新私聊自动纳入 · 受控自动</option>
+          <option value="auto_enroll_autopilot" ${mode === 'auto_enroll_autopilot' ? 'selected' : ''}>新私聊自动纳入 · 全自动接管</option>
         </select></label>
         <label class="agent-toggle-field"><input type="checkbox" name="welcomeEnabled" ${policy.welcomeEnabled ? 'checked' : ''}><span><strong>启用欢迎语</strong><small>默认只生成草稿,避免未经审核外发</small></span></label>
         <label class="agent-field"><span>欢迎语</span><textarea name="welcomeText" rows="4" placeholder="输入首次联系时的欢迎内容">${escapeHtml(policy.welcomeText || '')}</textarea></label>
         <label class="agent-field"><span>欢迎语处理</span><select name="welcomeSendMode">
           <option value="draft" ${sendMode === 'draft' ? 'selected' : ''}>生成待审草稿</option>
-          <option value="send" ${sendMode === 'send' ? 'selected' : ''}>直接发送(仅受控自动策略)</option>
+          <option value="send" ${sendMode === 'send' ? 'selected' : ''}>直接发送(仅全自动接管策略)</option>
         </select></label>
-        <div class="agent-policy-warning ${mode === 'auto_enroll_autopilot' ? 'danger' : ''}">${mode === 'auto_enroll_autopilot' ? '受控自动接入会把新私聊加入白名单并可直接发送欢迎语;后续回复仍受置信度与风险门禁。保存时必须再次确认。' : '通用默认仅处理人工选择的白名单联系人;待审核模式不会自动外发。'}</div>
+        <div class="agent-policy-warning ${mode === 'auto_enroll_autopilot' ? 'danger' : ''}">${mode === 'auto_enroll_autopilot' ? '全自动接入会把新私聊加入当前账号白名单,欢迎语和后续非空 Agent 回复都可能直接发送。保存时必须再次确认。' : '通用默认仅处理人工选择的白名单联系人;待审核模式不会自动外发。'}</div>
         <button class="btn" type="submit" ${state.agent.intakePolicyLoading ? 'disabled' : ''}>${state.agent.intakePolicyLoading ? '正在保存…' : '保存接入设置'}</button>
       </form>`;
   }
@@ -3337,6 +3337,7 @@
     const noReplyNotice = selected?.agentNotice || null;
     const activeDraft = pending;
     const manualActive = selected?.mode === 'human';
+    const autopilotActive = selected?.mode === 'autopilot';
     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));
@@ -3357,6 +3358,7 @@
         <div class="agent-mode-switch">
           <button class="agent-mode-btn ${!globalPaused && globalMode === 'review' ? 'active' : ''}" data-agent-mode="review">待审核</button>
           <button class="agent-mode-btn ${!globalPaused && globalMode === 'auto' ? 'active' : ''}" data-agent-mode="auto">高置信自动</button>
+          <button class="agent-mode-btn ${!globalPaused && globalMode === 'autopilot' ? 'active danger' : ''}" data-agent-mode="autopilot">全自动接管</button>
           <button class="agent-mode-btn ${globalPaused ? 'active danger' : ''}" data-agent-mode="paused">全局暂停</button>
         </div>
         <div class="agent-command-kpis" aria-label="工作台摘要"><span><b>${allowlistCount}</b> 白名单</span><span class="${pendingReplyCount ? 'attention' : ''}"><b>${pendingReplyCount}</b> 待审核</span><span class="${overdueCount ? 'danger' : ''}"><b>${reminderCount}</b> 待回复</span></div>
@@ -3432,7 +3434,8 @@
               <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 === 'auto' ? '' : 'btn-secondary'}" data-agent-conversation-mode="auto" data-id="${selected.id}">高置信自动</button>
+                <button class="btn btn-sm ${selected.mode === 'autopilot' ? 'agent-mode-danger active' : 'btn-secondary'}" data-agent-conversation-mode="autopilot" 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>
                 <button class="btn btn-sm ${selected.mode === 'paused' ? '' : 'btn-secondary'}" data-agent-conversation-mode="paused" data-id="${selected.id}">暂停</button>
               </div>
@@ -3444,14 +3447,14 @@
               <button class="btn btn-sm btn-secondary agent-session-action" data-agent-action="session-guide" data-id="${selected.id}">${sessionGuide?.ready ? '查看会话与打开方式' : '初始化 Session'}</button>
             </div>
             <div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
-            <div class="agent-composer ${manualActive ? 'manual' : ''}">
-              <div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span data-agent-edit-status>${pending ? (hasReplyEdit ? '已人工修改 · 批准后发送当前内容' : `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核,可直接编辑' : '可直接编辑后发送'}`) : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
-              <textarea id="agent-reply-editor" data-agent-edit-key="${escapeHtml(replyEditKey)}" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
-              ${voiceComposerToolbar({ selected, content: pendingText, draftId: pending?.id, disabled: conversationPaused })}
+            <div class="agent-composer ${manualActive ? 'manual' : autopilotActive ? 'autopilot' : ''}">
+              <div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : autopilotActive ? '全自动接管运行中' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span data-agent-edit-status>${pending ? (hasReplyEdit ? '已人工修改 · 批准后发送当前内容' : `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核,可直接编辑' : '可直接编辑后发送'}`) : manualActive ? 'Agent 不会生成或发送回复' : autopilotActive ? '新消息的非空 Agent 回复将直接发送,不创建待审核草稿' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
+              <textarea id="agent-reply-editor" data-agent-edit-key="${escapeHtml(replyEditKey)}" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : autopilotActive ? '全自动接管运行中…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused || (autopilotActive && !pending) ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
+              ${voiceComposerToolbar({ selected, content: pendingText, draftId: pending?.id, disabled: conversationPaused || (autopilotActive && !pending) })}
               <div class="agent-composer-actions">
                 <span>${pending ? escapeHtml(pending.reason || '请核对内容与依据后再发送') : `模型:${escapeHtml(agent.model || '未配置')} · ${agent.configured ? '已配置' : '未配置'}`}</span>
                 <div class="agent-review-actions">
-                  ${pending ? `<button class="btn btn-secondary" data-agent-action="reject" data-id="${selected.id}" data-draft-id="${pending.id}">驳回</button><button class="btn btn-secondary" data-agent-action="regenerate" data-id="${selected.id}" data-draft-id="${pending.id}">重新生成</button><button class="btn" data-agent-action="approve" data-id="${selected.id}" data-draft-id="${pending.id}">批准并发送</button>` : manualActive ? `<button class="btn" data-agent-action="manual-send" data-id="${selected.id}">人工发送</button>` : conversationPaused ? '' : `<button class="btn" data-agent-action="generate" data-id="${selected.id}">${noReplyNotice ? '重新处理' : '让 Agent 处理'}</button>`}
+                  ${pending ? `<button class="btn btn-secondary" data-agent-action="reject" data-id="${selected.id}" data-draft-id="${pending.id}">驳回</button><button class="btn btn-secondary" data-agent-action="regenerate" data-id="${selected.id}" data-draft-id="${pending.id}">重新生成</button><button class="btn" data-agent-action="approve" data-id="${selected.id}" data-draft-id="${pending.id}">批准并发送</button>` : manualActive ? `<button class="btn" data-agent-action="manual-send" data-id="${selected.id}">人工发送</button>` : conversationPaused || autopilotActive ? '' : `<button class="btn" data-agent-action="generate" data-id="${selected.id}">${noReplyNotice ? '重新处理' : '让 Agent 处理'}</button>`}
                 </div>
               </div>
             </div>
@@ -3515,7 +3518,7 @@
             <dl><div><dt>产品模式</dt><dd>${escapeHtml(product.label)}</dd></div><div><dt>消息接收</dt><dd>${messageIngressRunning ? '运行中' : '待恢复'}</dd></div><div><dt>默认模式</dt><dd>${escapeHtml(agentModeLabel(globalPaused ? 'paused' : globalMode))}</dd></div><div><dt>白名单</dt><dd>${allowlistCount} 人</dd></div></dl>
           </section>
           ${renderAgentIntakeSettings()}
-          <section class="agent-drawer-boundary"><strong>当前发送边界</strong><p>未知群聊始终拒绝;自动纳入联系人按当前账号隔离。通用包的受控自动模式仍受置信度与人工风险门禁。</p></section>
+          <section class="agent-drawer-boundary"><strong>当前发送边界</strong><p>${globalMode === 'autopilot' ? '全自动接管会直接发送白名单私聊中的非空 Agent 回复,不创建待审核草稿。' : '未知群聊始终拒绝;自动纳入联系人按当前账号隔离;高置信自动仍受置信度与人工风险门禁。'}</p></section>
         </div>
       </aside>
       <aside class="agent-drawer reminders ${state.agent.openDrawer === 'reminders' ? 'open' : ''}" aria-hidden="${state.agent.openDrawer !== 'reminders'}" aria-label="回复提醒">
@@ -3722,13 +3725,13 @@
         welcomeSendMode: String(formData.get('welcomeSendMode') || 'draft') === 'send' ? 'send' : 'draft',
       };
       if (policy.welcomeSendMode === 'send' && policy.mode !== 'auto_enroll_autopilot') {
-        toast('欢迎语直接发送仅可用于已明确确认的受控自动接入策略', 'error');
+        toast('欢迎语直接发送仅可用于已明确确认的全自动接管策略', 'error');
         return;
       }
-      if (policy.mode === 'auto_enroll_autopilot' && !await confirmModal('新私聊会自动加入白名单,欢迎语可以真实发送;后续回复仍受置信度与风险门禁。', {
-        title: '确认开启受控自动新联系人接入',
-        confirmText: '确认受控自动接入',
-        warning: '请仅在已确认业务范围和发送规则后开启。',
+      if (policy.mode === 'auto_enroll_autopilot' && !await confirmModal('新私聊会自动加入当前账号白名单,欢迎语和后续非空 Agent 回复可能直接真实发送。', {
+        title: '确认开启自动新联系人接入',
+        confirmText: '确认自动接入',
+        warning: '所有非空 Agent 回复都会直接发送,请确认当前账号的白名单范围。',
         danger: true,
       })) return;
       state.agent.intakePolicy = policy;
@@ -3880,8 +3883,14 @@
           warning: '符合条件的回复将不经人工审核直接发送。',
           danger: true,
         })) return;
+        if (mode === 'autopilot' && !await confirmModal(`全自动接管会向白名单中的 ${allowlistCount} 位联系人直接发送 Agent 回复,且不创建待审核草稿。`, {
+          title: '开启全自动接管',
+          confirmText: '确认开启全自动',
+          warning: '所有非空 Agent 回复都会直接发送,请确认当前白名单范围。',
+          danger: true,
+        })) return;
         try {
-          const result = await api('POST', '/api/agent/mode', { mode });
+          const result = await api('POST', '/api/agent/mode', { mode, ...(mode === 'autopilot' ? { confirmation: 'ENABLE_AUTOPILOT' } : {}) });
           toast(result.assistantMessage || '模式已更新');
           await loadAgentData(page);
         } catch (error) {
@@ -3900,10 +3909,16 @@
           warning: '符合条件的回复将不经人工审核直接发送给当前联系人。',
           danger: true,
         })) return;
-        const action = ({ review: 'resume', auto: 'auto', human: 'takeover', paused: 'pause' })[mode];
+        if (mode === 'autopilot' && !await confirmModal('该会话将进入全自动接管,Agent 回复会直接发送且不创建待审核草稿。', {
+          title: '开启会话全自动接管',
+          confirmText: '确认开启全自动',
+          warning: '所有非空 Agent 回复都会直接发送给当前联系人。',
+          danger: true,
+        })) return;
+        const action = ({ review: 'resume', auto: 'auto', autopilot: 'autopilot', human: 'takeover', paused: 'pause' })[mode];
         conversationModeButton.disabled = true;
         try {
-          const result = await api('POST', `/api/agent/conversations/${id}/${action}`, {});
+          const result = await api('POST', `/api/agent/conversations/${id}/${action}`, mode === 'autopilot' ? { confirmation: 'ENABLE_AUTOPILOT' } : {});
           toast(result.assistantMessage || '会话模式已更新');
           await loadAgentData(page);
         } catch (error) {

+ 3 - 2
mcp/src/dashboard/server.js

@@ -670,7 +670,7 @@ async function handleRequest(req, res) {
     }
     if (pathname === '/api/agent/mode' && req.method === 'POST') {
       const body = await readBody(req);
-      json(res, 200, changeGlobalMode(body.mode));
+      json(res, 200, changeGlobalMode(body.mode, body.confirmation));
       return;
     }
     if (pathname === '/api/agent/listener/start' && req.method === 'POST') {
@@ -709,7 +709,7 @@ async function handleRequest(req, res) {
       json(res, 200, updateCustomerMemory(decodeURIComponent(customerMemoryRoute[1]), await readBody(req)));
       return;
     }
-    const agentConversationRoute = pathname.match(/^\/api\/agent\/conversations\/([^/]+)\/(takeover|resume|pause|auto|approve-reply|generate|manual-send)$/);
+    const agentConversationRoute = pathname.match(/^\/api\/agent\/conversations\/([^/]+)\/(takeover|resume|pause|auto|autopilot|approve-reply|generate|manual-send)$/);
     if (agentConversationRoute && req.method === 'POST') {
       const [, conversationId, action] = agentConversationRoute;
       const body = await readBody(req);
@@ -717,6 +717,7 @@ async function handleRequest(req, res) {
       else if (action === 'resume') json(res, 200, changeConversationMode(conversationId, 'review'));
       else if (action === 'pause') json(res, 200, changeConversationMode(conversationId, 'paused'));
       else if (action === 'auto') json(res, 200, changeConversationMode(conversationId, 'auto'));
+      else if (action === 'autopilot') json(res, 200, changeConversationMode(conversationId, 'autopilot', body.confirmation));
       else if (action === 'generate') json(res, 200, await generateLatestDraft(conversationId));
       else if (action === 'manual-send') json(res, 200, await manualSend(conversationId, body.content));
       else json(res, 200, await approveReply(conversationId, body.content));

+ 3 - 0
mcp/src/dashboard/styles.css

@@ -1186,11 +1186,14 @@ html, body {
 }
 
 .agent-composer.manual { background: #fffaf4; }
+.agent-composer.autopilot { border-color: color-mix(in srgb, var(--agent-error) 30%, var(--agent-line)); background: var(--agent-error-soft); }
+.agent-composer.autopilot .agent-composer-label strong { color: var(--agent-error); }
 .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; }
+.agent-conversation-modes .agent-mode-danger.active { color: var(--agent-error); border-color: color-mix(in srgb, var(--agent-error) 35%, var(--agent-line)); background: var(--agent-error-soft); }
 .agent-composer-label span { color: var(--text-muted); font-size: 9px; }
 .agent-composer textarea { width: 100%; min-height: 76px; resize: vertical; padding: 10px 12px; border: 1px solid #dce5e1; border-radius: 10px; outline: 0; color: #34413c; background: #fbfdfc; font: inherit; font-size: 11px; line-height: 1.55; }
 .agent-composer textarea:focus { border-color: var(--agent-green); box-shadow: 0 0 0 3px rgba(19,122,91,.08); }

+ 5 - 2
mcp/src/server.js

@@ -1442,8 +1442,11 @@ function createServer() {
     'qiwei_agent_set_global',
     {
       title: '设置企微 Agent 全局策略',
-      description: '设置 paused(全局暂停)、review(生成待审核草稿)、auto(仅高置信且无需人工时自动发送)或 human(人工模式)。',
-      inputSchema: { mode: z.enum(['paused', 'review', 'auto', 'human']) }
+      description: '设置 paused(全局暂停)、review(生成待审核草稿)、auto(仅高置信且无需人工时自动发送)、autopilot(全自动接管,非空回复直接发送)或 human(人工模式)。autopilot 必须同时传 confirmation=ENABLE_AUTOPILOT。',
+      inputSchema: {
+        mode: z.enum(['paused', 'review', 'auto', 'autopilot', 'human']),
+        confirmation: z.string().optional(),
+      }
     },
     wrap(qiweiAgentSetGlobal)
   );

+ 1 - 1
mcp/src/tools/qiwei-agent-control-run.js

@@ -261,7 +261,7 @@ async function qiweiAgentListener(input = {}) {
 }
 
 async function qiweiAgentSetGlobal(input = {}) {
-  const result = agentService().changeGlobalMode(input.mode);
+  const result = agentService().changeGlobalMode(input.mode, input.confirmation);
   return okResult({ assistantMessage: result.assistantMessage, data: result.data });
 }
 

+ 5 - 1
mcp/src/tools/qiwei-voice-run.js

@@ -6,6 +6,7 @@ const net = require('net');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { createRunDir, outputsRoot, PACKAGE_ROOT } = require('../core/output-paths');
 const { VoiceCloneService } = require('../core/voice-clone-service');
+const { readFmodeVoiceToken } = require('../core/credentials');
 const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
 const {
   buildContext,
@@ -258,7 +259,10 @@ function voiceRuntime(input = {}) {
     qiwei,
     config: {
       endpoint: process.env.QIWEI_VOICE_ENDPOINT || env.QIWEI_VOICE_ENDPOINT,
-      authToken: input.authToken,
+      authToken: readFmodeVoiceToken({
+        voiceAuthToken: input.voiceAuthToken || input.authToken,
+        endpoint: process.env.QIWEI_VOICE_ENDPOINT || env.QIWEI_VOICE_ENDPOINT,
+      }),
       model: 'fmode-voice',
       requestTimeoutMs: process.env.QIWEI_TTS_TIMEOUT_MS || env.QIWEI_TTS_TIMEOUT_MS,
     },

+ 81 - 1
scripts/agent-console-smoke-test.js

@@ -32,7 +32,7 @@ const { AgentKnowledgeStore } = require('../mcp/src/core/agent-knowledge');
 const results = [];
 const PACKAGE_ROOT = path.resolve(__dirname, '..');
 
-function setup({ paused = false, defaultMode = 'review', agentRun } = {}) {
+function setup({ paused = false, defaultMode = 'review', agentRun, qiweiSend } = {}) {
   const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-smoke-'));
   const db = new AgentWorkbenchDb(path.join(dir, 'test.db'), {
     globalPaused: paused,
@@ -43,6 +43,7 @@ function setup({ paused = false, defaultMode = 'review', agentRun } = {}) {
   const qiwei = {
     isConfigured: () => true,
     async sendText(toId, content) {
+      if (qiweiSend) return qiweiSend(toId, content);
       sent.push({ toId, content });
       return { isSendSuccess: true };
     },
@@ -506,6 +507,82 @@ async function main() {
     } finally { ctx.close(); }
   });
 
+  await check('全自动接管直接发送回复且不创建待审核草稿', async () => {
+    const ctx = setup({
+      defaultMode: 'autopilot',
+      agentRun: async () => ({
+        content: '这条低置信回复也由全自动接管直接发送',
+        confidence: 0.12,
+        intent: 'autopilot_test',
+        reason: '全自动接管不经过草稿审核',
+        requiresHuman: true,
+        profileUpdates: {},
+        citations: [],
+        toolTrace: [],
+      }),
+    });
+    try {
+      const result = await ctx.service.ingestInbound({ externalId: 'm-autopilot', contactId: 'contact-1', contactName: '王刚', content: '全自动接管测试' });
+      assert.equal(result.status, 'autopilot_sent');
+      assert.deepEqual(ctx.sent, [{ toId: 'contact-1', content: '这条低置信回复也由全自动接管直接发送' }]);
+      assert.equal(ctx.db.listDrafts().length, 0);
+      assert.equal(ctx.db.listMessages(result.conversation.id).filter(item => item.direction === 'outbound').length, 1);
+      const outcome = ctx.db.latestAgentOutcome(result.conversation.id);
+      assert.equal(outcome.action, 'autopilot_message_sent');
+      assert.equal(outcome.entityId, result.message.id);
+    } finally { ctx.close(); }
+  });
+
+  await check('全自动接管发送失败保留失败审计且不创建草稿', async () => {
+    const ctx = setup({
+      defaultMode: 'autopilot',
+      qiweiSend: async () => { throw new Error('send failed'); },
+    });
+    try {
+      const result = await ctx.service.ingestInbound({ externalId: 'm-autopilot-failed', contactId: 'contact-1', contactName: '王刚', content: '失败审计测试' });
+      assert.equal(result.status, 'autopilot_send_failed');
+      assert.equal(ctx.db.listDrafts().length, 0);
+      assert.equal(ctx.db.latestAgentOutcome(result.conversation.id).action, 'autopilot_send_failed');
+    } finally { ctx.close(); }
+  });
+
+  await check('全局和单会话全自动接管都要求固定二次确认', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    assert.throws(() => __testing.requireAutopilotConfirmation('autopilot', ''), /二次确认/);
+    assert.throws(() => __testing.requireAutopilotConfirmation('autopilot', 'WRONG', 'conversation'), /会话全自动接管/);
+    assert.doesNotThrow(() => __testing.requireAutopilotConfirmation('autopilot', 'ENABLE_AUTOPILOT'));
+    assert.doesNotThrow(() => __testing.requireAutopilotConfirmation('auto', ''));
+  });
+
+  await check('旧会话数据库可幂等迁移到全自动接管模式', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-mode-migration-'));
+    const dbPath = path.join(dir, 'legacy.db');
+    try {
+      const raw = new DatabaseSync(dbPath);
+      raw.exec(`CREATE TABLE conversations (
+        id TEXT PRIMARY KEY,
+        contact_id TEXT NOT NULL UNIQUE,
+        contact_name TEXT NOT NULL DEFAULT '',
+        mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','human','paused')),
+        last_message_at TEXT,
+        created_at TEXT NOT NULL,
+        updated_at TEXT NOT NULL
+      );`);
+      const timestamp = new Date().toISOString();
+      raw.prepare('INSERT INTO conversations(id,contact_id,contact_name,mode,created_at,updated_at) VALUES(?,?,?,?,?,?)')
+        .run('legacy-conversation', 'legacy-contact', '历史客户', 'review', timestamp, timestamp);
+      raw.close();
+
+      const migrated = new AgentWorkbenchDb(dbPath, { defaultMode: 'review' });
+      try {
+        assert.equal(migrated.setConversationMode('legacy-conversation', 'autopilot').mode, 'autopilot');
+        const inserted = migrated.insertMessage({ conversationId: 'legacy-conversation', direction: 'inbound', senderType: 'customer', content: '迁移后消息' });
+        assert.equal(inserted.created, true);
+        assert.match(migrated.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='conversations'").get().sql, /autopilot/);
+      } finally { migrated.close(); }
+    } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+  });
+
   await check('全局暂停与人工接管都抑制 Agent', async () => {
     const ctx = setup({ paused: true });
     try {
@@ -838,6 +915,9 @@ async function main() {
       assert.match(prompt, /预算20万吧/);
       assert.doesNotMatch(prompt, /旧项目数据/);
       assert.doesNotMatch(prompt, /api服务/);
+      const autopilotPrompt = client.buildPrompt(messages, { conversation: { mode: 'autopilot' }, profile: { profile: {} } });
+      assert.match(autopilotPrompt, /全自动接管链路直接发送 reply/);
+      assert.doesNotMatch(autopilotPrompt, /只生成供 Dashboard 审核/);
       const budgetedClient = new ClaudeCodeClient({
         claudeSessionFile: path.join(dir, 'budgeted-sessions.json'),
         claudeWorkdir: dir,

+ 8 - 5
scripts/agent-intake-policy-smoke-test.js

@@ -32,10 +32,10 @@ function createContext({ accountKey = 'account-a', mode = 'allowlist_only', welc
   const sent = [];
   const config = {
     accountKey,
-    intake: { effectiveAutopilotMode: 'auto' },
+    intake: { effectiveAutopilotMode: 'autopilot' },
     memory: { enabled: false },
     agent: { apiKey: 'smoke', model: 'stub', provider: 'stub' },
-    qiwei: { accountKey, allowedSenders: [], manualAllowedSenders: [], autoEnrolledSenders: [], intakeAutopilotConversationMode: 'auto' },
+    qiwei: { accountKey, allowedSenders: [], manualAllowedSenders: [], autoEnrolledSenders: [], intakeAutopilotConversationMode: 'autopilot' },
   };
   const qiwei = {
     isConfigured: () => true,
@@ -108,13 +108,13 @@ async function main() {
     } finally { ctx.close(); }
   });
 
-  await check('generic automatic intake requires confirmation and maps to auto mode', async () => {
+  await check('generic automatic intake requires confirmation and maps to full autopilot mode', async () => {
     const ctx = createContext();
     try {
       assert.throws(() => __testing.updateIntakePolicyForWorkbench(ctx, { mode: 'auto_enroll_autopilot', welcomeEnabled: true, welcomeText: '欢迎', welcomeSendMode: 'send' }), /二次确认/);
       const policy = __testing.updateIntakePolicyForWorkbench(ctx, { mode: 'auto_enroll_autopilot', welcomeEnabled: true, welcomeText: '欢迎', welcomeSendMode: 'send', confirmation: 'ENABLE_AUTO_ENROLL_AUTOPILOT' }, 'smoke-admin');
       assert.equal(policy.autopilotConfirmed, true);
-      assert.equal(policy.effectiveConversationMode, 'auto');
+      assert.equal(policy.effectiveConversationMode, 'autopilot');
     } finally { ctx.close(); }
   });
 
@@ -123,12 +123,15 @@ async function main() {
     try {
       const first = await __testing.ingestMessageForWorkbench(ctx.target, inbound('send-contact'), 'smoke');
       assert.equal(first.status, 'welcome_send_failed');
-      assert.equal(ctx.db.getConversationByContactId('send-contact').mode, 'auto');
+      assert.equal(ctx.db.getConversationByContactId('send-contact').mode, 'autopilot');
       assert.equal(ctx.db.getOnboarding('account-a', 'send-contact').attempt_count, 1);
       ctx.setSendSuccess(true);
       assert.equal((await ctx.service.retryOnboardingWelcome('send-contact', 'smoke-admin')).status, 'welcome_sent');
       assert.equal(ctx.db.getOnboarding('account-a', 'send-contact').attempt_count, 2);
       await assert.rejects(() => ctx.service.retryOnboardingWelcome('send-contact', 'smoke-admin'), /明确发送失败/);
+      const next = await __testing.ingestMessageForWorkbench(ctx.target, inbound('send-contact', { content: '继续咨询' }), 'smoke-next');
+      assert.equal(next.status, 'autopilot_sent');
+      assert.equal(ctx.db.listDrafts({ conversationId: next.conversation.id }).length, 0);
     } finally { ctx.close(); }
   });
 

+ 12 - 0
scripts/agent-workspace-ui-smoke-test.js

@@ -96,4 +96,16 @@ check('intake settings use the fixed API contract and explicit confirmation', ()
   assert.match(app, /ENABLE_AUTO_ENROLL_AUTOPILOT/);
 });
 
+check('private chat exposes confirmed full autopilot separately from high-confidence auto', () => {
+  const server = fs.readFileSync(path.join(root, 'mcp', 'src', 'dashboard', 'server.js'), 'utf8');
+  assert.match(app, /data-agent-mode="autopilot">全自动接管/);
+  assert.match(app, /data-agent-conversation-mode="autopilot"/);
+  assert.match(app, /全自动接管会向白名单中的/);
+  assert.match(app, /该会话将进入全自动接管/);
+  assert.match(app, /confirmation: 'ENABLE_AUTOPILOT'/);
+  assert.match(app, /新消息的非空 Agent 回复将直接发送,不创建待审核草稿/);
+  assert.match(styles, /\.agent-composer\.autopilot/);
+  assert.match(server, /takeover\|resume\|pause\|auto\|autopilot/);
+});
+
 console.log(JSON.stringify({ status: 'ok', passed: results.length, results }, null, 2));

+ 28 - 2
scripts/voice-clone-smoke-test.js

@@ -16,6 +16,7 @@ const {
 } = require('../mcp/src/core/voice-clone-service');
 const { __testing: voiceToolTesting } = require('../mcp/src/tools/qiwei-voice-run');
 const { FmodeQiweiClient } = require('../mcp/src/providers/fmode-agent-transport');
+const { readFmodeVoiceToken, readQiweiAuthToken } = require('../mcp/src/core/credentials');
 
 function wavBuffer({ duration = 1, sampleRate = 16000, frequency = 220 } = {}) {
   const samples = Math.floor(duration * sampleRate);
@@ -41,6 +42,29 @@ function wavBuffer({ duration = 1, sampleRate = 16000, frequency = 220 } = {}) {
 }
 
 async function main() {
+  const emptySources = { processEnv: {}, fileEnv: {}, claudeEnv: {}, fmodeConfig: {} };
+  assert.equal(readFmodeVoiceToken(
+    { authToken: 'parse-session-token' },
+    { ...emptySources, processEnv: { FMODE_API_TOKEN: 'sk-fmode-voice' } },
+  ), 'sk-fmode-voice');
+  assert.equal(readFmodeVoiceToken({ authToken: 'parse-session-token' }, emptySources), '');
+  assert.equal(readFmodeVoiceToken(
+    {},
+    { ...emptySources, processEnv: { ANTHROPIC_AUTH_TOKEN: 'sk-ant-upstream', ANTHROPIC_BASE_URL: 'https://api.anthropic.com' } },
+  ), '');
+  assert.equal(readFmodeVoiceToken(
+    {},
+    { ...emptySources, processEnv: { FMODE_API_TOKEN: 'sk-other-provider', FMODE_API_BASE: 'https://api.openai.com' } },
+  ), '');
+  assert.equal(readQiweiAuthToken({ authToken: 'parse-session-token' }), 'parse-session-token');
+
+  const sessionOnlyVoice = new VoiceCloneService({
+    config: { authToken: 'parse-session-token' },
+    qiwei: { context: () => ({ token: 'parse-session-token' }) },
+  });
+  assert.equal(sessionOnlyVoice.status().configured, false);
+  assert.throws(() => sessionOnlyVoice.requireReady(), /https:\/\/api\.fmode\.cn\/keys\//);
+
   assert.equal(inferVoiceTone('非常抱歉给您带来了不好的体验').id, 'apology');
   assert.equal(inferVoiceTone('恭喜,您的申请已经审核通过').id, 'friendly');
   assert.equal(inferVoiceTone('提醒您,请于明天下午前提交资料').id, 'reminder');
@@ -62,7 +86,7 @@ async function main() {
   const calls = [];
   let sendSuccess = true;
   const qiwei = {
-    context: () => ({ token: 'fmode-smoke-token', uid: 'account-smoke', guid: 'guid-smoke' }),
+    context: () => ({ token: 'parse-session-token', uid: 'account-smoke', guid: 'guid-smoke' }),
     isConfigured: () => true,
     async uploadVoiceFile(filePath) {
       calls.push({ type: 'upload', filePath });
@@ -78,6 +102,7 @@ async function main() {
     qiwei,
     config: {
       endpoint: 'https://server.fmode.cn/api/voice/indextts2',
+      authToken: 'sk-fmode-smoke-token',
       model: 'fmode-voice',
     },
   });
@@ -95,7 +120,7 @@ async function main() {
   const generated = wavBuffer({ duration: 1.2, sampleRate: 22050, frequency: 330 });
   global.fetch = async (url, options = {}) => {
     assert.equal(url, 'https://server.fmode.cn/api/voice/indextts2');
-    assert.equal(options.headers.Authorization, 'Bearer fmode-smoke-token');
+    assert.equal(options.headers.Authorization, 'Bearer sk-fmode-smoke-token');
     const payload = JSON.parse(options.body.get('payload'));
     assert.equal(payload.use_random, false);
     assert.equal([0, 3].includes(payload.emo_control_method), true);
@@ -149,6 +174,7 @@ async function main() {
     guid: 'upload-guid',
     apiBase: 'https://server.fmode.cn/api/qiwei',
   });
+  assert.equal(uploadClient.context().token, 'fmode-upload-token');
   const silkPath = path.join(root, 'upload.silk');
   fs.writeFileSync(silkPath, Buffer.from('silk-smoke'));
   global.fetch = async (url, options = {}) => {

+ 3 - 2
skills/qiwei-dashboard/SKILL.md

@@ -54,7 +54,7 @@ node .claude/plugins/qiwei-assistant/install.js preview .
 1. `qiwei_agent_bind_controller`:自动检测失败时显式绑定项目主控 Session;
 2. `qiwei_agent_status`:读取账号、监听、Agent 和会话状态;
 3. `qiwei_agent_listener`:个人版控制本地监听;企业版回调自动常驻,该工具只返回回调状态;
-4. `qiwei_agent_set_global`:设置 paused、review、auto 或 human;
+4. `qiwei_agent_set_global`:设置 paused、review、auto、autopilot 或 human;其中 `auto` 仅发送高置信且无需人工的回复,`autopilot` 为经二次确认后直接发送非空 Agent 回复的全自动接管,调用时需同时传 `confirmation=ENABLE_AUTOPILOT`;
 5. `qiwei_agent_list_conversations`:读取客户会话和待审核草稿;
 6. `qiwei_agent_inbox`:读取前端、监听器、Agent 与人工操作事件;
 7. `qiwei_agent_generate_draft`:让客户专属 Claude Code Session 生成待审核草稿,不发送;
@@ -77,7 +77,8 @@ node .claude/plugins/qiwei-assistant/install.js preview .
 - 手动白名单、自动纳入名单和监听开关都按当前企微账号保存在独立 Workbench DB;扫码切换账号时不得广播到其他已打开账号。当前账号运行时白名单等于该账号的手动名单与自动纳入名单并集。
 - `allowlist_only` 是默认策略。未知私聊不创建会话、不写客户消息;未知群聊始终不自动纳入。
 - `auto_enroll_review` 只对新私聊生效,自动写入当前账号白名单并强制会话为 `review`,不继承全局自动模式。
-- `auto_enroll_autopilot` 仅在管理员输入固定确认词 `ENABLE_AUTO_ENROLL_AUTOPILOT` 后启用;通用版的新会话映射为受控 `auto`,继续经过置信度与风险门槛。
+- `auto_enroll_autopilot` 仅在管理员输入固定确认词 `ENABLE_AUTO_ENROLL_AUTOPILOT` 后启用;新会话映射为 `autopilot`,非空 Agent 回复直接发送且不创建待审核草稿。
+- `auto` 与 `autopilot` 是两个独立模式:前者保留置信度与人工风险门槛,后者只允许当前账号白名单私聊,并要求全局或单会话显式危险确认。
 - 欢迎语设置字段固定为 `welcomeEnabled`、`welcomeText`、`welcomeSendMode=draft|send`,默认生成草稿。欢迎状态按账号和联系人幂等;发送失败可人工重试,进程中断留下的 `delivery_unknown` 先人工核对,不自动重复发送。
 
 ## 项目主控与客户会话

+ 1 - 1
todolist/2026-08-04-dashboard-gray-overlay-port.md

@@ -27,7 +27,7 @@
 
 ## 验收
 
-- [x] 三份 `agent-workspace-ui-smoke-test.js` 均为 10/10。
+- [x] 两份通用源 `agent-workspace-ui-smoke-test.js` 为 11/11(含全自动接管 UI 契约);业务安装副本的灰屏专项保持既有 10/10。
 - [x] 关闭态:遮罩透明、抽屉隐藏、页面可交互。
 - [x] 打开态:遮罩和抽屉立即显示,不受实时数据刷新影响。
 - [x] 再次关闭:遮罩恢复透明、抽屉隐藏。

+ 30 - 23
todolist/2026-08-04-webui-evolva-optimization.md

@@ -72,37 +72,37 @@
 
 ### P0:工作台布局、排序和主题
 
-- 压缩顶部为 sticky 命令栏,只保留账号、在线/监听状态、全局模式、白名单数、待回复数和关键操作。
-- 将设置和提醒移入抽屉,三栏工作台使用 `100dvh`/`calc()` 占满剩余视口并独立滚动。
-- 私聊和群聊都采用最新消息降序、空时间末尾、相同时间稳定的规则。
-- 未回复/超时仅作为状态标识,不改变最新消息主排序。
-- Agent 工作台改用品牌、页面、面板、文字、边框、focus、success、warning、error 等语义 token。
-- 默认 Fmode 蓝紫 + 中性灰,租户通过 `data-theme` 或变量覆盖;不机械重写全站颜色。
+- [x] 压缩顶部为 sticky 命令栏,只保留账号、在线/监听状态、全局模式、白名单数、待回复数和关键操作。
+- [x] 将设置和提醒移入抽屉,三栏工作台使用 `100dvh`/`calc()` 占满剩余视口并独立滚动。
+- [x] 私聊和群聊都采用最新消息降序、空时间末尾、相同时间稳定的规则。
+- [x] 未回复/超时仅作为状态标识,不改变最新消息主排序。
+- [x] Agent 工作台改用品牌、页面、面板、文字、边框、focus、success、warning、error 等语义 token。
+- [x] 默认 Fmode 蓝紫 + 中性灰,租户通过 `data-theme` 或变量覆盖;不机械重写全站颜色。
 
 ### P1:新联系人接入与欢迎语
 
-- Workbench settings 作为运行真源。
-- 新增 `GET/POST /api/agent/intake-policy`。
-- 固定字段:`mode`、`welcomeEnabled`、`welcomeText`、`welcomeSendMode`。
-- 模式:`allowlist_only`、`auto_enroll_review`、`auto_enroll_autopilot`。
-- `auto_enroll_review` 强制会话为 review,不继承全局 autopilot。
-- `auto_enroll_autopilot` 必须由管理员显式选择并二次确认。
-- 未知群聊继续拒绝;仅通过群聊校验后的未知私聊可按策略纳入。
-- 欢迎语按账号 + 联系人幂等,失败状态可重试并保留审计。
+- [x] Workbench settings 作为运行真源。
+- [x] 新增 `GET/POST /api/agent/intake-policy`。
+- [x] 固定字段:`mode`、`welcomeEnabled`、`welcomeText`、`welcomeSendMode`。
+- [x] 模式:`allowlist_only`、`auto_enroll_review`、`auto_enroll_autopilot`。
+- [x] `auto_enroll_review` 强制会话为 review,不继承全局 autopilot。
+- [x] `auto_enroll_autopilot` 必须由管理员显式选择并二次确认。
+- [x] 未知群聊继续拒绝;仅通过群聊校验后的未知私聊可按策略纳入。
+- [x] 欢迎语按账号 + 联系人幂等,失败状态可重试并保留审计。
 
 ### P2:活动与工具轨迹
 
-- 右侧洞察区增加可折叠活动轨迹。
-- 复用现有 tool trace、audit 和 response monitor。
-- 展示阶段、工具名、状态、耗时、脱敏参数摘要、结果、失败、重试和审计入口。
-- 仅在底层真实支持时开放取消/重试;否则保留已有 regenerate 能力。
+- [x] 右侧洞察区增加可折叠活动轨迹。
+- [x] 复用现有 tool trace、audit 和 response monitor。
+- [x] 展示阶段、工具名、状态、耗时、脱敏参数摘要、结果、失败、重试和审计入口。
+- [x] 仅在底层真实支持时开放取消/重试;否则保留已有 regenerate 能力。
 
 ### 迁移与仓库治理
 
-- 业务安装副本先验证,再把等价通用能力同步到通用技能包。
-- 当前任务验收完成后,把通用技能包迁入 `E:\workspace\QIWEI-skill`。
-- 迁移时保留本仓库 `.git` 和 `todolist/`,禁止整包覆盖。
-- 不迁入房源 Provider、小牛默认值、真实房源数据、凭据、`.env.local`、outputs、数据库、Session、日志或 `node_modules`。
+- [x] 业务安装副本先验证,再把等价通用能力同步到通用技能包。
+- [x] 当前任务验收完成后,把通用技能包迁入 `E:\workspace\QIWEI-skill`。
+- [x] 迁移时保留本仓库 `.git` 和 `todolist/`,禁止整包覆盖。
+- [x] 不迁入房源 Provider、小牛默认值、真实房源数据、凭据、`.env.local`、outputs、数据库、Session、日志或 `node_modules`。
 
 ## 安全与产品决策
 
@@ -111,6 +111,8 @@
 | 未知私聊 | `allowlist_only`,忽略 | 不创建会话、不写消息、不外发 |
 | 自动纳入审核 | `auto_enroll_review` | 只生成草稿,强制 review |
 | 自动纳入自动回复 | 默认关闭 | 管理员二次确认后才允许 |
+| 高置信自动 | `auto` | 仅达到置信度阈值且无需人工时发送 |
+| 全自动接管 | `autopilot`,默认关闭 | 当前账号白名单私聊;全局/单会话固定二次确认;非空回复直接发送且不创建草稿 |
 | 未知群聊 | 拒绝 | 不因私聊接入策略放宽 |
 | 欢迎语 | draft/review | 账号 + 联系人幂等,失败可重试 |
 | 人工关闭监听 | 保持关闭 | 后台启动不得覆盖人工选择 |
@@ -128,6 +130,9 @@
 - [x] 欢迎语一次性、重启幂等、失败可重试。
 - [x] 人工关闭监听后不会被自动拉起。
 - [x] 活动轨迹参数和结果已脱敏。
+- [x] `auto` 与 `autopilot` 已分离;全自动接管直接发送且不创建待审草稿。
+- [x] 全自动接管失败保留审计,旧数据库可幂等迁移。
+- [x] 全局、单会话和新联系人全自动接入均需固定二次确认。
 
 ### UI
 
@@ -168,7 +173,9 @@
 - 账号隔离:A 账号手动白名单不进入 B,B 自动纳入不进入 A,切回 A 保留原状态;监听开关仅作用当前账号。
 - 接入策略:通用默认 `allowlist_only`;`auto_enroll_review` 只生成草稿;自动回复模式要求固定二次确认;未知群聊始终拒绝。
 - UI:1440x900、1280x720、390x844 无页面横向溢出、按钮溢出或会话子节点重叠;移动端设置抽屉边界为 `0,68,390,844`。
-- 通用版:Agent 44、接入 5、排序 6、UI 9、Runtime 25、Preview 5 全部通过;`release:check` 18 组通过,npm 包为 18 Skills / 95 Tools / 196 entries。
+- 通用版:Agent 48、接入 5、排序 6、UI 11、Runtime 25、Preview 5 全部通过;`release:check` 18 组通过,npm 包为 18 Skills / 95 Tools / 196 entries。
 - 业务版:Agent 40、接入 7、排序 6、UI 9、Runtime 25、Preview 5、房源专项全部通过,npm 包为 20 Skills / 98 Tools / 215 entries。
 - 通用版已删除房源 Skill、房源数据、房源 API/UI、默认房地产画像字段与测试语料;业务安装副本继续保留业务能力。
 - npm 保持 `0.5.2`,本轮只迁移源码仓库,未执行 `npm publish`。
+- 私聊模式现为 `review`、`auto`、`autopilot`、`human`、`paused`;`autopilot` 已完成直发、失败审计、旧库迁移、接入策略映射与固定确认门禁。
+- 真实浏览器复核 1440x900、1280x720、390x844 均无页面横向溢出或按钮文字溢出;全自动确认框可正常打开和取消。

+ 1 - 0
todolist/backlog.md

@@ -9,5 +9,6 @@
 | P2 | verified | generic | 活动/工具轨迹、任务证据和可观测性 | Evol/Evolva | 轨迹脱敏、失败/重试展示、审计入口均通过 |
 | P1 | verified | generic | 当前通用技能包迁入独立仓库并建立发布基线 | 仓库治理 | 完整测试、敏感扫描、提交并推送 master 均完成 |
 | P0 | verified | generic | Agent 抽屉关闭态灰色遮罩回归 | 2026-08-04 验收反馈 | 关闭/打开/关闭真实浏览器状态与三份 UI 专项均通过 |
+| P0 | verified | generic | 私聊全自动接管与高置信自动分离 | 用户补充反馈 | 直发无草稿、失败审计、旧库迁移、二次确认和三视口 UI 均通过 |
 
 详细设计与进度见 [2026-08-04-webui-evolva-optimization.md](2026-08-04-webui-evolva-optimization.md)。