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=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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -91,6 +91,7 @@ const CONSOLES = {
|
||||
plazaPosts: services.plazaPosts,
|
||||
plazaOps: services.plazaOps,
|
||||
wechatAdmin: services.wechatAdmin,
|
||||
wechatIntentRouterConfigService: services.wechatIntentRouterConfigService,
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
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<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 = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
@@ -88,6 +138,21 @@ export function WechatPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
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 () => {
|
||||
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 <p>加载中...</p>;
|
||||
|
||||
return (
|
||||
@@ -260,6 +369,143 @@ export function WechatPage() {
|
||||
</div>
|
||||
) : 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">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<h3 style={{ margin: 0 }}>主动通知推送</h3>
|
||||
|
||||
@@ -280,6 +280,7 @@ let skillRuntimeConfigService = null;
|
||||
let systemDisclosurePolicyService = null;
|
||||
let agentCodeRunPolicyService = null;
|
||||
let wechatScheduleLlmConfigService = null;
|
||||
let wechatIntentRouter = null;
|
||||
let mindSpace = null;
|
||||
let mindSpaceAssets = null;
|
||||
let mindSpaceAudit = null;
|
||||
@@ -471,6 +472,8 @@ async function bootstrapUserAuth() {
|
||||
memorySessionServices.agentCodeRunPolicyService;
|
||||
wechatScheduleLlmConfigService =
|
||||
memorySessionServices.wechatScheduleLlmConfigService;
|
||||
wechatIntentRouter =
|
||||
memorySessionServices.wechatIntentRouter;
|
||||
conversationMemoryService =
|
||||
memorySessionServices.conversationMemoryService;
|
||||
memoryV2 = memorySessionServices.memoryV2;
|
||||
@@ -530,6 +533,7 @@ async function bootstrapUserAuth() {
|
||||
scheduledTaskService,
|
||||
sessionSnapshotService,
|
||||
wechatScheduleLlmConfigService,
|
||||
wechatIntentRouter,
|
||||
llmProviderService,
|
||||
chatIntentRouter,
|
||||
systemDisclosurePolicyService,
|
||||
|
||||
@@ -33,6 +33,7 @@ export async function bootstrapPortalIntegrationServices({
|
||||
scheduledTaskService = null,
|
||||
sessionSnapshotService = null,
|
||||
wechatScheduleLlmConfigService,
|
||||
wechatIntentRouter = null,
|
||||
llmProviderService,
|
||||
chatIntentRouter,
|
||||
systemDisclosurePolicyService,
|
||||
@@ -165,6 +166,7 @@ export async function bootstrapPortalIntegrationServices({
|
||||
wechatScheduleLlmConfigService,
|
||||
llmProviderService,
|
||||
chatIntentRouter,
|
||||
wechatIntentRouter,
|
||||
systemDisclosurePolicyService,
|
||||
sessionIntentClassifier: ({ text }) =>
|
||||
chatIntentRouter?.classifySessionAction({
|
||||
|
||||
@@ -11,6 +11,8 @@ import { isSessionStreamReplayEnabled } from '../session-stream.mjs';
|
||||
import { createSkillRuntimeAdminConfigService } from '../skill-runtime-admin-config.mjs';
|
||||
import { createSystemDisclosurePolicyService } from '../system-disclosure-policy.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({
|
||||
pool,
|
||||
@@ -30,6 +32,10 @@ export async function bootstrapPortalMemorySessionServices({
|
||||
createAgentCodeRunAdminConfigService,
|
||||
createWechatScheduleLlmConfigServiceFn =
|
||||
createWechatScheduleLlmConfigService,
|
||||
createWechatIntentRouterConfigServiceFn =
|
||||
createWechatIntentRouterConfigService,
|
||||
createManagedWechatIntentRouterFn =
|
||||
createManagedWechatIntentRouter,
|
||||
createConversationMemoryServiceFn =
|
||||
createConversationMemoryService,
|
||||
createManagedMemoryV2RuntimeFn =
|
||||
@@ -71,6 +77,15 @@ export async function bootstrapPortalMemorySessionServices({
|
||||
createAgentCodeRunAdminConfigServiceFn(pool, { env });
|
||||
const wechatScheduleLlmConfigService =
|
||||
createWechatScheduleLlmConfigServiceFn(pool);
|
||||
const wechatIntentRouterConfigService =
|
||||
createWechatIntentRouterConfigServiceFn(pool, { env });
|
||||
const wechatIntentRouter =
|
||||
createManagedWechatIntentRouterFn({
|
||||
llmProviderService,
|
||||
configService: wechatIntentRouterConfigService,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
const getEffectiveEnv = async () => {
|
||||
const state = await memoryV2ConfigService
|
||||
@@ -140,6 +155,8 @@ export async function bootstrapPortalMemorySessionServices({
|
||||
systemDisclosurePolicyService,
|
||||
agentCodeRunPolicyService,
|
||||
wechatScheduleLlmConfigService,
|
||||
wechatIntentRouterConfigService,
|
||||
wechatIntentRouter,
|
||||
conversationMemoryService,
|
||||
memoryV2,
|
||||
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,
|
||||
llmProviderService = null,
|
||||
chatIntentRouter = null,
|
||||
wechatIntentRouter = null,
|
||||
systemDisclosurePolicyService = null,
|
||||
onPageGenerated = null,
|
||||
applySessionLlmProvider = null,
|
||||
@@ -2433,7 +2434,7 @@ export function createWechatMpService({
|
||||
};
|
||||
|
||||
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 reliabilityEnabled = isWechatMediaGrayUser(user, config.reliabilityGrayUsers);
|
||||
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);
|
||||
if (intent.msgType !== 'text' && intent.msgType !== 'voice') return wechatIntent;
|
||||
const sessionActionResult = await resolveWechatSessionAction(intent.agentText, {
|
||||
@@ -3080,7 +3081,26 @@ export function createWechatMpService({
|
||||
if (sessionActionResult.action === 'ignore_previous') {
|
||||
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 = {}) => {
|
||||
|
||||
Reference in New Issue
Block a user