merge: integrate MindSearch admin controls

This commit is contained in:
john
2026-07-15 13:47:19 +08:00
12 changed files with 992 additions and 5 deletions
+128
View File
@@ -98,6 +98,9 @@ export function createAdminApp(services) {
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
@@ -366,6 +369,131 @@ export function createAdminApp(services) {
res.json(result);
});
adminApi.get('/mindsearch/config', requireAdmin, async (_req, res) => {
if (!mindSearchConfigService?.getAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.getAdminConfig());
});
adminApi.patch('/mindsearch/config', requireAdmin, async (req, res) => {
if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id }));
});
adminApi.put('/mindsearch/config', requireAdmin, async (req, res) => {
if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id }));
});
adminApi.get('/mindsearch/runtime', requireAdmin, async (_req, res) => {
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.getRuntimeState());
});
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
try {
const response = await fetch(`${portalBaseUrl}/api/runtime/status`, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(2500),
});
if (!response.ok) {
return res.status(502).json({
ok: false,
message: `Memory V2 运行时状态返回 HTTP ${response.status}`,
});
}
const payload = await response.json();
return res.json({
ok: true,
checkedAt: Date.now(),
memory: payload?.memory ?? null,
});
} catch (err) {
return res.status(503).json({
ok: false,
checkedAt: Date.now(),
message: err instanceof Error ? err.message : 'Memory V2 运行时不可用',
});
}
});
adminApi.get('/memory-v2/candidates', requireAdmin, async (req, res) => {
if (!personalMemoryCandidateStore?.listCandidates) {
return res.status(503).json({ message: '候选记忆存储未启用' });
}
try {
const status = String(req.query.status ?? 'candidate');
const userId = String(req.query.userId ?? '').trim() || null;
const limit = Number(req.query.limit ?? 50);
const [items, counts] = await Promise.all([
personalMemoryCandidateStore.listCandidates({ status, userId, limit }),
personalMemoryCandidateStore.countByStatus(),
]);
return res.json({ items, counts });
} catch (err) {
const missingTable = err?.code === 'ER_NO_SUCH_TABLE';
return res.status(missingTable ? 503 : 400).json({
message: missingTable ? '候选记忆表尚未执行本地迁移' : (err instanceof Error ? err.message : '候选记忆读取失败'),
});
}
});
adminApi.post('/memory-v2/candidates/:id/accept', requireAdmin, async (req, res) => {
if (!personalMemoryCandidateStore?.reviewCandidate) {
return res.status(503).json({ message: '候选记忆存储未启用' });
}
const result = await personalMemoryCandidateStore.reviewCandidate(req.params.id, 'accepted', {
reviewedBy: req.currentUser.id,
});
if (!result.updated) return res.status(409).json({ message: '候选记忆已处理或不存在' });
return res.json(result);
});
adminApi.post('/memory-v2/candidates/:id/reject', requireAdmin, async (req, res) => {
if (!personalMemoryCandidateStore?.reviewCandidate) {
return res.status(503).json({ message: '候选记忆存储未启用' });
}
const result = await personalMemoryCandidateStore.reviewCandidate(req.params.id, 'rejected', {
reviewedBy: req.currentUser.id,
});
if (!result.updated) return res.status(409).json({ message: '候选记忆已处理或不存在' });
return res.json(result);
});
adminApi.get('/skill-runtime/config', requireAdmin, async (_req, res) => {
if (!skillRuntimeConfigService?.getAdminConfig) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
res.json(await skillRuntimeConfigService.getAdminConfig());
});
const updateSkillRuntimeConfig = async (req, res) => {
if (!skillRuntimeConfigService?.updateAdminConfig) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
const result = await skillRuntimeConfigService.updateAdminConfig(req.body?.config ?? req.body ?? {}, {
updatedBy: req.currentUser.id,
});
res.json(result);
};
adminApi.put('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig);
adminApi.patch('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig);
adminApi.get('/skill-runtime/catalog', requireAdmin, async (_req, res) => {
if (!skillRuntimeConfigService?.listCatalogSummary) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
res.json({ catalog: await skillRuntimeConfigService.listCatalogSummary() });
});
adminApi.get('/skill-runtime/runtime', requireAdmin, async (_req, res) => {
if (!skillRuntimeConfigService?.getPublicRuntimeConfig) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
});
adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => {
if (!systemTestAccountService) {
return res.status(503).json({ message: '系统测试账号服务未启用' });
+13
View File
@@ -29,6 +29,9 @@ export async function bootstrapAdminServices() {
updateMindSpaceConfig,
} = await importMemind('mindspace-config.mjs');
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
const { createMindSearchConfigService } = await importMemind('mindsearch-config.mjs');
const { createPersonalMemoryCandidateStore } = await importMemind('memory-v2-personal-store.mjs');
const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
@@ -93,6 +96,13 @@ export async function bootstrapAdminServices() {
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
env: process.env,
});
const mindSearchConfigService = createMindSearchConfigService(pool, { env: process.env });
await mindSearchConfigService.ensureSchema();
const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool);
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, {
env: process.env,
h5Root,
});
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, {
env: process.env,
});
@@ -145,6 +155,9 @@ export async function bootstrapAdminServices() {
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
+6
View File
@@ -105,6 +105,9 @@ ready
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
@@ -128,6 +131,9 @@ ready
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
+5
View File
@@ -8,6 +8,7 @@ import { DashboardPage } from './admin/pages/DashboardPage';
import { PoliciesPage } from './admin/pages/PoliciesPage';
import { MindSpacePage } from './admin/pages/MindSpacePage';
import { MemoryV2Page } from './admin/pages/MemoryV2Page';
import { MindSearchPage } from './admin/pages/MindSearchPage';
import { ProvidersPage } from './admin/pages/ProvidersPage';
import { SkillsPage } from './admin/pages/SkillsPage';
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
@@ -16,6 +17,7 @@ import { UsersPage } from './admin/pages/UsersPage';
import { WechatPage } from './admin/pages/WechatPage';
import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
import { SkillRuntimePage } from './admin/pages/SkillRuntimePage';
import { defaultHomePath } from './lib/routes';
import { OpsLayout } from './ops/components/OpsLayout';
import { AnalyticsPage } from './ops/pages/AnalyticsPage';
@@ -121,6 +123,8 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
<Route path="policies" element={<PoliciesPage />} />
<Route path="mindspace" element={<MindSpacePage />} />
<Route path="memory-v2" element={<MemoryV2Page />} />
<Route path="mindsearch" element={<MindSearchPage />} />
<Route path="skill-runtime" element={<SkillRuntimePage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="asset-gateway" element={<AssetGatewayPage />} />
@@ -168,6 +172,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|| pathname.startsWith('/policies')
|| pathname.startsWith('/mindspace')
|| pathname.startsWith('/memory-v2')
|| pathname.startsWith('/skill-runtime')
|| pathname.startsWith('/providers')
|| pathname.startsWith('/wechat')
|| pathname.startsWith('/asset-gateway')
+2
View File
@@ -29,6 +29,8 @@ const NAV_SECTIONS: NavSection[] = [
{ to: '/wechat', label: '服务号' },
{ to: '/mindspace', label: 'MindSpace 配置' },
{ to: '/memory-v2', label: 'Memory V2' },
{ to: '/mindsearch', label: 'MindSearch' },
{ to: '/skill-runtime', label: 'Skill Runtime' },
{ to: '/system-tests', label: '系统测试验证' },
{ to: '/capabilities', label: '能力' },
{ to: '/skills', label: '技能' },
+1
View File
@@ -12,6 +12,7 @@ const QUICK_LINKS = [
{ to: '/asset-gateway', label: '资产能力', desc: '可插拔素材插件、Provider 与专属 LLM 选择' },
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
{ to: '/memory-v2', label: 'Memory V2', desc: '长期记忆 backend 与前置路由开关' },
{ to: '/skill-runtime', label: 'Skill Runtime', desc: 'H5 manifest 关键词路由开关' },
{ to: '/system-tests', label: '系统测试验证', desc: '选择测试账号执行联调并汇总问题反馈' },
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
] as const;
+387 -3
View File
@@ -1,8 +1,11 @@
import { useCallback, useEffect, useState, type ChangeEvent } from 'react';
import {
getMemoryV2AdminConfig,
getMemoryV2RuntimeStatus,
listPersonalMemoryCandidates,
listMemoryV2ModelOptions,
updateMemoryV2AdminConfig,
reviewPersonalMemoryCandidate,
} from '../../api/client';
import type {
LlmGlobalSettings,
@@ -10,6 +13,8 @@ import type {
MemoryV2AdminConfig,
MemoryV2AdminConfigResponse,
MemoryV2ModelApiType,
MemoryV2RuntimeStatusResponse,
PersonalMemoryCandidateListResponse,
} from '../../types';
type BackendKey =
@@ -24,6 +29,154 @@ type BackendKey =
type ModelBackendKey = 'mem0' | 'letta' | 'langgraph';
type CapabilityKey =
| 'candidateMemory'
| 'policy'
| 'retriever'
| 'lifecycle'
| 'persona'
| 'graph'
| 'userMemory'
| 'pluginHealth';
type CapabilityField = {
key: string;
label: string;
type: 'boolean' | 'number' | 'select';
options?: Array<{ value: string; label: string }>;
};
const CAPABILITIES: Array<{
key: CapabilityKey;
label: string;
icon: string;
description: string;
fields: CapabilityField[];
}> = [
{
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: '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: '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' },
],
},
];
const BACKENDS: Array<{
key: BackendKey;
label: string;
@@ -161,6 +314,14 @@ function defaultConfig(): MemoryV2AdminConfig {
timeoutMs: '1500',
fallbackRoute: 'agent_orchestration',
},
candidateMemory: { enabled: false, mode: 'active', minImportance: '0.7', minConfidence: '0.8', maxPending: '500', persistenceEnabled: false },
policy: { enabled: false, saveExplicit: true, rejectSensitive: true, requireEvidence: true, retentionDays: '365' },
retriever: { enabled: false, episodicEnabled: true, 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: {},
@@ -177,6 +338,14 @@ function normalizeConfig(config?: Partial<MemoryV2AdminConfig> | null): MemoryV2
return {
global: { ...base.global, ...(config?.global ?? {}) },
chatIntentRouter: { ...base.chatIntentRouter, ...(config?.chatIntentRouter ?? {}) },
candidateMemory: { ...base.candidateMemory, ...(config?.candidateMemory ?? {}) },
policy: { ...base.policy, ...(config?.policy ?? {}) },
retriever: { ...base.retriever, ...(config?.retriever ?? {}) },
lifecycle: { ...base.lifecycle, ...(config?.lifecycle ?? {}) },
persona: { ...base.persona, ...(config?.persona ?? {}) },
graph: { ...base.graph, ...(config?.graph ?? {}) },
userMemory: { ...base.userMemory, ...(config?.userMemory ?? {}) },
pluginHealth: { ...base.pluginHealth, ...(config?.pluginHealth ?? {}) },
pgvector: { ...base.pgvector, ...(config?.pgvector ?? {}) },
qdrant: { ...base.qdrant, ...(config?.qdrant ?? {}) },
weaviate: { ...base.weaviate, ...(config?.weaviate ?? {}) },
@@ -234,12 +403,16 @@ function describeEffectiveModelSource(
export function MemoryV2Page() {
const [payload, setPayload] = useState<MemoryV2AdminConfigResponse | null>(null);
const [runtimeStatus, setRuntimeStatus] = useState<MemoryV2RuntimeStatusResponse | null>(null);
const [candidatePayload, setCandidatePayload] = useState<PersonalMemoryCandidateListResponse | null>(null);
const [candidateError, setCandidateError] = useState<string | null>(null);
const [candidateBusyId, setCandidateBusyId] = useState<string | 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 [busyScope, setBusyScope] = useState<'global' | 'chatIntentRouter' | CapabilityKey | BackendKey | 'reload' | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [expandedBackends, setExpandedBackends] = useState<Record<string, boolean>>({});
@@ -250,15 +423,24 @@ export function MemoryV2Page() {
setError(null);
setMessage(null);
try {
const [configResult, modelOptions] = await Promise.all([
const [configResult, modelOptions, statusResult] = await Promise.all([
getMemoryV2AdminConfig(),
listMemoryV2ModelOptions(),
getMemoryV2RuntimeStatus().catch(() => null),
]);
setPayload(configResult);
setDraft(normalizeConfig(configResult.config));
setProviderKeys(modelOptions.keys.filter((item) => item.status === 'active'));
setGlobalModelSettings(modelOptions.global);
setSupportedApiTypes(modelOptions.supportedApiTypes);
setRuntimeStatus(statusResult);
try {
setCandidatePayload(await listPersonalMemoryCandidates());
setCandidateError(null);
} catch (candidateErr) {
setCandidatePayload(null);
setCandidateError(candidateErr instanceof Error ? candidateErr.message : '候选记忆加载失败');
}
} catch (err) {
setError(err instanceof Error ? err.message : '加载 Memory V2 配置失败');
} finally {
@@ -309,6 +491,22 @@ export function MemoryV2Page() {
}));
};
const updateCapabilityFlag = (capability: CapabilityKey, key: string) => (event: ChangeEvent<HTMLInputElement>) => {
setDraft((current) => ({
...current,
[capability]: { ...current[capability], [key]: event.target.checked },
}));
};
const updateCapabilityValue = (capability: CapabilityKey, key: string) => (
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
setDraft((current) => ({
...current,
[capability]: { ...current[capability], [key]: event.target.value },
}));
};
const updateBackendValue = (backend: BackendKey, field: string) => (
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
@@ -368,7 +566,7 @@ export function MemoryV2Page() {
const saveConfigSection = async (
patch: Partial<MemoryV2AdminConfig>,
scope: 'global' | 'chatIntentRouter' | BackendKey,
scope: 'global' | 'chatIntentRouter' | CapabilityKey | BackendKey,
successMessage: string,
) => {
setBusyScope(scope);
@@ -408,6 +606,28 @@ export function MemoryV2Page() {
);
};
const saveCapabilityConfig = async (capability: CapabilityKey) => {
await saveConfigSection(
{ [capability]: draft[capability] },
capability,
`${CAPABILITIES.find((item) => item.key === capability)?.label ?? capability} 配置已保存。`,
);
};
const reviewCandidate = async (id: string, action: 'accept' | 'reject') => {
setCandidateBusyId(id);
setCandidateError(null);
try {
await reviewPersonalMemoryCandidate(id, action);
setCandidatePayload(await listPersonalMemoryCandidates());
setMessage(action === 'accept' ? '候选记忆已接纳。' : '候选记忆已拒绝。');
} catch (err) {
setCandidateError(err instanceof Error ? err.message : '候选记忆处理失败');
} finally {
setCandidateBusyId(null);
}
};
const current = payload?.config ? normalizeConfig(payload.config) : draft;
const routerSection = draft.chatIntentRouter;
const currentRouterSection = current.chatIntentRouter;
@@ -658,6 +878,170 @@ export function MemoryV2Page() {
</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">
{CAPABILITIES.map((capability, index) => {
const section = draft[capability.key];
const currentSection = current[capability.key];
const enabled = Boolean(section.enabled);
const accent = BACKEND_ACCENTS[index % BACKEND_ACCENTS.length];
return (
<section
key={capability.key}
className={`asset-plugin-card asset-plugin-card--${accent} model-center-card memory-v2-card`}
>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">{capability.icon}</div>
<div>
<h3>{capability.label}</h3>
<p>{capability.description}</p>
</div>
<span className={`asset-status ${enabled ? 'is-on' : ''}`}>
{enabled ? '配置已启用' : '配置未启用'}
</span>
</div>
<div className="admin-form-grid memory-v2-fields">
{capability.fields.map((field) => {
if (field.type === 'boolean') {
return (
<label key={field.key} className="inline-check">
<input
type="checkbox"
checked={Boolean(section[field.key])}
onChange={updateCapabilityFlag(capability.key, field.key)}
/>
{field.label}
</label>
);
}
if (field.type === 'select') {
return (
<label key={field.key}>
<span>{field.label}</span>
<select
value={String(section[field.key] ?? '')}
onChange={updateCapabilityValue(capability.key, 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="number"
value={String(section[field.key] ?? '')}
onChange={updateCapabilityValue(capability.key, field.key)}
/>
</label>
);
})}
</div>
<div className="asset-plugin-footer">
{capability.key === 'pluginHealth' && (
<div className="memory-v2-runtime-health">
<strong></strong>
{runtimeStatus?.memory?.personalMemory ? (
<span>
{String(runtimeStatus.memory.personalMemory.health?.state ?? 'unknown')}
{' · '} {runtimeStatus.memory.personalMemory.effectiveMode ?? 'off'}
{runtimeStatus.memory.personalMemory.autoReviewEnabled ? ' · 自动审核' : ' · 人工审核'}
{' · '} {runtimeStatus.memory.personalMemory.pendingCandidates ?? 0}
{' · '} {runtimeStatus.memory.personalMemory.health?.accepted ?? 0}
{' · '} {runtimeStatus.memory.personalMemory.health?.autoReviewed ?? 0}
{' · '} {runtimeStatus.memory.personalMemory.health?.rejected ?? 0}
{' · '} {runtimeStatus.memory.personalMemory.health?.deduped ?? 0}
</span>
) : (
<span> Shadow Pipeline</span>
)}
</div>
)}
<span className={`asset-status ${Boolean(currentSection.enabled) ? 'is-on' : ''}`}>
{Boolean(currentSection.enabled) ? '启用' : '关闭'}
</span>
<button
type="button"
className="send-btn"
disabled={loading || busyScope !== null}
onClick={() => void saveCapabilityConfig(capability.key)}
>
{busyScope === capability.key ? '保存中...' : `保存${capability.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>
{candidatePayload?.counts?.candidate ?? 0}
{' · '} {candidatePayload?.counts?.accepted ?? 0}
{' · '} {candidatePayload?.counts?.rejected ?? 0}
</p>
<p className="model-center-card-note">
Active Shadow / Canary
</p>
</div>
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={loading || candidateBusyId !== null}>
</button>
</div>
{candidateError && <p className="banner banner-error">{candidateError}</p>}
{!candidateError && (candidatePayload?.items.length ?? 0) === 0 && (
<p className="model-center-card-note">Active Shadow / Canary </p>
)}
<div className="memory-v2-candidate-list">
{(candidatePayload?.items ?? []).map((candidate) => (
<article key={candidate.id} className="memory-v2-candidate-item">
<div className="memory-v2-candidate-meta">
<span className="asset-status is-on">{candidate.memoryType}</span>
<span> {candidate.userId}</span>
<span> {candidate.importance.toFixed(2)}</span>
<span> {candidate.confidence.toFixed(2)}</span>
<span>{new Date(candidate.createdAt).toLocaleString('zh-CN', { hour12: false })}</span>
</div>
<p>{candidate.content}</p>
<small>Policy: {candidate.policyReason} · Evidence: {String(candidate.evidence?.sourceId ?? 'unknown')}</small>
<div className="asset-plugin-footer">
<button
type="button"
className="ghost-btn"
disabled={candidateBusyId !== null}
onClick={() => void reviewCandidate(candidate.id, 'reject')}
>
{candidateBusyId === candidate.id ? '处理中...' : '拒绝'}
</button>
<button
type="button"
className="send-btn"
disabled={candidateBusyId !== null}
onClick={() => void reviewCandidate(candidate.id, 'accept')}
>
{candidateBusyId === candidate.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
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import { getMindSearchConfig, updateMindSearchConfig } from '../../api/client';
import type { MindSearchConfig } from '../../types';
const initial: MindSearchConfig = {
enabled: false,
mode: 'off',
providers: { searxng: false, github: false, reader: false },
settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 },
};
export function MindSearchPage() {
const [config, setConfig] = useState(initial);
const [meta, setMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({});
const [message, setMessage] = useState('加载配置中…');
useEffect(() => {
getMindSearchConfig()
.then((result) => {
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
setMeta(result);
setMessage('');
})
.catch((error) => setMessage(error instanceof Error ? error.message : '配置加载失败'));
}, []);
const save = async () => {
setMessage('保存中…');
try {
const result = await updateMindSearchConfig(config);
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
setMeta(result);
setMessage('已保存到数据库;新会话生效,旧会话不受影响');
} catch (error) {
setMessage(error instanceof Error ? error.message : '保存失败');
}
};
const toggleProvider = (key: keyof MindSearchConfig['providers']) =>
setConfig((current) => ({ ...current, providers: { ...current.providers, [key]: !current.providers[key] } }));
const updateSetting = (key: keyof MindSearchConfig['settings'], value: string | number) =>
setConfig((current) => ({ ...current, settings: { ...current.settings, [key]: value } }));
return (
<section className="admin-page">
<div className="admin-page-header"><div><h1>MindSearch</h1><p></p></div></div>
<div className="admin-card">
<h2></h2>
<label><input type="checkbox" checked={config.enabled} onChange={(event) => setConfig({ ...config, enabled: event.target.checked, mode: event.target.checked ? (config.mode === 'off' ? 'shadow' : config.mode) : 'off' })} /> MindSearch</label>
<label> <select value={config.mode} onChange={(event) => setConfig({ ...config, mode: event.target.value as MindSearchConfig['mode'] })}><option value="off"></option><option value="shadow">Shadow</option><option value="assist">Assist</option></select></label>
<h2>Provider</h2>
<fieldset><legend> Provider</legend>
<label><input type="checkbox" checked={config.providers.searxng} onChange={() => toggleProvider('searxng')} /> SearXNG Web/News</label>
<label><input type="checkbox" checked={config.providers.github} onChange={() => toggleProvider('github')} /> GitHub CodeToken </label>
<label><input type="checkbox" checked={config.providers.reader} onChange={() => toggleProvider('reader')} /> Reader localhost/</label>
</fieldset>
<h2></h2>
<label>SearXNG <input value={config.settings.searxngEndpoint} placeholder="http://127.0.0.1:8080/search" onChange={(event) => updateSetting('searxngEndpoint', event.target.value)} /></label>
<label> <input type="number" min={1} max={20} value={config.settings.maxResults} onChange={(event) => updateSetting('maxResults', Number(event.target.value))} /></label>
<label>Provider <input type="number" min={1000} max={30000} value={config.settings.timeoutMs} onChange={(event) => updateSetting('timeoutMs', Number(event.target.value))} /></label>
<label>Reader <input type="number" min={1000} max={50000} value={config.settings.readerMaxChars} onChange={(event) => updateSetting('readerMaxChars', Number(event.target.value))} /></label>
<button type="button" onClick={save}></button>
{message && <p role="status">{message}</p>}
<p>{meta.source === 'admin' ? '数据库' : '环境变量默认值'}{meta.updatedAt ? new Date(meta.updatedAt).toLocaleString() : '尚未保存'}{meta.updatedBy ? `;操作人:${meta.updatedBy}` : ''}</p>
</div>
</section>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useState } from 'react';
import {
getSkillRuntimeAdminConfig,
listSkillRuntimeCatalog,
updateSkillRuntimeAdminConfig,
} from '../../api/client';
import type { SkillRuntimeAdminConfig, SkillRuntimeCatalogItem } from '../../types';
export function SkillRuntimePage() {
const [config, setConfig] = useState<SkillRuntimeAdminConfig>({
router: { v2Enabled: false, manifestRoutingEnabled: false },
});
const [catalog, setCatalog] = useState<SkillRuntimeCatalogItem[]>([]);
const [source, setSource] = useState('default');
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [configResult, catalogRows] = await Promise.all([
getSkillRuntimeAdminConfig(),
listSkillRuntimeCatalog(),
]);
setConfig(configResult.config);
setSource(configResult.source ?? 'default');
setUpdatedAt(configResult.updatedAt ?? null);
setCatalog(catalogRows);
} catch (err) {
setError(err instanceof Error ? err.message : '加载 Skill Runtime 配置失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const handleSave = async () => {
setSaving(true);
setError(null);
setNotice(null);
try {
const result = await updateSkillRuntimeAdminConfig(config);
setConfig(result.config);
setSource(result.source ?? 'admin-db');
setUpdatedAt(result.updatedAt ?? null);
setNotice('Skill Runtime 配置已保存。H5 用户刷新或重新登录后会读取新开关。');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
const manifestSkillCount = catalog.filter((item) => item.hasManifest && item.triggerKeywords.length > 0).length;
return (
<div className="admin-page">
<div className="admin-page-head">
<h2>Skill Runtime</h2>
<p className="muted"> H5 Skill Router v2 manifest </p>
</div>
{loading ? <p className="muted"></p> : null}
{error ? <p className="banner banner-error">{error}</p> : null}
{notice ? <p className="banner banner-info">{notice}</p> : null}
{!loading ? (
<>
<section className="admin-card">
<h3>Router </h3>
<p className="muted">
<strong>{source}</strong>
{updatedAt
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
: ''}
</p>
<p className="muted">
skill manifest 退 legacy
</p>
<label className="inline-check">
<input
type="checkbox"
checked={config.router.v2Enabled}
onChange={(event) => {
const v2Enabled = event.target.checked;
setConfig({
router: {
v2Enabled,
manifestRoutingEnabled: v2Enabled ? config.router.manifestRoutingEnabled : false,
},
});
}}
/>
<span>
<strong> Skill Router v2</strong>
<span className="muted"> H5 legacy regex </span>
</span>
</label>
<label className="inline-check">
<input
type="checkbox"
checked={config.router.manifestRoutingEnabled}
disabled={!config.router.v2Enabled}
onChange={(event) => {
setConfig({
router: {
...config.router,
manifestRoutingEnabled: event.target.checked,
},
});
}}
/>
<span>
<strong> manifest </strong>
<span className="muted">
skills/*/skill.yaml trigger.keywords manifest skill{manifestSkillCount}{' '}
</span>
</span>
</label>
<div className="admin-actions">
<button type="button" className="send-btn" disabled={saving} onClick={() => void handleSave()}>
{saving ? '保存中…' : '保存配置'}
</button>
<button
type="button"
className="admin-btn-secondary"
disabled={loading || saving}
onClick={() => void load()}
>
</button>
</div>
</section>
<section className="admin-card">
<h3> Catalog </h3>
<p className="muted">
Manifest skill <code>skill.yaml</code>Router v2
Phase 1 <strong>product-campaign-page</strong> manifest skill legacy{' '}
<code>SKILL.md</code> Manifest
</p>
<p className="muted"> skill.yaml Router v2 </p>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Skill</th>
<th>Manifest</th>
<th></th>
<th>Prompt</th>
<th></th>
</tr>
</thead>
<tbody>
{catalog.map((item) => (
<tr key={item.name}>
<td>
<strong>{item.name}</strong>
<div className="muted">{item.description}</div>
</td>
<td>{item.hasManifest ? '是' : '否'}</td>
<td>{item.triggerKeywords.length ? item.triggerKeywords.join('、') : '—'}</td>
<td>{item.routerPromptKey ?? '—'}</td>
<td>{item.routerPriority || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
) : null}
</div>
);
}
+51
View File
@@ -25,6 +25,7 @@ import type {
LlmProviderKeyRow,
PlanDefinition,
PlanSyncResult,
PersonalMemoryCandidateListResponse,
PolicyDefinition,
PolicyMap,
PortalUser,
@@ -32,6 +33,10 @@ import type {
MemoryV2AdminConfig,
MemoryV2AdminConfigResponse,
MemoryV2ModelApiType,
MemoryV2RuntimeStatusResponse,
SkillRuntimeAdminConfig,
SkillRuntimeAdminConfigResponse,
SkillRuntimeCatalogItem,
SkillDefinition,
SkillMap,
UsageRecord,
@@ -42,6 +47,7 @@ import type {
WechatScheduleLlmConfig,
WechatMessage,
WechatWebNotification,
MindSearchConfig,
} from '../types';
export class ApiError extends Error {
@@ -327,6 +333,25 @@ export async function getMemoryV2AdminConfig(): Promise<MemoryV2AdminConfigRespo
return portalFetch('/admin-api/memory-v2/config');
}
export async function getMemoryV2RuntimeStatus(): Promise<MemoryV2RuntimeStatusResponse> {
return portalFetch('/admin-api/memory-v2/status');
}
export async function listPersonalMemoryCandidates(
status = 'candidate',
): Promise<PersonalMemoryCandidateListResponse> {
return portalFetch(`/admin-api/memory-v2/candidates?status=${encodeURIComponent(status)}&limit=50`);
}
export async function reviewPersonalMemoryCandidate(
id: string,
action: 'accept' | 'reject',
): Promise<{ updated: boolean; status: string; reviewedAt: number }> {
return portalFetch(`/admin-api/memory-v2/candidates/${encodeURIComponent(id)}/${action}`, {
method: 'POST',
});
}
export async function updateMemoryV2AdminConfig(
payload: Partial<MemoryV2AdminConfig>,
): Promise<MemoryV2AdminConfigResponse> {
@@ -336,6 +361,24 @@ export async function updateMemoryV2AdminConfig(
});
}
export async function getSkillRuntimeAdminConfig(): Promise<SkillRuntimeAdminConfigResponse> {
return portalFetch('/admin-api/skill-runtime/config');
}
export async function updateSkillRuntimeAdminConfig(
config: SkillRuntimeAdminConfig,
): Promise<SkillRuntimeAdminConfigResponse> {
return portalFetch('/admin-api/skill-runtime/config', {
method: 'PUT',
body: JSON.stringify({ config }),
});
}
export async function listSkillRuntimeCatalog(): Promise<SkillRuntimeCatalogItem[]> {
const result = await portalFetch<{ catalog: SkillRuntimeCatalogItem[] }>('/admin-api/skill-runtime/catalog');
return result.catalog ?? [];
}
export async function listMemoryV2ModelOptions(): Promise<{
global: LlmGlobalSettings;
keys: LlmProviderKeyRow[];
@@ -611,6 +654,14 @@ export async function clearUserCapabilityOverrides(userId: string): Promise<void
await portalFetch(`/admin-api/users/${userId}/capabilities`, { method: 'DELETE' });
}
export async function getMindSearchConfig(): Promise<{ config: MindSearchConfig; source: string }> {
return portalFetch('/admin-api/mindsearch/config');
}
export async function updateMindSearchConfig(config: Partial<MindSearchConfig>): Promise<{ config: MindSearchConfig; source: string }> {
return portalFetch('/admin-api/mindsearch/config', { method: 'PATCH', body: JSON.stringify(config) });
}
// ── Policies ──────────────────────────────────────────
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
+57 -2
View File
@@ -1217,6 +1217,10 @@ body,
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.memory-v2-capability-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.memory-v2-page .asset-plugin-grid {
gap: 12px;
margin-bottom: 12px;
@@ -1322,18 +1326,69 @@ body,
font-size: 12px;
}
.memory-v2-runtime-health {
display: grid;
gap: 3px;
flex: 1 1 100%;
color: var(--color-text-muted);
font-size: 11px;
}
.memory-v2-runtime-health strong {
color: var(--color-text-primary);
font-size: 12px;
}
.memory-v2-candidate-review {
margin-bottom: 12px;
}
.memory-v2-candidate-list {
display: grid;
gap: 10px;
}
.memory-v2-candidate-item {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, .02);
}
.memory-v2-candidate-item p {
margin: 0;
line-height: 1.6;
}
.memory-v2-candidate-item small {
color: var(--color-text-muted);
}
.memory-v2-candidate-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
align-items: center;
color: var(--color-text-muted);
font-size: 11px;
}
.memory-v2-page .model-center-card-note {
margin: 0;
}
@media (min-width: 1280px) {
.memory-v2-backend-grid {
.memory-v2-backend-grid,
.memory-v2-capability-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.memory-v2-backend-grid {
.memory-v2-backend-grid,
.memory-v2-capability-grid {
grid-template-columns: minmax(0, 1fr);
}
}
+88
View File
@@ -296,6 +296,14 @@ export type MemoryV2AdminConfig = {
failOpen?: boolean;
};
chatIntentRouter: MemoryV2AdminSection;
candidateMemory: MemoryV2AdminSection;
policy: MemoryV2AdminSection;
retriever: MemoryV2AdminSection;
lifecycle: MemoryV2AdminSection;
persona: MemoryV2AdminSection;
graph: MemoryV2AdminSection;
userMemory: MemoryV2AdminSection;
pluginHealth: MemoryV2AdminSection;
pgvector: MemoryV2AdminSection;
qdrant: MemoryV2AdminSection;
weaviate: MemoryV2AdminSection;
@@ -312,6 +320,79 @@ export type MemoryV2AdminConfigResponse = {
updatedBy: string | null;
};
export type MemoryV2RuntimeStatusResponse = {
ok: boolean;
checkedAt?: number;
message?: string;
memory?: {
enabled?: boolean;
backend?: string;
selectedBackend?: string | null;
configSource?: string;
configUpdatedAt?: number | null;
personalMemory?: {
enabled?: boolean;
requestedMode?: string;
effectiveMode?: string;
autoReviewEnabled?: boolean;
phase?: string;
injectionEnabled?: boolean;
persistence?: string;
pendingCandidates?: number;
health?: Record<string, string | number | boolean | null>;
policy?: Record<string, string | number | boolean | null>;
};
} | null;
};
export type PersonalMemoryCandidate = {
id: string;
userId: string;
sessionId: string | null;
memoryType: string;
content: string;
importance: number;
confidence: number;
status: string;
policyReason: string;
evidence: Record<string, unknown>;
reviewedBy: string | null;
reviewedAt: number | null;
createdAt: number;
updatedAt: number;
};
export type PersonalMemoryCandidateListResponse = {
items: PersonalMemoryCandidate[];
counts: Record<string, number>;
};
export type SkillRuntimeAdminConfig = {
router: {
v2Enabled: boolean;
manifestRoutingEnabled: boolean;
};
};
export type SkillRuntimeAdminConfigResponse = {
config: SkillRuntimeAdminConfig;
updatedAt: number | null;
updatedBy: string | null;
source?: string;
};
export type SkillRuntimeCatalogItem = {
name: string;
dirName: string;
description: string;
version: string | null;
executors: string[];
hasManifest: boolean;
triggerKeywords: string[];
routerPromptKey: string | null;
routerPriority: number;
};
export type AdminSystemTestStepStatus = 'passed' | 'warning' | 'failed';
export type AdminSystemTestStep = {
@@ -516,3 +597,10 @@ export type AdminSubscription = {
note: string | null;
createdAt: number;
};
export type MindSearchConfig = {
enabled: boolean;
mode: 'off' | 'shadow' | 'assist';
providers: { searxng: boolean; github: boolean; reader: boolean };
settings: { searxngEndpoint: string; maxResults: number; timeoutMs: number; readerMaxChars: number };
};