ework-web 0.10.58 → 0.10.60
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 +41 -3
- package/src/render/layout.ts +1 -1
- package/src/views/projectAi.ts +88 -0
- package/src/views/projectMembers.ts +0 -7
- package/src/views/projectUpstreams.ts +3 -2
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
postComment,
|
|
39
39
|
setIssueState,
|
|
40
40
|
updateIssueAiStatus,
|
|
41
|
+
listIssues,
|
|
41
42
|
createAttachment,
|
|
42
43
|
getAttachment,
|
|
43
44
|
verifyUserPassword,
|
|
@@ -107,6 +108,7 @@ import { buildProjectMembersPage } from "./views/projectMembers";
|
|
|
107
108
|
import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
|
|
108
109
|
import { buildProjectLabelsPage } from "./views/projectLabels";
|
|
109
110
|
import { buildProjectModelPage } from "./views/projectModel";
|
|
111
|
+
import { buildProjectAiPage } from "./views/projectAi";
|
|
110
112
|
import { handleGiteaApi } from "./giteaApi";
|
|
111
113
|
import { deployRemoteDaemon, deployBatch, type DeployTarget } from "./daemon-deploy";
|
|
112
114
|
|
|
@@ -374,9 +376,11 @@ const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/
|
|
|
374
376
|
const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
|
|
375
377
|
const REPO_VISIBILITY_RE = /^\/([^/]+)\/([^/]+)\/settings\/visibility$/;
|
|
376
378
|
const REPO_DISPATCH_RE = /^\/([^/]+)\/([^/]+)\/settings\/dispatch$/;
|
|
379
|
+
const REPO_HALT_ALL_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai\/halt-all$/;
|
|
377
380
|
const SETTINGS_DISPATCH_RE = /^\/settings\/dispatch$/;
|
|
378
381
|
const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
|
|
379
382
|
const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
|
|
383
|
+
const REPO_AI_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai$/;
|
|
380
384
|
const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
|
|
381
385
|
const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
|
|
382
386
|
const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
|
|
@@ -1281,7 +1285,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1281
1285
|
return json({ ok: true, enabled });
|
|
1282
1286
|
}
|
|
1283
1287
|
|
|
1284
|
-
if (
|
|
1288
|
+
if (url.pathname === "/api/wake-policy" && req.method === "GET") {
|
|
1285
1289
|
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1286
1290
|
const cfg = await getConfigAll();
|
|
1287
1291
|
const appCfg = await loadConfig();
|
|
@@ -1952,7 +1956,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1952
1956
|
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1953
1957
|
const project = await getProject(owner, repo);
|
|
1954
1958
|
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1955
|
-
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/
|
|
1959
|
+
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/ai`;
|
|
1956
1960
|
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1957
1961
|
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1958
1962
|
}
|
|
@@ -1963,6 +1967,25 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1963
1967
|
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(isOff ? "已开启自动接单" : "已关闭自动接单")}`, 303);
|
|
1964
1968
|
}
|
|
1965
1969
|
|
|
1970
|
+
const repoHaltAllMatch = url.pathname.match(REPO_HALT_ALL_RE);
|
|
1971
|
+
if (repoHaltAllMatch) {
|
|
1972
|
+
const [, owner, repo] = repoHaltAllMatch;
|
|
1973
|
+
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1974
|
+
const project = await getProject(owner, repo);
|
|
1975
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1976
|
+
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/ai`;
|
|
1977
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1978
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1979
|
+
}
|
|
1980
|
+
const issues = await listIssues(project.id, { state: "all" });
|
|
1981
|
+
const processing = issues.filter((it) => it.ai_status === "processing");
|
|
1982
|
+
for (const it of processing) {
|
|
1983
|
+
await updateIssueAiStatus(it.id, "halted");
|
|
1984
|
+
void emitStatusChanged(project.id, it.number, "processing", "halted", ctx.user!.login, url.origin);
|
|
1985
|
+
}
|
|
1986
|
+
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已停止 ${processing.length} 个运行中AI会话`)}`, 303);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1966
1989
|
const settingsDispatchMatch = url.pathname.match(SETTINGS_DISPATCH_RE);
|
|
1967
1990
|
if (settingsDispatchMatch) {
|
|
1968
1991
|
if (!ctx.user || ctx.user.is_admin !== 1) return html(errorPage("需要管理员权限", ""), 403);
|
|
@@ -2201,9 +2224,24 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
2201
2224
|
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
2202
2225
|
const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
|
|
2203
2226
|
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
|
|
2227
|
+
return html(await buildProjectMembersPage(ctx.user!, project, flash));
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
const aiPage = url.pathname.match(REPO_AI_RE);
|
|
2231
|
+
if (aiPage) {
|
|
2232
|
+
const [, owner, repo] = aiPage;
|
|
2233
|
+
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
2234
|
+
const project = await getProject(owner, repo);
|
|
2235
|
+
if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
|
|
2236
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
2237
|
+
return html(errorPage("无权限", "需要该项目 admin 角色才能管理 AI 设置"), 403);
|
|
2238
|
+
}
|
|
2204
2239
|
const dispatchCfg = await getConfigAll();
|
|
2205
2240
|
const dispatchOff = dispatchCfg[`dispatchOff:${owner}/${repo}`] === "1";
|
|
2206
|
-
|
|
2241
|
+
const globalDispatchOff = dispatchCfg["dispatchEnabled"] === "false";
|
|
2242
|
+
const processingIssues = await listIssues(project.id, { state: "all" });
|
|
2243
|
+
const processingCount = processingIssues.filter((it) => it.ai_status === "processing").length;
|
|
2244
|
+
return html(buildProjectAiPage(project, dispatchOff, globalDispatchOff, processingCount).html);
|
|
2207
2245
|
}
|
|
2208
2246
|
|
|
2209
2247
|
const upstreamsPage = url.pathname.match(REPO_UPSTREAMS_RE);
|
package/src/render/layout.ts
CHANGED
|
@@ -275,7 +275,7 @@ export function aiStatusBadge(status: string | undefined): string {
|
|
|
275
275
|
if (!s) return "";
|
|
276
276
|
const map: Record<string, string> = {
|
|
277
277
|
processing: '<span class="ai-status-list" style="color:var(--accent)">⚙️ 处理中</span>',
|
|
278
|
-
halted: '<span class="ai-status-list" style="color:#bf8700"
|
|
278
|
+
halted: '<span class="ai-status-list" style="color:#bf8700">⏹️ 已停止</span>',
|
|
279
279
|
dispatch_off: '<span class="ai-status-list" style="color:#6f7781">🔕 不接单</span>',
|
|
280
280
|
completed: '<span class="ai-status-list" style="color:var(--green)">✓ 已完成</span>',
|
|
281
281
|
failed: '<span class="ai-status-list" style="color:#cf222e">✗ 失败</span>',
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { THEME_CSS, escapeHtml, escapeAttr } from "../render/layout";
|
|
2
|
+
import { projectSettingsTabsHTML } from "./projectUpstreams";
|
|
3
|
+
import type { ProjectRow } from "../store";
|
|
4
|
+
|
|
5
|
+
export function buildProjectAiPage(
|
|
6
|
+
project: ProjectRow,
|
|
7
|
+
dispatchOff: boolean,
|
|
8
|
+
globalDispatchOff: boolean,
|
|
9
|
+
processingCount: number,
|
|
10
|
+
): { html: string } {
|
|
11
|
+
const aiBase = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/ai`;
|
|
12
|
+
const dispatchAction = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/dispatch`;
|
|
13
|
+
const haltAllAction = `${aiBase}/halt-all`;
|
|
14
|
+
|
|
15
|
+
const dispatchCard = (() => {
|
|
16
|
+
const disabled = globalDispatchOff;
|
|
17
|
+
const hint = disabled
|
|
18
|
+
? `<div class="hint">⚠️ 全局自动接单已<a href="/settings">关闭</a>。项目级开关在此状态下无效——需先开启全局。</div>`
|
|
19
|
+
: `<div class="hint">关闭后,本项目新建 issue 不会自动派给 AI(评论仍可显式召唤)。<b>不影响已在运行的会话。</b></div>`;
|
|
20
|
+
return `<form class="card" method="POST" action="${escapeAttr(dispatchAction)}">
|
|
21
|
+
<h2>🔔 自动接单</h2>
|
|
22
|
+
${hint}
|
|
23
|
+
<div class="status-line">
|
|
24
|
+
<span class="status-dot ${dispatchOff ? "off" : "on"}"></span>
|
|
25
|
+
<span class="status-text">${dispatchOff ? "🔕 不接单" : "🔔 接单中"}</span>
|
|
26
|
+
</div>
|
|
27
|
+
<button type="submit" class="${dispatchOff ? "primary" : "secondary"}" ${disabled ? "disabled" : ""}>${dispatchOff ? "🔔 开启自动接单" : "🔕 关闭自动接单"}</button>
|
|
28
|
+
</form>`;
|
|
29
|
+
})();
|
|
30
|
+
|
|
31
|
+
const haltCard = (() => {
|
|
32
|
+
const hint = processingCount > 0
|
|
33
|
+
? `当前有 <b>${processingCount}</b> 个 AI 会话正在运行。点击下方按钮将全部停止(向 daemon 发送 halt 信号)。`
|
|
34
|
+
: `当前没有正在运行的 AI 会话。`;
|
|
35
|
+
return `<form class="card" method="POST" action="${escapeAttr(haltAllAction)}">
|
|
36
|
+
<h2>⏹️ 停止所有运行中AI</h2>
|
|
37
|
+
<div class="hint">${hint} <b>不影响自动接单状态。</b></div>
|
|
38
|
+
<div class="status-line">
|
|
39
|
+
<span class="status-dot ${processingCount > 0 ? "on" : "off"}"></span>
|
|
40
|
+
<span class="status-text">${processingCount > 0 ? `${processingCount} 个会话运行中` : "空闲"}</span>
|
|
41
|
+
</div>
|
|
42
|
+
<button type="submit" class="danger" ${processingCount === 0 ? "disabled" : ""}>⏹️ 停止全部</button>
|
|
43
|
+
</form>`;
|
|
44
|
+
})();
|
|
45
|
+
|
|
46
|
+
const html = `<!doctype html>
|
|
47
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
48
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
49
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
50
|
+
<title>${escapeHtml(project.owner + "/" + project.name)} · AI 设置</title>
|
|
51
|
+
<style>${THEME_CSS}
|
|
52
|
+
.nav{display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px}
|
|
53
|
+
.nav a{color:var(--header-text);opacity:.95}
|
|
54
|
+
.wrap{max-width:720px;margin:0 auto;padding:1rem}
|
|
55
|
+
.subtabs{display:flex;gap:.4rem;margin-bottom:1rem;flex-wrap:wrap}
|
|
56
|
+
.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}
|
|
57
|
+
.subtab.active{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
58
|
+
h1{font-size:18px;margin:0 0 .4rem}
|
|
59
|
+
.hint{color:var(--text-muted);font-size:13px;margin:.3rem 0 .6rem;line-height:1.5}
|
|
60
|
+
.card{border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;background:var(--bg-elev);margin-bottom:.9rem}
|
|
61
|
+
.card h2{font-size:14px;margin:0 0 .5rem}
|
|
62
|
+
.status-line{display:flex;align-items:center;gap:.5rem;margin-bottom:.7rem}
|
|
63
|
+
.status-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
|
64
|
+
.status-dot.on{background:var(--green,#3fb950)}
|
|
65
|
+
.status-dot.off{background:var(--text-muted)}
|
|
66
|
+
.status-text{font-size:13px;font-weight:600}
|
|
67
|
+
button{padding:.5rem 1.2rem;border:0;border-radius:6px;font-size:13px;cursor:pointer}
|
|
68
|
+
button.primary{background:var(--accent);color:#fff}
|
|
69
|
+
button.secondary{background:var(--bg-muted);color:var(--text);border:1px solid var(--border)}
|
|
70
|
+
button.danger{background:#da3633;color:#fff}
|
|
71
|
+
button:disabled{opacity:.5;cursor:not-allowed}
|
|
72
|
+
.future{opacity:.5;pointer-events:none}
|
|
73
|
+
</style></head><body>
|
|
74
|
+
<header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · ${escapeHtml(project.owner + "/" + project.name)}</span></header>
|
|
75
|
+
<main class="wrap">
|
|
76
|
+
${projectSettingsTabsHTML(project.owner, project.name, "ai")}
|
|
77
|
+
<h1>🤖 AI 设置</h1>
|
|
78
|
+
<p class="hint">两个独立控制:<b>🔔 自动接单</b>控制是否自动派新单(不影响运行中);<b>⏹️ 停止</b>杀死当前所有运行中AI会话(不影响接单状态)。模型选择请去 <a href="${escapeAttr(`/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/model`)}">⚙️ 模型</a> 标签页。</p>
|
|
79
|
+
${dispatchCard}
|
|
80
|
+
${haltCard}
|
|
81
|
+
|
|
82
|
+
<div class="card future">
|
|
83
|
+
<h2>更多配置(规划中)</h2>
|
|
84
|
+
<div class="hint">后续版本将在此标签页增加:项目级唤醒策略、Nudge 间隔、Observer 开关等。</div>
|
|
85
|
+
</div>
|
|
86
|
+
</main></body></html>`;
|
|
87
|
+
return { html };
|
|
88
|
+
}
|
|
@@ -26,7 +26,6 @@ export async function buildProjectMembersPage(
|
|
|
26
26
|
viewer: UserRow,
|
|
27
27
|
project: ProjectRow,
|
|
28
28
|
flash: Flash | null,
|
|
29
|
-
dispatchOff: boolean,
|
|
30
29
|
): Promise<string> {
|
|
31
30
|
const members = await listProjectMembersWithUsers(project.id);
|
|
32
31
|
const users = await listUsers();
|
|
@@ -123,12 +122,6 @@ ${flashHtml}
|
|
|
123
122
|
<button class="primary" type="submit">保存</button>
|
|
124
123
|
</form>
|
|
125
124
|
|
|
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
|
-
|
|
132
125
|
<div class="card">
|
|
133
126
|
<h2>当前成员(${members.length})</h2>
|
|
134
127
|
${rowsHtml}
|
|
@@ -16,7 +16,7 @@ interface Flash {
|
|
|
16
16
|
export function projectSettingsTabsHTML(
|
|
17
17
|
owner: string,
|
|
18
18
|
name: string,
|
|
19
|
-
active: "webhooks" | "members" | "upstreams" | "model" | "labels",
|
|
19
|
+
active: "webhooks" | "members" | "upstreams" | "model" | "labels" | "ai",
|
|
20
20
|
): string {
|
|
21
21
|
const base = `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/settings`;
|
|
22
22
|
const cls = (which: typeof active) => (active === which ? " active" : "");
|
|
@@ -25,7 +25,8 @@ export function projectSettingsTabsHTML(
|
|
|
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
27
|
<a class="subtab${cls("labels")}" href="${escapeAttr(base)}/labels">🏷️ 标签</a>
|
|
28
|
-
<a class="subtab${cls("
|
|
28
|
+
<a class="subtab${cls("ai")}" href="${escapeAttr(base)}/ai">🤖 AI</a>
|
|
29
|
+
<a class="subtab${cls("model")}" href="${escapeAttr(base)}/model">⚙️ 模型</a>
|
|
29
30
|
</nav>`;
|
|
30
31
|
}
|
|
31
32
|
|