- commit
- fdfa382
- parent
- 43de232
- author
- codex@macbookpro
- date
- 2026-04-01 18:54:27 +0800 CST
fix: harden Claude final-message SSE fallback
4 files changed,
+357,
-36
1@@ -13,4 +13,9 @@ Claude 的最终消息提取依赖 SSE chunk 中 `type: "completion"` + `complet
2
3 ## 建议
4
5-加 fallback:SSE 拼接为空时,尝试从 DOM 提取最终文本(找到最后一个 assistant message 的文本节点)。
6+在当前插件架构里,controller 侧只能拿到 page bridge 上报的 SSE / network 数据,并不能直接读取 Claude 业务页 DOM。因此更稳的落地方式不是 DOM fallback,而是补齐 Claude SSE 自身的文本提取:
7+
8+- 主路径继续优先 `type/event = completion` + `completion`
9+- 当主路径拿不到文本时,回退到 `content_block_delta.delta.type = text_delta` 的文本拼接
10+- 继续忽略 `thinking_delta`、`message_stop` 等协议/元数据事件,避免把中间片段误判成最终回复
11+- 保持 ChatGPT / Gemini 既有 final-message 路径不变
+2,
-1
1@@ -5,7 +5,7 @@ Firefox 插件的正式能力已经收口到四件事:
2 - 为 Claude / ChatGPT / Gemini 维持单个平台单空壳页
3 - 向本地 `conductor` 上报登录态元数据
4 - 在浏览器本地持有原始凭证并代发 API 请求
5-- 在 ChatGPT / Gemini 上观察最终 assistant message,并通过 `browser.final_message` 做 raw relay
6+- 在 Claude / ChatGPT / Gemini 上观察最终 assistant message,并通过 `browser.final_message` 做 raw relay
7
8 另有一条受限的 thin-plugin delivery 附属能力:
9
10@@ -159,6 +159,7 @@ browser.runtime.sendMessage({
11
12 - 插件会 live 发送 `browser.final_message.raw_text`
13 - 这层只保留最终 assistant message 原文,不上传中间 stream chunk
14+- Claude final-message 会优先使用 `completion` SSE 片段;如果上游改成 `content_block_delta.text_delta`,会自动走 text-delta fallback,并继续忽略 `thinking` / 协议元数据
15 - 当前服务端只做接收和最近快照保留,不把它写进现有浏览器元数据持久化表
16
17 `network_log` / `sse_event` 这类诊断路径不再向 WS 回传请求头值。
+101,
-34
1@@ -7,6 +7,16 @@
2 "max_tokens",
3 "stopped"
4 ]);
5+ const CLAUDE_SSE_PAYLOAD_TYPES = new Set([
6+ "completion",
7+ "content_block_delta",
8+ "content_block_start",
9+ "content_block_stop",
10+ "message_delta",
11+ "message_start",
12+ "message_stop",
13+ "thinking"
14+ ]);
15 const RECENT_RELAY_LIMIT = 20;
16 const MAX_WALK_DEPTH = 8;
17 const MAX_WALK_NODES = 400;
18@@ -710,31 +720,91 @@
19 };
20 }
21
22- function parseClaudeCompletionPayload(chunk) {
23+ function parseClaudeSsePayload(chunk) {
24 const payload = parseSseChunkPayload(chunk);
25 if (!isRecord(payload)) return null;
26
27 const payloadType = trimToNull(payload.type)?.toLowerCase() || null;
28 const eventType = trimToNull(parseSseChunkEvent(chunk))?.toLowerCase() || null;
29- if (payloadType !== "completion" && eventType !== "completion") {
30+ if (!payloadType && eventType !== "completion") {
31 return null;
32 }
33
34- return payload;
35+ if (payloadType && !CLAUDE_SSE_PAYLOAD_TYPES.has(payloadType) && eventType !== "completion") {
36+ return null;
37+ }
38+
39+ return {
40+ eventType,
41+ payload,
42+ payloadType
43+ };
44 }
45
46- function buildClaudeCandidate(rawText, payload, context) {
47- const assistantMessageId =
48- trimToNull(payload?.id)
49+ function extractClaudeAssistantMessageId(payload) {
50+ return trimToNull(payload?.id)
51+ || trimToNull(payload?.uuid)
52 || trimToNull(payload?.message_id)
53 || trimToNull(payload?.messageId)
54+ || trimToNull(payload?.message?.uuid)
55+ || trimToNull(payload?.message?.id)
56+ || trimToNull(payload?.message?.message_id)
57+ || trimToNull(payload?.message?.messageId)
58 || null;
59- const conversationId =
60- trimToNull(payload?.conversation_id)
61+ }
62+
63+ function extractClaudeConversationId(payload, context) {
64+ return trimToNull(payload?.conversation_id)
65 || trimToNull(payload?.conversationId)
66+ || trimToNull(payload?.conversation_uuid)
67+ || trimToNull(payload?.conversationUuid)
68+ || trimToNull(payload?.conversation?.id)
69+ || trimToNull(payload?.conversation?.uuid)
70+ || trimToNull(payload?.message?.conversation_id)
71+ || trimToNull(payload?.message?.conversationId)
72+ || trimToNull(payload?.message?.conversation_uuid)
73+ || trimToNull(payload?.message?.conversationUuid)
74+ || trimToNull(payload?.message?.conversation?.id)
75+ || trimToNull(payload?.message?.conversation?.uuid)
76 || extractClaudeConversationIdFromUrl(context.url)
77 || extractClaudeConversationIdFromUrl(context.pageUrl)
78 || null;
79+ }
80+
81+ function extractClaudeTextFragment(parsedChunk) {
82+ if (!parsedChunk?.payload || !isRecord(parsedChunk.payload)) {
83+ return null;
84+ }
85+
86+ if (parsedChunk.payloadType === "completion" || (!parsedChunk.payloadType && parsedChunk.eventType === "completion")) {
87+ if (typeof parsedChunk.payload.completion !== "string") {
88+ return null;
89+ }
90+
91+ return {
92+ kind: "completion",
93+ text: parsedChunk.payload.completion
94+ };
95+ }
96+
97+ if (parsedChunk.payloadType === "content_block_delta") {
98+ const deltaType = trimToNull(parsedChunk.payload.delta?.type)?.toLowerCase() || null;
99+ if (deltaType !== "text_delta" || typeof parsedChunk.payload.delta.text !== "string") {
100+ return null;
101+ }
102+
103+ return {
104+ kind: "text_delta",
105+ text: parsedChunk.payload.delta.text
106+ };
107+ }
108+
109+ return null;
110+ }
111+
112+ function buildClaudeCandidate(rawText, payload, context) {
113+ const assistantMessageId = extractClaudeAssistantMessageId(payload);
114+ const conversationId = extractClaudeConversationId(payload, context);
115 const normalizedRawText = typeof rawText === "string" && rawText.length > 0 ? rawText : null;
116
117 if (!normalizedRawText && !assistantMessageId && !conversationId) {
118@@ -757,55 +827,52 @@
119 let merged = null;
120
121 for (const block of String(text || "").split(/\n\n+/u)) {
122- const payload = parseClaudeCompletionPayload(block);
123- if (!payload) continue;
124- merged = mergeCandidates(merged, buildClaudeCandidate(null, payload, context));
125+ const parsedChunk = parseClaudeSsePayload(block);
126+ if (!parsedChunk) continue;
127+ merged = mergeCandidates(merged, buildClaudeCandidate(null, parsedChunk.payload, context));
128 }
129
130 return merged;
131 }
132
133 function extractClaudeCandidateFromText(text, context) {
134- let fullText = "";
135- let assistantMessageId = null;
136- let conversationId =
137- extractClaudeConversationIdFromUrl(context.url)
138- || extractClaudeConversationIdFromUrl(context.pageUrl)
139- || null;
140+ let completionText = "";
141+ let deltaText = "";
142+ let metadata = null;
143 let matched = false;
144
145 for (const block of String(text || "").split(/\n\n+/u)) {
146- const payload = parseClaudeCompletionPayload(block);
147- if (!payload) continue;
148+ const parsedChunk = parseClaudeSsePayload(block);
149+ if (!parsedChunk) continue;
150
151 matched = true;
152- if (typeof payload.completion === "string") {
153- fullText += payload.completion;
154+ metadata = mergeCandidates(metadata, buildClaudeCandidate(null, parsedChunk.payload, context));
155+
156+ const fragment = extractClaudeTextFragment(parsedChunk);
157+ if (!fragment) {
158+ continue;
159 }
160
161- assistantMessageId =
162- trimToNull(payload.id)
163- || trimToNull(payload.message_id)
164- || trimToNull(payload.messageId)
165- || assistantMessageId;
166- conversationId =
167- trimToNull(payload.conversation_id)
168- || trimToNull(payload.conversationId)
169- || conversationId;
170+ if (fragment.kind === "completion") {
171+ completionText += fragment.text;
172+ } else {
173+ deltaText += fragment.text;
174+ }
175 }
176
177 if (!matched) {
178 return null;
179 }
180
181+ const preferredText = normalizeMessageText(completionText) || normalizeMessageText(deltaText) || null;
182 return buildClaudeCandidate(
183- fullText,
184+ preferredText,
185 {
186- conversation_id: conversationId,
187- id: assistantMessageId
188+ conversation_id: metadata?.conversationId,
189+ id: metadata?.assistantMessageId
190 },
191 context
192- );
193+ ) || metadata;
194 }
195
196 function createRelayState(platform) {
+248,
-0
1@@ -3,6 +3,7 @@ const test = require("node:test");
2
3 const {
4 createRelayState,
5+ extractClaudeCandidateFromText,
6 extractGeminiCandidateFromText,
7 isRelevantStreamUrl,
8 observeSse
9@@ -28,6 +29,16 @@ function buildChatgptChunk({
10 })}`;
11 }
12
13+function buildClaudeChunk({
14+ event = null,
15+ payload = {}
16+} = {}) {
17+ return [
18+ event ? `event: ${event}` : null,
19+ `data: ${JSON.stringify(payload)}`
20+ ].filter(Boolean).join("\n");
21+}
22+
23 test("isRelevantStreamUrl only accepts ChatGPT root conversation streams", () => {
24 assert.equal(
25 isRelevantStreamUrl("chatgpt", "https://chatgpt.com/backend-api/conversation"),
26@@ -96,6 +107,243 @@ test("observeSse ignores ChatGPT auxiliary conversation subpaths", () => {
27 assert.equal(state.activeStream, null);
28 });
29
30+test("extractClaudeCandidateFromText falls back to Claude text_delta chunks when completion payloads are absent", () => {
31+ const candidate = extractClaudeCandidateFromText([
32+ buildClaudeChunk({
33+ event: "message_start",
34+ payload: {
35+ type: "message_start",
36+ message: {
37+ uuid: "msg_claude_delta"
38+ }
39+ }
40+ }),
41+ buildClaudeChunk({
42+ event: "content_block_delta",
43+ payload: {
44+ type: "content_block_delta",
45+ delta: {
46+ type: "thinking_delta",
47+ thinking: "Let me think"
48+ }
49+ }
50+ }),
51+ buildClaudeChunk({
52+ event: "content_block_delta",
53+ payload: {
54+ type: "content_block_delta",
55+ delta: {
56+ type: "text_delta",
57+ text: "Hello"
58+ }
59+ }
60+ }),
61+ buildClaudeChunk({
62+ event: "content_block_delta",
63+ payload: {
64+ type: "content_block_delta",
65+ delta: {
66+ type: "text_delta",
67+ text: " world"
68+ }
69+ }
70+ }),
71+ buildClaudeChunk({
72+ event: "message_stop",
73+ payload: {
74+ type: "message_stop"
75+ }
76+ })
77+ ].join("\n\n"), {
78+ pageUrl: "https://claude.ai/chat/conv-claude-delta",
79+ url: "https://claude.ai/api/organizations/org-1/chat_conversations/conv-claude-delta/completion"
80+ });
81+
82+ assert.ok(candidate);
83+ assert.equal(candidate.assistantMessageId, "msg_claude_delta");
84+ assert.equal(candidate.conversationId, "conv-claude-delta");
85+ assert.equal(candidate.rawText, "Hello world");
86+});
87+
88+test("extractClaudeCandidateFromText keeps completion as the primary Claude path when both payload shapes appear", () => {
89+ const candidate = extractClaudeCandidateFromText([
90+ buildClaudeChunk({
91+ event: "message_start",
92+ payload: {
93+ type: "message_start",
94+ message: {
95+ id: "msg_claude_primary"
96+ }
97+ }
98+ }),
99+ buildClaudeChunk({
100+ event: "content_block_delta",
101+ payload: {
102+ type: "content_block_delta",
103+ delta: {
104+ type: "text_delta",
105+ text: "fallback text"
106+ }
107+ }
108+ }),
109+ buildClaudeChunk({
110+ event: "completion",
111+ payload: {
112+ type: "completion",
113+ id: "msg_claude_primary",
114+ completion: "primary text"
115+ }
116+ })
117+ ].join("\n\n"), {
118+ pageUrl: "https://claude.ai/chat/conv-claude-primary",
119+ url: "https://claude.ai/api/organizations/org-1/chat_conversations/conv-claude-primary/completion"
120+ });
121+
122+ assert.ok(candidate);
123+ assert.equal(candidate.assistantMessageId, "msg_claude_primary");
124+ assert.equal(candidate.conversationId, "conv-claude-primary");
125+ assert.equal(candidate.rawText, "primary text");
126+});
127+
128+test("observeSse relays Claude text_delta fallback after an aborted SSE stream", () => {
129+ const state = createRelayState("claude");
130+ const meta = {
131+ observedAt: 1743206400000,
132+ pageUrl: "https://claude.ai/chat/conv-claude-sse"
133+ };
134+ const url = "https://claude.ai/api/organizations/org-1/chat_conversations/conv-claude-sse/completion";
135+
136+ assert.equal(
137+ observeSse(state, {
138+ chunk: buildClaudeChunk({
139+ event: "message_start",
140+ payload: {
141+ type: "message_start",
142+ message: {
143+ uuid: "msg-claude-sse"
144+ }
145+ }
146+ }),
147+ url
148+ }, meta),
149+ null
150+ );
151+
152+ assert.equal(
153+ observeSse(state, {
154+ chunk: buildClaudeChunk({
155+ event: "content_block_delta",
156+ payload: {
157+ type: "content_block_delta",
158+ delta: {
159+ type: "thinking_delta",
160+ thinking: "scratchpad"
161+ }
162+ }
163+ }),
164+ url
165+ }, meta),
166+ null
167+ );
168+
169+ assert.equal(
170+ observeSse(state, {
171+ chunk: buildClaudeChunk({
172+ event: "content_block_delta",
173+ payload: {
174+ type: "content_block_delta",
175+ delta: {
176+ type: "text_delta",
177+ text: "Alpha"
178+ }
179+ }
180+ }),
181+ url
182+ }, meta),
183+ null
184+ );
185+
186+ assert.equal(
187+ observeSse(state, {
188+ chunk: buildClaudeChunk({
189+ event: "content_block_delta",
190+ payload: {
191+ type: "content_block_delta",
192+ delta: {
193+ type: "text_delta",
194+ text: " beta"
195+ }
196+ }
197+ }),
198+ url
199+ }, meta),
200+ null
201+ );
202+
203+ const relay = observeSse(state, {
204+ error: "The operation was aborted.",
205+ url
206+ }, meta);
207+
208+ assert.ok(relay);
209+ assert.equal(relay.payload.platform, "claude");
210+ assert.equal(relay.payload.conversation_id, "conv-claude-sse");
211+ assert.equal(relay.payload.assistant_message_id, "msg-claude-sse");
212+ assert.equal(relay.payload.raw_text, "Alpha beta");
213+ assert.equal(relay.payload.observed_at, 1743206400000);
214+ assert.equal(state.activeStream, null);
215+});
216+
217+test("observeSse ignores Claude thinking-only protocol chunks", () => {
218+ const state = createRelayState("claude");
219+ const meta = {
220+ observedAt: 1743206400000,
221+ pageUrl: "https://claude.ai/chat/conv-claude-thinking-only"
222+ };
223+ const url = "https://claude.ai/api/organizations/org-1/chat_conversations/conv-claude-thinking-only/completion";
224+
225+ assert.equal(
226+ observeSse(state, {
227+ chunk: buildClaudeChunk({
228+ event: "message_start",
229+ payload: {
230+ type: "message_start",
231+ message: {
232+ uuid: "msg-claude-thinking-only"
233+ }
234+ }
235+ }),
236+ url
237+ }, meta),
238+ null
239+ );
240+
241+ assert.equal(
242+ observeSse(state, {
243+ chunk: buildClaudeChunk({
244+ event: "content_block_delta",
245+ payload: {
246+ type: "content_block_delta",
247+ delta: {
248+ type: "thinking_delta",
249+ thinking: "plan"
250+ }
251+ }
252+ }),
253+ url
254+ }, meta),
255+ null
256+ );
257+
258+ const relay = observeSse(state, {
259+ done: true,
260+ url
261+ }, meta);
262+
263+ assert.equal(relay, null);
264+ assert.equal(state.activeStream, null);
265+});
266+
267 test("extractGeminiCandidateFromText keeps short exact-reply tokens instead of serialized wrappers", () => {
268 const reqBody = new URLSearchParams({
269 "f.req": JSON.stringify([