diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 5f393d4..3ff7dbc 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -16,6 +16,7 @@ import { createSubscriptionService } from './billing-subscription.mjs'; import { createDbPool, isDatabaseConfigured } from './db.mjs'; import { createUserAuth } from './user-auth.mjs'; import { createLlmProviderService } from './llm-providers.mjs'; +import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs'; import { createPlazaInteractionService } from './plaza-interactions.mjs'; import { createPlazaOpsService } from './plaza-ops.mjs'; @@ -24,6 +25,7 @@ import { ensureAlgorithmConfig, loadAlgorithmConfig } from './plaza-algorithm.mj import { createNotificationDispatcher } from './notification-dispatcher.mjs'; import { createWechatAdminService } from './wechat-admin.mjs'; import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs'; +import { createAdminSystemTestService } from './admin-system-tests.mjs'; const noop = () => {}; @@ -36,7 +38,7 @@ const noop = () => {}; * @param {string} [env.apiSecret] Goosed/relay API secret. * @param {number} [env.defaultSignupBalanceCents] * @param {boolean} [env.ensureAdminUser] Ensure an admin account exists on boot (default true). - * @returns {Promise<{pool, userAuth, llmProviderService, plazaPosts, plazaOps, wechatAdmin}>} + * @returns {Promise<{pool, userAuth, llmProviderService, memoryV2ConfigService, adminSystemTestService, plazaPosts, plazaOps, wechatAdmin}>} */ export async function createAdminServices(env = {}) { if (!isDatabaseConfigured()) { @@ -91,6 +93,13 @@ export async function createAdminServices(env = {}) { } const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret }); + const memoryV2ConfigService = createMemoryV2AdminConfigService(pool); + const adminSystemTestService = createAdminSystemTestService({ + pool, + userAuth, + portalBaseUrl: process.env.SYSTEM_TEST_PORTAL_BASE_URL + ?? `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`, + }); const wechatMpConfig = loadWechatMpConfig(); const wechatMpService = createWechatMpService({ config: wechatMpConfig, @@ -116,5 +125,15 @@ export async function createAdminServices(env = {}) { : null, }); - return { pool, userAuth, llmProviderService, plazaPosts, plazaOps, wechatAdmin, subscriptionService }; + return { + pool, + userAuth, + llmProviderService, + memoryV2ConfigService, + adminSystemTestService, + plazaPosts, + plazaOps, + wechatAdmin, + subscriptionService, + }; } diff --git a/admin-routes.mjs b/admin-routes.mjs index 2999728..f40a2f4 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -30,6 +30,8 @@ function plazaRouteError(res, req, error) { * @param {Promise} [deps.ready] Optional bootstrap gate awaited before the first request resolves auth. * @param {object} deps.userAuth * @param {object|null} deps.llmProviderService + * @param {object|null} deps.memoryV2ConfigService + * @param {object|null} deps.adminSystemTestService * @param {object|null} deps.plazaPosts * @param {object|null} deps.plazaOps * @param {object|null} deps.wechatAdmin @@ -40,6 +42,8 @@ export function createAdminApi({ ready, userAuth, llmProviderService, + memoryV2ConfigService, + adminSystemTestService, plazaPosts, plazaOps, wechatAdmin, @@ -87,6 +91,49 @@ export function createAdminApi({ res.json({ summary: { ...summary, llm } }); }); + adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => { + if (!memoryV2ConfigService?.getAdminConfig) { + return res.status(503).json({ message: 'Memory V2 配置服务未启用' }); + } + const result = await memoryV2ConfigService.getAdminConfig(); + return res.json(result); + }); + + const updateMemoryV2Config = async (req, res) => { + if (!memoryV2ConfigService?.updateAdminConfig) { + return res.status(503).json({ message: 'Memory V2 配置服务未启用' }); + } + const result = await memoryV2ConfigService.updateAdminConfig(req.body ?? {}, { + updatedBy: req.currentUser.id, + }); + return res.json(result); + }; + + adminApi.put('/memory-v2/config', requireAdmin, updateMemoryV2Config); + adminApi.patch('/memory-v2/config', requireAdmin, updateMemoryV2Config); + + adminApi.get('/memory-v2/runtime', requireAdmin, async (_req, res) => { + if (!memoryV2ConfigService?.getRuntimeState) { + return res.status(503).json({ message: 'Memory V2 配置服务未启用' }); + } + const result = await memoryV2ConfigService.getRuntimeState(); + return res.json(result); + }); + + adminApi.post('/system-tests/skill-validation', requireAdmin, async (req, res) => { + if (!adminSystemTestService?.runSkillValidation) { + return res.status(503).json({ message: '系统测试服务未启用' }); + } + const username = String(req.body?.username ?? '').trim(); + const password = String(req.body?.password ?? ''); + const skillName = String(req.body?.skillName ?? 'service-integration-smoke').trim(); + if (!username || !password) { + return res.status(400).json({ message: '请输入测试账号和密码' }); + } + const result = await adminSystemTestService.runSkillValidation({ username, password, skillName }); + return res.json(result); + }); + adminApi.post('/users', requireAdmin, async (req, res) => { const result = await userAuth.createUser(req.body ?? {}); if (!result.ok) return res.status(400).json({ message: result.message }); diff --git a/admin-routes.test.mjs b/admin-routes.test.mjs new file mode 100644 index 0000000..04301f2 --- /dev/null +++ b/admin-routes.test.mjs @@ -0,0 +1,151 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import { once } from 'node:events'; +import { createAdminApi } from './admin-routes.mjs'; + +async function startTestServer(router) { + const app = express(); + app.use('/admin-api', router); + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + return { + server, + baseUrl: `http://127.0.0.1:${address.port}`, + close: async () => { + server.close(); + await once(server, 'close'); + }, + }; +} + +test('admin memory-v2 config routes expose config and runtime state', async () => { + const updates = []; + const router = createAdminApi({ + jsonBody: express.json(), + getToken() { + return 'token-admin'; + }, + userAuth: { + async getMe(token) { + if (token !== 'token-admin') return null; + return { id: 'admin-1', role: 'admin' }; + }, + }, + llmProviderService: null, + memoryV2ConfigService: { + async getAdminConfig() { + return { config: { chatIntentRouter: { enabled: true } }, updatedAt: 123 }; + }, + async updateAdminConfig(patch, { updatedBy }) { + updates.push({ patch, updatedBy }); + return { config: patch, updatedAt: 456, updatedBy }; + }, + async getRuntimeState() { + return { source: 'admin-db', overrides: { MEMIND_CHAT_LLM_ROUTER_ENABLED: '1' } }; + }, + }, + plazaPosts: null, + plazaOps: null, + wechatAdmin: null, + subscriptionService: null, + }); + + const server = await startTestServer(router); + try { + const configRes = await fetch(`${server.baseUrl}/admin-api/memory-v2/config`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(configRes.status, 200); + assert.deepEqual(await configRes.json(), { + config: { chatIntentRouter: { enabled: true } }, + updatedAt: 123, + }); + + const updateRes = await fetch(`${server.baseUrl}/admin-api/memory-v2/config`, { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + cookie: 'h5_user_session=token-admin', + }, + body: JSON.stringify({ chatIntentRouter: { enabled: false } }), + }); + assert.equal(updateRes.status, 200); + assert.deepEqual(updates, [{ + patch: { chatIntentRouter: { enabled: false } }, + updatedBy: 'admin-1', + }]); + + const runtimeRes = await fetch(`${server.baseUrl}/admin-api/memory-v2/runtime`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(runtimeRes.status, 200); + assert.deepEqual(await runtimeRes.json(), { + source: 'admin-db', + overrides: { MEMIND_CHAT_LLM_ROUTER_ENABLED: '1' }, + }); + } finally { + await server.close(); + } +}); + +test('admin system test route executes shared validation service', async () => { + const calls = []; + const router = createAdminApi({ + jsonBody: express.json(), + getToken() { + return 'token-admin'; + }, + userAuth: { + async getMe(token) { + if (token !== 'token-admin') return null; + return { id: 'admin-1', role: 'admin' }; + }, + }, + llmProviderService: null, + memoryV2ConfigService: null, + adminSystemTestService: { + async runSkillValidation(input) { + calls.push(input); + return { + ok: true, + selectedSkill: input.skillName, + account: { username: input.username }, + summary: { passed: 1, warnings: 0, failed: 0 }, + steps: [], + issues: [], + }; + }, + }, + plazaPosts: null, + plazaOps: null, + wechatAdmin: null, + subscriptionService: null, + }); + + const server = await startTestServer(router); + try { + const response = await fetch(`${server.baseUrl}/admin-api/system-tests/skill-validation`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie: 'h5_user_session=token-admin', + }, + body: JSON.stringify({ + username: 'john', + password: 'secret', + skillName: 'service-integration-smoke', + }), + }); + assert.equal(response.status, 200); + assert.deepEqual(calls, [{ + username: 'john', + password: 'secret', + skillName: 'service-integration-smoke', + }]); + assert.equal((await response.json()).ok, true); + } finally { + await server.close(); + } +}); diff --git a/admin-server.mjs b/admin-server.mjs index 2ac45b4..ca66418 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -76,6 +76,8 @@ const CONSOLES = { getToken, userAuth: services.userAuth, llmProviderService: services.llmProviderService, + memoryV2ConfigService: services.memoryV2ConfigService, + adminSystemTestService: services.adminSystemTestService, plazaPosts: services.plazaPosts, plazaOps: services.plazaOps, wechatAdmin: services.wechatAdmin, diff --git a/admin-system-tests.mjs b/admin-system-tests.mjs new file mode 100644 index 0000000..5f0e26e --- /dev/null +++ b/admin-system-tests.mjs @@ -0,0 +1,446 @@ +import crypto from 'node:crypto'; +import { buildChatSkillPrompt, CHAT_SKILL_DEFINITIONS } from './chat-skills.mjs'; + +const DEFAULT_SKILL = 'service-integration-smoke'; +const RUN_TIMEOUT_MS = 25000; +const POLL_INTERVAL_MS = 500; +const USER_COOKIE = 'tkmind_user_session'; + +function normalizeJson(value) { + if (!value) return null; + if (typeof value === 'object') return value; + if (typeof value !== 'string') return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function trimText(value, max = 600) { + const text = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (!text) return ''; + return text.length > max ? `${text.slice(0, max)}...` : text; +} + +function extractMessageText(message) { + const content = message?.content; + if (typeof content === 'string') return content.trim(); + if (!Array.isArray(content)) return String(message?.text ?? '').trim(); + return content + .map((item) => { + if (typeof item === 'string') return item; + if (item?.type === 'text') return item.text ?? ''; + return ''; + }) + .join('\n') + .trim(); +} + +function stepResult(step) { + return { + key: step.key, + label: step.label, + status: step.status, + message: step.message, + details: step.details ?? null, + }; +} + +function makeIssue(stepKey, severity, title, detail) { + return { + stepKey, + severity, + title, + detail: trimText(detail, 1000), + }; +} + +function cookieHeader(token) { + return `${USER_COOKIE}=${encodeURIComponent(token)}`; +} + +function skillPrompt(skillName) { + const definition = CHAT_SKILL_DEFINITIONS.find((item) => item.skillName === skillName); + if (definition?.promptKey) { + return buildChatSkillPrompt(definition.promptKey, skillName); + } + return `请使用 ${skillName} 技能:`; +} + +async function portalJson(fetchImpl, url, { token, method = 'GET', body } = {}) { + const response = await fetchImpl(url, { + method, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + Cookie: cookieHeader(token), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await response.text().catch(() => ''); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + return { ok: response.ok, status: response.status, payload }; +} + +export function createAdminSystemTestService({ + pool, + userAuth, + portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`, + fetchImpl = globalThis.fetch, +} = {}) { + if (!pool || !userAuth || !fetchImpl) { + throw new Error('admin system test service requires pool, userAuth, and fetch'); + } + + async function getRunById(runId) { + const [rows] = await pool.query( + `SELECT id, user_id, request_id, status, attempts, agent_session_id, error_message, completed_at + FROM h5_agent_runs + WHERE id = ? + LIMIT 1`, + [runId], + ); + return rows[0] ?? null; + } + + async function getRunEvents(runId) { + const [rows] = await pool.query( + `SELECT event_type, data_json, created_at + FROM h5_agent_run_events + WHERE run_id = ? + ORDER BY created_at ASC`, + [runId], + ); + return rows.map((row) => ({ + eventType: row.event_type, + data: normalizeJson(row.data_json) ?? row.data_json ?? null, + createdAt: Number(row.created_at ?? 0) || null, + })); + } + + async function getSessionPreview(sessionId) { + if (!sessionId) return null; + const [rows] = await pool.query( + `SELECT messages_json + FROM h5_session_snapshots + WHERE agent_session_id = ? + LIMIT 1`, + [sessionId], + ); + const parsed = normalizeJson(rows[0]?.messages_json); + if (!Array.isArray(parsed)) return null; + const assistant = parsed.filter((message) => message?.role === 'assistant'); + const latestAssistant = assistant.length ? assistant.at(-1) : null; + return { + messageCount: parsed.length, + assistantPreview: trimText(extractMessageText(latestAssistant), 800), + }; + } + + async function waitForTerminalRun(runId, timeoutMs = RUN_TIMEOUT_MS) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const run = await getRunById(runId); + if (!run) { + return { run: null, events: [], preview: null }; + } + if (['succeeded', 'failed'].includes(run.status)) { + const [events, preview] = await Promise.all([ + getRunEvents(runId), + getSessionPreview(run.agent_session_id), + ]); + return { run, events, preview }; + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + const run = await getRunById(runId); + const [events, preview] = await Promise.all([ + getRunEvents(runId), + getSessionPreview(run?.agent_session_id ?? null), + ]); + return { run, events, preview, timedOut: true }; + } + + async function cleanupSession(token, sessionId) { + if (!sessionId) return; + try { + await fetchImpl(`${portalBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, { + method: 'DELETE', + headers: { Cookie: cookieHeader(token) }, + }); + } catch { + // best effort + } + } + + async function runChatProbe(token, text, { + stepKey, + label, + expectDirectChat = false, + expectMemory = false, + } = {}) { + const requestId = crypto.randomUUID(); + const submit = await portalJson(fetchImpl, `${portalBaseUrl}/api/agent/runs`, { + token, + method: 'POST', + body: { + request_id: requestId, + user_message: { + id: `admin-system-test-${stepKey}`, + role: 'user', + created: Math.floor(Date.now() / 1000), + content: [{ type: 'text', text }], + metadata: { userVisible: true, agentVisible: true }, + }, + }, + }); + if (!submit.ok || !submit.payload?.run?.id) { + return { + step: stepResult({ + key: stepKey, + label, + status: 'failed', + message: `提交测试消息失败(HTTP ${submit.status})`, + details: submit.payload, + }), + issues: [makeIssue(stepKey, 'error', '测试消息提交失败', JSON.stringify(submit.payload ?? {}))], + }; + } + + const runId = submit.payload.run.id; + const result = await waitForTerminalRun(runId); + const issues = []; + const routeEvent = result.events.find((event) => event.eventType === 'intent_routed'); + const directEvent = result.events.find((event) => event.eventType === 'direct_chat_completed'); + const directFailed = result.events.find((event) => event.eventType === 'direct_chat_failed'); + const route = routeEvent?.data?.route ?? null; + const memory = routeEvent?.data?.memory ?? null; + const missingDirectChat = expectDirectChat && (!directEvent || route !== 'direct_chat'); + const missingMemory = expectMemory && (!memory || Number(memory.itemsUsed ?? 0) <= 0); + const status = result.timedOut || result.run?.status !== 'succeeded' || missingDirectChat + ? 'failed' + : missingMemory + ? 'warning' + : 'passed'; + + if (expectDirectChat && route !== 'direct_chat') { + issues.push(makeIssue(stepKey, 'error', '未走直连聊天', `实际 route=${route ?? 'unknown'}`)); + } + if (expectDirectChat && !directEvent) { + issues.push(makeIssue(stepKey, 'error', '直连聊天未完成', JSON.stringify(directFailed?.data ?? result.run ?? {}))); + } + if (expectMemory && (!memory || Number(memory.itemsUsed ?? 0) <= 0)) { + issues.push(makeIssue( + stepKey, + 'warning', + 'Memory V2 未命中可用记忆', + JSON.stringify(memory ?? routeEvent?.data ?? {}), + )); + } + if (result.run?.status !== 'succeeded') { + issues.push(makeIssue(stepKey, 'error', '聊天任务未成功结束', JSON.stringify(result.run ?? {}))); + } + + await cleanupSession(token, result.run?.agent_session_id ?? null); + + return { + step: stepResult({ + key: stepKey, + label, + status, + message: status === 'passed' + ? '验证通过' + : status === 'warning' + ? '验证完成,但存在待处理问题' + : '验证失败', + details: { + runId, + route, + runStatus: result.run?.status ?? null, + sessionId: result.run?.agent_session_id ?? null, + memory, + assistantPreview: result.preview?.assistantPreview ?? '', + directChatFailed: directFailed?.data ?? null, + timedOut: Boolean(result.timedOut), + }, + }), + issues, + }; + } + + async function runSkillProbe(token, skillName) { + const prompt = `${skillPrompt(skillName)}请基于当前账号做一次轻量联调,只返回“通过项 / 失败项 / 待确认项”,不要生成页面。`; + const result = await runChatProbe(token, prompt, { + stepKey: 'skill_probe', + label: 'Skill 验证', + expectDirectChat: false, + expectMemory: false, + }); + const preview = String(result.step.details?.assistantPreview ?? ''); + if (!preview || !/(通过项|失败项|待确认项)/.test(preview)) { + result.step.status = result.step.status === 'failed' ? 'failed' : 'warning'; + result.step.message = 'Skill 已执行,但输出不符合预期格式'; + result.issues.push(makeIssue( + 'skill_probe', + 'warning', + 'Skill 输出不完整', + preview || JSON.stringify(result.step.details ?? {}), + )); + } + return result; + } + + async function runSkillValidation({ username, password, skillName = DEFAULT_SKILL } = {}) { + const issues = []; + const steps = []; + const startedAt = Date.now(); + const normalizedSkill = String(skillName || DEFAULT_SKILL).trim() || DEFAULT_SKILL; + + const loginResult = await userAuth.login({ + username: String(username ?? '').trim(), + password: String(password ?? ''), + ip: 'admin-system-test', + now: startedAt, + }); + + if (!loginResult?.ok) { + return { + ok: false, + startedAt, + finishedAt: Date.now(), + portalBaseUrl, + selectedSkill: normalizedSkill, + account: { username: String(username ?? '').trim() }, + steps: [ + stepResult({ + key: 'login', + label: '账号登录', + status: 'failed', + message: loginResult?.message ?? '登录失败', + }), + ], + issues: [makeIssue('login', 'error', '账号密码校验失败', loginResult?.message ?? '登录失败')], + summary: { passed: 0, warnings: 0, failed: 1 }, + }; + } + + const token = loginResult.token; + const user = loginResult.user; + steps.push(stepResult({ + key: 'login', + label: '账号登录', + status: 'passed', + message: '登录成功', + details: { userId: user.id, username: user.username, displayName: user.displayName ?? null }, + })); + + try { + const authStatus = await portalJson(fetchImpl, `${portalBaseUrl}/auth/status`, { token }); + if (!authStatus.ok || !authStatus.payload?.authenticated) { + steps.push(stepResult({ + key: 'auth_status', + label: '会话状态', + status: 'failed', + message: `auth/status 异常(HTTP ${authStatus.status})`, + details: authStatus.payload, + })); + issues.push(makeIssue('auth_status', 'error', '用户会话未生效', JSON.stringify(authStatus.payload ?? {}))); + } else { + const grantedSkills = Array.isArray(authStatus.payload.grantedSkills) + ? authStatus.payload.grantedSkills + : []; + const hasSelectedSkill = grantedSkills.includes(normalizedSkill); + steps.push(stepResult({ + key: 'auth_status', + label: '会话状态', + status: 'passed', + message: 'auth/status 返回正常', + details: { + userId: authStatus.payload.user?.id ?? null, + grantedSkills, + capabilities: authStatus.payload.capabilities ?? null, + }, + })); + steps.push(stepResult({ + key: 'skill_grant', + label: 'Skill 授权', + status: hasSelectedSkill ? 'passed' : 'warning', + message: hasSelectedSkill + ? `账号已授予 ${normalizedSkill}` + : `账号未授予 ${normalizedSkill}`, + details: { selectedSkill: normalizedSkill, grantedSkills }, + })); + if (!hasSelectedSkill) { + issues.push(makeIssue( + 'skill_grant', + 'warning', + '账号未授予目标 skill', + `selected=${normalizedSkill}; granted=${grantedSkills.join(', ')}`, + )); + } + } + + const greetingProbe = await runChatProbe(token, 'hi', { + stepKey: 'direct_chat', + label: '直连聊天验证', + expectDirectChat: true, + }); + steps.push(greetingProbe.step); + issues.push(...greetingProbe.issues); + + const memoryProbe = await runChatProbe(token, '你记得我什么?', { + stepKey: 'memory_read', + label: 'Memory V2 读取验证', + expectDirectChat: true, + expectMemory: true, + }); + steps.push(memoryProbe.step); + issues.push(...memoryProbe.issues); + + const skillProbe = await runSkillProbe(token, normalizedSkill); + steps.push(skillProbe.step); + issues.push(...skillProbe.issues); + } finally { + try { + await fetchImpl(`${portalBaseUrl}/auth/logout`, { + method: 'POST', + headers: { Cookie: cookieHeader(token) }, + }); + } catch { + // best effort + } + } + + const summary = steps.reduce((acc, step) => { + if (step.status === 'passed') acc.passed += 1; + else if (step.status === 'warning') acc.warnings += 1; + else acc.failed += 1; + return acc; + }, { passed: 0, warnings: 0, failed: 0 }); + + return { + ok: summary.failed === 0, + startedAt, + finishedAt: Date.now(), + portalBaseUrl, + selectedSkill: normalizedSkill, + account: { + userId: user.id, + username: user.username, + displayName: user.displayName ?? null, + }, + summary, + steps, + issues, + }; + } + + return { + runSkillValidation, + }; +} diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index d5a5306..37103d4 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -173,6 +173,13 @@ function withRunMetadata(userMessage, { toolMode = 'chat', taskType = null, forc return { ...message, metadata }; } +function normalizeSessionMessageCount(value) { + if (value == null || value === '') return null; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return null; + return Math.floor(parsed); +} + function getRunOptionsFromMessage(userMessage) { const metadata = userMessage?.metadata; const runMetadata = metadata?.[RUN_METADATA_KEY] ?? metadata?.agentRun ?? {}; @@ -187,6 +194,9 @@ function getRunOptionsFromMessage(userMessage) { taskType: normalizeTaskType(runMetadata?.taskType ?? metadata?.taskType), forceDeepReasoning: runMetadata?.forceDeepReasoning === true || metadata?.forceDeepReasoning === true, validation: normalizeToolGatewayValidation(runMetadata?.validation ?? metadata?.toolGatewayValidation), + sessionMessageCount: normalizeSessionMessageCount( + runMetadata?.sessionMessageCount ?? metadata?.sessionMessageCount, + ), }; } @@ -213,6 +223,7 @@ export function createAgentRunGateway({ tkmindProxy, toolGateway = null, directChatService = null, + chatIntentRouter = null, retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS, autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true), maxConcurrentRuns = positiveInteger( @@ -387,21 +398,83 @@ export function createAgentRunGateway({ } } + async function resolveGrantedSkills(userId) { + if (!userId) return []; + if (userAuth?.getUserSkills) { + const skills = await userAuth.getUserSkills(userId).catch(() => null); + if (Array.isArray(skills?.grantedSkills)) return skills.grantedSkills; + } + if (!userAuth?.getUserCapabilities) return []; + const caps = await userAuth.getUserCapabilities(userId).catch(() => null); + return Array.isArray(caps?.grantedSkills) ? caps.grantedSkills : []; + } + + async function resolveRunRouting(row, userMessage, runOptions) { + if (!chatIntentRouter?.classify) return null; + const enabled = chatIntentRouter.isEnabled + ? await Promise.resolve(chatIntentRouter.isEnabled()).catch(() => false) + : true; + if (!enabled) return null; + const grantedSkills = await resolveGrantedSkills(row.user_id); + return chatIntentRouter.classify({ + userId: row.user_id, + userMessage, + sessionId: row.agent_session_id ?? null, + sessionMessageCount: runOptions.sessionMessageCount, + toolMode: runOptions.toolMode, + forceDeepReasoning: runOptions.forceDeepReasoning, + grantedSkills, + }); + } + async function executeRun(row, runId) { - const userMessage = safeJsonParse(row.user_message_json, {}); + let userMessage = safeJsonParse(row.user_message_json, {}); const runOptions = getRunOptionsFromMessage(userMessage); const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null; - if (!runOptions.forceDeepReasoning && directChatService?.canHandle?.({ + const routing = await resolveRunRouting(row, userMessage, runOptions); + if (routing) { + await appendEvent(runId, 'intent_routed', routing); + if (routing.route === 'agent_orchestration' && chatIntentRouter?.applyAgentOrchestration) { + const grantedSkills = await resolveGrantedSkills(row.user_id); + userMessage = chatIntentRouter.applyAgentOrchestration(userMessage, routing, { grantedSkills }); + } + } + const routingDecision = routing?.route ?? null; + const preferDirectChat = + routingDecision === 'direct_chat' || + (isDirectChatSessionId(row.agent_session_id ?? null) && !runOptions.forceDeepReasoning); + const directChatInput = { sessionId: row.agent_session_id ?? null, toolMode: runOptions.toolMode, userMessage, - })) { + routingDecision, + }; + const canDirectChat = Boolean( + preferDirectChat && directChatService?.canHandle?.(directChatInput), + ); + if (preferDirectChat && !canDirectChat) { + const rejection = directChatService?.explainCanHandle?.(directChatInput); + await appendEvent(runId, 'direct_chat_skipped', { + sessionId: row.agent_session_id ?? null, + routingDecision, + reason: rejection?.reason ?? 'can_handle_false', + }); + } + if (canDirectChat) { try { const result = await directChatService.run({ userId: row.user_id, sessionId: row.agent_session_id ?? null, requestId: row.request_id, userMessage, + routingDecision, + routingMemory: routing?.memory ?? null, + onSessionReady: async (activeSessionId) => { + await pool.query( + `UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`, + [activeSessionId, nowMs(), runId], + ); + }, }); await appendEvent(runId, 'direct_chat_completed', { sessionId: result.sessionId, @@ -495,7 +568,10 @@ export function createAgentRunGateway({ sessionId, row.request_id, userMessage, - { toolMode: runOptions.toolMode }, + { + toolMode: runOptions.toolMode, + forceDeepReasoning: runOptions.forceDeepReasoning, + }, ); return { sessionId }; } @@ -610,6 +686,12 @@ export function createAgentRunGateway({ ) h ON h.run_id = r.id WHERE r.status = 'running' AND h.latest_heartbeat_at IS NULL`, ); + const chatIntentRouterStatus = chatIntentRouter?.getStatus + ? await Promise.resolve(chatIntentRouter.getStatus()).catch((err) => ({ + enabled: false, + error: err instanceof Error ? err.message : String(err), + })) + : null; return { autoDispatch, maxConcurrentRuns, @@ -635,6 +717,7 @@ export function createAgentRunGateway({ terminalStatuses: [...TERMINAL_STATUSES], toolGateway: toolGateway?.getStatus ? toolGateway.getStatus() : null, directChat: directChatService?.getStatus ? directChatService.getStatus() : null, + chatIntentRouter: chatIntentRouterStatus, }; } diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 5079173..3acc62c 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -258,20 +258,36 @@ test('agent run starts a session and marks submitted reply as succeeded', async test('agent run uses direct chat service for eligible chat messages', async () => { const pool = createFakePool(); const directRuns = []; + const submitted = []; const gateway = createAgentRunGateway({ pool, userAuth: {}, tkmindProxy: { async startSessionForUser() { - assert.fail('backend session should not start for direct chat'); + return { id: 'agent-session-should-not-run' }; }, - async submitSessionReplyForUser() { - assert.fail('backend reply should not be submitted for direct chat'); + async submitSessionReplyForUser(...args) { + submitted.push(args); + }, + }, + chatIntentRouter: { + isEnabled() { + return true; + }, + async classify() { + return { + route: 'direct_chat', + confidence: 0.92, + reason: '普通问候', + source: 'llm', + }; }, }, directChatService: { - canHandle({ toolMode, userMessage }) { - return toolMode === 'chat' && userMessage?.content?.[0]?.text === 'hi'; + canHandle({ toolMode, userMessage, routingDecision }) { + return toolMode === 'chat' + && routingDecision === 'direct_chat' + && userMessage?.content?.[0]?.text === 'hi'; }, async run(input) { directRuns.push(input); @@ -297,10 +313,246 @@ test('agent run uses direct chat service for eligible chat messages', async () = await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); assert.equal(pool.runs.get(run.id).agent_session_id, 'h5direct_session-1'); assert.equal(directRuns.length, 1); + assert.equal(submitted.length, 0); assert.equal(directRuns[0].requestId, 'req-direct'); assert.ok(pool.events.some((event) => event.eventType === 'direct_chat_completed')); }); +test('agent run uses direct chat on regular agent sessions when llm routes direct_chat', async () => { + const pool = createFakePool(); + const directRuns = []; + const submitted = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async submitSessionReplyForUser(...args) { + submitted.push(args); + }, + }, + chatIntentRouter: { + isEnabled() { + return true; + }, + async classify() { + return { + route: 'direct_chat', + confidence: 0.95, + reason: '问答', + source: 'llm', + }; + }, + }, + directChatService: { + canHandle({ routingDecision }) { + return routingDecision === 'direct_chat'; + }, + explainCanHandle({ routingDecision }) { + return { ok: routingDecision === 'direct_chat', reason: null }; + }, + async run(input) { + directRuns.push(input); + return { + sessionId: input.sessionId ?? '20260704_11', + providerId: 'custom_deepseek', + model: 'deepseek-chat', + billing: { ok: true }, + }; + }, + getStatus() { + return { enabled: true }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + sessionId: '20260704_11', + requestId: 'req-taihu', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '我想对太湖有更多的了解' }], + metadata: { displayText: '我想对太湖有更多的了解' }, + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(pool.runs.get(run.id).agent_session_id, '20260704_11'); + assert.equal(directRuns.length, 1); + assert.equal(submitted.length, 0); + assert.ok(pool.events.some((event) => event.eventType === 'direct_chat_completed')); +}); + +test('agent run records direct_chat_skipped when execution is unavailable', async () => { + const pool = createFakePool(); + const submitted = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async submitSessionReplyForUser(...args) { + submitted.push(args); + }, + }, + chatIntentRouter: { + isEnabled() { + return true; + }, + async classify() { + return { + route: 'direct_chat', + confidence: 0.95, + reason: '问答', + source: 'llm', + }; + }, + }, + directChatService: { + canHandle() { + return false; + }, + explainCanHandle() { + return { ok: false, reason: 'session_not_direct_chat' }; + }, + getStatus() { + return { enabled: true }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + sessionId: '20260704_11', + requestId: 'req-skipped', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '你好' }], + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(submitted.length, 1); + const skipped = pool.events.find((event) => event.eventType === 'direct_chat_skipped'); + const skippedData = typeof skipped?.dataJson === 'string' + ? JSON.parse(skipped.dataJson) + : skipped?.dataJson; + assert.equal(skippedData?.reason, 'session_not_direct_chat'); +}); + +test('agent run falls back to backend session when router is disabled', async () => { + const pool = createFakePool(); + const submitted = []; + const directRuns = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'agent-session-1' }; + }, + async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) { + submitted.push({ userId, sessionId, requestId, userMessage }); + }, + }, + directChatService: { + canHandle() { + return true; + }, + async run(input) { + directRuns.push(input); + return { sessionId: 'h5direct_session-1' }; + }, + getStatus() { + return { enabled: true }; + }, + }, + chatIntentRouter: { + isEnabled() { + return false; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-agent-fallback', + userMessage: { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(pool.runs.get(run.id).agent_session_id, 'agent-session-1'); + assert.equal(directRuns.length, 0); + assert.equal(submitted.length, 1); + assert.equal(submitted[0].sessionId, 'agent-session-1'); +}); + +test('agent run uses chat intent router to enrich agent orchestration messages', async () => { + const pool = createFakePool(); + const submitted = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: { + async getUserCapabilities() { + return { grantedSkills: ['static-page-publish'] }; + }, + }, + tkmindProxy: { + async startSessionForUser(userId) { + assert.equal(userId, 'user-1'); + return { id: 'agent-session-1' }; + }, + async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) { + submitted.push({ userId, sessionId, requestId, userMessage }); + }, + }, + chatIntentRouter: { + isEnabled() { + return true; + }, + async classify() { + return { + route: 'agent_orchestration', + confidence: 0.93, + reason: '需要生成页面', + suggestedSkill: 'static-page-publish', + agentBrief: '生成并发布 HTML', + source: 'llm', + }; + }, + applyAgentOrchestration(userMessage, classification, { grantedSkills = [] }) { + const displayText = userMessage?.metadata?.displayText ?? userMessage?.content?.[0]?.text ?? ''; + return { + ...userMessage, + content: [{ + type: 'text', + text: `【Memind 任务编排】${classification.reason}\n用户任务:${displayText}`, + }], + metadata: { + ...(userMessage.metadata ?? {}), + displayText, + }, + }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-router-agent', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '帮我做一个页面' }], + metadata: { displayText: '帮我做一个页面' }, + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(submitted.length, 1); + assert.match(submitted[0].userMessage.content[0].text, /Memind 任务编排/); + assert.match(submitted[0].userMessage.content[0].text, /帮我做一个页面/); + assert.ok(pool.events.some((event) => event.eventType === 'intent_routed')); +}); + test('agent run escalates direct sessions to a new backend session when forced', async () => { const pool = createFakePool(); const submitted = []; @@ -365,7 +617,7 @@ test('agent run with code tool mode starts and submits with code policy', async }); await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); - assert.deepEqual(submitted.map((item) => item.options), [{ toolMode: 'code' }]); + assert.deepEqual(submitted.map((item) => item.options), [{ toolMode: 'code', forceDeepReasoning: false }]); assert.equal(submitted[0].userMessage.metadata.memindRun.toolMode, 'code'); }); diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs new file mode 100644 index 0000000..f607dd2 --- /dev/null +++ b/chat-intent-router.mjs @@ -0,0 +1,772 @@ +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, +]; + +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, + }; + 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, + }; +} diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs new file mode 100644 index 0000000..b922269 --- /dev/null +++ b/chat-intent-router.test.mjs @@ -0,0 +1,516 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyAgentOrchestrationToUserMessage, + buildRouterContext, + buildAgentOrchestrationAgentText, + CHAT_INTENT_ROUTE, + classifyWithRules, + createChatIntentRouter, + createManagedChatIntentRouter, +} from './chat-intent-router.mjs'; + +test('classifyWithRules routes greetings to direct chat', () => { + const result = classifyWithRules({ + text: '你好', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '你好' }], + metadata: { displayText: '你好' }, + }, + }); + assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.equal(result.source, 'rule'); +}); + +test('classifyWithRules routes page generation to agent orchestration', () => { + const result = classifyWithRules({ + text: '帮我做一个秋夜诗的 H5 页面', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '帮我做一个秋夜诗的 H5 页面' }], + metadata: { displayText: '帮我做一个秋夜诗的 H5 页面' }, + }, + }); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.suggestedSkill, 'static-page-publish'); +}); + +test('classifyWithRules honors forceDeepReasoning bypass', () => { + const result = classifyWithRules({ + text: '你好', + forceDeepReasoning: true, + userMessage: { + role: 'user', + content: [{ type: 'text', text: '你好' }], + }, + }); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.match(result.reason, /深度推理/); +}); + +test('classifyWithRules skips session continuation when llm router is enabled', () => { + const continued = classifyWithRules({ + text: '我想去日本玩', + sessionId: '20260704_10', + sessionMessageCount: 2, + llmRouterEnabled: true, + }); + assert.equal(continued, null); +}); + +test('classifyWithRules keeps empty pre-created sessions eligible for llm routing', () => { + const continued = classifyWithRules({ + text: '继续聊聊', + sessionId: '20260704_2', + sessionMessageCount: 2, + }); + assert.equal(continued.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(continued.reason, '延续已有 Agent 会话'); + + const fresh = classifyWithRules({ + text: '我是 John,你对我的记忆知道多少?', + sessionId: '20260704_3', + sessionMessageCount: 0, + }); + assert.equal(fresh.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.match(fresh.reason, /记忆/); +}); + +test('classifyWithRules routes memory recall to direct chat even in agent session', () => { + const result = classifyWithRules({ + text: '你记得我说想去哪儿吗', + sessionId: '20260704_9', + sessionMessageCount: 4, + userMessage: { + role: 'user', + content: [{ type: 'text', text: '你记得我说想去哪儿吗' }], + metadata: { displayText: '你记得我说想去哪儿吗' }, + }, + }); + assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.match(result.reason, /记忆/); +}); + +test('classifyWithRules bypasses llm router when user selected summarize skill', async () => { + const llmCalls = []; + const router = createChatIntentRouter({ + enabled: true, + llmProviderService: { + async createChatCompletion() { + llmCalls.push(1); + return { ok: true, reply: '{"route":"direct_chat","confidence":0.9,"reason":"x"}' }; + }, + }, + memoryV2: { + async resolve() { + assert.fail('memory resolve should not run for explicit skill selection'); + }, + }, + }); + + const prompt = '请总结以下内容,提炼核心结论、重点信息和可执行建议(条理清晰、中文输出):'; + const result = await router.classify({ + userMessage: { + role: 'user', + content: [{ type: 'text', text: `${prompt}这是待总结的正文` }], + metadata: { + displayText: `${prompt}这是待总结的正文`, + memindRun: { selectedChatSkill: 'summarize' }, + }, + }, + }); + + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.source, 'rule'); + assert.equal(result.reason, '用户已选择 skill'); + assert.equal(llmCalls.length, 0); +}); + +test('classifyWithRules bypasses llm router for explicit web skill prompt', async () => { + const llmCalls = []; + const router = createChatIntentRouter({ + enabled: true, + llmProviderService: { + async createChatCompletion() { + llmCalls.push(1); + return { ok: true, reply: '{"route":"direct_chat","confidence":0.9,"reason":"x"}' }; + }, + }, + }); + + const result = await router.classify({ + userMessage: { + role: 'user', + content: [{ + type: 'text', + text: '请使用 web 技能:帮我搜索并查阅相关资料(优先官方文档),并给出中文摘要与来源链接。我的问题是:今天 AI 新闻', + }], + metadata: { displayText: '今天 AI 新闻' }, + }, + grantedSkills: ['web'], + }); + + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.suggestedSkill, 'web'); + assert.equal(llmCalls.length, 0); +}); + +test('createChatIntentRouter classifies continued sessions through llm when enabled', async () => { + const llmCalls = []; + const router = createChatIntentRouter({ + enabled: true, + modelProviderKeyId: 'key-router', + model: 'deepseek-chat', + llmProviderService: { + async createChatCompletion(payload) { + llmCalls.push(payload); + return { + ok: true, + reply: JSON.stringify({ + route: 'agent_orchestration', + confidence: 0.88, + reason: '用户要规划日本旅行,需要搜索和生成内容', + suggested_skill: 'static-page-publish', + agent_brief: '整理日本旅行攻略', + }), + }; + }, + }, + memoryV2: { + async resolve() { + return { source: 'legacy-conversation-memory', memories: [{ label: 'goal', text: '用户想去日本旅游' }] }; + }, + }, + }); + + const result = await router.classify({ + userId: 'user-john', + sessionId: '20260704_10', + sessionMessageCount: 2, + userMessage: { + role: 'user', + content: [{ type: 'text', text: '我想去日本玩' }], + metadata: { displayText: '我想去日本玩' }, + }, + }); + + assert.equal(llmCalls.length, 1); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.source, 'llm'); + assert.match(result.reason, /日本旅行/); + assert.equal(result.memory?.itemsUsed ?? 0, 0); +}); + +test('createChatIntentRouter skips memory resolve for general conversational analysis', async () => { + const llmCalls = []; + const resolveCalls = []; + const router = createChatIntentRouter({ + enabled: true, + modelProviderKeyId: 'key-router', + model: 'deepseek-chat', + llmProviderService: { + async createChatCompletion({ messages, providerKeyId, model, temperature }) { + llmCalls.push({ messages, providerKeyId, model, temperature }); + return { + ok: true, + providerKeyId, + model, + reply: JSON.stringify({ + route: 'direct_chat', + confidence: 0.91, + reason: '用户在咨询概念,不需要执行工具', + suggested_skill: null, + agent_brief: '', + }), + }; + }, + }, + memoryV2: { + async resolve(input) { + resolveCalls.push(input); + return { + source: 'pgvector', + memories: [{ label: 'preference', text: '用户偏好简洁回答,不喜欢冗长铺垫' }], + activeGoals: ['完善春季促销活动页'], + }; + }, + }, + }); + + const result = await router.classify({ + userId: 'user-1', + userMessage: { + role: 'user', + content: [{ type: 'text', text: 'React 19 有哪些新特性?' }], + metadata: { displayText: 'React 19 有哪些新特性?' }, + }, + grantedSkills: ['web'], + }); + + assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.equal(result.source, 'llm'); + assert.equal(result.providerKeyId, 'key-router'); + assert.equal(result.model, 'deepseek-chat'); + assert.equal(result.memory?.itemsUsed ?? 0, 0); + assert.equal(resolveCalls.length, 0); + assert.equal(llmCalls.length, 1); + assert.equal(llmCalls[0].providerKeyId, 'key-router'); + assert.equal(llmCalls[0].model, 'deepseek-chat'); + assert.equal(llmCalls[0].temperature, 0); + assert.doesNotMatch(llmCalls[0].messages[1].content, /相关记忆/); +}); + +test('createChatIntentRouter resolves light memory for recall questions', async () => { + const resolveCalls = []; + const router = createChatIntentRouter({ + enabled: true, + modelProviderKeyId: 'key-router', + model: 'deepseek-chat', + llmProviderService: { + async createChatCompletion() { + return { + ok: true, + reply: JSON.stringify({ + route: 'direct_chat', + confidence: 0.95, + reason: '用户在询问个人记忆', + suggested_skill: null, + agent_brief: '', + }), + }; + }, + }, + memoryV2: { + async resolve(input) { + resolveCalls.push(input); + return { + source: 'pgvector', + memories: [{ label: 'goal', text: '用户想去日本旅游' }], + }; + }, + }, + }); + + const result = await router.classify({ + userId: 'user-john', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '你记得我说想去哪儿吗?' }], + metadata: { displayText: '你记得我说想去哪儿吗?' }, + }, + }); + + assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.equal(resolveCalls.length, 1); + assert.equal(resolveCalls[0].limit, 3); + assert.equal(result.memory?.itemsUsed, 1); +}); + +test('buildRouterContext trims rich Memory V2 resolve payload for routing', () => { + const context = buildRouterContext({ + source: 'letta', + memories: [ + { label: 'preference', text: '用户偏好简洁回答,不喜欢冗长铺垫,尤其在工程排障时希望先给结论。' }, + { label: 'habit', text: '用户经常要求生成 H5 活动页并发布到 MindSpace。' }, + ], + semanticMemories: ['上次正在修改活动页标题样式'], + behaviorSummary: '用户倾向先本地验证,再决定是否发布生产。额外句子不应进入轻量摘要。', + activeGoals: ['完善春季促销活动页'], + profile: { ignored: true }, + }); + + assert.equal(context.source, 'letta'); + assert.equal(context.itemsUsed, 5); + assert.match(context.content, /相关记忆/); + assert.match(context.content, /行为摘要/); + assert.match(context.content, /进行中目标/); + assert.doesNotMatch(context.content, /ignored/); +}); + +test('createChatIntentRouter fails open when Memory V2 resolve fails', async () => { + const router = createChatIntentRouter({ + enabled: true, + llmProviderService: { + async createChatCompletion({ messages }) { + assert.match(messages[1].content, /\[Router Context\]\n无/); + return { + ok: true, + reply: JSON.stringify({ + route: 'agent_orchestration', + confidence: 0.82, + reason: '需要工具执行', + suggested_skill: 'web', + agent_brief: '搜索并整理来源', + }), + }; + }, + }, + memoryV2: { + async resolve() { + throw new Error('memory down'); + }, + }, + logger: { warn() {} }, + }); + + const result = await router.classify({ + userId: 'user-1', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '你记得我之前说过什么吗?' }], + metadata: { displayText: '你记得我之前说过什么吗?' }, + }, + grantedSkills: ['web'], + }); + + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.memory.degraded, true); +}); + +test('createManagedChatIntentRouter hot-loads admin config and stays closed by default', async () => { + const states = [ + { + fingerprint: 'off', + overrides: { + MEMIND_CHAT_LLM_ROUTER_ENABLED: '0', + }, + }, + { + fingerprint: 'on', + overrides: { + MEMIND_CHAT_LLM_ROUTER_ENABLED: '1', + MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID: 'key-router', + MEMIND_CHAT_ROUTER_MODEL: 'deepseek-chat', + }, + }, + ]; + const llmCalls = []; + const router = createManagedChatIntentRouter({ + configService: { + async getRuntimeState() { + return states[0]; + }, + }, + llmProviderService: { + async createChatCompletion(input) { + llmCalls.push(input); + return { + ok: true, + providerKeyId: input.providerKeyId, + model: input.model, + reply: JSON.stringify({ + route: 'direct_chat', + confidence: 0.9, + reason: '纯文字问答', + suggested_skill: null, + agent_brief: '', + }), + }; + }, + }, + }); + + assert.equal(await router.isEnabled(), false); + states.shift(); + assert.equal(await router.isEnabled(), true); + const result = await router.classify({ + userMessage: { role: 'user', content: [{ type: 'text', text: '你好' }] }, + }); + assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.equal(llmCalls[0].providerKeyId, 'key-router'); +}); + +test('createChatIntentRouter escalates low-confidence direct chat to agent', async () => { + const router = createChatIntentRouter({ + enabled: true, + minConfidence: 0.8, + llmProviderService: { + async createChatCompletion() { + return { + ok: true, + reply: JSON.stringify({ + route: 'direct_chat', + confidence: 0.42, + reason: '可能是简单问答', + suggested_skill: null, + agent_brief: '', + }), + }; + }, + }, + }); + + const result = await router.classify({ + userMessage: { + role: 'user', + content: [{ type: 'text', text: '帮我整理一下这段材料' }], + metadata: { displayText: '帮我整理一下这段材料' }, + }, + }); + + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.source, 'threshold'); +}); + +test('createChatIntentRouter falls back to agent when LLM fails', async () => { + const router = createChatIntentRouter({ + enabled: true, + llmProviderService: { + async createChatCompletion() { + return { ok: false, message: 'router model unavailable' }; + }, + }, + }); + + const result = await router.classify({ + userMessage: { + role: 'user', + content: [{ type: 'text', text: '帮我查一下今天的 AI 新闻' }], + metadata: { displayText: '帮我查一下今天的 AI 新闻' }, + }, + grantedSkills: ['web'], + }); + + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.source, 'fallback'); +}); + +test('applyAgentOrchestrationToUserMessage preserves displayText and adds task envelope', () => { + const enriched = applyAgentOrchestrationToUserMessage( + { + role: 'user', + content: [{ type: 'text', text: '帮我做一个页面' }], + metadata: { displayText: '帮我做一个页面', userVisible: true }, + }, + { + route: CHAT_INTENT_ROUTE.AGENT, + confidence: 0.9, + reason: '需要生成页面', + suggestedSkill: 'static-page-publish', + agentBrief: '生成 HTML 并发布', + source: 'llm', + }, + { grantedSkills: ['static-page-publish'] }, + ); + + assert.equal(enriched.metadata.displayText, '帮我做一个页面'); + assert.match(enriched.content[0].text, /Memind 任务编排/); + assert.match(enriched.content[0].text, /static-page-publish/); + assert.match(enriched.content[0].text, /帮我做一个页面/); +}); + +test('buildAgentOrchestrationAgentText includes execution brief', () => { + const text = buildAgentOrchestrationAgentText({ + displayText: '帮我搜索今天的新闻', + classification: { + reason: '需要实时搜索', + suggestedSkill: 'web', + agentBrief: '使用 web skill 搜索并整理来源', + }, + skillPrompt: '请使用 web 技能:', + }); + assert.match(text, /需要实时搜索/); + assert.match(text, /帮我搜索今天的新闻/); + assert.match(text, /请使用 web 技能/); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs index 8fb7ad6..cb351e6 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -38,6 +38,15 @@ export const CHAT_SKILL_DEFINITIONS = [ prefillOnly: true, promptKey: 'search', }, + { + id: 'service-integration-smoke', + label: '联调测试', + icon: 'analyze', + skillName: 'service-integration-smoke', + requiresSkill: 'service-integration-smoke', + prefillOnly: true, + promptKey: 'service-integration-smoke', + }, { id: 'form-collect', label: '表单收集', @@ -96,6 +105,8 @@ export function buildChatSkillPrompt(promptKey, skillName) { return `请使用 ${skillName ?? 'search'} 技能:帮我在工作区中查找代码或文件。我要找的是:`; case 'form-builder': return `请使用 ${skillName ?? 'form-builder'} 技能:请用交互式表单收集以下场景所需的结构化字段(字段不超过 8 个):`; + case 'service-integration-smoke': + return `请使用 ${skillName ?? 'service-integration-smoke'} 技能:按标准联调流程检查当前服务,覆盖身份、普通聊天、记忆读取,以及我本轮明确要求验证的技能/发布链路,并输出通过项、失败项、待确认项:`; case 'table-viewer': return `请使用 ${skillName ?? 'table-viewer'} 技能:请把以下数据整理成可排序、可筛选的交互式表格:`; case 'product-campaign-page': @@ -167,3 +178,27 @@ export function stripKnownChatSkillPrompt(text) { } return next.trim(); } + +export function hasExplicitChatSkillPrompt(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + if (/请使用\s+[\w-]+\s+技能[::]/u.test(normalized)) return true; + return stripKnownChatSkillPrompt(normalized) !== normalized; +} + +export function extractSelectedChatSkillName(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return null; + const match = normalized.match(/请使用\s+([\w-]+)\s+技能[::]/u); + if (match?.[1]) return match[1]; + for (const definition of CHAT_SKILL_DEFINITIONS) { + if (!definition.promptKey) continue; + const prompt = buildChatSkillPrompt(definition.promptKey, definition.skillName); + if (prompt && normalized.includes(prompt)) { + return definition.skillName ?? definition.id; + } + } + const newsPrompt = buildWebNewsSkillPrompt('web'); + if (newsPrompt && normalized.includes(newsPrompt)) return 'web'; + return null; +} diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index a6b8ea9..d2b745b 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -22,6 +22,7 @@ test('filterChatSkills gates platform skills by grantedSkills', () => { assert.ok(visible.some((item) => item.id === 'web-search')); assert.ok(visible.some((item) => item.id === 'code-search')); assert.equal(visible.some((item) => item.id === 'form-collect'), false); + assert.equal(visible.some((item) => item.id === 'service-integration-smoke'), false); assert.equal(visible.some((item) => item.id === 'product-campaign-page'), false); }); @@ -33,6 +34,14 @@ test('filterChatSkills shows product campaign page when granted', () => { assert.ok(visible.some((item) => item.id === 'product-campaign-page')); }); +test('filterChatSkills shows service integration smoke when granted', () => { + const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { + canPublish: false, + grantedSkills: ['service-integration-smoke'], + }); + assert.ok(visible.some((item) => item.id === 'service-integration-smoke')); +}); + test('filterChatSkills shows generate-page when publish is allowed', () => { const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { canPublish: true }); assert.ok(visible.some((item) => item.id === 'generate-page')); @@ -40,6 +49,7 @@ test('filterChatSkills shows generate-page when publish is allowed', () => { test('buildChatSkillPrompt includes skill name for platform skills', () => { assert.match(buildChatSkillPrompt('web', 'web'), /请使用 web 技能/); + assert.match(buildChatSkillPrompt('service-integration-smoke'), /标准联调流程/); assert.match(buildChatSkillPrompt('product-campaign-page'), /商品宣传 \/ 活动页/); assert.match(buildChatSkillPrompt('generate-page'), /static-page-publish/); assert.match(buildChatSkillPrompt('generate-page'), /docx-generate/); diff --git a/conversation-memory.mjs b/conversation-memory.mjs index 3dd5cb8..bc16a90 100644 --- a/conversation-memory.mjs +++ b/conversation-memory.mjs @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import { fetch as undiciFetch } from 'undici'; +import { Agent, fetch as undiciFetch } from 'undici'; import { jsonrepair } from 'jsonrepair'; import { decryptSecret, @@ -7,6 +7,10 @@ import { resolveChatCompletionsUrl, } from './llm-providers.mjs'; +const httpsDispatcher = new Agent({ + connect: { rejectUnauthorized: false }, +}); + const MAX_MESSAGE_TEXT = 12000; const MAX_ANALYSIS_MESSAGES = 8; const MAX_MEMORY_TEXT = 1000; @@ -131,8 +135,10 @@ function fallbackMemoriesFromMessages(messages) { const patterns = [ { label: 'preference', re: /(?:我喜欢|我偏好|我更喜欢|以后.*(?:用|叫|按)|不要再|别再)(.+)/ }, { label: 'interest', re: /(?:我对|我关注|我感兴趣|我想了解)(.+)/ }, - { label: 'goal', re: /(?:我的目标是|我想要|我希望|我打算)(.+)/ }, + { label: 'goal', re: /(?:我的目标是|我想要|我希望|我打算|我计划|我想去|打算去|准备去)(.+)/ }, { label: 'habit', re: /(?:我通常|我习惯|我一般)(.+)/ }, + { label: 'fact', re: /(?:我是|我叫|我来自|我在)(.{2,40})/ }, + { label: 'experience', re: /(?:我们|我).{0,8}(?:去|到|在).{2,40}(?:玩|旅游|旅行|出差|度假)/ }, ]; for (const message of messages) { const text = String(message.text ?? '').replace(/\s+/g, ' ').trim(); @@ -150,9 +156,33 @@ function fallbackMemoriesFromMessages(messages) { return memories; } +function resolveMemoryExtractionModelConfig(env = process.env) { + const providerKeyId = String( + env?.USER_CONVERSATION_MEMORY_MODEL_PROVIDER_KEY_ID + ?? env?.MEMORY_EXTRACTION_MODEL_PROVIDER_KEY_ID + ?? env?.MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID + ?? '', + ).trim() || null; + const model = String( + env?.USER_CONVERSATION_MEMORY_MODEL + ?? env?.MEMORY_EXTRACTION_MODEL + ?? env?.MEMIND_CHAT_ROUTER_MODEL + ?? '', + ).trim() || null; + return { providerKeyId, model }; +} + +function parseExtractionMemories(rawText) { + const parsed = extractJsonObject(rawText); + const rawItems = Array.isArray(parsed?.memories) ? parsed.memories : []; + return rawItems.map((item) => normalizeMemoryItem(item)).filter(Boolean); +} + export function createConversationMemoryService(pool, options = {}) { const now = options.now ?? (() => Date.now()); const fetchImpl = options.fetch ?? undiciFetch; + const llmProviderService = options.llmProviderService ?? null; + const resolveEffectiveEnv = options.getEffectiveEnv ?? (async () => process.env); const encryptionKey = options.encryptionKey; const llmWarningIntervalMs = Math.max( 0, @@ -244,6 +274,22 @@ export function createConversationMemoryService(pool, options = {}) { } async function extractWithLlm(messages) { + const prompt = buildMemoryPrompt(messages); + const effectiveEnv = await resolveEffectiveEnv(); + const { providerKeyId, model } = resolveMemoryExtractionModelConfig(effectiveEnv); + + if (llmProviderService?.createChatCompletion) { + const completion = await llmProviderService.createChatCompletion({ + providerKeyId: providerKeyId || undefined, + model: model || undefined, + temperature: 0, + messages: [{ role: 'user', content: prompt }], + }); + if (!completion?.ok) return null; + const memories = parseExtractionMemories(completion.reply); + return memories.length ? memories : []; + } + const row = await selectedProvider(); if (!row) return null; const apiUrl = normalizeApiUrl(row.api_url); @@ -270,20 +316,19 @@ export function createConversationMemoryService(pool, options = {}) { Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ - model: row.default_model, - messages: [{ role: 'user', content: buildMemoryPrompt(messages) }], + model: model || row.default_model, + messages: [{ role: 'user', content: prompt }], stream: false, temperature: 0, ...(row.relay_provider ? { provider: row.relay_provider } : {}), }), + dispatcher: url.startsWith('https://') ? httpsDispatcher : undefined, }); const text = await upstream.text().catch(() => ''); if (!upstream.ok) return null; const payload = extractJsonObject(text); const content = payload?.choices?.[0]?.message?.content ?? payload?.content ?? text; - const parsed = extractJsonObject(content); - const rawItems = Array.isArray(parsed?.memories) ? parsed.memories : []; - return rawItems.map((item) => normalizeMemoryItem(item)).filter(Boolean); + return parseExtractionMemories(content); } async function storeMemories(userId, sourceMessageIds, memories, rawJson = null) { @@ -328,20 +373,18 @@ export function createConversationMemoryService(pool, options = {}) { const messages = await loadUnanalyzedUserMessages(userId); if (!messages.length) return { ok: true, analyzed: 0, memories: 0 }; let memories = null; - let extractionSucceeded = false; if (llmEnabled()) { try { memories = await extractWithLlm(messages); - extractionSucceeded = Array.isArray(memories); } catch (err) { warnLlmExtractionFailed(err); } } if (!memories) memories = fallbackMemoriesFromMessages(messages); const stored = await storeMemories(userId, messages, memories); - if (!llmEnabled() || extractionSucceeded || stored > 0) { - await markAnalyzed(messages.map((message) => message.id)); - } + // Always mark the batch processed after one pass so transient LLM failures + // do not leave messages permanently stuck in the analyze queue. + await markAnalyzed(messages.map((message) => message.id)); return { ok: true, analyzed: messages.length, memories: stored }; } diff --git a/conversation-memory.test.mjs b/conversation-memory.test.mjs index 23b23d4..1645a1c 100644 --- a/conversation-memory.test.mjs +++ b/conversation-memory.test.mjs @@ -152,14 +152,16 @@ test('saveAndAnalyze stores messages and fallback memories', async () => { else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; }); -test('saveAndAnalyze leaves messages unanalyzed when extraction fails and no memory is stored', async () => { +test('saveAndAnalyze marks messages analyzed when llm extraction fails and no memory is stored', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; const pool = createPool(); const service = createConversationMemoryService(pool, { now: () => 2000, - fetch: async () => { - throw new Error('upstream unavailable'); + llmProviderService: { + async createChatCompletion() { + throw new Error('upstream unavailable'); + }, }, }); @@ -174,7 +176,83 @@ test('saveAndAnalyze leaves messages unanalyzed when extraction fails and no mem assert.equal(result.saved, 1); assert.equal(result.memories, 0); - assert.equal(pool.state.messages.find((item) => item.message_key === 'm3')?.analyzed_at, null); + assert.equal(pool.state.messages.find((item) => item.message_key === 'm3')?.analyzed_at, 2000); + + if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; + else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; +}); + +test('saveAndAnalyze uses admin effective env for memory extraction model', async () => { + const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; + process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; + const pool = createPool(); + const llmCalls = []; + const service = createConversationMemoryService(pool, { + now: () => 2600, + getEffectiveEnv: async () => ({ + MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID: 'router-key', + MEMIND_CHAT_ROUTER_MODEL: 'deepseek-chat', + }), + llmProviderService: { + async createChatCompletion({ providerKeyId, model, messages }) { + llmCalls.push({ providerKeyId, model, messages }); + return { + ok: true, + reply: JSON.stringify({ + memories: [{ label: 'fact', text: '用户叫 John', confidence: 0.9 }], + }), + }; + }, + }, + }); + + await service.saveAndAnalyze('session-admin', 'user-admin', [ + { + id: 'm-admin', + role: 'user', + content: [{ type: 'text', text: '我是 John。' }], + }, + ]); + + assert.equal(llmCalls.length, 1); + assert.equal(llmCalls[0].providerKeyId, 'router-key'); + assert.equal(llmCalls[0].model, 'deepseek-chat'); + + if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; + else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; +}); + +test('saveAndAnalyze extracts memories through llmProviderService', async () => { + const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; + process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; + const pool = createPool(); + const service = createConversationMemoryService(pool, { + now: () => 2500, + llmProviderService: { + async createChatCompletion({ messages }) { + assert.match(String(messages?.[0]?.content ?? ''), /长期记忆/); + return { + ok: true, + reply: JSON.stringify({ + memories: [{ label: 'goal', text: '用户计划去日本旅游', confidence: 0.82 }], + }), + }; + }, + }, + }); + + const result = await service.saveAndAnalyze('session-llm', 'user-llm', [ + { + id: 'm-japan', + role: 'user', + content: [{ type: 'text', text: '我打算带家人去日本玩 5 天。' }], + metadata: { userVisible: true }, + }, + ]); + + assert.equal(result.memories, 1); + assert.equal(pool.state.memories[0].label, 'goal'); + assert.match(pool.state.memories[0].memory_text, /日本/); if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; diff --git a/direct-chat-service.mjs b/direct-chat-service.mjs index 9e6bc05..3abffbe 100644 --- a/direct-chat-service.mjs +++ b/direct-chat-service.mjs @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import { MEMORY_INTERVENTION_LIMIT } from './memory-intervention.mjs'; export const DIRECT_CHAT_SESSION_PREFIX = 'h5direct_'; @@ -117,11 +118,22 @@ function renderMemoryLines(memories) { ].join('\n'); } -function buildModelMessages({ previousMessages, userMessage, memories }) { +function buildMemorySystemBlock({ memories, routingMemoryContent } = {}) { + const routed = String(routingMemoryContent ?? '').trim(); + if (routed) { + return [ + '以下是当前用户的长期记忆,只用于改善回答,不要主动暴露记忆来源:', + routed, + ].join('\n'); + } + return renderMemoryLines(memories); +} + +function buildModelMessages({ previousMessages, userMessage, memories, routingMemoryContent }) { const system = [ '你是 TKMind H5 聊天助手。', '优先直接回答用户问题;不要调用工具;涉及需要执行代码、改文件、生成页面或操作外部系统的任务时,简要说明已交由后台任务处理或请用户确认具体任务。', - renderMemoryLines(memories), + buildMemorySystemBlock({ memories, routingMemoryContent }), ].filter(Boolean).join('\n\n'); const history = Array.isArray(previousMessages) ? previousMessages.slice(-12) : []; @@ -146,10 +158,56 @@ function buildAssistantMessage(reply, { requestId, now }) { metadata: { userVisible: true, source: 'portal-direct-chat', + chatRequestId: requestId || undefined, }, }; } +function evaluateCanHandle({ + sessionId = null, + toolMode = 'chat', + userMessage, + routingDecision = null, + enabled: enabledOverride, + userAuth, + llmProviderService, + sessionSnapshotService, +} = {}) { + const resolvedEnabled = enabledOverride ?? true; + if (!resolvedEnabled) return { ok: false, reason: 'disabled' }; + if (toolMode !== 'chat') return { ok: false, reason: 'tool_mode_not_chat' }; + if (routingDecision === 'agent_orchestration') { + return { ok: false, reason: 'routing_agent_orchestration' }; + } + if ( + sessionId && + !isDirectChatSessionId(sessionId) && + routingDecision !== 'direct_chat' + ) { + return { ok: false, reason: 'session_not_direct_chat' }; + } + if (!isTextOnlyUserMessage(userMessage)) { + return { ok: false, reason: 'non_text_message' }; + } + if (routingDecision !== 'direct_chat' && requiresTaskExecution(userMessage)) { + return { ok: false, reason: 'task_execution_required' }; + } + if (!userAuth || !llmProviderService || !sessionSnapshotService) { + return { ok: false, reason: 'dependencies_unavailable' }; + } + return { ok: true, reason: null }; +} + +export function isPortalDirectChatSnapshot(snapshot) { + const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : []; + const lastAssistant = [...messages].reverse().find((message) => message?.role === 'assistant'); + if (lastAssistant?.metadata?.source !== 'portal-direct-chat') return false; + const updatedAtRaw = snapshot?.session?.updated_at ?? snapshot?.meta?.saved_at ?? 0; + const updatedAt = Date.parse(String(updatedAtRaw)) || Number(updatedAtRaw) || 0; + if (!updatedAt) return true; + return Date.now() - updatedAt <= 5 * 60 * 1000; +} + function normalizeUsageForBilling(usage, previousState) { if (!usage) return null; const input = Math.max(0, Number(usage.inputTokens ?? 0) || 0); @@ -167,7 +225,7 @@ export function createDirectChatService({ sessionSnapshotService, memoryV2 = null, conversationMemoryService = null, - enabled = envFlag(process.env.MEMIND_DIRECT_CHAT_ENABLED, false), + enabled = envFlag(process.env.MEMIND_DIRECT_CHAT_ENABLED, true), } = {}) { function getStatus() { return { @@ -176,29 +234,48 @@ export function createDirectChatService({ }; } - function canHandle({ sessionId = null, toolMode = 'chat', userMessage } = {}) { - if (!enabled) return false; - if (toolMode !== 'chat') return false; - if (sessionId && !isDirectChatSessionId(sessionId)) return false; - if (!isTextOnlyUserMessage(userMessage)) return false; - if (requiresTaskExecution(userMessage)) return false; - return Boolean(userAuth && llmProviderService && sessionSnapshotService); + function canHandle(input = {}) { + return evaluateCanHandle({ + ...input, + enabled, + userAuth, + llmProviderService, + sessionSnapshotService, + }).ok; } - async function resolveMemories(userId, sessionId, query) { - if (!userId) return []; + function explainCanHandle(input = {}) { + return evaluateCanHandle({ + ...input, + enabled, + userAuth, + llmProviderService, + sessionSnapshotService, + }); + } + + async function resolveMemories(userId, sessionId, query, { limit = MEMORY_INTERVENTION_LIMIT.LIGHT_DIRECT_CHAT } = {}) { + if (!userId || limit <= 0) return []; if (memoryV2?.resolve) { - const result = await memoryV2.resolve({ userId, sessionId, query, limit: 40 }).catch(() => null); + const result = await memoryV2.resolve({ userId, sessionId, query, limit }).catch(() => null); return Array.isArray(result?.memories) ? result.memories : []; } if (conversationMemoryService?.listMemories) { - return conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => []); + return conversationMemoryService.listMemories(userId, { limit }).catch(() => []); } return []; } - async function run({ userId, sessionId = null, requestId, userMessage } = {}) { - if (!canHandle({ sessionId, toolMode: 'chat', userMessage })) { + async function run({ + userId, + sessionId = null, + requestId, + userMessage, + routingDecision = null, + routingMemory = null, + onSessionReady = null, + } = {}) { + if (!canHandle({ sessionId, toolMode: 'chat', userMessage, routingDecision })) { const err = new Error('Direct chat is not available for this run'); err.code = 'DIRECT_CHAT_UNAVAILABLE'; err.retryable = false; @@ -208,9 +285,44 @@ export function createDirectChatService({ const activeSessionId = sessionId || createDirectSessionId(); const snapshot = await sessionSnapshotService.get(activeSessionId).catch(() => null); const previousMessages = Array.isArray(snapshot?.messages) ? snapshot.messages : []; - const memories = await resolveMemories(userId, activeSessionId, messageText(userMessage)); + const now = new Date().toISOString(); + if (!sessionId) { + await userAuth.registerAgentSession(userId, activeSessionId, 'h5-direct'); + } + if (typeof onSessionReady === 'function') { + await onSessionReady(activeSessionId); + } + const pendingMessages = [...previousMessages, userMessage]; + await sessionSnapshotService.save( + activeSessionId, + userId, + { + id: activeSessionId, + name: snapshot?.session?.name ?? 'New Chat', + working_dir: snapshot?.session?.working_dir ?? '', + message_count: pendingMessages.length, + created_at: snapshot?.session?.created_at ?? now, + updated_at: now, + user_set_name: snapshot?.session?.user_set_name ?? false, + recipe: snapshot?.session?.recipe ?? null, + conversation: pendingMessages, + }, + pendingMessages, + ); + const routedMemoryContent = + routingMemory && !routingMemory.skipped && !routingMemory.degraded + ? String(routingMemory.content ?? '').trim() + : ''; + const memories = routedMemoryContent + ? [] + : await resolveMemories(userId, activeSessionId, messageText(userMessage)); const completion = await llmProviderService.createChatCompletion({ - messages: buildModelMessages({ previousMessages, userMessage, memories }), + messages: buildModelMessages({ + previousMessages, + userMessage, + memories, + routingMemoryContent: routedMemoryContent, + }), }); if (!completion?.ok) { const err = new Error(completion?.message ?? 'Portal 直连聊天失败'); @@ -218,9 +330,8 @@ export function createDirectChatService({ throw err; } - const now = new Date().toISOString(); const assistantMessage = buildAssistantMessage(completion.reply, { requestId, now }); - const messages = [...previousMessages, userMessage, assistantMessage]; + const messages = [...pendingMessages, assistantMessage]; const session = { id: activeSessionId, name: snapshot?.session?.name ?? 'New Chat', @@ -232,9 +343,6 @@ export function createDirectChatService({ recipe: snapshot?.session?.recipe ?? null, conversation: messages, }; - if (!sessionId) { - await userAuth.registerAgentSession(userId, activeSessionId, 'h5-direct'); - } await sessionSnapshotService.save(activeSessionId, userId, session, messages); let billing = null; @@ -264,6 +372,7 @@ export function createDirectChatService({ return { getStatus, canHandle, + explainCanHandle, run, }; } diff --git a/direct-chat-service.test.mjs b/direct-chat-service.test.mjs index 1a98101..80dbf68 100644 --- a/direct-chat-service.test.mjs +++ b/direct-chat-service.test.mjs @@ -6,16 +6,17 @@ import { sendDirectChatSessionEvents, } from './direct-chat-service.mjs'; -test('direct chat is disabled by default', () => { +test('direct chat is enabled by default', () => { const service = createDirectChatService({ userAuth: {}, llmProviderService: {}, sessionSnapshotService: {}, }); - assert.equal(service.getStatus().enabled, false); + assert.equal(service.getStatus().enabled, true); assert.equal(service.canHandle({ userMessage: { role: 'user', content: [{ type: 'text', text: 'hi' }] }, - }), false); + routingDecision: 'direct_chat', + }), true); }); test('direct chat creates a direct session, saves snapshot, and bills returned usage', async () => { @@ -80,9 +81,9 @@ test('direct chat creates a direct session, saves snapshot, and bills returned u assert.deepEqual(registered, [ { userId: 'user-1', sessionId: result.sessionId, target: 'h5-direct' }, ]); - assert.equal(saved.length, 1); - assert.equal(saved[0].messages.length, 2); - assert.equal(saved[0].messages[1].content[0].text, '你好,已收到。'); + assert.equal(saved.length, 2); + assert.equal(saved[1].messages.length, 2); + assert.equal(saved[1].messages[1].content[0].text, '你好,已收到。'); assert.match(llmMessages[0][0].content, /喜欢简洁回答/); assert.deepEqual(billed, [{ userId: 'user-1', @@ -201,3 +202,86 @@ test('direct chat rejects page generation tasks so the execution backend can han }, }), false); }); + +test('direct chat allows llm-routed replies on regular agent sessions', () => { + const service = createDirectChatService({ + enabled: true, + userAuth: {}, + llmProviderService: {}, + sessionSnapshotService: {}, + }); + const userMessage = { + role: 'user', + content: [{ type: 'text', text: '我想对太湖有更多的了解' }], + metadata: { displayText: '我想对太湖有更多的了解' }, + }; + assert.equal(service.canHandle({ + sessionId: '20260704_11', + routingDecision: 'direct_chat', + userMessage, + }), true); + assert.equal(service.explainCanHandle({ + sessionId: '20260704_11', + routingDecision: 'direct_chat', + userMessage, + }).reason, null); +}); + +test('direct chat run saves portal reply into an existing agent session', async () => { + const saved = []; + const service = createDirectChatService({ + enabled: true, + userAuth: { + async getBillingState() { + return null; + }, + async billSessionUsage() { + return null; + }, + }, + llmProviderService: { + async createChatCompletion() { + return { + ok: true, + model: 'deepseek-chat', + reply: '太湖是中国第三大淡水湖。', + usage: null, + }; + }, + }, + sessionSnapshotService: { + async get(sessionId) { + return { + session: { id: sessionId, name: 'New Chat', updated_at: '2026-07-04T00:00:00.000Z' }, + messages: [{ + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: 'hi' }], + metadata: { userVisible: true }, + }], + }; + }, + async save(sessionId, userId, session, messages) { + saved.push({ sessionId, userId, session, messages }); + }, + }, + }); + + const result = await service.run({ + userId: 'user-1', + sessionId: '20260704_11', + requestId: 'req-taihu', + routingDecision: 'direct_chat', + userMessage: { + id: 'user-taihu', + role: 'user', + content: [{ type: 'text', text: '我想对太湖有更多的了解' }], + metadata: { displayText: '我想对太湖有更多的了解', userVisible: true }, + }, + }); + + assert.equal(result.sessionId, '20260704_11'); + assert.equal(saved.length, 2); + assert.equal(saved[1].messages.at(-1)?.metadata?.source, 'portal-direct-chat'); + assert.match(saved[1].messages.at(-1)?.content?.[0]?.text ?? '', /太湖/); +}); diff --git a/llm-providers.mjs b/llm-providers.mjs index e0d984f..e7fa170 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -1431,9 +1431,18 @@ export function createLlmProviderService( return buildExecutorLaunchPlan(runtime, options); }, - async createChatCompletion({ messages, model = null, temperature = 0.7 } = {}, fetchImpl = apiFetchImpl) { - const row = await getSelectedRow(); + async createChatCompletion({ + messages, + providerKeyId = null, + model = null, + temperature = 0.7, + } = {}, fetchImpl = apiFetchImpl) { + const requestedProviderKeyId = String(providerKeyId ?? '').trim(); + const row = requestedProviderKeyId + ? await getRowById(requestedProviderKeyId) + : await getSelectedRow(); if (!row) return { ok: false, message: '未配置可用的聊天模型' }; + if (row.status !== 'active') return { ok: false, message: '聊天模型 Provider 已禁用' }; const profile = profileFromRow(row, decryptRow); const apiUrl = openAiCompatibleApiUrlForProfile(profile); if (!apiUrl) { @@ -1444,6 +1453,10 @@ export function createLlmProviderService( } const selectedModel = String(model || row.default_model || profile.defaultModel || '').trim(); if (!selectedModel) return { ok: false, message: '未配置聊天模型名称' }; + const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + if (publicRow.models.length && !publicRow.models.includes(selectedModel)) { + return { ok: false, message: '聊天模型不在当前 Provider 支持列表中' }; + } const upstream = await fetchImpl(apiUrl, { method: 'POST', headers: { diff --git a/llm-providers.test.mjs b/llm-providers.test.mjs index a12de9d..ea66a63 100644 --- a/llm-providers.test.mjs +++ b/llm-providers.test.mjs @@ -393,6 +393,80 @@ test('createChatCompletion uses selected OpenAI-compatible provider', async () = assert.equal(calls[0].body.stream, false); }); +test('createChatCompletion can use an explicit provider key for router calls', async () => { + const encryptedSelected = encryptSecret('sk-selected-test', 'unit-test-secret'); + const encryptedRouter = encryptSecret('sk-router-test', 'unit-test-secret'); + const selectedRow = { + id: 'key-selected', + provider_id: CUSTOM_PROVIDER_ID, + provider_kind: 'custom', + api_url: 'http://127.0.0.1:19998/v1', + base_path: null, + models_json: JSON.stringify(['selected-model']), + goosed_provider_id: null, + engine: 'openai', + relay_provider: null, + name: 'Selected Provider', + api_key_ciphertext: encryptedSelected.ciphertext, + api_key_iv: encryptedSelected.iv, + api_key_tag: encryptedSelected.tag, + default_model: 'selected-model', + status: 'active', + is_selected: 1, + created_at: 1, + updated_at: 1, + }; + const routerRow = { + ...selectedRow, + id: 'key-router', + api_url: 'http://127.0.0.1:19997/v1', + models_json: JSON.stringify(['router-model']), + name: 'Router Provider', + api_key_ciphertext: encryptedRouter.ciphertext, + api_key_iv: encryptedRouter.iv, + api_key_tag: encryptedRouter.tag, + default_model: 'router-model', + is_selected: 0, + }; + const rows = [selectedRow, routerRow]; + const pool = { + async query(sql, params = []) { + if (sql.includes('WHERE id = ?')) { + return [[rows.find((row) => row.id === params[0])].filter(Boolean)]; + } + if (sql.includes('WHERE is_selected = 1')) return [[selectedRow]]; + throw new Error(`Unexpected SQL: ${sql} ${JSON.stringify(params)}`); + }, + }; + const calls = []; + const mockFetch = async (url, init) => { + calls.push({ url: String(url), init, body: JSON.parse(init.body) }); + return { + ok: true, + text: async () => JSON.stringify({ + choices: [{ message: { content: 'router reply' } }], + }), + }; + }; + const service = createLlmProviderService(pool, { + encryptionKey: 'unit-test-secret', + apiFetchImpl: mockFetch, + }); + + const result = await service.createChatCompletion({ + providerKeyId: 'key-router', + model: 'router-model', + messages: [{ role: 'user', content: 'route this' }], + }); + + assert.equal(result.ok, true); + assert.equal(result.providerKeyId, 'key-router'); + assert.equal(result.model, 'router-model'); + assert.equal(result.reply, 'router reply'); + assert.equal(calls[0].url, 'http://127.0.0.1:19997/v1/chat/completions'); + assert.equal(calls[0].init.headers.Authorization, 'Bearer sk-router-test'); +}); + test('getExecutorRuntimeConfig resolves aider env from binding', async () => { const encrypted = encryptSecret('sk-deepseek-test', 'unit-test-secret'); const keyRow = { diff --git a/memory-intervention.mjs b/memory-intervention.mjs new file mode 100644 index 0000000..9ec5fbf --- /dev/null +++ b/memory-intervention.mjs @@ -0,0 +1,40 @@ +export const MEMORY_INTERVENTION_MODE = { + SKIP: 'skip', + LIGHT: 'light', + HEAVY: 'heavy', +}; + +export const MEMORY_INTERVENTION_LIMIT = { + LIGHT_ROUTER: 3, + LIGHT_DIRECT_CHAT: 5, + LIGHT_AGENT: 3, + HEAVY: 40, +}; + +export function resolveMemoryInterventionMode({ + forceDeepReasoning = false, + recallQuestion = false, + context = 'router', +} = {}) { + if (forceDeepReasoning) return MEMORY_INTERVENTION_MODE.HEAVY; + if (context === 'router') { + return recallQuestion ? MEMORY_INTERVENTION_MODE.LIGHT : MEMORY_INTERVENTION_MODE.SKIP; + } + return MEMORY_INTERVENTION_MODE.LIGHT; +} + +export function memoryLimitForIntervention(mode, { context = 'router' } = {}) { + if (mode === MEMORY_INTERVENTION_MODE.HEAVY) { + return MEMORY_INTERVENTION_LIMIT.HEAVY; + } + if (mode === MEMORY_INTERVENTION_MODE.SKIP) { + return 0; + } + if (context === 'direct_chat') { + return MEMORY_INTERVENTION_LIMIT.LIGHT_DIRECT_CHAT; + } + if (context === 'agent') { + return MEMORY_INTERVENTION_LIMIT.LIGHT_AGENT; + } + return MEMORY_INTERVENTION_LIMIT.LIGHT_ROUTER; +} diff --git a/memory-intervention.test.mjs b/memory-intervention.test.mjs new file mode 100644 index 0000000..85d9796 --- /dev/null +++ b/memory-intervention.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MEMORY_INTERVENTION_LIMIT, + MEMORY_INTERVENTION_MODE, + memoryLimitForIntervention, + resolveMemoryInterventionMode, +} from './memory-intervention.mjs'; + +test('resolveMemoryInterventionMode uses heavy only for deep reasoning', () => { + assert.equal( + resolveMemoryInterventionMode({ forceDeepReasoning: true, context: 'agent' }), + MEMORY_INTERVENTION_MODE.HEAVY, + ); + assert.equal( + resolveMemoryInterventionMode({ recallQuestion: true, context: 'router' }), + MEMORY_INTERVENTION_MODE.LIGHT, + ); + assert.equal(resolveMemoryInterventionMode({ context: 'router' }), MEMORY_INTERVENTION_MODE.SKIP); + assert.equal(resolveMemoryInterventionMode({ context: 'agent' }), MEMORY_INTERVENTION_MODE.LIGHT); +}); + +test('memoryLimitForIntervention maps modes to bounded limits', () => { + assert.equal(memoryLimitForIntervention(MEMORY_INTERVENTION_MODE.SKIP), 0); + assert.equal( + memoryLimitForIntervention(MEMORY_INTERVENTION_MODE.HEAVY, { context: 'agent' }), + MEMORY_INTERVENTION_LIMIT.HEAVY, + ); + assert.equal( + memoryLimitForIntervention(MEMORY_INTERVENTION_MODE.LIGHT, { context: 'direct_chat' }), + MEMORY_INTERVENTION_LIMIT.LIGHT_DIRECT_CHAT, + ); + assert.equal( + memoryLimitForIntervention(MEMORY_INTERVENTION_MODE.LIGHT, { context: 'agent' }), + MEMORY_INTERVENTION_LIMIT.LIGHT_AGENT, + ); +}); diff --git a/memory-v2-admin-config.mjs b/memory-v2-admin-config.mjs index 036d06a..aa815b8 100644 --- a/memory-v2-admin-config.mjs +++ b/memory-v2-admin-config.mjs @@ -12,6 +12,16 @@ const FIELD_SPECS = [ { env: 'MEMORY_VECTOR_ENABLED', group: 'global', field: 'vectorEnabled', type: 'boolean' }, { env: 'MEMORY_FAIL_OPEN', group: 'global', field: 'failOpen', type: 'boolean' }, + { env: 'MEMIND_CHAT_LLM_ROUTER_ENABLED', group: 'chatIntentRouter', field: 'enabled', type: 'boolean' }, + { env: 'MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID', group: 'chatIntentRouter', field: 'modelProviderKeyId', type: 'string' }, + { env: 'MEMIND_CHAT_ROUTER_MODEL', group: 'chatIntentRouter', field: 'model', type: 'string' }, + { env: 'MEMIND_CHAT_ROUTER_MODEL_API', group: 'chatIntentRouter', field: 'modelApiType', type: 'string' }, + { env: 'MEMIND_CHAT_ROUTER_MIN_CONFIDENCE', group: 'chatIntentRouter', field: 'minConfidence', type: 'number' }, + { env: 'MEMIND_CHAT_ROUTER_MEMORY_ENABLED', group: 'chatIntentRouter', field: 'memoryResolveEnabled', type: 'boolean' }, + { env: 'MEMIND_CHAT_ROUTER_MEMORY_LIMIT', group: 'chatIntentRouter', field: 'memoryResolveLimit', type: 'number' }, + { env: 'MEMIND_CHAT_ROUTER_TIMEOUT_MS', group: 'chatIntentRouter', field: 'timeoutMs', type: 'number' }, + { env: 'MEMIND_CHAT_ROUTER_FALLBACK_ROUTE', group: 'chatIntentRouter', field: 'fallbackRoute', type: 'string' }, + { env: 'MEMORY_PGVECTOR_ENABLED', group: 'pgvector', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_PGVECTOR_DATABASE_URL', group: 'pgvector', field: 'databaseUrl', type: 'secret' }, { env: 'MEMORY_PGVECTOR_TABLE', group: 'pgvector', field: 'table', type: 'string' }, @@ -84,6 +94,7 @@ const FIELD_SPECS = [ const GROUPS = [ 'global', + 'chatIntentRouter', 'pgvector', 'qdrant', 'weaviate', @@ -183,6 +194,24 @@ function applyEnv(config, secrets, env = process.env) { if (config.pgvector.enabled === undefined) { config.pgvector.enabled = normalizeBoolean(env?.MEMORY_VECTOR_ENABLED, false); } + if (env?.MEMIND_CHAT_ROUTER_MEMORY_ENABLED == null || String(env.MEMIND_CHAT_ROUTER_MEMORY_ENABLED).trim() === '') { + config.chatIntentRouter.memoryResolveEnabled = true; + } + if (!normalizeString(config.chatIntentRouter.modelApiType)) { + config.chatIntentRouter.modelApiType = 'chat'; + } + if (!normalizeString(config.chatIntentRouter.minConfidence)) { + config.chatIntentRouter.minConfidence = '0.65'; + } + if (!normalizeString(config.chatIntentRouter.memoryResolveLimit)) { + config.chatIntentRouter.memoryResolveLimit = '8'; + } + if (!normalizeString(config.chatIntentRouter.timeoutMs)) { + config.chatIntentRouter.timeoutMs = '1500'; + } + if (!normalizeString(config.chatIntentRouter.fallbackRoute)) { + config.chatIntentRouter.fallbackRoute = 'agent_orchestration'; + } return { config, secrets }; } diff --git a/memory-v2-admin-config.test.mjs b/memory-v2-admin-config.test.mjs index 3922987..e2c3c19 100644 --- a/memory-v2-admin-config.test.mjs +++ b/memory-v2-admin-config.test.mjs @@ -35,6 +35,9 @@ test('memory v2 admin config service loads defaults from env and masks secrets', env: { MEMORY_ENABLED: '1', MEMORY_BACKEND: 'pgvector', + MEMIND_CHAT_LLM_ROUTER_ENABLED: '1', + MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID: 'key-router', + MEMIND_CHAT_ROUTER_MODEL: 'deepseek-chat', MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://user:pass@127.0.0.1:5432/memory', MEMORY_QDRANT_API_KEY: 'secret-qdrant', }, @@ -44,6 +47,9 @@ test('memory v2 admin config service loads defaults from env and masks secrets', assert.equal(result.config.global.enabled, true); assert.equal(result.config.global.backend, 'pgvector'); + assert.equal(result.config.chatIntentRouter.enabled, true); + assert.equal(result.config.chatIntentRouter.modelProviderKeyId, 'key-router'); + assert.equal(result.config.chatIntentRouter.model, 'deepseek-chat'); assert.equal(result.config.pgvector.databaseUrlConfigured, true); assert.match(result.config.pgvector.databaseUrlMasked, /^post\*+/); assert.equal(result.config.qdrant.apiKeyConfigured, true); @@ -60,6 +66,16 @@ test('memory v2 admin config service persists non-secret and secret patches', as const updated = await service.updateAdminConfig({ global: { enabled: true, backend: 'qdrant' }, + chatIntentRouter: { + enabled: true, + modelProviderKeyId: 'key-router', + model: 'deepseek-chat', + minConfidence: '0.7', + memoryResolveEnabled: true, + memoryResolveLimit: '8', + timeoutMs: '1500', + fallbackRoute: 'agent_orchestration', + }, qdrant: { enabled: true, url: 'http://127.0.0.1:6333', @@ -69,6 +85,9 @@ test('memory v2 admin config service persists non-secret and secret patches', as assert.equal(updated.config.global.enabled, true); assert.equal(updated.config.global.backend, 'qdrant'); + assert.equal(updated.config.chatIntentRouter.enabled, true); + assert.equal(updated.config.chatIntentRouter.modelProviderKeyId, 'key-router'); + assert.equal(updated.config.chatIntentRouter.minConfidence, '0.7'); assert.equal(updated.config.qdrant.enabled, true); assert.equal(updated.config.qdrant.url, 'http://127.0.0.1:6333'); assert.equal(updated.config.qdrant.apiKeyConfigured, true); @@ -77,6 +96,14 @@ test('memory v2 admin config service persists non-secret and secret patches', as const runtimeState = await service.getRuntimeState(); assert.equal(runtimeState.overrides.MEMORY_ENABLED, '1'); assert.equal(runtimeState.overrides.MEMORY_BACKEND, 'qdrant'); + assert.equal(runtimeState.overrides.MEMIND_CHAT_LLM_ROUTER_ENABLED, '1'); + assert.equal(runtimeState.overrides.MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID, 'key-router'); + assert.equal(runtimeState.overrides.MEMIND_CHAT_ROUTER_MODEL, 'deepseek-chat'); + assert.equal(runtimeState.overrides.MEMIND_CHAT_ROUTER_MIN_CONFIDENCE, '0.7'); + assert.equal(runtimeState.overrides.MEMIND_CHAT_ROUTER_MEMORY_ENABLED, '1'); + assert.equal(runtimeState.overrides.MEMIND_CHAT_ROUTER_MEMORY_LIMIT, '8'); + assert.equal(runtimeState.overrides.MEMIND_CHAT_ROUTER_TIMEOUT_MS, '1500'); + assert.equal(runtimeState.overrides.MEMIND_CHAT_ROUTER_FALLBACK_ROUTE, 'agent_orchestration'); assert.equal(runtimeState.overrides.MEMORY_QDRANT_ENABLED, '1'); assert.equal(runtimeState.overrides.MEMORY_QDRANT_URL, 'http://127.0.0.1:6333'); assert.equal(runtimeState.overrides.MEMORY_QDRANT_API_KEY, 'secret-qdrant'); @@ -87,6 +114,13 @@ test('memory v2 admin config internals flatten booleans and numbers consistently { ...memoryV2AdminConfigInternals.defaultConfigShape(), global: { enabled: true, backend: 'pgvector', vectorEnabled: true }, + chatIntentRouter: { + enabled: true, + minConfidence: '0.65', + memoryResolveEnabled: true, + memoryResolveLimit: '8', + timeoutMs: '1500', + }, pgvector: { enabled: true, poolMax: '5', limit: '8' }, }, { @@ -97,6 +131,11 @@ test('memory v2 admin config internals flatten booleans and numbers consistently assert.equal(env.MEMORY_ENABLED, '1'); assert.equal(env.MEMORY_BACKEND, 'pgvector'); + assert.equal(env.MEMIND_CHAT_LLM_ROUTER_ENABLED, '1'); + assert.equal(env.MEMIND_CHAT_ROUTER_MIN_CONFIDENCE, '0.65'); + assert.equal(env.MEMIND_CHAT_ROUTER_MEMORY_ENABLED, '1'); + assert.equal(env.MEMIND_CHAT_ROUTER_MEMORY_LIMIT, '8'); + assert.equal(env.MEMIND_CHAT_ROUTER_TIMEOUT_MS, '1500'); assert.equal(env.MEMORY_VECTOR_ENABLED, '1'); assert.equal(env.MEMORY_PGVECTOR_ENABLED, '1'); assert.equal(env.MEMORY_PGVECTOR_POOL_MAX, '5'); diff --git a/memory-v2-runtime.mjs b/memory-v2-runtime.mjs index 945c24c..3e877b2 100644 --- a/memory-v2-runtime.mjs +++ b/memory-v2-runtime.mjs @@ -7,6 +7,7 @@ import { createMemoryV2PluginBackends } from './memory-v2-plugin-backends.mjs'; import { createMem0HttpClient, createMem0MemoryBackend } from './memory-v2-mem0.mjs'; import { createNeo4jHttpClient, createNeo4jMemoryBackend } from './memory-v2-neo4j.mjs'; import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs'; +import { backfillLegacyMemoriesToPgvector } from './memory-v2-pgvector-backfill.mjs'; import { createQdrantHttpClient, createQdrantMemoryBackend } from './memory-v2-qdrant.mjs'; import { createRedisStreamsClient, createRedisStreamsMemoryBackend } from './memory-v2-redis-streams.mjs'; import { createWeaviateHttpClient, createWeaviateMemoryBackend } from './memory-v2-weaviate.mjs'; @@ -55,6 +56,7 @@ async function createPgPool(connectionString, { importPg, max }) { export async function createMemoryV2Runtime({ legacyMemoryService = null, + mysqlPool = null, env = process.env, logger = console, importPg = (specifier) => import(specifier), @@ -75,6 +77,8 @@ export async function createMemoryV2Runtime({ const closers = []; let pgvectorBackend = null; + let pgPoolRef = null; + let pgEmbedQuery = null; if (pgvectorEnabled && connectionString) { try { const embedQuery = await loadEmbeddingModule(embeddingModule, importModule); @@ -90,6 +94,8 @@ export async function createMemoryV2Runtime({ max: Math.max(1, Number(env?.MEMORY_PGVECTOR_POOL_MAX ?? 5) || 5), }); closers.push(() => pool.end?.()); + pgPoolRef = pool; + pgEmbedQuery = embedQuery; pgvectorBackend = createPgvectorMemoryBackend({ pool, enabled: pgvectorEnabled, @@ -401,6 +407,55 @@ export async function createMemoryV2Runtime({ logger, }); + const pgBackfillEnabled = readFlag(env, 'MEMORY_PGVECTOR_BACKFILL_ENABLED', pgvectorEnabled); + let pgBackfillBusy = false; + let pgBackfillCursor = { updatedAt: 0, id: '' }; + + async function syncPgvectorFromLegacy() { + if (!pgBackfillEnabled || !pgPoolRef || !pgEmbedQuery || !mysqlPool?.query) return; + if (pgBackfillBusy) return; + pgBackfillBusy = true; + try { + const maxBatches = Math.max(1, Number(env?.MEMORY_PGVECTOR_BACKFILL_MAX_BATCHES ?? 3) || 3); + const batchLimit = Math.max(1, Number(env?.MEMORY_PGVECTOR_BACKFILL_LIMIT ?? 50) || 50); + for (let batch = 0; batch < maxBatches; batch += 1) { + const result = await backfillLegacyMemoriesToPgvector({ + mysqlPool, + pgPool: pgPoolRef, + embedMemory: (item) => pgEmbedQuery(item.text, item), + tableName: env?.MEMORY_PGVECTOR_TABLE, + cursor: pgBackfillCursor, + limit: batchLimit, + dryRun: false, + }); + pgBackfillCursor = result.nextCursor; + if (!result.hasMore || result.scanned === 0) break; + } + } catch (err) { + logger?.warn?.( + `[memory-v2] pgvector backfill skipped: ${err instanceof Error ? err.message : err}`, + ); + } finally { + pgBackfillBusy = false; + } + } + + function wrapMemorySideEffect(methodName) { + const original = memory[methodName].bind(memory); + memory[methodName] = async (input = {}) => { + const result = await original(input); + if ((result?.memories ?? 0) > 0 || (result?.analyzed ?? 0) > 0) { + void syncPgvectorFromLegacy(); + } + return result; + }; + } + + if (pgBackfillEnabled && pgPoolRef && pgEmbedQuery && mysqlPool) { + wrapMemorySideEffect('write'); + wrapMemorySideEffect('compact'); + } + memory.close = async () => { const results = await Promise.allSettled(closers.map((close) => close())); for (const result of results) { @@ -417,6 +472,7 @@ export async function createMemoryV2Runtime({ export async function createManagedMemoryV2Runtime({ legacyMemoryService = null, + mysqlPool = null, configService = null, env = process.env, logger = console, @@ -478,6 +534,7 @@ export async function createManagedMemoryV2Runtime({ } const nextRuntime = await createMemoryV2Runtime({ legacyMemoryService, + mysqlPool, env: state.effectiveEnv, logger, importPg, diff --git a/package.json b/package.json index 63a3b5f..bcf1295 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "smoke:memory-v2-qdrant": "node scripts/smoke-memory-v2-qdrant.mjs", "smoke:memory-v2-external": "node scripts/smoke-memory-v2-external.mjs", "mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", diff --git a/server.mjs b/server.mjs index 4544e13..38e4186 100644 --- a/server.mjs +++ b/server.mjs @@ -164,7 +164,8 @@ import { createScheduleService } from './schedule-service.mjs'; import { createFeedbackService } from './user-feedback.mjs'; import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs'; import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs'; -import { createDirectChatService, isDirectChatSessionId, sendDirectChatSessionEvents } from './direct-chat-service.mjs'; +import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents } from './direct-chat-service.mjs'; +import { createManagedChatIntentRouter } from './chat-intent-router.mjs'; import { createSessionSnapshotService } from './session-snapshot.mjs'; import { createConversationMemoryService } from './conversation-memory.mjs'; import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs'; @@ -285,6 +286,7 @@ if (ACCESS_PASSWORD && !isDatabaseConfigured()) { let userAuth = null; let tkmindProxy = null; let agentRunGateway = null; +let chatIntentRouter = null; let toolGateway = null; let directChatService = null; let sessionSnapshotService = null; @@ -523,14 +525,22 @@ async function bootstrapUserAuth() { void llmProviderService.syncSelectedToGoosed().catch((err) => { console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err); }); - conversationMemoryService = createConversationMemoryService(pool); memoryV2ConfigService = createMemoryV2AdminConfigService(pool); + conversationMemoryService = createConversationMemoryService(pool, { + llmProviderService, + getEffectiveEnv: async () => { + const state = await memoryV2ConfigService.getRuntimeState().catch(() => null); + return { ...process.env, ...(state?.overrides ?? {}) }; + }, + }); memoryV2 = await createManagedMemoryV2Runtime({ legacyMemoryService: conversationMemoryService, configService: memoryV2ConfigService, + mysqlPool: pool, }); sessionSnapshotService = createSessionSnapshotService(pool, { conversationMemoryService, + memoryV2, }); directChatService = createDirectChatService({ userAuth, @@ -539,6 +549,11 @@ async function bootstrapUserAuth() { memoryV2, conversationMemoryService, }); + chatIntentRouter = createManagedChatIntentRouter({ + llmProviderService, + memoryV2, + configService: memoryV2ConfigService, + }); tkmindProxy = createTkmindProxy({ apiTarget: API_TARGET, apiTargets: API_TARGETS, @@ -564,6 +579,7 @@ async function bootstrapUserAuth() { tkmindProxy, toolGateway, directChatService, + chatIntentRouter, autoDispatch: ['1', 'true', 'yes', 'on'].includes( String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(), ), @@ -4460,7 +4476,11 @@ api.get('/sessions/:sessionId', async (req, res, next) => { const canUseSnapshotCache = hintMc != null && hintUa != null; const mcMatch = snapshot.meta.synced_msg_count === hintMc; const uaMatch = snapshot.meta.source_updated_at === hintUa; - if (isDirectChatSessionId(sessionId) || (canUseSnapshotCache && mcMatch && uaMatch)) { + if ( + isDirectChatSessionId(sessionId) || + isPortalDirectChatSnapshot(snapshot) || + (canUseSnapshotCache && mcMatch && uaMatch) + ) { const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks( snapshot.messages, req.currentUser, @@ -4560,6 +4580,10 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { } return sendDirectChatSessionEvents(req, res, snapshot); } + const portalDirectSnapshot = await sessionSnapshotService?.get(sessionId).catch(() => null); + if (isPortalDirectChatSnapshot(portalDirectSnapshot)) { + return sendDirectChatSessionEvents(req, res, portalDirectSnapshot); + } const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id }); const syncPublicHtmlDuringStream = (event) => { materializePublicHtmlWritesFromSessionEvent(event, { publishDir }); diff --git a/session-snapshot.mjs b/session-snapshot.mjs index 10cd719..114423b 100644 --- a/session-snapshot.mjs +++ b/session-snapshot.mjs @@ -67,8 +67,35 @@ function resolveDisplayTitle(session, messages) { return deriveTitleFromMessages(messages); } +async function syncConversationMemory({ + memoryV2 = null, + conversationMemoryService = null, + sessionId, + userId, + messages, + logger = console, +} = {}) { + if (memoryV2?.write) { + const result = await memoryV2.write({ userId, sessionId, messages }); + if (result?.skipped && result?.reason === 'disabled') return result; + if (!result?.ok && !result?.skipped) { + logger?.warn?.( + '[snapshot] memory v2 write degraded:', + result?.reason ?? 'unknown', + ); + } + return result; + } + if (conversationMemoryService?.isEnabled?.()) { + return conversationMemoryService.saveAndAnalyze(sessionId, userId, messages); + } + return null; +} + export function createSessionSnapshotService(pool, options = {}) { const conversationMemoryService = options.conversationMemoryService ?? null; + const memoryV2 = options.memoryV2 ?? null; + const logger = options.logger ?? console; function isEnabled() { return process.env.SESSION_SNAPSHOT_CACHE_ENABLED !== '0'; @@ -145,15 +172,20 @@ export function createSessionSnapshotService(pool, options = {}) { now, ], ); - if (conversationMemoryService?.isEnabled?.()) { - void conversationMemoryService - .saveAndAnalyze(sessionId, userId, messages) - .catch((err) => { - console.warn( - '[snapshot] conversation memory sync failed:', - err instanceof Error ? err.message : err, - ); - }); + if (memoryV2?.write || conversationMemoryService?.isEnabled?.()) { + void syncConversationMemory({ + memoryV2, + conversationMemoryService, + sessionId, + userId, + messages, + logger, + }).catch((err) => { + logger?.warn?.( + '[snapshot] conversation memory sync failed:', + err instanceof Error ? err.message : err, + ); + }); } } catch (err) { // Non-fatal: log and continue; caller falls back to Goose. diff --git a/skills-registry.mjs b/skills-registry.mjs index ee1493c..4aa713c 100644 --- a/skills-registry.mjs +++ b/skills-registry.mjs @@ -14,6 +14,7 @@ export const DEFAULT_USER_SKILLS = { web: true, search: true, 'schedule-assistant': true, + 'service-integration-smoke': true, 'form-builder': true, 'table-viewer': true, 'product-campaign-page': true, diff --git a/skills-registry.test.mjs b/skills-registry.test.mjs index 96b9910..30d4c02 100644 --- a/skills-registry.test.mjs +++ b/skills-registry.test.mjs @@ -16,6 +16,7 @@ test('lists static-page-publish in platform catalog', () => { const catalog = listPlatformSkillCatalog(h5Root); assert.ok(catalog.some((item) => item.name === 'static-page-publish')); assert.ok(catalog.some((item) => item.name === 'schedule-assistant')); + assert.ok(catalog.some((item) => item.name === 'service-integration-smoke')); assert.ok(catalog.some((item) => item.name === 'product-campaign-page')); assert.ok(catalog.some((item) => item.name === 'long-image-download')); }); @@ -48,6 +49,7 @@ test('DEFAULT_USER_SKILLS enables common platform skills', () => { assert.equal(DEFAULT_USER_SKILLS.web, true); assert.equal(DEFAULT_USER_SKILLS.search, true); assert.equal(DEFAULT_USER_SKILLS['schedule-assistant'], true); + assert.equal(DEFAULT_USER_SKILLS['service-integration-smoke'], true); assert.equal(DEFAULT_USER_SKILLS['form-builder'], true); assert.equal(DEFAULT_USER_SKILLS['table-viewer'], true); assert.equal(DEFAULT_USER_SKILLS['product-campaign-page'], true); diff --git a/skills/service-integration-smoke/SKILL.md b/skills/service-integration-smoke/SKILL.md new file mode 100644 index 0000000..d932f55 --- /dev/null +++ b/skills/service-integration-smoke/SKILL.md @@ -0,0 +1,67 @@ +--- +name: service-integration-smoke +description: 按固定清单验证当前用户的聊天、路由、记忆、技能与页面发布链路,输出联调结论与异常点。 +--- + +# 服务联调测试 + +这个 skill 用来做 TKMind H5 的标准联调回归。目标不是“随便聊一下”,而是按固定顺序确认整条链路是否正常,并把异常点说清楚。 + +## 适用范围 + +- 用户明确要做联调、冒烟、回归、验收、服务自检 +- 需要验证聊天入口、技能、页面发布、记忆读取是否一起工作 +- 需要给出一份标准化的测试结论,方便重复执行 + +## 测试原则 + +1. 先做轻量检查,再做有副作用的检查。 +2. 没有实际完成的步骤,不能说“已验证通过”。 +3. 如果某一步依赖其它 skill,必须先 `load_skill` 再执行。 +4. 如果某一步失败,要继续完成后续无关检查,并在结论里明确标记失败项。 +5. 默认把测试输出控制在一份简短清单里:通过项、失败项、待人工确认项。 + +## 标准流程 + +### 1. 会话与身份 + +- 先用一句自然话确认当前对话身份与上下文是否正常 +- 若系统提示里有用户身份信息,应沿用该称呼,不要叫错用户 + +### 2. 纯聊天链路 + +- 发送一个不需要工具执行的轻量问题 +- 目标:验证普通对话是否能正常返回、语气是否自然、没有暴露内部 routing / skill 前缀 + +### 3. 记忆链路 + +- 询问与“我是谁 / 你记得我什么 / 我最近在做什么”类似的问题 +- 只有在实际拿到记忆线索时,才能说记忆已生效 +- 如果返回为空、没有记忆、或只能回答泛化内容,要明确写成“记忆读取未命中 / 未生效” + +### 4. 技能与任务链路 + +- 若用户要求验证页面 / 报告 / 公开链接: + 1. 先 `load_skill` → `static-page-publish` + 2. 在当前用户工作区生成一个最小可验证页面 `public/service-smoke-test.html` + 3. 返回真实可点击链接 +- 若用户要求验证其它专用能力,应只加载对应 skill,不要混入无关 skill + +### 5. 结果汇总 + +- 最后必须输出一份简短联调结论,至少包含: + - 身份链路 + - 普通聊天链路 + - 记忆链路 + - 技能/发布链路(若本轮执行) + - 当前异常点 + +## 输出格式 + +优先用短清单: + +- 通过:... +- 失败:... +- 待确认:... + +如果全部通过,也要说明本轮实际验证了哪些项,不要只说“都正常”。 diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index eefc96f..069e515 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -172,6 +172,7 @@ export function ChatPanel({ const [uploadingImage, setUploadingImage] = useState(false); const [imageError, setImageError] = useState(null); const [voiceStopSignal, setVoiceStopSignal] = useState(0); + const pendingSkillRef = useRef(null); const [randomPrompt] = useState( () => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)], ); @@ -432,9 +433,11 @@ export function ChatPanel({ return { accepted, message }; }, [pendingImageBytes, pendingImages.length]); - const submitText = useCallback(async (textOverride?: string) => { + const submitText = useCallback(async (textOverride?: string, skillIdOverride?: string) => { const baseText = typeof textOverride === 'string' ? textOverride : input; const trimmed = baseText.trim(); + const selectedChatSkill = skillIdOverride ?? pendingSkillRef.current ?? undefined; + pendingSkillRef.current = null; if ((!trimmed && pendingImages.length === 0) || voiceDisabled) return; if (pendingImages.length > 0 && !onUploadImage) { setImageError('当前会话暂不支持图片发送'); @@ -518,6 +521,7 @@ export function ChatPanel({ await onSubmit(trimmed, imagesToSend, previewImagesToSend, { messageId: outgoingMessageId, forceDeepReasoning, + selectedChatSkill, }); uploadedImages.forEach(revokePendingImage); setPendingImages([]); @@ -823,7 +827,10 @@ export function ChatPanel({ skills={chatSkills} disabled={voiceDisabled} onSelect={submitText} - onPrefill={setInput} + onPrefill={(prompt, skillId) => { + pendingSkillRef.current = skillId ?? null; + setInput(prompt); + }} /> )} {!showHomeWelcome && ( diff --git a/src/components/ChatSkillPicker.tsx b/src/components/ChatSkillPicker.tsx index 1f38c13..6babad4 100644 --- a/src/components/ChatSkillPicker.tsx +++ b/src/components/ChatSkillPicker.tsx @@ -10,8 +10,8 @@ export function ChatSkillPicker({ }: { skills: ChatSkillOption[]; disabled?: boolean; - onSelect: (prompt: string) => void | Promise; - onPrefill?: (prompt: string) => void; + onSelect: (prompt: string, skillId?: string) => void | Promise; + onPrefill?: (prompt: string, skillId?: string) => void; }) { const [open, setOpen] = useState(false); const wrapRef = useRef(null); @@ -40,10 +40,10 @@ export function ChatSkillPicker({ setOpen(false); const prompt = skill.buildPrompt(skill.skillName); if (skill.prefillOnly) { - onPrefill?.(prompt); + onPrefill?.(prompt, skill.id); return; } - void onSelect(prompt); + void onSelect(prompt, skill.id); }; return ( diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index fc5e1b0..67e6cc7 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -19,6 +19,7 @@ import { rememberProjectContext, uploadMindSpaceAsset, resumeSession, + startSession, subscribeAgentRunEvents, subscribeNotificationEvents, subscribeSessionEvents, @@ -82,21 +83,75 @@ function isDirectChatSessionId(sessionId?: string | null) { } async function waitForAgentRun(runId: string): Promise { + return await waitForAgentRunWithDirectChatPreview(runId, {}); +} + +const DIRECT_CHAT_SESSION_POLL_MS = 600; + +async function waitForAgentRunWithDirectChatPreview( + runId: string, + handlers: { + onSessionId?: (sessionId: string) => void; + onMessages?: (messages: Message[]) => void; + isCancelled?: () => boolean; + }, +): Promise { return await new Promise((resolve, reject) => { + let pollTimer: number | null = null; + let pollingSessionId: string | null = null; + + const stopPoll = () => { + if (pollTimer != null) { + window.clearInterval(pollTimer); + pollTimer = null; + } + }; + + const startPolling = (sessionId: string) => { + if (pollingSessionId === sessionId && pollTimer != null) return; + pollingSessionId = sessionId; + stopPoll(); + pollTimer = window.setInterval(() => { + if (handlers.isCancelled?.()) return; + void loadSessionDetail(sessionId) + .then(({ messages }) => { + const hasAssistant = messages.some( + (message) => + message.role === 'assistant' && + message.metadata?.source === 'portal-direct-chat', + ); + if (hasAssistant) { + handlers.onMessages?.(messages); + stopPoll(); + } + }) + .catch(() => {}); + }, DIRECT_CHAT_SESSION_POLL_MS); + }; + const unsubscribe = subscribeAgentRunEvents( runId, (run) => { + if (run.sessionId) { + handlers.onSessionId?.(run.sessionId); + if (isDirectChatSessionId(run.sessionId)) { + startPolling(run.sessionId); + } + } if (run.status === 'succeeded') { + stopPoll(); unsubscribe(); resolve(run); return; } if (run.status === 'failed') { + stopPoll(); unsubscribe(); reject(new Error(run.error || '后台任务失败,请稍后重试')); } }, (error) => { + stopPoll(); unsubscribe(); reject(error); }, @@ -820,7 +875,13 @@ export function useTKMindChat( } return; } - if (rid) processEvent(event, rid, sessionId); + if ( + rid || + (isDirectChatSessionId(sessionId) && + (event.type === 'UpdateConversation' || event.type === 'Finish')) + ) { + processEvent(event, rid ?? '', sessionId); + } }, (err) => { if (err instanceof ApiError && err.status === 401) return; @@ -905,13 +966,14 @@ export function useTKMindChat( limit: appConfig.sessionMessagePageSize, }), ); - const resumedPromise = options?.skipResume - ? Promise.resolve(options.seedSession ?? null) - : withTransientConnectRetry(() => - resumeSession(sessionId, { - skipReconcile: options?.skipReconcile ?? false, - }), - ); + const resumedPromise = + options?.skipResume || isDirectChatSessionId(sessionId) + ? Promise.resolve(options.seedSession ?? null) + : withTransientConnectRetry(() => + resumeSession(sessionId, { + skipReconcile: options?.skipReconcile ?? false, + }), + ); const { session: detail, messages: history, page } = await detailPromise; if (token !== connectTokenRef.current) return; @@ -1136,7 +1198,7 @@ export function useTKMindChat( const submit = useCallback( async ( text: string, - options?: { mindspaceContext?: MindSpaceChatContext; messageId?: string; forceDeepReasoning?: boolean }, + options?: { mindspaceContext?: MindSpaceChatContext; messageId?: string; forceDeepReasoning?: boolean; selectedChatSkill?: string }, imageUrls?: string[], previewImageUrls?: string[], ) => { @@ -1159,6 +1221,7 @@ export function useTKMindChat( const userPrefix = buildUserAddressPrefix(userRef.current); const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []); const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}`; + const priorMessageCount = messagesRef.current.length; const userMessage = buildUserMessage(trimmed, { id: options?.messageId, agentText: `${agentPrefix}${trimmed}`, @@ -1166,7 +1229,18 @@ export function useTKMindChat( imageUrls: normalizedImageUrls, previewImageUrls: normalizedPreviewImageUrls, }); + userMessage.metadata = { + ...userMessage.metadata, + memindRun: { + ...(userMessage.metadata?.memindRun && typeof userMessage.metadata.memindRun === 'object' + ? userMessage.metadata.memindRun + : {}), + sessionMessageCount: priorMessageCount, + ...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}), + }, + }; const requestId = crypto.randomUUID(); + const submitToken = connectTokenRef.current; activeRequestId.current = requestId; messagesRef.current = [...messagesRef.current, userMessage]; messageHistoryLoadedCountRef.current = messagesRef.current.length; @@ -1198,7 +1272,35 @@ export function useTKMindChat( }, ); const finishedRun = - createdRun.status === 'succeeded' ? createdRun : await waitForAgentRun(createdRun.id); + createdRun.status === 'succeeded' + ? createdRun + : await waitForAgentRunWithDirectChatPreview(createdRun.id, { + isCancelled: () => submitToken !== connectTokenRef.current, + onSessionId: (sessionId) => { + if (submitToken !== connectTokenRef.current) return; + if (activeSessionId === sessionId) return; + activeSessionId = sessionId; + const nextSession: Session = { + id: sessionId, + name: 'New Chat', + message_count: messagesRef.current.length, + working_dir: '', + }; + writeStoredSessionId(userRef.current?.id, sessionId); + setSession(nextSession); + setSessions((prev) => prependUnique(prev, nextSession)); + }, + onMessages: (snapshotMessages) => { + if (submitToken !== connectTokenRef.current) return; + messagesRef.current = mergeConversationSnapshot( + messagesRef.current, + snapshotMessages, + ); + messageHistoryLoadedCountRef.current = messagesRef.current.length; + setMessages(messagesRef.current); + }, + }); + if (submitToken !== connectTokenRef.current) return; activeSessionId = finishedRun.sessionId; if (!activeSessionId) { throw new Error('后台任务已提交,但未返回会话'); @@ -1226,8 +1328,25 @@ export function useTKMindChat( void loadProjectMemory(activeSessionId, false); void refreshSessions(); } - subscribeToSession(activeSessionId); - setChatState('streaming'); + if (isDirectChatSessionId(activeSessionId)) { + try { + const detail = await loadSessionDetail(activeSessionId); + if (submitToken === connectTokenRef.current) { + messagesRef.current = mergeConversationSnapshot(messagesRef.current, detail.messages); + messageHistoryLoadedCountRef.current = messagesRef.current.length; + setMessages(messagesRef.current); + setSession(detail.session); + } + } catch { + // Messages may already be present from the direct-chat preview poll. + } + clearActiveRequestMissingTimer(); + activeRequestId.current = null; + setChatState('idle'); + } else { + subscribeToSession(activeSessionId); + setChatState('streaming'); + } } catch (err) { if (activeSessionId && isAmbiguousReplySubmitError(err)) { subscribeToSession(activeSessionId); @@ -1288,10 +1407,20 @@ export function useTKMindChat( [session, pendingTool], ); - const newSession = useCallback(() => { - if (chatState === 'streaming' || chatState === 'waiting') return; + const newSession = useCallback(async () => { + const token = ++connectTokenRef.current; + const previousSession = sessionRef.current; + const previousSessionId = previousSession?.id ?? null; + const pendingRequestId = activeRequestId.current; + + if (previousSessionId && pendingRequestId) { + try { + await cancelRequest(previousSessionId, pendingRequestId); + } catch { + // Best-effort: the user is explicitly abandoning the in-flight turn. + } + } - connectTokenRef.current += 1; unsubscribeRef.current?.(); unsubscribeRef.current = null; clearActiveRequestMissingTimer(); @@ -1308,8 +1437,53 @@ export function useTKMindChat( setSession(null); setError(null); setPendingTool(null); - setChatState('idle'); - }, [chatState, clearActiveRequestMissingTimer]); + setChatState('connecting'); + + if ( + previousSessionId && + previousSession && + shouldShowNewChatTitle(toSessionSummary(previousSession)) + ) { + try { + await deleteChatSession(previousSessionId); + if (token === connectTokenRef.current) { + setSessions((prev) => prev.filter((item) => item.id !== previousSessionId)); + } + } catch { + // Keep the abandoned empty session in history if cleanup fails. + } + } + + try { + const started = await startSession(); + if (token !== connectTokenRef.current) return; + + const nextSession: Session = { + ...started, + name: started.name || 'New Chat', + working_dir: userRef.current?.workspaceRoot ?? started.working_dir ?? '', + conversation: null, + }; + writeStoredSessionId(userRef.current?.id, nextSession.id); + setSession(nextSession); + setSessions((prev) => prependUnique(prev, toSessionSummary(nextSession))); + await ensureProvider(nextSession.id); + if (token !== connectTokenRef.current) return; + subscribeToSession(nextSession.id); + setChatState('idle'); + void refreshSessions(); + } catch (err) { + if (token !== connectTokenRef.current) return; + setSession(null); + setChatState('idle'); + setError(err instanceof Error ? err.message : String(err)); + } + }, [ + clearActiveRequestMissingTimer, + ensureProvider, + refreshSessions, + subscribeToSession, + ]); const refreshProjectMemory = useCallback(async () => { if (!session || chatState === 'streaming' || chatState === 'waiting') return; diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 2ca432a..68223f0 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -16,6 +16,10 @@ import { buildCurrentTimeAgentPrefix, buildTaskRoutingAgentText } from './user-m import { reconcileAgentSession } from './session-reconcile.mjs'; import { createImgproxySigner } from './imgproxy-signer.mjs'; import { isDirectChatSessionId } from './direct-chat-service.mjs'; +import { + memoryLimitForIntervention, + resolveMemoryInterventionMode, +} from './memory-intervention.mjs'; const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false }, @@ -1078,19 +1082,31 @@ export function createTkmindProxy({ }; } - async function resolveUserMemories(userId, { sessionId = null, query = null, limit = 40 } = {}) { + async function resolveUserMemories( + userId, + { sessionId = null, query = null, limit = null, forceDeepReasoning = false, recallQuestion = false } = {}, + ) { if (!userId) return []; + const intervention = resolveMemoryInterventionMode({ + forceDeepReasoning, + recallQuestion, + context: 'agent', + }); + const resolvedLimit = Number.isFinite(Number(limit)) && Number(limit) > 0 + ? Number(limit) + : memoryLimitForIntervention(intervention, { context: 'agent' }); + if (resolvedLimit <= 0) return []; if (memoryV2?.resolve) { const resolved = await memoryV2.resolve({ userId, sessionId, query, - limit, + limit: resolvedLimit, }).catch(() => null); return Array.isArray(resolved?.memories) ? resolved.memories : []; } return conversationMemoryService?.listMemories - ? conversationMemoryService.listMemories(userId, { limit }).catch(() => []) + ? conversationMemoryService.listMemories(userId, { limit: resolvedLimit }).catch(() => []) : []; } @@ -1140,7 +1156,7 @@ export function createTkmindProxy({ } } const publishLayout = await userAuth.getUserPublishLayout(userId); - const userMemories = await resolveUserMemories(userId, { sessionId: session.id, limit: 40 }); + const userMemories = await resolveUserMemories(userId, { sessionId: session.id }); await reconcileAgentSession( (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init), session.id, @@ -1244,7 +1260,7 @@ export function createTkmindProxy({ async function reconcileSessionPolicyForUser( userId, sessionId, - { toolMode = 'chat', query = null } = {}, + { toolMode = 'chat', query = null, forceDeepReasoning = false } = {}, ) { if (!userId || !sessionId) return; const target = await resolveTarget(sessionId); @@ -1254,7 +1270,7 @@ export function createTkmindProxy({ const userMemories = await resolveUserMemories(userId, { sessionId, query, - limit: 40, + forceDeepReasoning, }); await reconcileAgentSession( (pathname, init) => apiFetch(target, apiSecret, pathname, init), @@ -1282,7 +1298,7 @@ export function createTkmindProxy({ sessionId, requestId, userMessage, - { toolMode = 'chat' } = {}, + { toolMode = 'chat', forceDeepReasoning = false } = {}, ) { if (!userId || !sessionId) throw new Error('缺少会话信息'); const owns = await userAuth.ownsSession(userId, sessionId); @@ -1298,6 +1314,7 @@ export function createTkmindProxy({ await reconcileSessionPolicyForUser(userId, sessionId, { toolMode, query: firstUserText(userMessage), + forceDeepReasoning, }); await applySessionLlmProvider(sessionId); @@ -1432,7 +1449,6 @@ export function createTkmindProxy({ const publishLayout = await userAuth.getUserPublishLayout(req.currentUser.id); const userMemories = await resolveUserMemories(req.currentUser.id, { sessionId: session.id, - limit: 40, }); const api = (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init); try { @@ -1503,7 +1519,6 @@ export function createTkmindProxy({ const publishLayout = await userAuth.getUserPublishLayout(req.currentUser.id); const userMemories = await resolveUserMemories(req.currentUser.id, { sessionId, - limit: 40, }); try { await reconcileAgentSession( diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs index 26732af..eec7734 100644 --- a/tkmind-proxy.test.mjs +++ b/tkmind-proxy.test.mjs @@ -406,7 +406,7 @@ test('startSessionForUser resolves memories through Memory V2 facade', async () userId: 'user-1', sessionId: 'session-1', query: null, - limit: 40, + limit: 3, }); assert.ok( harnessEntries.some((entry) => String(entry.content ?? '').includes('用户关注 Memory V2 架构边界')), @@ -577,6 +577,55 @@ test('submitSessionReplyForUser passes current prompt to Memory V2 resolve befor }, ); + assert.deepEqual(resolveInput, { + userId: 'user-1', + sessionId: 'session-1', + query: '帮我设计 memory-chain 下一阶段', + limit: 3, + }); + }); +}); + +test('submitSessionReplyForUser uses heavy memory resolve when deep reasoning is enabled', async () => { + let resolveInput = null; + await withFakeGoosedSession(async ({ apiTarget, workingDir }) => { + const proxy = createTkmindProxy({ + apiTarget, + apiSecret: 'test-secret', + userAuth: { + ...createMemoryTestUserAuth(workingDir), + async ownsSession() { + return true; + }, + async canUseChat() { + return { ok: true }; + }, + async getUserById() { + return { id: 'user-1' }; + }, + async resolveUserPolicies() { + return { unrestricted: true, policies: {} }; + }, + }, + memoryV2: { + async resolve(input) { + resolveInput = input; + return { memories: [] }; + }, + }, + }); + + await proxy.submitSessionReplyForUser( + 'user-1', + 'session-1', + 'request-1', + { + role: 'user', + content: [{ type: 'text', text: '帮我设计 memory-chain 下一阶段' }], + }, + { forceDeepReasoning: true }, + ); + assert.deepEqual(resolveInput, { userId: 'user-1', sessionId: 'session-1',