agent-service.js 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138
  1. const fs = require('fs');
  2. const path = require('path');
  3. const crypto = require('crypto');
  4. const { latestPath } = require('../core/output-paths');
  5. const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
  6. const { AgentKnowledgeStore } = require('../core/agent-knowledge');
  7. const { QiweiAgentRuntime, extractExplicitCustomerIntelligence } = require('../core/agent-runtime');
  8. const { getCustomerSessionGuide } = require('../core/agent-session-guide');
  9. const { AgentWorkbenchService } = require('../core/agent-workbench-service');
  10. const {
  11. searchTodoUsers,
  12. createTodoKnowledge,
  13. completeTodoKnowledge,
  14. } = require('./official-office-knowledge-service');
  15. const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
  16. const { messageTimestamp, evaluatePolledMessage } = require('../core/agent-poller-policy');
  17. const { setActiveQiweiContext } = require('../core/credentials');
  18. const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
  19. const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
  20. const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
  21. function readEnvFile(filePath) {
  22. try {
  23. const env = {};
  24. for (const rawLine of fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/)) {
  25. const line = rawLine.trim();
  26. if (!line || line.startsWith('#') || !line.includes('=')) continue;
  27. const index = line.indexOf('=');
  28. const key = line.slice(0, index).trim();
  29. let value = line.slice(index + 1).trim();
  30. if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
  31. env[key] = value;
  32. }
  33. return env;
  34. } catch {
  35. return {};
  36. }
  37. }
  38. function readClaudeSettingsEnv() {
  39. const result = {};
  40. const home = process.env.USERPROFILE || process.env.HOME || '';
  41. for (const filePath of [path.join(home, '.claude', 'settings.json'), path.join(home, '.claude', 'settings.local.json')]) {
  42. try {
  43. const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
  44. for (const [key, item] of Object.entries(parsed.env || {})) {
  45. if (!result[key] && typeof item === 'string' && item.trim()) result[key] = item.trim();
  46. }
  47. } catch {}
  48. }
  49. return result;
  50. }
  51. const fileEnv = readEnvFile(ENV_FILE);
  52. const claudeEnv = readClaudeSettingsEnv();
  53. function value(name, fallback = '') {
  54. const candidates = [process.env[name], fileEnv[name], claudeEnv[name], fallback];
  55. return String(candidates.find(item => typeof item === 'string' && item.trim()) ?? '').trim();
  56. }
  57. function bool(name, fallback = false) {
  58. return /^(1|true|yes|on)$/i.test(value(name, fallback ? 'true' : 'false'));
  59. }
  60. function number(name, fallback, min = -Infinity, max = Infinity) {
  61. const parsed = Number(value(name, String(fallback)));
  62. return Math.min(max, Math.max(min, Number.isFinite(parsed) ? parsed : fallback));
  63. }
  64. function resolvePath(input, fallback) {
  65. const selected = input || fallback;
  66. return selected ? path.resolve(PROJECT_ROOT, selected) : '';
  67. }
  68. function loadAgentConfig(overrides = {}) {
  69. const provider = value('AGENT_PROVIDER', 'claude-code');
  70. const anthropic = provider === 'anthropic';
  71. const claudeCode = provider === 'claude-code';
  72. const bundledPropertyFile = path.join(PROJECT_ROOT, 'knowledge-base', 'property-data', 'properties.json');
  73. const workspacePropertyFile = path.resolve(PROJECT_ROOT, '..', '..', 'huaxiangpipei', 'src', 'assets', 'data', 'properties.json');
  74. const configuredPropertyFile = value('QIWEI_AGENT_PROPERTY_DATA_FILE');
  75. const propertyDataFile = configuredPropertyFile
  76. ? resolvePath(configuredPropertyFile)
  77. : (fs.existsSync(bundledPropertyFile) ? bundledPropertyFile : (fs.existsSync(workspacePropertyFile) ? workspacePropertyFile : ''));
  78. const baseConfig = {
  79. dbPath: resolvePath(value('QIWEI_AGENT_DB_PATH'), latestPath('messages', 'agent-workbench.db')),
  80. legacyDbPath: path.resolve(PROJECT_ROOT, '..', '..', 'qiwei-agent-workbench', 'data', 'workbench.db'),
  81. globalDefaultPaused: bool('QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED', true),
  82. conversationDefaultMode: value('QIWEI_AGENT_DEFAULT_MODE', 'review'),
  83. autoSendConfidence: number('QIWEI_AGENT_AUTO_SEND_CONFIDENCE', 0.88, 0, 1),
  84. knowledgeDir: resolvePath(value('QIWEI_AGENT_KNOWLEDGE_DIR'), path.join(PROJECT_ROOT, 'knowledge')),
  85. propertyDataFile,
  86. agent: {
  87. provider,
  88. apiKey: value('AGENT_API_KEY') || value(anthropic ? 'ANTHROPIC_AUTH_TOKEN' : 'OPENAI_API_KEY') || value('QIWEI_AUTO_REPLY_AI_KEY'),
  89. 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(/\/$/, ''),
  90. model: value('AGENT_MODEL') || value(anthropic || claudeCode ? 'ANTHROPIC_MODEL' : 'OPENAI_MODEL') || value('QIWEI_AUTO_REPLY_AI_MODEL') || (anthropic || claudeCode ? 'sonnet' : 'gpt-4.1-mini'),
  91. maxToolRounds: number('AGENT_MAX_TOOL_ROUNDS', 4, 1, 8),
  92. claudeExecutable: value('CLAUDE_CODE_EXECUTABLE') || path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
  93. claudeWorkdir: resolvePath(value('CLAUDE_CODE_WORKDIR'), PROJECT_ROOT),
  94. claudeSessionFile: resolvePath(value('CLAUDE_CODE_SESSION_FILE'), latestPath('messages', 'claude-code-sessions.json')),
  95. claudeProjectId: value('QIWEI_AGENT_PROJECT_ID') || crypto.createHash('sha256').update(PROJECT_ROOT).digest('hex').slice(0, 16),
  96. claudeMainSessionId: value('QIWEI_AGENT_MAIN_SESSION_ID'),
  97. claudeTimeoutMs: number('CLAUDE_CODE_TIMEOUT_MS', 120000, 15000, 300000),
  98. claudeMaxBudgetUsd: number('CLAUDE_CODE_MAX_BUDGET_USD', 0.35, 0.05, 5),
  99. claudeTools: value('CLAUDE_CODE_ALLOWED_TOOLS', 'Read,Glob,Grep'),
  100. },
  101. qiwei: {
  102. transport: 'fmode-gateway',
  103. authToken: value('QIWEI_AUTH_TOKEN'),
  104. uid: value('QIWEI_UID') || value('QIWE_UID'),
  105. guid: value('QIWEI_GUID') || value('QIWE_GUID'),
  106. apiBase: value('QIWEI_API_BASE') || value('QIWE_API_BASE'),
  107. userId: '',
  108. nickname: '',
  109. corpName: '',
  110. allowedSenders: value('QIWEI_AUTO_REPLY_ALLOWED_SENDERS').split(',').map(item => item.trim()).filter(Boolean),
  111. selfUserId: value('QIWEI_AUTO_REPLY_SELF_USER_ID'),
  112. intervalMs: number('QIWEI_AUTO_REPLY_INTERVAL_MS', 10000, 3000, 60000),
  113. initialSyncLimit: number('QIWEI_AGENT_INITIAL_SYNC_LIMIT', 5000, 100, 5000),
  114. initialSyncMaxPages: number('QIWEI_AGENT_INITIAL_SYNC_MAX_PAGES', 200, 10, 500),
  115. startupGraceSeconds: number('QIWEI_AGENT_STARTUP_GRACE_SECONDS', 10, 0, 60),
  116. },
  117. };
  118. const config = {
  119. ...baseConfig,
  120. ...overrides,
  121. agent: { ...baseConfig.agent, ...(overrides.agent || {}) },
  122. qiwei: { ...baseConfig.qiwei, ...(overrides.qiwei || {}) },
  123. };
  124. config.agent.claudeAddDirs = [config.knowledgeDir, config.propertyDataFile ? path.dirname(config.propertyDataFile) : ''].filter(Boolean);
  125. return config;
  126. }
  127. function accountRuntimeKey(input = {}) {
  128. const source = String(input.uid || input.guid || input.userId || 'default').trim();
  129. return crypto.createHash('sha256').update(source || 'default').digest('hex').slice(0, 16);
  130. }
  131. function accountWorkbenchOverrides(input = {}) {
  132. const storageKey = accountRuntimeKey(input);
  133. return {
  134. dbPath: latestPath('messages', `agent-workbench-${storageKey}.db`),
  135. agent: {
  136. claudeSessionFile: latestPath('messages', `claude-code-sessions-${storageKey}.json`),
  137. claudeProjectId: `${crypto.createHash('sha256').update(PROJECT_ROOT).digest('hex').slice(0, 12)}-${storageKey.slice(0, 8)}`,
  138. },
  139. qiwei: {
  140. uid: String(input.uid || '').trim(),
  141. guid: String(input.guid || '').trim(),
  142. userId: String(input.userId || '').trim(),
  143. nickname: String(input.nickname || '').trim(),
  144. corpName: String(input.corpName || '').trim(),
  145. apiBase: String(input.apiBase || '').trim(),
  146. },
  147. };
  148. }
  149. function backfillCustomerIntelligence(db) {
  150. const version = '4';
  151. if (db.getSetting('customer_intelligence_backfill_version', '') === version) return { skipped: true, profileFields: 0, taskCount: 0, alertCount: 0 };
  152. const removed = db.lastIntelligenceMigration || { tasks: { removed: 0 }, alerts: { removed: 0 } };
  153. let profileFields = 0;
  154. let taskCount = 0;
  155. let alertCount = 0;
  156. for (const conversation of db.listConversations()) {
  157. const existing = db.getProfile(conversation.id);
  158. let current = { profile: { ...existing.profile }, tags: existing.tags || [] };
  159. for (const message of db.listMessages(conversation.id, 200).filter(item => item.direction === 'inbound')) {
  160. const intelligence = extractExplicitCustomerIntelligence(message.content, current.profile || {}, {});
  161. if (Object.keys(intelligence.profileUpdates).length) {
  162. current = { profile: { ...current.profile, ...intelligence.profileUpdates }, tags: current.tags };
  163. profileFields += Object.keys(intelligence.profileUpdates).length;
  164. }
  165. const changed = Object.keys(intelligence.profileUpdates).length > 0;
  166. const tasks = intelligence.tasks.map(item => ({ ...item, sourceMessageId: changed ? message.id : null }));
  167. const alerts = intelligence.alerts.map(item => ({ ...item, sourceMessageId: changed || item.managedBy === 'event' ? message.id : null }));
  168. taskCount += db.reconcileCustomerTasks(conversation.id, tasks, message.id).tasks.length;
  169. alertCount += db.reconcileCustomerAlerts(conversation.id, alerts, message.id).alerts.length;
  170. }
  171. if (Object.keys(current.profile).length) db.updateProfile(conversation.id, { ...existing.profile, ...current.profile }, existing.tags);
  172. const recommendationSent = db.listMessages(conversation.id, 200).some(message =>
  173. message.direction === 'outbound' && /(房源|方案|重点|推荐).{0,20}(套|房)|(套|房).{0,20}(房源|方案|推荐)/.test(String(message.content || ''))
  174. );
  175. if (recommendationSent) db.completeCustomerTaskByBusinessKey(conversation.id, 'recommendation:shortlist', 'historical_recommendation_sent');
  176. }
  177. db.setSetting('customer_intelligence_backfill_version', version);
  178. if (profileFields || taskCount || alertCount) {
  179. db.audit({ actor: 'migration', action: 'customer_intelligence_backfilled', detail: { version, removed, profileFields, taskCount, alertCount } });
  180. }
  181. return { version, removed, profileFields, taskCount, alertCount };
  182. }
  183. const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
  184. class QiweiAgentPoller {
  185. constructor({ config, db, qiwei, service }) {
  186. this.config = config;
  187. this.db = db;
  188. this.qiwei = qiwei;
  189. this.service = service;
  190. this.running = false;
  191. this.loopPromise = null;
  192. this.startedAt = 0;
  193. this.lastError = '';
  194. }
  195. status() {
  196. return {
  197. running: this.running,
  198. syncKey: Number(this.db.getPollState('sync_key', '0')),
  199. lastError: this.lastError,
  200. startedAt: this.startedAt || null,
  201. };
  202. }
  203. async start() {
  204. if (this.running) return this.status();
  205. if (!this.qiwei.isConfigured()) throw new Error('Fmode 企微网关尚未配置,请先完成 Fmode 鉴权和企微扫码登录');
  206. if (this.config.allowedSenders.length === 0) throw new Error('企微联系人白名单为空,拒绝启动');
  207. await this.qiwei.checkLogin();
  208. this.running = true;
  209. this.startedAt = Math.floor(Date.now() / 1000);
  210. this.lastError = '';
  211. let syncKey = Number(this.db.getPollState('sync_key', '0')) || 0;
  212. if (syncKey === 0) syncKey = await this.establishBaseline();
  213. this.db.audit({ actor: 'runtime', action: 'poller_started', detail: { syncKey, allowlistCount: this.config.allowedSenders.length } });
  214. this.loopPromise = this.loop(syncKey);
  215. return this.status();
  216. }
  217. stop() {
  218. if (!this.running) return this.status();
  219. this.running = false;
  220. this.db.audit({ actor: 'runtime', action: 'poller_stopped', detail: this.status() });
  221. return this.status();
  222. }
  223. async establishBaseline() {
  224. let cursor = 0;
  225. let reachedEnd = false;
  226. let pages = 0;
  227. let count = 0;
  228. while (pages < this.config.initialSyncMaxPages) {
  229. const result = await this.qiwei.syncMessages(cursor, this.config.initialSyncLimit);
  230. const list = result.syncMsgList || [];
  231. pages += 1;
  232. count += list.length;
  233. const seqs = list.map(item => Number(item.seq)).filter(Number.isFinite);
  234. const next = Math.max(cursor, Number(result.travelSyncKey) || 0, seqs.length ? Math.max(...seqs) : 0);
  235. if (list.length === 0) { reachedEnd = true; break; }
  236. if (next <= cursor) throw new Error(`历史消息游标没有前进(seq=${cursor})`);
  237. cursor = next;
  238. }
  239. if (!reachedEnd) throw new Error(`历史消息超过 ${this.config.initialSyncMaxPages} 页,拒绝启动自动处理`);
  240. this.db.setPollState('sync_key', cursor);
  241. this.db.audit({ actor: 'runtime', action: 'poller_baseline_established', detail: { cursor, pages, skippedHistory: count } });
  242. return cursor;
  243. }
  244. async loop(initialSyncKey) {
  245. let syncKey = initialSyncKey;
  246. while (this.running) {
  247. try {
  248. const result = await this.qiwei.syncMessages(syncKey, 50);
  249. const list = result.syncMsgList || [];
  250. const seqs = list.map(item => Number(item.seq)).filter(Number.isFinite);
  251. const next = Math.max(syncKey, Number(result.travelSyncKey) || 0, seqs.length ? Math.max(...seqs) : 0);
  252. for (const message of list) await this.process(message);
  253. syncKey = next;
  254. this.db.setPollState('sync_key', syncKey);
  255. this.lastError = '';
  256. } catch (error) {
  257. this.lastError = error.message;
  258. this.db.audit({ actor: 'runtime', action: 'poller_error', detail: { message: error.message } });
  259. }
  260. if (this.running) await delay(this.config.intervalMs);
  261. }
  262. }
  263. async process(message) {
  264. const candidate = evaluatePolledMessage(message, this.config);
  265. if (!candidate.eligible) return;
  266. const { content, senderId, timestamp } = candidate;
  267. await this.service.ingestInbound({
  268. externalId: String(message.msgServerId || message.msgUniqueIdentifier || `${senderId}:${message.seq}`),
  269. contactId: senderId,
  270. contactName: message.senderName || '王刚',
  271. content,
  272. timestamp: new Date(timestamp * 1000).toISOString(),
  273. raw: { seq: message.seq, msgType: message.msgType, timestamp: message.timestamp },
  274. });
  275. }
  276. }
  277. function createWorkbench(overrides = {}) {
  278. const config = loadAgentConfig(overrides.config || {});
  279. const db = overrides.db || new AgentWorkbenchDb(config.dbPath, {
  280. globalPaused: config.globalDefaultPaused,
  281. defaultMode: config.conversationDefaultMode,
  282. autoSendConfidence: config.autoSendConfidence,
  283. });
  284. if (!overrides.db && !overrides.skipLegacyImport) {
  285. try { db.importCompatibleDatabase(config.legacyDbPath); } catch (error) {
  286. db.audit({ actor: 'migration', action: 'legacy_workbench_import_failed', detail: { message: error.message } });
  287. }
  288. const removed = db.cleanupInboundContentDuplicates(60);
  289. if (removed) db.audit({ actor: 'migration', action: 'duplicate_messages_cleaned', detail: { removed } });
  290. backfillCustomerIntelligence(db);
  291. }
  292. const knowledge = overrides.knowledge || new AgentKnowledgeStore({
  293. knowledgeDir: config.knowledgeDir,
  294. propertyDataFile: config.propertyDataFile,
  295. });
  296. backfillPropertyRecommendations(db, knowledge);
  297. const qiwei = overrides.qiwei || new FmodeQiweiClient(config.qiwei);
  298. const agent = overrides.agent || new QiweiAgentRuntime({ config: config.agent, knowledge });
  299. const service = overrides.service || new AgentWorkbenchService({ db, agent, qiwei, config });
  300. const poller = overrides.poller || new QiweiAgentPoller({ config: config.qiwei, db, qiwei, service });
  301. return { config, db, knowledge, qiwei, agent, service, poller };
  302. }
  303. function configuredStartupAccount() {
  304. return {
  305. uid: value('QIWEI_UID') || value('QIWE_UID'),
  306. guid: value('QIWEI_GUID') || value('QIWE_GUID'),
  307. apiBase: value('QIWEI_API_BASE') || value('QIWE_API_BASE'),
  308. userId: '',
  309. nickname: '',
  310. corpName: '',
  311. };
  312. }
  313. const startupAccount = configuredStartupAccount();
  314. let workbench = createWorkbench(startupAccount.uid ? {
  315. config: accountWorkbenchOverrides(startupAccount),
  316. skipLegacyImport: true,
  317. } : {});
  318. function migrateStartupWorkbench(target) {
  319. if (!startupAccount.uid || target.db.listConversations().length) return { imported: false, reason: 'target_not_empty' };
  320. const candidates = [latestPath('messages', 'agent-workbench.db'), target.config.legacyDbPath];
  321. for (const sourcePath of candidates) {
  322. try {
  323. const result = target.db.importCompatibleDatabase(sourcePath);
  324. if (!result.imported) continue;
  325. const removed = target.db.cleanupInboundContentDuplicates(60);
  326. backfillCustomerIntelligence(target.db);
  327. backfillPropertyRecommendations(target.db, target.knowledge);
  328. target.db.audit({
  329. actor: 'migration',
  330. action: 'account_workbench_migration_completed',
  331. detail: { source: path.basename(sourcePath), removedDuplicates: removed },
  332. });
  333. return result;
  334. } catch (error) {
  335. target.db.audit({
  336. actor: 'migration',
  337. action: 'account_workbench_migration_failed',
  338. detail: { source: path.basename(sourcePath), message: error.message },
  339. });
  340. }
  341. }
  342. return { imported: false, reason: 'source_missing_or_incompatible' };
  343. }
  344. migrateStartupWorkbench(workbench);
  345. let accountStatusCache = { checkedAt: 0, value: null };
  346. let accountStatusRefresh = null;
  347. let accountStatusRefreshKey = '';
  348. const accountLastOnlineAt = new Map();
  349. const accountOfflineChecks = new Map();
  350. const ONLINE_STATUS_GRACE_MS = 60000;
  351. const workbenches = new Map();
  352. function activeAccountMetadata() {
  353. const context = workbench.qiwei.context();
  354. return {
  355. uid: String(context.uid || workbench.config.qiwei.uid || '').trim(),
  356. guid: String(context.guid || workbench.config.qiwei.guid || '').trim(),
  357. apiBase: String(context.apiBase || workbench.config.qiwei.apiBase || '').trim(),
  358. userId: String(workbench.config.qiwei.userId || '').trim(),
  359. nickname: String(workbench.config.qiwei.nickname || '').trim(),
  360. corpName: String(workbench.config.qiwei.corpName || '').trim(),
  361. };
  362. }
  363. function applyActiveAccountContext(account) {
  364. setActiveQiweiContext(account);
  365. Object.assign(workbench.config.qiwei, account);
  366. if (workbench.qiwei && workbench.qiwei.config) Object.assign(workbench.qiwei.config, account);
  367. }
  368. function provisionalAccountStatus(selected = activeAccountMetadata(), statusText = '正在检测账号状态') {
  369. return {
  370. uid: selected.uid,
  371. guid: selected.guid,
  372. userId: selected.userId,
  373. configured: workbench.qiwei.isConfigured(),
  374. online: false,
  375. nickname: selected.nickname || selected.userId || '当前企微账号',
  376. corpName: selected.corpName || '',
  377. statusCode: null,
  378. statusText,
  379. };
  380. }
  381. const initialAccount = activeAccountMetadata();
  382. workbenches.set(accountRuntimeKey(initialAccount), workbench);
  383. applyActiveAccountContext(initialAccount);
  384. async function switchActiveAccount(input = {}) {
  385. const account = {
  386. uid: String(input.uid || '').trim(),
  387. guid: String(input.guid || '').trim(),
  388. apiBase: String(input.apiBase || '').trim(),
  389. userId: String(input.userId || '').trim(),
  390. nickname: String(input.nickname || input.userId || '').trim(),
  391. corpName: String(input.corpName || '').trim(),
  392. };
  393. if (!account.uid) throw new Error('该账号缺少 Fmode 设备 uid,请重新扫码绑定后再切换');
  394. const current = activeAccountMetadata();
  395. const currentKey = accountRuntimeKey(current);
  396. const nextKey = accountRuntimeKey(account);
  397. const accountChanged = currentKey !== nextKey;
  398. if (accountChanged && workbench.poller.status().running) workbench.poller.stop();
  399. let nextWorkbench = workbenches.get(nextKey);
  400. if (!nextWorkbench) {
  401. nextWorkbench = createWorkbench({
  402. config: accountWorkbenchOverrides(account),
  403. skipLegacyImport: true,
  404. });
  405. workbenches.set(nextKey, nextWorkbench);
  406. }
  407. workbench = nextWorkbench;
  408. applyActiveAccountContext({
  409. ...activeAccountMetadata(),
  410. ...account,
  411. apiBase: account.apiBase || activeAccountMetadata().apiBase,
  412. });
  413. const status = provisionalAccountStatus();
  414. accountStatusCache = { checkedAt: Date.now(), value: status };
  415. void refreshAccountStatus();
  416. return {
  417. status: 'ok',
  418. assistantMessage: `已切换到账号:${status.nickname || account.nickname || account.userId || account.uid}`,
  419. summary: {
  420. switched: accountChanged,
  421. storageKey: nextKey,
  422. online: status.online,
  423. listenerStopped: accountChanged,
  424. },
  425. data: { account: status },
  426. };
  427. }
  428. async function refreshAccountStatus() {
  429. const selected = activeAccountMetadata();
  430. const targetWorkbench = workbench;
  431. const refreshKey = accountRuntimeKey(selected);
  432. if (accountStatusRefresh && accountStatusRefreshKey === refreshKey) return accountStatusRefresh;
  433. accountStatusRefreshKey = refreshKey;
  434. accountStatusRefresh = (async () => {
  435. let next;
  436. try {
  437. const data = await targetWorkbench.qiwei.checkLogin();
  438. const online = Number(data.userOnlineStatus) === 2 && Number(data.errorCode || 0) === 0;
  439. next = {
  440. uid: selected.uid,
  441. guid: selected.guid,
  442. userId: data.userId || selected.userId,
  443. configured: data.configured !== false,
  444. online,
  445. nickname: data.nickname || selected.nickname || selected.userId || '当前企微账号',
  446. corpName: data.corpName || selected.corpName || '',
  447. statusCode: data.userOnlineStatus ?? null,
  448. statusText: online ? '账号在线' : '账号离线',
  449. };
  450. if (online) {
  451. accountLastOnlineAt.set(refreshKey, Date.now());
  452. accountOfflineChecks.set(refreshKey, 0);
  453. } else {
  454. const offlineChecks = Number(accountOfflineChecks.get(refreshKey) || 0) + 1;
  455. accountOfflineChecks.set(refreshKey, offlineChecks);
  456. const lastOnlineAt = Number(accountLastOnlineAt.get(refreshKey) || 0);
  457. if (offlineChecks < 2 && lastOnlineAt && Date.now() - lastOnlineAt < ONLINE_STATUS_GRACE_MS) {
  458. next.online = true;
  459. next.statusCode = 2;
  460. next.statusText = '账号在线(正在复核)';
  461. }
  462. }
  463. } catch {
  464. next = {
  465. ...provisionalAccountStatus(selected, '状态检测失败'),
  466. configured: targetWorkbench.qiwei.isConfigured(),
  467. };
  468. const lastOnlineAt = Number(accountLastOnlineAt.get(refreshKey) || 0);
  469. if (lastOnlineAt && Date.now() - lastOnlineAt < ONLINE_STATUS_GRACE_MS) {
  470. next.online = true;
  471. next.statusCode = 2;
  472. next.statusText = '账号在线(状态刷新中)';
  473. }
  474. }
  475. if (accountRuntimeKey(activeAccountMetadata()) === refreshKey) {
  476. accountStatusCache = { checkedAt: Date.now(), value: next };
  477. }
  478. return next;
  479. })().finally(() => {
  480. if (accountStatusRefreshKey === refreshKey) {
  481. accountStatusRefresh = null;
  482. accountStatusRefreshKey = '';
  483. }
  484. });
  485. return accountStatusRefresh;
  486. }
  487. async function detectAccountStatus(force = false) {
  488. const selected = activeAccountMetadata();
  489. const selectedKey = accountRuntimeKey(selected);
  490. const cacheMatches = accountStatusCache.value && accountRuntimeKey(accountStatusCache.value) === selectedKey;
  491. if (force) return refreshAccountStatus();
  492. if (cacheMatches) {
  493. if (Date.now() - accountStatusCache.checkedAt >= 8000) void refreshAccountStatus();
  494. return accountStatusCache.value;
  495. }
  496. const provisional = provisionalAccountStatus(selected);
  497. accountStatusCache = { checkedAt: Date.now(), value: provisional };
  498. void refreshAccountStatus();
  499. return provisional;
  500. }
  501. function maskedId(value) {
  502. const text = String(value || '');
  503. if (text.length <= 4) return '测试联系人';
  504. return `${text.slice(0, 2)}***${text.slice(-2)}`;
  505. }
  506. function completeness(profile = {}) {
  507. const values = [
  508. profile.preferredRegion || profile.region || profile.district || profile.intent_area || profile.districts,
  509. profile.budgetWan || profile.budget || profile.budgetMax || profile.budget_max,
  510. profile.layout || profile.rooms || profile.house_type,
  511. profile.area || profile.areaMin || profile.area_min,
  512. profile.decoration,
  513. profile.timeline || profile.urgency,
  514. ];
  515. return Math.round(values.filter(value => Array.isArray(value) ? value.length : Boolean(value)).length / values.length * 100);
  516. }
  517. function parseJson(value, fallback) {
  518. try { return value ? JSON.parse(value) : fallback; }
  519. catch { return fallback; }
  520. }
  521. function propertyMatches(toolTrace = []) {
  522. const call = [...toolTrace].reverse().find(item => item.tool === 'search_properties');
  523. return (call?.result?.items || []).map(item => ({
  524. community: item.community,
  525. title: item.layout,
  526. price: item.totalPrice,
  527. layout: item.layout,
  528. area: item.area,
  529. score: null,
  530. level: call.result.warning || '',
  531. highlights: item.highlights || [],
  532. }));
  533. }
  534. function propertyRecommendationsFromTrace(toolTrace = []) {
  535. 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()];
  536. }
  537. function detectedPropertiesInMessage(content, properties = []) {
  538. const text = String(content || '');
  539. if (!text) return [];
  540. return properties.filter(property => {
  541. const id = String(property.id || '').trim();
  542. if (id && text.includes(id)) return true;
  543. const community = String(property.community || '').trim();
  544. const price = Number(property.totalPrice || 0);
  545. if (!community || !price || !text.includes(community)) return false;
  546. return new RegExp(`${price}(?:\\.0+)?\\s*万`).test(text);
  547. });
  548. }
  549. function backfillPropertyRecommendations(db, knowledge) {
  550. if (!db || typeof db.upsertCustomerRecommendations !== 'function') return 0;
  551. const properties = Array.isArray(knowledge?.properties) ? knowledge.properties : [];
  552. let count = 0;
  553. for (const conversation of db.listConversations()) {
  554. for (const draft of db.listDrafts({ conversationId: conversation.id, limit: 500 })) {
  555. const items = propertyRecommendationsFromTrace(draft.tool_trace);
  556. if (!items.length || draft.status === 'rejected') continue;
  557. db.upsertCustomerRecommendations(conversation.id, items, {
  558. type: 'agent-tool',
  559. entityId: draft.id,
  560. status: ['sent', 'approved'].includes(draft.status) ? 'recommended' : 'candidate',
  561. evidence: draft.status === 'sent' ? String(draft.content || '').slice(0, 500) : 'Agent 房源工具查询结果,尚未确认已发送给客户',
  562. createdAt: draft.created_at,
  563. });
  564. count += items.length;
  565. }
  566. if (!properties.length) continue;
  567. for (const message of db.listMessages(conversation.id, 500).filter(item => item.direction === 'outbound')) {
  568. const items = detectedPropertiesInMessage(message.content, properties);
  569. if (!items.length) continue;
  570. db.upsertCustomerRecommendations(conversation.id, items, {
  571. type: 'outbound-message-detected',
  572. entityId: message.id,
  573. status: 'recommended',
  574. evidence: String(message.content || '').slice(0, 500),
  575. createdAt: message.created_at,
  576. });
  577. count += items.length;
  578. }
  579. }
  580. return count;
  581. }
  582. function publicConversation(row) {
  583. const detail = workbench.service.conversationDetail(row.id);
  584. const claudeSession = getCustomerSessionGuide(row, {
  585. sessionFile: workbench.config.agent.claudeSessionFile,
  586. });
  587. const drafts = detail.drafts || [];
  588. const latestInbound = [...(detail.messages || [])].reverse().find(message => message.direction === 'inbound') || null;
  589. const currentDrafts = latestInbound
  590. ? drafts.filter(item => item.inbound_message_id === latestInbound.id)
  591. : [];
  592. const pending = currentDrafts.find(item => item.status === 'pending') || null;
  593. const latestDraft = pending || currentDrafts.find(item => ['sent', 'approved'].includes(item.status)) || null;
  594. const rawProfile = detail.profile?.profile || {};
  595. const { __evidence: profileEvidence = {}, ...profile } = rawProfile;
  596. const customerTasks = detail.tasks || [];
  597. const customerAlerts = detail.alerts || [];
  598. const customerRecommendations = detail.recommendations || [];
  599. const citations = latestDraft?.citations || [];
  600. const cutoverAt = Date.parse(workbench.db.getSetting('agent_cutover_at', '')) || Date.now();
  601. const displayMessages = [...new Map((detail.messages || []).map(message => [message.id, message])).values()]
  602. .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))
  603. .slice(-60);
  604. const visibleEntityIds = new Set(displayMessages.map(message => message.id));
  605. const visibleAudit = (detail.audit || []).filter(item =>
  606. Date.parse(item.created_at) >= cutoverAt || visibleEntityIds.has(item.entity_id)
  607. ).slice(0, 40);
  608. return {
  609. id: row.id,
  610. displayName: row.contact_name || '白名单测试联系人',
  611. maskedId: maskedId(row.contact_id),
  612. mode: row.mode,
  613. source: 'live',
  614. claudeSession,
  615. messages: displayMessages.map(message => ({
  616. id: message.id,
  617. role: message.direction === 'inbound' ? 'customer' : message.sender_type,
  618. content: message.content,
  619. timestamp: message.created_at,
  620. status: message.status,
  621. source: message.direction === 'inbound' ? 'live' : message.sender_type,
  622. })),
  623. analysis: {
  624. intent: latestDraft?.intent || '',
  625. intentLabel: latestDraft?.intent || (detail.agentError ? 'Agent 上游不可用' : '待 Agent 处理'),
  626. demand: profile,
  627. completenessScore: completeness(profile),
  628. matches: propertyMatches(latestDraft?.tool_trace || []),
  629. knowledgeSources: citations.length
  630. ? citations.map(item => `${item.heading || item.source}${item.source ? ` · ${item.source}` : ''}`)
  631. : ['真实企微消息', '客户画像', '企业规则库与知识库'],
  632. reasoning: latestDraft?.reason || detail.agentError?.message || '消息已进入真实企微链路,等待 Agent 生成可审核草稿。',
  633. },
  634. customerIntelligence: {
  635. profile,
  636. profileEvidence,
  637. profileUpdatedAt: detail.profile?.updatedAt || null,
  638. tags: detail.profile?.tags || [],
  639. tasks: customerTasks.map(item => ({
  640. id: item.id,
  641. businessKey: item.business_key,
  642. type: item.type,
  643. title: item.title,
  644. owner: item.owner,
  645. dueAt: item.due_at,
  646. priority: item.priority,
  647. status: item.status,
  648. reason: item.reason,
  649. evidence: item.evidence,
  650. evidenceItems: parseJson(item.evidence_json, []),
  651. resolutionReason: item.resolution_reason,
  652. officialTodoId: item.official_todo_id,
  653. officialSyncStatus: item.official_sync_status,
  654. officialSyncedAt: item.official_synced_at,
  655. updatedAt: item.updated_at,
  656. })),
  657. alerts: customerAlerts.map(item => ({
  658. id: item.id,
  659. businessKey: item.business_key,
  660. type: item.type,
  661. severity: item.severity,
  662. title: item.title,
  663. detail: item.detail,
  664. evidence: item.evidence,
  665. evidenceItems: parseJson(item.evidence_json, []),
  666. resolutionReason: item.resolution_reason,
  667. recommendedAction: item.recommended_action,
  668. status: item.status,
  669. updatedAt: item.updated_at,
  670. })),
  671. recommendations: customerRecommendations.map(item => ({
  672. id: item.id,
  673. propertyId: item.property_id,
  674. property: item.property_snapshot,
  675. status: item.status,
  676. feedbackReason: item.feedback_reason,
  677. recommendCount: item.recommend_count,
  678. sources: item.sources,
  679. firstRecommendedAt: item.first_recommended_at,
  680. lastRecommendedAt: item.last_recommended_at,
  681. updatedAt: item.updated_at,
  682. })),
  683. summary: {
  684. openTasks: customerTasks.filter(item => ['open', 'in_progress'].includes(item.status)).length,
  685. openAlerts: customerAlerts.filter(item => item.status === 'open').length,
  686. highAlerts: customerAlerts.filter(item => item.status === 'open' && ['high', 'critical'].includes(item.severity)).length,
  687. recommendationCount: customerRecommendations.length,
  688. pendingFeedbackCount: customerRecommendations.filter(item => ['candidate', 'recommended'].includes(item.status)).length,
  689. },
  690. },
  691. pendingReply: pending ? {
  692. id: pending.id,
  693. content: pending.content,
  694. status: pending.status,
  695. confidence: pending.confidence,
  696. reason: pending.reason,
  697. requiresHuman: pending.requires_human,
  698. citations: pending.citations,
  699. toolTrace: pending.tool_trace,
  700. createdAt: pending.created_at,
  701. } : null,
  702. drafts,
  703. audit: visibleAudit,
  704. agentError: detail.agentError,
  705. lastMessageAt: displayMessages.at(-1)?.created_at || row.last_message_at,
  706. updatedAt: row.updated_at,
  707. };
  708. }
  709. async function getAgentStatus() {
  710. const account = await detectAccountStatus();
  711. const state = workbench.service.state(workbench.poller.status());
  712. return {
  713. status: 'ok',
  714. data: {
  715. globalMode: state.global.paused ? 'paused' : state.global.defaultMode,
  716. global: state.global,
  717. listener: state.poller,
  718. account,
  719. agent: state.agent,
  720. knowledge: workbench.knowledge.stats(),
  721. config: {
  722. allowedSenderCount: state.qiwei.allowlistCount,
  723. testMode: false,
  724. demoMode: false,
  725. transport: state.qiwei.transport,
  726. pollIntervalMs: workbench.config.qiwei.intervalMs,
  727. },
  728. safety: {
  729. whitelistEnabled: state.qiwei.allowlistCount > 0,
  730. defaultReviewMode: true,
  731. globalPauseSupported: true,
  732. messageSendRequiresWhitelist: true,
  733. demoReplyDisabled: true,
  734. },
  735. },
  736. };
  737. }
  738. function displayableConversations() {
  739. return workbench.db.listConversations().filter(item => item.last_message_at || item.last_content || Number(item.pending_count || 0) > 0);
  740. }
  741. function getConversations() {
  742. return { status: 'ok', data: { conversations: displayableConversations().map(publicConversation) } };
  743. }
  744. function updateCustomerProfile(conversationId, input = {}) {
  745. const conversation = workbench.db.getConversation(conversationId);
  746. if (!conversation) throw new Error('客户会话不存在');
  747. const current = workbench.db.getProfile(conversationId);
  748. const nextProfile = { ...(current.profile || {}) };
  749. const evidence = { ...(nextProfile.__evidence || {}) };
  750. const patch = input.profile && typeof input.profile === 'object' ? input.profile : {};
  751. const changedFields = [];
  752. for (const [field, rawValue] of Object.entries(patch)) {
  753. if (!field || field.startsWith('__')) continue;
  754. const value = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
  755. if (value === '' || value === null || value === undefined) delete nextProfile[field];
  756. else nextProfile[field] = value;
  757. evidence[field] = {
  758. text: String(input.reason || '客户管理人工核对').trim(),
  759. sourceMessageId: null,
  760. source: 'human',
  761. updatedAt: new Date().toISOString(),
  762. };
  763. changedFields.push(field);
  764. }
  765. nextProfile.__evidence = evidence;
  766. const tags = input.tags === undefined
  767. ? current.tags
  768. : [...new Set((Array.isArray(input.tags) ? input.tags : String(input.tags || '').split(/[,,]/)).map(item => String(item).trim()).filter(Boolean))];
  769. const updated = workbench.db.updateProfile(conversationId, nextProfile, tags);
  770. workbench.db.audit({
  771. actor: 'human',
  772. action: 'customer_profile_updated',
  773. conversationId,
  774. entityId: conversationId,
  775. detail: { fields: changedFields, tagCount: tags.length, reason: String(input.reason || '').trim() },
  776. });
  777. const { __evidence, ...visibleProfile } = updated.profile || {};
  778. return {
  779. status: 'ok',
  780. assistantMessage: `客户“${conversation.contact_name || '未命名客户'}”的主档已更新。`,
  781. summary: { changedFields, tagCount: tags.length },
  782. data: { profile: visibleProfile, profileEvidence: __evidence || {}, tags, updatedAt: updated.updatedAt },
  783. warnings: [],
  784. errors: [],
  785. };
  786. }
  787. function updateCustomerRecommendation(conversationId, recommendationId, input = {}) {
  788. const recommendation = workbench.db.updateCustomerRecommendation(conversationId, recommendationId, {
  789. status: input.status,
  790. feedbackReason: input.feedbackReason,
  791. });
  792. if (!recommendation) throw new Error('房源推荐记录不存在');
  793. const current = workbench.db.getProfile(conversationId);
  794. const existingFeedback = Array.isArray(current.profile?.propertyFeedback) ? current.profile.propertyFeedback : [];
  795. const feedback = {
  796. propertyId: recommendation.property_id,
  797. status: recommendation.status,
  798. reason: recommendation.feedback_reason,
  799. recordedAt: new Date().toISOString(),
  800. };
  801. const byProperty = new Map(existingFeedback.map(item => [String(item.propertyId || ''), item]));
  802. byProperty.set(String(feedback.propertyId), feedback);
  803. const evidence = { ...(current.profile?.__evidence || {}) };
  804. evidence.propertyFeedback = { text: recommendation.feedback_reason || `人工标记为${recommendation.status}`, sourceMessageId: null, source: 'human', updatedAt: feedback.recordedAt };
  805. workbench.db.updateProfile(conversationId, { ...current.profile, propertyFeedback: [...byProperty.values()].slice(-50), __evidence: evidence }, current.tags);
  806. 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 } });
  807. return {
  808. status: 'ok',
  809. assistantMessage: `房源“${recommendation.property_snapshot?.community || recommendation.property_id}”反馈已记录为 ${recommendation.status}。`,
  810. summary: { recommendationId: recommendation.id, status: recommendation.status },
  811. data: { recommendation },
  812. warnings: [],
  813. errors: [],
  814. };
  815. }
  816. async function syncConversations() {
  817. const allowlist = new Set(workbench.config.qiwei.allowedSenders.map(String));
  818. if (!allowlist.size) throw new Error('测试联系人白名单为空,无法同步会话');
  819. const account = await detectAccountStatus(true);
  820. if (!account.online) throw new Error('测试账号当前不在线,无法同步企微会话');
  821. const grouped = new Map();
  822. const seen = new Set();
  823. const seenSemantic = new Set();
  824. let cursor = 0;
  825. let pages = 0;
  826. let scannedMessages = 0;
  827. while (pages < workbench.config.qiwei.initialSyncMaxPages) {
  828. const result = await workbench.qiwei.syncMessages(cursor, workbench.config.qiwei.initialSyncLimit);
  829. const list = Array.isArray(result.syncMsgList) ? result.syncMsgList : [];
  830. scannedMessages += list.length;
  831. for (const message of list) {
  832. const senderId = String(message.senderId || '');
  833. const receiverId = String(message.receiverId || '');
  834. const contactId = allowlist.has(senderId) ? senderId : allowlist.has(receiverId) ? receiverId : '';
  835. const content = String(message.msgData?.content || '').trim();
  836. if (!contactId || !content || ![0, 1, 2].includes(Number(message.msgType))) continue;
  837. const inbound = senderId === contactId;
  838. const timestampSeconds = messageTimestamp(message.timestamp);
  839. if (!timestampSeconds) continue;
  840. const timestamp = new Date(timestampSeconds * 1000).toISOString();
  841. const externalId = String(message.msgServerId || message.msgUniqueIdentifier || '');
  842. const dedupeKey = externalId || `${contactId}|${inbound ? 'in' : 'out'}|${timestamp}|${content}`;
  843. if (seen.has(dedupeKey)) continue;
  844. seen.add(dedupeKey);
  845. const semanticKey = `${contactId}|${inbound ? 'in' : 'out'}|${timestamp}|${content}`;
  846. if (seenSemantic.has(semanticKey)) continue;
  847. seenSemantic.add(semanticKey);
  848. if (!grouped.has(contactId)) grouped.set(contactId, { contactName: '', messages: [] });
  849. const group = grouped.get(contactId);
  850. if (inbound && message.senderName) group.contactName = String(message.senderName);
  851. group.messages.push({
  852. externalId: externalId || null,
  853. inbound,
  854. content,
  855. timestamp,
  856. raw: { seq: message.seq, msgType: message.msgType, timestamp: message.timestamp, source: 'manual_sync' },
  857. });
  858. }
  859. const seqs = list.map(item => Number(item.seq)).filter(Number.isFinite);
  860. const next = Math.max(cursor, Number(result.travelSyncKey) || 0, seqs.length ? Math.max(...seqs) : 0);
  861. pages += 1;
  862. if (!list.length || next <= cursor) break;
  863. cursor = next;
  864. }
  865. let syncedMessages = 0;
  866. let removedImportedMessages = 0;
  867. for (const [contactId, group] of grouped.entries()) {
  868. const conversation = workbench.db.ensureConversation(contactId, group.contactName || '白名单企微联系人');
  869. removedImportedMessages += workbench.db.deleteImportedMessages(conversation.id, 'manual_sync');
  870. const ordered = group.messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
  871. const recent = ordered.slice(-20);
  872. for (const message of recent) {
  873. const inserted = workbench.db.insertMessage({
  874. conversationId: conversation.id,
  875. externalId: message.externalId,
  876. direction: message.inbound ? 'inbound' : 'outbound',
  877. senderType: message.inbound ? 'customer' : 'human',
  878. content: message.content,
  879. status: message.inbound ? 'received' : 'sent',
  880. createdAt: message.timestamp,
  881. raw: message.raw,
  882. });
  883. if (inserted.created) syncedMessages += 1;
  884. }
  885. }
  886. const duplicatesRemoved = workbench.db.cleanupInboundContentDuplicates(60);
  887. workbench.db.audit({
  888. actor: 'human',
  889. action: 'conversation_history_synced',
  890. detail: { conversations: grouped.size, insertedMessages: syncedMessages, removedImportedMessages, scannedMessages, duplicatesRemoved },
  891. });
  892. return {
  893. status: 'ok',
  894. assistantMessage: grouped.size
  895. ? `已补采 ${grouped.size} 个白名单真实会话的最近消息;只入库,不运行 Agent、不发送回复`
  896. : '未读取到白名单联系人的文字会话,请先在企微中与该联系人收发一条消息',
  897. data: {
  898. syncedConversationCount: grouped.size,
  899. syncedMessageCount: syncedMessages,
  900. scannedMessageCount: scannedMessages,
  901. conversations: displayableConversations().map(publicConversation),
  902. },
  903. };
  904. }
  905. function changeGlobalMode(mode) {
  906. const selected = String(mode || '');
  907. let update;
  908. if (['paused', 'monitor'].includes(selected)) update = { paused: true };
  909. else if (['review', 'suggest'].includes(selected)) update = { paused: false, defaultMode: 'review' };
  910. else if (selected === 'auto') update = { paused: false, defaultMode: 'auto' };
  911. else if (selected === 'human') update = { paused: false, defaultMode: 'human' };
  912. else throw new Error('不支持的 Agent 模式');
  913. const global = workbench.service.setGlobal(update);
  914. return {
  915. status: 'ok',
  916. assistantMessage: global.paused ? 'Agent 已全局暂停;仍可接收消息,但不会生成或发送回复' : `全局策略已切换为 ${global.defaultMode}`,
  917. data: { global, globalMode: global.paused ? 'paused' : global.defaultMode },
  918. };
  919. }
  920. function changeConversationMode(id, mode) {
  921. const mapped = mode === 'agent' ? 'review' : mode === 'manual' ? 'human' : mode;
  922. const conversation = workbench.service.setConversationMode(id, mapped);
  923. const labels = { review: '待审核', auto: '高置信自动', human: '人工接管', paused: '会话暂停' };
  924. return { status: 'ok', assistantMessage: `会话已切换为${labels[mapped]}`, data: { conversation } };
  925. }
  926. async function approveReply(id, content) {
  927. const detail = workbench.service.conversationDetail(id);
  928. if (!detail) throw new Error('会话不存在');
  929. const pending = detail.drafts.find(item => item.status === 'pending');
  930. const result = pending
  931. ? await workbench.service.approveDraft(pending.id, { content, actor: 'human' })
  932. : { status: 'sent', message: await workbench.service.manualSend(id, content, 'human') };
  933. return { status: 'ok', assistantMessage: '回复已真实发送给白名单测试联系人', data: result };
  934. }
  935. async function approveDraft(draftId, content) {
  936. const result = await workbench.service.approveDraft(draftId, { content, actor: 'human' });
  937. return { status: 'ok', assistantMessage: '审核通过,回复已真实发送且只发送一次', data: result };
  938. }
  939. function rejectDraft(draftId, reason) {
  940. const result = workbench.service.rejectDraft(draftId, { reason, actor: 'human' });
  941. return { status: 'ok', assistantMessage: '草稿已驳回,不会发送给客户', data: result };
  942. }
  943. async function regenerateDraft(draftId) {
  944. const result = await workbench.service.regenerateDraft(draftId, 'human');
  945. return {
  946. status: 'ok',
  947. assistantMessage: result.status === 'pending_review' ? 'Agent 已重新生成待审核草稿' : (result.error || 'Agent 重新生成未完成'),
  948. data: result,
  949. };
  950. }
  951. async function generateLatestDraft(conversationId) {
  952. const result = await workbench.service.generateLatestDraft(conversationId, 'human');
  953. return {
  954. status: 'ok',
  955. assistantMessage: result.status === 'pending_review' ? 'Agent 已生成待审核草稿' : (result.error || 'Agent 未生成草稿'),
  956. data: result,
  957. };
  958. }
  959. async function manualSend(conversationId, content) {
  960. const message = await workbench.service.manualSend(conversationId, content, 'human');
  961. return { status: 'ok', assistantMessage: '人工回复已真实发送给白名单测试联系人', data: { message } };
  962. }
  963. async function updateCustomerTask(taskId, input = {}) {
  964. if (!['open', 'in_progress', 'done', 'dismissed'].includes(String(input.status || ''))) throw new Error('不支持的客户待办状态');
  965. const existing = workbench.db.getCustomerTask(taskId);
  966. if (!existing) throw new Error('客户待办不存在');
  967. let officialResult = null;
  968. if (input.status === 'done' && existing.official_todo_id) {
  969. officialResult = await completeTodoKnowledge(existing.official_todo_id);
  970. }
  971. const officialOk = !officialResult || officialResult.status === 'ok';
  972. const task = workbench.db.updateCustomerTask(taskId, {
  973. status: input.status,
  974. resolution_reason: input.status === 'done' ? 'human_completed' : input.status === 'dismissed' ? 'human_dismissed' : '',
  975. ...(existing.official_todo_id ? { official_sync_status: officialOk ? input.status : 'error' } : {}),
  976. });
  977. if (!task) throw new Error('客户待办不存在');
  978. 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 } });
  979. return {
  980. status: 'ok',
  981. assistantMessage: existing.official_todo_id && !officialOk
  982. ? `本地待办已更新为 ${task.status},但企微官方待办同步失败,请稍后重试`
  983. : `客户待办已更新为 ${task.status}${existing.official_todo_id ? ',企微官方待办已同步' : ''}`,
  984. data: { task, officialResult },
  985. warnings: existing.official_todo_id && !officialOk ? ['企微官方待办状态尚未同步'] : [],
  986. };
  987. }
  988. async function syncCustomerTaskToOfficialTodo(taskId, input = {}) {
  989. const syncCurrentAccountTask = createCustomerTaskOfficialSync({
  990. db: workbench.db,
  991. searchTodoUsers,
  992. createTodoKnowledge,
  993. });
  994. return syncCurrentAccountTask(taskId, input);
  995. }
  996. function updateCustomerAlert(alertId, input = {}) {
  997. if (!['open', 'acknowledged', 'resolved', 'dismissed'].includes(String(input.status || ''))) throw new Error('不支持的客户预警状态');
  998. const alert = workbench.db.updateCustomerAlert(alertId, { status: input.status });
  999. if (!alert) throw new Error('客户预警不存在');
  1000. workbench.db.audit({ actor: 'human', action: 'customer_alert_updated', conversationId: alert.conversation_id, entityId: alert.id, detail: { status: alert.status } });
  1001. return { status: 'ok', assistantMessage: `客户预警已更新为 ${alert.status}`, data: { alert } };
  1002. }
  1003. function getAudit(limit = 200) {
  1004. return { status: 'ok', data: { audit: workbench.db.listAudit(Math.max(1, Math.min(500, Number(limit) || 200))) } };
  1005. }
  1006. async function startListener() {
  1007. const account = await detectAccountStatus(true);
  1008. if (!account.online) throw new Error(`${account.nickname || '当前账号'}不在线,无法启动真实消息监听`);
  1009. workbench.service.setGlobal({ paused: false, defaultMode: 'auto' });
  1010. for (const conversation of workbench.db.listConversations()) {
  1011. workbench.service.setConversationMode(conversation.id, 'auto');
  1012. }
  1013. let status;
  1014. try {
  1015. status = await workbench.poller.start();
  1016. } catch (error) {
  1017. workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
  1018. for (const conversation of workbench.db.listConversations()) {
  1019. workbench.service.setConversationMode(conversation.id, 'human');
  1020. }
  1021. throw error;
  1022. }
  1023. return {
  1024. status: 'ok',
  1025. assistantMessage: 'AI 监听已启动:仅处理白名单联系人,高置信回复可自动发送,人工可随时接管',
  1026. data: status,
  1027. };
  1028. }
  1029. function stopListener() {
  1030. const status = workbench.poller.stop();
  1031. workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
  1032. for (const conversation of workbench.db.listConversations()) {
  1033. workbench.service.setConversationMode(conversation.id, 'human');
  1034. }
  1035. return { status: 'ok', assistantMessage: 'AI 监听已关闭,现有会话已切换为人工接管', data: status };
  1036. }
  1037. function getAgentRuntimeConfig() {
  1038. return { ...workbench.config.agent };
  1039. }
  1040. module.exports = {
  1041. switchActiveAccount,
  1042. getAgentStatus,
  1043. getConversations,
  1044. updateCustomerProfile,
  1045. updateCustomerRecommendation,
  1046. syncConversations,
  1047. changeGlobalMode,
  1048. changeConversationMode,
  1049. approveReply,
  1050. approveDraft,
  1051. rejectDraft,
  1052. regenerateDraft,
  1053. generateLatestDraft,
  1054. manualSend,
  1055. updateCustomerTask,
  1056. syncCustomerTaskToOfficialTodo,
  1057. updateCustomerAlert,
  1058. getAudit,
  1059. startListener,
  1060. stopListener,
  1061. getAgentRuntimeConfig,
  1062. createWorkbench,
  1063. __testing: { loadAgentConfig, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage },
  1064. };