pi-tinychat 0.1.0 → 0.1.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/LICENSE +21 -21
- package/README.md +139 -139
- package/bin/pi-tinychat.js +111 -111
- package/extension/plan.ts +281 -240
- package/package.json +68 -68
- package/public/app.js +3749 -3614
- package/public/favicon.svg +15 -15
- package/public/i18n.js +197 -169
- package/public/index.html +226 -226
- package/public/markdown.js +244 -244
- package/public/style.css +2814 -2762
- package/public/theme-init.js +18 -18
- package/src/compaction.js +139 -139
- package/src/errors.js +13 -13
- package/src/live.js +133 -133
- package/src/pi-path.js +70 -70
- package/src/pi-types.d.ts +85 -85
- package/src/pi.js +196 -196
- package/src/plan.js +91 -90
- package/src/server.js +910 -910
- package/src/sessions.js +256 -256
package/src/server.js
CHANGED
|
@@ -1,910 +1,910 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { createServer } from "node:http";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { TinyChatError } from "./errors.js";
|
|
7
|
-
import { PiBridge } from "./pi.js";
|
|
8
|
-
import { LiveState } from "./live.js";
|
|
9
|
-
import { PLAN_STATE_ENTRY, PLAN_TOOL, normalizePlan, planFromEntries } from "./plan.js";
|
|
10
|
-
import {
|
|
11
|
-
deleteSessionFile,
|
|
12
|
-
listSessions,
|
|
13
|
-
resolveSessionTargets,
|
|
14
|
-
} from "./sessions.js";
|
|
15
|
-
import {
|
|
16
|
-
clearCompactionThreshold,
|
|
17
|
-
describeCompaction,
|
|
18
|
-
setCompactionEnabled,
|
|
19
|
-
setCompactionThreshold,
|
|
20
|
-
} from "./compaction.js";
|
|
21
|
-
|
|
22
|
-
const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "public");
|
|
23
|
-
const PLAN_EXTENSION = join(
|
|
24
|
-
dirname(fileURLToPath(import.meta.url)),
|
|
25
|
-
"..",
|
|
26
|
-
"extension",
|
|
27
|
-
"plan.ts",
|
|
28
|
-
);
|
|
29
|
-
const MAX_BODY = 12 * 1024 * 1024;
|
|
30
|
-
const HEARTBEAT_MS = 25_000;
|
|
31
|
-
const PORT_FALLBACK_ATTEMPTS = 20;
|
|
32
|
-
|
|
33
|
-
const RPC_TIMEOUTS = {
|
|
34
|
-
abort: 90_000,
|
|
35
|
-
compact: 600_000,
|
|
36
|
-
export_html: 120_000,
|
|
37
|
-
};
|
|
38
|
-
const DEFAULT_RPC_TIMEOUT = 60_000;
|
|
39
|
-
|
|
40
|
-
const ASSETS = {
|
|
41
|
-
"/": ["index.html", "text/html; charset=utf-8"],
|
|
42
|
-
"/index.html": ["index.html", "text/html; charset=utf-8"],
|
|
43
|
-
"/app.js": ["app.js", "text/javascript; charset=utf-8"],
|
|
44
|
-
"/i18n.js": ["i18n.js", "text/javascript; charset=utf-8"],
|
|
45
|
-
"/markdown.js": ["markdown.js", "text/javascript; charset=utf-8"],
|
|
46
|
-
"/theme-init.js": ["theme-init.js", "text/javascript; charset=utf-8"],
|
|
47
|
-
"/style.css": ["style.css", "text/css; charset=utf-8"],
|
|
48
|
-
"/favicon.svg": ["favicon.svg", "image/svg+xml"],
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
// Only commands the UI needs are reachable from the browser.
|
|
52
|
-
const FORWARDED = new Set([
|
|
53
|
-
"prompt",
|
|
54
|
-
"steer",
|
|
55
|
-
"follow_up",
|
|
56
|
-
"abort",
|
|
57
|
-
"clear_queue",
|
|
58
|
-
"new_session",
|
|
59
|
-
"switch_session",
|
|
60
|
-
"set_model",
|
|
61
|
-
"set_thinking_level",
|
|
62
|
-
"compact",
|
|
63
|
-
"set_session_name",
|
|
64
|
-
"get_state",
|
|
65
|
-
"get_messages",
|
|
66
|
-
"get_session_stats",
|
|
67
|
-
"get_available_models",
|
|
68
|
-
"get_available_thinking_levels",
|
|
69
|
-
"get_commands",
|
|
70
|
-
"get_entries",
|
|
71
|
-
"get_tree",
|
|
72
|
-
"export_html",
|
|
73
|
-
]);
|
|
74
|
-
|
|
75
|
-
const CONTENT_SECURITY_POLICY = [
|
|
76
|
-
"default-src 'self'",
|
|
77
|
-
"script-src 'self'",
|
|
78
|
-
"style-src 'self' 'unsafe-inline'",
|
|
79
|
-
"img-src 'self' data:",
|
|
80
|
-
"connect-src 'self'",
|
|
81
|
-
"base-uri 'none'",
|
|
82
|
-
"form-action 'none'",
|
|
83
|
-
"frame-ancestors 'none'",
|
|
84
|
-
].join("; ");
|
|
85
|
-
|
|
86
|
-
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
87
|
-
|
|
88
|
-
async function waitForPi(bridge, timeout = 30_000) {
|
|
89
|
-
const startedAt = Date.now();
|
|
90
|
-
while (Date.now() - startedAt < timeout) {
|
|
91
|
-
if (bridge.lastExit) throw new Error(bridge.lastExit.reason);
|
|
92
|
-
try {
|
|
93
|
-
await bridge.request({ type: "get_state" }, { timeout: 4000 });
|
|
94
|
-
return;
|
|
95
|
-
} catch {
|
|
96
|
-
await delay(250);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (bridge.lastExit) throw new Error(bridge.lastExit.reason);
|
|
100
|
-
throw new TinyChatError("server.piTimeout");
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function publicModel(model) {
|
|
104
|
-
if (!model) return null;
|
|
105
|
-
return {
|
|
106
|
-
id: model.id,
|
|
107
|
-
name: model.name ?? model.id,
|
|
108
|
-
provider: model.provider,
|
|
109
|
-
reasoning: Boolean(model.reasoning),
|
|
110
|
-
contextWindow: model.contextWindow,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function validateImages(images) {
|
|
115
|
-
if (images === undefined) return undefined;
|
|
116
|
-
if (!Array.isArray(images) || images.length > 6) throw new TinyChatError("server.invalidImages");
|
|
117
|
-
return images.map((image) => {
|
|
118
|
-
if (
|
|
119
|
-
!image ||
|
|
120
|
-
image.type !== "image" ||
|
|
121
|
-
typeof image.data !== "string" ||
|
|
122
|
-
typeof image.mimeType !== "string" ||
|
|
123
|
-
image.data.length > MAX_BODY
|
|
124
|
-
) {
|
|
125
|
-
throw new TinyChatError("server.invalidImages");
|
|
126
|
-
}
|
|
127
|
-
return { type: "image", data: image.data, mimeType: image.mimeType };
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export async function createTinyChat({
|
|
132
|
-
cwd,
|
|
133
|
-
launcher,
|
|
134
|
-
env = process.env,
|
|
135
|
-
token = randomBytes(24).toString("base64url"),
|
|
136
|
-
port = 0,
|
|
137
|
-
host = "127.0.0.1",
|
|
138
|
-
piArgs = [],
|
|
139
|
-
portAttempts = PORT_FALLBACK_ATTEMPTS,
|
|
140
|
-
}) {
|
|
141
|
-
const clients = new Set();
|
|
142
|
-
const runtimes = new Map();
|
|
143
|
-
const startingSessions = new Map();
|
|
144
|
-
let activeRuntime = null;
|
|
145
|
-
let runtimeSequence = 0;
|
|
146
|
-
let closing = false;
|
|
147
|
-
let actualPort = port;
|
|
148
|
-
|
|
149
|
-
const sessionKey = (value) =>
|
|
150
|
-
process.platform === "win32" ? resolve(value).toLowerCase() : resolve(value);
|
|
151
|
-
|
|
152
|
-
function runtimeSummary(runtime) {
|
|
153
|
-
return {
|
|
154
|
-
id: runtime.id,
|
|
155
|
-
sessionId: runtime.sessionId,
|
|
156
|
-
sessionFile: runtime.sessionFile,
|
|
157
|
-
sessionName: runtime.sessionName,
|
|
158
|
-
running: runtime.running,
|
|
159
|
-
exited: Boolean(runtime.exit),
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async function refreshRuntimeState(runtime) {
|
|
164
|
-
if (!runtime.bridge.running) return null;
|
|
165
|
-
const state = await runtime.bridge
|
|
166
|
-
.request({ type: "get_state" }, { timeout: 5000 })
|
|
167
|
-
.catch(() => null);
|
|
168
|
-
if (!state) return null;
|
|
169
|
-
runtime.sessionId = state.sessionId ?? runtime.sessionId;
|
|
170
|
-
if (state.sessionFile !== undefined) runtime.sessionFile = state.sessionFile;
|
|
171
|
-
if (state.sessionName !== undefined) runtime.sessionName = state.sessionName;
|
|
172
|
-
runtime.running = Boolean(state.isStreaming);
|
|
173
|
-
if (runtime.running && !runtime.runStartedAt) runtime.runStartedAt = Date.now();
|
|
174
|
-
if (!runtime.running && runtime.runStartedAt && !state.isStreaming) {
|
|
175
|
-
runtime.runStartedAt = null;
|
|
176
|
-
}
|
|
177
|
-
return state;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function makeRuntime({ sessionPath = "", initial = false } = {}) {
|
|
181
|
-
const id = `r${++runtimeSequence}`;
|
|
182
|
-
const bridge = new PiBridge({
|
|
183
|
-
cwd,
|
|
184
|
-
launcher,
|
|
185
|
-
env,
|
|
186
|
-
piArgs: initial
|
|
187
|
-
? ["--extension", PLAN_EXTENSION, ...piArgs]
|
|
188
|
-
: ["--extension", PLAN_EXTENSION, ...(sessionPath ? ["--session", sessionPath] : [])],
|
|
189
|
-
});
|
|
190
|
-
const runtime = {
|
|
191
|
-
id,
|
|
192
|
-
bridge,
|
|
193
|
-
live: new LiveState(),
|
|
194
|
-
sessionId: "",
|
|
195
|
-
sessionFile: sessionPath,
|
|
196
|
-
sessionName: "",
|
|
197
|
-
running: false,
|
|
198
|
-
runStartedAt: null,
|
|
199
|
-
startedAt: Date.now(),
|
|
200
|
-
exit: null,
|
|
201
|
-
pendingUi: new Map(),
|
|
202
|
-
statuses: {},
|
|
203
|
-
widgets: {},
|
|
204
|
-
extensionTitle: "",
|
|
205
|
-
};
|
|
206
|
-
runtimes.set(id, runtime);
|
|
207
|
-
bridge.on("record", (record) => onRuntimeRecord(runtime, record));
|
|
208
|
-
bridge.on("exit", (info) => onRuntimeExit(runtime, info));
|
|
209
|
-
bridge.start();
|
|
210
|
-
return runtime;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** @param {import("./pi-types.js").PiRecord} record */
|
|
214
|
-
function onRuntimeRecord(runtime, record) {
|
|
215
|
-
runtime.live.apply(record);
|
|
216
|
-
if (record.type === "session_info_changed") {
|
|
217
|
-
runtime.sessionName = record.name ?? "";
|
|
218
|
-
}
|
|
219
|
-
if (record.type === "agent_start") {
|
|
220
|
-
runtime.running = true;
|
|
221
|
-
if (!runtime.runStartedAt) runtime.runStartedAt = Date.now();
|
|
222
|
-
}
|
|
223
|
-
if (record.type === "agent_settled") {
|
|
224
|
-
runtime.running = false;
|
|
225
|
-
runtime.runStartedAt = null;
|
|
226
|
-
refreshRuntimeState(runtime).catch(() => {});
|
|
227
|
-
}
|
|
228
|
-
if (
|
|
229
|
-
record.type === "extension_ui_request" &&
|
|
230
|
-
["select", "confirm", "input", "editor"].includes(record.method)
|
|
231
|
-
) {
|
|
232
|
-
runtime.pendingUi.set(record.id, record);
|
|
233
|
-
}
|
|
234
|
-
if (record.type === "extension_ui_request") {
|
|
235
|
-
if (record.method === "setStatus") {
|
|
236
|
-
if (record.statusText) runtime.statuses[record.statusKey] = record.statusText;
|
|
237
|
-
else delete runtime.statuses[record.statusKey];
|
|
238
|
-
}
|
|
239
|
-
if (record.method === "setWidget") {
|
|
240
|
-
const key = record.widgetKey ?? "widget";
|
|
241
|
-
if (Array.isArray(record.widgetLines) && record.widgetLines.length) {
|
|
242
|
-
runtime.widgets[key] = record.widgetLines.join("\n");
|
|
243
|
-
} else {
|
|
244
|
-
delete runtime.widgets[key];
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
if (record.method === "setTitle") {
|
|
248
|
-
runtime.extensionTitle = record.title || "";
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (runtime !== activeRuntime) {
|
|
253
|
-
if (
|
|
254
|
-
[
|
|
255
|
-
"agent_start",
|
|
256
|
-
"agent_settled",
|
|
257
|
-
"session_info_changed",
|
|
258
|
-
"compaction_start",
|
|
259
|
-
"compaction_end",
|
|
260
|
-
"auto_retry_start",
|
|
261
|
-
"auto_retry_end",
|
|
262
|
-
].includes(record.type)
|
|
263
|
-
) {
|
|
264
|
-
broadcast({ type: "tinychat:runtime", runtime: runtimeSummary(runtime) });
|
|
265
|
-
}
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
broadcast({
|
|
270
|
-
...record,
|
|
271
|
-
runtime: runtime.id,
|
|
272
|
-
...(record.type === "agent_start" ? { runStartedAt: runtime.runStartedAt } : {}),
|
|
273
|
-
});
|
|
274
|
-
if (record.type === "tool_execution_end" && record.toolName === PLAN_TOOL) {
|
|
275
|
-
broadcast({
|
|
276
|
-
type: "tinychat:plan",
|
|
277
|
-
runtime: runtime.id,
|
|
278
|
-
plan: normalizePlan(record.result?.details),
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
if (
|
|
282
|
-
record.type === "message_end" &&
|
|
283
|
-
record.message?.role === "toolResult" &&
|
|
284
|
-
record.message.toolName === PLAN_TOOL
|
|
285
|
-
) {
|
|
286
|
-
broadcast({
|
|
287
|
-
type: "tinychat:plan",
|
|
288
|
-
runtime: runtime.id,
|
|
289
|
-
plan: normalizePlan(record.message.details),
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
if (
|
|
293
|
-
record.type === "entry_appended" &&
|
|
294
|
-
record.entry?.type === "custom" &&
|
|
295
|
-
record.entry.customType === PLAN_STATE_ENTRY
|
|
296
|
-
) {
|
|
297
|
-
broadcast({
|
|
298
|
-
type: "tinychat:plan",
|
|
299
|
-
runtime: runtime.id,
|
|
300
|
-
plan: normalizePlan(record.entry.data),
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function onRuntimeExit(runtime, info) {
|
|
306
|
-
runtime.live.reset();
|
|
307
|
-
runtime.running = false;
|
|
308
|
-
runtime.runStartedAt = null;
|
|
309
|
-
runtime.exit = info;
|
|
310
|
-
runtime.pendingUi.clear();
|
|
311
|
-
runtime.statuses = {};
|
|
312
|
-
runtime.widgets = {};
|
|
313
|
-
runtime.extensionTitle = "";
|
|
314
|
-
if (runtime === activeRuntime) {
|
|
315
|
-
broadcast({ type: "tinychat:exit", runtime: runtime.id, reason: info.reason });
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
runtimes.delete(runtime.id);
|
|
319
|
-
broadcast({ type: "tinychat:runtime", runtime: runtimeSummary(runtime) });
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
async function startRuntime(options = {}) {
|
|
323
|
-
const runtime = makeRuntime(options);
|
|
324
|
-
try {
|
|
325
|
-
await waitForPi(runtime.bridge);
|
|
326
|
-
await refreshRuntimeState(runtime);
|
|
327
|
-
return runtime;
|
|
328
|
-
} catch (error) {
|
|
329
|
-
await runtime.bridge.stop().catch(() => {});
|
|
330
|
-
runtimes.delete(runtime.id);
|
|
331
|
-
throw error;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function activateRuntime(runtime) {
|
|
336
|
-
if (activeRuntime && activeRuntime !== runtime && activeRuntime.exit) {
|
|
337
|
-
runtimes.delete(activeRuntime.id);
|
|
338
|
-
}
|
|
339
|
-
activeRuntime = runtime;
|
|
340
|
-
return runtime;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function findRuntimeByPath(path) {
|
|
344
|
-
const key = sessionKey(path);
|
|
345
|
-
for (const runtime of runtimes.values()) {
|
|
346
|
-
if (runtime.exit) continue;
|
|
347
|
-
if (runtime.sessionFile && sessionKey(runtime.sessionFile) === key) return runtime;
|
|
348
|
-
}
|
|
349
|
-
return null;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async function runtimeForPath(path) {
|
|
353
|
-
const existing = findRuntimeByPath(path);
|
|
354
|
-
if (existing) return existing;
|
|
355
|
-
const key = sessionKey(path);
|
|
356
|
-
let pending = startingSessions.get(key);
|
|
357
|
-
if (!pending) {
|
|
358
|
-
pending = startRuntime({ sessionPath: path });
|
|
359
|
-
startingSessions.set(key, pending);
|
|
360
|
-
pending.then(
|
|
361
|
-
() => startingSessions.delete(key),
|
|
362
|
-
() => startingSessions.delete(key),
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
|
-
const runtime = await pending;
|
|
366
|
-
return findRuntimeByPath(path) ?? runtime;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function requireRuntime() {
|
|
370
|
-
if (!activeRuntime) throw new TinyChatError("server.notConnected");
|
|
371
|
-
if (activeRuntime.exit) throw new Error(activeRuntime.exit.reason);
|
|
372
|
-
return activeRuntime;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
async function allSessions() {
|
|
376
|
-
await Promise.all(
|
|
377
|
-
[...runtimes.values()]
|
|
378
|
-
.filter((runtime) => runtime.bridge.running)
|
|
379
|
-
.map((runtime) => refreshRuntimeState(runtime)),
|
|
380
|
-
);
|
|
381
|
-
const currentId = activeRuntime?.sessionId ?? "";
|
|
382
|
-
const sessions = await listSessions({ cwd, currentId, env });
|
|
383
|
-
const bySessionId = new Map();
|
|
384
|
-
for (const runtime of runtimes.values()) {
|
|
385
|
-
if (runtime.sessionId && !runtime.exit) bySessionId.set(runtime.sessionId, runtime);
|
|
386
|
-
}
|
|
387
|
-
const represented = new Set(sessions.map((session) => session.id));
|
|
388
|
-
const live = [];
|
|
389
|
-
for (const runtime of runtimes.values()) {
|
|
390
|
-
if (!runtime.sessionId || runtime.exit || represented.has(runtime.sessionId)) continue;
|
|
391
|
-
live.push({
|
|
392
|
-
path: runtime.sessionFile,
|
|
393
|
-
id: runtime.sessionId,
|
|
394
|
-
name: runtime.sessionName,
|
|
395
|
-
preview: "",
|
|
396
|
-
created: "",
|
|
397
|
-
modified: new Date(runtime.startedAt).toISOString(),
|
|
398
|
-
size: 0,
|
|
399
|
-
current: runtime === activeRuntime,
|
|
400
|
-
runtime: runtime.id,
|
|
401
|
-
running: runtime.running,
|
|
402
|
-
live: true,
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
const all = [
|
|
406
|
-
...live.sort((a, b) => b.modified.localeCompare(a.modified)),
|
|
407
|
-
...sessions.map((session) => {
|
|
408
|
-
const runtime = bySessionId.get(session.id);
|
|
409
|
-
return {
|
|
410
|
-
...session,
|
|
411
|
-
runtime: runtime?.id ?? null,
|
|
412
|
-
running: Boolean(runtime?.running),
|
|
413
|
-
live: Boolean(runtime),
|
|
414
|
-
};
|
|
415
|
-
}),
|
|
416
|
-
];
|
|
417
|
-
return { sessions: all, total: all.length };
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function broadcast(record) {
|
|
421
|
-
if (clients.size === 0) return;
|
|
422
|
-
const frame = `data: ${JSON.stringify(record)}\n\n`;
|
|
423
|
-
for (const client of clients) {
|
|
424
|
-
try {
|
|
425
|
-
client.write(frame);
|
|
426
|
-
} catch {
|
|
427
|
-
clients.delete(client);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
async function snapshot() {
|
|
433
|
-
const runtime = requireRuntime();
|
|
434
|
-
const bridge = runtime.bridge;
|
|
435
|
-
const [state, messages, stats, models, levels, commands, entries] = await Promise.all([
|
|
436
|
-
bridge.request({ type: "get_state" }),
|
|
437
|
-
bridge.request({ type: "get_messages" }),
|
|
438
|
-
bridge.request({ type: "get_session_stats" }).catch(() => null),
|
|
439
|
-
bridge.request({ type: "get_available_models" }).catch(() => null),
|
|
440
|
-
bridge.request({ type: "get_available_thinking_levels" }).catch(() => null),
|
|
441
|
-
bridge.request({ type: "get_commands" }).catch(() => null),
|
|
442
|
-
bridge.request({ type: "get_entries" }).catch(() => null),
|
|
443
|
-
]);
|
|
444
|
-
if (state) {
|
|
445
|
-
runtime.sessionId = state.sessionId ?? runtime.sessionId;
|
|
446
|
-
if (state.sessionFile !== undefined) runtime.sessionFile = state.sessionFile;
|
|
447
|
-
if (state.sessionName !== undefined) runtime.sessionName = state.sessionName;
|
|
448
|
-
runtime.running = Boolean(state.isStreaming);
|
|
449
|
-
if (runtime.running && !runtime.runStartedAt) runtime.runStartedAt = Date.now();
|
|
450
|
-
if (!runtime.running) runtime.runStartedAt = null;
|
|
451
|
-
}
|
|
452
|
-
const compaction = await describeCompaction({ env, model: state?.model });
|
|
453
|
-
return {
|
|
454
|
-
runtime: runtime.id,
|
|
455
|
-
cwd,
|
|
456
|
-
state,
|
|
457
|
-
runStartedAt: runtime.runStartedAt,
|
|
458
|
-
live: runtime.live.snapshot(),
|
|
459
|
-
messages: Array.isArray(messages?.messages) ? messages.messages : [],
|
|
460
|
-
stats: stats ?? null,
|
|
461
|
-
models: (models?.models ?? []).map(publicModel),
|
|
462
|
-
levels: levels?.levels ?? ["off"],
|
|
463
|
-
commands: commands?.commands ?? [],
|
|
464
|
-
plan: entries ? planFromEntries(entries.entries, entries.leafId) : null,
|
|
465
|
-
pendingUi: [...runtime.pendingUi.values()],
|
|
466
|
-
statuses: { ...runtime.statuses },
|
|
467
|
-
widgets: { ...runtime.widgets },
|
|
468
|
-
extensionTitle: runtime.extensionTitle,
|
|
469
|
-
compaction,
|
|
470
|
-
};
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
async function currentModel() {
|
|
474
|
-
const state = await requireRuntime().bridge.request({ type: "get_state" });
|
|
475
|
-
if (!state?.model) throw new TinyChatError("server.noModel");
|
|
476
|
-
return state.model;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
async function handleRpc(command) {
|
|
480
|
-
if (!command || typeof command !== "object" || typeof command.type !== "string") {
|
|
481
|
-
throw new TinyChatError("server.invalidCommand");
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (command.type === "tinychat:snapshot") return snapshot();
|
|
485
|
-
if (command.type === "tinychat:sessions") return await allSessions();
|
|
486
|
-
if (command.type === "tinychat:activate") {
|
|
487
|
-
const runtime = runtimes.get(String(command.runtimeId));
|
|
488
|
-
if (!runtime || runtime.exit) throw new TinyChatError("server.sessionNotFound");
|
|
489
|
-
activateRuntime(runtime);
|
|
490
|
-
return { runtime: runtime.id };
|
|
491
|
-
}
|
|
492
|
-
if (command.type === "tinychat:restart") {
|
|
493
|
-
const runtime = activeRuntime;
|
|
494
|
-
if (!runtime) throw new TinyChatError("server.notConnected");
|
|
495
|
-
runtime.pendingUi.clear();
|
|
496
|
-
runtime.statuses = {};
|
|
497
|
-
runtime.widgets = {};
|
|
498
|
-
runtime.extensionTitle = "";
|
|
499
|
-
runtime.exit = null;
|
|
500
|
-
await runtime.bridge.restart();
|
|
501
|
-
await waitForPi(runtime.bridge);
|
|
502
|
-
runtime.live.reset();
|
|
503
|
-
await refreshRuntimeState(runtime);
|
|
504
|
-
return { restarted: true };
|
|
505
|
-
}
|
|
506
|
-
if (command.type === "tinychat:delete_sessions") {
|
|
507
|
-
const all = command.all === true;
|
|
508
|
-
const ids = Array.isArray(command.ids)
|
|
509
|
-
? command.ids.filter((id) => typeof id === "string" && id)
|
|
510
|
-
: null;
|
|
511
|
-
if (!all && (!ids || ids.length === 0)) {
|
|
512
|
-
throw new TinyChatError("server.deleteTargetMissing");
|
|
513
|
-
}
|
|
514
|
-
const { targets, missing } = await resolveSessionTargets({
|
|
515
|
-
cwd,
|
|
516
|
-
ids,
|
|
517
|
-
all,
|
|
518
|
-
env,
|
|
519
|
-
});
|
|
520
|
-
const protectedPaths = new Set();
|
|
521
|
-
const protectedIds = new Set();
|
|
522
|
-
for (const runtime of runtimes.values()) {
|
|
523
|
-
if (runtime.exit) continue;
|
|
524
|
-
if (runtime !== activeRuntime && !runtime.running) continue;
|
|
525
|
-
if (runtime.sessionFile) protectedPaths.add(sessionKey(runtime.sessionFile));
|
|
526
|
-
if (runtime.sessionId) protectedIds.add(runtime.sessionId);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/** @type {{ id: string, error: string, errorKey?: string }[]} */
|
|
530
|
-
const failed = missing.map((id) => ({
|
|
531
|
-
id,
|
|
532
|
-
error: "server.sessionNotFound",
|
|
533
|
-
errorKey: "server.sessionNotFound",
|
|
534
|
-
}));
|
|
535
|
-
let deleted = 0;
|
|
536
|
-
for (const target of targets) {
|
|
537
|
-
if (
|
|
538
|
-
protectedPaths.has(sessionKey(target.path)) ||
|
|
539
|
-
protectedIds.has(target.id)
|
|
540
|
-
) {
|
|
541
|
-
failed.push({
|
|
542
|
-
id: target.id,
|
|
543
|
-
error: "server.sessionInUse",
|
|
544
|
-
errorKey: "server.sessionInUse",
|
|
545
|
-
});
|
|
546
|
-
continue;
|
|
547
|
-
}
|
|
548
|
-
const runtime =
|
|
549
|
-
findRuntimeByPath(target.path) ??
|
|
550
|
-
[...runtimes.values()].find(
|
|
551
|
-
(candidate) => !candidate.exit && candidate.sessionId === target.id,
|
|
552
|
-
);
|
|
553
|
-
if (runtime) {
|
|
554
|
-
if (runtime.running) {
|
|
555
|
-
failed.push({
|
|
556
|
-
id: target.id,
|
|
557
|
-
error: "server.sessionRunning",
|
|
558
|
-
});
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
// A background session may have been opened earlier in this
|
|
562
|
-
// Pi TinyChat run. Stop it before removing the file it writes to.
|
|
563
|
-
await runtime.bridge.stop().catch(() => {});
|
|
564
|
-
runtimes.delete(runtime.id);
|
|
565
|
-
}
|
|
566
|
-
try {
|
|
567
|
-
await deleteSessionFile({ cwd, path: target.path, env });
|
|
568
|
-
deleted += 1;
|
|
569
|
-
} catch (error) {
|
|
570
|
-
failed.push({ id: target.id, error: error.message });
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
return { deleted, failed };
|
|
574
|
-
}
|
|
575
|
-
if (command.type === "new_session") {
|
|
576
|
-
const runtime = await startRuntime();
|
|
577
|
-
activateRuntime(runtime);
|
|
578
|
-
return { cancelled: false, runtime: runtime.id };
|
|
579
|
-
}
|
|
580
|
-
if (command.type === "switch_session") {
|
|
581
|
-
if (typeof command.sessionPath !== "string") throw new TinyChatError("server.invalidPath");
|
|
582
|
-
const runtime = await runtimeForPath(command.sessionPath);
|
|
583
|
-
activateRuntime(runtime);
|
|
584
|
-
return { cancelled: false, runtime: runtime.id };
|
|
585
|
-
}
|
|
586
|
-
if (command.type === "tinychat:set_compaction") {
|
|
587
|
-
const model = await currentModel();
|
|
588
|
-
if (command.action === "enable") {
|
|
589
|
-
await setCompactionEnabled({ env, enabled: command.enabled !== false });
|
|
590
|
-
} else if (command.action === "clear") {
|
|
591
|
-
await clearCompactionThreshold({ env, model });
|
|
592
|
-
} else {
|
|
593
|
-
await setCompactionThreshold({
|
|
594
|
-
env,
|
|
595
|
-
model,
|
|
596
|
-
percent: Number(command.percent),
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
|
-
return { compaction: await describeCompaction({ env, model }) };
|
|
600
|
-
}
|
|
601
|
-
if (command.type === "tinychat:extension_ui_response") {
|
|
602
|
-
if (typeof command.id !== "string") throw new TinyChatError("server.invalidId");
|
|
603
|
-
const runtime = command.runtimeId
|
|
604
|
-
? runtimes.get(String(command.runtimeId))
|
|
605
|
-
: requireRuntime();
|
|
606
|
-
if (!runtime || runtime.exit) throw new TinyChatError("server.sessionNotFound");
|
|
607
|
-
const response = {
|
|
608
|
-
type: "extension_ui_response",
|
|
609
|
-
id: command.id,
|
|
610
|
-
...(command.cancelled
|
|
611
|
-
? { cancelled: true }
|
|
612
|
-
: command.confirmed !== undefined
|
|
613
|
-
? { confirmed: Boolean(command.confirmed) }
|
|
614
|
-
: { value: String(command.value ?? "") }),
|
|
615
|
-
};
|
|
616
|
-
runtime.pendingUi.delete(command.id);
|
|
617
|
-
runtime.bridge.sendRaw(response);
|
|
618
|
-
return { sent: true };
|
|
619
|
-
}
|
|
620
|
-
if (!FORWARDED.has(command.type)) throw new TinyChatError("server.unsupportedCommand");
|
|
621
|
-
|
|
622
|
-
const payload = { type: command.type };
|
|
623
|
-
if (command.type === "prompt" || command.type === "steer" || command.type === "follow_up") {
|
|
624
|
-
if (typeof command.message !== "string" || !command.message.trim()) {
|
|
625
|
-
throw new TinyChatError("server.invalidMessage");
|
|
626
|
-
}
|
|
627
|
-
payload.message = command.message;
|
|
628
|
-
const images = validateImages(command.images);
|
|
629
|
-
if (images) payload.images = images;
|
|
630
|
-
if (command.streamingBehavior) payload.streamingBehavior = command.streamingBehavior;
|
|
631
|
-
}
|
|
632
|
-
if (command.type === "set_model") {
|
|
633
|
-
if (typeof command.provider !== "string" || typeof command.modelId !== "string") {
|
|
634
|
-
throw new TinyChatError("server.invalidModel");
|
|
635
|
-
}
|
|
636
|
-
payload.provider = command.provider;
|
|
637
|
-
payload.modelId = command.modelId;
|
|
638
|
-
}
|
|
639
|
-
if (command.type === "set_thinking_level") {
|
|
640
|
-
if (typeof command.level !== "string") throw new TinyChatError("server.invalidLevel");
|
|
641
|
-
payload.level = command.level;
|
|
642
|
-
}
|
|
643
|
-
if (command.type === "set_session_name") {
|
|
644
|
-
if (typeof command.name !== "string") throw new TinyChatError("server.invalidName");
|
|
645
|
-
payload.name = command.name;
|
|
646
|
-
}
|
|
647
|
-
if (command.type === "compact" && typeof command.customInstructions === "string") {
|
|
648
|
-
payload.customInstructions = command.customInstructions;
|
|
649
|
-
}
|
|
650
|
-
return await requireRuntime().bridge.request(payload, {
|
|
651
|
-
timeout: RPC_TIMEOUTS[command.type] ?? DEFAULT_RPC_TIMEOUT,
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function cookieToken(request) {
|
|
656
|
-
const header = request.headers.cookie;
|
|
657
|
-
if (typeof header !== "string") return null;
|
|
658
|
-
for (const part of header.split(";")) {
|
|
659
|
-
const separator = part.indexOf("=");
|
|
660
|
-
if (separator === -1) continue;
|
|
661
|
-
if (part.slice(0, separator).trim() !== "pi_tinychat") continue;
|
|
662
|
-
try {
|
|
663
|
-
return decodeURIComponent(part.slice(separator + 1).trim());
|
|
664
|
-
} catch {
|
|
665
|
-
return null;
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
return null;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
function authorized(request, url) {
|
|
672
|
-
const header = request.headers["x-pi-tinychat-token"];
|
|
673
|
-
if (typeof header === "string" && header === token) return true;
|
|
674
|
-
if (cookieToken(request) === token) return true;
|
|
675
|
-
return (
|
|
676
|
-
url.searchParams.get("launch") === token || url.searchParams.get("token") === token
|
|
677
|
-
);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
function hostAllowed(request) {
|
|
681
|
-
const header = request.headers.host;
|
|
682
|
-
if (!header) return false;
|
|
683
|
-
const match = /^(.*):(\d+)$/.exec(header);
|
|
684
|
-
if (!match) return false;
|
|
685
|
-
if (match[2] !== String(actualPort)) return false;
|
|
686
|
-
return match[1] === "127.0.0.1" || match[1] === "localhost" || match[1] === "[::1]";
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
function json(response, status, body) {
|
|
690
|
-
const payload = JSON.stringify(body);
|
|
691
|
-
response.writeHead(status, {
|
|
692
|
-
"content-type": "application/json; charset=utf-8",
|
|
693
|
-
"cache-control": "no-store",
|
|
694
|
-
"content-length": Buffer.byteLength(payload),
|
|
695
|
-
});
|
|
696
|
-
response.end(payload);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function readBody(request) {
|
|
700
|
-
return new Promise((resolve, reject) => {
|
|
701
|
-
let size = 0;
|
|
702
|
-
const chunks = [];
|
|
703
|
-
request.on("data", (chunk) => {
|
|
704
|
-
size += chunk.length;
|
|
705
|
-
if (size > MAX_BODY) {
|
|
706
|
-
reject(new TinyChatError("server.requestTooLarge"));
|
|
707
|
-
request.destroy();
|
|
708
|
-
return;
|
|
709
|
-
}
|
|
710
|
-
chunks.push(chunk);
|
|
711
|
-
});
|
|
712
|
-
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
713
|
-
request.on("error", reject);
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
const server = createServer(async (request, response) => {
|
|
718
|
-
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? host}`);
|
|
719
|
-
|
|
720
|
-
if (!hostAllowed(request)) {
|
|
721
|
-
response.writeHead(421, { "content-type": "text/plain; charset=utf-8" });
|
|
722
|
-
response.end("Misdirected request");
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
if (
|
|
727
|
-
request.method === "GET" &&
|
|
728
|
-
url.pathname === "/" &&
|
|
729
|
-
(url.searchParams.has("launch") || url.searchParams.has("token"))
|
|
730
|
-
) {
|
|
731
|
-
if (!authorized(request, url)) {
|
|
732
|
-
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
733
|
-
response.end("Unauthorized — open the URL printed by pi-tinychat");
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
// Exchange the launch credential for a localhost cookie and redirect to
|
|
737
|
-
// a clean address. The credential itself stays as strong as before.
|
|
738
|
-
response.writeHead(303, {
|
|
739
|
-
location: "/",
|
|
740
|
-
"set-cookie": `pi_tinychat=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400`,
|
|
741
|
-
"cache-control": "no-store",
|
|
742
|
-
"referrer-policy": "no-referrer",
|
|
743
|
-
});
|
|
744
|
-
response.end();
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
if (url.pathname === "/api/events") {
|
|
749
|
-
if (!authorized(request, url)) {
|
|
750
|
-
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
751
|
-
response.end("Unauthorized");
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
response.writeHead(200, {
|
|
755
|
-
"content-type": "text/event-stream; charset=utf-8",
|
|
756
|
-
"cache-control": "no-store",
|
|
757
|
-
connection: "keep-alive",
|
|
758
|
-
"x-accel-buffering": "no",
|
|
759
|
-
});
|
|
760
|
-
response.write("retry: 3000\n\n");
|
|
761
|
-
response.write(`data: ${JSON.stringify({ type: "tinychat:hello", cwd })}\n\n`);
|
|
762
|
-
clients.add(response);
|
|
763
|
-
request.on("close", () => clients.delete(response));
|
|
764
|
-
response.on("error", () => clients.delete(response));
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
if (request.method === "POST" && url.pathname === "/api/rpc") {
|
|
769
|
-
const origin = request.headers.origin;
|
|
770
|
-
if (origin && origin !== `http://${request.headers.host}`) {
|
|
771
|
-
json(response, 403, { ok: false, error: "Forbidden" });
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
if (!authorized(request, url)) {
|
|
775
|
-
json(response, 401, { ok: false, error: "Unauthorized" });
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
let command;
|
|
779
|
-
try {
|
|
780
|
-
command = JSON.parse(await readBody(request));
|
|
781
|
-
} catch (error) {
|
|
782
|
-
json(response, 400, { ok: false, error: error.message });
|
|
783
|
-
return;
|
|
784
|
-
}
|
|
785
|
-
try {
|
|
786
|
-
json(response, 200, { ok: true, data: await handleRpc(command) });
|
|
787
|
-
} catch (error) {
|
|
788
|
-
json(response, 200, {
|
|
789
|
-
ok: false,
|
|
790
|
-
error: error.message,
|
|
791
|
-
...(error instanceof TinyChatError
|
|
792
|
-
? { errorKey: error.key, errorParams: error.params }
|
|
793
|
-
: {}),
|
|
794
|
-
});
|
|
795
|
-
}
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
800
|
-
response.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
|
|
801
|
-
response.end("Method not allowed");
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
const asset = ASSETS[url.pathname];
|
|
806
|
-
if (!asset) {
|
|
807
|
-
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
808
|
-
response.end("Not found");
|
|
809
|
-
return;
|
|
810
|
-
}
|
|
811
|
-
const [file, type] = asset;
|
|
812
|
-
// The HTML shell needs the session token; the assets it references do not
|
|
813
|
-
// contain any secret and are served without one so the page can boot.
|
|
814
|
-
const needsToken = type.startsWith("text/html");
|
|
815
|
-
if (needsToken && !authorized(request, url)) {
|
|
816
|
-
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
817
|
-
response.end("Unauthorized — open the URL printed by pi-tinychat");
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
response.writeHead(200, {
|
|
821
|
-
"content-type": type,
|
|
822
|
-
"cache-control": "no-store",
|
|
823
|
-
"content-security-policy": CONTENT_SECURITY_POLICY,
|
|
824
|
-
"referrer-policy": "no-referrer",
|
|
825
|
-
"x-content-type-options": "nosniff",
|
|
826
|
-
});
|
|
827
|
-
// Read on every request so UI edits show up on reload without a restart.
|
|
828
|
-
response.end(
|
|
829
|
-
request.method === "HEAD" ? undefined : await readFile(join(PUBLIC_DIR, file)),
|
|
830
|
-
);
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
const heartbeat = setInterval(() => {
|
|
834
|
-
for (const client of clients) {
|
|
835
|
-
try {
|
|
836
|
-
client.write(": ping\n\n");
|
|
837
|
-
} catch {
|
|
838
|
-
clients.delete(client);
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
}, HEARTBEAT_MS);
|
|
842
|
-
heartbeat.unref();
|
|
843
|
-
|
|
844
|
-
const lastPort = port === 0 ? 0 : Math.min(65535, port + portAttempts - 1);
|
|
845
|
-
let bound = false;
|
|
846
|
-
let listenError = null;
|
|
847
|
-
for (let candidate = port; candidate <= lastPort; candidate += 1) {
|
|
848
|
-
try {
|
|
849
|
-
await new Promise((resolve, reject) => {
|
|
850
|
-
const onError = (error) => reject(error);
|
|
851
|
-
server.once("error", onError);
|
|
852
|
-
server.listen(candidate, host, () => {
|
|
853
|
-
server.removeListener("error", onError);
|
|
854
|
-
resolve();
|
|
855
|
-
});
|
|
856
|
-
});
|
|
857
|
-
bound = true;
|
|
858
|
-
break;
|
|
859
|
-
} catch (error) {
|
|
860
|
-
listenError = error;
|
|
861
|
-
if (error.code !== "EADDRINUSE") {
|
|
862
|
-
clearInterval(heartbeat);
|
|
863
|
-
throw error;
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
if (!bound) {
|
|
868
|
-
clearInterval(heartbeat);
|
|
869
|
-
throw new Error(
|
|
870
|
-
port === 0
|
|
871
|
-
? `サーバーを起動できませんでした: ${listenError?.message ?? "unknown error"}`
|
|
872
|
-
: `ポート ${port}〜${lastPort} はすべて使用中です。--port で別のポートを指定してください。`,
|
|
873
|
-
);
|
|
874
|
-
}
|
|
875
|
-
const address = server.address();
|
|
876
|
-
actualPort = typeof address === "object" && address ? address.port : port;
|
|
877
|
-
|
|
878
|
-
try {
|
|
879
|
-
activateRuntime(await startRuntime({ initial: true }));
|
|
880
|
-
} catch (error) {
|
|
881
|
-
clearInterval(heartbeat);
|
|
882
|
-
server.close();
|
|
883
|
-
throw error;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
return {
|
|
887
|
-
server,
|
|
888
|
-
token,
|
|
889
|
-
port: actualPort,
|
|
890
|
-
url: `http://${host}:${actualPort}/?launch=${encodeURIComponent(token)}`,
|
|
891
|
-
get sessionName() {
|
|
892
|
-
return activeRuntime?.sessionName;
|
|
893
|
-
},
|
|
894
|
-
async close() {
|
|
895
|
-
if (closing) return;
|
|
896
|
-
closing = true;
|
|
897
|
-
clearInterval(heartbeat);
|
|
898
|
-
for (const client of clients) {
|
|
899
|
-
try {
|
|
900
|
-
client.end();
|
|
901
|
-
} catch {
|
|
902
|
-
/* ignore */
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
clients.clear();
|
|
906
|
-
await Promise.all([...runtimes.values()].map((runtime) => runtime.bridge.stop()));
|
|
907
|
-
await new Promise((resolve) => server.close(resolve));
|
|
908
|
-
},
|
|
909
|
-
};
|
|
910
|
-
}
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { TinyChatError } from "./errors.js";
|
|
7
|
+
import { PiBridge } from "./pi.js";
|
|
8
|
+
import { LiveState } from "./live.js";
|
|
9
|
+
import { PLAN_STATE_ENTRY, PLAN_TOOL, normalizePlan, planFromEntries } from "./plan.js";
|
|
10
|
+
import {
|
|
11
|
+
deleteSessionFile,
|
|
12
|
+
listSessions,
|
|
13
|
+
resolveSessionTargets,
|
|
14
|
+
} from "./sessions.js";
|
|
15
|
+
import {
|
|
16
|
+
clearCompactionThreshold,
|
|
17
|
+
describeCompaction,
|
|
18
|
+
setCompactionEnabled,
|
|
19
|
+
setCompactionThreshold,
|
|
20
|
+
} from "./compaction.js";
|
|
21
|
+
|
|
22
|
+
const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "public");
|
|
23
|
+
const PLAN_EXTENSION = join(
|
|
24
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
25
|
+
"..",
|
|
26
|
+
"extension",
|
|
27
|
+
"plan.ts",
|
|
28
|
+
);
|
|
29
|
+
const MAX_BODY = 12 * 1024 * 1024;
|
|
30
|
+
const HEARTBEAT_MS = 25_000;
|
|
31
|
+
const PORT_FALLBACK_ATTEMPTS = 20;
|
|
32
|
+
|
|
33
|
+
const RPC_TIMEOUTS = {
|
|
34
|
+
abort: 90_000,
|
|
35
|
+
compact: 600_000,
|
|
36
|
+
export_html: 120_000,
|
|
37
|
+
};
|
|
38
|
+
const DEFAULT_RPC_TIMEOUT = 60_000;
|
|
39
|
+
|
|
40
|
+
const ASSETS = {
|
|
41
|
+
"/": ["index.html", "text/html; charset=utf-8"],
|
|
42
|
+
"/index.html": ["index.html", "text/html; charset=utf-8"],
|
|
43
|
+
"/app.js": ["app.js", "text/javascript; charset=utf-8"],
|
|
44
|
+
"/i18n.js": ["i18n.js", "text/javascript; charset=utf-8"],
|
|
45
|
+
"/markdown.js": ["markdown.js", "text/javascript; charset=utf-8"],
|
|
46
|
+
"/theme-init.js": ["theme-init.js", "text/javascript; charset=utf-8"],
|
|
47
|
+
"/style.css": ["style.css", "text/css; charset=utf-8"],
|
|
48
|
+
"/favicon.svg": ["favicon.svg", "image/svg+xml"],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Only commands the UI needs are reachable from the browser.
|
|
52
|
+
const FORWARDED = new Set([
|
|
53
|
+
"prompt",
|
|
54
|
+
"steer",
|
|
55
|
+
"follow_up",
|
|
56
|
+
"abort",
|
|
57
|
+
"clear_queue",
|
|
58
|
+
"new_session",
|
|
59
|
+
"switch_session",
|
|
60
|
+
"set_model",
|
|
61
|
+
"set_thinking_level",
|
|
62
|
+
"compact",
|
|
63
|
+
"set_session_name",
|
|
64
|
+
"get_state",
|
|
65
|
+
"get_messages",
|
|
66
|
+
"get_session_stats",
|
|
67
|
+
"get_available_models",
|
|
68
|
+
"get_available_thinking_levels",
|
|
69
|
+
"get_commands",
|
|
70
|
+
"get_entries",
|
|
71
|
+
"get_tree",
|
|
72
|
+
"export_html",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const CONTENT_SECURITY_POLICY = [
|
|
76
|
+
"default-src 'self'",
|
|
77
|
+
"script-src 'self'",
|
|
78
|
+
"style-src 'self' 'unsafe-inline'",
|
|
79
|
+
"img-src 'self' data:",
|
|
80
|
+
"connect-src 'self'",
|
|
81
|
+
"base-uri 'none'",
|
|
82
|
+
"form-action 'none'",
|
|
83
|
+
"frame-ancestors 'none'",
|
|
84
|
+
].join("; ");
|
|
85
|
+
|
|
86
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
87
|
+
|
|
88
|
+
async function waitForPi(bridge, timeout = 30_000) {
|
|
89
|
+
const startedAt = Date.now();
|
|
90
|
+
while (Date.now() - startedAt < timeout) {
|
|
91
|
+
if (bridge.lastExit) throw new Error(bridge.lastExit.reason);
|
|
92
|
+
try {
|
|
93
|
+
await bridge.request({ type: "get_state" }, { timeout: 4000 });
|
|
94
|
+
return;
|
|
95
|
+
} catch {
|
|
96
|
+
await delay(250);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (bridge.lastExit) throw new Error(bridge.lastExit.reason);
|
|
100
|
+
throw new TinyChatError("server.piTimeout");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function publicModel(model) {
|
|
104
|
+
if (!model) return null;
|
|
105
|
+
return {
|
|
106
|
+
id: model.id,
|
|
107
|
+
name: model.name ?? model.id,
|
|
108
|
+
provider: model.provider,
|
|
109
|
+
reasoning: Boolean(model.reasoning),
|
|
110
|
+
contextWindow: model.contextWindow,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function validateImages(images) {
|
|
115
|
+
if (images === undefined) return undefined;
|
|
116
|
+
if (!Array.isArray(images) || images.length > 6) throw new TinyChatError("server.invalidImages");
|
|
117
|
+
return images.map((image) => {
|
|
118
|
+
if (
|
|
119
|
+
!image ||
|
|
120
|
+
image.type !== "image" ||
|
|
121
|
+
typeof image.data !== "string" ||
|
|
122
|
+
typeof image.mimeType !== "string" ||
|
|
123
|
+
image.data.length > MAX_BODY
|
|
124
|
+
) {
|
|
125
|
+
throw new TinyChatError("server.invalidImages");
|
|
126
|
+
}
|
|
127
|
+
return { type: "image", data: image.data, mimeType: image.mimeType };
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function createTinyChat({
|
|
132
|
+
cwd,
|
|
133
|
+
launcher,
|
|
134
|
+
env = process.env,
|
|
135
|
+
token = randomBytes(24).toString("base64url"),
|
|
136
|
+
port = 0,
|
|
137
|
+
host = "127.0.0.1",
|
|
138
|
+
piArgs = [],
|
|
139
|
+
portAttempts = PORT_FALLBACK_ATTEMPTS,
|
|
140
|
+
}) {
|
|
141
|
+
const clients = new Set();
|
|
142
|
+
const runtimes = new Map();
|
|
143
|
+
const startingSessions = new Map();
|
|
144
|
+
let activeRuntime = null;
|
|
145
|
+
let runtimeSequence = 0;
|
|
146
|
+
let closing = false;
|
|
147
|
+
let actualPort = port;
|
|
148
|
+
|
|
149
|
+
const sessionKey = (value) =>
|
|
150
|
+
process.platform === "win32" ? resolve(value).toLowerCase() : resolve(value);
|
|
151
|
+
|
|
152
|
+
function runtimeSummary(runtime) {
|
|
153
|
+
return {
|
|
154
|
+
id: runtime.id,
|
|
155
|
+
sessionId: runtime.sessionId,
|
|
156
|
+
sessionFile: runtime.sessionFile,
|
|
157
|
+
sessionName: runtime.sessionName,
|
|
158
|
+
running: runtime.running,
|
|
159
|
+
exited: Boolean(runtime.exit),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function refreshRuntimeState(runtime) {
|
|
164
|
+
if (!runtime.bridge.running) return null;
|
|
165
|
+
const state = await runtime.bridge
|
|
166
|
+
.request({ type: "get_state" }, { timeout: 5000 })
|
|
167
|
+
.catch(() => null);
|
|
168
|
+
if (!state) return null;
|
|
169
|
+
runtime.sessionId = state.sessionId ?? runtime.sessionId;
|
|
170
|
+
if (state.sessionFile !== undefined) runtime.sessionFile = state.sessionFile;
|
|
171
|
+
if (state.sessionName !== undefined) runtime.sessionName = state.sessionName;
|
|
172
|
+
runtime.running = Boolean(state.isStreaming);
|
|
173
|
+
if (runtime.running && !runtime.runStartedAt) runtime.runStartedAt = Date.now();
|
|
174
|
+
if (!runtime.running && runtime.runStartedAt && !state.isStreaming) {
|
|
175
|
+
runtime.runStartedAt = null;
|
|
176
|
+
}
|
|
177
|
+
return state;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function makeRuntime({ sessionPath = "", initial = false } = {}) {
|
|
181
|
+
const id = `r${++runtimeSequence}`;
|
|
182
|
+
const bridge = new PiBridge({
|
|
183
|
+
cwd,
|
|
184
|
+
launcher,
|
|
185
|
+
env,
|
|
186
|
+
piArgs: initial
|
|
187
|
+
? ["--extension", PLAN_EXTENSION, ...piArgs]
|
|
188
|
+
: ["--extension", PLAN_EXTENSION, ...(sessionPath ? ["--session", sessionPath] : [])],
|
|
189
|
+
});
|
|
190
|
+
const runtime = {
|
|
191
|
+
id,
|
|
192
|
+
bridge,
|
|
193
|
+
live: new LiveState(),
|
|
194
|
+
sessionId: "",
|
|
195
|
+
sessionFile: sessionPath,
|
|
196
|
+
sessionName: "",
|
|
197
|
+
running: false,
|
|
198
|
+
runStartedAt: null,
|
|
199
|
+
startedAt: Date.now(),
|
|
200
|
+
exit: null,
|
|
201
|
+
pendingUi: new Map(),
|
|
202
|
+
statuses: {},
|
|
203
|
+
widgets: {},
|
|
204
|
+
extensionTitle: "",
|
|
205
|
+
};
|
|
206
|
+
runtimes.set(id, runtime);
|
|
207
|
+
bridge.on("record", (record) => onRuntimeRecord(runtime, record));
|
|
208
|
+
bridge.on("exit", (info) => onRuntimeExit(runtime, info));
|
|
209
|
+
bridge.start();
|
|
210
|
+
return runtime;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @param {import("./pi-types.js").PiRecord} record */
|
|
214
|
+
function onRuntimeRecord(runtime, record) {
|
|
215
|
+
runtime.live.apply(record);
|
|
216
|
+
if (record.type === "session_info_changed") {
|
|
217
|
+
runtime.sessionName = record.name ?? "";
|
|
218
|
+
}
|
|
219
|
+
if (record.type === "agent_start") {
|
|
220
|
+
runtime.running = true;
|
|
221
|
+
if (!runtime.runStartedAt) runtime.runStartedAt = Date.now();
|
|
222
|
+
}
|
|
223
|
+
if (record.type === "agent_settled") {
|
|
224
|
+
runtime.running = false;
|
|
225
|
+
runtime.runStartedAt = null;
|
|
226
|
+
refreshRuntimeState(runtime).catch(() => {});
|
|
227
|
+
}
|
|
228
|
+
if (
|
|
229
|
+
record.type === "extension_ui_request" &&
|
|
230
|
+
["select", "confirm", "input", "editor"].includes(record.method)
|
|
231
|
+
) {
|
|
232
|
+
runtime.pendingUi.set(record.id, record);
|
|
233
|
+
}
|
|
234
|
+
if (record.type === "extension_ui_request") {
|
|
235
|
+
if (record.method === "setStatus") {
|
|
236
|
+
if (record.statusText) runtime.statuses[record.statusKey] = record.statusText;
|
|
237
|
+
else delete runtime.statuses[record.statusKey];
|
|
238
|
+
}
|
|
239
|
+
if (record.method === "setWidget") {
|
|
240
|
+
const key = record.widgetKey ?? "widget";
|
|
241
|
+
if (Array.isArray(record.widgetLines) && record.widgetLines.length) {
|
|
242
|
+
runtime.widgets[key] = record.widgetLines.join("\n");
|
|
243
|
+
} else {
|
|
244
|
+
delete runtime.widgets[key];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (record.method === "setTitle") {
|
|
248
|
+
runtime.extensionTitle = record.title || "";
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (runtime !== activeRuntime) {
|
|
253
|
+
if (
|
|
254
|
+
[
|
|
255
|
+
"agent_start",
|
|
256
|
+
"agent_settled",
|
|
257
|
+
"session_info_changed",
|
|
258
|
+
"compaction_start",
|
|
259
|
+
"compaction_end",
|
|
260
|
+
"auto_retry_start",
|
|
261
|
+
"auto_retry_end",
|
|
262
|
+
].includes(record.type)
|
|
263
|
+
) {
|
|
264
|
+
broadcast({ type: "tinychat:runtime", runtime: runtimeSummary(runtime) });
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
broadcast({
|
|
270
|
+
...record,
|
|
271
|
+
runtime: runtime.id,
|
|
272
|
+
...(record.type === "agent_start" ? { runStartedAt: runtime.runStartedAt } : {}),
|
|
273
|
+
});
|
|
274
|
+
if (record.type === "tool_execution_end" && record.toolName === PLAN_TOOL) {
|
|
275
|
+
broadcast({
|
|
276
|
+
type: "tinychat:plan",
|
|
277
|
+
runtime: runtime.id,
|
|
278
|
+
plan: normalizePlan(record.result?.details),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (
|
|
282
|
+
record.type === "message_end" &&
|
|
283
|
+
record.message?.role === "toolResult" &&
|
|
284
|
+
record.message.toolName === PLAN_TOOL
|
|
285
|
+
) {
|
|
286
|
+
broadcast({
|
|
287
|
+
type: "tinychat:plan",
|
|
288
|
+
runtime: runtime.id,
|
|
289
|
+
plan: normalizePlan(record.message.details),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
if (
|
|
293
|
+
record.type === "entry_appended" &&
|
|
294
|
+
record.entry?.type === "custom" &&
|
|
295
|
+
record.entry.customType === PLAN_STATE_ENTRY
|
|
296
|
+
) {
|
|
297
|
+
broadcast({
|
|
298
|
+
type: "tinychat:plan",
|
|
299
|
+
runtime: runtime.id,
|
|
300
|
+
plan: normalizePlan(record.entry.data),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function onRuntimeExit(runtime, info) {
|
|
306
|
+
runtime.live.reset();
|
|
307
|
+
runtime.running = false;
|
|
308
|
+
runtime.runStartedAt = null;
|
|
309
|
+
runtime.exit = info;
|
|
310
|
+
runtime.pendingUi.clear();
|
|
311
|
+
runtime.statuses = {};
|
|
312
|
+
runtime.widgets = {};
|
|
313
|
+
runtime.extensionTitle = "";
|
|
314
|
+
if (runtime === activeRuntime) {
|
|
315
|
+
broadcast({ type: "tinychat:exit", runtime: runtime.id, reason: info.reason });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
runtimes.delete(runtime.id);
|
|
319
|
+
broadcast({ type: "tinychat:runtime", runtime: runtimeSummary(runtime) });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function startRuntime(options = {}) {
|
|
323
|
+
const runtime = makeRuntime(options);
|
|
324
|
+
try {
|
|
325
|
+
await waitForPi(runtime.bridge);
|
|
326
|
+
await refreshRuntimeState(runtime);
|
|
327
|
+
return runtime;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
await runtime.bridge.stop().catch(() => {});
|
|
330
|
+
runtimes.delete(runtime.id);
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function activateRuntime(runtime) {
|
|
336
|
+
if (activeRuntime && activeRuntime !== runtime && activeRuntime.exit) {
|
|
337
|
+
runtimes.delete(activeRuntime.id);
|
|
338
|
+
}
|
|
339
|
+
activeRuntime = runtime;
|
|
340
|
+
return runtime;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function findRuntimeByPath(path) {
|
|
344
|
+
const key = sessionKey(path);
|
|
345
|
+
for (const runtime of runtimes.values()) {
|
|
346
|
+
if (runtime.exit) continue;
|
|
347
|
+
if (runtime.sessionFile && sessionKey(runtime.sessionFile) === key) return runtime;
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function runtimeForPath(path) {
|
|
353
|
+
const existing = findRuntimeByPath(path);
|
|
354
|
+
if (existing) return existing;
|
|
355
|
+
const key = sessionKey(path);
|
|
356
|
+
let pending = startingSessions.get(key);
|
|
357
|
+
if (!pending) {
|
|
358
|
+
pending = startRuntime({ sessionPath: path });
|
|
359
|
+
startingSessions.set(key, pending);
|
|
360
|
+
pending.then(
|
|
361
|
+
() => startingSessions.delete(key),
|
|
362
|
+
() => startingSessions.delete(key),
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
const runtime = await pending;
|
|
366
|
+
return findRuntimeByPath(path) ?? runtime;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function requireRuntime() {
|
|
370
|
+
if (!activeRuntime) throw new TinyChatError("server.notConnected");
|
|
371
|
+
if (activeRuntime.exit) throw new Error(activeRuntime.exit.reason);
|
|
372
|
+
return activeRuntime;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function allSessions() {
|
|
376
|
+
await Promise.all(
|
|
377
|
+
[...runtimes.values()]
|
|
378
|
+
.filter((runtime) => runtime.bridge.running)
|
|
379
|
+
.map((runtime) => refreshRuntimeState(runtime)),
|
|
380
|
+
);
|
|
381
|
+
const currentId = activeRuntime?.sessionId ?? "";
|
|
382
|
+
const sessions = await listSessions({ cwd, currentId, env });
|
|
383
|
+
const bySessionId = new Map();
|
|
384
|
+
for (const runtime of runtimes.values()) {
|
|
385
|
+
if (runtime.sessionId && !runtime.exit) bySessionId.set(runtime.sessionId, runtime);
|
|
386
|
+
}
|
|
387
|
+
const represented = new Set(sessions.map((session) => session.id));
|
|
388
|
+
const live = [];
|
|
389
|
+
for (const runtime of runtimes.values()) {
|
|
390
|
+
if (!runtime.sessionId || runtime.exit || represented.has(runtime.sessionId)) continue;
|
|
391
|
+
live.push({
|
|
392
|
+
path: runtime.sessionFile,
|
|
393
|
+
id: runtime.sessionId,
|
|
394
|
+
name: runtime.sessionName,
|
|
395
|
+
preview: "",
|
|
396
|
+
created: "",
|
|
397
|
+
modified: new Date(runtime.startedAt).toISOString(),
|
|
398
|
+
size: 0,
|
|
399
|
+
current: runtime === activeRuntime,
|
|
400
|
+
runtime: runtime.id,
|
|
401
|
+
running: runtime.running,
|
|
402
|
+
live: true,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
const all = [
|
|
406
|
+
...live.sort((a, b) => b.modified.localeCompare(a.modified)),
|
|
407
|
+
...sessions.map((session) => {
|
|
408
|
+
const runtime = bySessionId.get(session.id);
|
|
409
|
+
return {
|
|
410
|
+
...session,
|
|
411
|
+
runtime: runtime?.id ?? null,
|
|
412
|
+
running: Boolean(runtime?.running),
|
|
413
|
+
live: Boolean(runtime),
|
|
414
|
+
};
|
|
415
|
+
}),
|
|
416
|
+
];
|
|
417
|
+
return { sessions: all, total: all.length };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function broadcast(record) {
|
|
421
|
+
if (clients.size === 0) return;
|
|
422
|
+
const frame = `data: ${JSON.stringify(record)}\n\n`;
|
|
423
|
+
for (const client of clients) {
|
|
424
|
+
try {
|
|
425
|
+
client.write(frame);
|
|
426
|
+
} catch {
|
|
427
|
+
clients.delete(client);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function snapshot() {
|
|
433
|
+
const runtime = requireRuntime();
|
|
434
|
+
const bridge = runtime.bridge;
|
|
435
|
+
const [state, messages, stats, models, levels, commands, entries] = await Promise.all([
|
|
436
|
+
bridge.request({ type: "get_state" }),
|
|
437
|
+
bridge.request({ type: "get_messages" }),
|
|
438
|
+
bridge.request({ type: "get_session_stats" }).catch(() => null),
|
|
439
|
+
bridge.request({ type: "get_available_models" }).catch(() => null),
|
|
440
|
+
bridge.request({ type: "get_available_thinking_levels" }).catch(() => null),
|
|
441
|
+
bridge.request({ type: "get_commands" }).catch(() => null),
|
|
442
|
+
bridge.request({ type: "get_entries" }).catch(() => null),
|
|
443
|
+
]);
|
|
444
|
+
if (state) {
|
|
445
|
+
runtime.sessionId = state.sessionId ?? runtime.sessionId;
|
|
446
|
+
if (state.sessionFile !== undefined) runtime.sessionFile = state.sessionFile;
|
|
447
|
+
if (state.sessionName !== undefined) runtime.sessionName = state.sessionName;
|
|
448
|
+
runtime.running = Boolean(state.isStreaming);
|
|
449
|
+
if (runtime.running && !runtime.runStartedAt) runtime.runStartedAt = Date.now();
|
|
450
|
+
if (!runtime.running) runtime.runStartedAt = null;
|
|
451
|
+
}
|
|
452
|
+
const compaction = await describeCompaction({ env, model: state?.model });
|
|
453
|
+
return {
|
|
454
|
+
runtime: runtime.id,
|
|
455
|
+
cwd,
|
|
456
|
+
state,
|
|
457
|
+
runStartedAt: runtime.runStartedAt,
|
|
458
|
+
live: runtime.live.snapshot(),
|
|
459
|
+
messages: Array.isArray(messages?.messages) ? messages.messages : [],
|
|
460
|
+
stats: stats ?? null,
|
|
461
|
+
models: (models?.models ?? []).map(publicModel),
|
|
462
|
+
levels: levels?.levels ?? ["off"],
|
|
463
|
+
commands: commands?.commands ?? [],
|
|
464
|
+
plan: entries ? planFromEntries(entries.entries, entries.leafId) : null,
|
|
465
|
+
pendingUi: [...runtime.pendingUi.values()],
|
|
466
|
+
statuses: { ...runtime.statuses },
|
|
467
|
+
widgets: { ...runtime.widgets },
|
|
468
|
+
extensionTitle: runtime.extensionTitle,
|
|
469
|
+
compaction,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function currentModel() {
|
|
474
|
+
const state = await requireRuntime().bridge.request({ type: "get_state" });
|
|
475
|
+
if (!state?.model) throw new TinyChatError("server.noModel");
|
|
476
|
+
return state.model;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function handleRpc(command) {
|
|
480
|
+
if (!command || typeof command !== "object" || typeof command.type !== "string") {
|
|
481
|
+
throw new TinyChatError("server.invalidCommand");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (command.type === "tinychat:snapshot") return snapshot();
|
|
485
|
+
if (command.type === "tinychat:sessions") return await allSessions();
|
|
486
|
+
if (command.type === "tinychat:activate") {
|
|
487
|
+
const runtime = runtimes.get(String(command.runtimeId));
|
|
488
|
+
if (!runtime || runtime.exit) throw new TinyChatError("server.sessionNotFound");
|
|
489
|
+
activateRuntime(runtime);
|
|
490
|
+
return { runtime: runtime.id };
|
|
491
|
+
}
|
|
492
|
+
if (command.type === "tinychat:restart") {
|
|
493
|
+
const runtime = activeRuntime;
|
|
494
|
+
if (!runtime) throw new TinyChatError("server.notConnected");
|
|
495
|
+
runtime.pendingUi.clear();
|
|
496
|
+
runtime.statuses = {};
|
|
497
|
+
runtime.widgets = {};
|
|
498
|
+
runtime.extensionTitle = "";
|
|
499
|
+
runtime.exit = null;
|
|
500
|
+
await runtime.bridge.restart();
|
|
501
|
+
await waitForPi(runtime.bridge);
|
|
502
|
+
runtime.live.reset();
|
|
503
|
+
await refreshRuntimeState(runtime);
|
|
504
|
+
return { restarted: true };
|
|
505
|
+
}
|
|
506
|
+
if (command.type === "tinychat:delete_sessions") {
|
|
507
|
+
const all = command.all === true;
|
|
508
|
+
const ids = Array.isArray(command.ids)
|
|
509
|
+
? command.ids.filter((id) => typeof id === "string" && id)
|
|
510
|
+
: null;
|
|
511
|
+
if (!all && (!ids || ids.length === 0)) {
|
|
512
|
+
throw new TinyChatError("server.deleteTargetMissing");
|
|
513
|
+
}
|
|
514
|
+
const { targets, missing } = await resolveSessionTargets({
|
|
515
|
+
cwd,
|
|
516
|
+
ids,
|
|
517
|
+
all,
|
|
518
|
+
env,
|
|
519
|
+
});
|
|
520
|
+
const protectedPaths = new Set();
|
|
521
|
+
const protectedIds = new Set();
|
|
522
|
+
for (const runtime of runtimes.values()) {
|
|
523
|
+
if (runtime.exit) continue;
|
|
524
|
+
if (runtime !== activeRuntime && !runtime.running) continue;
|
|
525
|
+
if (runtime.sessionFile) protectedPaths.add(sessionKey(runtime.sessionFile));
|
|
526
|
+
if (runtime.sessionId) protectedIds.add(runtime.sessionId);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** @type {{ id: string, error: string, errorKey?: string }[]} */
|
|
530
|
+
const failed = missing.map((id) => ({
|
|
531
|
+
id,
|
|
532
|
+
error: "server.sessionNotFound",
|
|
533
|
+
errorKey: "server.sessionNotFound",
|
|
534
|
+
}));
|
|
535
|
+
let deleted = 0;
|
|
536
|
+
for (const target of targets) {
|
|
537
|
+
if (
|
|
538
|
+
protectedPaths.has(sessionKey(target.path)) ||
|
|
539
|
+
protectedIds.has(target.id)
|
|
540
|
+
) {
|
|
541
|
+
failed.push({
|
|
542
|
+
id: target.id,
|
|
543
|
+
error: "server.sessionInUse",
|
|
544
|
+
errorKey: "server.sessionInUse",
|
|
545
|
+
});
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const runtime =
|
|
549
|
+
findRuntimeByPath(target.path) ??
|
|
550
|
+
[...runtimes.values()].find(
|
|
551
|
+
(candidate) => !candidate.exit && candidate.sessionId === target.id,
|
|
552
|
+
);
|
|
553
|
+
if (runtime) {
|
|
554
|
+
if (runtime.running) {
|
|
555
|
+
failed.push({
|
|
556
|
+
id: target.id,
|
|
557
|
+
error: "server.sessionRunning",
|
|
558
|
+
});
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
// A background session may have been opened earlier in this
|
|
562
|
+
// Pi TinyChat run. Stop it before removing the file it writes to.
|
|
563
|
+
await runtime.bridge.stop().catch(() => {});
|
|
564
|
+
runtimes.delete(runtime.id);
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
await deleteSessionFile({ cwd, path: target.path, env });
|
|
568
|
+
deleted += 1;
|
|
569
|
+
} catch (error) {
|
|
570
|
+
failed.push({ id: target.id, error: error.message });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return { deleted, failed };
|
|
574
|
+
}
|
|
575
|
+
if (command.type === "new_session") {
|
|
576
|
+
const runtime = await startRuntime();
|
|
577
|
+
activateRuntime(runtime);
|
|
578
|
+
return { cancelled: false, runtime: runtime.id };
|
|
579
|
+
}
|
|
580
|
+
if (command.type === "switch_session") {
|
|
581
|
+
if (typeof command.sessionPath !== "string") throw new TinyChatError("server.invalidPath");
|
|
582
|
+
const runtime = await runtimeForPath(command.sessionPath);
|
|
583
|
+
activateRuntime(runtime);
|
|
584
|
+
return { cancelled: false, runtime: runtime.id };
|
|
585
|
+
}
|
|
586
|
+
if (command.type === "tinychat:set_compaction") {
|
|
587
|
+
const model = await currentModel();
|
|
588
|
+
if (command.action === "enable") {
|
|
589
|
+
await setCompactionEnabled({ env, enabled: command.enabled !== false });
|
|
590
|
+
} else if (command.action === "clear") {
|
|
591
|
+
await clearCompactionThreshold({ env, model });
|
|
592
|
+
} else {
|
|
593
|
+
await setCompactionThreshold({
|
|
594
|
+
env,
|
|
595
|
+
model,
|
|
596
|
+
percent: Number(command.percent),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return { compaction: await describeCompaction({ env, model }) };
|
|
600
|
+
}
|
|
601
|
+
if (command.type === "tinychat:extension_ui_response") {
|
|
602
|
+
if (typeof command.id !== "string") throw new TinyChatError("server.invalidId");
|
|
603
|
+
const runtime = command.runtimeId
|
|
604
|
+
? runtimes.get(String(command.runtimeId))
|
|
605
|
+
: requireRuntime();
|
|
606
|
+
if (!runtime || runtime.exit) throw new TinyChatError("server.sessionNotFound");
|
|
607
|
+
const response = {
|
|
608
|
+
type: "extension_ui_response",
|
|
609
|
+
id: command.id,
|
|
610
|
+
...(command.cancelled
|
|
611
|
+
? { cancelled: true }
|
|
612
|
+
: command.confirmed !== undefined
|
|
613
|
+
? { confirmed: Boolean(command.confirmed) }
|
|
614
|
+
: { value: String(command.value ?? "") }),
|
|
615
|
+
};
|
|
616
|
+
runtime.pendingUi.delete(command.id);
|
|
617
|
+
runtime.bridge.sendRaw(response);
|
|
618
|
+
return { sent: true };
|
|
619
|
+
}
|
|
620
|
+
if (!FORWARDED.has(command.type)) throw new TinyChatError("server.unsupportedCommand");
|
|
621
|
+
|
|
622
|
+
const payload = { type: command.type };
|
|
623
|
+
if (command.type === "prompt" || command.type === "steer" || command.type === "follow_up") {
|
|
624
|
+
if (typeof command.message !== "string" || !command.message.trim()) {
|
|
625
|
+
throw new TinyChatError("server.invalidMessage");
|
|
626
|
+
}
|
|
627
|
+
payload.message = command.message;
|
|
628
|
+
const images = validateImages(command.images);
|
|
629
|
+
if (images) payload.images = images;
|
|
630
|
+
if (command.streamingBehavior) payload.streamingBehavior = command.streamingBehavior;
|
|
631
|
+
}
|
|
632
|
+
if (command.type === "set_model") {
|
|
633
|
+
if (typeof command.provider !== "string" || typeof command.modelId !== "string") {
|
|
634
|
+
throw new TinyChatError("server.invalidModel");
|
|
635
|
+
}
|
|
636
|
+
payload.provider = command.provider;
|
|
637
|
+
payload.modelId = command.modelId;
|
|
638
|
+
}
|
|
639
|
+
if (command.type === "set_thinking_level") {
|
|
640
|
+
if (typeof command.level !== "string") throw new TinyChatError("server.invalidLevel");
|
|
641
|
+
payload.level = command.level;
|
|
642
|
+
}
|
|
643
|
+
if (command.type === "set_session_name") {
|
|
644
|
+
if (typeof command.name !== "string") throw new TinyChatError("server.invalidName");
|
|
645
|
+
payload.name = command.name;
|
|
646
|
+
}
|
|
647
|
+
if (command.type === "compact" && typeof command.customInstructions === "string") {
|
|
648
|
+
payload.customInstructions = command.customInstructions;
|
|
649
|
+
}
|
|
650
|
+
return await requireRuntime().bridge.request(payload, {
|
|
651
|
+
timeout: RPC_TIMEOUTS[command.type] ?? DEFAULT_RPC_TIMEOUT,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function cookieToken(request) {
|
|
656
|
+
const header = request.headers.cookie;
|
|
657
|
+
if (typeof header !== "string") return null;
|
|
658
|
+
for (const part of header.split(";")) {
|
|
659
|
+
const separator = part.indexOf("=");
|
|
660
|
+
if (separator === -1) continue;
|
|
661
|
+
if (part.slice(0, separator).trim() !== "pi_tinychat") continue;
|
|
662
|
+
try {
|
|
663
|
+
return decodeURIComponent(part.slice(separator + 1).trim());
|
|
664
|
+
} catch {
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function authorized(request, url) {
|
|
672
|
+
const header = request.headers["x-pi-tinychat-token"];
|
|
673
|
+
if (typeof header === "string" && header === token) return true;
|
|
674
|
+
if (cookieToken(request) === token) return true;
|
|
675
|
+
return (
|
|
676
|
+
url.searchParams.get("launch") === token || url.searchParams.get("token") === token
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function hostAllowed(request) {
|
|
681
|
+
const header = request.headers.host;
|
|
682
|
+
if (!header) return false;
|
|
683
|
+
const match = /^(.*):(\d+)$/.exec(header);
|
|
684
|
+
if (!match) return false;
|
|
685
|
+
if (match[2] !== String(actualPort)) return false;
|
|
686
|
+
return match[1] === "127.0.0.1" || match[1] === "localhost" || match[1] === "[::1]";
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function json(response, status, body) {
|
|
690
|
+
const payload = JSON.stringify(body);
|
|
691
|
+
response.writeHead(status, {
|
|
692
|
+
"content-type": "application/json; charset=utf-8",
|
|
693
|
+
"cache-control": "no-store",
|
|
694
|
+
"content-length": Buffer.byteLength(payload),
|
|
695
|
+
});
|
|
696
|
+
response.end(payload);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function readBody(request) {
|
|
700
|
+
return new Promise((resolve, reject) => {
|
|
701
|
+
let size = 0;
|
|
702
|
+
const chunks = [];
|
|
703
|
+
request.on("data", (chunk) => {
|
|
704
|
+
size += chunk.length;
|
|
705
|
+
if (size > MAX_BODY) {
|
|
706
|
+
reject(new TinyChatError("server.requestTooLarge"));
|
|
707
|
+
request.destroy();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
chunks.push(chunk);
|
|
711
|
+
});
|
|
712
|
+
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
713
|
+
request.on("error", reject);
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const server = createServer(async (request, response) => {
|
|
718
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? host}`);
|
|
719
|
+
|
|
720
|
+
if (!hostAllowed(request)) {
|
|
721
|
+
response.writeHead(421, { "content-type": "text/plain; charset=utf-8" });
|
|
722
|
+
response.end("Misdirected request");
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (
|
|
727
|
+
request.method === "GET" &&
|
|
728
|
+
url.pathname === "/" &&
|
|
729
|
+
(url.searchParams.has("launch") || url.searchParams.has("token"))
|
|
730
|
+
) {
|
|
731
|
+
if (!authorized(request, url)) {
|
|
732
|
+
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
733
|
+
response.end("Unauthorized — open the URL printed by pi-tinychat");
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
// Exchange the launch credential for a localhost cookie and redirect to
|
|
737
|
+
// a clean address. The credential itself stays as strong as before.
|
|
738
|
+
response.writeHead(303, {
|
|
739
|
+
location: "/",
|
|
740
|
+
"set-cookie": `pi_tinychat=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400`,
|
|
741
|
+
"cache-control": "no-store",
|
|
742
|
+
"referrer-policy": "no-referrer",
|
|
743
|
+
});
|
|
744
|
+
response.end();
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (url.pathname === "/api/events") {
|
|
749
|
+
if (!authorized(request, url)) {
|
|
750
|
+
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
751
|
+
response.end("Unauthorized");
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
response.writeHead(200, {
|
|
755
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
756
|
+
"cache-control": "no-store",
|
|
757
|
+
connection: "keep-alive",
|
|
758
|
+
"x-accel-buffering": "no",
|
|
759
|
+
});
|
|
760
|
+
response.write("retry: 3000\n\n");
|
|
761
|
+
response.write(`data: ${JSON.stringify({ type: "tinychat:hello", cwd })}\n\n`);
|
|
762
|
+
clients.add(response);
|
|
763
|
+
request.on("close", () => clients.delete(response));
|
|
764
|
+
response.on("error", () => clients.delete(response));
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (request.method === "POST" && url.pathname === "/api/rpc") {
|
|
769
|
+
const origin = request.headers.origin;
|
|
770
|
+
if (origin && origin !== `http://${request.headers.host}`) {
|
|
771
|
+
json(response, 403, { ok: false, error: "Forbidden" });
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (!authorized(request, url)) {
|
|
775
|
+
json(response, 401, { ok: false, error: "Unauthorized" });
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
let command;
|
|
779
|
+
try {
|
|
780
|
+
command = JSON.parse(await readBody(request));
|
|
781
|
+
} catch (error) {
|
|
782
|
+
json(response, 400, { ok: false, error: error.message });
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
json(response, 200, { ok: true, data: await handleRpc(command) });
|
|
787
|
+
} catch (error) {
|
|
788
|
+
json(response, 200, {
|
|
789
|
+
ok: false,
|
|
790
|
+
error: error.message,
|
|
791
|
+
...(error instanceof TinyChatError
|
|
792
|
+
? { errorKey: error.key, errorParams: error.params }
|
|
793
|
+
: {}),
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
800
|
+
response.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
|
|
801
|
+
response.end("Method not allowed");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const asset = ASSETS[url.pathname];
|
|
806
|
+
if (!asset) {
|
|
807
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
808
|
+
response.end("Not found");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const [file, type] = asset;
|
|
812
|
+
// The HTML shell needs the session token; the assets it references do not
|
|
813
|
+
// contain any secret and are served without one so the page can boot.
|
|
814
|
+
const needsToken = type.startsWith("text/html");
|
|
815
|
+
if (needsToken && !authorized(request, url)) {
|
|
816
|
+
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
817
|
+
response.end("Unauthorized — open the URL printed by pi-tinychat");
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
response.writeHead(200, {
|
|
821
|
+
"content-type": type,
|
|
822
|
+
"cache-control": "no-store",
|
|
823
|
+
"content-security-policy": CONTENT_SECURITY_POLICY,
|
|
824
|
+
"referrer-policy": "no-referrer",
|
|
825
|
+
"x-content-type-options": "nosniff",
|
|
826
|
+
});
|
|
827
|
+
// Read on every request so UI edits show up on reload without a restart.
|
|
828
|
+
response.end(
|
|
829
|
+
request.method === "HEAD" ? undefined : await readFile(join(PUBLIC_DIR, file)),
|
|
830
|
+
);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
const heartbeat = setInterval(() => {
|
|
834
|
+
for (const client of clients) {
|
|
835
|
+
try {
|
|
836
|
+
client.write(": ping\n\n");
|
|
837
|
+
} catch {
|
|
838
|
+
clients.delete(client);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}, HEARTBEAT_MS);
|
|
842
|
+
heartbeat.unref();
|
|
843
|
+
|
|
844
|
+
const lastPort = port === 0 ? 0 : Math.min(65535, port + portAttempts - 1);
|
|
845
|
+
let bound = false;
|
|
846
|
+
let listenError = null;
|
|
847
|
+
for (let candidate = port; candidate <= lastPort; candidate += 1) {
|
|
848
|
+
try {
|
|
849
|
+
await new Promise((resolve, reject) => {
|
|
850
|
+
const onError = (error) => reject(error);
|
|
851
|
+
server.once("error", onError);
|
|
852
|
+
server.listen(candidate, host, () => {
|
|
853
|
+
server.removeListener("error", onError);
|
|
854
|
+
resolve();
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
bound = true;
|
|
858
|
+
break;
|
|
859
|
+
} catch (error) {
|
|
860
|
+
listenError = error;
|
|
861
|
+
if (error.code !== "EADDRINUSE") {
|
|
862
|
+
clearInterval(heartbeat);
|
|
863
|
+
throw error;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (!bound) {
|
|
868
|
+
clearInterval(heartbeat);
|
|
869
|
+
throw new Error(
|
|
870
|
+
port === 0
|
|
871
|
+
? `サーバーを起動できませんでした: ${listenError?.message ?? "unknown error"}`
|
|
872
|
+
: `ポート ${port}〜${lastPort} はすべて使用中です。--port で別のポートを指定してください。`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
const address = server.address();
|
|
876
|
+
actualPort = typeof address === "object" && address ? address.port : port;
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
activateRuntime(await startRuntime({ initial: true }));
|
|
880
|
+
} catch (error) {
|
|
881
|
+
clearInterval(heartbeat);
|
|
882
|
+
server.close();
|
|
883
|
+
throw error;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return {
|
|
887
|
+
server,
|
|
888
|
+
token,
|
|
889
|
+
port: actualPort,
|
|
890
|
+
url: `http://${host}:${actualPort}/?launch=${encodeURIComponent(token)}`,
|
|
891
|
+
get sessionName() {
|
|
892
|
+
return activeRuntime?.sessionName;
|
|
893
|
+
},
|
|
894
|
+
async close() {
|
|
895
|
+
if (closing) return;
|
|
896
|
+
closing = true;
|
|
897
|
+
clearInterval(heartbeat);
|
|
898
|
+
for (const client of clients) {
|
|
899
|
+
try {
|
|
900
|
+
client.end();
|
|
901
|
+
} catch {
|
|
902
|
+
/* ignore */
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
clients.clear();
|
|
906
|
+
await Promise.all([...runtimes.values()].map((runtime) => runtime.bridge.stop()));
|
|
907
|
+
await new Promise((resolve) => server.close(resolve));
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
}
|