731d93843d
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>
187 lines
6.8 KiB
TypeScript
187 lines
6.8 KiB
TypeScript
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>
|
||
);
|
||
}
|