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
+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>
);
}