@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/runtime.ts
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
4
|
+
import {
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionCommandContext,
|
|
7
|
+
type ExtensionContext,
|
|
8
|
+
getSettingsListTheme,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
|
|
11
|
+
import { ConversationProjection, projectBranchMessages } from "./conversation.js";
|
|
12
|
+
import {
|
|
13
|
+
type BrowserImageInput,
|
|
14
|
+
type ProcessBrowserImageOptions,
|
|
15
|
+
processBrowserImages,
|
|
16
|
+
} from "./images.js";
|
|
17
|
+
import { type EffectivePiImageSettings, readEffectivePiImageSettings } from "./pi-settings.js";
|
|
18
|
+
import {
|
|
19
|
+
type WebSendRequest,
|
|
20
|
+
type WebSendResult,
|
|
21
|
+
WebUIServer,
|
|
22
|
+
type WebUIServerOptions,
|
|
23
|
+
} from "./server.js";
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_SETTINGS,
|
|
26
|
+
initializeSettings,
|
|
27
|
+
loadSettings,
|
|
28
|
+
type SettingsLoadResult,
|
|
29
|
+
saveSettings,
|
|
30
|
+
type WebUISettings,
|
|
31
|
+
} from "./settings.js";
|
|
32
|
+
|
|
33
|
+
const WIDGET_KEY = "webui";
|
|
34
|
+
const INPUT_HEADER = /^<pi-webui-input nonce="([0-9a-f-]+)">\n/;
|
|
35
|
+
const INPUT_FOOTER = "\n</pi-webui-input>";
|
|
36
|
+
const COMMAND_USAGE = "Usage: /webui [settings|status|help|init]";
|
|
37
|
+
const COMMAND_COMPLETIONS = [
|
|
38
|
+
{ value: "settings", label: "settings", description: "Open WebUI settings" },
|
|
39
|
+
{ value: "status", label: "status", description: "Show effective WebUI settings and state" },
|
|
40
|
+
{ value: "help", label: "help", description: "Show WebUI command help" },
|
|
41
|
+
{ value: "init", label: "init", description: "Create the default WebUI settings file" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
type ServerControl = Pick<WebUIServer, "issueLink" | "close">;
|
|
45
|
+
type LatestEventHandler = (event: unknown, ctx: ExtensionContext) => void | Promise<void>;
|
|
46
|
+
type LatestExtensionAPI = ExtensionAPI & {
|
|
47
|
+
on(event: "agent_settled", handler: LatestEventHandler): void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
interface PendingBrowserInput {
|
|
51
|
+
resolve(): void;
|
|
52
|
+
reject(error: Error): void;
|
|
53
|
+
text: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RuntimeDependencies {
|
|
57
|
+
loadSettings: typeof loadSettings;
|
|
58
|
+
saveSettings: typeof saveSettings;
|
|
59
|
+
initializeSettings: typeof initializeSettings;
|
|
60
|
+
startServer(options: WebUIServerOptions): Promise<ServerControl>;
|
|
61
|
+
readPiSettings(cwd: string, projectTrusted: boolean): Promise<EffectivePiImageSettings>;
|
|
62
|
+
processImages(
|
|
63
|
+
inputs: BrowserImageInput[],
|
|
64
|
+
options?: ProcessBrowserImageOptions,
|
|
65
|
+
): Promise<ImageContent[]>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const DEFAULT_DEPENDENCIES: RuntimeDependencies = {
|
|
69
|
+
loadSettings,
|
|
70
|
+
saveSettings,
|
|
71
|
+
initializeSettings,
|
|
72
|
+
startServer: (options) => WebUIServer.start(options),
|
|
73
|
+
readPiSettings: readEffectivePiImageSettings,
|
|
74
|
+
processImages: processBrowserImages,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export class WebUIRuntime {
|
|
78
|
+
private readonly dependencies: RuntimeDependencies;
|
|
79
|
+
private context?: ExtensionContext;
|
|
80
|
+
private conversation?: ConversationProjection;
|
|
81
|
+
private server?: ServerControl;
|
|
82
|
+
private serverStarting?: Promise<ServerControl>;
|
|
83
|
+
private sessionAbort = new AbortController();
|
|
84
|
+
private generation = 0;
|
|
85
|
+
private closed = true;
|
|
86
|
+
private lastSettingsWarning = "";
|
|
87
|
+
private nextLiveMessageId = 0;
|
|
88
|
+
private readonly activeMessageIds = new Map<string, string>();
|
|
89
|
+
private readonly finalMessageTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
90
|
+
private readonly pendingBrowserInputs = new Map<string, PendingBrowserInput>();
|
|
91
|
+
private readonly acceptedBrowserInputs = new Map<string, string>();
|
|
92
|
+
private settings: WebUISettings = { ...DEFAULT_SETTINGS };
|
|
93
|
+
private settingsDocument?: Record<string, unknown> = {};
|
|
94
|
+
private settingsPath = "pi-webui.json";
|
|
95
|
+
private settingsSource: SettingsLoadResult["source"] = "defaults";
|
|
96
|
+
private settingsSaveQueue: Promise<void> = Promise.resolve();
|
|
97
|
+
|
|
98
|
+
constructor(
|
|
99
|
+
private readonly pi: ExtensionAPI,
|
|
100
|
+
dependencies: Partial<RuntimeDependencies> = {},
|
|
101
|
+
) {
|
|
102
|
+
this.dependencies = { ...DEFAULT_DEPENDENCIES, ...dependencies };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
register(): void {
|
|
106
|
+
this.pi.registerCommand("webui", {
|
|
107
|
+
description: "Open or configure the local web companion for this Pi session",
|
|
108
|
+
getArgumentCompletions: (prefix) => {
|
|
109
|
+
const normalized = prefix.trimStart().toLowerCase();
|
|
110
|
+
if (/\s/.test(normalized)) return null;
|
|
111
|
+
const matches = COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
|
|
112
|
+
return matches.length > 0 ? matches : null;
|
|
113
|
+
},
|
|
114
|
+
handler: async (args, ctx) => {
|
|
115
|
+
this.context = ctx;
|
|
116
|
+
const action = args.trim().toLowerCase();
|
|
117
|
+
try {
|
|
118
|
+
if (!action) {
|
|
119
|
+
await this.presentLink(ctx);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (action === "settings") {
|
|
123
|
+
await this.showSettings(ctx);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (action === "status") {
|
|
127
|
+
this.showStatus(ctx);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (action === "help") {
|
|
131
|
+
this.showHelp(ctx);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (action === "init") {
|
|
135
|
+
await this.initializeSettings(ctx);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (ctx.hasUI) ctx.ui.notify(COMMAND_USAGE, "warning");
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!action) {
|
|
141
|
+
ctx.ui.notify(`Pi WebUI could not start: ${formatError(error)}`, "error");
|
|
142
|
+
} else if (ctx.hasUI) {
|
|
143
|
+
ctx.ui.notify(`Pi WebUI command failed: ${formatError(error)}`, "error");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
this.pi.on("session_start", async (_event, ctx) => this.start(ctx));
|
|
150
|
+
this.pi.on("session_shutdown", async (_event, ctx) => this.shutdown(ctx));
|
|
151
|
+
this.pi.on("session_tree", async (_event, ctx) => {
|
|
152
|
+
this.captureContext(ctx);
|
|
153
|
+
this.cancelPendingMessages();
|
|
154
|
+
this.conversation?.replaceBranch(projectBranchMessages(ctx.sessionManager.getBranch()));
|
|
155
|
+
});
|
|
156
|
+
this.pi.on("session_info_changed", async (event, ctx) => {
|
|
157
|
+
this.captureContext(ctx);
|
|
158
|
+
this.conversation?.updateSession({ name: event.name });
|
|
159
|
+
});
|
|
160
|
+
this.pi.on("input", (event) => {
|
|
161
|
+
if (event.source !== "extension") return;
|
|
162
|
+
// Keep the envelope through later handlers so copied internal markers remain browser-originated.
|
|
163
|
+
const wrapped = parseBrowserInput(event.text);
|
|
164
|
+
if (!wrapped) return;
|
|
165
|
+
const pending = this.pendingBrowserInputs.get(wrapped.nonce);
|
|
166
|
+
if (!pending) return;
|
|
167
|
+
this.acceptedBrowserInputs.set(wrapped.nonce, pending.text);
|
|
168
|
+
this.settleBrowserInput(wrapped.nonce);
|
|
169
|
+
});
|
|
170
|
+
this.pi.on("message_start", async (event, ctx) => {
|
|
171
|
+
this.captureContext(ctx);
|
|
172
|
+
this.recordMessage(
|
|
173
|
+
"start",
|
|
174
|
+
sanitizeBrowserMessageEvent(event, this.acceptedBrowserInputs, false),
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
this.pi.on("message_update", async (event, ctx) => {
|
|
178
|
+
this.captureContext(ctx);
|
|
179
|
+
this.recordMessage("update", event);
|
|
180
|
+
});
|
|
181
|
+
this.pi.on("message_end", async (event, ctx) => {
|
|
182
|
+
this.captureContext(ctx);
|
|
183
|
+
const sanitized = sanitizeBrowserMessageEvent(event, this.acceptedBrowserInputs, true);
|
|
184
|
+
this.recordMessage("end", sanitized);
|
|
185
|
+
// Pi applies this replacement before persistence and before the prompt reaches the model.
|
|
186
|
+
if (sanitized !== event && isRecord(sanitized) && isRecord(sanitized.message)) {
|
|
187
|
+
return { message: sanitized.message as unknown as typeof event.message };
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
this.pi.on("tool_execution_start", async (event, ctx) => {
|
|
191
|
+
this.captureContext(ctx);
|
|
192
|
+
this.recordTool("start", event);
|
|
193
|
+
});
|
|
194
|
+
this.pi.on("tool_execution_update", async (event, ctx) => {
|
|
195
|
+
this.captureContext(ctx);
|
|
196
|
+
this.recordTool("update", event);
|
|
197
|
+
});
|
|
198
|
+
this.pi.on("tool_execution_end", async (event, ctx) => {
|
|
199
|
+
this.captureContext(ctx);
|
|
200
|
+
this.recordTool("end", event);
|
|
201
|
+
});
|
|
202
|
+
this.pi.on("agent_start", async (_event, ctx) => {
|
|
203
|
+
this.captureContext(ctx);
|
|
204
|
+
this.conversation?.setActivity("running");
|
|
205
|
+
});
|
|
206
|
+
(this.pi as LatestExtensionAPI).on("agent_settled", async (_event, ctx) => {
|
|
207
|
+
this.captureContext(ctx);
|
|
208
|
+
if (ctx.isIdle() && !ctx.hasPendingMessages()) {
|
|
209
|
+
this.acceptedBrowserInputs.clear();
|
|
210
|
+
this.conversation?.setActivity("idle");
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async start(ctx: ExtensionContext): Promise<void> {
|
|
216
|
+
const generation = ++this.generation;
|
|
217
|
+
const previousConversation = this.conversation;
|
|
218
|
+
this.closed = true;
|
|
219
|
+
this.sessionAbort.abort();
|
|
220
|
+
this.cancelPendingMessages();
|
|
221
|
+
this.cancelBrowserInputs("The Pi session changed before the browser prompt was accepted.");
|
|
222
|
+
this.acceptedBrowserInputs.clear();
|
|
223
|
+
previousConversation?.close();
|
|
224
|
+
await this.releaseServer();
|
|
225
|
+
if (generation !== this.generation) return;
|
|
226
|
+
const settingsResult = await this.dependencies.loadSettings();
|
|
227
|
+
if (generation !== this.generation) return;
|
|
228
|
+
this.applySettingsResult(settingsResult);
|
|
229
|
+
this.sessionAbort = new AbortController();
|
|
230
|
+
this.context = ctx;
|
|
231
|
+
this.conversation = new ConversationProjection(
|
|
232
|
+
{
|
|
233
|
+
id: ctx.sessionManager.getSessionId(),
|
|
234
|
+
cwd: ctx.cwd,
|
|
235
|
+
projectName: basename(ctx.cwd) || ctx.cwd,
|
|
236
|
+
...(ctx.sessionManager.getSessionName()
|
|
237
|
+
? { name: ctx.sessionManager.getSessionName() }
|
|
238
|
+
: {}),
|
|
239
|
+
},
|
|
240
|
+
projectBranchMessages(ctx.sessionManager.getBranch()),
|
|
241
|
+
);
|
|
242
|
+
this.closed = false;
|
|
243
|
+
this.lastSettingsWarning = "";
|
|
244
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
245
|
+
if (settingsResult.warning) ctx.ui.notify(settingsResult.warning, "warning");
|
|
246
|
+
if (!this.settings.startOnSessionStart) return;
|
|
247
|
+
try {
|
|
248
|
+
await this.presentLink(ctx);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (generation !== this.generation || this.closed) return;
|
|
251
|
+
ctx.ui.notify(`Pi WebUI could not start: ${formatError(error)}`, "error");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async shutdown(ctx: ExtensionContext): Promise<void> {
|
|
256
|
+
const generation = ++this.generation;
|
|
257
|
+
this.closed = true;
|
|
258
|
+
this.sessionAbort.abort();
|
|
259
|
+
this.cancelPendingMessages();
|
|
260
|
+
this.cancelBrowserInputs("The Pi session ended before the browser prompt was accepted.");
|
|
261
|
+
this.acceptedBrowserInputs.clear();
|
|
262
|
+
this.conversation?.close();
|
|
263
|
+
await this.releaseServer();
|
|
264
|
+
if (generation !== this.generation) return;
|
|
265
|
+
this.context = undefined;
|
|
266
|
+
this.conversation = undefined;
|
|
267
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private captureContext(ctx: ExtensionContext): void {
|
|
271
|
+
if (!this.closed) this.context = ctx;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private recordMessage(phase: "start" | "update" | "end", event: unknown): void {
|
|
275
|
+
if (!isRecord(event) || !isRecord(event.message) || typeof event.message.role !== "string") {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const key = messageLifecycleKey(event.message);
|
|
279
|
+
let id = this.activeMessageIds.get(key);
|
|
280
|
+
if (phase === "start" || !id) {
|
|
281
|
+
id = `web-live:${++this.nextLiveMessageId}`;
|
|
282
|
+
this.activeMessageIds.set(key, id);
|
|
283
|
+
}
|
|
284
|
+
if (phase !== "end") {
|
|
285
|
+
this.recordProjectedMessage(event.message, false, id);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
this.activeMessageIds.delete(key);
|
|
289
|
+
const generation = this.generation;
|
|
290
|
+
const timer = setTimeout(() => {
|
|
291
|
+
this.finalMessageTimers.delete(timer);
|
|
292
|
+
if (generation !== this.generation || this.closed) return;
|
|
293
|
+
this.recordProjectedMessage(event.message, true, id);
|
|
294
|
+
}, 0);
|
|
295
|
+
this.finalMessageTimers.add(timer);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private recordProjectedMessage(message: unknown, final: boolean, id: string): void {
|
|
299
|
+
try {
|
|
300
|
+
this.conversation?.recordMessage(message, final, id);
|
|
301
|
+
} catch {
|
|
302
|
+
// Unknown custom message shapes do not block the supported transcript.
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private cancelPendingMessages(): void {
|
|
307
|
+
this.activeMessageIds.clear();
|
|
308
|
+
for (const timer of this.finalMessageTimers) clearTimeout(timer);
|
|
309
|
+
this.finalMessageTimers.clear();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private recordTool(phase: "start" | "update" | "end", event: unknown): void {
|
|
313
|
+
if (
|
|
314
|
+
!isRecord(event) ||
|
|
315
|
+
typeof event.toolCallId !== "string" ||
|
|
316
|
+
typeof event.toolName !== "string"
|
|
317
|
+
)
|
|
318
|
+
return;
|
|
319
|
+
const result =
|
|
320
|
+
phase === "update" ? event.partialResult : phase === "end" ? event.result : undefined;
|
|
321
|
+
this.conversation?.recordTool(
|
|
322
|
+
phase,
|
|
323
|
+
event.toolCallId,
|
|
324
|
+
event.toolName,
|
|
325
|
+
event.args,
|
|
326
|
+
result,
|
|
327
|
+
typeof event.isError === "boolean" ? event.isError : undefined,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async presentLink(ctx: ExtensionContext): Promise<void> {
|
|
332
|
+
const server = await this.ensureServer();
|
|
333
|
+
const link = server.issueLink();
|
|
334
|
+
ctx.ui.setWidget(WIDGET_KEY, [`🌐 Pi WebUI: ${link}`]);
|
|
335
|
+
ctx.ui.notify(`Pi WebUI: ${link}`, "info");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private async showSettings(ctx: ExtensionCommandContext): Promise<void> {
|
|
339
|
+
if (ctx.mode !== "tui") {
|
|
340
|
+
if (ctx.hasUI) {
|
|
341
|
+
ctx.ui.notify(`Edit WebUI settings manually: ${this.settingsPath}`, "info");
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const items: SettingItem[] = [
|
|
347
|
+
{
|
|
348
|
+
id: "startOnSessionStart",
|
|
349
|
+
label: "Start on session start",
|
|
350
|
+
description: "Start WebUI and display a link for each newly initialized Pi session",
|
|
351
|
+
currentValue: String(this.settings.startOnSessionStart),
|
|
352
|
+
values: ["true", "false"],
|
|
353
|
+
},
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
357
|
+
const container = new Container();
|
|
358
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Pi WebUI Settings")), 1, 1));
|
|
359
|
+
const list = new SettingsList(
|
|
360
|
+
items,
|
|
361
|
+
Math.min(items.length + 2, 15),
|
|
362
|
+
getSettingsListTheme(),
|
|
363
|
+
(id, value) => {
|
|
364
|
+
if (id !== "startOnSessionStart") return;
|
|
365
|
+
const requested = value === "true";
|
|
366
|
+
const operation = this.settingsSaveQueue.then(async () => {
|
|
367
|
+
const previous = this.settings.startOnSessionStart;
|
|
368
|
+
try {
|
|
369
|
+
if (!this.settingsDocument) {
|
|
370
|
+
throw new Error("the invalid settings file must be repaired manually first");
|
|
371
|
+
}
|
|
372
|
+
const next = { startOnSessionStart: requested };
|
|
373
|
+
const document = await this.dependencies.saveSettings(
|
|
374
|
+
next,
|
|
375
|
+
this.settingsDocument,
|
|
376
|
+
this.settingsPath,
|
|
377
|
+
);
|
|
378
|
+
this.settings = next;
|
|
379
|
+
this.settingsDocument = document;
|
|
380
|
+
this.settingsSource = "settings file";
|
|
381
|
+
} catch (error) {
|
|
382
|
+
list.updateValue(id, String(previous));
|
|
383
|
+
ctx.ui.notify(`WebUI settings save failed: ${formatError(error)}`, "error");
|
|
384
|
+
tui.requestRender();
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
this.settingsSaveQueue = operation.catch(() => undefined);
|
|
388
|
+
},
|
|
389
|
+
() => done(undefined),
|
|
390
|
+
{ enableSearch: true },
|
|
391
|
+
);
|
|
392
|
+
container.addChild(list);
|
|
393
|
+
return {
|
|
394
|
+
render: (width: number) => container.render(width),
|
|
395
|
+
invalidate: () => container.invalidate(),
|
|
396
|
+
handleInput(data: string) {
|
|
397
|
+
list.handleInput?.(data);
|
|
398
|
+
tui.requestRender();
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private showStatus(ctx: ExtensionCommandContext): void {
|
|
405
|
+
if (!ctx.hasUI) return;
|
|
406
|
+
const source =
|
|
407
|
+
this.settingsDocument === undefined ? "defaults (invalid file ignored)" : this.settingsSource;
|
|
408
|
+
ctx.ui.notify(
|
|
409
|
+
[
|
|
410
|
+
"Pi WebUI status",
|
|
411
|
+
`startOnSessionStart: ${this.settings.startOnSessionStart} (${source})`,
|
|
412
|
+
`Settings: ${this.settingsPath}`,
|
|
413
|
+
`Server: ${this.server ? "running" : "stopped"}`,
|
|
414
|
+
].join("\n"),
|
|
415
|
+
"info",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private showHelp(ctx: ExtensionCommandContext): void {
|
|
420
|
+
if (!ctx.hasUI) return;
|
|
421
|
+
ctx.ui.notify(
|
|
422
|
+
[
|
|
423
|
+
COMMAND_USAGE,
|
|
424
|
+
"/webui: start or reuse the current session server and display a fresh one-time link",
|
|
425
|
+
"settings: edit WebUI settings in TUI mode",
|
|
426
|
+
"status: show effective settings, source, path, and current server state",
|
|
427
|
+
"init: create the defaults file without overwriting existing content",
|
|
428
|
+
'Accepted JSON: { "startOnSessionStart": false }',
|
|
429
|
+
`Settings path: ${this.settingsPath}`,
|
|
430
|
+
"The default is false. Changes apply on the next session initialization or reload.",
|
|
431
|
+
].join("\n"),
|
|
432
|
+
"info",
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private async initializeSettings(ctx: ExtensionCommandContext): Promise<void> {
|
|
437
|
+
const result = await this.dependencies.initializeSettings(this.settingsPath);
|
|
438
|
+
if (ctx.hasUI) {
|
|
439
|
+
ctx.ui.notify(
|
|
440
|
+
result === "created"
|
|
441
|
+
? `Created WebUI settings: ${this.settingsPath}`
|
|
442
|
+
: `WebUI settings already exists and was not overwritten: ${this.settingsPath}`,
|
|
443
|
+
"info",
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
const loaded = await this.dependencies.loadSettings(this.settingsPath);
|
|
447
|
+
this.applySettingsResult(loaded);
|
|
448
|
+
if (loaded.warning && ctx.hasUI) ctx.ui.notify(loaded.warning, "warning");
|
|
449
|
+
if (ctx.mode === "tui") await this.showSettings(ctx);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private applySettingsResult(result: SettingsLoadResult): void {
|
|
453
|
+
this.settings = { ...result.settings };
|
|
454
|
+
this.settingsDocument = result.kind === "invalid" ? undefined : { ...(result.document ?? {}) };
|
|
455
|
+
this.settingsPath = result.path;
|
|
456
|
+
this.settingsSource = result.source;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private async ensureServer(): Promise<ServerControl> {
|
|
460
|
+
if (this.closed || !this.conversation) throw new Error("the Pi session is not ready");
|
|
461
|
+
if (this.server) return this.server;
|
|
462
|
+
if (!this.serverStarting) {
|
|
463
|
+
const generation = this.generation;
|
|
464
|
+
const conversation = this.conversation;
|
|
465
|
+
const starting = this.dependencies.startServer({
|
|
466
|
+
conversation,
|
|
467
|
+
send: (request) => this.sendBrowserMessage(request, generation),
|
|
468
|
+
});
|
|
469
|
+
this.serverStarting = starting.then(async (server) => {
|
|
470
|
+
if (generation !== this.generation || this.closed || conversation !== this.conversation) {
|
|
471
|
+
await server.close();
|
|
472
|
+
throw new Error("the Pi session changed while the server was starting");
|
|
473
|
+
}
|
|
474
|
+
this.server = server;
|
|
475
|
+
return server;
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const starting = this.serverStarting;
|
|
479
|
+
try {
|
|
480
|
+
return await starting;
|
|
481
|
+
} finally {
|
|
482
|
+
if (this.serverStarting === starting) this.serverStarting = undefined;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private async sendBrowserMessage(
|
|
487
|
+
request: WebSendRequest,
|
|
488
|
+
generation: number,
|
|
489
|
+
): Promise<WebSendResult> {
|
|
490
|
+
const ctx = this.context;
|
|
491
|
+
if (!ctx || this.closed || generation !== this.generation) {
|
|
492
|
+
throw new Error("The Pi session has ended.");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const signal = request.signal
|
|
496
|
+
? AbortSignal.any([this.sessionAbort.signal, request.signal])
|
|
497
|
+
: this.sessionAbort.signal;
|
|
498
|
+
if (signal.aborted) throw new Error("The browser message was cancelled.");
|
|
499
|
+
await this.preflightIdlePrompt(ctx, request, generation, signal);
|
|
500
|
+
let images: ImageContent[] = [];
|
|
501
|
+
if (request.images.length > 0) {
|
|
502
|
+
const settings = await this.dependencies.readPiSettings(ctx.cwd, ctx.isProjectTrusted());
|
|
503
|
+
this.notifySettingsWarnings(ctx, settings.warnings);
|
|
504
|
+
images = await this.dependencies.processImages(request.images, {
|
|
505
|
+
autoResize: settings.autoResize,
|
|
506
|
+
blockImages: settings.blockImages,
|
|
507
|
+
supportsImages: ctx.model?.input.includes("image") ?? false,
|
|
508
|
+
signal,
|
|
509
|
+
});
|
|
510
|
+
await this.validateCurrentModel(ctx, generation, signal, true);
|
|
511
|
+
}
|
|
512
|
+
if (signal.aborted) throw new Error("The browser message was cancelled.");
|
|
513
|
+
if (!this.context || this.closed || generation !== this.generation) {
|
|
514
|
+
throw new Error("The Pi session changed while the message was being prepared.");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const text = request.text;
|
|
518
|
+
const content: string | Array<TextContent | ImageContent> =
|
|
519
|
+
images.length === 0
|
|
520
|
+
? text
|
|
521
|
+
: [...(text.trim() ? ([{ type: "text", text }] satisfies TextContent[]) : []), ...images];
|
|
522
|
+
const wrapped = this.createBrowserInput(content);
|
|
523
|
+
const delivery =
|
|
524
|
+
request.delivery === "steer"
|
|
525
|
+
? "steer"
|
|
526
|
+
: !ctx.isIdle() || ctx.hasPendingMessages()
|
|
527
|
+
? "followUp"
|
|
528
|
+
: "immediate";
|
|
529
|
+
try {
|
|
530
|
+
this.pi.sendUserMessage(wrapped.content, {
|
|
531
|
+
deliverAs: delivery === "steer" ? "steer" : "followUp",
|
|
532
|
+
});
|
|
533
|
+
await wrapped.accepted;
|
|
534
|
+
} catch (error) {
|
|
535
|
+
this.settleBrowserInput(
|
|
536
|
+
wrapped.nonce,
|
|
537
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
538
|
+
);
|
|
539
|
+
await wrapped.accepted.catch(() => undefined);
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
return { delivery };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private async preflightIdlePrompt(
|
|
546
|
+
ctx: ExtensionContext,
|
|
547
|
+
request: WebSendRequest,
|
|
548
|
+
generation: number,
|
|
549
|
+
signal: AbortSignal,
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
if (request.delivery === "steer" || !ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
552
|
+
await this.validateCurrentModel(ctx, generation, signal, false);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private async validateCurrentModel(
|
|
556
|
+
ctx: ExtensionContext,
|
|
557
|
+
generation: number,
|
|
558
|
+
signal: AbortSignal,
|
|
559
|
+
requireImages: boolean,
|
|
560
|
+
): Promise<void> {
|
|
561
|
+
const model = ctx.model;
|
|
562
|
+
if (!model) throw new Error("No model is selected in Pi.");
|
|
563
|
+
if (requireImages && !model.input.includes("image")) {
|
|
564
|
+
throw new Error("The selected Pi model does not support images.");
|
|
565
|
+
}
|
|
566
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
|
|
567
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(model.provider);
|
|
568
|
+
if (!apiKey) throw new Error(`No authentication is available for "${model.provider}".`);
|
|
569
|
+
}
|
|
570
|
+
if (signal.aborted) throw new Error("The browser message was cancelled.");
|
|
571
|
+
if (!this.context || this.closed || generation !== this.generation) {
|
|
572
|
+
throw new Error("The Pi session changed while the message was being prepared.");
|
|
573
|
+
}
|
|
574
|
+
if (ctx.model !== model) throw new Error("The Pi model changed; retry the browser message.");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private createBrowserInput(content: string | Array<TextContent | ImageContent>): {
|
|
578
|
+
nonce: string;
|
|
579
|
+
content: string | Array<TextContent | ImageContent>;
|
|
580
|
+
accepted: Promise<void>;
|
|
581
|
+
} {
|
|
582
|
+
const nonce = randomUUID();
|
|
583
|
+
const text = typeof content === "string" ? content : contentText(content);
|
|
584
|
+
const envelope = `<pi-webui-input nonce="${nonce}">\n${INPUT_FOOTER}`;
|
|
585
|
+
const wrappedContent =
|
|
586
|
+
typeof content === "string"
|
|
587
|
+
? envelope
|
|
588
|
+
: [
|
|
589
|
+
{ type: "text" as const, text: envelope },
|
|
590
|
+
...content.filter((part): part is ImageContent => part.type === "image"),
|
|
591
|
+
];
|
|
592
|
+
const accepted = new Promise<void>((resolve, reject) => {
|
|
593
|
+
this.pendingBrowserInputs.set(nonce, { resolve, reject, text });
|
|
594
|
+
});
|
|
595
|
+
return { nonce, content: wrappedContent, accepted };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private settleBrowserInput(nonce: string, error?: Error): void {
|
|
599
|
+
const pending = this.pendingBrowserInputs.get(nonce);
|
|
600
|
+
if (!pending) return;
|
|
601
|
+
this.pendingBrowserInputs.delete(nonce);
|
|
602
|
+
if (error) pending.reject(error);
|
|
603
|
+
else pending.resolve();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private cancelBrowserInputs(message: string): void {
|
|
607
|
+
for (const nonce of [...this.pendingBrowserInputs.keys()]) {
|
|
608
|
+
this.settleBrowserInput(nonce, new Error(message));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private notifySettingsWarnings(ctx: ExtensionContext, warnings: string[]): void {
|
|
613
|
+
const message = warnings.join("\n");
|
|
614
|
+
if (!message || message === this.lastSettingsWarning) return;
|
|
615
|
+
this.lastSettingsWarning = message;
|
|
616
|
+
ctx.ui.notify(message, "warning");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private async releaseServer(): Promise<void> {
|
|
620
|
+
const server = this.server;
|
|
621
|
+
const starting = this.serverStarting;
|
|
622
|
+
this.server = undefined;
|
|
623
|
+
this.serverStarting = undefined;
|
|
624
|
+
if (server) await server.close();
|
|
625
|
+
if (starting) {
|
|
626
|
+
try {
|
|
627
|
+
await (await starting).close();
|
|
628
|
+
} catch {
|
|
629
|
+
// Failed and generation-stale startups have no remaining live server.
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function parseBrowserInput(text: string): { nonce: string } | undefined {
|
|
636
|
+
const header = INPUT_HEADER.exec(text);
|
|
637
|
+
if (
|
|
638
|
+
!header?.[1] ||
|
|
639
|
+
!text.endsWith(INPUT_FOOTER) ||
|
|
640
|
+
text.slice(header[0].length, -INPUT_FOOTER.length) !== ""
|
|
641
|
+
) {
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
return { nonce: header[1] };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function contentText(content: Array<TextContent | ImageContent>): string {
|
|
648
|
+
return content
|
|
649
|
+
.filter((part): part is TextContent => part.type === "text")
|
|
650
|
+
.map((part) => part.text)
|
|
651
|
+
.join("\n");
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function sanitizeBrowserMessageEvent(
|
|
655
|
+
event: unknown,
|
|
656
|
+
acceptedNonces: Map<string, string>,
|
|
657
|
+
consume: boolean,
|
|
658
|
+
): unknown {
|
|
659
|
+
if (!isRecord(event) || !isRecord(event.message) || event.message.role !== "user") return event;
|
|
660
|
+
const content = event.message.content;
|
|
661
|
+
if (typeof content === "string") {
|
|
662
|
+
const wrapped = parseBrowserInput(content);
|
|
663
|
+
if (!wrapped || !acceptedNonces.has(wrapped.nonce)) return event;
|
|
664
|
+
const text = acceptedNonces.get(wrapped.nonce) ?? "";
|
|
665
|
+
if (consume) acceptedNonces.delete(wrapped.nonce);
|
|
666
|
+
return { ...event, message: { ...event.message, content: text } };
|
|
667
|
+
}
|
|
668
|
+
if (!Array.isArray(content)) return event;
|
|
669
|
+
let changed = false;
|
|
670
|
+
const consumed = new Set<string>();
|
|
671
|
+
const sanitized = content.flatMap((part) => {
|
|
672
|
+
if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") return [part];
|
|
673
|
+
const wrapped = parseBrowserInput(part.text);
|
|
674
|
+
if (!wrapped || !acceptedNonces.has(wrapped.nonce)) return [part];
|
|
675
|
+
const text = acceptedNonces.get(wrapped.nonce) ?? "";
|
|
676
|
+
changed = true;
|
|
677
|
+
consumed.add(wrapped.nonce);
|
|
678
|
+
return text ? [{ ...part, text }] : [];
|
|
679
|
+
});
|
|
680
|
+
if (!changed) return event;
|
|
681
|
+
if (consume) {
|
|
682
|
+
for (const nonce of consumed) acceptedNonces.delete(nonce);
|
|
683
|
+
}
|
|
684
|
+
return { ...event, message: { ...event.message, content: sanitized } };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function messageLifecycleKey(message: Record<string, unknown>): string {
|
|
688
|
+
return `${message.role}:${typeof message.timestamp === "number" ? message.timestamp : "untimed"}`;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
692
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function formatError(error: unknown): string {
|
|
696
|
+
return error instanceof Error ? error.message : String(error);
|
|
697
|
+
}
|