- commit
- 73a70f9
- parent
- 667fc6a
- author
- im_wower
- date
- 2026-03-24 08:33:20 +0800 CST
merge: land BRW002 claude page automation http
2 files changed,
+1120,
-120
+225,
-96
1@@ -1,62 +1,219 @@
2 # Firefox Control Protocol
3
4-当前 Firefox 插件实现代码已经直接收口到本仓库子目录:
5+当前 Firefox 插件代码在:
6
7 - [`../../plugins/baa-firefox/`](../../plugins/baa-firefox/)
8
9-这里保留 Firefox 插件与 `baa-conductor` 的最小接入约定。
10+这份文档只描述当前 `baa-conductor` 里实际支持的 Firefox 插件能力。
11
12-## 当前固定入口
13+## 当前范围
14+
15+- 当前对外支持的页面 HTTP 代理能力只支持 `Claude`
16+- 主线是:
17+ - 捕获真实 Claude 页面请求里的凭证
18+ - 发现真实 Claude API endpoint
19+ - 在页面上下文里用真实 cookie / csrf / endpoint 直接代发 HTTP
20+- 不支持 ChatGPT / Gemini 的页面自动化协议
21+- 不把“找输入框、填 prompt、点击发送”作为主方案
22+
23+当前仓库里仍保留多平台的被动观测代码,但对后续服务端承诺的 runtime message 只针对 `Claude`。
24+
25+## 固定入口
26
27 - Local WS bridge: `ws://100.71.210.78:4317/ws/firefox`
28 - Local HTTP host: `http://100.71.210.78:4317`
29
30-当前插件默认同时使用这两条链路:
31+当前插件默认同时使用两条链路:
32+
33+- 本地 WS:负责 Firefox bridge 握手、浏览器元数据同步、服务端 `api_request` / `api_response`
34+- 本地 HTTP:负责控制面状态同步,以及 `pause` / `resume` / `drain`
35+
36+插件管理页不再允许手工编辑地址。
37+
38+## Claude HTTP-First 设计
39+
40+Claude 页面能力建立在三层信息上:
41+
42+1. 凭证抓取
43+ - `controller.js` 通过 `webRequest.onBeforeSendHeaders` 抓真实请求头
44+ - 当前会保存 Claude 的 `cookie`、`x-csrf-token`、`anthropic-client-*`
45+ - Claude 的 `org-id` 优先从请求 URL 提取,也会保存在凭证快照里
46+
47+2. Endpoint 发现
48+ - `page-interceptor.js` 和 `webRequest` 会把真实访问过的 Claude API 路径收集到 `endpoints`
49+ - 当前主线依赖的核心路径是:
50+ - `POST /api/organizations/{orgId}/chat_conversations`
51+ - `GET /api/organizations/{orgId}/chat_conversations/{convId}`
52+ - `POST /api/organizations/{orgId}/chat_conversations/{convId}/completion`
53+
54+3. 页面内 HTTP 代理
55+ - `controller.js` 发 `baa_page_proxy_request`
56+ - `content-script.js` 把请求转发到页面上下文
57+ - `page-interceptor.js` 在 Claude 页面里执行 `fetch(..., { credentials: "include" })`
58+ - Claude API 响应再通过 `baa_page_proxy_response` 回到插件
59+
60+这条链路的目标是让后续 `conductor` WS bridge 调的是“真实 Claude 页面里的 HTTP 能力”,不是 DOM 自动化。
61+
62+## 当前 runtime message
63+
64+### `claude_send`
65+
66+用途:
67+
68+- 基于已捕获的凭证和 endpoint 发起一轮 Claude HTTP 对话
69+
70+示例:
71+
72+```json
73+{
74+ "type": "claude_send",
75+ "prompt": "帮我总结当前仓库的 Firefox 插件结构",
76+ "conversationId": "optional",
77+ "organizationId": "optional",
78+ "createNew": false,
79+ "title": "optional"
80+}
81+```
82+
83+行为:
84+
85+- 如果没有传 `conversationId`,优先使用当前页面/缓存里的对话 ID
86+- 如果仍没有对话 ID,则先创建新对话
87+- 然后对 `/completion` 发 HTTP 请求
88+- 读取 SSE 文本,之后再用 `GET chat_conversations/{convId}` 拉一次权威会话内容
89+
90+返回重点字段:
91+
92+- `organizationId`
93+- `conversationId`
94+- `response.role`
95+- `response.content`
96+- `response.thinking`
97+- `response.timestamp`
98+- `response.messageUuid`
99+- `state.title`
100+- `state.currentUrl`
101+- `state.busy`
102+- `state.recentMessages`
103+
104+### `claude_read_conversation`
105+
106+用途:
107+
108+- 读取当前 Claude 对话的最近消息
109+
110+示例:
111+
112+```json
113+{
114+ "type": "claude_read_conversation",
115+ "conversationId": "optional",
116+ "organizationId": "optional"
117+}
118+```
119+
120+行为:
121+
122+- 先确定 `org-id`
123+- 再确定 `conversationId`
124+- 调 `GET /api/organizations/{orgId}/chat_conversations/{convId}`
125+- 返回最近消息、标题、URL、busy 状态
126+
127+### `claude_read_state`
128+
129+用途:
130+
131+- 读取最小 Claude 页面状态,给后续服务端消费
132
133-- 本地 WS:负责 Firefox bridge 握手、浏览器元数据同步、服务端快照展示
134-- 本地 HTTP:负责控制面状态同步,以及 `pause` / `resume` / `drain` 写入
135+示例:
136
137-不再允许在插件管理页中手工编辑地址。
138+```json
139+{
140+ "type": "claude_read_state",
141+ "refresh": true
142+}
143+```
144+
145+返回重点字段:
146+
147+- `title`
148+- `currentUrl`
149+- `busy`
150+- `recentMessages`
151+- `sources`
152+
153+其中 `recentMessages` 里的消息元素当前统一为:
154+
155+```json
156+{
157+ "id": "optional",
158+ "role": "user|assistant",
159+ "content": "text",
160+ "thinking": "optional",
161+ "timestamp": 0,
162+ "seq": 0
163+}
164+```
165+
166+## 数据来源说明
167+
168+### 来自 API
169+
170+- 对话标题
171+- 最近消息列表
172+- `role / content / timestamp`
173+- model
174+- 当前对话 ID
175+
176+这些数据来自:
177+
178+- `GET /api/organizations/{orgId}/chat_conversations/{convId}`
179+
180+### 来自 SSE
181+
182+- 本轮 `/completion` 的增量文本
183+- Claude 返回的 message uuid
184+- stop reason
185+
186+这些数据来自:
187
188-## 目标
189+- `POST /api/organizations/{orgId}/chat_conversations/{convId}/completion`
190
191-- 让 Firefox 插件自动接入 `mini` 本地 `/ws/firefox`
192-- 保留 `mini` 本地 HTTP 状态同步
193-- 把管理页收口成 WS 状态、HTTP 状态、控制按钮
194-- 在本地服务重启或公网 HTTP 短暂失败后自动恢复
195+### 来自浏览器 tab 元数据
196
197-## 非目标
198+- 当前 URL
199+- 当 API 还没拿到标题时的临时标题兜底
200
201-- 不让浏览器成为调度真相源
202-- 不重新引入旧的手工地址配置
203-- 不把 `/ws/firefox` 作为公网入口
204+当前实现没有依赖页面 DOM 解析输入框、消息列表或发送按钮。
205+
206+### 来自插件内部状态
207+
208+- `busy`
209+
210+`busy` 由两部分合并得到:
211+
212+- 插件自己发起的 Claude proxy 请求
213+- 页面里观察到的 Claude completion / SSE 活跃状态
214
215 ## 启动行为
216
217-- Firefox 启动时,`background.js` 会确保 `controller.html` 存在。
218-- `controller.html` 启动后立刻连接 `ws://100.71.210.78:4317/ws/firefox`。
219-- WS 断开后按固定间隔自动重连;本地服务恢复后连接会自动恢复。
220-- `controller.html` 启动后也会立刻请求 `GET /v1/system/state`。
221-- HTTP 成功后按 `15` 秒周期继续同步。
222-- HTTP 失败后按 `1` 秒、`3` 秒、`5` 秒快速重试,再进入 `30` 秒慢速重试。
223+- Firefox 启动时,`background.js` 会确保 `controller.html` 存在
224+- `controller.html` 启动后立刻连接 `ws://100.71.210.78:4317/ws/firefox`
225+- WS 断开后按固定间隔自动重连
226+- `controller.html` 启动后也会立刻请求 `GET /v1/system/state`
227+- HTTP 成功后按 `15` 秒周期继续同步
228+- HTTP 失败后按 `1` 秒、`3` 秒、`5` 秒快速重试,再进入 `30` 秒慢速重试
229
230 ## 管理页 UI
231
232-当前管理页只保留:
233+管理页当前仍保留:
234
235 - 本地 WS 状态卡片
236 - 本地 HTTP 状态卡片
237 - `暂停` / `恢复` / `排空` 按钮
238-- `WS 状态` 原始详情面板
239-- `HTTP 状态` 原始详情面板
240-
241-不再保留:
242+- 凭证 / endpoint / 平台状态面板
243
244-- WS 地址输入框
245-- HTTP 地址输入框
246-- 手工重连按钮
247-- 手工刷新按钮
248-- 标签页/凭证/端点/日志面板
249+Claude 的 runtime message 能力不依赖管理页按钮触发。
250
251 ## WS 协议
252
253@@ -76,9 +233,11 @@
254
255 - `credentials`
256 - `api_endpoints`
257+- `network_log`
258+- `sse_event`
259 - `client_log`
260
261-插件会消费服务端返回的:
262+同时也会消费服务端下发的:
263
264 - `hello_ack`
265 - `state_snapshot`
266@@ -89,80 +248,50 @@
267 - `action_result`
268 - `error`
269
270-其中 `state_snapshot` 用来驱动管理页里的 WS 状态展示。
271-
272-`api_request` / `api_response` 现在已经被正式纳入本地 WS bridge:
273-
274-- server 可以按 `clientId` 或默认最近活跃 client 下发 `api_request`
275-- 插件完成代发后会回 `api_response`
276-- daemon 会跟踪请求生命周期,并处理超时、client disconnect、同 `clientId` replacement
277-
278-当前仍然没有对外产品化的 `/v1/browser/*` HTTP 接口,这一层只补 transport / registry / request-response。
279-
280-## HTTP 协议
281-
282-读取:
283-
284-- `GET /v1/system/state`
285-
286-写入:
287-
288-- `POST /v1/system/pause`
289-- `POST /v1/system/resume`
290-- `POST /v1/system/drain`
291-
292-写接口请求体固定包含:
293-
294-```json
295-{
296- "requested_by": "browser_admin",
297- "source": "firefox_extension",
298- "reason": "human_clicked_pause",
299- "request_id": "uuid"
300-}
301-```
302+其中 `api_request` 仍然走统一 proxy 通道;Claude 的 runtime message 只是先在插件本地把这条能力补齐,后续由 `conductor` 的 WS bridge 调用。
303
304-插件会尽量从 HTTP 返回中归一化这些字段:
305+## 验收建议
306
307-- `mode`
308-- `leader`
309-- `lease_holder`
310-- `queue_depth` / `queued_tasks`
311-- `active_runs`
312-- `controlConnection`
313-- `retryCount`
314-- `lastSuccessAt`
315-- `lastFailureAt`
316-- `nextRetryAt`
317+### 1. 凭证与 endpoint
318
319-## 验收与验证
320+1. 安装插件并打开 `https://claude.ai/`
321+2. 手工登录 Claude
322+3. 手工发一条真实消息
323+4. 在管理页确认:
324+ - Claude 有有效凭证
325+ - 已发现 `/chat_conversations` 和 `/completion` endpoint
326
327-### 1. 验证 WS 连接
328+### 2. runtime message
329
330-1. 安装插件并启动 Firefox。
331-2. 确认 `controller.html` 自动打开。
332-3. 确认 `本地 WS` 最终显示 `已连接`。
333-4. 在 `WS 状态` 面板中确认:
334- - `wsUrl` 是 `ws://100.71.210.78:4317/ws/firefox`
335- - 有最近一次 `state_snapshot`
336+1. 发送 `claude_read_state`
337+2. 确认返回:
338+ - `currentUrl`
339+ - `busy`
340+ - `recentMessages`
341+3. 发送 `claude_read_conversation`
342+4. 确认标题和最近消息来自 Claude API
343+5. 发送 `claude_send`
344+6. 确认:
345+ - Claude completion 返回 SSE 文本
346+ - 之后能重新读到最新会话内容
347
348-### 2. 验证断线重连
349+### 3. 控制面
350
351-1. 在插件已连接的情况下停掉本地 `conductor-daemon`。
352-2. 确认 `本地 WS` 状态变成 `重连中` 或显示最近错误。
353-3. 重启本地服务。
354-4. 确认管理页无需刷新即可回到 `已连接`。
355+1. 点击 `暂停`,确认 HTTP 状态里的 `mode` 变成 `paused`
356+2. 点击 `恢复`,确认 `mode` 变成 `running`
357+3. 点击 `排空`,确认 `mode` 变成 `draining`
358
359-### 3. 验证按钮控制
360+## 已知限制
361
362-1. 点击 `暂停`,确认 HTTP 状态里的 `mode` 变成 `paused`。
363-2. 点击 `恢复`,确认 `mode` 变成 `running`。
364-3. 点击 `排空`,确认 `mode` 变成 `draining`。
365-4. 如果 HTTP 临时失败,确认管理页会展示明确错误并自动继续重试。
366+- 当前正式支持的页面 HTTP 代理只有 Claude
367+- 必须先在真实 Claude 页面里产生过请求,插件才能学到可用凭证和 `org-id`
368+- 如果当前页面 URL 里没有对话 ID,且最近没有观察到 Claude 会话请求,`claude_read_conversation` 需要显式传 `conversationId`
369+- `claude_send` 走 HTTP 代理,不会驱动 Claude 页面 DOM,也不会让页面自动导航到新建对话 URL
370+- `recentMessages` 当前只保留最近一段窗口,不返回完整长历史
371
372 ## 相关文件
373
374 - [`../../plugins/baa-firefox/controller.js`](../../plugins/baa-firefox/controller.js)
375-- [`../../plugins/baa-firefox/controller.html`](../../plugins/baa-firefox/controller.html)
376 - [`../../plugins/baa-firefox/background.js`](../../plugins/baa-firefox/background.js)
377-- [`../../plugins/baa-firefox/docs/conductor-control.md`](../../plugins/baa-firefox/docs/conductor-control.md)
378+- [`../../plugins/baa-firefox/content-script.js`](../../plugins/baa-firefox/content-script.js)
379+- [`../../plugins/baa-firefox/page-interceptor.js`](../../plugins/baa-firefox/page-interceptor.js)
+895,
-24
1@@ -17,7 +17,8 @@ const CONTROLLER_STORAGE_KEYS = {
2 lastCredentialAtByPlatform: "baaFirefox.lastCredentialAtByPlatform",
3 lastCredentialUrlByPlatform: "baaFirefox.lastCredentialUrlByPlatform",
4 lastCredentialTabIdByPlatform: "baaFirefox.lastCredentialTabIdByPlatform",
5- geminiSendTemplate: "baaFirefox.geminiSendTemplate"
6+ geminiSendTemplate: "baaFirefox.geminiSendTemplate",
7+ claudeState: "baaFirefox.claudeState"
8 };
9
10 const DEFAULT_LOCAL_API_BASE = "http://100.71.210.78:4317";
11@@ -37,6 +38,10 @@ const CONTROL_RETRY_LOG_INTERVAL = 60_000;
12 const TRACKED_TAB_REFRESH_DELAY = 150;
13 const CONTROL_STATUS_BODY_LIMIT = 12_000;
14 const WS_RECONNECT_DELAY = 3_000;
15+const PROXY_REQUEST_TIMEOUT = 180_000;
16+const CLAUDE_MESSAGE_LIMIT = 20;
17+const CLAUDE_TOOL_PLACEHOLDER_RE = /```\n?This block is not supported on your current device yet\.?\n?```/g;
18+const CLAUDE_THINKING_START_RE = /^(The user|Let me|I need to|I should|I'll|George|User |Looking at|This is a|OK[,.]|Alright|Hmm|Now |Here|So |Wait|Actually|My |Their |His |Her |We |用户|让我|我需要|我来|我想|好的|那|先|接下来)/;
19 const CHATGPT_SESSION_COOKIE_PATTERNS = [
20 /__secure-next-auth\.session-token=/i,
21 /__secure-authjs\.session-token=/i,
22@@ -174,6 +179,7 @@ const state = {
23 lastCredentialHash: createPlatformMap(() => ""),
24 lastCredentialSentAt: createPlatformMap(() => 0),
25 geminiSendTemplate: null,
26+ claudeState: createDefaultClaudeState(),
27 logs: []
28 };
29
30@@ -272,6 +278,358 @@ function cloneWsState(value) {
31 };
32 }
33
34+function normalizeClaudeMessageRole(value) {
35+ if (value === "human" || value === "user") return "user";
36+ if (value === "assistant") return "assistant";
37+ return typeof value === "string" && value.trim() ? value.trim() : "unknown";
38+}
39+
40+function parseClaudeTimestamp(value) {
41+ if (Number.isFinite(value)) return Number(value);
42+ if (typeof value === "string" && value.trim()) {
43+ const timestamp = Date.parse(value);
44+ return Number.isFinite(timestamp) ? timestamp : null;
45+ }
46+ return null;
47+}
48+
49+function normalizeClaudeMessageContent(message) {
50+ if (Array.isArray(message?.content) && message.content.length > 0) {
51+ const textBlocks = message.content
52+ .filter((block) => block?.type === "text" && typeof block.text === "string")
53+ .map((block) => block.text);
54+ const thinkingBlocks = message.content
55+ .filter((block) => block?.type === "thinking" && typeof block.thinking === "string")
56+ .map((block) => block.thinking);
57+ return {
58+ content: textBlocks.join("").trim() || null,
59+ thinking: thinkingBlocks.join("").trim() || null
60+ };
61+ }
62+
63+ let raw = typeof message?.text === "string" ? message.text : "";
64+ raw = raw.replace(CLAUDE_TOOL_PLACEHOLDER_RE, "").trim();
65+ raw = raw.replace(/\n{3,}/g, "\n\n");
66+
67+ if (raw && normalizeClaudeMessageRole(message?.sender) === "assistant" && CLAUDE_THINKING_START_RE.test(raw)) {
68+ const splitIndex = raw.search(/\n\n(?:[#*\-|>]|\*\*|\d+\.|[\u4e00-\u9fff])/);
69+ if (splitIndex > 20) {
70+ return {
71+ content: raw.slice(splitIndex + 2).trim() || null,
72+ thinking: raw.slice(0, splitIndex).trim() || null
73+ };
74+ }
75+
76+ return {
77+ content: null,
78+ thinking: raw
79+ };
80+ }
81+
82+ return {
83+ content: raw || null,
84+ thinking: null
85+ };
86+}
87+
88+function normalizeClaudeMessage(message, index = 0) {
89+ const parts = normalizeClaudeMessageContent(message);
90+ return {
91+ id: typeof message?.uuid === "string" && message.uuid ? message.uuid : null,
92+ role: normalizeClaudeMessageRole(message?.sender),
93+ content: parts.content,
94+ thinking: parts.thinking,
95+ timestamp: parseClaudeTimestamp(message?.created_at),
96+ seq: Number.isFinite(index) ? Number(index) : null
97+ };
98+}
99+
100+function normalizeClaudeMessages(messages, limit = CLAUDE_MESSAGE_LIMIT) {
101+ const source = Array.isArray(messages) ? messages : [];
102+ const normalized = source.map((message, index) => normalizeClaudeMessage(message, index));
103+ if (normalized.length <= limit) return normalized;
104+ return normalized.slice(-limit);
105+}
106+
107+function createDefaultClaudeState(overrides = {}) {
108+ return {
109+ organizationId: null,
110+ conversationId: null,
111+ title: null,
112+ titleSource: null,
113+ currentUrl: PLATFORMS?.claude?.rootUrl || "https://claude.ai/",
114+ tabId: null,
115+ tabTitle: null,
116+ busy: false,
117+ busyReason: null,
118+ lastError: null,
119+ lastActivityAt: 0,
120+ lastReadAt: 0,
121+ lastSendAt: 0,
122+ lastConversationSource: null,
123+ lastAssistantMessageUuid: null,
124+ model: null,
125+ messages: [],
126+ ...overrides
127+ };
128+}
129+
130+function cloneClaudeState(value) {
131+ if (!isRecord(value)) return createDefaultClaudeState();
132+ return {
133+ ...createDefaultClaudeState(),
134+ ...value,
135+ messages: Array.isArray(value.messages)
136+ ? value.messages.map((message) => ({
137+ id: typeof message?.id === "string" ? message.id : null,
138+ role: typeof message?.role === "string" ? message.role : "unknown",
139+ content: typeof message?.content === "string" ? message.content : null,
140+ thinking: typeof message?.thinking === "string" ? message.thinking : null,
141+ timestamp: Number.isFinite(message?.timestamp) ? Number(message.timestamp) : null,
142+ seq: Number.isFinite(message?.seq) ? Number(message.seq) : null
143+ }))
144+ : []
145+ };
146+}
147+
148+function loadClaudeState(raw) {
149+ const next = cloneClaudeState(raw);
150+ next.busy = false;
151+ next.busyReason = null;
152+ next.lastError = null;
153+ return next;
154+}
155+
156+function trimToNull(value) {
157+ return typeof value === "string" && value.trim() ? value.trim() : null;
158+}
159+
160+function buildRuntimeRequestId(prefix = "runtime") {
161+ if (typeof crypto?.randomUUID === "function") {
162+ return `${prefix}-${crypto.randomUUID()}`;
163+ }
164+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
165+}
166+
167+function parseClaudeApiContext(url) {
168+ try {
169+ const parsed = new URL(url, PLATFORMS.claude.rootUrl);
170+ const pathname = parsed.pathname || "/";
171+ const organizationMatch = pathname.match(/\/api\/organizations\/([a-f0-9-]{36})(?:\/|$)/i);
172+ const conversationMatch = pathname.match(/\/chat_conversations\/([a-f0-9-]{36})(?:\/|$)/i);
173+ return {
174+ pathname,
175+ organizationId: organizationMatch ? organizationMatch[1] : null,
176+ conversationId: conversationMatch ? conversationMatch[1] : null,
177+ isConversationList: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations$/i.test(pathname),
178+ isConversationItem: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations\/[a-f0-9-]{36}$/i.test(pathname),
179+ isCompletion: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations\/[a-f0-9-]{36}\/completion$/i.test(pathname)
180+ };
181+ } catch (_) {
182+ return {
183+ pathname: "",
184+ organizationId: null,
185+ conversationId: null,
186+ isConversationList: false,
187+ isConversationItem: false,
188+ isCompletion: false
189+ };
190+ }
191+}
192+
193+function extractClaudeConversationIdFromPageUrl(url) {
194+ try {
195+ const parsed = new URL(url, PLATFORMS.claude.rootUrl);
196+ const match = (parsed.pathname || "").match(/([a-f0-9-]{36})(?:\/)?$/i);
197+ return match ? match[1] : null;
198+ } catch (_) {
199+ return null;
200+ }
201+}
202+
203+function extractClaudeOrgId(headers = {}, requestUrl = "") {
204+ const headerOrgId = trimToNull(headers["x-org-id"]);
205+ if (headerOrgId) return headerOrgId;
206+
207+ const context = parseClaudeApiContext(requestUrl);
208+ if (context.organizationId) return context.organizationId;
209+
210+ const cookie = getHeaderValue(headers, "cookie");
211+ const cookieMatch = cookie.match(/(?:^|;\s*)lastActiveOrg=([a-f0-9-]{36})/i);
212+ if (cookieMatch) return cookieMatch[1];
213+
214+ return trimToNull(state.claudeState.organizationId) || null;
215+}
216+
217+function getClaudeOrgId() {
218+ return extractClaudeOrgId(state.lastHeaders.claude, state.lastCredentialUrl.claude || "");
219+}
220+
221+function getClaudeConversationIdFromState() {
222+ return trimToNull(state.claudeState.conversationId)
223+ || extractClaudeConversationIdFromPageUrl(state.claudeState.currentUrl || "")
224+ || parseClaudeApiContext(state.lastCredentialUrl.claude || "").conversationId
225+ || null;
226+}
227+
228+function getLastClaudeMessageUuid(messages) {
229+ const source = Array.isArray(messages) ? messages : [];
230+ for (let index = source.length - 1; index >= 0; index -= 1) {
231+ if (source[index]?.id) return source[index].id;
232+ }
233+ return null;
234+}
235+
236+function buildClaudeStateSnapshot() {
237+ const snapshot = cloneClaudeState(state.claudeState);
238+ return {
239+ platform: "claude",
240+ organizationId: snapshot.organizationId,
241+ conversationId: snapshot.conversationId,
242+ title: snapshot.title,
243+ currentUrl: snapshot.currentUrl,
244+ tabId: snapshot.tabId,
245+ busy: snapshot.busy,
246+ lastError: snapshot.lastError,
247+ lastActivityAt: snapshot.lastActivityAt || null,
248+ lastReadAt: snapshot.lastReadAt || null,
249+ lastSendAt: snapshot.lastSendAt || null,
250+ recentMessages: Array.isArray(snapshot.messages)
251+ ? snapshot.messages.slice(-CLAUDE_MESSAGE_LIMIT)
252+ : [],
253+ sources: {
254+ title: snapshot.titleSource || null,
255+ messages: snapshot.lastConversationSource || null,
256+ busy: snapshot.busyReason || null,
257+ currentUrl: "tab"
258+ }
259+ };
260+}
261+
262+function updateClaudeState(patch = {}, options = {}) {
263+ state.claudeState = cloneClaudeState({
264+ ...state.claudeState,
265+ ...patch
266+ });
267+ if (options.persist) {
268+ persistState().catch(() => {});
269+ }
270+ if (options.render) {
271+ render();
272+ }
273+ return state.claudeState;
274+}
275+
276+async function refreshClaudeTabState(createIfMissing = false) {
277+ const tab = createIfMissing
278+ ? await ensurePlatformTab("claude", { focus: false })
279+ : (await resolveTrackedTab("claude")) || await findPlatformTab("claude");
280+
281+ if (!tab) {
282+ updateClaudeState({
283+ tabId: null,
284+ currentUrl: PLATFORMS.claude.rootUrl,
285+ tabTitle: null
286+ }, {
287+ persist: true,
288+ render: true
289+ });
290+ return null;
291+ }
292+
293+ updateClaudeState({
294+ tabId: Number.isInteger(tab.id) ? tab.id : null,
295+ currentUrl: tab.url || state.claudeState.currentUrl || PLATFORMS.claude.rootUrl,
296+ tabTitle: trimToNull(tab.title),
297+ conversationId: extractClaudeConversationIdFromPageUrl(tab.url || "") || state.claudeState.conversationId,
298+ title: state.claudeState.title || trimToNull(tab.title),
299+ titleSource: state.claudeState.title ? state.claudeState.titleSource : (trimToNull(tab.title) ? "tab" : null)
300+ }, {
301+ persist: true,
302+ render: true
303+ });
304+
305+ return tab;
306+}
307+
308+function normalizeClaudeConversation(payload, meta = {}) {
309+ const source = isRecord(payload) ? payload : {};
310+ const messages = normalizeClaudeMessages(source.chat_messages || []);
311+ return {
312+ organizationId: trimToNull(meta.organizationId) || extractClaudeOrgId({}, meta.requestUrl || ""),
313+ conversationId: trimToNull(source.uuid) || trimToNull(meta.conversationId) || null,
314+ title: trimToNull(source.name),
315+ model: trimToNull(source.model),
316+ updatedAt: parseClaudeTimestamp(source.updated_at) || Date.now(),
317+ messages,
318+ lastAssistantMessageUuid: getLastClaudeMessageUuid(messages)
319+ };
320+}
321+
322+function applyClaudeConversation(payload, meta = {}) {
323+ const normalized = normalizeClaudeConversation(payload, meta);
324+ const fallbackTitle = trimToNull(state.claudeState.tabTitle);
325+ updateClaudeState({
326+ organizationId: normalized.organizationId || state.claudeState.organizationId,
327+ conversationId: normalized.conversationId || state.claudeState.conversationId,
328+ title: normalized.title || fallbackTitle || state.claudeState.title,
329+ titleSource: normalized.title ? "api" : (fallbackTitle ? "tab" : state.claudeState.titleSource),
330+ model: normalized.model || state.claudeState.model,
331+ messages: normalized.messages,
332+ lastAssistantMessageUuid: normalized.lastAssistantMessageUuid || state.claudeState.lastAssistantMessageUuid,
333+ lastConversationSource: meta.source || "api",
334+ lastReadAt: meta.readAt || Date.now(),
335+ lastActivityAt: normalized.updatedAt || Date.now(),
336+ lastError: null
337+ }, {
338+ persist: true,
339+ render: true
340+ });
341+ return buildClaudeStateSnapshot();
342+}
343+
344+function parseClaudeSseText(text) {
345+ let reply = "";
346+ let thinking = "";
347+ let messageUuid = null;
348+ let stopReason = null;
349+
350+ for (const line of String(text || "").split("\n")) {
351+ if (!line.startsWith("data: ")) continue;
352+ try {
353+ const data = JSON.parse(line.slice(6));
354+ if (data.type === "completion" && data.completion) {
355+ reply += data.completion;
356+ if (data.uuid) messageUuid = data.uuid;
357+ } else if (data.type === "message_start") {
358+ reply = "";
359+ thinking = "";
360+ messageUuid = data.message?.uuid || data.message?.id || messageUuid;
361+ } else if (data.type === "content_block_delta" && data.delta?.type === "text_delta") {
362+ reply += data.delta.text || "";
363+ } else if (data.type === "content_block_delta" && data.delta?.type === "thinking_delta") {
364+ thinking += data.delta.thinking || "";
365+ } else if (data.type === "thinking" && data.thinking) {
366+ thinking += data.thinking;
367+ }
368+
369+ if (!stopReason && typeof data.stop_reason === "string" && data.stop_reason) {
370+ stopReason = data.stop_reason;
371+ }
372+ if (!stopReason && data.type === "message_stop") {
373+ stopReason = "message_stop";
374+ }
375+ } catch (_) {}
376+ }
377+
378+ return {
379+ text: reply.trim(),
380+ thinking: thinking.trim() || null,
381+ messageUuid,
382+ stopReason
383+ };
384+}
385+
386 function createPlatformMap(factory) {
387 const out = {};
388 for (const platform of PLATFORM_ORDER) {
389@@ -1215,7 +1573,13 @@ async function persistState() {
390 [CONTROLLER_STORAGE_KEYS.lastCredentialAtByPlatform]: state.lastCredentialAt,
391 [CONTROLLER_STORAGE_KEYS.lastCredentialUrlByPlatform]: state.lastCredentialUrl,
392 [CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]: state.lastCredentialTabId,
393- [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: state.geminiSendTemplate
394+ [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: state.geminiSendTemplate,
395+ [CONTROLLER_STORAGE_KEYS.claudeState]: {
396+ ...cloneClaudeState(state.claudeState),
397+ busy: false,
398+ busyReason: null,
399+ lastError: null
400+ }
401 });
402 }
403
404@@ -1742,6 +2106,16 @@ function buildProxyHeaders(platform, apiPath, sourceHeaders = null) {
405 return out;
406 }
407
408+function buildClaudeHeaders(apiPath, overrides = {}) {
409+ const headers = buildProxyHeaders("claude", apiPath);
410+ for (const [name, value] of Object.entries(overrides || {})) {
411+ const lower = String(name || "").toLowerCase();
412+ if (!lower || value == null || value === "" || isForbiddenProxyHeader(lower)) continue;
413+ headers[lower] = String(value);
414+ }
415+ return headers;
416+}
417+
418 function buildGeminiAutoRequest(prompt) {
419 const template = state.geminiSendTemplate;
420 if (!template?.url || !template?.reqBody) {
421@@ -1801,6 +2175,74 @@ function buildGeminiAutoRequest(prompt) {
422 }
423 }
424
425+function createPendingProxyRequest(id, meta = {}) {
426+ let settled = false;
427+ let timer = null;
428+ let resolveFn = null;
429+ let rejectFn = null;
430+ const response = new Promise((resolve, reject) => {
431+ resolveFn = resolve;
432+ rejectFn = reject;
433+ });
434+
435+ const finish = (callback, value) => {
436+ if (settled) return false;
437+ settled = true;
438+ clearTimeout(timer);
439+ pendingProxyRequests.delete(id);
440+ callback(value);
441+ return true;
442+ };
443+
444+ timer = setTimeout(() => {
445+ finish(rejectFn, new Error(`${platformLabel(meta.platform || "claude")} 代理超时`));
446+ }, Math.max(1_000, Number(meta.timeoutMs) || PROXY_REQUEST_TIMEOUT));
447+
448+ const entry = {
449+ id,
450+ ...meta,
451+ response,
452+ resolve(value) {
453+ return finish(resolveFn, value);
454+ },
455+ reject(error) {
456+ const nextError = error instanceof Error ? error : new Error(String(error || "proxy_failed"));
457+ return finish(rejectFn, nextError);
458+ }
459+ };
460+
461+ pendingProxyRequests.set(id, entry);
462+ return entry;
463+}
464+
465+async function executeProxyRequest(payload, meta = {}) {
466+ const platform = payload?.platform;
467+ if (!platform || !PLATFORMS[platform]) {
468+ throw new Error(`未知平台:${platform || "-"}`);
469+ }
470+
471+ const tab = await ensurePlatformTab(platform, { focus: false });
472+ const id = payload.id || buildRuntimeRequestId(platform);
473+ const entry = createPendingProxyRequest(id, {
474+ ...meta,
475+ platform,
476+ method: payload.method,
477+ path: payload.path
478+ });
479+
480+ try {
481+ await postProxyRequestToTab(tab.id, {
482+ ...payload,
483+ id
484+ });
485+ } catch (error) {
486+ entry.reject(error);
487+ throw error;
488+ }
489+
490+ return entry.response;
491+}
492+
493 function sendEndpointSnapshot(platform = null) {
494 for (const target of getTargetPlatforms(platform)) {
495 const endpoints = Object.keys(state.endpoints[target]).sort();
496@@ -2005,7 +2447,12 @@ function connectWs(options = {}) {
497 break;
498 }
499 case "api_request":
500- proxyApiRequest(message).catch((error) => {
501+ proxyApiRequest(message).then((result) => {
502+ sendApiResponse(message.id, result.ok, result.status, result.body, result.error);
503+ if (!result.ok) {
504+ addLog("error", `代理 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${result.error || result.status || "proxy_failed"}`);
505+ }
506+ }).catch((error) => {
507 sendApiResponse(message.id, false, null, null, error.message);
508 addLog("error", `代理 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
509 });
510@@ -2098,6 +2545,16 @@ async function findPlatformTab(platform) {
511 async function setTrackedTab(platform, tab) {
512 state.trackedTabs[platform] = tab ? tab.id : null;
513 pruneInvalidCredentialState();
514+ if (platform === "claude") {
515+ updateClaudeState({
516+ tabId: tab?.id ?? null,
517+ currentUrl: tab?.url || state.claudeState.currentUrl,
518+ tabTitle: trimToNull(tab?.title),
519+ conversationId: extractClaudeConversationIdFromPageUrl(tab?.url || "") || state.claudeState.conversationId
520+ }, {
521+ render: true
522+ });
523+ }
524 await persistState();
525 render();
526 }
527@@ -2257,10 +2714,86 @@ function resolvePlatformFromRequest(details) {
528 return ensureTrackedTabId(platform, details.tabId, "request") ? platform : null;
529 }
530
531+function setClaudeBusy(isBusy, reason = null) {
532+ updateClaudeState({
533+ busy: !!isBusy,
534+ busyReason: isBusy ? reason || "proxy" : null,
535+ lastActivityAt: Date.now()
536+ }, {
537+ render: true
538+ });
539+}
540+
541+function applyObservedClaudeResponse(data, tabId) {
542+ const context = parseClaudeApiContext(data.url || "");
543+ const patch = {
544+ tabId: Number.isInteger(tabId) ? tabId : state.claudeState.tabId,
545+ organizationId: context.organizationId || state.claudeState.organizationId,
546+ conversationId: context.conversationId || state.claudeState.conversationId,
547+ lastActivityAt: Date.now()
548+ };
549+
550+ if (context.isCompletion) {
551+ patch.busy = true;
552+ patch.busyReason = data.source === "proxy" ? "proxy" : "page_sse";
553+ }
554+
555+ if (context.isConversationItem && typeof data.resBody === "string" && data.resBody) {
556+ try {
557+ const payload = JSON.parse(data.resBody);
558+ applyClaudeConversation(payload, {
559+ source: data.source === "proxy" ? "proxy_api" : "api_observed",
560+ organizationId: context.organizationId,
561+ conversationId: context.conversationId,
562+ readAt: Date.now()
563+ });
564+ return;
565+ } catch (_) {}
566+ }
567+
568+ updateClaudeState(patch, {
569+ persist: !!(patch.organizationId || patch.conversationId),
570+ render: true
571+ });
572+}
573+
574+function applyObservedClaudeSse(data, tabId) {
575+ const context = parseClaudeApiContext(data.url || "");
576+ const parsed = typeof data.chunk === "string" && data.chunk ? parseClaudeSseText(data.chunk) : null;
577+ const patch = {
578+ tabId: Number.isInteger(tabId) ? tabId : state.claudeState.tabId,
579+ organizationId: context.organizationId || state.claudeState.organizationId,
580+ conversationId: context.conversationId || state.claudeState.conversationId,
581+ lastActivityAt: Date.now()
582+ };
583+
584+ if (parsed?.messageUuid) {
585+ patch.lastAssistantMessageUuid = parsed.messageUuid;
586+ }
587+
588+ if (data.done || data.error) {
589+ patch.busy = false;
590+ patch.busyReason = null;
591+ patch.lastError = data.error || null;
592+ } else {
593+ patch.busy = true;
594+ patch.busyReason = "page_sse";
595+ }
596+
597+ updateClaudeState(patch, {
598+ persist: !!(patch.organizationId || patch.conversationId || patch.lastAssistantMessageUuid),
599+ render: true
600+ });
601+}
602+
603 function handlePageNetwork(data, sender) {
604 const context = getSenderContext(sender, data?.platform || null);
605 if (!context || !data || !data.url || !data.method) return;
606
607+ if (context.platform === "claude") {
608+ applyObservedClaudeResponse(data, context.tabId);
609+ }
610+
611 if (context.platform === "gemini" && typeof data.reqBody === "string" && data.reqBody) {
612 const templateHeaders = Object.keys(data.reqHeaders || {}).length > 0
613 ? mergeKnownHeaders(context.platform, data.reqHeaders || {})
614@@ -2286,6 +2819,10 @@ function handlePageSse(data, sender) {
615 const context = getSenderContext(sender, data?.platform || null);
616 if (!context || !data || !data.url) return;
617
618+ if (context.platform === "claude") {
619+ applyObservedClaudeSse(data, context.tabId);
620+ }
621+
622 wsSend({
623 type: "sse_event",
624 clientId: state.clientId,
625@@ -2304,10 +2841,26 @@ function handlePageProxyResponse(data, sender) {
626 const context = getSenderContext(sender, data?.platform || null);
627 if (!context || !data || !data.id) return;
628 const pending = pendingProxyRequests.get(data.id);
629+ if (!pending) return;
630+
631+ if (context.platform === "claude") {
632+ const parsed = parseClaudeApiContext(data.url || pending.path || "");
633+ updateClaudeState({
634+ organizationId: parsed.organizationId || state.claudeState.organizationId,
635+ conversationId: parsed.conversationId || state.claudeState.conversationId,
636+ busy: false,
637+ busyReason: null,
638+ lastActivityAt: Date.now(),
639+ lastError: data.error || null
640+ }, {
641+ persist: !!(parsed.organizationId || parsed.conversationId),
642+ render: true
643+ });
644+ }
645
646 if (
647 context.platform === "gemini"
648- && pending?.prompt
649+ && pending.prompt
650 && pending.attempts < 1
651 && Number(data.status) === 400
652 ) {
653@@ -2325,29 +2878,27 @@ function handlePageProxyResponse(data, sender) {
654 body: retry.body,
655 headers: retry.headers
656 }).catch((error) => {
657- pendingProxyRequests.delete(data.id);
658- sendApiResponse(data.id, false, null, null, error.message);
659+ pending.reject(error);
660 addLog("error", `Gemini 重试 ${data.id} 失败:${error.message}`);
661 });
662 return;
663 } catch (error) {
664- pendingProxyRequests.delete(data.id);
665- sendApiResponse(data.id, false, null, null, error.message);
666+ pending.reject(error);
667 addLog("error", `Gemini 重试 ${data.id} 失败:${error.message}`);
668 return;
669 }
670 }
671 }
672
673- pendingProxyRequests.delete(data.id);
674-
675- sendApiResponse(
676- data.id,
677- !data.error,
678- Number.isFinite(data.status) ? data.status : null,
679- typeof data.body === "string" ? data.body : (data.body == null ? null : JSON.stringify(data.body)),
680- data.error || null
681- );
682+ pending.resolve({
683+ id: data.id,
684+ ok: data.ok !== false && !data.error,
685+ status: Number.isFinite(data.status) ? data.status : null,
686+ body: typeof data.body === "string" ? data.body : (data.body == null ? null : JSON.stringify(data.body)),
687+ error: data.error || null,
688+ url: data.url || pending.path || null,
689+ method: data.method || pending.method || null
690+ });
691
692 if (!data.ok || (Number.isFinite(data.status) && data.status >= 400)) {
693 addLog(
694@@ -2364,6 +2915,17 @@ function handlePageBridgeReady(data, sender) {
695 if (!platform || !Number.isInteger(tabId)) return;
696
697 ensureTrackedTabId(platform, tabId, "bridge");
698+ if (platform === "claude") {
699+ updateClaudeState({
700+ tabId,
701+ currentUrl: senderUrl || state.claudeState.currentUrl,
702+ tabTitle: trimToNull(sender?.tab?.title),
703+ conversationId: extractClaudeConversationIdFromPageUrl(senderUrl) || state.claudeState.conversationId
704+ }, {
705+ persist: true,
706+ render: true
707+ });
708+ }
709 addLog("info", `${platformLabel(platform)} 钩子已就绪,标签页 ${tabId},来源 ${data?.source || "未知"}`);
710 }
711
712@@ -2372,6 +2934,12 @@ function handleBeforeSendHeaders(details) {
713 if (!platform) return;
714
715 const headers = headerArrayToObject(details.requestHeaders);
716+ if (platform === "claude") {
717+ const orgId = extractClaudeOrgId(headers, details.url || "");
718+ if (orgId) {
719+ headers["x-org-id"] = orgId;
720+ }
721+ }
722 if (Object.keys(headers).length === 0) return;
723
724 collectEndpoint(platform, details.method || "GET", details.url);
725@@ -2404,6 +2972,22 @@ function handleCompleted(details) {
726 function handleErrorOccurred(details) {
727 const platform = resolvePlatformFromRequest(details);
728 if (!platform || !shouldTrackRequest(platform, details.url)) return;
729+ if (platform === "claude") {
730+ const context = parseClaudeApiContext(details.url || "");
731+ if (context.isCompletion) {
732+ updateClaudeState({
733+ organizationId: context.organizationId || state.claudeState.organizationId,
734+ conversationId: context.conversationId || state.claudeState.conversationId,
735+ busy: false,
736+ busyReason: null,
737+ lastError: details.error || "request_failed",
738+ lastActivityAt: Date.now()
739+ }, {
740+ persist: true,
741+ render: true
742+ });
743+ }
744+ }
745 addLog("error", `${platformLabel(platform)} ${details.method} ${normalizePath(details.url)} 失败:${details.error}`);
746 }
747
748@@ -2432,33 +3016,294 @@ async function proxyApiRequest(message) {
749 if (!platform || !PLATFORMS[platform]) throw new Error(`未知平台:${platform || "-"}`);
750 if (!apiPath) throw new Error("缺少代理请求路径");
751
752- const tab = await ensurePlatformTab(platform, { focus: false });
753 const prompt = platform === "gemini" ? extractPromptFromProxyBody(body) : null;
754 const geminiAutoRequest = platform === "gemini" && prompt && isGeminiStreamGenerateUrl(apiPath)
755 ? buildGeminiAutoRequest(prompt)
756 : null;
757- const payload = {
758+
759+ return executeProxyRequest({
760 id,
761 platform,
762 method: geminiAutoRequest ? "POST" : String(method || "GET").toUpperCase(),
763 path: geminiAutoRequest ? geminiAutoRequest.path : apiPath,
764 body: geminiAutoRequest ? geminiAutoRequest.body : body,
765 headers: geminiAutoRequest ? geminiAutoRequest.headers : buildProxyHeaders(platform, apiPath)
766- };
767-
768- pendingProxyRequests.set(id, {
769+ }, {
770 platform,
771 prompt,
772 attempts: 0
773 });
774+}
775+
776+async function proxyClaudeRequest(method, path, body = null, options = {}) {
777+ const headers = buildClaudeHeaders(path, options.headers || {});
778+ const response = await executeProxyRequest({
779+ id: options.id || buildRuntimeRequestId("claude"),
780+ platform: "claude",
781+ method: String(method || "GET").toUpperCase(),
782+ path,
783+ body,
784+ headers
785+ }, {
786+ platform: "claude",
787+ timeoutMs: options.timeoutMs || PROXY_REQUEST_TIMEOUT
788+ });
789+
790+ if (!response.ok || (Number.isFinite(response.status) && response.status >= 400)) {
791+ const bodyPreview = typeof response.body === "string" && response.body
792+ ? `: ${response.body.slice(0, 240)}`
793+ : "";
794+ throw new Error(`Claude 请求失败 (${response.status || "unknown"})${bodyPreview}`);
795+ }
796+
797+ return response;
798+}
799+
800+async function proxyClaudeJson(method, path, body = null, options = {}) {
801+ const response = await proxyClaudeRequest(method, path, body, {
802+ ...options,
803+ headers: {
804+ accept: "application/json",
805+ ...(options.headers || {})
806+ }
807+ });
808+
809+ if (!response.body) return null;
810 try {
811- await postProxyRequestToTab(tab.id, payload);
812+ return JSON.parse(response.body);
813 } catch (error) {
814- pendingProxyRequests.delete(id);
815+ throw new Error(`Claude JSON 响应解析失败:${error.message}`);
816+ }
817+}
818+
819+async function readClaudeConversation(options = {}) {
820+ await refreshClaudeTabState(options.createTab === true);
821+ const conversationId = trimToNull(options.conversationId) || getClaudeConversationIdFromState();
822+ const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
823+
824+ if (!organizationId) {
825+ throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
826+ }
827+ if (!conversationId) {
828+ throw new Error("Claude 当前没有可读取的对话 ID");
829+ }
830+
831+ const payload = await proxyClaudeJson(
832+ "GET",
833+ `/api/organizations/${organizationId}/chat_conversations/${conversationId}`,
834+ null
835+ );
836+
837+ return applyClaudeConversation(payload, {
838+ source: "api_read",
839+ organizationId,
840+ conversationId,
841+ readAt: Date.now()
842+ });
843+}
844+
845+async function createClaudeConversation(options = {}) {
846+ const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
847+ if (!organizationId) {
848+ throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
849+ }
850+
851+ const payload = await proxyClaudeJson(
852+ "POST",
853+ `/api/organizations/${organizationId}/chat_conversations`,
854+ {
855+ name: trimToNull(options.title) || "",
856+ uuid: typeof crypto?.randomUUID === "function" ? crypto.randomUUID() : undefined
857+ }
858+ );
859+
860+ const conversationId = trimToNull(payload?.uuid);
861+ if (!conversationId) {
862+ throw new Error("Claude 创建对话未返回 uuid");
863+ }
864+
865+ updateClaudeState({
866+ organizationId,
867+ conversationId,
868+ title: trimToNull(payload?.name) || trimToNull(options.title) || null,
869+ titleSource: trimToNull(payload?.name) || trimToNull(options.title) ? "api" : null,
870+ messages: [],
871+ lastAssistantMessageUuid: null,
872+ lastConversationSource: "api_create",
873+ lastActivityAt: Date.now(),
874+ lastError: null
875+ }, {
876+ persist: true,
877+ render: true
878+ });
879+
880+ return {
881+ organizationId,
882+ conversationId,
883+ title: trimToNull(payload?.name) || trimToNull(options.title) || null
884+ };
885+}
886+
887+async function resolveClaudeSendTarget(options = {}) {
888+ await refreshClaudeTabState(true);
889+ const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
890+ if (!organizationId) {
891+ throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
892+ }
893+
894+ let conversationId = trimToNull(options.conversationId);
895+ let conversationSnapshot = null;
896+
897+ if (!conversationId && options.createNew !== true) {
898+ conversationId = getClaudeConversationIdFromState();
899+ }
900+
901+ if (conversationId && options.createNew !== true) {
902+ conversationSnapshot = await readClaudeConversation({
903+ organizationId,
904+ conversationId
905+ }).catch(() => null);
906+ }
907+
908+ if (!conversationId || options.createNew === true) {
909+ const created = await createClaudeConversation({
910+ organizationId,
911+ title: options.title
912+ });
913+ conversationId = created.conversationId;
914+ }
915+
916+ const lastMessageUuid = conversationSnapshot?.recentMessages?.length
917+ ? getLastClaudeMessageUuid(conversationSnapshot.recentMessages)
918+ : (conversationId === state.claudeState.conversationId
919+ ? state.claudeState.lastAssistantMessageUuid || null
920+ : null);
921+
922+ return {
923+ organizationId,
924+ conversationId,
925+ lastMessageUuid
926+ };
927+}
928+
929+async function sendClaudePrompt(message = {}) {
930+ const prompt = trimToNull(message.prompt || message.text || message.message);
931+ if (!prompt) {
932+ throw new Error("Claude prompt 不能为空");
933+ }
934+
935+ const target = await resolveClaudeSendTarget(message);
936+ setClaudeBusy(true, "proxy");
937+
938+ try {
939+ const completionBody = {
940+ prompt,
941+ timezone: "Asia/Shanghai",
942+ attachments: [],
943+ files: []
944+ };
945+
946+ if (target.lastMessageUuid) {
947+ completionBody.parent_message_uuid = target.lastMessageUuid;
948+ }
949+
950+ const response = await proxyClaudeRequest(
951+ "POST",
952+ `/api/organizations/${target.organizationId}/chat_conversations/${target.conversationId}/completion`,
953+ completionBody,
954+ {
955+ headers: {
956+ accept: "text/event-stream",
957+ "content-type": "application/json"
958+ }
959+ }
960+ );
961+
962+ const parsed = parseClaudeSseText(response.body || "");
963+ updateClaudeState({
964+ organizationId: target.organizationId,
965+ conversationId: target.conversationId,
966+ lastAssistantMessageUuid: parsed.messageUuid || state.claudeState.lastAssistantMessageUuid,
967+ lastSendAt: Date.now(),
968+ lastActivityAt: Date.now(),
969+ lastError: null
970+ }, {
971+ persist: true,
972+ render: true
973+ });
974+
975+ let conversation = null;
976+ try {
977+ conversation = await readClaudeConversation({
978+ organizationId: target.organizationId,
979+ conversationId: target.conversationId
980+ });
981+ } catch (error) {
982+ updateClaudeState({
983+ lastError: error.message
984+ }, {
985+ persist: true,
986+ render: true
987+ });
988+ conversation = buildClaudeStateSnapshot();
989+ }
990+
991+ setClaudeBusy(false, null);
992+
993+ return {
994+ ok: true,
995+ organizationId: target.organizationId,
996+ conversationId: target.conversationId,
997+ response: {
998+ role: "assistant",
999+ content: parsed.text,
1000+ thinking: parsed.thinking,
1001+ timestamp: Date.now(),
1002+ messageUuid: parsed.messageUuid || null,
1003+ stopReason: parsed.stopReason || null
1004+ },
1005+ state: conversation
1006+ };
1007+ } catch (error) {
1008+ updateClaudeState({
1009+ lastError: error.message
1010+ }, {
1011+ persist: true,
1012+ render: true
1013+ });
1014+ setClaudeBusy(false, null);
1015 throw error;
1016 }
1017 }
1018
1019+async function readClaudeState(options = {}) {
1020+ const tab = await refreshClaudeTabState(options.createTab === true);
1021+ const conversationId = trimToNull(options.conversationId) || getClaudeConversationIdFromState();
1022+ const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
1023+
1024+ if (organizationId && conversationId && options.refresh !== false) {
1025+ try {
1026+ return await readClaudeConversation({
1027+ organizationId,
1028+ conversationId
1029+ });
1030+ } catch (error) {
1031+ updateClaudeState({
1032+ lastError: error.message
1033+ }, {
1034+ persist: true,
1035+ render: true
1036+ });
1037+ }
1038+ }
1039+
1040+ if (!tab) {
1041+ throw new Error("Claude 标签页不存在");
1042+ }
1043+
1044+ return buildClaudeStateSnapshot();
1045+}
1046+
1047 function registerWebRequestListeners() {
1048 browser.webRequest.onBeforeSendHeaders.addListener(
1049 handleBeforeSendHeaders,
1050@@ -2511,6 +3356,30 @@ function registerRuntimeListeners() {
1051 ok: true,
1052 snapshot: cloneControlState(state.controlState)
1053 });
1054+ case "claude_send":
1055+ return sendClaudePrompt(message).catch((error) => ({
1056+ ok: false,
1057+ error: error.message,
1058+ state: buildClaudeStateSnapshot()
1059+ }));
1060+ case "claude_read_conversation":
1061+ return readClaudeConversation(message).then((stateSnapshot) => ({
1062+ ok: true,
1063+ state: stateSnapshot
1064+ })).catch((error) => ({
1065+ ok: false,
1066+ error: error.message,
1067+ state: buildClaudeStateSnapshot()
1068+ }));
1069+ case "claude_read_state":
1070+ return readClaudeState(message).then((stateSnapshot) => ({
1071+ ok: true,
1072+ state: stateSnapshot
1073+ })).catch((error) => ({
1074+ ok: false,
1075+ error: error.message,
1076+ state: buildClaudeStateSnapshot()
1077+ }));
1078 default:
1079 break;
1080 }
1081@@ -2611,6 +3480,7 @@ async function init() {
1082 saved[CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]
1083 );
1084 state.geminiSendTemplate = saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate] || null;
1085+ state.claudeState = loadClaudeState(saved[CONTROLLER_STORAGE_KEYS.claudeState]);
1086 if (needsStatusReset) {
1087 state.lastHeaders = createPlatformMap(() => ({}));
1088 state.lastCredentialAt = createPlatformMap(() => 0);
1089@@ -2632,6 +3502,7 @@ async function init() {
1090 const current = await browser.tabs.getCurrent();
1091 await browser.runtime.sendMessage({ type: "controller_ready", tabId: current.id });
1092 await refreshTrackedTabsFromBrowser("startup");
1093+ await refreshClaudeTabState(false);
1094 await persistState();
1095 render();
1096 addLog("info", `controller ready ${state.clientId}`, false);