ework-web 0.10.112 → 0.10.113

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.112",
3
+ "version": "0.10.113",
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
@@ -393,6 +393,7 @@ const REPO_VISIBILITY_RE = /^\/([^/]+)\/([^/]+)\/settings\/visibility$/;
393
393
  const REPO_DISPATCH_RE = /^\/([^/]+)\/([^/]+)\/settings\/dispatch$/;
394
394
 
395
395
  const REPO_WAKE_LOGINS_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai\/wake-logins$/;
396
+ const REPO_CONCURRENCY_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai\/concurrency$/;
396
397
  const REPO_HALT_ALL_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai\/halt-all$/;
397
398
  const SETTINGS_DISPATCH_RE = /^\/settings\/dispatch$/;
398
399
  const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
@@ -612,7 +613,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
612
613
  const aiStatus = issue?.ai_status ?? "";
613
614
  const issueOff = aiStatus === "dispatch_off" || aiStatus === "halted";
614
615
  const sessionResetMs = Number(cfgKv[`sessionReset:${owner}/${repo}#${number}`]) || null;
615
- return json({ dispatchOff: globalOff || projectOff || issueOff, aiStatus, sessionResetMs });
616
+ const concurrency = Number(cfgKv[`concurrency:${owner}/${repo}`]) || null;
617
+ return json({ dispatchOff: globalOff || projectOff || issueOff, aiStatus, sessionResetMs, concurrency });
616
618
  }
617
619
  if (url.pathname === "/api/v1/wake-logins" && req.method === "GET") {
618
620
  const owner = url.searchParams.get("owner") ?? "";
@@ -2173,7 +2175,30 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
2173
2175
  return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(isOff ? "已开启自动接单" : "已关闭自动接单")}`, 303);
2174
2176
  }
2175
2177
 
2178
+ const repoConcurrencyMatch = url.pathname.match(REPO_CONCURRENCY_RE);
2176
2179
  const repoWakeLoginsMatch = url.pathname.match(REPO_WAKE_LOGINS_RE);
2180
+ if (repoConcurrencyMatch) {
2181
+ const [, owner, repo] = repoConcurrencyMatch;
2182
+ if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
2183
+ const project = await getProject(owner, repo);
2184
+ if (!project) return html(errorPage("项目不存在", ""), 404);
2185
+ const aiBack = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/ai`;
2186
+ if (!(await canAdminProject(project.id, ctx.user))) {
2187
+ return Response.redirect(`${aiBack}?err=${encodeURIComponent("无权限")}`, 303);
2188
+ }
2189
+ const fd = await req.formData();
2190
+ const raw = String(fd.get("limit") ?? "").trim();
2191
+ const parsed = Number(raw);
2192
+ const key = `concurrency:${owner}/${repo}`;
2193
+ if (raw === "" || parsed === 0) {
2194
+ await deleteConfig(key);
2195
+ } else if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
2196
+ return Response.redirect(`${aiBack}?err=${encodeURIComponent("并发上限需为 1-100 的整数(0 或留空表示不限制)")}`, 303);
2197
+ } else {
2198
+ await setConfig(key, String(parsed));
2199
+ }
2200
+ return Response.redirect(aiBack, 303);
2201
+ }
2177
2202
  if (repoWakeLoginsMatch) {
2178
2203
  const [, owner, repo] = repoWakeLoginsMatch;
2179
2204
  if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
@@ -2528,7 +2553,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
2528
2553
  const running = await getRunningSessionsForProject(`${owner}/${repo}`);
2529
2554
  const processingCount = new Set(running.map((r) => r.issueNumber)).size;
2530
2555
  const wakeLoginsRaw = dispatchCfg[`wakeLogins:${owner}/${repo}`] ?? "";
2531
- return html(buildProjectAiPage(project, dispatchOff, globalDispatchOff, processingCount, wakeLoginsRaw).html);
2556
+ const concurrencyLimit = dispatchCfg[`concurrency:${owner}/${repo}`] ?? "";
2557
+ return html(buildProjectAiPage(project, dispatchOff, globalDispatchOff, processingCount, wakeLoginsRaw, concurrencyLimit).html);
2532
2558
  }
2533
2559
 
2534
2560
  const upstreamsPage = url.pathname.match(REPO_UPSTREAMS_RE);
@@ -8,6 +8,7 @@ export function buildProjectAiPage(
8
8
  globalDispatchOff: boolean,
9
9
  processingCount: number,
10
10
  wakeLoginsRaw = "",
11
+ concurrencyLimit = "",
11
12
  ): { html: string } {
12
13
  const aiBase = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/ai`;
13
14
  const dispatchAction = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/dispatch`;
@@ -35,6 +36,13 @@ ${hint}
35
36
  <div class="hint">默认只有平台成员能唤醒 AI。把外部 GitHub 用户加进来(逗号或换行分隔),他们在<b>本项目</b>的新 issue / 评论就会自动触发 AI 处理。bot 账号(如 github-actions[bot])永远无效;全局与项目接单开关仍是总闸。issue 评论区对外部用户也有「+白名单」一键按钮。清空内容保存即删除白名单。</div>
36
37
  <textarea name="logins" rows="4" style="width:100%;box-sizing:border-box;background:var(--bg-muted);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:.5rem;font-family:inherit;font-size:13px" placeholder="stirp&#10;Rika-xie">${escapeHtml(wakeList.join("\n"))}</textarea>
37
38
  <button type="submit" class="primary" style="margin-top:.5rem">保存白名单${wakeList.length > 0 ? `(当前 ${wakeList.length} 人)` : ""}</button>
39
+ </form>`;
40
+
41
+ const concurrencyCard = `<form class="card" method="POST" action="${escapeAttr(`${aiBase}/concurrency`)}">
42
+ <h2>⚡ 项目并发上限</h2>
43
+ <div class="hint">限制<b>本项目</b>同时运行的 AI 会话数量(例如 10 = 最多 10 个并行)。超出部分自动排队,有空位再跑。留空或 0 = 不限制(只受 daemon 全局并发约束)。对所有 daemon 生效。</div>
44
+ <input name="limit" type="number" min="0" max="100" inputmode="numeric" value="${escapeAttr(concurrencyLimit)}" placeholder="0" style="width:8rem;background:var(--bg-muted);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:.5rem;font-size:14px">
45
+ <button type="submit" class="primary" style="margin-left:.6rem">保存${concurrencyLimit ? `(当前上限 ${escapeHtml(concurrencyLimit)})` : "(当前不限制)"}</button>
38
46
  </form>`;
39
47
 
40
48
  const haltCard = (() => {
@@ -87,6 +95,7 @@ ${projectSettingsTabsHTML(project.owner, project.name, "ai")}
87
95
  <p class="hint">两个独立控制:<b>🔔 自动接单</b>控制是否自动派新单(不影响运行中);<b>⏹️ 停止</b>杀死当前所有运行中AI会话(不影响接单状态)。模型选择请去 <a href="${escapeAttr(`/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/model`)}">⚙️ 模型</a> 标签页。</p>
88
96
  ${dispatchCard}
89
97
  ${wakeCard}
98
+ ${concurrencyCard}
90
99
  ${haltCard}
91
100
 
92
101
  <div class="card future">