ework-web 0.1.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,709 @@
1
+ import type { OpencodeClient, SessionListItem, SessionExport, SessionMessage, MessagePart, ToolState } from "../opencode";
2
+ import { THEME_CSS, escapeHtml, escapeAttr, tabNavHTML } from "../render/layout";
3
+ import { renderMarkdown, linkifySessionIDs, linkifyAbsPaths } from "../render/markdown";
4
+ import { BUILD_ID } from "../build";
5
+ import { readFileSync } from "fs";
6
+ import { join } from "path";
7
+ import { homedir } from "os";
8
+
9
+ const LIST_LIMIT = 100;
10
+
11
+ export async function buildSessionList(client: OpencodeClient, q: string): Promise<{ html: string }> {
12
+ let sessions = await client.listSessions(LIST_LIMIT);
13
+ const needle = q.trim().toLowerCase();
14
+ if (needle) {
15
+ sessions = sessions.filter((s) => s.title.toLowerCase().includes(needle) || s.id.toLowerCase().includes(needle));
16
+ }
17
+ const rows = sessions.map(sessionRow).join("");
18
+
19
+ const html = `<!doctype html>
20
+ <html lang="zh"><head><meta charset="utf-8">
21
+ <meta name="viewport" content="width=device-width,initial-scale=1">
22
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
23
+ <title>ework-web · 会话</title>
24
+ <link rel="stylesheet" href="/static/highlight.css">
25
+ <style>${THEME_CSS}
26
+ .sbar{max-width:900px;margin:0 auto;padding:.6rem 1rem;display:flex;gap:.5rem;align-items:center}
27
+ .sbar input{flex:1;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.5rem .7rem;font:inherit;font-size:14px}
28
+ .sbar input:focus{outline:none;border-color:var(--accent)}
29
+ .slist{max-width:900px;margin:0 auto;padding:0 1rem 3rem}
30
+ .srow{display:block;padding:.7rem 1rem;border:1px solid var(--border);border-radius:10px;margin-bottom:.5rem;background:var(--bg-elev);text-decoration:none}
31
+ .srow:hover{border-color:var(--accent);text-decoration:none}
32
+ .srow .st{font-weight:600;color:var(--text);font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
33
+ .srow .sm{display:flex;gap:1rem;color:var(--text-muted);font-size:12px;margin-top:.25rem;flex-wrap:wrap}
34
+ .srow .sid{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
35
+ .empty{padding:2rem;text-align:center;color:var(--text-muted)}
36
+ </style></head><body>
37
+ <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"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · OpenCode 会话 ${sessions.length}</span></header>
38
+ ${tabNavHTML("sessions")}
39
+ <form class="sbar" method="get" action="/sessions">
40
+ <input name="q" value="${escapeAttr(q)}" placeholder="按标题或会话 ID 搜索…" autocomplete="off">
41
+ </form>
42
+ <main class="slist">${rows || `<div class="empty">${needle ? "没有匹配的会话" : "没有会话(确认 opencode CLI 可用)"}</div>`}</main>
43
+ </body></html>`;
44
+ return { html };
45
+ }
46
+
47
+ function sessionRow(s: SessionListItem): string {
48
+ const href = `/sessions/${encodeURIComponent(s.id)}`;
49
+ const shortId = s.id.startsWith("ses_") ? s.id.slice(0, 16) + "…" : s.id;
50
+ const badges = [
51
+ s.peakTokens ? `<span>🧮 峰值 ${kfmt(s.peakTokens)}</span>` : "",
52
+ s.msgCount ? `<span>💬 ${s.msgCount}</span>` : "",
53
+ ].join("");
54
+ return `<a class="srow" href="${escapeAttr(href)}">
55
+ <div class="st">${escapeHtml(s.title)}</div>
56
+ <div class="sm"><span class="sid">${escapeHtml(shortId)}</span><span>更新 ${relTimeMs(s.updated)}</span>${badges}</div>
57
+ </a>`;
58
+ }
59
+
60
+ export async function buildSessionView(client: OpencodeClient, id: string, desc: boolean, collapseLines: number, limit = 30, all = false): Promise<{ html: string }> {
61
+ const data = await client.exportSession(id);
62
+ const info = data.info;
63
+ const title = info.title || id;
64
+ const created = info.time.created ? relTimeMs(info.time.created) : "";
65
+ const updated = info.time.updated ? relTimeMs(info.time.updated) : "";
66
+ const total = data.messages.length;
67
+ let msgs = desc ? [...data.messages].reverse() : [...data.messages];
68
+ // Show the NEWEST `limit` msgs; desc=newest-first→slice head, asc→slice tail.
69
+ const truncated = !all && total > limit;
70
+ if (truncated) msgs = desc ? msgs.slice(0, limit) : msgs.slice(total - limit);
71
+ const maxMsgTok = computeMaxMsgTok(data.messages);
72
+ const baseDir = info.directory || "";
73
+ const acp = readAcpStats(info.id);
74
+ const anchors = acp && acp.logBlocks.length ? computeBlockAnchors(msgs, acp.logBlocks, desc) : null;
75
+ const bodyParts: string[] = [];
76
+ const pre = anchors && anchors.get(-1);
77
+ if (pre) bodyParts.push(pre.map(compressionMarkerHTML).join(""));
78
+ for (let i = 0; i < msgs.length; i++) {
79
+ const msg = msgs[i];
80
+ if (!msg) continue;
81
+ bodyParts.push(renderMessage(msg, maxMsgTok, collapseLines, baseDir));
82
+ const mk = anchors && anchors.get(i);
83
+ if (mk) bodyParts.push(mk.map(compressionMarkerHTML).join(""));
84
+ }
85
+ const body = bodyParts.join("");
86
+ const ordQs = desc ? "" : "&asc=1";
87
+ const moreAllHref = `/sessions/${encodeURIComponent(info.id)}?all=1${ordQs}`;
88
+ const moreBar = truncated
89
+ ? `<div class="more-bar" id="moreBar" data-total="${total}">共 ${total} 条,当前显示最新 ${limit} 条 · <button type="button" id="loadMoreBtn" data-offset="${limit}">再加载 30 条</button> · <a href="${escapeAttr(moreAllHref)}">查看全部</a></div>`
90
+ : "";
91
+ const lastCreated = data.messages.reduce((m, msg) => Math.max(m, msg.info.time?.created ?? 0), 0);
92
+ const stats = computeCtxStats(data.messages);
93
+ const bd = acp ? computeCtxBreakdown(data.messages, acp.byMessageId, acp.blocksById) : null;
94
+ const ordAsc = desc ? "" : " on";
95
+ const ordDesc = desc ? " on" : "";
96
+ const followNote = "";
97
+
98
+ const html = `<!doctype html>
99
+ <html lang="zh"><head><meta charset="utf-8">
100
+ <meta name="viewport" content="width=device-width,initial-scale=1">
101
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
102
+ <title>${escapeHtml(title)} · 会话</title>
103
+ <link rel="stylesheet" href="/static/highlight.css">
104
+ <style>${THEME_CSS}
105
+ .meta-bar .sid{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-muted)}
106
+ .mbar{max-width:900px;margin:0 auto;padding:.4rem 1rem;display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}
107
+ .mbar .ord{margin-left:auto;display:flex;gap:.3rem;font-size:12px}
108
+ .mbar .ord button{padding:.25rem .55rem;border:1px solid var(--border);border-radius:6px;color:var(--text-muted);background:var(--bg-elev);cursor:pointer;font:inherit}
109
+ .mbar .ord button.on{background:var(--bg-muted);color:var(--text);font-weight:600}
110
+ .mbar .ord button:hover{border-color:var(--accent)}
111
+ .msg-actions{position:absolute;top:.3rem;right:.5rem;display:flex;gap:.3rem;z-index:2}
112
+ .msg-actions .tbtn,.msg-actions .cbtn,.msg-actions .ttsbtn{background:var(--bg-muted);border:1px solid var(--border);border-radius:6px;padding:.15rem .4rem;cursor:pointer;font-size:12px;color:var(--text-muted);line-height:1.4}
113
+ .msg-actions .tbtn:hover,.msg-actions .cbtn:hover,.msg-actions .ttsbtn:hover{border-color:var(--accent);color:var(--accent)}
114
+ .msg-actions .tbtn.loading{opacity:.5;cursor:wait}
115
+ .msg-actions .ttsbtn.playing{color:var(--green);border-color:var(--green)}
116
+ .msg-actions .cbtn.done{color:var(--green);border-color:var(--green)}
117
+ .msg-actions .ttsstop{background:var(--bg-muted);border:1px solid var(--border);border-radius:6px;padding:.15rem .4rem;cursor:pointer;font-size:12px;color:var(--text-muted);line-height:1.4}
118
+ .msg-actions .ttsstop:hover{border-color:var(--accent);color:var(--accent)}
119
+ .mb .tr-note{font-size:12px;color:var(--text-muted);margin-bottom:.3rem;font-style:italic}
120
+ .mb .tr-toggle{font-size:12px;color:var(--accent);cursor:pointer;background:none;border:none;padding:0;text-decoration:underline}
121
+ .mb .tr-prog{white-space:pre-wrap;word-break:break-word}
122
+ .fbtn{background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.35rem .8rem;font-size:13px;cursor:pointer}
123
+ .fbtn:hover{border-color:var(--accent)}
124
+ .fbtn.on{background:var(--green);color:#fff;border-color:var(--green)}
125
+ .fbtn.flash{background:var(--accent);color:#fff;border-color:var(--accent)}
126
+ #mlist{max-width:900px;margin:0 auto;padding:.5rem 1rem 3rem}
127
+ .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}
128
+ .msg.msg-u{border-left-color:var(--human);background:color-mix(in srgb,var(--human) 4%,var(--bg-elev))}
129
+ .msg.msg-a{border-left-color:var(--bot);background:color-mix(in srgb,var(--bot) 4%,var(--bg-elev))}
130
+ .mh{display:flex;align-items:center;gap:.5rem;font-size:13px;flex-wrap:wrap;margin-bottom:.35rem;padding-right:5.5rem}
131
+ .mh .role{font-weight:600;font-size:11px;padding:.05rem .45rem;border-radius:4px;line-height:1.7}
132
+ .mh .role-u{background:color-mix(in srgb,var(--human) 18%,transparent);color:var(--human)}
133
+ .mh .role-a{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
134
+ .mh .model{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-muted)}
135
+ .mh .when{color:var(--text-muted);font-size:12px;margin-left:auto}
136
+ .mh .sz{color:var(--text-muted);font-size:11px}
137
+ .mh .tok{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--text-muted)}
138
+ .mh,.msg-actions,.tbar,.ttok,.mbar,.ord{user-select:none;-webkit-user-select:none}
139
+ .mb{overflow-wrap:anywhere;word-break:break-word;line-height:1.55}
140
+ .mb p{margin:.4rem 0}
141
+ .mb img{max-width:100%;height:auto;max-height:55vh;border-radius:6px;display:block;margin:.4rem 0}
142
+ .mb pre{background:var(--code-bg);padding:.6rem;border-radius:6px;overflow:auto;font-size:12.5px}
143
+ .mb code{background:var(--code-bg);padding:.1em .35em;border-radius:4px;font-size:12.5px}
144
+ .mb pre code{background:none;padding:0}
145
+ .mb details{margin:.3rem 0;border:1px solid var(--border);border-radius:6px;overflow:hidden}
146
+ .mb summary{cursor:pointer;padding:.4rem .6rem;background:var(--bg-muted);font-size:13px;display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}
147
+ .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}
148
+ .more-bar a{font-weight:600}
149
+ .fab{position:fixed;right:1rem;width:40px;height:40px;border-radius:50%;background:var(--bg-elev);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;text-decoration:none;color:var(--text);font-size:18px;box-shadow:0 2px 8px rgba(0,0,0,.15);z-index:50}
150
+ .fab:hover{border-color:var(--accent);color:var(--accent)}
151
+ .fab-top{bottom:3.6rem}
152
+ .fab-bot{bottom:.6rem}
153
+ .tbar{display:inline-block;width:128px;height:6px;background:var(--border);border-radius:3px;overflow:hidden;flex-shrink:0}
154
+ .tbar-fill{display:block;height:100%;border-radius:3px}
155
+ .ttok{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--text-muted);min-width:5.5em;text-align:right}
156
+ .mb summary .tbar{margin-left:auto}
157
+ .mh .tbar{margin-left:.5rem}
158
+ .mb details[open] summary{border-bottom:1px solid var(--border)}
159
+ .mb details.reasoning{border-left:3px solid color-mix(in srgb,var(--system) 45%,transparent)}
160
+ .mb details.reasoning > .tool-io{background:color-mix(in srgb,var(--system) 7%,var(--bg-elev))}
161
+ .mb .tool-io{position:relative;padding:.5rem .6rem}
162
+ .mb .tool-io pre{margin:.2rem 0}
163
+ .pcbtn{position:absolute;top:.3rem;right:.4rem;background:var(--bg-elev);border:1px solid var(--border);border-radius:5px;padding:.1rem .35rem;font-size:12px;line-height:1.4;cursor:pointer;opacity:.55;z-index:1}
164
+ .pcbtn:hover{opacity:1;border-color:var(--accent)}
165
+ .pcbtn.done{color:var(--green);opacity:1}
166
+ .trbtn{position:absolute;top:.3rem;right:2.3rem;background:var(--bg-elev);border:1px solid var(--border);border-radius:5px;padding:.1rem .45rem;font-size:12px;line-height:1.4;cursor:pointer;opacity:.55;z-index:1}
167
+ .trbtn:hover{opacity:1;border-color:var(--accent)}
168
+ .mb details.reasoning > summary .trbtn,.mb details.reasoning > summary .pcbtn{position:static;top:auto;right:auto;margin-left:.3rem;opacity:.7}
169
+ .mb summary .ttsbtn,.mb summary .ttsstop{background:var(--bg-muted);border:1px solid var(--border);border-radius:6px;padding:.05rem .35rem;cursor:pointer;font-size:11px;color:var(--text-muted);opacity:.7}
170
+ .mb summary .ttsbtn:hover,.mb summary .ttsstop:hover{opacity:1;border-color:var(--accent);color:var(--accent)}
171
+ .mb summary .sum-tools{margin-left:auto;display:inline-flex;align-items:center;gap:.4rem;flex-wrap:wrap;justify-content:flex-end}
172
+ .mb summary .sum-tools .tbar{margin-left:0}
173
+ .edit-ro{max-width:900px;margin:0 auto .9rem;padding:.7rem 1rem;color:var(--text-muted);font-size:13px;text-align:center;border:1px dashed var(--border);border-radius:8px}
174
+ .acp-log{max-width:1100px;margin:0 auto .6rem;padding:0 1rem}
175
+ .acp-log>summary{cursor:pointer;font-size:13px;color:var(--text-muted);padding:.3rem 0;user-select:none}
176
+ .acp-log>summary:hover{color:var(--accent)}
177
+ .acp-log-list{max-height:62vh;overflow:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-elev);padding:.2rem}
178
+ .acp-blk{border-bottom:1px solid var(--border);font-size:12px}
179
+ .acp-blk:last-child{border-bottom:none}
180
+ .acp-blk>summary{cursor:pointer;padding:.3rem .45rem;color:var(--text);line-height:1.5;user-select:none;word-break:break-all}
181
+ .acp-blk>summary:hover{background:var(--bg-muted)}.acp-blk.off>summary{opacity:.5}
182
+ .acp-sum{padding:.45rem .8rem;border-top:1px dashed var(--border);font-size:13px;line-height:1.6;overflow-wrap:anywhere;min-width:0}
183
+ .acp-sum h1,.acp-sum h2,.acp-sum h3{font-size:1em;margin:.4em 0 .2em}.acp-sum p{margin:.3em 0}
184
+ .acp-mark{position:relative;margin:.45rem 0;border-left:3px solid var(--green);background:var(--bg-muted);border-radius:0 6px 6px 0;padding:.3rem .7rem;font-size:12px;min-width:0;max-width:100%}
185
+ .acp-mark.off{border-left-color:var(--text-muted);opacity:.7}
186
+ .acp-st{display:inline-block;font-size:11px;padding:0 .35em;border-radius:3px;font-weight:600;line-height:1.6;vertical-align:baseline}
187
+ .acp-st.on{background:var(--green);color:#fff}
188
+ .acp-st.off{background:var(--text-muted);color:#fff}
189
+ .acp-mark>summary{cursor:pointer;list-style:none;font-weight:600;color:var(--text);word-break:break-all;line-height:1.5;padding-right:6rem}
190
+ .acp-mark>summary::-webkit-details-marker{display:none}
191
+ .acp-mark>summary:hover{color:var(--accent)}
192
+ .acp-mark .acp-sum{border-top:1px dashed var(--border);margin-top:.35rem;padding-top:.35rem}
193
+ .acp-sum ul,.acp-sum ol{margin:.3em 0;padding-left:1.4em}.acp-sum code{font-family:ui-monospace,monospace;font-size:.88em;background:var(--bg-muted);padding:.1em .3em;border-radius:3px;overflow-wrap:break-word}
194
+ .acp-sum pre{background:var(--bg-muted);border:1px solid var(--border);border-radius:6px;padding:.5rem .7rem;overflow-x:auto;max-width:100%;font-size:12px}
195
+ .acp-sum pre code{background:none;padding:0;overflow-wrap:normal}
196
+ .acp-sum table{display:block;max-width:100%;overflow-x:auto;border-collapse:collapse}
197
+ .acp-sum img{max-width:100%}
198
+ </style></head><body>
199
+ <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">
200
+ <a href="/" style="color:var(--header-text)">🏠</a><span style="opacity:.5">/</span>
201
+ <a href="/sessions" style="color:var(--header-text)">会话</a><span style="opacity:.5">/</span>
202
+ <span style="opacity:.85">${escapeHtml(title)}</span>
203
+ </header>
204
+ <div class="meta-bar">
205
+ <h1>${escapeHtml(title)}</h1>
206
+ <div class="meta-status">
207
+ <span class="sid">${escapeHtml(info.id)}</span>
208
+ ${info.directory ? `<span>📁 ${escapeHtml(info.directory)}</span>` : ""}
209
+ ${created ? `<span>创建 ${created}</span>` : ""}
210
+ ${updated ? `<span>更新 ${updated}</span>` : ""}
211
+ <span>${data.messages.length} 条消息</span>
212
+ ${stats.peak ? `<span>🧮 当前 ${kfmt(stats.current)} · 峰值 ${kfmt(stats.peak)}${stats.p90 ? ` · P90 ${kfmt(stats.p90)}` : ""}${stats.p50 ? ` · P50 ${kfmt(stats.p50)}` : ""}</span>` : ""}
213
+ ${stats.calls ? `<span>📊 累计 ${kfmt(stats.traffic)} · cache ${stats.cacheHit}% · ${stats.calls} 调用</span>` : ""}
214
+ ${acp ? `<span>🗜 压缩 ${acp.blocks} 段${acp.savedTokens ? ` · 省 ${kfmt(acp.savedTokens)}` : ""}</span>` : ""}
215
+ ${bd && bd.total ? `<span>📂 ${ctxBreakdownStr(bd, stats.current, stats.overhead)}</span>` : ""}
216
+ </div>
217
+ </div>
218
+ ${acp && acp.logBlocks.length ? compressionLogHTML(acp.logBlocks, acp.savedTokens, info.time.created ?? 0) : ""}
219
+ <div class="mbar">
220
+ <button type="button" id="followBtn" class="fbtn" title="自动追加新消息(类似 -f)">🔄 Follow</button>
221
+ <button type="button" id="collapseBtn" class="fbtn" title="一键折叠/展开所有 thinking 和工具输出">📂 展开全部</button>
222
+ <span style="font-size:12px;color:var(--text-muted)">📖 只读 · 编辑见第二步${followNote ? ` ${followNote}` : ""}</span>
223
+ <span class="ord">
224
+ <button type="button" class="ord-btn ${ordAsc}" data-ord="asc">正序</button>
225
+ <button type="button" class="ord-btn ${ordDesc}" data-ord="desc">倒序</button>
226
+ </span>
227
+ </div>
228
+ <main id="mlist"><div id="top"></div>${body || `<div class="empty" style="padding:2rem;text-align:center;color:var(--text-muted)">此会话没有消息</div>`}${moreBar}<div id="bottom"></div></main>
229
+ <a class="fab fab-top" href="#top" title="回到顶部">↑</a>
230
+ <a class="fab fab-bot" href="#bottom" title="去到底部">↓</a>
231
+ <script type="application/json" id="session-data">${JSON.stringify({ id: info.id, lastCreated, desc })}</script>
232
+ <script src="/static/tts.js?v=${BUILD_ID}" defer></script>
233
+ <script src="/static/session.js?v=${BUILD_ID}" defer></script>
234
+ </body></html>`;
235
+ return { html };
236
+ }
237
+
238
+ export function renderBatchHTML(data: SessionExport, offset: number, limit: number, desc: boolean, collapseLines: number): { html: string; total: number; hasMore: boolean } {
239
+ const total = data.messages.length;
240
+ const maxMsgTok = computeMaxMsgTok(data.messages);
241
+ const ordered = desc ? [...data.messages].reverse() : [...data.messages];
242
+ const slice = ordered.slice(offset, offset + limit);
243
+ const html = slice.map((m) => renderMessage(m, maxMsgTok, collapseLines, data.info?.directory || "")).join("");
244
+ return { html, total, hasMore: offset + limit < total };
245
+ }
246
+
247
+ export interface NewMessage {
248
+ id: string;
249
+ html: string;
250
+ created: number;
251
+ }
252
+
253
+ // For the follow endpoint: messages whose created-time is newer than sinceMs,
254
+ // each server-rendered to the same HTML renderMessage produces. Returning HTML
255
+ // (not raw data) lets the client append without duplicating the render logic.
256
+ //
257
+ // Empty placeholders are skipped WITHOUT advancing lastCreated — advancing past
258
+ // them would freeze them empty (cursor moves on); leaving them ahead of the
259
+ // cursor means the next poll re-checks and emits them once content streams in.
260
+ export function renderNewMessages(data: SessionExport, sinceMs: number, collapseLines: number): { items: NewMessage[]; lastCreated: number } {
261
+ let last = sinceMs;
262
+ const maxMsgTok = computeMaxMsgTok(data.messages);
263
+ const items: NewMessage[] = [];
264
+ for (const m of data.messages) {
265
+ const c = m.info.time?.created ?? 0;
266
+ if (c <= sinceMs) continue;
267
+ if (!messageHasContent(m)) continue;
268
+ if (c > last) last = c;
269
+ items.push({ id: m.info.id, html: renderMessage(m, maxMsgTok, collapseLines, data.info?.directory || ""), created: c });
270
+ }
271
+ return { items, lastCreated: last };
272
+ }
273
+
274
+ // opencode writes the message row before its parts stream in, so export can
275
+ // transiently contain empty placeholders mid-generation.
276
+ function messageHasContent(msg: SessionMessage): boolean {
277
+ for (const p of msg.parts) {
278
+ if (p.type === "text" || p.type === "reasoning") {
279
+ if ((p.text || "").trim()) return true;
280
+ } else if (p.type === "tool" && p.tool) {
281
+ return true;
282
+ }
283
+ }
284
+ return false;
285
+ }
286
+
287
+ function renderMessage(msg: SessionMessage, maxMsgTok: number, collapseLines: number, baseDir = ""): string {
288
+ const info = msg.info;
289
+ const isUser = info.role === "user";
290
+ const msgClass = isUser ? "msg msg-u" : "msg msg-a";
291
+ const roleClass = isUser ? "role-u" : "role-a";
292
+ const roleLabel = isUser ? "👤 User" : "🤖 Assistant";
293
+ const agent = info.agent && info.agent !== "build" ? ` <span class="model">(${escapeHtml(info.agent)})</span>` : "";
294
+ const model = info.modelID ? ` <span class="model">${escapeHtml(info.modelID)}</span>` : "";
295
+ const when = info.time?.created ? fmtMs(info.time.created) : "";
296
+ const sz = formatSize(messageSize(msg));
297
+ const tok = formatTokens(info.tokens);
298
+ const parts = msg.parts.map((p) => renderPart(p, maxMsgTok, collapseLines, baseDir)).join("");
299
+ const msgPartTok = msg.parts.reduce((s, p) => s + partTokens(p), 0);
300
+ const mbar = tokBarHTML(msgPartTok, maxMsgTok, false);
301
+ const actions = `<div class="msg-actions"><button type="button" class="cbtn" title="复制可见文本">📋</button><button type="button" class="linkbtn" title="复制楼层链接">🔗</button><button type="button" class="ttsstop" title="停止朗读">⏹</button><button type="button" class="ttsbtn" title="朗读(选中起点)">🔊</button><button type="button" class="tbtn" title="翻译成中文">翻译</button></div>`;
302
+ return `<div class="${msgClass}" id="m${info.id}">
303
+ ${actions}
304
+ <div class="mh">
305
+ <span class="role ${roleClass}">${roleLabel}</span>${agent}${model}
306
+ ${tok ? `<span class="tok">${tok}</span>` : ""}
307
+ <span class="when">${escapeHtml(when)}</span>
308
+ <span class="sz">${sz}</span>
309
+ ${mbar}
310
+ </div>
311
+ <div class="mb">${parts}</div>
312
+ </div>`;
313
+ }
314
+
315
+ function formatTokens(t?: { total?: number; input?: number; output?: number; reasoning?: number }): string {
316
+ if (!t) return "";
317
+ const parts: string[] = [];
318
+ if (t.input) parts.push(`in:${t.input}`);
319
+ if (t.output) parts.push(`out:${t.output}`);
320
+ if (t.reasoning) parts.push(`think:${t.reasoning}`);
321
+ return parts.length ? `[${parts.join(", ")}]` : "";
322
+ }
323
+
324
+ function renderPart(p: MessagePart, maxTok: number, collapseLines: number, baseDir = ""): string {
325
+ if (p.type === "text") {
326
+ const t = (p.text || "").trim();
327
+ return t ? `<div data-md="${escapeAttr(t)}">${renderMarkdown(t, baseDir)}</div>` : "";
328
+ }
329
+ if (p.type === "reasoning") {
330
+ const t = (p.text || "").trim();
331
+ if (!t) return "";
332
+ const bar = tokBarHTML(partTokens(p), maxTok);
333
+ const open = lineCount(t) <= collapseLines;
334
+ return `<details class="reasoning"${open ? " open" : ""}><summary>💭 Reasoning<span class="sum-tools">${bar}<button type="button" class="pcbtn" title="复制此段">📋</button></span></summary><div class="tool-io" data-md="${escapeAttr(t)}">${renderMarkdown(t, baseDir)}</div></details>`;
335
+ }
336
+ if (p.type === "tool") {
337
+ return renderToolPart(p, maxTok, collapseLines, baseDir);
338
+ }
339
+ return "";
340
+ }
341
+
342
+ function renderToolPart(p: MessagePart, maxTok: number, collapseLines: number, baseDir = ""): string {
343
+ const tool = p.tool || "tool";
344
+ const state = p.state || {};
345
+ const title = state.title || tool;
346
+ const inputJson = state.input !== undefined ? truncJson(state.input, 4000) : "";
347
+ const outputStr = formatOutput(state.output, 4000);
348
+ const bar = tokBarHTML(partTokens(p), maxTok);
349
+ const tok = bar ? ` ${bar}` : "";
350
+ const inputBlock = inputJson
351
+ ? `<div class="tool-io"><button type="button" class="pcbtn" title="复制此段">📋</button><div style="font-size:12px;color:var(--text-muted);margin-bottom:.2rem">Input</div><pre><code class="hljs language-json">${linkifyAbsPaths(linkifySessionIDs(escapeHtml(inputJson)), baseDir)}</code></pre></div>`
352
+ : "";
353
+ const outputBlock = outputStr
354
+ ? `<div class="tool-io"><button type="button" class="pcbtn" title="复制此段">📋</button><div style="font-size:12px;color:var(--text-muted);margin-bottom:.2rem">Output</div><pre><code>${linkifyAbsPaths(linkifySessionIDs(escapeHtml(outputStr)), baseDir)}</code></pre></div>`
355
+ : "";
356
+ const open = lineCount(inputJson) + lineCount(outputStr) <= collapseLines;
357
+ const openAttr = open ? " open" : "";
358
+ if (!inputBlock && !outputBlock) {
359
+ return `<details><summary>🔧 ${escapeHtml(tool)}${tok}</summary><div class="tool-io" style="color:var(--text-muted);font-size:13px">(无输入/输出)</div></details>`;
360
+ }
361
+ return `<details class="tool"${openAttr}><summary>🔧 ${escapeHtml(title)}${tok}</summary>${inputBlock}${outputBlock}</details>`;
362
+ }
363
+
364
+ // opencode export carries only per-message token totals, not per-tool; this
365
+ // estimates per-tool tokens from input+output byte size (≈ bytes/4).
366
+ function toolBytes(state: ToolState): number {
367
+ let n = 0;
368
+ if (state.input !== undefined) n += Buffer.byteLength(JSON.stringify(state.input), "utf-8");
369
+ const out = state.output;
370
+ if (typeof out === "string") n += Buffer.byteLength(out, "utf-8");
371
+ else if (out !== undefined) n += Buffer.byteLength(JSON.stringify(out), "utf-8");
372
+ return n;
373
+ }
374
+
375
+ function estFromBytes(bytes: number): number {
376
+ return bytes > 0 ? Math.max(1, Math.round(bytes / 4)) : 0;
377
+ }
378
+
379
+ function fmtTok(n: number): string {
380
+ if (n <= 0) return "";
381
+ if (n >= 1000) return `≈${(n / 1000).toFixed(1)}k tok`;
382
+ return `≈${n} tok`;
383
+ }
384
+
385
+ function kfmt(n: number): string {
386
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
387
+ if (n >= 1000) return `${Math.round(n / 1000)}K`;
388
+ return `${n}`;
389
+ }
390
+
391
+ interface CtxStats {
392
+ current: number; peak: number; p50: number; p90: number; p99: number;
393
+ calls: number; traffic: number; cacheHit: number; overhead: number;
394
+ }
395
+ function computeCtxStats(messages: SessionMessage[]): CtxStats {
396
+ const totals: number[] = [];
397
+ let traffic = 0, cacheRead = 0, inputSum = 0, current = 0;
398
+ // Fixed overhead (system prompt + tool defs) derived from the first API call:
399
+ // first call ctx − first user message content (mirrors acp-inspect derive_fixed_overhead).
400
+ let firstCtx = 0, firstUserEst = 0, userSeen = false;
401
+ for (const m of messages) {
402
+ if (m.info.role === "user" && !userSeen) {
403
+ userSeen = true;
404
+ let chars = 0;
405
+ for (const p of m.parts) if (p.type === "text") chars += (p.text ?? "").length;
406
+ firstUserEst = Math.max(1, Math.floor(chars / 4));
407
+ }
408
+ const t = m.info.tokens;
409
+ if (!t) continue;
410
+ const ctx = (t.input ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0);
411
+ if (ctx <= 0) continue;
412
+ if (!firstCtx) firstCtx = ctx;
413
+ totals.push(ctx);
414
+ current = ctx;
415
+ traffic += ctx + (t.output ?? 0) + (t.reasoning ?? 0);
416
+ cacheRead += t.cache?.read ?? 0;
417
+ inputSum += t.input ?? 0;
418
+ }
419
+ if (!totals.length) return { current: 0, peak: 0, p50: 0, p90: 0, p99: 0, calls: 0, traffic: 0, cacheHit: 0, overhead: 0 };
420
+ totals.sort((a, b) => a - b);
421
+ const at = (p: number): number => totals[Math.min(totals.length - 1, Math.floor((p / 100) * totals.length))] ?? 0;
422
+ const denom = cacheRead + inputSum;
423
+ return {
424
+ current,
425
+ peak: totals[totals.length - 1] ?? 0,
426
+ p50: at(50), p90: at(90), p99: at(99),
427
+ calls: totals.length, traffic,
428
+ cacheHit: denom > 0 ? Math.round((cacheRead / denom) * 100) : 0,
429
+ overhead: Math.max(0, firstCtx - firstUserEst),
430
+ };
431
+ }
432
+
433
+ interface AcpLogBlock {
434
+ blockId: number; active: boolean; createdAt: number;
435
+ startId: string; endId: string; direct: number; effective: number;
436
+ directMessageIds: string[];
437
+ compressedTokens: number; summaryTokens: number; topic: string; summary: string;
438
+ }
439
+
440
+ function readAcpStats(id: string): {
441
+ blocks: number; savedTokens: number;
442
+ byMessageId: Record<string, { activeBlockIds?: unknown[] }>;
443
+ blocksById: Record<string, { active?: boolean; summary?: string }>;
444
+ logBlocks: AcpLogBlock[];
445
+ } | null {
446
+ try {
447
+ const p = join(homedir(), ".local/share/opencode/storage/plugin/acp", `${id}.json`);
448
+ const d = JSON.parse(readFileSync(p, "utf-8")) as {
449
+ stats?: { totalPruneTokens?: unknown };
450
+ prune?: {
451
+ messages?: {
452
+ activeBlockIds?: unknown;
453
+ byMessageId?: Record<string, { activeBlockIds?: unknown[] }>;
454
+ blocksById?: Record<string, {
455
+ blockId?: unknown; active?: boolean; createdAt?: unknown;
456
+ startId?: string; endId?: string;
457
+ directMessageIds?: unknown[]; effectiveMessageIds?: unknown[];
458
+ compressedTokens?: unknown; summaryTokens?: unknown;
459
+ topic?: string; summary?: string;
460
+ }>;
461
+ };
462
+ };
463
+ };
464
+ const pm = d?.prune?.messages;
465
+ const active = pm?.activeBlockIds;
466
+ const blocks = Array.isArray(active) ? active.length : 0;
467
+ const savedTokens = typeof d?.stats?.totalPruneTokens === "number" ? d.stats.totalPruneTokens : 0;
468
+ const byMessageId = pm?.byMessageId ?? {};
469
+ const blocksById = pm?.blocksById ?? {};
470
+ const num = (v: unknown): number => typeof v === "number" && v > 0 ? v : 0;
471
+ const logBlocks: AcpLogBlock[] = Object.entries(blocksById).map(([k, b]) => ({
472
+ blockId: Number(k) || num(b?.blockId) || 0,
473
+ active: b?.active !== false,
474
+ createdAt: num(b?.createdAt),
475
+ startId: b?.startId ?? "?",
476
+ endId: b?.endId ?? "?",
477
+ direct: Array.isArray(b?.directMessageIds) ? (b?.directMessageIds?.length ?? 0) : 0,
478
+ effective: Array.isArray(b?.effectiveMessageIds) ? (b?.effectiveMessageIds?.length ?? 0) : 0,
479
+ directMessageIds: Array.isArray(b?.directMessageIds) ? b.directMessageIds.filter((x): x is string => typeof x === "string") : [],
480
+ compressedTokens: num(b?.compressedTokens),
481
+ summaryTokens: num(b?.summaryTokens),
482
+ topic: b?.topic ?? "",
483
+ summary: b?.summary ?? "",
484
+ })).sort((a, b) => a.blockId - b.blockId);
485
+ return blocks || savedTokens ? { blocks, savedTokens, byMessageId, blocksById, logBlocks } : null;
486
+ } catch {
487
+ return null;
488
+ }
489
+ }
490
+
491
+ function compressionLogHTML(blocks: AcpLogBlock[], savedTokens: number, sessionStart: number): string {
492
+ const activeN = blocks.filter((b) => b.active).length;
493
+ const elapsed = (b: AcpLogBlock): string => {
494
+ if (!b.createdAt || !sessionStart) return "?";
495
+ const m = Math.round((b.createdAt - sessionStart) / 60000);
496
+ return m > 0 ? `+${m}m` : "?";
497
+ };
498
+ const row = (b: AcpLogBlock): string => {
499
+ const est = Math.max(1, Math.floor(b.summary.length / 4));
500
+ const rto = b.compressedTokens > 0 ? `${Math.round(b.compressedTokens / est)}:1` : "?";
501
+ const msgN = b.direct === b.effective ? `${b.direct}` : b.direct === 0 ? `0→${b.effective}` : `${b.direct}→${b.effective}`;
502
+ const st = b.active ? '<span class="acp-st on">活跃</span>' : '<span class="acp-st off">失效</span>';
503
+ const line = `b${b.blockId} · ${st} · ${elapsed(b)} · ${escapeHtml(b.startId)}–${escapeHtml(b.endId)} · ${msgN}条 · 省${kfmt(b.compressedTokens)} · ${rto} · ${escapeHtml(b.topic.slice(0, 42))}`;
504
+ return `<details class="acp-blk${b.active ? " on" : " off"}"><summary>${line}</summary>` +
505
+ `<div class="acp-sum">${b.summary ? renderMarkdown(b.summary) : "<p><em>(空摘要)</em></p>"}</div></details>`;
506
+ };
507
+ return `<details class="acp-log"><summary>🗜 压缩日志 · ${blocks.length} 段(${activeN} 活跃 / ${blocks.length - activeN} 失效)· 共省 ${kfmt(savedTokens)}</summary>` +
508
+ `<div class="acp-log-list">${blocks.map(row).join("")}</div></details>`;
509
+ }
510
+
511
+ // Anchor each block at its compression time (createdAt): everything above the marker
512
+ // (asc) is context that existed when the compression fired, the marker is the event,
513
+ // everything below is post-compression. This matches the "see what was there, then it
514
+ // got compressed" intent — anchoring at the last direct message instead splits the
515
+ // pre-compression context (non-compressed msgs that also predate createdAt end up after
516
+ // the marker). Falls back to last direct message only if createdAt is missing.
517
+ function computeBlockAnchors(
518
+ msgs: SessionMessage[],
519
+ blocks: AcpLogBlock[],
520
+ desc: boolean,
521
+ ): Map<number, AcpLogBlock[]> {
522
+ const m = new Map<number, AcpLogBlock[]>();
523
+ for (const b of blocks) {
524
+ let anchor = -1;
525
+ if (b.createdAt) {
526
+ for (let i = 0; i < msgs.length; i++) {
527
+ const mt = msgs[i]?.info.time?.created ?? 0;
528
+ const beforeSeam = desc ? mt >= b.createdAt : mt <= b.createdAt;
529
+ if (beforeSeam) anchor = i; else break;
530
+ }
531
+ }
532
+ if (anchor < 0) {
533
+ const id2idx = new Map<string, number>();
534
+ for (let i = 0; i < msgs.length; i++) { const id = msgs[i]?.info?.id; if (id) id2idx.set(id, i); }
535
+ for (const id of b.directMessageIds) {
536
+ const idx = id2idx.get(id);
537
+ if (idx !== undefined && idx > anchor) anchor = idx;
538
+ }
539
+ }
540
+ if (anchor < 0) continue;
541
+ const arr = m.get(anchor);
542
+ if (arr) arr.push(b); else m.set(anchor, [b]);
543
+ }
544
+ return m;
545
+ }
546
+
547
+ function compressionMarkerHTML(b: AcpLogBlock): string {
548
+ const est = Math.max(1, Math.floor(b.summary.length / 4));
549
+ const rto = b.compressedTokens > 0 ? `${Math.round(b.compressedTokens / est)}:1` : "?";
550
+ const msgN = b.direct === b.effective ? `${b.direct}` : b.direct === 0 ? `0→${b.effective}` : `${b.direct}→${b.effective}`;
551
+ const st = b.active ? '<span class="acp-st on">活跃</span>' : '<span class="acp-st off">失效</span>';
552
+ const line = `b${b.blockId} · ${st} · ${escapeHtml(b.startId)}–${escapeHtml(b.endId)} · ${msgN}条 · 省${kfmt(b.compressedTokens)} · ${rto}`;
553
+ const sumMd = b.summary ? escapeAttr(b.summary) : "";
554
+ const sumHtml = b.summary ? renderMarkdown(b.summary) : "<p><em>(空摘要)</em></p>";
555
+ const acts = `<div class="msg-actions acp-acts"><button type="button" class="cbtn" title="复制摘要">📋</button><button type="button" class="ttsstop" title="停止朗读">⏹</button><button type="button" class="ttsbtn" title="朗读摘要">🔊</button><button type="button" class="tbtn" title="翻译摘要">翻译</button></div>`;
556
+ return `<details class="acp-mark${b.active ? " on" : " off"}"><summary>🗜 压缩 ${line}${b.topic ? ` · ${escapeHtml(b.topic.slice(0, 48))}` : ""}</summary>` +
557
+ `<div class="acp-sum"><div data-md="${sumMd}">${sumHtml}</div></div>${acts}</details>`;
558
+ }
559
+
560
+ interface CtxBreakdown {
561
+ tool: number; code: number; text: number; summary: number; total: number;
562
+ topTools: { name: string; tokens: number }[];
563
+ }
564
+
565
+ // Post-prune context composition (mirrors acp-inspect --breakdown): a message
566
+ // covered by an active block is hidden; its summary replaces the range. Reasoning
567
+ // is skipped (runtime-stripped); tokens are chars/4 (no per-part API counts).
568
+ function computeCtxBreakdown(
569
+ messages: SessionMessage[],
570
+ byMessageId: Record<string, { activeBlockIds?: unknown[] }>,
571
+ blocksById: Record<string, { active?: boolean; summary?: string }>,
572
+ ): CtxBreakdown {
573
+ let toolC = 0, codeC = 0, textC = 0;
574
+ const toolByName = new Map<string, number>();
575
+ for (const m of messages) {
576
+ const active = byMessageId[m.info.id]?.activeBlockIds;
577
+ if (Array.isArray(active) && active.length > 0) continue;
578
+ for (const p of m.parts) {
579
+ if (p.type === "reasoning") continue;
580
+ if (p.type === "tool") {
581
+ const out = typeof p.state?.output === "string" ? p.state.output : JSON.stringify(p.state?.output ?? "");
582
+ const inp = JSON.stringify(p.state?.input ?? "");
583
+ const c = out.length + inp.length;
584
+ toolC += c;
585
+ const name = p.tool || "?";
586
+ toolByName.set(name, (toolByName.get(name) ?? 0) + c);
587
+ } else if (p.type === "text") {
588
+ const t = p.text ?? "";
589
+ if (t.includes("```")) codeC += t.length; else textC += t.length;
590
+ }
591
+ }
592
+ }
593
+ let summaryC = 0;
594
+ for (const key of Object.keys(blocksById)) {
595
+ const b = blocksById[key];
596
+ if (b?.active !== false) summaryC += (b?.summary ?? "").length;
597
+ }
598
+ const tok = (c: number): number => Math.max(1, Math.floor(c / 4));
599
+ const tool = tok(toolC), code = tok(codeC), text = tok(textC), summary = tok(summaryC);
600
+ const total = tool + code + text + summary;
601
+ const topTools = [...toolByName.entries()]
602
+ .map(([name, c]) => ({ name, tokens: tok(c) }))
603
+ .sort((a, b) => b.tokens - a.tokens)
604
+ .slice(0, 3);
605
+ return { tool, code, text, summary, total, topTools };
606
+ }
607
+
608
+ function ctxBreakdownStr(bd: CtxBreakdown, current: number, overhead: number): string {
609
+ const denom = current > 0 ? current : bd.total;
610
+ const pct = (v: number) => (denom > 0 ? Math.round((v * 100) / denom) : 0);
611
+ const cats = [
612
+ { label: "工具", v: bd.tool },
613
+ { label: "摘要", v: bd.summary },
614
+ { label: "代码", v: bd.code },
615
+ { label: "文本", v: bd.text },
616
+ ].filter((x) => x.v > 0).sort((a, b) => b.v - a.v)
617
+ .map((x) => `${x.label} ${pct(x.v)}%`)
618
+ .join(" · ");
619
+ const ohStr = overhead > 0 ? ` · 系统/工具定义 ${pct(overhead)}%` : "";
620
+ const top = bd.topTools.filter((t) => t.tokens > 0)
621
+ .map((t) => `${t.name} ${pct(t.tokens)}%`).join(" · ");
622
+ return `${cats}${ohStr}${top ? ` · top: ${top}` : ""}`;
623
+ }
624
+
625
+ function lineCount(s: string): number {
626
+ if (!s) return 0;
627
+ let n = 1;
628
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++;
629
+ return n;
630
+ }
631
+
632
+ function partTokens(p: MessagePart): number {
633
+ if (p.type === "reasoning") return estFromBytes(Buffer.byteLength(p.text || "", "utf-8"));
634
+ if (p.type === "tool" && p.state) return estFromBytes(toolBytes(p.state));
635
+ return 0;
636
+ }
637
+
638
+ function computeMaxMsgTok(messages: SessionMessage[]): number {
639
+ let max = 0;
640
+ for (const m of messages) {
641
+ const n = m.parts.reduce((s, p) => s + partTokens(p), 0);
642
+ if (n > max) max = n;
643
+ }
644
+ return max;
645
+ }
646
+
647
+ // Bar width ∝ n/maxTok (session max = full); hue goes green(120)→red(0) as it grows.
648
+ function tokBarHTML(n: number, maxTok: number, withText = true): string {
649
+ if (n <= 0 || maxTok <= 0) return "";
650
+ const pct = Math.max(4, Math.min(100, Math.round((n / maxTok) * 100)));
651
+ const hue = Math.round(120 * (1 - pct / 100));
652
+ const text = withText ? `<span class="ttok">${fmtTok(n)}</span>` : "";
653
+ return `<span class="tbar"><span class="tbar-fill" style="width:${pct}%;background:hsl(${hue},65%,45%)"></span></span>${text}`;
654
+ }
655
+
656
+ function formatOutput(out: unknown, limit: number): string {
657
+ if (out === undefined || out === null || out === "") return "";
658
+ if (typeof out === "string") return truncate(out, limit);
659
+ return truncate(JSON.stringify(out, null, 2), limit);
660
+ }
661
+
662
+ function truncJson(v: unknown, limit: number): string {
663
+ return truncate(JSON.stringify(v, null, 2), limit);
664
+ }
665
+
666
+ function truncate(s: string, limit: number): string {
667
+ if (s.length <= limit) return s;
668
+ return s.slice(0, limit) + `\n… (truncated, ${s.length} total)`;
669
+ }
670
+
671
+ function messageSize(msg: SessionMessage): number {
672
+ let total = 0;
673
+ for (const p of msg.parts) {
674
+ if (p.type === "text" || p.type === "reasoning") {
675
+ total += Buffer.byteLength(p.text || "", "utf-8");
676
+ } else if (p.type === "tool" && p.state) {
677
+ if (p.state.input !== undefined) total += Buffer.byteLength(JSON.stringify(p.state.input), "utf-8");
678
+ const out = p.state.output;
679
+ if (typeof out === "string") total += Buffer.byteLength(out, "utf-8");
680
+ else if (out !== undefined) total += Buffer.byteLength(JSON.stringify(out), "utf-8");
681
+ }
682
+ }
683
+ return total;
684
+ }
685
+
686
+ function formatSize(n: number): string {
687
+ if (n < 1024) return `${n}B`;
688
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
689
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
690
+ }
691
+
692
+ function fmtMs(ms: number): string {
693
+ try {
694
+ return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
695
+ } catch {
696
+ return String(ms);
697
+ }
698
+ }
699
+
700
+ function relTimeMs(ms: number): string {
701
+ if (!ms) return "";
702
+ const d = (Date.now() - ms) / 1000;
703
+ if (d < 3600) return Math.max(1, Math.floor(d / 60)) + "分前";
704
+ if (d < 86400) return Math.floor(d / 3600) + "时前";
705
+ if (d < 86400 * 30) return Math.floor(d / 86400) + "天前";
706
+ return new Date(ms).toISOString().slice(0, 10);
707
+ }
708
+
709
+ export { LIST_LIMIT };