返回 JoyAI-Echo
PromptStackerPage.tsx
根目录 / echo_longvideo / Director_Agent / webui / src / pages / PromptStackerPage.tsx
1 import { useState, useMemo, useCallback, type PointerEvent as ReactPointerEvent } from "react";
2 import { usePromptStack } from "@/hooks/usePromptStack";
3 import { SessionList } from "@/components/promptstack/SessionList";
4 import { TraceTimeline } from "@/components/promptstack/TraceTimeline";
5 import { PartStack } from "@/components/promptstack/PartStack";
6 import { ResponseView } from "@/components/promptstack/ResponseView";
7 import { FullContextLog } from "@/components/promptstack/FullContextLog";
8
9 const SPLIT_STORAGE_KEY = "promptstack.split-height";
10 const MIN_TOP_HEIGHT = 200;
11 const MIN_BOTTOM_HEIGHT = 120;
12
13 function readSplitRatio(): number {
14 try {
15 const raw = window.localStorage.getItem(SPLIT_STORAGE_KEY);
16 if (raw) {
17 const val = Number(raw);
18 if (Number.isFinite(val) && val > 0.2 && val < 0.9) return val;
19 }
20 } catch { /* ignore */ }
21 return 0.55;
22 }
23
24 export function PromptStackerPage() {
25 const { sessions, activeSession, setActiveSession, trace, loading, refresh } =
26 usePromptStack();
27 const [activeTurn, setActiveTurn] = useState<string | null>(null);
28 const [splitRatio, setSplitRatio] = useState(readSplitRatio);
29
30 const activeRecord = useMemo(() => {
31 if (!activeTurn) return trace.length > 0 ? trace[trace.length - 1] : null;
32 return trace.find((r) => r.id === activeTurn) ?? null;
33 }, [trace, activeTurn]);
34
35 const effectiveTurn = activeTurn ?? (trace.length > 0 ? trace[trace.length - 1].id : null);
36
37 const startResize = useCallback(
38 (event: ReactPointerEvent<HTMLDivElement>) => {
39 event.preventDefault();
40 const container = (event.currentTarget.parentElement as HTMLElement);
41 const containerRect = container.getBoundingClientRect();
42 const containerHeight = containerRect.height;
43
44 const onMove = (e: PointerEvent) => {
45 const relativeY = e.clientY - containerRect.top;
46 const ratio = Math.max(
47 MIN_TOP_HEIGHT / containerHeight,
48 Math.min(1 - MIN_BOTTOM_HEIGHT / containerHeight, relativeY / containerHeight)
49 );
50 setSplitRatio(ratio);
51 };
52 const onUp = () => {
53 window.removeEventListener("pointermove", onMove);
54 window.removeEventListener("pointerup", onUp);
55 try {
56 window.localStorage.setItem(SPLIT_STORAGE_KEY, String(splitRatio));
57 } catch { /* ignore */ }
58 };
59 window.addEventListener("pointermove", onMove);
60 window.addEventListener("pointerup", onUp);
61 },
62 [splitRatio],
63 );
64
65 return (
66 <div className="flex h-screen w-screen bg-background text-foreground">
67 {/* Sidebar: sessions */}
68 <aside className="w-64 shrink-0 border-r overflow-y-auto flex flex-col">
69 <div className="flex items-center justify-between px-3 py-3 border-b">
70 <h1 className="text-sm font-semibold">Prompt Stacker</h1>
71 <div className="flex gap-1">
72 <button
73 onClick={refresh}
74 className="rounded-md px-2 py-1 text-xs hover:bg-muted transition-colors"
75 title="Refresh"
76 >
77 Refresh
78 </button>
79 <a
80 href="/"
81 className="rounded-md px-2 py-1 text-xs hover:bg-muted transition-colors"
82 >
83 Chat
84 </a>
85 </div>
86 </div>
87 <SessionList
88 sessions={sessions}
89 activeSession={activeSession}
90 onSelect={(id) => {
91 setActiveSession(id);
92 setActiveTurn(null);
93 }}
94 />
95 </aside>
96
97 {/* Main content */}
98 <main className="flex flex-1 flex-col min-w-0 overflow-hidden">
99 {loading ? (
100 <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
101 Loading trace...
102 </div>
103 ) : trace.length === 0 ? (
104 <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
105 {activeSession
106 ? "No turns recorded in this session"
107 : "Select a session from the sidebar"}
108 </div>
109 ) : (
110 <div className="flex flex-1 flex-col overflow-hidden relative">
111 {/* Timeline */}
112 <TraceTimeline
113 trace={trace}
114 activeTurn={effectiveTurn}
115 onSelect={setActiveTurn}
116 />
117
118 {/* Resizable split area */}
119 <div className="flex-1 flex flex-col overflow-hidden relative">
120 {/* Upper: Part Stack + Response */}
121 <div
122 className="overflow-hidden flex"
123 style={{ height: `${splitRatio * 100}%` }}
124 >
125 {activeRecord && (
126 <>
127 {/* Left: Part Stack */}
128 <div className="flex-1 overflow-y-auto p-4 border-r">
129 <div className="flex items-center gap-2 mb-3">
130 <h2 className="text-sm font-semibold">Prompt Parts</h2>
131 <span className="text-xs text-muted-foreground font-mono">
132 {activeRecord.model} &middot; iter #{activeRecord.iteration}
133 </span>
134 </div>
135 <PartStack parts={activeRecord.parts} />
136 </div>
137
138 {/* Right: Response */}
139 <div className="w-[45%] shrink-0 overflow-hidden flex flex-col">
140 <h2 className="text-sm font-semibold px-4 pt-4 pb-2 shrink-0">Model Response</h2>
141 <div className="flex-1 min-h-0 px-4 pb-2">
142 <ResponseView record={activeRecord} />
143 </div>
144 </div>
145 </>
146 )}
147 </div>
148
149 {/* Drag handle */}
150 <div
151 className="h-2 shrink-0 cursor-row-resize flex items-center justify-center hover:bg-muted/50 transition-colors group"
152 onPointerDown={startResize}
153 >
154 <div className="w-16 h-0.5 rounded-full bg-border group-hover:bg-foreground/30 transition-colors" />
155 </div>
156
157 {/* Lower: Full scrolling context log */}
158 <div
159 className="overflow-hidden border-t"
160 style={{ height: `${(1 - splitRatio) * 100}%` }}
161 >
162 <FullContextLog trace={trace} activeTurn={effectiveTurn} />
163 </div>
164 </div>
165 </div>
166 )}
167 </main>
168 </div>
169 );
170 }
171
171 lines Plain Text