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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user