ework-web 0.1.4 → 0.2.0

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.1.4",
3
+ "version": "0.2.0",
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/config.ts CHANGED
@@ -78,6 +78,11 @@ export const configSchema = z.object({
78
78
  daemonBotLogin: z.string().default(""),
79
79
  daemonWebhookUrl: z.string().default(""),
80
80
  daemonWebhookSecret: z.string().default(""),
81
+ // Default "provider/model" string passed to `opencode run --model <X>`.
82
+ // Empty = let opencode pick per its own opencode.json + env. ework-daemon
83
+ // pushes this (or the per-project override) on every spawn to defend
84
+ // against env-var-registered providers stealing the model slot.
85
+ defaultModel: z.string().default(""),
81
86
  autowireActive: z.coerce.boolean().default(true),
82
87
  webhookMaxConcurrent: z.preprocess((v) => {
83
88
  const n = typeof v === "number" ? v : Number(v);
@@ -88,7 +93,7 @@ export const configSchema = z.object({
88
93
 
89
94
  export type Config = z.infer<typeof configSchema>;
90
95
 
91
- export type FieldType = "text" | "number" | "backend";
96
+ export type FieldType = "text" | "number" | "backend" | "model";
92
97
  export interface SettingField {
93
98
  key: keyof Config;
94
99
  label: string;
@@ -99,6 +104,12 @@ export interface SettingGroup {
99
104
  fields: SettingField[];
100
105
  }
101
106
  export const SETTINGS_GROUPS: SettingGroup[] = [
107
+ {
108
+ title: "AI 模型",
109
+ fields: [
110
+ { key: "defaultModel", label: "默认模型(空 = 用 opencode 自己的默认)", type: "model" },
111
+ ],
112
+ },
102
113
  {
103
114
  title: "翻译",
104
115
  fields: [
@@ -166,6 +177,7 @@ export function loadConfig(): Config {
166
177
  daemonBotLogin: process.env.WORK_DAEMON_BOT_LOGIN ?? "",
167
178
  daemonWebhookUrl: process.env.WORK_DAEMON_WEBHOOK_URL ?? "",
168
179
  daemonWebhookSecret: process.env.WORK_DAEMON_WEBHOOK_SECRET ?? "",
180
+ defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL,
169
181
  autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
170
182
  webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
171
183
  });
package/src/db.ts CHANGED
@@ -55,6 +55,9 @@ function migrateProjectsTable(db: Database): void {
55
55
  if (!have.has("upstream_urls")) {
56
56
  db.exec("ALTER TABLE projects ADD COLUMN upstream_urls TEXT NOT NULL DEFAULT '[]'");
57
57
  }
58
+ if (!have.has("model")) {
59
+ db.exec("ALTER TABLE projects ADD COLUMN model TEXT NOT NULL DEFAULT ''");
60
+ }
58
61
  }
59
62
 
60
63
  function migrateIssuesTable(db: Database): void {
package/src/index.ts CHANGED
@@ -53,6 +53,9 @@ import {
53
53
  removeProjectMember,
54
54
  countProjectAdmins,
55
55
  getProjectMembership,
56
+ setProjectModel,
57
+ listCachedModels,
58
+ replaceCachedModels,
56
59
  type ProjectRole,
57
60
  type UserRow,
58
61
  } from "./store";
@@ -80,6 +83,7 @@ import { classifyActor, type CommentView } from "./render/components";
80
83
  import { buildWebhooksPage } from "./views/webhooks";
81
84
  import { buildProjectMembersPage } from "./views/projectMembers";
82
85
  import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
86
+ import { buildProjectModelPage } from "./views/projectModel";
83
87
  import { handleGiteaApi } from "./giteaApi";
84
88
 
85
89
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -180,6 +184,7 @@ const REPO_MEMBERS_RE = /^\/([^/]+)\/([^/]+)\/settings\/members$/;
180
184
  const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/(role|remove)$/;
181
185
  const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
182
186
  const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
187
+ const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
183
188
  const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
184
189
  const SESSIONS_RE = /^\/sessions$/;
185
190
  const SESSION_VIEW_RE = /^\/sessions\/([A-Za-z0-9_-]+)$/;
@@ -601,7 +606,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
601
606
 
602
607
  if (url.pathname === "/settings") {
603
608
  if (req.method === "GET") {
604
- return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!).html);
609
+ return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, listCachedModels()).html);
605
610
  }
606
611
  if (req.method === "POST") {
607
612
  if (!rateLimit(`settings:${ip}`, 10, 10 / 60)) return html(errorPage("太快了", "请稍后再试"), 429);
@@ -621,6 +626,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
621
626
  }
622
627
  }
623
628
 
629
+ if (url.pathname === "/settings/models/refresh" && req.method === "POST") {
630
+ if (!ctx.user || ctx.user.is_admin !== 1) return html(errorPage("403", "需要管理员"), 403);
631
+ const ids = await opencode.listModels();
632
+ replaceCachedModels(ids);
633
+ const rh = new Headers(SEC_HEADERS);
634
+ rh.set("location", "/settings?saved=1");
635
+ return new Response(null, { status: 303, headers: rh });
636
+ }
637
+
624
638
  if (url.pathname === "/me") {
625
639
  const me = ctx.user!;
626
640
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
@@ -1147,6 +1161,27 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1147
1161
  return Response.redirect(`${back}?err=${encodeURIComponent(result.msg)}`, 303);
1148
1162
  }
1149
1163
 
1164
+ const modelSave = url.pathname.match(REPO_MODEL_RE);
1165
+ if (modelSave) {
1166
+ const [, owner, repo] = modelSave;
1167
+ if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1168
+ const project = getProject(owner, repo);
1169
+ if (!project) return html(errorPage("项目不存在", ""), 404);
1170
+ const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/model`;
1171
+ if (!canAdminProject(project.id, ctx.user)) {
1172
+ return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1173
+ }
1174
+ const form = await req.formData().catch(() => new FormData());
1175
+ const raw = String(form.get("model") ?? "").trim();
1176
+ try {
1177
+ setProjectModel(project.id, raw);
1178
+ return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent("模型已保存")}`, 303);
1179
+ } catch (e) {
1180
+ const msg = e instanceof StoreError ? e.message : (e instanceof Error ? e.message : "保存失败");
1181
+ return Response.redirect(`${back}?err=${encodeURIComponent(msg)}`, 303);
1182
+ }
1183
+ }
1184
+
1150
1185
  return json({ error: "not found" }, 404);
1151
1186
  }
1152
1187
 
@@ -1263,6 +1298,21 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1263
1298
  return html(buildProjectUpstreamsPage(ctx.user!, project, flash));
1264
1299
  }
1265
1300
 
1301
+ const modelPage = url.pathname.match(REPO_MODEL_RE);
1302
+ if (modelPage) {
1303
+ const [, owner, repo] = modelPage;
1304
+ if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1305
+ const project = getProject(owner, repo);
1306
+ if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1307
+ if (!canAdminProject(project.id, ctx.user)) {
1308
+ return html(errorPage("无权限", "需要该项目 admin 角色才能管理模型"), 403);
1309
+ }
1310
+ const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
1311
+ const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
1312
+ const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
1313
+ return html(buildProjectModelPage(project, cfg.defaultModel, listCachedModels(), flash).html);
1314
+ }
1315
+
1266
1316
  const repoMatch = url.pathname.match(REPO_RE);
1267
1317
  if (repoMatch) {
1268
1318
  const [, owner, repo] = repoMatch;
package/src/opencode.ts CHANGED
@@ -159,6 +159,36 @@ export class OpencodeClient {
159
159
  return stdout;
160
160
  }
161
161
 
162
+ // List available models from `opencode models`. Output is plain text — one
163
+ // `provider/model` per line — with plugin banners (e.g. "[opencode-ework]
164
+ // registered 5 tools: ...") on stderr. We strip any line that doesn't
165
+ // match the `provider/model` shape, dedupe, sort. Errors (binary missing,
166
+ // non-zero exit) return an empty array — the settings UI degrades to a
167
+ // free-text input.
168
+ async listModels(): Promise<string[]> {
169
+ try {
170
+ const { stdout, code } = await this.run(["models"]);
171
+ if (code !== 0) return [];
172
+ const seen = new Set<string>();
173
+ const out: string[] = [];
174
+ for (const raw of stdout.split(/\r?\n/)) {
175
+ const line = raw.trim();
176
+ // Provider/model shape: non-empty segments on both sides of '/',
177
+ // alphanumeric + ._-. only. Rejects banner lines like "[omo-stable] ..."
178
+ // (which start with '['), "Exporting session:" (no slash), empty lines.
179
+ const m = line.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/);
180
+ if (!m) continue;
181
+ if (seen.has(line)) continue;
182
+ seen.add(line);
183
+ out.push(line);
184
+ }
185
+ out.sort();
186
+ return out;
187
+ } catch {
188
+ return [];
189
+ }
190
+ }
191
+
162
192
  // --- subprocess plumbing ---
163
193
 
164
194
  private async runJSON(args: string[]): Promise<unknown> {
package/src/schema.sql CHANGED
@@ -26,11 +26,25 @@ CREATE TABLE IF NOT EXISTS projects (
26
26
  -- repository.clone_url so it knows where AI should `git clone` from.
27
27
  -- Empty array = no upstream bound (project is purely a tracker).
28
28
  upstream_urls TEXT NOT NULL DEFAULT '[]',
29
+ -- Resolved "provider/model" string (e.g. "zhipuai/glm-4.6") passed to
30
+ -- `opencode run --model <X>` by ework-daemon. Empty = inherit global
31
+ -- defaultModel from the config table.
32
+ model TEXT NOT NULL DEFAULT '',
29
33
  created_at TEXT NOT NULL,
30
34
  updated_at TEXT NOT NULL,
31
35
  UNIQUE (owner, name)
32
36
  );
33
37
 
38
+ -- Cache of models reported by `opencode models`. Refreshed on demand from
39
+ -- the /settings page (or on first access if empty). Stored as rows rather
40
+ -- than a single JSON blob so the settings UI can render a select without
41
+ -- parsing JSON in SQL.
42
+ CREATE TABLE IF NOT EXISTS model_cache (
43
+ provider_model TEXT PRIMARY KEY,
44
+ label TEXT NOT NULL,
45
+ refreshed_at TEXT NOT NULL
46
+ );
47
+
34
48
  CREATE TABLE IF NOT EXISTS issues (
35
49
  id INTEGER PRIMARY KEY AUTOINCREMENT,
36
50
  project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
package/src/store.ts CHANGED
@@ -35,10 +35,16 @@ export interface ProjectRow {
35
35
  name: string;
36
36
  description: string;
37
37
  upstream_urls: string;
38
+ model: string;
38
39
  created_at: string;
39
40
  updated_at: string;
40
41
  }
41
42
 
43
+ export interface CachedModel {
44
+ id: string;
45
+ label: string;
46
+ }
47
+
42
48
  export interface IssueRow {
43
49
  id: number;
44
50
  project_id: number;
@@ -232,6 +238,58 @@ export function setProjectUpstreamUrls(projectId: number, urls: string[]): strin
232
238
  return cleaned;
233
239
  }
234
240
 
241
+ // `provider/model` shape (alphanumeric + ._-. on both sides of the slash).
242
+ // Same regex as OpencodeClient.listModels — keep in sync.
243
+ const MODEL_RE = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
244
+
245
+ export function setProjectModel(projectId: number, model: string): string {
246
+ const trimmed = String(model ?? "").trim();
247
+ if (trimmed && !MODEL_RE.test(trimmed)) {
248
+ throw new StoreError(400, `模型格式非法(须 provider/model): ${trimmed}`);
249
+ }
250
+ rawDB()
251
+ .query("UPDATE projects SET model = ?, updated_at = ? WHERE id = ?")
252
+ .run(trimmed, now(), projectId);
253
+ return trimmed;
254
+ }
255
+
256
+ // Resolve the effective model for a project: project override > global
257
+ // default. Returns "" when neither is set (caller omits --model and lets
258
+ // opencode pick per its own config).
259
+ export function resolveModel(projectModel: string, globalDefault: string): string {
260
+ const p = String(projectModel ?? "").trim();
261
+ if (p) return p;
262
+ return String(globalDefault ?? "").trim();
263
+ }
264
+
265
+ export function listCachedModels(): CachedModel[] {
266
+ const rows = rawDB().query("SELECT provider_model AS id, label FROM model_cache ORDER BY id").all() as CachedModel[];
267
+ return rows;
268
+ }
269
+
270
+ export function replaceCachedModels(ids: string[]): void {
271
+ const db = rawDB();
272
+ const ts = now();
273
+ // Dedupe to avoid UNIQUE constraint violations — caller may pass raw
274
+ // output from `opencode models` which could (theoretically) repeat.
275
+ const seen = new Set<string>();
276
+ const unique: string[] = [];
277
+ for (const id of ids) {
278
+ if (!seen.has(id)) {
279
+ seen.add(id);
280
+ unique.push(id);
281
+ }
282
+ }
283
+ const tx = db.transaction(() => {
284
+ db.exec("DELETE FROM model_cache");
285
+ const stmt = db.query("INSERT INTO model_cache (provider_model, label, refreshed_at) VALUES (?, ?, ?)");
286
+ for (const id of unique) {
287
+ stmt.run(id, id, ts);
288
+ }
289
+ });
290
+ tx();
291
+ }
292
+
235
293
  export function getIssue(projectId: number, number: number): IssueRow | null {
236
294
  return (
237
295
  (rawDB()
@@ -0,0 +1,77 @@
1
+ import { THEME_CSS, escapeHtml, escapeAttr } from "../render/layout";
2
+ import { projectSettingsTabsHTML } from "./projectUpstreams";
3
+ import type { ProjectRow, CachedModel } from "../store";
4
+
5
+ interface Flash {
6
+ kind: "ok" | "err";
7
+ msg: string;
8
+ }
9
+
10
+ export function buildProjectModelPage(
11
+ project: ProjectRow,
12
+ globalDefault: string,
13
+ models: CachedModel[],
14
+ flash: Flash | null,
15
+ ): { html: string } {
16
+ const base = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings`;
17
+ const cur = project.model;
18
+ const effective = cur || globalDefault;
19
+ const effectiveLine = effective
20
+ ? `<p class="hint">实际生效:<code>${escapeHtml(effective)}</code>${cur ? "" : " (继承全局默认)"}</p>`
21
+ : `<p class="hint">实际生效:<em>未配置</em> — daemon 不会加 <code>--model</code>,opencode 按自己的 opencode.json + 环境变量选。</p>`;
22
+ // Empty cache → free-text input (same degradation as global settings).
23
+ const field = models.length === 0
24
+ ? `<input type="text" name="model" value="${escapeAttr(cur)}" placeholder="provider/model(去 /settings 刷新模型列表)">`
25
+ : (() => {
26
+ const opts = [`<option value=""${cur === "" ? " selected" : ""}>(继承全局默认)</option>`]
27
+ .concat(
28
+ models.map(
29
+ (m) =>
30
+ `<option value="${escapeAttr(m.id)}"${m.id === cur ? " selected" : ""}>${escapeHtml(m.label)}</option>`,
31
+ ),
32
+ )
33
+ .join("");
34
+ return `<select name="model">${opts}</select>`;
35
+ })();
36
+ const banner = flash
37
+ ? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>`
38
+ : "";
39
+ const html = `<!doctype html>
40
+ <html lang="zh"><head><meta charset="utf-8">
41
+ <meta name="viewport" content="width=device-width,initial-scale=1">
42
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
43
+ <title>${escapeHtml(project.owner + "/" + project.name)} · 模型设置</title>
44
+ <style>${THEME_CSS}
45
+ .nav{display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px}
46
+ .nav a{color:var(--header-text);opacity:.95}
47
+ .wrap{max-width:720px;margin:0 auto;padding:1rem}
48
+ .subtabs{display:flex;gap:.4rem;margin-bottom:1rem;flex-wrap:wrap}
49
+ .subtab{padding:.35rem .7rem;border:1px solid var(--border);border-radius:6px;background:var(--bg-elev);color:var(--text-muted);font-size:13px;text-decoration:none}
50
+ .subtab.active{background:var(--accent);color:#fff;border-color:var(--accent)}
51
+ h1{font-size:18px;margin:0 0 .4rem}
52
+ .hint{color:var(--text-muted);font-size:13px;margin:0 0 1rem}
53
+ .card{border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;background:var(--bg-elev);margin-bottom:.9rem}
54
+ .card h2{font-size:14px;margin:0 0 .6rem}
55
+ .row{display:flex;align-items:center;gap:.7rem;margin:.45rem 0}
56
+ .row label{flex:0 0 120px;color:var(--text-muted);font-size:13px}
57
+ .row select,.row input{flex:1;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px;font-family:inherit}
58
+ button{padding:.5rem 1.2rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer}
59
+ .flash{padding:.5rem .8rem;border-radius:6px;font-size:13px;margin-bottom:.9rem}
60
+ .flash.ok{background:#1f6feb;color:#fff}
61
+ .flash.err{background:#da3633;color:#fff}
62
+ </style></head><body>
63
+ <header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · ${escapeHtml(project.owner + "/" + project.name)}</span></header>
64
+ <main class="wrap">
65
+ ${projectSettingsTabsHTML(project.owner, project.name, "model")}
66
+ <h1>🤖 模型设置</h1>
67
+ ${banner}
68
+ ${effectiveLine}
69
+ <form class="card" method="POST" action="${escapeAttr(base)}/model">
70
+ <h2>项目级模型覆盖</h2>
71
+ <div class="row"><label>model</label>${field}</div>
72
+ <div style="margin-top:.5rem"><button type="submit">保存</button></div>
73
+ </form>
74
+ <p class="hint">空 = 继承 <a href="/settings">全局默认</a>(当前: <code>${escapeHtml(globalDefault || "(未配置)")}</code>)。<br>模型列表来源:<code>opencode models</code>。要刷新去 <a href="/settings">全局设置</a> 点「刷新 opencode 模型列表」。</p>
75
+ </main></body></html>`;
76
+ return { html };
77
+ }
@@ -16,7 +16,7 @@ interface Flash {
16
16
  export function projectSettingsTabsHTML(
17
17
  owner: string,
18
18
  name: string,
19
- active: "webhooks" | "members" | "upstreams",
19
+ active: "webhooks" | "members" | "upstreams" | "model",
20
20
  ): string {
21
21
  const base = `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/settings`;
22
22
  const cls = (which: typeof active) => (active === which ? " active" : "");
@@ -24,6 +24,7 @@ export function projectSettingsTabsHTML(
24
24
  <a class="subtab${cls("webhooks")}" href="${escapeAttr(base)}/webhooks">Webhooks</a>
25
25
  <a class="subtab${cls("members")}" href="${escapeAttr(base)}/members">成员</a>
26
26
  <a class="subtab${cls("upstreams")}" href="${escapeAttr(base)}/upstreams">上游</a>
27
+ <a class="subtab${cls("model")}" href="${escapeAttr(base)}/model">🤖 模型</a>
27
28
  </nav>`;
28
29
  }
29
30
 
@@ -1,9 +1,9 @@
1
1
  import type { Config, SettingGroup } from "../config";
2
2
  import { SETTINGS_GROUPS } from "../config";
3
3
  import { THEME_CSS, escapeHtml, escapeAttr } from "../render/layout";
4
- import type { UserRow } from "../store";
4
+ import type { UserRow, CachedModel } from "../store";
5
5
 
6
- function fieldInput(group: SettingGroup, cfg: Config): string {
6
+ function fieldInput(group: SettingGroup, cfg: Config, models: CachedModel[]): string {
7
7
  return group.fields
8
8
  .map((f) => {
9
9
  const cur = String(cfg[f.key] ?? "");
@@ -16,21 +16,38 @@ function fieldInput(group: SettingGroup, cfg: Config): string {
16
16
  .join("");
17
17
  return `<label class="sf"><span>${escapeHtml(f.label)}</span><select name="${escapeAttr(String(f.key))}">${opts}</select></label>`;
18
18
  }
19
+ if (f.type === "model") {
20
+ // If the cache is empty (opencode not yet polled), fall back to a free
21
+ // text input so the user can still type a known provider/model id.
22
+ if (models.length === 0) {
23
+ return `<label class="sf"><span>${escapeHtml(f.label)}</span><input type="text" name="${escapeAttr(String(f.key))}" value="${escapeAttr(cur)}" placeholder="provider/model(点下面的刷新拉列表)"></label>`;
24
+ }
25
+ const opts = [`<option value=""${cur === "" ? " selected" : ""}>(用 opencode 默认)</option>`]
26
+ .concat(
27
+ models.map(
28
+ (m) =>
29
+ `<option value="${escapeAttr(m.id)}"${m.id === cur ? " selected" : ""}>${escapeHtml(m.label)}</option>`
30
+ )
31
+ )
32
+ .join("");
33
+ return `<label class="sf"><span>${escapeHtml(f.label)}</span><select name="${escapeAttr(String(f.key))}">${opts}</select></label>`;
34
+ }
19
35
  const inp = f.type === "number" ? "number" : "text";
20
36
  return `<label class="sf"><span>${escapeHtml(f.label)}</span><input type="${inp}" name="${escapeAttr(String(f.key))}" value="${escapeAttr(cur)}"></label>`;
21
37
  })
22
38
  .join("");
23
39
  }
24
40
 
25
- export function buildSettingsPage(cfg: Config, saved: boolean, viewer: UserRow): { html: string } {
41
+ export function buildSettingsPage(cfg: Config, saved: boolean, viewer: UserRow, models: CachedModel[]): { html: string } {
26
42
  const groups = SETTINGS_GROUPS.map(
27
43
  (g) =>
28
- `<section class="sg"><h2>${escapeHtml(g.title)}</h2>${fieldInput(g, cfg)}</section>`
44
+ `<section class="sg"><h2>${escapeHtml(g.title)}</h2>${fieldInput(g, cfg, models)}</section>`
29
45
  ).join("");
30
46
  const banner = saved ? `<div class="saved">✓ 已保存,立即生效</div>` : "";
31
47
  const ttsLink = viewer.is_admin === 1
32
48
  ? `<p class="hint">要增删朗读后端(kokoro / cosyvoice3 等),去 <a href="/admin/tts-backends">朗读后端管理</a>。</p>`
33
49
  : "";
50
+ const modelRefreshForm = `<form method="POST" action="/settings/models/refresh" style="margin-top:.4rem"><button type="submit" class="secondary">🔄 刷新 opencode 模型列表</button></form>`;
34
51
  const html = `<!doctype html>
35
52
  <html lang="zh"><head><meta charset="utf-8">
36
53
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -51,6 +68,7 @@ h1{font-size:18px;margin:0 0 .4rem}
51
68
  .saved{background:#1f6feb;color:#fff;padding:.5rem .8rem;border-radius:6px;font-size:13px;margin-bottom:.9rem}
52
69
  .bar{display:flex;gap:.6rem;align-items:center;margin-top:.4rem}
53
70
  button{padding:.5rem 1.2rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer}
71
+ button.secondary{background:transparent;color:var(--text-muted);border:1px solid var(--border)}
54
72
  .a-back{color:var(--text-muted);font-size:13px}
55
73
  </style></head><body>
56
74
  <header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · 设置</span></header>
@@ -61,6 +79,7 @@ ${banner}
61
79
  <form method="POST" action="/settings">${groups}
62
80
  <div class="bar"><button type="submit">保存</button><a class="a-back" href="/">返回</a></div>
63
81
  </form>
82
+ ${modelRefreshForm}
64
83
  ${ttsLink}
65
84
  </main></body></html>`;
66
85
  return { html };
package/src/webhooks.ts CHANGED
@@ -18,12 +18,14 @@
18
18
  import { createHmac, randomUUID } from "node:crypto";
19
19
  import { rawDB } from "./db";
20
20
  import { log } from "./logger";
21
+ import { loadConfig } from "./config";
21
22
  import {
22
23
  StoreError,
23
24
  getIssueById,
24
25
  getProjectById,
25
26
  getDefaultUpstreamUrl,
26
27
  ensureUser,
28
+ resolveModel,
27
29
  type IssueRow,
28
30
  type ProjectRow,
29
31
  type CommentRow,
@@ -235,6 +237,10 @@ interface PayloadRepository {
235
237
  default_branch: string;
236
238
  created_at: string;
237
239
  updated_at: string;
240
+ // Non-Gitea extension. ework-daemon reads this to decide whether to push
241
+ // `--model <X>` on opencode spawn. Empty string = no override (let opencode
242
+ // pick). Gitea-strict consumers ignore unknown fields per JSON POST rules.
243
+ ework_model?: string;
238
244
  }
239
245
 
240
246
  interface PayloadIssue {
@@ -303,7 +309,7 @@ function buildUser(login: string, origin: string): PayloadUser {
303
309
  };
304
310
  }
305
311
 
306
- function buildRepository(project: ProjectRow, origin: string): PayloadRepository {
312
+ function buildRepository(project: ProjectRow, origin: string, model?: string): PayloadRepository {
307
313
  const fullName = `${project.owner}/${project.name}`;
308
314
  const htmlUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
309
315
  // clone_url must be a real Git remote (ework-web is NOT a Git server). Use the
@@ -311,7 +317,7 @@ function buildRepository(project: ProjectRow, origin: string): PayloadRepository
311
317
  // + .git for purely tracker-only projects (recipients that don't attempt git
312
318
  // clone will still see a syntactically valid URL).
313
319
  const upstream = getDefaultUpstreamUrl(project);
314
- return {
320
+ const repo: PayloadRepository = {
315
321
  id: project.id,
316
322
  owner: buildUser(project.owner, origin),
317
323
  name: project.name,
@@ -335,13 +341,18 @@ function buildRepository(project: ProjectRow, origin: string): PayloadRepository
335
341
  created_at: project.created_at,
336
342
  updated_at: project.updated_at,
337
343
  };
344
+ // Only attach ework_model when non-empty (keeps payload compact + lets
345
+ // Gitea-strict consumers ignore the field entirely on no-op cases).
346
+ if (model) repo.ework_model = model;
347
+ return repo;
338
348
  }
339
349
 
340
350
  function buildIssue(
341
351
  issue: IssueRow,
342
352
  project: ProjectRow,
343
353
  commentCount: number,
344
- origin: string
354
+ origin: string,
355
+ model?: string,
345
356
  ): PayloadIssue {
346
357
  const repoUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
347
358
  const issueUrl = `${repoUrl}/issues/${issue.number}`;
@@ -364,7 +375,7 @@ function buildIssue(
364
375
  closed_at: issue.closed_at,
365
376
  due_date: null,
366
377
  pull_request: null,
367
- repository: buildRepository(project, origin),
378
+ repository: buildRepository(project, origin, model),
368
379
  user: buildUser(issue.author, origin),
369
380
  };
370
381
  }
@@ -394,13 +405,14 @@ function buildCommentPayload(
394
405
  comment: CommentRow,
395
406
  project: ProjectRow,
396
407
  commentCount: number,
397
- origin: string
408
+ origin: string,
409
+ model?: string,
398
410
  ): CommentEventPayload {
399
411
  return {
400
412
  action: "created",
401
- issue: buildIssue(issue, project, commentCount, origin),
413
+ issue: buildIssue(issue, project, commentCount, origin, model),
402
414
  comment: buildComment(issue, comment, project, origin),
403
- repository: buildRepository(project, origin),
415
+ repository: buildRepository(project, origin, model),
404
416
  sender: buildUser(comment.author, origin),
405
417
  };
406
418
  }
@@ -410,12 +422,13 @@ function buildIssuePayload(
410
422
  project: ProjectRow,
411
423
  commentCount: number,
412
424
  action: IssueAction,
413
- origin: string
425
+ origin: string,
426
+ model?: string,
414
427
  ): IssueEventPayload {
415
428
  return {
416
429
  action,
417
- issue: buildIssue(issue, project, commentCount, origin),
418
- repository: buildRepository(project, origin),
430
+ issue: buildIssue(issue, project, commentCount, origin, model),
431
+ repository: buildRepository(project, origin, model),
419
432
  sender: buildUser(issue.author, origin),
420
433
  };
421
434
  }
@@ -578,7 +591,12 @@ export function emitIssueEvent(
578
591
  const issue = getIssueById(issueId);
579
592
  if (!issue) return;
580
593
  const commentCount = countCommentsSafe(issueId);
581
- const payload = buildIssuePayload(issue, project, commentCount, action, origin);
594
+ // Resolve model: project override > global default. Empty string = no
595
+ // override (daemon omits --model, lets opencode pick). globalDefault
596
+ // comes from the config table via loadConfig() — cheap DB read.
597
+ const globalDefault = loadConfig().defaultModel;
598
+ const model = resolveModel(project.model, globalDefault);
599
+ const payload = buildIssuePayload(issue, project, commentCount, action, origin, model);
582
600
  const rawBody = JSON.stringify(payload);
583
601
  fanOut(projectId, "issues", rawBody);
584
602
  } catch (e) {
@@ -605,7 +623,9 @@ export function emitCommentEvent(
605
623
  const comment = getCommentByIdSafe(commentId);
606
624
  if (!comment) return;
607
625
  const commentCount = countCommentsSafe(issueId);
608
- const payload = buildCommentPayload(issue, comment, project, commentCount, origin);
626
+ const globalDefault = loadConfig().defaultModel;
627
+ const model = resolveModel(project.model, globalDefault);
628
+ const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model);
609
629
  const rawBody = JSON.stringify(payload);
610
630
  fanOut(projectId, "issue_comment", rawBody);
611
631
  } catch (e) {