709 lines
26 KiB
TypeScript
709 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|