Files
memind/chat-intent-router.mjs
T
john 14a00774d9 feat(h5): web 联网能力、实时查询路由与 session Finish 对齐
- 新增 web 能力并挂载 platform/web(web_search/fetch_url)
- 实时查询强制 web skill,router fallback 与 await session Finish
- Session Broker 覆盖率/指标、stream replay 与相关单测/E2E 脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 16:06:26 +08:00

1050 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
buildChatSkillPrompt,
buildWebNewsSkillPrompt,
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',
};
export const ROUTER_DECISION_ROUTE = {
CHAT: 'chat',
AGENT: 'agent',
};
export const ROUTER_DECISION_MODE = {
SSE: 'sse',
WS: 'ws',
};
export const ROUTER_DECISION_SESSION_HINT = {
REUSE: 'reuse',
NEW: 'new',
};
const ALLOWED_ROUTER_DECISION_FLAGS = new Set([
'memory_recall',
'selected_skill',
'force_deep_reasoning',
'code_task',
'long_running',
]);
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,
];
/** Pure text chat/creative prompts — fast-path even when LLM router is enabled. */
const OBVIOUS_DIRECT_CHAT_PATTERNS = [
/(?:讲|说|来|编).{0,10}(?:个|一段|一首|一个)?(?:睡前故事|故事|笑话|段子)/u,
/(?:写|作|创|生成).{0,10}(?:首|一段|一篇|一个)?(?:诗|散文|笑话|段子|故事)/u,
/(?:继续|再).{0,8}(?:写|讲|说|编).{0,10}(?:诗|故事|笑话|散文)/u,
];
/** Realtime / web-search prompts — fast-path to agent even when LLM router is enabled. */
const REALTIME_INFO_AGENT_PATTERNS = [
/(?:世界杯|欧冠|NBA|英超|西甲|意甲|德甲|法甲|欧洲杯|亚洲杯|奥运会).{0,16}(?:赛况|赛程|比分|战况|结果|积分|排名|进展|情况)/u,
/(?:赛况|赛程|比分|战况|积分榜|最新比赛|比赛结果).{0,16}(?:如何|怎样|怎么样|多少|是什么)/u,
/(?:看看|查(?:一下|查)?|搜索|打听|了解).{0,16}(?:今天|今日|现在|最新|当前).{0,16}(?:新闻|赛况|比分|天气|行情|股价|汇率|热点)/u,
/(?:今天|今日|现在|最新|当前).{0,12}(?:的)?(?:新闻|赛况|比分|天气|行情|股价|汇率|热点)/u,
/(?:实时|最新).{0,8}(?:信息|数据|情况|动态|资讯)/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));
}
export function isRealtimeInfoQuestion(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return REALTIME_INFO_AGENT_PATTERNS.some((pattern) => pattern.test(normalized));
}
function buildRealtimeInfoClassification() {
return normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.98,
reason: '需要搜索实时资料',
suggested_skill: 'web',
agent_brief: REALTIME_WEB_AGENT_BRIEF,
}, { source: 'rule' });
}
export function coerceRealtimeWebSkill(classification, text, { grantedSkills = [] } = {}) {
if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification;
if (!isRealtimeInfoQuestion(text)) return classification;
if (grantedSkills.length > 0 && !grantedSkills.includes('web')) return classification;
const reason = String(classification.reason ?? '').trim();
return {
...classification,
suggestedSkill: 'web',
agentBrief: REALTIME_WEB_AGENT_BRIEF,
reason: reason.includes('web') ? reason : `${reason || '需要搜索实时资料'}(实时查询强制 web`,
};
}
function resolveRouterFallbackRoute(env, overrides = {}) {
const raw = overrides.fallbackRoute
?? normalizeRoute(env?.MEMIND_CHAT_ROUTER_FALLBACK_ROUTE)
?? CHAT_INTENT_ROUTE.AGENT;
// Timeout/parse failures must fail open to agent so realtime/tool tasks are not dropped.
return raw === CHAT_INTENT_ROUTE.DIRECT_CHAT ? CHAT_INTENT_ROUTE.AGENT : raw;
}
const DEFAULT_ROUTER_TIMEOUT_MS = 2500;
const DEFAULT_ROUTER_MEMORY_LIMIT = 8;
const DEFAULT_ROUTER_MIN_CONFIDENCE = 0.55;
const REALTIME_WEB_AGENT_BRIEF =
'先 load_skill → web;优先 web_search/fetch_url 获取实时信息。若 web_search 不可用,立即改用 fetch_url 抓取 Bing/新华网/央视等可达来源;禁止使用 search 技能查工作区,不要反复 scrape 同一站点。';
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_orchestrationsuggested_skill 填 web(不要填 search',
'- 不确定时优先 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 || normalized === ROUTER_DECISION_ROUTE.CHAT) {
return CHAT_INTENT_ROUTE.DIRECT_CHAT;
}
if (
normalized === CHAT_INTENT_ROUTE.AGENT ||
normalized === 'agent' ||
normalized === 'agent_orchestration' ||
normalized === ROUTER_DECISION_ROUTE.AGENT
) {
return CHAT_INTENT_ROUTE.AGENT;
}
return null;
}
export function resolveNormalizedRouterDecisionMode(env = process.env) {
const raw = String(env?.MEMIND_ROUTER_NORMALIZED_DECISION ?? '').trim().toLowerCase();
if (!raw || raw === '0' || raw === 'false' || raw === 'off' || raw === 'no') return 'off';
if (raw === 'shadow') return 'shadow';
if (['1', 'true', 'yes', 'on'].includes(raw)) return 'on';
return 'off';
}
export function isNormalizedRouterDecisionEnabled(env = process.env) {
return resolveNormalizedRouterDecisionMode(env) === 'on';
}
export function isNormalizedRouterDecisionShadow(env = process.env) {
return resolveNormalizedRouterDecisionMode(env) === 'shadow';
}
export function buildRouterNormalizedDecision(
classification,
{
sessionId = null,
sessionMessageCount = null,
forceDeepReasoning = false,
toolMode = 'chat',
text = '',
} = {},
) {
const legacyRoute = classification?.route ?? CHAT_INTENT_ROUTE.AGENT;
const route =
legacyRoute === CHAT_INTENT_ROUTE.DIRECT_CHAT
? ROUTER_DECISION_ROUTE.CHAT
: ROUTER_DECISION_ROUTE.AGENT;
const mode = ROUTER_DECISION_MODE.SSE;
let sessionHint = ROUTER_DECISION_SESSION_HINT.NEW;
if (sessionId) {
sessionHint = ROUTER_DECISION_SESSION_HINT.REUSE;
}
if (
sessionId &&
!isDirectChatSessionId(sessionId) &&
sessionMessageCount != null &&
Number(sessionMessageCount) === 0
) {
sessionHint = ROUTER_DECISION_SESSION_HINT.NEW;
}
const flags = [];
if (isMemoryRecallQuestion(text)) flags.push('memory_recall');
if (forceDeepReasoning) flags.push('force_deep_reasoning');
if (toolMode === 'code') flags.push('code_task');
if (classification?.suggestedSkill) flags.push('selected_skill');
const normalizedFlags = [...new Set(flags)].filter((flag) => ALLOWED_ROUTER_DECISION_FLAGS.has(flag));
return {
route,
mode,
session_hint: sessionHint,
flags: normalizedFlags,
};
}
export function finalizeRouterClassification(classification, context = {}) {
if (!classification) return classification;
return {
...classification,
decision: buildRouterNormalizedDecision(classification, context),
};
}
export function resolveLegacyRouteFromClassification(classification) {
if (!classification) return null;
if (isNormalizedRouterDecisionEnabled() && classification.decision?.route) {
return classification.decision.route === ROUTER_DECISION_ROUTE.CHAT
? CHAT_INTENT_ROUTE.DIRECT_CHAT
: CHAT_INTENT_ROUTE.AGENT;
}
return classification.route ?? null;
}
export function logRouterDecisionShadow(
classification,
{ logger = console, requestId = null, sessionId = null, env = process.env } = {},
) {
if (!isNormalizedRouterDecisionShadow(env) || !classification?.decision) return;
const legacyRoute = classification.route ?? null;
const normalizedLegacyRoute =
classification.decision.route === ROUTER_DECISION_ROUTE.CHAT
? CHAT_INTENT_ROUTE.DIRECT_CHAT
: CHAT_INTENT_ROUTE.AGENT;
const payload = {
requestId,
sessionId,
legacyRoute,
normalizedRoute: normalizedLegacyRoute,
decision: classification.decision,
source: classification.source ?? null,
reason: classification.reason ?? null,
wouldChangeRoute: legacyRoute != null && legacyRoute !== normalizedLegacyRoute,
};
const line = `[router-shadow] ${JSON.stringify(payload)}`;
if (typeof logger.info === 'function') logger.info(line);
else logger.log(line);
}
/**
* Apply router session_hint for goosed agent path without overriding hard rules.
* Returns null when gateway should start a fresh goosed session.
*/
export function resolveGatewayAgentSessionId({
agentSessionId = null,
classification = null,
forceDeepReasoning = false,
enabled = isNormalizedRouterDecisionEnabled(),
} = {}) {
const sessionId = agentSessionId || null;
if (forceDeepReasoning) {
return sessionId;
}
if (!enabled || !classification?.decision) {
return sessionId;
}
const decision = classification.decision;
if (Array.isArray(decision.flags) && decision.flags.includes('force_deep_reasoning')) {
return sessionId;
}
if (!sessionId) {
return null;
}
if (isDirectChatSessionId(sessionId)) {
return sessionId;
}
if (decision.session_hint === ROUTER_DECISION_SESSION_HINT.NEW) {
return null;
}
return sessionId;
}
export function resolveChatIntentRouterPolicy({ env = process.env, overrides = {} } = {}) {
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 },
)),
...overrides,
fallbackRoute: resolveRouterFallbackRoute(env, 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 = [], displayText = '') {
const skillName = String(suggestedSkill ?? '').trim();
if (!skillName) return '';
if (grantedSkills.length > 0 && !grantedSkills.includes(skillName)) return '';
if (skillName === 'web' && isRealtimeInfoQuestion(displayText)) {
return buildWebNewsSkillPrompt(skillName);
}
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, displayText);
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 decisionContext = {
text,
forceDeepReasoning,
toolMode,
sessionId,
sessionMessageCount,
};
const normalized = String(text ?? '').trim();
if (forceDeepReasoning || toolMode !== 'chat') {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason: forceDeepReasoning ? '用户开启深度推理' : '代码任务模式',
}, { source: 'rule' }), decisionContext);
}
if (userMessage && messageHasExplicitChatSkillSelection(userMessage)) {
return finalizeRouterClassification(buildSkillSelectionClassification(userMessage), decisionContext);
}
if (userMessage && !isTextOnlyUserMessage(userMessage)) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason: '消息包含非文本内容',
}, { source: 'rule' }), decisionContext);
}
if (
!llmRouterEnabled &&
includeIntentPatterns &&
normalized &&
isMemoryRecallQuestion(normalized)
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.98,
reason: '用户在询问个人记忆或历史对话',
}, { source: 'rule' }), decisionContext);
}
if (!llmRouterEnabled && hasPriorAgentConversation(sessionId, sessionMessageCount)) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason: '延续已有 Agent 会话',
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_AGENT_PATTERNS.some((pattern) => pattern.test(normalized))
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.95,
reason: '明确需要生成或发布页面/文件',
suggested_skill: 'static-page-publish',
agent_brief: '生成或更新 MindSpace 公开页面,并返回可访问链接。',
}, { source: 'rule' }), decisionContext);
}
if (includeIntentPatterns && normalized && isRealtimeInfoQuestion(normalized)) {
return finalizeRouterClassification(buildRealtimeInfoClassification(), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_DIRECT_PATTERNS.some((pattern) => pattern.test(normalized))
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.95,
reason: '简单寒暄或连通性测试',
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_DIRECT_CHAT_PATTERNS.some((pattern) => pattern.test(normalized))
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.95,
reason: '纯文字创作或闲聊,无需执行任务',
}, { source: 'rule' }), decisionContext);
}
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,
normalizedDecisionEnabled: isNormalizedRouterDecisionEnabled(env),
normalizedDecisionMode: resolveNormalizedRouterDecisionMode(env),
};
}
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 decisionContext = {
text,
forceDeepReasoning,
toolMode,
sessionId,
sessionMessageCount,
};
const finalizeWithRealtimeCoercion = (classification) => {
if (!classification) return classification;
const base = { ...classification };
delete base.decision;
return finalizeRouterClassification(
coerceRealtimeWebSkill(base, text, { grantedSkills }),
decisionContext,
);
};
const ruleResult = classifyWithRules({
text,
forceDeepReasoning,
toolMode,
sessionId,
sessionMessageCount,
userMessage,
includeIntentPatterns: true,
llmRouterEnabled: Boolean(policy.enabled),
});
if (ruleResult) return finalizeWithRealtimeCoercion(ruleResult);
if (!isEnabled()) {
return finalizeWithRealtimeCoercion(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 finalizeWithRealtimeCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: err instanceof Error ? err.message : '意图路由失败,走默认通道',
memory: routerContext,
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
}
if (!completion?.ok) {
return finalizeWithRealtimeCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: completion?.message ?? '意图路由失败,走默认通道',
memory: routerContext,
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
}
const parsed = parseRouterJson(completion.reply);
if (!parsed) {
return finalizeWithRealtimeCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: '意图路由响应无法解析,走默认通道',
memory: routerContext,
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
}
const classification = finalizeWithRealtimeCoercion({
...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 finalizeWithRealtimeCoercion(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,
};
}