ework-web 0.10.93 → 0.10.94

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.93",
3
+ "version": "0.10.94",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "module";
2
- import { buildPiSessionPage } from "./pi-sessions";
2
+ import { buildPiSessionPage, loadPiSessionExport } from "./pi-sessions";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { readFileSync, appendFileSync, existsSync } from "fs";
@@ -733,13 +733,13 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
733
733
  }
734
734
  const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
735
735
  const piAsc = url.searchParams.get("asc") === "1";
736
- const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
736
+ const piPage = buildPiSessionPage(rawSid, piLimit, piAsc, cfg.collapseLines);
737
737
  if (piPage) return html(piPage, 200);
738
738
  } else {
739
739
  // non-ses_ id that resolved to nothing: try the pi session file before 404
740
740
  const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
741
741
  const piAsc = url.searchParams.get("asc") === "1";
742
- const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
742
+ const piPage = buildPiSessionPage(rawSid, piLimit, piAsc, cfg.collapseLines);
743
743
  if (piPage) return html(piPage, 200);
744
744
  }
745
745
  const desc = url.searchParams.get("asc") !== "1";
@@ -1703,6 +1703,11 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1703
1703
  if (!sid) return json({ error: "bad session path" }, 400);
1704
1704
  const sinceNum = Number(url.searchParams.get("since") ?? "0");
1705
1705
  const since = Number.isFinite(sinceNum) ? sinceNum : 0;
1706
+ const piData = sid.startsWith("ses_") ? null : loadPiSessionExport(sid);
1707
+ if (piData) {
1708
+ const { items, lastCreated } = renderNewMessages(piData, since, cfg.collapseLines);
1709
+ return json({ items, lastCreated });
1710
+ }
1706
1711
  try {
1707
1712
  const data = await opencode.exportSession(sid);
1708
1713
  const { items, lastCreated } = renderNewMessages(data, since, cfg.collapseLines);
@@ -1719,6 +1724,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1719
1724
  const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
1720
1725
  const limit = Math.min(500, Math.max(1, Number(url.searchParams.get("limit")) || 30));
1721
1726
  const desc = url.searchParams.get("asc") !== "1";
1727
+ const piData = sid.startsWith("ses_") ? null : loadPiSessionExport(sid);
1728
+ if (piData) {
1729
+ return json(renderBatchHTML(piData, offset, limit, desc, cfg.collapseLines));
1730
+ }
1722
1731
  try {
1723
1732
  const data = await opencode.exportSession(sid);
1724
1733
  const batch = renderBatchHTML(data, offset, limit, desc, cfg.collapseLines);
@@ -1,40 +1,34 @@
1
1
  import { readFileSync } from "fs";
2
- import { join, dirname } from "path";
2
+ import { join } from "path";
3
3
  import { existsSync } from "fs";
4
4
  import { homedir } from "os";
5
- import { THEME_CSS, escapeHtml, escapeAttr } from "./render/layout";
6
- import { renderMarkdown } from "./render/markdown";
5
+ import type { SessionExport, SessionMessage, MessagePart } from "./opencode";
6
+ import { buildSessionViewFromData } from "./views/sessionLog";
7
7
 
8
- interface PiEvent {
8
+ interface PiPart {
9
9
  type?: string;
10
- timestamp?: string;
11
- message?: {
12
- role?: string;
13
- toolCallId?: string;
14
- toolName?: string;
15
- isError?: boolean;
16
- content?: Array<{ type?: string; text?: string; thinking?: string; name?: string; arguments?: unknown; id?: string; output?: unknown }>;
17
- usage?: { input?: number; output?: number; cacheRead?: number; totalTokens?: number };
18
- model?: string;
19
- };
20
- cwd?: string;
10
+ text?: string;
11
+ thinking?: string;
12
+ name?: string;
13
+ arguments?: unknown;
14
+ id?: string;
21
15
  }
22
16
 
23
- interface PiToolCall {
24
- id: string;
25
- name: string;
26
- args: string;
27
- result?: string;
17
+ interface PiMessage {
18
+ role?: string;
19
+ toolCallId?: string;
20
+ toolName?: string;
21
+ isError?: boolean;
22
+ model?: string;
23
+ usage?: { input?: number; output?: number; cacheRead?: number; totalTokens?: number };
24
+ content?: PiPart[];
28
25
  }
29
26
 
30
- interface PiCard {
31
- ts: string;
32
- role: "user" | "assistant";
33
- model?: string;
34
- textParts: string[];
35
- reasoning: string[];
36
- tools: PiToolCall[];
37
- tok?: string;
27
+ export interface PiEvent {
28
+ type?: string;
29
+ timestamp?: string;
30
+ message?: PiMessage;
31
+ cwd?: string;
38
32
  }
39
33
 
40
34
  function sessionsRoot(): string {
@@ -42,7 +36,7 @@ function sessionsRoot(): string {
42
36
  ?? join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "sessions");
43
37
  }
44
38
 
45
- function findPiSessionFile(sessionId: string): string | null {
39
+ export function findPiSessionFile(sessionId: string): string | null {
46
40
  if (!/^[0-9a-f-]{16,64}$/i.test(sessionId)) return null;
47
41
  const root = sessionsRoot();
48
42
  if (!existsSync(root)) return null;
@@ -64,204 +58,133 @@ function findPiSessionFile(sessionId: string): string | null {
64
58
  return out[out.length - 1] ?? null;
65
59
  }
66
60
 
67
- function fmtTs(ts: string): string {
68
- return ts ? ts.replace("T", " ").replace(/\.\d+Z$/, "Z").slice(5, 19) : "";
69
- }
70
-
71
- function fmtNum(n: number): string {
72
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
73
- if (n >= 1000) return (n / 1000).toFixed(1) + "k";
74
- return String(n);
75
- }
76
-
77
- function resultText(output: unknown): string {
78
- if (output == null) return "";
79
- if (typeof output === "string") return output.slice(0, 4000);
80
- try { return JSON.stringify(output).slice(0, 4000); } catch { return String(output); }
81
- }
82
-
83
- function toolCard(t: PiToolCall): string {
84
- const argStr = t.args.length > 600 ? t.args.slice(0, 600) + "…" : t.args;
85
- const argsBlock = t.args && t.args !== "{}"
86
- ? `<div class="pi-io-label">完整参数</div><pre>${escapeHtml(t.args.length > 20000 ? t.args.slice(0, 20000) + "\n…(截断)" : t.args)}</pre>`
87
- : "";
88
- const resultBlock = `<div class="pi-io-label">返回</div><pre>${escapeHtml(t.result ?? "(无返回)")}</pre>`;
89
- return `<details class="pi-tool"><summary>🔧 ${escapeHtml(t.name)}<span class="pi-tool-args">${escapeHtml(argStr)}</span></summary><div class="tool-io">${argsBlock}${resultBlock}</div></details>`;
61
+ function outputText(m: PiMessage): string {
62
+ const payload = (m.content ?? []).filter(p => p.type === "text").map(p => p.text ?? "").join("\n");
63
+ if (payload) return payload;
64
+ try { return JSON.stringify(m.content ?? ""); } catch { return ""; }
90
65
  }
91
66
 
92
- function renderCard(c: PiCard): string {
93
- const isUser = c.role === "user";
94
- const parts: string[] = [];
95
- for (const r of c.reasoning) {
96
- if (!r.trim()) continue;
97
- parts.push(`<details class="reasoning"><summary>💭 Reasoning</summary><div class="tool-io" data-md="${escapeAttr(r.slice(0, 8000))}">${renderMarkdown(r.slice(0, 8000))}</div></details>`);
98
- }
99
- for (const t of c.tools) parts.push(toolCard(t));
100
- for (const tx of c.textParts) {
101
- if (!tx.trim()) continue;
102
- parts.push(`<div data-md="${escapeAttr(tx.slice(0, 20000))}">${renderMarkdown(tx.slice(0, 20000))}</div>`);
103
- }
104
- return `<div class="msg ${isUser ? "msg-u" : "msg-a"}">
105
- <div class="mh">
106
- <span class="role ${isUser ? "role-u" : "role-a"}">${isUser ? "👤 User" : "🤖 Assistant"}</span>
107
- ${c.model ? `<span class="model">${escapeHtml(c.model)}</span>` : ""}
108
- ${c.tok ? `<span class="tok">${c.tok}</span>` : ""}
109
- <span class="when">${escapeHtml(fmtTs(c.ts))}</span>
110
- </div>
111
- <div class="mb">${parts.join("") || "<p><em>(无文本输出)</em></p>"}</div>
112
- </div>`;
113
- }
114
-
115
- export function buildPiSessionPage(sessionId: string, limit: number, asc = false): string | null {
116
- const file = findPiSessionFile(sessionId);
117
- if (!file) return null;
118
- const raw = readFileSync(file, "utf8");
119
- const events: PiEvent[] = [];
120
- for (const line of raw.split("\n")) {
121
- const l = line.trim();
122
- if (!l) continue;
123
- try { events.push(JSON.parse(l) as PiEvent); } catch { /* skip torn tail line */ }
124
- }
125
-
126
- const cards: PiCard[] = [];
127
- const openTools = new Map<string, PiToolCall>();
128
- const seenContent = new Set<string>();
129
- let inputTok = 0, outputTok = 0, cacheTok = 0, peakTok = 0;
67
+ // pi re-emits identical message content as new events (streaming steps,
68
+ // re-persisted context, repeated tool results); each re-emission also repeats
69
+ // its usage, so collapsing repeats keeps the token stats honest.
70
+ export function piEventsToExport(sessionId: string, events: PiEvent[]): SessionExport {
71
+ const messages: SessionMessage[] = [];
72
+ const openTools = new Map<string, { part: MessagePart }>();
73
+ const seen = new Set<string>();
130
74
  let cwd = "";
131
- let first = "", last = "";
75
+ let created = 0;
76
+ let updated = 0;
77
+ let title = "";
78
+
79
+ const stamp = (e: PiEvent) => {
80
+ const t = e.timestamp ? Date.parse(e.timestamp) : NaN;
81
+ if (!Number.isFinite(t)) return;
82
+ if (!created) created = t;
83
+ updated = Math.max(updated, t);
84
+ };
85
+
132
86
  for (const e of events) {
133
87
  if (e.type === "session" && e.cwd) cwd = e.cwd;
134
- if (e.timestamp) {
135
- if (!first) first = e.timestamp;
136
- last = e.timestamp;
137
- }
88
+ stamp(e);
138
89
  if (e.type !== "message" || !e.message) continue;
139
90
  const m = e.message;
140
- // pi re-emits identical message content as new events (streaming steps,
141
- // re-persisted context, repeated tool results). Collapse exact repeats —
142
- // usage excluded from the key so re-emissions still merge, and their
143
- // (identical) usage is only counted once.
144
- const contentKey = `${m.role ?? ""}\u0000${JSON.stringify(m.content ?? [])}`;
145
- if (seenContent.has(contentKey)) continue;
146
- seenContent.add(contentKey);
147
- const u = m.usage;
148
- if (u) {
149
- inputTok += u.input ?? 0;
150
- outputTok += u.output ?? 0;
151
- cacheTok += u.cacheRead ?? 0;
152
- peakTok = Math.max(peakTok, u.totalTokens ?? 0);
153
- }
91
+ const dedupeKey = `${m.role ?? ""}\u0000${JSON.stringify(m.content ?? [])}`;
92
+ if (seen.has(dedupeKey)) continue;
93
+ seen.add(dedupeKey);
94
+
154
95
  if (m.role === "user") {
155
- const text = (m.content ?? []).filter(p => p.type === "text").map(p => p.text ?? "").join("\n");
156
- cards.push({ ts: e.timestamp ?? "", role: "user", textParts: text ? [text] : [], reasoning: [], tools: [] });
157
- } else if (m.role === "assistant") {
158
- const card: PiCard = { ts: e.timestamp ?? "", role: "assistant", model: m.model, textParts: [], reasoning: [], tools: [], tok: u?.output ? `↗ ${fmtNum(u.output)}` : undefined };
96
+ const parts: MessagePart[] = [];
159
97
  for (const p of m.content ?? []) {
160
- if (p.type === "thinking" && p.thinking) card.reasoning.push(p.thinking);
161
- else if (p.type === "text" && p.text) card.textParts.push(p.text);
98
+ if (p.type === "text" && p.text) parts.push({ type: "text", text: p.text });
99
+ }
100
+ if (!title) {
101
+ const first = parts.map(p => p.type === "text" ? p.text : "").join(" ").trim();
102
+ // daemon user messages are either a long system prompt or [SYSTEM
103
+ // FORWARD] wrappers whose header quotes the issue title
104
+ if (first.startsWith("[SYSTEM FORWARD]")) {
105
+ const quoted = first.match(/"([^"\n]{4,120})"/);
106
+ if (quoted?.[1]) title = quoted[1].replace(/\s+/g, " ").slice(0, 60);
107
+ } else if (first && first.length < 400) {
108
+ title = first.replace(/\s+/g, " ").slice(0, 60);
109
+ }
110
+ }
111
+ if (parts.length) messages.push({ info: { role: "user", id: `pi_u${messages.length}`, time: { created: updated } }, parts });
112
+ continue;
113
+ }
114
+
115
+ if (m.role === "assistant") {
116
+ const parts: MessagePart[] = [];
117
+ for (const p of m.content ?? []) {
118
+ if (p.type === "thinking" && p.thinking) parts.push({ type: "reasoning", text: p.thinking });
119
+ else if (p.type === "text" && p.text) parts.push({ type: "text", text: p.text });
162
120
  else if (p.type === "toolCall") {
163
- const tc: PiToolCall = {
164
- id: p.id ?? `t${openTools.size}`,
165
- name: p.name ?? "?",
166
- args: (() => { try { return typeof p.arguments === "string" ? p.arguments : JSON.stringify(p.arguments ?? {}); } catch { return "{}"; } })(),
121
+ const tool: MessagePart = {
122
+ type: "tool",
123
+ tool: p.name ?? "tool",
124
+ state: { title: p.name ?? "tool", input: p.arguments },
167
125
  };
168
- card.tools.push(tc);
169
- openTools.set(tc.id, tc);
126
+ if (p.id) openTools.set(p.id, { part: tool });
127
+ parts.push(tool);
170
128
  }
171
129
  }
172
- cards.push(card);
173
- } else if (m.role === "toolResult") {
174
- const payload = (m.content ?? []).filter(p => p.type === "text").map(p => p.text ?? "").join("\n");
175
- const key = m.toolCallId ?? [...openTools.keys()].pop();
176
- const tc = key ? openTools.get(key) : undefined;
177
- const text = (payload || resultText(m.content)).slice(0, 4000);
178
- const stamped = m.isError ? `⚠️ ${text}` : text;
179
- if (tc) tc.result = stamped;
180
- else {
181
- const orphan: PiToolCall = { id: m.toolCallId ?? `t${openTools.size}`, name: m.toolName ?? "tool", args: "", result: stamped };
182
- openTools.set(orphan.id, orphan);
183
- cards.push({ ts: e.timestamp ?? "", role: "assistant", textParts: [], reasoning: [], tools: [orphan] });
130
+ if (!parts.length) continue;
131
+ const u = m.usage;
132
+ messages.push({
133
+ info: {
134
+ role: "assistant",
135
+ id: `pi_a${messages.length}`,
136
+ modelID: m.model,
137
+ time: { created: updated },
138
+ tokens: u ? { input: u.input, output: u.output, cache: { read: u.cacheRead } } : undefined,
139
+ },
140
+ parts,
141
+ });
142
+ continue;
143
+ }
144
+
145
+ if (m.role === "toolResult") {
146
+ const text = outputText(m);
147
+ const open = m.toolCallId ? openTools.get(m.toolCallId) : undefined;
148
+ if (open && open.part.state) {
149
+ open.part.state.output = text;
150
+ if (m.isError) open.part.state.title = `${open.part.state.title} (error)`;
151
+ openTools.delete(m.toolCallId!);
152
+ } else {
153
+ const name = m.toolName ?? "tool";
154
+ messages.push({
155
+ info: { role: "assistant", id: `pi_t${messages.length}`, time: { created: updated } },
156
+ parts: [{ type: "tool", tool: name, state: { title: m.isError ? `${name} (error)` : name, output: text } }],
157
+ });
184
158
  }
185
159
  }
186
160
  }
187
161
 
188
- const total = cards.length;
189
- const shown = asc ? cards.slice(Math.max(0, total - limit)) : cards.slice(Math.max(0, total - limit)).reverse();
190
- const truncated = total > limit;
191
- const title = `pi · ${sessionId.slice(0, 13)}…`;
192
-
193
- const qs = (over: Record<string, string>) => {
194
- const params = new URLSearchParams(over);
195
- return `?${params.toString()}`;
162
+ return {
163
+ info: {
164
+ id: sessionId,
165
+ title: title || `pi ${sessionId.slice(0, 8)}`,
166
+ directory: cwd,
167
+ version: "pi",
168
+ time: { created, updated },
169
+ },
170
+ messages,
196
171
  };
197
- const moreHref = qs({ limit: String(Math.min(5000, limit * 3)) });
198
- const allHref = qs({ limit: "5000" });
199
- const ordBtn = (mode: "asc" | "desc") =>
200
- `<a class="ord-btn ${asc === (mode === "asc") ? "active" : ""}" href="${qs({ limit: String(limit), asc: mode === "asc" ? "1" : "0" })}">${mode === "asc" ? "正序" : "倒序"}</a>`;
172
+ }
201
173
 
202
- const body = shown.map(renderCard).join("");
203
- const moreBar = truncated
204
- ? `<div class="more-bar">共 ${total} 条,当前显示最新 ${limit} 条 · <a href="${moreHref}">加载更多</a> · <a href="${allHref}">查看全部</a></div>`
205
- : "";
174
+ export function loadPiSessionExport(sessionId: string): SessionExport | null {
175
+ const file = findPiSessionFile(sessionId);
176
+ if (!file) return null;
177
+ const events: PiEvent[] = [];
178
+ for (const line of readFileSync(file, "utf8").split("\n")) {
179
+ const t = line.trim();
180
+ if (!t) continue;
181
+ try { events.push(JSON.parse(t)); } catch { /* trailing/partial line */ }
182
+ }
183
+ return piEventsToExport(sessionId, events);
184
+ }
206
185
 
207
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
208
- <title>${escapeHtml(title)}</title>
209
- <style>${THEME_CSS}
210
- .meta-bar{max-width:900px;margin:0 auto;padding:.8rem 1rem .2rem}
211
- .meta-bar h1{font-size:17px;margin:0 0 .35rem;overflow-wrap:anywhere}
212
- .meta-status{display:flex;gap:.6rem;flex-wrap:wrap;font-size:12px;color:var(--text-muted)}
213
- .meta-status .sid{font-family:ui-monospace,monospace}
214
- .mbar{max-width:900px;margin:.6rem auto;padding:0 1rem;display:flex;gap:.6rem;align-items:center;flex-wrap:wrap}
215
- .mbar .note{font-size:12px;color:var(--text-muted)}
216
- .ord{margin-left:auto;display:flex;gap:.3rem}
217
- .ord-btn{padding:.25rem .7rem;border-radius:6px;border:1px solid var(--border);font-size:12px;color:var(--text-muted);text-decoration:none}
218
- .ord-btn.active{background:var(--bg-muted);color:var(--text);font-weight:600}
219
- #mlist{max-width:900px;margin:0 auto;padding:.5rem 1rem 3rem}
220
- .msg{position:relative;background:var(--bg-elev);border:1px solid var(--border);border-left:3px solid var(--border);border-radius:8px;padding:.6rem .9rem;margin-bottom:1rem;overflow:hidden}
221
- .msg.msg-u{border-left-color:var(--human);background:color-mix(in srgb,var(--human) 4%,var(--bg-elev))}
222
- .msg.msg-a{border-left-color:var(--bot);background:color-mix(in srgb,var(--bot) 4%,var(--bg-elev))}
223
- .mh{display:flex;align-items:center;gap:.5rem;font-size:13px;flex-wrap:wrap;margin-bottom:.35rem}
224
- .mh .role{font-weight:600;font-size:11px;padding:.05rem .45rem;border-radius:4px;line-height:1.7}
225
- .mh .role-u{background:color-mix(in srgb,var(--human) 18%,transparent);color:var(--human)}
226
- .mh .role-a{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
227
- .mh .model{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-muted)}
228
- .mh .when{color:var(--text-muted);font-size:12px;margin-left:auto}
229
- .mh .tok{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--text-muted)}
230
- .mb{overflow-wrap:anywhere;word-break:break-word;line-height:1.55}
231
- .mb p{margin:.4rem 0}
232
- .mb pre{background:var(--code-bg);padding:.6rem;border-radius:6px;overflow:auto;font-size:12.5px}
233
- .mb code{background:var(--code-bg);padding:.1em .35em;border-radius:4px;font-size:12.5px}
234
- .mb details{margin:.3rem 0;border:1px solid var(--border);border-radius:6px;overflow:hidden}
235
- .mb summary{cursor:pointer;padding:.4rem .6rem;background:var(--bg-muted);font-size:13px;display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}
236
- .mb details.reasoning{border-left:3px solid color-mix(in srgb,var(--system) 45%,transparent)}
237
- .mb details.reasoning>.tool-io{background:color-mix(in srgb,var(--system) 7%,var(--bg-elev))}
238
- .mb .tool-io{position:relative;padding:.5rem .6rem}
239
- .mb .tool-io pre{margin:.2rem 0}
240
- .pi-tool summary .pi-tool-args{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}
241
- .pi-io-label{font-size:11px;color:var(--text-muted);margin:6px 0 2px;font-weight:600}
242
- .more-bar{max-width:900px;margin:1rem auto 0;padding:.8rem 1rem;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--text-muted);font-size:13px}
243
- .more-bar a{font-weight:600}
244
- </style></head><body>
245
- <header class="nav" style="display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px;flex-wrap:wrap">
246
- <a href="/" style="color:var(--header-text)">🏠</a><span style="opacity:.5">/</span>
247
- <a href="/sessions" style="color:var(--header-text)">会话</a><span style="opacity:.5">/</span>
248
- <span style="opacity:.85">${escapeHtml(title)}</span>
249
- </header>
250
- <div class="meta-bar">
251
- <h1>${escapeHtml(title)}</h1>
252
- <div class="meta-status">
253
- <span class="sid">${escapeHtml(sessionId)}</span>
254
- ${cwd ? `<span>📁 ${escapeHtml(dirname(cwd))}</span>` : ""}
255
- ${first ? `<span>创建 ${escapeHtml(fmtTs(first))}</span>` : ""}
256
- ${last ? `<span>更新 ${escapeHtml(fmtTs(last))}</span>` : ""}
257
- <span>${total} 条消息</span>
258
- <span>🧮 峰值 ${fmtNum(peakTok)} · 累计 ↗ ${fmtNum(outputTok)} · cache ${fmtNum(cacheTok)}</span>
259
- </div>
260
- </div>
261
- <div class="mbar">
262
- <span class="note">📖 只读 · pi 运行时会话</span>
263
- <span class="ord">${ordBtn("asc")}${ordBtn("desc")}</span>
264
- </div>
265
- <main id="mlist">${body || `<div style="padding:2rem;text-align:center;color:var(--text-muted)">此会话没有消息</div>`}${moreBar}</main>
266
- </body></html>`;
186
+ export function buildPiSessionPage(sessionId: string, limit: number, asc: boolean, collapseLines = 12): string | null {
187
+ const data = loadPiSessionExport(sessionId);
188
+ if (!data) return null;
189
+ return buildSessionViewFromData(data, { desc: !asc, collapseLines, limit, all: false }).html;
267
190
  }
@@ -75,8 +75,22 @@ function sessionRow(s: SessionListItem): string {
75
75
 
76
76
  export async function buildSessionView(client: OpencodeClientInterface, id: string, desc: boolean, collapseLines: number, limit = 30, all = false): Promise<{ html: string }> {
77
77
  const data = await client.exportSession(id);
78
+ return buildSessionViewFromData(data, { desc, collapseLines, limit, all });
79
+ }
80
+
81
+ export interface SessionViewOpts {
82
+ desc: boolean;
83
+ collapseLines: number;
84
+ limit: number;
85
+ all?: boolean;
86
+ }
87
+
88
+ // Renders any SessionExport — opencode exports and pi-converted transcripts
89
+ // share this path so both viewers stay feature-identical.
90
+ export function buildSessionViewFromData(data: SessionExport, opts: SessionViewOpts): { html: string } {
91
+ const { desc, collapseLines, limit, all = false } = opts;
78
92
  const info = data.info;
79
- const title = info.title || id;
93
+ const title = info.title || info.id;
80
94
  const created = info.time.created ? relTimeMs(info.time.created) : "";
81
95
  const updated = info.time.updated ? relTimeMs(info.time.updated) : "";
82
96
  const total = data.messages.length;
@@ -227,6 +241,7 @@ export async function buildSessionView(client: OpencodeClientInterface, id: stri
227
241
  <span>${data.messages.length} 条消息</span>
228
242
  ${stats.peak ? `<span>🧮 当前 ${kfmt(stats.current)} · 峰值 ${kfmt(stats.peak)}${stats.p90 ? ` · P90 ${kfmt(stats.p90)}` : ""}${stats.p50 ? ` · P50 ${kfmt(stats.p50)}` : ""}</span>` : ""}
229
243
  ${stats.calls ? `<span>📊 累计 ${kfmt(stats.traffic)} · cache ${stats.cacheHit}% · ${stats.calls} 调用</span>` : ""}
244
+ ${info.version === "pi" ? `<span class="sd-badge" title="pi runtime session">🥧 pi</span>` : ""}
230
245
  ${acp ? `<span>🗜 压缩 ${acp.blocks} 段${acp.savedTokens ? ` · 省 ${kfmt(acp.savedTokens)}` : ""}</span>` : ""}
231
246
  ${bd && bd.total ? `<span>📂 ${ctxBreakdownStr(bd, stats.current, stats.overhead)}</span>` : ""}
232
247
  </div>