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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.84",
3
+ "version": "0.10.86",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
package/src/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", "upstream_comment_id", "upstream_comment_id BIGINT DEFAULT NULL");
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
+ }
@@ -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>`
@@ -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)}
@@ -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;
@@ -41,6 +41,7 @@ function toView(c: CommentRow): CommentView {
41
41
  return {
42
42
  id: c.id,
43
43
  tag: classifyActor(c.body, c.author_kind),
44
+ model: c.model || undefined,
44
45
  login: c.author,
45
46
  avatar: "",
46
47
  created_at: c.created_at,
@@ -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>