@tt-a1i/openpi 0.6.1 → 0.7.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/THIRD_PARTY_NOTICES.md +242 -0
- package/extensions/ai-providers/cursor/connect-frame-reader.ts +76 -0
- package/extensions/ai-providers/cursor/provider.ts +6 -19
- package/extensions/file-mutation-display/index.ts +13 -9
- package/extensions/shared/agent-transcript.ts +3 -2
- package/extensions/web/index.ts +69 -5
- package/extensions/workflows/artifacts.ts +362 -23
- package/extensions/workflows/dashboard.ts +2 -0
- package/package.json +28 -4
- package/web/dist/app.js +87 -0
- package/web/dist/favicon.svg +9 -0
- package/web/dist/index.html +15 -0
- package/web/dist/styles.css +3 -0
- package/web/host/web-host.ts +6 -9
- package/web/ui/index.html +3 -131
- package/web/ui/public/favicon.svg +9 -0
- package/web/ui/src/app/App.tsx +134 -0
- package/web/ui/src/app/providers.tsx +38 -0
- package/web/ui/src/components/Markdown.tsx +58 -0
- package/web/ui/src/components/OpenPiLogo.tsx +41 -0
- package/web/ui/src/features/activity/ActivityBar.tsx +120 -0
- package/web/ui/src/features/composer/Composer.tsx +237 -0
- package/web/ui/src/features/sessions/SessionSidebar.tsx +418 -0
- package/web/ui/src/features/transcript/Transcript.tsx +860 -0
- package/web/ui/src/i18n.ts +159 -0
- package/web/ui/src/lib/format.ts +57 -0
- package/web/ui/src/main.tsx +16 -0
- package/web/ui/src/protocol/client.ts +199 -0
- package/web/ui/src/protocol/event-stream.ts +88 -0
- package/web/ui/src/store/web-store.ts +926 -0
- package/web/ui/src/styles.css +420 -0
- package/web/ui/tsconfig.json +12 -0
- package/web/ui/vite-env.d.ts +1 -0
- package/web/vite.config.mjs +21 -1
- package/web/host/static-assets.ts +0 -4
- package/web/ui/app.js +0 -1700
- package/web/ui/styles.css +0 -680
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Bot,
|
|
3
|
+
Check,
|
|
4
|
+
Clipboard,
|
|
5
|
+
FilePenLine,
|
|
6
|
+
FileText,
|
|
7
|
+
Folder,
|
|
8
|
+
Globe,
|
|
9
|
+
Lightbulb,
|
|
10
|
+
Pencil,
|
|
11
|
+
Search,
|
|
12
|
+
Terminal,
|
|
13
|
+
Workflow,
|
|
14
|
+
Wrench,
|
|
15
|
+
X,
|
|
16
|
+
} from "lucide-react";
|
|
17
|
+
import {
|
|
18
|
+
Fragment,
|
|
19
|
+
type ReactNode,
|
|
20
|
+
useEffect,
|
|
21
|
+
useLayoutEffect,
|
|
22
|
+
useMemo,
|
|
23
|
+
useRef,
|
|
24
|
+
useState,
|
|
25
|
+
} from "react";
|
|
26
|
+
import { useTranslation } from "react-i18next";
|
|
27
|
+
import type {
|
|
28
|
+
WebLiveMessage,
|
|
29
|
+
WebMessagePart,
|
|
30
|
+
WebSnapshot,
|
|
31
|
+
} from "../../../../protocol/types.ts";
|
|
32
|
+
import { Markdown } from "../../components/Markdown.tsx";
|
|
33
|
+
import {
|
|
34
|
+
compactSummary,
|
|
35
|
+
formatElapsedMs,
|
|
36
|
+
formatTurnTime,
|
|
37
|
+
turnTitle,
|
|
38
|
+
} from "../../lib/format.ts";
|
|
39
|
+
import type { LiveEntry } from "../../store/web-store.ts";
|
|
40
|
+
|
|
41
|
+
type PersistedEntry = NonNullable<
|
|
42
|
+
WebSnapshot["selectedSession"]
|
|
43
|
+
>["entries"][number];
|
|
44
|
+
interface DisplayEntry {
|
|
45
|
+
key: string;
|
|
46
|
+
timestamp?: string;
|
|
47
|
+
message: WebLiveMessage;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface TranscriptProps {
|
|
51
|
+
snapshot: WebSnapshot;
|
|
52
|
+
liveMessages: LiveEntry[];
|
|
53
|
+
liveRunning: boolean;
|
|
54
|
+
livePhase: "idle" | "preparing" | "running";
|
|
55
|
+
liveRetry: { attempt: number; maxAttempts: number } | null;
|
|
56
|
+
thinkingStarts: Record<string, number>;
|
|
57
|
+
thinkingDurations: Record<string, number>;
|
|
58
|
+
scrollToBottom: number;
|
|
59
|
+
onResend: (content: string) => Promise<boolean>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type Status = "running" | "done" | "error" | "warn" | "unknown";
|
|
63
|
+
interface RenderRow {
|
|
64
|
+
key: string;
|
|
65
|
+
content: ReactNode;
|
|
66
|
+
groupable?: boolean;
|
|
67
|
+
error?: boolean;
|
|
68
|
+
icon?: ReactNode;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function record(value: unknown): Record<string, unknown> {
|
|
72
|
+
return value && typeof value === "object"
|
|
73
|
+
? (value as Record<string, unknown>)
|
|
74
|
+
: {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseArguments(raw: string) {
|
|
78
|
+
try {
|
|
79
|
+
return record(JSON.parse(raw));
|
|
80
|
+
} catch {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function canonicalStatus(value: unknown): Status {
|
|
86
|
+
if (value === "running") return "running";
|
|
87
|
+
if (value === "done" || value === "completed") return "done";
|
|
88
|
+
if (
|
|
89
|
+
["error", "failed", "aborted", "killed", "timed_out"].includes(
|
|
90
|
+
String(value),
|
|
91
|
+
)
|
|
92
|
+
) {
|
|
93
|
+
return "error";
|
|
94
|
+
}
|
|
95
|
+
if (value === "uncertain") return "warn";
|
|
96
|
+
return "unknown";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resultStatus(message?: WebLiveMessage): Status {
|
|
100
|
+
if (!message) return "running";
|
|
101
|
+
if (message.isError) return "error";
|
|
102
|
+
const status = canonicalStatus(record(message.details).status);
|
|
103
|
+
if (status !== "unknown") return status;
|
|
104
|
+
return message.isError === false ? "done" : "unknown";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function StatusMark({ status }: { status: Status }) {
|
|
108
|
+
if (status === "running") {
|
|
109
|
+
return (
|
|
110
|
+
<span className="status-mark running" role="img" aria-label="running">
|
|
111
|
+
<i />
|
|
112
|
+
</span>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (status === "done")
|
|
116
|
+
return <Check className="status-mark done" aria-label="completed" />;
|
|
117
|
+
if (status === "error")
|
|
118
|
+
return <X className="status-mark error" aria-label="failed" />;
|
|
119
|
+
if (status === "warn")
|
|
120
|
+
return (
|
|
121
|
+
<span className="status-mark warn" role="img" aria-label="uncertain">
|
|
122
|
+
?
|
|
123
|
+
</span>
|
|
124
|
+
);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function iconForTool(name: string) {
|
|
129
|
+
const lowered = name.toLowerCase();
|
|
130
|
+
if (lowered === "bash") return <Terminal />;
|
|
131
|
+
if (lowered === "read") return <FileText />;
|
|
132
|
+
if (lowered === "write" || lowered === "edit") return <FilePenLine />;
|
|
133
|
+
if (lowered === "grep") return <Search />;
|
|
134
|
+
if (lowered === "glob" || lowered === "ls") return <Folder />;
|
|
135
|
+
if (lowered === "webfetch" || lowered === "websearch") return <Globe />;
|
|
136
|
+
return <Wrench />;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toolSummary(name: string, args: Record<string, unknown>) {
|
|
140
|
+
const value =
|
|
141
|
+
name === "bash"
|
|
142
|
+
? args.command
|
|
143
|
+
: ["read", "write", "edit", "ls"].includes(name)
|
|
144
|
+
? args.path
|
|
145
|
+
: ["grep", "glob"].includes(name)
|
|
146
|
+
? args.pattern
|
|
147
|
+
: name === "webfetch"
|
|
148
|
+
? args.url
|
|
149
|
+
: name === "websearch"
|
|
150
|
+
? args.query
|
|
151
|
+
: "";
|
|
152
|
+
return typeof value === "string"
|
|
153
|
+
? compactSummary(value.split("\n").find(Boolean), 90)
|
|
154
|
+
: "";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function EvidenceDetails({
|
|
158
|
+
body,
|
|
159
|
+
icon,
|
|
160
|
+
name,
|
|
161
|
+
status,
|
|
162
|
+
summary,
|
|
163
|
+
thinking = false,
|
|
164
|
+
}: {
|
|
165
|
+
body: string;
|
|
166
|
+
icon: ReactNode;
|
|
167
|
+
name: string;
|
|
168
|
+
status: Status;
|
|
169
|
+
summary?: string;
|
|
170
|
+
thinking?: boolean;
|
|
171
|
+
}) {
|
|
172
|
+
return (
|
|
173
|
+
<details
|
|
174
|
+
className={`message-details tool-line ${status === "error" ? "error" : ""} ${thinking ? "thinking-line" : ""}`}
|
|
175
|
+
>
|
|
176
|
+
<summary>
|
|
177
|
+
<span className="details-mark" aria-hidden="true" />
|
|
178
|
+
<span className="tool-icon" aria-hidden="true">
|
|
179
|
+
{icon}
|
|
180
|
+
</span>
|
|
181
|
+
<span className="details-title">
|
|
182
|
+
<span className="tool-name">{name}</span>
|
|
183
|
+
{summary && <span className="tool-summary">{summary}</span>}
|
|
184
|
+
</span>
|
|
185
|
+
<StatusMark status={status} />
|
|
186
|
+
</summary>
|
|
187
|
+
<pre className="details-body tool-evidence">{body}</pre>
|
|
188
|
+
</details>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function ActivityCard({
|
|
193
|
+
body,
|
|
194
|
+
family,
|
|
195
|
+
meta,
|
|
196
|
+
status,
|
|
197
|
+
title,
|
|
198
|
+
}: {
|
|
199
|
+
body: string;
|
|
200
|
+
family: "subagent" | "workflow";
|
|
201
|
+
meta?: string;
|
|
202
|
+
status: Status;
|
|
203
|
+
title: string;
|
|
204
|
+
}) {
|
|
205
|
+
return (
|
|
206
|
+
<details className={`message-details activity-card ${family}`}>
|
|
207
|
+
<summary>
|
|
208
|
+
<span className="activity-icon" aria-hidden="true">
|
|
209
|
+
{family === "subagent" ? <Bot /> : <Workflow />}
|
|
210
|
+
</span>
|
|
211
|
+
<span className="activity-main">
|
|
212
|
+
<span className="activity-title">{title}</span>
|
|
213
|
+
{meta && <span className="activity-meta">{meta}</span>}
|
|
214
|
+
</span>
|
|
215
|
+
<StatusMark status={status} />
|
|
216
|
+
<span className="details-mark" aria-hidden="true" />
|
|
217
|
+
</summary>
|
|
218
|
+
<pre className="details-body tool-evidence">{body}</pre>
|
|
219
|
+
</details>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function familyCard(
|
|
224
|
+
part: Extract<WebMessagePart, { type: "toolCall" }>,
|
|
225
|
+
result?: WebLiveMessage,
|
|
226
|
+
) {
|
|
227
|
+
const name = part.name || "";
|
|
228
|
+
const args = parseArguments(part.arguments);
|
|
229
|
+
const details = record(result?.details);
|
|
230
|
+
const status = resultStatus(result);
|
|
231
|
+
if (name === "subagent_spawn") {
|
|
232
|
+
const meta = [args.agent_type, args.model, args.working_dir]
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.join(" · ");
|
|
235
|
+
return (
|
|
236
|
+
<ActivityCard
|
|
237
|
+
family="subagent"
|
|
238
|
+
title={`Spawn Subagent · ${String(details.title || args.name || "subagent")}`}
|
|
239
|
+
meta={meta || String(details.cwd || "")}
|
|
240
|
+
body={result?.content || String(args.prompt || part.arguments)}
|
|
241
|
+
status={status}
|
|
242
|
+
/>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (name.startsWith("subagent")) {
|
|
246
|
+
const action = name.replaceAll("_", " ").replace(/^subagent /u, "");
|
|
247
|
+
return (
|
|
248
|
+
<ActivityCard
|
|
249
|
+
family="subagent"
|
|
250
|
+
title={`${action[0]?.toUpperCase() || ""}${action.slice(1)} Subagent`}
|
|
251
|
+
meta={String(args.id || "")}
|
|
252
|
+
body={result?.content || part.arguments}
|
|
253
|
+
status={status}
|
|
254
|
+
/>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (name === "workflow") {
|
|
258
|
+
const script =
|
|
259
|
+
typeof args.script === "string" ? args.script : part.arguments;
|
|
260
|
+
const workflowName = String(
|
|
261
|
+
details.name ||
|
|
262
|
+
script.match(/\bname:\s*["'`]([^"'`]+)["'`]/u)?.[1] ||
|
|
263
|
+
"unnamed",
|
|
264
|
+
);
|
|
265
|
+
const agents = record(details.agents);
|
|
266
|
+
const meta = [
|
|
267
|
+
details.runId,
|
|
268
|
+
details.status,
|
|
269
|
+
agents.total
|
|
270
|
+
? `${Number(agents.total) - Number(agents.running || 0)}/${agents.total} agents`
|
|
271
|
+
: "",
|
|
272
|
+
]
|
|
273
|
+
.filter(Boolean)
|
|
274
|
+
.join(" · ");
|
|
275
|
+
return (
|
|
276
|
+
<ActivityCard
|
|
277
|
+
family="workflow"
|
|
278
|
+
title={`Workflow · ${workflowName}`}
|
|
279
|
+
meta={meta}
|
|
280
|
+
body={result?.content || script}
|
|
281
|
+
status={status}
|
|
282
|
+
/>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (name.startsWith("workflow")) {
|
|
286
|
+
return (
|
|
287
|
+
<ActivityCard
|
|
288
|
+
family="workflow"
|
|
289
|
+
title={name.replaceAll("_", " ")}
|
|
290
|
+
meta={String(args.runId || "")}
|
|
291
|
+
body={result?.content || part.arguments}
|
|
292
|
+
status={status}
|
|
293
|
+
/>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function useElapsed(start: number | undefined, active: boolean) {
|
|
300
|
+
const [now, setNow] = useState(Date.now());
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (!active) return;
|
|
303
|
+
const interval = window.setInterval(() => setNow(Date.now()), 1_000);
|
|
304
|
+
return () => window.clearInterval(interval);
|
|
305
|
+
}, [active]);
|
|
306
|
+
return start ? formatElapsedMs(start, active ? now : Date.now()) : "";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function ThinkingEvidence({
|
|
310
|
+
body,
|
|
311
|
+
start,
|
|
312
|
+
duration,
|
|
313
|
+
active,
|
|
314
|
+
}: {
|
|
315
|
+
body: string;
|
|
316
|
+
start?: number;
|
|
317
|
+
duration?: number;
|
|
318
|
+
active: boolean;
|
|
319
|
+
}) {
|
|
320
|
+
const { t } = useTranslation();
|
|
321
|
+
const elapsed = useElapsed(start, active);
|
|
322
|
+
const settled = duration ? formatElapsedMs(0, duration) : elapsed;
|
|
323
|
+
return (
|
|
324
|
+
<EvidenceDetails
|
|
325
|
+
body={body}
|
|
326
|
+
icon={<Lightbulb />}
|
|
327
|
+
name={active ? t("thinkingActive") : t("thinkingDone")}
|
|
328
|
+
status={active ? "running" : "done"}
|
|
329
|
+
summary={settled ? `· ${settled}` : undefined}
|
|
330
|
+
thinking
|
|
331
|
+
/>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function MessageActions({
|
|
336
|
+
content,
|
|
337
|
+
editable,
|
|
338
|
+
timestamp,
|
|
339
|
+
onResend,
|
|
340
|
+
}: {
|
|
341
|
+
content: string;
|
|
342
|
+
editable: boolean;
|
|
343
|
+
timestamp?: string;
|
|
344
|
+
onResend: (value: string) => Promise<boolean>;
|
|
345
|
+
}) {
|
|
346
|
+
const { t } = useTranslation();
|
|
347
|
+
const [copied, setCopied] = useState(false);
|
|
348
|
+
const [editing, setEditing] = useState(false);
|
|
349
|
+
const [draft, setDraft] = useState(content);
|
|
350
|
+
const editInput = useRef<HTMLTextAreaElement>(null);
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (editing) editInput.current?.focus();
|
|
353
|
+
}, [editing]);
|
|
354
|
+
if (editing) {
|
|
355
|
+
return (
|
|
356
|
+
<div className="message-editor">
|
|
357
|
+
<textarea
|
|
358
|
+
ref={editInput}
|
|
359
|
+
value={draft}
|
|
360
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
361
|
+
onKeyDown={(event) => {
|
|
362
|
+
if (event.key === "Escape") setEditing(false);
|
|
363
|
+
if (
|
|
364
|
+
event.key === "Enter" &&
|
|
365
|
+
!event.shiftKey &&
|
|
366
|
+
!event.nativeEvent.isComposing
|
|
367
|
+
) {
|
|
368
|
+
event.preventDefault();
|
|
369
|
+
void onResend(draft.trim()).then(
|
|
370
|
+
(sent) => sent && setEditing(false),
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}}
|
|
374
|
+
/>
|
|
375
|
+
<div className="message-edit-actions">
|
|
376
|
+
<button type="button" onClick={() => setEditing(false)}>
|
|
377
|
+
{t("cancel")}
|
|
378
|
+
</button>
|
|
379
|
+
<button
|
|
380
|
+
type="button"
|
|
381
|
+
className="confirm"
|
|
382
|
+
onClick={() =>
|
|
383
|
+
void onResend(draft.trim()).then(
|
|
384
|
+
(sent) => sent && setEditing(false),
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
>
|
|
388
|
+
{t("confirmEdit")}
|
|
389
|
+
</button>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const time = formatTurnTime(timestamp);
|
|
395
|
+
return (
|
|
396
|
+
<div className="message-actions">
|
|
397
|
+
{time && <time dateTime={timestamp}>{time}</time>}
|
|
398
|
+
{editable && (
|
|
399
|
+
<button
|
|
400
|
+
type="button"
|
|
401
|
+
aria-label={t("editMessage")}
|
|
402
|
+
title={t("editMessage")}
|
|
403
|
+
onClick={() => setEditing(true)}
|
|
404
|
+
>
|
|
405
|
+
<Pencil />
|
|
406
|
+
</button>
|
|
407
|
+
)}
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
aria-label={copied ? t("copiedMessage") : t("copyMessage")}
|
|
411
|
+
title={copied ? t("copiedMessage") : t("copyMessage")}
|
|
412
|
+
onClick={() => {
|
|
413
|
+
void navigator.clipboard.writeText(content).then(() => {
|
|
414
|
+
setCopied(true);
|
|
415
|
+
window.setTimeout(() => setCopied(false), 1_200);
|
|
416
|
+
});
|
|
417
|
+
}}
|
|
418
|
+
>
|
|
419
|
+
{copied ? <Check /> : <Clipboard />}
|
|
420
|
+
</button>
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function CustomResult({ message }: { message: WebLiveMessage }) {
|
|
426
|
+
const details = record(message.details);
|
|
427
|
+
if (message.customType === "subagent-result") {
|
|
428
|
+
return (
|
|
429
|
+
<ActivityCard
|
|
430
|
+
family="subagent"
|
|
431
|
+
title={`Subagent ${String(details.id || "")} · ${String(details.title || "result")}`}
|
|
432
|
+
meta={[details.outcome, details.elapsed].filter(Boolean).join(" · ")}
|
|
433
|
+
body={message.content}
|
|
434
|
+
status={canonicalStatus(details.status)}
|
|
435
|
+
/>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (message.customType === "workflow-result") {
|
|
439
|
+
const entries = Array.isArray(details.entries)
|
|
440
|
+
? details.entries.map(record)
|
|
441
|
+
: [];
|
|
442
|
+
const statuses = entries.map((entry) => canonicalStatus(entry.status));
|
|
443
|
+
const status: Status = statuses.includes("error")
|
|
444
|
+
? "error"
|
|
445
|
+
: statuses.includes("warn")
|
|
446
|
+
? "warn"
|
|
447
|
+
: statuses.length > 0 && statuses.every((value) => value === "done")
|
|
448
|
+
? "done"
|
|
449
|
+
: statuses.includes("running")
|
|
450
|
+
? "running"
|
|
451
|
+
: "unknown";
|
|
452
|
+
const body = entries.length
|
|
453
|
+
? entries
|
|
454
|
+
.map(
|
|
455
|
+
(entry) =>
|
|
456
|
+
`${entry.status === "completed" ? "✓" : "✗"} ${String(entry.summary || entry.runId || "run")}${entry.resultPreview ? `\nResult: ${entry.resultPreview}` : ""}`,
|
|
457
|
+
)
|
|
458
|
+
.join("\n")
|
|
459
|
+
: message.content;
|
|
460
|
+
return (
|
|
461
|
+
<ActivityCard
|
|
462
|
+
family="workflow"
|
|
463
|
+
title={
|
|
464
|
+
entries.length > 1
|
|
465
|
+
? `Workflow results · ${entries.length} runs`
|
|
466
|
+
: `Workflow ${String(entries[0]?.runId || "result")}`
|
|
467
|
+
}
|
|
468
|
+
body={body}
|
|
469
|
+
status={status}
|
|
470
|
+
/>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function isEmptyToolOutput(content: string) {
|
|
477
|
+
return ["", "[]", "{}", "null"].includes(content.trim());
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function buildEntries(
|
|
481
|
+
snapshot: WebSnapshot,
|
|
482
|
+
liveMessages: LiveEntry[],
|
|
483
|
+
): DisplayEntry[] {
|
|
484
|
+
const persisted = snapshot.selectedSession?.entries ?? [];
|
|
485
|
+
const entries = persisted.flatMap((entry: PersistedEntry): DisplayEntry[] =>
|
|
486
|
+
entry.type === "message" && entry.message
|
|
487
|
+
? [{ key: entry.id, timestamp: entry.timestamp, message: entry.message }]
|
|
488
|
+
: [],
|
|
489
|
+
);
|
|
490
|
+
const signatures = new Set(
|
|
491
|
+
entries.map(
|
|
492
|
+
(entry) => `${entry.message.role || ""}:${entry.message.content}`,
|
|
493
|
+
),
|
|
494
|
+
);
|
|
495
|
+
for (const live of liveMessages) {
|
|
496
|
+
if (signatures.has(`${live.message.role || ""}:${live.message.content}`))
|
|
497
|
+
continue;
|
|
498
|
+
entries.push({
|
|
499
|
+
key: live.key,
|
|
500
|
+
timestamp: new Date().toISOString(),
|
|
501
|
+
message: live.message,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return entries;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function groupRows(rows: RenderRow[], stepsLabel: string) {
|
|
508
|
+
const blocks: Array<{ grouped: boolean; rows: RenderRow[] }> = [];
|
|
509
|
+
for (const row of rows) {
|
|
510
|
+
const last = blocks.at(-1);
|
|
511
|
+
if (row.groupable && last?.grouped) last.rows.push(row);
|
|
512
|
+
else blocks.push({ grouped: Boolean(row.groupable), rows: [row] });
|
|
513
|
+
}
|
|
514
|
+
return blocks.map((block) => {
|
|
515
|
+
const blockKey = `${block.grouped ? "group" : "rows"}-${block.rows[0]?.key}`;
|
|
516
|
+
if (!block.grouped || block.rows.length < 4) {
|
|
517
|
+
return (
|
|
518
|
+
<Fragment key={blockKey}>
|
|
519
|
+
{block.rows.map((row) => (
|
|
520
|
+
<Fragment key={row.key}>{row.content}</Fragment>
|
|
521
|
+
))}
|
|
522
|
+
</Fragment>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
return (
|
|
526
|
+
<details
|
|
527
|
+
className={`tool-group ${block.rows.some((row) => row.error) ? "error" : ""}`}
|
|
528
|
+
key={blockKey}
|
|
529
|
+
>
|
|
530
|
+
<summary>
|
|
531
|
+
<span className="details-mark" aria-hidden="true" />
|
|
532
|
+
<span className="tool-group-icons" aria-hidden="true">
|
|
533
|
+
{block.rows.slice(0, 4).map((row) => (
|
|
534
|
+
<Fragment key={row.key}>{row.icon}</Fragment>
|
|
535
|
+
))}
|
|
536
|
+
</span>
|
|
537
|
+
<span>
|
|
538
|
+
{block.rows.length} {stepsLabel}
|
|
539
|
+
</span>
|
|
540
|
+
</summary>
|
|
541
|
+
<div className="tool-group-body">
|
|
542
|
+
{block.rows.map((row) => (
|
|
543
|
+
<Fragment key={row.key}>{row.content}</Fragment>
|
|
544
|
+
))}
|
|
545
|
+
</div>
|
|
546
|
+
</details>
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function Transcript(props: TranscriptProps) {
|
|
552
|
+
const { t } = useTranslation();
|
|
553
|
+
const viewport = useRef<HTMLDivElement>(null);
|
|
554
|
+
const pinned = useRef(true);
|
|
555
|
+
const lastPath = useRef<string | undefined>(undefined);
|
|
556
|
+
const lastScrollRequest = useRef(props.scrollToBottom);
|
|
557
|
+
const entries = useMemo(
|
|
558
|
+
() => buildEntries(props.snapshot, props.liveMessages),
|
|
559
|
+
[props.snapshot, props.liveMessages],
|
|
560
|
+
);
|
|
561
|
+
const selected = props.snapshot.selectedSession;
|
|
562
|
+
const active = selected?.id === props.snapshot.currentSessionId;
|
|
563
|
+
|
|
564
|
+
const { rows, turns } = useMemo(() => {
|
|
565
|
+
const results = new Map<string, WebLiveMessage>();
|
|
566
|
+
const familyIds = new Set<string>();
|
|
567
|
+
entries.forEach(({ message }) => {
|
|
568
|
+
if (message.role === "toolResult" && message.toolCallId)
|
|
569
|
+
results.set(message.toolCallId, message);
|
|
570
|
+
message.parts?.forEach((part) => {
|
|
571
|
+
if (
|
|
572
|
+
part.type === "toolCall" &&
|
|
573
|
+
part.id &&
|
|
574
|
+
/^(subagent|workflow)/u.test(part.name)
|
|
575
|
+
)
|
|
576
|
+
familyIds.add(part.id);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
const turnItems: Array<{ id: number; title: string }> = [];
|
|
580
|
+
let turn = 0;
|
|
581
|
+
let lastUserIndex = -1;
|
|
582
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
583
|
+
if (entries[index]?.message.role === "user") {
|
|
584
|
+
lastUserIndex = index;
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const lastAssistantByTurn = new Set<number>();
|
|
589
|
+
let assistantCandidate = -1;
|
|
590
|
+
entries.forEach(({ message }, index) => {
|
|
591
|
+
if (message.role === "user") {
|
|
592
|
+
if (assistantCandidate >= 0)
|
|
593
|
+
lastAssistantByTurn.add(assistantCandidate);
|
|
594
|
+
assistantCandidate = -1;
|
|
595
|
+
} else if (message.role === "assistant" && message.content.trim())
|
|
596
|
+
assistantCandidate = index;
|
|
597
|
+
});
|
|
598
|
+
if (assistantCandidate >= 0) lastAssistantByTurn.add(assistantCandidate);
|
|
599
|
+
|
|
600
|
+
const rendered = entries.flatMap((entry, index): RenderRow[] => {
|
|
601
|
+
const message = entry.message;
|
|
602
|
+
if (message.role === "custom") {
|
|
603
|
+
return [
|
|
604
|
+
{
|
|
605
|
+
key: entry.key,
|
|
606
|
+
content: (
|
|
607
|
+
<article className="message-row assistant detail-only">
|
|
608
|
+
<div className="message-content">
|
|
609
|
+
<CustomResult message={message} />
|
|
610
|
+
</div>
|
|
611
|
+
</article>
|
|
612
|
+
),
|
|
613
|
+
},
|
|
614
|
+
];
|
|
615
|
+
}
|
|
616
|
+
if (message.role === "user") {
|
|
617
|
+
turn++;
|
|
618
|
+
turnItems.push({ id: turn, title: turnTitle(message.content) });
|
|
619
|
+
return [
|
|
620
|
+
{
|
|
621
|
+
key: entry.key,
|
|
622
|
+
content: (
|
|
623
|
+
<article className="message-row user" id={`turn-${turn}`}>
|
|
624
|
+
<div className="message-content">
|
|
625
|
+
<div className="message-body">{message.content}</div>
|
|
626
|
+
</div>
|
|
627
|
+
<MessageActions
|
|
628
|
+
content={message.content}
|
|
629
|
+
editable={active && index === lastUserIndex}
|
|
630
|
+
timestamp={entry.timestamp}
|
|
631
|
+
onResend={props.onResend}
|
|
632
|
+
/>
|
|
633
|
+
</article>
|
|
634
|
+
),
|
|
635
|
+
},
|
|
636
|
+
];
|
|
637
|
+
}
|
|
638
|
+
if (message.role === "assistant") {
|
|
639
|
+
const detailRows: RenderRow[] = [];
|
|
640
|
+
message.parts?.forEach((part, partIndex) => {
|
|
641
|
+
if (part.type === "thinking") {
|
|
642
|
+
const isLive =
|
|
643
|
+
active && props.liveRunning && index === entries.length - 1;
|
|
644
|
+
detailRows.push({
|
|
645
|
+
key: `${entry.key}-thinking-${partIndex}`,
|
|
646
|
+
icon: <Lightbulb />,
|
|
647
|
+
content: (
|
|
648
|
+
<article className="message-row assistant detail-only">
|
|
649
|
+
<div className="message-content">
|
|
650
|
+
<ThinkingEvidence
|
|
651
|
+
body={part.text}
|
|
652
|
+
active={isLive}
|
|
653
|
+
start={props.thinkingStarts[entry.key]}
|
|
654
|
+
duration={props.thinkingDurations[entry.key]}
|
|
655
|
+
/>
|
|
656
|
+
</div>
|
|
657
|
+
</article>
|
|
658
|
+
),
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
if (part.type === "toolCall") {
|
|
662
|
+
const result = part.id ? results.get(part.id) : undefined;
|
|
663
|
+
const card = familyCard(part, result);
|
|
664
|
+
const args = parseArguments(part.arguments);
|
|
665
|
+
const toolIcon = iconForTool(part.name);
|
|
666
|
+
detailRows.push({
|
|
667
|
+
key: `${entry.key}-tool-${part.id || partIndex}`,
|
|
668
|
+
groupable: !card,
|
|
669
|
+
error: Boolean(result?.isError),
|
|
670
|
+
icon: toolIcon,
|
|
671
|
+
content: (
|
|
672
|
+
<article className="message-row assistant detail-only">
|
|
673
|
+
<div className="message-content">
|
|
674
|
+
{card || (
|
|
675
|
+
<EvidenceDetails
|
|
676
|
+
body={
|
|
677
|
+
part.name === "bash" &&
|
|
678
|
+
typeof args.command === "string"
|
|
679
|
+
? args.command
|
|
680
|
+
: part.arguments
|
|
681
|
+
}
|
|
682
|
+
icon={toolIcon}
|
|
683
|
+
name={part.name || "tool"}
|
|
684
|
+
summary={toolSummary(part.name, args)}
|
|
685
|
+
status={resultStatus(result)}
|
|
686
|
+
/>
|
|
687
|
+
)}
|
|
688
|
+
</div>
|
|
689
|
+
</article>
|
|
690
|
+
),
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
if (message.content.trim())
|
|
695
|
+
detailRows.push({
|
|
696
|
+
key: `${entry.key}-answer`,
|
|
697
|
+
content: (
|
|
698
|
+
<article className="message-row assistant">
|
|
699
|
+
<div className="message-content">
|
|
700
|
+
<Markdown>{message.content}</Markdown>
|
|
701
|
+
</div>
|
|
702
|
+
{lastAssistantByTurn.has(index) && (
|
|
703
|
+
<MessageActions
|
|
704
|
+
content={message.content}
|
|
705
|
+
editable={false}
|
|
706
|
+
timestamp={entry.timestamp}
|
|
707
|
+
onResend={props.onResend}
|
|
708
|
+
/>
|
|
709
|
+
)}
|
|
710
|
+
</article>
|
|
711
|
+
),
|
|
712
|
+
});
|
|
713
|
+
return detailRows;
|
|
714
|
+
}
|
|
715
|
+
if (message.role === "toolResult") {
|
|
716
|
+
if (message.toolCallId && familyIds.has(message.toolCallId)) return [];
|
|
717
|
+
const family = message.toolName?.startsWith("subagent")
|
|
718
|
+
? "subagent"
|
|
719
|
+
: message.toolName?.startsWith("workflow")
|
|
720
|
+
? "workflow"
|
|
721
|
+
: null;
|
|
722
|
+
const status = resultStatus(message);
|
|
723
|
+
const toolName = message.toolName || "tool";
|
|
724
|
+
const icon =
|
|
725
|
+
family === "subagent" ? (
|
|
726
|
+
<Bot key={`${entry.key}-icon`} />
|
|
727
|
+
) : family === "workflow" ? (
|
|
728
|
+
<Workflow key={`${entry.key}-icon`} />
|
|
729
|
+
) : (
|
|
730
|
+
iconForTool(toolName)
|
|
731
|
+
);
|
|
732
|
+
const content = family ? (
|
|
733
|
+
<ActivityCard
|
|
734
|
+
key={`${entry.key}-card`}
|
|
735
|
+
family={family}
|
|
736
|
+
title={`${toolName.replaceAll("_", " ")} · ${compactSummary(message.content)}`}
|
|
737
|
+
body={message.content}
|
|
738
|
+
status={status}
|
|
739
|
+
/>
|
|
740
|
+
) : isEmptyToolOutput(message.content) ? (
|
|
741
|
+
<div className="tool-line-empty" key={`${entry.key}-empty`}>
|
|
742
|
+
<span className="tool-icon">{icon}</span>
|
|
743
|
+
<span className="tool-name">{toolName}</span>
|
|
744
|
+
<span className="tool-summary">{t("noOutput")}</span>
|
|
745
|
+
<StatusMark status={status} />
|
|
746
|
+
</div>
|
|
747
|
+
) : (
|
|
748
|
+
<EvidenceDetails
|
|
749
|
+
key={`${entry.key}-evidence`}
|
|
750
|
+
body={message.content}
|
|
751
|
+
icon={icon}
|
|
752
|
+
name={toolName}
|
|
753
|
+
summary={compactSummary(message.content)}
|
|
754
|
+
status={status}
|
|
755
|
+
/>
|
|
756
|
+
);
|
|
757
|
+
return [
|
|
758
|
+
{
|
|
759
|
+
key: entry.key,
|
|
760
|
+
groupable: !family,
|
|
761
|
+
error: status === "error",
|
|
762
|
+
icon,
|
|
763
|
+
content: (
|
|
764
|
+
<article className="message-row assistant detail-only">
|
|
765
|
+
<div className="message-content">{content}</div>
|
|
766
|
+
</article>
|
|
767
|
+
),
|
|
768
|
+
},
|
|
769
|
+
];
|
|
770
|
+
}
|
|
771
|
+
return [];
|
|
772
|
+
});
|
|
773
|
+
return { rows: rendered, turns: turnItems };
|
|
774
|
+
}, [
|
|
775
|
+
active,
|
|
776
|
+
entries,
|
|
777
|
+
props.liveRunning,
|
|
778
|
+
props.onResend,
|
|
779
|
+
props.thinkingDurations,
|
|
780
|
+
props.thinkingStarts,
|
|
781
|
+
t,
|
|
782
|
+
]);
|
|
783
|
+
|
|
784
|
+
useLayoutEffect(() => {
|
|
785
|
+
// Streamed content can grow without changing message keys.
|
|
786
|
+
void entries;
|
|
787
|
+
const element = viewport.current;
|
|
788
|
+
if (!element || !selected) return;
|
|
789
|
+
const changed = lastPath.current !== selected.path;
|
|
790
|
+
const requested = lastScrollRequest.current !== props.scrollToBottom;
|
|
791
|
+
lastScrollRequest.current = props.scrollToBottom;
|
|
792
|
+
if (changed || requested || pinned.current) {
|
|
793
|
+
pinned.current = true;
|
|
794
|
+
if (typeof element.scrollTo === "function") {
|
|
795
|
+
element.scrollTo({ top: element.scrollHeight, behavior: "instant" });
|
|
796
|
+
} else {
|
|
797
|
+
element.scrollTop = element.scrollHeight;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
lastPath.current = selected.path;
|
|
801
|
+
}, [selected, entries, props.scrollToBottom]);
|
|
802
|
+
|
|
803
|
+
const running =
|
|
804
|
+
active &&
|
|
805
|
+
(props.snapshot.runtime.status === "running" || props.liveRunning);
|
|
806
|
+
const runningLabel = props.liveRetry
|
|
807
|
+
? `${t("modelRetrying")} (${props.liveRetry.attempt}/${props.liveRetry.maxAttempts})`
|
|
808
|
+
: props.livePhase === "preparing"
|
|
809
|
+
? t("modelPreparing")
|
|
810
|
+
: t("modelRunning");
|
|
811
|
+
|
|
812
|
+
return (
|
|
813
|
+
<>
|
|
814
|
+
<div
|
|
815
|
+
ref={viewport}
|
|
816
|
+
className="conversation"
|
|
817
|
+
role="log"
|
|
818
|
+
aria-label="Conversation"
|
|
819
|
+
onScroll={(event) => {
|
|
820
|
+
const element = event.currentTarget;
|
|
821
|
+
pinned.current =
|
|
822
|
+
element.scrollTop + element.clientHeight >=
|
|
823
|
+
element.scrollHeight - 48;
|
|
824
|
+
}}
|
|
825
|
+
>
|
|
826
|
+
{groupRows(rows, t("stepsLabel"))}
|
|
827
|
+
{running && (
|
|
828
|
+
<div
|
|
829
|
+
className="conversation-running"
|
|
830
|
+
role="status"
|
|
831
|
+
aria-live="polite"
|
|
832
|
+
>
|
|
833
|
+
<span className="conversation-running-dot" />
|
|
834
|
+
<span>{runningLabel}</span>
|
|
835
|
+
</div>
|
|
836
|
+
)}
|
|
837
|
+
</div>
|
|
838
|
+
{turns.length > 1 && (
|
|
839
|
+
<nav className="turn-rail" aria-label={t("conversationTurns")}>
|
|
840
|
+
{turns.map((item) => (
|
|
841
|
+
<button
|
|
842
|
+
key={item.id}
|
|
843
|
+
className="turn-tick"
|
|
844
|
+
type="button"
|
|
845
|
+
title={item.title}
|
|
846
|
+
onClick={() =>
|
|
847
|
+
document
|
|
848
|
+
.getElementById(`turn-${item.id}`)
|
|
849
|
+
?.scrollIntoView({ block: "start", behavior: "smooth" })
|
|
850
|
+
}
|
|
851
|
+
>
|
|
852
|
+
<span className="turn-tick-mark" />
|
|
853
|
+
<span className="turn-tick-label">{item.title}</span>
|
|
854
|
+
</button>
|
|
855
|
+
))}
|
|
856
|
+
</nav>
|
|
857
|
+
)}
|
|
858
|
+
</>
|
|
859
|
+
);
|
|
860
|
+
}
|