返回 JoyAI-Echo
ResponseView.tsx
1 import { useState, useCallback, type PointerEvent as ReactPointerEvent } from "react";
2 import type { TraceRecord } from "@/hooks/usePromptStack";
3
4 interface Props {
5 record: TraceRecord;
6 }
7
8 const MSG_SPLIT_KEY = "promptstack.msg-split";
9 const MIN_TOP = 80;
10 const MIN_BOTTOM = 80;
11
12 function readMsgSplit(): number {
13 try {
14 const raw = window.localStorage.getItem(MSG_SPLIT_KEY);
15 if (raw) {
16 const val = Number(raw);
17 if (Number.isFinite(val) && val > 0.1 && val < 0.9) return val;
18 }
19 } catch { /* ignore */ }
20 return 0.4;
21 }
22
23 export function ResponseView({ record }: Props) {
24 const [expandedMsgs, setExpandedMsgs] = useState<Set<number>>(new Set());
25 const [msgSplit, setMsgSplit] = useState(readMsgSplit);
26 const { response } = record;
27
28 const toggleMsg = (idx: number) => {
29 setExpandedMsgs((prev) => {
30 const next = new Set(prev);
31 if (next.has(idx)) next.delete(idx);
32 else next.add(idx);
33 return next;
34 });
35 };
36
37 const startMsgResize = useCallback(
38 (event: ReactPointerEvent<HTMLDivElement>) => {
39 event.preventDefault();
40 const container = event.currentTarget.parentElement as HTMLElement;
41 const rect = container.getBoundingClientRect();
42 const h = rect.height;
43
44 const onMove = (e: PointerEvent) => {
45 const ratio = Math.max(
46 MIN_TOP / h,
47 Math.min(1 - MIN_BOTTOM / h, (e.clientY - rect.top) / h),
48 );
49 setMsgSplit(ratio);
50 };
51 const onUp = () => {
52 window.removeEventListener("pointermove", onMove);
53 window.removeEventListener("pointerup", onUp);
54 try { window.localStorage.setItem(MSG_SPLIT_KEY, String(msgSplit)); } catch { /* */ }
55 };
56 window.addEventListener("pointermove", onMove);
57 window.addEventListener("pointerup", onUp);
58 },
59 [msgSplit],
60 );
61
62 const roleColor = (role: string) => {
63 switch (role) {
64 case "assistant": return "text-green-600 dark:text-green-400";
65 case "user": return "text-blue-600 dark:text-blue-400";
66 case "system": return "text-purple-600 dark:text-purple-400";
67 default: return "text-orange-600 dark:text-orange-400";
68 }
69 };
70
71 return (
72 <div className="flex flex-col h-full overflow-hidden">
73 {/* Upper section: response summary — scrolls independently */}
74 <div className="overflow-y-auto shrink-0" style={{ height: `${msgSplit * 100}%` }}>
75 <div className="flex flex-col gap-3 p-1">
76 {/* Usage */}
77 {response.usage && (
78 <div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
79 {Object.entries(response.usage).map(([key, val]) => (
80 <span key={key} className="font-mono">
81 {key}: {val}
82 </span>
83 ))}
84 </div>
85 )}
86
87 {/* Tool calls */}
88 {response.tool_calls && response.tool_calls.length > 0 && (
89 <div className="flex flex-col gap-1">
90 <span className="text-xs font-medium text-muted-foreground">Tool Calls:</span>
91 <div className="flex flex-wrap gap-1">
92 {response.tool_calls.map((tc, i) => (
93 <span
94 key={i}
95 className="inline-flex items-center rounded-md bg-orange-500/10 px-2 py-0.5 text-xs font-medium text-orange-700 dark:text-orange-300"
96 >
97 {tc.name}
98 </span>
99 ))}
100 </div>
101 </div>
102 )}
103
104 {/* Reasoning */}
105 {response.reasoning_content && (
106 <div className="flex flex-col gap-1">
107 <span className="text-xs font-medium text-muted-foreground">Reasoning:</span>
108 <pre className="text-xs whitespace-pre-wrap break-words font-mono bg-muted/30 rounded-md p-2 max-h-40 overflow-auto">
109 {response.reasoning_content}
110 </pre>
111 </div>
112 )}
113
114 {/* Response content */}
115 {response.content && (
116 <div className="flex flex-col gap-1">
117 <span className="text-xs font-medium text-muted-foreground">Response:</span>
118 <pre className="text-xs whitespace-pre-wrap break-words font-mono bg-muted/30 rounded-md p-3 max-h-60 overflow-auto">
119 {response.content}
120 </pre>
121 </div>
122 )}
123 </div>
124 </div>
125
126 {/* Drag handle */}
127 <div
128 className="h-2 shrink-0 cursor-row-resize flex items-center justify-center hover:bg-muted/50 transition-colors group"
129 onPointerDown={startMsgResize}
130 >
131 <div className="w-12 h-0.5 rounded-full bg-border group-hover:bg-foreground/30 transition-colors" />
132 </div>
133
134 {/* Lower section: messages list — independently scrollable */}
135 <div className="flex-1 overflow-hidden flex flex-col border-t min-h-0">
136 <div className="px-2 py-1 text-xs font-medium text-muted-foreground bg-muted/30 border-b shrink-0">
137 Messages ({record.messages_count} total)
138 </div>
139 <div className="flex-1 overflow-y-auto">
140 <div className="flex flex-col gap-0.5 p-1">
141 {record.messages.map((msg, i) => (
142 <div key={i} className="border rounded-md overflow-hidden">
143 <button
144 onClick={() => toggleMsg(i)}
145 className="flex w-full items-center gap-2 px-2 py-1 text-xs hover:bg-muted/50 transition-colors text-left"
146 >
147 <span className={`font-medium shrink-0 w-14 ${roleColor(msg.role)}`}>
148 {msg.role}
149 </span>
150 {msg.tool_name && (
151 <span className="text-orange-500 font-mono shrink-0">[{msg.tool_name}]</span>
152 )}
153 {msg.tool_calls && msg.tool_calls.length > 0 && (
154 <span className="text-orange-500 font-mono shrink-0">
155 [{msg.tool_calls.map(tc => tc.name).join(", ")}]
156 </span>
157 )}
158 <span className="text-muted-foreground truncate flex-1">{msg.preview}</span>
159 <span className="text-muted-foreground font-mono shrink-0 text-[10px]">
160 {msg.char_count >= 1000
161 ? `${(msg.char_count / 1000).toFixed(1)}k`
162 : msg.char_count}
163 </span>
164 <span className="text-muted-foreground text-[10px]">
165 {expandedMsgs.has(i) ? "▼" : "▶"}
166 </span>
167 </button>
168 {expandedMsgs.has(i) && (
169 <div className="border-t bg-muted/20 p-2 max-h-80 overflow-auto">
170 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/80">
171 {msg.content}
172 </pre>
173 {msg.tool_calls && msg.tool_calls.length > 0 && (
174 <div className="mt-2 border-t pt-2">
175 <span className="text-[10px] text-muted-foreground font-medium">Tool Call Arguments:</span>
176 {msg.tool_calls.map((tc, j) => (
177 <pre key={j} className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/60 mt-1">
178 {tc.name}: {tc.arguments}
179 </pre>
180 ))}
181 </div>
182 )}
183 </div>
184 )}
185 </div>
186 ))}
187 </div>
188 </div>
189 </div>
190 </div>
191 );
192 }
193
193 lines Plain Text