Files
beaver_project/app-instance/frontend/app/(app)/skills/page.tsx
steven_li ebfa242862 feat(outlook): 添加Outlook集成功能支持
添加完整的Outlook MCP集成,包括邮件和日历功能,通过AuthZ模式进行认证和权限管理,
支持邮箱连接、断开、状态检查和数据同步等功能。

fix(config): 统一配置文件路径从.nanobot到.beaver

将配置文件路径从/root/.nanobot统一更改为/root/.beaver,更新Dockerfile中的环境变量定义,
确保所有组件使用一致的配置目录结构。

feat(agent): 添加代理删除功能和助手身份提示

为代理注册表添加delete_agent方法,实现代理的动态删除功能;同时添加海狸助手身份提示,
确保AI助手在交互中保持一致的身份认知。

feat(engine): 增强引擎循环并添加意图决策快照

扩展AgentLoop类,添加intent_agent_decision参数用于意图驱动的代理决策,并在会话中记录
决策快照,便于后续分析和调试。

feat(authz): 扩展认证客户端功能

为AuthzClient添加设置权限、用户注册、后端注册和Outlook设置管理等新方法,增强系统
的认证和授权能力。
2026-05-14 16:01:46 +08:00

1366 lines
54 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 {
AlertCircle,
BarChart3,
Check,
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 {
approveSkillDraft,
deleteSkill,
disablePublishedSkill,
downloadSkill,
getSkillDetail,
getSkillFile,
getSkillVersion,
listSkillCandidates,
listSkillDrafts,
listSkills,
migrateSkills,
publishSkillDraft,
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';
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
export default function SkillsPage() {
const { locale } = useAppI18n();
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 [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]);
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 max-w-6xl space-y-6 bg-white p-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%]">
<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 items-center gap-2">
<Button onClick={() => void load()} variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
{t('刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowUpload(true)} size="sm">
<Upload className="mr-2 h-4 w-4" />
{t('上传技能', 'Upload skill')}
</Button>
<Button
onClick={() => void runAction('migrate-skills', () => migrateSkills())}
variant="outline"
size="sm"
disabled={Boolean(actionId)}
>
{actionId === 'migrate-skills' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Rocket className="mr-2 h-4 w-4" />}
{t('迁移旧技能', 'Migrate legacy skills')}
</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"
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" 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" onClick={() => onRollbackSkill(skillDetail.skill.name)}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('回滚', 'Rollback')}
</Button>
<Button
variant="outline"
size="sm"
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="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 defaultValue="published" className="space-y-4">
<TabsList>
<TabsTrigger value="published">{t('已发布', 'Published')}</TabsTrigger>
<TabsTrigger value="candidates">{t('候选', 'Candidates')}</TabsTrigger>
<TabsTrigger value="drafts">{t('草稿评审', 'Draft review')}</TabsTrigger>
</TabsList>
<TabsContent value="published">
<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">
<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">
<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)
)
}
onApprove={() =>
runAction(`approve:${draft.draft_id}`, () =>
approveSkillDraft(draft.skill_name, draft.draft_id)
)
}
onReject={() =>
runAction(`reject:${draft.draft_id}`, () =>
rejectSkillDraft(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')} />
) : (
<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">{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-7 w-7" 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-7 w-7"
onClick={(event) => {
event.stopPropagation();
onRollback(skill.name);
}}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(event) => {
event.stopPropagation();
onDisable(skill.name);
}}
>
<ShieldCheck className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={(event) => {
event.stopPropagation();
onDelete(skill.name);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</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="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">
{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">{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" disabled={Boolean(actionId)} onClick={onIgnore}>
{t('忽略', 'Ignore')}
</Button>
<Button
size="sm"
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"
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,
onApprove,
onReject,
onPublish,
}: {
draft: SkillDraft;
actionId: string | null;
onSubmit: () => Promise<unknown>;
onApprove: () => Promise<unknown>;
onReject: () => 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 publishBlocked =
draft.status !== 'approved'
|| !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 submitBlocked = draft.status !== 'draft' || safetyBlocksReview;
const approveBlocked = draft.status !== 'in_review' || 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="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="break-words text-lg font-semibold tracking-normal">{draft.skill_name}</h3>
</div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{draft.reason || description || t('没有提供草稿说明。', 'No draft notes were provided.')}
</p>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<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={<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">{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" disabled={busy || submitBlocked} onClick={() => void onSubmit()}>
<Send className="mr-2 h-4 w-4" />
{t('送审', 'Submit')}
</Button>
<Button variant="outline" size="sm" disabled={busy || approveBlocked} onClick={() => void onApprove()}>
<Check className="mr-2 h-4 w-4" />
{t('批准', 'Approve')}
</Button>
<Button variant="outline" size="sm" disabled={busy || rejectBlocked} onClick={() => void onReject()}>
<XCircle className="mr-2 h-4 w-4" />
{t('拒绝', 'Reject')}
</Button>
<Button size="sm" disabled={busy || publishBlocked} onClick={handlePublish}>
<Rocket className="mr-2 h-4 w-4" />
{t('发布', 'Publish')}
</Button>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="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">
<FileText className="h-4 w-4 text-muted-foreground" />
{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>
{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="space-y-3">
<GateSummary
title={t('发布门禁', 'Publish gates')}
summary={canPublishLabel}
items={[
{ label: t('草稿已批准', 'Draft approved'), ok: draft.status === 'approved' },
{ 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={{
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 gap-3 md:grid-cols-2">
<SafetyReportPanel report={safety} />
<EvalReportPanel report={evalReport} />
</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="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>{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">
<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 EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
if (!report) {
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="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>
);
}
return (
<div className="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 avg')} value={formatScore(report.baseline_score_avg)} />
<MetricTile label={t('候选均分', 'Candidate avg')} 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">
<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>
{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="max-h-48 overflow-auto">
<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('基线', '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">{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>
)}
<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="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="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="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="break-words text-sm leading-5">{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="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 rounded-md border border-border bg-white">
<summary className="flex 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">
{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">
<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> = {
validation_accepted_and_user_satisfied: t('任务验证通过且用户满意', 'Validation accepted and user satisfied'),
};
return labels[reason] || reason;
}
function publishBlockReason(draft: SkillDraft, t: (zh: string, en: string) => string): string {
if (draft.status !== 'approved') return t('草稿还没有批准,不能发布。', 'The draft is not approved 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 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 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, the skill enters draft review and runs safety and eval automatically.')}
</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>
);
}