ework-web 0.10.84 → 0.10.86
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/db.ts +5 -1
- package/src/giteaApi.ts +20 -0
- package/src/index.ts +10 -0
- package/src/opencode.ts +5 -2
- package/src/pi-sessions.ts +85 -0
- package/src/render/components.ts +2 -0
- package/src/render/layout.ts +1 -0
- package/src/schema-mysql.sql +2 -2
- package/src/schema.sql +2 -1
- package/src/store.ts +7 -0
- package/src/views/issueThread.ts +1 -0
- package/src/views/sessionLog.ts +1 -0
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -145,6 +145,9 @@ function migrateCommentsTable(db: Database): void {
|
|
|
145
145
|
if (!have.has("upstream_comment_id")) {
|
|
146
146
|
db.exec(applyPrefix("ALTER TABLE {{comments}} ADD COLUMN upstream_comment_id INTEGER"));
|
|
147
147
|
}
|
|
148
|
+
if (!have.has("model")) {
|
|
149
|
+
db.exec(applyPrefix("ALTER TABLE {{comments}} ADD COLUMN model TEXT NOT NULL DEFAULT ''"));
|
|
150
|
+
}
|
|
148
151
|
}
|
|
149
152
|
|
|
150
153
|
function migrateLabelsTable(db: Database): void {
|
|
@@ -389,7 +392,8 @@ async function migrateMysqlIssuesAiStatus(pool: Pool): Promise<void> {
|
|
|
389
392
|
await migrateMysqlColumn(pool, "issues", "ai_status", "ai_status VARCHAR(32) NOT NULL DEFAULT ''");
|
|
390
393
|
await migrateMysqlColumn(pool, "issues", "model", "model VARCHAR(128) NOT NULL DEFAULT ''");
|
|
391
394
|
await migrateMysqlColumn(pool, "issues", "upstream_issue_number", "upstream_issue_number INT DEFAULT NULL");
|
|
392
|
-
await migrateMysqlColumn(pool, "comments", "
|
|
395
|
+
await migrateMysqlColumn(pool, "comments", "model", "model VARCHAR(128) NOT NULL DEFAULT ''");
|
|
396
|
+
await migrateMysqlColumn(pool, "comments", "upstream_comment_id", "upstream_comment_id BIGINT DEFAULT NULL");
|
|
393
397
|
const indexes: Array<[string, string]> = [
|
|
394
398
|
["uq_issues_project_upstream", applyPrefix("CREATE UNIQUE INDEX uq_issues_project_upstream ON {{issues}} (project_id, upstream_issue_number)")],
|
|
395
399
|
["uq_comments_upstream", applyPrefix("CREATE UNIQUE INDEX uq_comments_upstream ON {{comments}} (upstream_comment_id)")],
|
package/src/giteaApi.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
ensureUser,
|
|
36
36
|
getUserByLogin,
|
|
37
37
|
type UserRow,
|
|
38
|
+
updateCommentModel,
|
|
38
39
|
} from "./store";
|
|
39
40
|
import {
|
|
40
41
|
buildUser,
|
|
@@ -59,6 +60,7 @@ const ROUTES = {
|
|
|
59
60
|
issueReactions: /^\/api\/v1\/repos\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/issues\/(\d+)\/reactions$/,
|
|
60
61
|
commentShow: /^\/api\/v1\/repos\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/issues\/comments\/(\d+)$/,
|
|
61
62
|
commentReactions: /^\/api\/v1\/repos\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/issues\/comments\/(\d+)\/reactions$/,
|
|
63
|
+
commentModel: /^\/api\/v1\/repos\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/issues\/comments\/(\d+)\/model$/,
|
|
62
64
|
} as const;
|
|
63
65
|
|
|
64
66
|
export interface GiteaApiResult {
|
|
@@ -335,6 +337,24 @@ export async function handleGiteaApi(
|
|
|
335
337
|
}
|
|
336
338
|
}
|
|
337
339
|
|
|
340
|
+
m = path.match(ROUTES.commentModel);
|
|
341
|
+
if (m) {
|
|
342
|
+
const [, owner, repo, cidStr] = m;
|
|
343
|
+
if (!(owner && repo && cidStr)) return giteaError(404, "not found");
|
|
344
|
+
const cid = Number(cidStr);
|
|
345
|
+
try {
|
|
346
|
+
const project = await getProject(owner, repo);
|
|
347
|
+
if (!project) return giteaError(404, "repository not found");
|
|
348
|
+
if (!(await canWriteProject(project.id, user))) return giteaError(403, "requires writer role");
|
|
349
|
+
const body = (await readJson(req).catch(() => ({}))) as { model?: unknown };
|
|
350
|
+
const model = typeof body.model === "string" ? body.model.trim() : "";
|
|
351
|
+
await updateCommentModel(cid, model);
|
|
352
|
+
return { status: 200, body: { ok: true, model } };
|
|
353
|
+
} catch (e) {
|
|
354
|
+
return giteaError(500, e instanceof Error ? e.message : String(e));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
338
358
|
m = path.match(ROUTES.commentReactions);
|
|
339
359
|
if (m) {
|
|
340
360
|
const [, owner, repo, cidStr] = m;
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildPiSessionPage } from "./pi-sessions";
|
|
1
2
|
import { join, dirname } from "path";
|
|
2
3
|
import { fileURLToPath } from "url";
|
|
3
4
|
import { readFileSync, appendFileSync, existsSync } from "fs";
|
|
@@ -724,6 +725,14 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
724
725
|
if (uidInfo) {
|
|
725
726
|
return html(errorPage("等待启动", "会话已创建,后端会话 ID 尚未生成(正在准备工作目录)。稍后刷新此页。"), 200);
|
|
726
727
|
}
|
|
728
|
+
const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
|
|
729
|
+
const piPage = buildPiSessionPage(rawSid, piLimit);
|
|
730
|
+
if (piPage) return html(piPage, 200);
|
|
731
|
+
} else {
|
|
732
|
+
// non-ses_ id that resolved to nothing: try the pi session file before 404
|
|
733
|
+
const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
|
|
734
|
+
const piPage = buildPiSessionPage(rawSid, piLimit);
|
|
735
|
+
if (piPage) return html(piPage, 200);
|
|
727
736
|
}
|
|
728
737
|
const desc = url.searchParams.get("asc") !== "1";
|
|
729
738
|
const all = url.searchParams.get("all") === "1";
|
|
@@ -1820,6 +1829,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1820
1829
|
view = {
|
|
1821
1830
|
id: c.id,
|
|
1822
1831
|
tag: classifyActor(c.body, c.author_kind),
|
|
1832
|
+
model: c.model ?? undefined,
|
|
1823
1833
|
login: c.author,
|
|
1824
1834
|
avatar: "",
|
|
1825
1835
|
created_at: c.created_at,
|
package/src/opencode.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface SessionListItem {
|
|
|
29
29
|
directory?: string;
|
|
30
30
|
peakTokens?: number;
|
|
31
31
|
msgCount?: number;
|
|
32
|
+
model?: string;
|
|
32
33
|
daemon?: { displayName: string; endpoint: string };
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -117,14 +118,15 @@ export class OpencodeClient {
|
|
|
117
118
|
const rows = db
|
|
118
119
|
.prepare(
|
|
119
120
|
"SELECT s.id AS id, s.title AS title, s.time_created AS created, s.time_updated AS updated, s.directory AS directory, " +
|
|
120
|
-
"m.peak AS peakTokens, m.calls AS msgCount " +
|
|
121
|
+
"m.peak AS peakTokens, m.calls AS msgCount, " +
|
|
122
|
+
"(SELECT json_extract(m2.data,'$.modelID') FROM message m2 WHERE m2.session_id = s.id AND json_extract(m2.data,'$.modelID') IS NOT NULL AND json_extract(m2.data,'$.modelID') != '' ORDER BY m2.time_created DESC LIMIT 1) AS model " +
|
|
121
123
|
"FROM session s LEFT JOIN (" +
|
|
122
124
|
"SELECT session_id, MAX(CAST(json_extract(data,'$.tokens.input') AS INT) + CAST(json_extract(data,'$.tokens.cache.read') AS INT) + CAST(json_extract(data,'$.tokens.cache.write') AS INT)) AS peak, " +
|
|
123
125
|
"COUNT(*) AS calls FROM message WHERE json_extract(data,'$.tokens.input') > 0 GROUP BY session_id" +
|
|
124
126
|
") m ON m.session_id = s.id " +
|
|
125
127
|
"WHERE s.time_archived IS NULL ORDER BY s.time_updated DESC LIMIT ?"
|
|
126
128
|
)
|
|
127
|
-
.all(limit) as Array<{ id: unknown; title: unknown; created: unknown; updated: unknown; directory: unknown; peakTokens: unknown; msgCount: unknown }>;
|
|
129
|
+
.all(limit) as Array<{ id: unknown; title: unknown; created: unknown; updated: unknown; directory: unknown; peakTokens: unknown; msgCount: unknown; model: unknown }>;
|
|
128
130
|
return rows
|
|
129
131
|
.map((r) => {
|
|
130
132
|
const id = typeof r.id === "string" ? r.id : "";
|
|
@@ -137,6 +139,7 @@ export class OpencodeClient {
|
|
|
137
139
|
directory: typeof r.directory === "string" ? r.directory : undefined,
|
|
138
140
|
peakTokens: typeof r.peakTokens === "number" && r.peakTokens > 0 ? r.peakTokens : undefined,
|
|
139
141
|
msgCount: typeof r.msgCount === "number" && r.msgCount > 0 ? r.msgCount : undefined,
|
|
142
|
+
model: typeof r.model === "string" && r.model ? r.model : undefined,
|
|
140
143
|
} as SessionListItem;
|
|
141
144
|
})
|
|
142
145
|
.filter((x): x is SessionListItem => x !== null);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { THEME_CSS, escapeHtml } from "./render/layout";
|
|
5
|
+
|
|
6
|
+
interface PiEvent {
|
|
7
|
+
type?: string;
|
|
8
|
+
timestamp?: string;
|
|
9
|
+
message?: {
|
|
10
|
+
role?: string;
|
|
11
|
+
content?: Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function findPiSessionFile(sessionId: string): string | null {
|
|
16
|
+
if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return null;
|
|
17
|
+
const root = process.env.PI_CODING_AGENT_SESSION_DIR ?? join(homedir(), ".pi/agent/sessions");
|
|
18
|
+
if (!existsSync(root)) return null;
|
|
19
|
+
for (const dir of readdirSync(root, { withFileTypes: true })) {
|
|
20
|
+
if (!dir.isDirectory()) continue;
|
|
21
|
+
const full = join(root, dir.name);
|
|
22
|
+
try {
|
|
23
|
+
const hit = readdirSync(full).find((f) => f.endsWith(`_${sessionId}.jsonl`));
|
|
24
|
+
if (hit) return join(full, hit);
|
|
25
|
+
} catch {
|
|
26
|
+
// unreadable project dir — skip
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildPiSessionPage(sessionId: string, limit = 200): string | null {
|
|
33
|
+
const file = findPiSessionFile(sessionId);
|
|
34
|
+
if (!file) return null;
|
|
35
|
+
|
|
36
|
+
const entries: { ts: string; role: string; text: string }[] = [];
|
|
37
|
+
const raw = readFileSync(file, "utf8");
|
|
38
|
+
for (const line of raw.split("\n")) {
|
|
39
|
+
if (!line.trim()) continue;
|
|
40
|
+
let ev: PiEvent;
|
|
41
|
+
try {
|
|
42
|
+
ev = JSON.parse(line) as PiEvent;
|
|
43
|
+
} catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ev.type !== "message" || !ev.message?.role) continue;
|
|
47
|
+
const role = ev.message.role;
|
|
48
|
+
const parts = ev.message.content ?? [];
|
|
49
|
+
let text = "";
|
|
50
|
+
for (const p of parts) {
|
|
51
|
+
if (p.type === "text" && p.text) text += p.text + "\n";
|
|
52
|
+
else if (p.type === "toolCall") text += `→ [tool] ${p.name ?? "?"}\n`;
|
|
53
|
+
}
|
|
54
|
+
if (role === "toolResult") text = text || "[tool output]\n";
|
|
55
|
+
if (!text.trim()) continue;
|
|
56
|
+
entries.push({ ts: (ev.timestamp ?? "").slice(11, 19), role, text: text.trim() });
|
|
57
|
+
}
|
|
58
|
+
const tail = entries.slice(-limit);
|
|
59
|
+
const rows = tail
|
|
60
|
+
.map(
|
|
61
|
+
(e) =>
|
|
62
|
+
`<div class="pi-row pi-${escapeHtml(e.role)}"><span class="pi-ts">${escapeHtml(e.ts)}</span><span class="pi-role">${escapeHtml(e.role)}</span><pre>${escapeHtml(e.text.slice(0, 4000))}</pre></div>`,
|
|
63
|
+
)
|
|
64
|
+
.join("\n");
|
|
65
|
+
const shown = tail.length;
|
|
66
|
+
const total = entries.length;
|
|
67
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
68
|
+
<title>pi session ${escapeHtml(sessionId)}</title>
|
|
69
|
+
<style>${THEME_CSS}
|
|
70
|
+
.pi-wrap{max-width:980px;margin:0 auto;padding:.6rem 1rem 3rem}
|
|
71
|
+
.pi-row{border:1px solid var(--border);border-radius:8px;padding:.5rem .7rem;margin:.45rem 0;background:var(--bg-elev)}
|
|
72
|
+
.pi-row pre{margin:.3rem 0 0;white-space:pre-wrap;word-break:break-word;font:12px/1.5 ui-monospace,monospace}
|
|
73
|
+
.pi-ts{color:var(--text-muted);font-size:11px;margin-right:.6rem}
|
|
74
|
+
.pi-role{font-size:11px;font-weight:600;color:var(--accent)}
|
|
75
|
+
.pi-assistant{border-left:3px solid var(--accent)}
|
|
76
|
+
.pi-user{border-left:3px solid var(--green)}
|
|
77
|
+
.pi-toolResult{border-left:3px solid var(--border);opacity:.85}
|
|
78
|
+
</style></head><body>
|
|
79
|
+
<header class="topbar"><a href="/" title="ework 主页" style="color:var(--header-text)">🏠</a></header>
|
|
80
|
+
<div class="pi-wrap">
|
|
81
|
+
<h2>pi 会话 <code>${escapeHtml(sessionId)}</code></h2>
|
|
82
|
+
<p style="color:var(--text-muted);font-size:12px">显示最近 ${shown} / 共 ${total} 条消息事件 · <a href="?limit=2000">加载更多</a></p>
|
|
83
|
+
${rows || '<p style="color:var(--text-muted)">(会话文件为空)</p>'}
|
|
84
|
+
</div></body></html>`;
|
|
85
|
+
}
|
package/src/render/components.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface CommentView {
|
|
|
21
21
|
body_html: string;
|
|
22
22
|
display_name?: string | null;
|
|
23
23
|
reactions?: { e: string; n: number }[];
|
|
24
|
+
model?: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const TAG_LABEL: Record<ActorTag, string> = { human: "👤", bot: "🤖", system: "⚙️" };
|
|
@@ -63,6 +64,7 @@ export function renderCommentCard(c: CommentView, cfg?: { translateUrl?: string;
|
|
|
63
64
|
`<span class="who">${esc(c.display_name || c.login)}</span>` +
|
|
64
65
|
(c.display_name ? `<span class="who-login">${esc(c.login)}</span>` : "") +
|
|
65
66
|
`<span class="when" data-ts="${esc(c.created_at)}" title="${esc(c.created_at)}">${relTime(c.created_at)}</span>` +
|
|
67
|
+
(c.model ? `<span class="cmodel" title="生成模型">${esc(c.model)}</span>` : "") +
|
|
66
68
|
rx +
|
|
67
69
|
`<span class="card-actions">` + actionBarHTML({ cid: String(c.id), copy: true, link: true, translate: true, tts: true, translateEnabled, ttsEnabled }) + `</span>` +
|
|
68
70
|
`</div><div class="card-b">${c.body_html}</div></div></div>`
|
package/src/render/layout.ts
CHANGED
|
@@ -64,6 +64,7 @@ header.topbar .num{opacity:.7}
|
|
|
64
64
|
.card-h{display:flex;align-items:center;gap:.5rem;padding:.4rem .7rem;background:var(--bg-muted);font-size:13px;flex-wrap:wrap}
|
|
65
65
|
.card-h .who{font-weight:600;color:var(--text)}
|
|
66
66
|
.card-h .who-login{font-weight:400;color:var(--text-muted);font-size:.85em;margin-left:.25rem}
|
|
67
|
+
.card-h .cmodel{background:var(--bg-elev);border:1px solid var(--border);border-radius:4px;padding:0 .3rem;font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-muted)}
|
|
67
68
|
.tag{font-size:11px;font-weight:600;padding:.05rem .4rem;border-radius:4px;line-height:1.5}
|
|
68
69
|
.tag-human{background:color-mix(in srgb,var(--human) 18%,transparent);color:var(--human)}
|
|
69
70
|
.tag-bot{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
|
package/src/schema-mysql.sql
CHANGED
|
@@ -77,8 +77,8 @@ CREATE TABLE IF NOT EXISTS {{comments}} (
|
|
|
77
77
|
upstream_comment_id BIGINT DEFAULT NULL,
|
|
78
78
|
UNIQUE uq_comments_upstream (upstream_comment_id),
|
|
79
79
|
CONSTRAINT {{fk_comments_issue}} FOREIGN KEY (issue_id) REFERENCES {{issues}}(id) ON DELETE CASCADE,
|
|
80
|
-
CONSTRAINT {{fk_comments_author}} FOREIGN KEY (author) REFERENCES {{users}}(login)
|
|
81
|
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
80
|
+
CONSTRAINT {{fk_comments_author}} FOREIGN KEY (author) REFERENCES {{users}}(login),
|
|
81
|
+
model VARCHAR(128) NOT NULL DEFAULT '') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
82
82
|
CREATE INDEX comments_issue_created ON {{comments}} (issue_id, created_at);
|
|
83
83
|
CREATE INDEX comments_author ON {{comments}} (author);
|
|
84
84
|
|
package/src/schema.sql
CHANGED
|
@@ -85,7 +85,8 @@ CREATE TABLE IF NOT EXISTS {{comments}} (
|
|
|
85
85
|
body TEXT NOT NULL,
|
|
86
86
|
created_at TEXT NOT NULL,
|
|
87
87
|
updated_at TEXT NOT NULL DEFAULT '',
|
|
88
|
-
upstream_comment_id INTEGER
|
|
88
|
+
upstream_comment_id INTEGER,
|
|
89
|
+
model TEXT NOT NULL DEFAULT ''
|
|
89
90
|
);
|
|
90
91
|
CREATE INDEX IF NOT EXISTS comments_issue_created
|
|
91
92
|
ON {{comments}} (issue_id, created_at);
|
package/src/store.ts
CHANGED
|
@@ -92,6 +92,7 @@ export interface CommentRow {
|
|
|
92
92
|
updated_at: string;
|
|
93
93
|
author_kind?: UserKind;
|
|
94
94
|
author_display_name?: string | null;
|
|
95
|
+
model?: string;
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
export interface LabelRow {
|
|
@@ -625,6 +626,12 @@ export async function getCommentByUpstreamId(upstreamCommentId: number): Promise
|
|
|
625
626
|
);
|
|
626
627
|
}
|
|
627
628
|
|
|
629
|
+
export async function updateCommentModel(commentId: number, model: string): Promise<void> {
|
|
630
|
+
await getDB().run(
|
|
631
|
+
"UPDATE {{comments}} SET model = ? WHERE id = ?",
|
|
632
|
+
[model.slice(0, 128), commentId],
|
|
633
|
+
);
|
|
634
|
+
}
|
|
628
635
|
export interface IssuePatch {
|
|
629
636
|
title?: string;
|
|
630
637
|
body?: string;
|
package/src/views/issueThread.ts
CHANGED
package/src/views/sessionLog.ts
CHANGED
|
@@ -65,6 +65,7 @@ function sessionRow(s: SessionListItem): string {
|
|
|
65
65
|
s.peakTokens ? `<span>🧮 峰值 ${kfmt(s.peakTokens)}</span>` : "",
|
|
66
66
|
s.msgCount ? `<span>💬 ${s.msgCount}</span>` : "",
|
|
67
67
|
s.daemon ? `<span class="sd-badge" title="${escapeAttr(s.daemon.endpoint)}">🖥️ ${escapeHtml(s.daemon.displayName)} ${escapeHtml(s.daemon.endpoint)}</span>` : "",
|
|
68
|
+
s.model ? `<span class="sd-badge" title="最近使用的模型">🧠 ${escapeHtml(s.model)}</span>` : "",
|
|
68
69
|
].join("");
|
|
69
70
|
return `<a class="srow" href="${escapeAttr(href)}">
|
|
70
71
|
<div class="st">${escapeHtml(s.title)}</div>
|