1958 lines
82 KiB
TypeScript
1958 lines
82 KiB
TypeScript
'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>
|
||
);
|
||
}
|