ework-web 0.10.85 → 0.10.87
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 +11 -0
- package/src/pi-sessions.ts +247 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildPiSessionPage } from "./pi-sessions";
|
|
1
2
|
import { join, dirname } from "path";
|
|
2
3
|
import { fileURLToPath } from "url";
|
|
3
4
|
import { readFileSync, appendFileSync, existsSync } from "fs";
|
|
@@ -724,6 +725,16 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
724
725
|
if (uidInfo) {
|
|
725
726
|
return html(errorPage("等待启动", "会话已创建,后端会话 ID 尚未生成(正在准备工作目录)。稍后刷新此页。"), 200);
|
|
726
727
|
}
|
|
728
|
+
const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
|
|
729
|
+
const piAsc = url.searchParams.get("asc") === "1";
|
|
730
|
+
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
|
|
731
|
+
if (piPage) return html(piPage, 200);
|
|
732
|
+
} else {
|
|
733
|
+
// non-ses_ id that resolved to nothing: try the pi session file before 404
|
|
734
|
+
const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
|
|
735
|
+
const piAsc = url.searchParams.get("asc") === "1";
|
|
736
|
+
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
|
|
737
|
+
if (piPage) return html(piPage, 200);
|
|
727
738
|
}
|
|
728
739
|
const desc = url.searchParams.get("asc") !== "1";
|
|
729
740
|
const all = url.searchParams.get("all") === "1";
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { THEME_CSS, escapeHtml, escapeAttr } from "./render/layout";
|
|
6
|
+
import { renderMarkdown } from "./render/markdown";
|
|
7
|
+
|
|
8
|
+
interface PiEvent {
|
|
9
|
+
type?: string;
|
|
10
|
+
timestamp?: string;
|
|
11
|
+
message?: {
|
|
12
|
+
role?: string;
|
|
13
|
+
content?: Array<{ type?: string; text?: string; thinking?: string; name?: string; arguments?: unknown; toolCallId?: string; output?: unknown }>;
|
|
14
|
+
usage?: { input?: number; output?: number; cacheRead?: number; totalTokens?: number };
|
|
15
|
+
model?: string;
|
|
16
|
+
};
|
|
17
|
+
cwd?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PiToolCall {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
args: string;
|
|
24
|
+
result?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PiCard {
|
|
28
|
+
ts: string;
|
|
29
|
+
role: "user" | "assistant";
|
|
30
|
+
model?: string;
|
|
31
|
+
textParts: string[];
|
|
32
|
+
reasoning: string[];
|
|
33
|
+
tools: PiToolCall[];
|
|
34
|
+
tok?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sessionsRoot(): string {
|
|
38
|
+
return process.env.PI_CODING_AGENT_SESSION_DIR
|
|
39
|
+
?? join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "sessions");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findPiSessionFile(sessionId: string): string | null {
|
|
43
|
+
if (!/^[0-9a-f-]{16,64}$/i.test(sessionId)) return null;
|
|
44
|
+
const root = sessionsRoot();
|
|
45
|
+
if (!existsSync(root)) return null;
|
|
46
|
+
const { readdirSync, statSync } = require("fs") as typeof import("fs");
|
|
47
|
+
const out: string[] = [];
|
|
48
|
+
const walk = (dir: string, depth: number) => {
|
|
49
|
+
let entries: string[] = [];
|
|
50
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
const full = join(dir, e);
|
|
53
|
+
let st;
|
|
54
|
+
try { st = statSync(full); } catch { continue; }
|
|
55
|
+
if (st.isDirectory() && depth < 3) walk(full, depth + 1);
|
|
56
|
+
else if (st.isFile() && e.includes(sessionId) && e.endsWith(".jsonl")) out.push(full);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
walk(root, 0);
|
|
60
|
+
out.sort();
|
|
61
|
+
return out[out.length - 1] ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fmtTs(ts: string): string {
|
|
65
|
+
return ts ? ts.replace("T", " ").replace(/\.\d+Z$/, "Z").slice(5, 19) : "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fmtNum(n: number): string {
|
|
69
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
70
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
|
71
|
+
return String(n);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resultText(output: unknown): string {
|
|
75
|
+
if (output == null) return "";
|
|
76
|
+
if (typeof output === "string") return output.slice(0, 4000);
|
|
77
|
+
try { return JSON.stringify(output).slice(0, 4000); } catch { return String(output); }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toolCard(t: PiToolCall): string {
|
|
81
|
+
const argStr = t.args.length > 600 ? t.args.slice(0, 600) + "…" : t.args;
|
|
82
|
+
return `<details class="pi-tool"><summary>🔧 ${escapeHtml(t.name)}<span class="pi-tool-args">${escapeHtml(argStr)}</span></summary><div class="tool-io"><pre>${escapeHtml(t.result ? resultText(t.result).slice(0, 4000) : "(无返回)")}</pre></div></details>`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderCard(c: PiCard): string {
|
|
86
|
+
const isUser = c.role === "user";
|
|
87
|
+
const parts: string[] = [];
|
|
88
|
+
for (const r of c.reasoning) {
|
|
89
|
+
if (!r.trim()) continue;
|
|
90
|
+
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>`);
|
|
91
|
+
}
|
|
92
|
+
for (const t of c.tools) parts.push(toolCard(t));
|
|
93
|
+
for (const tx of c.textParts) {
|
|
94
|
+
if (!tx.trim()) continue;
|
|
95
|
+
parts.push(`<div data-md="${escapeAttr(tx.slice(0, 20000))}">${renderMarkdown(tx.slice(0, 20000))}</div>`);
|
|
96
|
+
}
|
|
97
|
+
return `<div class="msg ${isUser ? "msg-u" : "msg-a"}">
|
|
98
|
+
<div class="mh">
|
|
99
|
+
<span class="role ${isUser ? "role-u" : "role-a"}">${isUser ? "👤 User" : "🤖 Assistant"}</span>
|
|
100
|
+
${c.model ? `<span class="model">${escapeHtml(c.model)}</span>` : ""}
|
|
101
|
+
${c.tok ? `<span class="tok">${c.tok}</span>` : ""}
|
|
102
|
+
<span class="when">${escapeHtml(fmtTs(c.ts))}</span>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="mb">${parts.join("") || "<p><em>(无文本输出)</em></p>"}</div>
|
|
105
|
+
</div>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildPiSessionPage(sessionId: string, limit: number, asc = false): string | null {
|
|
109
|
+
const file = findPiSessionFile(sessionId);
|
|
110
|
+
if (!file) return null;
|
|
111
|
+
const raw = readFileSync(file, "utf8");
|
|
112
|
+
const events: PiEvent[] = [];
|
|
113
|
+
for (const line of raw.split("\n")) {
|
|
114
|
+
const l = line.trim();
|
|
115
|
+
if (!l) continue;
|
|
116
|
+
try { events.push(JSON.parse(l) as PiEvent); } catch { /* skip torn tail line */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const cards: PiCard[] = [];
|
|
120
|
+
const openTools = new Map<string, PiToolCall>();
|
|
121
|
+
let inputTok = 0, outputTok = 0, cacheTok = 0, peakTok = 0;
|
|
122
|
+
let cwd = "";
|
|
123
|
+
let first = "", last = "";
|
|
124
|
+
for (const e of events) {
|
|
125
|
+
if (e.type === "session" && e.cwd) cwd = e.cwd;
|
|
126
|
+
if (e.timestamp) {
|
|
127
|
+
if (!first) first = e.timestamp;
|
|
128
|
+
last = e.timestamp;
|
|
129
|
+
}
|
|
130
|
+
if (e.type !== "message" || !e.message) continue;
|
|
131
|
+
const m = e.message;
|
|
132
|
+
const u = m.usage;
|
|
133
|
+
if (u) {
|
|
134
|
+
inputTok += u.input ?? 0;
|
|
135
|
+
outputTok += u.output ?? 0;
|
|
136
|
+
cacheTok += u.cacheRead ?? 0;
|
|
137
|
+
peakTok = Math.max(peakTok, u.totalTokens ?? 0);
|
|
138
|
+
}
|
|
139
|
+
if (m.role === "user") {
|
|
140
|
+
const text = (m.content ?? []).filter(p => p.type === "text").map(p => p.text ?? "").join("\n");
|
|
141
|
+
cards.push({ ts: e.timestamp ?? "", role: "user", textParts: text ? [text] : [], reasoning: [], tools: [] });
|
|
142
|
+
} else if (m.role === "assistant") {
|
|
143
|
+
const card: PiCard = { ts: e.timestamp ?? "", role: "assistant", model: m.model, textParts: [], reasoning: [], tools: [], tok: u?.output ? `↗ ${fmtNum(u.output)}` : undefined };
|
|
144
|
+
for (const p of m.content ?? []) {
|
|
145
|
+
if (p.type === "thinking" && p.thinking) card.reasoning.push(p.thinking);
|
|
146
|
+
else if (p.type === "text" && p.text) card.textParts.push(p.text);
|
|
147
|
+
else if (p.type === "toolCall") {
|
|
148
|
+
const tc: PiToolCall = {
|
|
149
|
+
id: p.toolCallId ?? `t${openTools.size}`,
|
|
150
|
+
name: p.name ?? "?",
|
|
151
|
+
args: (() => { try { return typeof p.arguments === "string" ? p.arguments : JSON.stringify(p.arguments ?? {}); } catch { return "{}"; } })(),
|
|
152
|
+
};
|
|
153
|
+
card.tools.push(tc);
|
|
154
|
+
openTools.set(tc.id, tc);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
cards.push(card);
|
|
158
|
+
} else {
|
|
159
|
+
for (const p of m.content ?? []) {
|
|
160
|
+
if (p.type === "toolResult") {
|
|
161
|
+
const key = p.toolCallId ?? [...openTools.keys()].pop();
|
|
162
|
+
const tc = key ? openTools.get(key) : undefined;
|
|
163
|
+
if (tc) tc.result = resultText(p.output);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const total = cards.length;
|
|
170
|
+
const shown = asc ? cards.slice(Math.max(0, total - limit)) : cards.slice(Math.max(0, total - limit)).reverse();
|
|
171
|
+
const truncated = total > limit;
|
|
172
|
+
const title = `pi · ${sessionId.slice(0, 13)}…`;
|
|
173
|
+
|
|
174
|
+
const qs = (over: Record<string, string>) => {
|
|
175
|
+
const params = new URLSearchParams(over);
|
|
176
|
+
return `?${params.toString()}`;
|
|
177
|
+
};
|
|
178
|
+
const moreHref = qs({ limit: String(Math.min(5000, limit * 3)) });
|
|
179
|
+
const allHref = qs({ limit: "5000" });
|
|
180
|
+
const ordBtn = (mode: "asc" | "desc") =>
|
|
181
|
+
`<a class="ord-btn ${asc === (mode === "asc") ? "active" : ""}" href="${qs({ limit: String(limit), asc: mode === "asc" ? "1" : "0" })}">${mode === "asc" ? "正序" : "倒序"}</a>`;
|
|
182
|
+
|
|
183
|
+
const body = shown.map(renderCard).join("");
|
|
184
|
+
const moreBar = truncated
|
|
185
|
+
? `<div class="more-bar">共 ${total} 条,当前显示最新 ${limit} 条 · <a href="${moreHref}">加载更多</a> · <a href="${allHref}">查看全部</a></div>`
|
|
186
|
+
: "";
|
|
187
|
+
|
|
188
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
189
|
+
<title>${escapeHtml(title)}</title>
|
|
190
|
+
<style>${THEME_CSS}
|
|
191
|
+
.meta-bar{max-width:900px;margin:0 auto;padding:.8rem 1rem .2rem}
|
|
192
|
+
.meta-bar h1{font-size:17px;margin:0 0 .35rem;overflow-wrap:anywhere}
|
|
193
|
+
.meta-status{display:flex;gap:.6rem;flex-wrap:wrap;font-size:12px;color:var(--text-muted)}
|
|
194
|
+
.meta-status .sid{font-family:ui-monospace,monospace}
|
|
195
|
+
.mbar{max-width:900px;margin:.6rem auto;padding:0 1rem;display:flex;gap:.6rem;align-items:center;flex-wrap:wrap}
|
|
196
|
+
.mbar .note{font-size:12px;color:var(--text-muted)}
|
|
197
|
+
.ord{margin-left:auto;display:flex;gap:.3rem}
|
|
198
|
+
.ord-btn{padding:.25rem .7rem;border-radius:6px;border:1px solid var(--border);font-size:12px;color:var(--text-muted);text-decoration:none}
|
|
199
|
+
.ord-btn.active{background:var(--bg-muted);color:var(--text);font-weight:600}
|
|
200
|
+
#mlist{max-width:900px;margin:0 auto;padding:.5rem 1rem 3rem}
|
|
201
|
+
.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}
|
|
202
|
+
.msg.msg-u{border-left-color:var(--human);background:color-mix(in srgb,var(--human) 4%,var(--bg-elev))}
|
|
203
|
+
.msg.msg-a{border-left-color:var(--bot);background:color-mix(in srgb,var(--bot) 4%,var(--bg-elev))}
|
|
204
|
+
.mh{display:flex;align-items:center;gap:.5rem;font-size:13px;flex-wrap:wrap;margin-bottom:.35rem}
|
|
205
|
+
.mh .role{font-weight:600;font-size:11px;padding:.05rem .45rem;border-radius:4px;line-height:1.7}
|
|
206
|
+
.mh .role-u{background:color-mix(in srgb,var(--human) 18%,transparent);color:var(--human)}
|
|
207
|
+
.mh .role-a{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
|
|
208
|
+
.mh .model{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-muted)}
|
|
209
|
+
.mh .when{color:var(--text-muted);font-size:12px;margin-left:auto}
|
|
210
|
+
.mh .tok{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--text-muted)}
|
|
211
|
+
.mb{overflow-wrap:anywhere;word-break:break-word;line-height:1.55}
|
|
212
|
+
.mb p{margin:.4rem 0}
|
|
213
|
+
.mb pre{background:var(--code-bg);padding:.6rem;border-radius:6px;overflow:auto;font-size:12.5px}
|
|
214
|
+
.mb code{background:var(--code-bg);padding:.1em .35em;border-radius:4px;font-size:12.5px}
|
|
215
|
+
.mb details{margin:.3rem 0;border:1px solid var(--border);border-radius:6px;overflow:hidden}
|
|
216
|
+
.mb summary{cursor:pointer;padding:.4rem .6rem;background:var(--bg-muted);font-size:13px;display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}
|
|
217
|
+
.mb details.reasoning{border-left:3px solid color-mix(in srgb,var(--system) 45%,transparent)}
|
|
218
|
+
.mb details.reasoning>.tool-io{background:color-mix(in srgb,var(--system) 7%,var(--bg-elev))}
|
|
219
|
+
.mb .tool-io{position:relative;padding:.5rem .6rem}
|
|
220
|
+
.mb .tool-io pre{margin:.2rem 0}
|
|
221
|
+
.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%}
|
|
222
|
+
.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}
|
|
223
|
+
.more-bar a{font-weight:600}
|
|
224
|
+
</style></head><body>
|
|
225
|
+
<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">
|
|
226
|
+
<a href="/" style="color:var(--header-text)">🏠</a><span style="opacity:.5">/</span>
|
|
227
|
+
<a href="/sessions" style="color:var(--header-text)">会话</a><span style="opacity:.5">/</span>
|
|
228
|
+
<span style="opacity:.85">${escapeHtml(title)}</span>
|
|
229
|
+
</header>
|
|
230
|
+
<div class="meta-bar">
|
|
231
|
+
<h1>${escapeHtml(title)}</h1>
|
|
232
|
+
<div class="meta-status">
|
|
233
|
+
<span class="sid">${escapeHtml(sessionId)}</span>
|
|
234
|
+
${cwd ? `<span>📁 ${escapeHtml(dirname(cwd))}</span>` : ""}
|
|
235
|
+
${first ? `<span>创建 ${escapeHtml(fmtTs(first))}</span>` : ""}
|
|
236
|
+
${last ? `<span>更新 ${escapeHtml(fmtTs(last))}</span>` : ""}
|
|
237
|
+
<span>${total} 条消息</span>
|
|
238
|
+
<span>🧮 峰值 ${fmtNum(peakTok)} · 累计 ↗ ${fmtNum(outputTok)} · cache ${fmtNum(cacheTok)}</span>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
<div class="mbar">
|
|
242
|
+
<span class="note">📖 只读 · pi 运行时会话</span>
|
|
243
|
+
<span class="ord">${ordBtn("asc")}${ordBtn("desc")}</span>
|
|
244
|
+
</div>
|
|
245
|
+
<main id="mlist">${body || `<div style="padding:2rem;text-align:center;color:var(--text-muted)">此会话没有消息</div>`}${moreBar}</main>
|
|
246
|
+
</body></html>`;
|
|
247
|
+
}
|