diff --git a/.env.example b/.env.example index c416d93..2bfac21 100644 --- a/.env.example +++ b/.env.example @@ -168,6 +168,16 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID= # MEMIND_CHAT_ROUTER_MODEL=deepseek-v4-pro +# 微信服务号专用 LLM 意图层(独立于 H5 chatIntentRouter;memindadm 可插拔) +# 默认关闭;Shadow=1 时只记日志不改行为;canary 留空=全员 +# MEMIND_WECHAT_INTENT_LLM_ENABLED=0 +# MEMIND_WECHAT_INTENT_LLM_SHADOW=1 +# MEMIND_WECHAT_INTENT_MODEL_PROVIDER_KEY_ID= +# MEMIND_WECHAT_INTENT_MODEL= +# MEMIND_WECHAT_INTENT_MIN_CONFIDENCE=0.65 +# MEMIND_WECHAT_INTENT_TIMEOUT_MS=4000 +# MEMIND_WECHAT_INTENT_CANARY_OPENIDS= + # H5 Session stream replay(docs/h5-session-architecture-20260706.md Patch 4c) # 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。 # MEMIND_SESSION_STREAM_REPLAY=0 diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index fc629d9..2e1ad3e 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -28,6 +28,7 @@ import { createSystemDisclosurePolicyService } from './system-disclosure-policy. import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs'; import { createMindSearchConfigService } from './mindsearch-config.mjs'; import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; +import { createWechatIntentRouterConfigService } from './wechat-intent-router-config.mjs'; import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs'; import { createPlazaInteractionService } from './plaza-interactions.mjs'; import { createPlazaOpsService } from './plaza-ops.mjs'; @@ -131,6 +132,7 @@ export async function createAdminServices(env = {}) { await systemDisclosurePolicyService.initialize(); const agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env }); const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); + const wechatIntentRouterConfigService = createWechatIntentRouterConfigService(pool); const adminSystemTestService = createAdminSystemTestService({ pool, userAuth, @@ -178,6 +180,7 @@ export async function createAdminServices(env = {}) { systemDisclosurePolicyService, agentCodeRunPolicyService, wechatScheduleLlmConfigService, + wechatIntentRouterConfigService, adminSystemTestService, plazaPosts, plazaOps, diff --git a/admin-routes.mjs b/admin-routes.mjs index 9c57dda..3f664b4 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -42,6 +42,7 @@ function plazaRouteError(res, req, error) { * @param {object|null} deps.plazaPosts * @param {object|null} deps.plazaOps * @param {object|null} deps.wechatAdmin + * @param {object|null} deps.wechatIntentRouterConfigService */ export function createAdminApi({ jsonBody, @@ -64,6 +65,7 @@ export function createAdminApi({ plazaPosts, plazaOps, wechatAdmin, + wechatIntentRouterConfigService, subscriptionService, }) { function requireAdmin(req, res, next) { @@ -838,6 +840,34 @@ export function createAdminApi({ res.json(result); }); + adminApi.get('/wechat/intent-router/config', requireAdmin, async (_req, res) => { + if (!wechatIntentRouterConfigService?.getConfig) { + return res.status(503).json({ message: '微信意图路由配置未启用' }); + } + const config = await wechatIntentRouterConfigService.getConfig(); + return res.json({ config }); + }); + + adminApi.get('/wechat/intent-router/runtime', requireAdmin, async (_req, res) => { + if (!wechatIntentRouterConfigService?.getRuntimeState) { + return res.status(503).json({ message: '微信意图路由配置未启用' }); + } + return res.json(await wechatIntentRouterConfigService.getRuntimeState()); + }); + + const updateWechatIntentRouterConfig = async (req, res) => { + if (!wechatIntentRouterConfigService?.updateConfig) { + return res.status(503).json({ message: '微信意图路由配置未启用' }); + } + const config = await wechatIntentRouterConfigService.updateConfig(req.body ?? {}, { + updatedBy: req.currentUser.id, + }); + return res.json({ config }); + }; + + adminApi.put('/wechat/intent-router/config', requireAdmin, updateWechatIntentRouterConfig); + adminApi.patch('/wechat/intent-router/config', requireAdmin, updateWechatIntentRouterConfig); + adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); res.json({ catalog: llmProviderService.catalog }); diff --git a/admin-routes.test.mjs b/admin-routes.test.mjs index 6bbe061..4886809 100644 --- a/admin-routes.test.mjs +++ b/admin-routes.test.mjs @@ -702,3 +702,94 @@ test('admin system test route executes shared validation service', async () => { await server.close(); } }); + +test('admin wechat intent router config routes', 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' }; + }, + }, + wechatIntentRouterConfigService: { + async getConfig() { + return { + enabled: true, + shadowMode: true, + modelProviderKeyId: 'key-1', + model: 'deepseek-v4-pro', + minConfidence: 0.65, + timeoutMs: 4000, + canaryOpenids: ['openid-a'], + updatedAt: 123, + updatedBy: 'admin-1', + }; + }, + async updateConfig(patch, { updatedBy }) { + updates.push({ patch, updatedBy }); + return { + enabled: false, + shadowMode: true, + modelProviderKeyId: null, + model: null, + minConfidence: 0.65, + timeoutMs: 4000, + canaryOpenids: [], + updatedAt: 456, + updatedBy, + }; + }, + async getRuntimeState() { + return { + source: 'admin-db', + updatedAt: 123, + updatedBy: 'admin-1', + fingerprint: 'fp-1', + overrides: { MEMIND_WECHAT_INTENT_LLM_ENABLED: '1' }, + config: { enabled: true, shadowMode: true }, + }; + }, + }, + plazaPosts: null, + plazaOps: null, + wechatAdmin: null, + subscriptionService: null, + }); + + const server = await startTestServer(router); + try { + const configRes = await fetch(`${server.baseUrl}/admin-api/wechat/intent-router/config`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(configRes.status, 200); + const configBody = await configRes.json(); + assert.equal(configBody.config.enabled, true); + assert.equal(configBody.config.model, 'deepseek-v4-pro'); + + const updateRes = await fetch(`${server.baseUrl}/admin-api/wechat/intent-router/config`, { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + cookie: 'h5_user_session=token-admin', + }, + body: JSON.stringify({ enabled: false }), + }); + assert.equal(updateRes.status, 200); + assert.deepEqual(updates, [{ patch: { enabled: false }, updatedBy: 'admin-1' }]); + + const runtimeRes = await fetch(`${server.baseUrl}/admin-api/wechat/intent-router/runtime`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(runtimeRes.status, 200); + assert.deepEqual((await runtimeRes.json()).overrides, { + MEMIND_WECHAT_INTENT_LLM_ENABLED: '1', + }); + } finally { + await server.close(); + } +}); diff --git a/admin-server.mjs b/admin-server.mjs index 1c552b9..713119f 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -91,6 +91,7 @@ const CONSOLES = { plazaPosts: services.plazaPosts, plazaOps: services.plazaOps, wechatAdmin: services.wechatAdmin, + wechatIntentRouterConfigService: services.wechatIntentRouterConfigService, subscriptionService: services.subscriptionService, }), }, diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index e50964c..a981ebd 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -738,6 +738,48 @@ export async function resumeWechatDigest(id: string) { }); } +export type WechatIntentRouterAdminConfig = { + enabled: boolean; + shadowMode: boolean; + modelProviderKeyId: string | null; + model: string | null; + minConfidence: number; + timeoutMs: number; + canaryOpenids: string[]; + updatedAt?: number | null; + updatedBy?: string | null; +}; + +export type WechatIntentRouterConfigState = { + config: WechatIntentRouterAdminConfig; +}; + +export type WechatIntentRouterRuntimeState = { + source: string; + updatedAt: number | null; + updatedBy: string | null; + fingerprint?: string; + overrides: Record; + config: WechatIntentRouterAdminConfig; +}; + +export async function fetchWechatIntentRouterConfig() { + return adminFetch('/admin-api/wechat/intent-router/config'); +} + +export async function patchWechatIntentRouterConfig( + patch: Partial>, +) { + return adminFetch('/admin-api/wechat/intent-router/config', { + method: 'PATCH', + body: JSON.stringify(patch), + }); +} + +export async function fetchWechatIntentRouterRuntime() { + return adminFetch('/admin-api/wechat/intent-router/runtime'); +} + // ─── Agent Code Run ─────────────────────────────────────────────────────────── export type AgentCodeRunConfigShape = { diff --git a/ops/src/pages/admin/WechatPage.tsx b/ops/src/pages/admin/WechatPage.tsx index db19e76..61de2f9 100644 --- a/ops/src/pages/admin/WechatPage.tsx +++ b/ops/src/pages/admin/WechatPage.tsx @@ -1,21 +1,28 @@ -import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'; import { cancelWechatDigest, createWechatWebNotification, clearWechatRoute, fetchAdminUsers, + fetchLlmProviderKeys, fetchWechatBindings, fetchWechatDeliveries, fetchWechatDigests, + fetchWechatIntentRouterConfig, + fetchWechatIntentRouterRuntime, fetchWechatMessages, fetchWechatSummary, fetchWechatWebNotifications, + patchWechatIntentRouterConfig, resumeWechatDigest, type AdminUser, + type LlmProviderKeyRow, type WechatAdminSummary, type WechatBinding, type WechatDeliveryLog, type WechatDigestSubscription, + type WechatIntentRouterAdminConfig, + type WechatIntentRouterRuntimeState, type WechatMessage, type WechatWebNotification, } from '../../api/admin'; @@ -44,6 +51,49 @@ function userName(row: { displayName?: string | null; username?: string | null } return row.displayName || row.username || '—'; } +type IntentRouterForm = { + enabled: boolean; + shadowMode: boolean; + modelProviderKeyId: string; + model: string; + minConfidence: string; + timeoutMs: string; + canaryOpenids: string; +}; + +function intentRouterToForm( + config: WechatIntentRouterAdminConfig | undefined, + keys: LlmProviderKeyRow[], +): IntentRouterForm { + const keyId = String(config?.modelProviderKeyId ?? '').trim(); + const selectedKey = keys.find((item) => item.id === keyId) ?? null; + const model = String(config?.model ?? '').trim() + || selectedKey?.defaultModel + || selectedKey?.models?.[0] + || ''; + return { + enabled: Boolean(config?.enabled), + shadowMode: config?.shadowMode !== false, + modelProviderKeyId: keyId, + model, + minConfidence: String(config?.minConfidence ?? 0.65), + timeoutMs: String(config?.timeoutMs ?? 4000), + canaryOpenids: Array.isArray(config?.canaryOpenids) ? config.canaryOpenids.join('\n') : '', + }; +} + +function intentRouterRuntimeMode(overrides: Record) { + const enabled = ['1', 'true', 'yes', 'on'].includes( + String(overrides.MEMIND_WECHAT_INTENT_LLM_ENABLED ?? '').toLowerCase(), + ); + const shadow = ['1', 'true', 'yes', 'on'].includes( + String(overrides.MEMIND_WECHAT_INTENT_LLM_SHADOW ?? '').toLowerCase(), + ); + if (!enabled) return '关闭(仅规则意图)'; + if (shadow) return 'Shadow 观测(行为不变)'; + return '已激活(chat.general 可升级为 page.generate)'; +} + const tableStyle = { width: '100%', borderCollapse: 'collapse', @@ -88,6 +138,21 @@ export function WechatPage() { const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); + const [llmKeys, setLlmKeys] = useState([]); + const [intentForm, setIntentForm] = useState(null); + const [savedIntentForm, setSavedIntentForm] = useState(null); + const [intentRuntime, setIntentRuntime] = useState(null); + const [intentSaving, setIntentSaving] = useState(false); + + const intentDirty = useMemo(() => { + if (!intentForm || !savedIntentForm) return false; + return JSON.stringify(intentForm) !== JSON.stringify(savedIntentForm); + }, [intentForm, savedIntentForm]); + + const selectedIntentKey = useMemo( + () => llmKeys.find((item) => item.id === intentForm?.modelProviderKeyId) ?? null, + [llmKeys, intentForm?.modelProviderKeyId], + ); const load = async () => { setError(null); @@ -100,6 +165,9 @@ export function WechatPage() { deliveryResult, notificationResult, userResult, + llmKeyResult, + intentConfigResult, + intentRuntimeResult, ] = await Promise.all([ fetchWechatSummary(), @@ -109,6 +177,9 @@ export function WechatPage() { fetchWechatDeliveries({ status: deliveryStatus || undefined, limit: 80 }), fetchWechatWebNotifications({ status: notificationStatus || undefined, limit: 80 }), fetchAdminUsers({ page: 1, pageSize: 200, status: 'active' }), + fetchLlmProviderKeys().catch(() => ({ keys: [] as LlmProviderKeyRow[] })), + fetchWechatIntentRouterConfig().catch(() => null), + fetchWechatIntentRouterRuntime().catch(() => null), ]); setSummary(summaryResult); setBindings(bindingResult.bindings); @@ -120,6 +191,13 @@ export function WechatPage() { setNotifyUserId((current) => current || userResult.users[0]?.id || '', ); + setLlmKeys(llmKeyResult.keys); + if (intentConfigResult?.config) { + const nextForm = intentRouterToForm(intentConfigResult.config, llmKeyResult.keys); + setIntentForm(nextForm); + setSavedIntentForm(nextForm); + } + setIntentRuntime(intentRuntimeResult); } catch (err) { setError(err instanceof Error ? err.message : '加载失败'); } @@ -235,6 +313,37 @@ export function WechatPage() { } }; + const handleSaveIntentRouter = async () => { + if (!intentForm) return; + setIntentSaving(true); + setError(null); + setNotice(null); + try { + const result = await patchWechatIntentRouterConfig({ + enabled: intentForm.enabled, + shadowMode: intentForm.shadowMode, + modelProviderKeyId: intentForm.modelProviderKeyId || null, + model: intentForm.model || null, + minConfidence: Number(intentForm.minConfidence) || 0.65, + timeoutMs: Number(intentForm.timeoutMs) || 4000, + canaryOpenids: intentForm.canaryOpenids + .split(/[\s,]+/u) + .map((item) => item.trim()) + .filter(Boolean), + }); + const nextForm = intentRouterToForm(result.config, llmKeys); + setIntentForm(nextForm); + setSavedIntentForm(nextForm); + const runtime = await fetchWechatIntentRouterRuntime().catch(() => null); + setIntentRuntime(runtime); + setNotice('微信 LLM 意图路由配置已保存。Portal 进程会在下次请求时自动热加载。'); + } catch (err) { + setError(err instanceof Error ? err.message : '保存失败'); + } finally { + setIntentSaving(false); + } + }; + if (!summary && !error) return

加载中...

; return ( @@ -260,6 +369,143 @@ export function WechatPage() { ) : null} + {intentForm ? ( +
+
+
+

微信 LLM 意图路由

+

+ 独立于 H5 Router。仅对规则判为 chat.general 的消息做 LLM 二次判定(page.generate vs chat.general)。 +

+
+ +
+ +
+ + + + + + + + + + + + setIntentForm((current) => current && { ...current, model: event.target.value }) + } + placeholder={selectedIntentKey?.defaultModel || 'deepseek-v4-pro'} + /> + + {(selectedIntentKey?.models ?? []).map((model) => ( + + + + + setIntentForm((current) => current && { ...current, minConfidence: event.target.value }) + } + /> + + + + setIntentForm((current) => current && { ...current, timeoutMs: event.target.value }) + } + /> + +
+ + +