- commit
- 60eb2e1
- parent
- 7dc34ad
- author
- im_wower
- date
- 2026-03-28 14:17:40 +0800 CST
feat: add in-page conductor control overlay
3 files changed,
+559,
-1
+417,
-0
1@@ -1,4 +1,6 @@
2 const CONTENT_SCRIPT_RUNTIME_KEY = "__baaFirefoxContentScriptRuntime__";
3+const CONTROL_STATE_STORAGE_KEY = "baaFirefox.controlState";
4+const PAGE_CONTROL_OVERLAY_ID = "__baaFirefoxPageControlOverlay__";
5
6 const previousContentScriptRuntime = window[CONTENT_SCRIPT_RUNTIME_KEY];
7 if (previousContentScriptRuntime && typeof previousContentScriptRuntime.dispose === "function") {
8@@ -25,6 +27,418 @@ function trimToNull(value) {
9 return normalized === "" ? null : normalized;
10 }
11
12+function normalizeMode(value) {
13+ const normalized = String(value || "").trim().toLowerCase();
14+
15+ if (normalized === "running" || normalized === "paused" || normalized === "draining") {
16+ return normalized;
17+ }
18+
19+ return "unknown";
20+}
21+
22+function normalizeControlConnection(value) {
23+ const normalized = String(value || "").trim().toLowerCase();
24+
25+ if (normalized === "connecting" || normalized === "connected" || normalized === "retrying" || normalized === "disconnected") {
26+ return normalized;
27+ }
28+
29+ return "disconnected";
30+}
31+
32+function formatModeLabel(mode) {
33+ switch (normalizeMode(mode)) {
34+ case "running":
35+ return "运行中";
36+ case "paused":
37+ return "已暂停";
38+ case "draining":
39+ return "排空中";
40+ default:
41+ return "未知";
42+ }
43+}
44+
45+function formatConnectionLabel(connection) {
46+ switch (normalizeControlConnection(connection)) {
47+ case "connecting":
48+ return "连接中";
49+ case "connected":
50+ return "已连接";
51+ case "retrying":
52+ return "重试中";
53+ default:
54+ return "已断开";
55+ }
56+}
57+
58+function createPageControlOverlayRuntime() {
59+ let root = null;
60+ let shadow = null;
61+ let panel = null;
62+ let statusText = null;
63+ let errorText = null;
64+ let pendingAction = null;
65+ let controlSnapshot = null;
66+ let destroyed = false;
67+ let dismissButton = null;
68+ let hidden = false;
69+ let buttonMap = {};
70+ let toggleButton = null;
71+
72+ const overlayStyle = `
73+ :host {
74+ all: initial;
75+ }
76+
77+ .baa-overlay {
78+ position: fixed;
79+ right: 14px;
80+ bottom: 14px;
81+ z-index: 2147483647;
82+ width: 220px;
83+ box-sizing: border-box;
84+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
85+ color: #f7f3e8;
86+ }
87+
88+ .baa-panel {
89+ box-sizing: border-box;
90+ border: 1px solid rgba(242, 203, 120, 0.35);
91+ border-radius: 12px;
92+ background: rgba(24, 20, 17, 0.92);
93+ backdrop-filter: blur(10px);
94+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.3);
95+ padding: 10px;
96+ }
97+
98+ .baa-panel[hidden] {
99+ display: none;
100+ }
101+
102+ .baa-head {
103+ display: flex;
104+ align-items: center;
105+ justify-content: space-between;
106+ gap: 8px;
107+ margin-bottom: 8px;
108+ }
109+
110+ .baa-title {
111+ font-size: 11px;
112+ line-height: 1.2;
113+ letter-spacing: 0.06em;
114+ text-transform: uppercase;
115+ color: #f2cb78;
116+ font-weight: 700;
117+ }
118+
119+ .baa-dismiss {
120+ appearance: none;
121+ border: 0;
122+ background: transparent;
123+ color: rgba(247, 243, 232, 0.72);
124+ cursor: pointer;
125+ font-size: 14px;
126+ line-height: 1;
127+ padding: 0;
128+ }
129+
130+ .baa-dismiss:hover {
131+ color: #ffffff;
132+ }
133+
134+ .baa-status {
135+ font-size: 12px;
136+ line-height: 1.4;
137+ color: #f7f3e8;
138+ margin-bottom: 8px;
139+ white-space: pre-wrap;
140+ word-break: break-word;
141+ }
142+
143+ .baa-error {
144+ min-height: 14px;
145+ font-size: 11px;
146+ line-height: 1.35;
147+ color: #ffb7a1;
148+ margin-bottom: 8px;
149+ }
150+
151+ .baa-actions {
152+ display: grid;
153+ grid-template-columns: repeat(3, minmax(0, 1fr));
154+ gap: 6px;
155+ }
156+
157+ .baa-btn {
158+ appearance: none;
159+ border: 1px solid rgba(255, 255, 255, 0.14);
160+ background: rgba(255, 255, 255, 0.08);
161+ color: #f7f3e8;
162+ border-radius: 8px;
163+ font-size: 12px;
164+ line-height: 1;
165+ padding: 8px 6px;
166+ cursor: pointer;
167+ transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease;
168+ }
169+
170+ .baa-btn:hover:not(:disabled) {
171+ background: rgba(255, 255, 255, 0.14);
172+ border-color: rgba(242, 203, 120, 0.48);
173+ }
174+
175+ .baa-btn:disabled {
176+ opacity: 0.52;
177+ cursor: default;
178+ }
179+
180+ .baa-btn[data-action="pause"] {
181+ color: #ffcfb3;
182+ }
183+
184+ .baa-btn[data-action="resume"] {
185+ color: #b9f0c7;
186+ }
187+
188+ .baa-btn[data-action="drain"] {
189+ color: #f2cb78;
190+ }
191+
192+ .baa-toggle {
193+ position: fixed;
194+ right: 14px;
195+ bottom: 14px;
196+ z-index: 2147483647;
197+ appearance: none;
198+ border: 1px solid rgba(242, 203, 120, 0.35);
199+ border-radius: 999px;
200+ background: rgba(24, 20, 17, 0.92);
201+ color: #f2cb78;
202+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.3);
203+ padding: 8px 10px;
204+ font-size: 11px;
205+ line-height: 1;
206+ cursor: pointer;
207+ display: none;
208+ }
209+
210+ .baa-toggle[data-visible="true"] {
211+ display: block;
212+ }
213+ `;
214+
215+ function setError(message) {
216+ if (!errorText) {
217+ return;
218+ }
219+
220+ errorText.textContent = trimToNull(message) || "";
221+ }
222+
223+ function updateStatus() {
224+ if (!statusText) {
225+ return;
226+ }
227+
228+ const snapshot = controlSnapshot || {};
229+ const modeLabel = formatModeLabel(snapshot.mode);
230+ const connectionLabel = formatConnectionLabel(snapshot.controlConnection);
231+ const actionLabel = pendingAction ? `\n动作: ${pendingAction}` : "";
232+
233+ statusText.textContent = `Conductor: ${connectionLabel}\n模式: ${modeLabel}${actionLabel}`;
234+ }
235+
236+ function updateButtons() {
237+ const activePending = trimToNull(pendingAction);
238+ const normalizedMode = normalizeMode(controlSnapshot?.mode);
239+
240+ for (const [action, button] of Object.entries(buttonMap)) {
241+ if (!button) {
242+ continue;
243+ }
244+
245+ button.disabled = activePending != null || normalizedMode === action;
246+ }
247+ }
248+
249+ function updateVisibility() {
250+ if (panel) {
251+ panel.hidden = hidden;
252+ }
253+
254+ if (toggleButton) {
255+ toggleButton.dataset.visible = hidden ? "true" : "false";
256+ }
257+
258+ if (dismissButton) {
259+ dismissButton.textContent = hidden ? "+" : "×";
260+ dismissButton.title = hidden ? "显示 BAA 控制" : "隐藏 BAA 控制";
261+ }
262+ }
263+
264+ function render() {
265+ updateStatus();
266+ updateButtons();
267+ updateVisibility();
268+ }
269+
270+ async function readStoredControlState() {
271+ try {
272+ const stored = await browser.storage.local.get(CONTROL_STATE_STORAGE_KEY);
273+ controlSnapshot = stored?.[CONTROL_STATE_STORAGE_KEY] || null;
274+ render();
275+ } catch (error) {
276+ setError(error instanceof Error ? error.message : String(error));
277+ }
278+ }
279+
280+ async function runControlAction(action) {
281+ pendingAction = action;
282+ setError("");
283+ render();
284+
285+ try {
286+ const result = await browser.runtime.sendMessage({
287+ type: "control_plane_command",
288+ action,
289+ source: "page_overlay"
290+ });
291+
292+ if (!result?.ok) {
293+ throw new Error(trimToNull(result?.error) || `${action} failed`);
294+ }
295+
296+ controlSnapshot = result.snapshot || controlSnapshot;
297+ } catch (error) {
298+ setError(error instanceof Error ? error.message : String(error));
299+ } finally {
300+ pendingAction = null;
301+ render();
302+ }
303+ }
304+
305+ function handleStorageChanged(changes, areaName) {
306+ if (destroyed || areaName !== "local" || !changes?.[CONTROL_STATE_STORAGE_KEY]) {
307+ return;
308+ }
309+
310+ controlSnapshot = changes[CONTROL_STATE_STORAGE_KEY].newValue || null;
311+ render();
312+ }
313+
314+ function handleDismiss() {
315+ hidden = !hidden;
316+ render();
317+ }
318+
319+ function mount() {
320+ if (destroyed || root?.isConnected) {
321+ return;
322+ }
323+
324+ root = document.createElement("div");
325+ root.id = PAGE_CONTROL_OVERLAY_ID;
326+ shadow = root.attachShadow({ mode: "open" });
327+
328+ const style = document.createElement("style");
329+ style.textContent = overlayStyle;
330+ shadow.appendChild(style);
331+
332+ toggleButton = document.createElement("button");
333+ toggleButton.type = "button";
334+ toggleButton.className = "baa-toggle";
335+ toggleButton.dataset.visible = "false";
336+ toggleButton.textContent = "BAA";
337+ toggleButton.addEventListener("click", handleDismiss);
338+ shadow.appendChild(toggleButton);
339+
340+ panel = document.createElement("div");
341+ panel.className = "baa-overlay baa-panel";
342+
343+ const head = document.createElement("div");
344+ head.className = "baa-head";
345+
346+ const title = document.createElement("div");
347+ title.className = "baa-title";
348+ title.textContent = "BAA Control";
349+ head.appendChild(title);
350+
351+ dismissButton = document.createElement("button");
352+ dismissButton.type = "button";
353+ dismissButton.className = "baa-dismiss";
354+ dismissButton.addEventListener("click", handleDismiss);
355+ head.appendChild(dismissButton);
356+
357+ panel.appendChild(head);
358+
359+ statusText = document.createElement("div");
360+ statusText.className = "baa-status";
361+ panel.appendChild(statusText);
362+
363+ errorText = document.createElement("div");
364+ errorText.className = "baa-error";
365+ panel.appendChild(errorText);
366+
367+ const actions = document.createElement("div");
368+ actions.className = "baa-actions";
369+
370+ for (const entry of [
371+ { action: "pause", label: "暂停" },
372+ { action: "resume", label: "恢复" },
373+ { action: "drain", label: "排空" }
374+ ]) {
375+ const button = document.createElement("button");
376+ button.type = "button";
377+ button.className = "baa-btn";
378+ button.dataset.action = entry.action;
379+ button.textContent = entry.label;
380+ button.addEventListener("click", () => {
381+ runControlAction(entry.action).catch(() => {});
382+ });
383+ buttonMap[entry.action] = button;
384+ actions.appendChild(button);
385+ }
386+
387+ panel.appendChild(actions);
388+ shadow.appendChild(panel);
389+
390+ const parent = document.body || document.documentElement;
391+ parent?.appendChild(root);
392+ render();
393+ }
394+
395+ if (document.readyState === "loading") {
396+ document.addEventListener("DOMContentLoaded", mount, { once: true });
397+ }
398+
399+ mount();
400+ readStoredControlState().catch(() => {});
401+ browser.storage.onChanged.addListener(handleStorageChanged);
402+
403+ return {
404+ dispose() {
405+ destroyed = true;
406+ browser.storage.onChanged.removeListener(handleStorageChanged);
407+
408+ if (root?.isConnected) {
409+ root.remove();
410+ }
411+
412+ root = null;
413+ shadow = null;
414+ panel = null;
415+ statusText = null;
416+ errorText = null;
417+ dismissButton = null;
418+ toggleButton = null;
419+ buttonMap = {};
420+ }
421+ };
422+}
423+
424 async function handleDeliveryCommand(data = {}) {
425 if (!deliveryRuntime) {
426 return {
427@@ -93,6 +507,8 @@ window.addEventListener("__baa_sse__", handlePageSse);
428 window.addEventListener("__baa_proxy_response__", handleProxyResponse);
429 browser.runtime.onMessage.addListener(handleRuntimeMessage);
430
431+const pageControlOverlayRuntime = createPageControlOverlayRuntime();
432+
433 sendBridgeMessage("baa_page_bridge_ready", {
434 url: location.href,
435 source: "content-script"
436@@ -105,6 +521,7 @@ const contentScriptRuntime = {
437 window.removeEventListener("__baa_sse__", handlePageSse);
438 window.removeEventListener("__baa_proxy_response__", handleProxyResponse);
439 browser.runtime.onMessage.removeListener(handleRuntimeMessage);
440+ pageControlOverlayRuntime?.dispose?.();
441
442 if (window[CONTENT_SCRIPT_RUNTIME_KEY] === contentScriptRuntime) {
443 try {
+140,
-0
1@@ -0,0 +1,140 @@
2+# Task T-S037:恢复 AI 页面右下角控制浮层,并支持页面级暂停 BAA 控制
3+
4+## 直接给对话的提示词
5+
6+读 `/Users/george/code/baa-conductor/tasks/T-S037.md` 任务文档,完成开发任务。
7+
8+如需补背景,再读:
9+
10+- `/Users/george/code/baa-conductor/plugins/baa-firefox/README.md`
11+- `/Users/george/code/baa-conductor/tasks/TASK_OVERVIEW.md`
12+- `/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md`
13+
14+## 当前基线
15+
16+- 仓库:`/Users/george/code/baa-conductor`
17+- 分支基线:`main`
18+- 提交:`7dc34ad`
19+- 开工要求:必须先从当前 `main` 新建任务分支,再开始开发;禁止直接在 `main` 上修改。功能任务分支名必须以 `feat/` 开头,缺陷任务分支名必须以 `bug/` 开头。
20+
21+## 必须创建的新分支名
22+
23+- `feat/page-level-baa-control-overlay`
24+
25+## 目标
26+
27+把 AI 网页右下角的控制浮层正式恢复回来,并支持“仅暂停当前页面 / 当前对话的 BAA 控制”,避免死循环时只能做全局暂停。
28+
29+## 背景
30+
31+当前仓库已经补了一个临时止损方案:在支持的 AI 网页右下角注入全局 `pause / resume / drain` 浮层。但这只是全局控制,不是用户真正需要的“只暂停当前页面 / 当前对话”。
32+
33+现状问题:
34+
35+- 当前页面没有正式的页面级 BAA 控制开关
36+- 一旦出现循环,用户只能全局 `pause`
37+- `browser.final_message` 和 delivery 仍然按平台主链工作,无法只对某一个页面停用
38+
39+这张卡的目标不是继续扩大全局控制,而是把页面级 stop switch 正式落地。
40+
41+## 涉及仓库
42+
43+- `/Users/george/code/baa-conductor`
44+
45+## 范围
46+
47+- 恢复 AI 页面右下角控制浮层为正式能力,而不是临时 stopgap
48+- 增加页面级 / tab 级 BAA 暂停状态
49+- 让被暂停页面不再继续参与 `browser.final_message` 上报和 delivery 回写
50+- 补最小自动化回归
51+
52+## 路径约束
53+
54+优先在 Firefox 插件运行时层完成,不要把这张卡扩成完整多页面会话框架重构。只有在确实需要 conductor 读面暴露时,才最小改动 `apps/conductor-daemon/src/`。
55+
56+## 推荐实现边界
57+
58+建议优先做:
59+
60+- 页面浮层状态以 `tabId + platform` 为主键;如果页面里已经能稳定取到 `conversationId`,可以作为补充字段保留
61+- 页面级暂停后,至少阻断该页面的 `browser.final_message`
62+- 如果该页面被标记为暂停,delivery 也不要继续往该页面注入 / 发送
63+- 在 controller 或 `/v1/browser` 读面里保留最小可观察状态,便于排查“为什么这个页面没有继续执行”
64+
65+## 允许修改的目录
66+
67+- `/Users/george/code/baa-conductor/plugins/baa-firefox/content-script.js`
68+- `/Users/george/code/baa-conductor/plugins/baa-firefox/controller.js`
69+- `/Users/george/code/baa-conductor/plugins/baa-firefox/page-interceptor.js`
70+- `/Users/george/code/baa-conductor/plugins/baa-firefox/final-message.js`
71+- `/Users/george/code/baa-conductor/plugins/baa-firefox/background.js`
72+- `/Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
73+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/firefox-ws.ts`
74+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/browser-types.ts`
75+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/local-api.ts`
76+
77+## 尽量不要修改
78+
79+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/instructions/`
80+- `/Users/george/code/baa-conductor/plugins/baa-firefox/delivery-adapters.js`
81+- `/Users/george/code/baa-conductor/docs/`
82+
83+## 必须完成
84+
85+### 1. 恢复正式页面浮层
86+
87+- 在 Claude / ChatGPT / Gemini 页面右下角显示 BAA 控制浮层
88+- 浮层至少要能显示当前页面是否被暂停
89+- 不要再只依赖 `controller.html` 做控制入口
90+
91+### 2. 支持页面级暂停
92+
93+- 用户可以只暂停当前页面 / 当前 tab 的 BAA 控制
94+- 被暂停页面的 `browser.final_message` 不再继续上报给 conductor
95+- 恢复后该页面能继续进入正常观察链
96+
97+### 3. 收口 delivery 行为
98+
99+- 被暂停页面不应继续接收自动 delivery 回写
100+- 其他未暂停页面和全局控制语义不能被破坏
101+- 临时全局浮层可以保留,但不能和页面级状态冲突
102+
103+### 4. 补回归
104+
105+- 覆盖页面级暂停后 `browser.final_message` 被抑制
106+- 覆盖恢复后观察链重新生效
107+- 覆盖现有全局 `pause / resume / drain` 语义不回归
108+
109+## 需要特别注意
110+
111+- 不要把这张卡扩成完整 conversation manager 或多 tab orchestration 重构
112+- 不要破坏当前 shell tab、desired / actual / drift 语义
113+- 不要回退已经修好的 Claude / ChatGPT / Gemini final-message 观察链
114+- 页面级暂停和全局 `system pause` 要能区分,不能混成一个状态
115+
116+## 验收标准
117+
118+- 在支持的 AI 网页里能看到右下角 BAA 控制浮层
119+- 用户可以只暂停当前页面,而不是只能全局暂停
120+- 被暂停页面不会继续触发 `browser.final_message -> conductor -> delivery`
121+- 恢复后该页面重新参与主链
122+- 现有 browser smoke 通过
123+
124+## 推荐验证命令
125+
126+- `node --check /Users/george/code/baa-conductor/plugins/baa-firefox/content-script.js`
127+- `node --check /Users/george/code/baa-conductor/plugins/baa-firefox/controller.js`
128+- `node --check /Users/george/code/baa-conductor/plugins/baa-firefox/page-interceptor.js`
129+- `node --check /Users/george/code/baa-conductor/plugins/baa-firefox/final-message.js`
130+- `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
131+- `git diff --check`
132+
133+## 交付要求
134+
135+完成后请说明:
136+
137+- 修改了哪些文件
138+- 页面级暂停状态是怎么建模的
139+- `browser.final_message` 和 delivery 各自如何尊重该状态
140+- 跑了哪些测试
141+- 还有哪些剩余风险
+2,
-1
1@@ -17,7 +17,7 @@
2
3 - `已完成`:见 [`./archive/README.md`](./archive/README.md)
4 - `当前 TODO`:
5- - 下一张主线任务卡待新建
6+ - [`T-S037.md`](./T-S037.md):恢复 AI 页面右下角控制浮层,并支持页面级暂停 BAA 控制
7 - `待处理缺陷`:见 [`../bugs/README.md`](../bugs/README.md)
8 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
9
10@@ -54,6 +54,7 @@
11 - `2026-03-28`:`T-BUG-018` 已完成并归档到 [`./archive/README.md`](./archive/README.md)
12 - `2026-03-28`:`T-MISSING-003` 已完成并归档到 [`./archive/README.md`](./archive/README.md)
13 - `2026-03-28`:`T-BUG-019` 已完成并归档到 [`./archive/README.md`](./archive/README.md)
14+- `2026-03-28`:当前活跃任务卡为 [`T-S037.md`](./T-S037.md),用于恢复 AI 页面右下角控制浮层,并实现页面级暂停 BAA 控制
15
16 ## 当前主线收口情况
17