Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
246 lines
9.5 KiB
TypeScript
246 lines
9.5 KiB
TypeScript
import type { EnrichFieldName, IdentityPoolItem, MetadataEnrichOutput } from "@umt/contracts";
|
||
import { ExternalLink, LoaderCircle, Sparkles } from "lucide-react";
|
||
import { useId, useState } from "react";
|
||
import { Link } from "react-router";
|
||
import { toast } from "sonner";
|
||
import { enrichPosterUrl, useApplyEnrich, useEnrichPool } from "~/api/identity.ts";
|
||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||
import { consolePath } from "~/components/console/nav.ts";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { Label } from "~/components/ui/label.tsx";
|
||
import { Switch } from "~/components/ui/switch.tsx";
|
||
import { errorMessage } from "~/lib/api.ts";
|
||
import { ENRICH_FIELD_LABEL, ENRICH_SKIPPED, ENRICH_SOURCE_KIND, EVIDENCE_LEVEL } from "./labels.ts";
|
||
import { Chip } from "./parts.tsx";
|
||
|
||
// META-1 待识别资源 → 元数据补全:先看建议(网页搜索 + 白名单网页 + Agent 抽取,不改作品),再应用(建本地临时作品并关联)。
|
||
|
||
type Suggestion = NonNullable<MetadataEnrichOutput["suggestion"]>;
|
||
type Value = {
|
||
value: unknown;
|
||
sourceUrl: string | null;
|
||
evidenceLevel: string;
|
||
verbatim: boolean;
|
||
sourceKind?: string;
|
||
confidence?: number;
|
||
hosts?: number;
|
||
};
|
||
|
||
const hostOf = (u: string | null) => {
|
||
if (!u) return null;
|
||
try {
|
||
return new URL(u).hostname.replace(/^www\./, "");
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
function SourceLink({ url }: { url: string | null }) {
|
||
const host = hostOf(url);
|
||
if (!url || !host) return <span className="text-meta text-muted-foreground">无出处</span>;
|
||
return (
|
||
<a
|
||
href={url}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
className="inline-flex items-center gap-1 text-meta break-all text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||
>
|
||
{host}
|
||
<ExternalLink className="h-3 w-3 shrink-0" />
|
||
</a>
|
||
);
|
||
}
|
||
|
||
function valuesOf(s: Suggestion, k: EnrichFieldName): Value[] {
|
||
const v = s.fields[k] as Value | Value[] | null;
|
||
return Array.isArray(v) ? v : v ? [v] : [];
|
||
}
|
||
|
||
function FieldRow({ k, s, enrichmentId }: { k: EnrichFieldName; s: Suggestion; enrichmentId: string }) {
|
||
const vals = valuesOf(s, k);
|
||
if (!vals.length) return null;
|
||
return (
|
||
<li className="flex flex-wrap items-start gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0">
|
||
<div className="w-16 shrink-0 pt-0.5 text-meta text-muted-foreground">{ENRICH_FIELD_LABEL[k] ?? k}</div>
|
||
<div className="min-w-0 flex-1 basis-48 space-y-1">
|
||
{k === "posterUrl" ? (
|
||
<img
|
||
src={enrichPosterUrl(enrichmentId)}
|
||
alt="海报"
|
||
className="h-32 w-auto rounded-md border border-border object-cover"
|
||
loading="lazy"
|
||
/>
|
||
) : null}
|
||
{vals.map((v, i) => (
|
||
<div key={i} className="min-w-0">
|
||
{k !== "posterUrl" ? <p className="text-sm break-words text-foreground">{String(v.value)}</p> : null}
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
<Chip>{EVIDENCE_LEVEL[v.evidenceLevel] ?? v.evidenceLevel}</Chip>
|
||
{v.verbatim ? null : <Chip>智能体归纳</Chip>}
|
||
{v.sourceKind ? <Chip>{ENRICH_SOURCE_KIND[v.sourceKind] ?? v.sourceKind}</Chip> : null}
|
||
{v.confidence !== undefined ? (
|
||
<span className="text-meta tabular-nums text-muted-foreground">
|
||
置信度 {Math.round(v.confidence * 100)}%{v.hosts && v.hosts > 1 ? ` · ${v.hosts} 个网站` : ""}
|
||
</span>
|
||
) : null}
|
||
<SourceLink url={v.sourceUrl} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
const FIELDS: EnrichFieldName[] = [
|
||
"title",
|
||
"aliases",
|
||
"year",
|
||
"platform",
|
||
"episodeCount",
|
||
"cast",
|
||
"genre",
|
||
"synopsis",
|
||
"posterUrl",
|
||
];
|
||
|
||
export function EnrichSection({ item }: { item: IdentityPoolItem }) {
|
||
const uid = useId();
|
||
const enrich = useEnrichPool();
|
||
const apply = useApplyEnrich();
|
||
const [out, setOut] = useState<MetadataEnrichOutput | null>(null);
|
||
const [lock, setLock] = useState(false);
|
||
const s = out?.suggestion ?? null;
|
||
|
||
const run = async (fresh: boolean) => {
|
||
try {
|
||
const r = await enrich.mutateAsync({ id: item.id, ...(fresh ? { fresh: true } : {}) });
|
||
setOut(r);
|
||
if (r.status === "failed") toast.error(r.error ?? "没有找到可用的信息");
|
||
} catch (err) {
|
||
toast.error(errorMessage(err, "补全失败"));
|
||
}
|
||
};
|
||
const doApply = async (mediaEntityId?: string) => {
|
||
if (!out) return;
|
||
try {
|
||
const r = await apply.mutateAsync({
|
||
id: item.id,
|
||
enrichmentId: out.enrichmentId,
|
||
lock,
|
||
...(mediaEntityId ? { mediaEntityId } : {}),
|
||
});
|
||
toast.success(r.created ? "已建临时作品并关联" : "已并入已有作品并关联");
|
||
setOut({ ...out, status: "applied", appliedMediaEntityId: r.mediaEntityId });
|
||
} catch (err) {
|
||
toast.error(errorMessage(err, "应用失败"));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<DetailSection
|
||
title="元数据补全"
|
||
actions={
|
||
<Button variant="outline" size="sm" disabled={enrich.isPending} onClick={() => run(false)}>
|
||
{enrich.isPending ? <LoaderCircle className="animate-spin" /> : <Sparkles />}
|
||
{out ? "重新载入" : "补全"}
|
||
</Button>
|
||
}
|
||
>
|
||
{!out ? (
|
||
<p className="text-caption text-muted-foreground">
|
||
TMDB
|
||
查不到时,按标题搜索网页,从平台和百科页面抽取字段,附出处和置信度。确认后才建作品。
|
||
</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<p className="text-meta text-muted-foreground">
|
||
{out.notice}
|
||
{" · "}
|
||
{out.cached ? "7 天内的缓存结果" : `搜索 ${out.usage.searchCalls} 次,读取网页 ${out.usage.fetchCalls} 次`}
|
||
{s ? ` · ${s.extractor === "model" ? `智能体抽取${s.model ? `(${s.model})` : ""}` : "规则抽取"}` : ""}
|
||
{` · 今日 ${out.budget.usedToday}/${out.budget.dailyLimit}`}
|
||
</p>
|
||
{out.error ? <p className="text-caption text-warning">{out.error}</p> : null}
|
||
{s ? (
|
||
<ul className="divide-y divide-border/60">
|
||
{FIELDS.map((k) => (
|
||
<FieldRow key={k} k={k} s={s} enrichmentId={out.enrichmentId} />
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
{s?.sources.length ? (
|
||
<details className="text-meta text-muted-foreground">
|
||
<summary className="cursor-pointer">搜索到的链接({s.sources.length})</summary>
|
||
<ul className="mt-1.5 space-y-1">
|
||
{s.sources.map((x) => (
|
||
<li key={x.url} className="flex flex-wrap items-center gap-1.5">
|
||
<SourceLink url={x.url} />
|
||
<span>
|
||
{x.fetched ? "已读取" : x.skipped ? (ENRICH_SKIPPED[x.skipped] ?? x.skipped) : "未读取"}
|
||
{x.snippetUsed ? ",用了搜索摘要" : ""}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</details>
|
||
) : null}
|
||
{out.appliedMediaEntityId ? (
|
||
<Button asChild variant="outline" size="sm">
|
||
<Link to={consolePath("library", out.appliedMediaEntityId)}>查看作品</Link>
|
||
</Button>
|
||
) : out.status === "suggested" && s ? (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Switch id={`${uid}-lock`} checked={lock} onCheckedChange={setLock} />
|
||
<Label htmlFor={`${uid}-lock`} className="text-caption">
|
||
应用的字段同时锁定
|
||
</Label>
|
||
</div>
|
||
{out.existing.length ? (
|
||
<div className="space-y-1.5">
|
||
<p className="text-caption text-muted-foreground">
|
||
已有同名作品。点「应用」时年份不冲突的自动并入,也可以指定一个:
|
||
</p>
|
||
{out.existing.map((e) => (
|
||
<div key={e.mediaEntityId} className="flex flex-wrap items-center gap-2">
|
||
<Link
|
||
to={consolePath("library", e.mediaEntityId)}
|
||
className="text-sm underline-offset-4 hover:underline"
|
||
>
|
||
{e.title}
|
||
{e.year ? ` (${e.year})` : ""}
|
||
</Link>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={apply.isPending}
|
||
onClick={() => doApply(e.mediaEntityId)}
|
||
>
|
||
并入
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" disabled={apply.isPending} onClick={() => doApply()}>
|
||
{apply.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||
应用
|
||
</Button>
|
||
<Button variant="ghost" size="sm" disabled={enrich.isPending} onClick={() => run(true)}>
|
||
重新搜索
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Button variant="ghost" size="sm" disabled={enrich.isPending} onClick={() => run(true)}>
|
||
重新搜索
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</DetailSection>
|
||
);
|
||
}
|