Files
beaver_project/app-instance/frontend/app/(app)/skills/page.tsx
steven_li 30ab74ffb2 feat(engine): 添加MCP连接管理和工具集成功能
- 集成MCP连接管理器,支持MCP服务器连接
- 添加多种内置工具:ClarifyTool、CronTool、DelegateTool、ExecuteCodeTool、
  PatchFileTool、ProcessTool、SendMessageTool、SpawnTool、TerminalTool、
  TodoTool、WebFetchTool、WebSearchTool、WriteFileTool等
- 实现工具注册和装配功能
- 添加技能选择上下文参数
- 支持思考模式控制参数thinking_enabled

feat(coordinator): 重构任务执行计划器参数命名

- 将learning_candidate_enabled重命名为allow_candidate_generation
- 更新TeamGraphScheduler中的参数传递
- 修改LocalAgentRunner中的相关参数处理
- 更新README文档中的相应描述

refactor(context): 标准化工具调用参数格式

- 添加_json导入用于参数序列化
- 实现_provider_tool_calls方法标准化OpenAI兼容的工具调用载荷
- 修复工具调用中参数非字符串类型的序列化问题

refactor(session): 优化消息历史记录过滤逻辑

- 修改get_messages_as_conversation为基于运行状态过滤消息
- 排除未完成、失败或错误结束的运行记录
- 改进对话历史的可见性控制机制

fix(store): 修复FTS索引重建逻辑

- 添加异常处理防止FTS索引创建失败
- 实现_rebuild_fts_index方法重新构建全文搜索索引
- 优化索引触发器和表的维护流程
2026-05-14 09:43:48 +08:00

631 lines
25 KiB
TypeScript

'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
AlertCircle,
Check,
Download,
FileText,
Loader2,
Puzzle,
RefreshCw,
Rocket,
Send,
ShieldCheck,
Trash2,
Upload,
X,
XCircle,
} from 'lucide-react';
import {
approveSkillDraft,
deleteSkill,
disablePublishedSkill,
downloadSkill,
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 type { Skill, SkillDraft, 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 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 hiddenCandidateStatuses = new Set(['rejected', 'superseded', 'published']);
const visibleCandidates = candidates.filter(
(candidate) => !ignoredCandidates.has(candidate.candidate_id) && !hiddenCandidateStatuses.has(candidate.status)
);
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 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" />
{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)}
/>
)}
<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}
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={(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>
{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) => (
<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"
variant="outline"
disabled={Boolean(actionId)}
onClick={() => setIgnoredCandidates((prev) => new Set(prev).add(candidate.candidate_id))}
>
{t('忽略', 'Ignore')}
</Button>
<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>
{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 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>
))}
</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: (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 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 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 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>
{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>
<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-3 grid gap-3 md:grid-cols-2">
<div className="rounded-md border border-border bg-muted/30 p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">{t('当前版本', 'Current version')}</div>
<pre className="max-h-52 overflow-auto whitespace-pre-wrap text-xs">
{draft.base_version ? `${t('基线版本', 'Base version')}: ${draft.base_version}` : t('无基线版本,视为新增技能', 'No base version, treated as a new skill')}
</pre>
</div>
<div className="rounded-md border border-border bg-muted/30 p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">{t('草稿变更', 'Draft changes')}</div>
<pre className="max-h-52 overflow-auto whitespace-pre-wrap text-xs">
{JSON.stringify(draft.proposed_frontmatter, null, 2)}
{'\n\n---\n\n'}
{draft.proposed_content}
</pre>
</div>
</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>
);
}
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>
);
}