Files
beaver_project/app-instance/frontend/app/(app)/skills/page.tsx
steven_li 4b0bf65ace ```
feat(engine): 优化智能体循环中的助手消息处理逻辑

- 在没有工具调用时才添加助手消息到上下文
- 确保工具调用响应正确添加到消息上下文中
- 修复了消息构建的条件逻辑

fix(cron): 改进定时任务调度的时间解析功能

- 添加正则表达式导入用于时间显示解析
- 实现从显示文本中提取毫秒间隔的功能
- 增强整数转换的安全性,避免类型错误
- 优化定时任务配置的解析逻辑

feat(outlook): 增强Outlook集成的功能和稳定性

- 将默认超时时间从10秒增加到180秒
- 为状态检查函数添加可选的验证参数
- 串行执行邮件概览获取操作而非并行
- 改进连接状态验证逻辑

feat(channel): 添加设备名称作为会话标识的选项

- 为终端WebSocket适配器添加新的配置选项
- 实现基于设备名称生成会话对等ID的功能
- 记录原始对等ID和设备名称的元数据
- 支持从设备名称创建会话对等ID

feat(skills): 完善技能学习评估系统和进度跟踪

- 在应用启动时自动调度待评估的技能草稿
- 为技能评估工作创建独立的循环工厂
- 实现异步技能评估任务的取消和清理机制
- 添加技能评估进度报告和状态跟踪功能
- 扩展会话列表API以包含更多详细信息
- 防止对不存在的会话进行操作
- 优化技能草稿提交和评估的业务逻辑

perf(skills): 提升技能评估的并发性能

- 实现并行技能案例评估以提高效率
- 添加最大并行案例数的环境变量控制
- 实现实时评估进度更新和回调机制
- 优化评估过程中的资源管理和同步

refactor(services): 创建隔离的智能体循环实例

- 添加创建独立智能体循环的工厂方法
- 确保新循环继承运行时服务配置
- 支持技能评估等需要隔离环境的场景
```
2026-06-15 14:48:16 +08:00

1676 lines
69 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 {
deleteSkill,
disablePublishedSkill,
downloadSkill,
getSkillDetail,
getSkillFile,
getSkillVersion,
listSkillCandidates,
listSkillDrafts,
listSkills,
publishSkillDraft,
recheckSkillDraftSafety,
regenerateSkillDraft,
rejectSkillDraft,
rollbackPublishedSkill,
submitSkillDraft,
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 {
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';
function normalizeSkillsTab(value: string | null | undefined): SkillsTab {
if (value === 'candidates' || value === 'drafts') {
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 [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, candidateData, draftData] = await Promise.all([
listSkills(),
listSkillCandidates().catch(() => []),
listSkillDrafts().catch(() => []),
]);
setSkills(Array.isArray(skillData) ? skillData : []);
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>
</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>
</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>
<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>
</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 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;
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>}
{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 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 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>
<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');
}
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'),
};
return labels[kind] || kind;
}
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>
);
}