feat: add memory v2 admin controls

This commit is contained in:
john
2026-07-03 09:34:42 +08:00
parent aa6a029530
commit 194e5ac1ef
9 changed files with 610 additions and 0 deletions
+14
View File
@@ -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));
+5
View File
@@ -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,
+2
View File
@@ -103,6 +103,7 @@ ready
wechatAdmin,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
wordFilterService,
planCatalogService,
subscriptionService,
@@ -121,6 +122,7 @@ ready
wechatAdmin,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
wordFilterService,
planCatalogService,
subscriptionService,
+3
View File
@@ -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 }
<Route path="skills" element={<SkillsPage />} />
<Route path="policies" element={<PoliciesPage />} />
<Route path="mindspace" element={<MindSpacePage />} />
<Route path="memory-v2" element={<MemoryV2Page />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="blocked-words" element={<BlockedWordsPage />} />
@@ -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')
) {
+1
View File
@@ -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: '策略' },
+1
View File
@@ -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;
+524
View File
@@ -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<MemoryV2AdminConfig> | 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 | null | undefined>): 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<MemoryV2AdminConfigResponse | null>(null);
const [draft, setDraft] = useState<MemoryV2AdminConfig>(defaultConfig());
const [providerKeys, setProviderKeys] = useState<LlmProviderKeyRow[]>([]);
const [globalModelSettings, setGlobalModelSettings] = useState<LlmGlobalSettings | null>(null);
const [supportedApiTypes, setSupportedApiTypes] = useState<MemoryV2ModelApiType[]>(['chat', 'response']);
const [loading, setLoading] = useState(true);
const [busyScope, setBusyScope] = useState<'global' | BackendKey | 'reload' | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(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<HTMLInputElement>) => {
setDraft((current) => ({
...current,
global: { ...current.global, [key]: event.target.checked },
}));
};
const updateGlobalText = (key: keyof MemoryV2AdminConfig['global']) => (event: ChangeEvent<HTMLSelectElement>) => {
setDraft((current) => ({
...current,
global: { ...current.global, [key]: event.target.value },
}));
};
const updateBackendFlag = (backend: BackendKey) => (event: ChangeEvent<HTMLInputElement>) => {
setDraft((current) => ({
...current,
[backend]: { ...current[backend], enabled: event.target.checked },
}));
};
const updateBackendValue = (backend: BackendKey, field: string) => (
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
setDraft((current) => ({
...current,
[backend]: { ...current[backend], [field]: event.target.value },
}));
};
const updateModelProvider = (backend: ModelBackendKey) => (event: ChangeEvent<HTMLSelectElement>) => {
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<MemoryV2AdminConfig>,
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 (
<div className="admin-page">
<div className="admin-page-head">
<h2>Memory V2 </h2>
<p className="muted"> Memory V2 `memind_adm` </p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<div className="admin-form">
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2></h2>
<p className="muted">{payload?.updatedAt ? new Date(payload.updatedAt).toLocaleString('zh-CN', { hour12: false }) : '未保存过数据库配置'}</p>
</div>
<div className="admin-actions">
<button type="button" className="send-btn" disabled={loading || busyScope !== null} onClick={() => void saveGlobalConfig()}>
{busyScope === 'global' ? '保存中...' : '保存全局'}
</button>
</div>
</div>
<div className="admin-form-grid">
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.enabled)} onChange={updateGlobalFlag('enabled')} />
Memory V2
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.profileEnabled)} onChange={updateGlobalFlag('profileEnabled')} />
Profile
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.eventLogEnabled)} onChange={updateGlobalFlag('eventLogEnabled')} />
Event Log
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.vectorEnabled)} onChange={updateGlobalFlag('vectorEnabled')} />
Vector
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.failOpen)} onChange={updateGlobalFlag('failOpen')} />
Memory fail-open
</label>
<label>
<span> Backend</span>
<select value={String(draft.global.backend ?? 'legacy')} onChange={updateGlobalText('backend')}>
<option value="legacy">legacy</option>
{BACKENDS.map((backend) => (
<option key={backend.key} value={backend.key}>{backend.label}</option>
))}
</select>
</label>
</div>
{!loading && (
<p className="muted">
backend<strong>{String(current.global.backend ?? 'legacy')}</strong>
</p>
)}
</section>
{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 (
<section key={backend.key} className="admin-card">
<div className="admin-card-head">
<div>
<h2>{backend.label}</h2>
<p className="muted"> backend </p>
</div>
<div className="admin-actions">
<label className="inline-check">
<input
type="checkbox"
checked={Boolean(section.enabled)}
onChange={updateBackendFlag(backend.key)}
/>
</label>
<button
type="button"
className="send-btn"
disabled={loading || busyScope !== null}
onClick={() => void saveBackendConfig(backend.key)}
>
{busyScope === backend.key ? '保存中...' : `保存 ${backend.label}`}
</button>
</div>
</div>
<div className="admin-form-grid">
{backend.fields.map((field) => {
const configured = Boolean(currentSection?.[`${field.key}Configured`]);
const masked = String(currentSection?.[`${field.key}Masked`] ?? '');
return (
<label key={field.key}>
<span>{field.label}</span>
<input
type={field.secret ? 'password' : 'text'}
value={String(section[field.key] ?? '')}
placeholder={field.secret && configured ? `已配置:${masked || '******'};留空保持不变` : ''}
onChange={updateBackendValue(backend.key, field.key)}
/>
</label>
);
})}
</div>
{isModelBackend(backend.key) && (
<>
<div className="admin-form-grid" style={{ marginTop: 12 }}>
<label>
<span></span>
<select
value={String(section.modelProviderKeyId ?? '')}
onChange={updateModelProvider(backend.key)}
>
<option value=""></option>
{providerKeys.map((item) => (
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
))}
</select>
</label>
<label>
<span></span>
<select
value={String(section.model ?? '')}
onChange={updateBackendValue(backend.key, 'model')}
>
<option value="">
{providerKey ? '使用当前 Provider 默认模型' : '跟随统一模型中心默认模型'}
</option>
{availableModels.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</label>
<label>
<span></span>
<select
value={String(section.modelApiType ?? '')}
onChange={updateBackendValue(backend.key, 'modelApiType')}
>
<option value=""></option>
{supportedApiTypes.map((apiType) => (
<option key={apiType} value={apiType}>
{MODEL_API_TYPES.find((item) => item.value === apiType)?.label ?? apiType}
</option>
))}
</select>
</label>
</div>
<p className="muted">
<strong>{describeEffectiveModelSource(section, providerKeys, globalModelSettings)}</strong>
</p>
{!providerKeys.length && (
<p className="muted">
key provider key
</p>
)}
</>
)}
</section>
);
})}
<div className="admin-actions">
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busyScope !== null && busyScope !== 'reload'}>
</button>
</div>
</div>
</div>
);
}
+32
View File
@@ -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<MemoryV2AdminConfigResponse> {
return portalFetch('/admin-api/memory-v2/config');
}
export async function updateMemoryV2AdminConfig(
payload: Partial<MemoryV2AdminConfig>,
): Promise<MemoryV2AdminConfigResponse> {
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;
+28
View File
@@ -255,6 +255,34 @@ export type MindSpaceAdminConfig = {
publicPageLimit: number;
};
export type MemoryV2AdminSection = Record<string, string | boolean>;
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;