Inspector.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. "use client";
  2. import { useEffect, useRef, useState } from "react";
  3. import { ArrowLeft, BookOpen, FileText, GitCompareArrows, X } from "lucide-react";
  4. import type { CanvasItem, CardBusinessData, InspectorDetailProjection, InspectorTab, InspectorWorkbenchProjection, RetrievalActivity } from "@/lib/types";
  5. import { StatusBadge } from "@/components/ui/Badges";
  6. import { InspectorComparison } from "@/components/inspector/InspectorComparison";
  7. import { LegacyInspector } from "@/components/inspector/LegacyInspector";
  8. interface Props {
  9. item: CanvasItem;
  10. mode?: "business" | "lineage";
  11. onModeChange?: (mode: "business" | "lineage") => void;
  12. onClose: () => void;
  13. onBack?: () => void;
  14. backLabel?: string;
  15. onNavigate?: (activity: RetrievalActivity) => void;
  16. onPrompt?: (promptRef: string) => void;
  17. onArtifact?: () => void;
  18. workbench?: InspectorWorkbenchProjection;
  19. workbenchLoading?: boolean;
  20. workbenchError?: string;
  21. topOverlayOpen?: boolean;
  22. // Kept only so old isolated callers still compile while the formal path uses workbench.
  23. tab?: InspectorTab;
  24. onTab?: (tab: InspectorTab) => void;
  25. detail?: unknown;
  26. detailLoading?: boolean;
  27. detailError?: string;
  28. cardData?: CardBusinessData;
  29. cardDataLoading?: boolean;
  30. cardDataError?: string;
  31. cardDataMissingRef?: boolean;
  32. }
  33. export function Inspector(props: Props) {
  34. const [internalTab, setInternalTab] = useState<InspectorTab>("business");
  35. useEffect(() => setInternalTab("business"), [props.item.id]);
  36. const tab = props.tab || internalTab;
  37. const onTab = props.onTab || setInternalTab;
  38. const mode = props.mode || (props.tab && props.onTab ? "business" : "lineage");
  39. if (mode === "business") return <LegacyInspector {...props} tab={tab} onTab={onTab} onLineage={props.onModeChange ? () => props.onModeChange?.("lineage") : undefined} />;
  40. return <ComparisonInspector {...props} onBusiness={props.onModeChange ? () => props.onModeChange?.("business") : undefined} />;
  41. }
  42. function ComparisonInspector({ item, onClose, onBack, backLabel, onNavigate, onPrompt, onArtifact, onBusiness, workbench, workbenchLoading, workbenchError, detailLoading, detailError, topOverlayOpen = false }: Props & { onBusiness?: () => void }) {
  43. const dialog = useRef<HTMLElement>(null);
  44. const topOverlayOpenRef = useRef(topOverlayOpen);
  45. topOverlayOpenRef.current = topOverlayOpen;
  46. const [activeBindingId, setActiveBindingId] = useState<string>();
  47. const [clearActiveSignal, setClearActiveSignal] = useState(0);
  48. const promptRef = promptRefFor(item);
  49. useEffect(() => setActiveBindingId(undefined), [item.id]);
  50. useEffect(() => {
  51. const keepFocusInside = (event: FocusEvent) => {
  52. const panel = dialog.current;
  53. if (topOverlayOpenRef.current || !panel || panel.contains(event.target as Node)) return;
  54. panel.querySelector<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])')?.focus();
  55. };
  56. document.addEventListener("focusin", keepFocusInside);
  57. return () => document.removeEventListener("focusin", keepFocusInside);
  58. }, []);
  59. const trapFocus = (event: React.KeyboardEvent<HTMLElement>) => {
  60. if (topOverlayOpen) return;
  61. if (event.key === "Escape") {
  62. if (activeBindingId) { event.preventDefault(); event.stopPropagation(); setActiveBindingId(undefined); setClearActiveSignal((value) => value + 1); return; }
  63. event.preventDefault(); event.stopPropagation(); onClose(); return;
  64. }
  65. if (event.key !== "Tab") return;
  66. const focusable = [...(dialog.current?.querySelectorAll<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])') || [])];
  67. if (!focusable.length) return;
  68. const first = focusable[0]; const last = focusable[focusable.length - 1];
  69. if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
  70. else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
  71. };
  72. const effectiveError = workbenchError || (!workbench && !workbenchLoading && detailError ? detailError : undefined);
  73. const effectiveLoading = workbenchLoading || (!workbench && !workbenchError && detailLoading);
  74. return <><div className="inspectorWorkbenchBackdrop" aria-hidden="true" /><aside ref={dialog} className="inspector inspectorWorkbench" role="dialog" aria-modal="true" aria-label={`${item.title}三列来源详情`} onKeyDown={trapFocus} onWheelCapture={(event) => event.stopPropagation()}>
  75. <header className="inspectorHeader comparisonInspectorHeader">
  76. {onBack ? <button className="iconButton inspectorBack" type="button" onClick={onBack} aria-label={backLabel || "返回上一级详情"}><ArrowLeft size={18} /></button> : null}
  77. <div className="comparisonInspectorTitle"><span>{itemLabel(item)}</span><h2>{item.title}</h2><div className="comparisonInspectorMeta"><StoryStatus item={item} /></div></div>
  78. <div className="comparisonInspectorActions">
  79. {onBusiness ? <button type="button" className="quietButton" onClick={onBusiness}><GitCompareArrows size={14} />返回业务详情</button> : null}
  80. {promptRef && onPrompt ? <button type="button" className="quietButton" data-focus-return={`prompt:${item.id}:inspector`} onClick={() => onPrompt(promptRef)}><BookOpen size={14} />{promptActionLabel(item)}</button> : null}
  81. {item.itemType === "final-result" && onArtifact ? <button type="button" className="quietButton" data-focus-return={`artifact:${item.id}:inspector`} onClick={onArtifact}><FileText size={14} />查看当前主脚本</button> : null}
  82. <button autoFocus className="iconButton" type="button" onClick={onClose} aria-label="关闭详情"><X size={18} /></button>
  83. </div>
  84. </header>
  85. <div className="inspectorBody comparisonInspectorBody">
  86. <InspectorComparison data={workbench} loading={effectiveLoading} error={effectiveError} onNavigate={onNavigate} onActiveBindingChange={setActiveBindingId} clearActiveSignal={clearActiveSignal} />
  87. </div>
  88. </aside></>;
  89. }
  90. function promptRefFor(item: CanvasItem) {
  91. if (item.itemType === "story") return item.decision?.promptRef || undefined;
  92. if (item.itemType === "multipath-review") return item.review.promptRef;
  93. if (item.itemType === "multipath-decision") return item.decision.decisionProjection?.promptRef || undefined;
  94. return undefined;
  95. }
  96. function promptActionLabel(item: CanvasItem) {
  97. if (item.itemType === "story" && item.role === "data-decision") return "查看取数任务与提示词";
  98. const actorRole = item.itemType === "story" ? item.decision?.actor.role : item.itemType === "multipath-review" ? "multipath-evaluator" : item.itemType === "multipath-decision" ? item.decision.decisionProjection?.actor.role : undefined;
  99. if (actorRole === "main") return "查看主 Agent 提示词";
  100. if (actorRole === "multipath-evaluator") return "查看评审任务与提示词";
  101. if (actorRole === "overall-evaluator") return "查看整体评审提示词";
  102. return "查看实现任务与提示词";
  103. }
  104. function StoryStatus({ item }: { item: CanvasItem }) {
  105. const status = item.itemType === "story" ? item.status
  106. : item.itemType === "branch-summary" ? item.branch.status
  107. : item.itemType === "round-summary" ? item.round.state
  108. : item.itemType === "final-result" ? item.result.status
  109. : item.itemType === "retrieval-stage" ? item.stage.status
  110. : item.itemType === "retrieval-agent" ? item.run.status
  111. : item.itemType === "retrieval-direct" ? item.group.failureCount || item.group.interruptedCount ? "partial" : item.group.runningCount ? "running" : "completed"
  112. : item.itemType === "query-attempt" ? item.attempt.status
  113. : item.itemType === "retrieval-activity" ? item.activity.status
  114. : item.itemType === "multipath-review" ? item.review.status
  115. : undefined;
  116. return status ? <StatusBadge status={status} /> : null;
  117. }
  118. function itemLabel(item: CanvasItem) { return item.itemType === "story" ? item.role === "planning-analysis" ? "构建开始前的规划" : item.kind === "decision" ? "这一步的决定" : item.kind === "result" ? "这一步的结果" : "这一步做了什么" : item.itemType === "branch-summary" ? "候选方案" : item.itemType === "round-summary" ? "轮次摘要" : item.itemType === "retrieval-stage" ? "取数阶段" : item.itemType === "retrieval-direct" ? "工具取数" : item.itemType === "retrieval-agent" ? "Agent 取数" : item.itemType === "query-attempt" ? "单次查询" : item.itemType === "retrieval-activity" ? item.activity.kind === "tool" ? "单次工具调用" : "单次查询" : item.itemType === "multipath-review" ? "候选方案评审" : item.itemType === "multipath-decision" ? "主 Agent 决策" : "最终结果"; }