ework-web 0.10.56 → 0.10.58

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.56",
3
+ "version": "0.10.58",
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/index.ts CHANGED
@@ -373,6 +373,8 @@ const REPO_MEMBERS_RE = /^\/([^/]+)\/([^/]+)\/settings\/members$/;
373
373
  const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/(role|remove)$/;
374
374
  const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
375
375
  const REPO_VISIBILITY_RE = /^\/([^/]+)\/([^/]+)\/settings\/visibility$/;
376
+ const REPO_DISPATCH_RE = /^\/([^/]+)\/([^/]+)\/settings\/dispatch$/;
377
+ const SETTINGS_DISPATCH_RE = /^\/settings\/dispatch$/;
376
378
  const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
377
379
  const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
378
380
  const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
@@ -1279,6 +1281,32 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1279
1281
  return json({ ok: true, enabled });
1280
1282
  }
1281
1283
 
1284
+ if (req.method === "GET" && url.pathname === "/api/wake-policy") {
1285
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1286
+ const cfg = await getConfigAll();
1287
+ const appCfg = await loadConfig();
1288
+ const list = (k: string, d: string) => (cfg[k] ?? d).split(",").map((s) => s.trim()).filter(Boolean);
1289
+ return json({
1290
+ wakeKinds: list("wakeKinds", "human"),
1291
+ wakeLogins: list("wakeLogins", ""),
1292
+ noWakeLogins: list("noWakeLogins", appCfg.daemonBotLogin),
1293
+ });
1294
+ }
1295
+
1296
+ if (req.method === "POST" && url.pathname === "/api/wake-policy") {
1297
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1298
+ const payload = await req.json().catch(() => ({} as Record<string, unknown>));
1299
+ const p = payload as { wakeKinds?: unknown; wakeLogins?: unknown; noWakeLogins?: unknown };
1300
+ const join = (v: unknown) => Array.isArray(v) ? v.filter((s) => typeof s === "string").join(",") : (typeof v === "string" ? v : "");
1301
+ const wk = join(p.wakeKinds);
1302
+ const wl = join(p.wakeLogins);
1303
+ const nw = join(p.noWakeLogins);
1304
+ if (wk) await setConfig("wakeKinds", wk); else await deleteConfig("wakeKinds");
1305
+ if (wl) await setConfig("wakeLogins", wl); else await deleteConfig("wakeLogins");
1306
+ if (nw) await setConfig("noWakeLogins", nw); else await deleteConfig("noWakeLogins");
1307
+ return json({ ok: true });
1308
+ }
1309
+
1282
1310
  if (url.pathname === "/api/router/daemons" && req.method === "GET") {
1283
1311
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1284
1312
  try {
@@ -1318,7 +1346,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1318
1346
 
1319
1347
  if (url.pathname === "/settings") {
1320
1348
  if (req.method === "GET") {
1321
- return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels()).html);
1349
+ const dispatchCfg = await getConfigAll();
1350
+ return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels(), dispatchCfg["dispatchEnabled"] === "false").html);
1322
1351
  }
1323
1352
  if (req.method === "POST") {
1324
1353
  if (!rateLimit(`settings:${ip}`, 10, 10 / 60)) return html(errorPage("太快了", "请稍后再试"), 429);
@@ -1917,6 +1946,33 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1917
1946
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`可见性已更新为 ${visibility === "public" ? "公开" : "私有"}`)}`, 303);
1918
1947
  }
1919
1948
 
1949
+ const repoDispatchMatch = url.pathname.match(REPO_DISPATCH_RE);
1950
+ if (repoDispatchMatch) {
1951
+ const [, owner, repo] = repoDispatchMatch;
1952
+ if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1953
+ const project = await getProject(owner, repo);
1954
+ if (!project) return html(errorPage("项目不存在", ""), 404);
1955
+ const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
1956
+ if (!(await canAdminProject(project.id, ctx.user))) {
1957
+ return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1958
+ }
1959
+ const cfg = await getConfigAll();
1960
+ const key = `dispatchOff:${owner}/${repo}`;
1961
+ const isOff = cfg[key] === "1";
1962
+ if (isOff) { await deleteConfig(key); } else { await setConfig(key, "1"); }
1963
+ return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(isOff ? "已开启自动接单" : "已关闭自动接单")}`, 303);
1964
+ }
1965
+
1966
+ const settingsDispatchMatch = url.pathname.match(SETTINGS_DISPATCH_RE);
1967
+ if (settingsDispatchMatch) {
1968
+ if (!ctx.user || ctx.user.is_admin !== 1) return html(errorPage("需要管理员权限", ""), 403);
1969
+ const back = `${url.origin}/settings`;
1970
+ const cfg = await getConfigAll();
1971
+ const isOff = cfg["dispatchEnabled"] === "false";
1972
+ if (isOff) { await deleteConfig("dispatchEnabled"); } else { await setConfig("dispatchEnabled", "false"); }
1973
+ return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(isOff ? "已开启全局自动接单" : "已关闭全局自动接单")}`, 303);
1974
+ }
1975
+
1920
1976
  const whAction = url.pathname.match(WH_ACTION_RE);
1921
1977
  if (whAction) {
1922
1978
  const [, idStr, action] = whAction;
@@ -2145,7 +2201,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
2145
2201
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
2146
2202
  const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
2147
2203
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
2148
- return html(await buildProjectMembersPage(ctx.user!, project, flash));
2204
+ const dispatchCfg = await getConfigAll();
2205
+ const dispatchOff = dispatchCfg[`dispatchOff:${owner}/${repo}`] === "1";
2206
+ return html(await buildProjectMembersPage(ctx.user!, project, flash, dispatchOff));
2149
2207
  }
2150
2208
 
2151
2209
  const upstreamsPage = url.pathname.match(REPO_UPSTREAMS_RE);
@@ -26,6 +26,7 @@ export async function buildProjectMembersPage(
26
26
  viewer: UserRow,
27
27
  project: ProjectRow,
28
28
  flash: Flash | null,
29
+ dispatchOff: boolean,
29
30
  ): Promise<string> {
30
31
  const members = await listProjectMembersWithUsers(project.id);
31
32
  const users = await listUsers();
@@ -122,6 +123,12 @@ ${flashHtml}
122
123
  <button class="primary" type="submit">保存</button>
123
124
  </form>
124
125
 
126
+ <form class="card" method="POST" action="${escapeAttr(`/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/dispatch`)}">
127
+ <h2>AI 自动接单</h2>
128
+ <div class="hint">关闭后,本项目新建 issue 不会自动派给 AI;评论仍可显式召唤。</div>
129
+ <button type="submit" class="${dispatchOff ? "primary" : "secondary"}">${dispatchOff ? "🔔 开启自动接单" : "🔕 关闭自动接单"}</button>
130
+ </form>
131
+
125
132
  <div class="card">
126
133
  <h2>当前成员(${members.length})</h2>
127
134
  ${rowsHtml}
@@ -99,7 +99,7 @@ function buildDaemonSection(viewer: UserRow): string {
99
99
  </section>`;
100
100
  }
101
101
 
102
- export function buildSettingsPage(cfg: Config, saved: boolean, viewer: UserRow, models: CachedModel[]): { html: string } {
102
+ export function buildSettingsPage(cfg: Config, saved: boolean, viewer: UserRow, models: CachedModel[], globalDispatchOff: boolean): { html: string } {
103
103
  const groups = SETTINGS_GROUPS.map(
104
104
  (g) =>
105
105
  `<section class="sg"><h2>${escapeHtml(g.title)}</h2>${fieldInput(g, cfg, models)}</section>`
@@ -109,6 +109,9 @@ export function buildSettingsPage(cfg: Config, saved: boolean, viewer: UserRow,
109
109
  ? `<p class="hint">要增删朗读后端(kokoro / cosyvoice3 等),去 <a href="/admin/tts-backends">朗读后端管理</a>。</p>`
110
110
  : "";
111
111
  const modelRefreshForm = `<section class="sg"><h2>opencode 模型列表</h2><p class="hint" style="margin:0 0 .6rem">从 <code>opencode models</code> 拉取可用模型并刷新上方下拉列表。刷新后自动选定一个具体模型作为默认(不会留空)。</p><form method="POST" action="/settings/models/refresh"><button type="submit" class="secondary">🔄 刷新 opencode 模型列表</button></form></section>`;
112
+ const dispatchToggle = viewer.is_admin === 1
113
+ ? `<section class="sg"><h2>全局 AI 自动接单</h2><p class="hint" style="margin:0 0 .6rem">关闭后,所有项目新建 issue 都不会自动派给 AI;评论仍可显式召唤。</p><form method="POST" action="/settings/dispatch"><button type="submit" class="${globalDispatchOff ? "primary" : "secondary"}">${globalDispatchOff ? "🔔 开启全局自动接单" : "🔕 关闭全局自动接单"}</button></form></section>`
114
+ : "";
112
115
  const html = `<!doctype html>
113
116
  <html lang="zh"><head><meta charset="utf-8">
114
117
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -171,6 +174,7 @@ ${banner}
171
174
  <div class="bar"><button type="submit">保存</button><a class="a-back" href="/">返回</a></div>
172
175
  </form>
173
176
  ${modelRefreshForm}
177
+ ${dispatchToggle}
174
178
  ${ttsLink}
175
179
  ${buildDbSection(viewer)}
176
180
  ${buildDaemonSection(viewer)}
package/src/webhooks.ts CHANGED
@@ -691,12 +691,30 @@ export async function emitCommentEvent(
691
691
  if (!comment) return;
692
692
  const cfg = await getConfigAll();
693
693
  const scopeKey = `${project.owner}/${project.name}`;
694
- if (cfg["dispatchEnabled"] === "false" || cfg[`dispatchOff:${scopeKey}`] === "1" || (issue as IssueRow).ai_status === "dispatch_off") {
694
+ // Issue-level dispatch_off is a per-issue explicit close block comments entirely (same as halted).
695
+ if ((issue as IssueRow).ai_status === "dispatch_off") {
696
+ log.info(`webhook: issue dispatch disabled — skipping comment_created (author=${comment.author}) for ${scopeKey}#${issue.number}`);
697
+ return;
698
+ }
699
+ // Global/project-level are "default no auto-dispatch" strategies — comments can still explicitly wake AI.
700
+ if (cfg["dispatchEnabled"] === "false" || cfg[`dispatchOff:${scopeKey}`] === "1") {
695
701
  log.info(`webhook: dispatch disabled but waking via comment_created (author=${comment.author}) for ${scopeKey}#${issue.number}`);
696
702
  }
697
703
  const authorUser = await getUserByLogin(comment.author);
698
- if (authorUser && authorUser.kind !== "human") {
699
- log.info(`webhook: comment wake denied (author=${comment.author} kind=${authorUser.kind}) for ${scopeKey}#${issue.number}`);
704
+ const appCfg = await loadConfig();
705
+ const cfgList = (k: string, d: string) =>
706
+ (cfg[k] ?? d).split(",").map((s) => s.trim()).filter(Boolean);
707
+ const noWake = cfgList("noWakeLogins", appCfg.daemonBotLogin);
708
+ const okLogins = cfgList("wakeLogins", "");
709
+ const okKinds = cfgList("wakeKinds", "human");
710
+ const author = comment.author;
711
+ const denied =
712
+ noWake.includes(author) ? "deny-list" :
713
+ okLogins.includes(author) ? null :
714
+ (authorUser && !okKinds.includes(authorUser.kind)) ? `kind=${authorUser.kind}` :
715
+ null;
716
+ if (denied) {
717
+ log.info(`webhook: comment wake denied (author=${author} reason=${denied}) for ${scopeKey}#${issue.number}`);
700
718
  return;
701
719
  }
702
720
  if (authorUser && !(await canWriteProject(projectId, { login: comment.author, is_admin: authorUser.is_admin }))) {