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 { formatDateTimeShanghai } from "@/lib/time"; 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; }; const COMMONS_SPECIAL_FILE_PATH = "/wiki/Special:FilePath/"; const COMMONS_FILE_PAGE_PATH = "/wiki/File:"; function getCompressedVisionImageUrl(imageUrl: string, width = 960) { try { const url = new URL(imageUrl); if (url.hostname !== "commons.wikimedia.org") { return imageUrl; } let fileName: string | null = null; if (url.pathname.startsWith(COMMONS_SPECIAL_FILE_PATH)) { fileName = url.pathname.slice(COMMONS_SPECIAL_FILE_PATH.length); } else if (url.pathname.startsWith(COMMONS_FILE_PAGE_PATH)) { fileName = url.pathname.slice(COMMONS_FILE_PAGE_PATH.length); } if (!fileName) { return imageUrl; } const decodedFileName = decodeURIComponent(fileName); return `https://commons.wikimedia.org/wiki/Special:Redirect/file/${encodeURIComponent(decodedFileName)}?width=${width}`; } catch { return imageUrl; } } function VisionPreviewImage({ src, alt, className, width = 960, }: { src: string; alt: string; className: string; width?: number; }) { const [displaySrc, setDisplaySrc] = useState(() => getCompressedVisionImageUrl(src, width)); useEffect(() => { setDisplaySrc(getCompressedVisionImageUrl(src, width)); }, [src, width]); return ( {alt} { if (displaySrc !== src) { setDisplaySrc(src); } }} /> ); } function statusBadge(run: VisionRun) { if (run.status === "failed" || run.visionStatus === "failed") { return 失败; } if (run.status === "queued" || run.visionStatus === "pending") { return 排队中; } if (run.visionStatus === "fallback") { return 文本降级; } return 视觉成功; } export default function VisionLab() { const { user } = useAuth(); const utils = trpc.useUtils(); const [activeTaskId, setActiveTaskId] = useState(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}`), }); const retryRunMutation = trpc.vision.retryRun.useMutation({ onSuccess: () => { toast.success("视觉记录已重新加入队列"); utils.vision.runs.invalidate(); }, onError: (error) => toast.error(`重新执行失败: ${error.message}`), }); const retryFallbacksMutation = trpc.vision.retryFallbacks.useMutation({ onSuccess: (data) => { toast.success(`已重新排队 ${data.count} 条历史视觉记录`); 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 (
); } return (

视觉标准图库

用公网可访问的网球标准图验证多模态纠正链路,并持久化每次测试结果。

{user?.role === "admin" ? ( <> ) : null}
{user?.role === "admin" ? ( Admin 视角 当前账号可查看全部视觉测试记录。若用户名为 `H1` 且被配置进 `ADMIN_USERNAMES`,登录后会自动拥有此视角。 ) : ( 个人测试视角 当前页面展示标准图库,以及你自己提交的视觉测试结果。 )} {activeTask.data?.status === "queued" || activeTask.data?.status === "running" ? ( 后台执行中 {activeTask.data.message || "视觉测试正在后台执行。"} ) : null}

标准图片库

{references.length} 张
{references.map((reference) => (
{reference.title} {reference.exerciseType} {reference.license ? {reference.license} : null} {reference.notes ? (

{reference.notes}

) : null} {reference.expectedFocus?.length ? (
{reference.expectedFocus.map((item) => ( {item} ))}
) : null}
来源页
))}

视觉测试记录

{runs.length} 条
{runs.map((run) => (

{run.title}

{statusBadge(run)} {run.exerciseType}

{formatDateTimeShanghai(run.createdAt)} {user?.role === "admin" && run.userName ? ` · 提交人:${run.userName}` : ""}

{run.configuredModel ? ( {run.configuredModel} ) : null}
{run.summary ?

{run.summary}

: null} {run.warning ? (

降级说明:{run.warning}

) : null} {run.error ? (

错误:{run.error}

) : null} {(run.visionStatus === "fallback" || run.status === "failed") ? (
) : null} {run.expectedFocus?.length ? (
{run.expectedFocus.map((item) => ( {item} ))}
) : null} {run.corrections ? (
{run.corrections}
) : null}
))} {runs.length === 0 ? ( 还没有视觉测试记录。先运行一张标准图测试,结果会自动入库并显示在这里。 ) : null}
); }