From 122a25649b6a2556b7a4dc27891323b9a1440ac6 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 1 Aug 2026 21:44:01 +0800 Subject: [PATCH] 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 --- admin-routes.mjs | 33 +- ops/src/api/admin.ts | 35 +- ops/src/components/AdminLayout.tsx | 2 +- ops/src/pages/admin/MemoryV2Page.tsx | 893 +++++++++++++++--- ops/src/pages/admin/SummaryPage.tsx | 2 +- ops/src/pages/admin/memory-v2-config.test.mjs | 17 + ops/src/pages/admin/memory-v2-config.ts | 478 ++++++++++ ops/src/pages/admin/memory-v2.css | 310 ++++++ 8 files changed, 1634 insertions(+), 136 deletions(-) create mode 100644 ops/src/pages/admin/memory-v2-config.test.mjs create mode 100644 ops/src/pages/admin/memory-v2-config.ts create mode 100644 ops/src/pages/admin/memory-v2.css diff --git a/admin-routes.mjs b/admin-routes.mjs index 3f664b4..3bec14a 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -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 : '读取候选记忆失败', diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index a981ebd..ce728d2 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -913,6 +913,29 @@ export type MemoryV2RuntimeState = { overrides: Record; }; +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; + 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('/admin-api/memory-v2/runtime'); } +export async function fetchMemoryV2Status() { + return adminFetch('/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; + }>( `/admin-api/memory-v2/candidates${suffix}`, ); } diff --git a/ops/src/components/AdminLayout.tsx b/ops/src/components/AdminLayout.tsx index 7648b77..968b332 100644 --- a/ops/src/components/AdminLayout.tsx +++ b/ops/src/components/AdminLayout.tsx @@ -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' }, ]; diff --git a/ops/src/pages/admin/MemoryV2Page.tsx b/ops/src/pages/admin/MemoryV2Page.tsx index daefb97..5aaedcd 100644 --- a/ops/src/pages/admin/MemoryV2Page.tsx +++ b/ops/src/pages/admin/MemoryV2Page.tsx @@ -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) { + 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; + onBooleanChange: (field: string) => (event: ChangeEvent) => void; + onValueChange: (field: string) => (event: ChangeEvent) => void; +}) { + const renderField = (field: MemoryV2FieldSpec) => { + if (field.type === 'boolean') { + return ( + + ); + } + if (field.type === 'select') { + return ( + + ); + } + return ( + + ); + }; + + return ( +
+ {section.fields.map(renderField)} +
+ ); } export function MemoryV2Page() { - const [metrics, setMetrics] = useState(null); + const [savedState, setSavedState] = useState(null); + const [draft, setDraft] = useState(() => createDefaultMemoryV2Config()); + const [keys, setKeys] = useState([]); + const [globalSettings, setGlobalSettings] = useState(null); + const [runtimeStatus, setRuntimeStatus] = useState(null); const [candidates, setCandidates] = useState([]); - const [error, setError] = useState(null); + const [candidateCounts, setCandidateCounts] = useState>({}); const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(null); const [reviewingId, setReviewingId] = useState(null); - const [autoReviewBusy, setAutoReviewBusy] = useState(false); - const [autoReviewNote, setAutoReviewNote] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [candidateError, setCandidateError] = useState(null); + const [expandedBackends, setExpandedBackends] = useState>({}); - const reload = useCallback(async () => { + const savedConfig = useMemo( + () => mergeMemoryV2Config(savedState?.config as Record | 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 }; + }), ]); - setMetrics(metricsRes); - setCandidates(candidatesRes.items); + setSavedState(configResult); + setDraft(mergeMemoryV2Config(configResult.config as Record | 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 | undefined)); + setNotice(message); + }; + + const savePatch = async (patch: Record, 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) => { + setDraft((current) => ({ + ...current, + global: { ...current.global, [field]: event.target.checked }, + })); + }; + + const updateGlobalValue = (field: string) => (event: ChangeEvent) => { + setDraft((current) => ({ + ...current, + global: { ...current.global, [field]: event.target.value }, + })); + }; + + const updateRouterBoolean = (field: string) => (event: ChangeEvent) => { + setDraft((current) => ({ + ...current, + chatIntentRouter: { ...current.chatIntentRouter, [field]: event.target.checked }, + })); + }; + + const updateRouterValue = (field: string) => (event: ChangeEvent) => { + setDraft((current) => ({ + ...current, + chatIntentRouter: { ...current.chatIntentRouter, [field]: event.target.value }, + })); + }; + + const updateSectionBoolean = (sectionKey: string, field: string) => ( + event: ChangeEvent, + ) => { + setDraft((current) => ({ + ...current, + [sectionKey]: { ...current[sectionKey], [field]: event.target.checked }, + })); + }; + + const updateSectionValue = (sectionKey: string, field: string) => ( + event: ChangeEvent, + ) => { + setDraft((current) => ({ + ...current, + [sectionKey]: { ...current[sectionKey], [field]: event.target.value }, + })); + }; + + const updateBackendBoolean = (sectionKey: string) => (event: ChangeEvent) => { + 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, + ) => { + 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

加载中…

; + 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

加载中…

; return ( -
- {error &&

{error}

} - {autoReviewNote &&

{autoReviewNote}

} - -
-
-
-

Memory V2 指标

-

近 {metrics?.metrics.window.since ?? '7d'} 产品事件与 shadow 审计

-
-
- - -
-
- {metrics && ( - <> -
- {Object.entries(metrics.metrics.events).map(([key, count]) => ( -
-

{key}

- {count} -
- ))} -
-
-

误存率:{formatPercent(metrics.audit.falseStoreRate)}

-

自动接受率:{formatPercent(metrics.audit.autoAcceptRate)}

-

resolve 命中率:{formatPercent(metrics.audit.resolveHitRate)}

-

- 候选队列: - {Object.entries(metrics.candidateCounts) - .map(([status, count]) => `${status} × ${count}`) - .join(',') || '—'} -

-
- - )} +
+
+

Memory V2 配置

+

+ 这里是 Memory V2 的统一后台控制面。`memind_adm` 负责改配置,主站会从共享数据库读取并自动切换。 +

-
-

需人工复核({candidates.length})

-

- canary 模式下写入时会自动审核:显式/偏好/目标类接受,decision_signal 与问句类拒绝。 - 此处仅展示自动审核未覆盖的例外项。 -

- {candidates.length === 0 ? ( -

没有待人工复核的候选。

- ) : ( -
- {candidates.map((item) => ( -
{error}

: null} + {notice ?

{notice}

: null} + +
+
+
MEMORY V2
+

统一记忆控制面

+

+ 全局 {draft.global.enabled ? '已启用' : '未启用'} + {' · '} + 默认 backend {String(draft.global.backend ?? 'legacy')} + {' · '} + 已启用 backend {enabledBackendCount}/{MEMORY_V2_BACKEND_SECTIONS.length} + {' · '} + 更新时间 {formatSavedTime(savedState?.updatedAt)} +

+
+
+ +
+
+ +

全局与路由

+
+
+
+ +
+

全局开关

+

控制 Memory V2 总开关、Profile / Event Log / Vector 与 fail-open 行为。

+
+ + {draft.global.enabled ? '已启用' : '未启用'} + +
+
+ + + + + +
+
+ +
+ {!loading ? ( +

+ 当前数据库配置默认 backend: + {String(savedConfig.global.backend ?? 'legacy')} +

+ ) : null} +
+ + {draft.global.enabled ? 'Memory V2 运行中' : 'Memory V2 关闭'} + + +
+
+ +
+
+ +
+

前置 LLM 意图路由

+

关闭时主站完全退回原有 Direct Chat / Agent 判定;打开后才会调用轻量 LLM。

+
+ +
+
+ + + +
+
+ 高级参数 +
+ + + + + +
+
+

+ 当前数据库配置: + {routerSaved.enabled ? '已启用' : '未启用'} + {' · '} + 生效模型链路: + {describeRouterModel(routerDraft, keys, globalSettings)} +

+ {!keys.length ? ( +

+ 统一模型中心当前没有可用的激活 key;若启用路由,请先配置 Provider key 与模型列表。 +

+ ) : null} +
+ + {routerDraft.enabled ? '路由已启用' : '路由已关闭'} + + +
+
+
+ +

Personal Memory 能力管理

+

+ 配置统一保存到 Memory V2 控制面。启用配置不代表运行插件已经健康,实际状态仍需结合插件健康与运行时状态判断。 +

+
+ {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.label}

+

{section.description}

-

{item.content}

-

- user={item.userId} · type={item.memoryType} · session={item.sessionId ?? '—'} -

-
+ + {draftEnabled ? '配置已启用' : '配置未启用'} + +
+ updateSectionBoolean(section.key, field)} + onValueChange={(field) => updateSectionValue(section.key, field)} + /> +
+ {section.key === 'pluginHealth' ? ( +
+ 运行时状态 + {personalMemory ? ( + + {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} + + ) : ( + 主站尚未加载 Shadow Pipeline,或运行时状态暂不可用 + )} +
+ ) : null} + + 当前保存:{savedEnabled ? '启用' : '关闭'} + + +
+
+ ); + })} +
+ +

候选记忆审核

+
+
+ +
+

待审核候选

+

+ 待审核 {candidateCounts.candidate ?? 0} + {' · '} + 已接纳 {candidateCounts.accepted ?? 0} + {' · '} + 已拒绝 {candidateCounts.rejected ?? 0} +

+

+ Active 模式下规则通过的候选会自动接纳。此处仅显示 Shadow / Canary 模式下仍需人工确认的例外项。 +

+
+ +
+ {candidateError ?

{candidateError}

: null} + {!candidateError && candidates.length === 0 ? ( +

+ 当前没有待审核候选。Active 模式下候选会自动接纳;Shadow / Canary 模式下低置信度项会出现在这里。 +

+ ) : null} +
+ {candidates.map((item) => ( +
+
+ {item.memoryType} + 用户 {item.userId} + 重要度 {item.importance.toFixed(2)} + 置信度 {item.confidence.toFixed(2)} + {formatDateTime(item.createdAt)} +
+

{item.content}

+ + Policy: {item.policyReason} + +
+ + +
+
+ ))} +
+
+ +

Backend 配置

+

+ 单独启用后,只有当全局默认 backend 指向它时才会成为主读写目标。未启用的 backend 默认折叠,点击标题可展开。 +

+
+ {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 ( +
{ + setExpandedBackends((current) => ({ + ...current, + [section.key]: event.currentTarget.open, + })); + }} + > + + +
+

{section.label}

+ + {draftSection.enabled ? '已启用' : '未启用 · 点击展开'} + +
+ +
+
+
+ {section.fields.map((field) => { + const configured = Boolean(savedSection[`${field.key}Configured`]); + const masked = String(savedSection[`${field.key}Masked`] ?? ''); + return ( + + ); + })} +
+ {MEMORY_V2_MODEL_BACKENDS.has(section.key) ? ( + <> +
+ + + +
+

+ 生效模型: + {describeRouterModel({ + enabled: false, + modelProviderKeyId: String(draftSection.modelProviderKeyId ?? ''), + model: String(draftSection.model ?? ''), + modelApiType: String(draftSection.modelApiType ?? ''), + }, keys, globalSettings)} +

+ + ) : null} +
+ + {draftSection.enabled ? `${section.label} 已启用` : `${section.label} 未启用`} + -
- ))} -
- )} + + ); + })}
); diff --git a/ops/src/pages/admin/SummaryPage.tsx b/ops/src/pages/admin/SummaryPage.tsx index 13f1337..448f126 100644 --- a/ops/src/pages/admin/SummaryPage.tsx +++ b/ops/src/pages/admin/SummaryPage.tsx @@ -72,7 +72,7 @@ export function SummaryPage() { 统一大模型 / H5 路由 - Memory V2 指标 + Memory V2 配置 Goal Run 观测 diff --git a/ops/src/pages/admin/memory-v2-config.test.mjs b/ops/src/pages/admin/memory-v2-config.test.mjs new file mode 100644 index 0000000..9e4c78d --- /dev/null +++ b/ops/src/pages/admin/memory-v2-config.test.mjs @@ -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); +}); diff --git a/ops/src/pages/admin/memory-v2-config.ts b/ops/src/pages/admin/memory-v2-config.ts new file mode 100644 index 0000000..734e20c --- /dev/null +++ b/ops/src/pages/admin/memory-v2-config.ts @@ -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>; + +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 = { + 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 | 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 | 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 | undefined) ?? {}), + }; + } + return merged; +} diff --git a/ops/src/pages/admin/memory-v2.css b/ops/src/pages/admin/memory-v2.css new file mode 100644 index 0000000..305ddb3 --- /dev/null +++ b/ops/src/pages/admin/memory-v2.css @@ -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; +}