@narumitw/pi-webui 0.31.0 → 0.33.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/README.md +24 -12
- package/package.json +3 -3
- package/src/menu.ts +194 -0
- package/src/runtime.ts +195 -44
- package/src/server.ts +1 -1
- package/src/web/app.js +18 -18
- package/src/web/ui/app.jsx +32 -6
- package/src/web/ui/client.js +87 -10
- package/src/web/ui/view-helpers.js +63 -0
package/src/web/ui/app.jsx
CHANGED
|
@@ -35,7 +35,16 @@ import {
|
|
|
35
35
|
Theme,
|
|
36
36
|
} from "@radix-ui/themes";
|
|
37
37
|
import { Collapsible, Popover, Tooltip } from "radix-ui";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
memo,
|
|
40
|
+
StrictMode,
|
|
41
|
+
useCallback,
|
|
42
|
+
useEffect,
|
|
43
|
+
useMemo,
|
|
44
|
+
useRef,
|
|
45
|
+
useState,
|
|
46
|
+
useSyncExternalStore,
|
|
47
|
+
} from "react";
|
|
39
48
|
import { createRoot } from "react-dom/client";
|
|
40
49
|
import { dropAfterTarget, imagesStackVertically } from "../image-drag.js";
|
|
41
50
|
import { parseMarkdown } from "../markdown.js";
|
|
@@ -156,10 +165,10 @@ function App() {
|
|
|
156
165
|
if (!open) requestAnimationFrame(() => clearReturnFocus.current?.focus());
|
|
157
166
|
}
|
|
158
167
|
|
|
159
|
-
|
|
168
|
+
const openForgetDialog = useCallback((id, trigger) => {
|
|
160
169
|
forgetReturnFocus.current = trigger;
|
|
161
170
|
setForgetId(id);
|
|
162
|
-
}
|
|
171
|
+
}, []);
|
|
163
172
|
|
|
164
173
|
function closeForgetDialog() {
|
|
165
174
|
setForgetId("");
|
|
@@ -355,7 +364,7 @@ function Conversation({ model, onForget, view }) {
|
|
|
355
364
|
);
|
|
356
365
|
}
|
|
357
366
|
|
|
358
|
-
function Message({ message, onForget, retained, tools }) {
|
|
367
|
+
const Message = memo(function Message({ message, onForget, retained, tools }) {
|
|
359
368
|
const role = knownRole(message.role);
|
|
360
369
|
const body = (
|
|
361
370
|
<Box className="message-body">
|
|
@@ -401,6 +410,23 @@ function Message({ message, onForget, retained, tools }) {
|
|
|
401
410
|
</li>
|
|
402
411
|
</Box>
|
|
403
412
|
);
|
|
413
|
+
}, messagePropsEqual);
|
|
414
|
+
|
|
415
|
+
function messagePropsEqual(previous, next) {
|
|
416
|
+
if (
|
|
417
|
+
previous.message !== next.message ||
|
|
418
|
+
previous.onForget !== next.onForget ||
|
|
419
|
+
previous.retained !== next.retained
|
|
420
|
+
) {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
if (previous.tools === next.tools) return true;
|
|
424
|
+
for (const block of previous.message.content ?? []) {
|
|
425
|
+
if (block.type === "toolCall" && previous.tools.get(block.id) !== next.tools.get(block.id)) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return true;
|
|
404
430
|
}
|
|
405
431
|
|
|
406
432
|
function MessageBlock({ block, onForget, retained, tool }) {
|
|
@@ -426,7 +452,7 @@ function MessageBlock({ block, onForget, retained, tool }) {
|
|
|
426
452
|
return null;
|
|
427
453
|
}
|
|
428
454
|
|
|
429
|
-
function Markdown({ text }) {
|
|
455
|
+
const Markdown = memo(function Markdown({ text }) {
|
|
430
456
|
return (
|
|
431
457
|
<Box className="message-markdown">
|
|
432
458
|
{withStableKeys(parseMarkdown(text)).map(({ key, value: block }) => (
|
|
@@ -434,7 +460,7 @@ function Markdown({ text }) {
|
|
|
434
460
|
))}
|
|
435
461
|
</Box>
|
|
436
462
|
);
|
|
437
|
-
}
|
|
463
|
+
});
|
|
438
464
|
|
|
439
465
|
function MarkdownBlock({ block }) {
|
|
440
466
|
if (block.type === "heading") {
|
package/src/web/ui/client.js
CHANGED
|
@@ -23,6 +23,14 @@ import {
|
|
|
23
23
|
prepareSend,
|
|
24
24
|
setNearBottom,
|
|
25
25
|
} from "../state.js";
|
|
26
|
+
import {
|
|
27
|
+
allowTranscriptAutoScroll,
|
|
28
|
+
createRenderBatcher,
|
|
29
|
+
hasConversationReferenceChange,
|
|
30
|
+
shouldBatchConversationEvent,
|
|
31
|
+
shouldScrollForConversationEvent,
|
|
32
|
+
withPublishedConversation,
|
|
33
|
+
} from "./view-helpers.js";
|
|
26
34
|
|
|
27
35
|
const SUPPORTED_IMAGE_TYPES = new Set([
|
|
28
36
|
"image/png",
|
|
@@ -42,6 +50,8 @@ const listeners = new Set();
|
|
|
42
50
|
const retryFiles = new Map();
|
|
43
51
|
const uploadProgress = new Map();
|
|
44
52
|
let model = initialState();
|
|
53
|
+
let publishedMessages = model.messages;
|
|
54
|
+
let publishedTools = model.tools;
|
|
45
55
|
let events;
|
|
46
56
|
let reconnectTimer;
|
|
47
57
|
let reconnectDelay = 500;
|
|
@@ -52,10 +62,23 @@ let draftSaveQueue = Promise.resolve();
|
|
|
52
62
|
let mutatingAttachments = false;
|
|
53
63
|
let started = false;
|
|
54
64
|
let view = createView();
|
|
65
|
+
const scheduleConversationRender = createRenderBatcher(
|
|
66
|
+
(callback) => {
|
|
67
|
+
// Streaming can produce many events per frame. Cap transcript work so input remains responsive.
|
|
68
|
+
setTimeout(() => requestAnimationFrame(callback), 50);
|
|
69
|
+
},
|
|
70
|
+
(extra) => {
|
|
71
|
+
publishConversation(extra);
|
|
72
|
+
emit({
|
|
73
|
+
...extra,
|
|
74
|
+
scrollToLatest: allowTranscriptAutoScroll(model.following, !model.closed),
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
);
|
|
55
78
|
|
|
56
79
|
function createView(extra = {}) {
|
|
57
80
|
return {
|
|
58
|
-
model,
|
|
81
|
+
model: withPublishedConversation(model, publishedMessages, publishedTools),
|
|
59
82
|
mutatingAttachments,
|
|
60
83
|
uploadProgress: new Map(uploadProgress),
|
|
61
84
|
retryableIds: new Set(retryFiles.keys()),
|
|
@@ -71,6 +94,20 @@ function emit(extra) {
|
|
|
71
94
|
for (const listener of listeners) listener();
|
|
72
95
|
}
|
|
73
96
|
|
|
97
|
+
function publishConversation(extra = {}) {
|
|
98
|
+
for (const key of extra.transcriptUpdateKeys ?? []) {
|
|
99
|
+
model = noteUnseenUpdate(model, key);
|
|
100
|
+
}
|
|
101
|
+
publishedMessages = model.messages;
|
|
102
|
+
publishedTools = model.tools;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function drainConversationRender() {
|
|
106
|
+
const pending = scheduleConversationRender.drain();
|
|
107
|
+
publishConversation(pending);
|
|
108
|
+
return pending;
|
|
109
|
+
}
|
|
110
|
+
|
|
74
111
|
export const webClient = {
|
|
75
112
|
subscribe(listener) {
|
|
76
113
|
listeners.add(listener);
|
|
@@ -148,14 +185,20 @@ async function refreshSnapshot(requiredSequence = 0) {
|
|
|
148
185
|
if (!snapshotRefresh) {
|
|
149
186
|
snapshotRefresh = (async () => {
|
|
150
187
|
do {
|
|
188
|
+
let pendingConversation = {};
|
|
151
189
|
const response = await fetch("/api/state", { cache: "no-store" });
|
|
152
190
|
if (!response.ok) throw new Error(await responseError(response));
|
|
153
191
|
const snapshot = await response.json();
|
|
192
|
+
const replacesConversation =
|
|
193
|
+
Number.isSafeInteger(snapshot?.sequence) && snapshot.sequence >= model.sequence;
|
|
154
194
|
model = applySnapshot(model, snapshot);
|
|
155
195
|
if (typeof snapshot.lease?.activeClientId === "string") {
|
|
156
196
|
model = applyLease(model, snapshot.lease, clientId);
|
|
157
197
|
}
|
|
158
|
-
|
|
198
|
+
if (replacesConversation) {
|
|
199
|
+
pendingConversation = drainConversationRender();
|
|
200
|
+
}
|
|
201
|
+
emit({ ...pendingConversation, scrollToLatest: model.following });
|
|
159
202
|
} while (model.sequence < snapshotTarget);
|
|
160
203
|
})().finally(() => {
|
|
161
204
|
snapshotRefresh = undefined;
|
|
@@ -188,20 +231,46 @@ function connectEvents() {
|
|
|
188
231
|
});
|
|
189
232
|
events.addEventListener("conversation", (event) => {
|
|
190
233
|
const conversationEvent = JSON.parse(event.data);
|
|
234
|
+
const previousMessages = model.messages;
|
|
235
|
+
const previousTools = model.tools;
|
|
191
236
|
model = applyConversationEvent(model, conversationEvent);
|
|
192
237
|
if (model.needsSnapshot) {
|
|
193
238
|
void refreshSnapshot(conversationEvent.sequence).catch(connectionFailure);
|
|
194
239
|
return;
|
|
195
240
|
}
|
|
196
|
-
|
|
241
|
+
if (shouldBatchConversationEvent(conversationEvent.type)) {
|
|
242
|
+
scheduleConversationRender({
|
|
243
|
+
transcriptAnnouncement: conversationAnnouncement(conversationEvent),
|
|
244
|
+
transcriptUpdateKeys: [conversationUpdateKey(conversationEvent)],
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
let pendingConversation = {};
|
|
249
|
+
if (conversationEvent.type === "snapshot" || conversationEvent.type === "session-ended") {
|
|
250
|
+
pendingConversation = drainConversationRender();
|
|
251
|
+
if (
|
|
252
|
+
conversationEvent.type === "snapshot" &&
|
|
253
|
+
hasConversationReferenceChange(previousMessages, previousTools, model)
|
|
254
|
+
) {
|
|
255
|
+
model = noteUnseenUpdate(model, conversationUpdateKey(conversationEvent));
|
|
256
|
+
}
|
|
257
|
+
publishConversation();
|
|
258
|
+
}
|
|
197
259
|
emit({
|
|
198
|
-
|
|
199
|
-
scrollToLatest:
|
|
260
|
+
...pendingConversation,
|
|
261
|
+
scrollToLatest: shouldScrollForConversationEvent(
|
|
262
|
+
conversationEvent.type,
|
|
263
|
+
model.following && !model.closed,
|
|
264
|
+
),
|
|
200
265
|
});
|
|
201
266
|
});
|
|
202
267
|
events.addEventListener("snapshot", (event) => {
|
|
203
|
-
|
|
204
|
-
|
|
268
|
+
const snapshot = JSON.parse(event.data);
|
|
269
|
+
const replacesConversation =
|
|
270
|
+
Number.isSafeInteger(snapshot?.sequence) && snapshot.sequence >= model.sequence;
|
|
271
|
+
model = applySnapshot(model, snapshot);
|
|
272
|
+
const pendingConversation = replacesConversation ? drainConversationRender() : {};
|
|
273
|
+
emit({ ...pendingConversation, scrollToLatest: model.following });
|
|
205
274
|
});
|
|
206
275
|
events.addEventListener("lease", (event) => {
|
|
207
276
|
model = applyLease(model, JSON.parse(event.data), clientId);
|
|
@@ -226,13 +295,20 @@ function connectEvents() {
|
|
|
226
295
|
events.addEventListener("session-ended", () => {
|
|
227
296
|
model = { ...model, closed: true, activity: "ended", connected: false };
|
|
228
297
|
events?.close();
|
|
229
|
-
|
|
298
|
+
const pendingConversation = drainConversationRender();
|
|
299
|
+
emit({
|
|
300
|
+
...pendingConversation,
|
|
301
|
+
transcriptAnnouncement: [pendingConversation.transcriptAnnouncement, "Pi session ended."]
|
|
302
|
+
.filter(Boolean)
|
|
303
|
+
.join(" "),
|
|
304
|
+
});
|
|
230
305
|
});
|
|
231
306
|
events.addEventListener("error", () => {
|
|
232
307
|
events?.close();
|
|
233
308
|
if (model.closed) return;
|
|
234
309
|
model = { ...model, connected: false };
|
|
235
|
-
|
|
310
|
+
const pendingConversation = drainConversationRender();
|
|
311
|
+
emit(pendingConversation);
|
|
236
312
|
scheduleReconnect();
|
|
237
313
|
});
|
|
238
314
|
}
|
|
@@ -254,7 +330,8 @@ function scheduleReconnect() {
|
|
|
254
330
|
|
|
255
331
|
function connectionFailure(error) {
|
|
256
332
|
model = { ...model, connected: false, error: errorMessage(error) };
|
|
257
|
-
|
|
333
|
+
const pendingConversation = drainConversationRender();
|
|
334
|
+
emit(pendingConversation);
|
|
258
335
|
scheduleReconnect();
|
|
259
336
|
}
|
|
260
337
|
|
|
@@ -1,3 +1,66 @@
|
|
|
1
|
+
export function shouldBatchConversationEvent(type) {
|
|
2
|
+
return type === "message" || type === "tool";
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function shouldScrollForConversationEvent(type, following) {
|
|
6
|
+
return type === "snapshot" && following;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function hasConversationReferenceChange(previousMessages, previousTools, current) {
|
|
10
|
+
return current.messages !== previousMessages || current.tools !== previousTools;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function withPublishedConversation(model, messages, tools) {
|
|
14
|
+
if (model.messages === messages && model.tools === tools) return model;
|
|
15
|
+
return { ...model, messages, tools };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function allowTranscriptAutoScroll(following, active = true) {
|
|
19
|
+
return Boolean(following && active);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createRenderBatcher(schedule, render) {
|
|
23
|
+
let generation = 0;
|
|
24
|
+
let scheduled = false;
|
|
25
|
+
let pending = {};
|
|
26
|
+
const batch = (extra = {}) => {
|
|
27
|
+
const transcriptAnnouncement = [pending.transcriptAnnouncement, extra.transcriptAnnouncement]
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.join(" ");
|
|
30
|
+
const transcriptUpdateKeys = [
|
|
31
|
+
...new Set([...(pending.transcriptUpdateKeys ?? []), ...(extra.transcriptUpdateKeys ?? [])]),
|
|
32
|
+
];
|
|
33
|
+
pending = {
|
|
34
|
+
...pending,
|
|
35
|
+
...extra,
|
|
36
|
+
transcriptAnnouncement,
|
|
37
|
+
scrollToLatest: Boolean(pending.scrollToLatest || extra.scrollToLatest),
|
|
38
|
+
...(transcriptUpdateKeys.length > 0 ? { transcriptUpdateKeys } : {}),
|
|
39
|
+
};
|
|
40
|
+
if (scheduled) return;
|
|
41
|
+
scheduled = true;
|
|
42
|
+
const scheduledGeneration = generation;
|
|
43
|
+
schedule(() => {
|
|
44
|
+
if (scheduledGeneration !== generation) return;
|
|
45
|
+
scheduled = false;
|
|
46
|
+
const next = pending;
|
|
47
|
+
pending = {};
|
|
48
|
+
render(next);
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
batch.drain = () => {
|
|
52
|
+
generation += 1;
|
|
53
|
+
scheduled = false;
|
|
54
|
+
const next = pending;
|
|
55
|
+
pending = {};
|
|
56
|
+
return next;
|
|
57
|
+
};
|
|
58
|
+
batch.cancel = () => {
|
|
59
|
+
batch.drain();
|
|
60
|
+
};
|
|
61
|
+
return batch;
|
|
62
|
+
}
|
|
63
|
+
|
|
1
64
|
export function withStableKeys(values) {
|
|
2
65
|
return values.map((value, index) => {
|
|
3
66
|
const type =
|