Add admin vision lab and LLM vision verification

这个提交包含在:
cryptocommuniums-afk
2026-03-15 00:41:09 +08:00
父节点 20e183d2da
当前提交 ad83ce9c68
修改 18 个文件,包含 915 行新增16 行删除

查看文件

@@ -0,0 +1,300 @@
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { Database, Image as ImageIcon, Loader2, Microscope, ShieldCheck, Sparkles } from "lucide-react";
type ReferenceImage = {
id: number;
slug: string;
title: string;
exerciseType: string;
imageUrl: string;
sourcePageUrl: string;
sourceLabel: string;
author: string | null;
license: string | null;
expectedFocus: string[] | null;
tags: string[] | null;
notes: string | null;
};
type VisionRun = {
id: number;
taskId: string;
userId: number;
userName: string | null;
referenceImageId: number | null;
referenceTitle: string | null;
title: string;
exerciseType: string;
imageUrl: string;
status: "queued" | "succeeded" | "failed";
visionStatus: "pending" | "ok" | "fallback" | "failed";
configuredModel: string | null;
expectedFocus: string[] | null;
summary: string | null;
corrections: string | null;
warning: string | null;
error: string | null;
createdAt: Date;
updatedAt: Date;
};
function statusBadge(run: VisionRun) {
if (run.status === "failed" || run.visionStatus === "failed") {
return <Badge variant="destructive"></Badge>;
}
if (run.status === "queued" || run.visionStatus === "pending") {
return <Badge variant="secondary"></Badge>;
}
if (run.visionStatus === "fallback") {
return <Badge variant="outline"></Badge>;
}
return <Badge className="bg-emerald-600 hover:bg-emerald-600"></Badge>;
}
export default function VisionLab() {
const { user } = useAuth();
const utils = trpc.useUtils();
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const activeTask = useBackgroundTask(activeTaskId);
const libraryQuery = trpc.vision.library.useQuery();
const runsQuery = trpc.vision.runs.useQuery(
{ limit: 50 },
{ refetchInterval: 4000 }
);
const seedMutation = trpc.vision.seedLibrary.useMutation({
onSuccess: (data) => {
toast.success(`标准图库已就绪,共 ${data.count}`);
utils.vision.library.invalidate();
},
onError: (error) => toast.error(`标准图库初始化失败: ${error.message}`),
});
const runReferenceMutation = trpc.vision.runReference.useMutation({
onSuccess: (data) => {
setActiveTaskId(data.taskId);
toast.success("视觉测试任务已提交");
utils.vision.runs.invalidate();
},
onError: (error) => toast.error(`视觉测试提交失败: ${error.message}`),
});
const runAllMutation = trpc.vision.runAll.useMutation({
onSuccess: (data) => {
toast.success(`已提交 ${data.count} 个视觉测试任务`);
if (data.queued[0]?.taskId) {
setActiveTaskId(data.queued[0].taskId);
}
utils.vision.runs.invalidate();
},
onError: (error) => toast.error(`批量视觉测试提交失败: ${error.message}`),
});
useEffect(() => {
if (activeTask.data?.status === "succeeded" || activeTask.data?.status === "failed") {
utils.vision.runs.invalidate();
setActiveTaskId(null);
}
}, [activeTask.data, utils.vision.runs]);
const references = useMemo(() => (libraryQuery.data ?? []) as ReferenceImage[], [libraryQuery.data]);
const runs = useMemo(() => (runsQuery.data ?? []) as VisionRun[], [runsQuery.data]);
if (libraryQuery.isLoading && runsQuery.isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-sm text-muted-foreground mt-1">
访
</p>
</div>
<div className="flex flex-wrap gap-2">
{user?.role === "admin" ? (
<Button
variant="outline"
onClick={() => seedMutation.mutate()}
disabled={seedMutation.isPending}
className="gap-2"
>
{seedMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Database className="h-4 w-4" />}
</Button>
) : null}
<Button
onClick={() => runAllMutation.mutate()}
disabled={runAllMutation.isPending || references.length === 0}
className="gap-2"
>
{runAllMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Microscope className="h-4 w-4" />}
</Button>
</div>
</div>
{user?.role === "admin" ? (
<Alert>
<ShieldCheck className="h-4 w-4" />
<AlertTitle>Admin </AlertTitle>
<AlertDescription>
`H1` `ADMIN_USERNAMES`
</AlertDescription>
</Alert>
) : (
<Alert>
<ImageIcon className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription></AlertDescription>
</Alert>
)}
{activeTask.data?.status === "queued" || activeTask.data?.status === "running" ? (
<Alert>
<Sparkles className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>{activeTask.data.message || "视觉测试正在后台执行。"}</AlertDescription>
</Alert>
) : null}
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<Badge variant="secondary">{references.length} </Badge>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{references.map((reference) => (
<Card key={reference.id} className="overflow-hidden border-0 shadow-sm">
<div className="aspect-[4/3] overflow-hidden bg-muted">
<img
src={reference.imageUrl}
alt={reference.title}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
</div>
<CardHeader className="pb-3">
<CardTitle className="text-base">{reference.title}</CardTitle>
<CardDescription className="flex flex-wrap gap-2">
<Badge variant="outline">{reference.exerciseType}</Badge>
{reference.license ? <Badge variant="secondary">{reference.license}</Badge> : null}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{reference.notes ? (
<p className="text-sm text-muted-foreground">{reference.notes}</p>
) : null}
{reference.expectedFocus?.length ? (
<div className="flex flex-wrap gap-2">
{reference.expectedFocus.map((item) => (
<Badge key={item} variant="outline">{item}</Badge>
))}
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<a
href={reference.sourcePageUrl}
target="_blank"
rel="noreferrer"
className="text-sm text-primary underline-offset-4 hover:underline"
>
</a>
<Button
size="sm"
className="gap-2"
onClick={() => runReferenceMutation.mutate({ referenceImageId: reference.id })}
disabled={runReferenceMutation.isPending}
>
{runReferenceMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Microscope className="h-4 w-4" />}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<Badge variant="secondary">{runs.length} </Badge>
</div>
<div className="grid gap-4">
{runs.map((run) => (
<Card key={run.id} className="border-0 shadow-sm">
<CardContent className="pt-5 space-y-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">{run.title}</h3>
{statusBadge(run)}
<Badge variant="outline">{run.exerciseType}</Badge>
</div>
<p className="text-xs text-muted-foreground">
{new Date(run.createdAt).toLocaleString("zh-CN")}
{user?.role === "admin" && run.userName ? ` · 提交人:${run.userName}` : ""}
</p>
</div>
{run.configuredModel ? (
<Badge variant="secondary">{run.configuredModel}</Badge>
) : null}
</div>
{run.summary ? <p className="text-sm">{run.summary}</p> : null}
{run.warning ? (
<p className="text-sm text-amber-700">{run.warning}</p>
) : null}
{run.error ? (
<p className="text-sm text-destructive">{run.error}</p>
) : null}
{run.expectedFocus?.length ? (
<div className="flex flex-wrap gap-2">
{run.expectedFocus.map((item) => (
<Badge key={item} variant="outline">{item}</Badge>
))}
</div>
) : null}
{run.corrections ? (
<div className="rounded-xl bg-muted/50 p-3 text-sm leading-6 whitespace-pre-wrap">
{run.corrections}
</div>
) : null}
</CardContent>
</Card>
))}
{runs.length === 0 ? (
<Card className="border-dashed">
<CardContent className="pt-6 text-sm text-muted-foreground">
</CardContent>
</Card>
) : null}
</div>
</section>
</div>
);
}