feat(beaver): 完成Task Team功能v1实现,重构后端架构支持统一内核
新增内部Task系统,包括验证、反馈门控机制,实现自动质量验证 (通过率>=0.75)和用户反馈闭环(satisfied/revise/abandon)。 实现Agent Team v1协调器,支持sequence/parallel/dag执行策略, sub-agent复用主AgentLoop,每个run使用独立memory snapshot。 建立Skill学习pipeline,包含draft/审核/发布/回滚完整生命周期, 通过Task验证通过且用户满意才生成学习候选。 重构目录结构,移除third_party依赖,建立统一engine内核, 所有agent共享运行时基础组件。 更新ContextBuilder清理provider消息字段,增强SkillContext版本管理, 集成TaskExecutionPlanner和TaskSkillResolver实现技能解析机制。
This commit is contained in:
@ -1,20 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Puzzle,
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Check,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Rocket,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Upload,
|
||||
Wand2,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { listSkills, deleteSkill, uploadSkill, downloadSkill } from '@/lib/api';
|
||||
|
||||
import {
|
||||
approveSkillDraft,
|
||||
deleteSkill,
|
||||
disablePublishedSkill,
|
||||
downloadSkill,
|
||||
listSkillCandidates,
|
||||
listSkillDrafts,
|
||||
listSkills,
|
||||
publishSkillDraft,
|
||||
regenerateSkillDraft,
|
||||
rejectSkillDraft,
|
||||
rollbackPublishedSkill,
|
||||
runSkillLearningOnce,
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -23,53 +48,63 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { Skill } from '@/types';
|
||||
import type { Skill, SkillDraft, SkillLearningCandidate } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
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 [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const loadSkills = async () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listSkills();
|
||||
setSkills(Array.isArray(data) ? data : []);
|
||||
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(() => {
|
||||
loadSkills();
|
||||
}, []);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
setDeleting(name);
|
||||
};
|
||||
|
||||
const confirmDelete = async (name: string) => {
|
||||
const runAction = async (id: string, action: () => Promise<unknown>) => {
|
||||
setActionId(id);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteSkill(name);
|
||||
setDeleting(null);
|
||||
loadSkills();
|
||||
await action();
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '删除技能失败', 'Failed to delete the skill'));
|
||||
setDeleting(null);
|
||||
setError(err.message || t('操作失败', 'Action failed'));
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadDone = () => {
|
||||
setShowUpload(false);
|
||||
loadSkills();
|
||||
const confirmDelete = async (name: string) => {
|
||||
await runAction(`delete:${name}`, async () => {
|
||||
await deleteSkill(name);
|
||||
setDeleting(null);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@ -81,20 +116,33 @@ export default function SkillsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<div className="mx-auto max-w-6xl space-y-6 p-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" />
|
||||
{pickAppText(locale, '技能', 'Skills')}
|
||||
{t('技能', 'Skills')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={loadSkills} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
<Button onClick={() => void load()} variant="outline" size="sm">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('刷新', 'Refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void runAction('learning:run-once', () => runSkillLearningOnce())}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={Boolean(actionId)}
|
||||
>
|
||||
{actionId === 'learning:run-once' ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('运行学习', 'Run learning')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowUpload(true)} size="sm">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '上传技能', 'Upload skill')}
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('上传技能', 'Upload skill')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -102,134 +150,396 @@ export default function SkillsPage() {
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Upload Dialog */}
|
||||
{showUpload && (
|
||||
<UploadSkillForm
|
||||
onDone={handleUploadDone}
|
||||
onDone={() => {
|
||||
setShowUpload(false);
|
||||
void load();
|
||||
}}
|
||||
onCancel={() => setShowUpload(false)}
|
||||
onError={(msg) => setError(msg)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{deleting && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm">
|
||||
{pickAppText(locale, '确定删除技能', 'Delete skill')} <strong>{deleting}</strong> {pickAppText(locale, '吗?此操作不可撤销。', '? This action cannot be undone.')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleting(null)}
|
||||
>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(deleting)}
|
||||
>
|
||||
{pickAppText(locale, '删除', 'Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<CardContent className="flex items-center justify-between gap-4 pt-6">
|
||||
<p className="text-sm">
|
||||
{t('确定删除技能', 'Delete skill')} <strong>{deleting}</strong>?
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setDeleting(null)}>
|
||||
{t('取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => void confirmDelete(deleting)}>
|
||||
{t('删除', 'Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Skills Table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{skills.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<Puzzle className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">{pickAppText(locale, '暂无技能', 'No skills yet')}</p>
|
||||
<p className="text-sm mt-1">{pickAppText(locale, '上传一个技能 zip 包即可开始使用。', 'Upload a skill zip package to get started.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '描述', 'Description')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '来源', 'Source')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
|
||||
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
|
||||
<Tabs defaultValue="published" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="published">{t('已发布', 'Published')}</TabsTrigger>
|
||||
<TabsTrigger value="candidates">{t('候选', 'Candidates')}</TabsTrigger>
|
||||
<TabsTrigger value="drafts">{t('草稿/评审', 'Drafts')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="published">
|
||||
<PublishedSkillsTable
|
||||
skills={skills}
|
||||
onDownload={(name) => downloadSkill(name).catch((err) => setError(err.message))}
|
||||
onDelete={(name) => setDeleting(name)}
|
||||
onDisable={(name) =>
|
||||
runAction(`disable:${name}`, () => disablePublishedSkill(name, t('人工禁用', 'Manual disable')))
|
||||
}
|
||||
onRollback={(name) => {
|
||||
const target = window.prompt(t('回滚到版本,例如 v0001', 'Rollback target version, for example v0001'));
|
||||
if (target) {
|
||||
void runAction(`rollback:${name}`, () =>
|
||||
rollbackPublishedSkill(name, target, t('人工回滚', 'Manual rollback'))
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="candidates">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('学习候选', 'Learning candidates')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{candidates.length === 0 ? (
|
||||
<EmptyState icon={<Wand2 className="h-8 w-8" />} text={t('暂无学习候选', 'No learning candidates yet')} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{candidates.map((candidate) => (
|
||||
<div key={candidate.candidate_id} className="rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{candidate.kind}</Badge>
|
||||
<Badge variant="secondary">{candidate.status}</Badge>
|
||||
<Badge variant={candidate.risk_level === 'critical' || candidate.risk_level === 'high' ? 'destructive' : 'outline'}>
|
||||
{candidate.risk_level || 'medium'}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs text-muted-foreground">{candidate.candidate_id}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-foreground">{candidate.reason}</p>
|
||||
{candidate.evidence_summary && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{candidate.evidence_summary}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('来源 runs', 'Source runs')}: {candidate.source_run_ids.join(', ') || '-'}
|
||||
</p>
|
||||
{candidate.related_skill_names.length > 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('关联技能', 'Related skills')}: {candidate.related_skill_names.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{candidate.last_error && (
|
||||
<p className="mt-1 text-xs text-destructive">{candidate.last_error}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={Boolean(actionId)}
|
||||
onClick={() =>
|
||||
void runAction(`draft:${candidate.candidate_id}`, () =>
|
||||
synthesizeSkillDraft(candidate.candidate_id)
|
||||
)
|
||||
}
|
||||
>
|
||||
{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 runAction(`regen:${candidate.candidate_id}`, () =>
|
||||
regenerateSkillDraft(candidate.candidate_id)
|
||||
)
|
||||
}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('重新生成', 'Regenerate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('草稿、评审与发布', 'Drafts, review, and publish')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{drafts.length === 0 ? (
|
||||
<EmptyState icon={<FileText className="h-8 w-8" />} text={t('暂无草稿', 'No drafts yet')} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{drafts.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={() =>
|
||||
runAction(`publish:${draft.draft_id}`, async () => {
|
||||
const confirmHighRisk = draft.safety_report?.risk_level === 'high';
|
||||
if (confirmHighRisk && !window.confirm(t('这是高风险草稿,确认发布?', 'This is a high-risk draft. Publish anyway?'))) {
|
||||
return;
|
||||
}
|
||||
await publishSkillDraft(draft.skill_name, draft.draft_id, '', confirmHighRisk);
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublishedSkillsTable({
|
||||
skills,
|
||||
onDownload,
|
||||
onDelete,
|
||||
onDisable,
|
||||
onRollback,
|
||||
}: {
|
||||
skills: Skill[];
|
||||
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}`}>
|
||||
<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={() => 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={() => onRollback(skill.name)}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => 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={() => onDelete(skill.name)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{skills.map((skill) => (
|
||||
<TableRow key={`${skill.source}:${skill.name}`}>
|
||||
<TableCell className="font-medium">{skill.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground truncate max-w-[300px] block">
|
||||
{skill.description}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{skill.source === 'builtin' ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{pickAppText(locale, '内置', 'Built in')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="text-xs">
|
||||
{pickAppText(locale, '工作区', 'Workspace')}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{skill.available ? (
|
||||
<Badge variant="default" className="text-xs bg-green-600">
|
||||
{pickAppText(locale, '可用', 'Available')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '不可用', 'Unavailable')}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title={pickAppText(locale, '下载', 'Download')}
|
||||
onClick={() => downloadSkill(skill.name).catch((e) => setError(e.message))}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{skill.source === 'workspace' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(skill.name)}
|
||||
title={pickAppText(locale, '删除', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftCard({
|
||||
draft,
|
||||
actionId,
|
||||
onSubmit,
|
||||
onApprove,
|
||||
onReject,
|
||||
onPublish,
|
||||
}: {
|
||||
draft: SkillDraft;
|
||||
actionId: string | null;
|
||||
onSubmit: () => Promise<unknown>;
|
||||
onApprove: () => Promise<unknown>;
|
||||
onReject: () => Promise<unknown>;
|
||||
onPublish: () => 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 publishBlocked =
|
||||
draft.status !== 'approved'
|
||||
|| !safety
|
||||
|| !safety.passed
|
||||
|| safety.risk_level === 'critical'
|
||||
|| (evalReport?.status !== 'skipped_provider_unavailable' && evalReport?.passed === false);
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{draft.proposal_kind}</Badge>
|
||||
<Badge variant="secondary">{draft.status}</Badge>
|
||||
{safety && (
|
||||
<Badge variant={safety.risk_level === 'critical' || safety.risk_level === 'high' ? 'destructive' : 'outline'}>
|
||||
{safety.risk_level}
|
||||
</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>
|
||||
)}
|
||||
<span className="font-mono text-xs text-muted-foreground">{draft.skill_name}/{draft.draft_id}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm">{draft.reason || t('无说明', 'No notes')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('base', 'base')}: {draft.base_version || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={busy || draft.status !== 'draft'} onClick={() => void onSubmit()}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{t('送审', 'Submit')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy || draft.status === 'published'} onClick={() => void onApprove()}>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{t('批准', 'Approve')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy || draft.status === 'published'} onClick={() => void onReject()}>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
{t('拒绝', 'Reject')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={busy || publishBlocked} onClick={() => void onPublish()}>
|
||||
<Rocket className="mr-2 h-4 w-4" />
|
||||
{t('发布', 'Publish')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<pre className="max-h-52 overflow-auto rounded-md bg-muted/50 p-3 text-xs">
|
||||
{JSON.stringify(draft.proposed_frontmatter, null, 2)}
|
||||
</pre>
|
||||
<pre className="max-h-52 overflow-auto whitespace-pre-wrap rounded-md bg-muted/50 p-3 text-xs">
|
||||
{draft.proposed_content}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<ReportBlock
|
||||
title={t('安全报告', 'Safety report')}
|
||||
empty={t('暂无安全报告', 'No safety report')}
|
||||
payload={safety}
|
||||
/>
|
||||
<ReportBlock
|
||||
title={t('评估报告', 'Eval report')}
|
||||
empty={t('暂无评估报告', 'No eval report')}
|
||||
payload={evalReport}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportBlock({ title, empty, payload }: { title: string; empty: string; payload: unknown }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">{title}</div>
|
||||
{payload ? (
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify(payload, null, 2)}</pre>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{empty}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -247,11 +557,10 @@ function UploadSkillForm({
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const file = fileRef.current?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadSkill(file);
|
||||
@ -269,7 +578,7 @@ function UploadSkillForm({
|
||||
<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="w-4 h-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@ -284,28 +593,16 @@ function UploadSkillForm({
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer"
|
||||
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, '压缩包中必须包含 `SKILL.md` 文件', 'The archive must contain a `SKILL.md` file')}
|
||||
</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="w-4 h-4 mr-2 animate-spin" />
|
||||
{pickAppText(locale, '上传中...', 'Uploading...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '上传', 'Upload')}
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user