diff --git a/server/app.mjs b/server/app.mjs index 88c7fe4..f52e986 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -98,6 +98,9 @@ export function createAdminApp(services) { loadMindSpaceConfig, updateMindSpaceConfig, memoryV2ConfigService, + mindSearchConfigService, + personalMemoryCandidateStore, + skillRuntimeConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, @@ -366,6 +369,131 @@ export function createAdminApp(services) { res.json(result); }); + adminApi.get('/mindsearch/config', requireAdmin, async (_req, res) => { + if (!mindSearchConfigService?.getAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' }); + res.json(await mindSearchConfigService.getAdminConfig()); + }); + + adminApi.patch('/mindsearch/config', requireAdmin, async (req, res) => { + if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' }); + res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id })); + }); + + adminApi.put('/mindsearch/config', requireAdmin, async (req, res) => { + if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' }); + res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id })); + }); + + adminApi.get('/mindsearch/runtime', requireAdmin, async (_req, res) => { + if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置未启用' }); + res.json(await mindSearchConfigService.getRuntimeState()); + }); + + 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..cb603c2 100644 --- a/server/bootstrap.mjs +++ b/server/bootstrap.mjs @@ -29,6 +29,9 @@ export async function bootstrapAdminServices() { updateMindSpaceConfig, } = await importMemind('mindspace-config.mjs'); const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs'); + const { createMindSearchConfigService } = await importMemind('mindsearch-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 +96,13 @@ export async function bootstrapAdminServices() { const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, { env: process.env, }); + const mindSearchConfigService = createMindSearchConfigService(pool, { env: process.env }); + await mindSearchConfigService.ensureSchema(); + const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool); + const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { + env: process.env, + h5Root, + }); const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, { env: process.env, }); @@ -145,6 +155,9 @@ export async function bootstrapAdminServices() { loadMindSpaceConfig, updateMindSpaceConfig, memoryV2ConfigService, + mindSearchConfigService, + personalMemoryCandidateStore, + skillRuntimeConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, diff --git a/server/index.mjs b/server/index.mjs index 4f96cdf..9f314d2 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -105,6 +105,9 @@ ready loadMindSpaceConfig, updateMindSpaceConfig, memoryV2ConfigService, + mindSearchConfigService, + personalMemoryCandidateStore, + skillRuntimeConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, @@ -128,6 +131,9 @@ ready loadMindSpaceConfig, updateMindSpaceConfig, memoryV2ConfigService, + mindSearchConfigService, + personalMemoryCandidateStore, + skillRuntimeConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, diff --git a/src/App.tsx b/src/App.tsx index e298a12..bbb91c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { DashboardPage } from './admin/pages/DashboardPage'; import { PoliciesPage } from './admin/pages/PoliciesPage'; import { MindSpacePage } from './admin/pages/MindSpacePage'; import { MemoryV2Page } from './admin/pages/MemoryV2Page'; +import { MindSearchPage } from './admin/pages/MindSearchPage'; import { ProvidersPage } from './admin/pages/ProvidersPage'; import { SkillsPage } from './admin/pages/SkillsPage'; import { SystemTestsPage } from './admin/pages/SystemTestsPage'; @@ -16,6 +17,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 +123,8 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void } } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -168,6 +172,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..a411b20 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -29,6 +29,8 @@ const NAV_SECTIONS: NavSection[] = [ { to: '/wechat', label: '服务号' }, { to: '/mindspace', label: 'MindSpace 配置' }, { to: '/memory-v2', label: 'Memory V2' }, + { to: '/mindsearch', label: 'MindSearch' }, + { 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..b86d6eb 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: '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: '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: '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' }, + 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,170 @@ 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.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} + + ) : ( + 主站尚未加载 Shadow Pipeline,或运行时状态暂不可用 + )} +
+ )} + + 当前保存:{Boolean(currentSection.enabled) ? '启用' : '关闭'} + + +
+
+ ); + })} +
+ +

候选记忆审核

+
+
+ +
+

待审核候选

+

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

+

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

+
+ +
+ + {candidateError &&

{candidateError}

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

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

+ )} +
+ {(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/MindSearchPage.tsx b/src/admin/pages/MindSearchPage.tsx new file mode 100644 index 0000000..71c5d28 --- /dev/null +++ b/src/admin/pages/MindSearchPage.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import { getMindSearchConfig, updateMindSearchConfig } from '../../api/client'; +import type { MindSearchConfig } from '../../types'; + +const initial: MindSearchConfig = { + enabled: false, + mode: 'off', + providers: { searxng: false, github: false, reader: false }, + settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 }, +}; + +export function MindSearchPage() { + const [config, setConfig] = useState(initial); + const [meta, setMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({}); + const [message, setMessage] = useState('加载配置中…'); + + useEffect(() => { + getMindSearchConfig() + .then((result) => { + setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } }); + setMeta(result); + setMessage(''); + }) + .catch((error) => setMessage(error instanceof Error ? error.message : '配置加载失败')); + }, []); + + const save = async () => { + setMessage('保存中…'); + try { + const result = await updateMindSearchConfig(config); + setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } }); + setMeta(result); + setMessage('已保存到数据库;新会话生效,旧会话不受影响'); + } catch (error) { + setMessage(error instanceof Error ? error.message : '保存失败'); + } + }; + + const toggleProvider = (key: keyof MindSearchConfig['providers']) => + setConfig((current) => ({ ...current, providers: { ...current.providers, [key]: !current.providers[key] } })); + const updateSetting = (key: keyof MindSearchConfig['settings'], value: string | number) => + setConfig((current) => ({ ...current, settings: { ...current.settings, [key]: value } })); + + return ( +

+

MindSearch

可插拔外部搜索增强。关闭时完全保持原有搜索、记忆和上下文链路。

+
+

总开关与模式

+ + +

Provider

+
可用 Provider + + + +
+

运行参数

+ + + + + + {message &&

{message}

} +

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

+
+
+ ); +} 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..a81fddb 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, @@ -42,6 +47,7 @@ import type { WechatScheduleLlmConfig, WechatMessage, WechatWebNotification, + MindSearchConfig, } from '../types'; export class ApiError extends Error { @@ -327,6 +333,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 +361,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[]; @@ -611,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/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..dc9341e 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,79 @@ 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; + autoReviewEnabled?: boolean; + 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 = { @@ -516,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 }; +};