feat(wechat): add admin-pluggable LLM intent router separate from H5.
Memind CI / Test, build, and release guards (push) Failing after 8s
Memind CI / Test, build, and release guards (push) Failing after 8s
Give WeChat MP its own chat.general→page.generate LLM refinement layer with memindadm toggles, shadow mode, and canary openids so service account routing stays independent of the H5 chatIntentRouter. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -168,6 +168,16 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
|||||||
# MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID=<h5_llm_provider_keys.id>
|
# MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID=<h5_llm_provider_keys.id>
|
||||||
# MEMIND_CHAT_ROUTER_MODEL=deepseek-v4-pro
|
# 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)
|
# H5 Session stream replay(docs/h5-session-architecture-20260706.md Patch 4c)
|
||||||
# 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
|
# 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
|
||||||
# MEMIND_SESSION_STREAM_REPLAY=0
|
# MEMIND_SESSION_STREAM_REPLAY=0
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { createSystemDisclosurePolicyService } from './system-disclosure-policy.
|
|||||||
import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs';
|
import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs';
|
||||||
import { createMindSearchConfigService } from './mindsearch-config.mjs';
|
import { createMindSearchConfigService } from './mindsearch-config.mjs';
|
||||||
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-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 { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
|
||||||
import { createPlazaInteractionService } from './plaza-interactions.mjs';
|
import { createPlazaInteractionService } from './plaza-interactions.mjs';
|
||||||
import { createPlazaOpsService } from './plaza-ops.mjs';
|
import { createPlazaOpsService } from './plaza-ops.mjs';
|
||||||
@@ -131,6 +132,7 @@ export async function createAdminServices(env = {}) {
|
|||||||
await systemDisclosurePolicyService.initialize();
|
await systemDisclosurePolicyService.initialize();
|
||||||
const agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env });
|
const agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env });
|
||||||
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
||||||
|
const wechatIntentRouterConfigService = createWechatIntentRouterConfigService(pool);
|
||||||
const adminSystemTestService = createAdminSystemTestService({
|
const adminSystemTestService = createAdminSystemTestService({
|
||||||
pool,
|
pool,
|
||||||
userAuth,
|
userAuth,
|
||||||
@@ -178,6 +180,7 @@ export async function createAdminServices(env = {}) {
|
|||||||
systemDisclosurePolicyService,
|
systemDisclosurePolicyService,
|
||||||
agentCodeRunPolicyService,
|
agentCodeRunPolicyService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
|
wechatIntentRouterConfigService,
|
||||||
adminSystemTestService,
|
adminSystemTestService,
|
||||||
plazaPosts,
|
plazaPosts,
|
||||||
plazaOps,
|
plazaOps,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function plazaRouteError(res, req, error) {
|
|||||||
* @param {object|null} deps.plazaPosts
|
* @param {object|null} deps.plazaPosts
|
||||||
* @param {object|null} deps.plazaOps
|
* @param {object|null} deps.plazaOps
|
||||||
* @param {object|null} deps.wechatAdmin
|
* @param {object|null} deps.wechatAdmin
|
||||||
|
* @param {object|null} deps.wechatIntentRouterConfigService
|
||||||
*/
|
*/
|
||||||
export function createAdminApi({
|
export function createAdminApi({
|
||||||
jsonBody,
|
jsonBody,
|
||||||
@@ -64,6 +65,7 @@ export function createAdminApi({
|
|||||||
plazaPosts,
|
plazaPosts,
|
||||||
plazaOps,
|
plazaOps,
|
||||||
wechatAdmin,
|
wechatAdmin,
|
||||||
|
wechatIntentRouterConfigService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
}) {
|
}) {
|
||||||
function requireAdmin(req, res, next) {
|
function requireAdmin(req, res, next) {
|
||||||
@@ -838,6 +840,34 @@ export function createAdminApi({
|
|||||||
res.json(result);
|
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) => {
|
adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => {
|
||||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||||
res.json({ catalog: llmProviderService.catalog });
|
res.json({ catalog: llmProviderService.catalog });
|
||||||
|
|||||||
@@ -702,3 +702,94 @@ test('admin system test route executes shared validation service', async () => {
|
|||||||
await server.close();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ const CONSOLES = {
|
|||||||
plazaPosts: services.plazaPosts,
|
plazaPosts: services.plazaPosts,
|
||||||
plazaOps: services.plazaOps,
|
plazaOps: services.plazaOps,
|
||||||
wechatAdmin: services.wechatAdmin,
|
wechatAdmin: services.wechatAdmin,
|
||||||
|
wechatIntentRouterConfigService: services.wechatIntentRouterConfigService,
|
||||||
subscriptionService: services.subscriptionService,
|
subscriptionService: services.subscriptionService,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<string, string>;
|
||||||
|
config: WechatIntentRouterAdminConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchWechatIntentRouterConfig() {
|
||||||
|
return adminFetch<WechatIntentRouterConfigState>('/admin-api/wechat/intent-router/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchWechatIntentRouterConfig(
|
||||||
|
patch: Partial<Omit<WechatIntentRouterAdminConfig, 'updatedAt' | 'updatedBy'>>,
|
||||||
|
) {
|
||||||
|
return adminFetch<WechatIntentRouterConfigState>('/admin-api/wechat/intent-router/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWechatIntentRouterRuntime() {
|
||||||
|
return adminFetch<WechatIntentRouterRuntimeState>('/admin-api/wechat/intent-router/runtime');
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Agent Code Run ───────────────────────────────────────────────────────────
|
// ─── Agent Code Run ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type AgentCodeRunConfigShape = {
|
export type AgentCodeRunConfigShape = {
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
cancelWechatDigest,
|
cancelWechatDigest,
|
||||||
createWechatWebNotification,
|
createWechatWebNotification,
|
||||||
clearWechatRoute,
|
clearWechatRoute,
|
||||||
fetchAdminUsers,
|
fetchAdminUsers,
|
||||||
|
fetchLlmProviderKeys,
|
||||||
fetchWechatBindings,
|
fetchWechatBindings,
|
||||||
fetchWechatDeliveries,
|
fetchWechatDeliveries,
|
||||||
fetchWechatDigests,
|
fetchWechatDigests,
|
||||||
|
fetchWechatIntentRouterConfig,
|
||||||
|
fetchWechatIntentRouterRuntime,
|
||||||
fetchWechatMessages,
|
fetchWechatMessages,
|
||||||
fetchWechatSummary,
|
fetchWechatSummary,
|
||||||
fetchWechatWebNotifications,
|
fetchWechatWebNotifications,
|
||||||
|
patchWechatIntentRouterConfig,
|
||||||
resumeWechatDigest,
|
resumeWechatDigest,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
|
type LlmProviderKeyRow,
|
||||||
type WechatAdminSummary,
|
type WechatAdminSummary,
|
||||||
type WechatBinding,
|
type WechatBinding,
|
||||||
type WechatDeliveryLog,
|
type WechatDeliveryLog,
|
||||||
type WechatDigestSubscription,
|
type WechatDigestSubscription,
|
||||||
|
type WechatIntentRouterAdminConfig,
|
||||||
|
type WechatIntentRouterRuntimeState,
|
||||||
type WechatMessage,
|
type WechatMessage,
|
||||||
type WechatWebNotification,
|
type WechatWebNotification,
|
||||||
} from '../../api/admin';
|
} from '../../api/admin';
|
||||||
@@ -44,6 +51,49 @@ function userName(row: { displayName?: string | null; username?: string | null }
|
|||||||
return row.displayName || row.username || '—';
|
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<string, string>) {
|
||||||
|
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 = {
|
const tableStyle = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderCollapse: 'collapse',
|
borderCollapse: 'collapse',
|
||||||
@@ -88,6 +138,21 @@ export function WechatPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [llmKeys, setLlmKeys] = useState<LlmProviderKeyRow[]>([]);
|
||||||
|
const [intentForm, setIntentForm] = useState<IntentRouterForm | null>(null);
|
||||||
|
const [savedIntentForm, setSavedIntentForm] = useState<IntentRouterForm | null>(null);
|
||||||
|
const [intentRuntime, setIntentRuntime] = useState<WechatIntentRouterRuntimeState | null>(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 () => {
|
const load = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -100,6 +165,9 @@ export function WechatPage() {
|
|||||||
deliveryResult,
|
deliveryResult,
|
||||||
notificationResult,
|
notificationResult,
|
||||||
userResult,
|
userResult,
|
||||||
|
llmKeyResult,
|
||||||
|
intentConfigResult,
|
||||||
|
intentRuntimeResult,
|
||||||
] =
|
] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchWechatSummary(),
|
fetchWechatSummary(),
|
||||||
@@ -109,6 +177,9 @@ export function WechatPage() {
|
|||||||
fetchWechatDeliveries({ status: deliveryStatus || undefined, limit: 80 }),
|
fetchWechatDeliveries({ status: deliveryStatus || undefined, limit: 80 }),
|
||||||
fetchWechatWebNotifications({ status: notificationStatus || undefined, limit: 80 }),
|
fetchWechatWebNotifications({ status: notificationStatus || undefined, limit: 80 }),
|
||||||
fetchAdminUsers({ page: 1, pageSize: 200, status: 'active' }),
|
fetchAdminUsers({ page: 1, pageSize: 200, status: 'active' }),
|
||||||
|
fetchLlmProviderKeys().catch(() => ({ keys: [] as LlmProviderKeyRow[] })),
|
||||||
|
fetchWechatIntentRouterConfig().catch(() => null),
|
||||||
|
fetchWechatIntentRouterRuntime().catch(() => null),
|
||||||
]);
|
]);
|
||||||
setSummary(summaryResult);
|
setSummary(summaryResult);
|
||||||
setBindings(bindingResult.bindings);
|
setBindings(bindingResult.bindings);
|
||||||
@@ -120,6 +191,13 @@ export function WechatPage() {
|
|||||||
setNotifyUserId((current) =>
|
setNotifyUserId((current) =>
|
||||||
current || userResult.users[0]?.id || '',
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '加载失败');
|
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 <p>加载中...</p>;
|
if (!summary && !error) return <p>加载中...</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -260,6 +369,143 @@ export function WechatPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{intentForm ? (
|
||||||
|
<section className="card grid">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0 }}>微信 LLM 意图路由</h3>
|
||||||
|
<p style={{ margin: '6px 0 0', color: '#68716c', fontSize: 13 }}>
|
||||||
|
独立于 H5 Router。仅对规则判为 chat.general 的消息做 LLM 二次判定(page.generate vs chat.general)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => void handleSaveIntentRouter()}
|
||||||
|
disabled={busy || intentSaving || !intentDirty}
|
||||||
|
>
|
||||||
|
{intentSaving ? '保存中…' : intentDirty ? '保存配置' : '已保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="启用 LLM 意图层">
|
||||||
|
<label style={{ display: 'flex', gap: 8, alignItems: 'center', minHeight: 38 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={intentForm.enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
setIntentForm((current) => current && { ...current, enabled: event.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{intentForm.enabled ? '已启用' : '关闭'}</span>
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
<Field label="Shadow 模式">
|
||||||
|
<label style={{ display: 'flex', gap: 8, alignItems: 'center', minHeight: 38 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={intentForm.shadowMode}
|
||||||
|
disabled={!intentForm.enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
setIntentForm((current) => current && { ...current, shadowMode: event.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{intentForm.shadowMode ? '只观测不改行为' : 'Active 生效'}</span>
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
<Field label="LLM Provider Key">
|
||||||
|
<select
|
||||||
|
value={intentForm.modelProviderKeyId}
|
||||||
|
onChange={(event) => {
|
||||||
|
const keyId = event.target.value;
|
||||||
|
const key = llmKeys.find((item) => item.id === keyId) ?? null;
|
||||||
|
setIntentForm((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
modelProviderKeyId: keyId,
|
||||||
|
model: key?.defaultModel || key?.models?.[0] || current.model,
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">使用全局选中 Key</option>
|
||||||
|
{llmKeys.map((key) => (
|
||||||
|
<option key={key.id} value={key.id}>
|
||||||
|
{key.name} ({key.providerLabel})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="模型">
|
||||||
|
<input
|
||||||
|
list="wechat-intent-models"
|
||||||
|
value={intentForm.model}
|
||||||
|
onChange={(event) =>
|
||||||
|
setIntentForm((current) => current && { ...current, model: event.target.value })
|
||||||
|
}
|
||||||
|
placeholder={selectedIntentKey?.defaultModel || 'deepseek-v4-pro'}
|
||||||
|
/>
|
||||||
|
<datalist id="wechat-intent-models">
|
||||||
|
{(selectedIntentKey?.models ?? []).map((model) => (
|
||||||
|
<option key={model} value={model} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</Field>
|
||||||
|
<Field label="最低置信度">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={intentForm.minConfidence}
|
||||||
|
onChange={(event) =>
|
||||||
|
setIntentForm((current) => current && { ...current, minConfidence: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="超时 (ms)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={500}
|
||||||
|
max={30000}
|
||||||
|
step={100}
|
||||||
|
value={intentForm.timeoutMs}
|
||||||
|
onChange={(event) =>
|
||||||
|
setIntentForm((current) => current && { ...current, timeoutMs: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Canary OpenID(每行或逗号分隔,留空=全员)">
|
||||||
|
<textarea
|
||||||
|
value={intentForm.canaryOpenids}
|
||||||
|
onChange={(event) =>
|
||||||
|
setIntentForm((current) => current && { ...current, canaryOpenids: event.target.value })
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
placeholder="oXXXX..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{intentRuntime ? (
|
||||||
|
<p style={{ margin: 0, color: '#68716c', fontSize: 12 }}>
|
||||||
|
运行时:{intentRouterRuntimeMode(intentRuntime.overrides)} · 来源 {intentRuntime.source}
|
||||||
|
{intentRuntime.updatedAt ? ` · 更新 ${time(intentRuntime.updatedAt)}` : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="card grid">
|
<section className="card grid">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<h3 style={{ margin: 0 }}>主动通知推送</h3>
|
<h3 style={{ margin: 0 }}>主动通知推送</h3>
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ let skillRuntimeConfigService = null;
|
|||||||
let systemDisclosurePolicyService = null;
|
let systemDisclosurePolicyService = null;
|
||||||
let agentCodeRunPolicyService = null;
|
let agentCodeRunPolicyService = null;
|
||||||
let wechatScheduleLlmConfigService = null;
|
let wechatScheduleLlmConfigService = null;
|
||||||
|
let wechatIntentRouter = null;
|
||||||
let mindSpace = null;
|
let mindSpace = null;
|
||||||
let mindSpaceAssets = null;
|
let mindSpaceAssets = null;
|
||||||
let mindSpaceAudit = null;
|
let mindSpaceAudit = null;
|
||||||
@@ -471,6 +472,8 @@ async function bootstrapUserAuth() {
|
|||||||
memorySessionServices.agentCodeRunPolicyService;
|
memorySessionServices.agentCodeRunPolicyService;
|
||||||
wechatScheduleLlmConfigService =
|
wechatScheduleLlmConfigService =
|
||||||
memorySessionServices.wechatScheduleLlmConfigService;
|
memorySessionServices.wechatScheduleLlmConfigService;
|
||||||
|
wechatIntentRouter =
|
||||||
|
memorySessionServices.wechatIntentRouter;
|
||||||
conversationMemoryService =
|
conversationMemoryService =
|
||||||
memorySessionServices.conversationMemoryService;
|
memorySessionServices.conversationMemoryService;
|
||||||
memoryV2 = memorySessionServices.memoryV2;
|
memoryV2 = memorySessionServices.memoryV2;
|
||||||
@@ -530,6 +533,7 @@ async function bootstrapUserAuth() {
|
|||||||
scheduledTaskService,
|
scheduledTaskService,
|
||||||
sessionSnapshotService,
|
sessionSnapshotService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
|
wechatIntentRouter,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
chatIntentRouter,
|
chatIntentRouter,
|
||||||
systemDisclosurePolicyService,
|
systemDisclosurePolicyService,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export async function bootstrapPortalIntegrationServices({
|
|||||||
scheduledTaskService = null,
|
scheduledTaskService = null,
|
||||||
sessionSnapshotService = null,
|
sessionSnapshotService = null,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
|
wechatIntentRouter = null,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
chatIntentRouter,
|
chatIntentRouter,
|
||||||
systemDisclosurePolicyService,
|
systemDisclosurePolicyService,
|
||||||
@@ -165,6 +166,7 @@ export async function bootstrapPortalIntegrationServices({
|
|||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
chatIntentRouter,
|
chatIntentRouter,
|
||||||
|
wechatIntentRouter,
|
||||||
systemDisclosurePolicyService,
|
systemDisclosurePolicyService,
|
||||||
sessionIntentClassifier: ({ text }) =>
|
sessionIntentClassifier: ({ text }) =>
|
||||||
chatIntentRouter?.classifySessionAction({
|
chatIntentRouter?.classifySessionAction({
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { isSessionStreamReplayEnabled } from '../session-stream.mjs';
|
|||||||
import { createSkillRuntimeAdminConfigService } from '../skill-runtime-admin-config.mjs';
|
import { createSkillRuntimeAdminConfigService } from '../skill-runtime-admin-config.mjs';
|
||||||
import { createSystemDisclosurePolicyService } from '../system-disclosure-policy.mjs';
|
import { createSystemDisclosurePolicyService } from '../system-disclosure-policy.mjs';
|
||||||
import { createWechatScheduleLlmConfigService } from '../wechat-schedule-llm-config.mjs';
|
import { createWechatScheduleLlmConfigService } from '../wechat-schedule-llm-config.mjs';
|
||||||
|
import { createWechatIntentRouterConfigService } from '../wechat-intent-router-config.mjs';
|
||||||
|
import { createManagedWechatIntentRouter } from '../wechat-intent-router.mjs';
|
||||||
|
|
||||||
export async function bootstrapPortalMemorySessionServices({
|
export async function bootstrapPortalMemorySessionServices({
|
||||||
pool,
|
pool,
|
||||||
@@ -30,6 +32,10 @@ export async function bootstrapPortalMemorySessionServices({
|
|||||||
createAgentCodeRunAdminConfigService,
|
createAgentCodeRunAdminConfigService,
|
||||||
createWechatScheduleLlmConfigServiceFn =
|
createWechatScheduleLlmConfigServiceFn =
|
||||||
createWechatScheduleLlmConfigService,
|
createWechatScheduleLlmConfigService,
|
||||||
|
createWechatIntentRouterConfigServiceFn =
|
||||||
|
createWechatIntentRouterConfigService,
|
||||||
|
createManagedWechatIntentRouterFn =
|
||||||
|
createManagedWechatIntentRouter,
|
||||||
createConversationMemoryServiceFn =
|
createConversationMemoryServiceFn =
|
||||||
createConversationMemoryService,
|
createConversationMemoryService,
|
||||||
createManagedMemoryV2RuntimeFn =
|
createManagedMemoryV2RuntimeFn =
|
||||||
@@ -71,6 +77,15 @@ export async function bootstrapPortalMemorySessionServices({
|
|||||||
createAgentCodeRunAdminConfigServiceFn(pool, { env });
|
createAgentCodeRunAdminConfigServiceFn(pool, { env });
|
||||||
const wechatScheduleLlmConfigService =
|
const wechatScheduleLlmConfigService =
|
||||||
createWechatScheduleLlmConfigServiceFn(pool);
|
createWechatScheduleLlmConfigServiceFn(pool);
|
||||||
|
const wechatIntentRouterConfigService =
|
||||||
|
createWechatIntentRouterConfigServiceFn(pool, { env });
|
||||||
|
const wechatIntentRouter =
|
||||||
|
createManagedWechatIntentRouterFn({
|
||||||
|
llmProviderService,
|
||||||
|
configService: wechatIntentRouterConfigService,
|
||||||
|
env,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
const getEffectiveEnv = async () => {
|
const getEffectiveEnv = async () => {
|
||||||
const state = await memoryV2ConfigService
|
const state = await memoryV2ConfigService
|
||||||
@@ -140,6 +155,8 @@ export async function bootstrapPortalMemorySessionServices({
|
|||||||
systemDisclosurePolicyService,
|
systemDisclosurePolicyService,
|
||||||
agentCodeRunPolicyService,
|
agentCodeRunPolicyService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
|
wechatIntentRouterConfigService,
|
||||||
|
wechatIntentRouter,
|
||||||
conversationMemoryService,
|
conversationMemoryService,
|
||||||
memoryV2,
|
memoryV2,
|
||||||
episodicMemoryService,
|
episodicMemoryService,
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
const CONFIG_TABLE = 'h5_wechat_admin_config';
|
||||||
|
const CONFIG_KEY = 'intent_router';
|
||||||
|
|
||||||
|
function normalizeBoolean(value, fallback = false) {
|
||||||
|
if (value == null || value === '') return fallback;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeString(value) {
|
||||||
|
if (value == null) return '';
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumber(value, fallback) {
|
||||||
|
if (value == null || value === '') return fallback;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfigJson(value) {
|
||||||
|
if (!value) return {};
|
||||||
|
if (typeof value === 'object') return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(value));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOpenidList(value) {
|
||||||
|
return [...new Set(String(value ?? '')
|
||||||
|
.split(/[\s,]+/u)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean))].slice(0, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureConfigTable(pool) {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
|
||||||
|
config_key VARCHAR(64) PRIMARY KEY,
|
||||||
|
config_json JSON NOT NULL,
|
||||||
|
updated_by CHAR(36) NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultsFromEnv(env = process.env) {
|
||||||
|
return {
|
||||||
|
enabled: normalizeBoolean(env.MEMIND_WECHAT_INTENT_LLM_ENABLED, false),
|
||||||
|
shadowMode: normalizeBoolean(env.MEMIND_WECHAT_INTENT_LLM_SHADOW, true),
|
||||||
|
modelProviderKeyId: normalizeString(env.MEMIND_WECHAT_INTENT_MODEL_PROVIDER_KEY_ID),
|
||||||
|
model: normalizeString(env.MEMIND_WECHAT_INTENT_MODEL),
|
||||||
|
minConfidence: normalizeNumber(env.MEMIND_WECHAT_INTENT_MIN_CONFIDENCE, 0.65),
|
||||||
|
timeoutMs: normalizeNumber(env.MEMIND_WECHAT_INTENT_TIMEOUT_MS, 4000),
|
||||||
|
canaryOpenids: normalizeOpenidList(env.MEMIND_WECHAT_INTENT_CANARY_OPENIDS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWechatIntentRouterConfigService(pool, { env = process.env } = {}) {
|
||||||
|
let ensurePromise = null;
|
||||||
|
|
||||||
|
async function ensureReady() {
|
||||||
|
if (!ensurePromise) ensurePromise = ensureConfigTable(pool);
|
||||||
|
await ensurePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRow() {
|
||||||
|
await ensureReady();
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT config_json, updated_by, updated_at
|
||||||
|
FROM ${CONFIG_TABLE}
|
||||||
|
WHERE config_key = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[CONFIG_KEY],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfig(row) {
|
||||||
|
const defaults = defaultsFromEnv(env);
|
||||||
|
const stored = parseConfigJson(row?.config_json);
|
||||||
|
return {
|
||||||
|
enabled: normalizeBoolean(stored.enabled, defaults.enabled),
|
||||||
|
shadowMode: normalizeBoolean(stored.shadowMode, defaults.shadowMode),
|
||||||
|
modelProviderKeyId: normalizeString(stored.modelProviderKeyId) || defaults.modelProviderKeyId || null,
|
||||||
|
model: normalizeString(stored.model) || defaults.model || null,
|
||||||
|
minConfidence: normalizeNumber(stored.minConfidence, defaults.minConfidence),
|
||||||
|
timeoutMs: normalizeNumber(stored.timeoutMs, defaults.timeoutMs),
|
||||||
|
canaryOpenids: stored.canaryOpenids != null
|
||||||
|
? normalizeOpenidList(stored.canaryOpenids)
|
||||||
|
: defaults.canaryOpenids,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getConfig() {
|
||||||
|
const row = await readRow();
|
||||||
|
return {
|
||||||
|
...mergeConfig(row),
|
||||||
|
updatedAt: row?.updated_at ? Number(row.updated_at) : null,
|
||||||
|
updatedBy: row?.updated_by ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRuntimeState() {
|
||||||
|
const config = await this.getConfig();
|
||||||
|
const overrides = {
|
||||||
|
MEMIND_WECHAT_INTENT_LLM_ENABLED: config.enabled ? '1' : '0',
|
||||||
|
MEMIND_WECHAT_INTENT_LLM_SHADOW: config.shadowMode ? '1' : '0',
|
||||||
|
MEMIND_WECHAT_INTENT_MODEL_PROVIDER_KEY_ID: config.modelProviderKeyId ?? '',
|
||||||
|
MEMIND_WECHAT_INTENT_MODEL: config.model ?? '',
|
||||||
|
MEMIND_WECHAT_INTENT_MIN_CONFIDENCE: String(config.minConfidence),
|
||||||
|
MEMIND_WECHAT_INTENT_TIMEOUT_MS: String(config.timeoutMs),
|
||||||
|
MEMIND_WECHAT_INTENT_CANARY_OPENIDS: config.canaryOpenids.join(','),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
source: 'admin-db',
|
||||||
|
updatedAt: config.updatedAt,
|
||||||
|
updatedBy: config.updatedBy,
|
||||||
|
fingerprint: JSON.stringify(config),
|
||||||
|
overrides,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateConfig(payload = {}, { updatedBy = null } = {}) {
|
||||||
|
const current = await this.getConfig();
|
||||||
|
const next = {
|
||||||
|
enabled: payload.enabled === undefined
|
||||||
|
? current.enabled
|
||||||
|
: normalizeBoolean(payload.enabled, current.enabled),
|
||||||
|
shadowMode: payload.shadowMode === undefined
|
||||||
|
? current.shadowMode
|
||||||
|
: normalizeBoolean(payload.shadowMode, current.shadowMode),
|
||||||
|
modelProviderKeyId: payload.modelProviderKeyId === undefined
|
||||||
|
? (current.modelProviderKeyId ?? '')
|
||||||
|
: normalizeString(payload.modelProviderKeyId),
|
||||||
|
model: payload.model === undefined
|
||||||
|
? (current.model ?? '')
|
||||||
|
: normalizeString(payload.model),
|
||||||
|
minConfidence: payload.minConfidence === undefined
|
||||||
|
? current.minConfidence
|
||||||
|
: normalizeNumber(payload.minConfidence, current.minConfidence),
|
||||||
|
timeoutMs: payload.timeoutMs === undefined
|
||||||
|
? current.timeoutMs
|
||||||
|
: normalizeNumber(payload.timeoutMs, current.timeoutMs),
|
||||||
|
canaryOpenids: payload.canaryOpenids === undefined
|
||||||
|
? current.canaryOpenids
|
||||||
|
: normalizeOpenidList(payload.canaryOpenids),
|
||||||
|
};
|
||||||
|
const now = Date.now();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO ${CONFIG_TABLE} (config_key, config_json, updated_by, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
config_json = VALUES(config_json),
|
||||||
|
updated_by = VALUES(updated_by),
|
||||||
|
updated_at = VALUES(updated_at)`,
|
||||||
|
[CONFIG_KEY, JSON.stringify(next), updatedBy, now],
|
||||||
|
);
|
||||||
|
return this.getConfig();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wechatIntentRouterConfigInternals = {
|
||||||
|
normalizeBoolean,
|
||||||
|
normalizeOpenidList,
|
||||||
|
defaultsFromEnv,
|
||||||
|
};
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import { wantsDocxDownload } from './wechat/intent/patterns.mjs';
|
||||||
|
import { defaultsFromEnv } from './wechat-intent-router-config.mjs';
|
||||||
|
|
||||||
|
export const WECHAT_LLM_REFINABLE_KINDS = new Set(['chat.general']);
|
||||||
|
|
||||||
|
const DEFAULT_MIN_CONFIDENCE = 0.65;
|
||||||
|
const DEFAULT_TIMEOUT_MS = 4000;
|
||||||
|
|
||||||
|
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 normalizeOpenidList(value) {
|
||||||
|
return [...new Set(String(value ?? '')
|
||||||
|
.split(/[\s,]+/u)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean))].slice(0, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWechatIntentRouterPolicy(env = process.env, overrides = {}) {
|
||||||
|
const merged = { ...env, ...overrides };
|
||||||
|
return {
|
||||||
|
enabled: envFlag(merged.MEMIND_WECHAT_INTENT_LLM_ENABLED, false),
|
||||||
|
shadowMode: envFlag(merged.MEMIND_WECHAT_INTENT_LLM_SHADOW, true),
|
||||||
|
modelProviderKeyId: String(merged.MEMIND_WECHAT_INTENT_MODEL_PROVIDER_KEY_ID ?? '').trim() || null,
|
||||||
|
model: String(merged.MEMIND_WECHAT_INTENT_MODEL ?? '').trim() || null,
|
||||||
|
minConfidence: boundedNumber(
|
||||||
|
merged.MEMIND_WECHAT_INTENT_MIN_CONFIDENCE,
|
||||||
|
DEFAULT_MIN_CONFIDENCE,
|
||||||
|
{ min: 0, max: 1 },
|
||||||
|
),
|
||||||
|
timeoutMs: Math.round(boundedNumber(
|
||||||
|
merged.MEMIND_WECHAT_INTENT_TIMEOUT_MS,
|
||||||
|
DEFAULT_TIMEOUT_MS,
|
||||||
|
{ min: 500, max: 30_000 },
|
||||||
|
)),
|
||||||
|
canaryOpenids: normalizeOpenidList(merged.MEMIND_WECHAT_INTENT_CANARY_OPENIDS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWechatIntentLlmEligible({ policy, openid = null } = {}) {
|
||||||
|
if (!policy?.enabled) return false;
|
||||||
|
if (!Array.isArray(policy.canaryOpenids) || policy.canaryOpenids.length === 0) return true;
|
||||||
|
const normalized = String(openid ?? '').trim();
|
||||||
|
return normalized.length > 0 && policy.canaryOpenids.includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRefineWechatIntent(ruleIntent, text = '') {
|
||||||
|
const kind = String(ruleIntent?.kind ?? '').trim();
|
||||||
|
if (!WECHAT_LLM_REFINABLE_KINDS.has(kind)) return false;
|
||||||
|
return String(text ?? '').trim().length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWechatIntentSystemPrompt() {
|
||||||
|
return [
|
||||||
|
'你是微信服务号意图分类器,只判断用户消息更适合哪种处理通道。',
|
||||||
|
'只输出 JSON,不要 markdown:',
|
||||||
|
'{"kind":"page.generate|chat.general","confidence":0.0,"reason":"一句话"}',
|
||||||
|
'page.generate:用户希望生成/整理/排版 HTML 页面、图文、推文、专题、报告页,或明确要把内容做成可发布/可转发的页面。',
|
||||||
|
'chat.general:普通问答、闲聊、解释、建议、查询,不需要产出 HTML 页面。',
|
||||||
|
'如果用户只是口头提到“页面/文章”但没有生成或交付页面意图,仍判 chat.general。',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWechatIntentUserPrompt(text) {
|
||||||
|
return ['[User]', String(text ?? '').trim() || '(empty)'].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWechatIntentJson(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWechatLlmIntent(raw) {
|
||||||
|
const kindRaw = String(raw?.kind ?? '').trim().toLowerCase();
|
||||||
|
const kind = kindRaw === 'page.generate' ? 'page.generate' : 'chat.general';
|
||||||
|
const confidenceRaw = Number(raw?.confidence);
|
||||||
|
const confidence = Number.isFinite(confidenceRaw)
|
||||||
|
? Math.min(1, Math.max(0, confidenceRaw))
|
||||||
|
: 0.7;
|
||||||
|
const reason = String(raw?.reason ?? '').trim() || '微信 LLM 意图判定';
|
||||||
|
return { kind, confidence, reason, source: 'llm' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout(promise, timeoutMs, label) {
|
||||||
|
if (!timeoutMs || timeoutMs <= 0) return promise;
|
||||||
|
let timer = null;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const err = new Error(`${label} timed out after ${timeoutMs}ms`);
|
||||||
|
err.code = 'WECHAT_INTENT_ROUTER_TIMEOUT';
|
||||||
|
reject(err);
|
||||||
|
}, timeoutMs);
|
||||||
|
timer.unref?.();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWechatIntentRouter({
|
||||||
|
llmProviderService,
|
||||||
|
env = process.env,
|
||||||
|
logger = console,
|
||||||
|
policy: policyOverride = null,
|
||||||
|
} = {}) {
|
||||||
|
const policy = policyOverride ?? resolveWechatIntentRouterPolicy(env);
|
||||||
|
|
||||||
|
async function classifyWithLlm(text) {
|
||||||
|
if (typeof llmProviderService?.createChatCompletion !== 'function') return null;
|
||||||
|
try {
|
||||||
|
const completion = await withTimeout(
|
||||||
|
llmProviderService.createChatCompletion({
|
||||||
|
providerKeyId: policy.modelProviderKeyId || undefined,
|
||||||
|
model: policy.model || undefined,
|
||||||
|
temperature: 0,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: buildWechatIntentSystemPrompt() },
|
||||||
|
{ role: 'user', content: buildWechatIntentUserPrompt(text) },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
policy.timeoutMs,
|
||||||
|
'WeChat intent router',
|
||||||
|
);
|
||||||
|
if (!completion?.ok) return null;
|
||||||
|
const parsed = parseWechatIntentJson(completion.reply);
|
||||||
|
if (!parsed) return null;
|
||||||
|
return normalizeWechatLlmIntent({
|
||||||
|
...parsed,
|
||||||
|
model: completion.model ?? policy.model,
|
||||||
|
providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn?.(
|
||||||
|
`[wechat-intent-router] LLM classify skipped: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus() {
|
||||||
|
return {
|
||||||
|
enabled: policy.enabled,
|
||||||
|
shadowMode: policy.shadowMode,
|
||||||
|
llmRoutingEnabled: Boolean(policy.enabled && !policy.shadowMode),
|
||||||
|
llmRoutingShadow: Boolean(policy.enabled && policy.shadowMode),
|
||||||
|
modelProviderKeyId: policy.modelProviderKeyId,
|
||||||
|
model: policy.model,
|
||||||
|
minConfidence: policy.minConfidence,
|
||||||
|
timeoutMs: policy.timeoutMs,
|
||||||
|
canaryOpenidCount: policy.canaryOpenids.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refineIntent({
|
||||||
|
ruleIntent,
|
||||||
|
text = '',
|
||||||
|
openid = null,
|
||||||
|
} = {}) {
|
||||||
|
const baseline = ruleIntent ?? { kind: 'chat.general' };
|
||||||
|
if (!shouldRefineWechatIntent(baseline, text)) {
|
||||||
|
return { ...baseline, intentSource: 'rules' };
|
||||||
|
}
|
||||||
|
if (!isWechatIntentLlmEligible({ policy, openid })) {
|
||||||
|
return { ...baseline, intentSource: 'rules' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const llmResult = await classifyWithLlm(text);
|
||||||
|
const shadowPayload = llmResult
|
||||||
|
? {
|
||||||
|
kind: llmResult.kind,
|
||||||
|
confidence: llmResult.confidence,
|
||||||
|
reason: llmResult.reason,
|
||||||
|
wouldChangeKind: llmResult.kind !== baseline.kind,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
skipped: true,
|
||||||
|
reason: 'llm_unavailable_or_low_signal',
|
||||||
|
wouldChangeKind: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (policy.shadowMode) {
|
||||||
|
logger?.info?.('[wechat-intent-router-shadow]', {
|
||||||
|
openid: openid ?? null,
|
||||||
|
baselineKind: baseline.kind,
|
||||||
|
...shadowPayload,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...baseline,
|
||||||
|
intentSource: 'rules',
|
||||||
|
llmShadow: shadowPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!llmResult || llmResult.confidence < policy.minConfidence) {
|
||||||
|
return {
|
||||||
|
...baseline,
|
||||||
|
intentSource: 'rules',
|
||||||
|
llmSuggestion: llmResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (llmResult.kind === baseline.kind) {
|
||||||
|
return {
|
||||||
|
...baseline,
|
||||||
|
intentSource: 'rules',
|
||||||
|
llmSuggestion: llmResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (llmResult.kind === 'page.generate') {
|
||||||
|
return {
|
||||||
|
kind: 'page.generate',
|
||||||
|
topic: String(text ?? '').trim(),
|
||||||
|
wantsDocx: wantsDocxDownload(text),
|
||||||
|
intentSource: 'llm',
|
||||||
|
llmReason: llmResult.reason,
|
||||||
|
llmConfidence: llmResult.confidence,
|
||||||
|
sessionActionResult: baseline.sessionActionResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'chat.general',
|
||||||
|
text: String(text ?? '').trim(),
|
||||||
|
intentSource: 'llm',
|
||||||
|
llmReason: llmResult.reason,
|
||||||
|
llmConfidence: llmResult.confidence,
|
||||||
|
sessionActionResult: baseline.sessionActionResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getStatus,
|
||||||
|
refineIntent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createManagedWechatIntentRouter({
|
||||||
|
llmProviderService,
|
||||||
|
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?.(
|
||||||
|
`[wechat-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 = createWechatIntentRouter({
|
||||||
|
llmProviderService,
|
||||||
|
env: state.effectiveEnv,
|
||||||
|
logger,
|
||||||
|
policy: resolveWechatIntentRouterPolicy(state.effectiveEnv),
|
||||||
|
});
|
||||||
|
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 refineIntent(input = {}) {
|
||||||
|
const router = await ensureRouter();
|
||||||
|
return router.refineIntent(input);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
createWechatIntentRouter,
|
||||||
|
isWechatIntentLlmEligible,
|
||||||
|
normalizeWechatLlmIntent,
|
||||||
|
parseWechatIntentJson,
|
||||||
|
resolveWechatIntentRouterPolicy,
|
||||||
|
shouldRefineWechatIntent,
|
||||||
|
} from './wechat-intent-router.mjs';
|
||||||
|
import {
|
||||||
|
createWechatIntentRouterConfigService,
|
||||||
|
defaultsFromEnv,
|
||||||
|
} from './wechat-intent-router-config.mjs';
|
||||||
|
|
||||||
|
test('resolveWechatIntentRouterPolicy reads env defaults', () => {
|
||||||
|
const policy = resolveWechatIntentRouterPolicy({
|
||||||
|
MEMIND_WECHAT_INTENT_LLM_ENABLED: '1',
|
||||||
|
MEMIND_WECHAT_INTENT_LLM_SHADOW: '0',
|
||||||
|
MEMIND_WECHAT_INTENT_MODEL: 'deepseek-v4-pro',
|
||||||
|
MEMIND_WECHAT_INTENT_MIN_CONFIDENCE: '0.7',
|
||||||
|
MEMIND_WECHAT_INTENT_TIMEOUT_MS: '5000',
|
||||||
|
MEMIND_WECHAT_INTENT_CANARY_OPENIDS: 'openid-a, openid-b',
|
||||||
|
});
|
||||||
|
assert.equal(policy.enabled, true);
|
||||||
|
assert.equal(policy.shadowMode, false);
|
||||||
|
assert.equal(policy.model, 'deepseek-v4-pro');
|
||||||
|
assert.equal(policy.minConfidence, 0.7);
|
||||||
|
assert.equal(policy.timeoutMs, 5000);
|
||||||
|
assert.deepEqual(policy.canaryOpenids, ['openid-a', 'openid-b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRefineWechatIntent only refines chat.general', () => {
|
||||||
|
assert.equal(shouldRefineWechatIntent({ kind: 'chat.general' }, '整理成一篇给读者看的文章'), true);
|
||||||
|
assert.equal(shouldRefineWechatIntent({ kind: 'page.generate' }, '生成 HTML 页面'), false);
|
||||||
|
assert.equal(shouldRefineWechatIntent({ kind: 'greeting' }, '你好'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isWechatIntentLlmEligible respects canary openids', () => {
|
||||||
|
const policy = { enabled: true, canaryOpenids: ['openid-a'] };
|
||||||
|
assert.equal(isWechatIntentLlmEligible({ policy, openid: 'openid-a' }), true);
|
||||||
|
assert.equal(isWechatIntentLlmEligible({ policy, openid: 'openid-b' }), false);
|
||||||
|
assert.equal(isWechatIntentLlmEligible({ policy: { enabled: true, canaryOpenids: [] }, openid: 'x' }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createWechatIntentRouter upgrades chat.general to page.generate in active mode', async () => {
|
||||||
|
const router = createWechatIntentRouter({
|
||||||
|
policy: resolveWechatIntentRouterPolicy({
|
||||||
|
MEMIND_WECHAT_INTENT_LLM_ENABLED: '1',
|
||||||
|
MEMIND_WECHAT_INTENT_LLM_SHADOW: '0',
|
||||||
|
MEMIND_WECHAT_INTENT_MIN_CONFIDENCE: '0.6',
|
||||||
|
}),
|
||||||
|
llmProviderService: {
|
||||||
|
async createChatCompletion() {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reply: JSON.stringify({
|
||||||
|
kind: 'page.generate',
|
||||||
|
confidence: 0.92,
|
||||||
|
reason: '用户希望整理成可发布页面',
|
||||||
|
}),
|
||||||
|
model: 'deepseek-v4-pro',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await router.refineIntent({
|
||||||
|
ruleIntent: { kind: 'chat.general', text: '帮我整理成一篇给读者看的专题页' },
|
||||||
|
text: '帮我整理成一篇给读者看的专题页',
|
||||||
|
openid: 'openid-a',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.kind, 'page.generate');
|
||||||
|
assert.equal(result.intentSource, 'llm');
|
||||||
|
assert.equal(result.wantsDocx, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createWechatIntentRouter keeps baseline in shadow mode', async () => {
|
||||||
|
const router = createWechatIntentRouter({
|
||||||
|
policy: resolveWechatIntentRouterPolicy({
|
||||||
|
MEMIND_WECHAT_INTENT_LLM_ENABLED: '1',
|
||||||
|
MEMIND_WECHAT_INTENT_LLM_SHADOW: '1',
|
||||||
|
}),
|
||||||
|
llmProviderService: {
|
||||||
|
async createChatCompletion() {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reply: JSON.stringify({
|
||||||
|
kind: 'page.generate',
|
||||||
|
confidence: 0.95,
|
||||||
|
reason: 'shadow',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await router.refineIntent({
|
||||||
|
ruleIntent: { kind: 'chat.general', text: '整理成文章' },
|
||||||
|
text: '整理成文章',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.kind, 'chat.general');
|
||||||
|
assert.equal(result.intentSource, 'rules');
|
||||||
|
assert.equal(result.llmShadow?.wouldChangeKind, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wechat intent router config service persists admin toggles', async () => {
|
||||||
|
const rows = new Map();
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params = []) {
|
||||||
|
if (String(sql).includes('CREATE TABLE')) return [[]];
|
||||||
|
if (String(sql).includes('SELECT config_json')) {
|
||||||
|
const row = rows.get(params[0]);
|
||||||
|
return [row ? [row] : []];
|
||||||
|
}
|
||||||
|
if (String(sql).includes('INSERT INTO')) {
|
||||||
|
rows.set(params[0], {
|
||||||
|
config_json: params[1],
|
||||||
|
updated_by: params[2],
|
||||||
|
updated_at: params[3],
|
||||||
|
});
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected sql: ${sql}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = createWechatIntentRouterConfigService(pool, {
|
||||||
|
env: defaultsFromEnv({}),
|
||||||
|
});
|
||||||
|
const saved = await service.updateConfig({
|
||||||
|
enabled: true,
|
||||||
|
shadowMode: true,
|
||||||
|
model: 'deepseek-v4-flash',
|
||||||
|
canaryOpenids: 'openid-test',
|
||||||
|
}, { updatedBy: 'admin-1' });
|
||||||
|
|
||||||
|
assert.equal(saved.enabled, true);
|
||||||
|
assert.equal(saved.shadowMode, true);
|
||||||
|
assert.equal(saved.model, 'deepseek-v4-flash');
|
||||||
|
assert.deepEqual(saved.canaryOpenids, ['openid-test']);
|
||||||
|
|
||||||
|
const runtime = await service.getRuntimeState();
|
||||||
|
assert.equal(runtime.overrides.MEMIND_WECHAT_INTENT_LLM_ENABLED, '1');
|
||||||
|
assert.equal(runtime.overrides.MEMIND_WECHAT_INTENT_LLM_SHADOW, '1');
|
||||||
|
assert.equal(runtime.overrides.MEMIND_WECHAT_INTENT_MODEL, 'deepseek-v4-flash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseWechatIntentJson accepts fenced JSON', () => {
|
||||||
|
const parsed = parseWechatIntentJson('```json\n{"kind":"page.generate","confidence":0.8}\n```');
|
||||||
|
assert.equal(parsed.kind, 'page.generate');
|
||||||
|
assert.equal(normalizeWechatLlmIntent(parsed).kind, 'page.generate');
|
||||||
|
});
|
||||||
+23
-3
@@ -1487,6 +1487,7 @@ export function createWechatMpService({
|
|||||||
wechatScheduleLlmConfigService = null,
|
wechatScheduleLlmConfigService = null,
|
||||||
llmProviderService = null,
|
llmProviderService = null,
|
||||||
chatIntentRouter = null,
|
chatIntentRouter = null,
|
||||||
|
wechatIntentRouter = null,
|
||||||
systemDisclosurePolicyService = null,
|
systemDisclosurePolicyService = null,
|
||||||
onPageGenerated = null,
|
onPageGenerated = null,
|
||||||
applySessionLlmProvider = null,
|
applySessionLlmProvider = null,
|
||||||
@@ -2433,7 +2434,7 @@ export function createWechatMpService({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const runIntentMessage = async ({ inbound, intent, user }) => {
|
const runIntentMessage = async ({ inbound, intent, user }) => {
|
||||||
const wechatIntent = await resolveWechatIntent(intent);
|
const wechatIntent = await resolveWechatIntent(intent, { openid: inbound.fromUserName });
|
||||||
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
|
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
|
||||||
const reliabilityEnabled = isWechatMediaGrayUser(user, config.reliabilityGrayUsers);
|
const reliabilityEnabled = isWechatMediaGrayUser(user, config.reliabilityGrayUsers);
|
||||||
const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0;
|
const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0;
|
||||||
@@ -3066,7 +3067,7 @@ export function createWechatMpService({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveWechatIntent = async (intent) => {
|
const resolveWechatIntent = async (intent, { openid = null } = {}) => {
|
||||||
const wechatIntent = classifyWechatIntent(intent);
|
const wechatIntent = classifyWechatIntent(intent);
|
||||||
if (intent.msgType !== 'text' && intent.msgType !== 'voice') return wechatIntent;
|
if (intent.msgType !== 'text' && intent.msgType !== 'voice') return wechatIntent;
|
||||||
const sessionActionResult = await resolveWechatSessionAction(intent.agentText, {
|
const sessionActionResult = await resolveWechatSessionAction(intent.agentText, {
|
||||||
@@ -3080,7 +3081,26 @@ export function createWechatMpService({
|
|||||||
if (sessionActionResult.action === 'ignore_previous') {
|
if (sessionActionResult.action === 'ignore_previous') {
|
||||||
intent.sessionAction = sessionActionResult.action;
|
intent.sessionAction = sessionActionResult.action;
|
||||||
}
|
}
|
||||||
return { ...wechatIntent, sessionActionResult };
|
const withSession = {
|
||||||
|
...wechatIntent,
|
||||||
|
sessionActionResult,
|
||||||
|
};
|
||||||
|
if (!wechatIntentRouter?.refineIntent) {
|
||||||
|
return withSession;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await wechatIntentRouter.refineIntent({
|
||||||
|
ruleIntent: withSession,
|
||||||
|
text: intent.agentText,
|
||||||
|
openid,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn?.(
|
||||||
|
'WeChat MP intent router refine skipped:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
return withSession;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInboundMessage = async (bodyText, query = {}) => {
|
const handleInboundMessage = async (bodyText, query = {}) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user