ework-web 0.10.92 → 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 +1 -1
- package/src/index.ts +19 -6
- package/src/pi-sessions.ts +132 -209
- package/src/views/sessionLog.ts +16 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import { buildPiSessionPage, loadPiSessionExport } from "./pi-sessions";
|
|
2
3
|
import { join, dirname } from "path";
|
|
3
4
|
import { fileURLToPath } from "url";
|
|
4
5
|
import { readFileSync, appendFileSync, existsSync } from "fs";
|
|
@@ -220,8 +221,10 @@ function buildCsp(cfg: Config): string {
|
|
|
220
221
|
const hlCss = loadHighlightCss();
|
|
221
222
|
|
|
222
223
|
function loadHighlightCss(): string {
|
|
223
|
-
|
|
224
|
-
|
|
224
|
+
let dir: string | null = null;
|
|
225
|
+
try { dir = dirname(createRequire(import.meta.url).resolve("highlight.js/package.json")); } catch { dir = null; }
|
|
226
|
+
const light = readFileSafe(dir ? join(dir, "styles", "github.css") : null) || readFileSafe(join(__dirname, "..", "node_modules", "highlight.js", "styles", "github.css"));
|
|
227
|
+
const dark = readFileSafe(dir ? join(dir, "styles", "github-dark.css") : null) || readFileSafe(join(__dirname, "..", "node_modules", "highlight.js", "styles", "github-dark.css"));
|
|
225
228
|
if (!light && !dark) return "";
|
|
226
229
|
const darkRule = dark
|
|
227
230
|
? `@media (prefers-color-scheme:dark){${stripAtMedia(dark)}}`
|
|
@@ -229,7 +232,8 @@ function loadHighlightCss(): string {
|
|
|
229
232
|
return `${light}${darkRule}`;
|
|
230
233
|
}
|
|
231
234
|
|
|
232
|
-
function readFileSafe(p: string): string {
|
|
235
|
+
function readFileSafe(p: string | null): string {
|
|
236
|
+
if (!p) return "";
|
|
233
237
|
try {
|
|
234
238
|
return readFileSync(p, "utf8");
|
|
235
239
|
} catch {
|
|
@@ -729,13 +733,13 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
729
733
|
}
|
|
730
734
|
const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
|
|
731
735
|
const piAsc = url.searchParams.get("asc") === "1";
|
|
732
|
-
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
|
|
736
|
+
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc, cfg.collapseLines);
|
|
733
737
|
if (piPage) return html(piPage, 200);
|
|
734
738
|
} else {
|
|
735
739
|
// non-ses_ id that resolved to nothing: try the pi session file before 404
|
|
736
740
|
const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
|
|
737
741
|
const piAsc = url.searchParams.get("asc") === "1";
|
|
738
|
-
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
|
|
742
|
+
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc, cfg.collapseLines);
|
|
739
743
|
if (piPage) return html(piPage, 200);
|
|
740
744
|
}
|
|
741
745
|
const desc = url.searchParams.get("asc") !== "1";
|
|
@@ -1699,6 +1703,11 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1699
1703
|
if (!sid) return json({ error: "bad session path" }, 400);
|
|
1700
1704
|
const sinceNum = Number(url.searchParams.get("since") ?? "0");
|
|
1701
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
|
+
}
|
|
1702
1711
|
try {
|
|
1703
1712
|
const data = await opencode.exportSession(sid);
|
|
1704
1713
|
const { items, lastCreated } = renderNewMessages(data, since, cfg.collapseLines);
|
|
@@ -1715,6 +1724,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1715
1724
|
const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
|
|
1716
1725
|
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get("limit")) || 30));
|
|
1717
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
|
+
}
|
|
1718
1731
|
try {
|
|
1719
1732
|
const data = await opencode.exportSession(sid);
|
|
1720
1733
|
const batch = renderBatchHTML(data, offset, limit, desc, cfg.collapseLines);
|
package/src/pi-sessions.ts
CHANGED
|
@@ -1,40 +1,34 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
|
-
import { join
|
|
2
|
+
import { join } from "path";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
4
|
import { homedir } from "os";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import type { SessionExport, SessionMessage, MessagePart } from "./opencode";
|
|
6
|
+
import { buildSessionViewFromData } from "./views/sessionLog";
|
|
7
7
|
|
|
8
|
-
interface
|
|
8
|
+
interface PiPart {
|
|
9
9
|
type?: string;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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 === "
|
|
161
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
121
|
+
const tool: MessagePart = {
|
|
122
|
+
type: "tool",
|
|
123
|
+
tool: p.name ?? "tool",
|
|
124
|
+
state: { title: p.name ?? "tool", input: p.arguments },
|
|
167
125
|
};
|
|
168
|
-
|
|
169
|
-
|
|
126
|
+
if (p.id) openTools.set(p.id, { part: tool });
|
|
127
|
+
parts.push(tool);
|
|
170
128
|
}
|
|
171
129
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
}
|
package/src/views/sessionLog.ts
CHANGED
|
@@ -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>
|