From 731d93843dbea9a83ca692d32dd78bf8c7cf1c18 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 13 Jul 2026 14:00:20 +0800 Subject: [PATCH 1/3] feat(admin): add Memory V2 personal controls and Skill Runtime page Wire admin UI and API routes to restored Memind personal memory and skill runtime modules, including candidate review and runtime status display. Co-authored-by: Cursor --- server/app.mjs | 107 ++++++++ server/bootstrap.mjs | 9 + server/index.mjs | 4 + src/App.tsx | 3 + src/admin/AdminNav.tsx | 1 + src/admin/pages/DashboardPage.tsx | 1 + src/admin/pages/MemoryV2Page.tsx | 385 ++++++++++++++++++++++++++- src/admin/pages/SkillRuntimePage.tsx | 186 +++++++++++++ src/api/client.ts | 42 +++ src/index.css | 59 +++- src/types.ts | 80 ++++++ 11 files changed, 872 insertions(+), 5 deletions(-) create mode 100644 src/admin/pages/SkillRuntimePage.tsx diff --git a/server/app.mjs b/server/app.mjs index 88c7fe4..a1bf2a6 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -98,6 +98,8 @@ export function createAdminApp(services) { loadMindSpaceConfig, updateMindSpaceConfig, memoryV2ConfigService, + personalMemoryCandidateStore, + skillRuntimeConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, @@ -366,6 +368,111 @@ export function createAdminApp(services) { res.json(result); }); + adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => { + const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`; + try { + const response = await fetch(`${portalBaseUrl}/api/runtime/status`, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(2500), + }); + if (!response.ok) { + return res.status(502).json({ + ok: false, + message: `Memory V2 运行时状态返回 HTTP ${response.status}`, + }); + } + const payload = await response.json(); + return res.json({ + ok: true, + checkedAt: Date.now(), + memory: payload?.memory ?? null, + }); + } catch (err) { + return res.status(503).json({ + ok: false, + checkedAt: Date.now(), + message: err instanceof Error ? err.message : 'Memory V2 运行时不可用', + }); + } + }); + + adminApi.get('/memory-v2/candidates', requireAdmin, async (req, res) => { + if (!personalMemoryCandidateStore?.listCandidates) { + return res.status(503).json({ message: '候选记忆存储未启用' }); + } + try { + const status = String(req.query.status ?? 'candidate'); + const userId = String(req.query.userId ?? '').trim() || null; + const limit = Number(req.query.limit ?? 50); + const [items, counts] = await Promise.all([ + personalMemoryCandidateStore.listCandidates({ status, userId, limit }), + personalMemoryCandidateStore.countByStatus(), + ]); + return res.json({ items, counts }); + } catch (err) { + const missingTable = err?.code === 'ER_NO_SUCH_TABLE'; + return res.status(missingTable ? 503 : 400).json({ + message: missingTable ? '候选记忆表尚未执行本地迁移' : (err instanceof Error ? err.message : '候选记忆读取失败'), + }); + } + }); + + adminApi.post('/memory-v2/candidates/:id/accept', requireAdmin, async (req, res) => { + if (!personalMemoryCandidateStore?.reviewCandidate) { + return res.status(503).json({ message: '候选记忆存储未启用' }); + } + const result = await personalMemoryCandidateStore.reviewCandidate(req.params.id, 'accepted', { + reviewedBy: req.currentUser.id, + }); + if (!result.updated) return res.status(409).json({ message: '候选记忆已处理或不存在' }); + return res.json(result); + }); + + adminApi.post('/memory-v2/candidates/:id/reject', requireAdmin, async (req, res) => { + if (!personalMemoryCandidateStore?.reviewCandidate) { + return res.status(503).json({ message: '候选记忆存储未启用' }); + } + const result = await personalMemoryCandidateStore.reviewCandidate(req.params.id, 'rejected', { + reviewedBy: req.currentUser.id, + }); + if (!result.updated) return res.status(409).json({ message: '候选记忆已处理或不存在' }); + return res.json(result); + }); + + adminApi.get('/skill-runtime/config', requireAdmin, async (_req, res) => { + if (!skillRuntimeConfigService?.getAdminConfig) { + return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' }); + } + res.json(await skillRuntimeConfigService.getAdminConfig()); + }); + + const updateSkillRuntimeConfig = async (req, res) => { + if (!skillRuntimeConfigService?.updateAdminConfig) { + return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' }); + } + const result = await skillRuntimeConfigService.updateAdminConfig(req.body?.config ?? req.body ?? {}, { + updatedBy: req.currentUser.id, + }); + res.json(result); + }; + + adminApi.put('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig); + adminApi.patch('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig); + + adminApi.get('/skill-runtime/catalog', requireAdmin, async (_req, res) => { + if (!skillRuntimeConfigService?.listCatalogSummary) { + return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' }); + } + res.json({ catalog: await skillRuntimeConfigService.listCatalogSummary() }); + }); + + adminApi.get('/skill-runtime/runtime', requireAdmin, async (_req, res) => { + if (!skillRuntimeConfigService?.getPublicRuntimeConfig) { + return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' }); + } + res.json(await skillRuntimeConfigService.getPublicRuntimeConfig()); + }); + adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => { if (!systemTestAccountService) { return res.status(503).json({ message: '系统测试账号服务未启用' }); diff --git a/server/bootstrap.mjs b/server/bootstrap.mjs index d6c1104..9c4f88c 100644 --- a/server/bootstrap.mjs +++ b/server/bootstrap.mjs @@ -29,6 +29,8 @@ export async function bootstrapAdminServices() { updateMindSpaceConfig, } = await importMemind('mindspace-config.mjs'); const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs'); + const { createPersonalMemoryCandidateStore } = await importMemind('memory-v2-personal-store.mjs'); + const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs'); const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs'); const { ensureAssetGatewaySchema } = await importMemind('db.mjs'); const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs'); @@ -93,6 +95,11 @@ export async function bootstrapAdminServices() { const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, { env: process.env, }); + const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool); + const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { + env: process.env, + h5Root, + }); const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, { env: process.env, }); @@ -145,6 +152,8 @@ export async function bootstrapAdminServices() { loadMindSpaceConfig, updateMindSpaceConfig, memoryV2ConfigService, + personalMemoryCandidateStore, + skillRuntimeConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, diff --git a/server/index.mjs b/server/index.mjs index 4f96cdf..18bc618 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -105,6 +105,8 @@ ready loadMindSpaceConfig, updateMindSpaceConfig, memoryV2ConfigService, + personalMemoryCandidateStore, + skillRuntimeConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, @@ -128,6 +130,8 @@ ready loadMindSpaceConfig, updateMindSpaceConfig, memoryV2ConfigService, + personalMemoryCandidateStore, + skillRuntimeConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, diff --git a/src/App.tsx b/src/App.tsx index e298a12..50ff2db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import { UsersPage } from './admin/pages/UsersPage'; import { WechatPage } from './admin/pages/WechatPage'; import { AssetGatewayPage } from './admin/pages/AssetGatewayPage'; import { BlockedWordsPage } from './admin/pages/BlockedWordsPage'; +import { SkillRuntimePage } from './admin/pages/SkillRuntimePage'; import { defaultHomePath } from './lib/routes'; import { OpsLayout } from './ops/components/OpsLayout'; import { AnalyticsPage } from './ops/pages/AnalyticsPage'; @@ -121,6 +122,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void } } /> } /> } /> + } /> } /> } /> } /> @@ -168,6 +170,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) { || pathname.startsWith('/policies') || pathname.startsWith('/mindspace') || pathname.startsWith('/memory-v2') + || pathname.startsWith('/skill-runtime') || pathname.startsWith('/providers') || pathname.startsWith('/wechat') || pathname.startsWith('/asset-gateway') diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 42d14e9..f3e3c02 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -29,6 +29,7 @@ const NAV_SECTIONS: NavSection[] = [ { to: '/wechat', label: '服务号' }, { to: '/mindspace', label: 'MindSpace 配置' }, { to: '/memory-v2', label: 'Memory V2' }, + { to: '/skill-runtime', label: 'Skill Runtime' }, { to: '/system-tests', label: '系统测试验证' }, { to: '/capabilities', label: '能力' }, { to: '/skills', label: '技能' }, diff --git a/src/admin/pages/DashboardPage.tsx b/src/admin/pages/DashboardPage.tsx index d1220e9..300f5a5 100644 --- a/src/admin/pages/DashboardPage.tsx +++ b/src/admin/pages/DashboardPage.tsx @@ -12,6 +12,7 @@ const QUICK_LINKS = [ { to: '/asset-gateway', label: '资产能力', desc: '可插拔素材插件、Provider 与专属 LLM 选择' }, { to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' }, { to: '/memory-v2', label: 'Memory V2', desc: '长期记忆 backend 与前置路由开关' }, + { to: '/skill-runtime', label: 'Skill Runtime', desc: 'H5 manifest 关键词路由开关' }, { to: '/system-tests', label: '系统测试验证', desc: '选择测试账号执行联调并汇总问题反馈' }, { to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' }, ] as const; diff --git a/src/admin/pages/MemoryV2Page.tsx b/src/admin/pages/MemoryV2Page.tsx index e805505..b64569b 100644 --- a/src/admin/pages/MemoryV2Page.tsx +++ b/src/admin/pages/MemoryV2Page.tsx @@ -1,8 +1,11 @@ import { useCallback, useEffect, useState, type ChangeEvent } from 'react'; import { getMemoryV2AdminConfig, + getMemoryV2RuntimeStatus, + listPersonalMemoryCandidates, listMemoryV2ModelOptions, updateMemoryV2AdminConfig, + reviewPersonalMemoryCandidate, } from '../../api/client'; import type { LlmGlobalSettings, @@ -10,6 +13,8 @@ import type { MemoryV2AdminConfig, MemoryV2AdminConfigResponse, MemoryV2ModelApiType, + MemoryV2RuntimeStatusResponse, + PersonalMemoryCandidateListResponse, } from '../../types'; type BackendKey = @@ -24,6 +29,154 @@ type BackendKey = type ModelBackendKey = 'mem0' | 'letta' | 'langgraph'; +type CapabilityKey = + | 'candidateMemory' + | 'policy' + | 'retriever' + | 'lifecycle' + | 'persona' + | 'graph' + | 'userMemory' + | 'pluginHealth'; + +type CapabilityField = { + key: string; + label: string; + type: 'boolean' | 'number' | 'select'; + options?: Array<{ value: string; label: string }>; +}; + +const CAPABILITIES: Array<{ + key: CapabilityKey; + label: string; + icon: string; + description: string; + fields: CapabilityField[]; +}> = [ + { + key: 'candidateMemory', + label: '候选记忆', + icon: 'C', + description: '控制候选提取模式、接纳阈值与待处理数量。', + fields: [ + { key: 'enabled', label: '启用候选记忆', type: 'boolean' }, + { key: 'mode', label: '运行模式', type: 'select', options: [ + { value: 'off', label: 'Off' }, + { value: 'shadow', label: 'Shadow' }, + { value: 'canary', label: 'Canary' }, + { value: 'active', label: 'Active' }, + ] }, + { key: 'minImportance', label: '最低重要度', type: 'number' }, + { key: 'minConfidence', label: '最低置信度', type: 'number' }, + { key: 'maxPending', label: '最大待处理数', type: 'number' }, + { key: 'persistenceEnabled', label: '持久化到 MySQL', type: 'boolean' }, + ], + }, + { + 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: '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' }, + ], + }, +]; + const BACKENDS: Array<{ key: BackendKey; label: string; @@ -161,6 +314,14 @@ function defaultConfig(): MemoryV2AdminConfig { timeoutMs: '1500', fallbackRoute: 'agent_orchestration', }, + candidateMemory: { enabled: false, mode: 'off', minImportance: '0.7', minConfidence: '0.8', maxPending: '500', persistenceEnabled: false }, + policy: { enabled: false, saveExplicit: true, rejectSensitive: true, requireEvidence: true, retentionDays: '365' }, + retriever: { enabled: false, episodicEnabled: true, 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: {}, @@ -177,6 +338,14 @@ function normalizeConfig(config?: Partial | null): MemoryV2 return { global: { ...base.global, ...(config?.global ?? {}) }, chatIntentRouter: { ...base.chatIntentRouter, ...(config?.chatIntentRouter ?? {}) }, + candidateMemory: { ...base.candidateMemory, ...(config?.candidateMemory ?? {}) }, + policy: { ...base.policy, ...(config?.policy ?? {}) }, + retriever: { ...base.retriever, ...(config?.retriever ?? {}) }, + lifecycle: { ...base.lifecycle, ...(config?.lifecycle ?? {}) }, + persona: { ...base.persona, ...(config?.persona ?? {}) }, + graph: { ...base.graph, ...(config?.graph ?? {}) }, + userMemory: { ...base.userMemory, ...(config?.userMemory ?? {}) }, + pluginHealth: { ...base.pluginHealth, ...(config?.pluginHealth ?? {}) }, pgvector: { ...base.pgvector, ...(config?.pgvector ?? {}) }, qdrant: { ...base.qdrant, ...(config?.qdrant ?? {}) }, weaviate: { ...base.weaviate, ...(config?.weaviate ?? {}) }, @@ -234,12 +403,16 @@ function describeEffectiveModelSource( export function MemoryV2Page() { const [payload, setPayload] = useState(null); + const [runtimeStatus, setRuntimeStatus] = useState(null); + const [candidatePayload, setCandidatePayload] = useState(null); + const [candidateError, setCandidateError] = useState(null); + const [candidateBusyId, setCandidateBusyId] = useState(null); const [draft, setDraft] = useState(defaultConfig()); const [providerKeys, setProviderKeys] = useState([]); const [globalModelSettings, setGlobalModelSettings] = useState(null); const [supportedApiTypes, setSupportedApiTypes] = useState(['chat', 'response']); const [loading, setLoading] = useState(true); - const [busyScope, setBusyScope] = useState<'global' | 'chatIntentRouter' | BackendKey | 'reload' | null>(null); + const [busyScope, setBusyScope] = useState<'global' | 'chatIntentRouter' | CapabilityKey | BackendKey | 'reload' | null>(null); const [error, setError] = useState(null); const [message, setMessage] = useState(null); const [expandedBackends, setExpandedBackends] = useState>({}); @@ -250,15 +423,24 @@ export function MemoryV2Page() { setError(null); setMessage(null); try { - const [configResult, modelOptions] = await Promise.all([ + const [configResult, modelOptions, statusResult] = await Promise.all([ getMemoryV2AdminConfig(), listMemoryV2ModelOptions(), + getMemoryV2RuntimeStatus().catch(() => null), ]); setPayload(configResult); setDraft(normalizeConfig(configResult.config)); setProviderKeys(modelOptions.keys.filter((item) => item.status === 'active')); setGlobalModelSettings(modelOptions.global); setSupportedApiTypes(modelOptions.supportedApiTypes); + setRuntimeStatus(statusResult); + try { + setCandidatePayload(await listPersonalMemoryCandidates()); + setCandidateError(null); + } catch (candidateErr) { + setCandidatePayload(null); + setCandidateError(candidateErr instanceof Error ? candidateErr.message : '候选记忆加载失败'); + } } catch (err) { setError(err instanceof Error ? err.message : '加载 Memory V2 配置失败'); } finally { @@ -309,6 +491,22 @@ export function MemoryV2Page() { })); }; + const updateCapabilityFlag = (capability: CapabilityKey, key: string) => (event: ChangeEvent) => { + setDraft((current) => ({ + ...current, + [capability]: { ...current[capability], [key]: event.target.checked }, + })); + }; + + const updateCapabilityValue = (capability: CapabilityKey, key: string) => ( + event: ChangeEvent, + ) => { + setDraft((current) => ({ + ...current, + [capability]: { ...current[capability], [key]: event.target.value }, + })); + }; + const updateBackendValue = (backend: BackendKey, field: string) => ( event: ChangeEvent, ) => { @@ -368,7 +566,7 @@ export function MemoryV2Page() { const saveConfigSection = async ( patch: Partial, - scope: 'global' | 'chatIntentRouter' | BackendKey, + scope: 'global' | 'chatIntentRouter' | CapabilityKey | BackendKey, successMessage: string, ) => { setBusyScope(scope); @@ -408,6 +606,28 @@ export function MemoryV2Page() { ); }; + const saveCapabilityConfig = async (capability: CapabilityKey) => { + await saveConfigSection( + { [capability]: draft[capability] }, + capability, + `${CAPABILITIES.find((item) => item.key === capability)?.label ?? capability} 配置已保存。`, + ); + }; + + const reviewCandidate = async (id: string, action: 'accept' | 'reject') => { + setCandidateBusyId(id); + setCandidateError(null); + try { + await reviewPersonalMemoryCandidate(id, action); + setCandidatePayload(await listPersonalMemoryCandidates()); + setMessage(action === 'accept' ? '候选记忆已接纳。' : '候选记忆已拒绝。'); + } catch (err) { + setCandidateError(err instanceof Error ? err.message : '候选记忆处理失败'); + } finally { + setCandidateBusyId(null); + } + }; + const current = payload?.config ? normalizeConfig(payload.config) : draft; const routerSection = draft.chatIntentRouter; const currentRouterSection = current.chatIntentRouter; @@ -658,6 +878,165 @@ export function MemoryV2Page() { +

Personal Memory 能力管理

+

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

+
+ {CAPABILITIES.map((capability, index) => { + const section = draft[capability.key]; + const currentSection = current[capability.key]; + const enabled = Boolean(section.enabled); + const accent = BACKEND_ACCENTS[index % BACKEND_ACCENTS.length]; + return ( +
+
+ +
+

{capability.label}

+

{capability.description}

+
+ + {enabled ? '配置已启用' : '配置未启用'} + +
+ +
+ {capability.fields.map((field) => { + if (field.type === 'boolean') { + return ( + + ); + } + if (field.type === 'select') { + return ( + + ); + } + return ( + + ); + })} +
+ +
+ {capability.key === 'pluginHealth' && ( +
+ 运行时状态 + {runtimeStatus?.memory?.personalMemory ? ( + + {String(runtimeStatus.memory.personalMemory.health?.state ?? 'unknown')} + {' · '}模式 {runtimeStatus.memory.personalMemory.effectiveMode ?? 'off'} + {' · '}候选 {runtimeStatus.memory.personalMemory.pendingCandidates ?? 0} + {' · '}接纳 {runtimeStatus.memory.personalMemory.health?.accepted ?? 0} + {' · '}拒绝 {runtimeStatus.memory.personalMemory.health?.rejected ?? 0} + {' · '}去重 {runtimeStatus.memory.personalMemory.health?.deduped ?? 0} + + ) : ( + 主站尚未加载 Shadow Pipeline,或运行时状态暂不可用 + )} +
+ )} + + 当前保存:{Boolean(currentSection.enabled) ? '启用' : '关闭'} + + +
+
+ ); + })} +
+ +

候选记忆审核

+
+
+ +
+

待审核候选

+

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

+
+ +
+ + {candidateError &&

{candidateError}

} + {!candidateError && (candidatePayload?.items.length ?? 0) === 0 && ( +

当前没有待审核候选。Shadow Pipeline 观察到稳定决策、偏好或目标后会写入这里。

+ )} +
+ {(candidatePayload?.items ?? []).map((candidate) => ( +
+
+ {candidate.memoryType} + 用户 {candidate.userId} + 重要度 {candidate.importance.toFixed(2)} + 置信度 {candidate.confidence.toFixed(2)} + {new Date(candidate.createdAt).toLocaleString('zh-CN', { hour12: false })} +
+

{candidate.content}

+ Policy: {candidate.policyReason} · Evidence: {String(candidate.evidence?.sourceId ?? 'unknown')} +
+ + +
+
+ ))} +
+
+

Backend 配置

单独启用后,只有当全局默认 backend 指向它时才会成为主读写目标。未启用的 backend 默认折叠,点击标题可展开。 diff --git a/src/admin/pages/SkillRuntimePage.tsx b/src/admin/pages/SkillRuntimePage.tsx new file mode 100644 index 0000000..48e32d0 --- /dev/null +++ b/src/admin/pages/SkillRuntimePage.tsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + getSkillRuntimeAdminConfig, + listSkillRuntimeCatalog, + updateSkillRuntimeAdminConfig, +} from '../../api/client'; +import type { SkillRuntimeAdminConfig, SkillRuntimeCatalogItem } from '../../types'; + +export function SkillRuntimePage() { + const [config, setConfig] = useState({ + router: { v2Enabled: false, manifestRoutingEnabled: false }, + }); + const [catalog, setCatalog] = useState([]); + const [source, setSource] = useState('default'); + const [updatedAt, setUpdatedAt] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [configResult, catalogRows] = await Promise.all([ + getSkillRuntimeAdminConfig(), + listSkillRuntimeCatalog(), + ]); + setConfig(configResult.config); + setSource(configResult.source ?? 'default'); + setUpdatedAt(configResult.updatedAt ?? null); + setCatalog(catalogRows); + } catch (err) { + setError(err instanceof Error ? err.message : '加载 Skill Runtime 配置失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const handleSave = async () => { + setSaving(true); + setError(null); + setNotice(null); + try { + const result = await updateSkillRuntimeAdminConfig(config); + setConfig(result.config); + setSource(result.source ?? 'admin-db'); + setUpdatedAt(result.updatedAt ?? null); + setNotice('Skill Runtime 配置已保存。H5 用户刷新或重新登录后会读取新开关。'); + } catch (err) { + setError(err instanceof Error ? err.message : '保存失败'); + } finally { + setSaving(false); + } + }; + + const manifestSkillCount = catalog.filter((item) => item.hasManifest && item.triggerKeywords.length > 0).length; + + return ( +

+
+

Skill Runtime

+

控制 H5 Skill Router v2 与 manifest 关键词路由(默认关闭)

+
+ + {loading ?

加载中…

: null} + {error ?

{error}

: null} + {notice ?

{notice}

: null} + + {!loading ? ( + <> +
+

Router 开关

+

+ 当前来源:{source} + {updatedAt + ? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}` + : ''} +

+

+ 开启后仅对已授予 skill 的用户生效;manifest 匹配失败会回退 legacy 路由。 +

+ + + + + +
+ + +
+
+ +
+

技能 Catalog 预览

+

+ 「Manifest」表示该 skill 目录下是否存在 skill.yaml(Router v2 关键词路由配置)。 + 目前 Phase 1 仅 product-campaign-page 做了 manifest 试点;其余 skill 仍只有 legacy{' '} + SKILL.md,因此 Manifest 显示「否」是正常现象。 +

+

有 skill.yaml 且配置了关键词的条目会在 Router v2 开启后参与匹配。

+
+ + + + + + + + + + + + {catalog.map((item) => ( + + + + + + + + ))} + +
SkillManifest关键词Prompt优先级
+ {item.name} +
{item.description}
+
{item.hasManifest ? '是' : '否'}{item.triggerKeywords.length ? item.triggerKeywords.join('、') : '—'}{item.routerPromptKey ?? '—'}{item.routerPriority || '—'}
+
+
+ + ) : null} +
+ ); +} diff --git a/src/api/client.ts b/src/api/client.ts index e438f17..0b5dcd1 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -25,6 +25,7 @@ import type { LlmProviderKeyRow, PlanDefinition, PlanSyncResult, + PersonalMemoryCandidateListResponse, PolicyDefinition, PolicyMap, PortalUser, @@ -32,6 +33,10 @@ import type { MemoryV2AdminConfig, MemoryV2AdminConfigResponse, MemoryV2ModelApiType, + MemoryV2RuntimeStatusResponse, + SkillRuntimeAdminConfig, + SkillRuntimeAdminConfigResponse, + SkillRuntimeCatalogItem, SkillDefinition, SkillMap, UsageRecord, @@ -327,6 +332,25 @@ export async function getMemoryV2AdminConfig(): Promise { + return portalFetch('/admin-api/memory-v2/status'); +} + +export async function listPersonalMemoryCandidates( + status = 'candidate', +): Promise { + return portalFetch(`/admin-api/memory-v2/candidates?status=${encodeURIComponent(status)}&limit=50`); +} + +export async function reviewPersonalMemoryCandidate( + id: string, + action: 'accept' | 'reject', +): Promise<{ updated: boolean; status: string; reviewedAt: number }> { + return portalFetch(`/admin-api/memory-v2/candidates/${encodeURIComponent(id)}/${action}`, { + method: 'POST', + }); +} + export async function updateMemoryV2AdminConfig( payload: Partial, ): Promise { @@ -336,6 +360,24 @@ export async function updateMemoryV2AdminConfig( }); } +export async function getSkillRuntimeAdminConfig(): Promise { + return portalFetch('/admin-api/skill-runtime/config'); +} + +export async function updateSkillRuntimeAdminConfig( + config: SkillRuntimeAdminConfig, +): Promise { + return portalFetch('/admin-api/skill-runtime/config', { + method: 'PUT', + body: JSON.stringify({ config }), + }); +} + +export async function listSkillRuntimeCatalog(): Promise { + const result = await portalFetch<{ catalog: SkillRuntimeCatalogItem[] }>('/admin-api/skill-runtime/catalog'); + return result.catalog ?? []; +} + export async function listMemoryV2ModelOptions(): Promise<{ global: LlmGlobalSettings; keys: LlmProviderKeyRow[]; diff --git a/src/index.css b/src/index.css index b9b9883..1fd88e8 100644 --- a/src/index.css +++ b/src/index.css @@ -1217,6 +1217,10 @@ body, grid-template-columns: repeat(2, minmax(0, 1fr)); } +.memory-v2-capability-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .memory-v2-page .asset-plugin-grid { gap: 12px; margin-bottom: 12px; @@ -1322,18 +1326,69 @@ body, font-size: 12px; } +.memory-v2-runtime-health { + display: grid; + gap: 3px; + flex: 1 1 100%; + color: var(--color-text-muted); + font-size: 11px; +} + +.memory-v2-runtime-health strong { + color: var(--color-text-primary); + font-size: 12px; +} + +.memory-v2-candidate-review { + margin-bottom: 12px; +} + +.memory-v2-candidate-list { + display: grid; + gap: 10px; +} + +.memory-v2-candidate-item { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, .02); +} + +.memory-v2-candidate-item p { + margin: 0; + line-height: 1.6; +} + +.memory-v2-candidate-item small { + color: var(--color-text-muted); +} + +.memory-v2-candidate-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + align-items: center; + color: var(--color-text-muted); + font-size: 11px; +} + .memory-v2-page .model-center-card-note { margin: 0; } @media (min-width: 1280px) { - .memory-v2-backend-grid { + .memory-v2-backend-grid, + .memory-v2-capability-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 900px) { - .memory-v2-backend-grid { + .memory-v2-backend-grid, + .memory-v2-capability-grid { grid-template-columns: minmax(0, 1fr); } } diff --git a/src/types.ts b/src/types.ts index d2a8d3e..1c1062b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -296,6 +296,14 @@ export type MemoryV2AdminConfig = { failOpen?: boolean; }; chatIntentRouter: MemoryV2AdminSection; + candidateMemory: MemoryV2AdminSection; + policy: MemoryV2AdminSection; + retriever: MemoryV2AdminSection; + lifecycle: MemoryV2AdminSection; + persona: MemoryV2AdminSection; + graph: MemoryV2AdminSection; + userMemory: MemoryV2AdminSection; + pluginHealth: MemoryV2AdminSection; pgvector: MemoryV2AdminSection; qdrant: MemoryV2AdminSection; weaviate: MemoryV2AdminSection; @@ -312,6 +320,78 @@ export type MemoryV2AdminConfigResponse = { updatedBy: string | null; }; +export type MemoryV2RuntimeStatusResponse = { + ok: boolean; + checkedAt?: number; + message?: string; + memory?: { + enabled?: boolean; + backend?: string; + selectedBackend?: string | null; + configSource?: string; + configUpdatedAt?: number | null; + personalMemory?: { + enabled?: boolean; + requestedMode?: string; + effectiveMode?: string; + phase?: string; + injectionEnabled?: boolean; + persistence?: string; + pendingCandidates?: number; + health?: Record; + policy?: Record; + }; + } | null; +}; + +export type PersonalMemoryCandidate = { + id: string; + userId: string; + sessionId: string | null; + memoryType: string; + content: string; + importance: number; + confidence: number; + status: string; + policyReason: string; + evidence: Record; + reviewedBy: string | null; + reviewedAt: number | null; + createdAt: number; + updatedAt: number; +}; + +export type PersonalMemoryCandidateListResponse = { + items: PersonalMemoryCandidate[]; + counts: Record; +}; + +export type SkillRuntimeAdminConfig = { + router: { + v2Enabled: boolean; + manifestRoutingEnabled: boolean; + }; +}; + +export type SkillRuntimeAdminConfigResponse = { + config: SkillRuntimeAdminConfig; + updatedAt: number | null; + updatedBy: string | null; + source?: string; +}; + +export type SkillRuntimeCatalogItem = { + name: string; + dirName: string; + description: string; + version: string | null; + executors: string[]; + hasManifest: boolean; + triggerKeywords: string[]; + routerPromptKey: string | null; + routerPriority: number; +}; + export type AdminSystemTestStepStatus = 'passed' | 'warning' | 'failed'; export type AdminSystemTestStep = { From 7df3ae35f19c27fe32172c84c041ab4d5f6e4a46 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 13 Jul 2026 14:16:57 +0800 Subject: [PATCH 2/3] feat(admin): surface Memory V2 candidate auto-review in management UI Clarify active/canary/shadow modes and show auto-review metrics so operators do not need to manually approve every user memory. Co-authored-by: Cursor --- src/admin/pages/MemoryV2Page.tsx | 19 ++++++++++++------- src/types.ts | 1 + 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/admin/pages/MemoryV2Page.tsx b/src/admin/pages/MemoryV2Page.tsx index b64569b..b86d6eb 100644 --- a/src/admin/pages/MemoryV2Page.tsx +++ b/src/admin/pages/MemoryV2Page.tsx @@ -57,14 +57,14 @@ const CAPABILITIES: Array<{ key: 'candidateMemory', label: '候选记忆', icon: 'C', - description: '控制候选提取模式、接纳阈值与待处理数量。', + description: '控制候选提取模式、自动接纳阈值与待处理数量。', fields: [ { key: 'enabled', label: '启用候选记忆', type: 'boolean' }, { key: 'mode', label: '运行模式', type: 'select', options: [ - { value: 'off', label: 'Off' }, - { value: 'shadow', label: 'Shadow' }, - { value: 'canary', label: 'Canary' }, - { value: 'active', label: 'Active' }, + { 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' }, @@ -314,7 +314,7 @@ function defaultConfig(): MemoryV2AdminConfig { timeoutMs: '1500', fallbackRoute: 'agent_orchestration', }, - candidateMemory: { enabled: false, mode: 'off', minImportance: '0.7', minConfidence: '0.8', maxPending: '500', persistenceEnabled: false }, + candidateMemory: { enabled: false, mode: 'active', minImportance: '0.7', minConfidence: '0.8', maxPending: '500', persistenceEnabled: false }, policy: { enabled: false, saveExplicit: true, rejectSensitive: true, requireEvidence: true, retentionDays: '365' }, retriever: { enabled: false, episodicEnabled: true, 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' }, @@ -954,8 +954,10 @@ export function MemoryV2Page() { {String(runtimeStatus.memory.personalMemory.health?.state ?? 'unknown')} {' · '}模式 {runtimeStatus.memory.personalMemory.effectiveMode ?? 'off'} + {runtimeStatus.memory.personalMemory.autoReviewEnabled ? ' · 自动审核' : ' · 人工审核'} {' · '}候选 {runtimeStatus.memory.personalMemory.pendingCandidates ?? 0} {' · '}接纳 {runtimeStatus.memory.personalMemory.health?.accepted ?? 0} + {' · '}自动接纳 {runtimeStatus.memory.personalMemory.health?.autoReviewed ?? 0} {' · '}拒绝 {runtimeStatus.memory.personalMemory.health?.rejected ?? 0} {' · '}去重 {runtimeStatus.memory.personalMemory.health?.deduped ?? 0} @@ -992,6 +994,9 @@ export function MemoryV2Page() { {' · '}已接纳 {candidatePayload?.counts?.accepted ?? 0} {' · '}已拒绝 {candidatePayload?.counts?.rejected ?? 0}

+

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

+ {message &&

{message}

} +

配置来源:{meta.source === 'admin' ? '数据库' : '环境变量默认值'};最后更新:{meta.updatedAt ? new Date(meta.updatedAt).toLocaleString() : '尚未保存'}{meta.updatedBy ? `;操作人:${meta.updatedBy}` : ''}

+ + + ); +} diff --git a/src/api/client.ts b/src/api/client.ts index 0b5dcd1..a81fddb 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -47,6 +47,7 @@ import type { WechatScheduleLlmConfig, WechatMessage, WechatWebNotification, + MindSearchConfig, } from '../types'; export class ApiError extends Error { @@ -653,6 +654,14 @@ export async function clearUserCapabilityOverrides(userId: string): Promise { + return portalFetch('/admin-api/mindsearch/config'); +} + +export async function updateMindSearchConfig(config: Partial): Promise<{ config: MindSearchConfig; source: string }> { + return portalFetch('/admin-api/mindsearch/config', { method: 'PATCH', body: JSON.stringify(config) }); +} + // ── Policies ────────────────────────────────────────── export async function listPolicyCatalog(): Promise { diff --git a/src/types.ts b/src/types.ts index 6b57b2b..dc9341e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -597,3 +597,10 @@ export type AdminSubscription = { note: string | null; createdAt: number; }; + +export type MindSearchConfig = { + enabled: boolean; + mode: 'off' | 'shadow' | 'assist'; + providers: { searxng: boolean; github: boolean; reader: boolean }; + settings: { searxngEndpoint: string; maxResults: number; timeoutMs: number; readerMaxChars: number }; +};