feat(skills-ui): manage plugin skill mirrors
This commit is contained in:
@ -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'),
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
29
app-instance/frontend/lib/plugin-api.test.ts
Normal file
29
app-instance/frontend/lib/plugin-api.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user