Compare commits

...

2 Commits

Author SHA1 Message Date
john dac06753ce fix(wechat): stop relay bootstrap from hijacking LLM provider selection.
Memind CI / Test, build, and release guards (push) Failing after 2s
Schedule LLM now binds an explicit provider key and fails closed on network errors so dead relay endpoints cannot block ordinary chat; relay bootstrap is opt-in only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 21:44:08 +08:00
john 122a25649b fix(ops): align Memory V2 Runtime Control badge with actual config flags.
The admin page treated every section as enabled via section.enabled, but runtimeControl has no such field; derive status from agent resolve/injection/lifecycle flags and proxy runtime status from Portal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 21:44:01 +08:00
15 changed files with 1755 additions and 175 deletions
+7 -2
View File
@@ -178,7 +178,12 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# MEMIND_WECHAT_INTENT_TIMEOUT_MS=4000
# MEMIND_WECHAT_INTENT_CANARY_OPENIDS=
# H5 Session stream replaydocs/h5-session-architecture-20260706.md Patch 4c
# 微信待办/提醒 LLM 意图(独立于 Intent Router;须绑定 DeepSeek 等直连 key,勿依赖全局 selected
# H5_WECHAT_SCHEDULE_LLM_ENABLED=0
# MEMIND_WECHAT_SCHEDULE_LLM_MODEL_PROVIDER_KEY_ID=
# MEMIND_WECHAT_SCHEDULE_LLM_MODEL=deepseek-v4-pro
# H5 Session stream replay
# 默认 0session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
# MEMIND_SESSION_STREAM_REPLAY=0
@@ -232,7 +237,7 @@ H5_ADMIN_PASSWORD=change-me-admin
# H5_ADMIN_WORKSPACE_ROOT=/Users/john/PycharmProjects/tkmind
# LLM Key 数据库加密(可选;默认派生自 TKMIND_SERVER__SECRET_KEY
# H5_SETTINGS_ENCRYPTION_KEY=change-me-encryption-key
# 启动时自动导入 Relay(若不存在同名配置);改用手动 Provider 时请设为 1
# Relay bootstrap 默认关闭;仅本地调试 relay 时设 H5_RELAY_BOOTSTRAP_DISABLED=0
# H5_RELAY_BOOTSTRAP_DISABLED=1
# H5_RELAY_BOOTSTRAP_NAME=Local Goose Relay DeepSeek
# H5_RELAY_BOOTSTRAP_URL=http://127.0.0.1:18300/relay/buyer/v1/chat/completions
+32 -1
View File
@@ -237,6 +237,36 @@ export function createAdminApi({
return res.json(result);
});
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
const portalBase = String(
process.env.SYSTEM_TEST_PORTAL_BASE_URL
?? `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`,
).replace(/\/$/, '');
try {
const upstream = await fetch(`${portalBase}/api/runtime/status`, {
headers: { accept: 'application/json' },
});
const payload = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return res.status(upstream.status).json({
ok: false,
message: payload?.message ?? '读取 Portal Memory V2 运行时状态失败',
memory: null,
});
}
return res.json({
ok: Boolean(payload?.ok ?? true),
memory: payload?.memory ?? null,
});
} catch (err) {
return res.status(502).json({
ok: false,
message: err instanceof Error ? err.message : '读取 Portal Memory V2 运行时状态失败',
memory: null,
});
}
});
adminApi.get('/memory-v2/metrics', requireAdmin, async (req, res) => {
if (!memoryV2AdminOpsService?.getProductMetrics) {
return res.status(503).json({ message: 'Memory V2 指标服务未启用' });
@@ -265,7 +295,8 @@ export function createAdminApi({
const limit = Number(req.query?.limit ?? 50);
const offset = Number(req.query?.offset ?? 0);
const items = await memoryV2AdminOpsService.listCandidates({ status, userId, limit, offset });
return res.json({ items, status, limit, offset });
const counts = await memoryV2AdminOpsService.countCandidatesByStatus();
return res.json({ items, status, limit, offset, counts });
} catch (err) {
return res.status(400).json({
message: err instanceof Error ? err.message : '读取候选记忆失败',
-3
View File
@@ -2304,9 +2304,6 @@ export function createLlmProviderService(
relayProvider: RELAY_BOOTSTRAP.relayProvider,
});
if (!result.ok) return result;
if (result.key && !result.key.isSelected) {
await this.selectKey(result.key.id);
}
return { ok: true, created: true, key: result.key };
},
};
+34 -1
View File
@@ -913,6 +913,29 @@ export type MemoryV2RuntimeState = {
overrides: Record<string, string>;
};
export type MemoryV2PersonalMemoryStatus = {
enabled?: boolean;
requestedMode?: string;
effectiveMode?: string;
autoReviewEnabled?: boolean;
pendingCandidates?: number;
health?: {
state?: string;
accepted?: number;
autoReviewed?: number;
rejected?: number;
deduped?: number;
};
};
export type MemoryV2StatusResponse = {
ok?: boolean;
memory?: {
runtimeControl?: Record<string, unknown>;
personalMemory?: MemoryV2PersonalMemoryStatus;
} | null;
};
export async function fetchLlmProviderKeys() {
return adminFetch<{ keys: LlmProviderKeyRow[] }>('/admin-api/llm-providers/keys');
}
@@ -936,6 +959,10 @@ export async function fetchMemoryV2Runtime() {
return adminFetch<MemoryV2RuntimeState>('/admin-api/memory-v2/runtime');
}
export async function fetchMemoryV2Status() {
return adminFetch<MemoryV2StatusResponse>('/admin-api/memory-v2/status');
}
export type MemoryV2ProductMetrics = {
window: { since: string; sinceMs: number; untilMs: number };
userId: string | null;
@@ -996,7 +1023,13 @@ export async function fetchMemoryV2Candidates(params?: {
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
const suffix = query.toString() ? `?${query.toString()}` : '';
return adminFetch<{ items: MemoryV2CandidateRow[]; status: string; limit: number; offset: number }>(
return adminFetch<{
items: MemoryV2CandidateRow[];
status: string;
limit: number;
offset: number;
counts?: Record<string, number>;
}>(
`/admin-api/memory-v2/candidates${suffix}`,
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ const links = [
{ to: '/admin/policy', label: '策略中心' },
{ to: '/admin/orchestrator', label: '任务编排' },
{ to: '/admin/llm', label: '统一大模型' },
{ to: '/admin/memory-v2', label: 'Memory V2' },
{ to: '/admin/memory-v2', label: 'Memory V2 配置' },
{ to: '/admin/goal-runs', label: 'Goal Run' },
];
+761 -132
View File
@@ -1,191 +1,820 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
import {
fetchLlmGlobalSettings,
fetchLlmProviderKeys,
fetchMemoryV2Candidates,
fetchMemoryV2Metrics,
fetchMemoryV2Config,
fetchMemoryV2Status,
patchMemoryV2Config,
reviewMemoryV2Candidate,
runMemoryV2CandidateAutoReview,
type ChatIntentRouterAdminConfig,
type LlmGlobalSettings,
type LlmProviderKeyRow,
type MemoryV2AdminConfigState,
type MemoryV2CandidateRow,
type MemoryV2MetricsResponse,
type MemoryV2StatusResponse,
} from '../../api/admin';
import {
createDefaultMemoryV2Config,
isMemoryV2SectionEnabled,
MEMORY_V2_BACKEND_ICONS,
MEMORY_V2_BACKEND_SECTIONS,
MEMORY_V2_CAPABILITY_SECTIONS,
MEMORY_V2_MODEL_API_OPTIONS,
MEMORY_V2_MODEL_BACKENDS,
MEMORY_V2_SECTION_COLORS,
mergeMemoryV2Config,
type MemoryV2ConfigShape,
type MemoryV2FieldSpec,
type MemoryV2SectionSpec,
} from './memory-v2-config';
import './memory-v2.css';
function formatTime(value: number | null | undefined) {
type SavingKey = string | 'global' | 'chatIntentRouter' | 'reload' | null;
function formatSavedTime(value: number | null | undefined) {
if (!value) return '未保存过数据库配置';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function formatDateTime(value: number | null | undefined) {
if (!value) return '—';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function formatPercent(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '—';
return `${(value * 100).toFixed(1)}%`;
function uniqueModels(values: Array<string | null | undefined>) {
return [...new Set(values.map((item) => String(item ?? '').trim()).filter(Boolean))];
}
function providerLabel(key: LlmProviderKeyRow) {
return `${key.name} · ${key.providerLabel}`;
}
function resolveProviderKey(keys: LlmProviderKeyRow[], keyId: string) {
const normalized = String(keyId ?? '').trim();
return normalized ? keys.find((item) => item.id === normalized) ?? null : null;
}
function modelOptionsForKey(key: LlmProviderKeyRow | null, global: LlmGlobalSettings | null) {
return uniqueModels([
...(key?.models ?? []),
key?.defaultModel,
global?.globalModel,
...(global?.availableModels ?? []),
]);
}
function describeRouterModel(
router: ChatIntentRouterAdminConfig,
keys: LlmProviderKeyRow[],
global: LlmGlobalSettings | null,
) {
const selectedKey = resolveProviderKey(keys, String(router.modelProviderKeyId ?? ''));
const model = String(router.model ?? '').trim()
|| selectedKey?.defaultModel
|| global?.globalModel
|| 'provider 默认模型';
const source = selectedKey
? providerLabel(selectedKey)
: global?.keyName
? `${global.keyName} · ${global.providerLabel ?? '统一模型中心'}`
: '统一模型中心默认';
const apiType = String(router.modelApiType ?? '').trim() || '未指定 API 类型';
return `${source} / ${model} / ${apiType}`;
}
function SectionFields({
section,
values,
onBooleanChange,
onValueChange,
}: {
section: MemoryV2SectionSpec;
values: Record<string, unknown>;
onBooleanChange: (field: string) => (event: ChangeEvent<HTMLInputElement>) => void;
onValueChange: (field: string) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void;
}) {
const renderField = (field: MemoryV2FieldSpec) => {
if (field.type === 'boolean') {
return (
<label key={field.key} className="inline-check">
<input
type="checkbox"
checked={Boolean(values[field.key])}
onChange={onBooleanChange(field.key)}
/>
{field.label}
</label>
);
}
if (field.type === 'select') {
return (
<label key={field.key}>
<span>{field.label}</span>
<select
value={String(values[field.key] ?? '')}
onChange={onValueChange(field.key)}
>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
);
}
return (
<label key={field.key}>
<span>{field.label}</span>
<input
type={field.type === 'number' ? 'number' : 'text'}
value={String(values[field.key] ?? '')}
onChange={onValueChange(field.key)}
/>
</label>
);
};
return (
<div className="admin-form-grid memory-v2-fields">
{section.fields.map(renderField)}
</div>
);
}
export function MemoryV2Page() {
const [metrics, setMetrics] = useState<MemoryV2MetricsResponse | null>(null);
const [savedState, setSavedState] = useState<MemoryV2AdminConfigState | null>(null);
const [draft, setDraft] = useState<MemoryV2ConfigShape>(() => createDefaultMemoryV2Config());
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
const [globalSettings, setGlobalSettings] = useState<LlmGlobalSettings | null>(null);
const [runtimeStatus, setRuntimeStatus] = useState<MemoryV2StatusResponse | null>(null);
const [candidates, setCandidates] = useState<MemoryV2CandidateRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [candidateCounts, setCandidateCounts] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<SavingKey>(null);
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [autoReviewBusy, setAutoReviewBusy] = useState(false);
const [autoReviewNote, setAutoReviewNote] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [candidateError, setCandidateError] = useState<string | null>(null);
const [expandedBackends, setExpandedBackends] = useState<Record<string, boolean>>({});
const reload = useCallback(async () => {
const savedConfig = useMemo(
() => mergeMemoryV2Config(savedState?.config as Record<string, unknown> | undefined),
[savedState],
);
const enabledBackendCount = useMemo(
() => MEMORY_V2_BACKEND_SECTIONS.filter((item) => Boolean(savedConfig[item.key]?.enabled)).length,
[savedConfig],
);
const load = useCallback(async () => {
setLoading(true);
setSaving('reload');
setError(null);
setNotice(null);
setCandidateError(null);
try {
const [metricsRes, candidatesRes] = await Promise.all([
fetchMemoryV2Metrics({ since: '7d' }),
fetchMemoryV2Candidates({ status: 'candidate', limit: 50 }),
const [configResult, keysResult, globalResult, statusResult, candidatesResult] = await Promise.all([
fetchMemoryV2Config(),
fetchLlmProviderKeys(),
fetchLlmGlobalSettings(),
fetchMemoryV2Status().catch(() => null),
fetchMemoryV2Candidates({ status: 'candidate', limit: 50 }).catch((err: unknown) => {
setCandidateError(err instanceof Error ? err.message : '候选记忆加载失败');
return { items: [], counts: {} as Record<string, number> };
}),
]);
setMetrics(metricsRes);
setCandidates(candidatesRes.items);
setSavedState(configResult);
setDraft(mergeMemoryV2Config(configResult.config as Record<string, unknown> | undefined));
setKeys((keysResult.keys ?? []).filter((item) => item.status === 'active'));
setGlobalSettings(globalResult.global ?? null);
setRuntimeStatus(statusResult);
setCandidates(candidatesResult.items ?? []);
setCandidateCounts(candidatesResult.counts ?? {});
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
setError(err instanceof Error ? err.message : '加载 Memory V2 配置失败');
} finally {
setLoading(false);
setSaving(null);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
void load();
}, [load]);
const runAutoReview = async () => {
setAutoReviewBusy(true);
setAutoReviewNote(null);
const applySavedConfig = (result: MemoryV2AdminConfigState, message: string) => {
setSavedState(result);
setDraft(mergeMemoryV2Config(result.config as Record<string, unknown> | undefined));
setNotice(message);
};
const savePatch = async (patch: Record<string, unknown>, key: SavingKey, message: string) => {
setSaving(key);
setError(null);
setNotice(null);
try {
const result = await runMemoryV2CandidateAutoReview({ limit: 200 });
setAutoReviewNote(
`自动审核完成:接受 ${result.accepted},拒绝 ${result.rejected},仍待人工 ${result.pending}`,
);
await reload();
const result = await patchMemoryV2Config(patch);
applySavedConfig(result, message);
const statusResult = await fetchMemoryV2Status().catch(() => null);
setRuntimeStatus(statusResult);
} catch (err) {
setError(err instanceof Error ? err.message : '自动审核失败');
setError(err instanceof Error ? err.message : '保存 Memory V2 配置失败');
} finally {
setAutoReviewBusy(false);
setSaving(null);
}
};
const review = async (id: string, status: 'accepted' | 'rejected') => {
const updateGlobalBoolean = (field: string) => (event: ChangeEvent<HTMLInputElement>) => {
setDraft((current) => ({
...current,
global: { ...current.global, [field]: event.target.checked },
}));
};
const updateGlobalValue = (field: string) => (event: ChangeEvent<HTMLSelectElement>) => {
setDraft((current) => ({
...current,
global: { ...current.global, [field]: event.target.value },
}));
};
const updateRouterBoolean = (field: string) => (event: ChangeEvent<HTMLInputElement>) => {
setDraft((current) => ({
...current,
chatIntentRouter: { ...current.chatIntentRouter, [field]: event.target.checked },
}));
};
const updateRouterValue = (field: string) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setDraft((current) => ({
...current,
chatIntentRouter: { ...current.chatIntentRouter, [field]: event.target.value },
}));
};
const updateSectionBoolean = (sectionKey: string, field: string) => (
event: ChangeEvent<HTMLInputElement>,
) => {
setDraft((current) => ({
...current,
[sectionKey]: { ...current[sectionKey], [field]: event.target.checked },
}));
};
const updateSectionValue = (sectionKey: string, field: string) => (
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
setDraft((current) => ({
...current,
[sectionKey]: { ...current[sectionKey], [field]: event.target.value },
}));
};
const updateBackendBoolean = (sectionKey: string) => (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.checked) {
setExpandedBackends((current) => ({ ...current, [sectionKey]: false }));
}
setDraft((current) => ({
...current,
[sectionKey]: { ...current[sectionKey], enabled: event.target.checked },
}));
};
const updateBackendValue = (sectionKey: string, field: string) => (
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
setDraft((current) => ({
...current,
[sectionKey]: { ...current[sectionKey], [field]: event.target.value },
}));
};
const reviewCandidate = async (id: string, action: 'accept' | 'reject') => {
setReviewingId(id);
setCandidateError(null);
try {
await reviewMemoryV2Candidate(id, status);
await reload();
await reviewMemoryV2Candidate(id, action === 'accept' ? 'accepted' : 'rejected');
const candidatesResult = await fetchMemoryV2Candidates({ status: 'candidate', limit: 50 });
setCandidates(candidatesResult.items ?? []);
setCandidateCounts(candidatesResult.counts ?? {});
setNotice(action === 'accept' ? '候选记忆已接纳。' : '候选记忆已拒绝。');
} catch (err) {
setError(err instanceof Error ? err.message : '审核失败');
setCandidateError(err instanceof Error ? err.message : '候选记忆处理失败');
} finally {
setReviewingId(null);
}
};
if (loading && !metrics) return <p></p>;
const routerDraft = draft.chatIntentRouter as ChatIntentRouterAdminConfig;
const routerSaved = savedConfig.chatIntentRouter as ChatIntentRouterAdminConfig;
const routerKey = resolveProviderKey(keys, String(routerDraft.modelProviderKeyId ?? ''));
const routerModels = modelOptionsForKey(routerKey, globalSettings);
const personalMemory = runtimeStatus?.memory?.personalMemory;
if (loading && !savedState) return <p></p>;
return (
<div className="grid">
{error && <p className="alert">{error}</p>}
{autoReviewNote && <p className="alert">{autoReviewNote}</p>}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center' }}>
<div>
<h2 style={{ marginTop: 0, marginBottom: 4 }}>Memory V2 </h2>
<p style={{ color: '#68716c', margin: 0 }}> {metrics?.metrics.window.since ?? '7d'} shadow </p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn"
disabled={autoReviewBusy}
onClick={() => void runAutoReview()}
>
{autoReviewBusy ? '自动审核中…' : '执行自动审核'}
</button>
<button type="button" className="btn secondary" onClick={() => void reload()}>
</button>
</div>
</div>
{metrics && (
<>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: 12,
marginTop: 16,
}}
>
{Object.entries(metrics.metrics.events).map(([key, count]) => (
<div key={key}>
<p style={{ color: '#68716c', marginBottom: 4 }}>{key}</p>
<strong style={{ fontSize: 24 }}>{count}</strong>
</div>
))}
</div>
<div style={{ marginTop: 16, display: 'grid', gap: 8 }}>
<p style={{ margin: 0 }}>{formatPercent(metrics.audit.falseStoreRate)}</p>
<p style={{ margin: 0 }}>{formatPercent(metrics.audit.autoAcceptRate)}</p>
<p style={{ margin: 0 }}>resolve {formatPercent(metrics.audit.resolveHitRate)}</p>
<p style={{ margin: 0 }}>
{Object.entries(metrics.candidateCounts)
.map(([status, count]) => `${status} × ${count}`)
.join('') || '—'}
</p>
</div>
</>
)}
<div className="admin-page memory-v2-page grid">
<div className="admin-page-head">
<h2>Memory V2 </h2>
<p className="muted">
Memory V2 `memind_adm`
</p>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>{candidates.length}</h3>
<p style={{ color: '#68716c', marginTop: 0 }}>
canary //decision_signal
</p>
{candidates.length === 0 ? (
<p style={{ color: '#68716c' }}></p>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{candidates.map((item) => (
<div
key={item.id}
style={{
border: '1px solid #e6ece8',
borderRadius: 12,
padding: 12,
display: 'grid',
gap: 8,
{error ? <p className="banner banner-error">{error}</p> : null}
{notice ? <p className="banner banner-info">{notice}</p> : null}
<section className="asset-gateway-hero">
<div>
<div className="asset-kicker">MEMORY V2</div>
<h3></h3>
<p>
{draft.global.enabled ? '已启用' : '未启用'}
{' · '}
backend <strong className="mono">{String(draft.global.backend ?? 'legacy')}</strong>
{' · '}
backend {enabledBackendCount}/{MEMORY_V2_BACKEND_SECTIONS.length}
{' · '}
{formatSavedTime(savedState?.updatedAt)}
</p>
</div>
<div className="model-center-hero-actions">
<button type="button" className="ghost-btn" disabled={saving !== null} onClick={() => void load()}>
{saving === 'reload' ? '加载中…' : '重新加载'}
</button>
</div>
</section>
<h3 className="model-center-section-title"></h3>
<div className="asset-plugin-grid memory-v2-top-grid">
<section className="asset-plugin-card asset-plugin-card--violet model-center-card memory-v2-card">
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">M</div>
<div>
<h3></h3>
<p> Memory V2 Profile / Event Log / Vector fail-open </p>
</div>
<span className={`asset-status ${draft.global.enabled ? 'is-on' : ''}`}>
{draft.global.enabled ? '已启用' : '未启用'}
</span>
</div>
<div className="memory-v2-toggle-grid">
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.enabled)} onChange={updateGlobalBoolean('enabled')} />
Memory V2
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.profileEnabled)} onChange={updateGlobalBoolean('profileEnabled')} />
Profile
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.eventLogEnabled)} onChange={updateGlobalBoolean('eventLogEnabled')} />
Event Log
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.vectorEnabled)} onChange={updateGlobalBoolean('vectorEnabled')} />
Vector
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.failOpen)} onChange={updateGlobalBoolean('failOpen')} />
Memory fail-open
</label>
</div>
<div className="asset-plugin-fields">
<label>
Backend
<select value={String(draft.global.backend ?? 'legacy')} onChange={updateGlobalValue('backend')}>
<option value="legacy">legacy</option>
{MEMORY_V2_BACKEND_SECTIONS.map((item) => (
<option key={item.key} value={item.key}>{item.label}</option>
))}
</select>
</label>
</div>
{!loading ? (
<p className="model-center-card-note">
backend
<strong>{String(savedConfig.global.backend ?? 'legacy')}</strong>
</p>
) : null}
<div className="asset-plugin-footer">
<span className={`asset-status ${draft.global.enabled ? 'is-on' : ''}`}>
{draft.global.enabled ? 'Memory V2 运行中' : 'Memory V2 关闭'}
</span>
<button
type="button"
className="send-btn"
disabled={loading || saving !== null}
onClick={() => void savePatch(
{ global: draft.global },
'global',
'全局 Memory V2 开关已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。',
)}
>
{saving === 'global' ? '保存中…' : '保存全局'}
</button>
</div>
</section>
<section className="asset-plugin-card asset-plugin-card--blue model-center-card memory-v2-card">
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">R</div>
<div>
<h3> LLM </h3>
<p>退 Direct Chat / Agent LLM</p>
</div>
<label className="asset-mini-toggle" aria-label="启用前置 LLM 意图路由">
<input type="checkbox" checked={Boolean(routerDraft.enabled)} onChange={updateRouterBoolean('enabled')} />
<span>{routerDraft.enabled ? '启用' : '关闭'}</span>
</label>
</div>
<div className="admin-form-grid memory-v2-fields">
<label>
<span></span>
<select
value={String(routerDraft.modelProviderKeyId ?? '')}
onChange={(event) => {
const nextKey = resolveProviderKey(keys, event.target.value);
const models = modelOptionsForKey(nextKey, globalSettings);
setDraft((current) => ({
...current,
chatIntentRouter: {
...current.chatIntentRouter,
modelProviderKeyId: event.target.value,
model: models.includes(String(current.chatIntentRouter.model ?? ''))
? String(current.chatIntentRouter.model ?? '')
: String(nextKey?.defaultModel ?? globalSettings?.globalModel ?? ''),
},
}));
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<strong>{item.policyReason}</strong>
<span style={{ color: '#68716c' }}>{formatTime(item.createdAt)}</span>
<option value=""></option>
{keys.map((item) => (
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
))}
</select>
</label>
<label>
<span></span>
<select value={String(routerDraft.model ?? '')} onChange={updateRouterValue('model')}>
<option value="">
{routerKey ? '使用当前 Provider 默认模型' : '跟随统一模型中心默认模型'}
</option>
{routerModels.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</label>
<label>
<span></span>
<select value={String(routerDraft.modelApiType ?? 'chat')} onChange={updateRouterValue('modelApiType')}>
{MEMORY_V2_MODEL_API_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
</div>
<details className="memory-v2-advanced-details">
<summary></summary>
<div className="admin-form-grid memory-v2-fields">
<label>
<span></span>
<input type="number" min="0" max="1" step="0.05" value={String(routerDraft.minConfidence ?? '0.65')} onChange={updateRouterValue('minConfidence')} />
</label>
<label>
<span></span>
<input type="number" min="1" max="50" value={String(routerDraft.memoryResolveLimit ?? '8')} onChange={updateRouterValue('memoryResolveLimit')} />
</label>
<label>
<span> Ms</span>
<input type="number" min="0" max="30000" value={String(routerDraft.timeoutMs ?? '1500')} onChange={updateRouterValue('timeoutMs')} />
</label>
<label>
<span> / 退</span>
<select value={String(routerDraft.fallbackRoute ?? 'agent_orchestration')} onChange={updateRouterValue('fallbackRoute')}>
<option value="agent_orchestration">Agent </option>
<option value="direct_chat">Direct Chat</option>
</select>
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(routerDraft.memoryResolveEnabled)} onChange={updateRouterBoolean('memoryResolveEnabled')} />
Memory V2
</label>
</div>
</details>
<p className="model-center-card-note">
<strong>{routerSaved.enabled ? '已启用' : '未启用'}</strong>
{' · '}
<strong>{describeRouterModel(routerDraft, keys, globalSettings)}</strong>
</p>
{!keys.length ? (
<p className="model-center-card-note">
key Provider key
</p>
) : null}
<div className="asset-plugin-footer">
<span className={`asset-status ${routerDraft.enabled ? 'is-on' : ''}`}>
{routerDraft.enabled ? '路由已启用' : '路由已关闭'}
</span>
<button
type="button"
className="send-btn"
disabled={loading || saving !== null}
onClick={() => void savePatch(
{ chatIntentRouter: draft.chatIntentRouter },
'chatIntentRouter',
'前置 LLM 意图路由配置已保存。关闭时主站会退回原有 Direct Chat / Agent 判定链路。',
)}
>
{saving === 'chatIntentRouter' ? '保存中…' : '保存路由'}
</button>
</div>
</section>
</div>
<h3 className="model-center-section-title">Personal Memory </h3>
<p className="model-center-card-note memory-v2-backend-intro">
Memory V2
</p>
<div className="asset-plugin-grid memory-v2-capability-grid">
{MEMORY_V2_CAPABILITY_SECTIONS.map((section, index) => {
const draftSection = draft[section.key] ?? {};
const savedSection = savedConfig[section.key] ?? {};
const draftEnabled = isMemoryV2SectionEnabled(section.key, draftSection);
const savedEnabled = isMemoryV2SectionEnabled(section.key, savedSection);
const color = MEMORY_V2_SECTION_COLORS[index % MEMORY_V2_SECTION_COLORS.length];
return (
<section
key={section.key}
className={`asset-plugin-card asset-plugin-card--${color} model-center-card memory-v2-card`}
>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">{section.icon}</div>
<div>
<h3>{section.label}</h3>
<p>{section.description}</p>
</div>
<p style={{ margin: 0 }}>{item.content}</p>
<p style={{ margin: 0, color: '#68716c', fontSize: 13 }}>
user={item.userId} · type={item.memoryType} · session={item.sessionId ?? '—'}
</p>
<div style={{ display: 'flex', gap: 8 }}>
<span className={`asset-status ${draftEnabled ? 'is-on' : ''}`}>
{draftEnabled ? '配置已启用' : '配置未启用'}
</span>
</div>
<SectionFields
section={section}
values={draftSection}
onBooleanChange={(field) => updateSectionBoolean(section.key, field)}
onValueChange={(field) => updateSectionValue(section.key, field)}
/>
<div className="asset-plugin-footer">
{section.key === 'pluginHealth' ? (
<div className="memory-v2-runtime-health">
<strong></strong>
{personalMemory ? (
<span>
{String(personalMemory.health?.state ?? 'unknown')}
{' · '}
{personalMemory.effectiveMode ?? 'off'}
{personalMemory.autoReviewEnabled ? ' · 自动审核' : ' · 人工审核'}
{' · '}
{personalMemory.pendingCandidates ?? 0}
{' · '}
{personalMemory.health?.accepted ?? 0}
{' · '}
{personalMemory.health?.autoReviewed ?? 0}
{' · '}
{personalMemory.health?.rejected ?? 0}
{' · '}
{personalMemory.health?.deduped ?? 0}
</span>
) : (
<span> Shadow Pipeline</span>
)}
</div>
) : null}
<span className={`asset-status ${savedEnabled ? 'is-on' : ''}`}>
{savedEnabled ? '启用' : '关闭'}
</span>
<button
type="button"
className="send-btn"
disabled={loading || saving !== null}
onClick={() => void savePatch(
{ [section.key]: draft[section.key] },
section.key,
`${section.label} 配置已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。`,
)}
>
{saving === section.key ? '保存中…' : `保存${section.label}`}
</button>
</div>
</section>
);
})}
</div>
<h3 className="model-center-section-title"></h3>
<section className="asset-plugin-card model-center-card memory-v2-card memory-v2-candidate-review">
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">C</div>
<div>
<h3></h3>
<p>
{candidateCounts.candidate ?? 0}
{' · '}
{candidateCounts.accepted ?? 0}
{' · '}
{candidateCounts.rejected ?? 0}
</p>
<p className="model-center-card-note">
Active Shadow / Canary
</p>
</div>
<button type="button" className="ghost-btn" disabled={loading || reviewingId !== null} onClick={() => void load()}>
</button>
</div>
{candidateError ? <p className="banner banner-error">{candidateError}</p> : null}
{!candidateError && candidates.length === 0 ? (
<p className="model-center-card-note">
Active Shadow / Canary
</p>
) : null}
<div className="memory-v2-candidate-list">
{candidates.map((item) => (
<article key={item.id} className="memory-v2-candidate-item">
<div className="memory-v2-candidate-meta">
<span className="asset-status is-on">{item.memoryType}</span>
<span> {item.userId}</span>
<span> {item.importance.toFixed(2)}</span>
<span> {item.confidence.toFixed(2)}</span>
<span>{formatDateTime(item.createdAt)}</span>
</div>
<p>{item.content}</p>
<small>
Policy: {item.policyReason}
</small>
<div className="asset-plugin-footer">
<button
type="button"
className="ghost-btn"
disabled={reviewingId !== null}
onClick={() => void reviewCandidate(item.id, 'reject')}
>
{reviewingId === item.id ? '处理中…' : '拒绝'}
</button>
<button
type="button"
className="send-btn"
disabled={reviewingId !== null}
onClick={() => void reviewCandidate(item.id, 'accept')}
>
{reviewingId === item.id ? '处理中…' : '接纳'}
</button>
</div>
</article>
))}
</div>
</section>
<h3 className="model-center-section-title">Backend </h3>
<p className="model-center-card-note memory-v2-backend-intro">
backend backend
</p>
<div className="asset-plugin-grid memory-v2-backend-grid">
{MEMORY_V2_BACKEND_SECTIONS.map((section, index) => {
const draftSection = draft[section.key] ?? {};
const savedSection = savedConfig[section.key] ?? {};
const color = MEMORY_V2_SECTION_COLORS[index % MEMORY_V2_SECTION_COLORS.length];
const open = Boolean(draftSection.enabled) || Boolean(expandedBackends[section.key]);
return (
<details
key={section.key}
className={`asset-plugin-card asset-plugin-card--${color} model-center-card memory-v2-card memory-v2-backend-details`}
open={open}
onToggle={(event) => {
setExpandedBackends((current) => ({
...current,
[section.key]: event.currentTarget.open,
}));
}}
>
<summary className="memory-v2-backend-summary">
<div className="asset-plugin-icon" aria-hidden="true">
{MEMORY_V2_BACKEND_ICONS[section.key]}
</div>
<div className="memory-v2-backend-summary-main">
<h3>{section.label}</h3>
<span className={`asset-status ${draftSection.enabled ? 'is-on' : ''}`}>
{draftSection.enabled ? '已启用' : '未启用 · 点击展开'}
</span>
</div>
<label
className="asset-mini-toggle"
aria-label={`启用 ${section.label}`}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<input
type="checkbox"
checked={Boolean(draftSection.enabled)}
onChange={updateBackendBoolean(section.key)}
/>
<span>{draftSection.enabled ? '启用' : '关闭'}</span>
</label>
</summary>
<div className="memory-v2-backend-body">
<div className="admin-form-grid memory-v2-fields">
{section.fields.map((field) => {
const configured = Boolean(savedSection[`${field.key}Configured`]);
const masked = String(savedSection[`${field.key}Masked`] ?? '');
return (
<label key={field.key}>
<span>{field.label}</span>
<input
type={field.secret ? 'password' : 'text'}
value={String(draftSection[field.key] ?? '')}
placeholder={field.secret && configured ? `已配置:${masked || '******'};留空保持不变` : ''}
onChange={updateBackendValue(section.key, field.key)}
/>
</label>
);
})}
</div>
{MEMORY_V2_MODEL_BACKENDS.has(section.key) ? (
<>
<div className="admin-form-grid memory-v2-fields">
<label>
<span></span>
<select
value={String(draftSection.modelProviderKeyId ?? '')}
onChange={(event) => updateBackendValue(section.key, 'modelProviderKeyId')(event)}
>
<option value=""></option>
{keys.map((item) => (
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
))}
</select>
</label>
<label>
<span></span>
<select
value={String(draftSection.model ?? '')}
onChange={updateBackendValue(section.key, 'model')}
>
<option value=""></option>
{modelOptionsForKey(resolveProviderKey(keys, String(draftSection.modelProviderKeyId ?? '')), globalSettings).map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</label>
<label>
<span></span>
<select
value={String(draftSection.modelApiType ?? '')}
onChange={updateBackendValue(section.key, 'modelApiType')}
>
<option value=""></option>
{MEMORY_V2_MODEL_API_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
</div>
<p className="model-center-card-note">
<strong>{describeRouterModel({
enabled: false,
modelProviderKeyId: String(draftSection.modelProviderKeyId ?? ''),
model: String(draftSection.model ?? ''),
modelApiType: String(draftSection.modelApiType ?? ''),
}, keys, globalSettings)}</strong>
</p>
</>
) : null}
<div className="asset-plugin-footer">
<span className={`asset-status ${draftSection.enabled ? 'is-on' : ''}`}>
{draftSection.enabled ? `${section.label} 已启用` : `${section.label} 未启用`}
</span>
<button
type="button"
className="btn"
disabled={reviewingId === item.id}
onClick={() => void review(item.id, 'accepted')}
className="send-btn"
disabled={loading || saving !== null}
onClick={() => void savePatch(
{ [section.key]: draft[section.key] },
section.key,
`${section.label} 配置已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。`,
)}
>
</button>
<button
type="button"
className="btn secondary"
disabled={reviewingId === item.id}
onClick={() => void review(item.id, 'rejected')}
>
{saving === section.key ? '保存中…' : `保存 ${section.label}`}
</button>
</div>
</div>
))}
</div>
)}
</details>
);
})}
</div>
</div>
);
+1 -1
View File
@@ -72,7 +72,7 @@ export function SummaryPage() {
/ H5
</Link>
<Link className="btn secondary" to="/admin/memory-v2" style={{ textAlign: 'center', textDecoration: 'none' }}>
Memory V2
Memory V2
</Link>
<Link className="btn secondary" to="/admin/goal-runs" style={{ textAlign: 'center', textDecoration: 'none' }}>
Goal Run
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isMemoryV2SectionEnabled } from './memory-v2-config.ts';
test('runtimeControl section enabled follows active runtime switches', () => {
assert.equal(isMemoryV2SectionEnabled('runtimeControl', {}), false);
assert.equal(isMemoryV2SectionEnabled('runtimeControl', {
agentResolveEnabled: true,
}), true);
assert.equal(isMemoryV2SectionEnabled('runtimeControl', {
agentInjectionMode: 'active',
}), true);
assert.equal(isMemoryV2SectionEnabled('runtimeControl', {
lifecycleRolloutMode: 'canary',
}), true);
assert.equal(isMemoryV2SectionEnabled('candidateMemory', { enabled: true }), true);
});
+478
View File
@@ -0,0 +1,478 @@
export type MemoryV2FieldType = 'boolean' | 'number' | 'text' | 'select';
export type MemoryV2SelectOption = {
value: string;
label: string;
};
export type MemoryV2FieldSpec = {
key: string;
label: string;
type: MemoryV2FieldType;
options?: MemoryV2SelectOption[];
};
export type MemoryV2SectionSpec = {
key: string;
label: string;
icon: string;
description: string;
fields: MemoryV2FieldSpec[];
};
export type MemoryV2BackendSpec = {
key: string;
label: string;
fields: Array<{ key: string; label: string; secret?: boolean }>;
};
export type MemoryV2ConfigShape = Record<string, Record<string, unknown>>;
export const MEMORY_V2_SECTION_COLORS = ['blue', 'violet', 'green', 'amber'] as const;
export const MEMORY_V2_MODEL_API_OPTIONS: MemoryV2SelectOption[] = [
{ value: 'chat', label: 'Chat API' },
{ value: 'response', label: 'Response API' },
];
export const MEMORY_V2_BACKEND_ICONS: Record<string, string> = {
pgvector: 'P',
qdrant: 'Q',
weaviate: 'W',
mem0: 'M',
letta: 'L',
neo4j: 'N',
redisStreams: 'R',
langgraph: 'G',
};
export const MEMORY_V2_MODEL_BACKENDS = new Set(['mem0', 'letta', 'langgraph']);
export const MEMORY_V2_CAPABILITY_SECTIONS: MemoryV2SectionSpec[] = [
{
key: 'candidateMemory',
label: '候选记忆',
icon: 'C',
description: '控制候选提取模式、自动接纳阈值与待处理数量。',
fields: [
{ key: 'enabled', label: '启用候选记忆', type: 'boolean' },
{
key: 'mode',
label: '运行模式',
type: 'select',
options: [
{ value: 'off', label: 'Off · 关闭' },
{ value: 'active', label: 'Active · 规则通过后自动接纳(推荐)' },
{ value: 'canary', label: 'Canary · 仅高置信度自动接纳' },
{ value: 'shadow', label: 'Shadow · 全部人工审核' },
],
},
{ key: 'minImportance', label: '最低重要度', type: 'number' },
{ key: 'minConfidence', label: '最低置信度', type: 'number' },
{ key: 'maxPending', label: '最大待处理数', type: 'number' },
{ key: 'persistenceEnabled', label: '持久化到 MySQL', type: 'boolean' },
],
},
{
key: 'runtimeControl',
label: 'Runtime 控制',
icon: 'A',
description: '独立于 Memory V2 总开关,控制 Agent 读取、注入、晋升与后续生命周期能力。',
fields: [
{ key: 'agentResolveEnabled', label: '允许 Agent Resolve', type: 'boolean' },
{
key: 'agentInjectionMode',
label: 'Agent 注入模式',
type: 'select',
options: [
{ value: 'off', label: 'Off · 关闭' },
{ value: 'shadow', label: 'Shadow · 只观察不注入' },
{ value: 'canary', label: 'Canary · 灰度注入' },
{ value: 'active', label: 'Active · 正式注入' },
],
},
{ key: 'agentCanaryUserIds', label: 'Canary 用户 ID(逗号分隔)', type: 'text' },
{ key: 'agentResolveLimit', label: 'Agent 召回条数', type: 'number' },
{ key: 'agentResolveTimeoutMs', label: 'Agent Resolve 超时 Ms', type: 'number' },
{ key: 'promotionEnabled', label: '允许候选晋升正式记忆', type: 'boolean' },
{ key: 'compactionV2Enabled', label: '启用 Compact V2', type: 'boolean' },
{ key: 'reflectionEnabled', label: '启用 Reflection', type: 'boolean' },
{ key: 'lifecycleWorkerEnabled', label: '启用 Lifecycle Worker', type: 'boolean' },
{
key: 'lifecycleRolloutMode',
label: 'Lifecycle 灰度模式',
type: 'select',
options: [
{ value: 'off', label: 'Off · 关闭' },
{ value: 'canary', label: 'Canary · 仅指定用户' },
{ value: 'active', label: 'Active · 全量' },
],
},
{ key: 'lifecycleRolloutUserIds', label: 'Lifecycle 用户 ID(逗号分隔)', type: 'text' },
],
},
{
key: 'policy',
label: 'Policy',
icon: 'P',
description: '管理证据、敏感信息、显式记忆与保留期限。',
fields: [
{ key: 'enabled', label: '启用 Policy', type: 'boolean' },
{ key: 'saveExplicit', label: '允许显式“记住”', type: 'boolean' },
{ key: 'rejectSensitive', label: '拒绝敏感内容', type: 'boolean' },
{ key: 'requireEvidence', label: '长期记忆必须有证据', type: 'boolean' },
{ key: 'retentionDays', label: '默认保留天数', type: 'number' },
],
},
{
key: 'retriever',
label: 'Retriever',
icon: 'R',
description: '组合事件、语义、偏好和目标召回,并限制上下文预算。',
fields: [
{ key: 'enabled', label: '启用组合召回', type: 'boolean' },
{ key: 'episodicEnabled', label: '事件记忆', type: 'boolean' },
{
key: 'episodicMode',
label: '历史会话召回模式',
type: 'select',
options: [
{ value: 'off', label: 'Off · 关闭' },
{ value: 'canary', label: 'Canary · 仅指定用户' },
{ value: 'active', label: 'Active · 全量' },
],
},
{ key: 'episodicCanaryUserIds', label: '历史会话 Canary 用户 ID(逗号分隔)', type: 'text' },
{ key: 'semanticEnabled', label: '语义记忆', type: 'boolean' },
{ key: 'preferenceEnabled', label: '用户偏好', type: 'boolean' },
{ key: 'goalEnabled', label: '活跃目标', type: 'boolean' },
{ key: 'limit', label: '召回条数', type: 'number' },
{ key: 'tokenBudget', label: 'Token 预算', type: 'number' },
{ key: 'timeoutMs', label: '超时毫秒', type: 'number' },
],
},
{
key: 'lifecycle',
label: 'Lifecycle',
icon: 'L',
description: '控制去重、冲突审核、衰减、遗忘与压缩周期。',
fields: [
{ key: 'enabled', label: '启用生命周期管理', type: 'boolean' },
{ key: 'dedupeEnabled', label: '自动去重', type: 'boolean' },
{ key: 'conflictReview', label: '冲突进入审核', type: 'boolean' },
{ key: 'decayEnabled', label: '启用记忆衰减', type: 'boolean' },
{ key: 'forgettingEnabled', label: '启用遗忘处理', type: 'boolean' },
{ key: 'compactIntervalHours', label: 'Compact 周期小时', type: 'number' },
],
},
{
key: 'persona',
label: 'Persona',
icon: 'A',
description: '管理用户画像来源、Shadow 注入、缓存和上下文上限。',
fields: [
{ key: 'enabled', label: '启用 Persona', type: 'boolean' },
{
key: 'provider',
label: '画像来源',
type: 'select',
options: [
{ value: 'none', label: '未配置' },
{ value: 'ai-mind', label: 'AI Mind Bridge' },
{ value: 'internal', label: '内部画像' },
],
},
{ key: 'shadowMode', label: '仅 Shadow 不注入', type: 'boolean' },
{ key: 'maxTokens', label: '最大 Token', type: 'number' },
{ key: 'cacheTtlSeconds', label: '缓存秒数', type: 'number' },
],
},
{
key: 'graph',
label: 'Graph',
icon: 'G',
description: '配置关系检索 Provider、最大深度和关系数量。',
fields: [
{ key: 'enabled', label: '启用关系检索', type: 'boolean' },
{
key: 'provider',
label: 'Graph Provider',
type: 'select',
options: [
{ value: 'postgres', label: 'PostgreSQL 关系表' },
{ value: 'neo4j', label: 'Neo4j' },
{ value: 'none', label: '未配置' },
],
},
{ key: 'maxDepth', label: '最大关系深度', type: 'number' },
{ key: 'relationLimit', label: '最大关系数', type: 'number' },
],
},
{
key: 'userMemory',
label: '用户记忆',
icon: 'U',
description: '管理用户查看、纠正、固定、遗忘和删除传播权限。',
fields: [
{ key: 'enabled', label: '启用用户记忆管理', type: 'boolean' },
{ key: 'reviewEnabled', label: '允许用户查看', type: 'boolean' },
{ key: 'correctionEnabled', label: '允许用户纠正', type: 'boolean' },
{ key: 'pinEnabled', label: '允许固定记忆', type: 'boolean' },
{ key: 'forgetEnabled', label: '允许遗忘记忆', type: 'boolean' },
{ key: 'deletePropagation', label: '删除传播至索引和缓存', type: 'boolean' },
],
},
{
key: 'pluginHealth',
label: '插件健康',
icon: 'H',
description: '配置健康检查、失败阈值和自动回退行为。',
fields: [
{ key: 'enabled', label: '启用插件健康检查', type: 'boolean' },
{ key: 'intervalSeconds', label: '检查间隔秒', type: 'number' },
{ key: 'timeoutMs', label: '检查超时毫秒', type: 'number' },
{ key: 'failureThreshold', label: '失败阈值', type: 'number' },
{ key: 'autoFallback', label: '异常时自动回退', type: 'boolean' },
],
},
];
export const MEMORY_V2_BACKEND_SECTIONS: MemoryV2BackendSpec[] = [
{
key: 'pgvector',
label: 'pgvector',
fields: [
{ key: 'databaseUrl', label: 'Database URL', secret: true },
{ key: 'table', label: 'Table' },
{ key: 'embeddingModule', label: 'Embedding Module' },
{ key: 'poolMax', label: 'Pool Max' },
{ key: 'limit', label: 'Recall Limit' },
],
},
{
key: 'qdrant',
label: 'Qdrant',
fields: [
{ key: 'url', label: 'Base URL' },
{ key: 'collection', label: 'Collection' },
{ key: 'embeddingModule', label: 'Embedding Module' },
{ key: 'apiKey', label: 'API Key', secret: true },
{ key: 'timeoutMs', label: 'Timeout Ms' },
{ key: 'limit', label: 'Recall Limit' },
],
},
{
key: 'weaviate',
label: 'Weaviate',
fields: [
{ key: 'url', label: 'Base URL' },
{ key: 'collection', label: 'Collection' },
{ key: 'embeddingModule', label: 'Embedding Module' },
{ key: 'apiKey', label: 'API Key', secret: true },
{ key: 'timeoutMs', label: 'Timeout Ms' },
{ key: 'limit', label: 'Recall Limit' },
],
},
{
key: 'mem0',
label: 'Mem0',
fields: [
{ key: 'apiKey', label: 'API Key', secret: true },
{ key: 'projectId', label: 'Project ID' },
{ key: 'baseUrl', label: 'Base URL' },
{ key: 'writePath', label: 'Write Path' },
{ key: 'compactPath', label: 'Compact Path' },
{ key: 'timeoutMs', label: 'Timeout Ms' },
],
},
{
key: 'letta',
label: 'Letta',
fields: [
{ key: 'apiKey', label: 'API Key', secret: true },
{ key: 'projectId', label: 'Project ID' },
{ key: 'agentId', label: 'Agent ID' },
{ key: 'baseUrl', label: 'Base URL' },
{ key: 'resolvePath', label: 'Resolve Path' },
{ key: 'writePath', label: 'Write Path' },
{ key: 'compactPath', label: 'Compact Path' },
{ key: 'timeoutMs', label: 'Timeout Ms' },
],
},
{
key: 'neo4j',
label: 'Neo4j',
fields: [
{ key: 'uri', label: 'Bolt URI' },
{ key: 'httpUrl', label: 'HTTP URL' },
{ key: 'user', label: 'Username' },
{ key: 'password', label: 'Password', secret: true },
{ key: 'database', label: 'Database' },
{ key: 'timeoutMs', label: 'Timeout Ms' },
],
},
{
key: 'redisStreams',
label: 'Redis Streams',
fields: [
{ key: 'url', label: 'Redis URL' },
{ key: 'stream', label: 'Stream' },
],
},
{
key: 'langgraph',
label: 'LangGraph',
fields: [
{ key: 'url', label: 'Base URL' },
{ key: 'policyId', label: 'Policy ID' },
{ key: 'apiKey', label: 'API Key', secret: true },
{ key: 'resolvePath', label: 'Resolve Path' },
{ key: 'timeoutMs', label: 'Timeout Ms' },
],
},
];
function modeEnabled(value: unknown) {
const mode = String(value ?? 'off').trim().toLowerCase();
return mode !== '' && mode !== 'off';
}
export function isMemoryV2SectionEnabled(
sectionKey: string,
section: Record<string, unknown> | undefined,
): boolean {
const config = section ?? {};
if (sectionKey === 'runtimeControl') {
return Boolean(config.agentResolveEnabled)
|| modeEnabled(config.agentInjectionMode)
|| Boolean(config.promotionEnabled)
|| Boolean(config.compactionV2Enabled)
|| Boolean(config.reflectionEnabled)
|| Boolean(config.lifecycleWorkerEnabled)
|| modeEnabled(config.lifecycleRolloutMode);
}
return Boolean(config.enabled);
}
export function createDefaultMemoryV2Config(): MemoryV2ConfigShape {
return {
global: {
enabled: false,
backend: 'legacy',
profileEnabled: false,
eventLogEnabled: false,
vectorEnabled: false,
failOpen: true,
},
chatIntentRouter: {
enabled: false,
modelProviderKeyId: '',
model: '',
modelApiType: 'chat',
minConfidence: '0.65',
memoryResolveEnabled: true,
memoryResolveLimit: '8',
timeoutMs: '1500',
fallbackRoute: 'agent_orchestration',
},
candidateMemory: {
enabled: false,
mode: 'active',
minImportance: '0.7',
minConfidence: '0.8',
maxPending: '500',
persistenceEnabled: false,
},
runtimeControl: {
agentResolveEnabled: false,
agentInjectionMode: 'off',
agentCanaryUserIds: '',
agentResolveLimit: '3',
agentResolveTimeoutMs: '1200',
promotionEnabled: false,
compactionV2Enabled: false,
reflectionEnabled: false,
lifecycleWorkerEnabled: false,
lifecycleRolloutMode: 'off',
lifecycleRolloutUserIds: '',
},
policy: {
enabled: false,
saveExplicit: true,
rejectSensitive: true,
requireEvidence: true,
retentionDays: '365',
},
retriever: {
enabled: false,
episodicEnabled: true,
episodicMode: 'off',
episodicCanaryUserIds: '',
semanticEnabled: true,
preferenceEnabled: true,
goalEnabled: true,
limit: '12',
tokenBudget: '1800',
timeoutMs: '1200',
},
lifecycle: {
enabled: false,
dedupeEnabled: true,
conflictReview: true,
decayEnabled: false,
forgettingEnabled: true,
compactIntervalHours: '24',
},
persona: {
enabled: false,
provider: 'none',
shadowMode: true,
maxTokens: '400',
cacheTtlSeconds: '300',
},
graph: {
enabled: false,
provider: 'postgres',
maxDepth: '2',
relationLimit: '20',
},
userMemory: {
enabled: false,
reviewEnabled: true,
correctionEnabled: true,
pinEnabled: true,
forgetEnabled: true,
deletePropagation: true,
},
pluginHealth: {
enabled: false,
intervalSeconds: '60',
timeoutMs: '1500',
failureThreshold: '3',
autoFallback: true,
},
pgvector: {},
qdrant: {},
weaviate: {},
mem0: {},
letta: {},
neo4j: {},
redisStreams: {},
langgraph: {},
};
}
export function mergeMemoryV2Config(
source: Record<string, unknown> | undefined,
): MemoryV2ConfigShape {
const defaults = createDefaultMemoryV2Config();
const input = source ?? {};
const merged: MemoryV2ConfigShape = { ...defaults };
for (const key of Object.keys(defaults)) {
merged[key] = {
...defaults[key],
...((input[key] as Record<string, unknown> | undefined) ?? {}),
};
}
return merged;
}
+310
View File
@@ -0,0 +1,310 @@
.memory-v2-page {
display: block;
}
.memory-v2-page .banner {
padding: 10px 12px;
border-radius: 12px;
margin: 0 0 12px;
}
.memory-v2-page .banner-error {
background: #fdecec;
color: #b42318;
}
.memory-v2-page .banner-info {
background: #ecfdf3;
color: #2f6f57;
}
.memory-v2-page .muted {
color: #68716c;
}
.memory-v2-page .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.admin-page-head h2 {
margin: 0 0 4px;
}
.asset-gateway-hero,
.memory-v2-top-grid,
.memory-v2-capability-grid,
.memory-v2-backend-grid {
display: grid;
gap: 12px;
margin-bottom: 12px;
}
.asset-gateway-hero {
grid-template-columns: 1fr auto;
align-items: start;
padding: 16px;
border: 1px solid #d6d0c3;
border-radius: 16px;
background: #fffdf7;
}
.asset-kicker {
color: #68716c;
font-size: 11px;
letter-spacing: 0.08em;
margin-bottom: 4px;
}
.model-center-section-title {
margin: 16px 0 8px;
font-size: 16px;
}
.model-center-card-note {
color: #68716c;
font-size: 13px;
margin: 0 0 12px;
}
.asset-plugin-grid {
display: grid;
gap: 12px;
}
.memory-v2-top-grid {
grid-template-columns: minmax(0, 1fr);
}
@media (min-width: 960px) {
.memory-v2-top-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.memory-v2-capability-grid,
.memory-v2-backend-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.asset-plugin-card {
display: grid;
gap: 10px;
padding: 12px 14px;
border: 1px solid #d6d0c3;
border-radius: 16px;
background: #fffdf7;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
.asset-plugin-card--blue { border-color: #b8d4ff; }
.asset-plugin-card--violet { border-color: #d5c4ff; }
.asset-plugin-card--green { border-color: #b8e6c8; }
.asset-plugin-card--amber { border-color: #f5d08a; }
.asset-plugin-card-head {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: start;
}
.asset-plugin-card-head h3,
.asset-plugin-card-head p {
margin: 0;
}
.asset-plugin-card-head p {
color: #68716c;
font-size: 13px;
margin-top: 4px;
}
.asset-plugin-icon {
width: 32px;
height: 32px;
border-radius: 10px;
display: grid;
place-items: center;
background: #ebe4d6;
font-weight: 700;
}
.asset-status {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
background: #ebe4d6;
color: #68716c;
font-size: 12px;
white-space: nowrap;
}
.asset-status.is-on {
background: #e6f4ec;
color: #2f6f57;
}
.asset-plugin-footer {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.memory-v2-toggle-grid,
.memory-v2-fields,
.admin-form-grid {
display: grid;
gap: 10px 12px;
}
.memory-v2-toggle-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.memory-v2-fields,
.admin-form-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.memory-v2-fields label,
.admin-form-grid label {
display: grid;
gap: 5px;
}
.memory-v2-fields label > span,
.admin-form-grid label > span {
color: #68716c;
font-size: 11px;
}
.inline-check {
display: flex;
gap: 8px;
align-items: center;
color: #68716c;
font-size: 12px;
}
.inline-check input {
width: auto;
}
.asset-mini-toggle {
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 12px;
}
.send-btn,
.ghost-btn {
border-radius: 999px;
padding: 8px 16px;
cursor: pointer;
font: inherit;
}
.send-btn {
border: 1px solid #2f6f57;
background: #2f6f57;
color: white;
}
.send-btn:disabled,
.ghost-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ghost-btn {
border: 1px solid #d6d0c3;
background: transparent;
color: #17221d;
}
.memory-v2-advanced-details {
border: 1px solid #d6d0c3;
border-radius: 12px;
padding: 0 10px 10px;
}
.memory-v2-advanced-details > summary {
padding: 8px 0;
color: #68716c;
font-size: 12px;
cursor: pointer;
}
.memory-v2-runtime-health {
display: grid;
gap: 3px;
flex: 1 1 100%;
color: #68716c;
font-size: 11px;
}
.memory-v2-runtime-health strong {
color: #17221d;
font-size: 12px;
}
.memory-v2-candidate-list {
display: grid;
gap: 10px;
}
.memory-v2-candidate-item {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid #d6d0c3;
border-radius: 12px;
background: rgba(255, 255, 255, 0.4);
}
.memory-v2-candidate-item p {
margin: 0;
line-height: 1.6;
}
.memory-v2-candidate-item small {
color: #68716c;
}
.memory-v2-candidate-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
align-items: center;
color: #68716c;
font-size: 11px;
}
.memory-v2-backend-details > summary {
list-style: none;
cursor: pointer;
}
.memory-v2-backend-summary {
display: flex;
gap: 10px;
align-items: center;
}
.memory-v2-backend-summary-main {
display: grid;
gap: 2px;
flex: 1;
min-width: 0;
}
.memory-v2-backend-body {
display: grid;
gap: 10px;
padding-top: 10px;
border-top: 1px solid #d6d0c3;
}
+2 -1
View File
@@ -190,7 +190,8 @@ export async function bootstrapPortalAgentServices({
const wordFilterService =
createWordFilterServiceFn(pool);
const relayBootstrapTask = env.H5_RELAY_BOOTSTRAP_DISABLED === '1'
// Relay bootstrap is opt-in only; production uses direct provider keys (e.g. DeepSeek).
const relayBootstrapTask = env.H5_RELAY_BOOTSTRAP_DISABLED !== '0'
? Promise.resolve({ ok: true, created: false, skipped: true })
: llmProviderService
.ensureBootstrapRelay()
@@ -367,6 +367,10 @@ test('falls back from PG experience and degrades invalid image config', async ()
test('keeps disabled experience null and reports background LLM failures', async () => {
const setup = createSetup({
env: {
EXPERIENCE_PG_URL: 'postgres://experience',
H5_RELAY_BOOTSTRAP_DISABLED: '0',
},
runtime: {
experienceEnabled: false,
agentWorker: { enabled: false },
+12
View File
@@ -34,6 +34,8 @@ async function ensureConfigTable(pool) {
function defaultsFromEnv(env = process.env) {
return {
scheduleLlmEnabled: normalizeBoolean(env.H5_WECHAT_SCHEDULE_LLM_ENABLED, false),
modelProviderKeyId: String(env.MEMIND_WECHAT_SCHEDULE_LLM_MODEL_PROVIDER_KEY_ID ?? '').trim() || null,
model: String(env.MEMIND_WECHAT_SCHEDULE_LLM_MODEL ?? '').trim() || null,
};
}
@@ -65,6 +67,8 @@ export function createWechatScheduleLlmConfigService(pool, { env = process.env }
stored.scheduleLlmEnabled,
defaults.scheduleLlmEnabled,
),
modelProviderKeyId: String(stored.modelProviderKeyId ?? defaults.modelProviderKeyId ?? '').trim() || null,
model: String(stored.model ?? defaults.model ?? '').trim() || null,
};
}
@@ -90,6 +94,14 @@ export function createWechatScheduleLlmConfigService(pool, { env = process.env }
payload.scheduleLlmEnabled === undefined
? current.scheduleLlmEnabled
: normalizeBoolean(payload.scheduleLlmEnabled, current.scheduleLlmEnabled),
modelProviderKeyId:
payload.modelProviderKeyId === undefined
? (current.modelProviderKeyId ?? '')
: String(payload.modelProviderKeyId ?? '').trim(),
model:
payload.model === undefined
? (current.model ?? '')
: String(payload.model ?? '').trim(),
};
const now = Date.now();
await pool.query(
+57 -28
View File
@@ -60,40 +60,50 @@ function normalizeLlmScheduleIntent(payload) {
return { action: 'none' };
}
async function parseScheduleIntentWithLlm({ text, llmProviderService, logger = console }) {
async function parseScheduleIntentWithLlm({ text, llmProviderService, modelProviderKeyId = null, model = null, logger = console }) {
if (!llmProviderService || typeof llmProviderService.createChatCompletion !== 'function') {
return { action: 'none' };
}
const result = await llmProviderService.createChatCompletion({
temperature: 0,
messages: [
{
role: 'system',
content: [
'你是公众号提醒助手,只做意图识别。',
'请严格输出 JSON,不要输出解释。',
'允许 action: create_todo, create_daily_todo_digest, create_balance_alert, query_schedule, schedule_agent, none。',
'create_todo 需要 title。',
'create_daily_todo_digest 需要 hour(0-23) 和 minute(0-59)。',
'create_balance_alert 需要 thresholdYuan 数字。',
'如果用户在说具体时间提醒、一次性闹钟、某天某时提醒,返回 schedule_agent。',
'不确定时返回 none。',
].join('\n'),
},
{
role: 'user',
content: text,
},
],
});
try {
const result = await llmProviderService.createChatCompletion({
...(modelProviderKeyId ? { providerKeyId: modelProviderKeyId } : {}),
...(model ? { model } : {}),
temperature: 0,
messages: [
{
role: 'system',
content: [
'你是公众号提醒助手,只做意图识别。',
'请严格输出 JSON,不要输出解释。',
'允许 action: create_todo, create_daily_todo_digest, create_balance_alert, query_schedule, schedule_agent, none。',
'create_todo 需要 title。',
'create_daily_todo_digest 需要 hour(0-23) 和 minute(0-59)。',
'create_balance_alert 需要 thresholdYuan 数字。',
'如果用户在说具体时间提醒、一次性闹钟、某天某时提醒,返回 schedule_agent。',
'不确定时返回 none。',
].join('\n'),
},
{
role: 'user',
content: text,
},
],
});
if (!result?.ok) {
logger?.warn?.('[wechat-schedule-llm] parse skipped:', result?.message ?? 'unknown');
if (!result?.ok) {
logger?.warn?.('[wechat-schedule-llm] parse skipped:', result?.message ?? 'unknown');
return { action: 'none' };
}
return normalizeLlmScheduleIntent(parseJsonReply(result.reply));
} catch (err) {
logger?.warn?.(
'[wechat-schedule-llm] parse skipped:',
err instanceof Error ? err.message : err,
);
return { action: 'none' };
}
return normalizeLlmScheduleIntent(parseJsonReply(result.reply));
}
async function resolveScheduleIntent({
@@ -118,7 +128,26 @@ async function resolveScheduleIntent({
}
if (!enabled) return ruleIntent;
return parseScheduleIntentWithLlm({ text, llmProviderService, logger });
let modelProviderKeyId = null;
let model = null;
try {
const config = await wechatScheduleLlmConfigService.getConfig();
modelProviderKeyId = config.modelProviderKeyId ?? null;
model = config.model ?? null;
} catch (err) {
logger?.warn?.(
'[wechat-schedule-llm] provider config load failed:',
err instanceof Error ? err.message : err,
);
}
return parseScheduleIntentWithLlm({
text,
llmProviderService,
modelProviderKeyId,
model,
logger,
});
}
export async function handleWechatScheduleIntent({
+39 -5
View File
@@ -19,13 +19,28 @@ function createScheduleService() {
};
}
function createScheduleLlmConfigService(enabled = true) {
return {
async isScheduleLlmEnabled() {
return enabled;
},
async getConfig() {
return {
scheduleLlmEnabled: enabled,
modelProviderKeyId: 'deepseek-key',
model: 'deepseek-v4-pro',
};
},
};
}
test('schedule handler keeps rule-based parsing as first priority', async () => {
let llmCalled = false;
const reply = await handleWechatScheduleIntent({
intent: { agentText: '帮我记一下 跟进合同', msgId: 'msg-1' },
user: { userId: 'user-1' },
scheduleService: createScheduleService(),
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
llmProviderService: {
async createChatCompletion() {
llmCalled = true;
@@ -42,7 +57,7 @@ test('schedule handler can use llm fallback for ambiguous todo text', async () =
intent: { agentText: '下周把合同发给客户,别忘了', msgId: 'msg-2' },
user: { userId: 'user-1' },
scheduleService: createScheduleService(),
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
llmProviderService: {
async createChatCompletion() {
return {
@@ -71,7 +86,7 @@ test('schedule handler does not call llm when switch is off', async () => {
intent: { agentText: '别忘了报销', msgId: 'msg-4' },
user: { userId: 'user-1' },
scheduleService: createScheduleService(),
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return false; } },
wechatScheduleLlmConfigService: createScheduleLlmConfigService(false),
llmProviderService: {
async createChatCompletion() {
llmCalled = true;
@@ -89,7 +104,7 @@ test('schedule handler falls back quietly when llm request fails', async () => {
intent: { agentText: '别忘了报销', msgId: 'msg-5' },
user: { userId: 'user-1' },
scheduleService: createScheduleService(),
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
llmProviderService: {
async createChatCompletion() {
return { ok: false, message: 'provider unavailable' };
@@ -102,12 +117,31 @@ test('schedule handler falls back quietly when llm request fails', async () => {
assert.match(warnings[0], /provider unavailable/);
});
test('schedule handler falls back quietly when llm request throws', async () => {
const warnings = [];
const reply = await handleWechatScheduleIntent({
intent: { agentText: '什么是机器学习', msgId: 'msg-6' },
user: { userId: 'user-1' },
scheduleService: createScheduleService(),
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
llmProviderService: {
async createChatCompletion() {
throw new TypeError('fetch failed');
},
},
logger: { warn: (...args) => warnings.push(args.join(' ')) },
});
assert.equal(reply, null);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /fetch failed/);
});
test('schedule handler falls through for llm schedule_agent results', async () => {
const reply = await handleWechatScheduleIntent({
intent: { agentText: '明天下午三点提醒我开会', msgId: 'msg-3' },
user: { userId: 'user-1' },
scheduleService: createScheduleService(),
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
llmProviderService: {
async createChatCompletion() {
return {