diff --git a/app-instance/frontend/app/(app)/skills/page.tsx b/app-instance/frontend/app/(app)/skills/page.tsx index 771461f..2271a5a 100644 --- a/app-instance/frontend/app/(app)/skills/page.tsx +++ b/app-instance/frontend/app/(app)/skills/page.tsx @@ -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([]); + const [plugins, setPlugins] = useState([]); const [candidates, setCandidates] = useState([]); const [drafts, setDrafts] = useState([]); const [activeTab, setActiveTab] = useState(() => 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() { {t('已发布', 'Published')} {t('候选', 'Candidates')} {t('草稿评审', 'Draft review')} + {t('插件', 'Plugins')} @@ -466,6 +478,25 @@ export default function SkillsPage() { + + + 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)) + } + /> + )} @@ -526,6 +557,11 @@ function PublishedSkillsTable({ {skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')} + {skill.source_kind === 'plugin' && ( + + {t('插件', 'Plugin')} + + )} {skill.available ? t('可用', 'Available') : t('不可用', 'Unavailable')} @@ -583,6 +619,11 @@ function PublishedSkillsTable({ {skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')} + {skill.source_kind === 'plugin' && ( + + {t('插件', 'Plugin')} + + )} @@ -658,6 +699,204 @@ function PublishedSkillsTable({ ); } +function PluginsTable({ + plugins, + actionId, + onSync, + onEnable, + onPause, + onResume, + onDisable, + onAdopt, +}: { + plugins: BeaverPlugin[]; + actionId: string | null; + onSync: () => Promise; + onEnable: (pluginId: string) => Promise; + onPause: (pluginId: string) => Promise; + onResume: (pluginId: string) => Promise; + onDisable: (pluginId: string, disableLinkedSkills: boolean) => Promise; + onAdopt: (pluginId: string, skillName: string) => Promise; +}) { + 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 ( + + + {t('声明式插件', 'Declarative plugins')} + + + + {plugins.length === 0 ? ( + } text={t('暂无已发现插件', 'No discovered plugins yet')} /> + ) : ( +
+ {plugins.map((plugin) => ( +
+
+
+
+

{plugin.name}

+ + {plugin.enabled ? t('已启用', 'Enabled') : t('未启用', 'Disabled')} + + + {plugin.updates_paused ? t('更新暂停', 'Updates paused') : t('自动更新', 'Auto updates')} + + {pluginStatusLabel(plugin.status, t)} +
+
+ {plugin.id} + {t('已安装版本', 'Installed')}: {plugin.installed_version || '-'} + {t('发现版本', 'Discovered')}: {plugin.discovered_version || '-'} + {plugin.manifest_path && {plugin.manifest_path}} +
+ {plugin.status === 'missing' && ( +
+ {t( + '插件 manifest 缺失:当前技能保持可用,插件更新已暂停。', + 'Plugin manifest is missing: current skills remain active, and plugin updates are suspended.' + )} +
+ )} + {plugin.last_error && ( +
{plugin.last_error}
+ )} +
+
+ {!plugin.enabled ? ( + + ) : plugin.updates_paused ? ( + + ) : ( + + )} + +
+
+ +
+ + + + {t('技能', 'Skill')} + {t('绑定状态', 'Binding')} + {t('版本', 'Version')} + {t('上游哈希', 'Upstream hash')} + {t('候选', 'Candidate')} + {t('操作', 'Actions')} + + + + {plugin.skills.map((binding) => ( + + {binding.name} + + + {pluginSkillBindingLabel(binding.status, t)} + + + + {binding.current_beaver_version || binding.accepted_beaver_version || '-'} + + + {shortHash(binding.observed_upstream_tree_hash || binding.accepted_upstream_tree_hash)} + + + {binding.pending_candidate_id || '-'} + + + + + + ))} + +
+
+
+ ))} +
+ )} +
+
+ ); +} + 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 (
@@ -698,6 +938,9 @@ function CandidateCard({ {t('风险', 'Risk')}: {riskLabel(risk, t)} {confidence && {t('置信度', 'Confidence')}: {confidence}} + {candidate.kind === 'plugin_skill_update' && pluginMergeMode && ( + {t('合并模式', 'Merge')}: {pluginMergeMode} + )} {typeof candidate.priority === 'number' && candidate.priority > 0 && ( {t('优先级', 'Priority')}: {candidate.priority} )} @@ -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({
{candidateKindLabel(draft.proposal_kind, t)} + {draft.proposal_kind === 'plugin_skill_update' && pluginMergeMode && ( + {t('合并模式', 'Merge')}: {pluginMergeMode} + )} {draftStatusLabel(draft.status, t)} {safety && ( @@ -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 = { + 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 = { + 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 = { open: t('待处理', 'Open'), diff --git a/app-instance/frontend/lib/api.ts b/app-instance/frontend/lib/api.ts index f7165ef..2945319 100644 --- a/app-instance/frontend/lib/api.ts +++ b/app-instance/frontend/lib/api.ts @@ -19,6 +19,7 @@ import type { FileAttachment, NotificationDetail, NotificationRun, + BeaverPlugin, ProviderConfigPayload, Session, SessionDetail, @@ -833,6 +834,55 @@ export async function listSkills(): Promise { return fetchJSON('/api/skills'); } +export async function listPlugins(): Promise { + return fetchJSON('/api/plugins'); +} + +export async function syncPlugins(): Promise { + return fetchJSON('/api/plugins/sync', { + method: 'POST', + body: JSON.stringify({}), + }); +} + +export async function enablePlugin(pluginId: string): Promise { + return fetchJSON(`/api/plugins/${encodeURIComponent(pluginId)}/enable`, { + method: 'POST', + body: JSON.stringify({}), + }); +} + +export async function pausePlugin(pluginId: string): Promise { + return fetchJSON(`/api/plugins/${encodeURIComponent(pluginId)}/pause`, { + method: 'POST', + body: JSON.stringify({}), + }); +} + +export async function resumePlugin(pluginId: string): Promise { + return fetchJSON(`/api/plugins/${encodeURIComponent(pluginId)}/resume`, { + method: 'POST', + body: JSON.stringify({}), + }); +} + +export async function disablePlugin( + pluginId: string, + payload: { disable_linked_skills: boolean } +): Promise { + return fetchJSON(`/api/plugins/${encodeURIComponent(pluginId)}/disable`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function adoptPluginSkill(pluginId: string, skillName: string): Promise { + return fetchJSON(`/api/plugins/${encodeURIComponent(pluginId)}/skills/${encodeURIComponent(skillName)}/adopt`, { + method: 'POST', + body: JSON.stringify({}), + }); +} + export async function getSkillDetail(skillName: string): Promise { return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/detail`); } diff --git a/app-instance/frontend/lib/plugin-api.test.ts b/app-instance/frontend/lib/plugin-api.test.ts new file mode 100644 index 0000000..fe2f8aa --- /dev/null +++ b/app-instance/frontend/lib/plugin-api.test.ts @@ -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'); + }); +}); diff --git a/app-instance/frontend/types/index.ts b/app-instance/frontend/types/index.ts index 1844689..c08c717 100644 --- a/app-instance/frontend/types/index.ts +++ b/app-instance/frontend/types/index.ts @@ -305,6 +305,29 @@ export interface Skill { agent_cards?: Record[]; } +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>; + provenance?: Record; proposal_kind: string; reviews?: SkillReviewRecord[]; safety_report?: SkillDraftSafetyReport | null;