ExecutionWorkbench.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. "use client";
  2. import { useCallback, useEffect, useRef, useState } from "react";
  3. import { AlertTriangle } from "lucide-react";
  4. import { ReactFlowProvider } from "@xyflow/react";
  5. import { errorMessage, getActivityDetail, getDecisionPrompt, getInspectorView, getRoundDetail } from "@/lib/api";
  6. import type { CanvasItem, ExecutionViewV8, InspectorWorkbenchProjection, OpenTarget, PromptProjection, RetrievalActivity, StoryNode } from "@/lib/types";
  7. import { branchFlow, branchSummaryItem, finalResultItem, multipathDecisionItem, multipathReviewItem, queryAttemptItem, retrievalActivityItem, retrievalAgentItem, retrievalDirectItem, retrievalStageItem, roundSummaryItem, storyItem } from "@/lib/layout";
  8. import { useExecutionView } from "@/hooks/useExecutionView";
  9. import { TopBar } from "@/components/workbench/TopBar";
  10. import { FlowCanvas } from "@/components/workbench/FlowCanvas";
  11. import { MobileTimeline } from "@/components/workbench/MobileTimeline";
  12. import { Inspector } from "@/components/inspector/Inspector";
  13. import { ScriptArtifactViewer } from "@/components/artifact/ScriptArtifactViewer";
  14. import { PromptDrawer } from "@/components/decision/PromptDrawer";
  15. import { expandedStateFor, isFullyExpanded } from "@/lib/expansion";
  16. interface ArtifactTarget { snapshotRef: string }
  17. export function ExecutionWorkbench() {
  18. const state = useExecutionView();
  19. const [expandedRounds, setExpandedRounds] = useState<Set<number>>(new Set());
  20. const [expandedBranches, setExpandedBranches] = useState<Set<string>>(new Set());
  21. const [expandedRetrievalAgents, setExpandedRetrievalAgents] = useState<Set<string>>(new Set());
  22. const [selected, setSelected] = useState<CanvasItem | null>(null);
  23. const [inspectorHistory, setInspectorHistory] = useState<CanvasItem[]>([]);
  24. const [artifactTarget, setArtifactTarget] = useState<ArtifactTarget | null>(null);
  25. const [promptRef, setPromptRef] = useState<string>();
  26. const [prompt, setPrompt] = useState<PromptProjection>();
  27. const [promptLoading, setPromptLoading] = useState(false);
  28. const [promptError, setPromptError] = useState<string>();
  29. const [inspectorMode, setInspectorMode] = useState<"business" | "lineage">("business");
  30. const [rawDetail, setRawDetail] = useState<unknown>();
  31. const [rawLoading, setRawLoading] = useState(false);
  32. const [detailError, setDetailError] = useState<string>();
  33. const [inspectorView, setInspectorView] = useState<InspectorWorkbenchProjection>();
  34. const [inspectorLoading, setInspectorLoading] = useState(false);
  35. const [inspectorError, setInspectorError] = useState<string>();
  36. const [mobileCanvas, setMobileCanvas] = useState(false);
  37. const [focusRound, setFocusRound] = useState<{ roundIndex: number; token: number }>();
  38. const selectedDetailRef = selected ? inspectorDetailRefFor(selected) : undefined;
  39. const inspectorTrigger = useRef<HTMLElement | null>(null);
  40. const inspectorTriggerKey = useRef<string | null>(null);
  41. const pendingFocusRestore = useRef<{ key: string | null; element: HTMLElement | null } | null>(null);
  42. const promptTrigger = useRef<HTMLElement | null>(null);
  43. const promptTriggerKey = useRef<string | null>(null);
  44. const pendingPromptFocusRestore = useRef<{ key: string | null; element: HTMLElement | null } | null>(null);
  45. const expansionPreference = useRef<"expanded" | "custom">("expanded");
  46. const detailCache = useRef(new Map<string, unknown>());
  47. const inspectorCache = useRef(new Map<string, InspectorWorkbenchProjection>());
  48. useEffect(() => {
  49. if (!state.view) return;
  50. expansionPreference.current = "expanded";
  51. const expanded = expandedStateFor(state.view);
  52. setExpandedRounds(expanded.rounds);
  53. setExpandedBranches(expanded.branches);
  54. setExpandedRetrievalAgents(expanded.retrievalAgents);
  55. setSelected(null);
  56. setInspectorHistory([]);
  57. setInspectorMode("business");
  58. detailCache.current.clear();
  59. inspectorCache.current.clear();
  60. setRawDetail(undefined);
  61. setDetailError(undefined);
  62. setInspectorView(undefined);
  63. setInspectorError(undefined);
  64. setArtifactTarget(null);
  65. setPromptRef(undefined);
  66. }, [state.view?.header.id]);
  67. useEffect(() => {
  68. if (!state.view || expansionPreference.current !== "expanded") return;
  69. const expanded = expandedStateFor(state.view);
  70. setExpandedRounds(expanded.rounds);
  71. setExpandedBranches(expanded.branches);
  72. setExpandedRetrievalAgents(expanded.retrievalAgents);
  73. }, [state.view?.capturedAt]);
  74. useEffect(() => {
  75. if (inspectorMode !== "business" || !selected || !state.view) return;
  76. const detailRef = selectedDetailRef;
  77. if (!detailRef) {
  78. setRawDetail(selected);
  79. setRawLoading(false);
  80. setDetailError(undefined);
  81. return;
  82. }
  83. const cacheKey = `${state.view.header.id}:${detailRef}:${state.view.capturedAt}`;
  84. const cached = detailCache.current.get(cacheKey);
  85. if (cached !== undefined && !isTransientDetail(cached)) {
  86. setRawDetail(cached);
  87. setRawLoading(false);
  88. setDetailError(undefined);
  89. return;
  90. }
  91. const controller = new AbortController();
  92. setRawDetail(undefined);
  93. setRawLoading(true);
  94. setDetailError(undefined);
  95. const request = selected.itemType === "round-summary"
  96. ? getRoundDetail(String(state.view.header.id), selected.roundIndex, controller.signal)
  97. : selected.itemType === "story" && selected.roundIndex && detailRef === `round:${selected.roundIndex}`
  98. ? getRoundDetail(String(state.view.header.id), selected.roundIndex, controller.signal)
  99. : getActivityDetail(String(state.view.header.id), detailRef, controller.signal);
  100. void request
  101. .then((detail) => {
  102. if (!isTransientDetail(detail)) detailCache.current.set(cacheKey, detail);
  103. setRawDetail(detail);
  104. })
  105. .catch((error) => {
  106. if (!controller.signal.aborted) {
  107. setRawDetail(undefined);
  108. setDetailError(errorMessage(error));
  109. }
  110. })
  111. .finally(() => {
  112. if (!controller.signal.aborted) setRawLoading(false);
  113. });
  114. return () => controller.abort();
  115. }, [inspectorMode, selected?.id, selectedDetailRef, state.view?.header.id, state.view?.capturedAt]);
  116. useEffect(() => {
  117. if (!selected || !state.view) return;
  118. const updated = findItem(state.view, selected.id);
  119. if (updated) setSelected(updated);
  120. }, [state.view?.capturedAt]);
  121. useEffect(() => {
  122. if (inspectorMode !== "lineage") return;
  123. if (!selected || !state.view) return;
  124. const detailRef = selectedDetailRef;
  125. if (!detailRef) {
  126. setInspectorView(undefined);
  127. setInspectorLoading(false);
  128. setInspectorError("当前卡片没有可用的详情引用。");
  129. return;
  130. }
  131. const cacheKey = `${state.view.header.id}:${detailRef}:${state.view.capturedAt}`;
  132. const cached = inspectorCache.current.get(cacheKey);
  133. if (cached) {
  134. setInspectorView(cached);
  135. setInspectorLoading(false);
  136. setInspectorError(undefined);
  137. return;
  138. }
  139. const controller = new AbortController();
  140. setInspectorView(undefined);
  141. setInspectorLoading(true);
  142. setInspectorError(undefined);
  143. void getInspectorView(String(state.view.header.id), detailRef, controller.signal)
  144. .then((payload) => {
  145. const prefix = `${state.view!.header.id}:${detailRef}:`;
  146. for (const key of inspectorCache.current.keys()) {
  147. if (key.startsWith(prefix)) inspectorCache.current.delete(key);
  148. }
  149. inspectorCache.current.set(cacheKey, payload);
  150. while (inspectorCache.current.size > 32) {
  151. const oldest = inspectorCache.current.keys().next().value;
  152. if (!oldest) break;
  153. inspectorCache.current.delete(oldest);
  154. }
  155. setInspectorView(payload);
  156. })
  157. .catch((error) => {
  158. if (!controller.signal.aborted) {
  159. setInspectorView(undefined);
  160. setInspectorError(errorMessage(error));
  161. }
  162. })
  163. .finally(() => {
  164. if (!controller.signal.aborted) setInspectorLoading(false);
  165. });
  166. return () => controller.abort();
  167. }, [inspectorMode, selected?.id, selectedDetailRef, state.view?.header.id, state.view?.capturedAt]);
  168. useEffect(() => {
  169. if (!promptRef || !state.view) return;
  170. const controller = new AbortController();
  171. setPrompt(undefined); setPromptError(undefined); setPromptLoading(true);
  172. void getDecisionPrompt(String(state.view.header.id), promptRef, controller.signal)
  173. .then(setPrompt)
  174. .catch((error) => {
  175. if (!controller.signal.aborted) setPromptError(errorMessage(error));
  176. })
  177. .finally(() => {
  178. if (!controller.signal.aborted) setPromptLoading(false);
  179. });
  180. return () => controller.abort();
  181. }, [promptRef, state.view?.header.id]);
  182. const closeInspector = useCallback(() => {
  183. pendingFocusRestore.current = { key: inspectorTriggerKey.current, element: inspectorTrigger.current };
  184. setSelected(null);
  185. setInspectorHistory([]);
  186. setInspectorMode("business");
  187. }, []);
  188. const closeArtifact = useCallback(() => {
  189. pendingFocusRestore.current = { key: inspectorTriggerKey.current, element: inspectorTrigger.current };
  190. setArtifactTarget(null);
  191. }, []);
  192. const openPrompt = useCallback((nextPromptRef: string) => {
  193. promptTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
  194. promptTriggerKey.current = promptTrigger.current?.dataset.focusReturn || null;
  195. setPromptRef(nextPromptRef);
  196. }, []);
  197. const openArtifactFromInspector = useCallback(() => {
  198. if (!selected) return;
  199. const snapshotRef = artifactRefFor(selected);
  200. if (!snapshotRef) return;
  201. inspectorTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
  202. inspectorTriggerKey.current = inspectorTrigger.current?.dataset.focusReturn || null;
  203. setArtifactTarget({ snapshotRef });
  204. }, [selected]);
  205. const closePrompt = useCallback(() => {
  206. pendingPromptFocusRestore.current = {
  207. key: promptTriggerKey.current,
  208. element: promptTrigger.current,
  209. };
  210. setPromptRef(undefined);
  211. }, []);
  212. useEffect(() => {
  213. const pending = pendingFocusRestore.current;
  214. if (artifactTarget || !pending) return;
  215. pendingFocusRestore.current = null;
  216. let secondFrame = 0;
  217. const firstFrame = requestAnimationFrame(() => {
  218. secondFrame = requestAnimationFrame(() => {
  219. const replacement = pending.key
  220. ? [...document.querySelectorAll<HTMLElement>("[data-focus-return]")]
  221. .find((element) => element.dataset.focusReturn === pending.key)
  222. : null;
  223. const target = replacement || (pending.element?.isConnected ? pending.element : null);
  224. target?.focus({ preventScroll: true });
  225. });
  226. });
  227. return () => {
  228. cancelAnimationFrame(firstFrame);
  229. if (secondFrame) cancelAnimationFrame(secondFrame);
  230. };
  231. }, [selected, artifactTarget]);
  232. useEffect(() => {
  233. const pending = pendingPromptFocusRestore.current;
  234. if (promptRef || !pending) return;
  235. pendingPromptFocusRestore.current = null;
  236. let secondFrame = 0;
  237. const firstFrame = requestAnimationFrame(() => {
  238. secondFrame = requestAnimationFrame(() => {
  239. const replacement = pending.key
  240. ? [...document.querySelectorAll<HTMLElement>("[data-focus-return]")]
  241. .find((element) => element.dataset.focusReturn === pending.key)
  242. : null;
  243. const target = replacement || (pending.element?.isConnected ? pending.element : null);
  244. target?.focus({ preventScroll: true });
  245. });
  246. });
  247. return () => {
  248. cancelAnimationFrame(firstFrame);
  249. if (secondFrame) cancelAnimationFrame(secondFrame);
  250. };
  251. }, [promptRef]);
  252. useEffect(() => { const close = (event: KeyboardEvent) => { if (event.key === "Escape" && !artifactTarget && !promptRef) { closeInspector(); setMobileCanvas(false); } }; window.addEventListener("keydown", close); return () => window.removeEventListener("keydown", close); }, [artifactTarget, closeInspector, promptRef]);
  253. const open = useCallback((item: CanvasItem, target: OpenTarget) => {
  254. inspectorTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
  255. inspectorTriggerKey.current = inspectorTrigger.current?.dataset.focusReturn || null;
  256. if (target === "artifact") {
  257. const snapshotRef = artifactRefFor(item);
  258. if (!snapshotRef) return;
  259. setSelected(null);
  260. setInspectorHistory([]);
  261. setArtifactTarget({ snapshotRef });
  262. return;
  263. }
  264. setInspectorHistory([]);
  265. setInspectorMode(target === "lineage" ? "lineage" : "business");
  266. setSelected(item);
  267. setRawDetail(undefined);
  268. setDetailError(undefined);
  269. setInspectorView(undefined);
  270. setInspectorError(undefined);
  271. }, []);
  272. const navigateInspector = useCallback((activity: RetrievalActivity) => {
  273. if (!selected || !activity.detailRef) return;
  274. const roundIndex = "roundIndex" in selected ? selected.roundIndex : 0;
  275. const branchId = "branchId" in selected ? selected.branchId : 0;
  276. setInspectorHistory((current) => [...current, selected]);
  277. setSelected(retrievalActivityItem(activity, roundIndex || 0, branchId || 0));
  278. setInspectorView(undefined);
  279. setInspectorError(undefined);
  280. }, [selected]);
  281. const navigateInspectorBack = useCallback(() => {
  282. setInspectorHistory((current) => {
  283. const parent = current[current.length - 1];
  284. if (!parent) return current;
  285. const refreshed = state.view ? findItem(state.view, parent.id) : undefined;
  286. setSelected(refreshed || parent);
  287. setInspectorView(undefined);
  288. setInspectorError(undefined);
  289. return current.slice(0, -1);
  290. });
  291. }, [state.view]);
  292. const toggleRound = useCallback((roundIndex: number) => {
  293. expansionPreference.current = "custom";
  294. setExpandedRounds((current) => { const next = new Set(current); next.has(roundIndex) ? next.delete(roundIndex) : next.add(roundIndex); return next; });
  295. }, []);
  296. const toggleBranch = useCallback((roundIndex: number, branchId: number) => setExpandedBranches((current) => {
  297. expansionPreference.current = "custom";
  298. const next = new Set(current);
  299. const key = `${roundIndex}:${branchId}`;
  300. next.has(key) ? next.delete(key) : next.add(key);
  301. return next;
  302. }), []);
  303. const toggleRetrievalAgent = useCallback((agentId: string) => setExpandedRetrievalAgents((current) => {
  304. expansionPreference.current = "custom";
  305. const next = new Set(current);
  306. next.has(agentId) ? next.delete(agentId) : next.add(agentId);
  307. return next;
  308. }), []);
  309. const navigateRound = useCallback((roundIndex: number) => {
  310. expansionPreference.current = "custom";
  311. setExpandedRounds((current) => new Set(current).add(roundIndex));
  312. setFocusRound((current) => ({ roundIndex, token: (current?.token || 0) + 1 }));
  313. }, []);
  314. const allExpanded = state.view
  315. ? isFullyExpanded(state.view, expandedRounds, expandedBranches, expandedRetrievalAgents)
  316. : false;
  317. const toggleAll = useCallback(() => {
  318. if (!state.view) return;
  319. if (isFullyExpanded(state.view, expandedRounds, expandedBranches, expandedRetrievalAgents)) {
  320. expansionPreference.current = "custom";
  321. setExpandedRounds(new Set());
  322. setExpandedBranches(new Set());
  323. setExpandedRetrievalAgents(new Set());
  324. return;
  325. }
  326. expansionPreference.current = "expanded";
  327. const expanded = expandedStateFor(state.view);
  328. setExpandedRounds(expanded.rounds);
  329. setExpandedBranches(expanded.branches);
  330. setExpandedRetrievalAgents(expanded.retrievalAgents);
  331. }, [state.view, expandedRounds, expandedBranches, expandedRetrievalAgents]);
  332. if (state.loading && !state.view) return <LoadingScreen />;
  333. if (!state.view) return <FatalState message={state.error || "无法读取真实构建数据"} />;
  334. const view = state.view;
  335. return <main className={`appFrame ${selected ? "withInspector" : ""} ${selected && inspectorMode === "lineage" ? "withLineageInspector" : ""}`}>
  336. <TopBar builds={state.builds} selectedId={state.selectedId} view={view} refreshing={state.refreshing} onSelect={state.selectBuild} expandedRounds={expandedRounds} allExpanded={allExpanded} onRound={navigateRound} onToggleAll={toggleAll} />
  337. {state.error ? <div className="errorBanner"><AlertTriangle size={15} /><span>{state.error}</span></div> : null}
  338. <div className="workspace">
  339. <MobileTimeline view={view} expandedRounds={expandedRounds} expandedBranches={expandedBranches} expandedRetrievalAgents={expandedRetrievalAgents} onToggleRound={toggleRound} onToggleBranch={toggleBranch} onToggleRetrievalAgent={toggleRetrievalAgent} onOpen={open} onCanvas={() => setMobileCanvas(true)} />
  340. <ReactFlowProvider><FlowCanvas view={view} expandedRounds={expandedRounds} expandedBranches={expandedBranches} expandedRetrievalAgents={expandedRetrievalAgents} onToggleRound={toggleRound} onToggleBranch={toggleBranch} onToggleRetrievalAgent={toggleRetrievalAgent} onOpen={open} onPrompt={openPrompt} selectedId={selected?.id} focusRound={focusRound} canvasFullscreen={mobileCanvas} onExitFullscreen={() => setMobileCanvas(false)} /></ReactFlowProvider>
  341. {selected ? <Inspector item={selected} mode={inspectorMode} onModeChange={setInspectorMode} onClose={closeInspector} onBack={inspectorHistory.length ? navigateInspectorBack : undefined} backLabel={inspectorHistory.length ? `返回${inspectorHistory[inspectorHistory.length - 1].title}` : undefined} onNavigate={navigateInspector} onPrompt={openPrompt} onArtifact={selected.itemType === "final-result" ? openArtifactFromInspector : undefined} detail={rawDetail} detailLoading={rawLoading} detailError={detailError} workbench={inspectorView} workbenchLoading={inspectorLoading} workbenchError={inspectorError} topOverlayOpen={Boolean(artifactTarget || promptRef)} /> : null}
  342. </div>
  343. {artifactTarget ? <ScriptArtifactViewer buildId={String(view.header.id)} snapshotRef={artifactTarget.snapshotRef} onClose={closeArtifact} /> : null}
  344. {promptRef ? <PromptDrawer prompt={prompt} loading={promptLoading} error={promptError} onClose={closePrompt} /> : null}
  345. </main>;
  346. }
  347. function artifactRefFor(item: CanvasItem): string | undefined {
  348. if (item.itemType === "final-result") return item.result.artifact.snapshotRef || item.detailRef || "artifact:base:current";
  349. if (item.itemType === "round-summary") return item.round.result.artifactRef || `artifact:round:${item.roundIndex}`;
  350. if (item.itemType === "branch-summary" && item.branch.output.type === "script-artifact") return item.branch.output.snapshotRef;
  351. if (item.itemType === "story") return item.artifactRef;
  352. return undefined;
  353. }
  354. function inspectorDetailRefFor(item: CanvasItem): string | undefined {
  355. if (item.itemType === "final-result") return "run:final-result";
  356. return "detailRef" in item ? item.detailRef : undefined;
  357. }
  358. function findItem(view: ExecutionViewV8, id: string): CanvasItem | undefined {
  359. if (id === view.planning?.id) return storyItem(view.planning);
  360. if (id === view.objective.id) return storyItem(view.objective);
  361. if (id === view.finalResult.id) return finalResultItem(view);
  362. for (const round of view.rounds) {
  363. if (id === `round:${round.roundIndex}:summary`) return roundSummaryItem(round);
  364. const main: StoryNode[] = [round.goal, round.plan.node, ...round.convergenceBatches.flatMap((batch) => [batch.missingReview, batch.missingDecision].filter((node): node is StoryNode => Boolean(node))), round.overallEvaluation, round.result];
  365. const mainStory = main.find((story) => story.id === id);
  366. if (mainStory) return storyItem(mainStory, round.roundIndex);
  367. for (const batch of round.convergenceBatches) {
  368. if (batch.review?.id === id) return multipathReviewItem(batch.review, round.roundIndex);
  369. if (batch.decision?.id === id) return multipathDecisionItem(batch.decision, round.roundIndex);
  370. }
  371. for (const branch of round.branches) {
  372. if (id === `round:${round.roundIndex}:branch:${branch.branchId}:summary`) return branchSummaryItem(branch, round.roundIndex);
  373. const branchStory = branchFlow(branch).find((story) => story.id === id);
  374. if (branchStory) return storyItem(branchStory, round.roundIndex, branch.branchId);
  375. if (branch.retrievalStage.id === id) return retrievalStageItem(branch.retrievalStage, round.roundIndex, branch.branchId);
  376. const direct = branch.retrievalStage.directToolGroups.find((group) => group.id === id);
  377. if (direct) return retrievalDirectItem(direct, round.roundIndex, branch.branchId);
  378. for (const group of branch.retrievalStage.directToolGroups) {
  379. const call = group.calls.find((item) => item.id === id || item.detailRef === id);
  380. if (call) return retrievalActivityItem({ id: call.id, kind: "tool", label: call.businessLabel, status: call.status, summary: call.resultSummary, detailRef: call.detailRef }, round.roundIndex, branch.branchId);
  381. }
  382. const agent = branch.retrievalStage.agentRuns.find((run) => run.id === id);
  383. if (agent) return retrievalAgentItem(agent, round.roundIndex, branch.branchId);
  384. for (const run of branch.retrievalStage.agentRuns) {
  385. const attempt = run.attempts.find((item) => item.id === id);
  386. if (attempt) return queryAttemptItem(attempt, round.roundIndex, branch.branchId);
  387. }
  388. }
  389. }
  390. return undefined;
  391. }
  392. function isTransientDetail(detail: unknown) {
  393. if (!detail || typeof detail !== "object" || Array.isArray(detail)) return false;
  394. const record = detail as { outcome?: { state?: string }; activities?: Array<{ status?: string }> };
  395. return record.outcome?.state === "running" || record.activities?.some((item) => item.status === "running") === true;
  396. }
  397. function LoadingScreen() { return <main className="loadingScreen"><h1>正在读取真实构建记录…</h1><div className="skeletonWide"><i /><i /><i /></div></main>; }
  398. function FatalState({ message }: { message: string }) { return <main className="fatalState"><AlertTriangle size={26} /><h1>真实运行数据暂时不可用</h1><p>{message}</p><p>请确认只读数据库连接和可视化后端已经启动。</p></main>; }