@narumitw/pi-webui 0.25.0 → 0.29.1
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/README.md +10 -5
- package/package.json +15 -6
- package/src/index.ts +1 -0
- package/src/server.ts +12 -11
- package/src/web/app.css +2 -0
- package/src/web/app.js +102 -995
- package/src/web/image-drag.js +0 -73
- package/src/web/index.html +3 -119
- package/src/web/markdown.js +0 -69
- package/src/web/transcript.js +0 -258
- package/src/web/ui/app.jsx +908 -0
- package/src/web/ui/client.js +690 -0
- package/src/web/ui/overlays.jsx +126 -0
- package/src/web/ui/styles.css +573 -0
- package/src/web/ui/view-helpers.js +68 -0
- package/src/web/styles.css +0 -946
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
import "@radix-ui/themes/styles.css";
|
|
2
|
+
import "@radix-ui/colors/jade.css";
|
|
3
|
+
import "@radix-ui/colors/jade-dark.css";
|
|
4
|
+
import "@radix-ui/colors/red.css";
|
|
5
|
+
import "@radix-ui/colors/red-dark.css";
|
|
6
|
+
import {
|
|
7
|
+
ArrowDownIcon,
|
|
8
|
+
CheckCircledIcon,
|
|
9
|
+
ChevronDownIcon,
|
|
10
|
+
CodeIcon,
|
|
11
|
+
ExclamationTriangleIcon,
|
|
12
|
+
FileTextIcon,
|
|
13
|
+
ImageIcon,
|
|
14
|
+
InfoCircledIcon,
|
|
15
|
+
PaperPlaneIcon,
|
|
16
|
+
ReloadIcon,
|
|
17
|
+
StopwatchIcon,
|
|
18
|
+
TrashIcon,
|
|
19
|
+
} from "@radix-ui/react-icons";
|
|
20
|
+
import {
|
|
21
|
+
Badge,
|
|
22
|
+
Box,
|
|
23
|
+
Button,
|
|
24
|
+
Callout,
|
|
25
|
+
Card,
|
|
26
|
+
Code,
|
|
27
|
+
Container,
|
|
28
|
+
Flex,
|
|
29
|
+
Heading,
|
|
30
|
+
IconButton,
|
|
31
|
+
Link,
|
|
32
|
+
Spinner,
|
|
33
|
+
Text,
|
|
34
|
+
TextArea,
|
|
35
|
+
Theme,
|
|
36
|
+
} from "@radix-ui/themes";
|
|
37
|
+
import { Collapsible, Popover, Tooltip } from "radix-ui";
|
|
38
|
+
import { StrictMode, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
39
|
+
import { createRoot } from "react-dom/client";
|
|
40
|
+
import { dropAfterTarget, imagesStackVertically } from "../image-drag.js";
|
|
41
|
+
import { parseMarkdown } from "../markdown.js";
|
|
42
|
+
import {
|
|
43
|
+
isCollapsibleMessageRole,
|
|
44
|
+
retainedImageStatus,
|
|
45
|
+
toolCommandPreview,
|
|
46
|
+
toolPhaseLabel,
|
|
47
|
+
} from "../transcript.js";
|
|
48
|
+
import {
|
|
49
|
+
attachmentItemLabel,
|
|
50
|
+
attachmentPhaseLabel,
|
|
51
|
+
canSubmit,
|
|
52
|
+
composerLocked,
|
|
53
|
+
composerStatus,
|
|
54
|
+
connectionLabel,
|
|
55
|
+
primaryActionLabel,
|
|
56
|
+
webClient,
|
|
57
|
+
} from "./client.js";
|
|
58
|
+
import {
|
|
59
|
+
ClearAttachmentsDialog,
|
|
60
|
+
ForgetImageDialog,
|
|
61
|
+
ImagePreviewDialog,
|
|
62
|
+
PageFooter,
|
|
63
|
+
} from "./overlays.jsx";
|
|
64
|
+
import "./styles.css";
|
|
65
|
+
import {
|
|
66
|
+
connectionColor,
|
|
67
|
+
hasDraggedFile,
|
|
68
|
+
isNearBottom,
|
|
69
|
+
isSupportedImageFile,
|
|
70
|
+
knownRole,
|
|
71
|
+
resizeInput,
|
|
72
|
+
roleLabel,
|
|
73
|
+
safeJson,
|
|
74
|
+
withStableKeys,
|
|
75
|
+
} from "./view-helpers.js";
|
|
76
|
+
|
|
77
|
+
const ACCEPTED_IMAGES =
|
|
78
|
+
"image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/heic,image/heif,image/avif,.bmp,.tif,.tiff,.heic,.heif,.avif";
|
|
79
|
+
const DRAG_TYPE = "application/x-pi-webui-image";
|
|
80
|
+
|
|
81
|
+
function useAppearance() {
|
|
82
|
+
const query = "(prefers-color-scheme: dark)";
|
|
83
|
+
const [appearance, setAppearance] = useState(() =>
|
|
84
|
+
window.matchMedia(query).matches ? "dark" : "light",
|
|
85
|
+
);
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const media = window.matchMedia(query);
|
|
88
|
+
const update = () => setAppearance(media.matches ? "dark" : "light");
|
|
89
|
+
media.addEventListener("change", update);
|
|
90
|
+
return () => media.removeEventListener("change", update);
|
|
91
|
+
}, []);
|
|
92
|
+
return appearance;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function App() {
|
|
96
|
+
const view = useSyncExternalStore(webClient.subscribe, webClient.getSnapshot);
|
|
97
|
+
const appearance = useAppearance();
|
|
98
|
+
const [clearOpen, setClearOpen] = useState(false);
|
|
99
|
+
const [forgetId, setForgetId] = useState("");
|
|
100
|
+
const [previewImage, setPreviewImage] = useState();
|
|
101
|
+
const [dragActive, setDragActive] = useState(false);
|
|
102
|
+
const dragDepth = useRef(0);
|
|
103
|
+
const clearReturnFocus = useRef();
|
|
104
|
+
const forgetReturnFocus = useRef();
|
|
105
|
+
const previewReturnFocus = useRef();
|
|
106
|
+
const model = view.model;
|
|
107
|
+
|
|
108
|
+
useEffect(() => webClient.start(), []);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
const update = () => webClient.setNearBottom(isNearBottom());
|
|
111
|
+
window.addEventListener("scroll", update, { passive: true });
|
|
112
|
+
return () => window.removeEventListener("scroll", update);
|
|
113
|
+
}, []);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!view.scrollToLatest) return;
|
|
116
|
+
requestAnimationFrame(() =>
|
|
117
|
+
window.scrollTo({
|
|
118
|
+
top: document.body.scrollHeight,
|
|
119
|
+
behavior: model.following ? "auto" : "smooth",
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}, [view, model.following]);
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!view.focusTarget) return;
|
|
125
|
+
requestAnimationFrame(() => {
|
|
126
|
+
if (view.focusTarget === "input") document.querySelector("#message-input")?.focus();
|
|
127
|
+
else {
|
|
128
|
+
const id = CSS.escape(view.focusTarget);
|
|
129
|
+
document.querySelector(`[data-image-id="${id}"]`)?.focus();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}, [view]);
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (previewImage && !model.images.some((image) => image.id === previewImage.id)) {
|
|
135
|
+
closeImagePreview();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const paste = (event) => {
|
|
140
|
+
const files = [...(event.clipboardData?.files ?? [])].filter(isSupportedImageFile);
|
|
141
|
+
if (files.length === 0 || composerLocked(view)) return;
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
void webClient.addFiles(files);
|
|
144
|
+
};
|
|
145
|
+
document.addEventListener("paste", paste);
|
|
146
|
+
return () => document.removeEventListener("paste", paste);
|
|
147
|
+
}, [view]);
|
|
148
|
+
|
|
149
|
+
function openClearDialog(trigger) {
|
|
150
|
+
clearReturnFocus.current = trigger;
|
|
151
|
+
setClearOpen(true);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function setClearDialogOpen(open) {
|
|
155
|
+
setClearOpen(open);
|
|
156
|
+
if (!open) requestAnimationFrame(() => clearReturnFocus.current?.focus());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function openForgetDialog(id, trigger) {
|
|
160
|
+
forgetReturnFocus.current = trigger;
|
|
161
|
+
setForgetId(id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function closeForgetDialog() {
|
|
165
|
+
setForgetId("");
|
|
166
|
+
requestAnimationFrame(() => forgetReturnFocus.current?.focus());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function openImagePreview(image, trigger) {
|
|
170
|
+
previewReturnFocus.current = trigger ?? document.activeElement;
|
|
171
|
+
setPreviewImage(image);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function closeImagePreview() {
|
|
175
|
+
setPreviewImage(undefined);
|
|
176
|
+
requestAnimationFrame(() => previewReturnFocus.current?.focus());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const fileDropProps = {
|
|
180
|
+
onDragEnter(event) {
|
|
181
|
+
if (!hasDraggedFile(event.nativeEvent) || composerLocked(view)) return;
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
dragDepth.current += 1;
|
|
184
|
+
setDragActive(true);
|
|
185
|
+
},
|
|
186
|
+
onDragOver(event) {
|
|
187
|
+
if (!hasDraggedFile(event.nativeEvent) || composerLocked(view)) return;
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
},
|
|
190
|
+
onDragLeave() {
|
|
191
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
192
|
+
if (dragDepth.current === 0) setDragActive(false);
|
|
193
|
+
},
|
|
194
|
+
onDrop(event) {
|
|
195
|
+
dragDepth.current = 0;
|
|
196
|
+
setDragActive(false);
|
|
197
|
+
const files = [...(event.dataTransfer?.files ?? [])].filter(isSupportedImageFile);
|
|
198
|
+
if (files.length === 0 || composerLocked(view)) return;
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
void webClient.addFiles(files);
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<Theme
|
|
206
|
+
appearance={appearance}
|
|
207
|
+
accentColor="jade"
|
|
208
|
+
grayColor="sand"
|
|
209
|
+
panelBackground="translucent"
|
|
210
|
+
radius="large"
|
|
211
|
+
scaling="100%"
|
|
212
|
+
>
|
|
213
|
+
<Tooltip.Provider delayDuration={350}>
|
|
214
|
+
<Link className="skip-link" href="#message-input" highContrast>
|
|
215
|
+
Skip to message
|
|
216
|
+
</Link>
|
|
217
|
+
<SessionHeader model={model} />
|
|
218
|
+
<Container asChild size="3" px={{ initial: "3", sm: "5" }}>
|
|
219
|
+
<main>
|
|
220
|
+
<Conversation model={model} onForget={openForgetDialog} view={view} />
|
|
221
|
+
{model.unseenUpdateIds.length > 0 && (
|
|
222
|
+
<Button
|
|
223
|
+
className="jump-latest"
|
|
224
|
+
highContrast
|
|
225
|
+
id="jump-latest"
|
|
226
|
+
onClick={() => webClient.followLatest()}
|
|
227
|
+
variant="surface"
|
|
228
|
+
>
|
|
229
|
+
<ArrowDownIcon />
|
|
230
|
+
{model.unseenUpdateIds.length > 1
|
|
231
|
+
? `${model.unseenUpdateIds.length} new updates`
|
|
232
|
+
: "Jump to latest"}
|
|
233
|
+
</Button>
|
|
234
|
+
)}
|
|
235
|
+
<BlockingState model={model} />
|
|
236
|
+
<Composer
|
|
237
|
+
dragActive={dragActive}
|
|
238
|
+
onClear={(event) => openClearDialog(event.currentTarget)}
|
|
239
|
+
onPreview={openImagePreview}
|
|
240
|
+
view={view}
|
|
241
|
+
{...fileDropProps}
|
|
242
|
+
/>
|
|
243
|
+
</main>
|
|
244
|
+
</Container>
|
|
245
|
+
<PageFooter />
|
|
246
|
+
<ClearAttachmentsDialog
|
|
247
|
+
count={model.images.length}
|
|
248
|
+
onOpenChange={setClearDialogOpen}
|
|
249
|
+
open={clearOpen}
|
|
250
|
+
/>
|
|
251
|
+
<ForgetImageDialog id={forgetId} onOpenChange={(open) => !open && closeForgetDialog()} />
|
|
252
|
+
<ImagePreviewDialog
|
|
253
|
+
image={previewImage}
|
|
254
|
+
onOpenChange={(open) => !open && closeImagePreview()}
|
|
255
|
+
revision={model.attachmentRevision}
|
|
256
|
+
/>
|
|
257
|
+
</Tooltip.Provider>
|
|
258
|
+
</Theme>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function SessionHeader({ model }) {
|
|
263
|
+
return (
|
|
264
|
+
<Container asChild size="3" px={{ initial: "3", sm: "5" }}>
|
|
265
|
+
<header className="session-header">
|
|
266
|
+
<Box className="session-identity">
|
|
267
|
+
<Text as="p" className="eyebrow" color="jade" size="1" weight="bold">
|
|
268
|
+
Pi WebUI
|
|
269
|
+
</Text>
|
|
270
|
+
<Heading as="h1" id="project-name" size="6">
|
|
271
|
+
{model.session?.projectName ?? "Connecting…"}
|
|
272
|
+
</Heading>
|
|
273
|
+
<Text as="p" color="gray" id="session-name" size="2">
|
|
274
|
+
{model.session?.name ?? "Current session"}
|
|
275
|
+
</Text>
|
|
276
|
+
</Box>
|
|
277
|
+
<Flex align="center" className="session-controls" gap="2">
|
|
278
|
+
<Badge color={connectionColor(model)} id="connection-status" role="status" size="2">
|
|
279
|
+
{model.activity === "running" && !model.stale && !model.closed ? (
|
|
280
|
+
<Spinner size="1" />
|
|
281
|
+
) : (
|
|
282
|
+
<CheckCircledIcon />
|
|
283
|
+
)}
|
|
284
|
+
{connectionLabel(model)}
|
|
285
|
+
</Badge>
|
|
286
|
+
<Popover.Root>
|
|
287
|
+
<Popover.Trigger asChild>
|
|
288
|
+
<Button color="gray" highContrast variant="ghost">
|
|
289
|
+
<InfoCircledIcon /> Session details <ChevronDownIcon />
|
|
290
|
+
</Button>
|
|
291
|
+
</Popover.Trigger>
|
|
292
|
+
<Popover.Content align="end" className="session-popover" sideOffset={6}>
|
|
293
|
+
<Text as="div" color="gray" size="1" weight="bold">
|
|
294
|
+
Working directory
|
|
295
|
+
</Text>
|
|
296
|
+
<Code id="cwd" size="1" variant="ghost">
|
|
297
|
+
{model.session?.cwd ?? "—"}
|
|
298
|
+
</Code>
|
|
299
|
+
<Popover.Arrow className="popover-arrow" />
|
|
300
|
+
</Popover.Content>
|
|
301
|
+
</Popover.Root>
|
|
302
|
+
</Flex>
|
|
303
|
+
</header>
|
|
304
|
+
</Container>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function Conversation({ model, onForget, view }) {
|
|
309
|
+
const tools = useMemo(() => new Map(model.tools.map((tool) => [tool.id, tool])), [model.tools]);
|
|
310
|
+
const retained = useMemo(
|
|
311
|
+
() => new Set((model.sentImages.items ?? []).map((image) => image.id)),
|
|
312
|
+
[model.sentImages.items],
|
|
313
|
+
);
|
|
314
|
+
return (
|
|
315
|
+
<section className="conversation" aria-labelledby="conversation-title">
|
|
316
|
+
<Heading as="h2" className="visually-hidden" id="conversation-title">
|
|
317
|
+
Current Pi conversation
|
|
318
|
+
</Heading>
|
|
319
|
+
{model.messages.length === 0 && (
|
|
320
|
+
<Flex align="center" className="empty-state" direction="column" justify="center">
|
|
321
|
+
<FileTextIcon className="empty-icon" />
|
|
322
|
+
<Heading as="h3" size="3">
|
|
323
|
+
No messages yet
|
|
324
|
+
</Heading>
|
|
325
|
+
<Text color="gray" size="2">
|
|
326
|
+
Messages from this Pi session will appear here.
|
|
327
|
+
</Text>
|
|
328
|
+
</Flex>
|
|
329
|
+
)}
|
|
330
|
+
<Box asChild>
|
|
331
|
+
<ol id="transcript">
|
|
332
|
+
{model.messages.map((message) => (
|
|
333
|
+
<Message
|
|
334
|
+
key={message.id}
|
|
335
|
+
message={message}
|
|
336
|
+
onForget={onForget}
|
|
337
|
+
retained={retained}
|
|
338
|
+
tools={tools}
|
|
339
|
+
/>
|
|
340
|
+
))}
|
|
341
|
+
</ol>
|
|
342
|
+
</Box>
|
|
343
|
+
<Text
|
|
344
|
+
as="p"
|
|
345
|
+
className="visually-hidden"
|
|
346
|
+
id="transcript-status"
|
|
347
|
+
role="status"
|
|
348
|
+
aria-live="polite"
|
|
349
|
+
>
|
|
350
|
+
{view.transcriptAnnouncement}
|
|
351
|
+
</Text>
|
|
352
|
+
</section>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function Message({ message, onForget, retained, tools }) {
|
|
357
|
+
const role = knownRole(message.role);
|
|
358
|
+
const body = (
|
|
359
|
+
<Box className="message-body">
|
|
360
|
+
{withStableKeys(message.content ?? []).map(({ key, value: block }) => (
|
|
361
|
+
<MessageBlock
|
|
362
|
+
key={key}
|
|
363
|
+
block={block}
|
|
364
|
+
onForget={onForget}
|
|
365
|
+
retained={retained}
|
|
366
|
+
tool={tools.get(block.id)}
|
|
367
|
+
/>
|
|
368
|
+
))}
|
|
369
|
+
{message.errorMessage && (
|
|
370
|
+
<Callout.Root color="red" role="alert" size="1" variant="soft">
|
|
371
|
+
<Callout.Icon>
|
|
372
|
+
<ExclamationTriangleIcon />
|
|
373
|
+
</Callout.Icon>
|
|
374
|
+
<Callout.Text>{message.errorMessage}</Callout.Text>
|
|
375
|
+
</Callout.Root>
|
|
376
|
+
)}
|
|
377
|
+
</Box>
|
|
378
|
+
);
|
|
379
|
+
return (
|
|
380
|
+
<Box asChild className={`message ${role}`}>
|
|
381
|
+
<li>
|
|
382
|
+
{isCollapsibleMessageRole(message.role) ? (
|
|
383
|
+
<Collapsible.Root className="message-disclosure">
|
|
384
|
+
<Collapsible.Trigger asChild>
|
|
385
|
+
<Button className="message-heading" color="gray" variant="ghost">
|
|
386
|
+
<CodeIcon /> {roleLabel(message)} <ChevronDownIcon className="disclosure-icon" />
|
|
387
|
+
</Button>
|
|
388
|
+
</Collapsible.Trigger>
|
|
389
|
+
<Collapsible.Content>{body}</Collapsible.Content>
|
|
390
|
+
</Collapsible.Root>
|
|
391
|
+
) : (
|
|
392
|
+
<>
|
|
393
|
+
<Text as="div" className="message-heading" color="gray" size="1" weight="bold">
|
|
394
|
+
{roleLabel(message)}
|
|
395
|
+
</Text>
|
|
396
|
+
{body}
|
|
397
|
+
</>
|
|
398
|
+
)}
|
|
399
|
+
</li>
|
|
400
|
+
</Box>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function MessageBlock({ block, onForget, retained, tool }) {
|
|
405
|
+
if (block.type === "text") return <Markdown text={block.text} />;
|
|
406
|
+
if (block.type === "thinking") {
|
|
407
|
+
return (
|
|
408
|
+
<Collapsible.Root className="thinking">
|
|
409
|
+
<Collapsible.Trigger asChild>
|
|
410
|
+
<Button color="gray" variant="ghost">
|
|
411
|
+
<StopwatchIcon /> Thinking <ChevronDownIcon className="disclosure-icon" />
|
|
412
|
+
</Button>
|
|
413
|
+
</Collapsible.Trigger>
|
|
414
|
+
<Collapsible.Content>
|
|
415
|
+
<pre>{block.text}</pre>
|
|
416
|
+
</Collapsible.Content>
|
|
417
|
+
</Collapsible.Root>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (block.type === "toolCall") return <ToolCall call={block} tool={tool} />;
|
|
421
|
+
if (block.type === "image") {
|
|
422
|
+
return <SentImageChip block={block} onForget={onForget} retained={retained} />;
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function Markdown({ text }) {
|
|
428
|
+
return (
|
|
429
|
+
<Box className="message-markdown">
|
|
430
|
+
{withStableKeys(parseMarkdown(text)).map(({ key, value: block }) => (
|
|
431
|
+
<MarkdownBlock block={block} key={key} />
|
|
432
|
+
))}
|
|
433
|
+
</Box>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function MarkdownBlock({ block }) {
|
|
438
|
+
if (block.type === "heading") {
|
|
439
|
+
const level = Math.min(6, block.level + 2);
|
|
440
|
+
return (
|
|
441
|
+
<Heading as={`h${level}`} className="markdown-heading" size="3">
|
|
442
|
+
<MarkdownInline nodes={block.children} />
|
|
443
|
+
</Heading>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (block.type === "list") {
|
|
447
|
+
const List = block.ordered ? "ol" : "ul";
|
|
448
|
+
return (
|
|
449
|
+
<List className="markdown-list">
|
|
450
|
+
{withStableKeys(block.items).map(({ key, value: item }) => (
|
|
451
|
+
<li key={key}>
|
|
452
|
+
<MarkdownInline nodes={item} />
|
|
453
|
+
</li>
|
|
454
|
+
))}
|
|
455
|
+
</List>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
if (block.type === "blockquote") {
|
|
459
|
+
return (
|
|
460
|
+
<blockquote>
|
|
461
|
+
{withStableKeys(block.children).map(({ key, value: child }) => (
|
|
462
|
+
<MarkdownBlock block={child} key={key} />
|
|
463
|
+
))}
|
|
464
|
+
</blockquote>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
if (block.type === "codeBlock") {
|
|
468
|
+
return (
|
|
469
|
+
<pre className="markdown-code">
|
|
470
|
+
<code data-language={block.language || undefined}>{block.text}</code>
|
|
471
|
+
</pre>
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
return (
|
|
475
|
+
<Text as="p" className="message-text">
|
|
476
|
+
<MarkdownInline nodes={block.children} />
|
|
477
|
+
</Text>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function MarkdownInline({ nodes }) {
|
|
482
|
+
return withStableKeys(nodes).map(({ key, value: node }) => {
|
|
483
|
+
if (node.type === "text") return node.text;
|
|
484
|
+
if (node.type === "code") return <Code key={key}>{node.text}</Code>;
|
|
485
|
+
if (node.type === "strong") {
|
|
486
|
+
return (
|
|
487
|
+
<strong key={key}>
|
|
488
|
+
<MarkdownInline nodes={node.children} />
|
|
489
|
+
</strong>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
if (node.type === "emphasis") {
|
|
493
|
+
return (
|
|
494
|
+
<em key={key}>
|
|
495
|
+
<MarkdownInline nodes={node.children} />
|
|
496
|
+
</em>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
return (
|
|
500
|
+
<Link href={node.href} key={key} rel="noopener noreferrer" target="_blank">
|
|
501
|
+
<MarkdownInline nodes={node.children} />
|
|
502
|
+
</Link>
|
|
503
|
+
);
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function ToolCall({ call, tool }) {
|
|
508
|
+
const phase = toolPhaseLabel(tool);
|
|
509
|
+
const command = toolCommandPreview(tool);
|
|
510
|
+
const args = safeJson(tool?.args ?? call.arguments);
|
|
511
|
+
const result = tool?.result === undefined ? "" : safeJson(tool.result);
|
|
512
|
+
return (
|
|
513
|
+
<Collapsible.Root className={`tool ${tool?.isError ? "failed" : ""}`}>
|
|
514
|
+
<Card asChild size="1">
|
|
515
|
+
<Box>
|
|
516
|
+
<Collapsible.Trigger asChild>
|
|
517
|
+
<Button className="tool-trigger" color={tool?.isError ? "red" : "gray"} variant="ghost">
|
|
518
|
+
<CodeIcon />
|
|
519
|
+
<span>{`${call.name} · ${phase}`}</span>
|
|
520
|
+
{command && <Code className="tool-command">{command}</Code>}
|
|
521
|
+
<ChevronDownIcon className="disclosure-icon" />
|
|
522
|
+
</Button>
|
|
523
|
+
</Collapsible.Trigger>
|
|
524
|
+
<Collapsible.Content className="tool-content">
|
|
525
|
+
<pre>{args}</pre>
|
|
526
|
+
{result && <pre>{result}</pre>}
|
|
527
|
+
</Collapsible.Content>
|
|
528
|
+
</Box>
|
|
529
|
+
</Card>
|
|
530
|
+
</Collapsible.Root>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function SentImageChip({ block, onForget, retained }) {
|
|
535
|
+
const status = retainedImageStatus(block, retained);
|
|
536
|
+
const label = `Image${block.mimeType ? ` · ${block.mimeType}` : ""}`;
|
|
537
|
+
return (
|
|
538
|
+
<Flex align="center" className="image-chip" gap="2" wrap="wrap">
|
|
539
|
+
<ImageIcon />
|
|
540
|
+
<Text color="gray" size="1">
|
|
541
|
+
{label}
|
|
542
|
+
</Text>
|
|
543
|
+
{status === "eligible" && (
|
|
544
|
+
<>
|
|
545
|
+
<Button
|
|
546
|
+
onClick={() => void webClient.reattachSentImage(block.retainedImageId)}
|
|
547
|
+
size="1"
|
|
548
|
+
variant="soft"
|
|
549
|
+
aria-label={`Attach image again: ${label}`}
|
|
550
|
+
>
|
|
551
|
+
<ImageIcon /> Attach again
|
|
552
|
+
</Button>
|
|
553
|
+
<Button
|
|
554
|
+
color="gray"
|
|
555
|
+
onClick={(event) => onForget(block.retainedImageId, event.currentTarget)}
|
|
556
|
+
size="1"
|
|
557
|
+
variant="ghost"
|
|
558
|
+
aria-label={`Forget retained image: ${label}`}
|
|
559
|
+
>
|
|
560
|
+
<TrashIcon /> Forget
|
|
561
|
+
</Button>
|
|
562
|
+
</>
|
|
563
|
+
)}
|
|
564
|
+
{status === "expired" && (
|
|
565
|
+
<Badge color="gray" variant="soft">
|
|
566
|
+
Expired
|
|
567
|
+
</Badge>
|
|
568
|
+
)}
|
|
569
|
+
</Flex>
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function BlockingState({ model }) {
|
|
574
|
+
if (!model.closed && !model.stale) return null;
|
|
575
|
+
return (
|
|
576
|
+
<Callout.Root className="blocking-state" color="red" id="blocking-state" role="alert">
|
|
577
|
+
<Callout.Icon>
|
|
578
|
+
<ExclamationTriangleIcon />
|
|
579
|
+
</Callout.Icon>
|
|
580
|
+
<Callout.Text>
|
|
581
|
+
<strong>{model.closed ? "Pi session ended" : "Another tab is active"}</strong>
|
|
582
|
+
<br />
|
|
583
|
+
{model.closed
|
|
584
|
+
? "Return to the terminal and run /webui for the active session."
|
|
585
|
+
: "This tab remains readable. Refresh it to take control."}
|
|
586
|
+
</Callout.Text>
|
|
587
|
+
</Callout.Root>
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function Composer({ dragActive, onClear, onPreview, view, ...dropProps }) {
|
|
592
|
+
const inputRef = useRef();
|
|
593
|
+
const fileRef = useRef();
|
|
594
|
+
const composerRef = useRef();
|
|
595
|
+
const model = view.model;
|
|
596
|
+
const locked = composerLocked(view);
|
|
597
|
+
const admissionLocked = locked || model.readingImages > 0;
|
|
598
|
+
useEffect(() => resizeInput(inputRef.current));
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
const composer = composerRef.current;
|
|
601
|
+
if (!composer) return;
|
|
602
|
+
const root = document.documentElement;
|
|
603
|
+
const updateComposerHeight = () =>
|
|
604
|
+
root.style.setProperty("--composer-height", `${composer.offsetHeight}px`);
|
|
605
|
+
updateComposerHeight();
|
|
606
|
+
const observer = new ResizeObserver(updateComposerHeight);
|
|
607
|
+
observer.observe(composer);
|
|
608
|
+
return () => {
|
|
609
|
+
observer.disconnect();
|
|
610
|
+
root.style.removeProperty("--composer-height");
|
|
611
|
+
};
|
|
612
|
+
}, []);
|
|
613
|
+
return (
|
|
614
|
+
<Card asChild className={`composer ${dragActive ? "drag-active" : ""}`} id="composer" size="2">
|
|
615
|
+
<form
|
|
616
|
+
onSubmit={(event) => {
|
|
617
|
+
event.preventDefault();
|
|
618
|
+
void webClient.send(false);
|
|
619
|
+
}}
|
|
620
|
+
ref={composerRef}
|
|
621
|
+
{...dropProps}
|
|
622
|
+
>
|
|
623
|
+
<Flex direction="column" gap="3">
|
|
624
|
+
<Text as="label" htmlFor="message-input" size="2" weight="bold">
|
|
625
|
+
Message Pi
|
|
626
|
+
</Text>
|
|
627
|
+
<TextArea
|
|
628
|
+
aria-label="Message Pi"
|
|
629
|
+
disabled={model.closed || model.stale}
|
|
630
|
+
id="message-input"
|
|
631
|
+
onChange={(event) => webClient.editText(event.target.value)}
|
|
632
|
+
onKeyDown={(event) => {
|
|
633
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
634
|
+
event.preventDefault();
|
|
635
|
+
void webClient.send(false);
|
|
636
|
+
}
|
|
637
|
+
}}
|
|
638
|
+
placeholder="Ask Pi to do something…"
|
|
639
|
+
ref={inputRef}
|
|
640
|
+
resize="none"
|
|
641
|
+
rows={1}
|
|
642
|
+
size="3"
|
|
643
|
+
value={model.text}
|
|
644
|
+
/>
|
|
645
|
+
{model.images.length > 0 && (
|
|
646
|
+
<Text
|
|
647
|
+
as="p"
|
|
648
|
+
className="attachment-summary"
|
|
649
|
+
color="gray"
|
|
650
|
+
id="attachment-summary"
|
|
651
|
+
size="1"
|
|
652
|
+
>
|
|
653
|
+
{`${model.images.length}/${model.imageLimits?.maxImages ?? model.images.length} images attached · Sensitive metadata is removed before sending.`}
|
|
654
|
+
</Text>
|
|
655
|
+
)}
|
|
656
|
+
<AttachmentList onPreview={onPreview} view={view} />
|
|
657
|
+
<Text
|
|
658
|
+
as="p"
|
|
659
|
+
aria-atomic="true"
|
|
660
|
+
aria-live="polite"
|
|
661
|
+
className="visually-hidden"
|
|
662
|
+
id="attachment-announcement"
|
|
663
|
+
role="status"
|
|
664
|
+
>
|
|
665
|
+
{model.images.length
|
|
666
|
+
? `Attachment state: ${attachmentPhaseLabel(model.attachmentPhase)}.`
|
|
667
|
+
: ""}
|
|
668
|
+
</Text>
|
|
669
|
+
{model.error && (
|
|
670
|
+
<Callout.Root color="red" id="composer-error" role="alert" size="1">
|
|
671
|
+
<Callout.Icon>
|
|
672
|
+
<ExclamationTriangleIcon />
|
|
673
|
+
</Callout.Icon>
|
|
674
|
+
<Callout.Text>{model.error}</Callout.Text>
|
|
675
|
+
</Callout.Root>
|
|
676
|
+
)}
|
|
677
|
+
<Flex align="center" className="composer-bottom" gap="2" justify="between" wrap="wrap">
|
|
678
|
+
<Flex align="center" className="composer-support" gap="2" wrap="wrap">
|
|
679
|
+
<Button asChild disabled={admissionLocked} variant="soft">
|
|
680
|
+
<label htmlFor="image-input">
|
|
681
|
+
<ImageIcon /> Add images
|
|
682
|
+
</label>
|
|
683
|
+
</Button>
|
|
684
|
+
<input
|
|
685
|
+
accept={ACCEPTED_IMAGES}
|
|
686
|
+
disabled={admissionLocked}
|
|
687
|
+
hidden
|
|
688
|
+
id="image-input"
|
|
689
|
+
multiple
|
|
690
|
+
onChange={(event) => {
|
|
691
|
+
void webClient.addFiles(event.target.files);
|
|
692
|
+
event.target.value = "";
|
|
693
|
+
}}
|
|
694
|
+
ref={fileRef}
|
|
695
|
+
type="file"
|
|
696
|
+
/>
|
|
697
|
+
<Text className="image-hint" color="gray" size="1">
|
|
698
|
+
Paste, drop, or choose images.
|
|
699
|
+
</Text>
|
|
700
|
+
{model.images.length >= 2 && (
|
|
701
|
+
<Button
|
|
702
|
+
color="gray"
|
|
703
|
+
disabled={locked}
|
|
704
|
+
onClick={onClear}
|
|
705
|
+
type="button"
|
|
706
|
+
variant="ghost"
|
|
707
|
+
>
|
|
708
|
+
<TrashIcon /> Clear attachments
|
|
709
|
+
</Button>
|
|
710
|
+
)}
|
|
711
|
+
</Flex>
|
|
712
|
+
<Flex className="send-actions" gap="2">
|
|
713
|
+
{model.activity === "running" && (
|
|
714
|
+
<Button
|
|
715
|
+
disabled={!canSubmit(view)}
|
|
716
|
+
onClick={() => void webClient.send(true)}
|
|
717
|
+
type="button"
|
|
718
|
+
variant="soft"
|
|
719
|
+
>
|
|
720
|
+
<ReloadIcon /> Steer
|
|
721
|
+
</Button>
|
|
722
|
+
)}
|
|
723
|
+
<Button disabled={!canSubmit(view)} id="send-next" type="submit">
|
|
724
|
+
<PaperPlaneIcon /> {primaryActionLabel(view)}
|
|
725
|
+
</Button>
|
|
726
|
+
</Flex>
|
|
727
|
+
</Flex>
|
|
728
|
+
<Text as="p" color="gray" id="composer-status" role="status" size="1">
|
|
729
|
+
{composerStatus(view)}
|
|
730
|
+
</Text>
|
|
731
|
+
</Flex>
|
|
732
|
+
</form>
|
|
733
|
+
</Card>
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function AttachmentList({ onPreview, view }) {
|
|
738
|
+
const listRef = useRef();
|
|
739
|
+
const [draggedId, setDraggedId] = useState("");
|
|
740
|
+
const [dropTarget, setDropTarget] = useState();
|
|
741
|
+
const model = view.model;
|
|
742
|
+
if (model.images.length === 0) return null;
|
|
743
|
+
return (
|
|
744
|
+
<Flex asChild gap="2" ref={listRef} wrap="wrap">
|
|
745
|
+
<ul
|
|
746
|
+
aria-busy={view.mutatingAttachments || undefined}
|
|
747
|
+
aria-label="Attached images"
|
|
748
|
+
id="image-previews"
|
|
749
|
+
>
|
|
750
|
+
{model.images.map((image, index) => {
|
|
751
|
+
const orderingLocked = composerLocked(view) || model.attachmentPhase !== "ready";
|
|
752
|
+
const reorderable = model.images.length > 1 && image.status === "ready";
|
|
753
|
+
const draggable = reorderable && !orderingLocked;
|
|
754
|
+
const retryable =
|
|
755
|
+
image.status === "error" && (image.retryable || view.retryableIds.has(image.id));
|
|
756
|
+
return (
|
|
757
|
+
<Card
|
|
758
|
+
asChild
|
|
759
|
+
className={`image-preview-item ${draggedId === image.id ? "dragging" : ""} ${dropTarget?.id === image.id ? `drag-${dropTarget.after ? "after" : "before"}` : ""}`}
|
|
760
|
+
key={image.id}
|
|
761
|
+
size="1"
|
|
762
|
+
>
|
|
763
|
+
<li
|
|
764
|
+
aria-keyshortcuts={reorderable ? "Alt+ArrowUp Alt+ArrowDown" : undefined}
|
|
765
|
+
aria-label={
|
|
766
|
+
reorderable
|
|
767
|
+
? `${image.name}, image ${index + 1} of ${model.images.length}. Drag to reorder or press Alt plus Up or Down Arrow.`
|
|
768
|
+
: undefined
|
|
769
|
+
}
|
|
770
|
+
data-image-id={image.id}
|
|
771
|
+
draggable={draggable}
|
|
772
|
+
onDragEnd={() => {
|
|
773
|
+
setDraggedId("");
|
|
774
|
+
setDropTarget(undefined);
|
|
775
|
+
}}
|
|
776
|
+
onDragLeave={(event) => {
|
|
777
|
+
if (!event.currentTarget.contains(event.relatedTarget)) setDropTarget(undefined);
|
|
778
|
+
}}
|
|
779
|
+
onDragOver={(event) => {
|
|
780
|
+
const sourceId = draggedId || event.dataTransfer?.getData(DRAG_TYPE);
|
|
781
|
+
if (orderingLocked || !sourceId || sourceId === image.id) return;
|
|
782
|
+
event.preventDefault();
|
|
783
|
+
const vertical = imagesStackVertically(listRef.current?.children ?? []);
|
|
784
|
+
setDropTarget({
|
|
785
|
+
id: image.id,
|
|
786
|
+
after: dropAfterTarget(event, event.currentTarget, vertical),
|
|
787
|
+
});
|
|
788
|
+
}}
|
|
789
|
+
onDragStart={(event) => {
|
|
790
|
+
if (!draggable) return;
|
|
791
|
+
setDraggedId(image.id);
|
|
792
|
+
event.dataTransfer.effectAllowed = "move";
|
|
793
|
+
event.dataTransfer.setData(DRAG_TYPE, image.id);
|
|
794
|
+
}}
|
|
795
|
+
onDrop={(event) => {
|
|
796
|
+
const sourceId = draggedId || event.dataTransfer?.getData(DRAG_TYPE);
|
|
797
|
+
if (orderingLocked || !sourceId || sourceId === image.id) return;
|
|
798
|
+
event.preventDefault();
|
|
799
|
+
const vertical = imagesStackVertically(listRef.current?.children ?? []);
|
|
800
|
+
void webClient.dropImage(
|
|
801
|
+
sourceId,
|
|
802
|
+
image.id,
|
|
803
|
+
dropAfterTarget(event, event.currentTarget, vertical),
|
|
804
|
+
);
|
|
805
|
+
setDraggedId("");
|
|
806
|
+
setDropTarget(undefined);
|
|
807
|
+
}}
|
|
808
|
+
onKeyDown={(event) => {
|
|
809
|
+
if (event.target !== event.currentTarget || orderingLocked || !event.altKey)
|
|
810
|
+
return;
|
|
811
|
+
const direction =
|
|
812
|
+
event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0;
|
|
813
|
+
if (
|
|
814
|
+
!direction ||
|
|
815
|
+
index + direction < 0 ||
|
|
816
|
+
index + direction >= model.images.length
|
|
817
|
+
)
|
|
818
|
+
return;
|
|
819
|
+
event.preventDefault();
|
|
820
|
+
void webClient.reorderImage(image.id, direction);
|
|
821
|
+
}}
|
|
822
|
+
tabIndex={reorderable ? 0 : undefined}
|
|
823
|
+
>
|
|
824
|
+
<Tooltip.Root>
|
|
825
|
+
<Tooltip.Trigger asChild>
|
|
826
|
+
<IconButton
|
|
827
|
+
aria-label={`Preview image ${image.name}`}
|
|
828
|
+
className="attachment-preview"
|
|
829
|
+
disabled={composerLocked(view) || image.status !== "ready"}
|
|
830
|
+
onClick={(event) => onPreview(image, event.currentTarget)}
|
|
831
|
+
type="button"
|
|
832
|
+
variant="soft"
|
|
833
|
+
>
|
|
834
|
+
{image.status === "ready" ? (
|
|
835
|
+
<img
|
|
836
|
+
alt=""
|
|
837
|
+
draggable={false}
|
|
838
|
+
src={`/api/attachments/${encodeURIComponent(image.id)}/preview?v=${model.attachmentRevision}`}
|
|
839
|
+
/>
|
|
840
|
+
) : (
|
|
841
|
+
<Spinner />
|
|
842
|
+
)}
|
|
843
|
+
</IconButton>
|
|
844
|
+
</Tooltip.Trigger>
|
|
845
|
+
<Tooltip.Content sideOffset={6}>Preview {image.name}</Tooltip.Content>
|
|
846
|
+
</Tooltip.Root>
|
|
847
|
+
<Box className="attachment-details">
|
|
848
|
+
<Text className="attachment-name" size="2" weight="medium">
|
|
849
|
+
{image.name}
|
|
850
|
+
</Text>
|
|
851
|
+
<Text color={image.status === "error" ? "red" : "gray"} size="1">
|
|
852
|
+
{attachmentItemLabel(image, view.uploadProgress.get(image.id))}
|
|
853
|
+
</Text>
|
|
854
|
+
{image.notes?.length > 0 && (
|
|
855
|
+
<Text color="gray" size="1">
|
|
856
|
+
{image.notes.join(" · ")}
|
|
857
|
+
</Text>
|
|
858
|
+
)}
|
|
859
|
+
{model.images.length > 1 && (
|
|
860
|
+
<Text color="gray" size="1" weight="bold">
|
|
861
|
+
Order {index + 1} of {model.images.length}
|
|
862
|
+
</Text>
|
|
863
|
+
)}
|
|
864
|
+
</Box>
|
|
865
|
+
<Flex align="center" className="image-actions" gap="1">
|
|
866
|
+
{retryable && (
|
|
867
|
+
<Button
|
|
868
|
+
disabled={composerLocked(view)}
|
|
869
|
+
onClick={() => void webClient.retryImage(image.id)}
|
|
870
|
+
size="1"
|
|
871
|
+
type="button"
|
|
872
|
+
variant="soft"
|
|
873
|
+
>
|
|
874
|
+
<ReloadIcon /> Retry
|
|
875
|
+
</Button>
|
|
876
|
+
)}
|
|
877
|
+
<Tooltip.Root>
|
|
878
|
+
<Tooltip.Trigger asChild>
|
|
879
|
+
<IconButton
|
|
880
|
+
aria-label={`Remove image ${image.name}`}
|
|
881
|
+
color="red"
|
|
882
|
+
disabled={composerLocked(view)}
|
|
883
|
+
onClick={() => void webClient.removeImage(image.id)}
|
|
884
|
+
type="button"
|
|
885
|
+
variant="ghost"
|
|
886
|
+
>
|
|
887
|
+
<TrashIcon />
|
|
888
|
+
</IconButton>
|
|
889
|
+
</Tooltip.Trigger>
|
|
890
|
+
<Tooltip.Content sideOffset={6}>Remove {image.name}</Tooltip.Content>
|
|
891
|
+
</Tooltip.Root>
|
|
892
|
+
</Flex>
|
|
893
|
+
</li>
|
|
894
|
+
</Card>
|
|
895
|
+
);
|
|
896
|
+
})}
|
|
897
|
+
</ul>
|
|
898
|
+
</Flex>
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const root = document.querySelector("#root");
|
|
903
|
+
if (!root) throw new Error("Pi WebUI root is unavailable.");
|
|
904
|
+
createRoot(root).render(
|
|
905
|
+
<StrictMode>
|
|
906
|
+
<App />
|
|
907
|
+
</StrictMode>,
|
|
908
|
+
);
|