llm-deep-trace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/bin/llm-deep-trace.js +24 -0
- package/next.config.ts +8 -0
- package/package.json +56 -0
- package/postcss.config.mjs +5 -0
- package/public/banner-v2.png +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo.png +0 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/agent-config/route.ts +31 -0
- package/src/app/api/all-sessions/route.ts +9 -0
- package/src/app/api/analytics/route.ts +379 -0
- package/src/app/api/detect-agents/route.ts +170 -0
- package/src/app/api/image/route.ts +73 -0
- package/src/app/api/search/route.ts +28 -0
- package/src/app/api/session-by-key/route.ts +21 -0
- package/src/app/api/sessions/[sessionId]/messages/route.ts +46 -0
- package/src/app/api/sse/route.ts +86 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +3518 -0
- package/src/app/icon.svg +4 -0
- package/src/app/layout.tsx +20 -0
- package/src/app/page.tsx +5 -0
- package/src/components/AnalyticsDashboard.tsx +393 -0
- package/src/components/App.tsx +243 -0
- package/src/components/CopyButton.tsx +42 -0
- package/src/components/Logo.tsx +20 -0
- package/src/components/MainPanel.tsx +1128 -0
- package/src/components/MessageRenderer.tsx +983 -0
- package/src/components/SessionTree.tsx +505 -0
- package/src/components/SettingsPanel.tsx +160 -0
- package/src/components/SetupView.tsx +206 -0
- package/src/components/Sidebar.tsx +714 -0
- package/src/components/ThemeToggle.tsx +54 -0
- package/src/lib/client-utils.ts +360 -0
- package/src/lib/normalizers.ts +371 -0
- package/src/lib/sessions.ts +1223 -0
- package/src/lib/store.ts +518 -0
- package/src/lib/types.ts +112 -0
- package/src/lib/useSSE.ts +81 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from "react";
|
|
4
|
+
import { NormalizedMessage, BlockColors } from "@/lib/types";
|
|
5
|
+
import type { BlockExpansion, BlockCategory } from "@/lib/store";
|
|
6
|
+
import {
|
|
7
|
+
fmtTime,
|
|
8
|
+
extractText,
|
|
9
|
+
extractResultText,
|
|
10
|
+
stripConversationMeta,
|
|
11
|
+
renderMarkdown,
|
|
12
|
+
highlightCode,
|
|
13
|
+
truncStr,
|
|
14
|
+
looksLikeMarkdown,
|
|
15
|
+
fileExt,
|
|
16
|
+
extToLang,
|
|
17
|
+
toolColorKey,
|
|
18
|
+
} from "@/lib/client-utils";
|
|
19
|
+
import CopyButton from "./CopyButton";
|
|
20
|
+
|
|
21
|
+
const ChevronSvg = ({ size = 8 }: { size?: number }) => (
|
|
22
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
|
23
|
+
<path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// ── Image helpers ──
|
|
28
|
+
const DATA_URI_RE = /data:image\/[a-z+]+;base64,[A-Za-z0-9+/=]+/g;
|
|
29
|
+
const FILE_PATH_RE = /(?:^|\s)(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))/gi;
|
|
30
|
+
|
|
31
|
+
function imagePathToSrc(filePath: string): string {
|
|
32
|
+
return `/api/image?path=${btoa(filePath)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractImagesFromText(text: string): string[] {
|
|
36
|
+
const images: string[] = [];
|
|
37
|
+
const dataMatches = text.match(DATA_URI_RE);
|
|
38
|
+
if (dataMatches) images.push(...dataMatches);
|
|
39
|
+
FILE_PATH_RE.lastIndex = 0;
|
|
40
|
+
let m;
|
|
41
|
+
while ((m = FILE_PATH_RE.exec(text)) !== null) {
|
|
42
|
+
images.push(imagePathToSrc(m[1].trim()));
|
|
43
|
+
}
|
|
44
|
+
return images;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ImageThumbnail({ src, alt }: { src: string; alt?: string }) {
|
|
48
|
+
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!lightboxOpen) return;
|
|
52
|
+
const handler = (e: KeyboardEvent) => {
|
|
53
|
+
if (e.key === "Escape") setLightboxOpen(false);
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener("keydown", handler);
|
|
56
|
+
return () => document.removeEventListener("keydown", handler);
|
|
57
|
+
}, [lightboxOpen]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<img
|
|
62
|
+
src={src}
|
|
63
|
+
alt={alt || "image"}
|
|
64
|
+
className="msg-image-thumb"
|
|
65
|
+
onClick={() => setLightboxOpen(true)}
|
|
66
|
+
loading="lazy"
|
|
67
|
+
/>
|
|
68
|
+
{lightboxOpen && (
|
|
69
|
+
<div className="msg-lightbox" onClick={() => setLightboxOpen(false)}>
|
|
70
|
+
<img
|
|
71
|
+
src={src}
|
|
72
|
+
alt={alt || "image"}
|
|
73
|
+
className="msg-lightbox-img"
|
|
74
|
+
onClick={(e) => e.stopPropagation()}
|
|
75
|
+
/>
|
|
76
|
+
<button className="msg-lightbox-close" onClick={() => setLightboxOpen(false)} title="Close (Esc)">
|
|
77
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
78
|
+
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
79
|
+
</svg>
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ImageBlock({ block }: { block: Record<string, unknown> }) {
|
|
88
|
+
if (block.type !== "image") return null;
|
|
89
|
+
const source = block.source as Record<string, unknown> | undefined;
|
|
90
|
+
if (!source || source.type !== "base64") return null;
|
|
91
|
+
const mediaType = (source.media_type as string) || "image/png";
|
|
92
|
+
const data = source.data as string;
|
|
93
|
+
if (!data) return null;
|
|
94
|
+
const src = `data:${mediaType};base64,${data}`;
|
|
95
|
+
return <ImageThumbnail src={src} alt="embedded image" />;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Arg Value with show more toggle ──
|
|
99
|
+
function ArgValue({ value }: { value: string }) {
|
|
100
|
+
const [expanded, setExpanded] = useState(false);
|
|
101
|
+
if (value.length <= 100) return <span className="tc-arg-val">{value}</span>;
|
|
102
|
+
return (
|
|
103
|
+
<span className="tc-arg-val">
|
|
104
|
+
{expanded ? value : value.slice(0, 100) + "\u2026"}
|
|
105
|
+
<button
|
|
106
|
+
className="tc-arg-toggle"
|
|
107
|
+
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
|
108
|
+
>
|
|
109
|
+
{expanded ? "less" : "more"}
|
|
110
|
+
</button>
|
|
111
|
+
</span>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Args List ──
|
|
116
|
+
function ArgsList({ input }: { input: Record<string, unknown> }) {
|
|
117
|
+
const entries = Object.entries(input);
|
|
118
|
+
if (entries.length === 0) return <div className="tc-args-empty">no arguments</div>;
|
|
119
|
+
return (
|
|
120
|
+
<div className="tc-args-list">
|
|
121
|
+
{entries.map(([key, value]) => {
|
|
122
|
+
let strVal: string;
|
|
123
|
+
if (typeof value === "string") strVal = value;
|
|
124
|
+
else if (typeof value === "number" || typeof value === "boolean") strVal = String(value);
|
|
125
|
+
else if (value === null || value === undefined) strVal = String(value);
|
|
126
|
+
else strVal = JSON.stringify(value, null, 2);
|
|
127
|
+
return (
|
|
128
|
+
<div key={key} className="tc-arg-row">
|
|
129
|
+
<span className="tc-arg-key">{key}</span>
|
|
130
|
+
<span className="tc-arg-sep">→</span>
|
|
131
|
+
<ArgValue value={strVal} />
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
})}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Thinking Block ── (fixed: forceOpen only sets initial state)
|
|
140
|
+
function ThinkingBlock({
|
|
141
|
+
text,
|
|
142
|
+
forceOpen,
|
|
143
|
+
accentColor,
|
|
144
|
+
}: {
|
|
145
|
+
text: string;
|
|
146
|
+
forceOpen: boolean;
|
|
147
|
+
accentColor: string;
|
|
148
|
+
}) {
|
|
149
|
+
const [open, setOpen] = useState(forceOpen);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
setOpen(forceOpen);
|
|
153
|
+
}, [forceOpen]);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div
|
|
157
|
+
className={`thinking-block ${open ? "expanded" : ""}`}
|
|
158
|
+
style={{ "--block-accent": accentColor } as React.CSSProperties}
|
|
159
|
+
>
|
|
160
|
+
<div className="thinking-header" onClick={() => setOpen(!open)}>
|
|
161
|
+
<span className={`thinking-chevron ${open ? "open" : ""}`}>
|
|
162
|
+
<ChevronSvg />
|
|
163
|
+
</span>
|
|
164
|
+
<span className="thinking-label">thinking</span>
|
|
165
|
+
<span className="thinking-sep">·</span>
|
|
166
|
+
<span className="thinking-hint">
|
|
167
|
+
{open ? "collapse" : "expand"}
|
|
168
|
+
</span>
|
|
169
|
+
</div>
|
|
170
|
+
<div className={`thinking-body ${open ? "open" : ""}`}>
|
|
171
|
+
<div className="thinking-body-inner">{text}</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Tool Call (collapsible with accent color) ──
|
|
178
|
+
function ToolCallBlock({
|
|
179
|
+
block,
|
|
180
|
+
blockColors,
|
|
181
|
+
autoExpand,
|
|
182
|
+
globalExpand,
|
|
183
|
+
}: {
|
|
184
|
+
block: Record<string, unknown>;
|
|
185
|
+
blockColors: BlockColors;
|
|
186
|
+
autoExpand: boolean;
|
|
187
|
+
globalExpand?: boolean;
|
|
188
|
+
}) {
|
|
189
|
+
const name = (block.name as string) || "?";
|
|
190
|
+
const input = (block.input || {}) as Record<string, unknown>;
|
|
191
|
+
const [expanded, setExpanded] = useState(autoExpand || !!globalExpand);
|
|
192
|
+
const [localOverride, setLocalOverride] = useState(false);
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (!localOverride) setExpanded(!!globalExpand || autoExpand);
|
|
196
|
+
}, [globalExpand, autoExpand, localOverride]);
|
|
197
|
+
|
|
198
|
+
const colorKey = toolColorKey(name);
|
|
199
|
+
const accent = colorKey ? blockColors[colorKey as keyof BlockColors] : "#888899";
|
|
200
|
+
|
|
201
|
+
const getDesc = (): string => {
|
|
202
|
+
switch (name) {
|
|
203
|
+
case "exec":
|
|
204
|
+
case "Bash": {
|
|
205
|
+
const cmd = (input.command as string) || (input.cmd as string) || "";
|
|
206
|
+
return cmd.length > 80 ? cmd.slice(0, 80) + "\u2026" : cmd;
|
|
207
|
+
}
|
|
208
|
+
case "read":
|
|
209
|
+
case "Read":
|
|
210
|
+
case "write":
|
|
211
|
+
case "Write":
|
|
212
|
+
case "edit":
|
|
213
|
+
case "Edit":
|
|
214
|
+
return (input.file_path as string) || (input.path as string) || (input.filePath as string) || "";
|
|
215
|
+
case "web_search":
|
|
216
|
+
case "WebSearch":
|
|
217
|
+
return (input.query as string) || (input.q as string) || "";
|
|
218
|
+
case "web_fetch":
|
|
219
|
+
case "WebFetch":
|
|
220
|
+
return (input.url as string) || "";
|
|
221
|
+
case "browser":
|
|
222
|
+
case "Browser": {
|
|
223
|
+
const action = (input.action as string) || "";
|
|
224
|
+
const url = (input.url as string) || "";
|
|
225
|
+
const selector = (input.selector as string) || "";
|
|
226
|
+
return `${action} ${url || selector}`.trim();
|
|
227
|
+
}
|
|
228
|
+
case "message":
|
|
229
|
+
case "Message":
|
|
230
|
+
case "SendMessage": {
|
|
231
|
+
const target = (input.recipient as string) || (input.target as string) || "";
|
|
232
|
+
const content = (input.content as string) || (input.message as string) || "";
|
|
233
|
+
const preview = content.slice(0, 50);
|
|
234
|
+
return target ? `${target}: ${preview}` : preview;
|
|
235
|
+
}
|
|
236
|
+
case "sessions_spawn":
|
|
237
|
+
return (input.label as string) || ((input.task as string) || "").slice(0, 60) || "subagent";
|
|
238
|
+
case "Task":
|
|
239
|
+
return (input.description as string) || "";
|
|
240
|
+
case "Glob":
|
|
241
|
+
return (input.pattern as string) || "";
|
|
242
|
+
case "Grep":
|
|
243
|
+
return (input.pattern as string) || "";
|
|
244
|
+
case "TodoWrite":
|
|
245
|
+
return "updating task list";
|
|
246
|
+
default: {
|
|
247
|
+
const keys = Object.keys(input);
|
|
248
|
+
if (keys.length === 0) return "";
|
|
249
|
+
const firstKey = keys[0];
|
|
250
|
+
const firstVal = String(input[firstKey] || "");
|
|
251
|
+
return `${firstKey}: ${firstVal.length > 60 ? firstVal.slice(0, 60) + "\u2026" : firstVal}`;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const desc = getDesc();
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div
|
|
260
|
+
className={`tool-call ${expanded ? "expanded" : ""}`}
|
|
261
|
+
style={{ "--block-accent": accent } as React.CSSProperties}
|
|
262
|
+
>
|
|
263
|
+
<div className="tool-call-header" onClick={() => { setLocalOverride(true); setExpanded(!expanded); }}>
|
|
264
|
+
<span className={`tc-chevron ${expanded ? "open" : ""}`}>
|
|
265
|
+
<ChevronSvg />
|
|
266
|
+
</span>
|
|
267
|
+
<span className="tc-dot" style={{ background: accent }} />
|
|
268
|
+
<span className="tc-name-label">{name}</span>
|
|
269
|
+
{!expanded && desc && <span className="tc-desc">{desc}</span>}
|
|
270
|
+
</div>
|
|
271
|
+
{expanded && (
|
|
272
|
+
<div className="tool-call-body open">
|
|
273
|
+
<ArgsList input={input} />
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Tool Result (collapsible with accent color) ──
|
|
281
|
+
function ToolResultBlock({
|
|
282
|
+
msg,
|
|
283
|
+
time,
|
|
284
|
+
showTime,
|
|
285
|
+
blockColors,
|
|
286
|
+
autoExpand,
|
|
287
|
+
globalExpand,
|
|
288
|
+
toolInputsMap,
|
|
289
|
+
onNavigateSession,
|
|
290
|
+
}: {
|
|
291
|
+
msg: NonNullable<NormalizedMessage["message"]>;
|
|
292
|
+
time: string;
|
|
293
|
+
showTime: boolean;
|
|
294
|
+
blockColors: BlockColors;
|
|
295
|
+
autoExpand: boolean;
|
|
296
|
+
globalExpand?: boolean;
|
|
297
|
+
toolInputsMap?: Map<string, Record<string, unknown>>;
|
|
298
|
+
onNavigateSession?: (key: string) => void;
|
|
299
|
+
}) {
|
|
300
|
+
const toolName = msg.toolName || "";
|
|
301
|
+
const isError = msg.isError || false;
|
|
302
|
+
const text = extractResultText(msg.content);
|
|
303
|
+
const [showFull, setShowFull] = useState(false);
|
|
304
|
+
const [mdrOpen, setMdrOpen] = useState(false);
|
|
305
|
+
const [expanded, setExpanded] = useState(autoExpand || !!globalExpand);
|
|
306
|
+
const [localOverride, setLocalOverride] = useState(false);
|
|
307
|
+
const toolInput = toolInputsMap?.get(msg.toolCallId || "") || {};
|
|
308
|
+
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
if (!localOverride) setExpanded(!!globalExpand || autoExpand);
|
|
311
|
+
}, [globalExpand, autoExpand, localOverride]);
|
|
312
|
+
|
|
313
|
+
const colorKey = toolColorKey(toolName);
|
|
314
|
+
const accent = colorKey ? blockColors[colorKey as keyof BlockColors] : "#888899";
|
|
315
|
+
|
|
316
|
+
// Build a 1-line summary for collapsed view
|
|
317
|
+
const getSummary = (): string => {
|
|
318
|
+
switch (toolName) {
|
|
319
|
+
case "exec":
|
|
320
|
+
case "Bash": {
|
|
321
|
+
const lines = text.split("\n").filter(Boolean);
|
|
322
|
+
if (lines.length === 0) return "(empty output)";
|
|
323
|
+
return lines[0].slice(0, 100);
|
|
324
|
+
}
|
|
325
|
+
case "write":
|
|
326
|
+
case "Write":
|
|
327
|
+
case "edit":
|
|
328
|
+
case "Edit": {
|
|
329
|
+
const fp = (toolInput.file_path as string) || (toolInput.path as string) || "";
|
|
330
|
+
return fp ? `${fp.split("/").pop()} ${isError ? "failed" : "ok"}` : (isError ? "failed" : "ok");
|
|
331
|
+
}
|
|
332
|
+
case "Read": {
|
|
333
|
+
const fp = (toolInput.file_path as string) || (toolInput.path as string) || "";
|
|
334
|
+
return fp ? fp.split("/").pop() || "file" : "file read";
|
|
335
|
+
}
|
|
336
|
+
case "web_search":
|
|
337
|
+
case "WebSearch":
|
|
338
|
+
return (toolInput.query as string)?.slice(0, 60) || "search results";
|
|
339
|
+
case "web_fetch":
|
|
340
|
+
case "WebFetch":
|
|
341
|
+
return (toolInput.url as string)?.slice(0, 60) || "fetched content";
|
|
342
|
+
case "message":
|
|
343
|
+
case "Message":
|
|
344
|
+
case "SendMessage":
|
|
345
|
+
return "sent";
|
|
346
|
+
case "sessions_spawn":
|
|
347
|
+
return "subagent dispatched";
|
|
348
|
+
case "Task":
|
|
349
|
+
case "task":
|
|
350
|
+
return text.slice(0, 80).replace(/\n/g, " ") || "task result";
|
|
351
|
+
default:
|
|
352
|
+
return text.slice(0, 80).replace(/\n/g, " ") || (isError ? "error" : "ok");
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
let bodyHtml = null;
|
|
357
|
+
|
|
358
|
+
// Only compute body content when expanded — skip all markdown/highlight work when collapsed
|
|
359
|
+
const renderTerminal = (t: string, maxH?: number) => (
|
|
360
|
+
<div className="tr-terminal copyable" style={maxH ? { maxHeight: maxH } : {}}>
|
|
361
|
+
{t}
|
|
362
|
+
<CopyButton text={text} label="Copy" />
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const renderCodeBlock = (t: string, lang: string, maxH?: number) => {
|
|
367
|
+
const highlighted = lang ? highlightCode(t, lang) : t;
|
|
368
|
+
return (
|
|
369
|
+
<div className="pre-wrap">
|
|
370
|
+
{lang && <span className="lang-tag">{lang}</span>}
|
|
371
|
+
<pre
|
|
372
|
+
className={lang ? "has-lang-tag hljs" : "hljs"}
|
|
373
|
+
style={maxH ? { maxHeight: maxH, overflowY: "auto" } : {}}
|
|
374
|
+
>
|
|
375
|
+
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
|
376
|
+
</pre>
|
|
377
|
+
<CopyButton text={text} label="Copy" />
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const renderMdResult = (t: string) => {
|
|
383
|
+
const preview = t.slice(0, 120).replace(/\n/g, " ").replace(/^[#-]+ /, "");
|
|
384
|
+
return (
|
|
385
|
+
<div className="tr-md-report">
|
|
386
|
+
<div className="tr-md-header" onClick={() => setMdrOpen(!mdrOpen)}>
|
|
387
|
+
<span className={`thinking-chevron ${mdrOpen ? "open" : ""}`}>
|
|
388
|
+
<ChevronSvg />
|
|
389
|
+
</span>
|
|
390
|
+
<span className="tr-md-preview">{preview}…</span>
|
|
391
|
+
</div>
|
|
392
|
+
<div className={`tr-md-body ${mdrOpen ? "open" : ""}`}>
|
|
393
|
+
<div className="md-content" dangerouslySetInnerHTML={{ __html: renderMarkdown(t) }} />
|
|
394
|
+
<CopyButton text={t} label="Copy markdown" />
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Only build body when expanded — skip all expensive markdown/highlight work when collapsed
|
|
401
|
+
if (expanded) {
|
|
402
|
+
// Build special body for "message" tool — speech bubble style
|
|
403
|
+
const isMessageTool = toolName === "message" || toolName === "Message" || toolName === "SendMessage";
|
|
404
|
+
|
|
405
|
+
if (isMessageTool) {
|
|
406
|
+
const target = (toolInput.recipient as string) || (toolInput.target as string) || "";
|
|
407
|
+
const content = text || "Sent";
|
|
408
|
+
bodyHtml = (
|
|
409
|
+
<div className="tr-message-bubble">
|
|
410
|
+
{target && <div className="tr-message-target">{target}</div>}
|
|
411
|
+
<div className="tr-message-content">{content === "Sent" ? "Sent" : content.slice(0, 300)}</div>
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
} else {
|
|
415
|
+
switch (toolName) {
|
|
416
|
+
case "exec":
|
|
417
|
+
case "Bash": {
|
|
418
|
+
const { t, trunc } = truncStr(text, 4000);
|
|
419
|
+
const displayText = showFull ? text : t;
|
|
420
|
+
const lineCount = displayText.split("\n").length;
|
|
421
|
+
const maxH = lineCount > 20 ? 360 : undefined;
|
|
422
|
+
bodyHtml = (
|
|
423
|
+
<>
|
|
424
|
+
{renderTerminal(displayText, maxH)}
|
|
425
|
+
{trunc && !showFull && (
|
|
426
|
+
<button className="show-more" onClick={() => setShowFull(true)}>
|
|
427
|
+
show more ({text.length.toLocaleString()} chars)
|
|
428
|
+
</button>
|
|
429
|
+
)}
|
|
430
|
+
</>
|
|
431
|
+
);
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
case "write":
|
|
435
|
+
case "Write":
|
|
436
|
+
case "edit":
|
|
437
|
+
case "Edit": {
|
|
438
|
+
const fp = (toolInput.file_path as string) || (toolInput.path as string) || "";
|
|
439
|
+
const displayMsg = text.slice(0, 200) || (toolName.toLowerCase() === "write" ? "Written" : "Edited");
|
|
440
|
+
bodyHtml = (
|
|
441
|
+
<div className="tr-success">
|
|
442
|
+
<svg width="13" height="13" viewBox="0 0 16 16" fill="none">
|
|
443
|
+
<path d="M3 8l4 4 6-6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
444
|
+
</svg>
|
|
445
|
+
{fp ? `${displayMsg}` : displayMsg}
|
|
446
|
+
</div>
|
|
447
|
+
);
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
case "Read": {
|
|
451
|
+
const fp = (toolInput.file_path as string) || (toolInput.path as string) || "";
|
|
452
|
+
const ext = fp ? fileExt(fp) : "";
|
|
453
|
+
const lang = ext ? extToLang(ext) : "";
|
|
454
|
+
const { t, trunc } = truncStr(text, 4000);
|
|
455
|
+
if (lang) {
|
|
456
|
+
bodyHtml = (
|
|
457
|
+
<>
|
|
458
|
+
{renderCodeBlock(showFull ? text : t, lang, 380)}
|
|
459
|
+
{trunc && !showFull && (
|
|
460
|
+
<button className="show-more" onClick={() => setShowFull(true)}>
|
|
461
|
+
show more ({text.length.toLocaleString()} chars)
|
|
462
|
+
</button>
|
|
463
|
+
)}
|
|
464
|
+
</>
|
|
465
|
+
);
|
|
466
|
+
} else {
|
|
467
|
+
bodyHtml = (
|
|
468
|
+
<>
|
|
469
|
+
{renderTerminal(showFull ? text : t, 380)}
|
|
470
|
+
{trunc && !showFull && (
|
|
471
|
+
<button className="show-more" onClick={() => setShowFull(true)}>
|
|
472
|
+
show more ({text.length.toLocaleString()} chars)
|
|
473
|
+
</button>
|
|
474
|
+
)}
|
|
475
|
+
</>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
case "web_search":
|
|
481
|
+
case "WebSearch": {
|
|
482
|
+
try {
|
|
483
|
+
const results = JSON.parse(text);
|
|
484
|
+
if (Array.isArray(results)) {
|
|
485
|
+
bodyHtml = (
|
|
486
|
+
<div className="tr-search-results">
|
|
487
|
+
{results.slice(0, 8).map((r, i) => (
|
|
488
|
+
<div key={i} className="tr-search-card">
|
|
489
|
+
<div className="sr-title">
|
|
490
|
+
<span className="sr-number">{i + 1}.</span> {r.title || ""}
|
|
491
|
+
</div>
|
|
492
|
+
<div className="sr-url">{r.url || r.link || ""}</div>
|
|
493
|
+
<div className="sr-snip">{r.snippet || r.description || ""}</div>
|
|
494
|
+
</div>
|
|
495
|
+
))}
|
|
496
|
+
</div>
|
|
497
|
+
);
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
} catch { /* fallthrough */ }
|
|
501
|
+
const { t, trunc } = truncStr(text, 800);
|
|
502
|
+
bodyHtml = (
|
|
503
|
+
<>
|
|
504
|
+
{renderTerminal(showFull ? text : t)}
|
|
505
|
+
{trunc && !showFull && (
|
|
506
|
+
<button className="show-more" onClick={() => setShowFull(true)}>show more</button>
|
|
507
|
+
)}
|
|
508
|
+
</>
|
|
509
|
+
);
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
case "web_fetch":
|
|
513
|
+
case "WebFetch": {
|
|
514
|
+
if (looksLikeMarkdown(text)) {
|
|
515
|
+
bodyHtml = renderMdResult(text);
|
|
516
|
+
} else {
|
|
517
|
+
const { t, trunc } = truncStr(text, 800);
|
|
518
|
+
bodyHtml = (
|
|
519
|
+
<>
|
|
520
|
+
{renderTerminal(showFull ? text : t)}
|
|
521
|
+
{trunc && !showFull && (
|
|
522
|
+
<button className="show-more" onClick={() => setShowFull(true)}>show more</button>
|
|
523
|
+
)}
|
|
524
|
+
</>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
case "tts": {
|
|
530
|
+
bodyHtml = (
|
|
531
|
+
<div className="tr-success">
|
|
532
|
+
<svg width="13" height="13" viewBox="0 0 16 16" fill="none">
|
|
533
|
+
<path d="M3 8l4 4 6-6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
534
|
+
</svg>
|
|
535
|
+
audio delivered
|
|
536
|
+
</div>
|
|
537
|
+
);
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
case "sessions_spawn": {
|
|
541
|
+
try {
|
|
542
|
+
const r = JSON.parse(text);
|
|
543
|
+
const key = r.childSessionKey || "";
|
|
544
|
+
const childId = r.childSessionId || "";
|
|
545
|
+
const status = r.status || "unknown";
|
|
546
|
+
const label = r.label || (key ? key.split(":subagent:")[1] || key.split(":").pop() : "");
|
|
547
|
+
const ok = status === "accepted" || status === "ok";
|
|
548
|
+
bodyHtml = (
|
|
549
|
+
<div className="spawn-nav-card">
|
|
550
|
+
<div className={`spawn-nav-status ${ok ? "" : "err"}`}>
|
|
551
|
+
<span className="dot" />
|
|
552
|
+
{ok ? "subagent dispatched" : "dispatch: " + status}
|
|
553
|
+
</div>
|
|
554
|
+
{key && (
|
|
555
|
+
<div
|
|
556
|
+
className="spawn-session-btn"
|
|
557
|
+
onClick={() => onNavigateSession?.(childId || key)}
|
|
558
|
+
role="button"
|
|
559
|
+
tabIndex={0}
|
|
560
|
+
>
|
|
561
|
+
<div className="ssb-body">
|
|
562
|
+
<div className="ssb-title">{label}</div>
|
|
563
|
+
<div className="ssb-key">{key.includes(":subagent:") ? key.split(":subagent:")[1] : key.slice(0, 24)}</div>
|
|
564
|
+
</div>
|
|
565
|
+
<div className="ssb-arrow-col">
|
|
566
|
+
<ChevronSvg size={14} />
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
)}
|
|
570
|
+
</div>
|
|
571
|
+
);
|
|
572
|
+
} catch {
|
|
573
|
+
const { t } = truncStr(text, 300);
|
|
574
|
+
bodyHtml = renderTerminal(t);
|
|
575
|
+
}
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
case "Task":
|
|
579
|
+
case "task": {
|
|
580
|
+
if (looksLikeMarkdown(text)) {
|
|
581
|
+
bodyHtml = renderMdResult(text);
|
|
582
|
+
} else {
|
|
583
|
+
const { t, trunc } = truncStr(text, 600);
|
|
584
|
+
bodyHtml = (
|
|
585
|
+
<>
|
|
586
|
+
{renderTerminal(showFull ? text : t)}
|
|
587
|
+
{trunc && !showFull && (
|
|
588
|
+
<button className="show-more" onClick={() => setShowFull(true)}>show more</button>
|
|
589
|
+
)}
|
|
590
|
+
</>
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
default: {
|
|
596
|
+
if (looksLikeMarkdown(text)) {
|
|
597
|
+
bodyHtml = renderMdResult(text);
|
|
598
|
+
} else {
|
|
599
|
+
const { t, trunc } = truncStr(text, 500);
|
|
600
|
+
bodyHtml = (
|
|
601
|
+
<>
|
|
602
|
+
{renderTerminal(showFull ? text : t)}
|
|
603
|
+
{trunc && !showFull && (
|
|
604
|
+
<button className="show-more" onClick={() => setShowFull(true)}>
|
|
605
|
+
show more ({text.length.toLocaleString()} chars)
|
|
606
|
+
</button>
|
|
607
|
+
)}
|
|
608
|
+
</>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
} // end if (expanded)
|
|
615
|
+
|
|
616
|
+
const summary = getSummary();
|
|
617
|
+
|
|
618
|
+
return (
|
|
619
|
+
<div
|
|
620
|
+
className={`tool-result-wrap ${expanded ? "expanded" : ""}`}
|
|
621
|
+
style={{ "--block-accent": accent } as React.CSSProperties}
|
|
622
|
+
>
|
|
623
|
+
<div className="tool-result-header" onClick={() => { setLocalOverride(true); setExpanded(!expanded); }}>
|
|
624
|
+
<span className={`tc-chevron ${expanded ? "open" : ""}`}>
|
|
625
|
+
<ChevronSvg />
|
|
626
|
+
</span>
|
|
627
|
+
<span className="tc-dot" style={{ background: accent }} />
|
|
628
|
+
<span className={`tr-status-inline ${isError ? "err" : ""}`}>
|
|
629
|
+
{toolName || "tool"} {isError ? "error" : "result"}
|
|
630
|
+
</span>
|
|
631
|
+
{!expanded && <span className="tr-summary">{summary}</span>}
|
|
632
|
+
</div>
|
|
633
|
+
{expanded && (
|
|
634
|
+
<div className="tool-result-body">
|
|
635
|
+
{bodyHtml}
|
|
636
|
+
{showTime && <div className="msg-time">{time}</div>}
|
|
637
|
+
</div>
|
|
638
|
+
)}
|
|
639
|
+
</div>
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ── User Message ──
|
|
644
|
+
function UserMessage({ content, time, showTime }: { content: unknown; time: string; showTime: boolean }) {
|
|
645
|
+
let text = extractText(content);
|
|
646
|
+
text = stripConversationMeta(text);
|
|
647
|
+
|
|
648
|
+
// Detect image blocks in user content
|
|
649
|
+
const imageBlocks: React.ReactElement[] = [];
|
|
650
|
+
if (Array.isArray(content)) {
|
|
651
|
+
(content as Record<string, unknown>[]).forEach((block, i) => {
|
|
652
|
+
if (block && block.type === "image") {
|
|
653
|
+
imageBlocks.push(<ImageBlock key={`uimg${i}`} block={block} />);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (!text && imageBlocks.length === 0) return null;
|
|
659
|
+
|
|
660
|
+
return (
|
|
661
|
+
<div className="msg">
|
|
662
|
+
<div className="msg-user copyable">
|
|
663
|
+
{text && <div className="msg-user-text">{text}</div>}
|
|
664
|
+
{imageBlocks.length > 0 && <div className="msg-image-row">{imageBlocks}</div>}
|
|
665
|
+
{text && <CopyButton text={text} label="Copy text" />}
|
|
666
|
+
</div>
|
|
667
|
+
{showTime && <div className="msg-time">{time}</div>}
|
|
668
|
+
</div>
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ── Assistant Message ──
|
|
673
|
+
function AssistantMessage({
|
|
674
|
+
content,
|
|
675
|
+
time,
|
|
676
|
+
showTime,
|
|
677
|
+
allThinkingExpanded,
|
|
678
|
+
blockExpansion,
|
|
679
|
+
blockColors,
|
|
680
|
+
autoExpand,
|
|
681
|
+
hiddenBlockTypes,
|
|
682
|
+
hideText,
|
|
683
|
+
}: {
|
|
684
|
+
content: unknown;
|
|
685
|
+
time: string;
|
|
686
|
+
showTime: boolean;
|
|
687
|
+
allThinkingExpanded: boolean;
|
|
688
|
+
blockExpansion?: BlockExpansion;
|
|
689
|
+
blockColors: BlockColors;
|
|
690
|
+
autoExpand: boolean;
|
|
691
|
+
hiddenBlockTypes?: Set<string>;
|
|
692
|
+
hideText?: boolean;
|
|
693
|
+
}) {
|
|
694
|
+
if (!content) return null;
|
|
695
|
+
|
|
696
|
+
// Separate buckets so each can be independently filtered
|
|
697
|
+
const textOnlyParts: React.ReactElement[] = []; // plain text/images (hidden by asst-text filter)
|
|
698
|
+
const thinkingParts: React.ReactElement[] = []; // thinking blocks (hidden by thinking filter, NOT by asst-text)
|
|
699
|
+
const toolCallParts: React.ReactElement[] = []; // tool use blocks (hidden by their own filters)
|
|
700
|
+
const rawMdParts: string[] = [];
|
|
701
|
+
|
|
702
|
+
if (typeof content === "string") {
|
|
703
|
+
textOnlyParts.push(
|
|
704
|
+
<div key="t0" className="md-content" dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }} />
|
|
705
|
+
);
|
|
706
|
+
rawMdParts.push(content);
|
|
707
|
+
// Detect embedded images in text
|
|
708
|
+
const imgs = extractImagesFromText(content);
|
|
709
|
+
for (let idx = 0; idx < imgs.length; idx++) {
|
|
710
|
+
textOnlyParts.push(<ImageThumbnail key={`img-s-${idx}`} src={imgs[idx]} />);
|
|
711
|
+
}
|
|
712
|
+
} else if (Array.isArray(content)) {
|
|
713
|
+
(content as Record<string, unknown>[]).forEach((block, i) => {
|
|
714
|
+
if (!block) return;
|
|
715
|
+
if (block.type === "image") {
|
|
716
|
+
textOnlyParts.push(<ImageBlock key={`img${i}`} block={block} />);
|
|
717
|
+
} else if (block.type === "text" && block.text) {
|
|
718
|
+
textOnlyParts.push(
|
|
719
|
+
<div key={`t${i}`} className="md-content" dangerouslySetInnerHTML={{ __html: renderMarkdown(block.text as string) }} />
|
|
720
|
+
);
|
|
721
|
+
rawMdParts.push(block.text as string);
|
|
722
|
+
// Detect embedded images in text blocks
|
|
723
|
+
const imgs = extractImagesFromText(block.text as string);
|
|
724
|
+
for (let idx = 0; idx < imgs.length; idx++) {
|
|
725
|
+
textOnlyParts.push(<ImageThumbnail key={`img-t${i}-${idx}`} src={imgs[idx]} />);
|
|
726
|
+
}
|
|
727
|
+
} else if (block.type === "thinking" && block.thinking) {
|
|
728
|
+
// Thinking goes to its own bucket — independently controlled by the thinking filter,
|
|
729
|
+
// NOT suppressed by the asst-text filter
|
|
730
|
+
if (!hiddenBlockTypes?.has("thinking")) {
|
|
731
|
+
thinkingParts.push(
|
|
732
|
+
<ThinkingBlock
|
|
733
|
+
key={`th${i}`}
|
|
734
|
+
text={block.thinking as string}
|
|
735
|
+
forceOpen={allThinkingExpanded}
|
|
736
|
+
accentColor={blockColors.thinking}
|
|
737
|
+
/>
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
} else if (block.type === "tool_use") {
|
|
741
|
+
const catKey = toolColorKey((block.name as string) || "") as BlockCategory;
|
|
742
|
+
if (!catKey || !hiddenBlockTypes?.has(catKey)) {
|
|
743
|
+
toolCallParts.push(
|
|
744
|
+
<ToolCallBlock key={`tc${i}`} block={block} blockColors={blockColors} autoExpand={autoExpand} globalExpand={catKey && blockExpansion ? blockExpansion[catKey] : undefined} />
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
} else if (block.type === "toolCall") {
|
|
748
|
+
const catKey = toolColorKey((block.name as string) || "") as BlockCategory;
|
|
749
|
+
if (!catKey || !hiddenBlockTypes?.has(catKey)) {
|
|
750
|
+
toolCallParts.push(
|
|
751
|
+
<ToolCallBlock
|
|
752
|
+
key={`tc${i}`}
|
|
753
|
+
block={{ id: block.id, name: block.name, input: block.arguments || block.input || {} }}
|
|
754
|
+
blockColors={blockColors}
|
|
755
|
+
autoExpand={autoExpand}
|
|
756
|
+
globalExpand={catKey && blockExpansion ? blockExpansion[catKey] : undefined}
|
|
757
|
+
/>
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
const fallbackText = (block.text as string) || (block.content as string) || "";
|
|
762
|
+
if (fallbackText) {
|
|
763
|
+
textOnlyParts.push(
|
|
764
|
+
<div key={`t${i}`} className="md-content" dangerouslySetInnerHTML={{ __html: renderMarkdown(fallbackText) }} />
|
|
765
|
+
);
|
|
766
|
+
rawMdParts.push(fallbackText);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
} else if (typeof content === "object" && content !== null) {
|
|
771
|
+
const obj = content as Record<string, unknown>;
|
|
772
|
+
const fallbackText = (obj.text as string) || (obj.content as string) || "";
|
|
773
|
+
if (fallbackText) {
|
|
774
|
+
textOnlyParts.push(
|
|
775
|
+
<div key="t0" className="md-content" dangerouslySetInnerHTML={{ __html: renderMarkdown(fallbackText) }} />
|
|
776
|
+
);
|
|
777
|
+
rawMdParts.push(fallbackText);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// hideText (asst-text filter) suppresses prose but NOT thinking or tool blocks
|
|
782
|
+
const showText = !hideText && textOnlyParts.length > 0;
|
|
783
|
+
if (!showText && !thinkingParts.length && !toolCallParts.length) return null;
|
|
784
|
+
|
|
785
|
+
const combinedMd = rawMdParts.join("\n\n");
|
|
786
|
+
|
|
787
|
+
return (
|
|
788
|
+
<>
|
|
789
|
+
{showText && (
|
|
790
|
+
<div className="msg">
|
|
791
|
+
<div className="msg-assistant copyable">
|
|
792
|
+
<div className="msg-text">{textOnlyParts}</div>
|
|
793
|
+
<CopyButton text={combinedMd} label="Copy as markdown" />
|
|
794
|
+
</div>
|
|
795
|
+
{showTime && <div className="msg-time">{time}</div>}
|
|
796
|
+
</div>
|
|
797
|
+
)}
|
|
798
|
+
{thinkingParts}
|
|
799
|
+
{toolCallParts}
|
|
800
|
+
</>
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ── Event renderers ──
|
|
805
|
+
function CompactionEvent({ entry }: { entry: NormalizedMessage }) {
|
|
806
|
+
const [open, setOpen] = useState(false);
|
|
807
|
+
const summary = entry.summary || "";
|
|
808
|
+
|
|
809
|
+
return (
|
|
810
|
+
<div className="event-divider">
|
|
811
|
+
<div className="event-inner" onClick={() => setOpen(!open)}>
|
|
812
|
+
context compacted
|
|
813
|
+
{summary && <div className={`event-summary ${open ? "open" : ""}`}>{summary}</div>}
|
|
814
|
+
</div>
|
|
815
|
+
</div>
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function ModelChangeEvent({ entry }: { entry: NormalizedMessage }) {
|
|
820
|
+
const model = entry.modelId || "?";
|
|
821
|
+
const provider = entry.provider || "";
|
|
822
|
+
const label = model.replace(/^(global\.|anthropic\.)/, "").replace(/-v\d+$/, "");
|
|
823
|
+
|
|
824
|
+
return (
|
|
825
|
+
<div className="event-divider">
|
|
826
|
+
<div className="model-inner">
|
|
827
|
+
<svg width="10" height="10" viewBox="0 0 16 16" fill="none">
|
|
828
|
+
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.4" />
|
|
829
|
+
<path d="M8 5v3l2 2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
|
830
|
+
</svg>
|
|
831
|
+
<span className="model-name">{label}</span>
|
|
832
|
+
{provider && (
|
|
833
|
+
<>
|
|
834
|
+
<span className="model-sep">·</span>
|
|
835
|
+
<span className="model-extra">{provider}</span>
|
|
836
|
+
</>
|
|
837
|
+
)}
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function CustomEvent({ entry }: { entry: NormalizedMessage }) {
|
|
844
|
+
const customType = entry.customType || "";
|
|
845
|
+
const data = entry.data || {};
|
|
846
|
+
|
|
847
|
+
const skip = new Set(["openclaw.cache-ttl"]);
|
|
848
|
+
if (skip.has(customType)) return null;
|
|
849
|
+
|
|
850
|
+
if (customType === "model-snapshot") {
|
|
851
|
+
const model = (data.modelId as string) || "?";
|
|
852
|
+
const provider = (data.provider as string) || "";
|
|
853
|
+
const label = model.replace(/^(global\.|anthropic\.)/, "").replace(/-v\d+$/, "");
|
|
854
|
+
return (
|
|
855
|
+
<div className="event-divider">
|
|
856
|
+
<div className="model-inner">
|
|
857
|
+
<svg width="10" height="10" viewBox="0 0 16 16" fill="none">
|
|
858
|
+
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.4" />
|
|
859
|
+
<path d="M8 5v3l2 2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
|
860
|
+
</svg>
|
|
861
|
+
<span className="model-name">{label}</span>
|
|
862
|
+
{provider && (
|
|
863
|
+
<>
|
|
864
|
+
<span className="model-sep">·</span>
|
|
865
|
+
<span className="model-extra">{provider}</span>
|
|
866
|
+
</>
|
|
867
|
+
)}
|
|
868
|
+
</div>
|
|
869
|
+
</div>
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (customType === "openclaw:prompt-error") {
|
|
874
|
+
const msg = String((data.message as string) || (data.error as string) || customType).slice(0, 120);
|
|
875
|
+
return (
|
|
876
|
+
<div className="event-divider">
|
|
877
|
+
<div className="model-inner">
|
|
878
|
+
<span className="model-error">{msg}</span>
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return (
|
|
885
|
+
<div className="event-pill">{customType || "custom"}</div>
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function ThinkingLevelChange({ entry }: { entry: NormalizedMessage }) {
|
|
890
|
+
const level = entry.thinkingLevel || "?";
|
|
891
|
+
return (
|
|
892
|
+
<div className="event-divider">
|
|
893
|
+
<div className="model-inner">
|
|
894
|
+
<svg width="10" height="10" viewBox="0 0 16 16" fill="none">
|
|
895
|
+
<path d="M8 2C5.2 2 3 4.2 3 7c0 1.8 1 3.4 2.5 4.3V13h5v-1.7C12 10.4 13 8.8 13 7c0-2.8-2.2-5-5-5z" stroke="currentColor" strokeWidth="1.3" />
|
|
896
|
+
</svg>
|
|
897
|
+
<span className="model-extra">thinking:</span>
|
|
898
|
+
<span className="model-name">{level}</span>
|
|
899
|
+
</div>
|
|
900
|
+
</div>
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── Main renderer ──
|
|
905
|
+
function MessageRenderer({
|
|
906
|
+
entry,
|
|
907
|
+
allThinkingExpanded,
|
|
908
|
+
blockExpansion,
|
|
909
|
+
blockColors,
|
|
910
|
+
settings,
|
|
911
|
+
toolInputsMap,
|
|
912
|
+
onNavigateSession,
|
|
913
|
+
hiddenBlockTypes,
|
|
914
|
+
}: {
|
|
915
|
+
entry: NormalizedMessage;
|
|
916
|
+
allThinkingExpanded: boolean;
|
|
917
|
+
blockExpansion?: BlockExpansion;
|
|
918
|
+
blockColors: BlockColors;
|
|
919
|
+
settings: { showTimestamps: boolean; autoExpandToolCalls: boolean };
|
|
920
|
+
toolInputsMap?: Map<string, Record<string, unknown>>;
|
|
921
|
+
onNavigateSession?: (key: string) => void;
|
|
922
|
+
hiddenBlockTypes?: Set<string>;
|
|
923
|
+
}) {
|
|
924
|
+
const t = entry.type;
|
|
925
|
+
|
|
926
|
+
if (t === "compaction") return <CompactionEvent entry={entry} />;
|
|
927
|
+
if (t === "model_change") return <ModelChangeEvent entry={entry} />;
|
|
928
|
+
if (t === "thinking_level_change") return <ThinkingLevelChange entry={entry} />;
|
|
929
|
+
if (t === "custom") return <CustomEvent entry={entry} />;
|
|
930
|
+
|
|
931
|
+
if (t !== "message") {
|
|
932
|
+
if (t && !["session", "summary"].includes(t)) {
|
|
933
|
+
return <div className="event-pill">{t}</div>;
|
|
934
|
+
}
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const msg = entry.message;
|
|
939
|
+
if (!msg) return null;
|
|
940
|
+
|
|
941
|
+
const role = msg.role;
|
|
942
|
+
const time = fmtTime(entry.timestamp);
|
|
943
|
+
const showTime = settings.showTimestamps;
|
|
944
|
+
|
|
945
|
+
if (role === "user") {
|
|
946
|
+
if (hiddenBlockTypes?.has("user-msg")) return null;
|
|
947
|
+
return <UserMessage content={msg.content} time={time} showTime={showTime} />;
|
|
948
|
+
}
|
|
949
|
+
if (role === "assistant")
|
|
950
|
+
return (
|
|
951
|
+
<AssistantMessage
|
|
952
|
+
content={msg.content}
|
|
953
|
+
time={time}
|
|
954
|
+
showTime={showTime}
|
|
955
|
+
allThinkingExpanded={allThinkingExpanded}
|
|
956
|
+
blockExpansion={blockExpansion}
|
|
957
|
+
blockColors={blockColors}
|
|
958
|
+
autoExpand={settings.autoExpandToolCalls}
|
|
959
|
+
hiddenBlockTypes={hiddenBlockTypes}
|
|
960
|
+
hideText={hiddenBlockTypes?.has("asst-text")}
|
|
961
|
+
/>
|
|
962
|
+
);
|
|
963
|
+
if (role === "toolResult") {
|
|
964
|
+
const catKey = toolColorKey(msg.toolName || "") as BlockCategory;
|
|
965
|
+
if (catKey && hiddenBlockTypes?.has(catKey)) return null;
|
|
966
|
+
return (
|
|
967
|
+
<ToolResultBlock
|
|
968
|
+
msg={msg}
|
|
969
|
+
time={time}
|
|
970
|
+
showTime={showTime}
|
|
971
|
+
blockColors={blockColors}
|
|
972
|
+
autoExpand={settings.autoExpandToolCalls}
|
|
973
|
+
globalExpand={catKey && blockExpansion ? blockExpansion[catKey] : undefined}
|
|
974
|
+
toolInputsMap={toolInputsMap}
|
|
975
|
+
onNavigateSession={onNavigateSession}
|
|
976
|
+
/>
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
export default React.memo(MessageRenderer);
|