Files
beaver_project/app-instance/frontend/app/(app)/skills/page.tsx

1958 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
AlertCircle,
BarChart3,
CheckCircle2,
ChevronDown,
ClipboardList,
Download,
FileCode2,
FileText,
GitCompare,
Info,
ListChecks,
Loader2,
Puzzle,
RefreshCw,
Rocket,
Send,
ShieldCheck,
ShieldAlert,
Trash2,
Upload,
X,
XCircle,
} from 'lucide-react';
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';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { SkillDetailView } from '@/components/skills/SkillDetailView';
import type {
BeaverPlugin,
Skill,
SkillDetailResponse,
SkillDraft,
SkillDraftEvalReport,
SkillDraftSafetyReport,
SkillFileContent,
SkillLearningCandidate,
} from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapping';
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' | 'plugins';
function normalizeSkillsTab(value: string | null | undefined): SkillsTab {
if (value === 'candidates' || value === 'drafts' || value === 'plugins') {
return value;
}
return 'published';
}
export default function SkillsPage() {
const { locale } = useAppI18n();
const pathname = usePathname();
const router = useRouter();
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')));
const [loading, setLoading] = useState(true);
const [actionId, setActionId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showUpload, setShowUpload] = useState(false);
const [ignoredCandidates, setIgnoredCandidates] = useState<Set<string>>(new Set());
const [selectedSkillName, setSelectedSkillName] = useState<string | null>(null);
const [skillDetail, setSkillDetail] = useState<SkillDetailResponse | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [versionLoading, setVersionLoading] = useState(false);
const [selectedFile, setSelectedFile] = useState<SkillFileContent | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
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) {
setError(err.message || pickAppText(locale, '加载技能失败', 'Failed to load skills'));
} finally {
setLoading(false);
}
}, [locale]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!drafts.some((draft) => draft.eval_status === 'pending')) return;
const timer = window.setInterval(() => {
void listSkillDrafts()
.then((items) => setDrafts(Array.isArray(items) ? items : []))
.catch(() => null);
}, 5000);
return () => window.clearInterval(timer);
}, [drafts]);
useEffect(() => {
setActiveTab(normalizeSkillsTab(searchParams?.get('tab')));
}, [searchParams]);
const changeTab = (value: string) => {
const nextTab = normalizeSkillsTab(value);
setActiveTab(nextTab);
const nextParams = new URLSearchParams(searchParams?.toString());
if (nextTab === 'published') {
nextParams.delete('tab');
} else {
nextParams.set('tab', nextTab);
}
const query = nextParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
};
const runAction = async (id: string, action: () => Promise<unknown>) => {
setActionId(id);
setError(null);
try {
await action();
await load();
} catch (err: any) {
setError(err.message || t('操作失败', 'Action failed'));
} finally {
setActionId(null);
}
};
const openSkillDetail = async (name: string) => {
setSelectedSkillName(name);
setSkillDetail(null);
setSelectedFile(null);
setDetailLoading(true);
setError(null);
try {
setSkillDetail(await getSkillDetail(name));
} catch (err: any) {
setError(err.message || t('加载技能详情失败', 'Failed to load skill details'));
setSelectedSkillName(null);
} finally {
setDetailLoading(false);
}
};
const openSkillVersion = async (version: string) => {
if (!selectedSkillName || skillDetail?.currentVersion === version) return;
setVersionLoading(true);
setSelectedFile(null);
setError(null);
try {
setSkillDetail(await getSkillVersion(selectedSkillName, version));
} catch (err: any) {
setError(err.message || t('加载技能版本失败', 'Failed to load skill version'));
} finally {
setVersionLoading(false);
}
};
const openSkillFile = async (filePath: string) => {
if (!selectedSkillName || !skillDetail) return;
setFileLoading(true);
setError(null);
try {
setSelectedFile(await getSkillFile(selectedSkillName, skillDetail.currentVersion, filePath));
} catch (err: any) {
setError(err.message || t('加载文件失败', 'Failed to load file'));
} finally {
setFileLoading(false);
}
};
const hiddenCandidateStatuses = new Set(['draft_ready', 'in_review', 'approved', 'rejected', 'superseded', 'published']);
const visibleCandidates = candidates.filter(
(candidate) =>
!ignoredCandidates.has(candidate.candidate_id) &&
!hiddenCandidateStatuses.has(candidate.status) &&
!candidate.draft_id
);
const visibleDrafts = drafts.filter((draft) => !TERMINAL_DRAFT_STATUSES.has(draft.status));
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="mx-auto w-full max-w-6xl space-y-6 overflow-x-hidden bg-white px-4 py-6 text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%] sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<Puzzle className="w-6 h-6" />
{t('技能', 'Skills')}
</h1>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => void load()} variant="outline" size="sm" className="h-11">
<RefreshCw className="mr-2 h-4 w-4" />
{t('刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowUpload(true)} size="sm" className="h-11">
<Upload className="mr-2 h-4 w-4" />
{t('上传技能', 'Upload skill')}
</Button>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
</div>
</CardContent>
</Card>
)}
{showUpload && (
<UploadSkillForm
onDone={() => {
setShowUpload(false);
void load();
}}
onCancel={() => setShowUpload(false)}
onError={(msg) => setError(msg)}
/>
)}
{selectedSkillName && (
<div className="space-y-4">
<Button
variant="ghost"
size="sm"
className="h-11"
onClick={() => {
setSelectedSkillName(null);
setSkillDetail(null);
setSelectedFile(null);
}}
>
{t('返回技能列表', 'Back to skills')}
</Button>
{detailLoading || !skillDetail ? (
<Card>
<CardContent className="flex justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : (
<SkillDetailView
title={skillDetail.skill.name}
summary={skillDetail.skill.description}
currentVersion={skillDetail.currentVersion}
versions={skillDetail.versions}
files={skillDetail.files}
content={skillDetail.content}
selectedFile={selectedFile}
loadingFile={fileLoading}
loadingVersion={versionLoading}
onSelectVersion={(version) => void openSkillVersion(version)}
onOpenFile={(filePath) => void openSkillFile(filePath)}
badges={
<>
<Badge variant={skillDetail.skill.source === 'builtin' ? 'secondary' : 'default'} className="text-xs">
{skillDetail.skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')}
</Badge>
<Badge variant={skillDetail.skill.available ? 'default' : 'outline'} className="text-xs">
{skillDetail.skill.available ? t('可用', 'Available') : t('不可用', 'Unavailable')}
</Badge>
</>
}
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-11" onClick={() => downloadSkill(skillDetail.skill.name).catch((err) => setError(err.message))}>
<Download className="mr-2 h-4 w-4" />
{t('下载', 'Download')}
</Button>
{skillDetail.skill.source === 'workspace' && (
<>
<Button variant="outline" size="sm" className="h-11" onClick={() => onRollbackSkill(skillDetail.skill.name)}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('回滚', 'Rollback')}
</Button>
<Button
variant="outline"
size="sm"
className="h-11"
disabled={Boolean(actionId)}
onClick={() => void runAction(`disable:${skillDetail.skill.name}`, () => disablePublishedSkill(skillDetail.skill.name, t('人工禁用', 'Manual disable')))}
>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('禁用', 'Disable')}
</Button>
<Button
variant="outline"
size="sm"
className="h-11 text-destructive hover:text-destructive"
disabled={Boolean(actionId)}
onClick={() => void runAction(`delete:${skillDetail.skill.name}`, () => deleteSkill(skillDetail.skill.name)).then(() => {
setSelectedSkillName(null);
setSkillDetail(null);
})}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('删除', 'Delete')}
</Button>
</>
)}
</div>
}
labels={{
overview: t('说明', 'Overview'),
files: t('文件', 'Files'),
versions: t('版本', 'Versions'),
noReadme: t('暂无说明', 'No overview available'),
noFiles: t('暂无文件', 'No files'),
selectFile: t('选择一个文件查看详情', 'Select a file to view details'),
binaryFile: t('二进制文件暂不预览', 'Binary file preview is not available'),
current: t('当前', 'Current'),
size: t('大小', 'Size'),
}}
/>
)}
</div>
)}
{!selectedSkillName && (
<Tabs value={activeTab} onValueChange={changeTab} className="min-w-0 space-y-4">
<TabsList className="h-auto min-h-11 w-full max-w-full justify-start overflow-x-auto sm:w-auto">
<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">
<PublishedSkillsTable
skills={skills}
onOpen={(name) => void openSkillDetail(name)}
onDownload={(name) => downloadSkill(name).catch((err) => setError(err.message))}
onDelete={(name) => void runAction(`delete:${name}`, () => deleteSkill(name))}
onDisable={(name) =>
runAction(`disable:${name}`, () => disablePublishedSkill(name, t('人工禁用', 'Manual disable')))
}
onRollback={onRollbackSkill}
/>
</TabsContent>
<TabsContent value="candidates" className="min-w-0">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('学习候选', 'Learning candidates')}</CardTitle>
</CardHeader>
<CardContent>
{visibleCandidates.length === 0 ? (
<EmptyState icon={<FileText className="h-8 w-8" />} text={t('暂无学习候选', 'No learning candidates yet')} />
) : (
<div className="space-y-3">
{visibleCandidates.map((candidate) => (
<CandidateCard
key={candidate.candidate_id}
candidate={candidate}
actionId={actionId}
onIgnore={() => setIgnoredCandidates((prev) => new Set(prev).add(candidate.candidate_id))}
onSynthesize={() =>
runAction(`draft:${candidate.candidate_id}`, () =>
synthesizeSkillDraft(candidate.candidate_id)
)
}
onRegenerate={() =>
runAction(`regen:${candidate.candidate_id}`, () =>
regenerateSkillDraft(candidate.candidate_id)
)
}
/>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts" className="min-w-0">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('草稿、评审与发布', 'Drafts, review, and publish')}</CardTitle>
</CardHeader>
<CardContent>
{visibleDrafts.length === 0 ? (
<EmptyState icon={<FileText className="h-8 w-8" />} text={t('暂无草稿', 'No drafts yet')} />
) : (
<div className="space-y-4">
{visibleDrafts.map((draft) => (
<DraftCard
key={`${draft.skill_name}:${draft.draft_id}`}
draft={draft}
actionId={actionId}
onSubmit={() =>
runAction(`submit:${draft.draft_id}`, () =>
submitSkillDraft(draft.skill_name, draft.draft_id)
)
}
onReject={() =>
runAction(`reject:${draft.draft_id}`, () =>
rejectSkillDraft(draft.skill_name, draft.draft_id)
)
}
onRecheckSafety={() =>
runAction(`safety:${draft.draft_id}`, () =>
recheckSkillDraftSafety(draft.skill_name, draft.draft_id)
)
}
onPublish={(confirmHighRisk) =>
runAction(`publish:${draft.draft_id}`, () =>
publishSkillDraft(draft.skill_name, draft.draft_id, '', confirmHighRisk)
)
}
/>
))}
</div>
)}
</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>
);
function onRollbackSkill(name: string) {
const target = window.prompt(t('回滚到版本,例如 v0001', 'Rollback target version, for example v0001'));
if (target) {
void runAction(`rollback:${name}`, () =>
rollbackPublishedSkill(name, target, t('人工回滚', 'Manual rollback'))
).then(() => {
if (selectedSkillName === name) {
void openSkillDetail(name);
}
});
}
}
}
function PublishedSkillsTable({
skills,
onOpen,
onDownload,
onDelete,
onDisable,
onRollback,
}: {
skills: Skill[];
onOpen: (name: string) => void;
onDownload: (name: string) => void;
onDelete: (name: string) => void;
onDisable: (name: string) => void;
onRollback: (name: string) => void;
}) {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
return (
<Card>
<CardContent className="p-0">
{skills.length === 0 ? (
<EmptyState icon={<Puzzle className="h-8 w-8" />} text={t('暂无技能', 'No skills yet')} />
) : (
<>
<div className="space-y-3 p-3 md:hidden">
{skills.map((skill) => (
<div key={`${skill.source}:${skill.name}:card`} className="min-w-0 rounded-lg border border-border bg-white p-4">
<button
type="button"
className="block min-h-11 w-full text-left"
onClick={() => onOpen(skill.name)}
>
<div className={`text-sm font-semibold ${containedLongTextClass}`}>{skill.name}</div>
<div className={`mt-1 text-sm leading-5 text-muted-foreground ${containedLongTextClass}`}>
{skill.description || '-'}
</div>
</button>
<div className="mt-3 flex flex-wrap gap-2">
<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>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-11" onClick={() => onDownload(skill.name)}>
<Download className="mr-2 h-4 w-4" />
{t('下载', 'Download')}
</Button>
{skill.source === 'workspace' && (
<>
<Button variant="outline" size="sm" className="h-11" onClick={() => onRollback(skill.name)}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('回滚', 'Rollback')}
</Button>
<Button variant="outline" size="sm" className="h-11" onClick={() => onDisable(skill.name)}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('禁用', 'Disable')}
</Button>
<Button variant="outline" size="sm" className="h-11 text-destructive hover:text-destructive" onClick={() => onDelete(skill.name)}>
<Trash2 className="mr-2 h-4 w-4" />
{t('删除', 'Delete')}
</Button>
</>
)}
</div>
</div>
))}
</div>
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('名称', 'Name')}</TableHead>
<TableHead>{t('描述', 'Description')}</TableHead>
<TableHead>{t('来源', 'Source')}</TableHead>
<TableHead>{t('状态', 'Status')}</TableHead>
<TableHead className="w-24">{t('操作', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{skills.map((skill) => (
<TableRow
key={`${skill.source}:${skill.name}`}
className="cursor-pointer"
onClick={() => onOpen(skill.name)}
>
<TableCell className={`font-medium ${containedLongTextClass}`}>{skill.name}</TableCell>
<TableCell>
<span className="block max-w-[360px] truncate text-sm text-muted-foreground">
{skill.description}
</span>
</TableCell>
<TableCell>
<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">
{skill.available ? t('可用', 'Available') : t('不可用', 'Unavailable')}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
aria-label={t('下载', 'Download')}
title={t('下载', 'Download')}
onClick={(event) => { event.stopPropagation(); onDownload(skill.name); }}
>
<Download className="h-3.5 w-3.5" />
</Button>
{skill.source === 'workspace' && (
<>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
aria-label={t('回滚', 'Rollback')}
title={t('回滚', 'Rollback')}
onClick={(event) => {
event.stopPropagation();
onRollback(skill.name);
}}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
aria-label={t('禁用', 'Disable')}
title={t('禁用', 'Disable')}
onClick={(event) => {
event.stopPropagation();
onDisable(skill.name);
}}
>
<ShieldCheck className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-11 w-11 text-destructive hover:text-destructive"
aria-label={t('删除', 'Delete')}
title={t('删除', 'Delete')}
onClick={(event) => {
event.stopPropagation();
onDelete(skill.name);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</CardContent>
</Card>
);
}
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,
onIgnore,
onSynthesize,
onRegenerate,
}: {
candidate: SkillLearningCandidate;
actionId: string | null;
onIgnore: () => void;
onSynthesize: () => Promise<unknown>;
onRegenerate: () => Promise<unknown>;
}) {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const evidence = candidate.evidence || {};
const title = candidateTitle(candidate, t);
const affectedSkills = candidate.draft_skill_name
? [candidate.draft_skill_name]
: candidate.related_skill_names.length > 0
? candidate.related_skill_names
: [];
const sourceRuns = candidate.source_run_ids || [];
const sourceSessions = candidate.source_session_ids || [];
const risk = candidate.risk_level || 'medium';
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">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{candidateKindLabel(candidate.kind, t)}</Badge>
<Badge variant="secondary">{candidateStatusLabel(candidate.status, t)}</Badge>
<Badge variant={risk === 'critical' || risk === 'high' ? 'destructive' : 'outline'}>
{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>
)}
</div>
<div>
<h3 className="break-words text-base font-semibold tracking-normal">{title}</h3>
<p className={`mt-1 text-sm leading-6 text-muted-foreground ${containedLongTextClass}`}>
{candidate.reason || t('没有提供候选理由。', 'No candidate reason was provided.')}
</p>
</div>
<div className="grid gap-3 md:grid-cols-3">
<ReadableFact
icon={<ClipboardList className="h-4 w-4" />}
label={t('候选任务', 'Candidate task')}
value={candidateTaskSummary(candidate, t)}
/>
<ReadableFact
icon={<GitCompare className="h-4 w-4" />}
label={t('影响范围', 'Impact')}
value={affectedSkills.length > 0 ? affectedSkills.join(', ') : t('新增技能', 'New skill')}
/>
<ReadableFact
icon={<ListChecks className="h-4 w-4" />}
label={t('证据数量', 'Evidence')}
value={t(
`${sourceRuns.length} 个运行,${sourceSessions.length} 个会话`,
`${sourceRuns.length} run(s), ${sourceSessions.length} session(s)`
)}
/>
</div>
{(candidate.evidence_summary || candidate.trigger_reason || candidate.last_error) && (
<div className="space-y-2 rounded-md border border-border bg-muted/20 p-3 text-sm">
{candidate.evidence_summary && (
<p>
<span className="font-medium">{t('证据摘要', 'Evidence summary')}:</span>{' '}
{candidate.evidence_summary}
</p>
)}
{candidate.trigger_reason && (
<p>
<span className="font-medium">{t('触发原因', 'Trigger')}:</span>{' '}
{triggerReasonLabel(candidate.trigger_reason, t)}
</p>
)}
{candidate.last_error && (
<p className="text-destructive">
<span className="font-medium">{t('最近错误', 'Last error')}:</span> {candidate.last_error}
</p>
)}
</div>
)}
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className={`font-mono ${containedLongTextClass}`}>{candidate.candidate_id}</span>
{String(evidence.task_id || '') && <span>{t('任务', 'Task')}: {String(evidence.task_id)}</span>}
{String(evidence.skill_version || '') && <span>{t('基线版本', 'Base version')}: {String(evidence.skill_version)}</span>}
{candidate.created_at && <span>{t('创建于', 'Created')}: {formatDateTime(candidate.created_at)}</span>}
</div>
<RawDetails title={t('原始候选数据', 'Raw candidate data')} payload={candidate} />
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button size="sm" variant="outline" className="h-11" disabled={Boolean(actionId)} onClick={onIgnore}>
{t('忽略', 'Ignore')}
</Button>
<Button
size="sm"
className="h-11"
disabled={Boolean(actionId)}
onClick={() => void onSynthesize()}
>
{actionId === `draft:${candidate.candidate_id}` ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
{t('生成草稿', 'Synthesize draft')}
</Button>
{candidate.draft_id && (
<Button
size="sm"
variant="outline"
className="h-11"
disabled={Boolean(actionId)}
onClick={() => void onRegenerate()}
>
<RefreshCw className="mr-2 h-4 w-4" />
{t('重新生成', 'Regenerate')}
</Button>
)}
</div>
</div>
</div>
);
}
function DraftCard({
draft,
actionId,
onSubmit,
onReject,
onRecheckSafety,
onPublish,
}: {
draft: SkillDraft;
actionId: string | null;
onSubmit: () => Promise<unknown>;
onReject: () => Promise<unknown>;
onRecheckSafety: () => Promise<unknown>;
onPublish: (confirmHighRisk: boolean) => Promise<unknown>;
}) {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const busy = Boolean(actionId);
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';
const isRevision = draft.proposal_kind === 'revise_skill' && Boolean(draft.base_skill);
const publishBlocked =
!submittedForReview
|| !safety
|| safety.risk_level === 'critical'
|| (evalReport?.status !== 'skipped_provider_unavailable' && evalReport?.passed === false);
const isHighRisk = safety?.risk_level === 'high';
const highRiskReason = [
...(safety?.blocked_reasons ?? []),
...(safety?.issues ?? []),
safety?.suggested_fix,
].filter(Boolean).join('\n');
const safetyBlocksReview = Boolean(safety && (!safety.passed || safety.risk_level === 'critical'));
const canRetryEval = draft.status === 'in_review' && draft.eval_status === 'failed';
const submitBlocked = (draft.status !== 'draft' && !canRetryEval) || safetyBlocksReview;
const rejectBlocked = !REJECTABLE_DRAFT_STATUSES.has(draft.status);
const canPublishLabel = publishBlocked
? publishBlockReason(draft, t)
: 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(
t('该草稿被标记为高风险。确认发布?', 'This draft is marked high risk. Publish anyway?')
);
if (!confirmed) return;
}
void onPublish(isHighRisk);
};
return (
<div className="min-w-0 max-w-full 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 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'}>
{t('安全', 'Safety')}: {safety.passed ? t('通过', 'Pass') : t('阻断', 'Blocked')} · {riskLabel(safety.risk_level, t)}
</Badge>
)}
{evalReport && (
<Badge variant={evalReport.passed ? 'outline' : 'destructive'}>
{evalReport.status === 'skipped_provider_unavailable'
? t('评估跳过', 'Eval skipped')
: evalReport.passed
? t('评估通过', 'Eval passed')
: t('评估失败', 'Eval failed')}
</Badge>
)}
</div>
<div className="mt-2">
<div className="text-xs font-medium text-muted-foreground">{t('技能名', 'Skill name')}</div>
<h3 className={`text-lg font-semibold tracking-normal ${containedLongTextClass}`}>{draft.skill_name}</h3>
</div>
<p className={`mt-1 text-sm leading-6 text-muted-foreground ${containedLongTextClass}`}>
{draft.reason || description || t('没有提供草稿说明。', 'No draft notes were provided.')}
</p>
{draft.proposal_kind === 'revise_skill' && draft.base_version && (
<div className="mt-2 text-sm font-medium text-muted-foreground">
{draft.skill_name}: {draft.base_version} {draft.target_version || t('下一版本', 'Next version')}
</div>
)}
<div className="mt-3 grid gap-3 md:grid-cols-4">
<ReadableFact
icon={<FileCode2 className="h-4 w-4" />}
label={t('草稿内容', 'Draft content')}
value={description || t('未填写技能描述', 'No skill description')}
/>
<ReadableFact
icon={<GitCompare className="h-4 w-4" />}
label={t('基线版本', 'Base version')}
value={draft.base_version || t('新增技能,无基线', 'New skill, no base')}
/>
<ReadableFact
icon={<GitCompare className="h-4 w-4" />}
label={t('目标版本', 'Target version')}
value={draft.target_version || '-'}
/>
<ReadableFact
icon={<Info className="h-4 w-4" />}
label={t('来源', 'Source')}
value={draft.trigger_run_id || draft.trigger_session_id || draft.created_by || '-'}
/>
</div>
{isHighRisk && (
<div className="mt-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
<div className="font-medium">{t('高风险理由来自 safety report', 'High-risk reason from safety report')}</div>
<pre className="mt-2 whitespace-pre-wrap font-sans">{highRiskReason || t('未提供具体理由', 'No concrete reason provided')}</pre>
</div>
)}
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className={`font-mono ${containedLongTextClass}`}>{draft.skill_name}/{draft.draft_id}</span>
<span>{t('创建者', 'Author')}: {draft.created_by}</span>
<span>{t('创建于', 'Created')}: {formatDateTime(draft.created_at)}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-11" disabled={busy || submitBlocked} onClick={() => void onSubmit()}>
<Send className="mr-2 h-4 w-4" />
{canRetryEval ? t('重试评估', 'Retry eval') : t('送审', 'Submit')}
</Button>
<Button variant="outline" size="sm" className="h-11" disabled={busy || rejectBlocked} onClick={() => void onReject()}>
<XCircle className="mr-2 h-4 w-4" />
{t('拒绝', 'Reject')}
</Button>
<Button variant="outline" size="sm" className="h-11" disabled={busy || TERMINAL_DRAFT_STATUSES.has(draft.status)} onClick={() => void onRecheckSafety()}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('复检', 'Recheck')}
</Button>
<Button size="sm" className="h-11" disabled={busy || publishBlocked} onClick={handlePublish}>
<Rocket className="mr-2 h-4 w-4" />
{draft.proposal_kind === 'revise_skill' ? t('发布修订', 'Publish revision') : t('发布', 'Publish')}
</Button>
</div>
</div>
<div className="mt-4 grid min-w-0 gap-3 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="min-w-0 max-w-full rounded-md border border-border bg-muted/20 p-3 sm:p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<FileText className="h-4 w-4 text-muted-foreground" />
{isRevision ? t('修改对比', 'Revision comparison') : t('拟发布的技能正文', 'Proposed skill body')}
</div>
{toolHints.length > 0 && (
<div className="flex flex-wrap gap-1">
{toolHints.map((tool) => (
<Badge key={tool} variant="outline" className="font-mono text-[11px]">
{tool}
</Badge>
))}
</div>
)}
</div>
{isRevision && draft.base_skill ? (
<RevisionComparison
baseVersion={draft.base_version || draft.base_skill.version}
targetVersion={draft.target_version || t('下一版本', 'Next version')}
baseContent={draft.base_skill.content}
proposedContent={draft.proposed_content}
/>
) : draft.proposed_content.trim() ? (
<MarkdownPreview content={draft.proposed_content} />
) : (
<p className="text-sm text-muted-foreground">{t('草稿没有正文内容。', 'This draft has no body content.')}</p>
)}
</div>
<div className="min-w-0 space-y-3">
<GateSummary
title={t('发布门禁', 'Publish gates')}
summary={canPublishLabel}
items={[
{ label: t('草稿已送审', 'Draft submitted'), ok: submittedForReview },
{ label: t('安全报告通过', 'Safety passed'), ok: Boolean(safety?.passed) && safety?.risk_level !== 'critical' },
{
label: t('评估未回退', 'No eval regression'),
ok: !evalReport || evalReport.status === 'skipped_provider_unavailable' || evalReport.passed,
},
]}
/>
<RawDetails
title={t('原始草稿内容', 'Raw draft payload')}
payload={{
base_skill: draft.base_skill,
proposed_frontmatter: draft.proposed_frontmatter,
proposed_content: draft.proposed_content,
evidence_refs: draft.evidence_refs,
reviews: draft.reviews,
}}
/>
</div>
</div>
<div className="mt-3 grid min-w-0 gap-3 md:grid-cols-2">
<SafetyReportPanel report={safety} />
<EvalReportPanel
report={evalReport}
status={draft.eval_status}
error={draft.eval_error}
progress={draft.eval_progress}
/>
</div>
</div>
);
}
function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null }) {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
if (!report) {
return (
<ReadablePanel
icon={<ShieldAlert className="h-4 w-4" />}
title={t('安全报告', 'Safety report')}
empty={t('还没有安全报告,不能送审或发布。', 'No safety report yet; review and publishing are blocked.')}
/>
);
}
const problems = [...(report.blocked_reasons || []), ...(report.issues || [])];
return (
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
{report.passed ? <ShieldCheck className="h-4 w-4 text-muted-foreground" /> : <ShieldAlert className="h-4 w-4 text-destructive" />}
{t('安全报告', 'Safety report')}
</div>
<Badge variant={report.passed ? 'outline' : 'destructive'}>
{report.passed ? t('通过', 'Pass') : t('阻断', 'Blocked')} · {riskLabel(report.risk_level, t)}
</Badge>
</div>
{problems.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">{t('发现的问题', 'Findings')}</p>
<ul className="space-y-1 text-sm leading-6">
{problems.map((item, index) => (
<li key={`${item}:${index}`} className="flex gap-2">
<AlertCircle className="mt-1 h-3.5 w-3.5 shrink-0 text-destructive" />
<span className={containedLongTextClass}>{item}</span>
</li>
))}
</ul>
</div>
) : (
<p className="text-sm text-muted-foreground">
{t('没有发现阻断项或需要人工注意的问题。', 'No blockers or manual-review issues were found.')}
</p>
)}
{report.suggested_fix && (
<p className={`mt-3 rounded-md border border-border bg-white p-3 text-sm ${containedLongTextClass}`}>
<span className="font-medium">{t('建议处理', 'Suggested fix')}:</span> {report.suggested_fix}
</p>
)}
<div className="mt-3 text-xs text-muted-foreground">{formatDateTime(report.created_at)}</div>
<RawDetails title={t('原始安全报告', 'Raw safety report')} payload={report} />
</div>
);
}
function RevisionComparison({
baseVersion,
targetVersion,
baseContent,
proposedContent,
}: {
baseVersion: string;
targetVersion: string;
baseContent: string;
proposedContent: string;
}) {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const diff = lineDiffSummary(baseContent, proposedContent);
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{baseVersion}</Badge>
<span></span>
<Badge variant="default">{targetVersion}</Badge>
<span>{t('新增', 'Added')}: {diff.added}</span>
<span>{t('删除', 'Removed')}: {diff.removed}</span>
<span>{t('修改', 'Changed')}: {diff.changed}</span>
</div>
<div className="grid min-w-0 gap-3 lg:grid-cols-2">
<DiffPane title={t('当前版本', 'Current version')} content={baseContent} />
<DiffPane title={t('草稿修订', 'Draft revision')} content={proposedContent} />
</div>
</div>
);
}
function DiffPane({ title, content }: { title: string; content: string }) {
return (
<div className="min-w-0 rounded-md border border-border bg-white">
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">{title}</div>
<pre className={`max-h-[520px] overflow-auto p-3 text-xs leading-5 ${containedLongTextClass}`}>
{content.trim() || '-'}
</pre>
</div>
);
}
function lineDiffSummary(baseContent: string, proposedContent: string): { added: number; removed: number; changed: number } {
const baseLines = baseContent.split(/\r?\n/);
const proposedLines = proposedContent.split(/\r?\n/);
const maxLength = Math.max(baseLines.length, proposedLines.length);
let added = 0;
let removed = 0;
let changed = 0;
for (let index = 0; index < maxLength; index += 1) {
const baseLine = baseLines[index];
const proposedLine = proposedLines[index];
if (baseLine === proposedLine) continue;
if (baseLine === undefined) {
added += 1;
} else if (proposedLine === undefined) {
removed += 1;
} else {
changed += 1;
}
}
return { added, removed, changed };
}
function EvalReportPanel({
report,
status,
error,
progress,
}: {
report?: SkillDraftEvalReport | null;
status?: SkillDraft['eval_status'];
error?: string | null;
progress?: SkillDraft['eval_progress'];
}) {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
if (!report) {
if (status === 'pending') {
const completedArms = Math.max(0, Number(progress?.completed_arms || 0));
const totalArms = Math.max(0, Number(progress?.total_arms || 0));
const progressText = totalArms > 0
? t(
`评估正在后台运行:已完成 ${completedArms}/${totalArms} 次回放(共 ${progress?.total_cases || 10} 个案例,每个案例包含 baseline 和 candidate`,
`Evaluation is running: ${completedArms}/${totalArms} replays completed (${progress?.total_cases || 10} cases, each with baseline and candidate).`
)
: t('评估正在准备案例,完成后会自动更新。', 'Evaluation cases are being prepared and will update automatically.');
return (
<ReadablePanel
icon={<Loader2 className="h-4 w-4 animate-spin" />}
title={t('评估报告', 'Eval report')}
empty={progressText}
/>
);
}
if (status === 'failed') {
return (
<ReadablePanel
icon={<BarChart3 className="h-4 w-4 text-destructive" />}
title={t('评估报告', 'Eval report')}
empty={`${t('评估失败,可再次点击送审重试。', 'Evaluation failed. Submit again to retry.')} ${error || ''}`.trim()}
/>
);
}
if (status === 'not_applicable') {
return (
<ReadablePanel
icon={<BarChart3 className="h-4 w-4" />}
title={t('评估报告', 'Eval report')}
empty={t('该草稿没有关联学习候选,不运行 replay eval。', 'This draft has no linked learning candidate, so replay eval does not run.')}
/>
);
}
return (
<ReadablePanel
icon={<BarChart3 className="h-4 w-4" />}
title={t('评估报告', 'Eval report')}
empty={t('还没有评估报告。若 provider 不可用,系统会标记为跳过。', 'No eval report yet. If the provider is unavailable, it will be marked as skipped.')}
/>
);
}
if (report.status === 'skipped_provider_unavailable') {
return (
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
{t('评估报告', 'Eval report')}
</div>
<p className="text-sm text-muted-foreground">
{t('评估被跳过:当前没有可用 provider。这个状态不会阻断发布但代表没有回放验证。', 'Eval was skipped because no provider was available. This does not block publishing, but no replay validation was run.')}
</p>
<RawDetails title={t('原始评估报告', 'Raw eval report')} payload={report} />
</div>
);
}
const abilitySummary = report.ability_score_summary || {};
const toolExecutionSummary = report.tool_execution_summary || report.tool_mode_summary || {};
const caseSelectionSummary = report.case_selection_summary || {};
const realScore = report.real_score_avg ?? abilitySummary.real_score_avg;
const syntheticScore = report.synthetic_score_avg ?? abilitySummary.synthetic_score_avg;
const overallScore = report.overall_score_avg ?? abilitySummary.overall_score_avg ?? report.candidate_score_avg;
const realCaseCount = toNumber(abilitySummary.real_case_count);
const syntheticCaseCount = toNumber(abilitySummary.synthetic_case_count);
const excludedSynthetic = toNumber(caseSelectionSummary.excluded_synthetic_without_validator);
return (
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
{t('评估报告', 'Eval report')}
</div>
<Badge variant={report.passed ? 'outline' : 'destructive'}>
{report.passed ? t('通过', 'Pass') : t('失败', 'Failed')}
</Badge>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<MetricTile label={t('基线能力均分', 'Baseline ability')} value={formatScore(report.baseline_score_avg)} />
<MetricTile label={t('候选能力均分', 'Candidate ability')} value={formatScore(report.candidate_score_avg)} />
<MetricTile
label={t('变化', 'Delta')}
value={`${report.score_delta >= 0 ? '+' : ''}${formatScore(report.score_delta)}`}
tone={report.score_delta < 0 ? 'bad' : report.score_delta > 0 ? 'good' : 'neutral'}
/>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<MetricTile label={t('真实案例均分', 'Real avg')} value={formatOptionalScore(realScore)} />
<MetricTile label={t('模拟案例均分', 'Synthetic avg')} value={formatOptionalScore(syntheticScore)} />
<MetricTile label={t('总体能力分', 'Overall ability')} value={formatOptionalScore(overallScore)} />
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<MetricTile label={t('工具执行覆盖', 'Tool execution')} value={formatPercent(toOptionalNumber(toolExecutionSummary.executed) ?? report.execution_coverage)} />
<MetricTile label={t('替代工具评估', 'Tool surrogate')} value={formatPercent(toOptionalNumber(toolExecutionSummary.surrogate) ?? report.surrogate_coverage)} />
<MetricTile label={t('置信度', 'Confidence')} value={report.confidence || 'low'} />
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<ReadableFact icon={<CheckCircle2 className="h-4 w-4" />} label={t('改进', 'Improved')} value={String(report.improved_count)} />
<ReadableFact icon={<XCircle className="h-4 w-4" />} label={t('回退', 'Regressed')} value={String(report.regression_count)} />
<ReadableFact icon={<Info className="h-4 w-4" />} label={t('不变', 'Unchanged')} value={String(report.unchanged_count)} />
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<ReadableFact icon={<Info className="h-4 w-4" />} label={t('真实案例', 'Real cases')} value={String(realCaseCount)} />
<ReadableFact icon={<Info className="h-4 w-4" />} label={t('模拟案例', 'Synthetic cases')} value={String(syntheticCaseCount)} />
<ReadableFact icon={<XCircle className="h-4 w-4" />} label={t('无验证器已排除', 'No-validator excluded')} value={String(excludedSynthetic)} />
</div>
{report.cases.length > 0 && (
<div className="mt-3 overflow-hidden rounded-md border border-border bg-white">
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
{t('回放案例', 'Replay cases')}
</div>
<div className="space-y-2 p-3 md:hidden">
{report.cases.map((item, index) => (
<div key={`${String(item.run_id || index)}:${index}:card`} className="rounded-md border border-border bg-muted/20 p-3 text-xs">
<div className={`font-mono ${containedLongTextClass}`}>{String(item.run_id || '-')}</div>
<div className="mt-2 grid grid-cols-3 gap-2">
<MetricTile label={t('基线', 'Baseline')} value={formatScore(toNumber(item.baseline_score))} />
<MetricTile label={t('候选', 'Candidate')} value={formatScore(toNumber(item.candidate_score))} />
<MetricTile label={t('变化', 'Delta')} value={formatSignedScore(toNumber(item.delta))} />
</div>
<div className="mt-2 text-muted-foreground">
{String(item.synthetic) === 'true' ? t('模拟案例', 'Synthetic case') : t('真实案例', 'Real case')}
{item.tier ? ` · ${String(item.tier)}` : ''}
</div>
</div>
))}
</div>
<div className="hidden max-h-48 overflow-auto md:block">
<table className="w-full text-left text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">{t('运行', 'Run')}</th>
<th className="px-3 py-2 font-medium">{t('来源', 'Source')}</th>
<th className="px-3 py-2 font-medium">{t('基线', 'Baseline')}</th>
<th className="px-3 py-2 font-medium">{t('候选', 'Candidate')}</th>
<th className="px-3 py-2 font-medium">{t('变化', 'Delta')}</th>
</tr>
</thead>
<tbody>
{report.cases.map((item, index) => (
<tr key={`${String(item.run_id || index)}:${index}`} className="border-t border-border">
<td className="max-w-[160px] truncate px-3 py-2 font-mono">{String(item.run_id || '-')}</td>
<td className="px-3 py-2">
{String(item.synthetic) === 'true' ? t('模拟', 'Synthetic') : t('真实', 'Real')}
{item.tier ? ` · ${String(item.tier)}` : ''}
</td>
<td className="px-3 py-2">{formatScore(toNumber(item.baseline_score))}</td>
<td className="px-3 py-2">{formatScore(toNumber(item.candidate_score))}</td>
<td className="px-3 py-2">{formatSignedScore(toNumber(item.delta))}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{Array.isArray(report.case_reports) && report.case_reports.length > 0 ? (
<RawDetails title={t('Replay case reports', 'Replay case reports')} payload={report.case_reports} />
) : null}
{Object.keys(abilitySummary).length > 0 ? (
<RawDetails title={t('能力评分汇总', 'Ability score summary')} payload={abilitySummary} />
) : null}
{Object.keys(toolExecutionSummary).length > 0 ? (
<RawDetails title={t('工具诊断汇总', 'Tool diagnostic summary')} payload={toolExecutionSummary} />
) : null}
{report.preservation_report ? (
<RawDetails title={t('Preservation report', 'Preservation report')} payload={report.preservation_report} />
) : null}
<div className="mt-3 text-xs text-muted-foreground">{formatDateTime(report.created_at)}</div>
<RawDetails title={t('原始评估报告', 'Raw eval report')} payload={report} />
</div>
);
}
function GateSummary({
title,
summary,
items,
}: {
title: string;
summary: string;
items: Array<{ label: string; ok: boolean }>;
}) {
return (
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<ListChecks className="h-4 w-4 text-muted-foreground" />
{title}
</div>
<p className="mb-3 text-sm leading-6 text-muted-foreground">{summary}</p>
<div className="space-y-2">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-2 text-sm">
{item.ok ? <CheckCircle2 className="h-4 w-4 text-muted-foreground" /> : <XCircle className="h-4 w-4 text-destructive" />}
<span>{item.label}</span>
</div>
))}
</div>
</div>
);
}
function ReadablePanel({
icon,
title,
empty,
}: {
icon: React.ReactNode;
title: string;
empty: string;
}) {
return (
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
{icon}
{title}
</div>
<p className="text-sm text-muted-foreground">{empty}</p>
</div>
);
}
function ReadableFact({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="min-w-0 rounded-md border border-border bg-white p-3">
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
{icon}
{label}
</div>
<div className={`text-sm leading-5 ${containedLongTextClass}`}>{value || '-'}</div>
</div>
);
}
function MetricTile({
label,
value,
tone = 'neutral',
}: {
label: string;
value: string;
tone?: 'neutral' | 'good' | 'bad';
}) {
const toneClass = tone === 'bad' ? 'text-destructive' : 'text-foreground';
return (
<div className="min-w-0 rounded-md border border-border bg-white p-3">
<div className="text-xs font-medium text-muted-foreground">{label}</div>
<div className={`mt-1 text-lg font-semibold ${toneClass}`}>{value}</div>
</div>
);
}
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
return (
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-white">
<summary className="flex h-11 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
{title}
<ChevronDown className="h-3.5 w-3.5" />
</summary>
<pre className={`max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5 ${containedJsonTextClass}`}>
{JSON.stringify(payload, null, 2)}
</pre>
</details>
);
}
function MarkdownPreview({ content }: { content: string }) {
return (
<div className={`prose prose-sm max-w-none text-black prose-a:text-black prose-code:rounded prose-code:bg-white prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-white prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_*]:min-w-0 ${containedLongTextClass}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
function candidateTitle(candidate: SkillLearningCandidate, t: (zh: string, en: string) => string): string {
const evidence = candidate.evidence || {};
const theme = String(evidence.theme || '').trim();
const taskId = String(evidence.task_id || '').trim();
const related = candidate.related_skill_names.filter(Boolean).join(', ');
if (candidate.draft_skill_name) {
return t(`${candidate.draft_skill_name} 生成草稿`, `Draft for ${candidate.draft_skill_name}`);
}
if (candidate.kind === 'new_skill') {
return theme
? t(`把“${theme}”沉淀成新技能`, `Extract "${theme}" into a new skill`)
: taskId
? t(`从任务 ${taskId} 提炼新技能`, `Extract a new skill from task ${taskId}`)
: t('提炼一个新技能', 'Extract a new skill');
}
if (candidate.kind === 'revise_skill') {
return related
? t(`修订技能 ${related}`, `Revise skill ${related}`)
: t('修订已有技能', 'Revise an existing skill');
}
if (candidate.kind === 'merge_skills') {
return related
? t(`合并技能 ${related}`, `Merge skills ${related}`)
: t('合并相似技能', 'Merge similar skills');
}
if (candidate.kind === 'retire_skill') {
return related
? 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;
}
function candidateTaskSummary(candidate: SkillLearningCandidate, t: (zh: string, en: string) => string): string {
const evidence = candidate.evidence || {};
const taskId = String(evidence.task_id || '').trim();
const theme = String(evidence.theme || '').trim();
const triggerRun = String(evidence.trigger_run_id || '').trim();
if (taskId) return t(`任务 ${taskId}`, `Task ${taskId}`);
if (theme) return t(`主题:${theme}`, `Theme: ${theme}`);
if (triggerRun) return t(`由运行 ${triggerRun} 触发`, `Triggered by run ${triggerRun}`);
const firstRun = candidate.source_run_ids?.[0];
if (firstRun) return t(`来自 ${candidate.source_run_ids.length} 个运行`, `From ${candidate.source_run_ids.length} run(s)`);
return t('系统学习候选', 'System learning candidate');
}
function candidateKindLabel(kind: string, t: (zh: string, en: string) => string): string {
const labels: Record<string, string> = {
new_skill: t('新增技能', 'New skill'),
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'),
queued: t('排队中', 'Queued'),
synthesizing: t('生成中', 'Synthesizing'),
draft_ready: t('草稿已就绪', 'Draft ready'),
safety_failed: t('安全未通过', 'Safety failed'),
eval_failed: t('评估未通过', 'Eval failed'),
review_pending: t('等待评审', 'Review pending'),
approved: t('已批准', 'Approved'),
rejected: t('已拒绝', 'Rejected'),
published: t('已发布', 'Published'),
failed: t('失败', 'Failed'),
superseded: t('已被替代', 'Superseded'),
};
return labels[status] || status;
}
function draftStatusLabel(status: string, t: (zh: string, en: string) => string): string {
const labels: Record<string, string> = {
draft: t('草稿', 'Draft'),
in_review: t('评审中', 'In review'),
approved: t('已批准', 'Approved'),
rejected: t('已拒绝', 'Rejected'),
published: t('已发布', 'Published'),
disabled: t('已禁用', 'Disabled'),
archived: t('已归档', 'Archived'),
};
return labels[status] || candidateStatusLabel(status, t);
}
function riskLabel(risk: string, t: (zh: string, en: string) => string): string {
const labels: Record<string, string> = {
low: t('低', 'Low'),
medium: t('中', 'Medium'),
high: t('高', 'High'),
critical: t('严重', 'Critical'),
};
return labels[risk] || risk;
}
function triggerReasonLabel(reason: string, t: (zh: string, en: string) => string): string {
const labels: Record<string, string> = {
task_accepted: t('任务已接受', 'Task accepted'),
};
return labels[reason] || reason;
}
function publishBlockReason(draft: SkillDraft, t: (zh: string, en: string) => string): string {
if (draft.status !== 'in_review' && draft.status !== 'approved') {
return t('草稿还没有送审,不能发布。', 'The draft has not been submitted yet.');
}
if (!draft.safety_report) return t('缺少安全报告,不能发布。', 'A safety report is required before publishing.');
if (draft.safety_report.risk_level === 'critical' || !draft.safety_report.passed) {
return t('安全报告存在阻断项,不能发布。', 'The safety report has blockers.');
}
if (draft.eval_report?.status !== 'skipped_provider_unavailable' && draft.eval_report?.passed === false) {
return t('评估报告显示回退或未达标,不能发布。', 'The eval report shows a regression or failed score.');
}
return t('当前状态不能发布。', 'The current state cannot be published.');
}
function normalizeStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
if (typeof value === 'string') {
return value.split(',').map((item) => item.trim()).filter(Boolean);
}
return [];
}
function formatDateTime(value?: string | null): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function formatScore(value: number): string {
if (!Number.isFinite(value)) return '-';
return value.toFixed(2);
}
function formatOptionalScore(value: unknown): string {
const parsed = toOptionalNumber(value);
return typeof parsed === 'number' ? formatScore(parsed) : '-';
}
function formatPercent(value?: number | null): string {
if (typeof value !== 'number' || Number.isNaN(value)) return '0%';
return `${Math.round(value * 100)}%`;
}
function formatSignedScore(value: number): string {
if (!Number.isFinite(value)) return '-';
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}`;
}
function toNumber(value: unknown): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function toOptionalNumber(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="py-12 text-center text-muted-foreground">
<div className="mx-auto mb-3 flex justify-center opacity-40">{icon}</div>
<p className="text-sm font-medium">{text}</p>
</div>
);
}
function UploadSkillForm({
onDone,
onCancel,
onError,
}: {
onDone: () => void;
onCancel: () => void;
onError: (msg: string) => void;
}) {
const { locale } = useAppI18n();
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const file = fileRef.current?.files?.[0];
if (!file) return;
setUploading(true);
try {
await uploadSkill(file);
onDone();
} catch (err: any) {
onError(err.message || pickAppText(locale, '上传失败', 'Upload failed'));
} finally {
setUploading(false);
}
};
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{pickAppText(locale, '上传技能', 'Upload skill')}</CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="skill-zip">
{pickAppText(locale, '技能压缩包', 'Skill archive')}
</label>
<input
id="skill-zip"
ref={fileRef}
type="file"
accept=".zip"
className="block w-full cursor-pointer text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary-foreground hover:file:bg-primary/90"
/>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '上传后生成草稿;送审后再运行 safety 和 eval。', 'After upload, a draft is created; safety and eval run after submission.')}
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
<Button type="submit" disabled={uploading}>
{uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
{pickAppText(locale, '上传', 'Upload')}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}