ework-web 0.10.70 → 0.10.71

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.70",
3
+ "version": "0.10.71",
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
@@ -131,6 +131,9 @@ function migrateIssuesTable(db: Database): void {
131
131
  if (!have.has("ai_status")) {
132
132
  db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN ai_status TEXT NOT NULL DEFAULT ''"));
133
133
  }
134
+ if (!have.has("model")) {
135
+ db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN model TEXT NOT NULL DEFAULT ''"));
136
+ }
134
137
  }
135
138
 
136
139
  function migrateLabelsTable(db: Database): void {
@@ -367,6 +370,16 @@ async function migrateMysqlIssuesAiStatus(pool: Pool): Promise<void> {
367
370
  } catch (e) {
368
371
  console.warn("[db] MySQL issues ai_status column add failed:", (e as Error).message);
369
372
  }
373
+ try {
374
+ const [mcols] = await pool.query(
375
+ applyPrefix("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{{issues}}' AND COLUMN_NAME = 'model'")
376
+ );
377
+ if (!(Array.isArray(mcols) && mcols.length > 0)) {
378
+ await pool.query(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN model VARCHAR(128) NOT NULL DEFAULT ''"));
379
+ }
380
+ } catch (e) {
381
+ console.warn("[db] MySQL issues model column add failed:", (e as Error).message);
382
+ }
370
383
  }
371
384
 
372
385
  class MysqlDriver implements AsyncDatabase {
package/src/giteaApi.ts CHANGED
@@ -197,6 +197,7 @@ export async function handleGiteaApi(
197
197
  updatedAt: asString(body.updated_at),
198
198
  state: asIssueState(body.state) ?? "open",
199
199
  closedAt: asString(body.closed_at) || undefined,
200
+ model: asString(body.model) || undefined,
200
201
  });
201
202
  void emitIssueEvent(project.id, created.id, "opened", origin);
202
203
  if (created.state === "closed") {
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  postComment,
39
39
  setIssueState,
40
40
  updateIssueAiStatus,
41
+ updateIssueModel,
41
42
  listIssues,
42
43
  createAttachment,
43
44
  getAttachment,
@@ -385,6 +386,7 @@ const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
385
386
  const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
386
387
  const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
387
388
  const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume|dispatch-off|dispatch-on)$/;
389
+ const REPO_ISSUE_MODEL_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/model$/;
388
390
  const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
389
391
  const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
390
392
  const SESSIONS_RE = /^\/sessions$/;
@@ -1832,6 +1834,28 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1832
1834
  }
1833
1835
  }
1834
1836
 
1837
+ const mp = url.pathname.match(REPO_ISSUE_MODEL_RE);
1838
+ if (mp) {
1839
+ const [, owner, repo, numStr] = mp;
1840
+ if (!(owner && repo && numStr)) return json({ error: "bad path" }, 400);
1841
+ const number = Number(numStr);
1842
+ try {
1843
+ const project = await getProject(owner, repo);
1844
+ if (!project) return json({ error: "project not found" }, 404);
1845
+ const issue = await getIssueWithMeta(project.id, number);
1846
+ if (!issue) return json({ error: "issue not found" }, 404);
1847
+ if (!(await canWriteProject(project.id, ctx.user))) {
1848
+ return json({ error: "writer role required" }, 403);
1849
+ }
1850
+ const form = await req.formData().catch(() => new FormData());
1851
+ const model = String(form.get("model") ?? "").trim().slice(0, 128);
1852
+ await updateIssueModel(issue.id, model);
1853
+ return json({ ok: true, model });
1854
+ } catch (e) {
1855
+ return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
1856
+ }
1857
+ }
1858
+
1835
1859
  const ci = url.pathname.match(REPO_LIST_RE);
1836
1860
  if (ci) {
1837
1861
  const [, owner, repo] = ci;
@@ -1840,6 +1864,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1840
1864
  const form = await req.formData().catch(() => new FormData());
1841
1865
  const title = String(form.get("title") ?? "").trim();
1842
1866
  const body = String(form.get("body") ?? "");
1867
+ const model = String(form.get("model") ?? "").trim().slice(0, 128);
1843
1868
  if (!title) return html(errorPage("标题不能为空", "回到上一页填写标题后重试"), 400);
1844
1869
  let project = await getProject(owner, repo);
1845
1870
  let createdProject = false;
@@ -1854,7 +1879,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1854
1879
  await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1855
1880
  await autoWireDaemon(project.id, url.origin);
1856
1881
  }
1857
- const issue = await createIssue(project.id, title, body, ctx.user!.login);
1882
+ const issue = await createIssue(project.id, title, body, ctx.user!.login, model ? { model } : {});
1858
1883
  void emitIssueEvent(project.id, issue.id, "opened", url.origin);
1859
1884
  return Response.redirect(
1860
1885
  `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issue.number}`,
@@ -2193,7 +2218,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
2193
2218
  if (isNew) {
2194
2219
  const [, owner, repo] = isNew;
2195
2220
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
2196
- return html(buildIssueNew(owner, repo, cfg.writesEnabled));
2221
+ return html(buildIssueNew(owner, repo, cfg.writesEnabled, await listCachedModels()));
2197
2222
  }
2198
2223
 
2199
2224
  const view = url.pathname.match(REPO_ISSUE_RE);
@@ -23,6 +23,7 @@ export interface LayoutProps {
23
23
  viewerIsAdmin?: boolean;
24
24
  customActions?: IssueAction[];
25
25
  extraStatusBadges?: Record<string, { cls: string; label: string }>;
26
+ modelSelect?: { current: string; options: { id: string; label: string }[] } | null;
26
27
  }
27
28
 
28
29
  export const THEME_CSS = `
@@ -127,6 +128,9 @@ header.topbar .num{opacity:.7}
127
128
  .issue-label{display:inline-flex;align-items:center;font-size:12px;font-weight:500;padding:.05rem .5rem;border:1px solid;border-radius:99px;line-height:1.6;background:color-mix(in srgb,currentColor 8%,transparent)}
128
129
  .label-edit-btn{background:none;border:1px solid var(--border);border-radius:6px;padding:.05rem .35rem;cursor:pointer;font-size:13px;line-height:1.5;color:var(--text-muted)}
129
130
  .label-edit-btn:hover{border-color:var(--accent);color:var(--accent)}
131
+ .model-select-wrap{display:inline-flex;align-items:center;gap:.25rem}
132
+ .model-select{background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:.15rem .3rem;font:12px system-ui,sans-serif;max-width:180px}
133
+ .model-save-btn{padding:.15rem .45rem;font-size:13px}
130
134
  #labelDlg{border:1px solid var(--border);border-radius:10px;background:var(--bg-elev);color:var(--text);padding:1.1rem;max-width:380px;width:90vw}
131
135
  #labelDlg::backdrop{background:rgba(0,0,0,.5)}
132
136
  #labelDlg h3{margin:0 0 .6rem;font-size:14px}
@@ -199,6 +203,12 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
199
203
  ? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 接单</button>`
200
204
  : `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="设为不自动接单">🔕 不接单</button>`
201
205
  : "";
206
+ const modelSelectHtml = props.modelSelect && props.modelSelect.options.length > 0 && showActions
207
+ ? `<span class="model-select-wrap"><select class="model-select" id="issueModelSelect" title="此 issue 的模型(覆盖项目/全局默认)">
208
+ <option value="">默认模型</option>
209
+ ${props.modelSelect.options.map((m) => `<option value="${escapeAttr(m.id)}" ${m.id === props.modelSelect!.current ? "selected" : ""}>${escapeHtml(m.label)}</option>`).join("")}
210
+ </select><button type="button" class="action-btn model-save-btn" id="issueModelSave" title="保存模型选择">💾</button></span>`
211
+ : "";
202
212
  return `<!doctype html>
203
213
  <html lang="zh">
204
214
  <head>
@@ -221,7 +231,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
221
231
  <div class="meta-status">
222
232
  <span class="state-badge ${stateClass}">${stateLabel}</span>
223
233
  ${aiBadgeHtml}
224
- ${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${(props.customActions ?? []).map((a) => {
234
+ ${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${modelSelectHtml}${(props.customActions ?? []).map((a) => {
225
235
  const attrs = [`data-action-href="${escapeAttr(a.href)}"`];
226
236
  if (a.method && a.method !== "POST") attrs.push(`data-action-method="${escapeAttr(a.method)}"`);
227
237
  if (a.confirm) attrs.push(`data-action-confirm="${escapeAttr(a.confirm)}"`);
@@ -54,6 +54,7 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
54
54
  updated_at VARCHAR(40) NOT NULL,
55
55
  closed_at VARCHAR(40) DEFAULT NULL,
56
56
  ai_status VARCHAR(32) NOT NULL DEFAULT '',
57
+ model VARCHAR(128) NOT NULL DEFAULT '',
57
58
  UNIQUE (project_id, number),
58
59
  CONSTRAINT {{fk_issues_project}} FOREIGN KEY (project_id) REFERENCES {{projects}}(id) ON DELETE CASCADE,
59
60
  CONSTRAINT {{fk_issues_author}} FOREIGN KEY (author) REFERENCES {{users}}(login)
package/src/schema.sql CHANGED
@@ -65,6 +65,8 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
65
65
  closed_at TEXT,
66
66
  -- AI processing status: '' (none) | 'processing' | 'halted' | 'completed' | 'failed'
67
67
  ai_status TEXT NOT NULL DEFAULT '',
68
+ -- Resolved "provider/model" for this issue. Empty = inherit project/global default.
69
+ model TEXT NOT NULL DEFAULT '',
68
70
  UNIQUE (project_id, number)
69
71
  );
70
72
  CREATE INDEX IF NOT EXISTS issues_project_state_updated
@@ -35,4 +35,30 @@
35
35
  btn.textContent = orig;
36
36
  });
37
37
  });
38
+
39
+ document.addEventListener("click", function (e) {
40
+ var saveBtn = e.target.closest("#issueModelSave");
41
+ if (!saveBtn) return;
42
+ e.preventDefault();
43
+ var sel = document.getElementById("issueModelSelect");
44
+ if (!sel) return;
45
+ var m = sel.value;
46
+ saveBtn.disabled = true;
47
+ var path = location.pathname.split("/").slice(0, 5).join("/");
48
+ fetch(path + "/model", {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
51
+ body: "model=" + encodeURIComponent(m)
52
+ })
53
+ .then(function (r) { return r.json(); })
54
+ .then(function (d) {
55
+ saveBtn.disabled = false;
56
+ if (d.ok) { saveBtn.textContent = "✓"; setTimeout(function () { saveBtn.textContent = "💾"; }, 1200); }
57
+ else alert(d.error || "保存失败");
58
+ })
59
+ .catch(function (err) {
60
+ saveBtn.disabled = false;
61
+ alert("网络错误: " + err);
62
+ });
63
+ });
38
64
  })();
package/src/store.ts CHANGED
@@ -74,6 +74,7 @@ export interface IssueRow {
74
74
  updated_at: string;
75
75
  closed_at: string | null;
76
76
  ai_status: string;
77
+ model: string;
77
78
  }
78
79
 
79
80
  export interface IssueWithMeta extends IssueRow {
@@ -316,7 +317,9 @@ export async function setProjectModel(projectId: number, model: string): Promise
316
317
  // Resolve the effective model for a project: project override > global
317
318
  // default. Returns "" when neither is set (caller omits --model and lets
318
319
  // opencode pick per its own config).
319
- export function resolveModel(projectModel: string, globalDefault: string): string {
320
+ export function resolveModel(projectModel: string, globalDefault: string, issueModel?: string): string {
321
+ const i = String(issueModel ?? "").trim();
322
+ if (i) return i;
320
323
  const p = String(projectModel ?? "").trim();
321
324
  if (p) return p;
322
325
  return String(globalDefault ?? "").trim();
@@ -448,6 +451,7 @@ export interface CreateIssueOpts {
448
451
  updatedAt?: string;
449
452
  state?: "open" | "closed";
450
453
  closedAt?: string | null;
454
+ model?: string;
451
455
  }
452
456
 
453
457
  function isoOr(value: string | undefined, fallback: string): string {
@@ -478,8 +482,8 @@ export async function createIssue(
478
482
  "SELECT COALESCE(MAX(number), 0) + 1 AS n FROM {{issues}} WHERE project_id = ?", [projectId]
479
483
  ))!;
480
484
  const info = await getDB().run(
481
- "INSERT INTO {{issues}} (project_id, number, title, body, state, author, created_at, updated_at, closed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
482
- [projectId, next.n, title, body, state, author, createdAt, updatedAt, closedAt]
485
+ "INSERT INTO {{issues}} (project_id, number, title, body, state, author, created_at, updated_at, closed_at, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
486
+ [projectId, next.n, title, body, state, author, createdAt, updatedAt, closedAt, opts.model ?? ""]
483
487
  );
484
488
  await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [updatedAt, projectId]);
485
489
  return (await getIssueById(info.insertId))!;
@@ -514,6 +518,11 @@ export async function getIssueAiStatus(issueId: number): Promise<string> {
514
518
  return row?.ai_status ?? "";
515
519
  }
516
520
 
521
+ export async function updateIssueModel(issueId: number, model: string): Promise<void> {
522
+ const clean = model.trim().slice(0, 128);
523
+ await getDB().run("UPDATE {{issues}} SET model = ?, updated_at = ? WHERE id = ?", [clean, now(), issueId]);
524
+ }
525
+
517
526
  export interface IssuePatch {
518
527
  title?: string;
519
528
  body?: string;
@@ -1,12 +1,20 @@
1
1
  import { THEME_CSS, escapeHtml, escapeAttr } from "../render/layout";
2
+ import type { CachedModel } from "../store";
2
3
 
3
- export function buildIssueNew(owner: string, repo: string, writesEnabled: boolean): string {
4
+ export function buildIssueNew(owner: string, repo: string, writesEnabled: boolean, models: CachedModel[] = [], currentModel = ""): string {
4
5
  const listHref = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`;
5
6
  const action = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`;
7
+ const modelSelect = models.length > 0
8
+ ? `<select name="model" class="new-model">
9
+ <option value="">默认模型(项目/全局设置)</option>
10
+ ${models.map((m) => `<option value="${escapeAttr(m.id)}" ${m.id === currentModel ? "selected" : ""}>${escapeHtml(m.label)}</option>`).join("")}
11
+ </select>`
12
+ : "";
6
13
  const body = writesEnabled
7
14
  ? `<form class="new-form" method="POST" action="${escapeAttr(action)}">
8
15
  <input type="text" name="title" placeholder="标题(必填)" required maxlength="255" class="new-title">
9
16
  <textarea name="body" rows="14" placeholder="正文(支持 Markdown)…"></textarea>
17
+ ${modelSelect}
10
18
  <div class="new-actions"><a class="new-cancel" href="${escapeAttr(listHref)}">取消</a><button type="submit">创建工单</button></div>
11
19
  </form>`
12
20
  : `<div class="composer-ro">只读模式:创建工单未启用(WORK_WRITES_ENABLED=false)</div>`;
@@ -20,6 +28,7 @@ export function buildIssueNew(owner: string, repo: string, writesEnabled: boolea
20
28
  .new-form{display:flex;flex-direction:column;gap:.6rem}
21
29
  .new-title{width:100%;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.55rem .7rem;font:600 16px system-ui,sans-serif}
22
30
  .new-form textarea{width:100%;resize:vertical;min-height:14em;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.6rem .7rem;font:14px/1.55 -apple-system,"PingFang SC",sans-serif}
31
+ .new-model{width:100%;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.5rem .7rem;font:13px system-ui,sans-serif}
23
32
  .new-actions{display:flex;gap:.6rem;justify-content:flex-end;align-items:center}
24
33
  .new-cancel{font-size:13px;color:var(--text-muted)}
25
34
  .new-form button{background:var(--green);color:#fff;border:none;border-radius:8px;padding:.55rem 1.2rem;font:600 13px system-ui,sans-serif;cursor:pointer}
@@ -14,6 +14,7 @@ import {
14
14
  getDefaultUpstreamUrl,
15
15
  listLabelsForIssue,
16
16
  getUserByLogin,
17
+ listCachedModels,
17
18
  type CommentRow,
18
19
  type IssueWithMeta,
19
20
  } from "../store";
@@ -156,6 +157,9 @@ export async function buildIssueThread(
156
157
  viewerIsAdmin,
157
158
  customActions,
158
159
  extraStatusBadges,
160
+ modelSelect: cfg.writesEnabled !== false
161
+ ? { current: issue.model ?? "", options: (await listCachedModels()).map((m) => ({ id: m.id, label: m.label })) }
162
+ : null,
159
163
  },
160
164
  safeJsonEmbed(payload),
161
165
  displayViews.map((v) => renderCommentCard(v, cfg)).join("")
package/src/webhooks.ts CHANGED
@@ -667,7 +667,7 @@ export async function emitIssueEvent(
667
667
  // override (daemon omits --model, lets opencode pick). globalDefault
668
668
  // comes from the config table via loadConfig() — cheap DB read.
669
669
  const globalDefault = (await loadConfig()).defaultModel;
670
- const model = resolveModel(project.model, globalDefault);
670
+ const model = resolveModel(project.model, globalDefault, issue.model);
671
671
  const labels = await toPayloadLabels(issueId);
672
672
  const payload = buildIssuePayload(issue, project, commentCount, action, origin, model, labels);
673
673
  (payload as IssueEventPayload).event_id = randomUUID();
@@ -707,7 +707,7 @@ export async function emitCommentEvent(
707
707
  const authorUser = await getUserByLogin(comment.author);
708
708
  const commentCount = await countCommentsSafe(issueId);
709
709
  const globalDefault = (await loadConfig()).defaultModel;
710
- const model = resolveModel(project.model, globalDefault);
710
+ const model = resolveModel(project.model, globalDefault, issue.model);
711
711
  const labels = await toPayloadLabels(issueId);
712
712
  const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels);
713
713
  (payload as CommentEventPayload).event_id = randomUUID();