baa-conductor

git clone 

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
M plugins/baa-firefox/content-script.js
+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 {
A tasks/T-S037.md
+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+- 还有哪些剩余风险
M tasks/TASK_OVERVIEW.md
+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