feat(h5): add LLM intent router admin controls and shadow verification.

Expose shadow/canary router policy in ops admin, add FAQ rule fast-path,
and tighten router defaults (1200ms timeout, 0.65 confidence).
Includes verify-h5-llm-router-shadow for production canary rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-01 16:48:01 +08:00
parent 005612029f
commit 43bc8bbc2b
14 changed files with 1391 additions and 80 deletions
+2
View File
@@ -15,6 +15,7 @@ import { AgentCodeRunPage } from './pages/admin/AgentCodeRunPage';
import { WechatPage } from './pages/admin/WechatPage';
import { SystemPolicyPage } from './pages/admin/SystemPolicyPage';
import { OrchestratorPage } from './pages/admin/OrchestratorPage';
import { LlmProvidersPage } from './pages/admin/LlmProvidersPage';
export function App() {
return (
@@ -50,6 +51,7 @@ export function App() {
<Route path="wechat" element={<WechatPage />} />
<Route path="policy" element={<SystemPolicyPage />} />
<Route path="orchestrator" element={<OrchestratorPage />} />
<Route path="llm" element={<LlmProvidersPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
+78
View File
@@ -815,3 +815,81 @@ export async function fetchAgentCodeRunHistory(limit = 50) {
`/admin-api/agent-code-run/runs?limit=${limit}`,
);
}
// ─── Unified LLM Providers ────────────────────────────────────────────────────
export type LlmProviderKeyRow = {
id: string;
providerId: string;
providerKind: 'builtin' | 'custom';
providerLabel: string;
name: string;
defaultModel: string;
models: string[];
apiUrl: string | null;
status: 'active' | 'disabled';
isSelected: boolean;
apiKeyMasked: string;
};
export type LlmGlobalSettings = {
keyId: string | null;
keyName: string | null;
providerLabel: string | null;
globalModel: string | null;
availableModels: string[];
};
export type ChatIntentRouterAdminConfig = {
enabled?: boolean;
shadowMode?: boolean;
canaryUserIds?: string;
modelProviderKeyId?: string;
model?: string;
modelApiType?: string;
minConfidence?: string;
memoryResolveEnabled?: boolean;
memoryResolveLimit?: string;
timeoutMs?: string;
fallbackRoute?: string;
};
export type MemoryV2AdminConfigState = {
config: {
chatIntentRouter?: ChatIntentRouterAdminConfig;
[key: string]: unknown;
};
updatedAt: number | null;
updatedBy: string | null;
};
export type MemoryV2RuntimeState = {
source: string;
updatedAt: number | null;
updatedBy: string | null;
fingerprint?: string;
overrides: Record<string, string>;
};
export async function fetchLlmProviderKeys() {
return adminFetch<{ keys: LlmProviderKeyRow[] }>('/admin-api/llm-providers/keys');
}
export async function fetchLlmGlobalSettings() {
return adminFetch<{ global: LlmGlobalSettings }>('/admin-api/llm-providers/global');
}
export async function fetchMemoryV2Config() {
return adminFetch<MemoryV2AdminConfigState>('/admin-api/memory-v2/config');
}
export async function patchMemoryV2Config(patch: Record<string, unknown>) {
return adminFetch<MemoryV2AdminConfigState>('/admin-api/memory-v2/config', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
export async function fetchMemoryV2Runtime() {
return adminFetch<MemoryV2RuntimeState>('/admin-api/memory-v2/runtime');
}
+1
View File
@@ -8,6 +8,7 @@ const links = [
{ to: '/admin/agent-code-run', label: 'Code Run' },
{ to: '/admin/policy', label: '策略中心' },
{ to: '/admin/orchestrator', label: '任务编排' },
{ to: '/admin/llm', label: '统一大模型' },
];
export function AdminLayout() {
+367
View File
@@ -0,0 +1,367 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
fetchLlmGlobalSettings,
fetchLlmProviderKeys,
fetchMemoryV2Config,
fetchMemoryV2Runtime,
patchMemoryV2Config,
type ChatIntentRouterAdminConfig,
type LlmGlobalSettings,
type LlmProviderKeyRow,
type MemoryV2RuntimeState,
} from '../../api/admin';
type RouterForm = {
enabled: boolean;
shadowMode: boolean;
modelProviderKeyId: string;
model: string;
canaryUserIds: string;
};
function formatTime(value: number | null | undefined) {
if (!value) return '—';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function routerConfigToForm(
config: ChatIntentRouterAdminConfig | undefined,
keys: LlmProviderKeyRow[],
): RouterForm {
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: Boolean(config?.shadowMode),
modelProviderKeyId: keyId,
model,
canaryUserIds: String(config?.canaryUserIds ?? '').trim(),
};
}
function formToPatch(form: RouterForm) {
return {
chatIntentRouter: {
enabled: form.enabled,
shadowMode: form.shadowMode,
modelProviderKeyId: form.modelProviderKeyId || '',
model: form.model || '',
canaryUserIds: form.canaryUserIds,
},
};
}
function ToggleRow({
label,
hint,
checked,
onChange,
disabled,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (next: boolean) => void;
disabled?: boolean;
}) {
return (
<label style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'start' }}>
<span>
<strong>{label}</strong>
{hint ? <p style={{ margin: '4px 0 0', color: '#68716c', fontSize: 13 }}>{hint}</p> : null}
</span>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
style={{ width: 'auto', marginTop: 4 }}
/>
</label>
);
}
function runtimeMode(overrides: Record<string, string>) {
const enabled = ['1', 'true', 'yes', 'on'].includes(String(overrides.MEMIND_CHAT_LLM_ROUTER_ENABLED ?? '').toLowerCase());
const shadow = ['1', 'true', 'yes', 'on'].includes(String(overrides.MEMIND_CHAT_LLM_ROUTER_SHADOW ?? '').toLowerCase());
if (!enabled) return '关闭(仅规则路由)';
if (shadow) return 'Shadow 观测(行为不变)';
return '已激活(规则未命中时走 LLM 路由)';
}
export function LlmProvidersPage() {
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
const [globalSettings, setGlobalSettings] = useState<LlmGlobalSettings | null>(null);
const [form, setForm] = useState<RouterForm | null>(null);
const [savedForm, setSavedForm] = useState<RouterForm | null>(null);
const [runtime, setRuntime] = useState<MemoryV2RuntimeState | null>(null);
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const activeKeys = useMemo(
() => keys.filter((item) => item.status === 'active'),
[keys],
);
const selectedRouterKey = useMemo(
() => activeKeys.find((item) => item.id === form?.modelProviderKeyId) ?? null,
[activeKeys, form?.modelProviderKeyId],
);
const modelOptions = useMemo(
() => selectedRouterKey?.models ?? [],
[selectedRouterKey],
);
const dirty = useMemo(() => {
if (!form || !savedForm) return false;
return JSON.stringify(form) !== JSON.stringify(savedForm);
}, [form, savedForm]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [keysResult, globalResult, configResult, runtimeResult] = await Promise.all([
fetchLlmProviderKeys(),
fetchLlmGlobalSettings(),
fetchMemoryV2Config(),
fetchMemoryV2Runtime(),
]);
const nextKeys = keysResult.keys ?? [];
const nextForm = routerConfigToForm(configResult.config?.chatIntentRouter, nextKeys);
setKeys(nextKeys);
setGlobalSettings(globalResult.global ?? null);
setForm(nextForm);
setSavedForm(nextForm);
setRuntime(runtimeResult);
setUpdatedAt(configResult.updatedAt ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const updateForm = (patch: Partial<RouterForm>) => {
setForm((current) => {
if (!current) return current;
const next = { ...current, ...patch };
if ('modelProviderKeyId' in patch) {
const key = activeKeys.find((item) => item.id === next.modelProviderKeyId);
const models = key?.models ?? [];
if (!models.includes(next.model)) {
next.model = key?.defaultModel ?? models[0] ?? '';
}
}
return next;
});
};
const save = async () => {
if (!form) return;
setSaving(true);
setError(null);
setNotice(null);
try {
const result = await patchMemoryV2Config(formToPatch(form));
const nextForm = routerConfigToForm(result.config?.chatIntentRouter, keys);
setForm(nextForm);
setSavedForm(nextForm);
setUpdatedAt(result.updatedAt ?? null);
const runtimeResult = await fetchMemoryV2Runtime();
setRuntime(runtimeResult);
setNotice('已保存。Portal 将在下一次 H5 路由请求时热加载新配置,无需重启。');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
if (loading) return <p></p>;
if (error && !form) return <p className="alert">{error}</p>;
if (!form) return <p className="alert"></p>;
return (
<div className="grid">
<div className="card">
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: '#68716c', marginTop: 0 }}>
LLM H5
</p>
{globalSettings ? (
<p style={{ marginBottom: 0 }}>
{' '}
<strong>{globalSettings.keyName ?? '(未选择密钥)'}</strong>
{' / '}
<strong>{globalSettings.globalModel ?? '(未设置模型)'}</strong>
</p>
) : null}
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}></h3>
{activeKeys.length === 0 ? (
<p style={{ color: '#68716c' }}> Provider Key</p>
) : (
<table className="table">
<thead>
<tr>
<th></th>
<th>Provider</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{activeKeys.map((item) => (
<tr key={item.id}>
<td>
{item.name}
{item.isSelected ? <span style={{ marginLeft: 8, color: '#2f7d32' }}></span> : null}
</td>
<td>{item.providerLabel}</td>
<td>{item.defaultModel}</td>
<td>{item.status}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>H5 LLM Router</h3>
<p style={{ color: '#68716c', marginTop: 0 }}>
LLM Shadow `[chat-llm-router-shadow]` `intent_routed.llmShadow`
</p>
<div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
<ToggleRow
label="启用 H5 LLM 路由"
hint="关闭时与现网一致:规则未命中直接 fallback 到 Goose。"
checked={form.enabled}
onChange={(enabled) => updateForm({ enabled })}
/>
<ToggleRow
label="Shadow 观测模式"
hint="开启后仍走 fallback Agent,仅记录 LLM 建议路由,适合灰度观测。"
checked={form.shadowMode}
disabled={!form.enabled}
onChange={(shadowMode) => updateForm({ shadowMode })}
/>
<label style={{ display: 'grid', gap: 6 }}>
<strong></strong>
<span style={{ color: '#68716c', fontSize: 13 }}>使 env </span>
<select
value={form.modelProviderKeyId}
disabled={!form.enabled}
onChange={(event) => updateForm({ modelProviderKeyId: event.target.value })}
>
<option value=""></option>
{activeKeys.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
{' '}
(
{item.providerLabel}
)
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: 6 }}>
<strong></strong>
<select
value={form.model}
disabled={!form.enabled || !form.modelProviderKeyId || modelOptions.length === 0}
onChange={(event) => updateForm({ model: event.target.value })}
>
{modelOptions.length === 0 ? (
<option value=""></option>
) : (
modelOptions.map((model) => (
<option key={model} value={model}>{model}</option>
))
)}
</select>
</label>
<label style={{ display: 'grid', gap: 6 }}>
<strong> ID</strong>
<span style={{ color: '#68716c', fontSize: 13 }}>
LLM
</span>
<input
value={form.canaryUserIds}
disabled={!form.enabled}
onChange={(event) => updateForm({ canaryUserIds: event.target.value })}
placeholder="user-id-1, user-id-2"
/>
</label>
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 20, alignItems: 'center', flexWrap: 'wrap' }}>
<button className="btn" type="button" disabled={!dirty || saving} onClick={() => void save()}>
{saving ? '保存中…' : '保存 H5 路由配置'}
</button>
<button className="btn secondary" type="button" disabled={loading || saving} onClick={() => void load()}>
</button>
<span style={{ color: '#68716c', fontSize: 13 }}>
{formatTime(updatedAt)}
</span>
</div>
{notice ? <p style={{ color: '#2f7d32', marginBottom: 0 }}>{notice}</p> : null}
{error ? <p className="alert" style={{ marginBottom: 0 }}>{error}</p> : null}
</div>
{runtime ? (
<div className="card">
<h3 style={{ marginTop: 0 }}></h3>
<p style={{ marginTop: 0 }}>
<strong>{runtime.source}</strong>
{' · '}
<strong>{runtimeMode(runtime.overrides ?? {})}</strong>
</p>
<div style={{ display: 'grid', gap: 8, fontSize: 14 }}>
<div>
<code>{runtime.overrides.MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID || '(未设置)'}</code>
</div>
<div>
<code>{runtime.overrides.MEMIND_CHAT_ROUTER_MODEL || '(未设置)'}</code>
</div>
<div>
<code>{runtime.overrides.MEMIND_CHAT_ROUTER_CANARY_USER_IDS || '(全部用户)'}</code>
</div>
<div style={{ color: '#68716c' }}>
fingerprint
{runtime.fingerprint ?? '—'}
</div>
</div>
</div>
) : null}
</div>
);
}
+4 -5
View File
@@ -68,11 +68,10 @@ export function SummaryPage() {
<Link className="btn secondary" to="/admin/agent-code-run" style={{ textAlign: 'center', textDecoration: 'none' }}>
Code Run
</Link>
<Link
className="btn secondary"
to="/admin/users"
style={{ textAlign: 'center', textDecoration: 'none' }}
>
<Link className="btn secondary" to="/admin/llm" style={{ textAlign: 'center', textDecoration: 'none' }}>
/ H5
</Link>
<Link className="btn secondary" to="/admin/users" style={{ textAlign: 'center', textDecoration: 'none' }}>
</Link>
</div>