feat: add memory v2 admin controls
This commit is contained in:
@@ -96,6 +96,7 @@ export function createAdminApp(services) {
|
|||||||
wechatAdmin,
|
wechatAdmin,
|
||||||
loadMindSpaceConfig,
|
loadMindSpaceConfig,
|
||||||
updateMindSpaceConfig,
|
updateMindSpaceConfig,
|
||||||
|
memoryV2ConfigService,
|
||||||
wordFilterService,
|
wordFilterService,
|
||||||
planCatalogService,
|
planCatalogService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
@@ -301,6 +302,19 @@ export function createAdminApp(services) {
|
|||||||
res.json({ config: result });
|
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) => {
|
adminApi.get('/wechat/bindings', requireAdmin, async (req, res) => {
|
||||||
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
|
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
|
||||||
res.json(await wechatAdmin.listBindings(req.query));
|
res.json(await wechatAdmin.listBindings(req.query));
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export async function bootstrapAdminServices() {
|
|||||||
loadMindSpaceConfig,
|
loadMindSpaceConfig,
|
||||||
updateMindSpaceConfig,
|
updateMindSpaceConfig,
|
||||||
} = await importMemind('mindspace-config.mjs');
|
} = await importMemind('mindspace-config.mjs');
|
||||||
|
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
|
||||||
const { createOpsApi } = await importMemind('admin-routes.mjs');
|
const { createOpsApi } = await importMemind('admin-routes.mjs');
|
||||||
const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs');
|
const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs');
|
||||||
const {
|
const {
|
||||||
@@ -82,6 +83,9 @@ export async function bootstrapAdminServices() {
|
|||||||
apiTarget,
|
apiTarget,
|
||||||
apiSecret,
|
apiSecret,
|
||||||
});
|
});
|
||||||
|
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
const wechatMpConfig = loadWechatMpConfig();
|
const wechatMpConfig = loadWechatMpConfig();
|
||||||
const wechatMpService = createWechatMpService({
|
const wechatMpService = createWechatMpService({
|
||||||
config: wechatMpConfig,
|
config: wechatMpConfig,
|
||||||
@@ -122,6 +126,7 @@ export async function bootstrapAdminServices() {
|
|||||||
mindSpaceConfig,
|
mindSpaceConfig,
|
||||||
loadMindSpaceConfig,
|
loadMindSpaceConfig,
|
||||||
updateMindSpaceConfig,
|
updateMindSpaceConfig,
|
||||||
|
memoryV2ConfigService,
|
||||||
wordFilterService,
|
wordFilterService,
|
||||||
planCatalogService,
|
planCatalogService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ ready
|
|||||||
wechatAdmin,
|
wechatAdmin,
|
||||||
loadMindSpaceConfig,
|
loadMindSpaceConfig,
|
||||||
updateMindSpaceConfig,
|
updateMindSpaceConfig,
|
||||||
|
memoryV2ConfigService,
|
||||||
wordFilterService,
|
wordFilterService,
|
||||||
planCatalogService,
|
planCatalogService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
@@ -121,6 +122,7 @@ ready
|
|||||||
wechatAdmin,
|
wechatAdmin,
|
||||||
loadMindSpaceConfig,
|
loadMindSpaceConfig,
|
||||||
updateMindSpaceConfig,
|
updateMindSpaceConfig,
|
||||||
|
memoryV2ConfigService,
|
||||||
wordFilterService,
|
wordFilterService,
|
||||||
planCatalogService,
|
planCatalogService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { CapabilitiesPage } from './admin/pages/CapabilitiesPage';
|
|||||||
import { DashboardPage } from './admin/pages/DashboardPage';
|
import { DashboardPage } from './admin/pages/DashboardPage';
|
||||||
import { PoliciesPage } from './admin/pages/PoliciesPage';
|
import { PoliciesPage } from './admin/pages/PoliciesPage';
|
||||||
import { MindSpacePage } from './admin/pages/MindSpacePage';
|
import { MindSpacePage } from './admin/pages/MindSpacePage';
|
||||||
|
import { MemoryV2Page } from './admin/pages/MemoryV2Page';
|
||||||
import { ProvidersPage } from './admin/pages/ProvidersPage';
|
import { ProvidersPage } from './admin/pages/ProvidersPage';
|
||||||
import { SkillsPage } from './admin/pages/SkillsPage';
|
import { SkillsPage } from './admin/pages/SkillsPage';
|
||||||
import { UserDetailPage } from './admin/pages/UserDetailPage';
|
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="skills" element={<SkillsPage />} />
|
||||||
<Route path="policies" element={<PoliciesPage />} />
|
<Route path="policies" element={<PoliciesPage />} />
|
||||||
<Route path="mindspace" element={<MindSpacePage />} />
|
<Route path="mindspace" element={<MindSpacePage />} />
|
||||||
|
<Route path="memory-v2" element={<MemoryV2Page />} />
|
||||||
<Route path="providers" element={<ProvidersPage />} />
|
<Route path="providers" element={<ProvidersPage />} />
|
||||||
<Route path="wechat" element={<WechatPage />} />
|
<Route path="wechat" element={<WechatPage />} />
|
||||||
<Route path="blocked-words" element={<BlockedWordsPage />} />
|
<Route path="blocked-words" element={<BlockedWordsPage />} />
|
||||||
@@ -158,6 +160,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|
|||||||
|| pathname.startsWith('/skills')
|
|| pathname.startsWith('/skills')
|
||||||
|| pathname.startsWith('/policies')
|
|| pathname.startsWith('/policies')
|
||||||
|| pathname.startsWith('/mindspace')
|
|| pathname.startsWith('/mindspace')
|
||||||
|
|| pathname.startsWith('/memory-v2')
|
||||||
|| pathname.startsWith('/providers')
|
|| pathname.startsWith('/providers')
|
||||||
|| pathname.startsWith('/wechat')
|
|| pathname.startsWith('/wechat')
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const NAV_SECTIONS: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/wechat', label: '服务号' },
|
{ to: '/wechat', label: '服务号' },
|
||||||
{ to: '/mindspace', label: 'MindSpace 配置' },
|
{ to: '/mindspace', label: 'MindSpace 配置' },
|
||||||
|
{ to: '/memory-v2', label: 'Memory V2' },
|
||||||
{ to: '/capabilities', label: '能力' },
|
{ to: '/capabilities', label: '能力' },
|
||||||
{ to: '/skills', label: '技能' },
|
{ to: '/skills', label: '技能' },
|
||||||
{ to: '/policies', label: '策略' },
|
{ to: '/policies', label: '策略' },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const QUICK_LINKS = [
|
|||||||
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
|
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
|
||||||
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
|
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
|
||||||
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
|
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
|
||||||
|
{ to: '/memory-v2', label: 'Memory V2', desc: '统一配置长期记忆 backend 与开关' },
|
||||||
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
|
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@ import type {
|
|||||||
PolicyMap,
|
PolicyMap,
|
||||||
PortalUser,
|
PortalUser,
|
||||||
MindSpaceAdminConfig,
|
MindSpaceAdminConfig,
|
||||||
|
MemoryV2AdminConfig,
|
||||||
|
MemoryV2AdminConfigResponse,
|
||||||
|
MemoryV2ModelApiType,
|
||||||
SkillDefinition,
|
SkillDefinition,
|
||||||
SkillMap,
|
SkillMap,
|
||||||
UsageRecord,
|
UsageRecord,
|
||||||
@@ -278,6 +281,35 @@ export async function updateMindSpaceAdminConfig(
|
|||||||
return result.config;
|
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?: {
|
export async function listWechatBindings(params?: {
|
||||||
search?: string;
|
search?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -255,6 +255,34 @@ export type MindSpaceAdminConfig = {
|
|||||||
publicPageLimit: number;
|
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 = {
|
export type WechatBinding = {
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user