feat(app): 移除内置agents并添加CORS支持和技能上传优化
移除了agents/registry.json中的所有内置agents配置,将agents数组清空。 为web应用添加了CORS中间件支持,允许指定的前端地址跨域访问。 重构了技能上传功能,增加了LLM重写机制,自动规范化上传的技能格式。 新增了工具名称提取逻辑,从技能正文中自动识别Required Tools段落。 更新了技能学习候选者和草稿的载荷结构,添加评估报告统计信息。 修改了意图路由技能的说明,改进任务状态管理逻辑。
This commit is contained in:
@ -28,8 +28,10 @@ import {
|
||||
deleteUserFile,
|
||||
createUserFileDir,
|
||||
getAccessToken,
|
||||
isApiError,
|
||||
} from '@/lib/api';
|
||||
import type { UserFileContent, UserFileItem } from '@/lib/api';
|
||||
import { canMutateUserFilesPath } from '@/lib/user-file-paths';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { type AppLocale, pickAppText } from '@/lib/i18n/core';
|
||||
@ -44,6 +46,10 @@ function sleep(ms: number): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function isAuthError(error: unknown): boolean {
|
||||
return isApiError(error, 401);
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [items, setItems] = useState<UserFileItem[]>([]);
|
||||
@ -78,6 +84,9 @@ export default function FilesPage() {
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (isAuthError(err)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const message = lastError instanceof Error ? lastError.message : pickAppText(locale, '加载文件失败', 'Failed to load files');
|
||||
@ -156,6 +165,15 @@ export default function FilesPage() {
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
if (!canMutateUserFilesPath(currentPath)) {
|
||||
setLoadError(pickAppText(
|
||||
locale,
|
||||
'请先进入 uploads、outputs、shared 或 tasks 目录后再上传。',
|
||||
'Open uploads, outputs, shared, or tasks before uploading.'
|
||||
));
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
@ -178,6 +196,14 @@ export default function FilesPage() {
|
||||
const handleCreateDir = async () => {
|
||||
const name = newDirName.trim();
|
||||
if (!name) return;
|
||||
if (!canMutateUserFilesPath(currentPath)) {
|
||||
setLoadError(pickAppText(
|
||||
locale,
|
||||
'请先进入 uploads、outputs、shared 或 tasks 目录后再新建文件夹。',
|
||||
'Open uploads, outputs, shared, or tasks before creating a folder.'
|
||||
));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dirPath = currentPath ? `${currentPath}/${name}` : name;
|
||||
await createUserFileDir(dirPath);
|
||||
@ -191,6 +217,7 @@ export default function FilesPage() {
|
||||
|
||||
// Build breadcrumbs
|
||||
const breadcrumbs = currentPath ? currentPath.split('/') : [];
|
||||
const canMutateCurrentPath = canMutateUserFilesPath(currentPath);
|
||||
|
||||
const formatSize = (bytes: number | null) => {
|
||||
if (bytes === null || bytes === undefined) return '';
|
||||
@ -224,7 +251,12 @@ export default function FilesPage() {
|
||||
size="sm"
|
||||
className="h-11"
|
||||
onClick={() => setShowMkdir(true)}
|
||||
disabled={loading}
|
||||
disabled={loading || !canMutateCurrentPath}
|
||||
title={
|
||||
canMutateCurrentPath
|
||||
? undefined
|
||||
: pickAppText(locale, '先进入 uploads、outputs、shared 或 tasks', 'Open uploads, outputs, shared, or tasks first')
|
||||
}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4 mr-1" />
|
||||
{pickAppText(locale, '新建文件夹', 'New folder')}
|
||||
@ -234,7 +266,12 @@ export default function FilesPage() {
|
||||
size="sm"
|
||||
className="h-11"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
disabled={uploading || !canMutateCurrentPath}
|
||||
title={
|
||||
canMutateCurrentPath
|
||||
? undefined
|
||||
: pickAppText(locale, '先进入 uploads、outputs、shared 或 tasks', 'Open uploads, outputs, shared, or tasks first')
|
||||
}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
@ -272,6 +309,15 @@ export default function FilesPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!canMutateCurrentPath && !loading && (
|
||||
<p className="mb-4 rounded-md border border-[#E6E1DE] bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
{pickAppText(
|
||||
locale,
|
||||
'请选择 uploads、outputs、shared 或 tasks 后再上传或新建文件夹。',
|
||||
'Select uploads, outputs, shared, or tasks before uploading or creating folders.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center gap-1 mb-4 text-sm text-muted-foreground flex-wrap">
|
||||
|
||||
@ -5,7 +5,6 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ClipboardList,
|
||||
@ -31,7 +30,6 @@ import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import {
|
||||
approveSkillDraft,
|
||||
deleteSkill,
|
||||
disablePublishedSkill,
|
||||
downloadSkill,
|
||||
@ -436,11 +434,6 @@ export default function SkillsPage() {
|
||||
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)
|
||||
@ -799,7 +792,6 @@ function DraftCard({
|
||||
draft,
|
||||
actionId,
|
||||
onSubmit,
|
||||
onApprove,
|
||||
onReject,
|
||||
onRecheckSafety,
|
||||
onPublish,
|
||||
@ -807,7 +799,6 @@ function DraftCard({
|
||||
draft: SkillDraft;
|
||||
actionId: string | null;
|
||||
onSubmit: () => Promise<unknown>;
|
||||
onApprove: () => Promise<unknown>;
|
||||
onReject: () => Promise<unknown>;
|
||||
onRecheckSafety: () => Promise<unknown>;
|
||||
onPublish: (confirmHighRisk: boolean) => Promise<unknown>;
|
||||
@ -820,8 +811,10 @@ function DraftCard({
|
||||
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 =
|
||||
draft.status !== 'approved'
|
||||
!submittedForReview
|
||||
|| !safety
|
||||
|| safety.risk_level === 'critical'
|
||||
|| (evalReport?.status !== 'skipped_provider_unavailable' && evalReport?.passed === false);
|
||||
@ -833,7 +826,6 @@ function DraftCard({
|
||||
].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)
|
||||
@ -878,7 +870,12 @@ function DraftCard({
|
||||
<p className={`mt-1 text-sm leading-6 text-muted-foreground ${containedLongTextClass}`}>
|
||||
{draft.reason || description || t('没有提供草稿说明。', 'No draft notes were provided.')}
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
{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')}
|
||||
@ -889,6 +886,11 @@ function DraftCard({
|
||||
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')}
|
||||
@ -912,10 +914,6 @@ function DraftCard({
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{t('送审', 'Submit')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-11" disabled={busy || approveBlocked} onClick={() => void onApprove()}>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{t('批准', 'Approve')}
|
||||
</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')}
|
||||
@ -926,7 +924,7 @@ function DraftCard({
|
||||
</Button>
|
||||
<Button size="sm" className="h-11" disabled={busy || publishBlocked} onClick={handlePublish}>
|
||||
<Rocket className="mr-2 h-4 w-4" />
|
||||
{t('发布', 'Publish')}
|
||||
{draft.proposal_kind === 'revise_skill' ? t('发布修订', 'Publish revision') : t('发布', 'Publish')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -936,7 +934,7 @@ function DraftCard({
|
||||
<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')}
|
||||
{isRevision ? t('修改对比', 'Revision comparison') : t('拟发布的技能正文', 'Proposed skill body')}
|
||||
</div>
|
||||
{toolHints.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@ -948,7 +946,14 @@ function DraftCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{draft.proposed_content.trim() ? (
|
||||
{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>
|
||||
@ -960,7 +965,7 @@ function DraftCard({
|
||||
title={t('发布门禁', 'Publish gates')}
|
||||
summary={canPublishLabel}
|
||||
items={[
|
||||
{ label: t('草稿已批准', 'Draft approved'), ok: draft.status === 'approved' },
|
||||
{ label: t('草稿已送审', 'Draft submitted'), ok: submittedForReview },
|
||||
{ label: t('安全报告通过', 'Safety passed'), ok: Boolean(safety?.passed) && safety?.risk_level !== 'critical' },
|
||||
{
|
||||
label: t('评估未回退', 'No eval regression'),
|
||||
@ -971,6 +976,7 @@ function DraftCard({
|
||||
<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,
|
||||
@ -1040,6 +1046,71 @@ function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null
|
||||
);
|
||||
}
|
||||
|
||||
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 }: { report?: SkillDraftEvalReport | null }) {
|
||||
const { locale } = useAppI18n();
|
||||
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
|
||||
@ -1066,6 +1137,15 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
|
||||
</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">
|
||||
@ -1079,8 +1159,8 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
|
||||
</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('基线能力均分', '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)}`}
|
||||
@ -1089,8 +1169,14 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<MetricTile label={t('执行覆盖', 'Execution')} value={formatPercent(report.execution_coverage)} />
|
||||
<MetricTile label={t('替代评估', 'Surrogate')} value={formatPercent(report.surrogate_coverage)} />
|
||||
<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>
|
||||
|
||||
@ -1100,6 +1186,12 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
|
||||
<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">
|
||||
@ -1114,6 +1206,10 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
|
||||
<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>
|
||||
@ -1122,6 +1218,7 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
|
||||
<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>
|
||||
@ -1131,6 +1228,10 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
|
||||
{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>
|
||||
@ -1144,6 +1245,12 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
|
||||
{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}
|
||||
@ -1366,7 +1473,9 @@ function triggerReasonLabel(reason: string, t: (zh: string, en: string) => strin
|
||||
}
|
||||
|
||||
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.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.');
|
||||
@ -1399,6 +1508,11 @@ function formatScore(value: number): string {
|
||||
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)}%`;
|
||||
@ -1414,6 +1528,12 @@ function toNumber(value: unknown): number {
|
||||
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">
|
||||
@ -1475,7 +1595,7 @@ function UploadSkillForm({
|
||||
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.')}
|
||||
{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">
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { buildAuthPortalUrl } from '@/lib/auth-portal';
|
||||
import { clearTokens, getMe, isLoggedIn } from '@/lib/api';
|
||||
import { AUTH_CLEARED_EVENT, clearTokens, getMe, isLoggedIn } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
@ -66,6 +66,18 @@ export default function AuthGuard({
|
||||
};
|
||||
}, [setIsAuthLoading, setUser]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAuthCleared = () => {
|
||||
setUser(null);
|
||||
setIsAuthLoading(false);
|
||||
};
|
||||
|
||||
window.addEventListener(AUTH_CLEARED_EVENT, handleAuthCleared);
|
||||
return () => {
|
||||
window.removeEventListener(AUTH_CLEARED_EVENT, handleAuthCleared);
|
||||
};
|
||||
}, [setIsAuthLoading, setUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthLoading) {
|
||||
return;
|
||||
|
||||
@ -58,6 +58,7 @@ const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.trim();
|
||||
const DEFAULT_API_URL = 'http://127.0.0.1:18080';
|
||||
const ACCESS_TOKEN_KEY = 'beaver_access_token';
|
||||
const REFRESH_TOKEN_KEY = 'beaver_refresh_token';
|
||||
export const AUTH_CLEARED_EVENT = 'beaver-auth-cleared';
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
const OUTLOOK_REQUEST_TIMEOUT_MS = 45000;
|
||||
const SKILL_LEARNING_REQUEST_TIMEOUT_MS = 120000;
|
||||
@ -117,6 +118,34 @@ type FetchJsonOptions = RequestInit & {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
detail: string;
|
||||
|
||||
constructor(message: string, options: { status: number; detail: string }) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = options.status;
|
||||
this.detail = options.detail;
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiError(error: unknown, status?: number): error is ApiError {
|
||||
return error instanceof ApiError && (status === undefined || error.status === status);
|
||||
}
|
||||
|
||||
function parseErrorDetail(text: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed.detail === 'string') {
|
||||
return parsed.detail;
|
||||
}
|
||||
} catch {
|
||||
// keep raw text
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function withTimeout(
|
||||
signal?: AbortSignal,
|
||||
timeoutMs: number = REQUEST_TIMEOUT_MS
|
||||
@ -163,6 +192,7 @@ export function clearTokens(): void {
|
||||
if (!isBrowser()) return;
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
window.dispatchEvent(new CustomEvent(AUTH_CLEARED_EVENT));
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
@ -215,16 +245,11 @@ async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
}
|
||||
let detail = text;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed.detail === 'string') {
|
||||
detail = parsed.detail;
|
||||
}
|
||||
} catch {
|
||||
// keep raw text
|
||||
}
|
||||
throw new Error(`${pickAppText(locale, '接口错误', 'API error')} ${res.status}: ${detail}`);
|
||||
const detail = parseErrorDetail(text);
|
||||
throw new ApiError(`${pickAppText(locale, '接口错误', 'API error')} ${res.status}: ${detail}`, {
|
||||
status: res.status,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@ -1216,7 +1241,7 @@ export async function uploadSkill(file: File): Promise<Skill> {
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`接口错误 ${res.status}: ${text}`);
|
||||
throw new Error(`接口错误 ${res.status}: ${parseErrorDetail(text)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
8
app-instance/frontend/lib/user-file-paths.ts
Normal file
8
app-instance/frontend/lib/user-file-paths.ts
Normal file
@ -0,0 +1,8 @@
|
||||
const USER_FILE_MUTABLE_ROOTS = new Set(['uploads', 'outputs', 'shared', 'tasks']);
|
||||
|
||||
export function canMutateUserFilesPath(path: string): boolean {
|
||||
const cleaned = path.trim().replace(/^\/+|\/+$/g, '');
|
||||
if (!cleaned) return false;
|
||||
const [root] = cleaned.split('/');
|
||||
return USER_FILE_MUTABLE_ROOTS.has(root);
|
||||
}
|
||||
@ -3,9 +3,23 @@ import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { canMutateUserFilesPath } from './user-file-paths';
|
||||
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
describe('user file system frontend wiring', () => {
|
||||
it('only enables mutating file actions inside concrete user-file roots', () => {
|
||||
expect(canMutateUserFilesPath('')).toBe(false);
|
||||
expect(canMutateUserFilesPath('/')).toBe(false);
|
||||
expect(canMutateUserFilesPath('qa-folder')).toBe(false);
|
||||
|
||||
expect(canMutateUserFilesPath('uploads')).toBe(true);
|
||||
expect(canMutateUserFilesPath('uploads/qa-folder')).toBe(true);
|
||||
expect(canMutateUserFilesPath('outputs/report.md')).toBe(true);
|
||||
expect(canMutateUserFilesPath('shared')).toBe(true);
|
||||
expect(canMutateUserFilesPath('tasks/task-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('routes API client helpers to user file endpoints', () => {
|
||||
const apiSource = readFileSync(resolve(root, 'lib/api.ts'), 'utf8');
|
||||
|
||||
@ -17,6 +31,13 @@ describe('user file system frontend wiring', () => {
|
||||
expect(apiSource).toContain('/api/user-files/mkdir');
|
||||
});
|
||||
|
||||
it('notifies the app shell when API auth is cleared', () => {
|
||||
const apiSource = readFileSync(resolve(root, 'lib/api.ts'), 'utf8');
|
||||
|
||||
expect(apiSource).toContain('AUTH_CLEARED_EVENT');
|
||||
expect(apiSource).toContain("window.dispatchEvent(new CustomEvent(AUTH_CLEARED_EVENT))");
|
||||
});
|
||||
|
||||
it('does not wire the Files page to workspace or MinIO management APIs', () => {
|
||||
const pageSource = readFileSync(resolve(root, 'app/(app)/files/page.tsx'), 'utf8');
|
||||
|
||||
@ -29,4 +50,18 @@ describe('user file system frontend wiring', () => {
|
||||
expect(pageSource).not.toContain('accessKey');
|
||||
expect(pageSource).not.toContain('secretKey');
|
||||
});
|
||||
|
||||
it('does not retry user-file loads after an auth failure', () => {
|
||||
const pageSource = readFileSync(resolve(root, 'app/(app)/files/page.tsx'), 'utf8');
|
||||
|
||||
expect(pageSource).toContain('isAuthError');
|
||||
expect(pageSource).toContain('if (isAuthError(err))');
|
||||
});
|
||||
|
||||
it('shows backend upload error details instead of raw JSON payloads', () => {
|
||||
const apiSource = readFileSync(resolve(root, 'lib/api.ts'), 'utf8');
|
||||
|
||||
expect(apiSource).toContain('function parseErrorDetail');
|
||||
expect(apiSource).toContain('throw new Error(`接口错误 ${res.status}: ${parseErrorDetail(text)}`)');
|
||||
});
|
||||
});
|
||||
|
||||
@ -993,6 +993,12 @@ export interface SkillDraftEvalReport {
|
||||
confidence?: 'low' | 'medium' | 'high' | string;
|
||||
case_reports?: Array<Record<string, unknown>>;
|
||||
tool_mode_summary?: Record<string, unknown>;
|
||||
ability_score_summary?: Record<string, unknown>;
|
||||
tool_execution_summary?: Record<string, unknown>;
|
||||
case_selection_summary?: Record<string, unknown>;
|
||||
real_score_avg?: number | null;
|
||||
synthetic_score_avg?: number | null;
|
||||
overall_score_avg?: number | null;
|
||||
preservation_report?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@ -1000,6 +1006,15 @@ export interface SkillDraft {
|
||||
draft_id: string;
|
||||
skill_name: string;
|
||||
base_version?: string | null;
|
||||
target_version?: string | null;
|
||||
base_skill?: {
|
||||
skill_name: string;
|
||||
version: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
content: string;
|
||||
summary?: string;
|
||||
tool_hints?: string[];
|
||||
} | null;
|
||||
proposed_content: string;
|
||||
proposed_frontmatter: Record<string, unknown>;
|
||||
created_at: string;
|
||||
|
||||
Reference in New Issue
Block a user