feat(skills-ui): manage plugin skill mirrors

This commit is contained in:
2026-06-16 12:11:35 +08:00
parent 0ac3cce6f3
commit a9b830d11e
4 changed files with 388 additions and 3 deletions

View File

@ -30,21 +30,28 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
adoptPluginSkill,
deleteSkill,
disablePlugin,
disablePublishedSkill,
downloadSkill,
enablePlugin,
getSkillDetail,
getSkillFile,
getSkillVersion,
listPlugins,
listSkillCandidates,
listSkillDrafts,
listSkills,
pausePlugin,
publishSkillDraft,
recheckSkillDraftSafety,
regenerateSkillDraft,
rejectSkillDraft,
resumePlugin,
rollbackPublishedSkill,
submitSkillDraft,
syncPlugins,
synthesizeSkillDraft,
uploadSkill,
} from '@/lib/api';
@ -62,6 +69,7 @@ import {
} from '@/components/ui/table';
import { SkillDetailView } from '@/components/skills/SkillDetailView';
import type {
BeaverPlugin,
Skill,
SkillDetailResponse,
SkillDraft,
@ -76,10 +84,10 @@ import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapp
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
type SkillsTab = 'published' | 'candidates' | 'drafts';
type SkillsTab = 'published' | 'candidates' | 'drafts' | 'plugins';
function normalizeSkillsTab(value: string | null | undefined): SkillsTab {
if (value === 'candidates' || value === 'drafts') {
if (value === 'candidates' || value === 'drafts' || value === 'plugins') {
return value;
}
return 'published';
@ -92,6 +100,7 @@ export default function SkillsPage() {
const searchParams = useSearchParams();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const [skills, setSkills] = useState<Skill[]>([]);
const [plugins, setPlugins] = useState<BeaverPlugin[]>([]);
const [candidates, setCandidates] = useState<SkillLearningCandidate[]>([]);
const [drafts, setDrafts] = useState<SkillDraft[]>([]);
const [activeTab, setActiveTab] = useState<SkillsTab>(() => normalizeSkillsTab(searchParams?.get('tab')));
@ -111,12 +120,14 @@ export default function SkillsPage() {
setLoading(true);
setError(null);
try {
const [skillData, candidateData, draftData] = await Promise.all([
const [skillData, pluginData, candidateData, draftData] = await Promise.all([
listSkills(),
listPlugins().catch(() => []),
listSkillCandidates().catch(() => []),
listSkillDrafts().catch(() => []),
]);
setSkills(Array.isArray(skillData) ? skillData : []);
setPlugins(Array.isArray(pluginData) ? pluginData : []);
setCandidates(Array.isArray(candidateData) ? candidateData : []);
setDrafts(Array.isArray(draftData) ? draftData : []);
} catch (err: any) {
@ -375,6 +386,7 @@ export default function SkillsPage() {
<TabsTrigger value="published" className="h-10">{t('已发布', 'Published')}</TabsTrigger>
<TabsTrigger value="candidates" className="h-10">{t('候选', 'Candidates')}</TabsTrigger>
<TabsTrigger value="drafts" className="h-10">{t('草稿评审', 'Draft review')}</TabsTrigger>
<TabsTrigger value="plugins" className="h-10">{t('插件', 'Plugins')}</TabsTrigger>
</TabsList>
<TabsContent value="published" className="min-w-0">
@ -466,6 +478,25 @@ export default function SkillsPage() {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="plugins" className="min-w-0">
<PluginsTable
plugins={plugins}
actionId={actionId}
onSync={() => runAction('plugins:sync', () => syncPlugins())}
onEnable={(pluginId) => runAction(`plugin:${pluginId}:enable`, () => enablePlugin(pluginId))}
onPause={(pluginId) => runAction(`plugin:${pluginId}:pause`, () => pausePlugin(pluginId))}
onResume={(pluginId) => runAction(`plugin:${pluginId}:resume`, () => resumePlugin(pluginId))}
onDisable={(pluginId, disableLinkedSkills) =>
runAction(`plugin:${pluginId}:disable`, () =>
disablePlugin(pluginId, { disable_linked_skills: disableLinkedSkills })
)
}
onAdopt={(pluginId, skillName) =>
runAction(`plugin:${pluginId}:skill:${skillName}:adopt`, () => adoptPluginSkill(pluginId, skillName))
}
/>
</TabsContent>
</Tabs>
)}
</div>
@ -526,6 +557,11 @@ function PublishedSkillsTable({
<Badge variant={skill.source === 'builtin' ? 'secondary' : 'default'} className="text-xs">
{skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')}
</Badge>
{skill.source_kind === 'plugin' && (
<Badge variant="outline" className="text-xs">
{t('插件', 'Plugin')}
</Badge>
)}
<Badge variant={skill.available ? 'default' : 'outline'} className="text-xs">
{skill.available ? t('可用', 'Available') : t('不可用', 'Unavailable')}
</Badge>
@ -583,6 +619,11 @@ function PublishedSkillsTable({
<Badge variant={skill.source === 'builtin' ? 'secondary' : 'default'} className="text-xs">
{skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')}
</Badge>
{skill.source_kind === 'plugin' && (
<Badge variant="outline" className="ml-1 text-xs">
{t('插件', 'Plugin')}
</Badge>
)}
</TableCell>
<TableCell>
<Badge variant={skill.available ? 'default' : 'outline'} className="text-xs">
@ -658,6 +699,204 @@ function PublishedSkillsTable({
);
}
function PluginsTable({
plugins,
actionId,
onSync,
onEnable,
onPause,
onResume,
onDisable,
onAdopt,
}: {
plugins: BeaverPlugin[];
actionId: string | null;
onSync: () => Promise<unknown>;
onEnable: (pluginId: string) => Promise<unknown>;
onPause: (pluginId: string) => Promise<unknown>;
onResume: (pluginId: string) => Promise<unknown>;
onDisable: (pluginId: string, disableLinkedSkills: boolean) => Promise<unknown>;
onAdopt: (pluginId: string, skillName: string) => Promise<unknown>;
}) {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const busy = Boolean(actionId);
const confirmDisable = (plugin: BeaverPlugin) => {
const confirmed = window.confirm(
t(
`禁用 ${plugin.name} 并同时禁用已镜像技能?`,
`Disable ${plugin.name} and its mirrored skills?`
)
);
if (!confirmed) return;
void onDisable(plugin.id, true);
};
const confirmAdopt = (plugin: BeaverPlugin, skillName: string) => {
const confirmed = window.confirm(
t(
`采纳 ${skillName} 的当前 Beaver 版本作为 ${plugin.name} 的本地分叉?后续自动上游合并会停止。`,
`Adopt the current Beaver version of ${skillName} as a local fork from ${plugin.name}? Future automatic upstream merges will stop.`
)
);
if (confirmed) {
void onAdopt(plugin.id, skillName);
}
};
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base">{t('声明式插件', 'Declarative plugins')}</CardTitle>
<Button variant="outline" size="sm" className="h-11" disabled={busy} onClick={() => void onSync()}>
{actionId === 'plugins:sync' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{t('同步插件', 'Sync plugins')}
</Button>
</CardHeader>
<CardContent>
{plugins.length === 0 ? (
<EmptyState icon={<Puzzle className="h-8 w-8" />} text={t('暂无已发现插件', 'No discovered plugins yet')} />
) : (
<div className="space-y-4">
{plugins.map((plugin) => (
<div key={plugin.id} className="min-w-0 rounded-lg border border-border bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className={`text-base font-semibold ${containedLongTextClass}`}>{plugin.name}</h3>
<Badge variant={plugin.enabled ? 'default' : 'outline'}>
{plugin.enabled ? t('已启用', 'Enabled') : t('未启用', 'Disabled')}
</Badge>
<Badge variant={plugin.updates_paused ? 'destructive' : 'outline'}>
{plugin.updates_paused ? t('更新暂停', 'Updates paused') : t('自动更新', 'Auto updates')}
</Badge>
<Badge variant="secondary">{pluginStatusLabel(plugin.status, t)}</Badge>
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className={`font-mono ${containedLongTextClass}`}>{plugin.id}</span>
<span>{t('已安装版本', 'Installed')}: {plugin.installed_version || '-'}</span>
<span>{t('发现版本', 'Discovered')}: {plugin.discovered_version || '-'}</span>
{plugin.manifest_path && <span className={containedLongTextClass}>{plugin.manifest_path}</span>}
</div>
{plugin.status === 'missing' && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-2 text-sm text-amber-900">
{t(
'插件 manifest 缺失:当前技能保持可用,插件更新已暂停。',
'Plugin manifest is missing: current skills remain active, and plugin updates are suspended.'
)}
</div>
)}
{plugin.last_error && (
<div className={`text-sm text-destructive ${containedLongTextClass}`}>{plugin.last_error}</div>
)}
</div>
<div className="flex flex-wrap gap-2">
{!plugin.enabled ? (
<Button
size="sm"
className="h-11"
disabled={busy}
onClick={() => void onEnable(plugin.id)}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t('启用', 'Enable')}
</Button>
) : plugin.updates_paused ? (
<Button
size="sm"
variant="outline"
className="h-11"
disabled={busy}
onClick={() => void onResume(plugin.id)}
>
<RefreshCw className="mr-2 h-4 w-4" />
{t('恢复更新', 'Resume')}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-11"
disabled={busy}
onClick={() => void onPause(plugin.id)}
>
<X className="mr-2 h-4 w-4" />
{t('暂停更新', 'Pause')}
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-11 text-destructive hover:text-destructive"
disabled={busy || !plugin.enabled}
onClick={() => confirmDisable(plugin)}
>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('禁用插件', 'Disable plugin')}
</Button>
</div>
</div>
<div className="mt-4 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('技能', 'Skill')}</TableHead>
<TableHead>{t('绑定状态', 'Binding')}</TableHead>
<TableHead>{t('版本', 'Version')}</TableHead>
<TableHead>{t('上游哈希', 'Upstream hash')}</TableHead>
<TableHead>{t('候选', 'Candidate')}</TableHead>
<TableHead className="w-28">{t('操作', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{plugin.skills.map((binding) => (
<TableRow key={`${plugin.id}:${binding.name}`}>
<TableCell className={`font-medium ${containedLongTextClass}`}>{binding.name}</TableCell>
<TableCell>
<Badge variant={binding.status === 'linked' ? 'outline' : 'secondary'}>
{pluginSkillBindingLabel(binding.status, t)}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{binding.current_beaver_version || binding.accepted_beaver_version || '-'}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{shortHash(binding.observed_upstream_tree_hash || binding.accepted_upstream_tree_hash)}
</TableCell>
<TableCell className={`text-xs text-muted-foreground ${containedLongTextClass}`}>
{binding.pending_candidate_id || '-'}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
className="h-11"
disabled={busy || binding.status === 'adopted'}
onClick={() => confirmAdopt(plugin, binding.name)}
>
{t('采纳', 'Adopt')}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function CandidateCard({
candidate,
actionId,
@ -686,6 +925,7 @@ function CandidateCard({
const confidence = typeof candidate.confidence === 'number' && candidate.confidence > 0
? `${Math.round(candidate.confidence * 100)}%`
: null;
const pluginMergeMode = String(evidence.merge_mode || '').trim();
return (
<div className="min-w-0 max-w-full rounded-lg border border-border bg-white p-4">
@ -698,6 +938,9 @@ function CandidateCard({
{t('风险', 'Risk')}: {riskLabel(risk, t)}
</Badge>
{confidence && <Badge variant="outline">{t('置信度', 'Confidence')}: {confidence}</Badge>}
{candidate.kind === 'plugin_skill_update' && pluginMergeMode && (
<Badge variant="outline">{t('合并模式', 'Merge')}: {pluginMergeMode}</Badge>
)}
{typeof candidate.priority === 'number' && candidate.priority > 0 && (
<Badge variant="outline">{t('优先级', 'Priority')}: {candidate.priority}</Badge>
)}
@ -819,6 +1062,7 @@ function DraftCard({
const safety = draft.safety_report;
const evalReport = draft.eval_report;
const frontmatter = draft.proposed_frontmatter || {};
const provenance = draft.provenance || {};
const description = String(frontmatter.description || '').trim();
const toolHints = normalizeStringList(frontmatter.tools);
const submittedForReview = draft.status === 'in_review' || draft.status === 'approved';
@ -843,6 +1087,7 @@ function DraftCard({
: isHighRisk
? t('高风险草稿,发布前需要再次确认。', 'High-risk draft; publishing requires confirmation.')
: t('已满足发布门禁。', 'Publish gates are satisfied.');
const pluginMergeMode = String(provenance.merge_mode || provenance.plugin_merge_mode || '').trim();
const handlePublish = () => {
if (isHighRisk) {
const confirmed = window.confirm(
@ -858,6 +1103,9 @@ function DraftCard({
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{candidateKindLabel(draft.proposal_kind, t)}</Badge>
{draft.proposal_kind === 'plugin_skill_update' && pluginMergeMode && (
<Badge variant="outline">{t('合并模式', 'Merge')}: {pluginMergeMode}</Badge>
)}
<Badge variant="secondary">{draftStatusLabel(draft.status, t)}</Badge>
{safety && (
<Badge variant={safety.risk_level === 'critical' || safety.risk_level === 'high' ? 'destructive' : 'outline'}>
@ -1459,6 +1707,11 @@ function candidateTitle(candidate: SkillLearningCandidate, t: (zh: string, en: s
? t(`考虑下线技能 ${related}`, `Consider retiring ${related}`)
: t('考虑下线技能', 'Consider retiring a skill');
}
if (candidate.kind === 'plugin_skill_update') {
return related
? t(`合并插件技能 ${related} 的上游更新`, `Merge upstream plugin update for ${related}`)
: t('合并插件技能上游更新', 'Merge an upstream plugin skill update');
}
return candidate.reason || candidate.candidate_id;
}
@ -1481,10 +1734,39 @@ function candidateKindLabel(kind: string, t: (zh: string, en: string) => string)
revise_skill: t('修订技能', 'Revise skill'),
merge_skills: t('合并技能', 'Merge skills'),
retire_skill: t('下线技能', 'Retire skill'),
plugin_skill_update: t('插件升级合并', 'Plugin update merge'),
};
return labels[kind] || kind;
}
function pluginStatusLabel(status: string, t: (zh: string, en: string) => string): string {
const labels: Record<string, string> = {
discovered: t('已发现', 'Discovered'),
enabled: t('已启用', 'Enabled'),
paused: t('已暂停', 'Paused'),
missing: t('缺失', 'Missing'),
disabled: t('已禁用', 'Disabled'),
error: t('错误', 'Error'),
};
return labels[status] || status;
}
function pluginSkillBindingLabel(status: string, t: (zh: string, en: string) => string): string {
const labels: Record<string, string> = {
linked: t('跟随上游', 'Linked'),
update_pending: t('待合并', 'Update pending'),
adopted: t('本地分叉', 'Adopted'),
disabled: t('已禁用', 'Disabled'),
missing: t('上游缺失', 'Missing upstream'),
};
return labels[status] || status;
}
function shortHash(value?: string | null): string {
if (!value) return '-';
return value.length > 12 ? value.slice(0, 12) : value;
}
function candidateStatusLabel(status: string, t: (zh: string, en: string) => string): string {
const labels: Record<string, string> = {
open: t('待处理', 'Open'),

View File

@ -19,6 +19,7 @@ import type {
FileAttachment,
NotificationDetail,
NotificationRun,
BeaverPlugin,
ProviderConfigPayload,
Session,
SessionDetail,
@ -833,6 +834,55 @@ export async function listSkills(): Promise<Skill[]> {
return fetchJSON('/api/skills');
}
export async function listPlugins(): Promise<BeaverPlugin[]> {
return fetchJSON('/api/plugins');
}
export async function syncPlugins(): Promise<BeaverPlugin[]> {
return fetchJSON('/api/plugins/sync', {
method: 'POST',
body: JSON.stringify({}),
});
}
export async function enablePlugin(pluginId: string): Promise<BeaverPlugin> {
return fetchJSON(`/api/plugins/${encodeURIComponent(pluginId)}/enable`, {
method: 'POST',
body: JSON.stringify({}),
});
}
export async function pausePlugin(pluginId: string): Promise<BeaverPlugin> {
return fetchJSON(`/api/plugins/${encodeURIComponent(pluginId)}/pause`, {
method: 'POST',
body: JSON.stringify({}),
});
}
export async function resumePlugin(pluginId: string): Promise<BeaverPlugin> {
return fetchJSON(`/api/plugins/${encodeURIComponent(pluginId)}/resume`, {
method: 'POST',
body: JSON.stringify({}),
});
}
export async function disablePlugin(
pluginId: string,
payload: { disable_linked_skills: boolean }
): Promise<BeaverPlugin> {
return fetchJSON(`/api/plugins/${encodeURIComponent(pluginId)}/disable`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function adoptPluginSkill(pluginId: string, skillName: string): Promise<BeaverPlugin> {
return fetchJSON(`/api/plugins/${encodeURIComponent(pluginId)}/skills/${encodeURIComponent(skillName)}/adopt`, {
method: 'POST',
body: JSON.stringify({}),
});
}
export async function getSkillDetail(skillName: string): Promise<SkillDetailResponse> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/detail`);
}

View File

@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = resolve(__dirname, '..');
describe('plugin API client wiring', () => {
it('declares plugin API types', () => {
const types = readFileSync(resolve(root, 'types/index.ts'), 'utf8');
expect(types).toContain('export interface PluginSkillBinding');
expect(types).toContain('export interface BeaverPlugin');
});
it('routes plugin API helpers to backend endpoints', () => {
const api = readFileSync(resolve(root, 'lib/api.ts'), 'utf8');
expect(api).toContain('listPlugins');
expect(api).toContain('/api/plugins');
expect(api).toContain('/api/plugins/sync');
expect(api).toContain('/api/plugins/${encodeURIComponent(pluginId)}/enable');
expect(api).toContain('/api/plugins/${encodeURIComponent(pluginId)}/pause');
expect(api).toContain('/api/plugins/${encodeURIComponent(pluginId)}/resume');
expect(api).toContain('/api/plugins/${encodeURIComponent(pluginId)}/disable');
expect(api).toContain('/api/plugins/${encodeURIComponent(pluginId)}/skills/${encodeURIComponent(skillName)}/adopt');
expect(api).toContain('disable_linked_skills');
});
});

View File

@ -305,6 +305,29 @@ export interface Skill {
agent_cards?: Record<string, unknown>[];
}
export interface PluginSkillBinding {
name: string;
status: string;
current_beaver_version?: string | null;
accepted_upstream_tree_hash?: string | null;
observed_upstream_tree_hash?: string | null;
accepted_beaver_version?: string | null;
pending_candidate_id?: string | null;
}
export interface BeaverPlugin {
id: string;
name: string;
discovered_version?: string | null;
installed_version?: string | null;
enabled: boolean;
updates_paused: boolean;
status: string;
last_error?: string | null;
manifest_path?: string | null;
skills: PluginSkillBinding[];
}
export interface SkillVersionRef {
version: string;
status?: string | null;
@ -1027,6 +1050,7 @@ export interface SkillDraft {
reason: string;
status: string;
evidence_refs: Array<Record<string, unknown>>;
provenance?: Record<string, unknown>;
proposal_kind: string;
reviews?: SkillReviewRecord[];
safety_report?: SkillDraftSafetyReport | null;