ework-web 0.10.57 → 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.57",
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$/;
@@ -1344,7 +1346,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1344
1346
 
1345
1347
  if (url.pathname === "/settings") {
1346
1348
  if (req.method === "GET") {
1347
- 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);
1348
1351
  }
1349
1352
  if (req.method === "POST") {
1350
1353
  if (!rateLimit(`settings:${ip}`, 10, 10 / 60)) return html(errorPage("太快了", "请稍后再试"), 429);
@@ -1943,6 +1946,33 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1943
1946
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`可见性已更新为 ${visibility === "public" ? "公开" : "私有"}`)}`, 303);
1944
1947
  }
1945
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
+
1946
1976
  const whAction = url.pathname.match(WH_ACTION_RE);
1947
1977
  if (whAction) {
1948
1978
  const [, idStr, action] = whAction;
@@ -2171,7 +2201,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
2171
2201
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
2172
2202
  const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
2173
2203
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
2174
- 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));
2175
2207
  }
2176
2208
 
2177
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,7 +691,13 @@ 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);