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 +1 -1
- package/src/config.ts +13 -1
- package/src/db.ts +3 -0
- package/src/index.ts +51 -1
- package/src/opencode.ts +30 -0
- package/src/schema.sql +14 -0
- package/src/store.ts +58 -0
- package/src/views/projectModel.ts +77 -0
- package/src/views/projectUpstreams.ts +2 -1
- package/src/views/settings.ts +23 -4
- package/src/webhooks.ts +32 -12
package/package.json
CHANGED
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
|
|
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
|
|
package/src/views/settings.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|