ework-web 0.10.47 → 0.10.49
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/index.ts +54 -4
- package/src/render/layout.ts +36 -1
- package/src/views/issueList.ts +3 -2
- package/src/views/issues.ts +3 -2
- package/src/webhooks.ts +19 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { spawn } from "child_process";
|
|
|
5
5
|
import { homedir } from "os";
|
|
6
6
|
import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
|
|
7
7
|
import type { Config } from "./config";
|
|
8
|
-
import { setConfig, initDB, getDB } from "./db";
|
|
8
|
+
import { setConfig, deleteConfig, getConfigAll, initDB, getDB } from "./db";
|
|
9
9
|
import { getActiveDaemons, listAllDaemons, getSessionDaemonMap, resolveDaemonEndpoint } from "./coordination";
|
|
10
10
|
import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql, generateMysqlDDL } from "./db-admin";
|
|
11
11
|
import type { MysqlTargetOpts } from "./db-admin";
|
|
@@ -378,7 +378,7 @@ const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
|
|
|
378
378
|
const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
|
|
379
379
|
const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
|
|
380
380
|
const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
|
|
381
|
-
const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume)$/;
|
|
381
|
+
const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume|dispatch-off|dispatch-on)$/;
|
|
382
382
|
const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
|
|
383
383
|
const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
|
|
384
384
|
const SESSIONS_RE = /^\/sessions$/;
|
|
@@ -1199,6 +1199,53 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1199
1199
|
});
|
|
1200
1200
|
}
|
|
1201
1201
|
|
|
1202
|
+
// ─── Dispatch switch (global / project) ───
|
|
1203
|
+
|
|
1204
|
+
const projectDispatchRe = /^\/api\/([^/]+)\/([^/]+)\/dispatch$/;
|
|
1205
|
+
|
|
1206
|
+
if (req.method === "GET" && url.pathname === "/api/dispatch") {
|
|
1207
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1208
|
+
const cfg = await getConfigAll();
|
|
1209
|
+
const globalEnabled = cfg["dispatchEnabled"] !== "false";
|
|
1210
|
+
const projectOverrides: Record<string, boolean> = {};
|
|
1211
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
1212
|
+
if (k.startsWith("dispatchOff:") && v === "1") {
|
|
1213
|
+
projectOverrides[k.slice("dispatchOff:".length)] = false;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
return json({ globalEnabled, projectOverrides });
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (req.method === "POST" && url.pathname === "/api/dispatch") {
|
|
1220
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1221
|
+
const payload = await req.json().catch(() => ({} as unknown));
|
|
1222
|
+
const enabled = !(payload && typeof payload === "object" && (payload as { enabled?: unknown }).enabled === false);
|
|
1223
|
+
if (enabled) {
|
|
1224
|
+
await deleteConfig("dispatchEnabled");
|
|
1225
|
+
} else {
|
|
1226
|
+
await setConfig("dispatchEnabled", "false");
|
|
1227
|
+
}
|
|
1228
|
+
return json({ ok: true, globalEnabled: enabled });
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (req.method === "POST" && projectDispatchRe.test(url.pathname)) {
|
|
1232
|
+
const m = url.pathname.match(projectDispatchRe)!;
|
|
1233
|
+
const owner = m[1]!;
|
|
1234
|
+
const repo = m[2]!;
|
|
1235
|
+
const project = await getProject(owner, repo);
|
|
1236
|
+
if (!project) return json({ error: "project not found" }, 404);
|
|
1237
|
+
if (!(await canWriteProject(project.id, ctx.user))) return json({ error: "insufficient permissions" }, 403);
|
|
1238
|
+
const payload = await req.json().catch(() => ({} as unknown));
|
|
1239
|
+
const enabled = !(payload && typeof payload === "object" && (payload as { enabled?: unknown }).enabled === false);
|
|
1240
|
+
const key = `dispatchOff:${owner}/${repo}`;
|
|
1241
|
+
if (enabled) {
|
|
1242
|
+
await deleteConfig(key);
|
|
1243
|
+
} else {
|
|
1244
|
+
await setConfig(key, "1");
|
|
1245
|
+
}
|
|
1246
|
+
return json({ ok: true, enabled });
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1202
1249
|
if (url.pathname === "/api/router/daemons" && req.method === "GET") {
|
|
1203
1250
|
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1204
1251
|
try {
|
|
@@ -1674,10 +1721,13 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1674
1721
|
if (!project) return json({ error: "project not found" }, 404);
|
|
1675
1722
|
const issue = await getIssueWithMeta(project.id, number);
|
|
1676
1723
|
if (!issue) return json({ error: "issue not found" }, 404);
|
|
1677
|
-
const
|
|
1724
|
+
const statusMap: Record<string, string> = { halt: "halted", resume: "", "dispatch-off": "dispatch_off", "dispatch-on": "" };
|
|
1725
|
+
const newStatus = statusMap[action] ?? "";
|
|
1678
1726
|
const oldStatus = issue.ai_status ?? "";
|
|
1679
1727
|
await updateIssueAiStatus(issue.id, newStatus);
|
|
1680
|
-
|
|
1728
|
+
if (action === "halt" || action === "resume") {
|
|
1729
|
+
void emitStatusChanged(project.id, issue.id, oldStatus, newStatus, ctx.user!.login, url.origin);
|
|
1730
|
+
}
|
|
1681
1731
|
return json({ ok: true, status: newStatus });
|
|
1682
1732
|
} catch (e) {
|
|
1683
1733
|
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
package/src/render/layout.ts
CHANGED
|
@@ -132,11 +132,14 @@ header.topbar .num{opacity:.7}
|
|
|
132
132
|
.ai-badge{font-size:12px;padding:.1rem .5rem;border-radius:10px;font-weight:600}
|
|
133
133
|
.ai-processing{background:#0969da;color:#fff;animation:ai-pulse 2s ease-in-out infinite}
|
|
134
134
|
.ai-halted{background:#bf8700;color:#fff}
|
|
135
|
+
.ai-dispatch-off{background:#6f7781;color:#fff}
|
|
135
136
|
.ai-completed{background:#1a7f37;color:#fff}
|
|
136
137
|
.ai-failed{background:#cf222e;color:#fff}
|
|
137
138
|
@keyframes ai-pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
|
138
139
|
.halt-btn{font-size:12px;padding:.15rem .6rem;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev);color:#cf222e;cursor:pointer;font-weight:600}
|
|
139
140
|
.halt-btn:hover{background:#cf222e;color:#fff}
|
|
141
|
+
.dispatch-btn{font-size:12px;padding:.15rem .6rem;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev);color:#6f7781;cursor:pointer;font-weight:600}
|
|
142
|
+
.dispatch-btn:hover{background:#6f7781;color:#fff}
|
|
140
143
|
`;
|
|
141
144
|
|
|
142
145
|
export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string {
|
|
@@ -161,6 +164,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
161
164
|
const map: Record<string, { cls: string; label: string }> = {
|
|
162
165
|
processing: { cls: "ai-processing", label: "⚙️ 处理中" },
|
|
163
166
|
halted: { cls: "ai-halted", label: "⏸️ 已暂停" },
|
|
167
|
+
dispatch_off: { cls: "ai-dispatch-off", label: "🔕 不接单" },
|
|
164
168
|
completed: { cls: "ai-completed", label: "✓ 已完成" },
|
|
165
169
|
failed: { cls: "ai-failed", label: "✗ 失败" },
|
|
166
170
|
};
|
|
@@ -171,6 +175,11 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
171
175
|
const haltBtnHtml = props.writesEnabled !== false && props.aiStatus !== "halted"
|
|
172
176
|
? `<button type="button" id="haltBtn" class="halt-btn" title="停止 AI 处理">⏹ 停止</button>`
|
|
173
177
|
: "";
|
|
178
|
+
const dispatchBtnHtml = props.writesEnabled !== false
|
|
179
|
+
? props.aiStatus === "dispatch_off"
|
|
180
|
+
? `<button type="button" id="dispatchBtn" class="dispatch-btn" title="允许自动接单">🔔 接单</button>`
|
|
181
|
+
: `<button type="button" id="dispatchBtn" class="dispatch-btn" title="设为不自动接单">🔕 不接单</button>`
|
|
182
|
+
: "";
|
|
174
183
|
return `<!doctype html>
|
|
175
184
|
<html lang="zh">
|
|
176
185
|
<head>
|
|
@@ -192,7 +201,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
192
201
|
<h1>${escapeHtml(props.issueTitle)}</h1>
|
|
193
202
|
<div class="meta-status">
|
|
194
203
|
<span class="state-badge ${stateClass}">${stateLabel}</span>
|
|
195
|
-
${aiBadgeHtml}${haltBtnHtml}
|
|
204
|
+
${aiBadgeHtml}${haltBtnHtml}${dispatchBtnHtml}
|
|
196
205
|
${labelsHtml}${labelPickerBtn}
|
|
197
206
|
<span class="count" id="count">…</span>
|
|
198
207
|
${props.upstreamWebUrl ? `<a class="upstream-link" href="${escapeAttr(props.upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="跳转到上游仓库">🔗 查看上游</a>` : ""}
|
|
@@ -237,6 +246,19 @@ ${haltBtnHtml ? `<script>
|
|
|
237
246
|
});
|
|
238
247
|
})();
|
|
239
248
|
</script>` : ""}
|
|
249
|
+
${dispatchBtnHtml ? `<script>
|
|
250
|
+
(function(){
|
|
251
|
+
var btn=document.getElementById("dispatchBtn");
|
|
252
|
+
if(!btn)return;
|
|
253
|
+
btn.addEventListener("click",function(){
|
|
254
|
+
var off=btn.textContent.indexOf("不接单")>=0;
|
|
255
|
+
btn.disabled=true;btn.textContent="⏳ …";
|
|
256
|
+
fetch(window.location.pathname+"/"+(off?"dispatch-off":"dispatch-on"),{method:"POST"}).then(function(r){return r.json()}).then(function(d){
|
|
257
|
+
if(d.ok){location.reload()}else{alert(d.error||"操作失败");btn.disabled=false;btn.textContent=off?"🔕 不接单":"🔔 接单"}
|
|
258
|
+
}).catch(function(e){alert("网络错误: "+e);btn.disabled=false;btn.textContent=off?"🔕 不接单":"🔔 接单"})
|
|
259
|
+
});
|
|
260
|
+
})();
|
|
261
|
+
</script>` : ""}
|
|
240
262
|
</body>
|
|
241
263
|
</html>`;
|
|
242
264
|
}
|
|
@@ -250,6 +272,19 @@ export function escapeHtml(s: string): string {
|
|
|
250
272
|
.replace(/'/g, "'");
|
|
251
273
|
}
|
|
252
274
|
|
|
275
|
+
export function aiStatusBadge(status: string | undefined): string {
|
|
276
|
+
const s = status ?? "";
|
|
277
|
+
if (!s) return "";
|
|
278
|
+
const map: Record<string, string> = {
|
|
279
|
+
processing: '<span class="ai-status-list" style="color:var(--accent)">⚙️ 处理中</span>',
|
|
280
|
+
halted: '<span class="ai-status-list" style="color:#bf8700">⏸️ 已暂停</span>',
|
|
281
|
+
dispatch_off: '<span class="ai-status-list" style="color:#6f7781">🔕 不接单</span>',
|
|
282
|
+
completed: '<span class="ai-status-list" style="color:var(--green)">✓ 已完成</span>',
|
|
283
|
+
failed: '<span class="ai-status-list" style="color:#cf222e">✗ 失败</span>',
|
|
284
|
+
};
|
|
285
|
+
return map[s] ?? "";
|
|
286
|
+
}
|
|
287
|
+
|
|
253
288
|
export function escapeAttr(s: string): string {
|
|
254
289
|
return escapeHtml(s);
|
|
255
290
|
}
|
package/src/views/issueList.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML } from "../render/layout";
|
|
1
|
+
import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML, aiStatusBadge } from "../render/layout";
|
|
2
2
|
import { getProject, listIssues, listLabelsForIssues, type IssueWithMeta, type LabelRow } from "../store";
|
|
3
3
|
import { relTime } from "../render/components";
|
|
4
4
|
|
|
@@ -22,9 +22,10 @@ function issueRow(
|
|
|
22
22
|
const chips = labels.length
|
|
23
23
|
? `<span class="row-labels">${labels.map((l) => labelChip(l, owner, repo, state)).join("")}</span>`
|
|
24
24
|
: "";
|
|
25
|
+
const aiBadge = aiStatusBadge(it.ai_status);
|
|
25
26
|
return `<a class="row" href="${escapeAttr(href)}">
|
|
26
27
|
<div class="row-title">${title}${chips}</div>
|
|
27
|
-
<div class="row-meta">#${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}</div>
|
|
28
|
+
<div class="row-meta">#${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}${aiBadge ? ` · ${aiBadge}` : ""}</div>
|
|
28
29
|
</a>`;
|
|
29
30
|
}
|
|
30
31
|
|
package/src/views/issues.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML } from "../render/layout";
|
|
1
|
+
import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML, aiStatusBadge } from "../render/layout";
|
|
2
2
|
import { listAllIssues, listLabelsForIssues, type IssueWithMeta, type LabelRow } from "../store";
|
|
3
3
|
import { relTime } from "../render/components";
|
|
4
4
|
|
|
@@ -16,9 +16,10 @@ function issueRow(it: IssueWithMeta, q: string, state: string, labels: LabelRow[
|
|
|
16
16
|
const chips = labels.length
|
|
17
17
|
? `<span class="row-labels">${labels.map((l) => labelChip(l, it.project_owner, it.project_name, state)).join("")}</span>`
|
|
18
18
|
: "";
|
|
19
|
+
const aiBadge = aiStatusBadge(it.ai_status);
|
|
19
20
|
return `<a class="row" href="${escapeAttr(href)}">
|
|
20
21
|
<div class="row-title">${title}${chips}</div>
|
|
21
|
-
<div class="row-meta">${projectStr} · #${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}</div>
|
|
22
|
+
<div class="row-meta">${projectStr} · #${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}${aiBadge ? ` · ${aiBadge}` : ""}</div>
|
|
22
23
|
</a>`;
|
|
23
24
|
}
|
|
24
25
|
|
package/src/webhooks.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// the full retry trail rather than an opaque final status.
|
|
17
17
|
|
|
18
18
|
import { createHmac, randomUUID } from "node:crypto";
|
|
19
|
-
import { getDB } from "./db";
|
|
19
|
+
import { getDB, getConfigAll } from "./db";
|
|
20
20
|
import { log } from "./logger";
|
|
21
21
|
import { loadConfig } from "./config";
|
|
22
22
|
import {
|
|
@@ -635,6 +635,24 @@ export async function emitIssueEvent(
|
|
|
635
635
|
if (!project) return;
|
|
636
636
|
const issue = await getIssueById(issueId);
|
|
637
637
|
if (!issue) return;
|
|
638
|
+
|
|
639
|
+
if (action === "opened") {
|
|
640
|
+
const cfg = await getConfigAll();
|
|
641
|
+
const scopeKey = `${project.owner}/${project.name}`;
|
|
642
|
+
if (cfg["dispatchEnabled"] === "false") {
|
|
643
|
+
log.info(`webhook: global dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (cfg[`dispatchOff:${scopeKey}`] === "1") {
|
|
647
|
+
log.info(`webhook: project dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if ((issue as IssueRow).ai_status === "dispatch_off") {
|
|
651
|
+
log.info(`webhook: issue dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
638
656
|
const commentCount = await countCommentsSafe(issueId);
|
|
639
657
|
// Resolve model: project override > global default. Empty string = no
|
|
640
658
|
// override (daemon omits --model, lets opencode pick). globalDefault
|