Compare commits

..

2 Commits

Author SHA1 Message Date
john 1af5b1dd82 feat(wechat): add admin UI for WeChat LLM intent router toggles.
Expose memindadm controls for the service-account-only intent refinement layer so operators can enable, shadow, and canary the router without touching H5 chatIntentRouter settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 21:16:35 +08:00
john 9933bfb89c merge: fix billing usage summary and user balance display 2026-07-31 17:30:27 +08:00
3 changed files with 291 additions and 1 deletions
+244 -1
View File
@@ -1,24 +1,31 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
cancelWechatDigest,
clearWechatRoute,
createWechatWebNotification,
getWechatAdminSummary,
getWechatIntentRouterConfig,
getWechatIntentRouterRuntime,
listAdminUsers,
listLlmProviderKeys,
listWechatBindings,
listWechatDeliveries,
listWechatDigests,
listWechatMessages,
listWechatWebNotifications,
patchWechatIntentRouterConfig,
resumeWechatDigest,
updateWechatScheduleLlmConfig,
} from '../../api/client';
import type {
AdminUserRow,
LlmProviderKeyRow,
WechatAdminSummary,
WechatBinding,
WechatDeliveryLog,
WechatDigestSubscription,
WechatIntentRouterAdminConfig,
WechatIntentRouterRuntimeState,
WechatMessage,
WechatWebNotification,
} from '../../types';
@@ -70,6 +77,49 @@ function dateLabel(value?: number | null) {
return value ? formatTime(value) : '—';
}
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';
}
export function WechatPage() {
const [summary, setSummary] = useState<WechatAdminSummary | null>(null);
const [bindings, setBindings] = useState<WechatBinding[]>([]);
@@ -91,12 +141,27 @@ export function WechatPage() {
const [notifyType, setNotifyType] = useState('manual');
const [notifyChannels, setNotifyChannels] = useState<Array<'web' | 'wechat'>>(['web']);
const [scheduleLlmEnabled, setScheduleLlmEnabled] = useState(false);
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 [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const safe = safeSummary(summary);
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 = useCallback(async () => {
setLoading(true);
setError(null);
@@ -109,6 +174,9 @@ export function WechatPage() {
nextDeliveries,
nextNotifications,
nextUsers,
nextLlmKeys,
nextIntentConfig,
nextIntentRuntime,
] =
await Promise.all([
getWechatAdminSummary(),
@@ -118,6 +186,9 @@ export function WechatPage() {
listWechatDeliveries({ status: deliveryStatus || undefined, limit: 80 }),
listWechatWebNotifications({ status: notificationStatus || undefined, limit: 80 }),
listAdminUsers({ page: 1, pageSize: 200, status: 'active' }),
listLlmProviderKeys().catch(() => [] as LlmProviderKeyRow[]),
getWechatIntentRouterConfig().catch(() => null),
getWechatIntentRouterRuntime().catch(() => null),
]);
setSummary(nextSummary);
setScheduleLlmEnabled(nextSummary.config.scheduleLlmEnabled ?? false);
@@ -128,6 +199,13 @@ export function WechatPage() {
setWebNotifications(nextNotifications);
setUsers(nextUsers.items);
setNotifyUserId((current) => current || nextUsers.items[0]?.id || '');
setLlmKeys(nextLlmKeys);
if (nextIntentConfig) {
const nextForm = intentRouterToForm(nextIntentConfig, nextLlmKeys);
setIntentForm(nextForm);
setSavedIntentForm(nextForm);
}
setIntentRuntime(nextIntentRuntime);
} catch (err) {
setError(err instanceof Error ? err.message : '加载服务号管理失败');
} finally {
@@ -235,6 +313,36 @@ 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, llmKeys);
setIntentForm(nextForm);
setSavedIntentForm(nextForm);
setIntentRuntime(await getWechatIntentRouterRuntime().catch(() => null));
setNotice('微信 LLM 意图路由配置已保存。Portal 会在下次请求时自动热加载。');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setIntentSaving(false);
}
};
return (
<div className="admin-page">
<div className="admin-page-head">
@@ -316,6 +424,141 @@ export function WechatPage() {
</section>
)}
{intentForm ? (
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2> LLM </h2>
<p className="muted" style={{ marginTop: 6 }}>
H5 Router chat.general LLM page.generate vs chat.general
</p>
</div>
<button
type="button"
className="ghost-btn"
onClick={() => void handleSaveIntentRouter()}
disabled={busy || intentSaving || !intentDirty}
>
{intentSaving ? '保存中…' : intentDirty ? '保存配置' : '已保存'}
</button>
</div>
<div className="admin-form" style={{ marginTop: 16 }}>
<label>
<input
type="checkbox"
checked={intentForm.enabled}
disabled={busy || intentSaving}
onChange={(event) =>
setIntentForm((current) => current && { ...current, enabled: event.target.checked })
}
/>{' '}
LLM
</label>
<label>
<input
type="checkbox"
checked={intentForm.shadowMode}
disabled={busy || intentSaving || !intentForm.enabled}
onChange={(event) =>
setIntentForm((current) => current && { ...current, shadowMode: event.target.checked })
}
/>{' '}
Shadow
</label>
<label>
LLM Provider Key
<select
value={intentForm.modelProviderKeyId}
disabled={busy || intentSaving}
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>
</label>
<label>
<input
list="wechat-intent-models"
value={intentForm.model}
disabled={busy || intentSaving}
placeholder={selectedIntentKey?.defaultModel || 'deepseek-v4-pro'}
onChange={(event) =>
setIntentForm((current) => current && { ...current, model: event.target.value })
}
/>
<datalist id="wechat-intent-models">
{(selectedIntentKey?.models ?? []).map((model) => (
<option key={model} value={model} />
))}
</datalist>
</label>
<label>
<input
type="number"
min={0}
max={1}
step={0.05}
value={intentForm.minConfidence}
disabled={busy || intentSaving}
onChange={(event) =>
setIntentForm((current) => current && { ...current, minConfidence: event.target.value })
}
/>
</label>
<label>
(ms)
<input
type="number"
min={500}
max={30000}
step={100}
value={intentForm.timeoutMs}
disabled={busy || intentSaving}
onChange={(event) =>
setIntentForm((current) => current && { ...current, timeoutMs: event.target.value })
}
/>
</label>
<label>
Canary OpenID=
<textarea
value={intentForm.canaryOpenids}
disabled={busy || intentSaving}
rows={3}
placeholder="oXXXX..."
onChange={(event) =>
setIntentForm((current) => current && { ...current, canaryOpenids: event.target.value })
}
/>
</label>
</div>
{intentRuntime ? (
<p className="muted" style={{ marginTop: 12 }}>
{intentRouterRuntimeMode(intentRuntime.overrides)} · {intentRuntime.source}
{intentRuntime.updatedAt ? ` · 更新 ${dateLabel(intentRuntime.updatedAt)}` : ''}
</p>
) : null}
</section>
) : null}
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
+22
View File
@@ -51,6 +51,9 @@ import type {
WechatDeliveryLog,
WechatDigestSubscription,
WechatScheduleLlmConfig,
WechatIntentRouterAdminConfig,
WechatIntentRouterConfigState,
WechatIntentRouterRuntimeState,
WechatMessage,
WechatWebNotification,
MindSearchConfig,
@@ -446,6 +449,25 @@ export async function updateWechatScheduleLlmConfig(
});
}
export async function getWechatIntentRouterConfig(): Promise<WechatIntentRouterAdminConfig> {
const result = await portalFetch<WechatIntentRouterConfigState>('/admin-api/wechat/intent-router/config');
return result.config;
}
export async function patchWechatIntentRouterConfig(
patch: Partial<Omit<WechatIntentRouterAdminConfig, 'updatedAt' | 'updatedBy'>>,
): Promise<WechatIntentRouterAdminConfig> {
const result = await portalFetch<WechatIntentRouterConfigState>('/admin-api/wechat/intent-router/config', {
method: 'PATCH',
body: JSON.stringify(patch),
});
return result.config;
}
export async function getWechatIntentRouterRuntime(): Promise<WechatIntentRouterRuntimeState> {
return portalFetch<WechatIntentRouterRuntimeState>('/admin-api/wechat/intent-router/runtime');
}
export async function getAssetGatewayConfig(): Promise<AssetGatewayConfig> {
return portalFetch('/admin-api/asset-gateway/config');
}
+25
View File
@@ -317,6 +317,31 @@ export type WechatScheduleLlmConfig = {
updatedBy: string | null;
};
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 type MindSpaceAdminConfig = {
publicPageLimit: number;
analytics: {