"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { createPortal } from "react-dom"; import { ArrowRight, Bot, Database, LoaderCircle } from "lucide-react"; import { errorMessage, getActivityDetail } from "@/lib/api"; import type { CanvasItem, InspectorDetailProjection } from "@/lib/types"; type RetrievalItem = Extract; interface Options { anchorRef: RefObject; buildId?: string; item: RetrievalItem; onOpenDetail: () => void; } const detailCache = new Map(); const OPEN_DELAY = 300; const CLOSE_DELAY = 160; const PREVIEW_WIDTH = 380; export function useRetrievalHoverPreview({ anchorRef, buildId, item, onOpenDetail }: Options) { const previewRef = useRef(null); const openTimer = useRef | undefined>(undefined); const closeTimer = useRef | undefined>(undefined); const [open, setOpen] = useState(false); const [position, setPosition] = useState({ left: 0, top: 0 }); const clearTimers = useCallback(() => { if (openTimer.current) clearTimeout(openTimer.current); if (closeTimer.current) clearTimeout(closeTimer.current); }, []); const place = useCallback(() => { const anchor = anchorRef.current; if (!anchor) return; const rect = anchor.getBoundingClientRect(); const gap = 12; const left = rect.right + PREVIEW_WIDTH + gap <= window.innerWidth ? rect.right + gap : Math.max(gap, rect.left - PREVIEW_WIDTH - gap); const estimatedHeight = Math.min(460, Math.max(260, window.innerHeight * .58)); const top = Math.min(Math.max(gap, rect.top), Math.max(gap, window.innerHeight - estimatedHeight - gap)); setPosition({ left, top }); }, [anchorRef]); const showSoon = useCallback(() => { if (closeTimer.current) clearTimeout(closeTimer.current); if (open || openTimer.current) return; openTimer.current = setTimeout(() => { openTimer.current = undefined; place(); setOpen(true); }, OPEN_DELAY); }, [open, place]); const showNow = useCallback(() => { clearTimers(); place(); setOpen(true); }, [clearTimers, place]); const hideSoon = useCallback(() => { if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = undefined; } if (closeTimer.current) clearTimeout(closeTimer.current); closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY); }, []); const keepOpen = useCallback(() => { if (closeTimer.current) clearTimeout(closeTimer.current); }, []); useEffect(() => clearTimers, [clearTimers]); useEffect(() => { if (!open) return; const update = () => place(); window.addEventListener("resize", update); window.addEventListener("scroll", update, true); return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); }; }, [open, place]); const preview = open && typeof document !== "undefined" ? createPortal( , document.body, ) : null; return { preview, anchorProps: { onPointerEnter: showSoon, onPointerLeave: hideSoon, onFocusCapture: showNow, onBlurCapture: (event: React.FocusEvent) => { const next = event.relatedTarget as Node | null; if (next && (anchorRef.current?.contains(next) || previewRef.current?.contains(next))) return; hideSoon(); }, }, }; } const RetrievalPreviewPanel = function RetrievalPreviewPanel({ buildId, item, position, onPointerEnter, onPointerLeave, onOpenDetail, ref, }: { buildId?: string; item: RetrievalItem; position: { left: number; top: number }; onPointerEnter: () => void; onPointerLeave: () => void; onOpenDetail: () => void; ref: RefObject; }) { const isAgent = item.itemType === "retrieval-agent"; return
{isAgent ? : }
; }; function AgentPreview({ item }: { item: Extract }) { const run = item.run; const hits = run.attempts.filter((attempt) => attempt.status === "hit"); const returnedCount = hits.reduce((sum, attempt) => sum + (attempt.resultCount || 0), 0); const screening = cleanPreview(run.screening.preview || ""); return <>
Agent 取数

{run.businessLabel}

{run.querySummary.hit} 次命中

{run.querySummary.total} 次查询{returnedCount ? `,共返回 ${returnedCount} 条结果` : ""}{run.querySummary.empty ? `,${run.querySummary.empty} 次无结果` : ""}{run.querySummary.failure ? `,${run.querySummary.failure} 次失败` : ""}。

{hits.length ?

命中了什么

    {hits.slice(0, 3).map((attempt) =>
  • {attempt.queryLabel}{attempt.resultCount ?? "?"} 条
  • )}
{hits.length > 3 ? 另有 {hits.length - 3} 次命中 : null}
: null}

初筛得到

{screening || (run.screening.state === "running" ? "正在整理查询结果。" : "本次运行没有保存初筛结论。")}

; } function DirectToolPreview({ buildId, item }: { buildId?: string; item: Extract }) { const calls = item.group.calls; const [details, setDetails] = useState>([]); const [loading, setLoading] = useState(Boolean(buildId && calls.length)); useEffect(() => { let active = true; if (!buildId || !calls.length) { setLoading(false); return; } setLoading(true); void Promise.all(calls.slice(0, 4).map(async (call) => { const key = `${buildId}:${call.detailRef}`; const cached = detailCache.get(key); if (cached) return { callId: call.id, detail: cached }; try { const detail = await getActivityDetail(buildId, call.detailRef); detailCache.set(key, detail); return { callId: call.id, detail }; } catch (error) { return { callId: call.id, error: errorMessage(error) }; } })).then((result) => { if (active) { setDetails(result); setLoading(false); } }); return () => { active = false; }; }, [buildId, calls]); const summaries = useMemo(() => calls.slice(0, 4).map((call) => { const detail = details.find((entry) => entry.callId === call.id); return { call, outcome: detail?.detail?.outcome?.message, items: (detail?.detail?.items || []).slice(0, 3), error: detail?.error, }; }), [calls, details]); return <>
工具取数

{item.group.sources.map((source) => source.businessLabel).join("、") || "读取内容"}

{item.group.successCount}/{item.group.callCount} 完成
{loading ?
正在读取真实返回内容…
: null} {!loading ?
{summaries.map(({ call, outcome, items, error }) =>

{call.businessLabel}

{error ?

完整返回暂时不可用:{error}

: <>

{outcome || call.resultSummary}

{items.length ?
    {items.map((result) =>
  • {result.title}{result.subtitle ? {cleanPreview(result.subtitle)} : null}
  • )}
: null} }
)}
: null} {calls.length > 4 ? 完整详情中还有 {calls.length - 4} 次工具调用 : null} ; } function cleanPreview(value: string) { if (!value || value.startsWith("functioncall:")) return ""; const clean = value .replace(/```[\s\S]*?```/g, " ") .replace(/^#{1,6}\s+/gm, "") .replace(/^[-*+]\s+/gm, "") .replace(/\|/g, " ") .replace(/[*_`>#]/g, "") .replace(/\s+/g, " ") .trim(); return clean.length > 360 ? `${clean.slice(0, 360).trim()}…` : clean; }