Files
memind_adm/src/admin/pages/MemoryV2Page.tsx
T

709 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
},
chatIntentRouter: {
enabled: false,
modelProviderKeyId: '',
model: '',
modelApiType: 'chat',
minConfidence: '0.65',
memoryResolveEnabled: true,
memoryResolveLimit: '8',
timeoutMs: '1500',
fallbackRoute: 'agent_orchestration',
},
pgvector: {},
qdrant: {},
weaviate: {},
mem0: {},
letta: {},
neo4j: {},
redisStreams: {},
langgraph: {},
};
}
function normalizeConfig(config?: Partial<MemoryV2AdminConfig> | null): MemoryV2AdminConfig {
const base = defaultConfig();
return {
global: { ...base.global, ...(config?.global ?? {}) },
chatIntentRouter: { ...base.chatIntentRouter, ...(config?.chatIntentRouter ?? {}) },
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' | 'chatIntentRouter' | 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 updateRouterFlag = (key: string) => (event: ChangeEvent<HTMLInputElement>) => {
setDraft((current) => ({
...current,
chatIntentRouter: { ...current.chatIntentRouter, [key]: event.target.checked },
}));
};
const updateRouterValue = (key: string) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setDraft((current) => ({
...current,
chatIntentRouter: { ...current.chatIntentRouter, [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 updateRouterModelProvider = (event: ChangeEvent<HTMLSelectElement>) => {
const nextProviderKeyId = event.target.value;
const nextProviderKey = findProviderKey(providerKeys, nextProviderKeyId);
const nextOptions = modelOptionsForProvider(nextProviderKey, globalModelSettings);
setDraft((current) => {
const currentSection = current.chatIntentRouter;
const currentModel = String(currentSection.model ?? '').trim();
const nextModel = nextOptions.includes(currentModel)
? currentModel
: String(nextProviderKey?.defaultModel ?? globalModelSettings?.globalModel ?? '').trim();
return {
...current,
chatIntentRouter: {
...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' | 'chatIntentRouter' | 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 saveRouterConfig = async () => {
await saveConfigSection(
{ chatIntentRouter: draft.chatIntentRouter },
'chatIntentRouter',
'前置 LLM 意图路由配置已保存。关闭时主站会退回原有 Direct Chat / Agent 判定链路。',
);
};
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;
const routerSection = draft.chatIntentRouter;
const currentRouterSection = current.chatIntentRouter;
const routerProviderKey = findProviderKey(providerKeys, String(routerSection.modelProviderKeyId ?? ''));
const routerAvailableModels = modelOptionsForProvider(routerProviderKey, globalModelSettings);
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>
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2>前置 LLM 意图路由</h2>
<p className="muted">关闭时主站完全退回原有 Direct Chat / Agent 判定;打开后才会调用轻量 LLM,并可读取 Memory V2 的轻量上下文。</p>
</div>
<div className="admin-actions">
<label className="inline-check">
<input
type="checkbox"
checked={Boolean(routerSection.enabled)}
onChange={updateRouterFlag('enabled')}
/>
启用
</label>
<button
type="button"
className="send-btn"
disabled={loading || busyScope !== null}
onClick={() => void saveRouterConfig()}
>
{busyScope === 'chatIntentRouter' ? '保存中...' : '保存路由'}
</button>
</div>
</div>
<div className="admin-form-grid">
<label>
<span>模型来源</span>
<select
value={String(routerSection.modelProviderKeyId ?? '')}
onChange={updateRouterModelProvider}
>
<option value="">跟随统一模型中心默认</option>
{providerKeys.map((item) => (
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
))}
</select>
</label>
<label>
<span>模型</span>
<select
value={String(routerSection.model ?? '')}
onChange={updateRouterValue('model')}
>
<option value="">
{routerProviderKey ? '使用当前 Provider 默认模型' : '跟随统一模型中心默认模型'}
</option>
{routerAvailableModels.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</label>
<label>
<span>模型类型</span>
<select
value={String(routerSection.modelApiType ?? 'chat')}
onChange={updateRouterValue('modelApiType')}
>
{MODEL_API_TYPES.map((apiType) => (
<option key={apiType.value} value={apiType.value}>{apiType.label}</option>
))}
</select>
</label>
<label>
<span>最低置信度</span>
<input
type="number"
min="0"
max="1"
step="0.05"
value={String(routerSection.minConfidence ?? '0.65')}
onChange={updateRouterValue('minConfidence')}
/>
</label>
<label>
<span>记忆条数</span>
<input
type="number"
min="1"
max="50"
value={String(routerSection.memoryResolveLimit ?? '8')}
onChange={updateRouterValue('memoryResolveLimit')}
/>
</label>
<label>
<span>超时 Ms</span>
<input
type="number"
min="0"
max="30000"
value={String(routerSection.timeoutMs ?? '1500')}
onChange={updateRouterValue('timeoutMs')}
/>
</label>
<label>
<span>低置信 / 失败回退</span>
<select
value={String(routerSection.fallbackRoute ?? 'agent_orchestration')}
onChange={updateRouterValue('fallbackRoute')}
>
<option value="agent_orchestration">Agent 编排</option>
<option value="direct_chat">Direct Chat</option>
</select>
</label>
<label className="inline-check">
<input
type="checkbox"
checked={Boolean(routerSection.memoryResolveEnabled)}
onChange={updateRouterFlag('memoryResolveEnabled')}
/>
路由前读取 Memory V2
</label>
</div>
<p className="muted">
当前数据库配置:<strong>{currentRouterSection.enabled ? '已启用' : '未启用'}</strong>
{' · '}
生效模型链路:<strong>{describeEffectiveModelSource(routerSection, providerKeys, globalModelSettings)}</strong>
</p>
{!providerKeys.length && (
<p className="muted">
统一模型中心当前没有可用的激活 key;若启用路由,请先配置 Provider key 与模型列表。
</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>
);
}