ework-web 0.10.86 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.86",
3
+ "version": "0.10.87",
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
@@ -726,12 +726,14 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
726
726
  return html(errorPage("等待启动", "会话已创建,后端会话 ID 尚未生成(正在准备工作目录)。稍后刷新此页。"), 200);
727
727
  }
728
728
  const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
729
- const piPage = buildPiSessionPage(rawSid, piLimit);
729
+ const piAsc = url.searchParams.get("asc") === "1";
730
+ const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
730
731
  if (piPage) return html(piPage, 200);
731
732
  } else {
732
733
  // non-ses_ id that resolved to nothing: try the pi session file before 404
733
734
  const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
734
- const piPage = buildPiSessionPage(rawSid, piLimit);
735
+ const piAsc = url.searchParams.get("asc") === "1";
736
+ const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
735
737
  if (piPage) return html(piPage, 200);
736
738
  }
737
739
  const desc = url.searchParams.get("asc") !== "1";
@@ -1,85 +1,247 @@
1
- import { readdirSync, readFileSync, existsSync } from "fs";
2
- import { join } from "path";
1
+ import { readFileSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import { existsSync } from "fs";
3
4
  import { homedir } from "os";
4
- import { THEME_CSS, escapeHtml } from "./render/layout";
5
+ import { THEME_CSS, escapeHtml, escapeAttr } from "./render/layout";
6
+ import { renderMarkdown } from "./render/markdown";
5
7
 
6
8
  interface PiEvent {
7
9
  type?: string;
8
10
  timestamp?: string;
9
11
  message?: {
10
12
  role?: string;
11
- content?: Array<{ type?: string; text?: string; thinking?: string; name?: 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;
12
16
  };
17
+ cwd?: string;
13
18
  }
14
19
 
15
- export function findPiSessionFile(sessionId: string): string | null {
16
- if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return null;
17
- const root = process.env.PI_CODING_AGENT_SESSION_DIR ?? join(homedir(), ".pi/agent/sessions");
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();
18
45
  if (!existsSync(root)) return null;
19
- for (const dir of readdirSync(root, { withFileTypes: true })) {
20
- if (!dir.isDirectory()) continue;
21
- const full = join(root, dir.name);
22
- try {
23
- const hit = readdirSync(full).find((f) => f.endsWith(`_${sessionId}.jsonl`));
24
- if (hit) return join(full, hit);
25
- } catch {
26
- // unreadable project dir — skip
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);
27
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>`);
28
96
  }
29
- return null;
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>`;
30
106
  }
31
107
 
32
- export function buildPiSessionPage(sessionId: string, limit = 200): string | null {
108
+ export function buildPiSessionPage(sessionId: string, limit: number, asc = false): string | null {
33
109
  const file = findPiSessionFile(sessionId);
34
110
  if (!file) return null;
35
-
36
- const entries: { ts: string; role: string; text: string }[] = [];
37
111
  const raw = readFileSync(file, "utf8");
112
+ const events: PiEvent[] = [];
38
113
  for (const line of raw.split("\n")) {
39
- if (!line.trim()) continue;
40
- let ev: PiEvent;
41
- try {
42
- ev = JSON.parse(line) as PiEvent;
43
- } catch {
44
- continue;
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;
45
129
  }
46
- if (ev.type !== "message" || !ev.message?.role) continue;
47
- const role = ev.message.role;
48
- const parts = ev.message.content ?? [];
49
- let text = "";
50
- for (const p of parts) {
51
- if (p.type === "text" && p.text) text += p.text + "\n";
52
- else if (p.type === "toolCall") text += `→ [tool] ${p.name ?? "?"}\n`;
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
+ }
53
166
  }
54
- if (role === "toolResult") text = text || "[tool output]\n";
55
- if (!text.trim()) continue;
56
- entries.push({ ts: (ev.timestamp ?? "").slice(11, 19), role, text: text.trim() });
57
167
  }
58
- const tail = entries.slice(-limit);
59
- const rows = tail
60
- .map(
61
- (e) =>
62
- `<div class="pi-row pi-${escapeHtml(e.role)}"><span class="pi-ts">${escapeHtml(e.ts)}</span><span class="pi-role">${escapeHtml(e.role)}</span><pre>${escapeHtml(e.text.slice(0, 4000))}</pre></div>`,
63
- )
64
- .join("\n");
65
- const shown = tail.length;
66
- const total = entries.length;
67
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
68
- <title>pi session ${escapeHtml(sessionId)}</title>
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>
69
190
  <style>${THEME_CSS}
70
- .pi-wrap{max-width:980px;margin:0 auto;padding:.6rem 1rem 3rem}
71
- .pi-row{border:1px solid var(--border);border-radius:8px;padding:.5rem .7rem;margin:.45rem 0;background:var(--bg-elev)}
72
- .pi-row pre{margin:.3rem 0 0;white-space:pre-wrap;word-break:break-word;font:12px/1.5 ui-monospace,monospace}
73
- .pi-ts{color:var(--text-muted);font-size:11px;margin-right:.6rem}
74
- .pi-role{font-size:11px;font-weight:600;color:var(--accent)}
75
- .pi-assistant{border-left:3px solid var(--accent)}
76
- .pi-user{border-left:3px solid var(--green)}
77
- .pi-toolResult{border-left:3px solid var(--border);opacity:.85}
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}
78
224
  </style></head><body>
79
- <header class="topbar"><a href="/" title="ework 主页" style="color:var(--header-text)">🏠</a></header>
80
- <div class="pi-wrap">
81
- <h2>pi 会话 <code>${escapeHtml(sessionId)}</code></h2>
82
- <p style="color:var(--text-muted);font-size:12px">显示最近 ${shown} / 共 ${total} 条消息事件 · <a href="?limit=2000">加载更多</a></p>
83
- ${rows || '<p style="color:var(--text-muted)">(会话文件为空)</p>'}
84
- </div></body></html>`;
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>`;
85
247
  }