pi-web-ui 0.58.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * dsh-serialize.ts — DSH 会话事件/消息 → pi-web-ui 协议的 UiMessage[]。
3
+ *
4
+ * 事件面 ground truth(dsh 0.1.1-rc.2,实测 dump,见 docs/dsh-engine.md §2.1):
5
+ * session.event 通知 params = { sessionId, event: { type, seq, time, data } }
6
+ * 持久事件类型(全量 append-only,按 seq 排序):
7
+ * user/message data: { content[], source, role, id }
8
+ * assistant/message data: { turn, step, message: { role, content[], id, time } }
9
+ * tool/result data: { turn, step, message: { role:"user",
10
+ * content:[{type:"tool-result", toolCallId, content[], isError}] },
11
+ * meta, sourceEventSeqs, surfaceOp }
12
+ * assistant/chunk data: { turn, step, chunk: {…} }
13
+ * chunk: block-start {index, blockType: reasoning|text|tool-call}
14
+ * reasoning-delta {index, text} text-delta {index, text}
15
+ * tool-call-delta {index, id, name, argumentsDelta}
16
+ * block-end {index, block} usage {usage} finish {reason}
17
+ * turn/start step/start step/end turn/end session/title …
18
+ * session.status 通知 params = { sessionId, status: running|idle }
19
+ *
20
+ * 序列化策略(对齐 pi 引擎 serialize.ts 的 UiMessage 形状):
21
+ * - 持久消息:user/message、assistant/message、tool/result 三个事件各产一条;
22
+ * id 稳定:u-<msgId> / a-<msgId> / t-<toolCallId>(前端 React key 稳定)。
23
+ * - streamingMessage:由 assistant/chunk 增量累积(reasoning→thinking、
24
+ * text→text、tool-call→toolCall),id 用 stream-<会话seq> 稳定跨快照。
25
+ */
26
+ const TEXT_CAP = 200_000;
27
+ const TOOL_OUTPUT_CAP = 100_000;
28
+ const ARGS_CAP = 20_000;
29
+ function truncate(s, cap) {
30
+ const str = typeof s === "string" ? s : String(s ?? "");
31
+ if (str.length <= cap)
32
+ return { text: str, truncated: false };
33
+ return { text: `${str.slice(0, cap)}\n\n… [truncated]`, truncated: true };
34
+ }
35
+ /** DSH user-role content blocks → UiContentBlock[]。 */
36
+ export function serializeUserBlocks(blocks) {
37
+ if (!Array.isArray(blocks) || blocks.length === 0) {
38
+ return [{ type: "text", text: "" }];
39
+ }
40
+ return blocks.map((b) => {
41
+ if (b?.type === "image") {
42
+ const att = b.attachment;
43
+ return { type: "image", dataUrl: att?.url };
44
+ }
45
+ return { type: "text", text: String(b?.text ?? "") };
46
+ });
47
+ }
48
+ /** DSH assistant-role content blocks → UiContentBlock[]。 */
49
+ export function serializeAssistantBlocks(blocks) {
50
+ if (!Array.isArray(blocks))
51
+ return [];
52
+ return blocks.map((b) => {
53
+ switch (b?.type) {
54
+ case "text": {
55
+ const { text, truncated } = truncate(b.text ?? "", TEXT_CAP);
56
+ return { type: "text", text, truncated };
57
+ }
58
+ case "reasoning": {
59
+ return { type: "thinking", thinking: b.text ?? "" };
60
+ }
61
+ case "tool-call": {
62
+ const args = typeof b.arguments === "string" ? b.arguments : "";
63
+ if (!args) {
64
+ return { type: "toolCall", id: b.id ?? "", name: b.name ?? "tool" };
65
+ }
66
+ const { text, truncated } = truncate(args, ARGS_CAP);
67
+ return {
68
+ type: "toolCall",
69
+ id: b.id ?? "",
70
+ name: b.name ?? "tool",
71
+ argumentsText: text,
72
+ argumentsTruncated: truncated,
73
+ };
74
+ }
75
+ default:
76
+ return { type: "unknown", ...b };
77
+ }
78
+ });
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // 事件 → UiMessage
82
+ // ---------------------------------------------------------------------------
83
+ /** tool/result 事件 data → UiMessage(role toolResult)。 */
84
+ export function toolResultEventToUiMessage(data) {
85
+ const blocks = Array.isArray(data.message?.content) ? data.message.content : [];
86
+ const tr = blocks.find((b) => b?.type === "tool-result");
87
+ if (!tr)
88
+ return null;
89
+ const raw = (Array.isArray(tr.content) ? tr.content : [])
90
+ .map((c) => (c?.type === "text" ? String(c.text ?? "") : "[image result]"))
91
+ .filter((t) => t !== undefined && t !== null)
92
+ .join("\n");
93
+ const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
94
+ return {
95
+ id: `t-${tr.toolCallId}`,
96
+ role: "toolResult",
97
+ content: [{ type: "text", text, truncated }],
98
+ toolCallId: tr.toolCallId,
99
+ isError: tr.isError === true,
100
+ timestamp: data.message?.time,
101
+ };
102
+ }
103
+ /** user/message 事件 data → UiMessage。 */
104
+ export function userMessageEventToUiMessage(data) {
105
+ return {
106
+ id: `u-${data.id ?? "?"}`,
107
+ role: "user",
108
+ content: serializeUserBlocks(data.content ?? []),
109
+ timestamp: data.time,
110
+ };
111
+ }
112
+ /** assistant/message 事件 data → UiMessage。 */
113
+ export function assistantMessageEventToUiMessage(data) {
114
+ const m = data.message;
115
+ if (!m || !m.id)
116
+ return null;
117
+ return {
118
+ id: `a-${m.id}`,
119
+ role: "assistant",
120
+ content: serializeAssistantBlocks(m.content ?? []),
121
+ timestamp: m.time,
122
+ model: m.model,
123
+ provider: m.provider,
124
+ stopReason: m.finishReason,
125
+ errorMessage: m.errorMessage,
126
+ };
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // streaming 累积
130
+ // ---------------------------------------------------------------------------
131
+ /**
132
+ * assistant/chunk 增量累积器:reasoning/text/tool-call 三轨按 chunk.index
133
+ * 归位,产出与持久 assistant 消息同构的 content[]。
134
+ * id 必须跨快照稳定 —— 用会话级单调 seq 作时间戳锚(chunk 事件按 seq 到达,
135
+ * 首个 chunk 的 seq 恒定)。
136
+ */
137
+ export class DshStreamAccumulator {
138
+ turn;
139
+ /** 稳定 id(stream-<首个 chunk seq>)。 */
140
+ id;
141
+ blocks = new Map();
142
+ started = false;
143
+ constructor(firstSeq, turn) {
144
+ this.turn = turn;
145
+ this.id = `stream-${firstSeq}-t${turn}`;
146
+ }
147
+ get isEmpty() {
148
+ return this.blocks.size === 0;
149
+ }
150
+ /** 处理一个 chunk,返回是否产生了内容变化。 */
151
+ apply(chunk) {
152
+ switch (chunk.type) {
153
+ case "block-start": {
154
+ const index = chunk.index;
155
+ const blockType = chunk.blockType;
156
+ if (blockType === "reasoning") {
157
+ this.blocks.set(index, { type: "thinking", thinking: "" });
158
+ }
159
+ else if (blockType === "tool-call") {
160
+ this.blocks.set(index, { type: "toolCall", id: "", name: "tool", argumentsText: "" });
161
+ }
162
+ else {
163
+ this.blocks.set(index, { type: "text", text: "" });
164
+ }
165
+ return true;
166
+ }
167
+ case "reasoning-delta": {
168
+ const index = chunk.index;
169
+ const b = this.blocks.get(index);
170
+ if (b?.type === "thinking") {
171
+ b.thinking += String(chunk.text ?? "");
172
+ return true;
173
+ }
174
+ return false;
175
+ }
176
+ case "text-delta": {
177
+ const index = chunk.index;
178
+ const b = this.blocks.get(index);
179
+ if (b?.type === "text") {
180
+ b.text += String(chunk.text ?? "");
181
+ return true;
182
+ }
183
+ return false;
184
+ }
185
+ case "tool-call-delta": {
186
+ const index = chunk.index;
187
+ const b = this.blocks.get(index);
188
+ if (b?.type === "toolCall") {
189
+ if (chunk.id !== undefined)
190
+ b.id = String(chunk.id);
191
+ if (chunk.name !== undefined)
192
+ b.name = String(chunk.name);
193
+ b.argumentsText = (b.argumentsText ?? "") + String(chunk.argumentsDelta ?? "");
194
+ return true;
195
+ }
196
+ return false;
197
+ }
198
+ case "block-end": {
199
+ // 完整 block 到达:直接替换(内容与 delta 拼接一致,且带截断语义)。
200
+ const block = chunk.block;
201
+ if (block) {
202
+ const index = chunk.index;
203
+ if (block.type === "reasoning") {
204
+ this.blocks.set(index, { type: "thinking", thinking: block.text ?? "" });
205
+ }
206
+ else if (block.type === "tool-call") {
207
+ const { text, truncated } = truncate(typeof block.arguments === "string" ? block.arguments : "", ARGS_CAP);
208
+ this.blocks.set(index, {
209
+ type: "toolCall",
210
+ id: block.id ?? "",
211
+ name: block.name ?? "tool",
212
+ argumentsText: text,
213
+ argumentsTruncated: truncated,
214
+ });
215
+ }
216
+ else if (block.type === "text") {
217
+ const { text, truncated } = truncate(block.text ?? "", TEXT_CAP);
218
+ this.blocks.set(index, { type: "text", text, truncated });
219
+ }
220
+ return true;
221
+ }
222
+ return false;
223
+ }
224
+ default:
225
+ // usage / finish 等不改变内容。
226
+ return false;
227
+ }
228
+ }
229
+ /** 按 index 升序输出 content[](toolCall 空参数时省略 argumentsText)。 */
230
+ content() {
231
+ const out = [];
232
+ for (const index of [...this.blocks.keys()].sort((a, b) => a - b)) {
233
+ const b = this.blocks.get(index);
234
+ if (b.type === "toolCall" && (b.argumentsText ?? "") === "") {
235
+ out.push({ type: "toolCall", id: b.id, name: b.name });
236
+ }
237
+ else {
238
+ out.push(b);
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+ toUiMessage(timestamp, model, provider) {
244
+ return {
245
+ id: this.id,
246
+ role: "assistant",
247
+ content: this.content(),
248
+ timestamp,
249
+ model,
250
+ provider,
251
+ };
252
+ }
253
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * dsh-sessions.ts — DSH JSONL 会话存储的只读访问(会话列表/回放/fork 素材)。
3
+ *
4
+ * 布局(实测,dsh 0.1.1-rc.2 + dsh-session-persistence-jsonl):
5
+ * <DSH_SESSION_ROOT>/--<cwd projectKey>--/<sessionId>/session.jsonl.zstd
6
+ * 每个写批次追加一个 zstd 帧 → 必须多帧解压(zstdDecompressSync 只解首帧)。
7
+ *
8
+ * JSONL 行:type "session"(header)→ 逐行事件。text-chunks / reasoning-chunks
9
+ * / tool-call-chunks 是打包记录,解包成多个 assistant/chunk 事件。
10
+ */
11
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { zstdDecompressSync } from "node:zlib";
14
+ /** projectKey(cwd) — 镜像 DSH 运行时的会话目录命名(--<cwd>--)。分隔符 → "-",非法字符 → ~XXXX(大写 hex)。 */
15
+ export function projectKey(cwd) {
16
+ let readable = "";
17
+ let separatorRun = false;
18
+ for (let i = 0; i < cwd.length; i++) {
19
+ const code = cwd.charCodeAt(i);
20
+ const ch = String.fromCharCode(code);
21
+ if (ch === "/" || ch === "\\" || ch === ":") {
22
+ if (!separatorRun)
23
+ readable += "-";
24
+ separatorRun = true;
25
+ }
26
+ else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
27
+ readable += ch;
28
+ separatorRun = false;
29
+ }
30
+ else {
31
+ readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
32
+ separatorRun = false;
33
+ }
34
+ }
35
+ return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
36
+ }
37
+ /** 解包一条存储记录:打包的 chunk 行 → 多个 assistant/chunk 事件;否则原样返回。 */
38
+ function decodeStorageRecord(value) {
39
+ const t = value.type;
40
+ if (t === "text-chunks" ||
41
+ t === "reasoning-chunks" ||
42
+ t === "tool-call-chunks") {
43
+ const { seq0, time0, data } = value;
44
+ if (!data || (!Array.isArray(data.texts) && !Array.isArray(data.args))) {
45
+ return [value];
46
+ }
47
+ const texts = Array.isArray(data.texts) ? data.texts : [];
48
+ const args = Array.isArray(data.args) ? data.args : [];
49
+ const count = t === "tool-call-chunks" ? args.length : texts.length;
50
+ const out = [];
51
+ let time = time0 ?? 0;
52
+ for (let k = 0; k < count; k++) {
53
+ if (k > 0 && Array.isArray(data.dt) && k - 1 < data.dt.length) {
54
+ time += data.dt[k - 1];
55
+ }
56
+ const chunk = t === "text-chunks"
57
+ ? { type: "text-delta", index: data.index, text: texts[k] }
58
+ : t === "reasoning-chunks"
59
+ ? { type: "reasoning-delta", index: data.index, text: texts[k] }
60
+ : {
61
+ type: "tool-call-delta",
62
+ index: data.index,
63
+ id: data.id,
64
+ name: data.name,
65
+ argumentsDelta: args[k],
66
+ };
67
+ out.push({
68
+ type: "assistant/chunk",
69
+ seq: (seq0 ?? 0) + k,
70
+ time,
71
+ data: { turn: data.turn, step: data.step, chunk },
72
+ });
73
+ }
74
+ return out;
75
+ }
76
+ return [value];
77
+ }
78
+ /** 多帧 zstd 解压(运行时每个写批次追加一帧)。失败回退原文。 */
79
+ function zstdDecompressAll(buf) {
80
+ const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
81
+ const starts = [];
82
+ for (let i = 0; i + 4 <= buf.length; i++) {
83
+ if (buf[i] === MAGIC[0] &&
84
+ buf[i + 1] === MAGIC[1] &&
85
+ buf[i + 2] === MAGIC[2] &&
86
+ buf[i + 3] === MAGIC[3]) {
87
+ starts.push(i);
88
+ }
89
+ }
90
+ let out = "";
91
+ for (let i = 0; i < starts.length; i++) {
92
+ const from = starts[i];
93
+ const to = i + 1 < starts.length ? starts[i + 1] : buf.length;
94
+ try {
95
+ out += zstdDecompressSync(buf.subarray(from, to)).toString("utf8");
96
+ }
97
+ catch {
98
+ /* skip unreadable frame */
99
+ }
100
+ }
101
+ return out || buf.toString("utf8");
102
+ }
103
+ /** 读一个会话 JSONL 文件 → { header, events }。 */
104
+ export function readSessionLog(file) {
105
+ const raw = readFileSync(file);
106
+ const text = file.endsWith(".jsonl.zstd")
107
+ ? zstdDecompressAll(raw)
108
+ : raw.toString("utf8");
109
+ const lines = text.split("\n").filter((l) => l.trim());
110
+ let header = null;
111
+ const events = [];
112
+ for (const line of lines) {
113
+ let value;
114
+ try {
115
+ value = JSON.parse(line);
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ if (!value || typeof value !== "object")
121
+ continue;
122
+ if (value.type === "session") {
123
+ header = value;
124
+ continue;
125
+ }
126
+ for (const ev of decodeStorageRecord(value)) {
127
+ events.push(ev);
128
+ }
129
+ }
130
+ return { header, events };
131
+ }
132
+ /** 递归找 root 下的 *.jsonl / *.jsonl.zstd 会话文件(按 mtime 倒序)。 */
133
+ export function findSessionFiles(root) {
134
+ if (!existsSync(root))
135
+ return [];
136
+ const out = [];
137
+ const walk = (dir) => {
138
+ let entries;
139
+ try {
140
+ entries = readdirSync(dir, { withFileTypes: true });
141
+ }
142
+ catch {
143
+ return;
144
+ }
145
+ for (const e of entries) {
146
+ const full = join(dir, e.name);
147
+ if (e.isDirectory())
148
+ walk(full);
149
+ else if (e.name.endsWith(".jsonl") || e.name.endsWith(".jsonl.zstd")) {
150
+ out.push(full);
151
+ }
152
+ }
153
+ };
154
+ walk(root);
155
+ return out.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
156
+ }
157
+ /** 一个工作区的会话文件:官方布局(root/--<cwd>--)+ 旧版 per-cwd 布局。 */
158
+ export function findSessionFilesForCwd(sessionRoot, cwd) {
159
+ const dirs = [
160
+ join(sessionRoot, projectKey(cwd)),
161
+ join(sessionRoot, encodeURIComponent(cwd), projectKey(cwd)),
162
+ ];
163
+ const out = [];
164
+ for (const d of dirs)
165
+ out.push(...findSessionFiles(d));
166
+ return out.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
167
+ }
168
+ /** 第一个用户文本(会话标题素材)。 */
169
+ export function firstUserText(events) {
170
+ for (const ev of events) {
171
+ if (ev.type === "user/message") {
172
+ const blocks = ev.data?.content;
173
+ if (Array.isArray(blocks)) {
174
+ for (const b of blocks) {
175
+ const bb = b;
176
+ if (bb?.type === "text" && typeof bb.text === "string" && bb.text.trim()) {
177
+ const t = bb.text.trim().replace(/\s+/g, " ");
178
+ return t.length > 30 ? `${t.slice(0, 30)}…` : t;
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+ return "新对话";
185
+ }
186
+ import { assistantMessageEventToUiMessage, toolResultEventToUiMessage, userMessageEventToUiMessage, } from "./dsh-serialize.js";
187
+ export function replayEventsToMessages(events) {
188
+ const messages = [];
189
+ const seen = new Set();
190
+ for (const ev of events) {
191
+ let msg = null;
192
+ if (ev.type === "user/message") {
193
+ msg = userMessageEventToUiMessage(ev.data);
194
+ }
195
+ else if (ev.type === "assistant/message") {
196
+ msg = assistantMessageEventToUiMessage(ev.data);
197
+ }
198
+ else if (ev.type === "tool/result") {
199
+ msg = toolResultEventToUiMessage(ev.data);
200
+ }
201
+ if (msg && !seen.has(msg.id)) {
202
+ seen.add(msg.id);
203
+ messages.push(msg);
204
+ }
205
+ }
206
+ return messages;
207
+ }
@@ -0,0 +1 @@
1
+ []