ework-web 0.10.105 → 0.10.107

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.105",
3
+ "version": "0.10.107",
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
@@ -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
- return html(buildProjectAiPage(project, dispatchOff, globalDispatchOff, processingCount).html);
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);
@@ -26,6 +26,7 @@ export interface LayoutProps {
26
26
  extraStatusBadges?: Record<string, { cls: string; label: string }>;
27
27
  modelSelect?: { current: string; options: { id: string; label: string }[] } | null;
28
28
  runtimeSelect?: { current: string } | null;
29
+ authorLineHtml?: string;
29
30
  }
30
31
 
31
32
  export const THEME_CSS = `
@@ -83,6 +84,8 @@ header.topbar .num{opacity:.7}
83
84
  .ttsstop:hover{color:var(--accent)}
84
85
  .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
86
  .cbtn:hover,.tbtn:hover{opacity:1;color:var(--accent)}
87
+ .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}
88
+ .wlbtn:hover{color:var(--accent);border-color:var(--accent)}
86
89
  .clink:hover{opacity:1;color:var(--accent)}
87
90
  .clink.done{color:var(--green);opacity:1}
88
91
  .rx{display:inline-flex;gap:.3rem;align-items:center;margin-left:.2rem;flex-wrap:wrap}
@@ -161,6 +164,9 @@ header.topbar .num{opacity:.7}
161
164
  .action-btn.dispatch-btn:hover{background:#6f7781;color:#fff}
162
165
  .action-btn.custom-btn{color:var(--accent)}
163
166
  .action-btn.custom-btn:hover{background:var(--accent);color:#fff}
167
+ .author-line{color:var(--fg-muted);font-size:.9em;margin:2px 0 6px}
168
+ .author-line .wl-form{display:inline;margin-left:8px}
169
+ .author-line .kind-tag{font-size:.75em;border:1px solid var(--border);border-radius:8px;padding:0 6px;margin-left:4px;color:var(--fg-muted)}
164
170
  `;
165
171
 
166
172
  export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string {
@@ -241,6 +247,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
241
247
  </header>
242
248
  <div class="meta-bar">
243
249
  <h1>${escapeHtml(props.issueTitle)}</h1>
250
+ ${props.authorLineHtml ?? ""}
244
251
  <div class="meta-status">
245
252
  <span class="state-badge ${stateClass}">${stateLabel}</span>
246
253
  ${aiBadgeHtml}
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) => {
@@ -1,7 +1,7 @@
1
1
  import type { Config } from "../config";
2
2
  import { classifyActor, renderCommentCard, type CommentView } from "../render/components";
3
3
  import { renderMarkdown } from "../render/markdown";
4
- import { renderLayout, escapeHtml } from "../render/layout";
4
+ import { renderLayout, escapeHtml, escapeAttr } from "../render/layout";
5
5
  import { runIssueActionsHook, type IssueActionContext, type IssueAction } from "../issue-actions-hook";
6
6
  import { hydrateReactions } from "../reactions";
7
7
  import {
@@ -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,36 @@ 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
+
153
+ // Submitter line: synced upstream issues/PRs carry their real author. The
154
+ // +白名单 form targets the wake-logins route (add+back fields, 303 back).
155
+ const authorUser = await getUserByLogin(issue.author);
156
+ const authorKind = authorUser?.kind ?? "human";
157
+ const trustedList = payload.trustedLogins ?? [];
158
+ const wakeList = payload.wakeLogins ?? [];
159
+ const lowerAuthor = issue.author.toLowerCase();
160
+ const showAuthorWhitelist =
161
+ viewerIsAdmin &&
162
+ authorKind === "human" &&
163
+ !trustedList.some((l) => l.toLowerCase() === lowerAuthor) &&
164
+ !wakeList.some((l) => l.toLowerCase() === lowerAuthor);
165
+ const authorLineHtml =
166
+ `<div class="author-line">由 <strong>${escapeHtml(issue.author)}</strong> 提交` +
167
+ (authorKind === "bot" ? ` <span class="kind-tag">bot</span>` : "") +
168
+ (showAuthorWhitelist
169
+ ? `<form method="post" action="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/ai/wake-logins" class="wl-form">` +
170
+ `<input type="hidden" name="back" value="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issue.number}">` +
171
+ `<input type="hidden" name="add" value="${escapeAttr(issue.author)}">` +
172
+ `<button type="submit" class="wlbtn">+白名单</button></form>`
173
+ : "") +
174
+ `</div>`;
175
+
142
176
  let customActions: IssueAction[] = [];
143
177
  let extraStatusBadges: Record<string, { cls: string; label: string }> | undefined;
144
178
  if (cfg.issueActionsHook && viewerLogin) {
@@ -183,6 +217,7 @@ export async function buildIssueThread(
183
217
  ? { current: issue.model ?? "", options: (await listCachedModels()).map((m) => ({ id: m.id, label: m.label })) }
184
218
  : null,
185
219
  runtimeSelect: cfg.writesEnabled !== false ? { current: issue.runtime ?? "" } : null,
220
+ authorLineHtml,
186
221
  },
187
222
  safeJsonEmbed(payload),
188
223
  displayViews.map((v) => renderCommentCard(v, cfg)).join("")
@@ -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&#10;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">后续版本将在此标签页增加:项目级唤醒策略、Nudge 间隔、Observer 开关等。</div>
94
+ <div class="hint">后续版本将在此标签页增加:Nudge 间隔、Observer 开关等。</div>
85
95
  </div>
86
96
  </main></body></html>`;
87
97
  return { html };