@narumitw/pi-webui 0.20.2
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 +160 -0
- package/package.json +51 -0
- package/src/conversation.ts +374 -0
- package/src/images.ts +221 -0
- package/src/pi-settings.ts +74 -0
- package/src/runtime.ts +697 -0
- package/src/server.ts +518 -0
- package/src/settings.ts +174 -0
- package/src/web/app.js +537 -0
- package/src/web/index.html +108 -0
- package/src/web/markdown.js +198 -0
- package/src/web/state.js +166 -0
- package/src/web/styles.css +781 -0
- package/src/web/transcript.js +222 -0
- package/src/webui.ts +9 -0
package/src/web/app.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyConversationEvent,
|
|
3
|
+
applyLease,
|
|
4
|
+
applySnapshot,
|
|
5
|
+
busyLabel,
|
|
6
|
+
canSend,
|
|
7
|
+
completeSend,
|
|
8
|
+
deliveryNotice,
|
|
9
|
+
failSend,
|
|
10
|
+
followLatest,
|
|
11
|
+
initialState,
|
|
12
|
+
invalidateSendAttempt,
|
|
13
|
+
noteUnseenUpdate,
|
|
14
|
+
prepareSend,
|
|
15
|
+
setNearBottom,
|
|
16
|
+
} from "./state.js";
|
|
17
|
+
import { createTranscriptRenderer } from "./transcript.js";
|
|
18
|
+
|
|
19
|
+
const clientId = crypto.randomUUID();
|
|
20
|
+
let model = initialState();
|
|
21
|
+
let events;
|
|
22
|
+
let reconnectTimer;
|
|
23
|
+
let reconnectDelay = 500;
|
|
24
|
+
let snapshotRefresh;
|
|
25
|
+
let snapshotTarget = 0;
|
|
26
|
+
let transcriptFrame;
|
|
27
|
+
let transcriptAnnouncement = "";
|
|
28
|
+
let dragDepth = 0;
|
|
29
|
+
let previewReturnFocus;
|
|
30
|
+
|
|
31
|
+
const ui = {
|
|
32
|
+
project: document.querySelector("#project-name"),
|
|
33
|
+
session: document.querySelector("#session-name"),
|
|
34
|
+
cwd: document.querySelector("#cwd"),
|
|
35
|
+
connection: document.querySelector("#connection-status"),
|
|
36
|
+
empty: document.querySelector("#empty-state"),
|
|
37
|
+
transcript: document.querySelector("#transcript"),
|
|
38
|
+
transcriptStatus: document.querySelector("#transcript-status"),
|
|
39
|
+
jumpLatest: document.querySelector("#jump-latest"),
|
|
40
|
+
blocking: document.querySelector("#blocking-state"),
|
|
41
|
+
blockingTitle: document.querySelector("#blocking-title"),
|
|
42
|
+
blockingMessage: document.querySelector("#blocking-message"),
|
|
43
|
+
composer: document.querySelector("#composer"),
|
|
44
|
+
input: document.querySelector("#message-input"),
|
|
45
|
+
imageInput: document.querySelector("#image-input"),
|
|
46
|
+
addImages: document.querySelector('label[for="image-input"]'),
|
|
47
|
+
previews: document.querySelector("#image-previews"),
|
|
48
|
+
attachmentStatus: document.querySelector("#attachment-status"),
|
|
49
|
+
status: document.querySelector("#composer-status"),
|
|
50
|
+
error: document.querySelector("#composer-error"),
|
|
51
|
+
send: document.querySelector("#send-next"),
|
|
52
|
+
steer: document.querySelector("#steer"),
|
|
53
|
+
previewDialog: document.querySelector("#image-preview-dialog"),
|
|
54
|
+
previewTitle: document.querySelector("#image-preview-title"),
|
|
55
|
+
previewImage: document.querySelector("#image-preview"),
|
|
56
|
+
previewClose: document.querySelector("#image-preview-close"),
|
|
57
|
+
previewDismiss: document.querySelector("#image-preview-dismiss"),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const transcriptRenderer = createTranscriptRenderer({ documentRef: document, list: ui.transcript });
|
|
61
|
+
|
|
62
|
+
ui.input.addEventListener("input", () => {
|
|
63
|
+
model = invalidateSendAttempt({ ...model, text: ui.input.value, error: "" });
|
|
64
|
+
resizeInput();
|
|
65
|
+
renderComposer();
|
|
66
|
+
});
|
|
67
|
+
ui.composer.addEventListener("submit", (event) => {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
void send(false);
|
|
70
|
+
});
|
|
71
|
+
ui.steer.addEventListener("click", () => void send(true));
|
|
72
|
+
ui.imageInput.addEventListener("change", () => {
|
|
73
|
+
void addFiles(ui.imageInput.files);
|
|
74
|
+
ui.imageInput.value = "";
|
|
75
|
+
});
|
|
76
|
+
ui.input.addEventListener("keydown", (event) => {
|
|
77
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
void send(false);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
ui.jumpLatest.addEventListener("click", () => {
|
|
83
|
+
model = followLatest(model);
|
|
84
|
+
renderJumpLatest();
|
|
85
|
+
requestAnimationFrame(() =>
|
|
86
|
+
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
window.addEventListener(
|
|
90
|
+
"scroll",
|
|
91
|
+
() => {
|
|
92
|
+
model = setNearBottom(model, isNearBottom());
|
|
93
|
+
renderJumpLatest();
|
|
94
|
+
},
|
|
95
|
+
{ passive: true },
|
|
96
|
+
);
|
|
97
|
+
document.addEventListener("paste", (event) => {
|
|
98
|
+
const files = [...(event.clipboardData?.files ?? [])].filter((file) =>
|
|
99
|
+
file.type.startsWith("image/"),
|
|
100
|
+
);
|
|
101
|
+
if (files.length === 0 || composerLocked()) return;
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
void addFiles(files);
|
|
104
|
+
});
|
|
105
|
+
ui.composer.addEventListener("dragenter", (event) => {
|
|
106
|
+
if (!hasDraggedFile(event) || composerLocked()) return;
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
dragDepth += 1;
|
|
109
|
+
ui.composer.classList.add("drag-active");
|
|
110
|
+
});
|
|
111
|
+
ui.composer.addEventListener("dragover", (event) => {
|
|
112
|
+
if (!hasDraggedFile(event) || composerLocked()) return;
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
});
|
|
115
|
+
ui.composer.addEventListener("dragleave", () => {
|
|
116
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
117
|
+
if (dragDepth === 0) ui.composer.classList.remove("drag-active");
|
|
118
|
+
});
|
|
119
|
+
ui.composer.addEventListener("drop", (event) => {
|
|
120
|
+
dragDepth = 0;
|
|
121
|
+
ui.composer.classList.remove("drag-active");
|
|
122
|
+
const files = [...(event.dataTransfer?.files ?? [])].filter((file) =>
|
|
123
|
+
file.type.startsWith("image/"),
|
|
124
|
+
);
|
|
125
|
+
if (files.length === 0 || composerLocked()) return;
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
void addFiles(files);
|
|
128
|
+
});
|
|
129
|
+
ui.previewClose.addEventListener("click", () => ui.previewDialog.close());
|
|
130
|
+
ui.previewDismiss.addEventListener("click", () => ui.previewDialog.close());
|
|
131
|
+
ui.previewDialog.addEventListener("close", () => {
|
|
132
|
+
ui.previewImage.removeAttribute("src");
|
|
133
|
+
ui.previewImage.alt = "";
|
|
134
|
+
if (previewReturnFocus?.isConnected) previewReturnFocus.focus();
|
|
135
|
+
previewReturnFocus = undefined;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
void initialize();
|
|
139
|
+
|
|
140
|
+
async function initialize() {
|
|
141
|
+
try {
|
|
142
|
+
await refreshSnapshot();
|
|
143
|
+
await claimLease();
|
|
144
|
+
connectEvents();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
model = { ...model, connected: false, error: errorMessage(error) };
|
|
147
|
+
render();
|
|
148
|
+
scheduleReconnect();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function refreshSnapshot(requiredSequence = 0) {
|
|
153
|
+
snapshotTarget = Math.max(snapshotTarget, requiredSequence);
|
|
154
|
+
if (!snapshotRefresh) {
|
|
155
|
+
snapshotRefresh = (async () => {
|
|
156
|
+
do {
|
|
157
|
+
const response = await fetch("/api/state", { cache: "no-store" });
|
|
158
|
+
if (!response.ok) throw new Error(await responseError(response));
|
|
159
|
+
const snapshot = await response.json();
|
|
160
|
+
model = applySnapshot(model, snapshot);
|
|
161
|
+
if (typeof snapshot.lease?.activeClientId === "string") {
|
|
162
|
+
model = applyLease(model, snapshot.lease, clientId);
|
|
163
|
+
}
|
|
164
|
+
render({ updateKey: "snapshot" });
|
|
165
|
+
} while (model.sequence < snapshotTarget);
|
|
166
|
+
})().finally(() => {
|
|
167
|
+
snapshotRefresh = undefined;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return snapshotRefresh;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function claimLease() {
|
|
174
|
+
const response = await fetch("/api/lease", {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: { "Content-Type": "application/json", "X-Pi-Web-Client": clientId },
|
|
177
|
+
body: JSON.stringify({ clientId }),
|
|
178
|
+
});
|
|
179
|
+
if (!response.ok) throw new Error(await responseError(response));
|
|
180
|
+
model = applyLease(model, await response.json(), clientId, true);
|
|
181
|
+
render();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function connectEvents() {
|
|
185
|
+
clearTimeout(reconnectTimer);
|
|
186
|
+
events?.close();
|
|
187
|
+
events = new EventSource(`/api/events?since=${model.sequence}`);
|
|
188
|
+
events.addEventListener("open", () => {
|
|
189
|
+
reconnectDelay = 500;
|
|
190
|
+
model = { ...model, connected: true, error: "" };
|
|
191
|
+
render();
|
|
192
|
+
});
|
|
193
|
+
events.addEventListener("conversation", (event) => {
|
|
194
|
+
const conversationEvent = JSON.parse(event.data);
|
|
195
|
+
model = applyConversationEvent(model, conversationEvent);
|
|
196
|
+
if (model.needsSnapshot) {
|
|
197
|
+
void refreshSnapshot(conversationEvent.sequence).catch(connectionFailure);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
render({
|
|
201
|
+
updateKey: conversationUpdateKey(conversationEvent),
|
|
202
|
+
announcement: conversationAnnouncement(conversationEvent),
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
events.addEventListener("snapshot", (event) => {
|
|
206
|
+
model = applySnapshot(model, JSON.parse(event.data));
|
|
207
|
+
render({ updateKey: "snapshot" });
|
|
208
|
+
});
|
|
209
|
+
events.addEventListener("lease", (event) => {
|
|
210
|
+
model = applyLease(model, JSON.parse(event.data), clientId);
|
|
211
|
+
render();
|
|
212
|
+
});
|
|
213
|
+
events.addEventListener("session-ended", () => {
|
|
214
|
+
model = { ...model, closed: true, activity: "ended", connected: false };
|
|
215
|
+
events?.close();
|
|
216
|
+
render({ announcement: "Pi session ended." });
|
|
217
|
+
});
|
|
218
|
+
events.addEventListener("error", () => {
|
|
219
|
+
events?.close();
|
|
220
|
+
if (model.closed) return;
|
|
221
|
+
model = { ...model, connected: false };
|
|
222
|
+
render();
|
|
223
|
+
scheduleReconnect();
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function scheduleReconnect() {
|
|
228
|
+
if (model.closed || reconnectTimer) return;
|
|
229
|
+
reconnectTimer = setTimeout(async () => {
|
|
230
|
+
reconnectTimer = undefined;
|
|
231
|
+
try {
|
|
232
|
+
await refreshSnapshot();
|
|
233
|
+
if (!model.leaseClaimed) await claimLease();
|
|
234
|
+
connectEvents();
|
|
235
|
+
} catch (error) {
|
|
236
|
+
connectionFailure(error);
|
|
237
|
+
}
|
|
238
|
+
}, reconnectDelay);
|
|
239
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 5_000);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function connectionFailure(error) {
|
|
243
|
+
model = { ...model, connected: false, error: errorMessage(error) };
|
|
244
|
+
render();
|
|
245
|
+
scheduleReconnect();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function send(steer) {
|
|
249
|
+
if (!canSend(model)) return;
|
|
250
|
+
const prepared = prepareSend(model, crypto.randomUUID(), steer ? "steer" : "next");
|
|
251
|
+
const attempt = prepared.attempt;
|
|
252
|
+
model = prepared.state;
|
|
253
|
+
renderComposer();
|
|
254
|
+
const payload = {
|
|
255
|
+
requestId: attempt.requestId,
|
|
256
|
+
text: attempt.text,
|
|
257
|
+
images: attempt.images.map(({ name, mimeType, data }) => ({ name, mimeType, data })),
|
|
258
|
+
delivery: attempt.delivery,
|
|
259
|
+
};
|
|
260
|
+
try {
|
|
261
|
+
const response = await fetch("/api/messages", {
|
|
262
|
+
method: "POST",
|
|
263
|
+
headers: { "Content-Type": "application/json", "X-Pi-Web-Client": clientId },
|
|
264
|
+
body: JSON.stringify(payload),
|
|
265
|
+
});
|
|
266
|
+
if (!response.ok) {
|
|
267
|
+
const message = await responseError(response);
|
|
268
|
+
model = invalidateSendAttempt(model);
|
|
269
|
+
throw new Error(message);
|
|
270
|
+
}
|
|
271
|
+
const accepted = await response.json();
|
|
272
|
+
for (const image of attempt.images) URL.revokeObjectURL(image.previewUrl);
|
|
273
|
+
if (attempt.images.some((image) => image.previewUrl === ui.previewImage.src)) {
|
|
274
|
+
ui.previewDialog.close();
|
|
275
|
+
}
|
|
276
|
+
model = completeSend(model, attempt, accepted.delivery);
|
|
277
|
+
render();
|
|
278
|
+
ui.input.focus();
|
|
279
|
+
} catch (error) {
|
|
280
|
+
model = failSend(model, attempt, errorMessage(error));
|
|
281
|
+
render();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function addFiles(fileList) {
|
|
286
|
+
if (composerLocked()) return;
|
|
287
|
+
const supported = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
|
288
|
+
const files = [...(fileList ?? [])];
|
|
289
|
+
if (model.images.length + files.length > 8) {
|
|
290
|
+
model = { ...model, error: "You can attach at most 8 images." };
|
|
291
|
+
render();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
model = { ...model, readingImages: model.readingImages + 1 };
|
|
295
|
+
renderComposer();
|
|
296
|
+
try {
|
|
297
|
+
for (const file of files) {
|
|
298
|
+
if (model.images.length >= 8) {
|
|
299
|
+
model = { ...model, error: "You can attach at most 8 images." };
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
if (!supported.has(file.type)) {
|
|
303
|
+
model = { ...model, error: `${file.name || "Image"} is not a supported image.` };
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (file.size > 10 * 1024 * 1024) {
|
|
307
|
+
model = { ...model, error: `${file.name || "Image"} is larger than 10 MB.` };
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const data = await readBase64(file);
|
|
312
|
+
model = invalidateSendAttempt({
|
|
313
|
+
...model,
|
|
314
|
+
images: [
|
|
315
|
+
...model.images,
|
|
316
|
+
{
|
|
317
|
+
id: crypto.randomUUID(),
|
|
318
|
+
name: file.name || "Pasted image",
|
|
319
|
+
mimeType: file.type,
|
|
320
|
+
data,
|
|
321
|
+
previewUrl: URL.createObjectURL(file),
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
error: "",
|
|
325
|
+
});
|
|
326
|
+
} catch (error) {
|
|
327
|
+
model = { ...model, error: errorMessage(error) };
|
|
328
|
+
}
|
|
329
|
+
render();
|
|
330
|
+
}
|
|
331
|
+
} finally {
|
|
332
|
+
model = { ...model, readingImages: Math.max(0, model.readingImages - 1) };
|
|
333
|
+
renderComposer();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function removeImage(id) {
|
|
338
|
+
if (model.pending) return;
|
|
339
|
+
const removed = model.images.find((image) => image.id === id);
|
|
340
|
+
if (removed) {
|
|
341
|
+
if (ui.previewImage.src === removed.previewUrl) ui.previewDialog.close();
|
|
342
|
+
URL.revokeObjectURL(removed.previewUrl);
|
|
343
|
+
}
|
|
344
|
+
model = invalidateSendAttempt({
|
|
345
|
+
...model,
|
|
346
|
+
images: model.images.filter((image) => image.id !== id),
|
|
347
|
+
});
|
|
348
|
+
render();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function openImagePreview(image) {
|
|
352
|
+
previewReturnFocus = document.activeElement;
|
|
353
|
+
ui.previewTitle.textContent = image.name;
|
|
354
|
+
ui.previewImage.src = image.previewUrl;
|
|
355
|
+
ui.previewImage.alt = image.name;
|
|
356
|
+
ui.previewDialog.showModal();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function readBase64(file) {
|
|
360
|
+
return new Promise((resolve, reject) => {
|
|
361
|
+
const reader = new FileReader();
|
|
362
|
+
reader.addEventListener("error", () => reject(new Error(`Could not read ${file.name}.`)));
|
|
363
|
+
reader.addEventListener("load", () => {
|
|
364
|
+
if (typeof reader.result !== "string") {
|
|
365
|
+
reject(new Error(`Could not read ${file.name}.`));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
resolve(reader.result.slice(reader.result.indexOf(",") + 1));
|
|
369
|
+
});
|
|
370
|
+
reader.readAsDataURL(file);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function render(options = {}) {
|
|
375
|
+
renderHeader();
|
|
376
|
+
queueTranscriptRender(options.updateKey, options.announcement);
|
|
377
|
+
renderComposer();
|
|
378
|
+
renderBlocking();
|
|
379
|
+
renderJumpLatest();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function renderHeader() {
|
|
383
|
+
ui.project.textContent = model.session?.projectName ?? "Connecting…";
|
|
384
|
+
ui.session.textContent = model.session?.name ?? "Current session";
|
|
385
|
+
ui.cwd.textContent = model.session?.cwd ?? "—";
|
|
386
|
+
if (model.closed) ui.connection.textContent = "Session ended";
|
|
387
|
+
else if (!model.connected) ui.connection.textContent = "Reconnecting…";
|
|
388
|
+
else if (model.stale) ui.connection.textContent = "Read-only tab";
|
|
389
|
+
else ui.connection.textContent = model.activity === "running" ? "Pi is working" : "Connected";
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function queueTranscriptRender(updateKey, announcement) {
|
|
393
|
+
if (updateKey) model = noteUnseenUpdate(model, updateKey);
|
|
394
|
+
if (announcement) transcriptAnnouncement = announcement;
|
|
395
|
+
if (transcriptFrame) return;
|
|
396
|
+
transcriptFrame = requestAnimationFrame(() => {
|
|
397
|
+
transcriptFrame = undefined;
|
|
398
|
+
transcriptRenderer.render(model.messages, model.tools);
|
|
399
|
+
ui.empty.hidden = model.messages.length > 0;
|
|
400
|
+
if (transcriptAnnouncement) {
|
|
401
|
+
ui.transcriptStatus.textContent = transcriptAnnouncement;
|
|
402
|
+
transcriptAnnouncement = "";
|
|
403
|
+
}
|
|
404
|
+
if (model.following) window.scrollTo({ top: document.body.scrollHeight });
|
|
405
|
+
renderJumpLatest();
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function renderComposer() {
|
|
410
|
+
if (ui.input.value !== model.text) ui.input.value = model.text;
|
|
411
|
+
resizeInput();
|
|
412
|
+
const locked = composerLocked();
|
|
413
|
+
ui.composer.classList.toggle("locked", locked);
|
|
414
|
+
ui.send.textContent = busyLabel(model);
|
|
415
|
+
ui.send.disabled = !canSend(model);
|
|
416
|
+
ui.steer.hidden = model.activity !== "running";
|
|
417
|
+
ui.steer.disabled = !canSend(model);
|
|
418
|
+
ui.input.disabled = locked;
|
|
419
|
+
ui.imageInput.disabled = locked;
|
|
420
|
+
ui.addImages.classList.toggle("disabled", locked);
|
|
421
|
+
ui.addImages.setAttribute("aria-disabled", String(locked));
|
|
422
|
+
ui.status.textContent = composerStatus();
|
|
423
|
+
ui.error.hidden = !model.error;
|
|
424
|
+
ui.error.textContent = model.error;
|
|
425
|
+
ui.attachmentStatus.hidden = model.images.length === 0;
|
|
426
|
+
const attachmentStatus =
|
|
427
|
+
model.images.length === 0
|
|
428
|
+
? ""
|
|
429
|
+
: `${model.images.length} of 8 images attached · Sensitive metadata is removed before sending.`;
|
|
430
|
+
if (ui.attachmentStatus.textContent !== attachmentStatus) {
|
|
431
|
+
ui.attachmentStatus.textContent = attachmentStatus;
|
|
432
|
+
}
|
|
433
|
+
ui.previews.replaceChildren();
|
|
434
|
+
for (const image of model.images) {
|
|
435
|
+
const item = document.createElement("li");
|
|
436
|
+
item.className = "image-preview-item";
|
|
437
|
+
const previewButton = document.createElement("button");
|
|
438
|
+
previewButton.type = "button";
|
|
439
|
+
previewButton.className = "attachment-preview";
|
|
440
|
+
previewButton.setAttribute("aria-label", `Preview image ${image.name}`);
|
|
441
|
+
previewButton.disabled = model.pending;
|
|
442
|
+
previewButton.addEventListener("click", () => openImagePreview(image));
|
|
443
|
+
const preview = document.createElement("img");
|
|
444
|
+
preview.src = image.previewUrl;
|
|
445
|
+
preview.alt = "";
|
|
446
|
+
previewButton.append(preview);
|
|
447
|
+
const name = document.createElement("span");
|
|
448
|
+
name.textContent = image.name;
|
|
449
|
+
const remove = document.createElement("button");
|
|
450
|
+
remove.type = "button";
|
|
451
|
+
remove.className = "remove-image";
|
|
452
|
+
remove.textContent = "Remove";
|
|
453
|
+
remove.disabled = model.pending;
|
|
454
|
+
remove.setAttribute("aria-label", `Remove image ${image.name}`);
|
|
455
|
+
remove.addEventListener("click", () => removeImage(image.id));
|
|
456
|
+
item.append(previewButton, name, remove);
|
|
457
|
+
ui.previews.append(item);
|
|
458
|
+
}
|
|
459
|
+
document.documentElement.style.setProperty("--composer-height", `${ui.composer.offsetHeight}px`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function renderBlocking() {
|
|
463
|
+
ui.blocking.hidden = !model.closed && !model.stale;
|
|
464
|
+
if (model.closed) {
|
|
465
|
+
ui.blockingTitle.textContent = "Pi session ended";
|
|
466
|
+
ui.blockingMessage.textContent =
|
|
467
|
+
"Return to the terminal and run /webui for the active session.";
|
|
468
|
+
} else if (model.stale) {
|
|
469
|
+
ui.blockingTitle.textContent = "Another tab is active";
|
|
470
|
+
ui.blockingMessage.textContent = "This tab remains readable. Refresh it to take control.";
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderJumpLatest() {
|
|
475
|
+
const count = model.unseenUpdateIds.length;
|
|
476
|
+
ui.jumpLatest.hidden = count === 0;
|
|
477
|
+
ui.jumpLatest.textContent = count > 1 ? `↓ ${count} new updates` : "↓ Jump to latest";
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function resizeInput() {
|
|
481
|
+
ui.input.style.height = "auto";
|
|
482
|
+
ui.input.style.height = `${Math.min(ui.input.scrollHeight, window.innerHeight * 0.32)}px`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function composerStatus() {
|
|
486
|
+
if (model.readingImages > 0) return "Preparing images…";
|
|
487
|
+
if (model.pending) return "Submitting message…";
|
|
488
|
+
const notice = deliveryNotice(model);
|
|
489
|
+
if (notice) return notice;
|
|
490
|
+
if (!model.connected) return "Connection unavailable · Draft is preserved.";
|
|
491
|
+
if (model.closed) return "This Pi session has ended.";
|
|
492
|
+
if (model.stale) return "Another tab controls this session.";
|
|
493
|
+
if (model.activity === "running")
|
|
494
|
+
return "Pi is working · Queue waits; Steer redirects the active turn.";
|
|
495
|
+
return "Pi is idle · Messages send immediately.";
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function conversationUpdateKey(event) {
|
|
499
|
+
if (event.type === "message" && event.payload?.id) return `message:${event.payload.id}`;
|
|
500
|
+
if (event.type === "tool" && event.payload?.id) return `tool:${event.payload.id}`;
|
|
501
|
+
return event.type;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function conversationAnnouncement(event) {
|
|
505
|
+
if (event.type === "message" && event.payload?.final && event.payload.role === "assistant") {
|
|
506
|
+
return "New completed message from Pi.";
|
|
507
|
+
}
|
|
508
|
+
if (event.type === "tool" && event.payload?.phase === "end") {
|
|
509
|
+
return event.payload.isError ? "Tool failed." : "Tool completed.";
|
|
510
|
+
}
|
|
511
|
+
return "";
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function composerLocked() {
|
|
515
|
+
return model.closed || model.stale || model.pending;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function hasDraggedFile(event) {
|
|
519
|
+
return [...(event.dataTransfer?.items ?? [])].some((item) => item.kind === "file");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function isNearBottom() {
|
|
523
|
+
return document.documentElement.scrollHeight - window.scrollY - window.innerHeight < 160;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function responseError(response) {
|
|
527
|
+
try {
|
|
528
|
+
const body = await response.json();
|
|
529
|
+
return body.error || `${response.status} ${response.statusText}`;
|
|
530
|
+
} catch {
|
|
531
|
+
return `${response.status} ${response.statusText}`;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function errorMessage(error) {
|
|
536
|
+
return error instanceof Error ? error.message : String(error);
|
|
537
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<title>Pi WebUI</title>
|
|
8
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<a class="skip-link" href="#message-input">Skip to message</a>
|
|
12
|
+
<header class="session-header">
|
|
13
|
+
<div class="session-identity">
|
|
14
|
+
<p class="eyebrow">Pi WebUI</p>
|
|
15
|
+
<h1 id="project-name">Connecting…</h1>
|
|
16
|
+
<p id="session-name" class="session-name">Current session</p>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="session-controls">
|
|
19
|
+
<p id="connection-status" class="connection" role="status">Connecting…</p>
|
|
20
|
+
<details class="session-details">
|
|
21
|
+
<summary>Session details</summary>
|
|
22
|
+
<dl>
|
|
23
|
+
<dt>Working directory</dt>
|
|
24
|
+
<dd id="cwd">—</dd>
|
|
25
|
+
</dl>
|
|
26
|
+
</details>
|
|
27
|
+
</div>
|
|
28
|
+
</header>
|
|
29
|
+
|
|
30
|
+
<main>
|
|
31
|
+
<section class="conversation" aria-labelledby="conversation-title">
|
|
32
|
+
<h2 id="conversation-title" class="visually-hidden">Current Pi conversation</h2>
|
|
33
|
+
<div id="empty-state" class="empty-state">
|
|
34
|
+
<strong>No messages yet</strong>
|
|
35
|
+
<span>Messages from this Pi session will appear here.</span>
|
|
36
|
+
</div>
|
|
37
|
+
<ol id="transcript"></ol>
|
|
38
|
+
<p id="transcript-status" class="visually-hidden" role="status" aria-live="polite"></p>
|
|
39
|
+
</section>
|
|
40
|
+
|
|
41
|
+
<button id="jump-latest" class="jump-latest" type="button" hidden>
|
|
42
|
+
↓ Jump to latest
|
|
43
|
+
</button>
|
|
44
|
+
|
|
45
|
+
<section id="blocking-state" class="blocking-state" role="alert" hidden>
|
|
46
|
+
<strong id="blocking-title">This page is read-only</strong>
|
|
47
|
+
<span id="blocking-message"></span>
|
|
48
|
+
</section>
|
|
49
|
+
|
|
50
|
+
<form id="composer" class="composer">
|
|
51
|
+
<label for="message-input">Message Pi</label>
|
|
52
|
+
<textarea
|
|
53
|
+
id="message-input"
|
|
54
|
+
aria-label="Message Pi"
|
|
55
|
+
rows="1"
|
|
56
|
+
placeholder="Ask Pi to do something…"
|
|
57
|
+
></textarea>
|
|
58
|
+
<ul id="image-previews" class="image-previews" aria-label="Attached images"></ul>
|
|
59
|
+
<p id="attachment-status" class="attachment-status" role="status" aria-live="polite" hidden></p>
|
|
60
|
+
<p id="composer-error" class="composer-error" role="alert" hidden></p>
|
|
61
|
+
<div class="composer-bottom">
|
|
62
|
+
<div class="composer-support">
|
|
63
|
+
<label class="button secondary" for="image-input">Add images</label>
|
|
64
|
+
<input
|
|
65
|
+
id="image-input"
|
|
66
|
+
type="file"
|
|
67
|
+
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
68
|
+
multiple
|
|
69
|
+
hidden
|
|
70
|
+
/>
|
|
71
|
+
<span class="image-hint">Paste, drop, or choose images.</span>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="send-actions">
|
|
74
|
+
<button id="steer" class="button secondary" type="button" hidden>Steer</button>
|
|
75
|
+
<button id="send-next" class="button primary" type="submit">Send</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<p id="composer-status" class="composer-status" role="status">
|
|
79
|
+
Pi is idle · Messages send immediately.
|
|
80
|
+
</p>
|
|
81
|
+
</form>
|
|
82
|
+
</main>
|
|
83
|
+
|
|
84
|
+
<footer>
|
|
85
|
+
<details>
|
|
86
|
+
<summary>Privacy and limitations</summary>
|
|
87
|
+
<p>
|
|
88
|
+
This loopback page reflects semantic Pi messages, not terminal pixels. Data stays in
|
|
89
|
+
this live Pi process and browser tab. Thinking is collapsed by default.
|
|
90
|
+
</p>
|
|
91
|
+
</details>
|
|
92
|
+
</footer>
|
|
93
|
+
|
|
94
|
+
<dialog id="image-preview-dialog" class="image-preview-dialog" aria-labelledby="image-preview-title">
|
|
95
|
+
<div class="image-preview-content">
|
|
96
|
+
<header>
|
|
97
|
+
<h2 id="image-preview-title">Image preview</h2>
|
|
98
|
+
<button id="image-preview-close" class="button secondary" type="button">Close</button>
|
|
99
|
+
</header>
|
|
100
|
+
<button id="image-preview-dismiss" class="image-preview-dismiss" type="button" aria-label="Close enlarged image">
|
|
101
|
+
<img id="image-preview" alt="" />
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
</dialog>
|
|
105
|
+
|
|
106
|
+
<script type="module" src="/app.js"></script>
|
|
107
|
+
</body>
|
|
108
|
+
</html>
|