ework-web 0.10.93 → 0.10.95
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 +13 -4
- package/src/pi-sessions.ts +132 -209
- package/src/render/markdown.ts +31 -2
- package/src/views/issueThread.ts +9 -8
- package/src/views/sessionLog.ts +16 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
|
-
import { buildPiSessionPage } from "./pi-sessions";
|
|
2
|
+
import { buildPiSessionPage, loadPiSessionExport } from "./pi-sessions";
|
|
3
3
|
import { join, dirname } from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import { readFileSync, appendFileSync, existsSync } from "fs";
|
|
@@ -733,13 +733,13 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
733
733
|
}
|
|
734
734
|
const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
|
|
735
735
|
const piAsc = url.searchParams.get("asc") === "1";
|
|
736
|
-
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
|
|
736
|
+
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc, cfg.collapseLines);
|
|
737
737
|
if (piPage) return html(piPage, 200);
|
|
738
738
|
} else {
|
|
739
739
|
// non-ses_ id that resolved to nothing: try the pi session file before 404
|
|
740
740
|
const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
|
|
741
741
|
const piAsc = url.searchParams.get("asc") === "1";
|
|
742
|
-
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
|
|
742
|
+
const piPage = buildPiSessionPage(rawSid, piLimit, piAsc, cfg.collapseLines);
|
|
743
743
|
if (piPage) return html(piPage, 200);
|
|
744
744
|
}
|
|
745
745
|
const desc = url.searchParams.get("asc") !== "1";
|
|
@@ -1703,6 +1703,11 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1703
1703
|
if (!sid) return json({ error: "bad session path" }, 400);
|
|
1704
1704
|
const sinceNum = Number(url.searchParams.get("since") ?? "0");
|
|
1705
1705
|
const since = Number.isFinite(sinceNum) ? sinceNum : 0;
|
|
1706
|
+
const piData = sid.startsWith("ses_") ? null : loadPiSessionExport(sid);
|
|
1707
|
+
if (piData) {
|
|
1708
|
+
const { items, lastCreated } = renderNewMessages(piData, since, cfg.collapseLines);
|
|
1709
|
+
return json({ items, lastCreated });
|
|
1710
|
+
}
|
|
1706
1711
|
try {
|
|
1707
1712
|
const data = await opencode.exportSession(sid);
|
|
1708
1713
|
const { items, lastCreated } = renderNewMessages(data, since, cfg.collapseLines);
|
|
@@ -1719,6 +1724,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1719
1724
|
const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
|
|
1720
1725
|
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get("limit")) || 30));
|
|
1721
1726
|
const desc = url.searchParams.get("asc") !== "1";
|
|
1727
|
+
const piData = sid.startsWith("ses_") ? null : loadPiSessionExport(sid);
|
|
1728
|
+
if (piData) {
|
|
1729
|
+
return json(renderBatchHTML(piData, offset, limit, desc, cfg.collapseLines));
|
|
1730
|
+
}
|
|
1722
1731
|
try {
|
|
1723
1732
|
const data = await opencode.exportSession(sid);
|
|
1724
1733
|
const batch = renderBatchHTML(data, offset, limit, desc, cfg.collapseLines);
|
|
@@ -1841,7 +1850,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1841
1850
|
login: c.author,
|
|
1842
1851
|
avatar: "",
|
|
1843
1852
|
created_at: c.created_at,
|
|
1844
|
-
body_html: renderMarkdown(c.body),
|
|
1853
|
+
body_html: renderMarkdown(c.body, "", `/${owner}/${repo}/issues/${issue.number}`),
|
|
1845
1854
|
};
|
|
1846
1855
|
}
|
|
1847
1856
|
let closed = false;
|
package/src/pi-sessions.ts
CHANGED
|
@@ -1,40 +1,34 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
|
-
import { join
|
|
2
|
+
import { join } from "path";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
4
|
import { homedir } from "os";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import type { SessionExport, SessionMessage, MessagePart } from "./opencode";
|
|
6
|
+
import { buildSessionViewFromData } from "./views/sessionLog";
|
|
7
7
|
|
|
8
|
-
interface
|
|
8
|
+
interface PiPart {
|
|
9
9
|
type?: string;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
isError?: boolean;
|
|
16
|
-
content?: Array<{ type?: string; text?: string; thinking?: string; name?: string; arguments?: unknown; id?: string; output?: unknown }>;
|
|
17
|
-
usage?: { input?: number; output?: number; cacheRead?: number; totalTokens?: number };
|
|
18
|
-
model?: string;
|
|
19
|
-
};
|
|
20
|
-
cwd?: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
thinking?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
arguments?: unknown;
|
|
14
|
+
id?: string;
|
|
21
15
|
}
|
|
22
16
|
|
|
23
|
-
interface
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
17
|
+
interface PiMessage {
|
|
18
|
+
role?: string;
|
|
19
|
+
toolCallId?: string;
|
|
20
|
+
toolName?: string;
|
|
21
|
+
isError?: boolean;
|
|
22
|
+
model?: string;
|
|
23
|
+
usage?: { input?: number; output?: number; cacheRead?: number; totalTokens?: number };
|
|
24
|
+
content?: PiPart[];
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
interface
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
reasoning: string[];
|
|
36
|
-
tools: PiToolCall[];
|
|
37
|
-
tok?: string;
|
|
27
|
+
export interface PiEvent {
|
|
28
|
+
type?: string;
|
|
29
|
+
timestamp?: string;
|
|
30
|
+
message?: PiMessage;
|
|
31
|
+
cwd?: string;
|
|
38
32
|
}
|
|
39
33
|
|
|
40
34
|
function sessionsRoot(): string {
|
|
@@ -42,7 +36,7 @@ function sessionsRoot(): string {
|
|
|
42
36
|
?? join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "sessions");
|
|
43
37
|
}
|
|
44
38
|
|
|
45
|
-
function findPiSessionFile(sessionId: string): string | null {
|
|
39
|
+
export function findPiSessionFile(sessionId: string): string | null {
|
|
46
40
|
if (!/^[0-9a-f-]{16,64}$/i.test(sessionId)) return null;
|
|
47
41
|
const root = sessionsRoot();
|
|
48
42
|
if (!existsSync(root)) return null;
|
|
@@ -64,204 +58,133 @@ function findPiSessionFile(sessionId: string): string | null {
|
|
|
64
58
|
return out[out.length - 1] ?? null;
|
|
65
59
|
}
|
|
66
60
|
|
|
67
|
-
function
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
function fmtNum(n: number): string {
|
|
72
|
-
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
73
|
-
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
|
74
|
-
return String(n);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function resultText(output: unknown): string {
|
|
78
|
-
if (output == null) return "";
|
|
79
|
-
if (typeof output === "string") return output.slice(0, 4000);
|
|
80
|
-
try { return JSON.stringify(output).slice(0, 4000); } catch { return String(output); }
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function toolCard(t: PiToolCall): string {
|
|
84
|
-
const argStr = t.args.length > 600 ? t.args.slice(0, 600) + "…" : t.args;
|
|
85
|
-
const argsBlock = t.args && t.args !== "{}"
|
|
86
|
-
? `<div class="pi-io-label">完整参数</div><pre>${escapeHtml(t.args.length > 20000 ? t.args.slice(0, 20000) + "\n…(截断)" : t.args)}</pre>`
|
|
87
|
-
: "";
|
|
88
|
-
const resultBlock = `<div class="pi-io-label">返回</div><pre>${escapeHtml(t.result ?? "(无返回)")}</pre>`;
|
|
89
|
-
return `<details class="pi-tool"><summary>🔧 ${escapeHtml(t.name)}<span class="pi-tool-args">${escapeHtml(argStr)}</span></summary><div class="tool-io">${argsBlock}${resultBlock}</div></details>`;
|
|
61
|
+
function outputText(m: PiMessage): string {
|
|
62
|
+
const payload = (m.content ?? []).filter(p => p.type === "text").map(p => p.text ?? "").join("\n");
|
|
63
|
+
if (payload) return payload;
|
|
64
|
+
try { return JSON.stringify(m.content ?? ""); } catch { return ""; }
|
|
90
65
|
}
|
|
91
66
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
for (const t of c.tools) parts.push(toolCard(t));
|
|
100
|
-
for (const tx of c.textParts) {
|
|
101
|
-
if (!tx.trim()) continue;
|
|
102
|
-
parts.push(`<div data-md="${escapeAttr(tx.slice(0, 20000))}">${renderMarkdown(tx.slice(0, 20000))}</div>`);
|
|
103
|
-
}
|
|
104
|
-
return `<div class="msg ${isUser ? "msg-u" : "msg-a"}">
|
|
105
|
-
<div class="mh">
|
|
106
|
-
<span class="role ${isUser ? "role-u" : "role-a"}">${isUser ? "👤 User" : "🤖 Assistant"}</span>
|
|
107
|
-
${c.model ? `<span class="model">${escapeHtml(c.model)}</span>` : ""}
|
|
108
|
-
${c.tok ? `<span class="tok">${c.tok}</span>` : ""}
|
|
109
|
-
<span class="when">${escapeHtml(fmtTs(c.ts))}</span>
|
|
110
|
-
</div>
|
|
111
|
-
<div class="mb">${parts.join("") || "<p><em>(无文本输出)</em></p>"}</div>
|
|
112
|
-
</div>`;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function buildPiSessionPage(sessionId: string, limit: number, asc = false): string | null {
|
|
116
|
-
const file = findPiSessionFile(sessionId);
|
|
117
|
-
if (!file) return null;
|
|
118
|
-
const raw = readFileSync(file, "utf8");
|
|
119
|
-
const events: PiEvent[] = [];
|
|
120
|
-
for (const line of raw.split("\n")) {
|
|
121
|
-
const l = line.trim();
|
|
122
|
-
if (!l) continue;
|
|
123
|
-
try { events.push(JSON.parse(l) as PiEvent); } catch { /* skip torn tail line */ }
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const cards: PiCard[] = [];
|
|
127
|
-
const openTools = new Map<string, PiToolCall>();
|
|
128
|
-
const seenContent = new Set<string>();
|
|
129
|
-
let inputTok = 0, outputTok = 0, cacheTok = 0, peakTok = 0;
|
|
67
|
+
// pi re-emits identical message content as new events (streaming steps,
|
|
68
|
+
// re-persisted context, repeated tool results); each re-emission also repeats
|
|
69
|
+
// its usage, so collapsing repeats keeps the token stats honest.
|
|
70
|
+
export function piEventsToExport(sessionId: string, events: PiEvent[]): SessionExport {
|
|
71
|
+
const messages: SessionMessage[] = [];
|
|
72
|
+
const openTools = new Map<string, { part: MessagePart }>();
|
|
73
|
+
const seen = new Set<string>();
|
|
130
74
|
let cwd = "";
|
|
131
|
-
let
|
|
75
|
+
let created = 0;
|
|
76
|
+
let updated = 0;
|
|
77
|
+
let title = "";
|
|
78
|
+
|
|
79
|
+
const stamp = (e: PiEvent) => {
|
|
80
|
+
const t = e.timestamp ? Date.parse(e.timestamp) : NaN;
|
|
81
|
+
if (!Number.isFinite(t)) return;
|
|
82
|
+
if (!created) created = t;
|
|
83
|
+
updated = Math.max(updated, t);
|
|
84
|
+
};
|
|
85
|
+
|
|
132
86
|
for (const e of events) {
|
|
133
87
|
if (e.type === "session" && e.cwd) cwd = e.cwd;
|
|
134
|
-
|
|
135
|
-
if (!first) first = e.timestamp;
|
|
136
|
-
last = e.timestamp;
|
|
137
|
-
}
|
|
88
|
+
stamp(e);
|
|
138
89
|
if (e.type !== "message" || !e.message) continue;
|
|
139
90
|
const m = e.message;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const contentKey = `${m.role ?? ""}\u0000${JSON.stringify(m.content ?? [])}`;
|
|
145
|
-
if (seenContent.has(contentKey)) continue;
|
|
146
|
-
seenContent.add(contentKey);
|
|
147
|
-
const u = m.usage;
|
|
148
|
-
if (u) {
|
|
149
|
-
inputTok += u.input ?? 0;
|
|
150
|
-
outputTok += u.output ?? 0;
|
|
151
|
-
cacheTok += u.cacheRead ?? 0;
|
|
152
|
-
peakTok = Math.max(peakTok, u.totalTokens ?? 0);
|
|
153
|
-
}
|
|
91
|
+
const dedupeKey = `${m.role ?? ""}\u0000${JSON.stringify(m.content ?? [])}`;
|
|
92
|
+
if (seen.has(dedupeKey)) continue;
|
|
93
|
+
seen.add(dedupeKey);
|
|
94
|
+
|
|
154
95
|
if (m.role === "user") {
|
|
155
|
-
const
|
|
156
|
-
cards.push({ ts: e.timestamp ?? "", role: "user", textParts: text ? [text] : [], reasoning: [], tools: [] });
|
|
157
|
-
} else if (m.role === "assistant") {
|
|
158
|
-
const card: PiCard = { ts: e.timestamp ?? "", role: "assistant", model: m.model, textParts: [], reasoning: [], tools: [], tok: u?.output ? `↗ ${fmtNum(u.output)}` : undefined };
|
|
96
|
+
const parts: MessagePart[] = [];
|
|
159
97
|
for (const p of m.content ?? []) {
|
|
160
|
-
if (p.type === "
|
|
161
|
-
|
|
98
|
+
if (p.type === "text" && p.text) parts.push({ type: "text", text: p.text });
|
|
99
|
+
}
|
|
100
|
+
if (!title) {
|
|
101
|
+
const first = parts.map(p => p.type === "text" ? p.text : "").join(" ").trim();
|
|
102
|
+
// daemon user messages are either a long system prompt or [SYSTEM
|
|
103
|
+
// FORWARD] wrappers whose header quotes the issue title
|
|
104
|
+
if (first.startsWith("[SYSTEM FORWARD]")) {
|
|
105
|
+
const quoted = first.match(/"([^"\n]{4,120})"/);
|
|
106
|
+
if (quoted?.[1]) title = quoted[1].replace(/\s+/g, " ").slice(0, 60);
|
|
107
|
+
} else if (first && first.length < 400) {
|
|
108
|
+
title = first.replace(/\s+/g, " ").slice(0, 60);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (parts.length) messages.push({ info: { role: "user", id: `pi_u${messages.length}`, time: { created: updated } }, parts });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (m.role === "assistant") {
|
|
116
|
+
const parts: MessagePart[] = [];
|
|
117
|
+
for (const p of m.content ?? []) {
|
|
118
|
+
if (p.type === "thinking" && p.thinking) parts.push({ type: "reasoning", text: p.thinking });
|
|
119
|
+
else if (p.type === "text" && p.text) parts.push({ type: "text", text: p.text });
|
|
162
120
|
else if (p.type === "toolCall") {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
121
|
+
const tool: MessagePart = {
|
|
122
|
+
type: "tool",
|
|
123
|
+
tool: p.name ?? "tool",
|
|
124
|
+
state: { title: p.name ?? "tool", input: p.arguments },
|
|
167
125
|
};
|
|
168
|
-
|
|
169
|
-
|
|
126
|
+
if (p.id) openTools.set(p.id, { part: tool });
|
|
127
|
+
parts.push(tool);
|
|
170
128
|
}
|
|
171
129
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
130
|
+
if (!parts.length) continue;
|
|
131
|
+
const u = m.usage;
|
|
132
|
+
messages.push({
|
|
133
|
+
info: {
|
|
134
|
+
role: "assistant",
|
|
135
|
+
id: `pi_a${messages.length}`,
|
|
136
|
+
modelID: m.model,
|
|
137
|
+
time: { created: updated },
|
|
138
|
+
tokens: u ? { input: u.input, output: u.output, cache: { read: u.cacheRead } } : undefined,
|
|
139
|
+
},
|
|
140
|
+
parts,
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (m.role === "toolResult") {
|
|
146
|
+
const text = outputText(m);
|
|
147
|
+
const open = m.toolCallId ? openTools.get(m.toolCallId) : undefined;
|
|
148
|
+
if (open && open.part.state) {
|
|
149
|
+
open.part.state.output = text;
|
|
150
|
+
if (m.isError) open.part.state.title = `${open.part.state.title} (error)`;
|
|
151
|
+
openTools.delete(m.toolCallId!);
|
|
152
|
+
} else {
|
|
153
|
+
const name = m.toolName ?? "tool";
|
|
154
|
+
messages.push({
|
|
155
|
+
info: { role: "assistant", id: `pi_t${messages.length}`, time: { created: updated } },
|
|
156
|
+
parts: [{ type: "tool", tool: name, state: { title: m.isError ? `${name} (error)` : name, output: text } }],
|
|
157
|
+
});
|
|
184
158
|
}
|
|
185
159
|
}
|
|
186
160
|
}
|
|
187
161
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
162
|
+
return {
|
|
163
|
+
info: {
|
|
164
|
+
id: sessionId,
|
|
165
|
+
title: title || `pi ${sessionId.slice(0, 8)}`,
|
|
166
|
+
directory: cwd,
|
|
167
|
+
version: "pi",
|
|
168
|
+
time: { created, updated },
|
|
169
|
+
},
|
|
170
|
+
messages,
|
|
196
171
|
};
|
|
197
|
-
|
|
198
|
-
const allHref = qs({ limit: "5000" });
|
|
199
|
-
const ordBtn = (mode: "asc" | "desc") =>
|
|
200
|
-
`<a class="ord-btn ${asc === (mode === "asc") ? "active" : ""}" href="${qs({ limit: String(limit), asc: mode === "asc" ? "1" : "0" })}">${mode === "asc" ? "正序" : "倒序"}</a>`;
|
|
172
|
+
}
|
|
201
173
|
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
174
|
+
export function loadPiSessionExport(sessionId: string): SessionExport | null {
|
|
175
|
+
const file = findPiSessionFile(sessionId);
|
|
176
|
+
if (!file) return null;
|
|
177
|
+
const events: PiEvent[] = [];
|
|
178
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
179
|
+
const t = line.trim();
|
|
180
|
+
if (!t) continue;
|
|
181
|
+
try { events.push(JSON.parse(t)); } catch { /* trailing/partial line */ }
|
|
182
|
+
}
|
|
183
|
+
return piEventsToExport(sessionId, events);
|
|
184
|
+
}
|
|
206
185
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
.meta-bar h1{font-size:17px;margin:0 0 .35rem;overflow-wrap:anywhere}
|
|
212
|
-
.meta-status{display:flex;gap:.6rem;flex-wrap:wrap;font-size:12px;color:var(--text-muted)}
|
|
213
|
-
.meta-status .sid{font-family:ui-monospace,monospace}
|
|
214
|
-
.mbar{max-width:900px;margin:.6rem auto;padding:0 1rem;display:flex;gap:.6rem;align-items:center;flex-wrap:wrap}
|
|
215
|
-
.mbar .note{font-size:12px;color:var(--text-muted)}
|
|
216
|
-
.ord{margin-left:auto;display:flex;gap:.3rem}
|
|
217
|
-
.ord-btn{padding:.25rem .7rem;border-radius:6px;border:1px solid var(--border);font-size:12px;color:var(--text-muted);text-decoration:none}
|
|
218
|
-
.ord-btn.active{background:var(--bg-muted);color:var(--text);font-weight:600}
|
|
219
|
-
#mlist{max-width:900px;margin:0 auto;padding:.5rem 1rem 3rem}
|
|
220
|
-
.msg{position:relative;background:var(--bg-elev);border:1px solid var(--border);border-left:3px solid var(--border);border-radius:8px;padding:.6rem .9rem;margin-bottom:1rem;overflow:hidden}
|
|
221
|
-
.msg.msg-u{border-left-color:var(--human);background:color-mix(in srgb,var(--human) 4%,var(--bg-elev))}
|
|
222
|
-
.msg.msg-a{border-left-color:var(--bot);background:color-mix(in srgb,var(--bot) 4%,var(--bg-elev))}
|
|
223
|
-
.mh{display:flex;align-items:center;gap:.5rem;font-size:13px;flex-wrap:wrap;margin-bottom:.35rem}
|
|
224
|
-
.mh .role{font-weight:600;font-size:11px;padding:.05rem .45rem;border-radius:4px;line-height:1.7}
|
|
225
|
-
.mh .role-u{background:color-mix(in srgb,var(--human) 18%,transparent);color:var(--human)}
|
|
226
|
-
.mh .role-a{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
|
|
227
|
-
.mh .model{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-muted)}
|
|
228
|
-
.mh .when{color:var(--text-muted);font-size:12px;margin-left:auto}
|
|
229
|
-
.mh .tok{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--text-muted)}
|
|
230
|
-
.mb{overflow-wrap:anywhere;word-break:break-word;line-height:1.55}
|
|
231
|
-
.mb p{margin:.4rem 0}
|
|
232
|
-
.mb pre{background:var(--code-bg);padding:.6rem;border-radius:6px;overflow:auto;font-size:12.5px}
|
|
233
|
-
.mb code{background:var(--code-bg);padding:.1em .35em;border-radius:4px;font-size:12.5px}
|
|
234
|
-
.mb details{margin:.3rem 0;border:1px solid var(--border);border-radius:6px;overflow:hidden}
|
|
235
|
-
.mb summary{cursor:pointer;padding:.4rem .6rem;background:var(--bg-muted);font-size:13px;display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}
|
|
236
|
-
.mb details.reasoning{border-left:3px solid color-mix(in srgb,var(--system) 45%,transparent)}
|
|
237
|
-
.mb details.reasoning>.tool-io{background:color-mix(in srgb,var(--system) 7%,var(--bg-elev))}
|
|
238
|
-
.mb .tool-io{position:relative;padding:.5rem .6rem}
|
|
239
|
-
.mb .tool-io pre{margin:.2rem 0}
|
|
240
|
-
.pi-tool summary .pi-tool-args{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}
|
|
241
|
-
.pi-io-label{font-size:11px;color:var(--text-muted);margin:6px 0 2px;font-weight:600}
|
|
242
|
-
.more-bar{max-width:900px;margin:1rem auto 0;padding:.8rem 1rem;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--text-muted);font-size:13px}
|
|
243
|
-
.more-bar a{font-weight:600}
|
|
244
|
-
</style></head><body>
|
|
245
|
-
<header class="nav" style="display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px;flex-wrap:wrap">
|
|
246
|
-
<a href="/" style="color:var(--header-text)">🏠</a><span style="opacity:.5">/</span>
|
|
247
|
-
<a href="/sessions" style="color:var(--header-text)">会话</a><span style="opacity:.5">/</span>
|
|
248
|
-
<span style="opacity:.85">${escapeHtml(title)}</span>
|
|
249
|
-
</header>
|
|
250
|
-
<div class="meta-bar">
|
|
251
|
-
<h1>${escapeHtml(title)}</h1>
|
|
252
|
-
<div class="meta-status">
|
|
253
|
-
<span class="sid">${escapeHtml(sessionId)}</span>
|
|
254
|
-
${cwd ? `<span>📁 ${escapeHtml(dirname(cwd))}</span>` : ""}
|
|
255
|
-
${first ? `<span>创建 ${escapeHtml(fmtTs(first))}</span>` : ""}
|
|
256
|
-
${last ? `<span>更新 ${escapeHtml(fmtTs(last))}</span>` : ""}
|
|
257
|
-
<span>${total} 条消息</span>
|
|
258
|
-
<span>🧮 峰值 ${fmtNum(peakTok)} · 累计 ↗ ${fmtNum(outputTok)} · cache ${fmtNum(cacheTok)}</span>
|
|
259
|
-
</div>
|
|
260
|
-
</div>
|
|
261
|
-
<div class="mbar">
|
|
262
|
-
<span class="note">📖 只读 · pi 运行时会话</span>
|
|
263
|
-
<span class="ord">${ordBtn("asc")}${ordBtn("desc")}</span>
|
|
264
|
-
</div>
|
|
265
|
-
<main id="mlist">${body || `<div style="padding:2rem;text-align:center;color:var(--text-muted)">此会话没有消息</div>`}${moreBar}</main>
|
|
266
|
-
</body></html>`;
|
|
186
|
+
export function buildPiSessionPage(sessionId: string, limit: number, asc: boolean, collapseLines = 12): string | null {
|
|
187
|
+
const data = loadPiSessionExport(sessionId);
|
|
188
|
+
if (!data) return null;
|
|
189
|
+
return buildSessionViewFromData(data, { desc: !asc, collapseLines, limit, all: false }).html;
|
|
267
190
|
}
|
package/src/render/markdown.ts
CHANGED
|
@@ -33,11 +33,40 @@ const PURIFY_OPTS = {
|
|
|
33
33
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|\.\/|\.\.\/|#)/i,
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
export function renderMarkdown(body: string, baseDir = ""): string {
|
|
36
|
+
export function renderMarkdown(body: string, baseDir = "", baseIssuePath = ""): string {
|
|
37
37
|
const dirty = marked.parse(body ?? "");
|
|
38
38
|
const html = typeof dirty === "string" ? dirty : "";
|
|
39
39
|
const clean = purify.sanitize(html, PURIFY_OPTS) as unknown as string;
|
|
40
|
-
return linkifyAbsPaths(linkifySessionIDs(clean), baseDir);
|
|
40
|
+
return linkifyIssueRefs(linkifyAbsPaths(linkifySessionIDs(clean), baseDir), baseIssuePath);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Linkify #N issue references inside rendered issue content. Only applied
|
|
44
|
+
// when the caller supplies the current issue's path — same-repo shorthand
|
|
45
|
+
// (#204) resolves against it. Skips tag interiors and existing anchors,
|
|
46
|
+
// same token-scanning approach as linkifySessionIDs.
|
|
47
|
+
export function linkifyIssueRefs(html: string, baseIssuePath = ""): string {
|
|
48
|
+
if (!baseIssuePath) return html;
|
|
49
|
+
const issuePath = baseIssuePath.replace(/\/+$/, "");
|
|
50
|
+
let out = "";
|
|
51
|
+
let inA = false;
|
|
52
|
+
let inCode = false;
|
|
53
|
+
const re = /(<[^>]*>)|([^<]+)/g;
|
|
54
|
+
let m: RegExpExecArray | null;
|
|
55
|
+
while ((m = re.exec(html)) !== null) {
|
|
56
|
+
if (m[1] !== undefined) {
|
|
57
|
+
const tag = m[1];
|
|
58
|
+
if (/^<a[\s>]/i.test(tag)) inA = true;
|
|
59
|
+
else if (/^<\/a[\s>]/i.test(tag)) inA = false;
|
|
60
|
+
else if (/^<pre[\s>]/i.test(tag) || /^<code[\s>]/i.test(tag)) inCode = true;
|
|
61
|
+
else if (/^<\/pre[\s>]/i.test(tag) || /^<\/code[\s>]/i.test(tag)) inCode = false;
|
|
62
|
+
out += tag;
|
|
63
|
+
} else if (m[2] !== undefined) {
|
|
64
|
+
out += inA || inCode
|
|
65
|
+
? m[2]
|
|
66
|
+
: m[2].replace(/(^|[^\w#])#([1-9][0-9]{0,4})(?![0-9\w])/g, (_all, pre: string, num: string) => `${pre}<a href="${issuePath.replace(/\/issues\/\d+$/, "")}/issues/${num}">#${num}</a>`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
// Linkify ses_ IDs in text nodes only — skips tag interiors (don't corrupt
|
package/src/views/issueThread.ts
CHANGED
|
@@ -37,7 +37,7 @@ export interface IssueThreadPayload {
|
|
|
37
37
|
comments: CommentView[];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function toView(c: CommentRow): CommentView {
|
|
40
|
+
function toView(c: CommentRow, issuePath = ""): CommentView {
|
|
41
41
|
return {
|
|
42
42
|
id: c.id,
|
|
43
43
|
tag: classifyActor(c.body, c.author_kind),
|
|
@@ -45,13 +45,13 @@ function toView(c: CommentRow): CommentView {
|
|
|
45
45
|
login: c.author,
|
|
46
46
|
avatar: "",
|
|
47
47
|
created_at: c.created_at,
|
|
48
|
-
body_html: renderMarkdown(c.body),
|
|
48
|
+
body_html: renderMarkdown(c.body, "", issuePath),
|
|
49
49
|
display_name: c.author_display_name ?? null,
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export async function viewsFromComments(rows: CommentRow[]): Promise<CommentView[]> {
|
|
54
|
-
const views = rows.map((r) => toView(r));
|
|
53
|
+
export async function viewsFromComments(rows: CommentRow[], issuePath = ""): Promise<CommentView[]> {
|
|
54
|
+
const views = rows.map((r) => toView(r, issuePath));
|
|
55
55
|
await hydrateReactions(views);
|
|
56
56
|
return views;
|
|
57
57
|
}
|
|
@@ -105,12 +105,13 @@ export async function buildIssueThread(
|
|
|
105
105
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
106
106
|
const currentPage = totalPages;
|
|
107
107
|
const { rows } = await listCommentsPage(issue.id, currentPage, PAGE_SIZE);
|
|
108
|
-
const
|
|
108
|
+
const issuePath = `/${owner}/${repo}/issues/${issue.number}`;
|
|
109
|
+
const views = await viewsFromComments(rows, issuePath);
|
|
109
110
|
const hasOlder = currentPage > 1;
|
|
110
111
|
const displayViews = orderForDisplay(views, cfg.commentSort);
|
|
111
112
|
const payload = payloadFromComments(issue, displayViews, currentPage, hasOlder, cfg.commentSort);
|
|
112
113
|
|
|
113
|
-
const descriptionHtml = renderMarkdown(issue.body);
|
|
114
|
+
const descriptionHtml = renderMarkdown(issue.body, "", issuePath);
|
|
114
115
|
const descriptionCollapsed = issue.body.length > 1200;
|
|
115
116
|
const upstreamWebUrl = (() => {
|
|
116
117
|
const clone = getDefaultUpstreamUrl(project);
|
|
@@ -187,7 +188,7 @@ export async function fetchIssuePage(
|
|
|
187
188
|
const issue = await getIssueWithMeta(project.id, number);
|
|
188
189
|
if (!issue) throw new StoreError(404, `#${number} 不存在`);
|
|
189
190
|
const { rows, page: clamped } = await listCommentsPage(issue.id, page, PAGE_SIZE);
|
|
190
|
-
const views = await viewsFromComments(rows);
|
|
191
|
+
const views = await viewsFromComments(rows, `/${owner}/${repo}/issues/${issue.number}`);
|
|
191
192
|
return { issue, views, currentPage: clamped, hasOlder: clamped > 1 };
|
|
192
193
|
}
|
|
193
194
|
|
|
@@ -202,7 +203,7 @@ export async function fetchIssueSince(
|
|
|
202
203
|
const issue = await getIssueWithMeta(project.id, number);
|
|
203
204
|
if (!issue) throw new StoreError(404, `#${number} 不存在`);
|
|
204
205
|
const rows = await listCommentsSince(issue.id, sinceISO);
|
|
205
|
-
return await viewsFromComments(rows);
|
|
206
|
+
return await viewsFromComments(rows, `/${owner}/${repo}/issues/${issue.number}`);
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
export function safeJsonEmbed(v: unknown): string {
|
package/src/views/sessionLog.ts
CHANGED
|
@@ -75,8 +75,22 @@ function sessionRow(s: SessionListItem): string {
|
|
|
75
75
|
|
|
76
76
|
export async function buildSessionView(client: OpencodeClientInterface, id: string, desc: boolean, collapseLines: number, limit = 30, all = false): Promise<{ html: string }> {
|
|
77
77
|
const data = await client.exportSession(id);
|
|
78
|
+
return buildSessionViewFromData(data, { desc, collapseLines, limit, all });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SessionViewOpts {
|
|
82
|
+
desc: boolean;
|
|
83
|
+
collapseLines: number;
|
|
84
|
+
limit: number;
|
|
85
|
+
all?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Renders any SessionExport — opencode exports and pi-converted transcripts
|
|
89
|
+
// share this path so both viewers stay feature-identical.
|
|
90
|
+
export function buildSessionViewFromData(data: SessionExport, opts: SessionViewOpts): { html: string } {
|
|
91
|
+
const { desc, collapseLines, limit, all = false } = opts;
|
|
78
92
|
const info = data.info;
|
|
79
|
-
const title = info.title || id;
|
|
93
|
+
const title = info.title || info.id;
|
|
80
94
|
const created = info.time.created ? relTimeMs(info.time.created) : "";
|
|
81
95
|
const updated = info.time.updated ? relTimeMs(info.time.updated) : "";
|
|
82
96
|
const total = data.messages.length;
|
|
@@ -227,6 +241,7 @@ export async function buildSessionView(client: OpencodeClientInterface, id: stri
|
|
|
227
241
|
<span>${data.messages.length} 条消息</span>
|
|
228
242
|
${stats.peak ? `<span>🧮 当前 ${kfmt(stats.current)} · 峰值 ${kfmt(stats.peak)}${stats.p90 ? ` · P90 ${kfmt(stats.p90)}` : ""}${stats.p50 ? ` · P50 ${kfmt(stats.p50)}` : ""}</span>` : ""}
|
|
229
243
|
${stats.calls ? `<span>📊 累计 ${kfmt(stats.traffic)} · cache ${stats.cacheHit}% · ${stats.calls} 调用</span>` : ""}
|
|
244
|
+
${info.version === "pi" ? `<span class="sd-badge" title="pi runtime session">🥧 pi</span>` : ""}
|
|
230
245
|
${acp ? `<span>🗜 压缩 ${acp.blocks} 段${acp.savedTokens ? ` · 省 ${kfmt(acp.savedTokens)}` : ""}</span>` : ""}
|
|
231
246
|
${bd && bd.total ? `<span>📂 ${ctxBreakdownStr(bd, stats.current, stats.overhead)}</span>` : ""}
|
|
232
247
|
</div>
|