| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209 |
- "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<CanvasItem, { itemType: "retrieval-direct" | "retrieval-agent" }>;
- interface Options {
- anchorRef: RefObject<HTMLElement | null>;
- buildId?: string;
- item: RetrievalItem;
- onOpenDetail: () => void;
- }
- const detailCache = new Map<string, InspectorDetailProjection>();
- const OPEN_DELAY = 300;
- const CLOSE_DELAY = 160;
- const PREVIEW_WIDTH = 380;
- export function useRetrievalHoverPreview({ anchorRef, buildId, item, onOpenDetail }: Options) {
- const previewRef = useRef<HTMLDivElement>(null);
- const openTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
- const closeTimer = useRef<ReturnType<typeof setTimeout> | 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(
- <RetrievalPreviewPanel
- ref={previewRef}
- buildId={buildId}
- item={item}
- position={position}
- onPointerEnter={keepOpen}
- onPointerLeave={hideSoon}
- onOpenDetail={onOpenDetail}
- />,
- document.body,
- ) : null;
- return {
- preview,
- anchorProps: {
- onPointerEnter: showSoon,
- onPointerLeave: hideSoon,
- onFocusCapture: showNow,
- onBlurCapture: (event: React.FocusEvent<HTMLElement>) => {
- 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<HTMLDivElement | null>;
- }) {
- const isAgent = item.itemType === "retrieval-agent";
- return <div
- ref={ref}
- className="retrievalHoverPreview nodrag nopan"
- role="dialog"
- aria-label={isAgent ? `${item.run.businessLabel}取数预览` : "工具取数预览"}
- style={{ left: position.left, top: position.top }}
- onPointerEnter={onPointerEnter}
- onPointerLeave={onPointerLeave}
- >
- {isAgent ? <AgentPreview item={item} /> : <DirectToolPreview buildId={buildId} item={item} />}
- <button type="button" className="retrievalPreviewAction" onClick={onOpenDetail}>查看完整详情<ArrowRight size={14} /></button>
- </div>;
- };
- function AgentPreview({ item }: { item: Extract<RetrievalItem, { itemType: "retrieval-agent" }> }) {
- 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 <>
- <header className="retrievalPreviewHeader"><span className="retrievalPreviewIcon is-agent"><Bot size={16} /></span><div><small>Agent 取数</small><h3>{run.businessLabel}</h3></div><strong>{run.querySummary.hit} 次命中</strong></header>
- <p className="retrievalPreviewSummary">{run.querySummary.total} 次查询{returnedCount ? `,共返回 ${returnedCount} 条结果` : ""}{run.querySummary.empty ? `,${run.querySummary.empty} 次无结果` : ""}{run.querySummary.failure ? `,${run.querySummary.failure} 次失败` : ""}。</p>
- {hits.length ? <section><h4>命中了什么</h4><ul>{hits.slice(0, 3).map((attempt) => <li key={attempt.id}><span>{attempt.queryLabel}</span><em>{attempt.resultCount ?? "?"} 条</em></li>)}</ul>{hits.length > 3 ? <small className="retrievalPreviewMore">另有 {hits.length - 3} 次命中</small> : null}</section> : null}
- <section><h4>初筛得到</h4><p className="retrievalPreviewText">{screening || (run.screening.state === "running" ? "正在整理查询结果。" : "本次运行没有保存初筛结论。")}</p></section>
- </>;
- }
- function DirectToolPreview({ buildId, item }: { buildId?: string; item: Extract<RetrievalItem, { itemType: "retrieval-direct" }> }) {
- const calls = item.group.calls;
- const [details, setDetails] = useState<Array<{ callId: string; detail?: InspectorDetailProjection; error?: string }>>([]);
- 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 <>
- <header className="retrievalPreviewHeader"><span className="retrievalPreviewIcon is-tool"><Database size={16} /></span><div><small>工具取数</small><h3>{item.group.sources.map((source) => source.businessLabel).join("、") || "读取内容"}</h3></div><strong>{item.group.successCount}/{item.group.callCount} 完成</strong></header>
- {loading ? <div className="retrievalPreviewLoading"><LoaderCircle size={16} />正在读取真实返回内容…</div> : null}
- {!loading ? <div className="retrievalPreviewCalls">{summaries.map(({ call, outcome, items, error }) => <section key={call.id}>
- <h4>{call.businessLabel}</h4>
- {error ? <p className="retrievalPreviewError">完整返回暂时不可用:{error}</p> : <>
- <p className="retrievalPreviewSummary">{outcome || call.resultSummary}</p>
- {items.length ? <ul>{items.map((result) => <li key={result.id}><span>{result.title}</span>{result.subtitle ? <small>{cleanPreview(result.subtitle)}</small> : null}</li>)}</ul> : null}
- </>}
- </section>)}</div> : null}
- {calls.length > 4 ? <small className="retrievalPreviewMore">完整详情中还有 {calls.length - 4} 次工具调用</small> : 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;
- }
|