import { buildChatSkillPrompt, extractSelectedChatSkillName, hasExplicitChatSkillPrompt, } from './chat-skills.mjs'; import { isDirectChatSessionId } from './direct-chat-service.mjs'; import { memoryLimitForIntervention, resolveMemoryInterventionMode, } from './memory-intervention.mjs'; export const CHAT_INTENT_ROUTE = { DIRECT_CHAT: 'direct_chat', AGENT: 'agent_orchestration', }; const AGENT_ORCHESTRATION_HEADER = '【Memind 任务编排】'; const SKILL_PROMPT_KEYS = { web: 'web', search: 'search', 'static-page-publish': 'generate-page', 'form-builder': 'form-builder', 'table-viewer': 'table-viewer', 'product-campaign-page': 'product-campaign-page', }; const OBVIOUS_AGENT_PATTERNS = [ /\bpublic\/[^\s"'<>]+\.html\b/i, /MindSpace\/[^/\s]+\/public\/[^\s"'<>]+\.html/i, /(?:生成|创建|制作|做|写|设计|发布).{0,24}(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u, /(?:H5|h5|HTML|html|网页|页面).{0,24}(?:生成|创建|制作|做|写|设计|发布)/u, /(?:写|做|生成|制作|设计).{0,16}主题(?:页面|文章)/u, /主题(?:页面|文章).{0,16}(?:写|做|生成|制作|设计)/u, ]; const OBVIOUS_DIRECT_PATTERNS = [ /^(?:你好|您好|在吗|在不在|嗨|hi|hello|hey)[!!。.\s]*$/iu, /^(?:测试\s*\d*|test\s*\d*)[!!。.\s]*$/iu, /^[??]+$/u, ]; const MEMORY_RECALL_PATTERNS = [ /你(?:还)?记得(?:我)?/u, /(?:有没有|是否).{0,6}记住/u, /你对(?:我)?(?:的)?记忆/u, /(?:我|之前).{0,16}(?:说过|提到|聊过|告诉)/u, /(?:我的|之前的)(?:记忆|偏好|计划|目标|想法)/u, /之前(?:说|提|聊)(?:过|的)/u, ]; export function isMemoryRecallQuestion(text) { const normalized = String(text ?? '').trim(); if (!normalized) return false; return MEMORY_RECALL_PATTERNS.some((pattern) => pattern.test(normalized)); } const DEFAULT_ROUTER_TIMEOUT_MS = 1500; const DEFAULT_ROUTER_MEMORY_LIMIT = 8; const DEFAULT_ROUTER_MIN_CONFIDENCE = 0.55; function envFlag(value, fallback = false) { const raw = String(value ?? '').trim().toLowerCase(); if (!raw) return fallback; return ['1', 'true', 'yes', 'on'].includes(raw); } function boundedNumber(value, fallback, { min = 0, max = Number.POSITIVE_INFINITY } = {}) { if (value == null || value === '') return fallback; const parsed = Number(value); if (!Number.isFinite(parsed)) return fallback; return Math.min(max, Math.max(min, parsed)); } function pickDefined(source, keys) { return Object.fromEntries( keys .filter((key) => source[key] !== undefined) .map((key) => [key, source[key]]), ); } function truncateText(value, maxLength) { const text = String(value ?? '').replace(/\s+/g, ' ').trim(); if (!text) return ''; return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text; } function firstSentence(value, maxLength) { const text = truncateText(value, maxLength); if (!text) return ''; const match = text.match(/^(.{12,}?[。.!!??])/u); return truncateText(match?.[1] ?? text, maxLength); } function normalizeMemoryText(item) { if (typeof item === 'string') return { label: null, text: item }; const text = item?.text ?? item?.memory_text ?? item?.memoryText ?? item?.content ?? item?.summary ?? ''; const label = String(item?.label ?? item?.type ?? '').trim() || null; return { label, text }; } function withTimeout(promise, timeoutMs, label) { const timeout = Number(timeoutMs); if (!Number.isFinite(timeout) || timeout <= 0) return promise; let timer = null; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { const err = new Error(`${label} timed out after ${timeout}ms`); err.code = 'CHAT_INTENT_ROUTER_TIMEOUT'; reject(err); }, timeout); }); return Promise.race([promise, timeoutPromise]).finally(() => { if (timer) clearTimeout(timer); }); } function messageDisplayText(message) { const displayText = message?.metadata?.displayText; if (typeof displayText === 'string' && displayText.trim()) return displayText.trim(); return extractMessageText(message); } function extractMessageText(message) { const content = message?.content; if (typeof content === 'string') return content.trim(); if (!Array.isArray(content)) return String(message?.text ?? message?.value ?? '').trim(); return content .map((item) => { if (typeof item === 'string') return item; if (item?.type === 'text') return item.text ?? ''; return ''; }) .join('\n') .trim(); } function isTextOnlyUserMessage(message) { const content = message?.content; if (!Array.isArray(content)) return Boolean(extractMessageText(message)); if (content.length === 0) return false; return content.every((item) => { if (typeof item === 'string') return true; return item?.type === 'text'; }); } function readSelectedChatSkillId(message) { const selected = message?.metadata?.memindRun?.selectedChatSkill ?? message?.metadata?.selectedChatSkill; return typeof selected === 'string' && selected.trim() ? selected.trim() : null; } function messageHasExplicitChatSkillSelection(message) { if (!message) return false; if (readSelectedChatSkillId(message)) return true; return [extractMessageText(message), messageDisplayText(message)].some((text) => hasExplicitChatSkillPrompt(text), ); } function buildSkillSelectionClassification(userMessage) { const selectedSkillId = readSelectedChatSkillId(userMessage); const suggestedSkill = selectedSkillId ?? extractSelectedChatSkillName(extractMessageText(userMessage)) ?? extractSelectedChatSkillName(messageDisplayText(userMessage)); return normalizeClassification({ route: CHAT_INTENT_ROUTE.AGENT, confidence: 1, reason: '用户已选择 skill', suggested_skill: suggestedSkill, agent_brief: suggestedSkill ? `使用 ${suggestedSkill} 执行用户任务` : '执行用户选择的 skill 任务', }, { source: 'rule' }); } function buildRouterSystemPrompt(grantedSkills = []) { const skills = Array.isArray(grantedSkills) ? grantedSkills.filter(Boolean) : []; return [ '你是 TKMind H5 聊天意图路由器,只负责判断用户消息应该走哪条处理通道。', '', '系统有两条通道:', '1. direct_chat — 纯文字交流:问答、解释、闲聊、总结已有文字、给建议,不需要写文件、生成页面、调工具。', '2. agent_orchestration — 任务编排 Agent:需要实际执行并产出结果的任务,例如生成/修改 HTML 页面、发布到 MindSpace、搜索实时资料、写代码改仓库、表单收集、数据表格、商品页、docx/长图导出等。', '', '判断原则:', '- 用户只要文字回答,不要求“做出来/发布/生成链接/改文件” → direct_chat', '- 用户要产出可访问页面、文件、链接,或需要工具/skills → agent_orchestration', '- 不确定时优先 agent_orchestration,避免漏执行', '- 记忆线索只用于辅助判断本轮意图,不能替用户扩写新需求', '', skills.length ? `当前用户已授权 skills:${skills.join(', ')}` : '当前用户未授权额外 skills。', skills.length ? '若走 agent_orchestration,可在 suggested_skill 中填写最匹配的 skill 名称(须来自上述列表),否则填 null。' : 'suggested_skill 通常填 null。', '', '只输出 JSON,不要 markdown,不要解释:', '{"route":"direct_chat|agent_orchestration","confidence":0.0,"reason":"一句话","suggested_skill":null,"agent_brief":"给 Agent 的执行要点,direct_chat 时可为空"}', ].join('\n'); } export function buildRouterContext(resolveResult, { memoryLimit = 5, semanticLimit = 3, goalLimit = 2, memoryTextLimit = 80, semanticTextLimit = 60, goalTextLimit = 60, behaviorTextLimit = 100, } = {}) { const result = resolveResult && typeof resolveResult === 'object' ? resolveResult : {}; const lines = []; let itemsUsed = 0; const memories = Array.isArray(result.memories) ? result.memories : []; const memoryLines = memories .map((item) => normalizeMemoryText(item)) .map(({ label, text }) => { const clipped = truncateText(text, memoryTextLimit); if (!clipped) return ''; return `- ${label ? `[${label}] ` : ''}${clipped}`; }) .filter(Boolean) .slice(0, memoryLimit); if (memoryLines.length) { lines.push('相关记忆:', ...memoryLines); itemsUsed += memoryLines.length; } const semanticLines = (Array.isArray(result.semanticMemories) ? result.semanticMemories : []) .map((item) => (typeof item === 'string' ? item : normalizeMemoryText(item).text)) .map((text) => truncateText(text, semanticTextLimit)) .filter(Boolean) .slice(0, semanticLimit) .map((text) => `- ${text}`); if (semanticLines.length) { lines.push('语义线索:', ...semanticLines); itemsUsed += semanticLines.length; } const behaviorSummary = firstSentence(result.behaviorSummary, behaviorTextLimit); if (behaviorSummary) { lines.push(`行为摘要:${behaviorSummary}`); itemsUsed += 1; } const goals = Array.isArray(result.activeGoals) && result.activeGoals.length ? result.activeGoals : (Array.isArray(result.contextGoals) ? result.contextGoals : []); const goalLines = goals .map((text) => truncateText(text, goalTextLimit)) .filter(Boolean) .slice(0, goalLimit) .map((text) => `- ${text}`); if (goalLines.length) { lines.push('进行中目标:', ...goalLines); itemsUsed += goalLines.length; } return { content: lines.join('\n').trim(), itemsUsed, source: result.source ?? null, degraded: Boolean(result.degraded), skipped: Boolean(result.skipped), reason: result.reason ?? null, }; } function buildRouterUserPrompt({ text, routerContext }) { const context = String(routerContext ?? '').trim() || '无'; return [ '[Router Context]', context, '', '[User]', text || '(empty)', ].join('\n'); } function parseRouterJson(reply) { const text = String(reply ?? '').trim(); if (!text) return null; const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); const candidate = (fenced?.[1] ?? text).trim(); try { return JSON.parse(candidate); } catch { const start = candidate.indexOf('{'); const end = candidate.lastIndexOf('}'); if (start < 0 || end <= start) return null; try { return JSON.parse(candidate.slice(start, end + 1)); } catch { return null; } } } function normalizeRoute(value) { const normalized = String(value ?? '').trim().toLowerCase(); if (normalized === CHAT_INTENT_ROUTE.DIRECT_CHAT) return CHAT_INTENT_ROUTE.DIRECT_CHAT; if (normalized === CHAT_INTENT_ROUTE.AGENT || normalized === 'agent') return CHAT_INTENT_ROUTE.AGENT; return null; } export function resolveChatIntentRouterPolicy({ env = process.env, overrides = {} } = {}) { const fallbackRoute = normalizeRoute(env?.MEMIND_CHAT_ROUTER_FALLBACK_ROUTE) ?? CHAT_INTENT_ROUTE.AGENT; return { enabled: envFlag(env?.MEMIND_CHAT_LLM_ROUTER_ENABLED, false), modelProviderKeyId: String(env?.MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID ?? '').trim() || null, model: String(env?.MEMIND_CHAT_ROUTER_MODEL ?? '').trim() || null, modelApiType: String(env?.MEMIND_CHAT_ROUTER_MODEL_API ?? '').trim() || 'chat', temperature: boundedNumber(env?.MEMIND_CHAT_ROUTER_TEMPERATURE, 0, { min: 0, max: 2 }), minConfidence: boundedNumber( env?.MEMIND_CHAT_ROUTER_MIN_CONFIDENCE, DEFAULT_ROUTER_MIN_CONFIDENCE, { min: 0, max: 1 }, ), memoryResolveEnabled: envFlag(env?.MEMIND_CHAT_ROUTER_MEMORY_ENABLED, true), memoryResolveLimit: Math.round(boundedNumber( env?.MEMIND_CHAT_ROUTER_MEMORY_LIMIT, DEFAULT_ROUTER_MEMORY_LIMIT, { min: 1, max: 50 }, )), timeoutMs: Math.round(boundedNumber( env?.MEMIND_CHAT_ROUTER_TIMEOUT_MS, DEFAULT_ROUTER_TIMEOUT_MS, { min: 0, max: 30_000 }, )), fallbackRoute, ...overrides, }; } function normalizeClassification(raw, { source, fallbackRoute = CHAT_INTENT_ROUTE.AGENT } = {}) { const route = normalizeRoute(raw?.route) ?? fallbackRoute; const confidenceRaw = Number(raw?.confidence); const confidence = Number.isFinite(confidenceRaw) ? Math.min(1, Math.max(0, confidenceRaw)) : source === 'llm' ? 0.7 : 1; const suggestedSkill = String(raw?.suggested_skill ?? raw?.suggestedSkill ?? '').trim() || null; const agentBrief = String(raw?.agent_brief ?? raw?.agentBrief ?? '').trim(); const reason = String(raw?.reason ?? '').trim() || (source === 'llm' ? '模型路由判定' : '规则路由判定'); return { route, confidence, reason, suggestedSkill, agentBrief, source, providerKeyId: raw?.providerKeyId ?? raw?.provider_key_id ?? null, model: raw?.model ?? null, memory: raw?.memory ?? null, }; } function resolveSkillPrompt(suggestedSkill, grantedSkills = []) { const skillName = String(suggestedSkill ?? '').trim(); if (!skillName) return ''; if (grantedSkills.length > 0 && !grantedSkills.includes(skillName)) return ''; const promptKey = SKILL_PROMPT_KEYS[skillName]; if (!promptKey) return ''; return buildChatSkillPrompt(promptKey, skillName); } export function buildAgentOrchestrationAgentText({ displayText, classification, skillPrompt = '', }) { const taskBody = String(displayText ?? '').trim(); const lines = [ `${AGENT_ORCHESTRATION_HEADER}以下为用户任务,请使用工具与技能实际执行并产出结果,不要只做文字描述。`, `路由判定:${classification.reason}`, classification.agentBrief ? `执行要点:${classification.agentBrief}` : '', classification.suggestedSkill ? `建议 skill:${classification.suggestedSkill}` : '', skillPrompt, '', '用户任务:', taskBody, ].filter((line, index, all) => line !== '' || index === all.length - 2); return lines.join('\n'); } export function applyAgentOrchestrationToUserMessage(userMessage, classification, { grantedSkills = [] } = {}) { const displayText = messageDisplayText(userMessage); const skillPrompt = resolveSkillPrompt(classification?.suggestedSkill, grantedSkills); const agentText = buildAgentOrchestrationAgentText({ displayText, classification, skillPrompt, }); const content = Array.isArray(userMessage?.content) ? userMessage.content.map((item, index) => { if (index !== 0) return item; if (typeof item === 'string') return agentText; if (item?.type === 'text') return { ...item, text: agentText }; return item; }) : [{ type: 'text', text: agentText }]; const metadata = { ...(userMessage?.metadata ?? {}), displayText: displayText || undefined, chatIntentRoute: CHAT_INTENT_ROUTE.AGENT, chatIntentSource: classification?.source ?? null, userVisible: userMessage?.metadata?.userVisible ?? true, agentVisible: userMessage?.metadata?.agentVisible ?? true, }; return { ...userMessage, content, metadata, }; } function hasPriorAgentConversation(sessionId, sessionMessageCount) { if (!sessionId || isDirectChatSessionId(sessionId)) return false; if (sessionMessageCount == null) return true; return Number(sessionMessageCount) > 0; } export function classifyWithRules({ text, forceDeepReasoning = false, toolMode = 'chat', sessionId = null, sessionMessageCount = null, userMessage = null, includeIntentPatterns = true, llmRouterEnabled = false, } = {}) { const normalized = String(text ?? '').trim(); if (forceDeepReasoning || toolMode !== 'chat') { return normalizeClassification({ route: CHAT_INTENT_ROUTE.AGENT, confidence: 1, reason: forceDeepReasoning ? '用户开启深度推理' : '代码任务模式', }, { source: 'rule' }); } if (userMessage && messageHasExplicitChatSkillSelection(userMessage)) { return buildSkillSelectionClassification(userMessage); } if (userMessage && !isTextOnlyUserMessage(userMessage)) { return normalizeClassification({ route: CHAT_INTENT_ROUTE.AGENT, confidence: 1, reason: '消息包含非文本内容', }, { source: 'rule' }); } if ( !llmRouterEnabled && includeIntentPatterns && normalized && isMemoryRecallQuestion(normalized) ) { return normalizeClassification({ route: CHAT_INTENT_ROUTE.DIRECT_CHAT, confidence: 0.98, reason: '用户在询问个人记忆或历史对话', }, { source: 'rule' }); } if (!llmRouterEnabled && hasPriorAgentConversation(sessionId, sessionMessageCount)) { return normalizeClassification({ route: CHAT_INTENT_ROUTE.AGENT, confidence: 1, reason: '延续已有 Agent 会话', }, { source: 'rule' }); } if ( includeIntentPatterns && normalized && OBVIOUS_AGENT_PATTERNS.some((pattern) => pattern.test(normalized)) ) { return normalizeClassification({ route: CHAT_INTENT_ROUTE.AGENT, confidence: 0.95, reason: '明确需要生成或发布页面/文件', suggested_skill: 'static-page-publish', agent_brief: '生成或更新 MindSpace 公开页面,并返回可访问链接。', }, { source: 'rule' }); } if ( includeIntentPatterns && normalized && OBVIOUS_DIRECT_PATTERNS.some((pattern) => pattern.test(normalized)) ) { return normalizeClassification({ route: CHAT_INTENT_ROUTE.DIRECT_CHAT, confidence: 0.95, reason: '简单寒暄或连通性测试', }, { source: 'rule' }); } return null; } export function createChatIntentRouter(options = {}) { const { llmProviderService, memoryV2 = null, env = process.env, logger = console, } = options; const policy = options.policy ?? resolveChatIntentRouterPolicy({ env, overrides: pickDefined(options, [ 'enabled', 'modelProviderKeyId', 'model', 'modelApiType', 'temperature', 'minConfidence', 'memoryResolveEnabled', 'memoryResolveLimit', 'timeoutMs', 'fallbackRoute', ]), }); function getStatus() { return { enabled: Boolean(policy.enabled), modelProviderKeyId: policy.modelProviderKeyId ?? null, model: policy.model ?? null, modelApiType: policy.modelApiType ?? 'chat', minConfidence: policy.minConfidence, memoryResolveEnabled: Boolean(policy.memoryResolveEnabled), memoryResolveLimit: policy.memoryResolveLimit, timeoutMs: policy.timeoutMs, fallbackRoute: policy.fallbackRoute, }; } function isEnabled() { return Boolean(policy.enabled && llmProviderService?.createChatCompletion); } async function resolveRouterContext({ userId, sessionId, text, forceDeepReasoning = false }) { const intervention = resolveMemoryInterventionMode({ forceDeepReasoning, recallQuestion: isMemoryRecallQuestion(text), }); const limit = memoryLimitForIntervention(intervention, { context: 'router' }); if ( limit <= 0 || !policy.memoryResolveEnabled || !memoryV2?.resolve || !userId ) { return buildRouterContext(null); } try { const resolved = await withTimeout( memoryV2.resolve({ userId, sessionId, query: text, limit, }), policy.timeoutMs, 'Memory V2 router resolve', ); return buildRouterContext(resolved); } catch (err) { logger?.warn?.( `[chat-intent-router] memory resolve skipped: ${err instanceof Error ? err.message : err}`, ); return buildRouterContext({ degraded: true, reason: 'memory_resolve_failed', }); } } async function classify({ userId = null, userMessage, sessionId = null, sessionMessageCount = null, toolMode = 'chat', forceDeepReasoning = false, grantedSkills = [], } = {}) { const text = messageDisplayText(userMessage); const ruleResult = classifyWithRules({ text, forceDeepReasoning, toolMode, sessionId, sessionMessageCount, userMessage, includeIntentPatterns: false, llmRouterEnabled: Boolean(policy.enabled), }); if (ruleResult) return ruleResult; if (!isEnabled()) { return normalizeClassification({ route: policy.fallbackRoute, confidence: 0.5, reason: '意图路由未启用,走默认通道', }, { source: 'fallback' }); } const routerContext = await resolveRouterContext({ userId, sessionId, text, forceDeepReasoning, }); let completion = null; try { completion = await withTimeout( llmProviderService.createChatCompletion({ providerKeyId: policy.modelProviderKeyId || undefined, model: policy.model || undefined, modelApiType: policy.modelApiType || undefined, temperature: policy.temperature, messages: [ { role: 'system', content: buildRouterSystemPrompt(grantedSkills) }, { role: 'user', content: buildRouterUserPrompt({ text, routerContext: routerContext.content, }), }, ], }), policy.timeoutMs, 'Chat intent router', ); } catch (err) { return normalizeClassification({ route: policy.fallbackRoute, confidence: 0, reason: err instanceof Error ? err.message : '意图路由失败,走默认通道', memory: routerContext, }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }); } if (!completion?.ok) { return normalizeClassification({ route: policy.fallbackRoute, confidence: 0, reason: completion?.message ?? '意图路由失败,走默认通道', memory: routerContext, }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }); } const parsed = parseRouterJson(completion.reply); if (!parsed) { return normalizeClassification({ route: policy.fallbackRoute, confidence: 0, reason: '意图路由响应无法解析,走默认通道', memory: routerContext, }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }); } const classification = { ...normalizeClassification(parsed, { source: 'llm', fallbackRoute: policy.fallbackRoute }), providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId ?? null, model: completion.model ?? policy.model ?? null, memory: routerContext, }; if ( classification.route === CHAT_INTENT_ROUTE.DIRECT_CHAT && classification.confidence < policy.minConfidence ) { return normalizeClassification({ ...classification, route: policy.fallbackRoute, reason: `${classification.reason}(置信度 ${classification.confidence} 低于阈值,走默认通道)`, }, { source: 'threshold', fallbackRoute: policy.fallbackRoute }); } return classification; } return { getStatus, isEnabled, classify, applyAgentOrchestration: applyAgentOrchestrationToUserMessage, }; } export function createManagedChatIntentRouter({ llmProviderService, memoryV2 = null, configService = null, env = process.env, logger = console, } = {}) { let activeRouter = null; let activeFingerprint = null; let activeMeta = { source: 'env', updatedAt: null, updatedBy: null, configError: null }; async function loadRouterState() { if (!configService?.getRuntimeState) { return { source: 'env', updatedAt: null, updatedBy: null, fingerprint: 'env-only', effectiveEnv: { ...env }, configError: null, }; } try { const state = await configService.getRuntimeState(); return { source: state?.source ?? 'admin-db', updatedAt: state?.updatedAt ?? null, updatedBy: state?.updatedBy ?? null, fingerprint: state?.fingerprint ?? `admin-db:${Date.now()}`, effectiveEnv: { ...env, ...(state?.overrides ?? {}) }, configError: null, }; } catch (err) { logger?.warn?.( `[chat-intent-router] admin config unavailable, using process env: ${err instanceof Error ? err.message : err}`, ); return { source: 'env-fallback', updatedAt: null, updatedBy: null, fingerprint: 'env-fallback', effectiveEnv: { ...env }, configError: err instanceof Error ? err.message : String(err), }; } } async function ensureRouter() { const state = await loadRouterState(); if (activeRouter && activeFingerprint === state.fingerprint) { activeMeta = state; return activeRouter; } activeRouter = createChatIntentRouter({ llmProviderService, memoryV2, env: state.effectiveEnv, logger, }); activeFingerprint = state.fingerprint; activeMeta = state; return activeRouter; } return { async getStatus() { const router = await ensureRouter(); return { ...router.getStatus(), configSource: activeMeta.source, configUpdatedAt: activeMeta.updatedAt, configUpdatedBy: activeMeta.updatedBy, configError: activeMeta.configError, }; }, async isEnabled() { const router = await ensureRouter(); return router.isEnabled(); }, async classify(input = {}) { const router = await ensureRouter(); return router.classify(input); }, applyAgentOrchestration: applyAgentOrchestrationToUserMessage, }; }