RetrievalHoverPreview.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
  3. import { createPortal } from "react-dom";
  4. import { ArrowRight, Bot, Database, LoaderCircle } from "lucide-react";
  5. import { errorMessage, getActivityDetail } from "@/lib/api";
  6. import type { CanvasItem, InspectorDetailProjection } from "@/lib/types";
  7. type RetrievalItem = Extract<CanvasItem, { itemType: "retrieval-direct" | "retrieval-agent" }>;
  8. interface Options {
  9. anchorRef: RefObject<HTMLElement | null>;
  10. buildId?: string;
  11. item: RetrievalItem;
  12. onOpenDetail: () => void;
  13. }
  14. const detailCache = new Map<string, InspectorDetailProjection>();
  15. const OPEN_DELAY = 300;
  16. const CLOSE_DELAY = 160;
  17. const PREVIEW_WIDTH = 380;
  18. export function useRetrievalHoverPreview({ anchorRef, buildId, item, onOpenDetail }: Options) {
  19. const previewRef = useRef<HTMLDivElement>(null);
  20. const openTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  21. const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  22. const [open, setOpen] = useState(false);
  23. const [position, setPosition] = useState({ left: 0, top: 0 });
  24. const clearTimers = useCallback(() => {
  25. if (openTimer.current) clearTimeout(openTimer.current);
  26. if (closeTimer.current) clearTimeout(closeTimer.current);
  27. }, []);
  28. const place = useCallback(() => {
  29. const anchor = anchorRef.current;
  30. if (!anchor) return;
  31. const rect = anchor.getBoundingClientRect();
  32. const gap = 12;
  33. const left = rect.right + PREVIEW_WIDTH + gap <= window.innerWidth
  34. ? rect.right + gap
  35. : Math.max(gap, rect.left - PREVIEW_WIDTH - gap);
  36. const estimatedHeight = Math.min(460, Math.max(260, window.innerHeight * .58));
  37. const top = Math.min(Math.max(gap, rect.top), Math.max(gap, window.innerHeight - estimatedHeight - gap));
  38. setPosition({ left, top });
  39. }, [anchorRef]);
  40. const showSoon = useCallback(() => {
  41. if (closeTimer.current) clearTimeout(closeTimer.current);
  42. if (open || openTimer.current) return;
  43. openTimer.current = setTimeout(() => {
  44. openTimer.current = undefined;
  45. place();
  46. setOpen(true);
  47. }, OPEN_DELAY);
  48. }, [open, place]);
  49. const showNow = useCallback(() => {
  50. clearTimers();
  51. place();
  52. setOpen(true);
  53. }, [clearTimers, place]);
  54. const hideSoon = useCallback(() => {
  55. if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = undefined; }
  56. if (closeTimer.current) clearTimeout(closeTimer.current);
  57. closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY);
  58. }, []);
  59. const keepOpen = useCallback(() => {
  60. if (closeTimer.current) clearTimeout(closeTimer.current);
  61. }, []);
  62. useEffect(() => clearTimers, [clearTimers]);
  63. useEffect(() => {
  64. if (!open) return;
  65. const update = () => place();
  66. window.addEventListener("resize", update);
  67. window.addEventListener("scroll", update, true);
  68. return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); };
  69. }, [open, place]);
  70. const preview = open && typeof document !== "undefined" ? createPortal(
  71. <RetrievalPreviewPanel
  72. ref={previewRef}
  73. buildId={buildId}
  74. item={item}
  75. position={position}
  76. onPointerEnter={keepOpen}
  77. onPointerLeave={hideSoon}
  78. onOpenDetail={onOpenDetail}
  79. />,
  80. document.body,
  81. ) : null;
  82. return {
  83. preview,
  84. anchorProps: {
  85. onPointerEnter: showSoon,
  86. onPointerLeave: hideSoon,
  87. onFocusCapture: showNow,
  88. onBlurCapture: (event: React.FocusEvent<HTMLElement>) => {
  89. const next = event.relatedTarget as Node | null;
  90. if (next && (anchorRef.current?.contains(next) || previewRef.current?.contains(next))) return;
  91. hideSoon();
  92. },
  93. },
  94. };
  95. }
  96. const RetrievalPreviewPanel = function RetrievalPreviewPanel({
  97. buildId,
  98. item,
  99. position,
  100. onPointerEnter,
  101. onPointerLeave,
  102. onOpenDetail,
  103. ref,
  104. }: {
  105. buildId?: string;
  106. item: RetrievalItem;
  107. position: { left: number; top: number };
  108. onPointerEnter: () => void;
  109. onPointerLeave: () => void;
  110. onOpenDetail: () => void;
  111. ref: RefObject<HTMLDivElement | null>;
  112. }) {
  113. const isAgent = item.itemType === "retrieval-agent";
  114. return <div
  115. ref={ref}
  116. className="retrievalHoverPreview nodrag nopan"
  117. role="dialog"
  118. aria-label={isAgent ? `${item.run.businessLabel}取数预览` : "工具取数预览"}
  119. style={{ left: position.left, top: position.top }}
  120. onPointerEnter={onPointerEnter}
  121. onPointerLeave={onPointerLeave}
  122. >
  123. {isAgent ? <AgentPreview item={item} /> : <DirectToolPreview buildId={buildId} item={item} />}
  124. <button type="button" className="retrievalPreviewAction" onClick={onOpenDetail}>查看完整详情<ArrowRight size={14} /></button>
  125. </div>;
  126. };
  127. function AgentPreview({ item }: { item: Extract<RetrievalItem, { itemType: "retrieval-agent" }> }) {
  128. const run = item.run;
  129. const hits = run.attempts.filter((attempt) => attempt.status === "hit");
  130. const returnedCount = hits.reduce((sum, attempt) => sum + (attempt.resultCount || 0), 0);
  131. const screening = cleanPreview(run.screening.preview || "");
  132. return <>
  133. <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>
  134. <p className="retrievalPreviewSummary">{run.querySummary.total} 次查询{returnedCount ? `,共返回 ${returnedCount} 条结果` : ""}{run.querySummary.empty ? `,${run.querySummary.empty} 次无结果` : ""}{run.querySummary.failure ? `,${run.querySummary.failure} 次失败` : ""}。</p>
  135. {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}
  136. <section><h4>初筛得到</h4><p className="retrievalPreviewText">{screening || (run.screening.state === "running" ? "正在整理查询结果。" : "本次运行没有保存初筛结论。")}</p></section>
  137. </>;
  138. }
  139. function DirectToolPreview({ buildId, item }: { buildId?: string; item: Extract<RetrievalItem, { itemType: "retrieval-direct" }> }) {
  140. const calls = item.group.calls;
  141. const [details, setDetails] = useState<Array<{ callId: string; detail?: InspectorDetailProjection; error?: string }>>([]);
  142. const [loading, setLoading] = useState(Boolean(buildId && calls.length));
  143. useEffect(() => {
  144. let active = true;
  145. if (!buildId || !calls.length) { setLoading(false); return; }
  146. setLoading(true);
  147. void Promise.all(calls.slice(0, 4).map(async (call) => {
  148. const key = `${buildId}:${call.detailRef}`;
  149. const cached = detailCache.get(key);
  150. if (cached) return { callId: call.id, detail: cached };
  151. try {
  152. const detail = await getActivityDetail(buildId, call.detailRef);
  153. detailCache.set(key, detail);
  154. return { callId: call.id, detail };
  155. } catch (error) {
  156. return { callId: call.id, error: errorMessage(error) };
  157. }
  158. })).then((result) => { if (active) { setDetails(result); setLoading(false); } });
  159. return () => { active = false; };
  160. }, [buildId, calls]);
  161. const summaries = useMemo(() => calls.slice(0, 4).map((call) => {
  162. const detail = details.find((entry) => entry.callId === call.id);
  163. return {
  164. call,
  165. outcome: detail?.detail?.outcome?.message,
  166. items: (detail?.detail?.items || []).slice(0, 3),
  167. error: detail?.error,
  168. };
  169. }), [calls, details]);
  170. return <>
  171. <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>
  172. {loading ? <div className="retrievalPreviewLoading"><LoaderCircle size={16} />正在读取真实返回内容…</div> : null}
  173. {!loading ? <div className="retrievalPreviewCalls">{summaries.map(({ call, outcome, items, error }) => <section key={call.id}>
  174. <h4>{call.businessLabel}</h4>
  175. {error ? <p className="retrievalPreviewError">完整返回暂时不可用:{error}</p> : <>
  176. <p className="retrievalPreviewSummary">{outcome || call.resultSummary}</p>
  177. {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}
  178. </>}
  179. </section>)}</div> : null}
  180. {calls.length > 4 ? <small className="retrievalPreviewMore">完整详情中还有 {calls.length - 4} 次工具调用</small> : null}
  181. </>;
  182. }
  183. function cleanPreview(value: string) {
  184. if (!value || value.startsWith("functioncall:")) return "";
  185. const clean = value
  186. .replace(/```[\s\S]*?```/g, " ")
  187. .replace(/^#{1,6}\s+/gm, "")
  188. .replace(/^[-*+]\s+/gm, "")
  189. .replace(/\|/g, " ")
  190. .replace(/[*_`>#]/g, "")
  191. .replace(/\s+/g, " ")
  192. .trim();
  193. return clean.length > 360 ? `${clean.slice(0, 360).trim()}…` : clean;
  194. }