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 }, };