ework-web 0.10.86 → 0.10.88

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.86",
3
+ "version": "0.10.88",
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
@@ -134,6 +134,9 @@ function migrateIssuesTable(db: Database): void {
134
134
  if (!have.has("model")) {
135
135
  db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN model TEXT NOT NULL DEFAULT ''"));
136
136
  }
137
+ if (!have.has("runtime")) {
138
+ db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN runtime TEXT NOT NULL DEFAULT ''"));
139
+ }
137
140
  if (!have.has("upstream_issue_number")) {
138
141
  db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN upstream_issue_number INTEGER"));
139
142
  }
@@ -391,6 +394,7 @@ async function migrateMysqlColumn(pool: Pool, table: string, column: string, ddl
391
394
  async function migrateMysqlIssuesAiStatus(pool: Pool): Promise<void> {
392
395
  await migrateMysqlColumn(pool, "issues", "ai_status", "ai_status VARCHAR(32) NOT NULL DEFAULT ''");
393
396
  await migrateMysqlColumn(pool, "issues", "model", "model VARCHAR(128) NOT NULL DEFAULT ''");
397
+ await migrateMysqlColumn(pool, "issues", "runtime", "runtime VARCHAR(32) NOT NULL DEFAULT ''");
394
398
  await migrateMysqlColumn(pool, "issues", "upstream_issue_number", "upstream_issue_number INT DEFAULT NULL");
395
399
  await migrateMysqlColumn(pool, "comments", "model", "model VARCHAR(128) NOT NULL DEFAULT ''");
396
400
  await migrateMysqlColumn(pool, "comments", "upstream_comment_id", "upstream_comment_id BIGINT DEFAULT NULL");
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  setIssueState,
41
41
  updateIssueAiStatus,
42
42
  updateIssueModel,
43
+ updateIssueRuntime,
43
44
  listIssues,
44
45
  createAttachment,
45
46
  getAttachment,
@@ -398,6 +399,7 @@ const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
398
399
  const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
399
400
  const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume|dispatch-off|dispatch-on)$/;
400
401
  const REPO_ISSUE_MODEL_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/model$/;
402
+ const REPO_ISSUE_RUNTIME_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/runtime$/;
401
403
  const REPO_ISSUE_STATUS_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/ai-status$/;
402
404
  const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
403
405
  const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
@@ -726,12 +728,14 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
726
728
  return html(errorPage("等待启动", "会话已创建,后端会话 ID 尚未生成(正在准备工作目录)。稍后刷新此页。"), 200);
727
729
  }
728
730
  const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
729
- const piPage = buildPiSessionPage(rawSid, piLimit);
731
+ const piAsc = url.searchParams.get("asc") === "1";
732
+ const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
730
733
  if (piPage) return html(piPage, 200);
731
734
  } else {
732
735
  // non-ses_ id that resolved to nothing: try the pi session file before 404
733
736
  const piLimit = Math.min(5000, Math.max(10, Number(url.searchParams.get("limit")) || 200));
734
- const piPage = buildPiSessionPage(rawSid, piLimit);
737
+ const piAsc = url.searchParams.get("asc") === "1";
738
+ const piPage = buildPiSessionPage(rawSid, piLimit, piAsc);
735
739
  if (piPage) return html(piPage, 200);
736
740
  }
737
741
  const desc = url.searchParams.get("asc") !== "1";
@@ -1901,6 +1905,28 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1901
1905
  }
1902
1906
  }
1903
1907
 
1908
+ const rt = url.pathname.match(REPO_ISSUE_RUNTIME_RE);
1909
+ if (rt) {
1910
+ const [, owner, repo, numStr] = rt;
1911
+ if (!(owner && repo && numStr)) return json({ error: "bad path" }, 400);
1912
+ const number = Number(numStr);
1913
+ try {
1914
+ const project = await getProject(owner, repo);
1915
+ if (!project) return json({ error: "project not found" }, 404);
1916
+ const issue = await getIssueWithMeta(project.id, number);
1917
+ if (!issue) return json({ error: "issue not found" }, 404);
1918
+ if (!(await canWriteProject(project.id, ctx.user))) {
1919
+ return json({ error: "writer role required" }, 403);
1920
+ }
1921
+ const form = await req.formData().catch(() => new FormData());
1922
+ const runtime = String(form.get("runtime") ?? "").trim().slice(0, 32);
1923
+ await updateIssueRuntime(issue.id, runtime);
1924
+ return json({ ok: true, runtime: runtime === "pi" || runtime === "opencode" ? runtime : "" });
1925
+ } catch (e) {
1926
+ return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
1927
+ }
1928
+ }
1929
+
1904
1930
  const ci = url.pathname.match(REPO_LIST_RE);
1905
1931
  if (ci) {
1906
1932
  const [, owner, repo] = ci;
@@ -1924,7 +1950,11 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1924
1950
  await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1925
1951
  await autoWireDaemon(project.id, url.origin);
1926
1952
  }
1927
- const issue = await createIssue(project.id, title, body, ctx.user!.login, model ? { model } : {});
1953
+ const formRuntime = String(form.get("runtime") ?? "").trim();
1954
+ const issueOpts: { model?: string; runtime?: string } = {};
1955
+ if (model) issueOpts.model = model;
1956
+ if (formRuntime === "pi" || formRuntime === "opencode") issueOpts.runtime = formRuntime;
1957
+ const issue = await createIssue(project.id, title, body, ctx.user!.login, issueOpts);
1928
1958
  void emitIssueEvent(project.id, issue.id, "opened", url.origin);
1929
1959
  return Response.redirect(
1930
1960
  `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issue.number}`,
@@ -1,85 +1,247 @@
1
- import { readdirSync, readFileSync, existsSync } from "fs";
2
- import { join } from "path";
1
+ import { readFileSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import { existsSync } from "fs";
3
4
  import { homedir } from "os";
4
- import { THEME_CSS, escapeHtml } from "./render/layout";
5
+ import { THEME_CSS, escapeHtml, escapeAttr } from "./render/layout";
6
+ import { renderMarkdown } from "./render/markdown";
5
7
 
6
8
  interface PiEvent {
7
9
  type?: string;
8
10
  timestamp?: string;
9
11
  message?: {
10
12
  role?: string;
11
- content?: Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
13
+ content?: Array<{ type?: string; text?: string; thinking?: string; name?: string; arguments?: unknown; toolCallId?: string; output?: unknown }>;
14
+ usage?: { input?: number; output?: number; cacheRead?: number; totalTokens?: number };
15
+ model?: string;
12
16
  };
17
+ cwd?: string;
13
18
  }
14
19
 
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");
20
+ interface PiToolCall {
21
+ id: string;
22
+ name: string;
23
+ args: string;
24
+ result?: string;
25
+ }
26
+
27
+ interface PiCard {
28
+ ts: string;
29
+ role: "user" | "assistant";
30
+ model?: string;
31
+ textParts: string[];
32
+ reasoning: string[];
33
+ tools: PiToolCall[];
34
+ tok?: string;
35
+ }
36
+
37
+ function sessionsRoot(): string {
38
+ return process.env.PI_CODING_AGENT_SESSION_DIR
39
+ ?? join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "sessions");
40
+ }
41
+
42
+ function findPiSessionFile(sessionId: string): string | null {
43
+ if (!/^[0-9a-f-]{16,64}$/i.test(sessionId)) return null;
44
+ const root = sessionsRoot();
18
45
  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
46
+ const { readdirSync, statSync } = require("fs") as typeof import("fs");
47
+ const out: string[] = [];
48
+ const walk = (dir: string, depth: number) => {
49
+ let entries: string[] = [];
50
+ try { entries = readdirSync(dir); } catch { return; }
51
+ for (const e of entries) {
52
+ const full = join(dir, e);
53
+ let st;
54
+ try { st = statSync(full); } catch { continue; }
55
+ if (st.isDirectory() && depth < 3) walk(full, depth + 1);
56
+ else if (st.isFile() && e.includes(sessionId) && e.endsWith(".jsonl")) out.push(full);
27
57
  }
58
+ };
59
+ walk(root, 0);
60
+ out.sort();
61
+ return out[out.length - 1] ?? null;
62
+ }
63
+
64
+ function fmtTs(ts: string): string {
65
+ return ts ? ts.replace("T", " ").replace(/\.\d+Z$/, "Z").slice(5, 19) : "";
66
+ }
67
+
68
+ function fmtNum(n: number): string {
69
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
70
+ if (n >= 1000) return (n / 1000).toFixed(1) + "k";
71
+ return String(n);
72
+ }
73
+
74
+ function resultText(output: unknown): string {
75
+ if (output == null) return "";
76
+ if (typeof output === "string") return output.slice(0, 4000);
77
+ try { return JSON.stringify(output).slice(0, 4000); } catch { return String(output); }
78
+ }
79
+
80
+ function toolCard(t: PiToolCall): string {
81
+ const argStr = t.args.length > 600 ? t.args.slice(0, 600) + "…" : t.args;
82
+ return `<details class="pi-tool"><summary>🔧 ${escapeHtml(t.name)}<span class="pi-tool-args">${escapeHtml(argStr)}</span></summary><div class="tool-io"><pre>${escapeHtml(t.result ? resultText(t.result).slice(0, 4000) : "(无返回)")}</pre></div></details>`;
83
+ }
84
+
85
+ function renderCard(c: PiCard): string {
86
+ const isUser = c.role === "user";
87
+ const parts: string[] = [];
88
+ for (const r of c.reasoning) {
89
+ if (!r.trim()) continue;
90
+ parts.push(`<details class="reasoning"><summary>💭 Reasoning</summary><div class="tool-io" data-md="${escapeAttr(r.slice(0, 8000))}">${renderMarkdown(r.slice(0, 8000))}</div></details>`);
91
+ }
92
+ for (const t of c.tools) parts.push(toolCard(t));
93
+ for (const tx of c.textParts) {
94
+ if (!tx.trim()) continue;
95
+ parts.push(`<div data-md="${escapeAttr(tx.slice(0, 20000))}">${renderMarkdown(tx.slice(0, 20000))}</div>`);
28
96
  }
29
- return null;
97
+ return `<div class="msg ${isUser ? "msg-u" : "msg-a"}">
98
+ <div class="mh">
99
+ <span class="role ${isUser ? "role-u" : "role-a"}">${isUser ? "👤 User" : "🤖 Assistant"}</span>
100
+ ${c.model ? `<span class="model">${escapeHtml(c.model)}</span>` : ""}
101
+ ${c.tok ? `<span class="tok">${c.tok}</span>` : ""}
102
+ <span class="when">${escapeHtml(fmtTs(c.ts))}</span>
103
+ </div>
104
+ <div class="mb">${parts.join("") || "<p><em>(无文本输出)</em></p>"}</div>
105
+ </div>`;
30
106
  }
31
107
 
32
- export function buildPiSessionPage(sessionId: string, limit = 200): string | null {
108
+ export function buildPiSessionPage(sessionId: string, limit: number, asc = false): string | null {
33
109
  const file = findPiSessionFile(sessionId);
34
110
  if (!file) return null;
35
-
36
- const entries: { ts: string; role: string; text: string }[] = [];
37
111
  const raw = readFileSync(file, "utf8");
112
+ const events: PiEvent[] = [];
38
113
  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;
114
+ const l = line.trim();
115
+ if (!l) continue;
116
+ try { events.push(JSON.parse(l) as PiEvent); } catch { /* skip torn tail line */ }
117
+ }
118
+
119
+ const cards: PiCard[] = [];
120
+ const openTools = new Map<string, PiToolCall>();
121
+ let inputTok = 0, outputTok = 0, cacheTok = 0, peakTok = 0;
122
+ let cwd = "";
123
+ let first = "", last = "";
124
+ for (const e of events) {
125
+ if (e.type === "session" && e.cwd) cwd = e.cwd;
126
+ if (e.timestamp) {
127
+ if (!first) first = e.timestamp;
128
+ last = e.timestamp;
45
129
  }
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`;
130
+ if (e.type !== "message" || !e.message) continue;
131
+ const m = e.message;
132
+ const u = m.usage;
133
+ if (u) {
134
+ inputTok += u.input ?? 0;
135
+ outputTok += u.output ?? 0;
136
+ cacheTok += u.cacheRead ?? 0;
137
+ peakTok = Math.max(peakTok, u.totalTokens ?? 0);
138
+ }
139
+ if (m.role === "user") {
140
+ const text = (m.content ?? []).filter(p => p.type === "text").map(p => p.text ?? "").join("\n");
141
+ cards.push({ ts: e.timestamp ?? "", role: "user", textParts: text ? [text] : [], reasoning: [], tools: [] });
142
+ } else if (m.role === "assistant") {
143
+ const card: PiCard = { ts: e.timestamp ?? "", role: "assistant", model: m.model, textParts: [], reasoning: [], tools: [], tok: u?.output ? `↗ ${fmtNum(u.output)}` : undefined };
144
+ for (const p of m.content ?? []) {
145
+ if (p.type === "thinking" && p.thinking) card.reasoning.push(p.thinking);
146
+ else if (p.type === "text" && p.text) card.textParts.push(p.text);
147
+ else if (p.type === "toolCall") {
148
+ const tc: PiToolCall = {
149
+ id: p.toolCallId ?? `t${openTools.size}`,
150
+ name: p.name ?? "?",
151
+ args: (() => { try { return typeof p.arguments === "string" ? p.arguments : JSON.stringify(p.arguments ?? {}); } catch { return "{}"; } })(),
152
+ };
153
+ card.tools.push(tc);
154
+ openTools.set(tc.id, tc);
155
+ }
156
+ }
157
+ cards.push(card);
158
+ } else {
159
+ for (const p of m.content ?? []) {
160
+ if (p.type === "toolResult") {
161
+ const key = p.toolCallId ?? [...openTools.keys()].pop();
162
+ const tc = key ? openTools.get(key) : undefined;
163
+ if (tc) tc.result = resultText(p.output);
164
+ }
165
+ }
53
166
  }
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
167
  }
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>
168
+
169
+ const total = cards.length;
170
+ const shown = asc ? cards.slice(Math.max(0, total - limit)) : cards.slice(Math.max(0, total - limit)).reverse();
171
+ const truncated = total > limit;
172
+ const title = `pi · ${sessionId.slice(0, 13)}…`;
173
+
174
+ const qs = (over: Record<string, string>) => {
175
+ const params = new URLSearchParams(over);
176
+ return `?${params.toString()}`;
177
+ };
178
+ const moreHref = qs({ limit: String(Math.min(5000, limit * 3)) });
179
+ const allHref = qs({ limit: "5000" });
180
+ const ordBtn = (mode: "asc" | "desc") =>
181
+ `<a class="ord-btn ${asc === (mode === "asc") ? "active" : ""}" href="${qs({ limit: String(limit), asc: mode === "asc" ? "1" : "0" })}">${mode === "asc" ? "正序" : "倒序"}</a>`;
182
+
183
+ const body = shown.map(renderCard).join("");
184
+ const moreBar = truncated
185
+ ? `<div class="more-bar">共 ${total} 条,当前显示最新 ${limit} 条 · <a href="${moreHref}">加载更多</a> · <a href="${allHref}">查看全部</a></div>`
186
+ : "";
187
+
188
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
189
+ <title>${escapeHtml(title)}</title>
69
190
  <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}
191
+ .meta-bar{max-width:900px;margin:0 auto;padding:.8rem 1rem .2rem}
192
+ .meta-bar h1{font-size:17px;margin:0 0 .35rem;overflow-wrap:anywhere}
193
+ .meta-status{display:flex;gap:.6rem;flex-wrap:wrap;font-size:12px;color:var(--text-muted)}
194
+ .meta-status .sid{font-family:ui-monospace,monospace}
195
+ .mbar{max-width:900px;margin:.6rem auto;padding:0 1rem;display:flex;gap:.6rem;align-items:center;flex-wrap:wrap}
196
+ .mbar .note{font-size:12px;color:var(--text-muted)}
197
+ .ord{margin-left:auto;display:flex;gap:.3rem}
198
+ .ord-btn{padding:.25rem .7rem;border-radius:6px;border:1px solid var(--border);font-size:12px;color:var(--text-muted);text-decoration:none}
199
+ .ord-btn.active{background:var(--bg-muted);color:var(--text);font-weight:600}
200
+ #mlist{max-width:900px;margin:0 auto;padding:.5rem 1rem 3rem}
201
+ .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}
202
+ .msg.msg-u{border-left-color:var(--human);background:color-mix(in srgb,var(--human) 4%,var(--bg-elev))}
203
+ .msg.msg-a{border-left-color:var(--bot);background:color-mix(in srgb,var(--bot) 4%,var(--bg-elev))}
204
+ .mh{display:flex;align-items:center;gap:.5rem;font-size:13px;flex-wrap:wrap;margin-bottom:.35rem}
205
+ .mh .role{font-weight:600;font-size:11px;padding:.05rem .45rem;border-radius:4px;line-height:1.7}
206
+ .mh .role-u{background:color-mix(in srgb,var(--human) 18%,transparent);color:var(--human)}
207
+ .mh .role-a{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
208
+ .mh .model{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-muted)}
209
+ .mh .when{color:var(--text-muted);font-size:12px;margin-left:auto}
210
+ .mh .tok{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--text-muted)}
211
+ .mb{overflow-wrap:anywhere;word-break:break-word;line-height:1.55}
212
+ .mb p{margin:.4rem 0}
213
+ .mb pre{background:var(--code-bg);padding:.6rem;border-radius:6px;overflow:auto;font-size:12.5px}
214
+ .mb code{background:var(--code-bg);padding:.1em .35em;border-radius:4px;font-size:12.5px}
215
+ .mb details{margin:.3rem 0;border:1px solid var(--border);border-radius:6px;overflow:hidden}
216
+ .mb summary{cursor:pointer;padding:.4rem .6rem;background:var(--bg-muted);font-size:13px;display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}
217
+ .mb details.reasoning{border-left:3px solid color-mix(in srgb,var(--system) 45%,transparent)}
218
+ .mb details.reasoning>.tool-io{background:color-mix(in srgb,var(--system) 7%,var(--bg-elev))}
219
+ .mb .tool-io{position:relative;padding:.5rem .6rem}
220
+ .mb .tool-io pre{margin:.2rem 0}
221
+ .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%}
222
+ .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}
223
+ .more-bar a{font-weight:600}
78
224
  </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>`;
225
+ <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">
226
+ <a href="/" style="color:var(--header-text)">🏠</a><span style="opacity:.5">/</span>
227
+ <a href="/sessions" style="color:var(--header-text)">会话</a><span style="opacity:.5">/</span>
228
+ <span style="opacity:.85">${escapeHtml(title)}</span>
229
+ </header>
230
+ <div class="meta-bar">
231
+ <h1>${escapeHtml(title)}</h1>
232
+ <div class="meta-status">
233
+ <span class="sid">${escapeHtml(sessionId)}</span>
234
+ ${cwd ? `<span>📁 ${escapeHtml(dirname(cwd))}</span>` : ""}
235
+ ${first ? `<span>创建 ${escapeHtml(fmtTs(first))}</span>` : ""}
236
+ ${last ? `<span>更新 ${escapeHtml(fmtTs(last))}</span>` : ""}
237
+ <span>${total} 条消息</span>
238
+ <span>🧮 峰值 ${fmtNum(peakTok)} · 累计 ↗ ${fmtNum(outputTok)} · cache ${fmtNum(cacheTok)}</span>
239
+ </div>
240
+ </div>
241
+ <div class="mbar">
242
+ <span class="note">📖 只读 · pi 运行时会话</span>
243
+ <span class="ord">${ordBtn("asc")}${ordBtn("desc")}</span>
244
+ </div>
245
+ <main id="mlist">${body || `<div style="padding:2rem;text-align:center;color:var(--text-muted)">此会话没有消息</div>`}${moreBar}</main>
246
+ </body></html>`;
85
247
  }
@@ -24,6 +24,7 @@ export interface LayoutProps {
24
24
  customActions?: IssueAction[];
25
25
  extraStatusBadges?: Record<string, { cls: string; label: string }>;
26
26
  modelSelect?: { current: string; options: { id: string; label: string }[] } | null;
27
+ runtimeSelect?: { current: string } | null;
27
28
  }
28
29
 
29
30
  export const THEME_CSS = `
@@ -206,6 +207,13 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
206
207
  ? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 恢复接单</button>`
207
208
  : `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="关闭此 issue 的自动接单">🔕 暂停接单</button>`
208
209
  : "";
210
+ const runtimeSelectHtml = props.runtimeSelect && showActions
211
+ ? `<span class="model-select-wrap"><select class="model-select" id="issueRuntimeSelect" title="此 issue 的运行时(新会话生效)">
212
+ <option value="" ${props.runtimeSelect.current === "" ? "selected" : ""}>默认运行时</option>
213
+ <option value="opencode" ${props.runtimeSelect.current === "opencode" ? "selected" : ""}>opencode</option>
214
+ <option value="pi" ${props.runtimeSelect.current === "pi" ? "selected" : ""}>pi</option>
215
+ </select><button type="button" class="action-btn model-save-btn" id="issueRuntimeSave" title="保存运行时选择">💾</button></span>`
216
+ : "";
209
217
  const modelSelectHtml = props.modelSelect && props.modelSelect.options.length > 0 && showActions
210
218
  ? `<span class="model-select-wrap"><select class="model-select" id="issueModelSelect" title="此 issue 的模型(覆盖项目/全局默认)">
211
219
  <option value="">默认模型</option>
@@ -234,7 +242,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
234
242
  <div class="meta-status">
235
243
  <span class="state-badge ${stateClass}">${stateLabel}</span>
236
244
  ${aiBadgeHtml}
237
- ${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${modelSelectHtml}${(props.customActions ?? []).map((a) => {
245
+ ${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${runtimeSelectHtml}${modelSelectHtml}${(props.customActions ?? []).map((a) => {
238
246
  const attrs = [`data-action-href="${escapeAttr(a.href)}"`];
239
247
  if (a.method && a.method !== "POST") attrs.push(`data-action-method="${escapeAttr(a.method)}"`);
240
248
  if (a.confirm) attrs.push(`data-action-confirm="${escapeAttr(a.confirm)}"`);
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
55
55
  closed_at VARCHAR(40) DEFAULT NULL,
56
56
  ai_status VARCHAR(32) NOT NULL DEFAULT '',
57
57
  model VARCHAR(128) NOT NULL DEFAULT '',
58
+ runtime VARCHAR(32) NOT NULL DEFAULT '',
58
59
  upstream_issue_number INT DEFAULT NULL,
59
60
  UNIQUE (project_id, number),
60
61
  UNIQUE uq_issues_project_upstream (project_id, upstream_issue_number),
@@ -78,7 +79,7 @@ CREATE TABLE IF NOT EXISTS {{comments}} (
78
79
  UNIQUE uq_comments_upstream (upstream_comment_id),
79
80
  CONSTRAINT {{fk_comments_issue}} FOREIGN KEY (issue_id) REFERENCES {{issues}}(id) ON DELETE CASCADE,
80
81
  CONSTRAINT {{fk_comments_author}} FOREIGN KEY (author) REFERENCES {{users}}(login),
81
- model VARCHAR(128) NOT NULL DEFAULT '') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
82
+ model VARCHAR(128) NOT NULL DEFAULT '',
82
83
  CREATE INDEX comments_issue_created ON {{comments}} (issue_id, created_at);
83
84
  CREATE INDEX comments_author ON {{comments}} (author);
84
85
 
package/src/schema.sql CHANGED
@@ -67,6 +67,8 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
67
67
  ai_status TEXT NOT NULL DEFAULT '',
68
68
  -- Resolved "provider/model" for this issue. Empty = inherit project/global default.
69
69
  model TEXT NOT NULL DEFAULT '',
70
+ -- Runtime backend pinned for this issue ('' = daemon default, 'opencode', 'pi').
71
+ runtime TEXT NOT NULL DEFAULT '',
70
72
  -- Upstream Gitea issue number this row was imported from (NULL = native).
71
73
  upstream_issue_number INTEGER,
72
74
  UNIQUE (project_id, number)
@@ -86,7 +88,8 @@ CREATE TABLE IF NOT EXISTS {{comments}} (
86
88
  created_at TEXT NOT NULL,
87
89
  updated_at TEXT NOT NULL DEFAULT '',
88
90
  upstream_comment_id INTEGER,
89
- model TEXT NOT NULL DEFAULT ''
91
+ model TEXT NOT NULL DEFAULT '',
92
+ runtime TEXT NOT NULL DEFAULT ''
90
93
  );
91
94
  CREATE INDEX IF NOT EXISTS comments_issue_created
92
95
  ON {{comments}} (issue_id, created_at);
@@ -115,4 +115,37 @@
115
115
  document.addEventListener("change", function (e) {
116
116
  if (e.target && e.target.id === "issueModelSelect") saveModel(document.getElementById("issueModelSave"));
117
117
  });
118
+
119
+ function saveRuntime(saveBtn) {
120
+ var sel = document.getElementById("issueRuntimeSelect");
121
+ if (!sel || !saveBtn) return;
122
+ saveBtn.disabled = true;
123
+ var path = location.pathname.split("/").slice(0, 5).join("/");
124
+ fetch(path + "/runtime", {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
127
+ body: "runtime=" + encodeURIComponent(sel.value)
128
+ })
129
+ .then(function (r) { return r.json(); })
130
+ .then(function (d) {
131
+ saveBtn.disabled = false;
132
+ if (d.ok) { saveBtn.textContent = "✓"; setTimeout(function () { saveBtn.textContent = "💾"; }, 1200); }
133
+ else alert(d.error || "保存失败");
134
+ })
135
+ .catch(function (err) {
136
+ saveBtn.disabled = false;
137
+ alert("网络错误: " + err);
138
+ });
139
+ }
140
+
141
+ document.addEventListener("click", function (e) {
142
+ var saveBtn = e.target.closest("#issueRuntimeSave");
143
+ if (!saveBtn) return;
144
+ e.preventDefault();
145
+ saveRuntime(saveBtn);
146
+ });
147
+
148
+ document.addEventListener("change", function (e) {
149
+ if (e.target && e.target.id === "issueRuntimeSelect") saveRuntime(document.getElementById("issueRuntimeSave"));
150
+ });
118
151
  })();
package/src/store.ts CHANGED
@@ -75,6 +75,7 @@ export interface IssueRow {
75
75
  closed_at: string | null;
76
76
  ai_status: string;
77
77
  model: string;
78
+ runtime: string;
78
79
  }
79
80
 
80
81
  export interface IssueWithMeta extends IssueRow {
@@ -453,6 +454,7 @@ export interface CreateIssueOpts {
453
454
  state?: "open" | "closed";
454
455
  closedAt?: string | null;
455
456
  model?: string;
457
+ runtime?: string;
456
458
  upstreamIssueNumber?: number;
457
459
  }
458
460
 
@@ -484,8 +486,8 @@ export async function createIssue(
484
486
  "SELECT COALESCE(MAX(number), 0) + 1 AS n FROM {{issues}} WHERE project_id = ?", [projectId]
485
487
  ))!;
486
488
  const info = await getDB().run(
487
- "INSERT INTO {{issues}} (project_id, number, title, body, state, author, created_at, updated_at, closed_at, model, upstream_issue_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
488
- [projectId, next.n, title, body, state, author, createdAt, updatedAt, closedAt, opts.model ?? "", opts.upstreamIssueNumber ?? null]
489
+ "INSERT INTO {{issues}} (project_id, number, title, body, state, author, created_at, updated_at, closed_at, model, runtime, upstream_issue_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
490
+ [projectId, next.n, title, body, state, author, createdAt, updatedAt, closedAt, opts.model ?? "", opts.runtime ?? "", opts.upstreamIssueNumber ?? null]
489
491
  );
490
492
  await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [updatedAt, projectId]);
491
493
  return (await getIssueById(info.insertId))!;
@@ -525,6 +527,11 @@ export async function updateIssueModel(issueId: number, model: string): Promise<
525
527
  await getDB().run("UPDATE {{issues}} SET model = ?, updated_at = ? WHERE id = ?", [clean, now(), issueId]);
526
528
  }
527
529
 
530
+ export async function updateIssueRuntime(issueId: number, runtime: string): Promise<void> {
531
+ const v = runtime === "pi" || runtime === "opencode" ? runtime : "";
532
+ await getDB().run("UPDATE {{issues}} SET runtime = ? WHERE id = ?", [v, issueId]);
533
+ }
534
+
528
535
  export interface UpstreamSyncRow {
529
536
  id: number;
530
537
  project_id: number;
@@ -15,6 +15,11 @@ export function buildIssueNew(owner: string, repo: string, writesEnabled: boolea
15
15
  <input type="text" name="title" placeholder="标题(必填)" required maxlength="255" class="new-title">
16
16
  <textarea name="body" rows="14" placeholder="正文(支持 Markdown)…"></textarea>
17
17
  ${modelSelect}
18
+ <select name="runtime" class="new-model">
19
+ <option value="">默认运行时(daemon 设置)</option>
20
+ <option value="opencode">opencode</option>
21
+ <option value="pi">pi</option>
22
+ </select>
18
23
  <div class="new-actions"><a class="new-cancel" href="${escapeAttr(listHref)}">取消</a><button type="submit">创建工单</button></div>
19
24
  </form>`
20
25
  : `<div class="composer-ro">只读模式:创建工单未启用(WORK_WRITES_ENABLED=false)</div>`;
@@ -161,6 +161,7 @@ export async function buildIssueThread(
161
161
  modelSelect: cfg.writesEnabled !== false
162
162
  ? { current: issue.model ?? "", options: (await listCachedModels()).map((m) => ({ id: m.id, label: m.label })) }
163
163
  : null,
164
+ runtimeSelect: cfg.writesEnabled !== false ? { current: issue.runtime ?? "" } : null,
164
165
  },
165
166
  safeJsonEmbed(payload),
166
167
  displayViews.map((v) => renderCommentCard(v, cfg)).join("")
package/src/webhooks.ts CHANGED
@@ -269,6 +269,7 @@ interface PayloadRepository {
269
269
  // `--model <X>` on opencode spawn. Empty string = no override (let opencode
270
270
  // pick). Gitea-strict consumers ignore unknown fields per JSON POST rules.
271
271
  ework_model?: string;
272
+ ework_runtime?: string;
272
273
  }
273
274
 
274
275
  interface PayloadIssue {
@@ -364,7 +365,12 @@ function buildUserFromRow(user: UserRow, origin: string): PayloadUser {
364
365
  };
365
366
  }
366
367
 
367
- function buildRepository(project: ProjectRow, origin: string, model?: string): PayloadRepository {
368
+ function buildRepository(
369
+ project: ProjectRow,
370
+ origin: string,
371
+ model?: string,
372
+ runtime?: string,
373
+ ): PayloadRepository {
368
374
  const fullName = `${project.owner}/${project.name}`;
369
375
  const htmlUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
370
376
  // clone_url must be a real Git remote (ework-web is NOT a Git server). Use the
@@ -399,6 +405,7 @@ function buildRepository(project: ProjectRow, origin: string, model?: string): P
399
405
  // Only attach ework_model when non-empty (keeps payload compact + lets
400
406
  // Gitea-strict consumers ignore the field entirely on no-op cases).
401
407
  if (model) repo.ework_model = model;
408
+ if (runtime) repo.ework_runtime = runtime;
402
409
  return repo;
403
410
  }
404
411
 
@@ -436,7 +443,7 @@ function buildIssue(
436
443
  closed_at: issue.closed_at,
437
444
  due_date: null,
438
445
  pull_request: null,
439
- repository: buildRepository(project, origin, model),
446
+ repository: buildRepository(project, origin, model, issue.runtime || undefined),
440
447
  user: buildUser(issue.author, origin),
441
448
  ai_status: issue.ai_status ?? "",
442
449
  };
@@ -475,7 +482,7 @@ function buildCommentPayload(
475
482
  action: "created",
476
483
  issue: buildIssue(issue, project, commentCount, origin, model, labels),
477
484
  comment: buildComment(issue, comment, project, origin),
478
- repository: buildRepository(project, origin, model),
485
+ repository: buildRepository(project, origin, model, issue.runtime || undefined),
479
486
  sender: buildUser(comment.author, origin),
480
487
  };
481
488
  }
@@ -492,7 +499,7 @@ function buildIssuePayload(
492
499
  return {
493
500
  action,
494
501
  issue: buildIssue(issue, project, commentCount, origin, model, labels),
495
- repository: buildRepository(project, origin, model),
502
+ repository: buildRepository(project, origin, model, issue.runtime || undefined),
496
503
  sender: buildUser(issue.author, origin),
497
504
  };
498
505
  }