,
- 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() {
+ Personal Memory 能力管理
+
+ 配置统一保存到 Memory V2 控制面。启用配置不代表运行插件已经健康,实际状态仍需结合插件健康与运行时状态判断。
+
+
+ {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 (
+
+
+
{capability.icon}
+
+
{capability.label}
+
{capability.description}
+
+
+ {enabled ? '配置已启用' : '配置未启用'}
+
+
+
+
+ {capability.fields.map((field) => {
+ if (field.type === 'boolean') {
+ return (
+
+ );
+ }
+ if (field.type === 'select') {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ })}
+
+
+
+ {capability.key === 'pluginHealth' && (
+
+ 运行时状态
+ {runtimeStatus?.memory?.personalMemory ? (
+
+ {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}
+
+ ) : (
+ 主站尚未加载 Shadow Pipeline,或运行时状态暂不可用
+ )}
+
+ )}
+
+ 当前保存:{Boolean(currentSection.enabled) ? '启用' : '关闭'}
+
+
+
+
+ );
+ })}
+
+
+ 候选记忆审核
+
+
+
C
+
+
待审核候选
+
+ 待审核 {candidatePayload?.counts?.candidate ?? 0}
+ {' · '}已接纳 {candidatePayload?.counts?.accepted ?? 0}
+ {' · '}已拒绝 {candidatePayload?.counts?.rejected ?? 0}
+
+
+
+
+
+ {candidateError && {candidateError}
}
+ {!candidateError && (candidatePayload?.items.length ?? 0) === 0 && (
+ 当前没有待审核候选。Shadow Pipeline 观察到稳定决策、偏好或目标后会写入这里。
+ )}
+
+ {(candidatePayload?.items ?? []).map((candidate) => (
+
+
+ {candidate.memoryType}
+ 用户 {candidate.userId}
+ 重要度 {candidate.importance.toFixed(2)}
+ 置信度 {candidate.confidence.toFixed(2)}
+ {new Date(candidate.createdAt).toLocaleString('zh-CN', { hour12: false })}
+
+ {candidate.content}
+ Policy: {candidate.policyReason} · Evidence: {String(candidate.evidence?.sourceId ?? 'unknown')}
+
+
+
+
+
+ ))}
+
+
+
Backend 配置
单独启用后,只有当全局默认 backend 指向它时才会成为主读写目标。未启用的 backend 默认折叠,点击标题可展开。
diff --git a/src/admin/pages/SkillRuntimePage.tsx b/src/admin/pages/SkillRuntimePage.tsx
new file mode 100644
index 0000000..48e32d0
--- /dev/null
+++ b/src/admin/pages/SkillRuntimePage.tsx
@@ -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({
+ router: { v2Enabled: false, manifestRoutingEnabled: false },
+ });
+ const [catalog, setCatalog] = useState([]);
+ const [source, setSource] = useState('default');
+ const [updatedAt, setUpdatedAt] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(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 (
+
+
+
Skill Runtime
+
控制 H5 Skill Router v2 与 manifest 关键词路由(默认关闭)
+
+
+ {loading ?
加载中…
: null}
+ {error ?
{error}
: null}
+ {notice ?
{notice}
: null}
+
+ {!loading ? (
+ <>
+
+
+
+ 技能 Catalog 预览
+
+ 「Manifest」表示该 skill 目录下是否存在 skill.yaml(Router v2 关键词路由配置)。
+ 目前 Phase 1 仅 product-campaign-page 做了 manifest 试点;其余 skill 仍只有 legacy{' '}
+ SKILL.md,因此 Manifest 显示「否」是正常现象。
+
+ 有 skill.yaml 且配置了关键词的条目会在 Router v2 开启后参与匹配。
+
+
+
+
+ | Skill |
+ Manifest |
+ 关键词 |
+ Prompt |
+ 优先级 |
+
+
+
+ {catalog.map((item) => (
+
+ |
+ {item.name}
+ {item.description}
+ |
+ {item.hasManifest ? '是' : '否'} |
+ {item.triggerKeywords.length ? item.triggerKeywords.join('、') : '—'} |
+ {item.routerPromptKey ?? '—'} |
+ {item.routerPriority || '—'} |
+
+ ))}
+
+
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/src/api/client.ts b/src/api/client.ts
index e438f17..0b5dcd1 100644
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -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 {
+ return portalFetch('/admin-api/memory-v2/status');
+}
+
+export async function listPersonalMemoryCandidates(
+ status = 'candidate',
+): Promise {
+ 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,
): Promise {
@@ -336,6 +360,24 @@ export async function updateMemoryV2AdminConfig(
});
}
+export async function getSkillRuntimeAdminConfig(): Promise {
+ return portalFetch('/admin-api/skill-runtime/config');
+}
+
+export async function updateSkillRuntimeAdminConfig(
+ config: SkillRuntimeAdminConfig,
+): Promise {
+ return portalFetch('/admin-api/skill-runtime/config', {
+ method: 'PUT',
+ body: JSON.stringify({ config }),
+ });
+}
+
+export async function listSkillRuntimeCatalog(): Promise {
+ const result = await portalFetch<{ catalog: SkillRuntimeCatalogItem[] }>('/admin-api/skill-runtime/catalog');
+ return result.catalog ?? [];
+}
+
export async function listMemoryV2ModelOptions(): Promise<{
global: LlmGlobalSettings;
keys: LlmProviderKeyRow[];
diff --git a/src/index.css b/src/index.css
index b9b9883..1fd88e8 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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);
}
}
diff --git a/src/types.ts b/src/types.ts
index d2a8d3e..1c1062b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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;
+ policy?: Record;
+ };
+ } | 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;
+ reviewedBy: string | null;
+ reviewedAt: number | null;
+ createdAt: number;
+ updatedAt: number;
+};
+
+export type PersonalMemoryCandidateListResponse = {
+ items: PersonalMemoryCandidate[];
+ counts: Record;
+};
+
+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 = {