|
|
@@ -0,0 +1,1138 @@
|
|
|
+const fs = require('fs');
|
|
|
+const path = require('path');
|
|
|
+const crypto = require('crypto');
|
|
|
+const { latestPath } = require('../core/output-paths');
|
|
|
+const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
|
|
|
+const { AgentKnowledgeStore } = require('../core/agent-knowledge');
|
|
|
+const { QiweiAgentRuntime, extractExplicitCustomerIntelligence } = require('../core/agent-runtime');
|
|
|
+const { getCustomerSessionGuide } = require('../core/agent-session-guide');
|
|
|
+const { AgentWorkbenchService } = require('../core/agent-workbench-service');
|
|
|
+const {
|
|
|
+ searchTodoUsers,
|
|
|
+ createTodoKnowledge,
|
|
|
+ completeTodoKnowledge,
|
|
|
+} = require('./official-office-knowledge-service');
|
|
|
+const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
|
|
|
+const { messageTimestamp, evaluatePolledMessage } = require('../core/agent-poller-policy');
|
|
|
+const { setActiveQiweiContext } = require('../core/credentials');
|
|
|
+const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
|
|
|
+
|
|
|
+const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
|
+const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
|
|
|
+
|
|
|
+function readEnvFile(filePath) {
|
|
|
+ try {
|
|
|
+ const env = {};
|
|
|
+ for (const rawLine of fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/)) {
|
|
|
+ const line = rawLine.trim();
|
|
|
+ if (!line || line.startsWith('#') || !line.includes('=')) continue;
|
|
|
+ const index = line.indexOf('=');
|
|
|
+ const key = line.slice(0, index).trim();
|
|
|
+ let value = line.slice(index + 1).trim();
|
|
|
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
|
|
+ env[key] = value;
|
|
|
+ }
|
|
|
+ return env;
|
|
|
+ } catch {
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function readClaudeSettingsEnv() {
|
|
|
+ const result = {};
|
|
|
+ const home = process.env.USERPROFILE || process.env.HOME || '';
|
|
|
+ for (const filePath of [path.join(home, '.claude', 'settings.json'), path.join(home, '.claude', 'settings.local.json')]) {
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
|
|
|
+ for (const [key, item] of Object.entries(parsed.env || {})) {
|
|
|
+ if (!result[key] && typeof item === 'string' && item.trim()) result[key] = item.trim();
|
|
|
+ }
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+const fileEnv = readEnvFile(ENV_FILE);
|
|
|
+const claudeEnv = readClaudeSettingsEnv();
|
|
|
+
|
|
|
+function value(name, fallback = '') {
|
|
|
+ const candidates = [process.env[name], fileEnv[name], claudeEnv[name], fallback];
|
|
|
+ return String(candidates.find(item => typeof item === 'string' && item.trim()) ?? '').trim();
|
|
|
+}
|
|
|
+
|
|
|
+function bool(name, fallback = false) {
|
|
|
+ return /^(1|true|yes|on)$/i.test(value(name, fallback ? 'true' : 'false'));
|
|
|
+}
|
|
|
+
|
|
|
+function number(name, fallback, min = -Infinity, max = Infinity) {
|
|
|
+ const parsed = Number(value(name, String(fallback)));
|
|
|
+ return Math.min(max, Math.max(min, Number.isFinite(parsed) ? parsed : fallback));
|
|
|
+}
|
|
|
+
|
|
|
+function resolvePath(input, fallback) {
|
|
|
+ const selected = input || fallback;
|
|
|
+ return selected ? path.resolve(PROJECT_ROOT, selected) : '';
|
|
|
+}
|
|
|
+
|
|
|
+function loadAgentConfig(overrides = {}) {
|
|
|
+ const provider = value('AGENT_PROVIDER', 'claude-code');
|
|
|
+ const anthropic = provider === 'anthropic';
|
|
|
+ const claudeCode = provider === 'claude-code';
|
|
|
+ const bundledPropertyFile = path.join(PROJECT_ROOT, 'knowledge-base', 'property-data', 'properties.json');
|
|
|
+ const workspacePropertyFile = path.resolve(PROJECT_ROOT, '..', '..', 'huaxiangpipei', 'src', 'assets', 'data', 'properties.json');
|
|
|
+ const configuredPropertyFile = value('QIWEI_AGENT_PROPERTY_DATA_FILE');
|
|
|
+ const propertyDataFile = configuredPropertyFile
|
|
|
+ ? resolvePath(configuredPropertyFile)
|
|
|
+ : (fs.existsSync(bundledPropertyFile) ? bundledPropertyFile : (fs.existsSync(workspacePropertyFile) ? workspacePropertyFile : ''));
|
|
|
+ const baseConfig = {
|
|
|
+ dbPath: resolvePath(value('QIWEI_AGENT_DB_PATH'), latestPath('messages', 'agent-workbench.db')),
|
|
|
+ legacyDbPath: path.resolve(PROJECT_ROOT, '..', '..', 'qiwei-agent-workbench', 'data', 'workbench.db'),
|
|
|
+ globalDefaultPaused: bool('QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED', true),
|
|
|
+ conversationDefaultMode: value('QIWEI_AGENT_DEFAULT_MODE', 'review'),
|
|
|
+ autoSendConfidence: number('QIWEI_AGENT_AUTO_SEND_CONFIDENCE', 0.88, 0, 1),
|
|
|
+ knowledgeDir: resolvePath(value('QIWEI_AGENT_KNOWLEDGE_DIR'), path.join(PROJECT_ROOT, 'knowledge')),
|
|
|
+ propertyDataFile,
|
|
|
+ agent: {
|
|
|
+ provider,
|
|
|
+ apiKey: value('AGENT_API_KEY') || value(anthropic ? 'ANTHROPIC_AUTH_TOKEN' : 'OPENAI_API_KEY') || value('QIWEI_AUTO_REPLY_AI_KEY'),
|
|
|
+ baseUrl: (value('AGENT_BASE_URL') || value(anthropic ? 'ANTHROPIC_BASE_URL' : 'OPENAI_BASE_URL') || value('QIWEI_AUTO_REPLY_AI_BASE_URL') || (anthropic ? 'https://api.anthropic.com' : 'https://api.openai.com/v1')).replace(/\/$/, ''),
|
|
|
+ model: value('AGENT_MODEL') || value(anthropic || claudeCode ? 'ANTHROPIC_MODEL' : 'OPENAI_MODEL') || value('QIWEI_AUTO_REPLY_AI_MODEL') || (anthropic || claudeCode ? 'sonnet' : 'gpt-4.1-mini'),
|
|
|
+ maxToolRounds: number('AGENT_MAX_TOOL_ROUNDS', 4, 1, 8),
|
|
|
+ claudeExecutable: value('CLAUDE_CODE_EXECUTABLE') || path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
|
|
|
+ claudeWorkdir: resolvePath(value('CLAUDE_CODE_WORKDIR'), PROJECT_ROOT),
|
|
|
+ claudeSessionFile: resolvePath(value('CLAUDE_CODE_SESSION_FILE'), latestPath('messages', 'claude-code-sessions.json')),
|
|
|
+ claudeProjectId: value('QIWEI_AGENT_PROJECT_ID') || crypto.createHash('sha256').update(PROJECT_ROOT).digest('hex').slice(0, 16),
|
|
|
+ claudeMainSessionId: value('QIWEI_AGENT_MAIN_SESSION_ID'),
|
|
|
+ claudeTimeoutMs: number('CLAUDE_CODE_TIMEOUT_MS', 120000, 15000, 300000),
|
|
|
+ claudeMaxBudgetUsd: number('CLAUDE_CODE_MAX_BUDGET_USD', 0.35, 0.05, 5),
|
|
|
+ claudeTools: value('CLAUDE_CODE_ALLOWED_TOOLS', 'Read,Glob,Grep'),
|
|
|
+ },
|
|
|
+ qiwei: {
|
|
|
+ transport: 'fmode-gateway',
|
|
|
+ authToken: value('QIWEI_AUTH_TOKEN'),
|
|
|
+ uid: value('QIWEI_UID') || value('QIWE_UID'),
|
|
|
+ guid: value('QIWEI_GUID') || value('QIWE_GUID'),
|
|
|
+ apiBase: value('QIWEI_API_BASE') || value('QIWE_API_BASE'),
|
|
|
+ userId: '',
|
|
|
+ nickname: '',
|
|
|
+ corpName: '',
|
|
|
+ allowedSenders: value('QIWEI_AUTO_REPLY_ALLOWED_SENDERS').split(',').map(item => item.trim()).filter(Boolean),
|
|
|
+ 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),
|
|
|
+ initialSyncMaxPages: number('QIWEI_AGENT_INITIAL_SYNC_MAX_PAGES', 200, 10, 500),
|
|
|
+ startupGraceSeconds: number('QIWEI_AGENT_STARTUP_GRACE_SECONDS', 10, 0, 60),
|
|
|
+ },
|
|
|
+ };
|
|
|
+ const config = {
|
|
|
+ ...baseConfig,
|
|
|
+ ...overrides,
|
|
|
+ agent: { ...baseConfig.agent, ...(overrides.agent || {}) },
|
|
|
+ qiwei: { ...baseConfig.qiwei, ...(overrides.qiwei || {}) },
|
|
|
+ };
|
|
|
+ config.agent.claudeAddDirs = [config.knowledgeDir, config.propertyDataFile ? path.dirname(config.propertyDataFile) : ''].filter(Boolean);
|
|
|
+ return config;
|
|
|
+}
|
|
|
+
|
|
|
+function accountRuntimeKey(input = {}) {
|
|
|
+ const source = String(input.uid || input.guid || input.userId || 'default').trim();
|
|
|
+ return crypto.createHash('sha256').update(source || 'default').digest('hex').slice(0, 16);
|
|
|
+}
|
|
|
+
|
|
|
+function accountWorkbenchOverrides(input = {}) {
|
|
|
+ const storageKey = accountRuntimeKey(input);
|
|
|
+ return {
|
|
|
+ dbPath: latestPath('messages', `agent-workbench-${storageKey}.db`),
|
|
|
+ agent: {
|
|
|
+ claudeSessionFile: latestPath('messages', `claude-code-sessions-${storageKey}.json`),
|
|
|
+ claudeProjectId: `${crypto.createHash('sha256').update(PROJECT_ROOT).digest('hex').slice(0, 12)}-${storageKey.slice(0, 8)}`,
|
|
|
+ },
|
|
|
+ qiwei: {
|
|
|
+ uid: String(input.uid || '').trim(),
|
|
|
+ guid: String(input.guid || '').trim(),
|
|
|
+ userId: String(input.userId || '').trim(),
|
|
|
+ nickname: String(input.nickname || '').trim(),
|
|
|
+ corpName: String(input.corpName || '').trim(),
|
|
|
+ apiBase: String(input.apiBase || '').trim(),
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+function backfillCustomerIntelligence(db) {
|
|
|
+ const version = '4';
|
|
|
+ if (db.getSetting('customer_intelligence_backfill_version', '') === version) return { skipped: true, profileFields: 0, taskCount: 0, alertCount: 0 };
|
|
|
+ const removed = db.lastIntelligenceMigration || { tasks: { removed: 0 }, alerts: { removed: 0 } };
|
|
|
+ let profileFields = 0;
|
|
|
+ let taskCount = 0;
|
|
|
+ let alertCount = 0;
|
|
|
+ for (const conversation of db.listConversations()) {
|
|
|
+ const existing = db.getProfile(conversation.id);
|
|
|
+ let current = { profile: { ...existing.profile }, tags: existing.tags || [] };
|
|
|
+ for (const message of db.listMessages(conversation.id, 200).filter(item => item.direction === 'inbound')) {
|
|
|
+ const intelligence = extractExplicitCustomerIntelligence(message.content, current.profile || {}, {});
|
|
|
+ if (Object.keys(intelligence.profileUpdates).length) {
|
|
|
+ current = { profile: { ...current.profile, ...intelligence.profileUpdates }, tags: current.tags };
|
|
|
+ profileFields += Object.keys(intelligence.profileUpdates).length;
|
|
|
+ }
|
|
|
+ const changed = Object.keys(intelligence.profileUpdates).length > 0;
|
|
|
+ const tasks = intelligence.tasks.map(item => ({ ...item, sourceMessageId: changed ? message.id : null }));
|
|
|
+ const alerts = intelligence.alerts.map(item => ({ ...item, sourceMessageId: changed || item.managedBy === 'event' ? message.id : null }));
|
|
|
+ taskCount += db.reconcileCustomerTasks(conversation.id, tasks, message.id).tasks.length;
|
|
|
+ alertCount += db.reconcileCustomerAlerts(conversation.id, alerts, message.id).alerts.length;
|
|
|
+ }
|
|
|
+ if (Object.keys(current.profile).length) db.updateProfile(conversation.id, { ...existing.profile, ...current.profile }, existing.tags);
|
|
|
+ const recommendationSent = db.listMessages(conversation.id, 200).some(message =>
|
|
|
+ message.direction === 'outbound' && /(房源|方案|重点|推荐).{0,20}(套|房)|(套|房).{0,20}(房源|方案|推荐)/.test(String(message.content || ''))
|
|
|
+ );
|
|
|
+ if (recommendationSent) db.completeCustomerTaskByBusinessKey(conversation.id, 'recommendation:shortlist', 'historical_recommendation_sent');
|
|
|
+ }
|
|
|
+ db.setSetting('customer_intelligence_backfill_version', version);
|
|
|
+ if (profileFields || taskCount || alertCount) {
|
|
|
+ db.audit({ actor: 'migration', action: 'customer_intelligence_backfilled', detail: { version, removed, profileFields, taskCount, alertCount } });
|
|
|
+ }
|
|
|
+ return { version, removed, profileFields, taskCount, alertCount };
|
|
|
+}
|
|
|
+
|
|
|
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
+
|
|
|
+class QiweiAgentPoller {
|
|
|
+ constructor({ config, db, qiwei, service }) {
|
|
|
+ this.config = config;
|
|
|
+ this.db = db;
|
|
|
+ this.qiwei = qiwei;
|
|
|
+ this.service = service;
|
|
|
+ this.running = false;
|
|
|
+ this.loopPromise = null;
|
|
|
+ this.startedAt = 0;
|
|
|
+ this.lastError = '';
|
|
|
+ }
|
|
|
+
|
|
|
+ status() {
|
|
|
+ return {
|
|
|
+ running: this.running,
|
|
|
+ syncKey: Number(this.db.getPollState('sync_key', '0')),
|
|
|
+ lastError: this.lastError,
|
|
|
+ startedAt: this.startedAt || null,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ async start() {
|
|
|
+ if (this.running) return this.status();
|
|
|
+ if (!this.qiwei.isConfigured()) throw new Error('Fmode 企微网关尚未配置,请先完成 Fmode 鉴权和企微扫码登录');
|
|
|
+ if (this.config.allowedSenders.length === 0) throw new Error('企微联系人白名单为空,拒绝启动');
|
|
|
+ await this.qiwei.checkLogin();
|
|
|
+ this.running = true;
|
|
|
+ this.startedAt = Math.floor(Date.now() / 1000);
|
|
|
+ this.lastError = '';
|
|
|
+ let syncKey = Number(this.db.getPollState('sync_key', '0')) || 0;
|
|
|
+ if (syncKey === 0) syncKey = await this.establishBaseline();
|
|
|
+ this.db.audit({ actor: 'runtime', action: 'poller_started', detail: { syncKey, allowlistCount: this.config.allowedSenders.length } });
|
|
|
+ this.loopPromise = this.loop(syncKey);
|
|
|
+ return this.status();
|
|
|
+ }
|
|
|
+
|
|
|
+ stop() {
|
|
|
+ if (!this.running) return this.status();
|
|
|
+ this.running = false;
|
|
|
+ this.db.audit({ actor: 'runtime', action: 'poller_stopped', detail: this.status() });
|
|
|
+ return this.status();
|
|
|
+ }
|
|
|
+
|
|
|
+ async establishBaseline() {
|
|
|
+ let cursor = 0;
|
|
|
+ let reachedEnd = false;
|
|
|
+ let pages = 0;
|
|
|
+ let count = 0;
|
|
|
+ while (pages < this.config.initialSyncMaxPages) {
|
|
|
+ const result = await this.qiwei.syncMessages(cursor, this.config.initialSyncLimit);
|
|
|
+ const list = result.syncMsgList || [];
|
|
|
+ pages += 1;
|
|
|
+ count += list.length;
|
|
|
+ const seqs = list.map(item => Number(item.seq)).filter(Number.isFinite);
|
|
|
+ const next = Math.max(cursor, Number(result.travelSyncKey) || 0, seqs.length ? Math.max(...seqs) : 0);
|
|
|
+ if (list.length === 0) { reachedEnd = true; break; }
|
|
|
+ if (next <= cursor) throw new Error(`历史消息游标没有前进(seq=${cursor})`);
|
|
|
+ cursor = next;
|
|
|
+ }
|
|
|
+ if (!reachedEnd) throw new Error(`历史消息超过 ${this.config.initialSyncMaxPages} 页,拒绝启动自动处理`);
|
|
|
+ this.db.setPollState('sync_key', cursor);
|
|
|
+ this.db.audit({ actor: 'runtime', action: 'poller_baseline_established', detail: { cursor, pages, skippedHistory: count } });
|
|
|
+ return cursor;
|
|
|
+ }
|
|
|
+
|
|
|
+ async loop(initialSyncKey) {
|
|
|
+ let syncKey = initialSyncKey;
|
|
|
+ while (this.running) {
|
|
|
+ try {
|
|
|
+ const result = await this.qiwei.syncMessages(syncKey, 50);
|
|
|
+ const list = result.syncMsgList || [];
|
|
|
+ const seqs = list.map(item => Number(item.seq)).filter(Number.isFinite);
|
|
|
+ const next = Math.max(syncKey, Number(result.travelSyncKey) || 0, seqs.length ? Math.max(...seqs) : 0);
|
|
|
+ for (const message of list) await this.process(message);
|
|
|
+ syncKey = next;
|
|
|
+ this.db.setPollState('sync_key', syncKey);
|
|
|
+ this.lastError = '';
|
|
|
+ } catch (error) {
|
|
|
+ this.lastError = error.message;
|
|
|
+ this.db.audit({ actor: 'runtime', action: 'poller_error', detail: { message: error.message } });
|
|
|
+ }
|
|
|
+ if (this.running) await delay(this.config.intervalMs);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async process(message) {
|
|
|
+ const candidate = evaluatePolledMessage(message, this.config);
|
|
|
+ if (!candidate.eligible) return;
|
|
|
+ const { content, senderId, timestamp } = candidate;
|
|
|
+ await this.service.ingestInbound({
|
|
|
+ externalId: String(message.msgServerId || message.msgUniqueIdentifier || `${senderId}:${message.seq}`),
|
|
|
+ contactId: senderId,
|
|
|
+ contactName: message.senderName || '王刚',
|
|
|
+ content,
|
|
|
+ timestamp: new Date(timestamp * 1000).toISOString(),
|
|
|
+ raw: { seq: message.seq, msgType: message.msgType, timestamp: message.timestamp },
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function createWorkbench(overrides = {}) {
|
|
|
+ const config = loadAgentConfig(overrides.config || {});
|
|
|
+ const db = overrides.db || new AgentWorkbenchDb(config.dbPath, {
|
|
|
+ globalPaused: config.globalDefaultPaused,
|
|
|
+ defaultMode: config.conversationDefaultMode,
|
|
|
+ autoSendConfidence: config.autoSendConfidence,
|
|
|
+ });
|
|
|
+ if (!overrides.db && !overrides.skipLegacyImport) {
|
|
|
+ try { db.importCompatibleDatabase(config.legacyDbPath); } catch (error) {
|
|
|
+ db.audit({ actor: 'migration', action: 'legacy_workbench_import_failed', detail: { message: error.message } });
|
|
|
+ }
|
|
|
+ const removed = db.cleanupInboundContentDuplicates(60);
|
|
|
+ if (removed) db.audit({ actor: 'migration', action: 'duplicate_messages_cleaned', detail: { removed } });
|
|
|
+ backfillCustomerIntelligence(db);
|
|
|
+ }
|
|
|
+ const knowledge = overrides.knowledge || new AgentKnowledgeStore({
|
|
|
+ knowledgeDir: config.knowledgeDir,
|
|
|
+ propertyDataFile: config.propertyDataFile,
|
|
|
+ });
|
|
|
+ backfillPropertyRecommendations(db, knowledge);
|
|
|
+ const qiwei = overrides.qiwei || new FmodeQiweiClient(config.qiwei);
|
|
|
+ const agent = overrides.agent || new QiweiAgentRuntime({ config: config.agent, knowledge });
|
|
|
+ const service = overrides.service || new AgentWorkbenchService({ db, agent, qiwei, config });
|
|
|
+ const poller = overrides.poller || new QiweiAgentPoller({ config: config.qiwei, db, qiwei, service });
|
|
|
+ return { config, db, knowledge, qiwei, agent, service, poller };
|
|
|
+}
|
|
|
+
|
|
|
+function configuredStartupAccount() {
|
|
|
+ return {
|
|
|
+ uid: value('QIWEI_UID') || value('QIWE_UID'),
|
|
|
+ guid: value('QIWEI_GUID') || value('QIWE_GUID'),
|
|
|
+ apiBase: value('QIWEI_API_BASE') || value('QIWE_API_BASE'),
|
|
|
+ userId: '',
|
|
|
+ nickname: '',
|
|
|
+ corpName: '',
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+const startupAccount = configuredStartupAccount();
|
|
|
+let workbench = createWorkbench(startupAccount.uid ? {
|
|
|
+ config: accountWorkbenchOverrides(startupAccount),
|
|
|
+ skipLegacyImport: true,
|
|
|
+} : {});
|
|
|
+
|
|
|
+function migrateStartupWorkbench(target) {
|
|
|
+ if (!startupAccount.uid || target.db.listConversations().length) return { imported: false, reason: 'target_not_empty' };
|
|
|
+ const candidates = [latestPath('messages', 'agent-workbench.db'), target.config.legacyDbPath];
|
|
|
+ for (const sourcePath of candidates) {
|
|
|
+ try {
|
|
|
+ const result = target.db.importCompatibleDatabase(sourcePath);
|
|
|
+ if (!result.imported) continue;
|
|
|
+ const removed = target.db.cleanupInboundContentDuplicates(60);
|
|
|
+ backfillCustomerIntelligence(target.db);
|
|
|
+ backfillPropertyRecommendations(target.db, target.knowledge);
|
|
|
+ target.db.audit({
|
|
|
+ actor: 'migration',
|
|
|
+ action: 'account_workbench_migration_completed',
|
|
|
+ detail: { source: path.basename(sourcePath), removedDuplicates: removed },
|
|
|
+ });
|
|
|
+ return result;
|
|
|
+ } catch (error) {
|
|
|
+ target.db.audit({
|
|
|
+ actor: 'migration',
|
|
|
+ action: 'account_workbench_migration_failed',
|
|
|
+ detail: { source: path.basename(sourcePath), message: error.message },
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return { imported: false, reason: 'source_missing_or_incompatible' };
|
|
|
+}
|
|
|
+
|
|
|
+migrateStartupWorkbench(workbench);
|
|
|
+let accountStatusCache = { checkedAt: 0, value: null };
|
|
|
+let accountStatusRefresh = null;
|
|
|
+let accountStatusRefreshKey = '';
|
|
|
+const accountLastOnlineAt = new Map();
|
|
|
+const accountOfflineChecks = new Map();
|
|
|
+const ONLINE_STATUS_GRACE_MS = 60000;
|
|
|
+const workbenches = new Map();
|
|
|
+
|
|
|
+function activeAccountMetadata() {
|
|
|
+ const context = workbench.qiwei.context();
|
|
|
+ return {
|
|
|
+ uid: String(context.uid || workbench.config.qiwei.uid || '').trim(),
|
|
|
+ guid: String(context.guid || workbench.config.qiwei.guid || '').trim(),
|
|
|
+ apiBase: String(context.apiBase || workbench.config.qiwei.apiBase || '').trim(),
|
|
|
+ userId: String(workbench.config.qiwei.userId || '').trim(),
|
|
|
+ nickname: String(workbench.config.qiwei.nickname || '').trim(),
|
|
|
+ corpName: String(workbench.config.qiwei.corpName || '').trim(),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function applyActiveAccountContext(account) {
|
|
|
+ setActiveQiweiContext(account);
|
|
|
+ Object.assign(workbench.config.qiwei, account);
|
|
|
+ if (workbench.qiwei && workbench.qiwei.config) Object.assign(workbench.qiwei.config, account);
|
|
|
+}
|
|
|
+
|
|
|
+function provisionalAccountStatus(selected = activeAccountMetadata(), statusText = '正在检测账号状态') {
|
|
|
+ return {
|
|
|
+ uid: selected.uid,
|
|
|
+ guid: selected.guid,
|
|
|
+ userId: selected.userId,
|
|
|
+ configured: workbench.qiwei.isConfigured(),
|
|
|
+ online: false,
|
|
|
+ nickname: selected.nickname || selected.userId || '当前企微账号',
|
|
|
+ corpName: selected.corpName || '',
|
|
|
+ statusCode: null,
|
|
|
+ statusText,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+const initialAccount = activeAccountMetadata();
|
|
|
+workbenches.set(accountRuntimeKey(initialAccount), workbench);
|
|
|
+applyActiveAccountContext(initialAccount);
|
|
|
+
|
|
|
+async function switchActiveAccount(input = {}) {
|
|
|
+ const account = {
|
|
|
+ uid: String(input.uid || '').trim(),
|
|
|
+ guid: String(input.guid || '').trim(),
|
|
|
+ apiBase: String(input.apiBase || '').trim(),
|
|
|
+ userId: String(input.userId || '').trim(),
|
|
|
+ nickname: String(input.nickname || input.userId || '').trim(),
|
|
|
+ corpName: String(input.corpName || '').trim(),
|
|
|
+ };
|
|
|
+ if (!account.uid) throw new Error('该账号缺少 Fmode 设备 uid,请重新扫码绑定后再切换');
|
|
|
+
|
|
|
+ const current = activeAccountMetadata();
|
|
|
+ const currentKey = accountRuntimeKey(current);
|
|
|
+ const nextKey = accountRuntimeKey(account);
|
|
|
+ const accountChanged = currentKey !== nextKey;
|
|
|
+ if (accountChanged && workbench.poller.status().running) workbench.poller.stop();
|
|
|
+
|
|
|
+ let nextWorkbench = workbenches.get(nextKey);
|
|
|
+ if (!nextWorkbench) {
|
|
|
+ nextWorkbench = createWorkbench({
|
|
|
+ config: accountWorkbenchOverrides(account),
|
|
|
+ skipLegacyImport: true,
|
|
|
+ });
|
|
|
+ workbenches.set(nextKey, nextWorkbench);
|
|
|
+ }
|
|
|
+ workbench = nextWorkbench;
|
|
|
+ applyActiveAccountContext({
|
|
|
+ ...activeAccountMetadata(),
|
|
|
+ ...account,
|
|
|
+ apiBase: account.apiBase || activeAccountMetadata().apiBase,
|
|
|
+ });
|
|
|
+ const status = provisionalAccountStatus();
|
|
|
+ accountStatusCache = { checkedAt: Date.now(), value: status };
|
|
|
+ void refreshAccountStatus();
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ assistantMessage: `已切换到账号:${status.nickname || account.nickname || account.userId || account.uid}`,
|
|
|
+ summary: {
|
|
|
+ switched: accountChanged,
|
|
|
+ storageKey: nextKey,
|
|
|
+ online: status.online,
|
|
|
+ listenerStopped: accountChanged,
|
|
|
+ },
|
|
|
+ data: { account: status },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshAccountStatus() {
|
|
|
+ const selected = activeAccountMetadata();
|
|
|
+ const targetWorkbench = workbench;
|
|
|
+ const refreshKey = accountRuntimeKey(selected);
|
|
|
+ if (accountStatusRefresh && accountStatusRefreshKey === refreshKey) return accountStatusRefresh;
|
|
|
+ accountStatusRefreshKey = refreshKey;
|
|
|
+ accountStatusRefresh = (async () => {
|
|
|
+ let next;
|
|
|
+ try {
|
|
|
+ const data = await targetWorkbench.qiwei.checkLogin();
|
|
|
+ const online = Number(data.userOnlineStatus) === 2 && Number(data.errorCode || 0) === 0;
|
|
|
+ next = {
|
|
|
+ uid: selected.uid,
|
|
|
+ guid: selected.guid,
|
|
|
+ userId: data.userId || selected.userId,
|
|
|
+ configured: data.configured !== false,
|
|
|
+ online,
|
|
|
+ nickname: data.nickname || selected.nickname || selected.userId || '当前企微账号',
|
|
|
+ corpName: data.corpName || selected.corpName || '',
|
|
|
+ statusCode: data.userOnlineStatus ?? null,
|
|
|
+ statusText: online ? '账号在线' : '账号离线',
|
|
|
+ };
|
|
|
+ if (online) {
|
|
|
+ accountLastOnlineAt.set(refreshKey, Date.now());
|
|
|
+ accountOfflineChecks.set(refreshKey, 0);
|
|
|
+ } else {
|
|
|
+ const offlineChecks = Number(accountOfflineChecks.get(refreshKey) || 0) + 1;
|
|
|
+ accountOfflineChecks.set(refreshKey, offlineChecks);
|
|
|
+ const lastOnlineAt = Number(accountLastOnlineAt.get(refreshKey) || 0);
|
|
|
+ if (offlineChecks < 2 && lastOnlineAt && Date.now() - lastOnlineAt < ONLINE_STATUS_GRACE_MS) {
|
|
|
+ next.online = true;
|
|
|
+ next.statusCode = 2;
|
|
|
+ next.statusText = '账号在线(正在复核)';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ next = {
|
|
|
+ ...provisionalAccountStatus(selected, '状态检测失败'),
|
|
|
+ configured: targetWorkbench.qiwei.isConfigured(),
|
|
|
+ };
|
|
|
+ const lastOnlineAt = Number(accountLastOnlineAt.get(refreshKey) || 0);
|
|
|
+ if (lastOnlineAt && Date.now() - lastOnlineAt < ONLINE_STATUS_GRACE_MS) {
|
|
|
+ next.online = true;
|
|
|
+ next.statusCode = 2;
|
|
|
+ next.statusText = '账号在线(状态刷新中)';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (accountRuntimeKey(activeAccountMetadata()) === refreshKey) {
|
|
|
+ accountStatusCache = { checkedAt: Date.now(), value: next };
|
|
|
+ }
|
|
|
+ return next;
|
|
|
+ })().finally(() => {
|
|
|
+ if (accountStatusRefreshKey === refreshKey) {
|
|
|
+ accountStatusRefresh = null;
|
|
|
+ accountStatusRefreshKey = '';
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return accountStatusRefresh;
|
|
|
+}
|
|
|
+
|
|
|
+async function detectAccountStatus(force = false) {
|
|
|
+ const selected = activeAccountMetadata();
|
|
|
+ const selectedKey = accountRuntimeKey(selected);
|
|
|
+ const cacheMatches = accountStatusCache.value && accountRuntimeKey(accountStatusCache.value) === selectedKey;
|
|
|
+ if (force) return refreshAccountStatus();
|
|
|
+ if (cacheMatches) {
|
|
|
+ if (Date.now() - accountStatusCache.checkedAt >= 8000) void refreshAccountStatus();
|
|
|
+ return accountStatusCache.value;
|
|
|
+ }
|
|
|
+ const provisional = provisionalAccountStatus(selected);
|
|
|
+ accountStatusCache = { checkedAt: Date.now(), value: provisional };
|
|
|
+ void refreshAccountStatus();
|
|
|
+ return provisional;
|
|
|
+}
|
|
|
+
|
|
|
+function maskedId(value) {
|
|
|
+ const text = String(value || '');
|
|
|
+ if (text.length <= 4) return '测试联系人';
|
|
|
+ return `${text.slice(0, 2)}***${text.slice(-2)}`;
|
|
|
+}
|
|
|
+
|
|
|
+function completeness(profile = {}) {
|
|
|
+ const values = [
|
|
|
+ profile.preferredRegion || profile.region || profile.district || profile.intent_area || profile.districts,
|
|
|
+ profile.budgetWan || profile.budget || profile.budgetMax || profile.budget_max,
|
|
|
+ profile.layout || profile.rooms || profile.house_type,
|
|
|
+ profile.area || profile.areaMin || profile.area_min,
|
|
|
+ profile.decoration,
|
|
|
+ profile.timeline || profile.urgency,
|
|
|
+ ];
|
|
|
+ return Math.round(values.filter(value => Array.isArray(value) ? value.length : Boolean(value)).length / values.length * 100);
|
|
|
+}
|
|
|
+
|
|
|
+function parseJson(value, fallback) {
|
|
|
+ try { return value ? JSON.parse(value) : fallback; }
|
|
|
+ catch { return fallback; }
|
|
|
+}
|
|
|
+
|
|
|
+function propertyMatches(toolTrace = []) {
|
|
|
+ const call = [...toolTrace].reverse().find(item => item.tool === 'search_properties');
|
|
|
+ return (call?.result?.items || []).map(item => ({
|
|
|
+ community: item.community,
|
|
|
+ title: item.layout,
|
|
|
+ price: item.totalPrice,
|
|
|
+ layout: item.layout,
|
|
|
+ area: item.area,
|
|
|
+ score: null,
|
|
|
+ level: call.result.warning || '',
|
|
|
+ highlights: item.highlights || [],
|
|
|
+ }));
|
|
|
+}
|
|
|
+
|
|
|
+function propertyRecommendationsFromTrace(toolTrace = []) {
|
|
|
+ return [...new Map((toolTrace || []).filter(item => item?.tool === 'search_properties').flatMap(item => item.result?.items || []).filter(item => item?.id).map(item => [String(item.id), item])).values()];
|
|
|
+}
|
|
|
+
|
|
|
+function detectedPropertiesInMessage(content, properties = []) {
|
|
|
+ const text = String(content || '');
|
|
|
+ if (!text) return [];
|
|
|
+ return properties.filter(property => {
|
|
|
+ const id = String(property.id || '').trim();
|
|
|
+ if (id && text.includes(id)) return true;
|
|
|
+ const community = String(property.community || '').trim();
|
|
|
+ const price = Number(property.totalPrice || 0);
|
|
|
+ if (!community || !price || !text.includes(community)) return false;
|
|
|
+ return new RegExp(`${price}(?:\\.0+)?\\s*万`).test(text);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function backfillPropertyRecommendations(db, knowledge) {
|
|
|
+ if (!db || typeof db.upsertCustomerRecommendations !== 'function') return 0;
|
|
|
+ const properties = Array.isArray(knowledge?.properties) ? knowledge.properties : [];
|
|
|
+ let count = 0;
|
|
|
+ for (const conversation of db.listConversations()) {
|
|
|
+ for (const draft of db.listDrafts({ conversationId: conversation.id, limit: 500 })) {
|
|
|
+ const items = propertyRecommendationsFromTrace(draft.tool_trace);
|
|
|
+ if (!items.length || draft.status === 'rejected') continue;
|
|
|
+ db.upsertCustomerRecommendations(conversation.id, items, {
|
|
|
+ type: 'agent-tool',
|
|
|
+ entityId: draft.id,
|
|
|
+ status: ['sent', 'approved'].includes(draft.status) ? 'recommended' : 'candidate',
|
|
|
+ evidence: draft.status === 'sent' ? String(draft.content || '').slice(0, 500) : 'Agent 房源工具查询结果,尚未确认已发送给客户',
|
|
|
+ createdAt: draft.created_at,
|
|
|
+ });
|
|
|
+ count += items.length;
|
|
|
+ }
|
|
|
+ if (!properties.length) continue;
|
|
|
+ for (const message of db.listMessages(conversation.id, 500).filter(item => item.direction === 'outbound')) {
|
|
|
+ const items = detectedPropertiesInMessage(message.content, properties);
|
|
|
+ if (!items.length) continue;
|
|
|
+ db.upsertCustomerRecommendations(conversation.id, items, {
|
|
|
+ type: 'outbound-message-detected',
|
|
|
+ entityId: message.id,
|
|
|
+ status: 'recommended',
|
|
|
+ evidence: String(message.content || '').slice(0, 500),
|
|
|
+ createdAt: message.created_at,
|
|
|
+ });
|
|
|
+ count += items.length;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return count;
|
|
|
+}
|
|
|
+
|
|
|
+function publicConversation(row) {
|
|
|
+ const detail = workbench.service.conversationDetail(row.id);
|
|
|
+ const claudeSession = getCustomerSessionGuide(row, {
|
|
|
+ sessionFile: workbench.config.agent.claudeSessionFile,
|
|
|
+ });
|
|
|
+ const drafts = detail.drafts || [];
|
|
|
+ const latestInbound = [...(detail.messages || [])].reverse().find(message => message.direction === 'inbound') || null;
|
|
|
+ const currentDrafts = latestInbound
|
|
|
+ ? drafts.filter(item => item.inbound_message_id === latestInbound.id)
|
|
|
+ : [];
|
|
|
+ const pending = currentDrafts.find(item => item.status === 'pending') || null;
|
|
|
+ const latestDraft = pending || currentDrafts.find(item => ['sent', 'approved'].includes(item.status)) || null;
|
|
|
+ const rawProfile = detail.profile?.profile || {};
|
|
|
+ const { __evidence: profileEvidence = {}, ...profile } = rawProfile;
|
|
|
+ const customerTasks = detail.tasks || [];
|
|
|
+ const customerAlerts = detail.alerts || [];
|
|
|
+ const customerRecommendations = detail.recommendations || [];
|
|
|
+ const citations = latestDraft?.citations || [];
|
|
|
+ const cutoverAt = Date.parse(workbench.db.getSetting('agent_cutover_at', '')) || Date.now();
|
|
|
+ const displayMessages = [...new Map((detail.messages || []).map(message => [message.id, message])).values()]
|
|
|
+ .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))
|
|
|
+ .slice(-60);
|
|
|
+ const visibleEntityIds = new Set(displayMessages.map(message => message.id));
|
|
|
+ const visibleAudit = (detail.audit || []).filter(item =>
|
|
|
+ Date.parse(item.created_at) >= cutoverAt || visibleEntityIds.has(item.entity_id)
|
|
|
+ ).slice(0, 40);
|
|
|
+ return {
|
|
|
+ id: row.id,
|
|
|
+ displayName: row.contact_name || '白名单测试联系人',
|
|
|
+ maskedId: maskedId(row.contact_id),
|
|
|
+ mode: row.mode,
|
|
|
+ source: 'live',
|
|
|
+ claudeSession,
|
|
|
+ messages: displayMessages.map(message => ({
|
|
|
+ id: message.id,
|
|
|
+ role: message.direction === 'inbound' ? 'customer' : message.sender_type,
|
|
|
+ content: message.content,
|
|
|
+ timestamp: message.created_at,
|
|
|
+ status: message.status,
|
|
|
+ source: message.direction === 'inbound' ? 'live' : message.sender_type,
|
|
|
+ })),
|
|
|
+ analysis: {
|
|
|
+ intent: latestDraft?.intent || '',
|
|
|
+ intentLabel: latestDraft?.intent || (detail.agentError ? 'Agent 上游不可用' : '待 Agent 处理'),
|
|
|
+ demand: profile,
|
|
|
+ completenessScore: completeness(profile),
|
|
|
+ matches: propertyMatches(latestDraft?.tool_trace || []),
|
|
|
+ knowledgeSources: citations.length
|
|
|
+ ? citations.map(item => `${item.heading || item.source}${item.source ? ` · ${item.source}` : ''}`)
|
|
|
+ : ['真实企微消息', '客户画像', '企业规则库与知识库'],
|
|
|
+ reasoning: latestDraft?.reason || detail.agentError?.message || '消息已进入真实企微链路,等待 Agent 生成可审核草稿。',
|
|
|
+ },
|
|
|
+ customerIntelligence: {
|
|
|
+ profile,
|
|
|
+ profileEvidence,
|
|
|
+ profileUpdatedAt: detail.profile?.updatedAt || null,
|
|
|
+ tags: detail.profile?.tags || [],
|
|
|
+ tasks: customerTasks.map(item => ({
|
|
|
+ id: item.id,
|
|
|
+ businessKey: item.business_key,
|
|
|
+ type: item.type,
|
|
|
+ title: item.title,
|
|
|
+ owner: item.owner,
|
|
|
+ dueAt: item.due_at,
|
|
|
+ priority: item.priority,
|
|
|
+ status: item.status,
|
|
|
+ reason: item.reason,
|
|
|
+ evidence: item.evidence,
|
|
|
+ evidenceItems: parseJson(item.evidence_json, []),
|
|
|
+ resolutionReason: item.resolution_reason,
|
|
|
+ officialTodoId: item.official_todo_id,
|
|
|
+ officialSyncStatus: item.official_sync_status,
|
|
|
+ officialSyncedAt: item.official_synced_at,
|
|
|
+ updatedAt: item.updated_at,
|
|
|
+ })),
|
|
|
+ alerts: customerAlerts.map(item => ({
|
|
|
+ id: item.id,
|
|
|
+ businessKey: item.business_key,
|
|
|
+ type: item.type,
|
|
|
+ severity: item.severity,
|
|
|
+ title: item.title,
|
|
|
+ detail: item.detail,
|
|
|
+ evidence: item.evidence,
|
|
|
+ evidenceItems: parseJson(item.evidence_json, []),
|
|
|
+ resolutionReason: item.resolution_reason,
|
|
|
+ recommendedAction: item.recommended_action,
|
|
|
+ status: item.status,
|
|
|
+ updatedAt: item.updated_at,
|
|
|
+ })),
|
|
|
+ recommendations: customerRecommendations.map(item => ({
|
|
|
+ id: item.id,
|
|
|
+ propertyId: item.property_id,
|
|
|
+ property: item.property_snapshot,
|
|
|
+ status: item.status,
|
|
|
+ feedbackReason: item.feedback_reason,
|
|
|
+ recommendCount: item.recommend_count,
|
|
|
+ sources: item.sources,
|
|
|
+ firstRecommendedAt: item.first_recommended_at,
|
|
|
+ lastRecommendedAt: item.last_recommended_at,
|
|
|
+ updatedAt: item.updated_at,
|
|
|
+ })),
|
|
|
+ summary: {
|
|
|
+ openTasks: customerTasks.filter(item => ['open', 'in_progress'].includes(item.status)).length,
|
|
|
+ openAlerts: customerAlerts.filter(item => item.status === 'open').length,
|
|
|
+ highAlerts: customerAlerts.filter(item => item.status === 'open' && ['high', 'critical'].includes(item.severity)).length,
|
|
|
+ recommendationCount: customerRecommendations.length,
|
|
|
+ pendingFeedbackCount: customerRecommendations.filter(item => ['candidate', 'recommended'].includes(item.status)).length,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ pendingReply: pending ? {
|
|
|
+ id: pending.id,
|
|
|
+ content: pending.content,
|
|
|
+ status: pending.status,
|
|
|
+ confidence: pending.confidence,
|
|
|
+ reason: pending.reason,
|
|
|
+ requiresHuman: pending.requires_human,
|
|
|
+ citations: pending.citations,
|
|
|
+ toolTrace: pending.tool_trace,
|
|
|
+ createdAt: pending.created_at,
|
|
|
+ } : null,
|
|
|
+ drafts,
|
|
|
+ audit: visibleAudit,
|
|
|
+ agentError: detail.agentError,
|
|
|
+ lastMessageAt: displayMessages.at(-1)?.created_at || row.last_message_at,
|
|
|
+ updatedAt: row.updated_at,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function getAgentStatus() {
|
|
|
+ const account = await detectAccountStatus();
|
|
|
+ const state = workbench.service.state(workbench.poller.status());
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ data: {
|
|
|
+ globalMode: state.global.paused ? 'paused' : state.global.defaultMode,
|
|
|
+ global: state.global,
|
|
|
+ listener: state.poller,
|
|
|
+ account,
|
|
|
+ agent: state.agent,
|
|
|
+ knowledge: workbench.knowledge.stats(),
|
|
|
+ config: {
|
|
|
+ allowedSenderCount: state.qiwei.allowlistCount,
|
|
|
+ testMode: false,
|
|
|
+ demoMode: false,
|
|
|
+ transport: state.qiwei.transport,
|
|
|
+ pollIntervalMs: workbench.config.qiwei.intervalMs,
|
|
|
+ },
|
|
|
+ safety: {
|
|
|
+ whitelistEnabled: state.qiwei.allowlistCount > 0,
|
|
|
+ defaultReviewMode: true,
|
|
|
+ globalPauseSupported: true,
|
|
|
+ messageSendRequiresWhitelist: true,
|
|
|
+ demoReplyDisabled: true,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function displayableConversations() {
|
|
|
+ return workbench.db.listConversations().filter(item => item.last_message_at || item.last_content || Number(item.pending_count || 0) > 0);
|
|
|
+}
|
|
|
+
|
|
|
+function getConversations() {
|
|
|
+ return { status: 'ok', data: { conversations: displayableConversations().map(publicConversation) } };
|
|
|
+}
|
|
|
+
|
|
|
+function updateCustomerProfile(conversationId, input = {}) {
|
|
|
+ const conversation = workbench.db.getConversation(conversationId);
|
|
|
+ if (!conversation) throw new Error('客户会话不存在');
|
|
|
+ const current = workbench.db.getProfile(conversationId);
|
|
|
+ const nextProfile = { ...(current.profile || {}) };
|
|
|
+ const evidence = { ...(nextProfile.__evidence || {}) };
|
|
|
+ const patch = input.profile && typeof input.profile === 'object' ? input.profile : {};
|
|
|
+ const changedFields = [];
|
|
|
+ for (const [field, rawValue] of Object.entries(patch)) {
|
|
|
+ if (!field || field.startsWith('__')) continue;
|
|
|
+ const value = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
|
|
|
+ if (value === '' || value === null || value === undefined) delete nextProfile[field];
|
|
|
+ else nextProfile[field] = value;
|
|
|
+ evidence[field] = {
|
|
|
+ text: String(input.reason || '客户管理人工核对').trim(),
|
|
|
+ sourceMessageId: null,
|
|
|
+ source: 'human',
|
|
|
+ updatedAt: new Date().toISOString(),
|
|
|
+ };
|
|
|
+ changedFields.push(field);
|
|
|
+ }
|
|
|
+ nextProfile.__evidence = evidence;
|
|
|
+ const tags = input.tags === undefined
|
|
|
+ ? current.tags
|
|
|
+ : [...new Set((Array.isArray(input.tags) ? input.tags : String(input.tags || '').split(/[,,]/)).map(item => String(item).trim()).filter(Boolean))];
|
|
|
+ const updated = workbench.db.updateProfile(conversationId, nextProfile, tags);
|
|
|
+ workbench.db.audit({
|
|
|
+ actor: 'human',
|
|
|
+ action: 'customer_profile_updated',
|
|
|
+ conversationId,
|
|
|
+ entityId: conversationId,
|
|
|
+ detail: { fields: changedFields, tagCount: tags.length, reason: String(input.reason || '').trim() },
|
|
|
+ });
|
|
|
+ const { __evidence, ...visibleProfile } = updated.profile || {};
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ assistantMessage: `客户“${conversation.contact_name || '未命名客户'}”的主档已更新。`,
|
|
|
+ summary: { changedFields, tagCount: tags.length },
|
|
|
+ data: { profile: visibleProfile, profileEvidence: __evidence || {}, tags, updatedAt: updated.updatedAt },
|
|
|
+ warnings: [],
|
|
|
+ errors: [],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function updateCustomerRecommendation(conversationId, recommendationId, input = {}) {
|
|
|
+ const recommendation = workbench.db.updateCustomerRecommendation(conversationId, recommendationId, {
|
|
|
+ status: input.status,
|
|
|
+ feedbackReason: input.feedbackReason,
|
|
|
+ });
|
|
|
+ if (!recommendation) throw new Error('房源推荐记录不存在');
|
|
|
+ const current = workbench.db.getProfile(conversationId);
|
|
|
+ const existingFeedback = Array.isArray(current.profile?.propertyFeedback) ? current.profile.propertyFeedback : [];
|
|
|
+ const feedback = {
|
|
|
+ propertyId: recommendation.property_id,
|
|
|
+ status: recommendation.status,
|
|
|
+ reason: recommendation.feedback_reason,
|
|
|
+ recordedAt: new Date().toISOString(),
|
|
|
+ };
|
|
|
+ const byProperty = new Map(existingFeedback.map(item => [String(item.propertyId || ''), item]));
|
|
|
+ byProperty.set(String(feedback.propertyId), feedback);
|
|
|
+ const evidence = { ...(current.profile?.__evidence || {}) };
|
|
|
+ evidence.propertyFeedback = { text: recommendation.feedback_reason || `人工标记为${recommendation.status}`, sourceMessageId: null, source: 'human', updatedAt: feedback.recordedAt };
|
|
|
+ workbench.db.updateProfile(conversationId, { ...current.profile, propertyFeedback: [...byProperty.values()].slice(-50), __evidence: evidence }, current.tags);
|
|
|
+ workbench.db.audit({ actor: 'human', action: 'property_recommendation_feedback', conversationId, entityId: recommendation.id, detail: { propertyId: recommendation.property_id, status: recommendation.status, reason: recommendation.feedback_reason } });
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ assistantMessage: `房源“${recommendation.property_snapshot?.community || recommendation.property_id}”反馈已记录为 ${recommendation.status}。`,
|
|
|
+ summary: { recommendationId: recommendation.id, status: recommendation.status },
|
|
|
+ data: { recommendation },
|
|
|
+ warnings: [],
|
|
|
+ errors: [],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function syncConversations() {
|
|
|
+ const allowlist = new Set(workbench.config.qiwei.allowedSenders.map(String));
|
|
|
+ if (!allowlist.size) throw new Error('测试联系人白名单为空,无法同步会话');
|
|
|
+ const account = await detectAccountStatus(true);
|
|
|
+ if (!account.online) throw new Error('测试账号当前不在线,无法同步企微会话');
|
|
|
+
|
|
|
+ const grouped = new Map();
|
|
|
+ const seen = new Set();
|
|
|
+ const seenSemantic = new Set();
|
|
|
+ let cursor = 0;
|
|
|
+ let pages = 0;
|
|
|
+ let scannedMessages = 0;
|
|
|
+
|
|
|
+ while (pages < workbench.config.qiwei.initialSyncMaxPages) {
|
|
|
+ const result = await workbench.qiwei.syncMessages(cursor, workbench.config.qiwei.initialSyncLimit);
|
|
|
+ const list = Array.isArray(result.syncMsgList) ? result.syncMsgList : [];
|
|
|
+ scannedMessages += list.length;
|
|
|
+
|
|
|
+ for (const message of list) {
|
|
|
+ const senderId = String(message.senderId || '');
|
|
|
+ const receiverId = String(message.receiverId || '');
|
|
|
+ const contactId = allowlist.has(senderId) ? senderId : allowlist.has(receiverId) ? receiverId : '';
|
|
|
+ const content = String(message.msgData?.content || '').trim();
|
|
|
+ if (!contactId || !content || ![0, 1, 2].includes(Number(message.msgType))) continue;
|
|
|
+
|
|
|
+ const inbound = senderId === contactId;
|
|
|
+ const timestampSeconds = messageTimestamp(message.timestamp);
|
|
|
+ if (!timestampSeconds) continue;
|
|
|
+ const timestamp = new Date(timestampSeconds * 1000).toISOString();
|
|
|
+ const externalId = String(message.msgServerId || message.msgUniqueIdentifier || '');
|
|
|
+ const dedupeKey = externalId || `${contactId}|${inbound ? 'in' : 'out'}|${timestamp}|${content}`;
|
|
|
+ if (seen.has(dedupeKey)) continue;
|
|
|
+ seen.add(dedupeKey);
|
|
|
+ const semanticKey = `${contactId}|${inbound ? 'in' : 'out'}|${timestamp}|${content}`;
|
|
|
+ if (seenSemantic.has(semanticKey)) continue;
|
|
|
+ seenSemantic.add(semanticKey);
|
|
|
+
|
|
|
+ if (!grouped.has(contactId)) grouped.set(contactId, { contactName: '', messages: [] });
|
|
|
+ const group = grouped.get(contactId);
|
|
|
+ if (inbound && message.senderName) group.contactName = String(message.senderName);
|
|
|
+ group.messages.push({
|
|
|
+ externalId: externalId || null,
|
|
|
+ inbound,
|
|
|
+ content,
|
|
|
+ timestamp,
|
|
|
+ raw: { seq: message.seq, msgType: message.msgType, timestamp: message.timestamp, source: 'manual_sync' },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const seqs = list.map(item => Number(item.seq)).filter(Number.isFinite);
|
|
|
+ const next = Math.max(cursor, Number(result.travelSyncKey) || 0, seqs.length ? Math.max(...seqs) : 0);
|
|
|
+ pages += 1;
|
|
|
+ if (!list.length || next <= cursor) break;
|
|
|
+ cursor = next;
|
|
|
+ }
|
|
|
+
|
|
|
+ let syncedMessages = 0;
|
|
|
+ let removedImportedMessages = 0;
|
|
|
+ for (const [contactId, group] of grouped.entries()) {
|
|
|
+ const conversation = workbench.db.ensureConversation(contactId, group.contactName || '白名单企微联系人');
|
|
|
+ removedImportedMessages += workbench.db.deleteImportedMessages(conversation.id, 'manual_sync');
|
|
|
+ const ordered = group.messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
|
+ const recent = ordered.slice(-20);
|
|
|
+ for (const message of recent) {
|
|
|
+ const inserted = workbench.db.insertMessage({
|
|
|
+ conversationId: conversation.id,
|
|
|
+ externalId: message.externalId,
|
|
|
+ direction: message.inbound ? 'inbound' : 'outbound',
|
|
|
+ senderType: message.inbound ? 'customer' : 'human',
|
|
|
+ content: message.content,
|
|
|
+ status: message.inbound ? 'received' : 'sent',
|
|
|
+ createdAt: message.timestamp,
|
|
|
+ raw: message.raw,
|
|
|
+ });
|
|
|
+ if (inserted.created) syncedMessages += 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const duplicatesRemoved = workbench.db.cleanupInboundContentDuplicates(60);
|
|
|
+ workbench.db.audit({
|
|
|
+ actor: 'human',
|
|
|
+ action: 'conversation_history_synced',
|
|
|
+ detail: { conversations: grouped.size, insertedMessages: syncedMessages, removedImportedMessages, scannedMessages, duplicatesRemoved },
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ assistantMessage: grouped.size
|
|
|
+ ? `已补采 ${grouped.size} 个白名单真实会话的最近消息;只入库,不运行 Agent、不发送回复`
|
|
|
+ : '未读取到白名单联系人的文字会话,请先在企微中与该联系人收发一条消息',
|
|
|
+ data: {
|
|
|
+ syncedConversationCount: grouped.size,
|
|
|
+ syncedMessageCount: syncedMessages,
|
|
|
+ scannedMessageCount: scannedMessages,
|
|
|
+ conversations: displayableConversations().map(publicConversation),
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function changeGlobalMode(mode) {
|
|
|
+ const selected = String(mode || '');
|
|
|
+ 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 === 'human') update = { paused: false, defaultMode: 'human' };
|
|
|
+ else throw new Error('不支持的 Agent 模式');
|
|
|
+ const global = workbench.service.setGlobal(update);
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ assistantMessage: global.paused ? 'Agent 已全局暂停;仍可接收消息,但不会生成或发送回复' : `全局策略已切换为 ${global.defaultMode}`,
|
|
|
+ data: { global, globalMode: global.paused ? 'paused' : global.defaultMode },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function changeConversationMode(id, mode) {
|
|
|
+ const mapped = mode === 'agent' ? 'review' : mode === 'manual' ? 'human' : mode;
|
|
|
+ const conversation = workbench.service.setConversationMode(id, mapped);
|
|
|
+ const labels = { review: '待审核', auto: '高置信自动', human: '人工接管', paused: '会话暂停' };
|
|
|
+ return { status: 'ok', assistantMessage: `会话已切换为${labels[mapped]}`, data: { conversation } };
|
|
|
+}
|
|
|
+
|
|
|
+async function approveReply(id, content) {
|
|
|
+ const detail = workbench.service.conversationDetail(id);
|
|
|
+ if (!detail) throw new Error('会话不存在');
|
|
|
+ const pending = detail.drafts.find(item => item.status === 'pending');
|
|
|
+ const result = pending
|
|
|
+ ? await workbench.service.approveDraft(pending.id, { content, actor: 'human' })
|
|
|
+ : { status: 'sent', message: await workbench.service.manualSend(id, content, 'human') };
|
|
|
+ return { status: 'ok', assistantMessage: '回复已真实发送给白名单测试联系人', data: result };
|
|
|
+}
|
|
|
+
|
|
|
+async function approveDraft(draftId, content) {
|
|
|
+ const result = await workbench.service.approveDraft(draftId, { content, actor: 'human' });
|
|
|
+ return { status: 'ok', assistantMessage: '审核通过,回复已真实发送且只发送一次', data: result };
|
|
|
+}
|
|
|
+
|
|
|
+function rejectDraft(draftId, reason) {
|
|
|
+ const result = workbench.service.rejectDraft(draftId, { reason, actor: 'human' });
|
|
|
+ return { status: 'ok', assistantMessage: '草稿已驳回,不会发送给客户', data: result };
|
|
|
+}
|
|
|
+
|
|
|
+async function regenerateDraft(draftId) {
|
|
|
+ const result = await workbench.service.regenerateDraft(draftId, 'human');
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ assistantMessage: result.status === 'pending_review' ? 'Agent 已重新生成待审核草稿' : (result.error || 'Agent 重新生成未完成'),
|
|
|
+ data: result,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function generateLatestDraft(conversationId) {
|
|
|
+ const result = await workbench.service.generateLatestDraft(conversationId, 'human');
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ assistantMessage: result.status === 'pending_review' ? 'Agent 已生成待审核草稿' : (result.error || 'Agent 未生成草稿'),
|
|
|
+ data: result,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function manualSend(conversationId, content) {
|
|
|
+ const message = await workbench.service.manualSend(conversationId, content, 'human');
|
|
|
+ return { status: 'ok', assistantMessage: '人工回复已真实发送给白名单测试联系人', data: { message } };
|
|
|
+}
|
|
|
+
|
|
|
+async function updateCustomerTask(taskId, input = {}) {
|
|
|
+ if (!['open', 'in_progress', 'done', 'dismissed'].includes(String(input.status || ''))) throw new Error('不支持的客户待办状态');
|
|
|
+ const existing = workbench.db.getCustomerTask(taskId);
|
|
|
+ if (!existing) throw new Error('客户待办不存在');
|
|
|
+ let officialResult = null;
|
|
|
+ if (input.status === 'done' && existing.official_todo_id) {
|
|
|
+ officialResult = await completeTodoKnowledge(existing.official_todo_id);
|
|
|
+ }
|
|
|
+ const officialOk = !officialResult || officialResult.status === 'ok';
|
|
|
+ const task = workbench.db.updateCustomerTask(taskId, {
|
|
|
+ status: input.status,
|
|
|
+ resolution_reason: input.status === 'done' ? 'human_completed' : input.status === 'dismissed' ? 'human_dismissed' : '',
|
|
|
+ ...(existing.official_todo_id ? { official_sync_status: officialOk ? input.status : 'error' } : {}),
|
|
|
+ });
|
|
|
+ if (!task) throw new Error('客户待办不存在');
|
|
|
+ workbench.db.audit({ actor: 'human', action: 'customer_task_updated', conversationId: task.conversation_id, entityId: task.id, detail: { status: task.status, officialSynced: Boolean(existing.official_todo_id), officialOk } });
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ assistantMessage: existing.official_todo_id && !officialOk
|
|
|
+ ? `本地待办已更新为 ${task.status},但企微官方待办同步失败,请稍后重试`
|
|
|
+ : `客户待办已更新为 ${task.status}${existing.official_todo_id ? ',企微官方待办已同步' : ''}`,
|
|
|
+ data: { task, officialResult },
|
|
|
+ warnings: existing.official_todo_id && !officialOk ? ['企微官方待办状态尚未同步'] : [],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function syncCustomerTaskToOfficialTodo(taskId, input = {}) {
|
|
|
+ const syncCurrentAccountTask = createCustomerTaskOfficialSync({
|
|
|
+ db: workbench.db,
|
|
|
+ searchTodoUsers,
|
|
|
+ createTodoKnowledge,
|
|
|
+ });
|
|
|
+ return syncCurrentAccountTask(taskId, input);
|
|
|
+}
|
|
|
+
|
|
|
+function updateCustomerAlert(alertId, input = {}) {
|
|
|
+ if (!['open', 'acknowledged', 'resolved', 'dismissed'].includes(String(input.status || ''))) throw new Error('不支持的客户预警状态');
|
|
|
+ const alert = workbench.db.updateCustomerAlert(alertId, { status: input.status });
|
|
|
+ if (!alert) throw new Error('客户预警不存在');
|
|
|
+ workbench.db.audit({ actor: 'human', action: 'customer_alert_updated', conversationId: alert.conversation_id, entityId: alert.id, detail: { status: alert.status } });
|
|
|
+ return { status: 'ok', assistantMessage: `客户预警已更新为 ${alert.status}`, data: { alert } };
|
|
|
+}
|
|
|
+
|
|
|
+function getAudit(limit = 200) {
|
|
|
+ return { status: 'ok', data: { audit: workbench.db.listAudit(Math.max(1, Math.min(500, Number(limit) || 200))) } };
|
|
|
+}
|
|
|
+
|
|
|
+async function startListener() {
|
|
|
+ const account = await detectAccountStatus(true);
|
|
|
+ if (!account.online) throw new Error(`${account.nickname || '当前账号'}不在线,无法启动真实消息监听`);
|
|
|
+ workbench.service.setGlobal({ paused: false, defaultMode: 'auto' });
|
|
|
+ for (const conversation of workbench.db.listConversations()) {
|
|
|
+ workbench.service.setConversationMode(conversation.id, 'auto');
|
|
|
+ }
|
|
|
+ let status;
|
|
|
+ try {
|
|
|
+ status = await workbench.poller.start();
|
|
|
+ } catch (error) {
|
|
|
+ workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
|
|
|
+ for (const conversation of workbench.db.listConversations()) {
|
|
|
+ workbench.service.setConversationMode(conversation.id, 'human');
|
|
|
+ }
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ assistantMessage: 'AI 监听已启动:仅处理白名单联系人,高置信回复可自动发送,人工可随时接管',
|
|
|
+ data: status,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function stopListener() {
|
|
|
+ const status = workbench.poller.stop();
|
|
|
+ workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
|
|
|
+ for (const conversation of workbench.db.listConversations()) {
|
|
|
+ workbench.service.setConversationMode(conversation.id, 'human');
|
|
|
+ }
|
|
|
+ return { status: 'ok', assistantMessage: 'AI 监听已关闭,现有会话已切换为人工接管', data: status };
|
|
|
+}
|
|
|
+
|
|
|
+function getAgentRuntimeConfig() {
|
|
|
+ return { ...workbench.config.agent };
|
|
|
+}
|
|
|
+
|
|
|
+module.exports = {
|
|
|
+ switchActiveAccount,
|
|
|
+ getAgentStatus,
|
|
|
+ getConversations,
|
|
|
+ updateCustomerProfile,
|
|
|
+ updateCustomerRecommendation,
|
|
|
+ syncConversations,
|
|
|
+ changeGlobalMode,
|
|
|
+ changeConversationMode,
|
|
|
+ approveReply,
|
|
|
+ approveDraft,
|
|
|
+ rejectDraft,
|
|
|
+ regenerateDraft,
|
|
|
+ generateLatestDraft,
|
|
|
+ manualSend,
|
|
|
+ updateCustomerTask,
|
|
|
+ syncCustomerTaskToOfficialTodo,
|
|
|
+ updateCustomerAlert,
|
|
|
+ getAudit,
|
|
|
+ startListener,
|
|
|
+ stopListener,
|
|
|
+ getAgentRuntimeConfig,
|
|
|
+ createWorkbench,
|
|
|
+ __testing: { loadAgentConfig, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage },
|
|
|
+};
|