- commit
- aade8e2
- parent
- 37bf1d0
- author
- codex@macbookpro
- date
- 2026-04-01 12:10:24 +0800 CST
feat: unify overlay automation control
12 files changed,
+937,
-73
1@@ -1,5 +1,10 @@
2 import type { BaaLiveInstructionIngestSnapshot } from "./instructions/ingest.js";
3 import type { BaaDeliveryBridgeSnapshot } from "./artifacts/types.js";
4+import type {
5+ ConversationAutomationExecutionState,
6+ ConversationAutomationStatus,
7+ ConversationPauseReason
8+} from "../../../packages/artifact-db/dist/index.js";
9
10 export type BrowserBridgeLoginStatus = "fresh" | "stale" | "lost";
11
12@@ -146,9 +151,38 @@ export interface BrowserBridgeClientSnapshot {
13 shell_runtime: BrowserBridgeShellRuntimeSnapshot[];
14 }
15
16+export interface BrowserBridgeAutomationLinkSnapshot {
17+ client_id: string | null;
18+ link_id: string;
19+ local_conversation_id: string;
20+ page_title: string | null;
21+ page_url: string | null;
22+ remote_conversation_id: string | null;
23+ route_path: string | null;
24+ route_pattern: string | null;
25+ target_id: string | null;
26+ target_kind: string | null;
27+ updated_at: number;
28+}
29+
30+export interface BrowserBridgeConversationAutomationSnapshot {
31+ active_link: BrowserBridgeAutomationLinkSnapshot | null;
32+ automation_status: ConversationAutomationStatus;
33+ execution_state: ConversationAutomationExecutionState;
34+ last_error: string | null;
35+ last_non_paused_automation_status: ConversationAutomationStatus;
36+ local_conversation_id: string;
37+ pause_reason: ConversationPauseReason | null;
38+ paused_at: number | null;
39+ platform: string;
40+ remote_conversation_id: string | null;
41+ updated_at: number;
42+}
43+
44 export interface BrowserBridgeStateSnapshot {
45 active_client_id: string | null;
46 active_connection_id: string | null;
47+ automation_conversations: BrowserBridgeConversationAutomationSnapshot[];
48 client_count: number;
49 clients: BrowserBridgeClientSnapshot[];
50 delivery: BaaDeliveryBridgeSnapshot;
+97,
-0
1@@ -18,6 +18,7 @@ import type {
2 BrowserBridgeActionResultSnapshot,
3 BrowserBridgeActionResultSummarySnapshot,
4 BrowserBridgeActionResultTargetSnapshot,
5+ BrowserBridgeConversationAutomationSnapshot,
6 BrowserBridgeClientSnapshot,
7 BrowserBridgeEndpointMetadataSnapshot,
8 BrowserBridgeFinalMessageSnapshot,
9@@ -1032,6 +1033,7 @@ export class ConductorFirefoxWebSocketServer {
10 private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
11 private readonly connections = new Set<FirefoxWebSocketConnection>();
12 private readonly connectionsByClientId = new Map<string, FirefoxWebSocketConnection>();
13+ private automationConversationsSnapshot: BrowserBridgeConversationAutomationSnapshot[] = [];
14 private broadcastQueue: Promise<void> = Promise.resolve();
15 private lastSnapshotSignature: string | null = null;
16 private lastTimestampMs = 0;
17@@ -2066,6 +2068,7 @@ export class ConductorFirefoxWebSocketServer {
18 }
19
20 private async buildStateSnapshot(): Promise<Record<string, unknown>> {
21+ await this.refreshConversationAutomationSnapshot();
22 const runtime = this.snapshotLoader();
23
24 return {
25@@ -2099,6 +2102,15 @@ export class ConductorFirefoxWebSocketServer {
26 return {
27 active_client_id: activeClient?.clientId ?? null,
28 active_connection_id: activeClient?.connectionId ?? null,
29+ automation_conversations: this.automationConversationsSnapshot.map((entry) => ({
30+ ...entry,
31+ active_link:
32+ entry.active_link == null
33+ ? null
34+ : {
35+ ...entry.active_link
36+ }
37+ })),
38 client_count: clients.length,
39 clients,
40 delivery: this.deliveryBridge.getSnapshot(),
41@@ -2113,6 +2125,91 @@ export class ConductorFirefoxWebSocketServer {
42 };
43 }
44
45+ private async refreshConversationAutomationSnapshot(): Promise<void> {
46+ this.automationConversationsSnapshot = await this.loadConversationAutomationSnapshot();
47+ }
48+
49+ private async loadConversationAutomationSnapshot(): Promise<BrowserBridgeConversationAutomationSnapshot[]> {
50+ if (this.artifactStore == null) {
51+ return [];
52+ }
53+
54+ const activeLinks = await this.artifactStore.listConversationLinks({
55+ isActive: true,
56+ limit: 200
57+ });
58+
59+ if (activeLinks.length === 0) {
60+ return [];
61+ }
62+
63+ const conversations = await Promise.all(
64+ [...new Set(activeLinks.map((entry) => entry.localConversationId))]
65+ .map(async (localConversationId) => [
66+ localConversationId,
67+ await this.artifactStore!.getLocalConversation(localConversationId)
68+ ] as const)
69+ );
70+ const conversationsById = new Map(
71+ conversations.filter(([, entry]) => entry != null) as Array<
72+ readonly [string, NonNullable<Awaited<ReturnType<ArtifactStore["getLocalConversation"]>>>]
73+ >
74+ );
75+
76+ const snapshots: BrowserBridgeConversationAutomationSnapshot[] = [];
77+
78+ for (const link of activeLinks) {
79+ const conversation = conversationsById.get(link.localConversationId);
80+
81+ if (conversation == null) {
82+ continue;
83+ }
84+
85+ snapshots.push({
86+ active_link: {
87+ client_id: link.clientId ?? null,
88+ link_id: link.linkId,
89+ local_conversation_id: link.localConversationId,
90+ page_title: link.pageTitle ?? null,
91+ page_url: link.pageUrl ?? null,
92+ remote_conversation_id: link.remoteConversationId ?? null,
93+ route_path: link.routePath ?? null,
94+ route_pattern: link.routePattern ?? null,
95+ target_id: link.targetId ?? null,
96+ target_kind: link.targetKind ?? null,
97+ updated_at: link.updatedAt
98+ },
99+ automation_status: conversation.automationStatus,
100+ execution_state: conversation.executionState,
101+ last_error: conversation.lastError ?? null,
102+ last_non_paused_automation_status: conversation.lastNonPausedAutomationStatus,
103+ local_conversation_id: conversation.localConversationId,
104+ pause_reason: conversation.pauseReason ?? null,
105+ paused_at: conversation.pausedAt ?? null,
106+ platform: conversation.platform,
107+ remote_conversation_id: link.remoteConversationId ?? null,
108+ updated_at: conversation.updatedAt
109+ });
110+ }
111+
112+ return snapshots.sort((left, right) => {
113+ const leftKey = [
114+ left.platform,
115+ left.remote_conversation_id ?? "",
116+ left.local_conversation_id,
117+ left.active_link?.link_id ?? ""
118+ ].join("\u0000");
119+ const rightKey = [
120+ right.platform,
121+ right.remote_conversation_id ?? "",
122+ right.local_conversation_id,
123+ right.active_link?.link_id ?? ""
124+ ].join("\u0000");
125+
126+ return leftKey.localeCompare(rightKey);
127+ });
128+ }
129+
130 private async sendStateSnapshotTo(
131 connection: FirefoxWebSocketConnection,
132 reason: string
+115,
-0
1@@ -8431,6 +8431,121 @@ test("ConductorRuntime persists renewal conversation links from browser.final_me
2 }
3 });
4
5+test("ConductorRuntime broadcasts conversation automation snapshots after renewal API mutations", async () => {
6+ const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-overlay-automation-sync-"));
7+ const runtime = new ConductorRuntime(
8+ {
9+ nodeId: "mini-main",
10+ host: "mini",
11+ role: "primary",
12+ controlApiBase: "https://control.example.test",
13+ localApiBase: "http://127.0.0.1:0",
14+ sharedToken: "replace-me",
15+ paths: {
16+ runsDir: "/tmp/runs",
17+ stateDir
18+ }
19+ },
20+ {
21+ autoStartLoops: false,
22+ now: () => 250
23+ }
24+ );
25+
26+ let client = null;
27+
28+ try {
29+ const snapshot = await runtime.start();
30+ const baseUrl = snapshot.controlApi.localApiBase;
31+ client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-overlay-automation");
32+
33+ client.socket.send(
34+ JSON.stringify({
35+ type: "browser.final_message",
36+ platform: "chatgpt",
37+ conversation_id: "conv-overlay-automation",
38+ assistant_message_id: "msg-overlay-automation-1",
39+ raw_text: "hello overlay automation",
40+ observed_at: 1_710_000_060_000,
41+ page_title: "ChatGPT Overlay Automation",
42+ page_url: "https://chatgpt.com/c/conv-overlay-automation",
43+ tab_id: 99
44+ })
45+ );
46+
47+ const localConversationId = await waitForCondition(async () => {
48+ const linksResponse = await fetch(
49+ `${baseUrl}/v1/renewal/links?platform=chatgpt&remote_conversation_id=conv-overlay-automation`
50+ );
51+ assert.equal(linksResponse.status, 200);
52+ const payload = await linksResponse.json();
53+ assert.equal(payload.data.count, 1);
54+ return payload.data.links[0].local_conversation_id;
55+ }, 5_000, 50);
56+
57+ const pauseResponse = await fetch(
58+ `${baseUrl}/v1/renewal/conversations/${localConversationId}/paused`,
59+ {
60+ method: "POST",
61+ headers: {
62+ "content-type": "application/json"
63+ },
64+ body: JSON.stringify({
65+ pause_reason: "user_pause"
66+ })
67+ }
68+ );
69+ assert.equal(pauseResponse.status, 200);
70+
71+ const pausedSnapshot = await client.queue.next(
72+ (message) =>
73+ message.type === "state_snapshot"
74+ && Array.isArray(message.snapshot?.browser?.automation_conversations)
75+ && message.snapshot.browser.automation_conversations.some((entry) =>
76+ entry.platform === "chatgpt"
77+ && entry.remote_conversation_id === "conv-overlay-automation"
78+ && entry.automation_status === "paused"
79+ && entry.pause_reason === "user_pause"
80+ ),
81+ 8_000
82+ );
83+ assert.equal(
84+ pausedSnapshot.snapshot.browser.automation_conversations.find((entry) =>
85+ entry.remote_conversation_id === "conv-overlay-automation"
86+ )?.local_conversation_id,
87+ localConversationId
88+ );
89+
90+ const autoResponse = await fetch(
91+ `${baseUrl}/v1/renewal/conversations/${localConversationId}/auto`,
92+ {
93+ method: "POST"
94+ }
95+ );
96+ assert.equal(autoResponse.status, 200);
97+
98+ await client.queue.next(
99+ (message) =>
100+ message.type === "state_snapshot"
101+ && Array.isArray(message.snapshot?.browser?.automation_conversations)
102+ && message.snapshot.browser.automation_conversations.some((entry) =>
103+ entry.platform === "chatgpt"
104+ && entry.remote_conversation_id === "conv-overlay-automation"
105+ && entry.automation_status === "auto"
106+ ),
107+ 8_000
108+ );
109+ } finally {
110+ client?.queue.stop();
111+ client?.socket.close(1000, "done");
112+ await runtime.stop();
113+ rmSync(stateDir, {
114+ force: true,
115+ recursive: true
116+ });
117+ }
118+});
119+
120 test("ConductorRuntime gives control instructions priority over ordinary baa instructions and persists pause_reason", async () => {
121 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-control-priority-"));
122 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-control-priority-host-"));
+41,
-0
1@@ -2128,6 +2128,7 @@ function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): Browser
2 return {
3 active_client_id: null,
4 active_connection_id: null,
5+ automation_conversations: [],
6 client_count: 0,
7 clients: [],
8 delivery: {
9@@ -2153,6 +2154,9 @@ function createEmptyBrowserInstructionIngestSnapshot(): BrowserBridgeStateSnapsh
10 function normalizeBrowserStateSnapshot(state: BrowserBridgeStateSnapshot): BrowserBridgeStateSnapshot {
11 return {
12 ...state,
13+ automation_conversations: Array.isArray(state.automation_conversations)
14+ ? state.automation_conversations
15+ : [],
16 delivery: state.delivery ?? {
17 activeSessionCount: 0,
18 lastRoute: null,
19@@ -3120,6 +3124,39 @@ function serializeBrowserClientSnapshot(snapshot: BrowserBridgeClientSnapshot):
20 };
21 }
22
23+function serializeBrowserAutomationConversationSnapshot(
24+ snapshot: BrowserBridgeStateSnapshot["automation_conversations"][number]
25+): JsonObject {
26+ return compactJsonObject({
27+ active_link:
28+ snapshot.active_link == null
29+ ? null
30+ : compactJsonObject({
31+ client_id: snapshot.active_link.client_id ?? undefined,
32+ link_id: snapshot.active_link.link_id,
33+ local_conversation_id: snapshot.active_link.local_conversation_id,
34+ page_title: snapshot.active_link.page_title ?? undefined,
35+ page_url: snapshot.active_link.page_url ?? undefined,
36+ remote_conversation_id: snapshot.active_link.remote_conversation_id ?? undefined,
37+ route_path: snapshot.active_link.route_path ?? undefined,
38+ route_pattern: snapshot.active_link.route_pattern ?? undefined,
39+ target_id: snapshot.active_link.target_id ?? undefined,
40+ target_kind: snapshot.active_link.target_kind ?? undefined,
41+ updated_at: snapshot.active_link.updated_at
42+ }),
43+ automation_status: snapshot.automation_status,
44+ execution_state: snapshot.execution_state,
45+ last_error: snapshot.last_error ?? undefined,
46+ last_non_paused_automation_status: snapshot.last_non_paused_automation_status,
47+ local_conversation_id: snapshot.local_conversation_id,
48+ pause_reason: snapshot.pause_reason ?? undefined,
49+ paused_at: snapshot.paused_at ?? undefined,
50+ platform: snapshot.platform,
51+ remote_conversation_id: snapshot.remote_conversation_id ?? undefined,
52+ updated_at: snapshot.updated_at
53+ });
54+}
55+
56 function extractArrayObjects(value: JsonValue | string | null, fieldNames: readonly string[]): JsonObject[] {
57 if (Array.isArray(value)) {
58 return value.filter((entry): entry is JsonObject => isJsonObject(entry));
59@@ -3711,6 +3748,9 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
60 }
61
62 return {
63+ automation_conversations: browserState.automation_conversations.map(
64+ serializeBrowserAutomationConversationSnapshot
65+ ),
66 bridge: {
67 active_client_id: browserState.active_client_id,
68 active_connection_id: browserState.active_connection_id,
69@@ -3793,6 +3833,7 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
70 serializeBrowserMergedRecord(record, browserState.active_client_id)
71 ),
72 summary: {
73+ automation_conversation_count: browserState.automation_conversations.length,
74 active_records: records.filter((record) => record.activeConnection != null).length,
75 matched_records: records.length,
76 persisted_only_records: records.filter((record) => record.view === "persisted_only").length,
+2,
-1
1@@ -53,7 +53,7 @@
2
3 | 方法 | 路径 | 作用 |
4 | --- | --- | --- |
5-| `GET` | `/v1/browser` | 查看浏览器登录态元数据、持久化记录、插件在线状态和 `fresh` / `stale` / `lost` 状态 |
6+| `GET` | `/v1/browser` | 查看浏览器登录态元数据、持久化记录、插件在线状态,以及供 Firefox 浮层同步的 `automation_conversations` 快照 |
7 | `GET` | `/v1/browser/claude/current` | 查看当前 Claude 代理回读结果;这是 legacy Claude 辅助读接口,不是浏览器桥接主模型 |
8 | `GET` | `/v1/renewal/conversations?platform=chatgpt&status=paused` | 查看续命对话自动化状态,可按 `platform` / `status` 过滤 |
9 | `GET` | `/v1/renewal/conversations/:local_conversation_id` | 查看单个本地续命对话、当前自动化状态和 active link |
10@@ -77,6 +77,7 @@
11
12 - `GET /v1/browser` 是当前正式浏览器桥接读面;支持按 `platform`、`account`、`client_id`、`host`、`status` 过滤
13 - 这个读面只返回 `account`、凭证指纹、端点元数据和时间戳状态;不会暴露原始 `cookie`、`token` 或 header 值
14+- `GET /v1/browser` 现在会额外返回 `automation_conversations`,按 `platform + remote_conversation_id` 暴露当前对话的 `automation_status`、`pause_reason` 和 active link,供 Firefox 浮层和调试读面同步统一自动化状态
15 - `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
16 - 当前浏览器代发面正式支持 `claude` 和 `chatgpt`;Gemini 继续留在下一波
17 - `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` 当前必须显式带 `path`
+3,
-1
1@@ -69,7 +69,7 @@
2
3 | 方法 | 路径 | 作用 |
4 | --- | --- | --- |
5-| `GET` | `/v1/browser` | 返回 Firefox bridge 在线状态、插件摘要、最新 `shell_runtime` / `last_action_result` 和浏览器登录态持久化记录 |
6+| `GET` | `/v1/browser` | 返回 Firefox bridge 在线状态、插件摘要、最新 `shell_runtime` / `last_action_result`、浏览器登录态持久化记录,以及浮层同步所需的 `automation_conversations` |
7 | `GET` | `/v1/system/state` | 返回当前 automation mode、leader、queue、active runs |
8
9 ### Browser / Plugin 管理
10@@ -83,6 +83,7 @@
11 browser/plugin 管理约定:
12
13 - `GET /v1/browser` 是 plugin 状态和登录态元数据的共享读面;不是单独的第三层 describe
14+- `GET /v1/browser` 现在也会暴露 `automation_conversations`,把当前 active link 对应的 `automation_status`、`pause_reason` 和 `remote_conversation_id` 提供给 Firefox 浮层同步
15 - `POST /v1/browser/actions` 当前正式支持:
16 - `request_credentials`
17 - `tab_open`
18@@ -127,6 +128,7 @@ browser/plugin 管理约定:
19 - `GET /v1/renewal/jobs?local_conversation_id=...`
20 - `paused` 不会删除任务,只会阻止 dispatcher 继续推进待执行 job
21 - `manual` 和 `auto` / `paused` 共用同一份 `local_conversations` 后端状态,不存在插件侧单独影子开关
22+- renewal REST 写接口修改状态后,Firefox bridge 的 `state_snapshot.browser.automation_conversations` 会在下一轮 WS 推送中同步更新,供浮层实时刷新
23 - `GET /v1/renewal/conversations/:local_conversation_id` 现在会额外暴露:
24 - `pause_reason`
25 - `last_error`
+15,
-0
1@@ -125,6 +125,20 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
2 }
3 },
4 "browser": {
5+ "automation_conversations": [
6+ {
7+ "platform": "chatgpt",
8+ "remote_conversation_id": "conv_overlay_demo",
9+ "local_conversation_id": "lc_overlay_demo",
10+ "automation_status": "paused",
11+ "last_non_paused_automation_status": "auto",
12+ "pause_reason": "repeated_message",
13+ "active_link": {
14+ "page_url": "https://chatgpt.com/c/conv_overlay_demo",
15+ "page_title": "ChatGPT Overlay Demo"
16+ }
17+ }
18+ ],
19 "client_count": 1,
20 "clients": [
21 {
22@@ -145,6 +159,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
23 - `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
24 - `snapshot.browser.clients[].credentials` 只回传 `account`、`credential_fingerprint`、`freshness`、`header_count` 和时间戳
25 - `snapshot.browser.clients[].final_messages` 只保留当前活跃 bridge client 最近观测到的最终消息,不写入当前持久化表
26+- `snapshot.browser.automation_conversations` 会按 active link 暴露当前页面/对话的 `automation_status`、`pause_reason`、`local_conversation_id` 和 `remote_conversation_id`,供 Firefox 浮层同步统一自动化状态
27 - `snapshot.browser.instruction_ingest` 暴露 live ingest / execute 的持久化读面:
28 - `last_ingest`
29 - `last_execute`
+122,
-37
1@@ -119,18 +119,25 @@ function resolveCompactControlAction(snapshot, pendingAction) {
2 if (mode === "paused" || mode === "draining") {
3 return {
4 action: "resume",
5- label: "恢复",
6- title: "恢复 Conductor"
7+ label: "恢复系统",
8+ title: "恢复系统自动化"
9 };
10 }
11
12 return {
13 action: "pause",
14- label: "暂停",
15- title: "暂停 Conductor"
16+ label: "暂停系统",
17+ title: "暂停系统自动化"
18 };
19 }
20
21+function normalizePageAutomationStatus(value) {
22+ const normalized = String(value || "").trim().toLowerCase();
23+ return normalized === "auto" || normalized === "manual" || normalized === "paused"
24+ ? normalized
25+ : null;
26+}
27+
28 function normalizePageControlSnapshot(value) {
29 if (!value || typeof value !== "object" || Array.isArray(value)) {
30 return null;
31@@ -141,9 +148,13 @@ function normalizePageControlSnapshot(value) {
32 platform: trimToNull(value.platform),
33 tabId: Number.isInteger(value.tabId) ? value.tabId : null,
34 conversationId: trimToNull(value.conversationId),
35+ localConversationId: trimToNull(value.localConversationId),
36 pageUrl: trimToNull(value.pageUrl),
37 pageTitle: trimToNull(value.pageTitle),
38+ automationStatus: normalizePageAutomationStatus(value.automationStatus),
39+ lastNonPausedAutomationStatus: normalizePageAutomationStatus(value.lastNonPausedAutomationStatus),
40 paused: value.paused === true,
41+ pauseReason: trimToNull(value.pauseReason),
42 shellPage: value.shellPage === true,
43 updatedAt: Number(value.updatedAt) || 0
44 };
45@@ -162,17 +173,52 @@ function formatPageStatusLabel(snapshot) {
46 return "未识别";
47 }
48
49- return snapshot.paused ? "本页已暂停" : "本页运行中";
50+ switch (normalizePageAutomationStatus(snapshot.automationStatus)) {
51+ case "auto":
52+ return "自动";
53+ case "manual":
54+ return "手动";
55+ case "paused":
56+ return "已暂停";
57+ default:
58+ return snapshot.paused ? "本页已暂停" : "本页运行中";
59+ }
60+}
61+
62+function formatPauseReasonLabel(reason) {
63+ switch (trimToNull(reason)) {
64+ case "ai_pause":
65+ return "AI 主动暂停";
66+ case "error_loop":
67+ return "错误循环";
68+ case "execution_failure":
69+ return "执行失败";
70+ case "page_paused_by_user":
71+ case "user_pause":
72+ return "用户暂停";
73+ case "repeated_message":
74+ return "重复消息";
75+ case "repeated_renewal":
76+ return "重复续命";
77+ case "rescue_wait":
78+ return "等待救援";
79+ case "system_pause":
80+ return "系统暂停";
81+ default:
82+ return trimToNull(reason);
83+ }
84 }
85
86 function formatPendingActionLabel(action) {
87 switch (String(action || "").trim().toLowerCase()) {
88 case "pause":
89- return "全局暂停";
90+ return "系统暂停";
91 case "resume":
92- return "全局恢复";
93+ return "系统恢复";
94 case "drain":
95- return "全局排空";
96+ return "系统排空";
97+ case "page_manual":
98+ return "设为手动";
99 case "page_pause":
100 return "暂停本页";
101 case "page_resume":
102@@ -218,9 +264,9 @@ function createPageControlOverlayRuntime() {
103 let dismissButton = null;
104 let hidden = false;
105 let buttonMap = {};
106+ let pageButtonMap = {};
107 let badgeButton = null;
108 let compactActionButton = null;
109- let pageActionButton = null;
110
111 const overlayStyle = `
112 :host {
113@@ -232,7 +278,7 @@ function createPageControlOverlayRuntime() {
114 right: 14px;
115 bottom: 14px;
116 z-index: 2147483647;
117- width: 220px;
118+ width: 252px;
119 box-sizing: border-box;
120 font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
121 color: #f7f3e8;
122@@ -295,7 +341,7 @@ function createPageControlOverlayRuntime() {
123
124 .baa-page-actions {
125 display: grid;
126- grid-template-columns: minmax(0, 1fr);
127+ grid-template-columns: repeat(3, minmax(0, 1fr));
128 gap: 6px;
129 margin-bottom: 6px;
130 }
131@@ -353,7 +399,11 @@ function createPageControlOverlayRuntime() {
132 color: #9fd9ff;
133 }
134
135- .baa-btn[data-role="page"][data-paused="true"] {
136+ .baa-btn[data-role="page"][data-state="manual"] {
137+ color: #f2cb78;
138+ }
139+
140+ .baa-btn[data-role="page"][data-state="paused"] {
141 color: #b9f0c7;
142 }
143
144@@ -455,25 +505,50 @@ function createPageControlOverlayRuntime() {
145 const modeLabel = formatModeLabel(snapshot.mode);
146 const connectionLabel = formatConnectionLabel(snapshot.controlConnection);
147 const pageLabel = formatPageStatusLabel(pageSnapshot);
148+ const pauseReasonLabel = pageSnapshot?.paused && pageSnapshot?.pauseReason
149+ ? `\n暂停原因: ${formatPauseReasonLabel(pageSnapshot.pauseReason) || pageSnapshot.pauseReason}`
150+ : "";
151 const conversationLabel = pageSnapshot?.conversationId ? `\n对话: ${pageSnapshot.conversationId}` : "";
152 const actionLabel = formatPendingActionLabel(pendingAction)
153 ? `\n动作: ${formatPendingActionLabel(pendingAction)}`
154 : "";
155
156- statusText.textContent = `本页: ${pageLabel}${conversationLabel}\nConductor: ${connectionLabel}\n系统: ${modeLabel}${actionLabel}`;
157+ statusText.textContent = `系统: ${modeLabel}\n连接: ${connectionLabel}\n当前对话: ${pageLabel}${pauseReasonLabel}${conversationLabel}${actionLabel}`;
158 }
159
160 function updateButtons() {
161 const activePending = trimToNull(pendingAction);
162 const normalizedMode = normalizeMode(controlSnapshot?.mode);
163+ const automationStatus = normalizePageAutomationStatus(pageControlSnapshot?.automationStatus);
164 const pagePaused = pageControlSnapshot?.paused === true;
165+ const hasConversationAutomation = trimToNull(pageControlSnapshot?.localConversationId) != null || automationStatus != null;
166
167- if (pageActionButton) {
168- pageActionButton.disabled = activePending != null || !pageControlSnapshot;
169- pageActionButton.dataset.paused = pagePaused ? "true" : "false";
170- pageActionButton.textContent = pageControlSnapshot
171- ? (pagePaused ? "恢复本页" : "暂停本页")
172- : "本页未识别";
173+ for (const [action, button] of Object.entries(pageButtonMap)) {
174+ if (!button) {
175+ continue;
176+ }
177+
178+ let disabled = activePending != null || !pageControlSnapshot;
179+
180+ if (!disabled) {
181+ switch (action) {
182+ case "pause":
183+ disabled = pagePaused;
184+ break;
185+ case "resume":
186+ disabled = !pagePaused;
187+ break;
188+ case "manual":
189+ disabled = !hasConversationAutomation || automationStatus === "manual";
190+ break;
191+ default:
192+ disabled = true;
193+ break;
194+ }
195+ }
196+
197+ button.disabled = disabled;
198+ button.dataset.state = automationStatus || (pagePaused ? "paused" : "auto");
199 }
200
201 for (const [action, button] of Object.entries(buttonMap)) {
202@@ -493,7 +568,7 @@ function createPageControlOverlayRuntime() {
203
204 if (badgeButton) {
205 badgeButton.dataset.state = visualState;
206- badgeButton.title = `打开 BAA 控制面板(${connectionLabel} / ${modeLabel})`;
207+ badgeButton.title = `打开自动化控制面板(${connectionLabel} / ${modeLabel})`;
208 }
209
210 if (compactActionButton) {
211@@ -516,7 +591,7 @@ function createPageControlOverlayRuntime() {
212
213 if (dismissButton) {
214 dismissButton.textContent = hidden ? "+" : "×";
215- dismissButton.title = hidden ? "显示 BAA 控制" : "隐藏 BAA 控制";
216+ dismissButton.title = hidden ? "显示自动化控制" : "隐藏自动化控制";
217 }
218 }
219
220@@ -587,7 +662,10 @@ function createPageControlOverlayRuntime() {
221 }
222
223 async function runPageControlAction(action) {
224- pendingAction = action === "pause" ? "page_pause" : "page_resume";
225+ pendingAction =
226+ action === "manual"
227+ ? "page_manual"
228+ : (action === "pause" ? "page_pause" : "page_resume");
229 setError("");
230 render();
231
232@@ -694,7 +772,7 @@ function createPageControlOverlayRuntime() {
233
234 const title = document.createElement("div");
235 title.className = "baa-title";
236- title.textContent = "BAA Control";
237+ title.textContent = "自动化控制";
238 head.appendChild(title);
239
240 dismissButton = document.createElement("button");
241@@ -712,16 +790,23 @@ function createPageControlOverlayRuntime() {
242 const pageActions = document.createElement("div");
243 pageActions.className = "baa-page-actions";
244
245- pageActionButton = document.createElement("button");
246- pageActionButton.type = "button";
247- pageActionButton.className = "baa-btn";
248- pageActionButton.dataset.role = "page";
249- pageActionButton.dataset.paused = "false";
250- pageActionButton.textContent = "暂停本页";
251- pageActionButton.addEventListener("click", () => {
252- runPageControlAction(pageControlSnapshot?.paused === true ? "resume" : "pause").catch(() => {});
253- });
254- pageActions.appendChild(pageActionButton);
255+ for (const entry of [
256+ { action: "pause", label: "暂停本页" },
257+ { action: "resume", label: "恢复本页" },
258+ { action: "manual", label: "设为手动" }
259+ ]) {
260+ const button = document.createElement("button");
261+ button.type = "button";
262+ button.className = "baa-btn";
263+ button.dataset.role = "page";
264+ button.dataset.state = "auto";
265+ button.textContent = entry.label;
266+ button.addEventListener("click", () => {
267+ runPageControlAction(entry.action).catch(() => {});
268+ });
269+ pageButtonMap[entry.action] = button;
270+ pageActions.appendChild(button);
271+ }
272 panel.appendChild(pageActions);
273
274 errorText = document.createElement("div");
275@@ -732,9 +817,9 @@ function createPageControlOverlayRuntime() {
276 actions.className = "baa-actions";
277
278 for (const entry of [
279- { action: "pause", label: "暂停" },
280- { action: "resume", label: "恢复" },
281- { action: "drain", label: "排空" }
282+ { action: "pause", label: "系统暂停" },
283+ { action: "resume", label: "系统恢复" },
284+ { action: "drain", label: "系统排空" }
285 ]) {
286 const button = document.createElement("button");
287 button.type = "button";
288@@ -783,8 +868,8 @@ function createPageControlOverlayRuntime() {
289 dismissButton = null;
290 badgeButton = null;
291 compactActionButton = null;
292- pageActionButton = null;
293 buttonMap = {};
294+ pageButtonMap = {};
295 }
296 };
297 }
+349,
-22
1@@ -332,6 +332,23 @@ function cloneControlState(value) {
2 };
3 }
4
5+function normalizeConversationAutomationStatus(value) {
6+ const normalized = String(value || "").trim().toLowerCase();
7+ return normalized === "auto" || normalized === "manual" || normalized === "paused"
8+ ? normalized
9+ : null;
10+}
11+
12+function derivePageControlPaused(automationStatus, fallbackPaused = false) {
13+ const normalizedAutomationStatus = normalizeConversationAutomationStatus(automationStatus);
14+
15+ if (normalizedAutomationStatus == null) {
16+ return fallbackPaused === true;
17+ }
18+
19+ return normalizedAutomationStatus !== "auto";
20+}
21+
22 function createDefaultWsState(overrides = {}) {
23 return {
24 connection: "disconnected",
25@@ -491,9 +508,12 @@ function createDefaultPageControlState(overrides = {}) {
26 platform: null,
27 tabId: null,
28 conversationId: null,
29+ localConversationId: null,
30 pageUrl: null,
31 pageTitle: null,
32 shellPage: false,
33+ automationStatus: null,
34+ lastNonPausedAutomationStatus: null,
35 paused: false,
36 pauseSource: null,
37 pauseReason: null,
38@@ -515,10 +535,16 @@ function clonePageControlState(value) {
39 platform: normalizedPlatform,
40 tabId: Number.isInteger(value.tabId) ? value.tabId : null,
41 conversationId: trimToNull(value.conversationId),
42+ localConversationId: trimToNull(value.localConversationId),
43 pageUrl: trimToNull(value.pageUrl),
44 pageTitle: trimToNull(value.pageTitle),
45 shellPage: value.shellPage === true,
46- paused: value.paused === true,
47+ automationStatus: normalizeConversationAutomationStatus(value.automationStatus),
48+ lastNonPausedAutomationStatus: normalizeConversationAutomationStatus(value.lastNonPausedAutomationStatus),
49+ paused: derivePageControlPaused(
50+ normalizeConversationAutomationStatus(value.automationStatus),
51+ value.paused === true
52+ ),
53 pauseSource: trimToNull(value.pauseSource),
54 pauseReason: trimToNull(value.pauseReason),
55 updatedAt: Number(value.updatedAt) || 0
56@@ -628,13 +654,16 @@ function serializePageControlState(entry) {
57 platform: normalized.platform,
58 tabId: normalized.tabId,
59 conversationId: normalized.conversationId,
60+ localConversationId: normalized.localConversationId,
61 pageUrl: normalized.pageUrl,
62 pageTitle: normalized.pageTitle,
63 shellPage: normalized.shellPage,
64+ automationStatus: normalized.automationStatus,
65+ lastNonPausedAutomationStatus: normalized.lastNonPausedAutomationStatus,
66 paused: normalized.paused,
67 pauseSource: normalized.pauseSource,
68 pauseReason: normalized.pauseReason,
69- status: normalized.paused ? "paused" : "running",
70+ status: normalized.automationStatus || (normalized.paused ? "paused" : "running"),
71 updatedAt: normalized.updatedAt || 0
72 };
73 }
74@@ -656,11 +685,21 @@ function updatePageControlState(input = {}, options = {}) {
75 tabId
76 });
77 let changed = !state.pageControls[key];
78+ let conversationChanged = false;
79
80 if (Object.prototype.hasOwnProperty.call(input, "conversationId")) {
81 const conversationId = trimToNull(input.conversationId);
82 if (next.conversationId !== conversationId) {
83 next.conversationId = conversationId;
84+ conversationChanged = true;
85+ changed = true;
86+ }
87+ }
88+
89+ if (Object.prototype.hasOwnProperty.call(input, "localConversationId")) {
90+ const localConversationId = trimToNull(input.localConversationId);
91+ if (next.localConversationId !== localConversationId) {
92+ next.localConversationId = localConversationId;
93 changed = true;
94 }
95 }
96@@ -689,6 +728,38 @@ function updatePageControlState(input = {}, options = {}) {
97 }
98 }
99
100+ if (Object.prototype.hasOwnProperty.call(input, "automationStatus")) {
101+ const automationStatus = normalizeConversationAutomationStatus(input.automationStatus);
102+ if (next.automationStatus !== automationStatus) {
103+ next.automationStatus = automationStatus;
104+ changed = true;
105+ }
106+ }
107+
108+ if (Object.prototype.hasOwnProperty.call(input, "lastNonPausedAutomationStatus")) {
109+ const lastNonPausedAutomationStatus = normalizeConversationAutomationStatus(input.lastNonPausedAutomationStatus);
110+ if (next.lastNonPausedAutomationStatus !== lastNonPausedAutomationStatus) {
111+ next.lastNonPausedAutomationStatus = lastNonPausedAutomationStatus;
112+ changed = true;
113+ }
114+ }
115+
116+ if (
117+ conversationChanged
118+ && !Object.prototype.hasOwnProperty.call(input, "automationStatus")
119+ && !Object.prototype.hasOwnProperty.call(input, "lastNonPausedAutomationStatus")
120+ && !Object.prototype.hasOwnProperty.call(input, "localConversationId")
121+ && previous.localConversationId != null
122+ ) {
123+ next.localConversationId = null;
124+ next.automationStatus = null;
125+ next.lastNonPausedAutomationStatus = null;
126+ next.paused = false;
127+ next.pauseSource = null;
128+ next.pauseReason = null;
129+ changed = true;
130+ }
131+
132 if (Object.prototype.hasOwnProperty.call(input, "paused")) {
133 const paused = input.paused === true;
134 const pauseSource = paused
135@@ -714,6 +785,32 @@ function updatePageControlState(input = {}, options = {}) {
136 }
137 }
138
139+ if (Object.prototype.hasOwnProperty.call(input, "automationStatus")) {
140+ const paused = derivePageControlPaused(next.automationStatus, next.paused);
141+ const pauseSource = paused
142+ ? (trimToNull(input.pauseSource) || trimToNull(options.source) || next.pauseSource)
143+ : null;
144+ const pauseReason =
145+ next.automationStatus === "paused"
146+ ? (trimToNull(input.pauseReason) || trimToNull(options.reason) || next.pauseReason || "user_pause")
147+ : null;
148+
149+ if (next.paused !== paused) {
150+ next.paused = paused;
151+ changed = true;
152+ }
153+
154+ if (next.pauseSource !== pauseSource) {
155+ next.pauseSource = pauseSource;
156+ changed = true;
157+ }
158+
159+ if (next.pauseReason !== pauseReason) {
160+ next.pauseReason = pauseReason;
161+ changed = true;
162+ }
163+ }
164+
165 if (!changed) {
166 return serializePageControlState(previous);
167 }
168@@ -2198,9 +2295,12 @@ function buildPluginStatusPayload(options = {}) {
169 platform: entry.platform,
170 tab_id: entry.tabId,
171 conversation_id: entry.conversationId,
172+ local_conversation_id: entry.localConversationId,
173 page_url: entry.pageUrl,
174 page_title: entry.pageTitle,
175 shell_page: entry.shellPage,
176+ automation_status: entry.automationStatus,
177+ last_non_paused_automation_status: entry.lastNonPausedAutomationStatus,
178 paused: entry.paused,
179 pause_source: entry.pauseSource,
180 pause_reason: entry.pauseReason,
181@@ -3924,7 +4024,74 @@ async function refreshControlPlaneState(options = {}) {
182
183 function normalizePageControlAction(action) {
184 const methodName = String(action || "").trim().toLowerCase();
185- return methodName === "pause" || methodName === "resume" ? methodName : null;
186+ return methodName === "manual" || methodName === "pause" || methodName === "resume"
187+ ? methodName
188+ : null;
189+}
190+
191+function extractRenewalConversationData(payload) {
192+ if (!isRecord(payload)) {
193+ return null;
194+ }
195+
196+ return isRecord(payload.data) ? payload.data : payload;
197+}
198+
199+function updatePageControlFromRenewalDetail(pageControl, detail, options = {}) {
200+ if (!pageControl?.platform || !Number.isInteger(pageControl.tabId) || !isRecord(detail)) {
201+ return pageControl || null;
202+ }
203+
204+ const activeLink = isRecord(detail.active_link) ? detail.active_link : null;
205+ const nextConversationId = trimToNull(activeLink?.remote_conversation_id) || pageControl.conversationId;
206+
207+ return updatePageControlState({
208+ platform: pageControl.platform,
209+ tabId: pageControl.tabId,
210+ conversationId: nextConversationId,
211+ localConversationId: trimToNull(detail.local_conversation_id) || pageControl.localConversationId,
212+ pageUrl: trimToNull(activeLink?.page_url) || pageControl.pageUrl,
213+ pageTitle: trimToNull(activeLink?.page_title) || pageControl.pageTitle,
214+ automationStatus: detail.automation_status,
215+ lastNonPausedAutomationStatus: detail.last_non_paused_automation_status,
216+ pauseSource: options.source || "automation_sync",
217+ pauseReason: detail.automation_status === "paused" ? detail.pause_reason : null,
218+ updatedAt: Number(detail.updated_at) || Date.now()
219+ }, {
220+ persist: options.persist !== false,
221+ render: options.render !== false,
222+ reason: detail.automation_status === "paused"
223+ ? (trimToNull(detail.pause_reason) || "user_pause")
224+ : null,
225+ source: options.source || "automation_sync"
226+ });
227+}
228+
229+async function requestConversationAutomationControl(context, pageControl, action, options = {}) {
230+ const conversationId = trimToNull(pageControl?.conversationId) || trimToNull(context?.conversationId);
231+
232+ if (!context?.platform || !conversationId) {
233+ return null;
234+ }
235+
236+ const response = await requestControlPlane("/v1/internal/automation/conversations/control", {
237+ method: "POST",
238+ body: JSON.stringify({
239+ action,
240+ scope: "current",
241+ ...(action === "manual" || action === "resume"
242+ ? {
243+ action: "mode",
244+ mode: action === "manual" ? "manual" : "auto"
245+ }
246+ : {}),
247+ platform: context.platform,
248+ reason: action === "pause" ? (options.reason || "user_pause") : undefined,
249+ source_conversation_id: conversationId
250+ })
251+ });
252+
253+ return extractRenewalConversationData(response.payload);
254 }
255
256 async function runPageControlAction(action, sender, options = {}) {
257@@ -3940,21 +4107,57 @@ async function runPageControlAction(action, sender, options = {}) {
258 throw new Error("当前页面不是受支持的 AI 页面");
259 }
260
261- const page = syncPageControlFromContext(context, {
262- paused: methodName === "pause",
263- pauseSource: options.source || "runtime",
264- pauseReason: methodName === "pause"
265- ? (options.reason || "page_paused_by_user")
266- : (options.reason || "page_resumed_by_user")
267- }, {
268- persist: true,
269- render: true,
270- reason: options.reason,
271- source: options.source
272- });
273+ const currentPage = buildPageControlSnapshotForSender(sender, detectPlatformFromUrl(sender?.tab?.url || ""));
274+ let page = currentPage;
275+
276+ if (currentPage?.conversationId) {
277+ try {
278+ const detail = await requestConversationAutomationControl(context, currentPage, methodName, {
279+ reason: methodName === "pause" ? "user_pause" : null
280+ });
281+
282+ if (detail != null) {
283+ page = updatePageControlFromRenewalDetail(currentPage, detail, {
284+ persist: true,
285+ render: true,
286+ source: options.source || "runtime"
287+ });
288+ }
289+ } catch (error) {
290+ const statusCode = Number.isFinite(Number(error?.statusCode)) ? Number(error.statusCode) : null;
291+
292+ if (methodName === "manual" || statusCode !== 404) {
293+ throw error;
294+ }
295+ }
296+ }
297+
298+ if (page == null || (methodName !== "manual" && page.automationStatus == null && page.localConversationId == null)) {
299+ if (methodName === "manual") {
300+ throw new Error("当前对话还没有 conductor 记录,暂不能设为手动");
301+ }
302+
303+ page = syncPageControlFromContext(context, {
304+ localConversationId: null,
305+ automationStatus: null,
306+ lastNonPausedAutomationStatus: null,
307+ paused: methodName === "pause",
308+ pauseSource: options.source || "runtime",
309+ pauseReason: methodName === "pause"
310+ ? (options.reason || "page_paused_by_user")
311+ : null
312+ }, {
313+ persist: true,
314+ render: true,
315+ reason: options.reason,
316+ source: options.source
317+ });
318+ }
319+
320+ const pageStateLabel = page?.automationStatus || (page?.paused ? "paused" : "auto");
321 addLog(
322 "info",
323- `${platformLabel(context.platform)} 页面 ${page?.paused ? "已暂停" : "已恢复"},tab=${context.tabId}${page?.conversationId ? ` conversation=${page.conversationId}` : ""}`,
324+ `${platformLabel(context.platform)} 页面控制已更新:state=${pageStateLabel},tab=${context.tabId}${page?.conversationId ? ` conversation=${page.conversationId}` : ""}`,
325 false
326 );
327
328@@ -4806,25 +5009,105 @@ function handleWsHelloAck(message) {
329 });
330 }
331
332+function findMatchingAutomationSnapshot(pageControl, snapshots) {
333+ if (!pageControl?.platform || !Array.isArray(snapshots) || snapshots.length === 0) {
334+ return null;
335+ }
336+
337+ const conversationId = trimToNull(pageControl.conversationId);
338+ const pageUrl = trimToNull(pageControl.pageUrl);
339+
340+ return snapshots.find((entry) =>
341+ entry?.platform === pageControl.platform
342+ && (
343+ (conversationId && entry.remoteConversationId === conversationId)
344+ || (pageUrl && entry.pageUrl === pageUrl)
345+ )
346+ ) || null;
347+}
348+
349+function normalizeAutomationConversationSnapshot(value) {
350+ if (!isRecord(value)) {
351+ return null;
352+ }
353+
354+ const activeLink = isRecord(value.active_link) ? value.active_link : null;
355+ return {
356+ automationStatus: normalizeConversationAutomationStatus(value.automation_status),
357+ lastNonPausedAutomationStatus: normalizeConversationAutomationStatus(value.last_non_paused_automation_status),
358+ localConversationId: trimToNull(value.local_conversation_id),
359+ pageTitle: trimToNull(activeLink?.page_title),
360+ pageUrl: trimToNull(activeLink?.page_url),
361+ pauseReason: trimToNull(value.pause_reason),
362+ platform: trimToNull(value.platform),
363+ remoteConversationId: trimToNull(value.remote_conversation_id) || trimToNull(activeLink?.remote_conversation_id),
364+ updatedAt: Number(value.updated_at) || 0
365+ };
366+}
367+
368+function applyWsAutomationConversationSnapshot(browserSnapshot) {
369+ const rawEntries = Array.isArray(browserSnapshot?.automation_conversations)
370+ ? browserSnapshot.automation_conversations
371+ : [];
372+ const snapshots = rawEntries
373+ .map(normalizeAutomationConversationSnapshot)
374+ .filter((entry) => entry?.platform && entry.automationStatus);
375+ let changed = false;
376+
377+ for (const pageControl of listPageControlStates()) {
378+ const match = findMatchingAutomationSnapshot(pageControl, snapshots);
379+
380+ if (!match) {
381+ continue;
382+ }
383+
384+ const previous = serializePageControlState(pageControl);
385+ const next = updatePageControlState({
386+ platform: pageControl.platform,
387+ tabId: pageControl.tabId,
388+ conversationId: match.remoteConversationId || pageControl.conversationId,
389+ localConversationId: match.localConversationId,
390+ pageUrl: match.pageUrl || pageControl.pageUrl,
391+ pageTitle: match.pageTitle || pageControl.pageTitle,
392+ automationStatus: match.automationStatus,
393+ lastNonPausedAutomationStatus: match.lastNonPausedAutomationStatus,
394+ pauseReason: match.automationStatus === "paused" ? match.pauseReason : null,
395+ pauseSource: "ws_snapshot",
396+ updatedAt: match.updatedAt || Date.now()
397+ }, {
398+ persist: false,
399+ render: false,
400+ reason: match.automationStatus === "paused" ? match.pauseReason : null,
401+ source: "ws_snapshot"
402+ });
403+
404+ if (JSON.stringify(previous) !== JSON.stringify(next)) {
405+ changed = true;
406+ }
407+ }
408+
409+ return changed;
410+}
411+
412 function handleWsStateSnapshot(message) {
413- const previous = cloneWsState(state.wsState);
414+ const previousWsState = cloneWsState(state.wsState);
415+ const previousControlState = cloneControlState(state.controlState);
416 const snapshot = isRecord(message.snapshot) ? message.snapshot : {};
417 const server = isRecord(snapshot.server) ? snapshot.server : {};
418 const browserSnapshot = isRecord(snapshot.browser) ? snapshot.browser : {};
419 const clientCount = normalizeCount(getFirstDefinedValue(browserSnapshot, ["client_count", "clients"]));
420-
421- setWsState({
422- ...previous,
423+ const nextWsState = createDefaultWsState({
424+ ...previousWsState,
425 connection: "connected",
426 wsUrl: typeof server.ws_url === "string" && server.ws_url.trim() ? server.ws_url.trim() : state.wsUrl,
427 localApiBase: typeof server.local_api_base === "string" && server.local_api_base.trim()
428 ? server.local_api_base.trim()
429- : previous.localApiBase,
430+ : previousWsState.localApiBase,
431 serverIdentity: typeof server.identity === "string" && server.identity.trim() ? server.identity.trim() : null,
432 serverHost: typeof server.host === "string" && server.host.trim() ? server.host.trim() : null,
433 serverRole: typeof server.role === "string" && server.role.trim() ? server.role.trim() : null,
434 leaseState: typeof server.lease_state === "string" && server.lease_state.trim() ? server.lease_state.trim() : null,
435- clientCount: clientCount ?? previous.clientCount,
436+ clientCount: clientCount ?? previousWsState.clientCount,
437 retryCount: 0,
438 nextRetryAt: 0,
439 lastMessageAt: Date.now(),
440@@ -4833,6 +5116,37 @@ function handleWsStateSnapshot(message) {
441 lastError: null,
442 raw: truncateControlRaw(snapshot)
443 });
444+ let renderChanged = JSON.stringify(previousWsState) !== JSON.stringify(nextWsState);
445+ let persistChanged = false;
446+
447+ state.wsState = nextWsState;
448+
449+ if (isRecord(snapshot.system)) {
450+ const nextControlState = createControlSuccessState(snapshot.system, {
451+ ok: true,
452+ statusCode: 200,
453+ source: "ws_snapshot"
454+ }, previousControlState);
455+
456+ if (JSON.stringify(previousControlState) !== JSON.stringify(nextControlState)) {
457+ state.controlState = nextControlState;
458+ renderChanged = true;
459+ persistChanged = true;
460+ }
461+ }
462+
463+ if (applyWsAutomationConversationSnapshot(browserSnapshot)) {
464+ renderChanged = true;
465+ persistChanged = true;
466+ }
467+
468+ if (persistChanged) {
469+ persistState().catch(() => {});
470+ }
471+
472+ if (renderChanged) {
473+ render();
474+ }
475 }
476
477 function closeWsConnection() {
478@@ -5830,6 +6144,10 @@ function syncPageControlFromContext(context, overrides = {}, options = {}) {
479 : context.isShellPage
480 };
481
482+ if (Object.prototype.hasOwnProperty.call(overrides, "localConversationId")) {
483+ input.localConversationId = overrides.localConversationId;
484+ }
485+
486 if (Object.prototype.hasOwnProperty.call(overrides, "paused")) {
487 input.paused = overrides.paused;
488 }
489@@ -5842,6 +6160,14 @@ function syncPageControlFromContext(context, overrides = {}, options = {}) {
490 input.pauseReason = overrides.pauseReason;
491 }
492
493+ if (Object.prototype.hasOwnProperty.call(overrides, "automationStatus")) {
494+ input.automationStatus = overrides.automationStatus;
495+ }
496+
497+ if (Object.prototype.hasOwnProperty.call(overrides, "lastNonPausedAutomationStatus")) {
498+ input.lastNonPausedAutomationStatus = overrides.lastNonPausedAutomationStatus;
499+ }
500+
501 if (Object.prototype.hasOwnProperty.call(overrides, "updatedAt")) {
502 input.updatedAt = overrides.updatedAt;
503 }
504@@ -7583,6 +7909,7 @@ function exposeControllerTestApi() {
505 handlePageDiagnosticLog,
506 handlePageNetwork,
507 handlePageSse,
508+ handleWsStateSnapshot,
509 persistFinalMessageRelayCache,
510 reinjectAllOpenPlatformTabs,
511 reinjectPlatformTabs,
+130,
-1
1@@ -39,7 +39,7 @@ class FakeWebSocket {
2 }
3 }
4
5-function createControllerHarness() {
6+function createControllerHarness(options = {}) {
7 FakeWebSocket.instances = [];
8
9 const windowListeners = new Map();
10@@ -133,6 +133,9 @@ function createControllerHarness() {
11 }
12 },
13 fetch() {
14+ if (typeof options.fetchImpl === "function") {
15+ return options.fetchImpl(...arguments);
16+ }
17 return Promise.resolve(new Response("{}", { status: 200 }));
18 },
19 location: window.location,
20@@ -229,3 +232,129 @@ test("controller bounds buffered plugin diagnostic logs and flushes the newest e
21 assert.equal(sent[49]?.text, "buffered-log-60");
22 assert.equal(api.state.pendingPluginDiagnosticLogs.length, 0);
23 });
24+
25+test("controller routes page control actions to conversation automation control and updates page state", async () => {
26+ const requests = [];
27+ const { api } = createControllerHarness({
28+ fetchImpl(url, options = {}) {
29+ requests.push({
30+ body: options.body ? JSON.parse(options.body) : null,
31+ method: options.method || "GET",
32+ url
33+ });
34+ return Promise.resolve(new Response(JSON.stringify({
35+ ok: true,
36+ data: {
37+ local_conversation_id: "lc-chatgpt-1",
38+ platform: "chatgpt",
39+ automation_status: "manual",
40+ last_non_paused_automation_status: "manual",
41+ updated_at: 1_710_000_100_000,
42+ active_link: {
43+ remote_conversation_id: "conv-automation-control",
44+ page_url: "https://chatgpt.com/c/conv-automation-control",
45+ page_title: "ChatGPT Automation"
46+ }
47+ }
48+ }), {
49+ status: 200,
50+ headers: {
51+ "content-type": "application/json"
52+ }
53+ }));
54+ }
55+ });
56+
57+ api.state.controlBaseUrl = "http://127.0.0.1:4317";
58+
59+ const sender = {
60+ tab: {
61+ id: 42,
62+ title: "ChatGPT Automation",
63+ url: "https://chatgpt.com/c/conv-automation-control"
64+ }
65+ };
66+
67+ const result = await api.runPageControlAction("manual", sender, {
68+ source: "page_overlay"
69+ });
70+
71+ assert.equal(requests.length, 1);
72+ assert.equal(requests[0].method, "POST");
73+ assert.equal(requests[0].url, "http://127.0.0.1:4317/v1/internal/automation/conversations/control");
74+ assert.deepEqual(requests[0].body, {
75+ action: "mode",
76+ scope: "current",
77+ mode: "manual",
78+ platform: "chatgpt",
79+ source_conversation_id: "conv-automation-control"
80+ });
81+ assert.equal(result.page.automationStatus, "manual");
82+ assert.equal(result.page.localConversationId, "lc-chatgpt-1");
83+ assert.equal(result.page.paused, true);
84+});
85+
86+test("controller applies websocket automation snapshots to control and page state", () => {
87+ const { api } = createControllerHarness();
88+ const sender = {
89+ tab: {
90+ id: 7,
91+ title: "ChatGPT Overlay",
92+ url: "https://chatgpt.com/c/conv-ws-sync"
93+ }
94+ };
95+ const context = api.getSenderContext(sender, "chatgpt");
96+
97+ api.syncPageControlFromContext(context, {}, {
98+ persist: false,
99+ render: false
100+ });
101+
102+ api.handleWsStateSnapshot({
103+ reason: "poll",
104+ snapshot: {
105+ server: {
106+ identity: "mini-main@mini(primary)",
107+ local_api_base: "http://127.0.0.1:4317",
108+ ws_url: "ws://127.0.0.1:4317/ws/firefox"
109+ },
110+ system: {
111+ mode: "paused",
112+ updated_at: 1_710_000_000_000,
113+ leader: {
114+ controller_id: "mini-main"
115+ },
116+ queue: {
117+ active_runs: 0,
118+ queued_tasks: 0
119+ }
120+ },
121+ browser: {
122+ client_count: 1,
123+ automation_conversations: [
124+ {
125+ platform: "chatgpt",
126+ remote_conversation_id: "conv-ws-sync",
127+ local_conversation_id: "lc-ws-sync",
128+ automation_status: "paused",
129+ last_non_paused_automation_status: "auto",
130+ pause_reason: "repeated_message",
131+ updated_at: 1_710_000_000_500,
132+ active_link: {
133+ page_url: "https://chatgpt.com/c/conv-ws-sync",
134+ page_title: "ChatGPT Overlay"
135+ }
136+ }
137+ ]
138+ }
139+ }
140+ });
141+
142+ assert.equal(api.state.controlState.mode, "paused");
143+ assert.equal(api.state.controlState.controlConnection, "connected");
144+ const pageState = api.state.pageControls["chatgpt:7"];
145+ assert.equal(pageState.localConversationId, "lc-ws-sync");
146+ assert.equal(pageState.automationStatus, "paused");
147+ assert.equal(pageState.pauseReason, "repeated_message");
148+ assert.equal(pageState.paused, true);
149+});
+25,
-6
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- 当前状态:`待开始`
6+- 当前状态:`已完成`
7 - 规模预估:`M`
8 - 依赖任务:`T-S060`
9 - 建议执行者:`Claude / Codex`(需要同时处理插件浮层、WS 同步和状态映射)
10@@ -151,22 +151,41 @@
11
12 ### 开始执行
13
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-04-01 11:31:00 CST`
18 - 状态变更:`待开始` → `进行中`
19
20 ### 完成摘要
21
22-- 完成时间:
23+- 完成时间:`2026-04-01 12:08:52 CST`
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+ - `plugins/baa-firefox/content-script.js`
27+ - `plugins/baa-firefox/controller.js`
28+ - `plugins/baa-firefox/controller.test.cjs`
29+ - `apps/conductor-daemon/src/browser-types.ts`
30+ - `apps/conductor-daemon/src/firefox-ws.ts`
31+ - `apps/conductor-daemon/src/local-api.ts`
32+ - `apps/conductor-daemon/src/index.test.js`
33+ - `docs/api/business-interfaces.md`
34+ - `docs/api/control-interfaces.md`
35+ - `docs/api/firefox-local-ws.md`
36+ - `tasks/T-S061.md`
37+ - `tasks/TASK_OVERVIEW.md`
38 - 核心实现思路:
39+ - 把 Firefox 浮层从单一 `page paused` 布尔值升级成系统级 + 当前对话级分层展示,新增 `manual / auto / paused`、`local_conversation_id` 和 `pause_reason` 的本地状态映射,并把页面按钮拆成 `暂停本页` / `恢复本页` / `设为手动`
40+ - controller 在当前对话已被 conductor 识别时,统一通过 `/v1/internal/automation/conversations/control` 驱动 renewal 自动化状态;如果对话还没有 local conversation,则继续只落本地 page_control,避免误把“恢复本页”升级成系统恢复
41+ - conductor 的 Firefox WS `state_snapshot` 新增 `automation_conversations`,按 active link 暴露 `remote_conversation_id`、`automation_status`、`pause_reason` 和 `local_conversation_id`,让 REST 改状态、自动熔断和插件浮层都走同一份快照同步
42 - 跑了哪些测试:
43+ - `cd /Users/george/code/baa-conductor-unified-overlay-automation-control && pnpm install`
44+ - `node --test /Users/george/code/baa-conductor-unified-overlay-automation-control/plugins/baa-firefox/controller.test.cjs`
45+ - `cd /Users/george/code/baa-conductor-unified-overlay-automation-control && pnpm -C apps/conductor-daemon test`
46
47 ### 执行过程中遇到的问题
48
49-- 暂无
50+- 新 worktree 初始没有 `node_modules`,`pnpm exec tsc` 会直接报 `tsc not found`;先在 worktree 根目录补了一次 `pnpm install`
51+- Firefox WS 新增 `automation_conversations` 后,TypeScript 对 `map(...).filter(Boolean)` 的推断不够收敛;改成显式构造数组后恢复通过
52
53 ### 剩余风险
54
55-- 暂无
56+- 浏览器侧 real-time 刷新目前依赖 Firefox WS 的 2 秒轮询快照;对外部 REST 改状态已经能自动同步到浮层,但如果后续要做“立即 push”级别的时效性,可能还需要单独事件通知通道
+4,
-5
1@@ -44,6 +44,7 @@
2 - conductor 执行链路已补统一超时保护
3 - renewal dispatcher 已支持 inter-job jitter 和 retry jitter
4 - 自动化仲裁基础已经落地:同条 final-message 现在按 `control > instruction > renewal` 顺序仲裁,同对话具备执行锁、`pause_reason` 和自动熔断基础能力
5+ - Firefox 浮层统一自动化控制已经落地:页面入口会同步显示系统状态、当前对话 `automation_status` 和 `pause_reason`,并通过 WS 与 renewal/page_control 保持一致
6
7 ## 当前已确认的不一致
8
9@@ -74,13 +75,13 @@
10 | [`T-S058`](./T-S058.md) | 消息同步任务生成续命任务 | M | T-S055, T-S056, T-S057 | Claude / Codex | 已完成 |
11 | [`T-S059`](./T-S059.md) | 续命执行任务与运维接口 | M | T-S055, T-S056, T-S057, T-S058 | Codex | 已完成 |
12 | [`T-S060`](./T-S060.md) | 自动化仲裁与自动熔断基础 | L | T-S056, T-S058, T-S059 | Codex | 已完成 |
13+| [`T-S061`](./T-S061.md) | 浮层统一自动化控制 | M | T-S060 | Codex | 已完成 |
14
15 ### 当前下一波任务
16
17 | 项目 | 标题 | 类型 | 状态 | 说明 |
18 |---|---|---|---|---|
19 | [`T-S063`](./T-S063.md) | normalize / parse 错误隔离 | task | 待开始 | 收口指令链路健壮性,避免单个坏 block 中断整批 |
20-| [`T-S061`](./T-S061.md) | 浮层统一自动化控制 | task | 待开始 | 统一页面级 BAA / renewal 控制入口并显示 pause_reason |
21 | [`T-S062`](./T-S062.md) | 系统级暂停接入自动化主链 | task | 待开始 | 把系统级暂停接入 BAA 与 timed-jobs 主链 |
22 | [`T-S064`](./T-S064.md) | timed-jobs 异步日志写入 | task | 待开始 | 减少同步日志 IO 对事件循环的阻塞 |
23 | [`T-S065`](./T-S065.md) | policy 配置化 | task | 待开始 | 为自动化控制指令和后续扩面提供策略入口 |
24@@ -137,7 +138,6 @@
25
26 ### P2(次级优化)
27
28-- [`T-S061`](./T-S061.md)
29 - [`T-S062`](./T-S062.md)
30 - [`T-S064`](./T-S064.md)
31 - [`T-S065`](./T-S065.md)
32@@ -177,11 +177,10 @@
33
34 ## 当前主线判断
35
36-Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060` 的自动化仲裁基础也已落地。当前主线已经没有 open bug blocker,下一步是:
37+Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060` 的自动化仲裁基础和 `T-S061` 的浮层统一自动化控制都已落地。当前主线已经没有 open bug blocker,下一步是:
38
39 - 先做 `T-S063`
40-- 然后做 `T-S061`
41-- 再做 `T-S062`
42+- 然后做 `T-S062`
43 - 之后收口 `T-S064`、`T-S065`
44 - `OPT-004`、`OPT-009` 继续保留为 open opt
45