diff --git a/server/app.mjs b/server/app.mjs index ce3c28f..f5754e5 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -96,6 +96,7 @@ export function createAdminApp(services) { wechatAdmin, loadMindSpaceConfig, updateMindSpaceConfig, + memoryV2ConfigService, wordFilterService, planCatalogService, subscriptionService, @@ -301,6 +302,19 @@ export function createAdminApp(services) { res.json({ config: result }); }); + adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => { + if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' }); + res.json(await memoryV2ConfigService.getAdminConfig()); + }); + + adminApi.patch('/memory-v2/config', requireAdmin, async (req, res) => { + if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' }); + const result = await memoryV2ConfigService.updateAdminConfig(req.body ?? {}, { + updatedBy: req.currentUser.id, + }); + res.json(result); + }); + adminApi.get('/wechat/bindings', requireAdmin, async (req, res) => { if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); res.json(await wechatAdmin.listBindings(req.query)); diff --git a/server/bootstrap.mjs b/server/bootstrap.mjs index 557ee13..59914f2 100644 --- a/server/bootstrap.mjs +++ b/server/bootstrap.mjs @@ -27,6 +27,7 @@ export async function bootstrapAdminServices() { loadMindSpaceConfig, updateMindSpaceConfig, } = await importMemind('mindspace-config.mjs'); + const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs'); const { createOpsApi } = await importMemind('admin-routes.mjs'); const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs'); const { @@ -82,6 +83,9 @@ export async function bootstrapAdminServices() { apiTarget, apiSecret, }); + const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, { + env: process.env, + }); const wechatMpConfig = loadWechatMpConfig(); const wechatMpService = createWechatMpService({ config: wechatMpConfig, @@ -122,6 +126,7 @@ export async function bootstrapAdminServices() { mindSpaceConfig, loadMindSpaceConfig, updateMindSpaceConfig, + memoryV2ConfigService, wordFilterService, planCatalogService, subscriptionService, diff --git a/server/index.mjs b/server/index.mjs index f66bd33..a708efd 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -103,6 +103,7 @@ ready wechatAdmin, loadMindSpaceConfig, updateMindSpaceConfig, + memoryV2ConfigService, wordFilterService, planCatalogService, subscriptionService, @@ -121,6 +122,7 @@ ready wechatAdmin, loadMindSpaceConfig, updateMindSpaceConfig, + memoryV2ConfigService, wordFilterService, planCatalogService, subscriptionService, diff --git a/src/App.tsx b/src/App.tsx index abd6cb5..265fcfa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { CapabilitiesPage } from './admin/pages/CapabilitiesPage'; 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 { ProvidersPage } from './admin/pages/ProvidersPage'; import { SkillsPage } from './admin/pages/SkillsPage'; import { UserDetailPage } from './admin/pages/UserDetailPage'; @@ -115,6 +116,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void } } /> } /> } /> + } /> } /> } /> } /> @@ -158,6 +160,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) { || pathname.startsWith('/skills') || pathname.startsWith('/policies') || pathname.startsWith('/mindspace') + || pathname.startsWith('/memory-v2') || pathname.startsWith('/providers') || pathname.startsWith('/wechat') ) { diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 67e30ef..ec98d80 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -28,6 +28,7 @@ const NAV_SECTIONS: NavSection[] = [ items: [ { to: '/wechat', label: '服务号' }, { to: '/mindspace', label: 'MindSpace 配置' }, + { to: '/memory-v2', label: 'Memory V2' }, { to: '/capabilities', label: '能力' }, { to: '/skills', label: '技能' }, { to: '/policies', label: '策略' }, diff --git a/src/admin/pages/DashboardPage.tsx b/src/admin/pages/DashboardPage.tsx index 5cbff92..30b29df 100644 --- a/src/admin/pages/DashboardPage.tsx +++ b/src/admin/pages/DashboardPage.tsx @@ -10,6 +10,7 @@ const QUICK_LINKS = [ { to: '/billing/recharge', label: '充值', desc: '为用户账户充值' }, { to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' }, { to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' }, + { to: '/memory-v2', label: 'Memory V2', desc: '统一配置长期记忆 backend 与开关' }, { to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' }, ] as const; diff --git a/src/admin/pages/MemoryV2Page.tsx b/src/admin/pages/MemoryV2Page.tsx new file mode 100644 index 0000000..05ca813 --- /dev/null +++ b/src/admin/pages/MemoryV2Page.tsx @@ -0,0 +1,524 @@ +import { useCallback, useEffect, useState, type ChangeEvent } from 'react'; +import { + getMemoryV2AdminConfig, + listMemoryV2ModelOptions, + updateMemoryV2AdminConfig, +} from '../../api/client'; +import type { + LlmGlobalSettings, + LlmProviderKeyRow, + MemoryV2AdminConfig, + MemoryV2AdminConfigResponse, + MemoryV2ModelApiType, +} from '../../types'; + +type BackendKey = + | 'pgvector' + | 'qdrant' + | 'weaviate' + | 'mem0' + | 'letta' + | 'neo4j' + | 'redisStreams' + | 'langgraph'; + +type ModelBackendKey = 'mem0' | 'letta' | 'langgraph'; + +const BACKENDS: Array<{ + key: BackendKey; + label: string; + fields: Array<{ key: string; label: string; secret?: boolean }>; +}> = [ + { + 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' }, + ], + }, +]; + +const MODEL_BACKENDS: ModelBackendKey[] = ['mem0', 'letta', 'langgraph']; +const MODEL_API_TYPES: Array<{ value: MemoryV2ModelApiType; label: string }> = [ + { value: 'chat', label: 'Chat API' }, + { value: 'response', label: 'Response API' }, +]; + +function defaultConfig(): MemoryV2AdminConfig { + return { + global: { + enabled: false, + backend: 'legacy', + profileEnabled: false, + eventLogEnabled: false, + vectorEnabled: false, + failOpen: true, + }, + pgvector: {}, + qdrant: {}, + weaviate: {}, + mem0: {}, + letta: {}, + neo4j: {}, + redisStreams: {}, + langgraph: {}, + }; +} + +function normalizeConfig(config?: Partial | null): MemoryV2AdminConfig { + const base = defaultConfig(); + return { + global: { ...base.global, ...(config?.global ?? {}) }, + pgvector: { ...base.pgvector, ...(config?.pgvector ?? {}) }, + qdrant: { ...base.qdrant, ...(config?.qdrant ?? {}) }, + weaviate: { ...base.weaviate, ...(config?.weaviate ?? {}) }, + mem0: { ...base.mem0, ...(config?.mem0 ?? {}) }, + letta: { ...base.letta, ...(config?.letta ?? {}) }, + neo4j: { ...base.neo4j, ...(config?.neo4j ?? {}) }, + redisStreams: { ...base.redisStreams, ...(config?.redisStreams ?? {}) }, + langgraph: { ...base.langgraph, ...(config?.langgraph ?? {}) }, + }; +} + +function isModelBackend(backend: BackendKey): backend is ModelBackendKey { + return MODEL_BACKENDS.includes(backend as ModelBackendKey); +} + +function dedupeStrings(values: Array): string[] { + return [...new Set(values.map((value) => String(value ?? '').trim()).filter(Boolean))]; +} + +function findProviderKey(keys: LlmProviderKeyRow[], keyId: string | null | undefined): LlmProviderKeyRow | null { + const resolvedKeyId = String(keyId ?? '').trim(); + if (!resolvedKeyId) return null; + return keys.find((key) => key.id === resolvedKeyId) ?? null; +} + +function providerLabel(key: LlmProviderKeyRow): string { + return `${key.name} · ${key.providerLabel}`; +} + +function modelOptionsForProvider( + providerKey: LlmProviderKeyRow | null, + globalSettings: LlmGlobalSettings | null, +): string[] { + if (!providerKey) { + return dedupeStrings([globalSettings?.globalModel, ...(globalSettings?.availableModels ?? [])]); + } + return dedupeStrings([providerKey.defaultModel, ...(providerKey.models ?? [])]); +} + +function describeEffectiveModelSource( + section: MemoryV2AdminConfig[ModelBackendKey], + providerKeys: LlmProviderKeyRow[], + globalSettings: LlmGlobalSettings | null, +): string { + const providerKey = findProviderKey(providerKeys, section.modelProviderKeyId); + const model = String(section.model ?? '').trim(); + const apiType = String(section.modelApiType ?? '').trim(); + const providerText = providerKey ? providerLabel(providerKey) : (globalSettings?.keyName + ? `${globalSettings.keyName} · ${globalSettings.providerLabel ?? '统一模型中心'}` + : '统一模型中心默认'); + const modelText = model || providerKey?.defaultModel || globalSettings?.globalModel || 'provider 默认模型'; + const apiText = apiType || '未指定 API 类型'; + return `${providerText} / ${modelText} / ${apiText}`; +} + +export function MemoryV2Page() { + const [payload, setPayload] = 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' | BackendKey | 'reload' | null>(null); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setBusyScope('reload'); + setError(null); + setMessage(null); + try { + const [configResult, modelOptions] = await Promise.all([ + getMemoryV2AdminConfig(), + listMemoryV2ModelOptions(), + ]); + setPayload(configResult); + setDraft(normalizeConfig(configResult.config)); + setProviderKeys(modelOptions.keys.filter((item) => item.status === 'active')); + setGlobalModelSettings(modelOptions.global); + setSupportedApiTypes(modelOptions.supportedApiTypes); + } catch (err) { + setError(err instanceof Error ? err.message : '加载 Memory V2 配置失败'); + } finally { + setLoading(false); + setBusyScope((current) => (current === 'reload' ? null : current)); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const updateGlobalFlag = (key: keyof MemoryV2AdminConfig['global']) => (event: ChangeEvent) => { + setDraft((current) => ({ + ...current, + global: { ...current.global, [key]: event.target.checked }, + })); + }; + + const updateGlobalText = (key: keyof MemoryV2AdminConfig['global']) => (event: ChangeEvent) => { + setDraft((current) => ({ + ...current, + global: { ...current.global, [key]: event.target.value }, + })); + }; + + const updateBackendFlag = (backend: BackendKey) => (event: ChangeEvent) => { + setDraft((current) => ({ + ...current, + [backend]: { ...current[backend], enabled: event.target.checked }, + })); + }; + + const updateBackendValue = (backend: BackendKey, field: string) => ( + event: ChangeEvent, + ) => { + setDraft((current) => ({ + ...current, + [backend]: { ...current[backend], [field]: event.target.value }, + })); + }; + + const updateModelProvider = (backend: ModelBackendKey) => (event: ChangeEvent) => { + const nextProviderKeyId = event.target.value; + const nextProviderKey = findProviderKey(providerKeys, nextProviderKeyId); + const nextOptions = modelOptionsForProvider(nextProviderKey, globalModelSettings); + setDraft((current) => { + const currentSection = current[backend]; + const currentModel = String(currentSection.model ?? '').trim(); + const nextModel = nextOptions.includes(currentModel) + ? currentModel + : String(nextProviderKey?.defaultModel ?? globalModelSettings?.globalModel ?? '').trim(); + return { + ...current, + [backend]: { + ...currentSection, + modelProviderKeyId: nextProviderKeyId, + model: nextModel, + }, + }; + }); + }; + + const applySavedConfig = (result: MemoryV2AdminConfigResponse, successMessage: string) => { + setPayload(result); + setDraft(normalizeConfig(result.config)); + setMessage(successMessage); + }; + + const saveConfigSection = async ( + patch: Partial, + scope: 'global' | BackendKey, + successMessage: string, + ) => { + setBusyScope(scope); + setError(null); + setMessage(null); + try { + const result = await updateMemoryV2AdminConfig(patch); + applySavedConfig(result, successMessage); + } catch (err) { + setError(err instanceof Error ? err.message : '保存 Memory V2 配置失败'); + } finally { + setBusyScope((current) => (current === scope ? null : current)); + } + }; + + const saveGlobalConfig = async () => { + await saveConfigSection( + { global: draft.global }, + 'global', + '全局 Memory V2 开关已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。', + ); + }; + + const saveBackendConfig = async (backend: BackendKey) => { + await saveConfigSection( + { [backend]: draft[backend] }, + backend, + `${BACKENDS.find((item) => item.key === backend)?.label ?? backend} 配置已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。`, + ); + }; + + const current = payload?.config ? normalizeConfig(payload.config) : draft; + + return ( +
+
+

Memory V2 配置

+

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

+
+ + {error &&

{error}

} + {message &&

{message}

} + +
+
+
+
+

全局开关

+

更新时间:{payload?.updatedAt ? new Date(payload.updatedAt).toLocaleString('zh-CN', { hour12: false }) : '未保存过数据库配置'}

+
+
+ +
+
+
+ + + + + + +
+ {!loading && ( +

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

+ )} +
+ + {BACKENDS.map((backend) => { + const section = draft[backend.key]; + const currentSection = current[backend.key]; + const providerKey = isModelBackend(backend.key) + ? findProviderKey(providerKeys, section.modelProviderKeyId) + : null; + const availableModels = isModelBackend(backend.key) + ? modelOptionsForProvider(providerKey, globalModelSettings) + : []; + + return ( +
+
+
+

{backend.label}

+

单独启用后,只有当全局默认 backend 指向它时才会成为主读写目标。

+
+
+ + +
+
+
+ {backend.fields.map((field) => { + const configured = Boolean(currentSection?.[`${field.key}Configured`]); + const masked = String(currentSection?.[`${field.key}Masked`] ?? ''); + return ( + + ); + })} +
+ + {isModelBackend(backend.key) && ( + <> +
+ + + +
+

+ 当前生效模型链路:{describeEffectiveModelSource(section, providerKeys, globalModelSettings)} +

+ {!providerKeys.length && ( +

+ 统一模型中心当前没有可用的激活 key,请先到“统一模型中心”配置 provider key 与模型列表。 +

+ )} + + )} +
+ ); + })} + +
+ +
+
+
+ ); +} diff --git a/src/api/client.ts b/src/api/client.ts index af50e5e..99b39ec 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -26,6 +26,9 @@ import type { PolicyMap, PortalUser, MindSpaceAdminConfig, + MemoryV2AdminConfig, + MemoryV2AdminConfigResponse, + MemoryV2ModelApiType, SkillDefinition, SkillMap, UsageRecord, @@ -278,6 +281,35 @@ export async function updateMindSpaceAdminConfig( return result.config; } +export async function getMemoryV2AdminConfig(): Promise { + return portalFetch('/admin-api/memory-v2/config'); +} + +export async function updateMemoryV2AdminConfig( + payload: Partial, +): Promise { + return portalFetch('/admin-api/memory-v2/config', { + method: 'PATCH', + body: JSON.stringify(payload), + }); +} + +export async function listMemoryV2ModelOptions(): Promise<{ + global: LlmGlobalSettings; + keys: LlmProviderKeyRow[]; + supportedApiTypes: MemoryV2ModelApiType[]; +}> { + const [global, keys] = await Promise.all([ + getLlmGlobalSettings(), + listLlmProviderKeys(), + ]); + return { + global, + keys, + supportedApiTypes: ['chat', 'response'], + }; +} + export async function listWechatBindings(params?: { search?: string; limit?: number; diff --git a/src/types.ts b/src/types.ts index 7104d5e..6fc31f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -255,6 +255,34 @@ export type MindSpaceAdminConfig = { publicPageLimit: number; }; +export type MemoryV2AdminSection = Record; +export type MemoryV2ModelApiType = 'chat' | 'response'; + +export type MemoryV2AdminConfig = { + global: { + enabled?: boolean; + backend?: string; + profileEnabled?: boolean; + eventLogEnabled?: boolean; + vectorEnabled?: boolean; + failOpen?: boolean; + }; + pgvector: MemoryV2AdminSection; + qdrant: MemoryV2AdminSection; + weaviate: MemoryV2AdminSection; + mem0: MemoryV2AdminSection; + letta: MemoryV2AdminSection; + neo4j: MemoryV2AdminSection; + redisStreams: MemoryV2AdminSection; + langgraph: MemoryV2AdminSection; +}; + +export type MemoryV2AdminConfigResponse = { + config: MemoryV2AdminConfig; + updatedAt: number | null; + updatedBy: string | null; +}; + export type WechatBinding = { userId: string; username: string;