|
@@ -1,7 +1,7 @@
|
|
|
const fs = require('fs');
|
|
const fs = require('fs');
|
|
|
const path = require('path');
|
|
const path = require('path');
|
|
|
const crypto = require('crypto');
|
|
const crypto = require('crypto');
|
|
|
-const { latestPath } = require('../core/output-paths');
|
|
|
|
|
|
|
+const { PACKAGE_ROOT, WORKSPACE_ROOT, latestPath } = require('../core/output-paths');
|
|
|
const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
|
|
const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
|
|
|
const { AgentKnowledgeStore } = require('../core/agent-knowledge');
|
|
const { AgentKnowledgeStore } = require('../core/agent-knowledge');
|
|
|
const { QiweiAgentRuntime, extractExplicitCustomerIntelligence } = require('../core/agent-runtime');
|
|
const { QiweiAgentRuntime, extractExplicitCustomerIntelligence } = require('../core/agent-runtime');
|
|
@@ -13,11 +13,14 @@ const {
|
|
|
completeTodoKnowledge,
|
|
completeTodoKnowledge,
|
|
|
} = require('./official-office-knowledge-service');
|
|
} = require('./official-office-knowledge-service');
|
|
|
const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
|
|
const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
|
|
|
-const { messageTimestamp, evaluatePolledMessage } = require('../core/agent-poller-policy');
|
|
|
|
|
|
|
+const { messageTimestamp, roomIdOf, isGroupMessage, evaluatePolledMessage } = require('../core/agent-poller-policy');
|
|
|
const { setActiveQiweiContext } = require('../core/credentials');
|
|
const { setActiveQiweiContext } = require('../core/credentials');
|
|
|
const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
|
|
const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
|
|
|
|
|
+const { responseMonitor } = require('./response-monitor-service');
|
|
|
|
|
+const { GroupAgentService } = require('./group-agent-service');
|
|
|
|
|
+const { normalizeAllowlistIds, normalizeAllowlistContact, writeEnvValue } = require('../core/allowlist-config');
|
|
|
|
|
|
|
|
-const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
|
|
|
|
|
+const PROJECT_ROOT = WORKSPACE_ROOT;
|
|
|
const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
|
|
const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
|
|
|
|
|
|
|
|
function readEnvFile(filePath) {
|
|
function readEnvFile(filePath) {
|
|
@@ -78,19 +81,25 @@ function loadAgentConfig(overrides = {}) {
|
|
|
const provider = value('AGENT_PROVIDER', 'claude-code');
|
|
const provider = value('AGENT_PROVIDER', 'claude-code');
|
|
|
const anthropic = provider === 'anthropic';
|
|
const anthropic = provider === 'anthropic';
|
|
|
const claudeCode = provider === 'claude-code';
|
|
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 projectPropertyFile = path.join(PROJECT_ROOT, 'knowledge-base', 'property-data', 'properties.json');
|
|
|
|
|
+ const bundledPropertyFile = path.join(PACKAGE_ROOT, 'knowledge-base', 'property-data', 'properties.json');
|
|
|
|
|
+ const workspacePropertyFile = path.resolve(PACKAGE_ROOT, '..', '..', 'huaxiangpipei', 'src', 'assets', 'data', 'properties.json');
|
|
|
const configuredPropertyFile = value('QIWEI_AGENT_PROPERTY_DATA_FILE');
|
|
const configuredPropertyFile = value('QIWEI_AGENT_PROPERTY_DATA_FILE');
|
|
|
const propertyDataFile = configuredPropertyFile
|
|
const propertyDataFile = configuredPropertyFile
|
|
|
? resolvePath(configuredPropertyFile)
|
|
? resolvePath(configuredPropertyFile)
|
|
|
- : (fs.existsSync(bundledPropertyFile) ? bundledPropertyFile : (fs.existsSync(workspacePropertyFile) ? workspacePropertyFile : ''));
|
|
|
|
|
|
|
+ : (fs.existsSync(projectPropertyFile)
|
|
|
|
|
+ ? projectPropertyFile
|
|
|
|
|
+ : (fs.existsSync(bundledPropertyFile) ? bundledPropertyFile : (fs.existsSync(workspacePropertyFile) ? workspacePropertyFile : '')));
|
|
|
const baseConfig = {
|
|
const baseConfig = {
|
|
|
dbPath: resolvePath(value('QIWEI_AGENT_DB_PATH'), latestPath('messages', 'agent-workbench.db')),
|
|
dbPath: resolvePath(value('QIWEI_AGENT_DB_PATH'), latestPath('messages', 'agent-workbench.db')),
|
|
|
- legacyDbPath: path.resolve(PROJECT_ROOT, '..', '..', 'qiwei-agent-workbench', 'data', 'workbench.db'),
|
|
|
|
|
|
|
+ legacyDbPath: path.resolve(PACKAGE_ROOT, '..', '..', 'qiwei-agent-workbench', 'data', 'workbench.db'),
|
|
|
globalDefaultPaused: bool('QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED', true),
|
|
globalDefaultPaused: bool('QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED', true),
|
|
|
conversationDefaultMode: value('QIWEI_AGENT_DEFAULT_MODE', 'review'),
|
|
conversationDefaultMode: value('QIWEI_AGENT_DEFAULT_MODE', 'review'),
|
|
|
autoSendConfidence: number('QIWEI_AGENT_AUTO_SEND_CONFIDENCE', 0.88, 0, 1),
|
|
autoSendConfidence: number('QIWEI_AGENT_AUTO_SEND_CONFIDENCE', 0.88, 0, 1),
|
|
|
- knowledgeDir: resolvePath(value('QIWEI_AGENT_KNOWLEDGE_DIR'), path.join(PROJECT_ROOT, 'knowledge')),
|
|
|
|
|
|
|
+ knowledgeDir: resolvePath(
|
|
|
|
|
+ value('QIWEI_AGENT_KNOWLEDGE_DIR'),
|
|
|
|
|
+ fs.existsSync(path.join(PROJECT_ROOT, 'knowledge')) ? path.join(PROJECT_ROOT, 'knowledge') : path.join(PACKAGE_ROOT, 'knowledge')
|
|
|
|
|
+ ),
|
|
|
propertyDataFile,
|
|
propertyDataFile,
|
|
|
agent: {
|
|
agent: {
|
|
|
provider,
|
|
provider,
|
|
@@ -105,6 +114,9 @@ function loadAgentConfig(overrides = {}) {
|
|
|
claudeMainSessionId: value('QIWEI_AGENT_MAIN_SESSION_ID'),
|
|
claudeMainSessionId: value('QIWEI_AGENT_MAIN_SESSION_ID'),
|
|
|
claudeTimeoutMs: number('CLAUDE_CODE_TIMEOUT_MS', 120000, 15000, 300000),
|
|
claudeTimeoutMs: number('CLAUDE_CODE_TIMEOUT_MS', 120000, 15000, 300000),
|
|
|
claudeMaxBudgetUsd: number('CLAUDE_CODE_MAX_BUDGET_USD', 0.35, 0.05, 5),
|
|
claudeMaxBudgetUsd: number('CLAUDE_CODE_MAX_BUDGET_USD', 0.35, 0.05, 5),
|
|
|
|
|
+ claudeRetryMaxBudgetUsd: number('CLAUDE_CODE_RETRY_MAX_BUDGET_USD', 1, 0.1, 5),
|
|
|
|
|
+ claudeBare: bool('CLAUDE_CODE_BARE', true),
|
|
|
|
|
+ claudeEffort: value('CLAUDE_CODE_EFFORT', 'low'),
|
|
|
claudeTools: value('CLAUDE_CODE_ALLOWED_TOOLS', 'Read,Glob,Grep'),
|
|
claudeTools: value('CLAUDE_CODE_ALLOWED_TOOLS', 'Read,Glob,Grep'),
|
|
|
},
|
|
},
|
|
|
qiwei: {
|
|
qiwei: {
|
|
@@ -122,6 +134,9 @@ function loadAgentConfig(overrides = {}) {
|
|
|
initialSyncLimit: number('QIWEI_AGENT_INITIAL_SYNC_LIMIT', 5000, 100, 5000),
|
|
initialSyncLimit: number('QIWEI_AGENT_INITIAL_SYNC_LIMIT', 5000, 100, 5000),
|
|
|
initialSyncMaxPages: number('QIWEI_AGENT_INITIAL_SYNC_MAX_PAGES', 200, 10, 500),
|
|
initialSyncMaxPages: number('QIWEI_AGENT_INITIAL_SYNC_MAX_PAGES', 200, 10, 500),
|
|
|
startupGraceSeconds: number('QIWEI_AGENT_STARTUP_GRACE_SECONDS', 10, 0, 60),
|
|
startupGraceSeconds: number('QIWEI_AGENT_STARTUP_GRACE_SECONDS', 10, 0, 60),
|
|
|
|
|
+ trustConfiguredOnStatusError: bool('QIWEI_TRUST_CONFIGURED_ON_STATUS_ERROR', false),
|
|
|
|
|
+ responseReminderMinutes: number('QIWEI_RESPONSE_REMINDER_MINUTES', 15, 1, 1440),
|
|
|
|
|
+ responseUrgentMinutes: number('QIWEI_RESPONSE_URGENT_MINUTES', 60, 1, 10080),
|
|
|
},
|
|
},
|
|
|
};
|
|
};
|
|
|
const config = {
|
|
const config = {
|
|
@@ -282,6 +297,13 @@ class QiweiAgentPoller {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async process(message) {
|
|
async process(message) {
|
|
|
|
|
+ if (isGroupMessage(message)) {
|
|
|
|
|
+ try { await groupAgentService.ingestPolledMessage(message, this.config); }
|
|
|
|
|
+ catch (error) {
|
|
|
|
|
+ this.db.audit({ actor: 'agent', action: 'group_agent_failed', detail: { roomId: roomIdOf(message), message: error.message } });
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
const candidate = evaluatePolledMessage(message, this.config);
|
|
const candidate = evaluatePolledMessage(message, this.config);
|
|
|
if (!candidate.eligible) return;
|
|
if (!candidate.eligible) return;
|
|
|
const { content, senderId, timestamp } = candidate;
|
|
const { content, senderId, timestamp } = candidate;
|
|
@@ -291,7 +313,17 @@ class QiweiAgentPoller {
|
|
|
contactName: message.senderName || '王刚',
|
|
contactName: message.senderName || '王刚',
|
|
|
content,
|
|
content,
|
|
|
timestamp: new Date(timestamp * 1000).toISOString(),
|
|
timestamp: new Date(timestamp * 1000).toISOString(),
|
|
|
- raw: { seq: message.seq, msgType: message.msgType, timestamp: message.timestamp },
|
|
|
|
|
|
|
+ raw: {
|
|
|
|
|
+ seq: message.seq,
|
|
|
|
|
+ msgType: message.msgType,
|
|
|
|
|
+ timestamp: message.timestamp,
|
|
|
|
|
+ senderId: message.senderId,
|
|
|
|
|
+ receiverId: message.receiverId,
|
|
|
|
|
+ senderName: message.senderName,
|
|
|
|
|
+ fromRoomId: message.fromRoomId || null,
|
|
|
|
|
+ isRoomNotice: message.isRoomNotice,
|
|
|
|
|
+ IsWWWXOutRoom: message.IsWWWXOutRoom,
|
|
|
|
|
+ },
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -388,6 +420,12 @@ function activeAccountMetadata() {
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const groupAgentService = new GroupAgentService({
|
|
|
|
|
+ projectRoot: PROJECT_ROOT,
|
|
|
|
|
+ getRuntime: () => workbench,
|
|
|
|
|
+ getAccount: () => activeAccountMetadata(),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
function applyActiveAccountContext(account) {
|
|
function applyActiveAccountContext(account) {
|
|
|
setActiveQiweiContext(account);
|
|
setActiveQiweiContext(account);
|
|
|
Object.assign(workbench.config.qiwei, account);
|
|
Object.assign(workbench.config.qiwei, account);
|
|
@@ -413,9 +451,10 @@ workbenches.set(accountRuntimeKey(initialAccount), workbench);
|
|
|
applyActiveAccountContext(initialAccount);
|
|
applyActiveAccountContext(initialAccount);
|
|
|
|
|
|
|
|
async function switchActiveAccount(input = {}) {
|
|
async function switchActiveAccount(input = {}) {
|
|
|
|
|
+ const requestedGuid = String(input.guid || '').trim();
|
|
|
const account = {
|
|
const account = {
|
|
|
uid: String(input.uid || '').trim(),
|
|
uid: String(input.uid || '').trim(),
|
|
|
- guid: String(input.guid || '').trim(),
|
|
|
|
|
|
|
+ guid: requestedGuid === 'server-managed' ? '' : requestedGuid,
|
|
|
apiBase: String(input.apiBase || '').trim(),
|
|
apiBase: String(input.apiBase || '').trim(),
|
|
|
userId: String(input.userId || '').trim(),
|
|
userId: String(input.userId || '').trim(),
|
|
|
nickname: String(input.nickname || input.userId || '').trim(),
|
|
nickname: String(input.nickname || input.userId || '').trim(),
|
|
@@ -438,11 +477,13 @@ async function switchActiveAccount(input = {}) {
|
|
|
workbenches.set(nextKey, nextWorkbench);
|
|
workbenches.set(nextKey, nextWorkbench);
|
|
|
}
|
|
}
|
|
|
workbench = nextWorkbench;
|
|
workbench = nextWorkbench;
|
|
|
- applyActiveAccountContext({
|
|
|
|
|
- ...activeAccountMetadata(),
|
|
|
|
|
|
|
+ const currentAccount = activeAccountMetadata();
|
|
|
|
|
+ const accountPatch = {
|
|
|
...account,
|
|
...account,
|
|
|
- apiBase: account.apiBase || activeAccountMetadata().apiBase,
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ apiBase: account.apiBase || currentAccount.apiBase,
|
|
|
|
|
+ };
|
|
|
|
|
+ if (!accountPatch.guid) delete accountPatch.guid;
|
|
|
|
|
+ applyActiveAccountContext({ ...currentAccount, ...accountPatch });
|
|
|
const status = provisionalAccountStatus();
|
|
const status = provisionalAccountStatus();
|
|
|
accountStatusCache = { checkedAt: Date.now(), value: status };
|
|
accountStatusCache = { checkedAt: Date.now(), value: status };
|
|
|
void refreshAccountStatus();
|
|
void refreshAccountStatus();
|
|
@@ -479,7 +520,7 @@ async function refreshAccountStatus() {
|
|
|
nickname: data.nickname || selected.nickname || selected.userId || '当前企微账号',
|
|
nickname: data.nickname || selected.nickname || selected.userId || '当前企微账号',
|
|
|
corpName: data.corpName || selected.corpName || '',
|
|
corpName: data.corpName || selected.corpName || '',
|
|
|
statusCode: data.userOnlineStatus ?? null,
|
|
statusCode: data.userOnlineStatus ?? null,
|
|
|
- statusText: online ? '账号在线' : '账号离线',
|
|
|
|
|
|
|
+ statusText: online ? (data.statusFallback ? '账号在线(状态接口待复核)' : '账号在线') : '账号离线',
|
|
|
};
|
|
};
|
|
|
if (online) {
|
|
if (online) {
|
|
|
accountLastOnlineAt.set(refreshKey, Date.now());
|
|
accountLastOnlineAt.set(refreshKey, Date.now());
|
|
@@ -509,6 +550,10 @@ async function refreshAccountStatus() {
|
|
|
if (accountRuntimeKey(activeAccountMetadata()) === refreshKey) {
|
|
if (accountRuntimeKey(activeAccountMetadata()) === refreshKey) {
|
|
|
accountStatusCache = { checkedAt: Date.now(), value: next };
|
|
accountStatusCache = { checkedAt: Date.now(), value: next };
|
|
|
}
|
|
}
|
|
|
|
|
+ if (next.userId) {
|
|
|
|
|
+ targetWorkbench.config.qiwei.userId = String(next.userId);
|
|
|
|
|
+ targetWorkbench.config.qiwei.selfUserId = String(next.userId);
|
|
|
|
|
+ }
|
|
|
return next;
|
|
return next;
|
|
|
})().finally(() => {
|
|
})().finally(() => {
|
|
|
if (accountStatusRefreshKey === refreshKey) {
|
|
if (accountStatusRefreshKey === refreshKey) {
|
|
@@ -622,6 +667,24 @@ function backfillPropertyRecommendations(db, knowledge) {
|
|
|
return count;
|
|
return count;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function conversationChannelInfo(row, messages = []) {
|
|
|
|
|
+ const contactId = String(row?.contact_id || '').trim();
|
|
|
|
|
+ let roomId = '';
|
|
|
|
|
+ for (const message of messages) {
|
|
|
|
|
+ const raw = parseJson(message.raw_json, {});
|
|
|
|
|
+ if (!isGroupMessage(raw)) continue;
|
|
|
|
|
+ roomId = roomIdOf(raw) || contactId;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!roomId && /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(contactId)) roomId = contactId;
|
|
|
|
|
+ const channelType = roomId ? 'group' : 'private';
|
|
|
|
|
+ return {
|
|
|
|
|
+ channelType,
|
|
|
|
|
+ channelLabel: channelType === 'group' ? '客户群聊' : '客户私聊',
|
|
|
|
|
+ channelId: roomId || contactId,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function publicConversation(row) {
|
|
function publicConversation(row) {
|
|
|
const detail = workbench.service.conversationDetail(row.id);
|
|
const detail = workbench.service.conversationDetail(row.id);
|
|
|
const claudeSession = getCustomerSessionGuide(row, {
|
|
const claudeSession = getCustomerSessionGuide(row, {
|
|
@@ -634,6 +697,9 @@ function publicConversation(row) {
|
|
|
: [];
|
|
: [];
|
|
|
const pending = currentDrafts.find(item => item.status === 'pending') || null;
|
|
const pending = currentDrafts.find(item => item.status === 'pending') || null;
|
|
|
const latestDraft = pending || currentDrafts.find(item => ['sent', 'approved'].includes(item.status)) || null;
|
|
const latestDraft = pending || currentDrafts.find(item => ['sent', 'approved'].includes(item.status)) || null;
|
|
|
|
|
+ const currentAgentOutcome = latestInbound && detail.agentOutcome?.entityId === latestInbound.id ? detail.agentOutcome : null;
|
|
|
|
|
+ const agentError = ['agent_failed', 'agent_not_configured'].includes(currentAgentOutcome?.action) ? currentAgentOutcome : null;
|
|
|
|
|
+ const agentNotice = currentAgentOutcome?.action === 'agent_no_reply_needed' ? currentAgentOutcome : null;
|
|
|
const rawProfile = detail.profile?.profile || {};
|
|
const rawProfile = detail.profile?.profile || {};
|
|
|
const { __evidence: profileEvidence = {}, ...profile } = rawProfile;
|
|
const { __evidence: profileEvidence = {}, ...profile } = rawProfile;
|
|
|
const customerTasks = detail.tasks || [];
|
|
const customerTasks = detail.tasks || [];
|
|
@@ -644,6 +710,7 @@ function publicConversation(row) {
|
|
|
const displayMessages = [...new Map((detail.messages || []).map(message => [message.id, message])).values()]
|
|
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))
|
|
.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))
|
|
|
.slice(-60);
|
|
.slice(-60);
|
|
|
|
|
+ const channel = conversationChannelInfo(row, displayMessages);
|
|
|
const visibleEntityIds = new Set(displayMessages.map(message => message.id));
|
|
const visibleEntityIds = new Set(displayMessages.map(message => message.id));
|
|
|
const visibleAudit = (detail.audit || []).filter(item =>
|
|
const visibleAudit = (detail.audit || []).filter(item =>
|
|
|
Date.parse(item.created_at) >= cutoverAt || visibleEntityIds.has(item.entity_id)
|
|
Date.parse(item.created_at) >= cutoverAt || visibleEntityIds.has(item.entity_id)
|
|
@@ -652,6 +719,7 @@ function publicConversation(row) {
|
|
|
id: row.id,
|
|
id: row.id,
|
|
|
displayName: row.contact_name || '白名单测试联系人',
|
|
displayName: row.contact_name || '白名单测试联系人',
|
|
|
maskedId: maskedId(row.contact_id),
|
|
maskedId: maskedId(row.contact_id),
|
|
|
|
|
+ ...channel,
|
|
|
mode: row.mode,
|
|
mode: row.mode,
|
|
|
source: 'live',
|
|
source: 'live',
|
|
|
claudeSession,
|
|
claudeSession,
|
|
@@ -665,14 +733,14 @@ function publicConversation(row) {
|
|
|
})),
|
|
})),
|
|
|
analysis: {
|
|
analysis: {
|
|
|
intent: latestDraft?.intent || '',
|
|
intent: latestDraft?.intent || '',
|
|
|
- intentLabel: latestDraft?.intent || (detail.agentError ? 'Agent 上游不可用' : '待 Agent 处理'),
|
|
|
|
|
|
|
+ intentLabel: latestDraft?.intent || (agentError ? 'Agent 上游不可用' : agentNotice ? '无需回复' : '待 Agent 处理'),
|
|
|
demand: profile,
|
|
demand: profile,
|
|
|
completenessScore: completeness(profile),
|
|
completenessScore: completeness(profile),
|
|
|
matches: propertyMatches(latestDraft?.tool_trace || []),
|
|
matches: propertyMatches(latestDraft?.tool_trace || []),
|
|
|
knowledgeSources: citations.length
|
|
knowledgeSources: citations.length
|
|
|
? citations.map(item => `${item.heading || item.source}${item.source ? ` · ${item.source}` : ''}`)
|
|
? citations.map(item => `${item.heading || item.source}${item.source ? ` · ${item.source}` : ''}`)
|
|
|
: ['真实企微消息', '客户画像', '企业规则库与知识库'],
|
|
: ['真实企微消息', '客户画像', '企业规则库与知识库'],
|
|
|
- reasoning: latestDraft?.reason || detail.agentError?.message || '消息已进入真实企微链路,等待 Agent 生成可审核草稿。',
|
|
|
|
|
|
|
+ reasoning: latestDraft?.reason || agentError?.message || agentNotice?.message || '消息已进入真实企微链路,等待 Agent 生成可审核草稿。',
|
|
|
},
|
|
},
|
|
|
customerIntelligence: {
|
|
customerIntelligence: {
|
|
|
profile,
|
|
profile,
|
|
@@ -744,7 +812,8 @@ function publicConversation(row) {
|
|
|
} : null,
|
|
} : null,
|
|
|
drafts,
|
|
drafts,
|
|
|
audit: visibleAudit,
|
|
audit: visibleAudit,
|
|
|
- agentError: detail.agentError,
|
|
|
|
|
|
|
+ agentError,
|
|
|
|
|
+ agentNotice,
|
|
|
lastMessageAt: displayMessages.at(-1)?.created_at || row.last_message_at,
|
|
lastMessageAt: displayMessages.at(-1)?.created_at || row.last_message_at,
|
|
|
updatedAt: row.updated_at,
|
|
updatedAt: row.updated_at,
|
|
|
};
|
|
};
|
|
@@ -780,12 +849,200 @@ async function getAgentStatus() {
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+async function getAllowlistCandidates() {
|
|
|
|
|
+ const selectedIds = [...workbench.config.qiwei.allowedSenders];
|
|
|
|
|
+ const candidates = new Map();
|
|
|
|
|
+ let remoteCount = 0;
|
|
|
|
|
+ let warning = '';
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const account = await detectAccountStatus(true);
|
|
|
|
|
+ if (account.online) {
|
|
|
|
|
+ const result = await workbench.qiwei.listExternalContacts({ limit: 500, maxPages: 10 });
|
|
|
|
|
+ remoteCount = Number(result.contactCount) || result.contacts.length;
|
|
|
|
|
+ for (const item of result.contacts) {
|
|
|
|
|
+ const contact = normalizeAllowlistContact(item);
|
|
|
|
|
+ if (contact) candidates.set(contact.id, contact);
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ warning = '当前企微账号离线,只显示已保存或已有会话中的联系人';
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ warning = '联系人列表暂时读取失败,仍可管理已保存联系人或手工添加联系人 ID';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const row of workbench.db.listConversations()) {
|
|
|
|
|
+ const contact = normalizeAllowlistContact({ contactId: row.contact_id }, row.contact_name);
|
|
|
|
|
+ if (contact && !candidates.has(contact.id)) candidates.set(contact.id, contact);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const id of selectedIds) {
|
|
|
|
|
+ if (!candidates.has(id)) candidates.set(id, normalizeAllowlistContact({ id }, '已保存联系人'));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const contacts = [...candidates.values()]
|
|
|
|
|
+ .map(contact => ({ ...contact, selected: selectedIds.includes(contact.id) }))
|
|
|
|
|
+ .sort((a, b) => Number(b.selected) - Number(a.selected) || a.displayName.localeCompare(b.displayName, 'zh-CN'));
|
|
|
|
|
+ return {
|
|
|
|
|
+ status: 'ok',
|
|
|
|
|
+ assistantMessage: warning || `已读取 ${contacts.length} 位可选联系人`,
|
|
|
|
|
+ summary: { selectedCount: selectedIds.length, candidateCount: contacts.length, remoteCount },
|
|
|
|
|
+ data: { selectedIds, contacts },
|
|
|
|
|
+ warnings: warning ? [warning] : [],
|
|
|
|
|
+ errors: [],
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function updateAllowlist(input = {}) {
|
|
|
|
|
+ const ids = normalizeAllowlistIds(input.contactIds || input.allowedSenders || []);
|
|
|
|
|
+ const serialized = ids.join(',');
|
|
|
|
|
+ writeEnvValue(ENV_FILE, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS', serialized);
|
|
|
|
|
+ fileEnv.QIWEI_AUTO_REPLY_ALLOWED_SENDERS = serialized;
|
|
|
|
|
+ process.env.QIWEI_AUTO_REPLY_ALLOWED_SENDERS = serialized;
|
|
|
|
|
+
|
|
|
|
|
+ let listenerStopped = false;
|
|
|
|
|
+ for (const target of workbenches.values()) {
|
|
|
|
|
+ target.config.qiwei.allowedSenders = [...ids];
|
|
|
|
|
+ if (!ids.length && target.poller.status().running) {
|
|
|
|
|
+ target.poller.stop();
|
|
|
|
|
+ listenerStopped = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ workbench.db.audit({
|
|
|
|
|
+ actor: 'human',
|
|
|
|
|
+ action: 'allowlist_updated',
|
|
|
|
|
+ detail: { count: ids.length, listenerStopped },
|
|
|
|
|
+ });
|
|
|
|
|
+ return {
|
|
|
|
|
+ status: 'ok',
|
|
|
|
|
+ assistantMessage: ids.length
|
|
|
|
|
+ ? `白名单已保存,共 ${ids.length} 位联系人,立即生效`
|
|
|
|
|
+ : '白名单已清空,AI 监听已停止',
|
|
|
|
|
+ summary: { selectedCount: ids.length, listenerStopped },
|
|
|
|
|
+ data: { selectedIds: ids, selectedCount: ids.length },
|
|
|
|
|
+ warnings: [],
|
|
|
|
|
+ errors: [],
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function displayableConversations() {
|
|
function displayableConversations() {
|
|
|
return workbench.db.listConversations().filter(item => item.last_message_at || item.last_content || Number(item.pending_count || 0) > 0);
|
|
return workbench.db.listConversations().filter(item => item.last_message_at || item.last_content || Number(item.pending_count || 0) > 0);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function getConversations() {
|
|
function getConversations() {
|
|
|
- return { status: 'ok', data: { conversations: displayableConversations().map(publicConversation) } };
|
|
|
|
|
|
|
+ const conversations = displayableConversations().map(publicConversation);
|
|
|
|
|
+ return {
|
|
|
|
|
+ status: 'ok',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ conversations,
|
|
|
|
|
+ summary: {
|
|
|
|
|
+ privateCount: conversations.filter(item => item.channelType === 'private').length,
|
|
|
|
|
+ groupCount: conversations.filter(item => item.channelType === 'group').length,
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getResponseMonitor() {
|
|
|
|
|
+ const allowlist = new Set(workbench.config.qiwei.allowedSenders.map(String));
|
|
|
|
|
+ const conversations = displayableConversations()
|
|
|
|
|
+ .filter(row => allowlist.has(String(row.contact_id || '')))
|
|
|
|
|
+ .flatMap(row => {
|
|
|
|
|
+ const conversation = publicConversation(row);
|
|
|
|
|
+ if (conversation.channelType === 'group') return [];
|
|
|
|
|
+ return [{
|
|
|
|
|
+ id: conversation.id,
|
|
|
|
|
+ contactId: row.contact_id,
|
|
|
|
|
+ displayName: conversation.displayName,
|
|
|
|
|
+ messages: conversation.messages,
|
|
|
|
|
+ handledMessageId: conversation.agentNotice?.entityId || '',
|
|
|
|
|
+ }];
|
|
|
|
|
+ });
|
|
|
|
|
+ const account = activeAccountMetadata();
|
|
|
|
|
+ const data = responseMonitor({
|
|
|
|
|
+ projectRoot: PROJECT_ROOT,
|
|
|
|
|
+ conversations,
|
|
|
|
|
+ selfUserIds: [workbench.config.qiwei.selfUserId, account.userId],
|
|
|
|
|
+ selfNames: [account.nickname],
|
|
|
|
|
+ warningMinutes: workbench.config.qiwei.responseReminderMinutes,
|
|
|
|
|
+ urgentMinutes: workbench.config.qiwei.responseUrgentMinutes,
|
|
|
|
|
+ });
|
|
|
|
|
+ return {
|
|
|
|
|
+ status: 'ok',
|
|
|
|
|
+ assistantMessage: data.summary.overdue
|
|
|
|
|
+ ? `当前有 ${data.summary.overdue} 个会话超过回复时效,请客服优先处理。`
|
|
|
|
|
+ : '当前监听范围内没有超时未回复会话。',
|
|
|
|
|
+ summary: data.summary,
|
|
|
|
|
+ data,
|
|
|
|
|
+ warnings: [],
|
|
|
|
|
+ errors: [],
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getGroupAgents() {
|
|
|
|
|
+ const groups = groupAgentService.list();
|
|
|
|
|
+ return {
|
|
|
|
|
+ status: 'ok',
|
|
|
|
|
+ assistantMessage: `已读取 ${groups.length} 个客户群的智能回复状态`,
|
|
|
|
|
+ summary: {
|
|
|
|
|
+ groupCount: groups.length,
|
|
|
|
|
+ pendingCount: groups.filter(item => item.pendingReply).length,
|
|
|
|
|
+ errorCount: groups.filter(item => item.agentError || item.sendError).length,
|
|
|
|
|
+ autoCount: groups.filter(item => item.mode === 'auto').length,
|
|
|
|
|
+ },
|
|
|
|
|
+ data: { groups },
|
|
|
|
|
+ warnings: [],
|
|
|
|
|
+ errors: [],
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function generateGroupReply(roomId, options = {}) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await groupAgentService.generate(roomId, options);
|
|
|
|
|
+ return {
|
|
|
|
|
+ status: 'ok',
|
|
|
|
|
+ assistantMessage: result.status === 'no_reply_needed'
|
|
|
|
|
+ ? 'Agent 判断这条群消息无需回复'
|
|
|
|
|
+ : result.status === 'auto_sent' ? '群聊 Agent 已忽略风险并自动发送回复' : '群聊 Agent 已生成待审核草稿',
|
|
|
|
|
+ data: result,
|
|
|
|
|
+ };
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ throw new Error('群聊 Agent 暂时不可用,消息已保留,可稍后重试或人工回复');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function changeGroupMode(roomId, mode, confirmation) {
|
|
|
|
|
+ const group = groupAgentService.setMode(roomId, mode, confirmation);
|
|
|
|
|
+ return {
|
|
|
|
|
+ status: 'ok',
|
|
|
|
|
+ assistantMessage: group.mode === 'auto'
|
|
|
|
|
+ ? '群聊全自动模式已开启:后续 Agent 非空回复将忽略风险并直接发送'
|
|
|
|
|
+ : '群聊已切回人工审核模式',
|
|
|
|
|
+ data: { group },
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function approveGroupDraft(roomId, draftId, content) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await groupAgentService.approve(roomId, draftId, content);
|
|
|
|
|
+ return { status: 'ok', assistantMessage: '群聊回复已真实发送', data: result };
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (/不存在|已经是|不能为空|过长|尚未确认为/.test(error.message)) throw error;
|
|
|
|
|
+ throw new Error('群聊发送失败,请确认企微账号在线后重试');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function rejectGroupDraft(roomId, draftId, reason) {
|
|
|
|
|
+ const result = groupAgentService.reject(roomId, draftId, reason);
|
|
|
|
|
+ return { status: 'ok', assistantMessage: '群聊草稿已驳回', data: result };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function regenerateGroupDraft(roomId, draftId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await groupAgentService.regenerate(roomId, draftId);
|
|
|
|
|
+ return { status: 'ok', assistantMessage: '群聊 Agent 已重新生成待审核草稿', data: result };
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ throw new Error('群聊 Agent 暂时不可用,原草稿已保留');
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function updateCustomerProfile(conversationId, input = {}) {
|
|
function updateCustomerProfile(conversationId, input = {}) {
|
|
@@ -881,6 +1138,7 @@ async function syncConversations() {
|
|
|
scannedMessages += list.length;
|
|
scannedMessages += list.length;
|
|
|
|
|
|
|
|
for (const message of list) {
|
|
for (const message of list) {
|
|
|
|
|
+ if (isGroupMessage(message)) continue;
|
|
|
const senderId = String(message.senderId || '');
|
|
const senderId = String(message.senderId || '');
|
|
|
const receiverId = String(message.receiverId || '');
|
|
const receiverId = String(message.receiverId || '');
|
|
|
const contactId = allowlist.has(senderId) ? senderId : allowlist.has(receiverId) ? receiverId : '';
|
|
const contactId = allowlist.has(senderId) ? senderId : allowlist.has(receiverId) ? receiverId : '';
|
|
@@ -1017,7 +1275,7 @@ async function generateLatestDraft(conversationId) {
|
|
|
const result = await workbench.service.generateLatestDraft(conversationId, 'human');
|
|
const result = await workbench.service.generateLatestDraft(conversationId, 'human');
|
|
|
return {
|
|
return {
|
|
|
status: 'ok',
|
|
status: 'ok',
|
|
|
- assistantMessage: result.status === 'pending_review' ? 'Agent 已生成待审核草稿' : (result.error || 'Agent 未生成草稿'),
|
|
|
|
|
|
|
+ assistantMessage: result.assistantMessage || (result.status === 'pending_review' ? 'Agent 已生成待审核草稿' : (result.error || 'Agent 未生成草稿')),
|
|
|
data: result,
|
|
data: result,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
@@ -1074,30 +1332,21 @@ function getAudit(limit = 200) {
|
|
|
return { status: 'ok', data: { audit: workbench.db.listAudit(Math.max(1, Math.min(500, Number(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);
|
|
|
|
|
|
|
+async function startListenerForWorkbench(target, account) {
|
|
|
if (!account.online) throw new Error(`${account.nickname || '当前账号'}不在线,无法启动真实消息监听`);
|
|
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;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const status = await target.poller.start();
|
|
|
return {
|
|
return {
|
|
|
status: 'ok',
|
|
status: 'ok',
|
|
|
- assistantMessage: 'AI 监听已启动:仅处理白名单联系人,高置信回复可自动发送,人工可随时接管',
|
|
|
|
|
|
|
+ assistantMessage: 'AI 监听已启动:白名单私聊按当前策略处理;已确认客户群自动生成待审核草稿,不会自动群发',
|
|
|
data: status,
|
|
data: status,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+async function startListener() {
|
|
|
|
|
+ const account = await detectAccountStatus(true);
|
|
|
|
|
+ return startListenerForWorkbench(workbench, account);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function stopListener() {
|
|
function stopListener() {
|
|
|
const status = workbench.poller.stop();
|
|
const status = workbench.poller.stop();
|
|
|
workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
|
|
workbench.service.setGlobal({ paused: false, defaultMode: 'review' });
|
|
@@ -1114,7 +1363,16 @@ function getAgentRuntimeConfig() {
|
|
|
module.exports = {
|
|
module.exports = {
|
|
|
switchActiveAccount,
|
|
switchActiveAccount,
|
|
|
getAgentStatus,
|
|
getAgentStatus,
|
|
|
|
|
+ getAllowlistCandidates,
|
|
|
|
|
+ updateAllowlist,
|
|
|
getConversations,
|
|
getConversations,
|
|
|
|
|
+ getResponseMonitor,
|
|
|
|
|
+ getGroupAgents,
|
|
|
|
|
+ changeGroupMode,
|
|
|
|
|
+ generateGroupReply,
|
|
|
|
|
+ approveGroupDraft,
|
|
|
|
|
+ rejectGroupDraft,
|
|
|
|
|
+ regenerateGroupDraft,
|
|
|
updateCustomerProfile,
|
|
updateCustomerProfile,
|
|
|
updateCustomerRecommendation,
|
|
updateCustomerRecommendation,
|
|
|
syncConversations,
|
|
syncConversations,
|
|
@@ -1134,5 +1392,5 @@ module.exports = {
|
|
|
stopListener,
|
|
stopListener,
|
|
|
getAgentRuntimeConfig,
|
|
getAgentRuntimeConfig,
|
|
|
createWorkbench,
|
|
createWorkbench,
|
|
|
- __testing: { loadAgentConfig, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage },
|
|
|
|
|
|
|
+ __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, detectedPropertiesInMessage, groupAgentService, startListenerForWorkbench },
|
|
|
};
|
|
};
|