Add admin vision lab and LLM vision verification
这个提交包含在:
300
client/src/pages/VisionLab.tsx
普通文件
300
client/src/pages/VisionLab.tsx
普通文件
@@ -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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户