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/public/theme-init.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
// Applied before the stylesheet so the first paint matches the saved theme.
|
|
2
|
-
// This is the only script that reads localStorage before the app boots.
|
|
3
|
-
(() => {
|
|
4
|
-
let choice = "system";
|
|
5
|
-
try {
|
|
6
|
-
const raw =
|
|
7
|
-
localStorage.getItem("pi-tinychat:settings") ?? localStorage.getItem("pi-dock:settings");
|
|
8
|
-
if (raw) {
|
|
9
|
-
const parsed = JSON.parse(raw);
|
|
10
|
-
if (parsed && ["system", "light", "dark"].includes(parsed.theme)) choice = parsed.theme;
|
|
11
|
-
}
|
|
12
|
-
} catch {
|
|
13
|
-
/* storage unavailable */
|
|
14
|
-
}
|
|
15
|
-
const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
16
|
-
document.documentElement.dataset.theme =
|
|
17
|
-
choice === "system" ? (dark ? "dark" : "light") : choice;
|
|
18
|
-
})();
|
|
1
|
+
// Applied before the stylesheet so the first paint matches the saved theme.
|
|
2
|
+
// This is the only script that reads localStorage before the app boots.
|
|
3
|
+
(() => {
|
|
4
|
+
let choice = "system";
|
|
5
|
+
try {
|
|
6
|
+
const raw =
|
|
7
|
+
localStorage.getItem("pi-tinychat:settings") ?? localStorage.getItem("pi-dock:settings");
|
|
8
|
+
if (raw) {
|
|
9
|
+
const parsed = JSON.parse(raw);
|
|
10
|
+
if (parsed && ["system", "light", "dark"].includes(parsed.theme)) choice = parsed.theme;
|
|
11
|
+
}
|
|
12
|
+
} catch {
|
|
13
|
+
/* storage unavailable */
|
|
14
|
+
}
|
|
15
|
+
const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
16
|
+
document.documentElement.dataset.theme =
|
|
17
|
+
choice === "system" ? (dark ? "dark" : "light") : choice;
|
|
18
|
+
})();
|
package/src/compaction.js
CHANGED
|
@@ -1,139 +1,139 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import { TinyChatError } from "./errors.js";
|
|
5
|
-
import { agentDir } from "./sessions.js";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Pi owns compaction configuration. Pi TinyChat only reads and writes Pi's global
|
|
9
|
-
* settings so the UI can show the threshold as a percentage of the current
|
|
10
|
-
* model's context window. Project settings and per-model overrides are left
|
|
11
|
-
* entirely to Pi.
|
|
12
|
-
*
|
|
13
|
-
* Writes happen only on explicit user action, with an atomic rename. Do not
|
|
14
|
-
* edit the same settings file from Pi and Pi TinyChat at the same moment.
|
|
15
|
-
*/
|
|
16
|
-
const DEFAULT_RESERVE = 16_384;
|
|
17
|
-
const ASSUMED_CONTEXT_WINDOW = 1_000_000;
|
|
18
|
-
|
|
19
|
-
export function settingsPath(env = process.env) {
|
|
20
|
-
return join(agentDir(env), "settings.json");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async function readSettings(path, { strict = false } = {}) {
|
|
24
|
-
try {
|
|
25
|
-
const text = (await readFile(path, "utf8")).replace(/^\uFEFF/, "");
|
|
26
|
-
if (!text.trim()) return {};
|
|
27
|
-
const value = JSON.parse(text);
|
|
28
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
29
|
-
if (strict) throw new TinyChatError("server.invalidSettings");
|
|
30
|
-
return {};
|
|
31
|
-
}
|
|
32
|
-
return value;
|
|
33
|
-
} catch (error) {
|
|
34
|
-
if (error instanceof TinyChatError) throw error;
|
|
35
|
-
if (error.code === "ENOENT") return {};
|
|
36
|
-
if (strict) throw new TinyChatError("server.readSettings", { message: error.message });
|
|
37
|
-
return {};
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function safeInteger(value) {
|
|
42
|
-
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export async function describeCompaction({ env = process.env, model }) {
|
|
46
|
-
const path = settingsPath(env);
|
|
47
|
-
const settings = await readSettings(path);
|
|
48
|
-
const compaction =
|
|
49
|
-
settings.compaction && typeof settings.compaction === "object" ? settings.compaction : {};
|
|
50
|
-
const contextWindow = safeInteger(model?.contextWindow) ?? 0;
|
|
51
|
-
const displayContextWindow = contextWindow || ASSUMED_CONTEXT_WINDOW;
|
|
52
|
-
const configuredReserve = safeInteger(compaction.reserveTokens);
|
|
53
|
-
const reserveTokens = configuredReserve ?? DEFAULT_RESERVE;
|
|
54
|
-
const thresholdTokens = Math.max(0, displayContextWindow - reserveTokens);
|
|
55
|
-
return {
|
|
56
|
-
enabled: compaction.enabled !== false,
|
|
57
|
-
settingsPath: path,
|
|
58
|
-
reserveTokens,
|
|
59
|
-
reserveSource: configuredReserve === null ? "pi-default" : "settings",
|
|
60
|
-
contextWindow,
|
|
61
|
-
displayContextWindow,
|
|
62
|
-
contextWindowKnown: contextWindow > 0,
|
|
63
|
-
thresholdTokens,
|
|
64
|
-
thresholdPercent: (thresholdTokens / displayContextWindow) * 100,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function pruneEmpty(settings) {
|
|
69
|
-
const compaction = settings.compaction;
|
|
70
|
-
if (!compaction || typeof compaction !== "object") return;
|
|
71
|
-
if (Object.keys(compaction).length === 0) delete settings.compaction;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export async function setCompactionThreshold({ env = process.env, model, percent }) {
|
|
75
|
-
const contextWindow = safeInteger(model?.contextWindow) ?? 0;
|
|
76
|
-
if (!contextWindow) throw new TinyChatError("server.contextUnknown");
|
|
77
|
-
if (typeof percent !== "number" || !Number.isFinite(percent) || percent <= 0 || percent >= 100) {
|
|
78
|
-
throw new TinyChatError("server.percentRange");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const path = settingsPath(env);
|
|
82
|
-
const settings = await readSettings(path, { strict: true });
|
|
83
|
-
const compaction =
|
|
84
|
-
settings.compaction && typeof settings.compaction === "object" ? settings.compaction : {};
|
|
85
|
-
compaction.reserveTokens = contextWindow - Math.round((contextWindow * percent) / 100);
|
|
86
|
-
settings.compaction = compaction;
|
|
87
|
-
await writeSettings(path, settings);
|
|
88
|
-
return describeCompaction({ env, model });
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export async function clearCompactionThreshold({ env = process.env, model }) {
|
|
92
|
-
const path = settingsPath(env);
|
|
93
|
-
const settings = await readSettings(path, { strict: true });
|
|
94
|
-
const compaction =
|
|
95
|
-
settings.compaction && typeof settings.compaction === "object" ? settings.compaction : null;
|
|
96
|
-
if (!compaction) return describeCompaction({ env, model });
|
|
97
|
-
delete compaction.reserveTokens;
|
|
98
|
-
pruneEmpty(settings);
|
|
99
|
-
await writeSettings(path, settings);
|
|
100
|
-
return describeCompaction({ env, model });
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export async function setCompactionEnabled({ env = process.env, enabled }) {
|
|
104
|
-
const path = settingsPath(env);
|
|
105
|
-
const settings = await readSettings(path, { strict: true });
|
|
106
|
-
const compaction =
|
|
107
|
-
settings.compaction && typeof settings.compaction === "object" ? settings.compaction : {};
|
|
108
|
-
compaction.enabled = Boolean(enabled);
|
|
109
|
-
settings.compaction = compaction;
|
|
110
|
-
await writeSettings(path, settings);
|
|
111
|
-
return { enabled: Boolean(enabled) };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async function writeSettings(path, settings) {
|
|
115
|
-
pruneEmpty(settings);
|
|
116
|
-
const body = `${JSON.stringify(settings, null, 2)}\n`;
|
|
117
|
-
let current = null;
|
|
118
|
-
try {
|
|
119
|
-
current = await readFile(path, "utf8");
|
|
120
|
-
} catch (error) {
|
|
121
|
-
if (error.code !== "ENOENT") throw error;
|
|
122
|
-
}
|
|
123
|
-
if (current === body) return;
|
|
124
|
-
let mode = 0o600;
|
|
125
|
-
try {
|
|
126
|
-
mode = (await stat(path)).mode & 0o777;
|
|
127
|
-
} catch (error) {
|
|
128
|
-
if (error.code !== "ENOENT") throw error;
|
|
129
|
-
}
|
|
130
|
-
await mkdir(dirname(path), { recursive: true });
|
|
131
|
-
const temporaryPath = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
132
|
-
try {
|
|
133
|
-
await writeFile(temporaryPath, body, { encoding: "utf8", mode });
|
|
134
|
-
if (process.platform !== "win32") await chmod(temporaryPath, mode);
|
|
135
|
-
await rename(temporaryPath, path);
|
|
136
|
-
} finally {
|
|
137
|
-
await unlink(temporaryPath).catch(() => {});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { TinyChatError } from "./errors.js";
|
|
5
|
+
import { agentDir } from "./sessions.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pi owns compaction configuration. Pi TinyChat only reads and writes Pi's global
|
|
9
|
+
* settings so the UI can show the threshold as a percentage of the current
|
|
10
|
+
* model's context window. Project settings and per-model overrides are left
|
|
11
|
+
* entirely to Pi.
|
|
12
|
+
*
|
|
13
|
+
* Writes happen only on explicit user action, with an atomic rename. Do not
|
|
14
|
+
* edit the same settings file from Pi and Pi TinyChat at the same moment.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_RESERVE = 16_384;
|
|
17
|
+
const ASSUMED_CONTEXT_WINDOW = 1_000_000;
|
|
18
|
+
|
|
19
|
+
export function settingsPath(env = process.env) {
|
|
20
|
+
return join(agentDir(env), "settings.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readSettings(path, { strict = false } = {}) {
|
|
24
|
+
try {
|
|
25
|
+
const text = (await readFile(path, "utf8")).replace(/^\uFEFF/, "");
|
|
26
|
+
if (!text.trim()) return {};
|
|
27
|
+
const value = JSON.parse(text);
|
|
28
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
29
|
+
if (strict) throw new TinyChatError("server.invalidSettings");
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error instanceof TinyChatError) throw error;
|
|
35
|
+
if (error.code === "ENOENT") return {};
|
|
36
|
+
if (strict) throw new TinyChatError("server.readSettings", { message: error.message });
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function safeInteger(value) {
|
|
42
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function describeCompaction({ env = process.env, model }) {
|
|
46
|
+
const path = settingsPath(env);
|
|
47
|
+
const settings = await readSettings(path);
|
|
48
|
+
const compaction =
|
|
49
|
+
settings.compaction && typeof settings.compaction === "object" ? settings.compaction : {};
|
|
50
|
+
const contextWindow = safeInteger(model?.contextWindow) ?? 0;
|
|
51
|
+
const displayContextWindow = contextWindow || ASSUMED_CONTEXT_WINDOW;
|
|
52
|
+
const configuredReserve = safeInteger(compaction.reserveTokens);
|
|
53
|
+
const reserveTokens = configuredReserve ?? DEFAULT_RESERVE;
|
|
54
|
+
const thresholdTokens = Math.max(0, displayContextWindow - reserveTokens);
|
|
55
|
+
return {
|
|
56
|
+
enabled: compaction.enabled !== false,
|
|
57
|
+
settingsPath: path,
|
|
58
|
+
reserveTokens,
|
|
59
|
+
reserveSource: configuredReserve === null ? "pi-default" : "settings",
|
|
60
|
+
contextWindow,
|
|
61
|
+
displayContextWindow,
|
|
62
|
+
contextWindowKnown: contextWindow > 0,
|
|
63
|
+
thresholdTokens,
|
|
64
|
+
thresholdPercent: (thresholdTokens / displayContextWindow) * 100,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pruneEmpty(settings) {
|
|
69
|
+
const compaction = settings.compaction;
|
|
70
|
+
if (!compaction || typeof compaction !== "object") return;
|
|
71
|
+
if (Object.keys(compaction).length === 0) delete settings.compaction;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function setCompactionThreshold({ env = process.env, model, percent }) {
|
|
75
|
+
const contextWindow = safeInteger(model?.contextWindow) ?? 0;
|
|
76
|
+
if (!contextWindow) throw new TinyChatError("server.contextUnknown");
|
|
77
|
+
if (typeof percent !== "number" || !Number.isFinite(percent) || percent <= 0 || percent >= 100) {
|
|
78
|
+
throw new TinyChatError("server.percentRange");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const path = settingsPath(env);
|
|
82
|
+
const settings = await readSettings(path, { strict: true });
|
|
83
|
+
const compaction =
|
|
84
|
+
settings.compaction && typeof settings.compaction === "object" ? settings.compaction : {};
|
|
85
|
+
compaction.reserveTokens = contextWindow - Math.round((contextWindow * percent) / 100);
|
|
86
|
+
settings.compaction = compaction;
|
|
87
|
+
await writeSettings(path, settings);
|
|
88
|
+
return describeCompaction({ env, model });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function clearCompactionThreshold({ env = process.env, model }) {
|
|
92
|
+
const path = settingsPath(env);
|
|
93
|
+
const settings = await readSettings(path, { strict: true });
|
|
94
|
+
const compaction =
|
|
95
|
+
settings.compaction && typeof settings.compaction === "object" ? settings.compaction : null;
|
|
96
|
+
if (!compaction) return describeCompaction({ env, model });
|
|
97
|
+
delete compaction.reserveTokens;
|
|
98
|
+
pruneEmpty(settings);
|
|
99
|
+
await writeSettings(path, settings);
|
|
100
|
+
return describeCompaction({ env, model });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function setCompactionEnabled({ env = process.env, enabled }) {
|
|
104
|
+
const path = settingsPath(env);
|
|
105
|
+
const settings = await readSettings(path, { strict: true });
|
|
106
|
+
const compaction =
|
|
107
|
+
settings.compaction && typeof settings.compaction === "object" ? settings.compaction : {};
|
|
108
|
+
compaction.enabled = Boolean(enabled);
|
|
109
|
+
settings.compaction = compaction;
|
|
110
|
+
await writeSettings(path, settings);
|
|
111
|
+
return { enabled: Boolean(enabled) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function writeSettings(path, settings) {
|
|
115
|
+
pruneEmpty(settings);
|
|
116
|
+
const body = `${JSON.stringify(settings, null, 2)}\n`;
|
|
117
|
+
let current = null;
|
|
118
|
+
try {
|
|
119
|
+
current = await readFile(path, "utf8");
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error.code !== "ENOENT") throw error;
|
|
122
|
+
}
|
|
123
|
+
if (current === body) return;
|
|
124
|
+
let mode = 0o600;
|
|
125
|
+
try {
|
|
126
|
+
mode = (await stat(path)).mode & 0o777;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error.code !== "ENOENT") throw error;
|
|
129
|
+
}
|
|
130
|
+
await mkdir(dirname(path), { recursive: true });
|
|
131
|
+
const temporaryPath = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
132
|
+
try {
|
|
133
|
+
await writeFile(temporaryPath, body, { encoding: "utf8", mode });
|
|
134
|
+
if (process.platform !== "win32") await chmod(temporaryPath, mode);
|
|
135
|
+
await rename(temporaryPath, path);
|
|
136
|
+
} finally {
|
|
137
|
+
await unlink(temporaryPath).catch(() => {});
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/errors.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A user-facing error generated by Pi TinyChat (not Pi/OS). The browser translates
|
|
3
|
-
* `key` with its current language; the raw `message` stays for compatibility.
|
|
4
|
-
*/
|
|
5
|
-
export class TinyChatError extends Error {
|
|
6
|
-
/** @param {string} key @param {Record<string, unknown>} [params] */
|
|
7
|
-
constructor(key, params = {}) {
|
|
8
|
-
super(key);
|
|
9
|
-
this.name = "TinyChatError";
|
|
10
|
-
this.key = key;
|
|
11
|
-
this.params = params;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* A user-facing error generated by Pi TinyChat (not Pi/OS). The browser translates
|
|
3
|
+
* `key` with its current language; the raw `message` stays for compatibility.
|
|
4
|
+
*/
|
|
5
|
+
export class TinyChatError extends Error {
|
|
6
|
+
/** @param {string} key @param {Record<string, unknown>} [params] */
|
|
7
|
+
constructor(key, params = {}) {
|
|
8
|
+
super(key);
|
|
9
|
+
this.name = "TinyChatError";
|
|
10
|
+
this.key = key;
|
|
11
|
+
this.params = params;
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/live.js
CHANGED
|
@@ -1,133 +1,133 @@
|
|
|
1
|
-
/** @typedef {import("./pi-types.js").PiRecord} PiRecord */
|
|
2
|
-
/** @typedef {import("./pi-types.js").PiAssistantMessageEvent} PiAssistantMessageEvent */
|
|
3
|
-
/** @typedef {import("./pi-types.js").PiTextBlock} PiTextBlock */
|
|
4
|
-
/** @typedef {import("./pi-types.js").PiToolOutput} PiToolOutput */
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* In-memory view of the assistant message currently being streamed.
|
|
8
|
-
* Used only for snapshots (page reload during a run); rendering itself is
|
|
9
|
-
* driven by the event stream in the browser.
|
|
10
|
-
*/
|
|
11
|
-
export class LiveState {
|
|
12
|
-
#blocks = [];
|
|
13
|
-
#tools = new Map();
|
|
14
|
-
|
|
15
|
-
reset() {
|
|
16
|
-
this.#blocks = [];
|
|
17
|
-
this.#tools.clear();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** @param {PiRecord} record */
|
|
21
|
-
apply(record) {
|
|
22
|
-
switch (record.type) {
|
|
23
|
-
case "agent_start":
|
|
24
|
-
this.reset();
|
|
25
|
-
break;
|
|
26
|
-
case "message_start":
|
|
27
|
-
if (record.message?.role === "assistant") this.#blocks = [];
|
|
28
|
-
break;
|
|
29
|
-
case "message_update":
|
|
30
|
-
this.#applyUpdate(record.assistantMessageEvent);
|
|
31
|
-
break;
|
|
32
|
-
case "tool_execution_start":
|
|
33
|
-
this.#tools.set(record.toolCallId, {
|
|
34
|
-
id: record.toolCallId,
|
|
35
|
-
name: record.toolName,
|
|
36
|
-
args: record.args,
|
|
37
|
-
status: "running",
|
|
38
|
-
output: "",
|
|
39
|
-
isError: false,
|
|
40
|
-
});
|
|
41
|
-
break;
|
|
42
|
-
case "tool_execution_update": {
|
|
43
|
-
const tool = this.#tools.get(record.toolCallId);
|
|
44
|
-
if (tool) tool.output = toolText(record.partialResult);
|
|
45
|
-
break;
|
|
46
|
-
}
|
|
47
|
-
case "tool_execution_end": {
|
|
48
|
-
const tool = this.#tools.get(record.toolCallId);
|
|
49
|
-
if (tool) {
|
|
50
|
-
tool.status = record.isError ? "error" : "done";
|
|
51
|
-
tool.isError = Boolean(record.isError);
|
|
52
|
-
tool.output = toolText(record.result) || tool.output;
|
|
53
|
-
tool.durationMs = record.durationMs;
|
|
54
|
-
}
|
|
55
|
-
break;
|
|
56
|
-
}
|
|
57
|
-
case "message_end":
|
|
58
|
-
if (record.message?.role === "assistant") this.#blocks = [];
|
|
59
|
-
break;
|
|
60
|
-
case "agent_settled":
|
|
61
|
-
this.reset();
|
|
62
|
-
break;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** @param {PiAssistantMessageEvent} update */
|
|
67
|
-
#applyUpdate(update) {
|
|
68
|
-
if (!update || !("contentIndex" in update)) return;
|
|
69
|
-
const index = update.contentIndex;
|
|
70
|
-
const block = this.#blocks[index];
|
|
71
|
-
switch (update.type) {
|
|
72
|
-
case "text_start":
|
|
73
|
-
this.#blocks[index] = { type: "text", text: "" };
|
|
74
|
-
break;
|
|
75
|
-
case "text_delta":
|
|
76
|
-
if (block?.type === "text") block.text += update.delta;
|
|
77
|
-
break;
|
|
78
|
-
case "text_end":
|
|
79
|
-
this.#blocks[index] = { type: "text", text: update.content };
|
|
80
|
-
break;
|
|
81
|
-
case "thinking_start":
|
|
82
|
-
this.#blocks[index] = { type: "thinking", thinking: "" };
|
|
83
|
-
break;
|
|
84
|
-
case "thinking_delta":
|
|
85
|
-
if (block?.type === "thinking") block.thinking += update.delta;
|
|
86
|
-
break;
|
|
87
|
-
case "thinking_end":
|
|
88
|
-
this.#blocks[index] = { type: "thinking", thinking: update.content };
|
|
89
|
-
break;
|
|
90
|
-
case "toolcall_start":
|
|
91
|
-
this.#blocks[index] = {
|
|
92
|
-
type: "toolCall",
|
|
93
|
-
id: update.id,
|
|
94
|
-
name: update.toolName,
|
|
95
|
-
arguments: {},
|
|
96
|
-
partialArgs: "",
|
|
97
|
-
};
|
|
98
|
-
break;
|
|
99
|
-
case "toolcall_delta":
|
|
100
|
-
if (block?.type === "toolCall") block.partialArgs += update.delta;
|
|
101
|
-
break;
|
|
102
|
-
case "toolcall_end": {
|
|
103
|
-
const call = update.toolCall;
|
|
104
|
-
this.#blocks[index] = {
|
|
105
|
-
type: "toolCall",
|
|
106
|
-
id: call.id,
|
|
107
|
-
name: call.name,
|
|
108
|
-
arguments: call.arguments ?? {},
|
|
109
|
-
};
|
|
110
|
-
break;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
snapshot() {
|
|
116
|
-
const blocks = this.#blocks.filter(Boolean);
|
|
117
|
-
return {
|
|
118
|
-
blocks,
|
|
119
|
-
tools: Object.fromEntries(this.#tools),
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** @param {PiToolOutput | string | null | undefined} result */
|
|
125
|
-
export function toolText(result) {
|
|
126
|
-
if (!result) return "";
|
|
127
|
-
if (typeof result === "string") return result;
|
|
128
|
-
const content = result.content ?? result.partialResult?.content ?? [];
|
|
129
|
-
if (typeof content === "string") return content;
|
|
130
|
-
if (!Array.isArray(content)) return "";
|
|
131
|
-
const texts = /** @type {PiTextBlock[]} */ (content.filter((block) => block?.type === "text"));
|
|
132
|
-
return texts.map((block) => block.text).join("\n");
|
|
133
|
-
}
|
|
1
|
+
/** @typedef {import("./pi-types.js").PiRecord} PiRecord */
|
|
2
|
+
/** @typedef {import("./pi-types.js").PiAssistantMessageEvent} PiAssistantMessageEvent */
|
|
3
|
+
/** @typedef {import("./pi-types.js").PiTextBlock} PiTextBlock */
|
|
4
|
+
/** @typedef {import("./pi-types.js").PiToolOutput} PiToolOutput */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-memory view of the assistant message currently being streamed.
|
|
8
|
+
* Used only for snapshots (page reload during a run); rendering itself is
|
|
9
|
+
* driven by the event stream in the browser.
|
|
10
|
+
*/
|
|
11
|
+
export class LiveState {
|
|
12
|
+
#blocks = [];
|
|
13
|
+
#tools = new Map();
|
|
14
|
+
|
|
15
|
+
reset() {
|
|
16
|
+
this.#blocks = [];
|
|
17
|
+
this.#tools.clear();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {PiRecord} record */
|
|
21
|
+
apply(record) {
|
|
22
|
+
switch (record.type) {
|
|
23
|
+
case "agent_start":
|
|
24
|
+
this.reset();
|
|
25
|
+
break;
|
|
26
|
+
case "message_start":
|
|
27
|
+
if (record.message?.role === "assistant") this.#blocks = [];
|
|
28
|
+
break;
|
|
29
|
+
case "message_update":
|
|
30
|
+
this.#applyUpdate(record.assistantMessageEvent);
|
|
31
|
+
break;
|
|
32
|
+
case "tool_execution_start":
|
|
33
|
+
this.#tools.set(record.toolCallId, {
|
|
34
|
+
id: record.toolCallId,
|
|
35
|
+
name: record.toolName,
|
|
36
|
+
args: record.args,
|
|
37
|
+
status: "running",
|
|
38
|
+
output: "",
|
|
39
|
+
isError: false,
|
|
40
|
+
});
|
|
41
|
+
break;
|
|
42
|
+
case "tool_execution_update": {
|
|
43
|
+
const tool = this.#tools.get(record.toolCallId);
|
|
44
|
+
if (tool) tool.output = toolText(record.partialResult);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case "tool_execution_end": {
|
|
48
|
+
const tool = this.#tools.get(record.toolCallId);
|
|
49
|
+
if (tool) {
|
|
50
|
+
tool.status = record.isError ? "error" : "done";
|
|
51
|
+
tool.isError = Boolean(record.isError);
|
|
52
|
+
tool.output = toolText(record.result) || tool.output;
|
|
53
|
+
tool.durationMs = record.durationMs;
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case "message_end":
|
|
58
|
+
if (record.message?.role === "assistant") this.#blocks = [];
|
|
59
|
+
break;
|
|
60
|
+
case "agent_settled":
|
|
61
|
+
this.reset();
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {PiAssistantMessageEvent} update */
|
|
67
|
+
#applyUpdate(update) {
|
|
68
|
+
if (!update || !("contentIndex" in update)) return;
|
|
69
|
+
const index = update.contentIndex;
|
|
70
|
+
const block = this.#blocks[index];
|
|
71
|
+
switch (update.type) {
|
|
72
|
+
case "text_start":
|
|
73
|
+
this.#blocks[index] = { type: "text", text: "" };
|
|
74
|
+
break;
|
|
75
|
+
case "text_delta":
|
|
76
|
+
if (block?.type === "text") block.text += update.delta;
|
|
77
|
+
break;
|
|
78
|
+
case "text_end":
|
|
79
|
+
this.#blocks[index] = { type: "text", text: update.content };
|
|
80
|
+
break;
|
|
81
|
+
case "thinking_start":
|
|
82
|
+
this.#blocks[index] = { type: "thinking", thinking: "" };
|
|
83
|
+
break;
|
|
84
|
+
case "thinking_delta":
|
|
85
|
+
if (block?.type === "thinking") block.thinking += update.delta;
|
|
86
|
+
break;
|
|
87
|
+
case "thinking_end":
|
|
88
|
+
this.#blocks[index] = { type: "thinking", thinking: update.content };
|
|
89
|
+
break;
|
|
90
|
+
case "toolcall_start":
|
|
91
|
+
this.#blocks[index] = {
|
|
92
|
+
type: "toolCall",
|
|
93
|
+
id: update.id,
|
|
94
|
+
name: update.toolName,
|
|
95
|
+
arguments: {},
|
|
96
|
+
partialArgs: "",
|
|
97
|
+
};
|
|
98
|
+
break;
|
|
99
|
+
case "toolcall_delta":
|
|
100
|
+
if (block?.type === "toolCall") block.partialArgs += update.delta;
|
|
101
|
+
break;
|
|
102
|
+
case "toolcall_end": {
|
|
103
|
+
const call = update.toolCall;
|
|
104
|
+
this.#blocks[index] = {
|
|
105
|
+
type: "toolCall",
|
|
106
|
+
id: call.id,
|
|
107
|
+
name: call.name,
|
|
108
|
+
arguments: call.arguments ?? {},
|
|
109
|
+
};
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
snapshot() {
|
|
116
|
+
const blocks = this.#blocks.filter(Boolean);
|
|
117
|
+
return {
|
|
118
|
+
blocks,
|
|
119
|
+
tools: Object.fromEntries(this.#tools),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @param {PiToolOutput | string | null | undefined} result */
|
|
125
|
+
export function toolText(result) {
|
|
126
|
+
if (!result) return "";
|
|
127
|
+
if (typeof result === "string") return result;
|
|
128
|
+
const content = result.content ?? result.partialResult?.content ?? [];
|
|
129
|
+
if (typeof content === "string") return content;
|
|
130
|
+
if (!Array.isArray(content)) return "";
|
|
131
|
+
const texts = /** @type {PiTextBlock[]} */ (content.filter((block) => block?.type === "text"));
|
|
132
|
+
return texts.map((block) => block.text).join("\n");
|
|
133
|
+
}
|