- commit
- 43de232
- parent
- 54f1c61
- author
- codex@macbookpro
- date
- 2026-04-01 18:34:07 +0800 CST
feat: formalize Gemini raw relay support
14 files changed,
+648,
-83
Raw patch view.
1diff --git a/apps/conductor-daemon/src/browser-types.ts b/apps/conductor-daemon/src/browser-types.ts
2index d7ab05d50e012bd94682779de1fc65b9cf2fd82c..b8f0714defcc2a7294b4105bb0e1915159bd03be 100644
3--- a/apps/conductor-daemon/src/browser-types.ts
4+++ b/apps/conductor-daemon/src/browser-types.ts
5@@ -294,6 +294,7 @@ export interface BrowserBridgeController {
6 apiRequest(input: {
7 body?: unknown;
8 clientId?: string | null;
9+ conversationId?: string | null;
10 headers?: Record<string, string> | null;
11 id?: string | null;
12 idleTimeoutMs?: number | null;
13@@ -378,6 +379,7 @@ export interface BrowserBridgeController {
14 streamRequest(input: {
15 body?: unknown;
16 clientId?: string | null;
17+ conversationId?: string | null;
18 headers?: Record<string, string> | null;
19 id?: string | null;
20 idleTimeoutMs?: number | null;
21diff --git a/apps/conductor-daemon/src/firefox-bridge.ts b/apps/conductor-daemon/src/firefox-bridge.ts
22index 409d2e5e4bf5fba1a334adff70f8e2239b992844..4d3749ce4a847ce75e026f04eac33771fe27bdb7 100644
23--- a/apps/conductor-daemon/src/firefox-bridge.ts
24+++ b/apps/conductor-daemon/src/firefox-bridge.ts
25@@ -89,6 +89,7 @@ export interface FirefoxPluginActionCommandInput extends FirefoxBridgeCommandTar
26
27 export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarget {
28 body?: unknown;
29+ conversationId?: string | null;
30 headers?: Record<string, string> | null;
31 id?: string | null;
32 idleTimeoutMs?: number | null;
33@@ -1581,6 +1582,7 @@ export class FirefoxBridgeService {
34 return await this.broker.sendApiRequest(
35 compactRecord({
36 body: input.body ?? null,
37+ conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
38 headers: normalizeHeaderRecord(input.headers),
39 method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
40 path,
41@@ -1617,6 +1619,7 @@ export class FirefoxBridgeService {
42 return this.broker.openApiStream(
43 compactRecord({
44 body: input.body ?? null,
45+ conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
46 headers: normalizeHeaderRecord(input.headers),
47 method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
48 path,
49diff --git a/apps/conductor-daemon/src/index.test.js b/apps/conductor-daemon/src/index.test.js
50index 1c7cdd0c7ab1951baffc3b683720eeb0807bea19..7e4bae0409347254c8254e06adc62d930d5e305e 100644
51--- a/apps/conductor-daemon/src/index.test.js
52+++ b/apps/conductor-daemon/src/index.test.js
53@@ -6010,7 +6010,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
54 );
55 assert.deepEqual(
56 describePayload.data.browser.request_contract.supported_platforms,
57- ["claude", "chatgpt"]
58+ ["claude", "chatgpt", "gemini"]
59 );
60 assert.deepEqual(
61 describePayload.data.browser.action_contract.supported_platforms,
62@@ -6066,7 +6066,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
63 assert.equal(businessDescribePayload.data.browser.request_contract.route.path, "/v1/browser/request");
64 assert.deepEqual(
65 businessDescribePayload.data.browser.request_contract.supported_platforms,
66- ["claude", "chatgpt"]
67+ ["claude", "chatgpt", "gemini"]
68 );
69 assert.match(
70 JSON.stringify(businessDescribePayload.data.browser.request_contract.supported_response_modes),
71@@ -6298,6 +6298,36 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
72 assert.equal(chatgptBufferedPayload.data.response.models[0].slug, "gpt-5.4");
73 assert.equal(chatgptBufferedPayload.data.policy.platform, "chatgpt");
74
75+ const geminiBufferedResponse = await handleConductorHttpRequest(
76+ {
77+ body: JSON.stringify({
78+ conversationId: "conv-gemini-current",
79+ platform: "gemini",
80+ prompt: "hello generic gemini relay",
81+ requestId: "browser-gemini-buffered-123",
82+ path: "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
83+ }),
84+ method: "POST",
85+ path: "/v1/browser/request"
86+ },
87+ localApiContext
88+ );
89+ assert.equal(geminiBufferedResponse.status, 200);
90+ const geminiBufferedPayload = parseJsonBody(geminiBufferedResponse);
91+ assert.equal(geminiBufferedPayload.data.request_mode, "api_request");
92+ assert.equal(
93+ geminiBufferedPayload.data.proxy.path,
94+ "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
95+ );
96+ assert.equal(geminiBufferedPayload.data.proxy.request_id, "browser-gemini-buffered-123");
97+ assert.equal(geminiBufferedPayload.data.response.conversation_id, "conv-gemini-current");
98+ assert.equal(geminiBufferedPayload.data.policy.platform, "gemini");
99+ const geminiBufferedCall = browser.calls.find(
100+ (entry) => entry.kind === "apiRequest" && entry.id === "browser-gemini-buffered-123"
101+ );
102+ assert.ok(geminiBufferedCall);
103+ assert.equal(geminiBufferedCall.conversationId, "conv-gemini-current");
104+
105 const chatgptLegacySendResponse = await handleConductorHttpRequest(
106 {
107 body: JSON.stringify({
108@@ -6804,6 +6834,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
109 "apiRequest:GET:/api/stream-buffered-smoke",
110 "apiRequest:GET:/backend-api/conversation-buffered-smoke",
111 "apiRequest:GET:/backend-api/models",
112+ "apiRequest:POST:/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate",
113 "apiRequest:POST:/backend-api/conversation",
114 "apiRequest:GET:/backend-api/conversation/conv-chatgpt-current",
115 "apiRequest:POST:/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate",
116diff --git a/apps/conductor-daemon/src/local-api.ts b/apps/conductor-daemon/src/local-api.ts
117index d5b92a2ab66ba54df1c9b2b9bb454af914186930..fb8af94b1e6d72cc91dfb4b9ea6e3b35593da64e 100644
118--- a/apps/conductor-daemon/src/local-api.ts
119+++ b/apps/conductor-daemon/src/local-api.ts
120@@ -198,7 +198,7 @@ const SUPPORTED_BROWSER_ACTIONS = [
121 ] as const;
122 const RESERVED_BROWSER_ACTIONS = [] as const;
123 const FORMAL_BROWSER_SHELL_PLATFORMS = ["claude", "chatgpt"] as const;
124-const FORMAL_BROWSER_REQUEST_PLATFORMS = ["claude", "chatgpt"] as const;
125+const FORMAL_BROWSER_REQUEST_PLATFORMS = ["claude", "chatgpt", "gemini"] as const;
126 const SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES = ["buffered", "sse"] as const;
127 const RESERVED_BROWSER_REQUEST_RESPONSE_MODES = [] as const;
128 const MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS = 60_000;
129@@ -3405,6 +3405,7 @@ async function requestBrowserProxy(
130 action: string;
131 body?: JsonValue;
132 clientId?: string | null;
133+ conversationId?: string | null;
134 headers?: Record<string, string>;
135 id?: string | null;
136 method: string;
137@@ -3441,6 +3442,7 @@ async function requestBrowserProxy(
138 const apiRequestPromise = bridge.apiRequest({
139 body: input.body,
140 clientId: input.clientId,
141+ conversationId: input.conversationId,
142 headers: input.headers,
143 id: requestId,
144 method: input.method,
145@@ -4165,7 +4167,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
146 route: describeRoute(requireRouteDefinition("browser.request")),
147 request_body: {
148 platform:
149- "必填字符串;当前正式支持 claude 和 chatgpt。claude 额外支持省略 path + prompt 的兼容模式;chatgpt 当前只支持显式 path 的 raw proxy 请求。",
150+ "必填字符串;当前正式支持 claude、chatgpt 和 gemini。claude 额外支持省略 path + prompt 的兼容模式;chatgpt / gemini 当前只支持显式 path 的 raw proxy 请求。",
151 clientId: "可选字符串;指定目标 Firefox bridge client。",
152 requestId: "可选字符串;用于 trace 和未来 cancel 对齐。缺省时由 conductor 生成。",
153 method: "可选字符串;默认 GET。若携带 requestBody 或 prompt 且未显式指定,则默认 POST。",
154@@ -4174,7 +4176,8 @@ function buildBrowserRequestContract(origin: string): JsonObject {
155 requestBody: "可选任意 JSON;作为代发请求体原样传入。",
156 prompt: '可选 Claude 兼容字段;当 platform=claude 且省略 path 时,会自动补全 completion 路径。',
157 organizationId: "可选 Claude 字段;覆盖自动选择的 organization。",
158- conversationId: "可选 Claude 字段;覆盖自动选择的 conversation。",
159+ conversationId:
160+ "可选字符串;Claude 可覆盖自动选择的 conversation;Gemini 会优先用于匹配会话级持久化发送模板。",
161 responseMode:
162 '可选字符串 buffered 或 sse;buffered 返回 JSON,sse 返回 text/event-stream,并按 stream_open / stream_event / stream_end / stream_error 编码。',
163 timeoutMs: `可选整数 > 0;默认 ${DEFAULT_BROWSER_PROXY_TIMEOUT_MS}。`
164@@ -4205,6 +4208,15 @@ function buildBrowserRequestContract(origin: string): JsonObject {
165 method: "GET",
166 path: "/backend-api/models"
167 })
168+ },
169+ {
170+ title: "Issue a Gemini raw relay request against a captured StreamGenerate template",
171+ curl: buildCurlExample(origin, requireRouteDefinition("browser.request"), {
172+ conversationId: "conv-gemini-current",
173+ platform: "gemini",
174+ path: BROWSER_GEMINI_STREAM_GENERATE_PATH,
175+ prompt: "Summarize the latest conductor health snapshot."
176+ })
177 }
178 ],
179 error_semantics: [
180@@ -4279,8 +4291,9 @@ function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: str
181 notes: [
182 "Business-facing browser work now lands on POST /v1/browser/request; browser/plugin management lands on POST /v1/browser/actions.",
183 "GET /v1/browser remains the shared read model for login-state metadata, plugin connectivity, shell_runtime, and the latest structured action_result per client.",
184- "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT raw relay, and expects a local Firefox bridge client.",
185- "Claude keeps the prompt shortcut when path is omitted; ChatGPT currently requires an explicit path and a real browser login context captured on the selected client.",
186+ "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT and Gemini raw relay, and expects a local Firefox bridge client.",
187+ "Claude keeps the prompt shortcut when path is omitted; ChatGPT and Gemini require an explicit path and a real browser login context captured on the selected client.",
188+ "Gemini raw relay can additionally use conversationId to prefer a conversation-matched persisted send template when the plugin has one.",
189 "The legacy helper surface now also exposes /v1/browser/chatgpt/* and /v1/browser/gemini/* wrappers for BAA target compatibility.",
190 "POST /v1/browser/actions now waits for the plugin to return a structured action_result instead of returning only a dispatch ack.",
191 "POST /v1/browser/request now supports buffered JSON and formal SSE event envelopes; POST /v1/browser/request/cancel cancels an in-flight browser request by requestId.",
192@@ -5047,7 +5060,7 @@ async function handleCapabilitiesRead(
193 notes: [
194 "Read routes are safe for discovery and inspection.",
195 "The browser HTTP contract is now split into GET /v1/browser, POST /v1/browser/request, and POST /v1/browser/actions.",
196- "The generic browser request surface now formally supports Claude and ChatGPT; /v1/browser/{claude,chatgpt,gemini}/* remains available as legacy compatibility wrappers.",
197+ "The generic browser request surface now formally supports Claude, ChatGPT, and Gemini; /v1/browser/{claude,chatgpt,gemini}/* remains available as legacy compatibility wrappers.",
198 "All /v1/codex routes proxy the independent codexd daemon over local HTTP.",
199 "POST /v1/system/* writes the local automation mode immediately.",
200 "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN> and return 401 JSON on missing or wrong tokens.",
201@@ -5598,6 +5611,7 @@ async function executeBrowserRequest(
202 const stream = requireBrowserBridge(context).streamRequest({
203 body: requestBody ?? { prompt: "" },
204 clientId: selection.client.client_id,
205+ conversationId: input.conversationId ?? conversation.id,
206 headers: input.headers,
207 id: requestId,
208 idleTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.idleTimeoutMs,
209@@ -5635,6 +5649,7 @@ async function executeBrowserRequest(
210 action: "browser request",
211 body: requestBody ?? { prompt: "" },
212 clientId: selection.client.client_id,
213+ conversationId: input.conversationId ?? conversation.id,
214 headers: input.headers,
215 id: requestId,
216 method: requestMethod,
217@@ -5697,6 +5712,7 @@ async function executeBrowserRequest(
218 const stream = requireBrowserBridge(context).streamRequest({
219 body: requestBody,
220 clientId: targetClient.client_id,
221+ conversationId: input.conversationId,
222 headers: input.headers,
223 id: requestId,
224 idleTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.idleTimeoutMs,
225@@ -5734,6 +5750,7 @@ async function executeBrowserRequest(
226 action: "browser request",
227 body: requestBody,
228 clientId: targetClient.client_id,
229+ conversationId: input.conversationId,
230 headers: input.headers,
231 id: requestId,
232 method: requestMethod,
233diff --git a/docs/api/README.md b/docs/api/README.md
234index f3396284683286d562fb318fc9d4c11cecd07dea..c36e7831b6a005551bac9024bc63a3df572c9b76 100644
235--- a/docs/api/README.md
236+++ b/docs/api/README.md
237@@ -153,15 +153,15 @@ Browser 面约定:
238 - `conductor` 只保存并回显 `account`、`credential_fingerprint`、`endpoints`、`endpoint_metadata`、时间戳和 `fresh/stale/lost`
239 - 原始 `cookie`、`token`、header 值不会入库,也不会出现在 `/v1/browser` 读接口里
240 - 连接断开或流量老化后,持久化记录仍可读,但状态会从 `fresh` 变成 `stale` / `lost`
241-- 当前浏览器本地代发面正式支持 `claude` 和 `chatgpt`;Gemini 目前仍只保留壳页和元数据上报,不在正式 HTTP relay 合同里
242-- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` 当前必须显式提供 `path`
243+- 当前浏览器本地代发面正式支持 `claude`、`chatgpt` 和 `gemini`;其中 Gemini 已进入正式 HTTP relay 合同
244+- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` / `platform=gemini` 当前都必须显式提供 `path`
245 - `POST /v1/browser/request` 支持 `responseMode=buffered` 和 `responseMode=sse`
246 - SSE 响应固定用 `stream_open`、`stream_event`、`stream_end`、`stream_error` 作为 event name;`stream_event` 带递增 `seq`
247 - `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`,并返回结构化 `action_result`
248 - `GET /v1/browser` 会回显当前风控默认值、最新 `shell_runtime` / `last_action_result` 和运行时 target/platform 状态,便于观察抖动、限流、退避和熔断
249 - `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
250 - `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
251-- ChatGPT raw relay 同样依赖真实浏览器里已捕获到的有效登录态 / header;建议先用 `GET /v1/browser?platform=chatgpt&status=fresh` 确认再发请求
252+- ChatGPT / Gemini raw relay 同样依赖真实浏览器里已捕获到的有效登录态 / header;建议先用 `GET /v1/browser?platform=chatgpt&status=fresh`、`GET /v1/browser?platform=gemini&status=fresh` 确认再发请求
253 - 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
254 - 如果已连接 client 还没拿到 Claude 凭证,会返回 `409` JSON 错误并提示先在浏览器里完成一轮真实请求
255
256diff --git a/docs/api/business-interfaces.md b/docs/api/business-interfaces.md
257index a1632224cb9521f6f538153b1b71ce4a306f95b1..a141e4687f970ba1237fc51301e86704effb0cd6 100644
258--- a/docs/api/business-interfaces.md
259+++ b/docs/api/business-interfaces.md
260@@ -79,8 +79,8 @@
261 - 这个读面只返回 `account`、凭证指纹、端点元数据和时间戳状态;不会暴露原始 `cookie`、`token` 或 header 值
262 - `GET /v1/browser` 现在会额外返回 `automation_conversations`,按 `platform + remote_conversation_id` 暴露当前对话的 `automation_status`、`pause_reason` 和 active link,供 Firefox 浮层和调试读面同步统一自动化状态
263 - `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
264-- 当前浏览器代发面正式支持 `claude` 和 `chatgpt`;Gemini 继续留在下一波
265-- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` 当前必须显式带 `path`
266+- 当前浏览器代发面正式支持 `claude`、`chatgpt` 和 `gemini`
267+- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` / `platform=gemini` 当前都必须显式带 `path`
268 - `POST /v1/browser/request` 支持 `responseMode=buffered` 和 `responseMode=sse`
269 - `responseMode=sse` 会返回 `text/event-stream`,事件名固定为 `stream_open`、`stream_event`、`stream_end`、`stream_error`
270 - `stream_event` 都带递增 `seq`;失败、超时或取消时会带着已收到的 partial 状态落到 `stream_error`
271@@ -99,7 +99,7 @@
272 - `repeated_message_count`
273 - `repeated_renewal_count`
274 - `conductor` 启动时会自动释放异常退出遗留的 `execution_state`,并把 `status=running` 的 renewal job 安全回排为 `pending`
275-- ChatGPT raw relay 仍依赖浏览器里真实捕获到的登录态 / header;建议先看 `GET /v1/browser?platform=chatgpt&status=fresh`
276+- ChatGPT / Gemini raw relay 仍依赖浏览器里真实捕获到的登录态 / header;建议先看 `GET /v1/browser?platform=chatgpt&status=fresh`、`GET /v1/browser?platform=gemini&status=fresh`
277 - 如果没有活跃 Firefox bridge client,会返回 `503`
278 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
279 - 打开、聚焦、重载标签页等 browser/plugin 管理动作已经移到 [`control-interfaces.md`](./control-interfaces.md) 和 `GET /describe/control`
280diff --git a/docs/api/control-interfaces.md b/docs/api/control-interfaces.md
281index c74587ed93bf5d74fecc5e2c8d19eebcf4c4b7fb..eb72fef2b821396567bc49718f21c5f8fa2d1a6e 100644
282--- a/docs/api/control-interfaces.md
283+++ b/docs/api/control-interfaces.md
284@@ -97,7 +97,7 @@ browser/plugin 管理约定:
285 - `disconnectMs` / `disconnect_ms` / `delayMs` / `delay_ms`
286 - `repeatCount` / `repeat_count`
287 - `repeatIntervalMs` / `repeat_interval_ms` / `intervalMs` / `interval_ms`
288-- 当前正式 shell / credential 管理平台已覆盖 `claude` 和 `chatgpt`;Gemini 仍以空壳页和元数据上报为主
289+- 当前正式 shell / credential 管理平台仍覆盖 `claude` 和 `chatgpt`;Gemini raw relay 已转入 business 面,但 control 面的 shell 管理合同没有在这轮一起扩面
290 - 如果没有活跃 Firefox bridge client,会返回 `503`
291 - 如果指定了不存在的 `clientId`,会返回 `409`
292 - `POST /v1/browser/actions` 会等待插件回传结构化 `action_result`,返回 `accepted` / `completed` / `failed` / `reason` / `target` / `result` / `shell_runtime`
293diff --git a/docs/firefox/README.md b/docs/firefox/README.md
294index 6672ae4718907bac14dc772cfba3dbd591bcc9ec..20bb20bb51f9b067d2c95919397e9b3c291cef5b 100644
295--- a/docs/firefox/README.md
296+++ b/docs/firefox/README.md
297@@ -194,14 +194,14 @@
298
299 这条链路的关键边界:
300
301-- 当前正式 relay 平台已支持 Claude 和 ChatGPT;其中 Claude 保留 prompt shortcut,ChatGPT 当前只支持显式 path 的 raw relay
302+- 当前正式 relay 平台已支持 Claude、ChatGPT 和 Gemini;其中 Claude 保留 prompt shortcut,ChatGPT / Gemini 当前只支持显式 path 的 raw relay
303 - `request` / `current` 通过插件已有的页面内 HTTP 代理完成,不是 DOM 自动化
304 - `conductor` 不直接持有原始平台凭证
305 - `responseMode=sse` 时,浏览器会通过 `stream_open` / `stream_event` / `stream_end` / `stream_error` 回传
306 - `POST /v1/browser/request/cancel` 会向插件下发正式 `request_cancel`
307 - 如果没有活跃 Firefox bridge client,会返回 `503`
308 - 如果 client 还没有 Claude 凭证和 endpoint,Claude prompt helper 会返回 `409`
309-- ChatGPT raw relay 同样依赖真实浏览器里已捕获到的有效 header / 登录态;Gemini 仍不在正式 HTTP relay 合同里
310+- ChatGPT / Gemini raw relay 同样依赖真实浏览器里已捕获到的有效 header / 登录态;Gemini 现在会优先匹配持久化的会话级发送模板
311
312 ## 启动和管理页
313
314diff --git a/plans/STATUS_SUMMARY.md b/plans/STATUS_SUMMARY.md
315index e66fa79d91e16b4a6f20af4137f23285bd57e487..1ca24524e04b1021004b380317e33244f8334a76 100644
316--- a/plans/STATUS_SUMMARY.md
317+++ b/plans/STATUS_SUMMARY.md
318@@ -53,6 +53,7 @@
319 - browser request 风控状态现在会持久化到 `artifact.db`,重启后会恢复限流/退避/熔断窗口,并回收遗留执行锁与 `running` renewal job
320 - `proxy_delivery` 结果现在会异步补齐 Level 1 下游 HTTP 状态码;renewal dispatcher 仅在 `200` 时标记 `done`,`429/5xx` 改为 `retry`
321 - ChatGPT send template 现在会持久化到插件本地缓存;controller 重载后会恢复最近模板,renewal dispatcher 对模板缺失改为 5s 起步短延迟 retry,并显式记录冷启动/预热完成日志
322+ - Gemini send template 现在会按 shell / conversation 持久化到插件本地缓存;controller 重载后会恢复最近模板,`browser.proxy_delivery` 与 `/v1/browser/request` 都会优先匹配 Gemini 专用 raw relay builder
323
324 ## 当前已纠正的文档/代码不一致
325
326@@ -74,19 +75,16 @@
327 **当前下一波任务:**
328
329 1. `T-S065`:policy 配置化
330-2. `T-S067`:Gemini 正式接入 raw relay 支持面
331-3. `OPT-004`:Claude final-message 更稳 fallback
332-4. `OPT-009`:renewal 模块重复工具函数抽取
333+2. `OPT-004`:Claude final-message 更稳 fallback
334+3. `OPT-009`:renewal 模块重复工具函数抽取
335
336 **并行优化项:**
337
338 1. `T-S065`
339 让 policy 白名单配置化,为后续 automation control 指令扩面铺路
340-2. `T-S067`
341- 把 Gemini 提升到正式 raw relay 支持面,减少 helper/proxy mix 带来的脆弱性
342-3. `OPT-004`
343+2. `OPT-004`
344 为 Claude final-message 增加更稳的 SSE fallback
345-4. `OPT-009`
346+3. `OPT-009`
347 renewal 模块重复工具函数抽取,减少重复逻辑
348
349 **已关闭的优化项:**
350@@ -124,7 +122,7 @@ Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续
351 - 已有本地对话/关联/续命任务、projector、dispatcher 和最小续命运维读接口
352 - 当前主线已无 open bug blocker
353 - `browser.chatgpt` / `browser.gemini` helper target 与 Gemini DOM delivery adapter 已在主线
354-- 当前主要以 delivery 可靠性增强、policy 配置化和 Gemini raw relay 收口为主
355+- 当前主要以 policy 配置化、Claude final-message fallback 和 renewal 代码卫生为主
356 - 自动化仲裁、统一浮层控制、系统级暂停和重启后风控恢复都已完成
357
358 之前的浏览器主链继续保持:
359@@ -136,11 +134,11 @@ Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续
360
361 ## 当前仍需关注
362
363-- `Gemini` 当前仍不是 `/v1/browser/request` 的正式 raw relay 支持面;`@browser.gemini` 走 helper / proxy mix,仍需依赖最近观测到的真实请求上下文
364+- `Gemini` 已正式纳入 `/v1/browser/request` raw relay 支持面;如果后续需要把 `tab_open` / `tab_restore` 等 control-shell 合同也正式扩到 Gemini,建议另开独立任务
365 - ChatGPT proxy send 虽已持久化最近模板并在冷启动时走短延迟 retry,但如果 controller 重载前本地没有任何可恢复模板,首批 job 仍需等待下一次真实 ChatGPT 发送完成预热
366 - Claude 的 `organizationId` 当前仍依赖最近观测到的 org 上下文,不是完整的多页多 org 精确映射
367 - `proxy_delivery` 当前已补齐 Level 1 HTTP 接受确认,但仍不是“下游 AI 已完整回复”;Level 3 仍依赖后续 final-message 链路
368 - ChatGPT root message / mapping 结构如果后续变化,final-message 提取启发式仍需跟进
369 - recent relay cache 是有限窗口;极老 replay 超出窗口后,仍会落回 conductor dedupe
370 - `status-api` 继续保留为显式 opt-in 兼容层,不是当前删除重点
371-- 以上几项风险现主要拆成 `T-S067` 跟踪;`T-S068`、`T-S069` 已分别完成冷启动保护和首版 Level 1 收口
372+- 以上几项风险现主要拆到 `T-S065`、`OPT-004`、`OPT-009` 跟踪;`T-S067`、`T-S068`、`T-S069` 已完成
373diff --git a/plugins/baa-firefox/README.md b/plugins/baa-firefox/README.md
374index 03e06c3becd008ce1368ed809844b2018ec6549b..bd8e495d47d8f28b052a95eb6d9db83103af1117 100644
375--- a/plugins/baa-firefox/README.md
376+++ b/plugins/baa-firefox/README.md
377@@ -169,7 +169,7 @@ browser.runtime.sendMessage({
378
379 - Claude 同源 API 代发
380 - ChatGPT 同源 API 代发
381-- Gemini 基于已发现模板的本地代发
382+- Gemini 基于持久化 shell / conversation 发送模板的本地代发
383
384 这些原始值只在浏览器内部使用,不进入 `conductor` 的 WS 元数据上报。
385
386diff --git a/plugins/baa-firefox/controller.js b/plugins/baa-firefox/controller.js
387index 8c596f187b6d3064ca6af05163779fcb760ce97d..4ba7953bfcc4f94229ca7fe53a6108c2ba16ed5d 100644
388--- a/plugins/baa-firefox/controller.js
389+++ b/plugins/baa-firefox/controller.js
390@@ -24,6 +24,7 @@ const CONTROLLER_STORAGE_KEYS = {
391 credentialFingerprintByPlatform: "baaFirefox.credentialFingerprintByPlatform",
392 accountByPlatform: "baaFirefox.accountByPlatform",
393 chatgptSendTemplates: "baaFirefox.chatgptSendTemplates",
394+ geminiSendTemplates: "baaFirefox.geminiSendTemplates",
395 geminiSendTemplate: "baaFirefox.geminiSendTemplate",
396 claudeState: "baaFirefox.claudeState",
397 finalMessageRelayCache: "baaFirefox.finalMessageRelayCache"
398@@ -70,6 +71,10 @@ const REDACTED_CREDENTIAL_VALUE = "[redacted]";
399 const ACCOUNT_EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
400 const CHATGPT_SEND_TEMPLATE_LIMIT = 12;
401 const CHATGPT_SEND_TEMPLATE_TTL = CREDENTIAL_TTL;
402+const GEMINI_SEND_TEMPLATE_LIMIT = 12;
403+const GEMINI_SEND_TEMPLATE_TTL = CREDENTIAL_TTL;
404+const GEMINI_SHELL_TEMPLATE_KEY = "__shell__";
405+const GEMINI_FALLBACK_TEMPLATE_KEY = "__fallback__";
406 const CHATGPT_SESSION_COOKIE_PATTERNS = [
407 /__secure-next-auth\.session-token=/i,
408 /__secure-authjs\.session-token=/i,
409@@ -223,7 +228,7 @@ const state = {
410 lastCredentialHash: createPlatformMap(() => ""),
411 lastCredentialSentAt: createPlatformMap(() => 0),
412 chatgptSendTemplates: {},
413- geminiSendTemplate: null,
414+ geminiSendTemplates: {},
415 claudeState: createDefaultClaudeState(),
416 controllerRuntime: createDefaultControllerRuntimeState(),
417 finalMessageRelayObservers: createPlatformMap((platform) => createFinalMessageRelayObserver(platform)),
418@@ -3122,7 +3127,29 @@ function hasGeminiTemplateHeaders(headers) {
419 && (source["x-same-domain"] === "1" || Object.keys(source).some((name) => name.startsWith("x-goog-ext-")));
420 }
421
422-function parseGeminiSendTemplate(url, reqBody, reqHeaders = null) {
423+function normalizeGeminiTemplateKey(conversationId = null, options = {}) {
424+ const normalizedConversationId = trimToNull(conversationId);
425+ if (normalizedConversationId) {
426+ return normalizedConversationId;
427+ }
428+
429+ return options.shellPage === true ? GEMINI_SHELL_TEMPLATE_KEY : GEMINI_FALLBACK_TEMPLATE_KEY;
430+}
431+
432+function describeGeminiTemplateTarget(templateKey, template = null) {
433+ const conversationId = trimToNull(template?.conversationId);
434+ if (conversationId) {
435+ return `conversation=${conversationId}`;
436+ }
437+
438+ if (templateKey === GEMINI_SHELL_TEMPLATE_KEY || template?.shellPage === true) {
439+ return "shell";
440+ }
441+
442+ return "fallback";
443+}
444+
445+function parseGeminiSendTemplate(context, url, reqBody, reqHeaders = null) {
446 if (!isGeminiStreamGenerateUrl(url) || typeof reqBody !== "string" || !reqBody) return null;
447
448 try {
449@@ -3130,6 +3157,17 @@ function parseGeminiSendTemplate(url, reqBody, reqHeaders = null) {
450 const parsedUrl = new URL(normalizedUrl, PLATFORMS.gemini.rootUrl);
451 const headers = cloneHeaderMap(reqHeaders);
452 if (Object.keys(headers).length > 0 && !hasGeminiTemplateHeaders(headers)) return null;
453+ const pageUrl = trimToNull(context?.senderUrl || context?.pageUrl);
454+ const conversationId =
455+ trimToNull(context?.conversationId)
456+ || extractGeminiConversationIdFromPageUrl(pageUrl || "")
457+ || null;
458+ const shellPage =
459+ conversationId == null
460+ && (
461+ context?.isShellPage === true
462+ || (pageUrl != null && isPlatformShellUrl("gemini", pageUrl, { allowFallback: true }))
463+ );
464 const params = new URLSearchParams(reqBody);
465 const outerPayload = params.get("f.req");
466 if (!outerPayload) return null;
467@@ -3141,6 +3179,10 @@ function parseGeminiSendTemplate(url, reqBody, reqHeaders = null) {
468 if (!Array.isArray(inner) || !Array.isArray(inner[0])) return null;
469
470 return {
471+ conversationId,
472+ credentialFingerprint: trimToNull(state.credentialFingerprint.gemini),
473+ pageUrl,
474+ shellPage,
475 url: normalizedUrl,
476 reqBody,
477 headers,
478@@ -3153,11 +3195,150 @@ function parseGeminiSendTemplate(url, reqBody, reqHeaders = null) {
479 }
480 }
481
482-function rememberGeminiSendTemplate(url, reqBody, reqHeaders = null) {
483- const next = parseGeminiSendTemplate(url, reqBody, reqHeaders);
484+function normalizeGeminiSendTemplateEntry(value, templateKey = null, now = Date.now()) {
485+ if (!isRecord(value)) {
486+ return null;
487+ }
488+
489+ const reqBody = typeof value.reqBody === "string" && value.reqBody.trim()
490+ ? value.reqBody
491+ : null;
492+ const url = typeof value.url === "string" && value.url.trim()
493+ ? value.url
494+ : null;
495+ const updatedAt = Number.isFinite(Number(value.updatedAt)) && Number(value.updatedAt) > 0
496+ ? Math.round(Number(value.updatedAt))
497+ : 0;
498+
499+ if (!reqBody || !url || updatedAt <= 0 || now - updatedAt > GEMINI_SEND_TEMPLATE_TTL) {
500+ return null;
501+ }
502+
503+ const normalizedPageUrl = trimToNull(value.pageUrl);
504+ const normalizedConversationId =
505+ trimToNull(value.conversationId)
506+ || extractGeminiConversationIdFromPageUrl(normalizedPageUrl || "")
507+ || null;
508+ const shellPage = normalizedConversationId == null
509+ && (
510+ value.shellPage === true
511+ || templateKey === GEMINI_SHELL_TEMPLATE_KEY
512+ );
513+ const parsed = parseGeminiSendTemplate({
514+ conversationId: normalizedConversationId,
515+ isShellPage: shellPage,
516+ senderUrl: normalizedPageUrl
517+ }, url, reqBody, value.headers);
518+
519+ if (!parsed) {
520+ return null;
521+ }
522+
523+ return {
524+ ...parsed,
525+ conversationId: normalizedConversationId,
526+ credentialFingerprint: trimToNull(value.credentialFingerprint) || trimToNull(parsed.credentialFingerprint),
527+ pageUrl: normalizedPageUrl || parsed.pageUrl || null,
528+ shellPage,
529+ updatedAt
530+ };
531+}
532+
533+function loadGeminiSendTemplates(raw, legacyRaw = null, now = Date.now()) {
534+ const source = isRecord(raw) ? raw : {};
535+ const normalized = [];
536+
537+ for (const [templateKey, entry] of Object.entries(source)) {
538+ const template = normalizeGeminiSendTemplateEntry(entry, templateKey, now);
539+ if (!template) continue;
540+
541+ normalized.push({
542+ key: normalizeGeminiTemplateKey(template.conversationId, {
543+ shellPage: template.shellPage === true
544+ }),
545+ ...template
546+ });
547+ }
548+
549+ if (normalized.length === 0) {
550+ const legacyTemplate = normalizeGeminiSendTemplateEntry(
551+ legacyRaw,
552+ GEMINI_FALLBACK_TEMPLATE_KEY,
553+ now
554+ );
555+ if (legacyTemplate) {
556+ normalized.push({
557+ key: normalizeGeminiTemplateKey(legacyTemplate.conversationId, {
558+ shellPage: legacyTemplate.shellPage === true
559+ }),
560+ ...legacyTemplate
561+ });
562+ }
563+ }
564+
565+ normalized.sort((left, right) => right.updatedAt - left.updatedAt);
566+
567+ const next = {};
568+ for (const entry of normalized.slice(0, GEMINI_SEND_TEMPLATE_LIMIT)) {
569+ if (next[entry.key]) {
570+ continue;
571+ }
572+ next[entry.key] = {
573+ conversationId: entry.conversationId,
574+ credentialFingerprint: entry.credentialFingerprint,
575+ headers: entry.headers,
576+ pageUrl: entry.pageUrl,
577+ prompt: entry.prompt,
578+ reqBody: entry.reqBody,
579+ reqId: entry.reqId,
580+ shellPage: entry.shellPage,
581+ updatedAt: entry.updatedAt,
582+ url: entry.url
583+ };
584+ }
585+
586+ return next;
587+}
588+
589+function serializeGeminiSendTemplates(now = Date.now()) {
590+ return loadGeminiSendTemplates(state.geminiSendTemplates, null, now);
591+}
592+
593+function serializeLegacyGeminiSendTemplate(now = Date.now()) {
594+ const entries = Object.values(serializeGeminiSendTemplates(now))
595+ .sort((left, right) => right.updatedAt - left.updatedAt);
596+ return entries[0] || null;
597+}
598+
599+function pruneGeminiSendTemplates(now = Date.now()) {
600+ state.geminiSendTemplates = loadGeminiSendTemplates(state.geminiSendTemplates, null, now);
601+}
602+
603+function invalidateGeminiSendTemplate(templateKey) {
604+ const normalizedKey = trimToNull(templateKey);
605+
606+ if (!normalizedKey || !state.geminiSendTemplates[normalizedKey]) {
607+ return false;
608+ }
609+
610+ delete state.geminiSendTemplates[normalizedKey];
611+ persistState().catch(() => {});
612+ return true;
613+}
614+
615+function rememberGeminiSendTemplate(context, url, reqBody, reqHeaders = null) {
616+ const next = parseGeminiSendTemplate(context, url, reqBody, reqHeaders);
617 if (!next) return false;
618
619- const previous = state.geminiSendTemplate;
620+ const templateKey = normalizeGeminiTemplateKey(next.conversationId, {
621+ shellPage: next.shellPage === true
622+ });
623+ const previous = normalizeGeminiSendTemplateEntry(
624+ state.geminiSendTemplates[templateKey],
625+ templateKey,
626+ next.updatedAt
627+ );
628+
629 if (Number.isFinite(previous?.reqId) && Number.isFinite(next.reqId)) {
630 next.reqId = Math.max(previous.reqId, next.reqId);
631 } else if (Number.isFinite(previous?.reqId) && !Number.isFinite(next.reqId)) {
632@@ -3169,29 +3350,146 @@ function rememberGeminiSendTemplate(url, reqBody, reqHeaders = null) {
633 || previous.reqBody !== next.reqBody
634 || previous.prompt !== next.prompt
635 || previous.reqId !== next.reqId
636+ || previous.pageUrl !== next.pageUrl
637+ || previous.conversationId !== next.conversationId
638+ || previous.credentialFingerprint !== next.credentialFingerprint
639 || JSON.stringify(previous.headers || {}) !== JSON.stringify(next.headers || {});
640
641- state.geminiSendTemplate = next;
642+ state.geminiSendTemplates[templateKey] = next;
643+ pruneGeminiSendTemplates(next.updatedAt);
644 persistState().catch(() => {});
645
646 if (changed) {
647- addLog("info", `已捕获 Gemini 发送模板 reqid=${next.reqId || "-"}`);
648+ addLog(
649+ "info",
650+ previous
651+ ? `已更新 Gemini 发送模板 ${describeGeminiTemplateTarget(templateKey, next)}`
652+ : `Gemini 发送模板预热完成 ${describeGeminiTemplateTarget(templateKey, next)}`,
653+ false
654+ );
655 }
656 return true;
657 }
658
659+function getGeminiSendTemplateByKey(templateKey) {
660+ const normalizedKey = trimToNull(templateKey);
661+ if (!normalizedKey) {
662+ return null;
663+ }
664+
665+ const template = normalizeGeminiSendTemplateEntry(
666+ state.geminiSendTemplates[normalizedKey],
667+ normalizedKey
668+ );
669+
670+ if (!template) {
671+ invalidateGeminiSendTemplate(normalizedKey);
672+ return null;
673+ }
674+
675+ const currentFingerprint = trimToNull(state.credentialFingerprint.gemini);
676+
677+ if (
678+ template.credentialFingerprint
679+ && currentFingerprint
680+ && template.credentialFingerprint !== currentFingerprint
681+ ) {
682+ invalidateGeminiSendTemplate(normalizedKey);
683+ return null;
684+ }
685+
686+ state.geminiSendTemplates[normalizedKey] = template;
687+ return {
688+ key: normalizedKey,
689+ ...template
690+ };
691+}
692+
693+function selectMostRecentGeminiSendTemplate(excludedKeys = []) {
694+ const excluded = new Set(excludedKeys.map((key) => trimToNull(key)).filter(Boolean));
695+ const candidates = Object.keys(serializeGeminiSendTemplates())
696+ .filter((key) => !excluded.has(key))
697+ .map((key) => getGeminiSendTemplateByKey(key))
698+ .filter(Boolean)
699+ .sort((left, right) => right.updatedAt - left.updatedAt);
700+
701+ return candidates[0] || null;
702+}
703+
704+function getGeminiSendTemplate(options = {}) {
705+ const conversationId = trimToNull(options.conversationId);
706+ const allowRecentFallback = options.allowRecentFallback === true;
707+ const allowShellFallback = options.allowShellFallback === true;
708+ const attemptedKeys = [];
709+
710+ if (conversationId) {
711+ attemptedKeys.push(conversationId);
712+ const exact = getGeminiSendTemplateByKey(conversationId);
713+ if (exact) {
714+ return {
715+ match: "conversation",
716+ ...exact
717+ };
718+ }
719+ }
720+
721+ if (!conversationId || allowShellFallback) {
722+ attemptedKeys.push(GEMINI_SHELL_TEMPLATE_KEY);
723+ const shell = getGeminiSendTemplateByKey(GEMINI_SHELL_TEMPLATE_KEY);
724+ if (shell) {
725+ return {
726+ match: "shell",
727+ ...shell
728+ };
729+ }
730+ }
731+
732+ if (!allowRecentFallback) {
733+ return null;
734+ }
735+
736+ const recent = selectMostRecentGeminiSendTemplate(attemptedKeys);
737+ if (!recent) {
738+ return null;
739+ }
740+
741+ return {
742+ match: recent.key === GEMINI_FALLBACK_TEMPLATE_KEY ? "fallback" : "recent",
743+ ...recent
744+ };
745+}
746+
747 function extractGeminiXsrfToken(body) {
748 const match = String(body || "").match(/"xsrf","([^"]+)"/);
749 return match ? match[1] : null;
750 }
751
752-function updateGeminiTemplateXsrf(xsrfToken) {
753- if (!xsrfToken || !state.geminiSendTemplate?.reqBody) return false;
754+function updateGeminiTemplateReqId(templateKey, reqId) {
755+ const normalizedKey = trimToNull(templateKey);
756+ if (!normalizedKey || !Number.isFinite(reqId)) return false;
757+
758+ const template = getGeminiSendTemplateByKey(normalizedKey);
759+ if (!template) return false;
760
761- const params = new URLSearchParams(state.geminiSendTemplate.reqBody);
762+ state.geminiSendTemplates[normalizedKey] = {
763+ ...template,
764+ reqId,
765+ updatedAt: Date.now()
766+ };
767+ persistState().catch(() => {});
768+ return true;
769+}
770+
771+function updateGeminiTemplateXsrf(templateKey, xsrfToken) {
772+ const normalizedKey = trimToNull(templateKey);
773+ const template = normalizedKey ? getGeminiSendTemplateByKey(normalizedKey) : null;
774+
775+ if (!xsrfToken || !template?.reqBody) return false;
776+
777+ const params = new URLSearchParams(template.reqBody);
778 params.set("at", xsrfToken);
779- state.geminiSendTemplate = {
780- ...state.geminiSendTemplate,
781+ state.geminiSendTemplates[normalizedKey] = {
782+ ...template,
783 reqBody: params.toString(),
784 updatedAt: Date.now()
785 };
786@@ -3667,7 +3965,8 @@ async function persistState() {
787 [CONTROLLER_STORAGE_KEYS.credentialFingerprintByPlatform]: state.credentialFingerprint,
788 [CONTROLLER_STORAGE_KEYS.accountByPlatform]: state.account,
789 [CONTROLLER_STORAGE_KEYS.chatgptSendTemplates]: serializeChatgptSendTemplates(),
790- [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: state.geminiSendTemplate,
791+ [CONTROLLER_STORAGE_KEYS.geminiSendTemplates]: serializeGeminiSendTemplates(),
792+ [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: serializeLegacyGeminiSendTemplate(),
793 [CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]: serializeFinalMessageRelayCache(),
794 [CONTROLLER_STORAGE_KEYS.claudeState]: {
795 ...cloneClaudeState(state.claudeState),
796@@ -4914,16 +5213,15 @@ function buildClaudeHeaders(apiPath, overrides = {}) {
797 return headers;
798 }
799
800-function buildGeminiAutoRequest(prompt) {
801- const template = state.geminiSendTemplate;
802- if (!template?.url || !template?.reqBody) {
803- throw new Error("missing Gemini send template; send one real Gemini message first");
804+function buildGeminiRequestFromTemplate(templateSelection, prompt) {
805+ if (!templateSelection?.url || !templateSelection?.reqBody) {
806+ throw new Error("missing Gemini send template");
807 }
808 const credential = requireCredentialState("gemini");
809
810 try {
811- const url = new URL(template.url, PLATFORMS.gemini.rootUrl);
812- const params = new URLSearchParams(template.reqBody);
813+ const url = new URL(templateSelection.url, PLATFORMS.gemini.rootUrl);
814+ const params = new URLSearchParams(templateSelection.reqBody);
815 const outerPayload = params.get("f.req");
816 if (!outerPayload) throw new Error("template missing f.req");
817
818@@ -4942,21 +5240,16 @@ function buildGeminiAutoRequest(prompt) {
819 params.set("f.req", JSON.stringify(outer));
820
821 const currentReqId = Number(url.searchParams.get("_reqid"));
822- const baseReqId = Number.isFinite(template.reqId) ? template.reqId : currentReqId;
823+ const baseReqId = Number.isFinite(templateSelection.reqId) ? templateSelection.reqId : currentReqId;
824 if (Number.isFinite(baseReqId)) {
825 const nextReqId = baseReqId + 100000;
826 url.searchParams.set("_reqid", String(nextReqId));
827- state.geminiSendTemplate = {
828- ...template,
829- reqId: nextReqId,
830- updatedAt: Date.now()
831- };
832- persistState().catch(() => {});
833+ updateGeminiTemplateReqId(templateSelection.key, nextReqId);
834 }
835
836 const path = `${url.pathname || "/"}${url.search || ""}`;
837- const headerSource = hasGeminiTemplateHeaders(template.headers)
838- ? template.headers
839+ const headerSource = hasGeminiTemplateHeaders(templateSelection.headers)
840+ ? templateSelection.headers
841 : credential.headers;
842 const headers = buildProxyHeaders("gemini", path, headerSource);
843 if (!hasGeminiTemplateHeaders(headers)) {
844@@ -4964,15 +5257,69 @@ function buildGeminiAutoRequest(prompt) {
845 }
846
847 return {
848- path,
849 body: params.toString(),
850- headers
851+ headers,
852+ path,
853+ templateKey: templateSelection.key
854 };
855 } catch (error) {
856 throw new Error(`构建 Gemini 请求失败:${error.message}`);
857 }
858 }
859
860+function buildGeminiAutoRequest(prompt, options = {}) {
861+ const conversationId = trimToNull(options.conversationId);
862+ const templateSelection = getGeminiSendTemplate({
863+ conversationId,
864+ allowRecentFallback: options.allowRecentFallback !== false,
865+ allowShellFallback: options.allowShellFallback === true
866+ });
867+
868+ if (!templateSelection?.url || !templateSelection?.reqBody) {
869+ throw new Error(
870+ conversationId
871+ ? `missing Gemini send template for conversation=${conversationId}; send one real Gemini message first`
872+ : "missing Gemini send template; send one real Gemini message first"
873+ );
874+ }
875+
876+ return buildGeminiRequestFromTemplate(templateSelection, prompt);
877+}
878+
879+function buildGeminiDeliveryRequest(options = {}) {
880+ const conversationId = trimToNull(options.conversationId);
881+ const messageText = trimToNull(options.messageText);
882+ const shellPage = options.shellPage === true;
883+
884+ if (!messageText) {
885+ throw new Error("delivery.invalid_payload: Gemini proxy_delivery requires message_text");
886+ }
887+
888+ const templateSelection = getGeminiSendTemplate({
889+ conversationId,
890+ allowRecentFallback: conversationId == null,
891+ allowShellFallback: shellPage
892+ });
893+
894+ if (!templateSelection?.url || !templateSelection?.reqBody) {
895+ throw new Error(
896+ conversationId
897+ ? `delivery.template_missing: missing Gemini send template for conversation=${conversationId}; send one real Gemini message in this conversation first`
898+ : "delivery.template_missing: missing Gemini send template; send one real Gemini message first"
899+ );
900+ }
901+
902+ const request = buildGeminiRequestFromTemplate(templateSelection, messageText);
903+
904+ return {
905+ body: request.body,
906+ headers: request.headers,
907+ method: "POST",
908+ path: request.path,
909+ templateKey: request.templateKey
910+ };
911+}
912+
913 function createPendingProxyRequest(id, meta = {}) {
914 let ackSettled = false;
915 let settled = false;
916@@ -6687,7 +7034,7 @@ function handlePageNetwork(data, sender) {
917 }
918
919 if (context.platform === "gemini" && typeof data.reqBody === "string" && data.reqBody) {
920- rememberGeminiSendTemplate(data.url, data.reqBody, observedHeaders);
921+ rememberGeminiSendTemplate(context, data.url, data.reqBody, observedHeaders);
922 }
923
924 collectEndpoint(context.platform, data.method, data.url);
925@@ -6854,11 +7201,20 @@ function handlePageProxyResponse(data, sender) {
926 && Number(data.status) === 400
927 ) {
928 const xsrfToken = extractGeminiXsrfToken(data.body);
929- if (updateGeminiTemplateXsrf(xsrfToken)) {
930+ if (updateGeminiTemplateXsrf(pending.templateKey, xsrfToken)) {
931 pending.attempts += 1;
932 addLog("info", `Gemini xsrf 已刷新,正在重试代理 ${data.id}`);
933 try {
934- const retry = buildGeminiAutoRequest(pending.prompt);
935+ const retryTemplate = trimToNull(pending.templateKey)
936+ ? getGeminiSendTemplateByKey(pending.templateKey)
937+ : null;
938+ const retry = retryTemplate
939+ ? buildGeminiRequestFromTemplate(retryTemplate, pending.prompt)
940+ : buildGeminiAutoRequest(pending.prompt, {
941+ allowRecentFallback: true,
942+ allowShellFallback: pending.shellPage === true,
943+ conversationId: pending.conversationId
944+ });
945 postProxyRequestToTab(context.tabId, {
946 id: data.id,
947 platform: "gemini",
948@@ -7337,20 +7693,40 @@ async function runProxyDeliveryAction(message) {
949 throw createPausedPageDeliveryError("proxy_delivery", pausedPage, target.conversationId);
950 }
951
952- const request = platform === "claude"
953- ? buildClaudeDeliveryProxyRequest(message, target)
954- : buildChatgptDeliveryRequest({
955+ let request = null;
956+
957+ switch (platform) {
958+ case "claude":
959+ request = buildClaudeDeliveryProxyRequest(message, target);
960+ break;
961+ case "chatgpt":
962+ request = buildChatgptDeliveryRequest({
963 conversationId: target.conversationId,
964 messageText: message?.message_text || message?.messageText,
965 sourceAssistantMessageId: message?.assistant_message_id || message?.assistantMessageId
966 });
967+ break;
968+ case "gemini":
969+ request = buildGeminiDeliveryRequest({
970+ conversationId: target.conversationId,
971+ messageText: message?.message_text || message?.messageText,
972+ shellPage: target.shellPage === true
973+ });
974+ break;
975+ default:
976+ throw new Error(`未知平台:${platform}`);
977+ }
978+
979 const proxyRequestId = `${planId}:proxy_delivery`;
980 const pending = createPendingProxyRequest(proxyRequestId, {
981+ conversationId: target.conversationId,
982 method: request.method,
983 path: request.path,
984 platform,
985 responseMode: "sse",
986+ shellPage: target.shellPage === true,
987 tabId: target.tab.id,
988+ templateKey: request.templateKey || null,
989 timeoutMs
990 });
991
992@@ -7479,16 +7855,29 @@ async function runDeliveryAction(message, command) {
993 }
994
995 async function proxyApiRequest(message) {
996- const { id, platform, method = "GET", path: apiPath, body = null } = message || {};
997+ const {
998+ conversation_id: rawConversationId,
999+ conversationId: rawConversationIdCamel,
1000+ id,
1001+ platform,
1002+ method = "GET",
1003+ path: apiPath,
1004+ body = null
1005+ } = message || {};
1006 if (!id) throw new Error("缺少代理请求 ID");
1007 if (!platform || !PLATFORMS[platform]) throw new Error(`未知平台:${platform || "-"}`);
1008 if (!apiPath) throw new Error("缺少代理请求路径");
1009
1010 const responseMode = String(message?.response_mode || message?.responseMode || "buffered").toLowerCase();
1011 const streamId = trimToNull(message?.stream_id) || trimToNull(message?.streamId) || id;
1012+ const conversationId = trimToNull(rawConversationId) || trimToNull(rawConversationIdCamel);
1013 const prompt = platform === "gemini" ? extractPromptFromProxyBody(body) : null;
1014 const geminiAutoRequest = platform === "gemini" && prompt && isGeminiStreamGenerateUrl(apiPath)
1015- ? buildGeminiAutoRequest(prompt)
1016+ ? buildGeminiAutoRequest(prompt, {
1017+ allowRecentFallback: true,
1018+ allowShellFallback: true,
1019+ conversationId
1020+ })
1021 : null;
1022
1023 return executeProxyRequest({
1024@@ -7499,11 +7888,14 @@ async function proxyApiRequest(message) {
1025 body: geminiAutoRequest ? geminiAutoRequest.body : body,
1026 headers: geminiAutoRequest ? geminiAutoRequest.headers : buildProxyHeaders(platform, apiPath)
1027 }, {
1028+ conversationId,
1029 platform,
1030 prompt,
1031 responseMode,
1032 streamId,
1033- attempts: 0
1034+ attempts: 0,
1035+ shellPage: false,
1036+ templateKey: geminiAutoRequest?.templateKey || null
1037 });
1038 }
1039
1040@@ -8066,7 +8458,10 @@ async function init() {
1041 state.chatgptSendTemplates = loadChatgptSendTemplates(
1042 saved[CONTROLLER_STORAGE_KEYS.chatgptSendTemplates]
1043 );
1044- state.geminiSendTemplate = saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate] || null;
1045+ state.geminiSendTemplates = loadGeminiSendTemplates(
1046+ saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplates],
1047+ saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate]
1048+ );
1049 restoreFinalMessageRelayCache(saved[CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]);
1050 state.claudeState = loadClaudeState(saved[CONTROLLER_STORAGE_KEYS.claudeState]);
1051 state.controllerRuntime = loadControllerRuntimeState(saved[CONTROLLER_STORAGE_KEYS.controllerRuntime]);
1052@@ -8163,12 +8558,15 @@ function exposeControllerTestApi() {
1053 }
1054
1055 Object.assign(target, {
1056+ buildGeminiAutoRequest,
1057+ buildGeminiDeliveryRequest,
1058 buildChatgptDeliveryRequest,
1059 buildPageControlSnapshotForSender,
1060 connectWs,
1061 createPluginDiagnosticPayload,
1062 flushBufferedPluginDiagnosticLogs,
1063 getChatgptSendTemplate,
1064+ getGeminiSendTemplate,
1065 getSenderContext,
1066 handlePageBridgeReady,
1067 handlePageDiagnosticLog,
1068@@ -8176,8 +8574,10 @@ function exposeControllerTestApi() {
1069 handlePageSse,
1070 handleWsStateSnapshot,
1071 loadChatgptSendTemplates,
1072+ loadGeminiSendTemplates,
1073 persistFinalMessageRelayCache,
1074 rememberChatgptSendTemplate,
1075+ rememberGeminiSendTemplate,
1076 reinjectAllOpenPlatformTabs,
1077 reinjectPlatformTabs,
1078 restoreFinalMessageRelayCache,
1079@@ -8187,6 +8587,7 @@ function exposeControllerTestApi() {
1080 runPluginManagementAction,
1081 sendPluginDiagnosticLog,
1082 serializeChatgptSendTemplates,
1083+ serializeGeminiSendTemplates,
1084 serializeFinalMessageRelayCache,
1085 setDesiredTabState,
1086 syncPageControlFromContext,
1087diff --git a/plugins/baa-firefox/controller.test.cjs b/plugins/baa-firefox/controller.test.cjs
1088index 7612bb660e7410a1fb2ff9e8045bbb2f5f023a96..01dabd952b971e99e4d7a51c49dd2848f11af527 100644
1089--- a/plugins/baa-firefox/controller.test.cjs
1090+++ b/plugins/baa-firefox/controller.test.cjs
1091@@ -156,6 +156,12 @@ function createControllerHarness(options = {}) {
1092 };
1093 }
1094
1095+function parseGeminiRequestTuple(reqBody) {
1096+ const params = new URLSearchParams(reqBody);
1097+ const outer = JSON.parse(params.get("f.req"));
1098+ return JSON.parse(outer[1]);
1099+}
1100+
1101 test("controller flushes buffered plugin diagnostic logs after the WS opens", () => {
1102 const { api } = createControllerHarness();
1103
1104@@ -361,7 +367,7 @@ test("controller applies websocket automation snapshots to control and page stat
1105 });
1106
1107 test("controller restores persisted ChatGPT send templates and reuses them for proxy delivery", () => {
1108- const nowMs = Date.UTC(2026, 3, 1, 10, 0, 0);
1109+ const nowMs = Date.now();
1110 const { api } = createControllerHarness();
1111
1112 api.state.credentialFingerprint.chatgpt = "fp-chatgpt-1";
1113@@ -423,7 +429,7 @@ test("controller restores persisted ChatGPT send templates and reuses them for p
1114 });
1115
1116 test("controller invalidates persisted ChatGPT templates when the credential fingerprint changes", () => {
1117- const nowMs = Date.UTC(2026, 3, 1, 10, 5, 0);
1118+ const nowMs = Date.now();
1119 const { api } = createControllerHarness();
1120
1121 api.state.chatgptSendTemplates = api.loadChatgptSendTemplates({
1122@@ -458,3 +464,110 @@ test("controller invalidates persisted ChatGPT templates when the credential fin
1123 assert.equal(template, null);
1124 assert.equal(Object.keys(api.serializeChatgptSendTemplates(nowMs)).length, 0);
1125 });
1126+
1127+test("controller restores persisted Gemini send templates and reuses the matching conversation template for proxy delivery", () => {
1128+ const nowMs = Date.now();
1129+ const templateUrl = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?_reqid=420000&rt=c";
1130+ const requestHeaders = {
1131+ "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
1132+ cookie: "__Secure-1PSID=test-session",
1133+ "x-goog-authuser": "0",
1134+ "x-same-domain": "1"
1135+ };
1136+ const templateBody = new URLSearchParams({
1137+ at: "xsrf-token-1",
1138+ "f.req": JSON.stringify([
1139+ null,
1140+ JSON.stringify([
1141+ ["hello from gemini"],
1142+ null,
1143+ ["conv-gemini-persisted", "resp-1", "choice-1"]
1144+ ])
1145+ ])
1146+ }).toString();
1147+ const { api } = createControllerHarness();
1148+
1149+ api.state.credentialFingerprint.gemini = "fp-gemini-1";
1150+ api.rememberGeminiSendTemplate({
1151+ conversationId: "conv-gemini-persisted",
1152+ isShellPage: false,
1153+ senderUrl: "https://gemini.google.com/app/conv-gemini-persisted"
1154+ }, templateUrl, templateBody, requestHeaders);
1155+
1156+ const savedTemplates = api.serializeGeminiSendTemplates(nowMs);
1157+ const { api: restoredApi } = createControllerHarness();
1158+
1159+ restoredApi.state.geminiSendTemplates = restoredApi.loadGeminiSendTemplates(savedTemplates, null, nowMs);
1160+ restoredApi.state.credentialFingerprint.gemini = "fp-gemini-1";
1161+ restoredApi.state.trackedTabs.gemini = 29;
1162+ restoredApi.state.lastHeaders.gemini = requestHeaders;
1163+ restoredApi.state.credentialCapturedAt.gemini = nowMs;
1164+ restoredApi.state.lastCredentialAt.gemini = nowMs;
1165+ restoredApi.state.lastCredentialTabId.gemini = 29;
1166+ restoredApi.state.lastCredentialUrl.gemini = templateUrl;
1167+
1168+ const template = restoredApi.getGeminiSendTemplate({
1169+ conversationId: "conv-gemini-persisted"
1170+ });
1171+ const request = restoredApi.buildGeminiDeliveryRequest({
1172+ conversationId: "conv-gemini-persisted",
1173+ messageText: "follow-up from persisted Gemini template"
1174+ });
1175+ const requestTuple = parseGeminiRequestTuple(request.body);
1176+ const requestPath = new URL(request.path, "https://gemini.google.com/");
1177+
1178+ assert.ok(template);
1179+ assert.equal(template.match, "conversation");
1180+ assert.equal(template.credentialFingerprint, "fp-gemini-1");
1181+ assert.equal(request.method, "POST");
1182+ assert.equal(request.templateKey, "conv-gemini-persisted");
1183+ assert.equal(
1184+ requestPath.pathname,
1185+ "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
1186+ );
1187+ assert.equal(Number(requestPath.searchParams.get("_reqid")) > 420000, true);
1188+ assert.equal(requestTuple[0][0], "follow-up from persisted Gemini template");
1189+ assert.deepEqual(requestTuple[2], ["conv-gemini-persisted", "resp-1", "choice-1"]);
1190+ assert.equal(request.headers["content-type"], "application/x-www-form-urlencoded;charset=UTF-8");
1191+ assert.equal(request.headers["x-same-domain"], "1");
1192+});
1193+
1194+test("controller invalidates persisted Gemini templates when the credential fingerprint changes", () => {
1195+ const nowMs = Date.now();
1196+ const { api } = createControllerHarness();
1197+
1198+ api.state.geminiSendTemplates = api.loadGeminiSendTemplates({
1199+ "conv-gemini-fingerprint-mismatch": {
1200+ conversationId: "conv-gemini-fingerprint-mismatch",
1201+ credentialFingerprint: "fp-old",
1202+ headers: {
1203+ "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
1204+ "x-same-domain": "1"
1205+ },
1206+ pageUrl: "https://gemini.google.com/app/conv-gemini-fingerprint-mismatch",
1207+ reqBody: new URLSearchParams({
1208+ at: "xsrf-token-old",
1209+ "f.req": JSON.stringify([
1210+ null,
1211+ JSON.stringify([
1212+ ["hello"],
1213+ null,
1214+ ["conv-gemini-fingerprint-mismatch", "resp-old", "choice-old"]
1215+ ])
1216+ ])
1217+ }).toString(),
1218+ reqId: 520000,
1219+ shellPage: false,
1220+ updatedAt: nowMs,
1221+ url: "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?_reqid=520000&rt=c"
1222+ }
1223+ }, null, nowMs);
1224+ api.state.credentialFingerprint.gemini = "fp-new";
1225+
1226+ const template = api.getGeminiSendTemplate({
1227+ conversationId: "conv-gemini-fingerprint-mismatch"
1228+ });
1229+
1230+ assert.equal(template, null);
1231+ assert.equal(Object.keys(api.serializeGeminiSendTemplates(nowMs)).length, 0);
1232+});
1233diff --git a/tasks/T-S067.md b/tasks/T-S067.md
1234index 4991bdb137ba9285872e4b4a7899a7548b1efd6b..54833dc0f2fc1a9279f970a4b17aad091868d4c8 100644
1235--- a/tasks/T-S067.md
1236+++ b/tasks/T-S067.md
1237@@ -2,7 +2,7 @@
1238
1239 ## 状态
1240
1241-- 当前状态:`待开始`
1242+- 当前状态:`已完成`
1243 - 规模预估:`M`
1244 - 依赖任务:无
1245 - 建议执行者:`Codex`
1246@@ -74,22 +74,23 @@ Gemini 当前不是 `/v1/browser/request` 的正式 raw relay 支持面。`@brow
1247
1248 ### 开始执行
1249
1250-- 执行者:
1251-- 开始时间:
1252+- 执行者:Codex
1253+- 开始时间:2026-04-01 17:35 CST
1254 - 状态变更:`待开始` → `进行中`
1255
1256 ### 完成摘要
1257
1258-- 完成时间:
1259+- 完成时间:2026-04-01 18:31 CST
1260 - 状态变更:`进行中` → `已完成`
1261-- 修改了哪些文件:
1262-- 核心实现思路:
1263-- 跑了哪些测试:
1264+- 修改了哪些文件:`plugins/baa-firefox/controller.js`、`plugins/baa-firefox/controller.test.cjs`、`plugins/baa-firefox/README.md`、`apps/conductor-daemon/src/browser-types.ts`、`apps/conductor-daemon/src/firefox-bridge.ts`、`apps/conductor-daemon/src/local-api.ts`、`apps/conductor-daemon/src/index.test.js`、`docs/api/README.md`、`docs/api/business-interfaces.md`、`docs/api/control-interfaces.md`、`docs/firefox/README.md`、`tasks/T-S067.md`、`tasks/TASK_OVERVIEW.md`、`plans/STATUS_SUMMARY.md`
1265+- 核心实现思路:把 Gemini 从单一 `geminiSendTemplate` 升级成可持久化的 shell / conversation 模板缓存,补 TTL 与 credential fingerprint 校验,并让 controller 重载后恢复;`browser.proxy_delivery` 新增 Gemini 专用 raw builder,按目标 conversation 精确命中模板并把 XSRF 重试绑定到命中的模板;conductor 侧把 Gemini 纳入 `/v1/browser/request` 正式 supported platforms,并把 `conversationId` 透传到 Firefox bridge `api_request` / `stream_request`,用于模板匹配。
1266+- 跑了哪些测试:`node --test plugins/baa-firefox/controller.test.cjs`、`node --test plugins/baa-firefox/final-message.test.cjs plugins/baa-firefox/delivery-adapters.test.cjs`、`pnpm -C apps/conductor-daemon test`
1267
1268 ### 执行过程中遇到的问题
1269
1270--
1271+- 新建 worktree 默认没有 `node_modules`,`pnpm -C apps/conductor-daemon test` 初次执行时因 `pnpm exec tsc` 找不到命令失败;通过复用主工作区 `/Users/george/code/baa-conductor` 的根目录与 `apps/conductor-daemon` 依赖后完成测试,无需额外改源码。
1272
1273 ### 剩余风险
1274
1275--
1276+- Gemini 的 `f.req` 结构仍是上游私有协议;如果 Google 后续调整 `StreamGenerate` payload 形态,模板提取或会话级匹配逻辑仍需要跟进。
1277+- 这轮只把 Gemini 纳入 business 面的正式 raw relay 合同;`/v1/browser/actions` 的正式 shell 管理 supported platforms 仍维持 `claude` / `chatgpt` 口径,如需扩面应另立任务。
1278diff --git a/tasks/TASK_OVERVIEW.md b/tasks/TASK_OVERVIEW.md
1279index 3b3a57a2492fdf11cfc13c602de1f17bd7598d91..df84b60f9c1dde1ac5efb9fe535e168f752c93bf 100644
1280--- a/tasks/TASK_OVERVIEW.md
1281+++ b/tasks/TASK_OVERVIEW.md
1282@@ -51,6 +51,7 @@
1283 - browser request 风控状态现在会持久化到 `artifact.db`,重启后会恢复限流/退避/熔断窗口,并清理遗留执行锁与 `running` renewal job
1284 - `proxy_delivery` 结果现在会异步补齐 Level 1 下游 HTTP 状态码;renewal dispatcher 仅在 `200` 时标记 `done`,`429/5xx` 改为 `retry`
1285 - ChatGPT send template 现在会持久化到插件本地缓存;controller 重载后会恢复最近模板,renewal dispatcher 对模板缺失改为 5s 起步短延迟 retry,并显式记录冷启动/预热完成日志
1286+ - Gemini send template 现在会按 shell / conversation 持久化到插件本地缓存;controller 重载后会恢复最近模板,`browser.proxy_delivery` 与 `/v1/browser/request` 都会优先匹配 Gemini 专用 raw relay builder
1287
1288 ## 当前已确认的不一致
1289
1290@@ -86,6 +87,7 @@
1291 | [`T-S063`](./T-S063.md) | normalize / parse 错误隔离 | S | 无 | Codex | 已完成 |
1292 | [`T-S064`](./T-S064.md) | timed-jobs 异步日志写入 | S | 无 | Codex | 已完成 |
1293 | [`T-S066`](./T-S066.md) | 风控状态持久化 | M | T-S060 | Codex | 已完成 |
1294+| [`T-S067`](./T-S067.md) | Gemini 正式接入 raw relay 支持面 | M | 无 | Codex | 已完成 |
1295 | [`T-S068`](./T-S068.md) | ChatGPT proxy send 冷启动降级保护 | S | 无 | Codex | 已完成 |
1296 | [`T-S069`](./T-S069.md) | proxy_delivery 成功语义增强 | L | T-S060 | Codex | 已完成 |
1297
1298@@ -94,7 +96,6 @@
1299 | 项目 | 标题 | 类型 | 状态 | 说明 |
1300 |---|---|---|---|---|
1301 | [`T-S065`](./T-S065.md) | policy 配置化 | task | 待开始 | 为自动化控制指令和后续扩面提供策略入口 |
1302-| [`T-S067`](./T-S067.md) | Gemini 正式接入 raw relay 支持面 | task | 待开始 | 把 `@browser.gemini` 提升到稳定 raw relay 支持面 |
1303 | [`../bugs/OPT-004-final-message-claude-sse-fallback.md`](../bugs/OPT-004-final-message-claude-sse-fallback.md) | Claude final-message SSE fallback | opt | open | 降低上游 SSE 协议变化的脆弱性 |
1304 | [`../bugs/OPT-009-renewal-duplicate-utility-functions.md`](../bugs/OPT-009-renewal-duplicate-utility-functions.md) | renewal 工具函数去重 | opt | open | 收口重复逻辑,属于低风险代码卫生 |
1305
1306@@ -145,7 +146,6 @@
1307 ### P1(并行优化)
1308
1309 - [`T-S065`](./T-S065.md)
1310-- [`T-S067`](./T-S067.md)
1311 - [`../bugs/OPT-004-final-message-claude-sse-fallback.md`](../bugs/OPT-004-final-message-claude-sse-fallback.md)
1312 - [`../bugs/OPT-009-renewal-duplicate-utility-functions.md`](../bugs/OPT-009-renewal-duplicate-utility-functions.md)
1313
1314@@ -182,10 +182,9 @@
1315
1316 ## 当前主线判断
1317
1318-Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060`、`T-S061`、`T-S062`、`T-S063`、`T-S064`、`T-S066`、`T-S068`、`T-S069` 已经落地。当前主线已经没有 open bug blocker,下一步是:
1319+Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060`、`T-S061`、`T-S062`、`T-S063`、`T-S064`、`T-S066`、`T-S067`、`T-S068`、`T-S069` 已经落地。当前主线已经没有 open bug blocker,下一步是:
1320
1321 - 先做 `T-S065`
1322-- 再推进 `T-S067`
1323 - `OPT-004`、`OPT-009` 继续保留为 open opt
1324
1325 ## 现在该读什么