import type { OpencodeClientInterface, SessionListItem, SessionExport, SessionMessage, MessagePart, ToolState } from "../opencode"; import type { SessionDaemonInfo } from "../coordination"; import { THEME_CSS, escapeHtml, escapeAttr, tabNavHTML } from "../render/layout"; import { renderMarkdown, linkifySessionIDs, linkifyAbsPaths } from "../render/markdown"; import { BUILD_ID } from "../build"; import { readFileSync } from "fs"; import { join } from "path"; import { homedir } from "os"; const LIST_LIMIT = 100; export async function buildSessionList( client: OpencodeClientInterface, q: string, daemonMap?: Map, viewer?: { login: string; is_admin: number } | null, ): Promise<{ html: string }> { let sessions = await client.listSessions(LIST_LIMIT); if (daemonMap) { sessions = sessions.map((s) => { const di = daemonMap.get(s.id); if (di) return { ...s, daemon: { displayName: di.displayName, endpoint: di.endpoint } }; return s; }); } const needle = q.trim().toLowerCase(); if (needle) { sessions = sessions.filter((s) => s.title.toLowerCase().includes(needle) || s.id.toLowerCase().includes(needle)); } const rows = sessions.map(sessionRow).join(""); const html = ` ework-web · 会话 ${tabNavHTML("sessions", viewer ? { login: viewer.login, is_admin: viewer.is_admin } : undefined)}
${rows || `
${needle ? "没有匹配的会话" : "没有会话(确认 opencode CLI 可用)"}
`}
`; return { html }; } function sessionRow(s: SessionListItem): string { const href = `/sessions/${encodeURIComponent(s.id)}`; const shortId = s.id.startsWith("ses_") ? s.id.slice(0, 16) + "…" : s.id; const badges = [ s.peakTokens ? `🧮 峰值 ${kfmt(s.peakTokens)}` : "", s.msgCount ? `💬 ${s.msgCount}` : "", s.daemon ? `🖥️ ${escapeHtml(s.daemon.displayName)} ${escapeHtml(s.daemon.endpoint)}` : "", s.model ? `🧠 ${escapeHtml(s.model)}` : "", ].join(""); return `
${escapeHtml(s.title)}
${escapeHtml(shortId)}更新 ${relTimeMs(s.updated)}${badges}
`; } export async function buildSessionView(client: OpencodeClientInterface, id: string, desc: boolean, collapseLines: number, limit = 30, all = false): Promise<{ html: string }> { const data = await client.exportSession(id); return buildSessionViewFromData(data, { desc, collapseLines, limit, all }); } export interface SessionViewOpts { desc: boolean; collapseLines: number; limit: number; all?: boolean; } // Renders any SessionExport — opencode exports and pi-converted transcripts // share this path so both viewers stay feature-identical. export function buildSessionViewFromData(data: SessionExport, opts: SessionViewOpts): { html: string } { const { desc, collapseLines, limit, all = false } = opts; const info = data.info; const title = info.title || info.id; const created = info.time.created ? relTimeMs(info.time.created) : ""; const updated = info.time.updated ? relTimeMs(info.time.updated) : ""; const total = data.messages.length; let msgs = desc ? [...data.messages].reverse() : [...data.messages]; // Show the NEWEST `limit` msgs; desc=newest-first→slice head, asc→slice tail. const truncated = !all && total > limit; if (truncated) msgs = desc ? msgs.slice(0, limit) : msgs.slice(total - limit); const maxMsgTok = computeMaxMsgTok(data.messages); const baseDir = info.directory || ""; const acp = readAcpStats(info.id); const anchors = acp && acp.logBlocks.length ? computeBlockAnchors(msgs, acp.logBlocks, desc) : null; const bodyParts: string[] = []; const pre = anchors && anchors.get(-1); if (pre) bodyParts.push(pre.map(compressionMarkerHTML).join("")); for (let i = 0; i < msgs.length; i++) { const msg = msgs[i]; if (!msg) continue; bodyParts.push(renderMessage(msg, maxMsgTok, collapseLines, baseDir)); const mk = anchors && anchors.get(i); if (mk) bodyParts.push(mk.map(compressionMarkerHTML).join("")); } const body = bodyParts.join(""); const ordQs = desc ? "" : "&asc=1"; const moreAllHref = `/sessions/${encodeURIComponent(info.id)}?all=1${ordQs}`; const moreBar = truncated ? `
共 ${total} 条,当前显示最新 ${limit} 条 · · 查看全部
` : ""; const lastCreated = data.messages.reduce((m, msg) => Math.max(m, msg.info.time?.created ?? 0), 0); const stats = computeCtxStats(data.messages); const bd = acp ? computeCtxBreakdown(data.messages, acp.byMessageId, acp.blocksById) : null; const ordAsc = desc ? "" : " on"; const ordDesc = desc ? " on" : ""; const followNote = ""; const html = ` ${escapeHtml(title)} · 会话

${escapeHtml(title)}

${escapeHtml(info.id)} ${info.directory ? `📁 ${escapeHtml(info.directory)}` : ""} ${created ? `创建 ${created}` : ""} ${updated ? `更新 ${updated}` : ""} ${data.messages.length} 条消息 ${stats.peak ? `🧮 当前 ${kfmt(stats.current)} · 峰值 ${kfmt(stats.peak)}${stats.p90 ? ` · P90 ${kfmt(stats.p90)}` : ""}${stats.p50 ? ` · P50 ${kfmt(stats.p50)}` : ""}` : ""} ${stats.calls ? `📊 累计 ${kfmt(stats.traffic)} · cache ${stats.cacheHit}% · ${stats.calls} 调用` : ""} ${info.version === "pi" ? `🥧 pi` : ""} ${acp ? `🗜 压缩 ${acp.blocks} 段${acp.savedTokens ? ` · 省 ${kfmt(acp.savedTokens)}` : ""}` : ""} ${bd && bd.total ? `📂 ${ctxBreakdownStr(bd, stats.current, stats.overhead)}` : ""}
${acp && acp.logBlocks.length ? compressionLogHTML(acp.logBlocks, acp.savedTokens, info.time.created ?? 0) : ""}
📖 只读 · 编辑见第二步${followNote ? ` ${followNote}` : ""}
${body || `
此会话没有消息
`}${moreBar}
`; return { html }; } export function renderBatchHTML(data: SessionExport, offset: number, limit: number, desc: boolean, collapseLines: number): { html: string; total: number; hasMore: boolean } { const total = data.messages.length; const maxMsgTok = computeMaxMsgTok(data.messages); const ordered = desc ? [...data.messages].reverse() : [...data.messages]; const slice = ordered.slice(offset, offset + limit); const html = slice.map((m) => renderMessage(m, maxMsgTok, collapseLines, data.info?.directory || "")).join(""); return { html, total, hasMore: offset + limit < total }; } export interface NewMessage { id: string; html: string; created: number; } // For the follow endpoint: messages whose created-time is newer than sinceMs, // each server-rendered to the same HTML renderMessage produces. Returning HTML // (not raw data) lets the client append without duplicating the render logic. // // Empty placeholders are skipped WITHOUT advancing lastCreated — advancing past // them would freeze them empty (cursor moves on); leaving them ahead of the // cursor means the next poll re-checks and emits them once content streams in. export function renderNewMessages(data: SessionExport, sinceMs: number, collapseLines: number): { items: NewMessage[]; lastCreated: number } { let last = sinceMs; const maxMsgTok = computeMaxMsgTok(data.messages); const items: NewMessage[] = []; for (const m of data.messages) { const c = m.info.time?.created ?? 0; if (c <= sinceMs) continue; if (!messageHasContent(m)) continue; if (c > last) last = c; items.push({ id: m.info.id, html: renderMessage(m, maxMsgTok, collapseLines, data.info?.directory || ""), created: c }); } return { items, lastCreated: last }; } // opencode writes the message row before its parts stream in, so export can // transiently contain empty placeholders mid-generation. function messageHasContent(msg: SessionMessage): boolean { for (const p of msg.parts) { if (p.type === "text" || p.type === "reasoning") { if ((p.text || "").trim()) return true; } else if (p.type === "tool" && p.tool) { return true; } } return false; } function renderMessage(msg: SessionMessage, maxMsgTok: number, collapseLines: number, baseDir = ""): string { const info = msg.info; const isUser = info.role === "user"; const msgClass = isUser ? "msg msg-u" : "msg msg-a"; const roleClass = isUser ? "role-u" : "role-a"; const roleLabel = isUser ? "👤 User" : "🤖 Assistant"; const agent = info.agent && info.agent !== "build" ? ` (${escapeHtml(info.agent)})` : ""; const model = info.modelID ? ` ${escapeHtml(info.modelID)}` : ""; const when = info.time?.created ? fmtMs(info.time.created) : ""; const sz = formatSize(messageSize(msg)); const tok = formatTokens(info.tokens); const parts = msg.parts.map((p) => renderPart(p, maxMsgTok, collapseLines, baseDir)).join(""); const msgPartTok = msg.parts.reduce((s, p) => s + partTokens(p), 0); const mbar = tokBarHTML(msgPartTok, maxMsgTok, false); const actions = `
`; return `
${actions}
${roleLabel}${agent}${model} ${tok ? `${tok}` : ""} ${escapeHtml(when)} ${sz} ${mbar}
${parts}
`; } function formatTokens(t?: { total?: number; input?: number; output?: number; reasoning?: number }): string { if (!t) return ""; const parts: string[] = []; if (t.input) parts.push(`in:${t.input}`); if (t.output) parts.push(`out:${t.output}`); if (t.reasoning) parts.push(`think:${t.reasoning}`); return parts.length ? `[${parts.join(", ")}]` : ""; } function renderPart(p: MessagePart, maxTok: number, collapseLines: number, baseDir = ""): string { if (p.type === "text") { const t = (p.text || "").trim(); return t ? `
${renderMarkdown(t, baseDir)}
` : ""; } if (p.type === "reasoning") { const t = (p.text || "").trim(); if (!t) return ""; const bar = tokBarHTML(partTokens(p), maxTok); const open = lineCount(t) <= collapseLines; return `
💭 Reasoning${bar}
${renderMarkdown(t, baseDir)}
`; } if (p.type === "tool") { return renderToolPart(p, maxTok, collapseLines, baseDir); } return ""; } function renderToolPart(p: MessagePart, maxTok: number, collapseLines: number, baseDir = ""): string { const tool = p.tool || "tool"; const state = p.state || {}; const title = state.title || tool; const inputJson = state.input !== undefined ? truncJson(state.input, 4000) : ""; const outputStr = formatOutput(state.output, 4000); const bar = tokBarHTML(partTokens(p), maxTok); const tok = bar ? ` ${bar}` : ""; const inputBlock = inputJson ? `
Input
${linkifyAbsPaths(linkifySessionIDs(escapeHtml(inputJson)), baseDir)}
` : ""; const outputBlock = outputStr ? `
Output
${linkifyAbsPaths(linkifySessionIDs(escapeHtml(outputStr)), baseDir)}
` : ""; const open = lineCount(inputJson) + lineCount(outputStr) <= collapseLines; const openAttr = open ? " open" : ""; if (!inputBlock && !outputBlock) { return `
🔧 ${escapeHtml(tool)}${tok}
(无输入/输出)
`; } return `
🔧 ${escapeHtml(title)}${tok}${inputBlock}${outputBlock}
`; } // opencode export carries only per-message token totals, not per-tool; this // estimates per-tool tokens from input+output byte size (≈ bytes/4). function toolBytes(state: ToolState): number { let n = 0; if (state.input !== undefined) n += Buffer.byteLength(JSON.stringify(state.input), "utf-8"); const out = state.output; if (typeof out === "string") n += Buffer.byteLength(out, "utf-8"); else if (out !== undefined) n += Buffer.byteLength(JSON.stringify(out), "utf-8"); return n; } function estFromBytes(bytes: number): number { return bytes > 0 ? Math.max(1, Math.round(bytes / 4)) : 0; } function fmtTok(n: number): string { if (n <= 0) return ""; if (n >= 1000) return `≈${(n / 1000).toFixed(1)}k tok`; return `≈${n} tok`; } function kfmt(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`; if (n >= 1000) return `${Math.round(n / 1000)}K`; return `${n}`; } interface CtxStats { current: number; peak: number; p50: number; p90: number; p99: number; calls: number; traffic: number; cacheHit: number; overhead: number; } function computeCtxStats(messages: SessionMessage[]): CtxStats { const totals: number[] = []; let traffic = 0, cacheRead = 0, inputSum = 0, current = 0; // Fixed overhead (system prompt + tool defs) derived from the first API call: // first call ctx − first user message content (mirrors acp-inspect derive_fixed_overhead). let firstCtx = 0, firstUserEst = 0, userSeen = false; for (const m of messages) { if (m.info.role === "user" && !userSeen) { userSeen = true; let chars = 0; for (const p of m.parts) if (p.type === "text") chars += (p.text ?? "").length; firstUserEst = Math.max(1, Math.floor(chars / 4)); } const t = m.info.tokens; if (!t) continue; const ctx = (t.input ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0); if (ctx <= 0) continue; if (!firstCtx) firstCtx = ctx; totals.push(ctx); current = ctx; traffic += ctx + (t.output ?? 0) + (t.reasoning ?? 0); cacheRead += t.cache?.read ?? 0; inputSum += t.input ?? 0; } if (!totals.length) return { current: 0, peak: 0, p50: 0, p90: 0, p99: 0, calls: 0, traffic: 0, cacheHit: 0, overhead: 0 }; totals.sort((a, b) => a - b); const at = (p: number): number => totals[Math.min(totals.length - 1, Math.floor((p / 100) * totals.length))] ?? 0; const denom = cacheRead + inputSum; return { current, peak: totals[totals.length - 1] ?? 0, p50: at(50), p90: at(90), p99: at(99), calls: totals.length, traffic, cacheHit: denom > 0 ? Math.round((cacheRead / denom) * 100) : 0, overhead: Math.max(0, firstCtx - firstUserEst), }; } interface AcpLogBlock { blockId: number; active: boolean; createdAt: number; startId: string; endId: string; direct: number; effective: number; directMessageIds: string[]; compressedTokens: number; summaryTokens: number; topic: string; summary: string; } function readAcpStats(id: string): { blocks: number; savedTokens: number; byMessageId: Record; blocksById: Record; logBlocks: AcpLogBlock[]; } | null { try { const p = join(homedir(), ".local/share/opencode/storage/plugin/acp", `${id}.json`); const d = JSON.parse(readFileSync(p, "utf-8")) as { stats?: { totalPruneTokens?: unknown }; prune?: { messages?: { activeBlockIds?: unknown; byMessageId?: Record; blocksById?: Record; }; }; }; const pm = d?.prune?.messages; const active = pm?.activeBlockIds; const blocks = Array.isArray(active) ? active.length : 0; const savedTokens = typeof d?.stats?.totalPruneTokens === "number" ? d.stats.totalPruneTokens : 0; const byMessageId = pm?.byMessageId ?? {}; const blocksById = pm?.blocksById ?? {}; const num = (v: unknown): number => typeof v === "number" && v > 0 ? v : 0; const logBlocks: AcpLogBlock[] = Object.entries(blocksById).map(([k, b]) => ({ blockId: Number(k) || num(b?.blockId) || 0, active: b?.active !== false, createdAt: num(b?.createdAt), startId: b?.startId ?? "?", endId: b?.endId ?? "?", direct: Array.isArray(b?.directMessageIds) ? (b?.directMessageIds?.length ?? 0) : 0, effective: Array.isArray(b?.effectiveMessageIds) ? (b?.effectiveMessageIds?.length ?? 0) : 0, directMessageIds: Array.isArray(b?.directMessageIds) ? b.directMessageIds.filter((x): x is string => typeof x === "string") : [], compressedTokens: num(b?.compressedTokens), summaryTokens: num(b?.summaryTokens), topic: b?.topic ?? "", summary: b?.summary ?? "", })).sort((a, b) => a.blockId - b.blockId); return blocks || savedTokens ? { blocks, savedTokens, byMessageId, blocksById, logBlocks } : null; } catch { return null; } } function compressionLogHTML(blocks: AcpLogBlock[], savedTokens: number, sessionStart: number): string { const activeN = blocks.filter((b) => b.active).length; const elapsed = (b: AcpLogBlock): string => { if (!b.createdAt || !sessionStart) return "?"; const m = Math.round((b.createdAt - sessionStart) / 60000); return m > 0 ? `+${m}m` : "?"; }; const row = (b: AcpLogBlock): string => { const est = Math.max(1, Math.floor(b.summary.length / 4)); const rto = b.compressedTokens > 0 ? `${Math.round(b.compressedTokens / est)}:1` : "?"; const msgN = b.direct === b.effective ? `${b.direct}` : b.direct === 0 ? `0→${b.effective}` : `${b.direct}→${b.effective}`; const st = b.active ? '活跃' : '失效'; 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))}`; return `
${line}` + `
${b.summary ? renderMarkdown(b.summary) : "

(空摘要)

"}
`; }; return `
🗜 压缩日志 · ${blocks.length} 段(${activeN} 活跃 / ${blocks.length - activeN} 失效)· 共省 ${kfmt(savedTokens)}` + `
${blocks.map(row).join("")}
`; } // Anchor each block at its compression time (createdAt): everything above the marker // (asc) is context that existed when the compression fired, the marker is the event, // everything below is post-compression. This matches the "see what was there, then it // got compressed" intent — anchoring at the last direct message instead splits the // pre-compression context (non-compressed msgs that also predate createdAt end up after // the marker). Falls back to last direct message only if createdAt is missing. function computeBlockAnchors( msgs: SessionMessage[], blocks: AcpLogBlock[], desc: boolean, ): Map { const m = new Map(); for (const b of blocks) { let anchor = -1; if (b.createdAt) { for (let i = 0; i < msgs.length; i++) { const mt = msgs[i]?.info.time?.created ?? 0; const beforeSeam = desc ? mt >= b.createdAt : mt <= b.createdAt; if (beforeSeam) anchor = i; else break; } } if (anchor < 0) { const id2idx = new Map(); for (let i = 0; i < msgs.length; i++) { const id = msgs[i]?.info?.id; if (id) id2idx.set(id, i); } for (const id of b.directMessageIds) { const idx = id2idx.get(id); if (idx !== undefined && idx > anchor) anchor = idx; } } if (anchor < 0) continue; const arr = m.get(anchor); if (arr) arr.push(b); else m.set(anchor, [b]); } return m; } function compressionMarkerHTML(b: AcpLogBlock): string { const est = Math.max(1, Math.floor(b.summary.length / 4)); const rto = b.compressedTokens > 0 ? `${Math.round(b.compressedTokens / est)}:1` : "?"; const msgN = b.direct === b.effective ? `${b.direct}` : b.direct === 0 ? `0→${b.effective}` : `${b.direct}→${b.effective}`; const st = b.active ? '活跃' : '失效'; const line = `b${b.blockId} · ${st} · ${escapeHtml(b.startId)}–${escapeHtml(b.endId)} · ${msgN}条 · 省${kfmt(b.compressedTokens)} · ${rto}`; const sumMd = b.summary ? escapeAttr(b.summary) : ""; const sumHtml = b.summary ? renderMarkdown(b.summary) : "

(空摘要)

"; const acts = `
`; return `
🗜 压缩 ${line}${b.topic ? ` · ${escapeHtml(b.topic.slice(0, 48))}` : ""}` + `
${sumHtml}
${acts}
`; } interface CtxBreakdown { tool: number; code: number; text: number; summary: number; total: number; topTools: { name: string; tokens: number }[]; } // Post-prune context composition (mirrors acp-inspect --breakdown): a message // covered by an active block is hidden; its summary replaces the range. Reasoning // is skipped (runtime-stripped); tokens are chars/4 (no per-part API counts). function computeCtxBreakdown( messages: SessionMessage[], byMessageId: Record, blocksById: Record, ): CtxBreakdown { let toolC = 0, codeC = 0, textC = 0; const toolByName = new Map(); for (const m of messages) { const active = byMessageId[m.info.id]?.activeBlockIds; if (Array.isArray(active) && active.length > 0) continue; for (const p of m.parts) { if (p.type === "reasoning") continue; if (p.type === "tool") { const out = typeof p.state?.output === "string" ? p.state.output : JSON.stringify(p.state?.output ?? ""); const inp = JSON.stringify(p.state?.input ?? ""); const c = out.length + inp.length; toolC += c; const name = p.tool || "?"; toolByName.set(name, (toolByName.get(name) ?? 0) + c); } else if (p.type === "text") { const t = p.text ?? ""; if (t.includes("```")) codeC += t.length; else textC += t.length; } } } let summaryC = 0; for (const key of Object.keys(blocksById)) { const b = blocksById[key]; if (b?.active !== false) summaryC += (b?.summary ?? "").length; } const tok = (c: number): number => Math.max(1, Math.floor(c / 4)); const tool = tok(toolC), code = tok(codeC), text = tok(textC), summary = tok(summaryC); const total = tool + code + text + summary; const topTools = [...toolByName.entries()] .map(([name, c]) => ({ name, tokens: tok(c) })) .sort((a, b) => b.tokens - a.tokens) .slice(0, 3); return { tool, code, text, summary, total, topTools }; } function ctxBreakdownStr(bd: CtxBreakdown, current: number, overhead: number): string { const denom = current > 0 ? current : bd.total; const pct = (v: number) => (denom > 0 ? Math.round((v * 100) / denom) : 0); const cats = [ { label: "工具", v: bd.tool }, { label: "摘要", v: bd.summary }, { label: "代码", v: bd.code }, { label: "文本", v: bd.text }, ].filter((x) => x.v > 0).sort((a, b) => b.v - a.v) .map((x) => `${x.label} ${pct(x.v)}%`) .join(" · "); const ohStr = overhead > 0 ? ` · 系统/工具定义 ${pct(overhead)}%` : ""; const top = bd.topTools.filter((t) => t.tokens > 0) .map((t) => `${t.name} ${pct(t.tokens)}%`).join(" · "); return `${cats}${ohStr}${top ? ` · top: ${top}` : ""}`; } function lineCount(s: string): number { if (!s) return 0; let n = 1; for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++; return n; } function partTokens(p: MessagePart): number { if (p.type === "reasoning") return estFromBytes(Buffer.byteLength(p.text || "", "utf-8")); if (p.type === "tool" && p.state) return estFromBytes(toolBytes(p.state)); return 0; } function computeMaxMsgTok(messages: SessionMessage[]): number { let max = 0; for (const m of messages) { const n = m.parts.reduce((s, p) => s + partTokens(p), 0); if (n > max) max = n; } return max; } // Bar width ∝ n/maxTok (session max = full); hue goes green(120)→red(0) as it grows. function tokBarHTML(n: number, maxTok: number, withText = true): string { if (n <= 0 || maxTok <= 0) return ""; const pct = Math.max(4, Math.min(100, Math.round((n / maxTok) * 100))); const hue = Math.round(120 * (1 - pct / 100)); const text = withText ? `${fmtTok(n)}` : ""; return `${text}`; } function formatOutput(out: unknown, limit: number): string { if (out === undefined || out === null || out === "") return ""; if (typeof out === "string") return truncate(out, limit); return truncate(JSON.stringify(out, null, 2), limit); } function truncJson(v: unknown, limit: number): string { return truncate(JSON.stringify(v, null, 2), limit); } function truncate(s: string, limit: number): string { if (s.length <= limit) return s; return s.slice(0, limit) + `\n… (truncated, ${s.length} total)`; } function messageSize(msg: SessionMessage): number { let total = 0; for (const p of msg.parts) { if (p.type === "text" || p.type === "reasoning") { total += Buffer.byteLength(p.text || "", "utf-8"); } else if (p.type === "tool" && p.state) { if (p.state.input !== undefined) total += Buffer.byteLength(JSON.stringify(p.state.input), "utf-8"); const out = p.state.output; if (typeof out === "string") total += Buffer.byteLength(out, "utf-8"); else if (out !== undefined) total += Buffer.byteLength(JSON.stringify(out), "utf-8"); } } return total; } function formatSize(n: number): string { if (n < 1024) return `${n}B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`; return `${(n / (1024 * 1024)).toFixed(1)}MB`; } function fmtMs(ms: number): string { try { return new Date(ms).toISOString().replace("T", " ").slice(0, 19); } catch { return String(ms); } } function relTimeMs(ms: number): string { if (!ms) return ""; const d = (Date.now() - ms) / 1000; if (d < 3600) return Math.max(1, Math.floor(d / 60)) + "分前"; if (d < 86400) return Math.floor(d / 3600) + "时前"; if (d < 86400 * 30) return Math.floor(d / 86400) + "天前"; return new Date(ms).toISOString().slice(0, 10); } export { LIST_LIMIT };