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 remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
adoptPluginSkill,
|
||||||
deleteSkill,
|
deleteSkill,
|
||||||
|
disablePlugin,
|
||||||
disablePublishedSkill,
|
disablePublishedSkill,
|
||||||
downloadSkill,
|
downloadSkill,
|
||||||
|
enablePlugin,
|
||||||
getSkillDetail,
|
getSkillDetail,
|
||||||
getSkillFile,
|
getSkillFile,
|
||||||
getSkillVersion,
|
getSkillVersion,
|
||||||
|
listPlugins,
|
||||||
listSkillCandidates,
|
listSkillCandidates,
|
||||||
listSkillDrafts,
|
listSkillDrafts,
|
||||||
listSkills,
|
listSkills,
|
||||||
|
pausePlugin,
|
||||||
publishSkillDraft,
|
publishSkillDraft,
|
||||||
recheckSkillDraftSafety,
|
recheckSkillDraftSafety,
|
||||||
regenerateSkillDraft,
|
regenerateSkillDraft,
|
||||||
rejectSkillDraft,
|
rejectSkillDraft,
|
||||||
|
resumePlugin,
|
||||||
rollbackPublishedSkill,
|
rollbackPublishedSkill,
|
||||||
submitSkillDraft,
|
submitSkillDraft,
|
||||||
|
syncPlugins,
|
||||||
synthesizeSkillDraft,
|
synthesizeSkillDraft,
|
||||||
uploadSkill,
|
uploadSkill,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
@ -62,6 +69,7 @@ import {
|
|||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { SkillDetailView } from '@/components/skills/SkillDetailView';
|
import { SkillDetailView } from '@/components/skills/SkillDetailView';
|
||||||
import type {
|
import type {
|
||||||
|
BeaverPlugin,
|
||||||
Skill,
|
Skill,
|
||||||
SkillDetailResponse,
|
SkillDetailResponse,
|
||||||
SkillDraft,
|
SkillDraft,
|
||||||
@ -76,10 +84,10 @@ import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapp
|
|||||||
|
|
||||||
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
|
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
|
||||||
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
|
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 {
|
function normalizeSkillsTab(value: string | null | undefined): SkillsTab {
|
||||||
if (value === 'candidates' || value === 'drafts') {
|
if (value === 'candidates' || value === 'drafts' || value === 'plugins') {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
return 'published';
|
return 'published';
|
||||||
@ -92,6 +100,7 @@ export default function SkillsPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
|
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
|
||||||
const [skills, setSkills] = useState<Skill[]>([]);
|
const [skills, setSkills] = useState<Skill[]>([]);
|
||||||
|
const [plugins, setPlugins] = useState<BeaverPlugin[]>([]);
|
||||||
const [candidates, setCandidates] = useState<SkillLearningCandidate[]>([]);
|
const [candidates, setCandidates] = useState<SkillLearningCandidate[]>([]);
|
||||||
const [drafts, setDrafts] = useState<SkillDraft[]>([]);
|
const [drafts, setDrafts] = useState<SkillDraft[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<SkillsTab>(() => normalizeSkillsTab(searchParams?.get('tab')));
|
const [activeTab, setActiveTab] = useState<SkillsTab>(() => normalizeSkillsTab(searchParams?.get('tab')));
|
||||||
@ -111,12 +120,14 @@ export default function SkillsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [skillData, candidateData, draftData] = await Promise.all([
|
const [skillData, pluginData, candidateData, draftData] = await Promise.all([
|
||||||
listSkills(),
|
listSkills(),
|
||||||
|
listPlugins().catch(() => []),
|
||||||
listSkillCandidates().catch(() => []),
|
listSkillCandidates().catch(() => []),
|
||||||
listSkillDrafts().catch(() => []),
|
listSkillDrafts().catch(() => []),
|
||||||
]);
|
]);
|
||||||
setSkills(Array.isArray(skillData) ? skillData : []);
|
setSkills(Array.isArray(skillData) ? skillData : []);
|
||||||
|
setPlugins(Array.isArray(pluginData) ? pluginData : []);
|
||||||
setCandidates(Array.isArray(candidateData) ? candidateData : []);
|
setCandidates(Array.isArray(candidateData) ? candidateData : []);
|
||||||
setDrafts(Array.isArray(draftData) ? draftData : []);
|
setDrafts(Array.isArray(draftData) ? draftData : []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -375,6 +386,7 @@ export default function SkillsPage() {
|
|||||||
<TabsTrigger value="published" className="h-10">{t('已发布', 'Published')}</TabsTrigger>
|
<TabsTrigger value="published" className="h-10">{t('已发布', 'Published')}</TabsTrigger>
|
||||||
<TabsTrigger value="candidates" className="h-10">{t('候选', 'Candidates')}</TabsTrigger>
|
<TabsTrigger value="candidates" className="h-10">{t('候选', 'Candidates')}</TabsTrigger>
|
||||||
<TabsTrigger value="drafts" className="h-10">{t('草稿评审', 'Draft review')}</TabsTrigger>
|
<TabsTrigger value="drafts" className="h-10">{t('草稿评审', 'Draft review')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="plugins" className="h-10">{t('插件', 'Plugins')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="published" className="min-w-0">
|
<TabsContent value="published" className="min-w-0">
|
||||||
@ -466,6 +478,25 @@ export default function SkillsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -526,6 +557,11 @@ function PublishedSkillsTable({
|
|||||||
<Badge variant={skill.source === 'builtin' ? 'secondary' : 'default'} className="text-xs">
|
<Badge variant={skill.source === 'builtin' ? 'secondary' : 'default'} className="text-xs">
|
||||||
{skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')}
|
{skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{skill.source_kind === 'plugin' && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{t('插件', 'Plugin')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<Badge variant={skill.available ? 'default' : 'outline'} className="text-xs">
|
<Badge variant={skill.available ? 'default' : 'outline'} className="text-xs">
|
||||||
{skill.available ? t('可用', 'Available') : t('不可用', 'Unavailable')}
|
{skill.available ? t('可用', 'Available') : t('不可用', 'Unavailable')}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -583,6 +619,11 @@ function PublishedSkillsTable({
|
|||||||
<Badge variant={skill.source === 'builtin' ? 'secondary' : 'default'} className="text-xs">
|
<Badge variant={skill.source === 'builtin' ? 'secondary' : 'default'} className="text-xs">
|
||||||
{skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')}
|
{skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{skill.source_kind === 'plugin' && (
|
||||||
|
<Badge variant="outline" className="ml-1 text-xs">
|
||||||
|
{t('插件', 'Plugin')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={skill.available ? 'default' : 'outline'} className="text-xs">
|
<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({
|
function CandidateCard({
|
||||||
candidate,
|
candidate,
|
||||||
actionId,
|
actionId,
|
||||||
@ -686,6 +925,7 @@ function CandidateCard({
|
|||||||
const confidence = typeof candidate.confidence === 'number' && candidate.confidence > 0
|
const confidence = typeof candidate.confidence === 'number' && candidate.confidence > 0
|
||||||
? `${Math.round(candidate.confidence * 100)}%`
|
? `${Math.round(candidate.confidence * 100)}%`
|
||||||
: null;
|
: null;
|
||||||
|
const pluginMergeMode = String(evidence.merge_mode || '').trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 max-w-full rounded-lg border border-border bg-white p-4">
|
<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)}
|
{t('风险', 'Risk')}: {riskLabel(risk, t)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{confidence && <Badge variant="outline">{t('置信度', 'Confidence')}: {confidence}</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 && (
|
{typeof candidate.priority === 'number' && candidate.priority > 0 && (
|
||||||
<Badge variant="outline">{t('优先级', 'Priority')}: {candidate.priority}</Badge>
|
<Badge variant="outline">{t('优先级', 'Priority')}: {candidate.priority}</Badge>
|
||||||
)}
|
)}
|
||||||
@ -819,6 +1062,7 @@ function DraftCard({
|
|||||||
const safety = draft.safety_report;
|
const safety = draft.safety_report;
|
||||||
const evalReport = draft.eval_report;
|
const evalReport = draft.eval_report;
|
||||||
const frontmatter = draft.proposed_frontmatter || {};
|
const frontmatter = draft.proposed_frontmatter || {};
|
||||||
|
const provenance = draft.provenance || {};
|
||||||
const description = String(frontmatter.description || '').trim();
|
const description = String(frontmatter.description || '').trim();
|
||||||
const toolHints = normalizeStringList(frontmatter.tools);
|
const toolHints = normalizeStringList(frontmatter.tools);
|
||||||
const submittedForReview = draft.status === 'in_review' || draft.status === 'approved';
|
const submittedForReview = draft.status === 'in_review' || draft.status === 'approved';
|
||||||
@ -843,6 +1087,7 @@ function DraftCard({
|
|||||||
: isHighRisk
|
: isHighRisk
|
||||||
? t('高风险草稿,发布前需要再次确认。', 'High-risk draft; publishing requires confirmation.')
|
? t('高风险草稿,发布前需要再次确认。', 'High-risk draft; publishing requires confirmation.')
|
||||||
: t('已满足发布门禁。', 'Publish gates are satisfied.');
|
: t('已满足发布门禁。', 'Publish gates are satisfied.');
|
||||||
|
const pluginMergeMode = String(provenance.merge_mode || provenance.plugin_merge_mode || '').trim();
|
||||||
const handlePublish = () => {
|
const handlePublish = () => {
|
||||||
if (isHighRisk) {
|
if (isHighRisk) {
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
@ -858,6 +1103,9 @@ function DraftCard({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="outline">{candidateKindLabel(draft.proposal_kind, t)}</Badge>
|
<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>
|
<Badge variant="secondary">{draftStatusLabel(draft.status, t)}</Badge>
|
||||||
{safety && (
|
{safety && (
|
||||||
<Badge variant={safety.risk_level === 'critical' || safety.risk_level === 'high' ? 'destructive' : 'outline'}>
|
<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(`考虑下线技能 ${related}`, `Consider retiring ${related}`)
|
||||||
: t('考虑下线技能', 'Consider retiring a skill');
|
: 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;
|
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'),
|
revise_skill: t('修订技能', 'Revise skill'),
|
||||||
merge_skills: t('合并技能', 'Merge skills'),
|
merge_skills: t('合并技能', 'Merge skills'),
|
||||||
retire_skill: t('下线技能', 'Retire skill'),
|
retire_skill: t('下线技能', 'Retire skill'),
|
||||||
|
plugin_skill_update: t('插件升级合并', 'Plugin update merge'),
|
||||||
};
|
};
|
||||||
return labels[kind] || kind;
|
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 {
|
function candidateStatusLabel(status: string, t: (zh: string, en: string) => string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
open: t('待处理', 'Open'),
|
open: t('待处理', 'Open'),
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import type {
|
|||||||
FileAttachment,
|
FileAttachment,
|
||||||
NotificationDetail,
|
NotificationDetail,
|
||||||
NotificationRun,
|
NotificationRun,
|
||||||
|
BeaverPlugin,
|
||||||
ProviderConfigPayload,
|
ProviderConfigPayload,
|
||||||
Session,
|
Session,
|
||||||
SessionDetail,
|
SessionDetail,
|
||||||
@ -833,6 +834,55 @@ export async function listSkills(): Promise<Skill[]> {
|
|||||||
return fetchJSON('/api/skills');
|
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> {
|
export async function getSkillDetail(skillName: string): Promise<SkillDetailResponse> {
|
||||||
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/detail`);
|
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>[];
|
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 {
|
export interface SkillVersionRef {
|
||||||
version: string;
|
version: string;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
@ -1027,6 +1050,7 @@ export interface SkillDraft {
|
|||||||
reason: string;
|
reason: string;
|
||||||
status: string;
|
status: string;
|
||||||
evidence_refs: Array<Record<string, unknown>>;
|
evidence_refs: Array<Record<string, unknown>>;
|
||||||
|
provenance?: Record<string, unknown>;
|
||||||
proposal_kind: string;
|
proposal_kind: string;
|
||||||
reviews?: SkillReviewRecord[];
|
reviews?: SkillReviewRecord[];
|
||||||
safety_report?: SkillDraftSafetyReport | null;
|
safety_report?: SkillDraftSafetyReport | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user