Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dac06753ce | |||
| 122a25649b |
+7
-2
@@ -178,7 +178,12 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
||||
# MEMIND_WECHAT_INTENT_TIMEOUT_MS=4000
|
||||
# MEMIND_WECHAT_INTENT_CANARY_OPENIDS=
|
||||
|
||||
# H5 Session stream replay(docs/h5-session-architecture-20260706.md Patch 4c)
|
||||
# 微信待办/提醒 LLM 意图(独立于 Intent Router;须绑定 DeepSeek 等直连 key,勿依赖全局 selected)
|
||||
# H5_WECHAT_SCHEDULE_LLM_ENABLED=0
|
||||
# MEMIND_WECHAT_SCHEDULE_LLM_MODEL_PROVIDER_KEY_ID=
|
||||
# MEMIND_WECHAT_SCHEDULE_LLM_MODEL=deepseek-v4-pro
|
||||
|
||||
# H5 Session stream replay
|
||||
# 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
|
||||
# MEMIND_SESSION_STREAM_REPLAY=0
|
||||
|
||||
@@ -232,7 +237,7 @@ H5_ADMIN_PASSWORD=change-me-admin
|
||||
# H5_ADMIN_WORKSPACE_ROOT=/Users/john/PycharmProjects/tkmind
|
||||
# LLM Key 数据库加密(可选;默认派生自 TKMIND_SERVER__SECRET_KEY)
|
||||
# H5_SETTINGS_ENCRYPTION_KEY=change-me-encryption-key
|
||||
# 启动时自动导入 Relay(若不存在同名配置);改用手动 Provider 时请设为 1
|
||||
# Relay bootstrap 默认关闭;仅本地调试 relay 时设 H5_RELAY_BOOTSTRAP_DISABLED=0
|
||||
# H5_RELAY_BOOTSTRAP_DISABLED=1
|
||||
# H5_RELAY_BOOTSTRAP_NAME=Local Goose Relay DeepSeek
|
||||
# H5_RELAY_BOOTSTRAP_URL=http://127.0.0.1:18300/relay/buyer/v1/chat/completions
|
||||
|
||||
+32
-1
@@ -237,6 +237,36 @@ export function createAdminApi({
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
|
||||
const portalBase = String(
|
||||
process.env.SYSTEM_TEST_PORTAL_BASE_URL
|
||||
?? `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`,
|
||||
).replace(/\/$/, '');
|
||||
try {
|
||||
const upstream = await fetch(`${portalBase}/api/runtime/status`, {
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
const payload = await upstream.json().catch(() => ({}));
|
||||
if (!upstream.ok) {
|
||||
return res.status(upstream.status).json({
|
||||
ok: false,
|
||||
message: payload?.message ?? '读取 Portal Memory V2 运行时状态失败',
|
||||
memory: null,
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
ok: Boolean(payload?.ok ?? true),
|
||||
memory: payload?.memory ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(502).json({
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : '读取 Portal Memory V2 运行时状态失败',
|
||||
memory: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/memory-v2/metrics', requireAdmin, async (req, res) => {
|
||||
if (!memoryV2AdminOpsService?.getProductMetrics) {
|
||||
return res.status(503).json({ message: 'Memory V2 指标服务未启用' });
|
||||
@@ -265,7 +295,8 @@ export function createAdminApi({
|
||||
const limit = Number(req.query?.limit ?? 50);
|
||||
const offset = Number(req.query?.offset ?? 0);
|
||||
const items = await memoryV2AdminOpsService.listCandidates({ status, userId, limit, offset });
|
||||
return res.json({ items, status, limit, offset });
|
||||
const counts = await memoryV2AdminOpsService.countCandidatesByStatus();
|
||||
return res.json({ items, status, limit, offset, counts });
|
||||
} catch (err) {
|
||||
return res.status(400).json({
|
||||
message: err instanceof Error ? err.message : '读取候选记忆失败',
|
||||
|
||||
@@ -2304,9 +2304,6 @@ export function createLlmProviderService(
|
||||
relayProvider: RELAY_BOOTSTRAP.relayProvider,
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
if (result.key && !result.key.isSelected) {
|
||||
await this.selectKey(result.key.id);
|
||||
}
|
||||
return { ok: true, created: true, key: result.key };
|
||||
},
|
||||
};
|
||||
|
||||
+34
-1
@@ -913,6 +913,29 @@ export type MemoryV2RuntimeState = {
|
||||
overrides: Record<string, string>;
|
||||
};
|
||||
|
||||
export type MemoryV2PersonalMemoryStatus = {
|
||||
enabled?: boolean;
|
||||
requestedMode?: string;
|
||||
effectiveMode?: string;
|
||||
autoReviewEnabled?: boolean;
|
||||
pendingCandidates?: number;
|
||||
health?: {
|
||||
state?: string;
|
||||
accepted?: number;
|
||||
autoReviewed?: number;
|
||||
rejected?: number;
|
||||
deduped?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type MemoryV2StatusResponse = {
|
||||
ok?: boolean;
|
||||
memory?: {
|
||||
runtimeControl?: Record<string, unknown>;
|
||||
personalMemory?: MemoryV2PersonalMemoryStatus;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export async function fetchLlmProviderKeys() {
|
||||
return adminFetch<{ keys: LlmProviderKeyRow[] }>('/admin-api/llm-providers/keys');
|
||||
}
|
||||
@@ -936,6 +959,10 @@ export async function fetchMemoryV2Runtime() {
|
||||
return adminFetch<MemoryV2RuntimeState>('/admin-api/memory-v2/runtime');
|
||||
}
|
||||
|
||||
export async function fetchMemoryV2Status() {
|
||||
return adminFetch<MemoryV2StatusResponse>('/admin-api/memory-v2/status');
|
||||
}
|
||||
|
||||
export type MemoryV2ProductMetrics = {
|
||||
window: { since: string; sinceMs: number; untilMs: number };
|
||||
userId: string | null;
|
||||
@@ -996,7 +1023,13 @@ export async function fetchMemoryV2Candidates(params?: {
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return adminFetch<{ items: MemoryV2CandidateRow[]; status: string; limit: number; offset: number }>(
|
||||
return adminFetch<{
|
||||
items: MemoryV2CandidateRow[];
|
||||
status: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
counts?: Record<string, number>;
|
||||
}>(
|
||||
`/admin-api/memory-v2/candidates${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const links = [
|
||||
{ to: '/admin/policy', label: '策略中心' },
|
||||
{ to: '/admin/orchestrator', label: '任务编排' },
|
||||
{ to: '/admin/llm', label: '统一大模型' },
|
||||
{ to: '/admin/memory-v2', label: 'Memory V2' },
|
||||
{ to: '/admin/memory-v2', label: 'Memory V2 配置' },
|
||||
{ to: '/admin/goal-runs', label: 'Goal Run' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,191 +1,820 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
|
||||
import {
|
||||
fetchLlmGlobalSettings,
|
||||
fetchLlmProviderKeys,
|
||||
fetchMemoryV2Candidates,
|
||||
fetchMemoryV2Metrics,
|
||||
fetchMemoryV2Config,
|
||||
fetchMemoryV2Status,
|
||||
patchMemoryV2Config,
|
||||
reviewMemoryV2Candidate,
|
||||
runMemoryV2CandidateAutoReview,
|
||||
type ChatIntentRouterAdminConfig,
|
||||
type LlmGlobalSettings,
|
||||
type LlmProviderKeyRow,
|
||||
type MemoryV2AdminConfigState,
|
||||
type MemoryV2CandidateRow,
|
||||
type MemoryV2MetricsResponse,
|
||||
type MemoryV2StatusResponse,
|
||||
} from '../../api/admin';
|
||||
import {
|
||||
createDefaultMemoryV2Config,
|
||||
isMemoryV2SectionEnabled,
|
||||
MEMORY_V2_BACKEND_ICONS,
|
||||
MEMORY_V2_BACKEND_SECTIONS,
|
||||
MEMORY_V2_CAPABILITY_SECTIONS,
|
||||
MEMORY_V2_MODEL_API_OPTIONS,
|
||||
MEMORY_V2_MODEL_BACKENDS,
|
||||
MEMORY_V2_SECTION_COLORS,
|
||||
mergeMemoryV2Config,
|
||||
type MemoryV2ConfigShape,
|
||||
type MemoryV2FieldSpec,
|
||||
type MemoryV2SectionSpec,
|
||||
} from './memory-v2-config';
|
||||
import './memory-v2.css';
|
||||
|
||||
function formatTime(value: number | null | undefined) {
|
||||
type SavingKey = string | 'global' | 'chatIntentRouter' | 'reload' | null;
|
||||
|
||||
function formatSavedTime(value: number | null | undefined) {
|
||||
if (!value) return '未保存过数据库配置';
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function formatDateTime(value: number | null | undefined) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined) {
|
||||
if (value == null || !Number.isFinite(value)) return '—';
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
function uniqueModels(values: Array<string | null | undefined>) {
|
||||
return [...new Set(values.map((item) => String(item ?? '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function providerLabel(key: LlmProviderKeyRow) {
|
||||
return `${key.name} · ${key.providerLabel}`;
|
||||
}
|
||||
|
||||
function resolveProviderKey(keys: LlmProviderKeyRow[], keyId: string) {
|
||||
const normalized = String(keyId ?? '').trim();
|
||||
return normalized ? keys.find((item) => item.id === normalized) ?? null : null;
|
||||
}
|
||||
|
||||
function modelOptionsForKey(key: LlmProviderKeyRow | null, global: LlmGlobalSettings | null) {
|
||||
return uniqueModels([
|
||||
...(key?.models ?? []),
|
||||
key?.defaultModel,
|
||||
global?.globalModel,
|
||||
...(global?.availableModels ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
function describeRouterModel(
|
||||
router: ChatIntentRouterAdminConfig,
|
||||
keys: LlmProviderKeyRow[],
|
||||
global: LlmGlobalSettings | null,
|
||||
) {
|
||||
const selectedKey = resolveProviderKey(keys, String(router.modelProviderKeyId ?? ''));
|
||||
const model = String(router.model ?? '').trim()
|
||||
|| selectedKey?.defaultModel
|
||||
|| global?.globalModel
|
||||
|| 'provider 默认模型';
|
||||
const source = selectedKey
|
||||
? providerLabel(selectedKey)
|
||||
: global?.keyName
|
||||
? `${global.keyName} · ${global.providerLabel ?? '统一模型中心'}`
|
||||
: '统一模型中心默认';
|
||||
const apiType = String(router.modelApiType ?? '').trim() || '未指定 API 类型';
|
||||
return `${source} / ${model} / ${apiType}`;
|
||||
}
|
||||
|
||||
function SectionFields({
|
||||
section,
|
||||
values,
|
||||
onBooleanChange,
|
||||
onValueChange,
|
||||
}: {
|
||||
section: MemoryV2SectionSpec;
|
||||
values: Record<string, unknown>;
|
||||
onBooleanChange: (field: string) => (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onValueChange: (field: string) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void;
|
||||
}) {
|
||||
const renderField = (field: MemoryV2FieldSpec) => {
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<label key={field.key} className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(values[field.key])}
|
||||
onChange={onBooleanChange(field.key)}
|
||||
/>
|
||||
{field.label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<label key={field.key}>
|
||||
<span>{field.label}</span>
|
||||
<select
|
||||
value={String(values[field.key] ?? '')}
|
||||
onChange={onValueChange(field.key)}
|
||||
>
|
||||
{(field.options ?? []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label key={field.key}>
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={String(values[field.key] ?? '')}
|
||||
onChange={onValueChange(field.key)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-form-grid memory-v2-fields">
|
||||
{section.fields.map(renderField)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemoryV2Page() {
|
||||
const [metrics, setMetrics] = useState<MemoryV2MetricsResponse | null>(null);
|
||||
const [savedState, setSavedState] = useState<MemoryV2AdminConfigState | null>(null);
|
||||
const [draft, setDraft] = useState<MemoryV2ConfigShape>(() => createDefaultMemoryV2Config());
|
||||
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
|
||||
const [globalSettings, setGlobalSettings] = useState<LlmGlobalSettings | null>(null);
|
||||
const [runtimeStatus, setRuntimeStatus] = useState<MemoryV2StatusResponse | null>(null);
|
||||
const [candidates, setCandidates] = useState<MemoryV2CandidateRow[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [candidateCounts, setCandidateCounts] = useState<Record<string, number>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<SavingKey>(null);
|
||||
const [reviewingId, setReviewingId] = useState<string | null>(null);
|
||||
const [autoReviewBusy, setAutoReviewBusy] = useState(false);
|
||||
const [autoReviewNote, setAutoReviewNote] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [candidateError, setCandidateError] = useState<string | null>(null);
|
||||
const [expandedBackends, setExpandedBackends] = useState<Record<string, boolean>>({});
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const savedConfig = useMemo(
|
||||
() => mergeMemoryV2Config(savedState?.config as Record<string, unknown> | undefined),
|
||||
[savedState],
|
||||
);
|
||||
|
||||
const enabledBackendCount = useMemo(
|
||||
() => MEMORY_V2_BACKEND_SECTIONS.filter((item) => Boolean(savedConfig[item.key]?.enabled)).length,
|
||||
[savedConfig],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setSaving('reload');
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setCandidateError(null);
|
||||
try {
|
||||
const [metricsRes, candidatesRes] = await Promise.all([
|
||||
fetchMemoryV2Metrics({ since: '7d' }),
|
||||
fetchMemoryV2Candidates({ status: 'candidate', limit: 50 }),
|
||||
const [configResult, keysResult, globalResult, statusResult, candidatesResult] = await Promise.all([
|
||||
fetchMemoryV2Config(),
|
||||
fetchLlmProviderKeys(),
|
||||
fetchLlmGlobalSettings(),
|
||||
fetchMemoryV2Status().catch(() => null),
|
||||
fetchMemoryV2Candidates({ status: 'candidate', limit: 50 }).catch((err: unknown) => {
|
||||
setCandidateError(err instanceof Error ? err.message : '候选记忆加载失败');
|
||||
return { items: [], counts: {} as Record<string, number> };
|
||||
}),
|
||||
]);
|
||||
setMetrics(metricsRes);
|
||||
setCandidates(candidatesRes.items);
|
||||
setSavedState(configResult);
|
||||
setDraft(mergeMemoryV2Config(configResult.config as Record<string, unknown> | undefined));
|
||||
setKeys((keysResult.keys ?? []).filter((item) => item.status === 'active'));
|
||||
setGlobalSettings(globalResult.global ?? null);
|
||||
setRuntimeStatus(statusResult);
|
||||
setCandidates(candidatesResult.items ?? []);
|
||||
setCandidateCounts(candidatesResult.counts ?? {});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
setError(err instanceof Error ? err.message : '加载 Memory V2 配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSaving(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const runAutoReview = async () => {
|
||||
setAutoReviewBusy(true);
|
||||
setAutoReviewNote(null);
|
||||
const applySavedConfig = (result: MemoryV2AdminConfigState, message: string) => {
|
||||
setSavedState(result);
|
||||
setDraft(mergeMemoryV2Config(result.config as Record<string, unknown> | undefined));
|
||||
setNotice(message);
|
||||
};
|
||||
|
||||
const savePatch = async (patch: Record<string, unknown>, key: SavingKey, message: string) => {
|
||||
setSaving(key);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await runMemoryV2CandidateAutoReview({ limit: 200 });
|
||||
setAutoReviewNote(
|
||||
`自动审核完成:接受 ${result.accepted},拒绝 ${result.rejected},仍待人工 ${result.pending}`,
|
||||
);
|
||||
await reload();
|
||||
const result = await patchMemoryV2Config(patch);
|
||||
applySavedConfig(result, message);
|
||||
const statusResult = await fetchMemoryV2Status().catch(() => null);
|
||||
setRuntimeStatus(statusResult);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '自动审核失败');
|
||||
setError(err instanceof Error ? err.message : '保存 Memory V2 配置失败');
|
||||
} finally {
|
||||
setAutoReviewBusy(false);
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const review = async (id: string, status: 'accepted' | 'rejected') => {
|
||||
const updateGlobalBoolean = (field: string) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
global: { ...current.global, [field]: event.target.checked },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateGlobalValue = (field: string) => (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
global: { ...current.global, [field]: event.target.value },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateRouterBoolean = (field: string) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
chatIntentRouter: { ...current.chatIntentRouter, [field]: event.target.checked },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateRouterValue = (field: string) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
chatIntentRouter: { ...current.chatIntentRouter, [field]: event.target.value },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateSectionBoolean = (sectionKey: string, field: string) => (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[sectionKey]: { ...current[sectionKey], [field]: event.target.checked },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateSectionValue = (sectionKey: string, field: string) => (
|
||||
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[sectionKey]: { ...current[sectionKey], [field]: event.target.value },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateBackendBoolean = (sectionKey: string) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.checked) {
|
||||
setExpandedBackends((current) => ({ ...current, [sectionKey]: false }));
|
||||
}
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[sectionKey]: { ...current[sectionKey], enabled: event.target.checked },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateBackendValue = (sectionKey: string, field: string) => (
|
||||
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[sectionKey]: { ...current[sectionKey], [field]: event.target.value },
|
||||
}));
|
||||
};
|
||||
|
||||
const reviewCandidate = async (id: string, action: 'accept' | 'reject') => {
|
||||
setReviewingId(id);
|
||||
setCandidateError(null);
|
||||
try {
|
||||
await reviewMemoryV2Candidate(id, status);
|
||||
await reload();
|
||||
await reviewMemoryV2Candidate(id, action === 'accept' ? 'accepted' : 'rejected');
|
||||
const candidatesResult = await fetchMemoryV2Candidates({ status: 'candidate', limit: 50 });
|
||||
setCandidates(candidatesResult.items ?? []);
|
||||
setCandidateCounts(candidatesResult.counts ?? {});
|
||||
setNotice(action === 'accept' ? '候选记忆已接纳。' : '候选记忆已拒绝。');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '审核失败');
|
||||
setCandidateError(err instanceof Error ? err.message : '候选记忆处理失败');
|
||||
} finally {
|
||||
setReviewingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !metrics) return <p>加载中…</p>;
|
||||
const routerDraft = draft.chatIntentRouter as ChatIntentRouterAdminConfig;
|
||||
const routerSaved = savedConfig.chatIntentRouter as ChatIntentRouterAdminConfig;
|
||||
const routerKey = resolveProviderKey(keys, String(routerDraft.modelProviderKeyId ?? ''));
|
||||
const routerModels = modelOptionsForKey(routerKey, globalSettings);
|
||||
const personalMemory = runtimeStatus?.memory?.personalMemory;
|
||||
|
||||
if (loading && !savedState) return <p>加载中…</p>;
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
{error && <p className="alert">{error}</p>}
|
||||
{autoReviewNote && <p className="alert">{autoReviewNote}</p>}
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0, marginBottom: 4 }}>Memory V2 指标</h2>
|
||||
<p style={{ color: '#68716c', margin: 0 }}>近 {metrics?.metrics.window.since ?? '7d'} 产品事件与 shadow 审计</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={autoReviewBusy}
|
||||
onClick={() => void runAutoReview()}
|
||||
>
|
||||
{autoReviewBusy ? '自动审核中…' : '执行自动审核'}
|
||||
</button>
|
||||
<button type="button" className="btn secondary" onClick={() => void reload()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{metrics && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
{Object.entries(metrics.metrics.events).map(([key, count]) => (
|
||||
<div key={key}>
|
||||
<p style={{ color: '#68716c', marginBottom: 4 }}>{key}</p>
|
||||
<strong style={{ fontSize: 24 }}>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, display: 'grid', gap: 8 }}>
|
||||
<p style={{ margin: 0 }}>误存率:{formatPercent(metrics.audit.falseStoreRate)}</p>
|
||||
<p style={{ margin: 0 }}>自动接受率:{formatPercent(metrics.audit.autoAcceptRate)}</p>
|
||||
<p style={{ margin: 0 }}>resolve 命中率:{formatPercent(metrics.audit.resolveHitRate)}</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
候选队列:
|
||||
{Object.entries(metrics.candidateCounts)
|
||||
.map(([status, count]) => `${status} × ${count}`)
|
||||
.join(',') || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="admin-page memory-v2-page grid">
|
||||
<div className="admin-page-head">
|
||||
<h2>Memory V2 配置</h2>
|
||||
<p className="muted">
|
||||
这里是 Memory V2 的统一后台控制面。`memind_adm` 负责改配置,主站会从共享数据库读取并自动切换。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>需人工复核({candidates.length})</h3>
|
||||
<p style={{ color: '#68716c', marginTop: 0 }}>
|
||||
canary 模式下写入时会自动审核:显式/偏好/目标类接受,decision_signal 与问句类拒绝。
|
||||
此处仅展示自动审核未覆盖的例外项。
|
||||
</p>
|
||||
{candidates.length === 0 ? (
|
||||
<p style={{ color: '#68716c' }}>没有待人工复核的候选。</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{candidates.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
border: '1px solid #e6ece8',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
{error ? <p className="banner banner-error">{error}</p> : null}
|
||||
{notice ? <p className="banner banner-info">{notice}</p> : null}
|
||||
|
||||
<section className="asset-gateway-hero">
|
||||
<div>
|
||||
<div className="asset-kicker">MEMORY V2</div>
|
||||
<h3>统一记忆控制面</h3>
|
||||
<p>
|
||||
全局 {draft.global.enabled ? '已启用' : '未启用'}
|
||||
{' · '}
|
||||
默认 backend <strong className="mono">{String(draft.global.backend ?? 'legacy')}</strong>
|
||||
{' · '}
|
||||
已启用 backend {enabledBackendCount}/{MEMORY_V2_BACKEND_SECTIONS.length}
|
||||
{' · '}
|
||||
更新时间 {formatSavedTime(savedState?.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="model-center-hero-actions">
|
||||
<button type="button" className="ghost-btn" disabled={saving !== null} onClick={() => void load()}>
|
||||
{saving === 'reload' ? '加载中…' : '重新加载'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h3 className="model-center-section-title">全局与路由</h3>
|
||||
<div className="asset-plugin-grid memory-v2-top-grid">
|
||||
<section className="asset-plugin-card asset-plugin-card--violet model-center-card memory-v2-card">
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon" aria-hidden="true">M</div>
|
||||
<div>
|
||||
<h3>全局开关</h3>
|
||||
<p>控制 Memory V2 总开关、Profile / Event Log / Vector 与 fail-open 行为。</p>
|
||||
</div>
|
||||
<span className={`asset-status ${draft.global.enabled ? 'is-on' : ''}`}>
|
||||
{draft.global.enabled ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="memory-v2-toggle-grid">
|
||||
<label className="inline-check">
|
||||
<input type="checkbox" checked={Boolean(draft.global.enabled)} onChange={updateGlobalBoolean('enabled')} />
|
||||
启用 Memory V2
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input type="checkbox" checked={Boolean(draft.global.profileEnabled)} onChange={updateGlobalBoolean('profileEnabled')} />
|
||||
启用 Profile
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input type="checkbox" checked={Boolean(draft.global.eventLogEnabled)} onChange={updateGlobalBoolean('eventLogEnabled')} />
|
||||
启用 Event Log
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input type="checkbox" checked={Boolean(draft.global.vectorEnabled)} onChange={updateGlobalBoolean('vectorEnabled')} />
|
||||
启用 Vector
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input type="checkbox" checked={Boolean(draft.global.failOpen)} onChange={updateGlobalBoolean('failOpen')} />
|
||||
Memory 故障时 fail-open
|
||||
</label>
|
||||
</div>
|
||||
<div className="asset-plugin-fields">
|
||||
<label>
|
||||
默认 Backend
|
||||
<select value={String(draft.global.backend ?? 'legacy')} onChange={updateGlobalValue('backend')}>
|
||||
<option value="legacy">legacy</option>
|
||||
{MEMORY_V2_BACKEND_SECTIONS.map((item) => (
|
||||
<option key={item.key} value={item.key}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{!loading ? (
|
||||
<p className="model-center-card-note">
|
||||
当前数据库配置默认 backend:
|
||||
<strong>{String(savedConfig.global.backend ?? 'legacy')}</strong>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="asset-plugin-footer">
|
||||
<span className={`asset-status ${draft.global.enabled ? 'is-on' : ''}`}>
|
||||
{draft.global.enabled ? 'Memory V2 运行中' : 'Memory V2 关闭'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={loading || saving !== null}
|
||||
onClick={() => void savePatch(
|
||||
{ global: draft.global },
|
||||
'global',
|
||||
'全局 Memory V2 开关已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。',
|
||||
)}
|
||||
>
|
||||
{saving === 'global' ? '保存中…' : '保存全局'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="asset-plugin-card asset-plugin-card--blue model-center-card memory-v2-card">
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon" aria-hidden="true">R</div>
|
||||
<div>
|
||||
<h3>前置 LLM 意图路由</h3>
|
||||
<p>关闭时主站完全退回原有 Direct Chat / Agent 判定;打开后才会调用轻量 LLM。</p>
|
||||
</div>
|
||||
<label className="asset-mini-toggle" aria-label="启用前置 LLM 意图路由">
|
||||
<input type="checkbox" checked={Boolean(routerDraft.enabled)} onChange={updateRouterBoolean('enabled')} />
|
||||
<span>{routerDraft.enabled ? '启用' : '关闭'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-form-grid memory-v2-fields">
|
||||
<label>
|
||||
<span>模型来源</span>
|
||||
<select
|
||||
value={String(routerDraft.modelProviderKeyId ?? '')}
|
||||
onChange={(event) => {
|
||||
const nextKey = resolveProviderKey(keys, event.target.value);
|
||||
const models = modelOptionsForKey(nextKey, globalSettings);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
chatIntentRouter: {
|
||||
...current.chatIntentRouter,
|
||||
modelProviderKeyId: event.target.value,
|
||||
model: models.includes(String(current.chatIntentRouter.model ?? ''))
|
||||
? String(current.chatIntentRouter.model ?? '')
|
||||
: String(nextKey?.defaultModel ?? globalSettings?.globalModel ?? ''),
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<strong>{item.policyReason}</strong>
|
||||
<span style={{ color: '#68716c' }}>{formatTime(item.createdAt)}</span>
|
||||
<option value="">跟随统一模型中心默认</option>
|
||||
{keys.map((item) => (
|
||||
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>模型</span>
|
||||
<select value={String(routerDraft.model ?? '')} onChange={updateRouterValue('model')}>
|
||||
<option value="">
|
||||
{routerKey ? '使用当前 Provider 默认模型' : '跟随统一模型中心默认模型'}
|
||||
</option>
|
||||
{routerModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>模型类型</span>
|
||||
<select value={String(routerDraft.modelApiType ?? 'chat')} onChange={updateRouterValue('modelApiType')}>
|
||||
{MEMORY_V2_MODEL_API_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<details className="memory-v2-advanced-details">
|
||||
<summary>高级参数</summary>
|
||||
<div className="admin-form-grid memory-v2-fields">
|
||||
<label>
|
||||
<span>最低置信度</span>
|
||||
<input type="number" min="0" max="1" step="0.05" value={String(routerDraft.minConfidence ?? '0.65')} onChange={updateRouterValue('minConfidence')} />
|
||||
</label>
|
||||
<label>
|
||||
<span>记忆条数</span>
|
||||
<input type="number" min="1" max="50" value={String(routerDraft.memoryResolveLimit ?? '8')} onChange={updateRouterValue('memoryResolveLimit')} />
|
||||
</label>
|
||||
<label>
|
||||
<span>超时 Ms</span>
|
||||
<input type="number" min="0" max="30000" value={String(routerDraft.timeoutMs ?? '1500')} onChange={updateRouterValue('timeoutMs')} />
|
||||
</label>
|
||||
<label>
|
||||
<span>低置信 / 失败回退</span>
|
||||
<select value={String(routerDraft.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(routerDraft.memoryResolveEnabled)} onChange={updateRouterBoolean('memoryResolveEnabled')} />
|
||||
路由前读取 Memory V2
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<p className="model-center-card-note">
|
||||
当前数据库配置:
|
||||
<strong>{routerSaved.enabled ? '已启用' : '未启用'}</strong>
|
||||
{' · '}
|
||||
生效模型链路:
|
||||
<strong>{describeRouterModel(routerDraft, keys, globalSettings)}</strong>
|
||||
</p>
|
||||
{!keys.length ? (
|
||||
<p className="model-center-card-note">
|
||||
统一模型中心当前没有可用的激活 key;若启用路由,请先配置 Provider key 与模型列表。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="asset-plugin-footer">
|
||||
<span className={`asset-status ${routerDraft.enabled ? 'is-on' : ''}`}>
|
||||
{routerDraft.enabled ? '路由已启用' : '路由已关闭'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={loading || saving !== null}
|
||||
onClick={() => void savePatch(
|
||||
{ chatIntentRouter: draft.chatIntentRouter },
|
||||
'chatIntentRouter',
|
||||
'前置 LLM 意图路由配置已保存。关闭时主站会退回原有 Direct Chat / Agent 判定链路。',
|
||||
)}
|
||||
>
|
||||
{saving === 'chatIntentRouter' ? '保存中…' : '保存路由'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h3 className="model-center-section-title">Personal Memory 能力管理</h3>
|
||||
<p className="model-center-card-note memory-v2-backend-intro">
|
||||
配置统一保存到 Memory V2 控制面。启用配置不代表运行插件已经健康,实际状态仍需结合插件健康与运行时状态判断。
|
||||
</p>
|
||||
<div className="asset-plugin-grid memory-v2-capability-grid">
|
||||
{MEMORY_V2_CAPABILITY_SECTIONS.map((section, index) => {
|
||||
const draftSection = draft[section.key] ?? {};
|
||||
const savedSection = savedConfig[section.key] ?? {};
|
||||
const draftEnabled = isMemoryV2SectionEnabled(section.key, draftSection);
|
||||
const savedEnabled = isMemoryV2SectionEnabled(section.key, savedSection);
|
||||
const color = MEMORY_V2_SECTION_COLORS[index % MEMORY_V2_SECTION_COLORS.length];
|
||||
return (
|
||||
<section
|
||||
key={section.key}
|
||||
className={`asset-plugin-card asset-plugin-card--${color} model-center-card memory-v2-card`}
|
||||
>
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon" aria-hidden="true">{section.icon}</div>
|
||||
<div>
|
||||
<h3>{section.label}</h3>
|
||||
<p>{section.description}</p>
|
||||
</div>
|
||||
<p style={{ margin: 0 }}>{item.content}</p>
|
||||
<p style={{ margin: 0, color: '#68716c', fontSize: 13 }}>
|
||||
user={item.userId} · type={item.memoryType} · session={item.sessionId ?? '—'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<span className={`asset-status ${draftEnabled ? 'is-on' : ''}`}>
|
||||
{draftEnabled ? '配置已启用' : '配置未启用'}
|
||||
</span>
|
||||
</div>
|
||||
<SectionFields
|
||||
section={section}
|
||||
values={draftSection}
|
||||
onBooleanChange={(field) => updateSectionBoolean(section.key, field)}
|
||||
onValueChange={(field) => updateSectionValue(section.key, field)}
|
||||
/>
|
||||
<div className="asset-plugin-footer">
|
||||
{section.key === 'pluginHealth' ? (
|
||||
<div className="memory-v2-runtime-health">
|
||||
<strong>运行时状态</strong>
|
||||
{personalMemory ? (
|
||||
<span>
|
||||
{String(personalMemory.health?.state ?? 'unknown')}
|
||||
{' · '}
|
||||
模式 {personalMemory.effectiveMode ?? 'off'}
|
||||
{personalMemory.autoReviewEnabled ? ' · 自动审核' : ' · 人工审核'}
|
||||
{' · '}
|
||||
候选 {personalMemory.pendingCandidates ?? 0}
|
||||
{' · '}
|
||||
接纳 {personalMemory.health?.accepted ?? 0}
|
||||
{' · '}
|
||||
自动接纳 {personalMemory.health?.autoReviewed ?? 0}
|
||||
{' · '}
|
||||
拒绝 {personalMemory.health?.rejected ?? 0}
|
||||
{' · '}
|
||||
去重 {personalMemory.health?.deduped ?? 0}
|
||||
</span>
|
||||
) : (
|
||||
<span>主站尚未加载 Shadow Pipeline,或运行时状态暂不可用</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<span className={`asset-status ${savedEnabled ? 'is-on' : ''}`}>
|
||||
当前保存:{savedEnabled ? '启用' : '关闭'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={loading || saving !== null}
|
||||
onClick={() => void savePatch(
|
||||
{ [section.key]: draft[section.key] },
|
||||
section.key,
|
||||
`${section.label} 配置已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。`,
|
||||
)}
|
||||
>
|
||||
{saving === section.key ? '保存中…' : `保存${section.label}`}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h3 className="model-center-section-title">候选记忆审核</h3>
|
||||
<section className="asset-plugin-card model-center-card memory-v2-card memory-v2-candidate-review">
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon" aria-hidden="true">C</div>
|
||||
<div>
|
||||
<h3>待审核候选</h3>
|
||||
<p>
|
||||
待审核 {candidateCounts.candidate ?? 0}
|
||||
{' · '}
|
||||
已接纳 {candidateCounts.accepted ?? 0}
|
||||
{' · '}
|
||||
已拒绝 {candidateCounts.rejected ?? 0}
|
||||
</p>
|
||||
<p className="model-center-card-note">
|
||||
Active 模式下规则通过的候选会自动接纳。此处仅显示 Shadow / Canary 模式下仍需人工确认的例外项。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="ghost-btn" disabled={loading || reviewingId !== null} onClick={() => void load()}>
|
||||
刷新候选
|
||||
</button>
|
||||
</div>
|
||||
{candidateError ? <p className="banner banner-error">{candidateError}</p> : null}
|
||||
{!candidateError && candidates.length === 0 ? (
|
||||
<p className="model-center-card-note">
|
||||
当前没有待审核候选。Active 模式下候选会自动接纳;Shadow / Canary 模式下低置信度项会出现在这里。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="memory-v2-candidate-list">
|
||||
{candidates.map((item) => (
|
||||
<article key={item.id} className="memory-v2-candidate-item">
|
||||
<div className="memory-v2-candidate-meta">
|
||||
<span className="asset-status is-on">{item.memoryType}</span>
|
||||
<span>用户 {item.userId}</span>
|
||||
<span>重要度 {item.importance.toFixed(2)}</span>
|
||||
<span>置信度 {item.confidence.toFixed(2)}</span>
|
||||
<span>{formatDateTime(item.createdAt)}</span>
|
||||
</div>
|
||||
<p>{item.content}</p>
|
||||
<small>
|
||||
Policy: {item.policyReason}
|
||||
</small>
|
||||
<div className="asset-plugin-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={reviewingId !== null}
|
||||
onClick={() => void reviewCandidate(item.id, 'reject')}
|
||||
>
|
||||
{reviewingId === item.id ? '处理中…' : '拒绝'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={reviewingId !== null}
|
||||
onClick={() => void reviewCandidate(item.id, 'accept')}
|
||||
>
|
||||
{reviewingId === item.id ? '处理中…' : '接纳'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h3 className="model-center-section-title">Backend 配置</h3>
|
||||
<p className="model-center-card-note memory-v2-backend-intro">
|
||||
单独启用后,只有当全局默认 backend 指向它时才会成为主读写目标。未启用的 backend 默认折叠,点击标题可展开。
|
||||
</p>
|
||||
<div className="asset-plugin-grid memory-v2-backend-grid">
|
||||
{MEMORY_V2_BACKEND_SECTIONS.map((section, index) => {
|
||||
const draftSection = draft[section.key] ?? {};
|
||||
const savedSection = savedConfig[section.key] ?? {};
|
||||
const color = MEMORY_V2_SECTION_COLORS[index % MEMORY_V2_SECTION_COLORS.length];
|
||||
const open = Boolean(draftSection.enabled) || Boolean(expandedBackends[section.key]);
|
||||
return (
|
||||
<details
|
||||
key={section.key}
|
||||
className={`asset-plugin-card asset-plugin-card--${color} model-center-card memory-v2-card memory-v2-backend-details`}
|
||||
open={open}
|
||||
onToggle={(event) => {
|
||||
setExpandedBackends((current) => ({
|
||||
...current,
|
||||
[section.key]: event.currentTarget.open,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<summary className="memory-v2-backend-summary">
|
||||
<div className="asset-plugin-icon" aria-hidden="true">
|
||||
{MEMORY_V2_BACKEND_ICONS[section.key]}
|
||||
</div>
|
||||
<div className="memory-v2-backend-summary-main">
|
||||
<h3>{section.label}</h3>
|
||||
<span className={`asset-status ${draftSection.enabled ? 'is-on' : ''}`}>
|
||||
{draftSection.enabled ? '已启用' : '未启用 · 点击展开'}
|
||||
</span>
|
||||
</div>
|
||||
<label
|
||||
className="asset-mini-toggle"
|
||||
aria-label={`启用 ${section.label}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(draftSection.enabled)}
|
||||
onChange={updateBackendBoolean(section.key)}
|
||||
/>
|
||||
<span>{draftSection.enabled ? '启用' : '关闭'}</span>
|
||||
</label>
|
||||
</summary>
|
||||
<div className="memory-v2-backend-body">
|
||||
<div className="admin-form-grid memory-v2-fields">
|
||||
{section.fields.map((field) => {
|
||||
const configured = Boolean(savedSection[`${field.key}Configured`]);
|
||||
const masked = String(savedSection[`${field.key}Masked`] ?? '');
|
||||
return (
|
||||
<label key={field.key}>
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
type={field.secret ? 'password' : 'text'}
|
||||
value={String(draftSection[field.key] ?? '')}
|
||||
placeholder={field.secret && configured ? `已配置:${masked || '******'};留空保持不变` : ''}
|
||||
onChange={updateBackendValue(section.key, field.key)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{MEMORY_V2_MODEL_BACKENDS.has(section.key) ? (
|
||||
<>
|
||||
<div className="admin-form-grid memory-v2-fields">
|
||||
<label>
|
||||
<span>模型来源</span>
|
||||
<select
|
||||
value={String(draftSection.modelProviderKeyId ?? '')}
|
||||
onChange={(event) => updateBackendValue(section.key, 'modelProviderKeyId')(event)}
|
||||
>
|
||||
<option value="">跟随统一模型中心默认</option>
|
||||
{keys.map((item) => (
|
||||
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>模型</span>
|
||||
<select
|
||||
value={String(draftSection.model ?? '')}
|
||||
onChange={updateBackendValue(section.key, 'model')}
|
||||
>
|
||||
<option value="">跟随统一模型中心默认模型</option>
|
||||
{modelOptionsForKey(resolveProviderKey(keys, String(draftSection.modelProviderKeyId ?? '')), globalSettings).map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>模型类型</span>
|
||||
<select
|
||||
value={String(draftSection.modelApiType ?? '')}
|
||||
onChange={updateBackendValue(section.key, 'modelApiType')}
|
||||
>
|
||||
<option value="">未指定</option>
|
||||
{MEMORY_V2_MODEL_API_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="model-center-card-note">
|
||||
生效模型:
|
||||
<strong>{describeRouterModel({
|
||||
enabled: false,
|
||||
modelProviderKeyId: String(draftSection.modelProviderKeyId ?? ''),
|
||||
model: String(draftSection.model ?? ''),
|
||||
modelApiType: String(draftSection.modelApiType ?? ''),
|
||||
}, keys, globalSettings)}</strong>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
<div className="asset-plugin-footer">
|
||||
<span className={`asset-status ${draftSection.enabled ? 'is-on' : ''}`}>
|
||||
{draftSection.enabled ? `${section.label} 已启用` : `${section.label} 未启用`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={reviewingId === item.id}
|
||||
onClick={() => void review(item.id, 'accepted')}
|
||||
className="send-btn"
|
||||
disabled={loading || saving !== null}
|
||||
onClick={() => void savePatch(
|
||||
{ [section.key]: draft[section.key] },
|
||||
section.key,
|
||||
`${section.label} 配置已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。`,
|
||||
)}
|
||||
>
|
||||
接受
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
disabled={reviewingId === item.id}
|
||||
onClick={() => void review(item.id, 'rejected')}
|
||||
>
|
||||
拒绝
|
||||
{saving === section.key ? '保存中…' : `保存 ${section.label}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ export function SummaryPage() {
|
||||
统一大模型 / H5 路由
|
||||
</Link>
|
||||
<Link className="btn secondary" to="/admin/memory-v2" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
Memory V2 指标
|
||||
Memory V2 配置
|
||||
</Link>
|
||||
<Link className="btn secondary" to="/admin/goal-runs" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
Goal Run 观测
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isMemoryV2SectionEnabled } from './memory-v2-config.ts';
|
||||
|
||||
test('runtimeControl section enabled follows active runtime switches', () => {
|
||||
assert.equal(isMemoryV2SectionEnabled('runtimeControl', {}), false);
|
||||
assert.equal(isMemoryV2SectionEnabled('runtimeControl', {
|
||||
agentResolveEnabled: true,
|
||||
}), true);
|
||||
assert.equal(isMemoryV2SectionEnabled('runtimeControl', {
|
||||
agentInjectionMode: 'active',
|
||||
}), true);
|
||||
assert.equal(isMemoryV2SectionEnabled('runtimeControl', {
|
||||
lifecycleRolloutMode: 'canary',
|
||||
}), true);
|
||||
assert.equal(isMemoryV2SectionEnabled('candidateMemory', { enabled: true }), true);
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
export type MemoryV2FieldType = 'boolean' | 'number' | 'text' | 'select';
|
||||
|
||||
export type MemoryV2SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type MemoryV2FieldSpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: MemoryV2FieldType;
|
||||
options?: MemoryV2SelectOption[];
|
||||
};
|
||||
|
||||
export type MemoryV2SectionSpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
fields: MemoryV2FieldSpec[];
|
||||
};
|
||||
|
||||
export type MemoryV2BackendSpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
fields: Array<{ key: string; label: string; secret?: boolean }>;
|
||||
};
|
||||
|
||||
export type MemoryV2ConfigShape = Record<string, Record<string, unknown>>;
|
||||
|
||||
export const MEMORY_V2_SECTION_COLORS = ['blue', 'violet', 'green', 'amber'] as const;
|
||||
|
||||
export const MEMORY_V2_MODEL_API_OPTIONS: MemoryV2SelectOption[] = [
|
||||
{ value: 'chat', label: 'Chat API' },
|
||||
{ value: 'response', label: 'Response API' },
|
||||
];
|
||||
|
||||
export const MEMORY_V2_BACKEND_ICONS: Record<string, string> = {
|
||||
pgvector: 'P',
|
||||
qdrant: 'Q',
|
||||
weaviate: 'W',
|
||||
mem0: 'M',
|
||||
letta: 'L',
|
||||
neo4j: 'N',
|
||||
redisStreams: 'R',
|
||||
langgraph: 'G',
|
||||
};
|
||||
|
||||
export const MEMORY_V2_MODEL_BACKENDS = new Set(['mem0', 'letta', 'langgraph']);
|
||||
|
||||
export const MEMORY_V2_CAPABILITY_SECTIONS: MemoryV2SectionSpec[] = [
|
||||
{
|
||||
key: 'candidateMemory',
|
||||
label: '候选记忆',
|
||||
icon: 'C',
|
||||
description: '控制候选提取模式、自动接纳阈值与待处理数量。',
|
||||
fields: [
|
||||
{ key: 'enabled', label: '启用候选记忆', type: 'boolean' },
|
||||
{
|
||||
key: 'mode',
|
||||
label: '运行模式',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'off', label: 'Off · 关闭' },
|
||||
{ value: 'active', label: 'Active · 规则通过后自动接纳(推荐)' },
|
||||
{ value: 'canary', label: 'Canary · 仅高置信度自动接纳' },
|
||||
{ value: 'shadow', label: 'Shadow · 全部人工审核' },
|
||||
],
|
||||
},
|
||||
{ key: 'minImportance', label: '最低重要度', type: 'number' },
|
||||
{ key: 'minConfidence', label: '最低置信度', type: 'number' },
|
||||
{ key: 'maxPending', label: '最大待处理数', type: 'number' },
|
||||
{ key: 'persistenceEnabled', label: '持久化到 MySQL', type: 'boolean' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'runtimeControl',
|
||||
label: 'Runtime 控制',
|
||||
icon: 'A',
|
||||
description: '独立于 Memory V2 总开关,控制 Agent 读取、注入、晋升与后续生命周期能力。',
|
||||
fields: [
|
||||
{ key: 'agentResolveEnabled', label: '允许 Agent Resolve', type: 'boolean' },
|
||||
{
|
||||
key: 'agentInjectionMode',
|
||||
label: 'Agent 注入模式',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'off', label: 'Off · 关闭' },
|
||||
{ value: 'shadow', label: 'Shadow · 只观察不注入' },
|
||||
{ value: 'canary', label: 'Canary · 灰度注入' },
|
||||
{ value: 'active', label: 'Active · 正式注入' },
|
||||
],
|
||||
},
|
||||
{ key: 'agentCanaryUserIds', label: 'Canary 用户 ID(逗号分隔)', type: 'text' },
|
||||
{ key: 'agentResolveLimit', label: 'Agent 召回条数', type: 'number' },
|
||||
{ key: 'agentResolveTimeoutMs', label: 'Agent Resolve 超时 Ms', type: 'number' },
|
||||
{ key: 'promotionEnabled', label: '允许候选晋升正式记忆', type: 'boolean' },
|
||||
{ key: 'compactionV2Enabled', label: '启用 Compact V2', type: 'boolean' },
|
||||
{ key: 'reflectionEnabled', label: '启用 Reflection', type: 'boolean' },
|
||||
{ key: 'lifecycleWorkerEnabled', label: '启用 Lifecycle Worker', type: 'boolean' },
|
||||
{
|
||||
key: 'lifecycleRolloutMode',
|
||||
label: 'Lifecycle 灰度模式',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'off', label: 'Off · 关闭' },
|
||||
{ value: 'canary', label: 'Canary · 仅指定用户' },
|
||||
{ value: 'active', label: 'Active · 全量' },
|
||||
],
|
||||
},
|
||||
{ key: 'lifecycleRolloutUserIds', label: 'Lifecycle 用户 ID(逗号分隔)', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'policy',
|
||||
label: 'Policy',
|
||||
icon: 'P',
|
||||
description: '管理证据、敏感信息、显式记忆与保留期限。',
|
||||
fields: [
|
||||
{ key: 'enabled', label: '启用 Policy', type: 'boolean' },
|
||||
{ key: 'saveExplicit', label: '允许显式“记住”', type: 'boolean' },
|
||||
{ key: 'rejectSensitive', label: '拒绝敏感内容', type: 'boolean' },
|
||||
{ key: 'requireEvidence', label: '长期记忆必须有证据', type: 'boolean' },
|
||||
{ key: 'retentionDays', label: '默认保留天数', type: 'number' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'retriever',
|
||||
label: 'Retriever',
|
||||
icon: 'R',
|
||||
description: '组合事件、语义、偏好和目标召回,并限制上下文预算。',
|
||||
fields: [
|
||||
{ key: 'enabled', label: '启用组合召回', type: 'boolean' },
|
||||
{ key: 'episodicEnabled', label: '事件记忆', type: 'boolean' },
|
||||
{
|
||||
key: 'episodicMode',
|
||||
label: '历史会话召回模式',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'off', label: 'Off · 关闭' },
|
||||
{ value: 'canary', label: 'Canary · 仅指定用户' },
|
||||
{ value: 'active', label: 'Active · 全量' },
|
||||
],
|
||||
},
|
||||
{ key: 'episodicCanaryUserIds', label: '历史会话 Canary 用户 ID(逗号分隔)', type: 'text' },
|
||||
{ key: 'semanticEnabled', label: '语义记忆', type: 'boolean' },
|
||||
{ key: 'preferenceEnabled', label: '用户偏好', type: 'boolean' },
|
||||
{ key: 'goalEnabled', label: '活跃目标', type: 'boolean' },
|
||||
{ key: 'limit', label: '召回条数', type: 'number' },
|
||||
{ key: 'tokenBudget', label: 'Token 预算', type: 'number' },
|
||||
{ key: 'timeoutMs', label: '超时毫秒', type: 'number' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'lifecycle',
|
||||
label: 'Lifecycle',
|
||||
icon: 'L',
|
||||
description: '控制去重、冲突审核、衰减、遗忘与压缩周期。',
|
||||
fields: [
|
||||
{ key: 'enabled', label: '启用生命周期管理', type: 'boolean' },
|
||||
{ key: 'dedupeEnabled', label: '自动去重', type: 'boolean' },
|
||||
{ key: 'conflictReview', label: '冲突进入审核', type: 'boolean' },
|
||||
{ key: 'decayEnabled', label: '启用记忆衰减', type: 'boolean' },
|
||||
{ key: 'forgettingEnabled', label: '启用遗忘处理', type: 'boolean' },
|
||||
{ key: 'compactIntervalHours', label: 'Compact 周期小时', type: 'number' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'persona',
|
||||
label: 'Persona',
|
||||
icon: 'A',
|
||||
description: '管理用户画像来源、Shadow 注入、缓存和上下文上限。',
|
||||
fields: [
|
||||
{ key: 'enabled', label: '启用 Persona', type: 'boolean' },
|
||||
{
|
||||
key: 'provider',
|
||||
label: '画像来源',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'none', label: '未配置' },
|
||||
{ value: 'ai-mind', label: 'AI Mind Bridge' },
|
||||
{ value: 'internal', label: '内部画像' },
|
||||
],
|
||||
},
|
||||
{ key: 'shadowMode', label: '仅 Shadow 不注入', type: 'boolean' },
|
||||
{ key: 'maxTokens', label: '最大 Token', type: 'number' },
|
||||
{ key: 'cacheTtlSeconds', label: '缓存秒数', type: 'number' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'graph',
|
||||
label: 'Graph',
|
||||
icon: 'G',
|
||||
description: '配置关系检索 Provider、最大深度和关系数量。',
|
||||
fields: [
|
||||
{ key: 'enabled', label: '启用关系检索', type: 'boolean' },
|
||||
{
|
||||
key: 'provider',
|
||||
label: 'Graph Provider',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'postgres', label: 'PostgreSQL 关系表' },
|
||||
{ value: 'neo4j', label: 'Neo4j' },
|
||||
{ value: 'none', label: '未配置' },
|
||||
],
|
||||
},
|
||||
{ key: 'maxDepth', label: '最大关系深度', type: 'number' },
|
||||
{ key: 'relationLimit', label: '最大关系数', type: 'number' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'userMemory',
|
||||
label: '用户记忆',
|
||||
icon: 'U',
|
||||
description: '管理用户查看、纠正、固定、遗忘和删除传播权限。',
|
||||
fields: [
|
||||
{ key: 'enabled', label: '启用用户记忆管理', type: 'boolean' },
|
||||
{ key: 'reviewEnabled', label: '允许用户查看', type: 'boolean' },
|
||||
{ key: 'correctionEnabled', label: '允许用户纠正', type: 'boolean' },
|
||||
{ key: 'pinEnabled', label: '允许固定记忆', type: 'boolean' },
|
||||
{ key: 'forgetEnabled', label: '允许遗忘记忆', type: 'boolean' },
|
||||
{ key: 'deletePropagation', label: '删除传播至索引和缓存', type: 'boolean' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'pluginHealth',
|
||||
label: '插件健康',
|
||||
icon: 'H',
|
||||
description: '配置健康检查、失败阈值和自动回退行为。',
|
||||
fields: [
|
||||
{ key: 'enabled', label: '启用插件健康检查', type: 'boolean' },
|
||||
{ key: 'intervalSeconds', label: '检查间隔秒', type: 'number' },
|
||||
{ key: 'timeoutMs', label: '检查超时毫秒', type: 'number' },
|
||||
{ key: 'failureThreshold', label: '失败阈值', type: 'number' },
|
||||
{ key: 'autoFallback', label: '异常时自动回退', type: 'boolean' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const MEMORY_V2_BACKEND_SECTIONS: MemoryV2BackendSpec[] = [
|
||||
{
|
||||
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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function modeEnabled(value: unknown) {
|
||||
const mode = String(value ?? 'off').trim().toLowerCase();
|
||||
return mode !== '' && mode !== 'off';
|
||||
}
|
||||
|
||||
export function isMemoryV2SectionEnabled(
|
||||
sectionKey: string,
|
||||
section: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
const config = section ?? {};
|
||||
if (sectionKey === 'runtimeControl') {
|
||||
return Boolean(config.agentResolveEnabled)
|
||||
|| modeEnabled(config.agentInjectionMode)
|
||||
|| Boolean(config.promotionEnabled)
|
||||
|| Boolean(config.compactionV2Enabled)
|
||||
|| Boolean(config.reflectionEnabled)
|
||||
|| Boolean(config.lifecycleWorkerEnabled)
|
||||
|| modeEnabled(config.lifecycleRolloutMode);
|
||||
}
|
||||
return Boolean(config.enabled);
|
||||
}
|
||||
|
||||
export function createDefaultMemoryV2Config(): MemoryV2ConfigShape {
|
||||
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',
|
||||
},
|
||||
candidateMemory: {
|
||||
enabled: false,
|
||||
mode: 'active',
|
||||
minImportance: '0.7',
|
||||
minConfidence: '0.8',
|
||||
maxPending: '500',
|
||||
persistenceEnabled: false,
|
||||
},
|
||||
runtimeControl: {
|
||||
agentResolveEnabled: false,
|
||||
agentInjectionMode: 'off',
|
||||
agentCanaryUserIds: '',
|
||||
agentResolveLimit: '3',
|
||||
agentResolveTimeoutMs: '1200',
|
||||
promotionEnabled: false,
|
||||
compactionV2Enabled: false,
|
||||
reflectionEnabled: false,
|
||||
lifecycleWorkerEnabled: false,
|
||||
lifecycleRolloutMode: 'off',
|
||||
lifecycleRolloutUserIds: '',
|
||||
},
|
||||
policy: {
|
||||
enabled: false,
|
||||
saveExplicit: true,
|
||||
rejectSensitive: true,
|
||||
requireEvidence: true,
|
||||
retentionDays: '365',
|
||||
},
|
||||
retriever: {
|
||||
enabled: false,
|
||||
episodicEnabled: true,
|
||||
episodicMode: 'off',
|
||||
episodicCanaryUserIds: '',
|
||||
semanticEnabled: true,
|
||||
preferenceEnabled: true,
|
||||
goalEnabled: true,
|
||||
limit: '12',
|
||||
tokenBudget: '1800',
|
||||
timeoutMs: '1200',
|
||||
},
|
||||
lifecycle: {
|
||||
enabled: false,
|
||||
dedupeEnabled: true,
|
||||
conflictReview: true,
|
||||
decayEnabled: false,
|
||||
forgettingEnabled: true,
|
||||
compactIntervalHours: '24',
|
||||
},
|
||||
persona: {
|
||||
enabled: false,
|
||||
provider: 'none',
|
||||
shadowMode: true,
|
||||
maxTokens: '400',
|
||||
cacheTtlSeconds: '300',
|
||||
},
|
||||
graph: {
|
||||
enabled: false,
|
||||
provider: 'postgres',
|
||||
maxDepth: '2',
|
||||
relationLimit: '20',
|
||||
},
|
||||
userMemory: {
|
||||
enabled: false,
|
||||
reviewEnabled: true,
|
||||
correctionEnabled: true,
|
||||
pinEnabled: true,
|
||||
forgetEnabled: true,
|
||||
deletePropagation: true,
|
||||
},
|
||||
pluginHealth: {
|
||||
enabled: false,
|
||||
intervalSeconds: '60',
|
||||
timeoutMs: '1500',
|
||||
failureThreshold: '3',
|
||||
autoFallback: true,
|
||||
},
|
||||
pgvector: {},
|
||||
qdrant: {},
|
||||
weaviate: {},
|
||||
mem0: {},
|
||||
letta: {},
|
||||
neo4j: {},
|
||||
redisStreams: {},
|
||||
langgraph: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeMemoryV2Config(
|
||||
source: Record<string, unknown> | undefined,
|
||||
): MemoryV2ConfigShape {
|
||||
const defaults = createDefaultMemoryV2Config();
|
||||
const input = source ?? {};
|
||||
const merged: MemoryV2ConfigShape = { ...defaults };
|
||||
for (const key of Object.keys(defaults)) {
|
||||
merged[key] = {
|
||||
...defaults[key],
|
||||
...((input[key] as Record<string, unknown> | undefined) ?? {}),
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
.memory-v2-page {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.memory-v2-page .banner {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.memory-v2-page .banner-error {
|
||||
background: #fdecec;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.memory-v2-page .banner-info {
|
||||
background: #ecfdf3;
|
||||
color: #2f6f57;
|
||||
}
|
||||
|
||||
.memory-v2-page .muted {
|
||||
color: #68716c;
|
||||
}
|
||||
|
||||
.memory-v2-page .mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.admin-page-head h2 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.asset-gateway-hero,
|
||||
.memory-v2-top-grid,
|
||||
.memory-v2-capability-grid,
|
||||
.memory-v2-backend-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.asset-gateway-hero {
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
padding: 16px;
|
||||
border: 1px solid #d6d0c3;
|
||||
border-radius: 16px;
|
||||
background: #fffdf7;
|
||||
}
|
||||
|
||||
.asset-kicker {
|
||||
color: #68716c;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.model-center-section-title {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.model-center-card-note {
|
||||
color: #68716c;
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.asset-plugin-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.memory-v2-top-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.memory-v2-top-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.memory-v2-capability-grid,
|
||||
.memory-v2-backend-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.asset-plugin-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #d6d0c3;
|
||||
border-radius: 16px;
|
||||
background: #fffdf7;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.asset-plugin-card--blue { border-color: #b8d4ff; }
|
||||
.asset-plugin-card--violet { border-color: #d5c4ff; }
|
||||
.asset-plugin-card--green { border-color: #b8e6c8; }
|
||||
.asset-plugin-card--amber { border-color: #f5d08a; }
|
||||
|
||||
.asset-plugin-card-head {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.asset-plugin-card-head h3,
|
||||
.asset-plugin-card-head p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.asset-plugin-card-head p {
|
||||
color: #68716c;
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.asset-plugin-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #ebe4d6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.asset-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #ebe4d6;
|
||||
color: #68716c;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-status.is-on {
|
||||
background: #e6f4ec;
|
||||
color: #2f6f57;
|
||||
}
|
||||
|
||||
.asset-plugin-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.memory-v2-toggle-grid,
|
||||
.memory-v2-fields,
|
||||
.admin-form-grid {
|
||||
display: grid;
|
||||
gap: 10px 12px;
|
||||
}
|
||||
|
||||
.memory-v2-toggle-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.memory-v2-fields,
|
||||
.admin-form-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.memory-v2-fields label,
|
||||
.admin-form-grid label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.memory-v2-fields label > span,
|
||||
.admin-form-grid label > span {
|
||||
color: #68716c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.inline-check {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: #68716c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inline-check input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.asset-mini-toggle {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.send-btn,
|
||||
.ghost-btn {
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
border: 1px solid #2f6f57;
|
||||
background: #2f6f57;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.send-btn:disabled,
|
||||
.ghost-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
border: 1px solid #d6d0c3;
|
||||
background: transparent;
|
||||
color: #17221d;
|
||||
}
|
||||
|
||||
.memory-v2-advanced-details {
|
||||
border: 1px solid #d6d0c3;
|
||||
border-radius: 12px;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.memory-v2-advanced-details > summary {
|
||||
padding: 8px 0;
|
||||
color: #68716c;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-v2-runtime-health {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
flex: 1 1 100%;
|
||||
color: #68716c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.memory-v2-runtime-health strong {
|
||||
color: #17221d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.memory-v2-candidate-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.memory-v2-candidate-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d6d0c3;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.memory-v2-candidate-item p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.memory-v2-candidate-item small {
|
||||
color: #68716c;
|
||||
}
|
||||
|
||||
.memory-v2-candidate-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
align-items: center;
|
||||
color: #68716c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.memory-v2-backend-details > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-v2-backend-summary {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.memory-v2-backend-summary-main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-v2-backend-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #d6d0c3;
|
||||
}
|
||||
@@ -190,7 +190,8 @@ export async function bootstrapPortalAgentServices({
|
||||
const wordFilterService =
|
||||
createWordFilterServiceFn(pool);
|
||||
|
||||
const relayBootstrapTask = env.H5_RELAY_BOOTSTRAP_DISABLED === '1'
|
||||
// Relay bootstrap is opt-in only; production uses direct provider keys (e.g. DeepSeek).
|
||||
const relayBootstrapTask = env.H5_RELAY_BOOTSTRAP_DISABLED !== '0'
|
||||
? Promise.resolve({ ok: true, created: false, skipped: true })
|
||||
: llmProviderService
|
||||
.ensureBootstrapRelay()
|
||||
|
||||
@@ -367,6 +367,10 @@ test('falls back from PG experience and degrades invalid image config', async ()
|
||||
|
||||
test('keeps disabled experience null and reports background LLM failures', async () => {
|
||||
const setup = createSetup({
|
||||
env: {
|
||||
EXPERIENCE_PG_URL: 'postgres://experience',
|
||||
H5_RELAY_BOOTSTRAP_DISABLED: '0',
|
||||
},
|
||||
runtime: {
|
||||
experienceEnabled: false,
|
||||
agentWorker: { enabled: false },
|
||||
|
||||
@@ -34,6 +34,8 @@ async function ensureConfigTable(pool) {
|
||||
function defaultsFromEnv(env = process.env) {
|
||||
return {
|
||||
scheduleLlmEnabled: normalizeBoolean(env.H5_WECHAT_SCHEDULE_LLM_ENABLED, false),
|
||||
modelProviderKeyId: String(env.MEMIND_WECHAT_SCHEDULE_LLM_MODEL_PROVIDER_KEY_ID ?? '').trim() || null,
|
||||
model: String(env.MEMIND_WECHAT_SCHEDULE_LLM_MODEL ?? '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +67,8 @@ export function createWechatScheduleLlmConfigService(pool, { env = process.env }
|
||||
stored.scheduleLlmEnabled,
|
||||
defaults.scheduleLlmEnabled,
|
||||
),
|
||||
modelProviderKeyId: String(stored.modelProviderKeyId ?? defaults.modelProviderKeyId ?? '').trim() || null,
|
||||
model: String(stored.model ?? defaults.model ?? '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,6 +94,14 @@ export function createWechatScheduleLlmConfigService(pool, { env = process.env }
|
||||
payload.scheduleLlmEnabled === undefined
|
||||
? current.scheduleLlmEnabled
|
||||
: normalizeBoolean(payload.scheduleLlmEnabled, current.scheduleLlmEnabled),
|
||||
modelProviderKeyId:
|
||||
payload.modelProviderKeyId === undefined
|
||||
? (current.modelProviderKeyId ?? '')
|
||||
: String(payload.modelProviderKeyId ?? '').trim(),
|
||||
model:
|
||||
payload.model === undefined
|
||||
? (current.model ?? '')
|
||||
: String(payload.model ?? '').trim(),
|
||||
};
|
||||
const now = Date.now();
|
||||
await pool.query(
|
||||
|
||||
@@ -60,40 +60,50 @@ function normalizeLlmScheduleIntent(payload) {
|
||||
return { action: 'none' };
|
||||
}
|
||||
|
||||
async function parseScheduleIntentWithLlm({ text, llmProviderService, logger = console }) {
|
||||
async function parseScheduleIntentWithLlm({ text, llmProviderService, modelProviderKeyId = null, model = null, logger = console }) {
|
||||
if (!llmProviderService || typeof llmProviderService.createChatCompletion !== 'function') {
|
||||
return { action: 'none' };
|
||||
}
|
||||
|
||||
const result = await llmProviderService.createChatCompletion({
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
'你是公众号提醒助手,只做意图识别。',
|
||||
'请严格输出 JSON,不要输出解释。',
|
||||
'允许 action: create_todo, create_daily_todo_digest, create_balance_alert, query_schedule, schedule_agent, none。',
|
||||
'create_todo 需要 title。',
|
||||
'create_daily_todo_digest 需要 hour(0-23) 和 minute(0-59)。',
|
||||
'create_balance_alert 需要 thresholdYuan 数字。',
|
||||
'如果用户在说具体时间提醒、一次性闹钟、某天某时提醒,返回 schedule_agent。',
|
||||
'不确定时返回 none。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
const result = await llmProviderService.createChatCompletion({
|
||||
...(modelProviderKeyId ? { providerKeyId: modelProviderKeyId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
'你是公众号提醒助手,只做意图识别。',
|
||||
'请严格输出 JSON,不要输出解释。',
|
||||
'允许 action: create_todo, create_daily_todo_digest, create_balance_alert, query_schedule, schedule_agent, none。',
|
||||
'create_todo 需要 title。',
|
||||
'create_daily_todo_digest 需要 hour(0-23) 和 minute(0-59)。',
|
||||
'create_balance_alert 需要 thresholdYuan 数字。',
|
||||
'如果用户在说具体时间提醒、一次性闹钟、某天某时提醒,返回 schedule_agent。',
|
||||
'不确定时返回 none。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!result?.ok) {
|
||||
logger?.warn?.('[wechat-schedule-llm] parse skipped:', result?.message ?? 'unknown');
|
||||
if (!result?.ok) {
|
||||
logger?.warn?.('[wechat-schedule-llm] parse skipped:', result?.message ?? 'unknown');
|
||||
return { action: 'none' };
|
||||
}
|
||||
|
||||
return normalizeLlmScheduleIntent(parseJsonReply(result.reply));
|
||||
} catch (err) {
|
||||
logger?.warn?.(
|
||||
'[wechat-schedule-llm] parse skipped:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return { action: 'none' };
|
||||
}
|
||||
|
||||
return normalizeLlmScheduleIntent(parseJsonReply(result.reply));
|
||||
}
|
||||
|
||||
async function resolveScheduleIntent({
|
||||
@@ -118,7 +128,26 @@ async function resolveScheduleIntent({
|
||||
}
|
||||
if (!enabled) return ruleIntent;
|
||||
|
||||
return parseScheduleIntentWithLlm({ text, llmProviderService, logger });
|
||||
let modelProviderKeyId = null;
|
||||
let model = null;
|
||||
try {
|
||||
const config = await wechatScheduleLlmConfigService.getConfig();
|
||||
modelProviderKeyId = config.modelProviderKeyId ?? null;
|
||||
model = config.model ?? null;
|
||||
} catch (err) {
|
||||
logger?.warn?.(
|
||||
'[wechat-schedule-llm] provider config load failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
|
||||
return parseScheduleIntentWithLlm({
|
||||
text,
|
||||
llmProviderService,
|
||||
modelProviderKeyId,
|
||||
model,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleWechatScheduleIntent({
|
||||
|
||||
@@ -19,13 +19,28 @@ function createScheduleService() {
|
||||
};
|
||||
}
|
||||
|
||||
function createScheduleLlmConfigService(enabled = true) {
|
||||
return {
|
||||
async isScheduleLlmEnabled() {
|
||||
return enabled;
|
||||
},
|
||||
async getConfig() {
|
||||
return {
|
||||
scheduleLlmEnabled: enabled,
|
||||
modelProviderKeyId: 'deepseek-key',
|
||||
model: 'deepseek-v4-pro',
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('schedule handler keeps rule-based parsing as first priority', async () => {
|
||||
let llmCalled = false;
|
||||
const reply = await handleWechatScheduleIntent({
|
||||
intent: { agentText: '帮我记一下 跟进合同', msgId: 'msg-1' },
|
||||
user: { userId: 'user-1' },
|
||||
scheduleService: createScheduleService(),
|
||||
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
|
||||
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
|
||||
llmProviderService: {
|
||||
async createChatCompletion() {
|
||||
llmCalled = true;
|
||||
@@ -42,7 +57,7 @@ test('schedule handler can use llm fallback for ambiguous todo text', async () =
|
||||
intent: { agentText: '下周把合同发给客户,别忘了', msgId: 'msg-2' },
|
||||
user: { userId: 'user-1' },
|
||||
scheduleService: createScheduleService(),
|
||||
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
|
||||
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
|
||||
llmProviderService: {
|
||||
async createChatCompletion() {
|
||||
return {
|
||||
@@ -71,7 +86,7 @@ test('schedule handler does not call llm when switch is off', async () => {
|
||||
intent: { agentText: '别忘了报销', msgId: 'msg-4' },
|
||||
user: { userId: 'user-1' },
|
||||
scheduleService: createScheduleService(),
|
||||
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return false; } },
|
||||
wechatScheduleLlmConfigService: createScheduleLlmConfigService(false),
|
||||
llmProviderService: {
|
||||
async createChatCompletion() {
|
||||
llmCalled = true;
|
||||
@@ -89,7 +104,7 @@ test('schedule handler falls back quietly when llm request fails', async () => {
|
||||
intent: { agentText: '别忘了报销', msgId: 'msg-5' },
|
||||
user: { userId: 'user-1' },
|
||||
scheduleService: createScheduleService(),
|
||||
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
|
||||
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
|
||||
llmProviderService: {
|
||||
async createChatCompletion() {
|
||||
return { ok: false, message: 'provider unavailable' };
|
||||
@@ -102,12 +117,31 @@ test('schedule handler falls back quietly when llm request fails', async () => {
|
||||
assert.match(warnings[0], /provider unavailable/);
|
||||
});
|
||||
|
||||
test('schedule handler falls back quietly when llm request throws', async () => {
|
||||
const warnings = [];
|
||||
const reply = await handleWechatScheduleIntent({
|
||||
intent: { agentText: '什么是机器学习', msgId: 'msg-6' },
|
||||
user: { userId: 'user-1' },
|
||||
scheduleService: createScheduleService(),
|
||||
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
|
||||
llmProviderService: {
|
||||
async createChatCompletion() {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
},
|
||||
logger: { warn: (...args) => warnings.push(args.join(' ')) },
|
||||
});
|
||||
assert.equal(reply, null);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /fetch failed/);
|
||||
});
|
||||
|
||||
test('schedule handler falls through for llm schedule_agent results', async () => {
|
||||
const reply = await handleWechatScheduleIntent({
|
||||
intent: { agentText: '明天下午三点提醒我开会', msgId: 'msg-3' },
|
||||
user: { userId: 'user-1' },
|
||||
scheduleService: createScheduleService(),
|
||||
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
|
||||
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
|
||||
llmProviderService: {
|
||||
async createChatCompletion() {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user