返回 JoyAI-Echo
FullContextLog.tsx
1 import { useState, useRef, useEffect } from "react";
2 import type { TraceRecord } from "@/hooks/usePromptStack";
3
4 interface Props {
5 trace: TraceRecord[];
6 activeTurn: string | null;
7 }
8
9 export function FullContextLog({ trace, activeTurn }: Props) {
10 const [expandedTurns, setExpandedTurns] = useState<Set<string>>(new Set());
11 const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
12 const activeRef = useRef<HTMLDivElement>(null);
13
14 // Auto-expand active turn
15 useEffect(() => {
16 if (activeTurn && !expandedTurns.has(activeTurn)) {
17 setExpandedTurns((prev) => new Set([...prev, activeTurn]));
18 }
19 }, [activeTurn]);
20
21 // Auto-scroll to active turn
22 useEffect(() => {
23 if (activeRef.current) {
24 activeRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
25 }
26 }, [activeTurn]);
27
28 const toggleTurn = (turnId: string) => {
29 setExpandedTurns((prev) => {
30 const next = new Set(prev);
31 if (next.has(turnId)) next.delete(turnId);
32 else next.add(turnId);
33 return next;
34 });
35 };
36
37 const toggleItem = (key: string) => {
38 setExpandedItems((prev) => {
39 const next = new Set(prev);
40 if (next.has(key)) next.delete(key);
41 else next.add(key);
42 return next;
43 });
44 };
45
46 const roleColor = (role: string) => {
47 switch (role) {
48 case "system": return "text-purple-600 dark:text-purple-400 border-l-purple-400";
49 case "user": return "text-blue-600 dark:text-blue-400 border-l-blue-400";
50 case "assistant": return "text-green-600 dark:text-green-400 border-l-green-400";
51 case "assistant_response": return "text-emerald-600 dark:text-emerald-400 border-l-emerald-400";
52 case "tool": return "text-orange-600 dark:text-orange-400 border-l-orange-400";
53 default: return "text-gray-600 dark:text-gray-400 border-l-gray-400";
54 }
55 };
56
57 if (trace.length === 0) {
58 return (
59 <div className="flex h-full items-center justify-center text-xs text-muted-foreground">
60 Full context will appear here
61 </div>
62 );
63 }
64
65 return (
66 <div className="h-full overflow-y-auto">
67 <div className="p-2 border-b bg-muted/30 sticky top-0 z-10">
68 <span className="text-xs font-medium text-muted-foreground">
69 Full Context Log &middot; {trace.length} turns
70 </span>
71 </div>
72 <div className="flex flex-col">
73 {trace.map((record) => {
74 const isActive = record.id === activeTurn;
75 const isTurnExpanded = expandedTurns.has(record.id);
76 const totalChars = record.messages.reduce((s, m) => s + m.char_count, 0);
77 const respChars = record.response.content?.length ?? 0;
78
79 return (
80 <div key={record.id} ref={isActive ? activeRef : undefined}>
81 {/* Turn header - always visible */}
82 <button
83 onClick={() => toggleTurn(record.id)}
84 className={`flex w-full items-center gap-2 px-3 py-1.5 text-xs font-medium border-b hover:bg-muted/50 transition-colors ${
85 isActive ? "bg-primary/8 text-primary" : "bg-muted/30 text-muted-foreground"
86 }`}
87 >
88 <span className="text-[10px]">{isTurnExpanded ? "▼" : "▶"}</span>
89 <span>Turn #{record.iteration}</span>
90 <span className="font-mono text-[10px] opacity-70">{record.model}</span>
91 <span className="text-[10px] opacity-70">
92 {record.messages_count} msgs
93 </span>
94 <span className="text-[10px] opacity-70 font-mono">
95 {(totalChars / 1000).toFixed(1)}k→{(respChars / 1000).toFixed(1)}k
96 </span>
97 <span className="text-[10px] opacity-50 ml-auto">
98 {new Date(record.timestamp).toLocaleTimeString()}
99 </span>
100 </button>
101
102 {/* Messages - shown only when turn is expanded */}
103 {isTurnExpanded && (
104 <div className="flex flex-col">
105 {record.messages.map((msg, i) => {
106 const itemKey = `${record.id}_msg_${i}`;
107 const isExpanded = expandedItems.has(itemKey);
108 return (
109 <div key={itemKey}>
110 <button
111 onClick={() => toggleItem(itemKey)}
112 className={`flex w-full items-center gap-1.5 px-3 py-0.5 text-xs text-left border-l-2 hover:bg-muted/30 transition-colors ${
113 roleColor(msg.role)
114 }`}
115 >
116 <span className="font-medium w-12 shrink-0 text-[10px]">
117 {msg.role}
118 </span>
119 {msg.tool_name && (
120 <span className="font-mono text-[10px] shrink-0 opacity-70">[{msg.tool_name}]</span>
121 )}
122 {msg.tool_calls && msg.tool_calls.length > 0 && (
123 <span className="font-mono text-[10px] shrink-0 opacity-70">
124 [{msg.tool_calls.map(tc => tc.name).join(",")}]
125 </span>
126 )}
127 <span className="truncate flex-1 text-muted-foreground">{msg.preview}</span>
128 <span className="font-mono text-[10px] text-muted-foreground shrink-0">
129 {msg.char_count >= 1000 ? `${(msg.char_count / 1000).toFixed(1)}k` : msg.char_count}
130 </span>
131 </button>
132 {isExpanded && (
133 <div className={`border-l-2 mx-3 mb-1 p-2 bg-muted/20 rounded-md max-h-60 overflow-auto ${roleColor(msg.role)}`}>
134 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/80">
135 {msg.content}
136 </pre>
137 {msg.tool_calls && msg.tool_calls.length > 0 && (
138 <div className="mt-2 pt-2 border-t border-border/50">
139 <span className="text-[10px] text-muted-foreground">Tool calls:</span>
140 {msg.tool_calls.map((tc, j) => (
141 <pre key={j} className="text-[10px] whitespace-pre-wrap break-words font-mono text-foreground/60 mt-0.5">
142 {tc.name}({tc.arguments?.slice(0, 500)}{(tc.arguments?.length ?? 0) > 500 ? "..." : ""})
143 </pre>
144 ))}
145 </div>
146 )}
147 </div>
148 )}
149 </div>
150 );
151 })}
152
153 {/* Model response as last entry */}
154 {record.response.content && (() => {
155 const respKey = `${record.id}_response`;
156 const isRespExpanded = expandedItems.has(respKey);
157 return (
158 <div>
159 <button
160 onClick={() => toggleItem(respKey)}
161 className={`flex w-full items-center gap-1.5 px-3 py-0.5 text-xs text-left border-l-2 hover:bg-muted/30 transition-colors ${roleColor("assistant_response")}`}
162 >
163 <span className="font-medium w-12 shrink-0 text-[10px] text-emerald-600 dark:text-emerald-400">
164 resp
165 </span>
166 {record.response.tool_calls && record.response.tool_calls.length > 0 && (
167 <span className="font-mono text-[10px] shrink-0 opacity-70">
168 [{record.response.tool_calls.map(tc => tc.name).join(",")}]
169 </span>
170 )}
171 <span className="truncate flex-1 text-muted-foreground">
172 {record.response.content.slice(0, 150)}
173 </span>
174 <span className="font-mono text-[10px] text-muted-foreground shrink-0">
175 {record.response.content.length >= 1000
176 ? `${(record.response.content.length / 1000).toFixed(1)}k`
177 : record.response.content.length}
178 </span>
179 </button>
180 {isRespExpanded && (
181 <div className={`border-l-2 mx-3 mb-1 p-2 bg-muted/20 rounded-md max-h-60 overflow-auto ${roleColor("assistant_response")}`}>
182 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/80">
183 {record.response.content}
184 </pre>
185 </div>
186 )}
187 </div>
188 );
189 })()}
190 </div>
191 )}
192 </div>
193 );
194 })}
195 </div>
196 </div>
197 );
198 }
199
199 lines Plain Text