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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user