ework-web 0.10.57 → 0.10.59
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 +48 -1
- package/src/views/projectAi.ts +68 -0
- package/src/views/projectUpstreams.ts +3 -2
- package/src/views/settings.ts +5 -1
- package/src/webhooks.ts +7 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -107,6 +107,7 @@ import { buildProjectMembersPage } from "./views/projectMembers";
|
|
|
107
107
|
import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
|
|
108
108
|
import { buildProjectLabelsPage } from "./views/projectLabels";
|
|
109
109
|
import { buildProjectModelPage } from "./views/projectModel";
|
|
110
|
+
import { buildProjectAiPage } from "./views/projectAi";
|
|
110
111
|
import { handleGiteaApi } from "./giteaApi";
|
|
111
112
|
import { deployRemoteDaemon, deployBatch, type DeployTarget } from "./daemon-deploy";
|
|
112
113
|
|
|
@@ -373,8 +374,11 @@ const REPO_MEMBERS_RE = /^\/([^/]+)\/([^/]+)\/settings\/members$/;
|
|
|
373
374
|
const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/(role|remove)$/;
|
|
374
375
|
const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
|
|
375
376
|
const REPO_VISIBILITY_RE = /^\/([^/]+)\/([^/]+)\/settings\/visibility$/;
|
|
377
|
+
const REPO_DISPATCH_RE = /^\/([^/]+)\/([^/]+)\/settings\/dispatch$/;
|
|
378
|
+
const SETTINGS_DISPATCH_RE = /^\/settings\/dispatch$/;
|
|
376
379
|
const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
|
|
377
380
|
const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
|
|
381
|
+
const REPO_AI_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai$/;
|
|
378
382
|
const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
|
|
379
383
|
const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
|
|
380
384
|
const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
|
|
@@ -1344,7 +1348,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1344
1348
|
|
|
1345
1349
|
if (url.pathname === "/settings") {
|
|
1346
1350
|
if (req.method === "GET") {
|
|
1347
|
-
|
|
1351
|
+
const dispatchCfg = await getConfigAll();
|
|
1352
|
+
return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels(), dispatchCfg["dispatchEnabled"] === "false").html);
|
|
1348
1353
|
}
|
|
1349
1354
|
if (req.method === "POST") {
|
|
1350
1355
|
if (!rateLimit(`settings:${ip}`, 10, 10 / 60)) return html(errorPage("太快了", "请稍后再试"), 429);
|
|
@@ -1943,6 +1948,33 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1943
1948
|
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`可见性已更新为 ${visibility === "public" ? "公开" : "私有"}`)}`, 303);
|
|
1944
1949
|
}
|
|
1945
1950
|
|
|
1951
|
+
const repoDispatchMatch = url.pathname.match(REPO_DISPATCH_RE);
|
|
1952
|
+
if (repoDispatchMatch) {
|
|
1953
|
+
const [, owner, repo] = repoDispatchMatch;
|
|
1954
|
+
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1955
|
+
const project = await getProject(owner, repo);
|
|
1956
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1957
|
+
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
|
|
1958
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1959
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1960
|
+
}
|
|
1961
|
+
const cfg = await getConfigAll();
|
|
1962
|
+
const key = `dispatchOff:${owner}/${repo}`;
|
|
1963
|
+
const isOff = cfg[key] === "1";
|
|
1964
|
+
if (isOff) { await deleteConfig(key); } else { await setConfig(key, "1"); }
|
|
1965
|
+
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(isOff ? "已开启自动接单" : "已关闭自动接单")}`, 303);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
const settingsDispatchMatch = url.pathname.match(SETTINGS_DISPATCH_RE);
|
|
1969
|
+
if (settingsDispatchMatch) {
|
|
1970
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return html(errorPage("需要管理员权限", ""), 403);
|
|
1971
|
+
const back = `${url.origin}/settings`;
|
|
1972
|
+
const cfg = await getConfigAll();
|
|
1973
|
+
const isOff = cfg["dispatchEnabled"] === "false";
|
|
1974
|
+
if (isOff) { await deleteConfig("dispatchEnabled"); } else { await setConfig("dispatchEnabled", "false"); }
|
|
1975
|
+
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(isOff ? "已开启全局自动接单" : "已关闭全局自动接单")}`, 303);
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1946
1978
|
const whAction = url.pathname.match(WH_ACTION_RE);
|
|
1947
1979
|
if (whAction) {
|
|
1948
1980
|
const [, idStr, action] = whAction;
|
|
@@ -2174,6 +2206,21 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
2174
2206
|
return html(await buildProjectMembersPage(ctx.user!, project, flash));
|
|
2175
2207
|
}
|
|
2176
2208
|
|
|
2209
|
+
const aiPage = url.pathname.match(REPO_AI_RE);
|
|
2210
|
+
if (aiPage) {
|
|
2211
|
+
const [, owner, repo] = aiPage;
|
|
2212
|
+
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
2213
|
+
const project = await getProject(owner, repo);
|
|
2214
|
+
if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
|
|
2215
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
2216
|
+
return html(errorPage("无权限", "需要该项目 admin 角色才能管理 AI 设置"), 403);
|
|
2217
|
+
}
|
|
2218
|
+
const dispatchCfg = await getConfigAll();
|
|
2219
|
+
const dispatchOff = dispatchCfg[`dispatchOff:${owner}/${repo}`] === "1";
|
|
2220
|
+
const globalDispatchOff = dispatchCfg["dispatchEnabled"] === "false";
|
|
2221
|
+
return html(buildProjectAiPage(project, dispatchOff, globalDispatchOff).html);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2177
2224
|
const upstreamsPage = url.pathname.match(REPO_UPSTREAMS_RE);
|
|
2178
2225
|
if (upstreamsPage) {
|
|
2179
2226
|
const [, owner, repo] = upstreamsPage;
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
): { html: string } {
|
|
10
|
+
const dispatchAction = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/dispatch`;
|
|
11
|
+
|
|
12
|
+
const dispatchCard = (() => {
|
|
13
|
+
const disabled = globalDispatchOff;
|
|
14
|
+
const hint = disabled
|
|
15
|
+
? `<div class="hint">⚠️ 全局自动接单已<a href="/settings">关闭</a>。项目级开关在此状态下无效——需先开启全局。</div>`
|
|
16
|
+
: `<div class="hint">关闭后,本项目新建 issue 不会自动派给 AI(评论仍可显式召唤)。</div>`;
|
|
17
|
+
return `<form class="card" method="POST" action="${escapeAttr(dispatchAction)}">
|
|
18
|
+
<h2>自动接单</h2>
|
|
19
|
+
${hint}
|
|
20
|
+
<div class="status-line">
|
|
21
|
+
<span class="status-dot ${dispatchOff ? "off" : "on"}"></span>
|
|
22
|
+
<span class="status-text">${dispatchOff ? "已暂停" : "运行中"}</span>
|
|
23
|
+
</div>
|
|
24
|
+
<button type="submit" class="${dispatchOff ? "primary" : "secondary"}" ${disabled ? "disabled" : ""}>${dispatchOff ? "🔔 开启自动接单" : "🔕 关闭自动接单"}</button>
|
|
25
|
+
</form>`;
|
|
26
|
+
})();
|
|
27
|
+
|
|
28
|
+
const html = `<!doctype html>
|
|
29
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
30
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
31
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
32
|
+
<title>${escapeHtml(project.owner + "/" + project.name)} · AI 设置</title>
|
|
33
|
+
<style>${THEME_CSS}
|
|
34
|
+
.nav{display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px}
|
|
35
|
+
.nav a{color:var(--header-text);opacity:.95}
|
|
36
|
+
.wrap{max-width:720px;margin:0 auto;padding:1rem}
|
|
37
|
+
.subtabs{display:flex;gap:.4rem;margin-bottom:1rem;flex-wrap:wrap}
|
|
38
|
+
.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}
|
|
39
|
+
.subtab.active{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
40
|
+
h1{font-size:18px;margin:0 0 .4rem}
|
|
41
|
+
.hint{color:var(--text-muted);font-size:13px;margin:.3rem 0 .6rem;line-height:1.5}
|
|
42
|
+
.card{border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;background:var(--bg-elev);margin-bottom:.9rem}
|
|
43
|
+
.card h2{font-size:14px;margin:0 0 .5rem}
|
|
44
|
+
.status-line{display:flex;align-items:center;gap:.5rem;margin-bottom:.7rem}
|
|
45
|
+
.status-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
|
46
|
+
.status-dot.on{background:var(--green,#3fb950)}
|
|
47
|
+
.status-dot.off{background:var(--text-muted)}
|
|
48
|
+
.status-text{font-size:13px;font-weight:600}
|
|
49
|
+
button{padding:.5rem 1.2rem;border:0;border-radius:6px;font-size:13px;cursor:pointer}
|
|
50
|
+
button.primary{background:var(--accent);color:#fff}
|
|
51
|
+
button.secondary{background:var(--bg-muted);color:var(--text);border:1px solid var(--border)}
|
|
52
|
+
button:disabled{opacity:.5;cursor:not-allowed}
|
|
53
|
+
.future{opacity:.5;pointer-events:none}
|
|
54
|
+
</style></head><body>
|
|
55
|
+
<header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · ${escapeHtml(project.owner + "/" + project.name)}</span></header>
|
|
56
|
+
<main class="wrap">
|
|
57
|
+
${projectSettingsTabsHTML(project.owner, project.name, "ai")}
|
|
58
|
+
<h1>🤖 AI 设置</h1>
|
|
59
|
+
<p class="hint">AI / 智能体相关的项目级配置。模型选择请去 <a href="${escapeAttr(`/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/model`)}">⚙️ 模型</a> 标签页。</p>
|
|
60
|
+
${dispatchCard}
|
|
61
|
+
|
|
62
|
+
<div class="card future">
|
|
63
|
+
<h2>更多配置(规划中)</h2>
|
|
64
|
+
<div class="hint">后续版本将在此标签页增加:项目级唤醒策略、Nudge 间隔、Observer 开关等。</div>
|
|
65
|
+
</div>
|
|
66
|
+
</main></body></html>`;
|
|
67
|
+
return { html };
|
|
68
|
+
}
|
|
@@ -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
|
|
package/src/views/settings.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|