feat(admin): add Memory V2 personal controls and Skill Runtime page

Wire admin UI and API routes to restored Memind personal memory and skill
runtime modules, including candidate review and runtime status display.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-13 14:00:20 +08:00
parent 4cbacb8529
commit 731d93843d
11 changed files with 872 additions and 5 deletions
+107
View File
@@ -98,6 +98,8 @@ export function createAdminApp(services) {
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
@@ -366,6 +368,111 @@ export function createAdminApp(services) {
res.json(result);
});
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: '系统测试账号服务未启用' });
+9
View File
@@ -29,6 +29,8 @@ export async function bootstrapAdminServices() {
updateMindSpaceConfig,
} = await importMemind('mindspace-config.mjs');
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-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 +95,11 @@ export async function bootstrapAdminServices() {
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
env: process.env,
});
const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool);
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, {
env: process.env,
h5Root,
});
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, {
env: process.env,
});
@@ -145,6 +152,8 @@ export async function bootstrapAdminServices() {
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
+4
View File
@@ -105,6 +105,8 @@ ready
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
@@ -128,6 +130,8 @@ ready
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
+3
View File
@@ -16,6 +16,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 +122,7 @@ 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="skill-runtime" element={<SkillRuntimePage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="asset-gateway" element={<AssetGatewayPage />} />
@@ -168,6 +170,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')
+1
View File
@@ -29,6 +29,7 @@ const NAV_SECTIONS: NavSection[] = [
{ to: '/wechat', label: '服务号' },
{ to: '/mindspace', label: 'MindSpace 配置' },
{ to: '/memory-v2', label: 'Memory V2' },
{ 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;
+382 -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: 'shadow', label: 'Shadow' },
{ value: 'canary', label: 'Canary' },
{ value: 'active', label: 'Active' },
] },
{ 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: 'off', 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,165 @@ 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.pendingCandidates ?? 0}
{' · '} {runtimeStatus.memory.personalMemory.health?.accepted ?? 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>
</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">Shadow Pipeline </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
+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>
);
}
+42
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,
@@ -327,6 +332,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 +360,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[];
+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);
}
}
+80
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,78 @@ 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;
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 = {