ework-web 0.10.105 → 0.10.106
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 +64 -1
- package/src/render/layout.ts +2 -0
- package/src/static/app.js +28 -0
- package/src/views/issueThread.ts +12 -1
- package/src/views/projectAi.ts +11 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -391,6 +391,8 @@ const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/
|
|
|
391
391
|
const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
|
|
392
392
|
const REPO_VISIBILITY_RE = /^\/([^/]+)\/([^/]+)\/settings\/visibility$/;
|
|
393
393
|
const REPO_DISPATCH_RE = /^\/([^/]+)\/([^/]+)\/settings\/dispatch$/;
|
|
394
|
+
|
|
395
|
+
const REPO_WAKE_LOGINS_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai\/wake-logins$/;
|
|
394
396
|
const REPO_HALT_ALL_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai\/halt-all$/;
|
|
395
397
|
const SETTINGS_DISPATCH_RE = /^\/settings\/dispatch$/;
|
|
396
398
|
const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
|
|
@@ -611,6 +613,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
611
613
|
const issueOff = aiStatus === "dispatch_off" || aiStatus === "halted";
|
|
612
614
|
return json({ dispatchOff: globalOff || projectOff || issueOff, aiStatus });
|
|
613
615
|
}
|
|
616
|
+
if (url.pathname === "/api/v1/wake-logins" && req.method === "GET") {
|
|
617
|
+
const owner = url.searchParams.get("owner") ?? "";
|
|
618
|
+
const repo = url.searchParams.get("repo") ?? "";
|
|
619
|
+
if (!owner || !repo) return json({ error: "owner, repo required" }, 400);
|
|
620
|
+
const cfgKv = await getConfigAll();
|
|
621
|
+
const logins = (cfgKv[`wakeLogins:${owner}/${repo}`] ?? "")
|
|
622
|
+
.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
|
623
|
+
return json({ logins });
|
|
624
|
+
}
|
|
614
625
|
const evStatus = url.pathname.match(/^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/issues\/(\d+)\/status$/);
|
|
615
626
|
if (evStatus && req.method === "POST") {
|
|
616
627
|
const [, owner, repo, numStr] = evStatus;
|
|
@@ -2128,6 +2139,57 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
2128
2139
|
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(isOff ? "已开启自动接单" : "已关闭自动接单")}`, 303);
|
|
2129
2140
|
}
|
|
2130
2141
|
|
|
2142
|
+
const repoWakeLoginsMatch = url.pathname.match(REPO_WAKE_LOGINS_RE);
|
|
2143
|
+
if (repoWakeLoginsMatch) {
|
|
2144
|
+
const [, owner, repo] = repoWakeLoginsMatch;
|
|
2145
|
+
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
2146
|
+
const project = await getProject(owner, repo);
|
|
2147
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
2148
|
+
const aiBack = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/ai`;
|
|
2149
|
+
const wantsJson = url.searchParams.get("json") === "1";
|
|
2150
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
2151
|
+
if (wantsJson) return json({ error: "需要该项目 admin 角色" }, 403);
|
|
2152
|
+
return Response.redirect(`${aiBack}?err=${encodeURIComponent("无权限")}`, 303);
|
|
2153
|
+
}
|
|
2154
|
+
const fd = await req.formData();
|
|
2155
|
+
const key = `wakeLogins:${owner}/${repo}`;
|
|
2156
|
+
const current = ((await getConfigAll())[key] ?? "")
|
|
2157
|
+
.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
|
2158
|
+
const validLogin = (l: string) => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38}[A-Za-z0-9])?$/.test(l);
|
|
2159
|
+
let next: string[];
|
|
2160
|
+
const add = String(fd.get("add") ?? "").trim();
|
|
2161
|
+
if (add) {
|
|
2162
|
+
if (!validLogin(add)) {
|
|
2163
|
+
if (wantsJson) return json({ error: "无效的用户名" }, 400);
|
|
2164
|
+
return Response.redirect(`${aiBack}?err=${encodeURIComponent(`无效的用户名:${add}`)}`, 303);
|
|
2165
|
+
}
|
|
2166
|
+
next = current.some((l) => l.toLowerCase() === add.toLowerCase())
|
|
2167
|
+
? current
|
|
2168
|
+
: [...current, add];
|
|
2169
|
+
} else {
|
|
2170
|
+
const seen = new Set<string>();
|
|
2171
|
+
next = String(fd.get("logins") ?? "")
|
|
2172
|
+
.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean)
|
|
2173
|
+
.filter((l) => {
|
|
2174
|
+
if (!validLogin(l)) return false;
|
|
2175
|
+
const lower = l.toLowerCase();
|
|
2176
|
+
if (seen.has(lower)) return false;
|
|
2177
|
+
seen.add(lower);
|
|
2178
|
+
return true;
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
if (next.length === 0) await deleteConfig(key);
|
|
2182
|
+
else await setConfig(key, next.join(","));
|
|
2183
|
+
if (wantsJson) return json({ ok: true, logins: next });
|
|
2184
|
+
const backRaw = String(fd.get("back") ?? "").trim();
|
|
2185
|
+
const back = backRaw && backRaw.startsWith("/") ? backRaw : aiBack;
|
|
2186
|
+
const sep = back.includes("?") ? "&" : "?";
|
|
2187
|
+
return Response.redirect(
|
|
2188
|
+
`${back}${sep}ok=1&ok_msg=${encodeURIComponent(add ? `已把 ${add} 加入唤醒白名单` : "唤醒白名单已保存")}`,
|
|
2189
|
+
303,
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2131
2193
|
const repoHaltAllMatch = url.pathname.match(REPO_HALT_ALL_RE);
|
|
2132
2194
|
if (repoHaltAllMatch) {
|
|
2133
2195
|
const [, owner, repo] = repoHaltAllMatch;
|
|
@@ -2431,7 +2493,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
2431
2493
|
const globalDispatchOff = dispatchCfg["dispatchEnabled"] === "false";
|
|
2432
2494
|
const running = await getRunningSessionsForProject(`${owner}/${repo}`);
|
|
2433
2495
|
const processingCount = new Set(running.map((r) => r.issueNumber)).size;
|
|
2434
|
-
|
|
2496
|
+
const wakeLoginsRaw = dispatchCfg[`wakeLogins:${owner}/${repo}`] ?? "";
|
|
2497
|
+
return html(buildProjectAiPage(project, dispatchOff, globalDispatchOff, processingCount, wakeLoginsRaw).html);
|
|
2435
2498
|
}
|
|
2436
2499
|
|
|
2437
2500
|
const upstreamsPage = url.pathname.match(REPO_UPSTREAMS_RE);
|
package/src/render/layout.ts
CHANGED
|
@@ -83,6 +83,8 @@ header.topbar .num{opacity:.7}
|
|
|
83
83
|
.ttsstop:hover{color:var(--accent)}
|
|
84
84
|
.cbtn,.tbtn{background:none;border:none;cursor:pointer;font-size:13px;color:var(--text-muted);padding:0 .2rem;opacity:.6;line-height:1.5}
|
|
85
85
|
.cbtn:hover,.tbtn:hover{opacity:1;color:var(--accent)}
|
|
86
|
+
.wlbtn{background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:11px;color:var(--text-muted);padding:.05rem .35rem;margin-right:.3rem;line-height:1.5}
|
|
87
|
+
.wlbtn:hover{color:var(--accent);border-color:var(--accent)}
|
|
86
88
|
.clink:hover{opacity:1;color:var(--accent)}
|
|
87
89
|
.clink.done{color:var(--green);opacity:1}
|
|
88
90
|
.rx{display:inline-flex;gap:.3rem;align-items:center;margin-left:.2rem;flex-wrap:wrap}
|
package/src/static/app.js
CHANGED
|
@@ -36,8 +36,17 @@
|
|
|
36
36
|
seenIds: new Set(P.comments.map((c) => c.id)),
|
|
37
37
|
newIds: new Set(),
|
|
38
38
|
booted: false,
|
|
39
|
+
canWhitelist: P.canWhitelist === true,
|
|
40
|
+
wakeSet: toSet(P.wakeLogins),
|
|
41
|
+
trustedSet: toSet(P.trustedLogins),
|
|
39
42
|
};
|
|
40
43
|
|
|
44
|
+
function toSet(list) {
|
|
45
|
+
const s = {};
|
|
46
|
+
(list || []).forEach(function (l) { s[String(l).toLowerCase()] = 1; });
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
const $ = (id) => document.getElementById(id);
|
|
42
51
|
const itemsEl = $("items");
|
|
43
52
|
const countEl = $("count");
|
|
@@ -131,6 +140,9 @@
|
|
|
131
140
|
`<span class="when" data-ts="${esc(c.created_at)}" title="${esc(c.created_at)}">${relTime(c.created_at)}</span>` +
|
|
132
141
|
rx +
|
|
133
142
|
`<span class="card-actions">` +
|
|
143
|
+
(state.canWhitelist && tag === "human" && c.login && !state.trustedSet[c.login.toLowerCase()] && !state.wakeSet[c.login.toLowerCase()]
|
|
144
|
+
? `<button type="button" class="wlbtn" data-login="${esc(c.login)}" title="把 ${esc(c.login)} 加入本项目唤醒白名单">+白名单</button>`
|
|
145
|
+
: "") +
|
|
134
146
|
`<button type="button" class="cbtn" data-cid="${c.id}" title="复制">📋</button>` +
|
|
135
147
|
`<button type="button" class="clink" data-cid="${c.id}" title="复制楼层链接">🔗</button>` +
|
|
136
148
|
`<button type="button" class="tbtn" data-cid="${c.id}" title="翻译">翻译</button>` +
|
|
@@ -359,6 +371,22 @@
|
|
|
359
371
|
}
|
|
360
372
|
if (e.target.closest(".cbtn")) { doCopy(e.target.closest(".cbtn")); return; }
|
|
361
373
|
if (e.target.closest(".tbtn")) { doTranslate(e.target.closest(".tbtn")); return; }
|
|
374
|
+
const wl = e.target.closest(".wlbtn");
|
|
375
|
+
if (wl) {
|
|
376
|
+
const login = wl.getAttribute("data-login") || "";
|
|
377
|
+
if (!login || wl.disabled) return;
|
|
378
|
+
wl.disabled = true;
|
|
379
|
+
wl.textContent = "…";
|
|
380
|
+
fetch(`/${encodeURIComponent(state.owner)}/${encodeURIComponent(state.repo)}/settings/ai/wake-logins?json=1`, {
|
|
381
|
+
method: "POST",
|
|
382
|
+
body: new URLSearchParams({ add: login }),
|
|
383
|
+
}).then((r) => r.json().then((d) => ({ ok: r.ok, d }))).then(({ ok, d }) => {
|
|
384
|
+
if (!ok) { wl.disabled = false; wl.textContent = "+白名单"; alert((d && d.error) || "加白失败"); return; }
|
|
385
|
+
state.wakeSet[login.toLowerCase()] = 1;
|
|
386
|
+
render();
|
|
387
|
+
}).catch(() => { wl.disabled = false; wl.textContent = "+白名单"; alert("网络错误,加白失败"); });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
362
390
|
});
|
|
363
391
|
|
|
364
392
|
itemsEl.addEventListener("click", (e) => {
|
package/src/views/issueThread.ts
CHANGED
|
@@ -15,10 +15,12 @@ import {
|
|
|
15
15
|
listLabelsForIssue,
|
|
16
16
|
getUserByLogin,
|
|
17
17
|
listCachedModels,
|
|
18
|
+
listProjectMembersWithUsers,
|
|
18
19
|
type CommentRow,
|
|
19
20
|
type IssueWithMeta,
|
|
20
21
|
type ProjectRow,
|
|
21
22
|
} from "../store";
|
|
23
|
+
import { getConfigAll } from "../db";
|
|
22
24
|
import { webUrlFromClone } from "./projectUpstreams";
|
|
23
25
|
|
|
24
26
|
export const PAGE_SIZE = 30;
|
|
@@ -36,6 +38,9 @@ export interface IssueThreadPayload {
|
|
|
36
38
|
sinceISO: string;
|
|
37
39
|
commentSort: "desc" | "asc";
|
|
38
40
|
comments: CommentView[];
|
|
41
|
+
canWhitelist?: boolean;
|
|
42
|
+
wakeLogins?: string[];
|
|
43
|
+
trustedLogins?: string[];
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
// Issues in a project cloned from an upstream are that upstream's issues:
|
|
@@ -127,7 +132,6 @@ export async function buildIssueThread(
|
|
|
127
132
|
const hasOlder = currentPage > 1;
|
|
128
133
|
const displayViews = orderForDisplay(views, cfg.commentSort);
|
|
129
134
|
const payload = payloadFromComments(issue, displayViews, currentPage, hasOlder, cfg.commentSort);
|
|
130
|
-
|
|
131
135
|
const descriptionHtml = renderMarkdown(issue.body, "", issuePath);
|
|
132
136
|
const descriptionCollapsed = issue.body.length > 1200;
|
|
133
137
|
const upstreamWebUrl = (() => {
|
|
@@ -139,6 +143,13 @@ export async function buildIssueThread(
|
|
|
139
143
|
const viewerUser = viewerLogin ? await getUserByLogin(viewerLogin) : null;
|
|
140
144
|
const viewerIsAdmin = !!viewerUser?.is_admin;
|
|
141
145
|
|
|
146
|
+
// Consumed by app.js to render +白名单 buttons on external authors' comments.
|
|
147
|
+
const cfgKv = await getConfigAll();
|
|
148
|
+
payload.canWhitelist = viewerIsAdmin;
|
|
149
|
+
payload.wakeLogins = (cfgKv[`wakeLogins:${owner}/${repo}`] ?? "")
|
|
150
|
+
.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
|
151
|
+
payload.trustedLogins = (await listProjectMembersWithUsers(project.id)).map((m) => m.user_login);
|
|
152
|
+
|
|
142
153
|
let customActions: IssueAction[] = [];
|
|
143
154
|
let extraStatusBadges: Record<string, { cls: string; label: string }> | undefined;
|
|
144
155
|
if (cfg.issueActionsHook && viewerLogin) {
|
package/src/views/projectAi.ts
CHANGED
|
@@ -7,6 +7,7 @@ export function buildProjectAiPage(
|
|
|
7
7
|
dispatchOff: boolean,
|
|
8
8
|
globalDispatchOff: boolean,
|
|
9
9
|
processingCount: number,
|
|
10
|
+
wakeLoginsRaw = "",
|
|
10
11
|
): { html: string } {
|
|
11
12
|
const aiBase = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/ai`;
|
|
12
13
|
const dispatchAction = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/dispatch`;
|
|
@@ -28,6 +29,14 @@ ${hint}
|
|
|
28
29
|
</form>`;
|
|
29
30
|
})();
|
|
30
31
|
|
|
32
|
+
const wakeList = wakeLoginsRaw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
|
33
|
+
const wakeCard = `<form class="card" method="POST" action="${escapeAttr(`${aiBase}/wake-logins`)}">
|
|
34
|
+
<h2>👥 唤醒白名单</h2>
|
|
35
|
+
<div class="hint">默认只有平台成员能唤醒 AI。把外部 GitHub 用户加进来(逗号或换行分隔),他们在<b>本项目</b>的新 issue / 评论就会自动触发 AI 处理。bot 账号(如 github-actions[bot])永远无效;全局与项目接单开关仍是总闸。issue 评论区对外部用户也有「+白名单」一键按钮。清空内容保存即删除白名单。</div>
|
|
36
|
+
<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 Rika-xie">${escapeHtml(wakeList.join("\n"))}</textarea>
|
|
37
|
+
<button type="submit" class="primary" style="margin-top:.5rem">保存白名单${wakeList.length > 0 ? `(当前 ${wakeList.length} 人)` : ""}</button>
|
|
38
|
+
</form>`;
|
|
39
|
+
|
|
31
40
|
const haltCard = (() => {
|
|
32
41
|
const hint = processingCount > 0
|
|
33
42
|
? `当前有 <b>${processingCount}</b> 个 AI 会话正在运行。点击下方按钮将全部停止(向 daemon 发送 halt 信号)。`
|
|
@@ -77,11 +86,12 @@ ${projectSettingsTabsHTML(project.owner, project.name, "ai")}
|
|
|
77
86
|
<h1>🤖 AI 设置</h1>
|
|
78
87
|
<p class="hint">两个独立控制:<b>🔔 自动接单</b>控制是否自动派新单(不影响运行中);<b>⏹️ 停止</b>杀死当前所有运行中AI会话(不影响接单状态)。模型选择请去 <a href="${escapeAttr(`/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/model`)}">⚙️ 模型</a> 标签页。</p>
|
|
79
88
|
${dispatchCard}
|
|
89
|
+
${wakeCard}
|
|
80
90
|
${haltCard}
|
|
81
91
|
|
|
82
92
|
<div class="card future">
|
|
83
93
|
<h2>更多配置(规划中)</h2>
|
|
84
|
-
<div class="hint"
|
|
94
|
+
<div class="hint">后续版本将在此标签页增加:Nudge 间隔、Observer 开关等。</div>
|
|
85
95
|
</div>
|
|
86
96
|
</main></body></html>`;
|
|
87
97
|
return { html };
|