From c4e7af0b04ee2578511c792811c443bcd3153036 Mon Sep 17 00:00:00 2001 From: TW Date: Thu, 3 Sep 2026 21:11:55 +0800 Subject: [PATCH 1/2] feat: add DeepSeek network response capture - Capture DeepSeek completion streams through XMLHttpRequest with DOM fallback. - Reconstruct only RESPONSE fragments and ignore THINK content during tool detection. - Add adapter configuration, documentation, and coverage for DeepSeek stream handling. --- bridge-browser/src/page/network_capture.ts | 239 ++++++++++++-- .../test/network_capture_page.test.ts | 177 ++++++++++ doc/PLATFORM_GUIDE.md | 16 +- doc/PLATFORM_GUIDE_en.md | 16 +- gateway-vscode/package.json | 6 +- gateway-vscode/src/platforms.ts | 9 + .../src/unit-test/deepseekStream.test.ts | 116 +++++++ .../src/unit-test/platforms.test.ts | 7 + shared/src/deepseekStream.ts | 308 ++++++++++++++++++ shared/src/index.ts | 1 + shared/src/networkCapture.ts | 4 +- 11 files changed, 865 insertions(+), 34 deletions(-) create mode 100644 bridge-browser/test/network_capture_page.test.ts create mode 100644 gateway-vscode/src/unit-test/deepseekStream.test.ts create mode 100644 shared/src/deepseekStream.ts diff --git a/bridge-browser/src/page/network_capture.ts b/bridge-browser/src/page/network_capture.ts index cc3fc3c..0bcd539 100644 --- a/bridge-browser/src/page/network_capture.ts +++ b/bridge-browser/src/page/network_capture.ts @@ -1,7 +1,9 @@ import { ChatGptEventStreamDecoder, + DeepSeekEventStreamDecoder, extractToolCallTextCandidates, PROTOCOL, + type NetworkCaptureAdapter, type SiteNetworkCaptureConfig, } from "@webcode/shared"; import { @@ -14,6 +16,21 @@ interface ActiveCaptureConfig { token: string; } +interface CaptureStreamDecoder { + finish: () => { + complete: boolean; + conversationId?: string; + messages: Array<{ id: string; text: string }>; + reason?: string; + }; + push: (chunk: string) => void; +} + +interface XhrRequestDetails { + method: string; + url: string; +} + let activeConfig: ActiveCaptureConfig | null = null; window.addEventListener("message", (event: MessageEvent) => { @@ -28,10 +45,19 @@ window.addEventListener("message", (event: MessageEvent) => { window.postMessage({ type: PROTOCOL.networkCaptureReadyMessage }, window.location.origin); const originalFetch = window.fetch.bind(window); +// These methods are restored onto individual XHR instances with Reflect.apply below. +// eslint-disable-next-line @typescript-eslint/unbound-method +const originalXhrOpen = XMLHttpRequest.prototype.open; +// eslint-disable-next-line @typescript-eslint/unbound-method +const originalXhrSend = XMLHttpRequest.prototype.send; +const xhrRequestDetails = new WeakMap(); window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const config = activeConfig; - if (!config || !shouldCaptureRequest(input, init, config.capture)) { + if (config?.capture.transport !== "fetch-sse") { + return originalFetch(input, init); + } + if (!shouldCaptureFetchRequest(input, init, config.capture)) { return originalFetch(input, init); } @@ -67,6 +93,156 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise this.handleReadyStateChange()); + xhr.addEventListener("progress", () => this.consumeAvailableText()); + xhr.addEventListener("loadend", () => this.complete()); + xhr.addEventListener("abort", () => this.fail("Request was aborted.")); + xhr.addEventListener("error", () => this.fail("XHR request failed.")); + xhr.addEventListener("timeout", () => this.fail("XHR request timed out.")); + } + + public fail(reason: string): void { + if (this.terminated) { + return; + } + this.terminated = true; + postFailedCapture(this.config.token, this.captureId, this.requestUrl, reason); + } + + private handleReadyStateChange(): void { + if (this.terminated) { + return; + } + if (this.xhr.readyState >= XMLHttpRequest.HEADERS_RECEIVED && !this.validateResponse()) { + return; + } + if (this.xhr.readyState === XMLHttpRequest.LOADING) { + this.consumeAvailableText(); + } + } + + private validateResponse(): boolean { + if (this.validatedResponse) { + return true; + } + let contentType: string | null; + try { + contentType = this.xhr.getResponseHeader("content-type"); + } catch (error) { + this.fail(getErrorMessage(error)); + return false; + } + if (this.xhr.status < 200 || this.xhr.status >= 300 || + !contentType?.toLowerCase().includes("text/event-stream")) { + this.fail("Response is not a successful EventStream."); + return false; + } + if (this.xhr.responseType !== "" && this.xhr.responseType !== "text") { + this.fail(`Unsupported XHR response type: ${this.xhr.responseType}`); + return false; + } + this.validatedResponse = true; + return true; + } + + private consumeAvailableText(): void { + if (this.terminated || !this.validateResponse()) { + return; + } + let responseText: string; + try { + responseText = this.xhr.responseText; + } catch (error) { + this.fail(getErrorMessage(error)); + return; + } + if (responseText.length < this.consumedChars) { + this.fail("XHR response text was reset during capture."); + return; + } + if (responseText.length > this.consumedChars) { + try { + this.decoder.push(responseText.slice(this.consumedChars)); + this.consumedChars = responseText.length; + } catch (error) { + this.fail(getErrorMessage(error)); + } + } + } + + private complete(): void { + if (this.terminated || !this.validateResponse()) { + return; + } + try { + this.consumeAvailableText(); + if (this.terminated) { + return; + } + const result = this.decoder.finish(); + if (!result.complete) { + this.fail(result.reason ?? "incomplete"); + return; + } + this.terminated = true; + postCompletedCapture(this.config.token, this.captureId, this.requestUrl, result); + } catch (error) { + this.fail(getErrorMessage(error)); + } + } + + private postStarted(): void { + postCaptureEvent({ + captureId: this.captureId, + event: "started", + token: this.config.token, + type: PROTOCOL.networkCaptureEventMessage, + url: this.requestUrl, + }); + } +} + async function captureEventStream( response: Response, config: ActiveCaptureConfig, @@ -78,7 +254,7 @@ async function captureEventStream( throw new Error("Response body is unavailable."); } - const decoder = new ChatGptEventStreamDecoder({ channels: config.capture.channels }); + const decoder = createCaptureDecoder(config.capture); const reader = response.body.getReader(); const textDecoder = new TextDecoder(); try { @@ -92,18 +268,7 @@ async function captureEventStream( postFailedCapture(config.token, captureId, requestUrl, result.reason ?? "incomplete"); return; } - postCaptureEvent({ - calls: result.messages.flatMap((message) => - extractToolCallTextCandidates(message.text) - .map((text, index) => ({ index, messageId: message.id, text })) - ), - captureId, - conversationId: result.conversationId, - event: "completed", - token: config.token, - type: PROTOCOL.networkCaptureEventMessage, - url: requestUrl, - }); + postCompletedCapture(config.token, captureId, requestUrl, result); } catch (error) { postFailedCapture(config.token, captureId, requestUrl, getErrorMessage(error)); } @@ -112,7 +277,7 @@ async function captureEventStream( async function consumeResponseBody( reader: ReadableStreamDefaultReader, textDecoder: TextDecoder, - decoder: ChatGptEventStreamDecoder + decoder: CaptureStreamDecoder ): Promise { while (true) { const result = await reader.read(); @@ -127,18 +292,32 @@ async function consumeResponseBody( } } -function shouldCaptureRequest( +function shouldCaptureFetchRequest( input: RequestInfo | URL, init: RequestInit | undefined, config: SiteNetworkCaptureConfig ): boolean { - if (config.adapter !== "chatgpt-delta-v1" || config.transport !== "fetch-sse") { + return shouldCaptureTarget(getRequestMethod(input, init), getRequestUrl(input), config); +} + +function shouldCaptureTarget(method: string, url: string, config: SiteNetworkCaptureConfig): boolean { + if (!isSupportedAdapter(config.adapter) || method !== config.method) { return false; } - if (getRequestMethod(input, init) !== config.method) { - return false; + return urlsMatch(url, config.url); +} + +function createCaptureDecoder(config: SiteNetworkCaptureConfig): CaptureStreamDecoder { + switch (config.adapter) { + case "chatgpt-delta-v1": + return new ChatGptEventStreamDecoder({ channels: config.channels }); + case "deepseek-chat-v0": + return new DeepSeekEventStreamDecoder(); } - return urlsMatch(getRequestUrl(input), config.url); +} + +function isSupportedAdapter(adapter: NetworkCaptureAdapter): boolean { + return adapter === "chatgpt-delta-v1" || adapter === "deepseek-chat-v0"; } function shouldCaptureResponse(response: Response): boolean { @@ -182,6 +361,26 @@ function postFailedCapture(token: string, captureId: string, url: string, reason }); } +function postCompletedCapture( + token: string, + captureId: string, + url: string, + result: ReturnType +): void { + postCaptureEvent({ + calls: result.messages.flatMap((message) => + extractToolCallTextCandidates(message.text) + .map((text, index) => ({ index, messageId: message.id, text })) + ), + captureId, + conversationId: result.conversationId, + event: "completed", + token, + type: PROTOCOL.networkCaptureEventMessage, + url, + }); +} + function postCaptureEvent(event: NetworkCapturePageEvent): void { window.postMessage(event, window.location.origin); } diff --git a/bridge-browser/test/network_capture_page.test.ts b/bridge-browser/test/network_capture_page.test.ts new file mode 100644 index 0000000..68d5349 --- /dev/null +++ b/bridge-browser/test/network_capture_page.test.ts @@ -0,0 +1,177 @@ +import { PROTOCOL, type SiteNetworkCaptureConfig } from "@webcode/shared"; + +type MessageListener = (event: MessageEvent) => void; +type XhrListener = () => void; + +class FakeWindow { + public fetch = (): Promise => Promise.resolve(new Response()); + public readonly location = { + href: "https://chat.deepseek.com/", + origin: "https://chat.deepseek.com", + }; + public readonly postedMessages: unknown[] = []; + private readonly messageListeners = new Set(); + + public addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + if (type === "message" && typeof listener === "function") { + this.messageListeners.add(listener as MessageListener); + } + } + + public dispatchMessage(data: unknown): void { + const event = { data, source: this } as unknown as MessageEvent; + this.messageListeners.forEach((listener) => listener(event)); + } + + public postMessage(message: unknown): void { + this.postedMessages.push(message); + } +} + +class FakeXmlHttpRequest { + public static readonly DONE = 4; + public static readonly HEADERS_RECEIVED = 2; + public static readonly LOADING = 3; + public readyState = 0; + public responseText = ""; + public responseType: XMLHttpRequestResponseType = ""; + public status = 0; + private readonly listeners = new Map(); + + public addEventListener(type: string, listener: XhrListener): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + public getResponseHeader(name: string): string | null { + return name.toLowerCase() === "content-type" ? "text/event-stream; charset=utf-8" : null; + } + + public open(_method: string, _url: string | URL): void { + this.readyState = 1; + } + + public send(): void { + this.status = 200; + this.readyState = FakeXmlHttpRequest.HEADERS_RECEIVED; + this.dispatch("readystatechange"); + for (const chunk of splitStream(createDeepSeekStream(), 17)) { + this.responseText += chunk; + this.readyState = FakeXmlHttpRequest.LOADING; + this.dispatch("progress"); + } + this.readyState = FakeXmlHttpRequest.DONE; + this.dispatch("readystatechange"); + this.dispatch("loadend"); + } + + private dispatch(type: string): void { + this.listeners.get(type)?.forEach((listener) => listener()); + } +} + +const CAPTURE_CONFIG: SiteNetworkCaptureConfig = { + adapter: "deepseek-chat-v0", + channels: ["response"], + enabled: true, + method: "POST", + strategy: "network-preferred", + transport: "xhr-sse", + url: "https://chat.deepseek.com/api/v0/chat/completion", +}; + +async function main(): Promise { + const fakeWindow = new FakeWindow(); + Object.defineProperty(globalThis, "window", { configurable: true, value: fakeWindow }); + Object.defineProperty(globalThis, "XMLHttpRequest", { + configurable: true, + value: FakeXmlHttpRequest, + }); + await import("../src/page/network_capture"); + + fakeWindow.dispatchMessage({ + capture: CAPTURE_CONFIG, + token: "capture-token", + type: PROTOCOL.networkCaptureConfigMessage, + }); + const xhr = new XMLHttpRequest(); + xhr.open("POST", CAPTURE_CONFIG.url); + xhr.send(); + + const captureEvents = fakeWindow.postedMessages.filter(isCaptureEvent); + assertEqual(captureEvents.length, 2, "expected one started and one completed event"); + assertEqual(captureEvents[0].event, "started", "XHR capture did not announce its start"); + assertEqual(captureEvents[1].event, "completed", "XHR capture did not complete"); + if (captureEvents[1].event !== "completed") { + throw new Error("expected a completed capture event"); + } + assertEqual(captureEvents[1].calls.length, 1, "expected one final-response tool call"); + assert( + captureEvents[1].calls[0].text.includes('"path":"response.txt"'), + "capture used the THINK fragment instead of the RESPONSE fragment" + ); + console.log("PASS captures DeepSeek SSE responses transported by XHR"); +} + +function createDeepSeekStream(): string { + const thoughtCall = createToolCall("thought.txt"); + const responseCall = createToolCall("response.txt"); + return [ + "event: ready\n", + 'data: {"request_message_id":1,"response_message_id":2,"model_type":"expert"}\n\n', + `data: ${JSON.stringify({ + v: { + response: { + fragments: [{ content: thoughtCall, id: 2, type: "THINK" }], + message_id: 2, + role: "ASSISTANT", + status: "WIP", + }, + }, + })}\n\n`, + `data: ${JSON.stringify({ + o: "APPEND", + p: "response/fragments", + v: [{ content: `\`\`\`json\n${responseCall}\n\`\`\``, id: 3, type: "RESPONSE" }], + })}\n\n`, + 'data: {"o":"SET","p":"response/status","v":"FINISHED"}\n\n', + "event: close\n", + 'data: {"click_behavior":"none","auto_resume":false}\n\n', + ].join(""); +} + +function createToolCall(path: string): string { + return JSON.stringify({ + arguments: { path }, + mcp_action: "call", + name: "read_file", + purpose: "Read a file", + }); +} + +function splitStream(stream: string, chunkSize: number): string[] { + const chunks: string[] = []; + for (let index = 0; index < stream.length; index += chunkSize) { + chunks.push(stream.slice(index, index + chunkSize)); + } + return chunks; +} + +function isCaptureEvent(value: unknown): value is { + calls: Array<{ text: string }>; + event: "started" | "completed"; +} { + return typeof value === "object" && value !== null && + (value as Record).type === PROTOCOL.networkCaptureEventMessage; +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) {throw new Error(message);} +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) {throw new Error(`${message}: expected ${String(expected)}, received ${String(actual)}`);} +} + +void main(); diff --git a/doc/PLATFORM_GUIDE.md b/doc/PLATFORM_GUIDE.md index d989290..b38f015 100644 --- a/doc/PLATFORM_GUIDE.md +++ b/doc/PLATFORM_GUIDE.md @@ -244,7 +244,7 @@ selectors 建议: ## 网络响应捕获 -网络捕获与现有 DOM 扫描并行。当前内置 ChatGPT 配置会匹配 +网络捕获与现有 DOM 扫描并行。内置 ChatGPT 配置会匹配 `POST https://chatgpt.com/backend-api/f/conversation` 的 EventStream,并使用 `chatgpt-delta-v1` adapter 重建 `commentary` 消息: @@ -264,19 +264,25 @@ selectors 建议: } ``` +内置 DeepSeek 配置使用 `xhr-sse` 监听 +`POST https://chat.deepseek.com/api/v0/chat/completion`,再由 `deepseek-chat-v0` adapter +重建正文 `RESPONSE` fragment。`THINK` fragment 始终被排除,避免执行思考过程中提到、 +随后可能在正文中再次输出的工具调用。 + 运行规则: -- 页面主世界脚本在 `document_start` 包装 `window.fetch`,返回给网站的原始 `Response` 不变, - 扩展只读取 clone。 +- 页面主世界脚本在 `document_start` 包装 `window.fetch` 和 `XMLHttpRequest`。fetch 路径返回给网站的 + 原始 `Response` 不变并只读取 clone;XHR 路径只增量读取原有 `responseText`。 - 仅匹配配置的 URL、HTTP method 和 `text/event-stream` 响应;URL query 不参与匹配。 - adapter 会跨 SSE chunk 重建增量消息,因此工具调用 JSON 即使被拆到多个 chunk 也能识别。 - 整个响应成功结束并收到流完成标记后,才会提交其中的工具调用;不完整、失败或已移除的消息会丢弃。 - 网络回合开始后会暂时抑制同一回合的 DOM 捕获,避免重复执行;网络捕获未命中或失败时继续使用 DOM 兜底。 - 页面和隔离的 content script 之间使用每页随机 token 关联并过滤消息,只传递提取出的工具调用 JSON 候选。 -- 原始 EventStream、隐藏 commentary 正文、请求头和凭据不会传给 content script 或 Gateway,也不会写入存储。 +- 原始 EventStream、隐藏的 commentary/思考正文、请求头和凭据不会传给 content script 或 Gateway, + 也不会写入存储。 `capture` 是声明式配置,不是通用网络脚本。新增协议时,需要先在浏览器扩展中实现并发布 adapter, -再把该 adapter 名称写入平台配置。内置 ChatGPT 网络捕获可以通过下面的覆盖临时关闭: +再把该 adapter 名称写入平台配置。内置网络捕获可以通过站点 id 覆盖临时关闭,例如: ```json { diff --git a/doc/PLATFORM_GUIDE_en.md b/doc/PLATFORM_GUIDE_en.md index 04c6049..12d3927 100644 --- a/doc/PLATFORM_GUIDE_en.md +++ b/doc/PLATFORM_GUIDE_en.md @@ -230,10 +230,16 @@ This path requires Chromium 111 or later for the static `MAIN`-world content scr } ``` +The built-in DeepSeek configuration uses `xhr-sse` for +`POST https://chat.deepseek.com/api/v0/chat/completion`. Its `deepseek-chat-v0` adapter reconstructs only +visible `RESPONSE` fragments. It always excludes `THINK` fragments so a tool call mentioned during reasoning +cannot execute before the model potentially emits it again in the final response. + Runtime rules: -- A main-world script wraps `window.fetch` at `document_start`. The site receives the untouched original - `Response`; the extension reads only a clone. +- A main-world script wraps `window.fetch` and `XMLHttpRequest` at `document_start`. The fetch path leaves the + original `Response` untouched and reads only a clone; the XHR path incrementally reads the existing + `responseText`. - Only the configured URL, HTTP method, and `text/event-stream` responses match. URL query parameters are ignored during matching. - The adapter reconstructs deltas across SSE chunks, including tool-call JSON split across chunks. @@ -243,12 +249,12 @@ Runtime rules: or capture failure falls back to the DOM path. - A per-page random token correlates and filters messages between the page and isolated content script. Only extracted tool-call JSON candidates cross that boundary. -- Raw streams, hidden commentary text, request headers, and credentials are not sent to the content script or - gateway and are not persisted. +- Raw streams, hidden commentary/reasoning text, request headers, and credentials are not sent to the content + script or gateway and are not persisted. `capture` is declarative rather than a general network script. Supporting another stream protocol requires shipping an adapter in the browser extension before selecting its name in platform configuration. Disable -the built-in ChatGPT capture temporarily with this override: +a built-in capture temporarily by overriding its site id, for example: ```json { diff --git a/gateway-vscode/package.json b/gateway-vscode/package.json index 5fd9668..e48f781 100644 --- a/gateway-vscode/package.json +++ b/gateway-vscode/package.json @@ -106,7 +106,8 @@ "transport": { "type": "string", "enum": [ - "fetch-sse" + "fetch-sse", + "xhr-sse" ] }, "method": { @@ -123,7 +124,8 @@ "adapter": { "type": "string", "enum": [ - "chatgpt-delta-v1" + "chatgpt-delta-v1", + "deepseek-chat-v0" ] }, "channels": { diff --git a/gateway-vscode/src/platforms.ts b/gateway-vscode/src/platforms.ts index 04b14ba..8be2c1d 100644 --- a/gateway-vscode/src/platforms.ts +++ b/gateway-vscode/src/platforms.ts @@ -90,6 +90,15 @@ const BUILTIN_AI_SITES: ResolvedAiSiteConfig[] = [ name: 'DeepSeek', address: 'https://chat.deepseek.com', showQuickLaunch: true, + capture: { + adapter: 'deepseek-chat-v0', + channels: ['response'], + enabled: true, + method: 'POST', + strategy: 'network-preferred', + transport: 'xhr-sse', + url: 'https://chat.deepseek.com/api/v0/chat/completion' + }, selectors: { messageBlocks: '.ds-message', codeBlocks: '.ds-markdown.ds-assistant-message-main-content pre', diff --git a/gateway-vscode/src/unit-test/deepseekStream.test.ts b/gateway-vscode/src/unit-test/deepseekStream.test.ts new file mode 100644 index 0000000..981d061 --- /dev/null +++ b/gateway-vscode/src/unit-test/deepseekStream.test.ts @@ -0,0 +1,116 @@ +import * as assert from 'assert'; + +import { + DeepSeekEventStreamDecoder, + extractToolCallTextCandidates, +} from '@webcode/shared'; + +suite('DeepSeek event stream capture', () => { + test('captures only RESPONSE fragments and ignores a duplicate call in THINK', () => { + const decoder = new DeepSeekEventStreamDecoder(); + const toolCall = createToolCall('response.txt'); + const stream = buildCompletedStream([ + createInitialResponse('expert', true, 'THINK', `Reasoning\n\n\`\`\`json\n${toolCall}\n\`\`\``), + createFragmentAppend('RESPONSE', 'Visible response\n\n```json\n'), + JSON.stringify({ p: 'response/fragments/-1/content', o: 'APPEND', v: toolCall.slice(0, 20) }), + JSON.stringify({ v: `${toolCall.slice(20)}\n\`\`\`` }), + createFinishedStatus(), + ], 'expert'); + + pushInSmallChunks(decoder, stream, 11); + const result = decoder.finish(); + + assert.strictEqual(result.complete, true); + assert.strictEqual(result.messages.length, 1); + assert.deepStrictEqual( + extractToolCallTextCandidates(result.messages[0].text), + [toolCall] + ); + }); + + test('captures a non-thinking default response', () => { + const decoder = new DeepSeekEventStreamDecoder(); + const toolCall = createToolCall('README.md'); + decoder.push(buildCompletedStream([ + createInitialResponse('default', false, 'RESPONSE', `\`\`\`json\n${toolCall}\n\`\`\``), + createFinishedStatus(), + ], 'default')); + + const result = decoder.finish(); + + assert.strictEqual(result.complete, true); + assert.strictEqual(result.messages[0].id, '2'); + assert.deepStrictEqual(extractToolCallTextCandidates(result.messages[0].text), [toolCall]); + }); + + test('does not expose a completed-looking call from an incomplete stream', () => { + const decoder = new DeepSeekEventStreamDecoder(); + decoder.push([ + 'event: ready\n', + 'data: {"request_message_id":1,"response_message_id":2,"model_type":"default"}\n\n', + `data: ${createInitialResponse('default', false, 'RESPONSE', createToolCall('README.md'))}\n\n`, + ].join('')); + + const result = decoder.finish(); + + assert.strictEqual(result.complete, false); + assert.deepStrictEqual(result.messages, []); + }); +}); + +function buildCompletedStream(deltas: readonly string[], modelType: string): string { + return [ + 'event: ready\n', + `data: {"request_message_id":1,"response_message_id":2,"model_type":"${modelType}"}\n\n`, + ...deltas.map(delta => `data: ${delta}\n\n`), + 'event: close\n', + 'data: {"click_behavior":"none","auto_resume":false}\n\n', + ].join(''); +} + +function createInitialResponse( + modelType: string, + thinkingEnabled: boolean, + fragmentType: string, + content: string +): string { + return JSON.stringify({ + v: { + response: { + fragments: [{ content, id: 2, stage_id: 1, type: fragmentType }], + message_id: 2, + model: modelType, + role: 'ASSISTANT', + status: 'WIP', + thinking_enabled: thinkingEnabled, + }, + }, + }); +} + +function createFragmentAppend(type: string, content: string): string { + return JSON.stringify({ + o: 'APPEND', + p: 'response/fragments', + v: [{ content, id: 3, stage_id: 1, type }], + }); +} + +function createFinishedStatus(): string { + return JSON.stringify({ o: 'SET', p: 'response/status', v: 'FINISHED' }); +} + +function createToolCall(path: string): string { + return JSON.stringify({ + arguments: { path }, + mcp_action: 'call', + name: 'read_file', + purpose: 'Read a file', + }); +} + +function pushInSmallChunks(decoder: DeepSeekEventStreamDecoder, stream: string, chunkSize: number): void { + for (let index = 0; index < stream.length; index += chunkSize) { + decoder.push(stream.slice(index, index + chunkSize)); + } +} diff --git a/gateway-vscode/src/unit-test/platforms.test.ts b/gateway-vscode/src/unit-test/platforms.test.ts index a34f3f9..f115e24 100644 --- a/gateway-vscode/src/unit-test/platforms.test.ts +++ b/gateway-vscode/src/unit-test/platforms.test.ts @@ -18,6 +18,13 @@ suite('platform registry', () => { assert.strictEqual(chatgpt.capture?.adapter, 'chatgpt-delta-v1'); assert.strictEqual(chatgpt.capture?.url, 'https://chatgpt.com/backend-api/f/conversation'); assert.strictEqual(typeof chatgpt.selectors.inputArea, 'string'); + + const deepseek = findAiSiteById(sites, 'deepseek'); + assert.ok(deepseek); + assert.strictEqual(deepseek.capture?.adapter, 'deepseek-chat-v0'); + assert.deepStrictEqual(deepseek.capture?.channels, ['response']); + assert.strictEqual(deepseek.capture?.transport, 'xhr-sse'); + assert.strictEqual(deepseek.capture?.url, 'https://chat.deepseek.com/api/v0/chat/completion'); }); test('uses Qwen selectors that prefer the first comparison response', () => { diff --git a/shared/src/deepseekStream.ts b/shared/src/deepseekStream.ts new file mode 100644 index 0000000..8bf1a33 --- /dev/null +++ b/shared/src/deepseekStream.ts @@ -0,0 +1,308 @@ +import { ServerSentEventDecoder, type ServerSentEvent } from "./sse"; + +export interface DeepSeekCapturedMessage { + id: string; + text: string; +} + +export interface DeepSeekCaptureResult { + complete: boolean; + messages: DeepSeekCapturedMessage[]; + reason?: "incomplete" | "invalid_stream"; +} + +interface DeepSeekFragment { + content: string; + type: string; +} + +interface DeepSeekResponseState { + fragments: DeepSeekFragment[]; + id: string; + status: string; +} + +interface DeltaOperation { + o?: unknown; + p?: unknown; + v?: unknown; +} + +const DEFAULT_MAX_MESSAGE_CHARS = 256_000; + +/** + * Reconstructs the assistant's visible DeepSeek response from its chat SSE stream. + * THINK fragments are deliberately never exposed so tool calls mentioned during + * reasoning cannot execute before the model emits them in the final response. + */ +export class DeepSeekEventStreamDecoder { + private readonly maxMessageChars: number; + private readonly sseDecoder = new ServerSentEventDecoder(); + private failed = false; + private lastOperation = ""; + private lastPath = ""; + private readyResponseId: string | null = null; + private receivedClose = false; + private response: DeepSeekResponseState | null = null; + + public constructor(options: { maxMessageChars?: number } = {}) { + this.maxMessageChars = options.maxMessageChars ?? DEFAULT_MAX_MESSAGE_CHARS; + } + + public push(chunk: string): void { + this.consumeEvents(this.sseDecoder.push(chunk)); + } + + public finish(): DeepSeekCaptureResult { + this.consumeEvents(this.sseDecoder.finish()); + if (this.failed) { + return { complete: false, messages: [], reason: "invalid_stream" }; + } + if (!this.receivedClose || this.response?.status !== "FINISHED") { + return { complete: false, messages: [], reason: "incomplete" }; + } + + const text = this.response.fragments + .filter((fragment) => fragment.type === "RESPONSE") + .map((fragment) => fragment.content) + .join(""); + return { + complete: true, + messages: text.trim() ? [{ id: this.response.id, text }] : [], + }; + } + + private consumeEvents(events: readonly ServerSentEvent[]): void { + events.forEach((event) => this.consumeEvent(event)); + } + + private consumeEvent(event: ServerSentEvent): void { + let payload: unknown; + try { + payload = JSON.parse(event.data) as unknown; + } catch { + this.failed = true; + return; + } + + if (event.event === "close") { + this.receivedClose = true; + return; + } + if (event.event === "error") { + this.failed = true; + return; + } + if (!isRecord(payload)) { + return; + } + if (event.event === "ready") { + this.readyResponseId = readId(payload.response_message_id); + return; + } + if (event.event !== "message") { + return; + } + + this.consumeDelta(payload); + } + + private consumeDelta(delta: Record): void { + if (hasOwn(delta, "o") && typeof delta.o === "string") { + this.lastOperation = delta.o.toUpperCase(); + } + if (hasOwn(delta, "p") && typeof delta.p === "string") { + this.lastPath = normalizePath(delta.p); + } + + const rootValue = delta.v; + if (isRecord(rootValue) && isRecord(rootValue.response)) { + this.consumeResponse(rootValue.response); + return; + } + if (!this.response) { + return; + } + if (this.lastOperation === "BATCH" && Array.isArray(rootValue)) { + rootValue.forEach((operation) => { + if (isRecord(operation)) { + this.applyOperation(operation, this.lastPath); + } + }); + return; + } + + this.applyOperation({ + o: this.lastOperation, + p: this.lastPath, + v: rootValue, + }); + } + + private consumeResponse(response: Record): void { + if (response.role !== "ASSISTANT" || !Array.isArray(response.fragments)) { + this.failed = true; + return; + } + const id = readId(response.message_id) ?? this.readyResponseId; + if (!id) { + this.failed = true; + return; + } + + const fragments = response.fragments + .map(readFragment) + .filter((fragment): fragment is DeepSeekFragment => Boolean(fragment)); + this.assertMessageLimit(fragments); + this.response = { + fragments, + id, + status: typeof response.status === "string" ? response.status.toUpperCase() : "", + }; + } + + private applyOperation(operation: DeltaOperation, basePath = ""): void { + const response = this.response; + if (!response) { + return; + } + const operationName = typeof operation.o === "string" + ? operation.o.toUpperCase() + : this.lastOperation; + const operationPath = typeof operation.p === "string" + ? joinPaths(basePath, operation.p) + : basePath; + + if (applyResponseStatus(response, operationPath, operation.v)) { + return; + } + if (operationPath === "response/fragments") { + if (!applyFragmentsOperation(response, operationName, operation.v)) { + this.failed = true; + return; + } + this.assertMessageLimit(response.fragments); + return; + } + + const fragmentContentResult = applyFragmentContent(response, operationName, operationPath, operation.v); + if (fragmentContentResult === "unsupported") { + this.failed = true; + } else if (fragmentContentResult === "applied") { + this.assertMessageLimit(response.fragments); + } + } + + private assertMessageLimit(fragments: readonly DeepSeekFragment[]): void { + const responseChars = fragments + .filter((fragment) => fragment.type === "RESPONSE") + .reduce((total, fragment) => total + fragment.content.length, 0); + if (responseChars > this.maxMessageChars) { + this.failed = true; + throw new Error("Captured DeepSeek message exceeded the size limit."); + } + } +} + +function applyResponseStatus(response: DeepSeekResponseState, path: string, value: unknown): boolean { + if (path !== "response/status" || typeof value !== "string") { + return false; + } + response.status = value.toUpperCase(); + return true; +} + +function applyFragmentsOperation( + response: DeepSeekResponseState, + operation: string, + value: unknown +): boolean { + if (operation === "SET" && Array.isArray(value)) { + response.fragments = value + .map(readFragment) + .filter((fragment): fragment is DeepSeekFragment => Boolean(fragment)); + return true; + } + if (operation !== "APPEND") { + return false; + } + const values = Array.isArray(value) ? value : [value]; + values.forEach((item) => { + const fragment = readFragment(item); + if (fragment) { + response.fragments.push(fragment); + } + }); + return true; +} + +function applyFragmentContent( + response: DeepSeekResponseState, + operation: string, + path: string, + value: unknown +): "applied" | "ignored" | "unsupported" { + const fragmentIndex = readFragmentContentIndex(path, response.fragments.length); + if (fragmentIndex === null) { + return "ignored"; + } + if ((operation !== "APPEND" && operation !== "SET") || typeof value !== "string") { + return "unsupported"; + } + const fragment = response.fragments[fragmentIndex]; + if (!fragment) { + return "unsupported"; + } + if (fragment.type !== "RESPONSE") { + return "applied"; + } + fragment.content = operation === "APPEND" ? fragment.content + value : value; + return "applied"; +} + +function readFragment(value: unknown): DeepSeekFragment | null { + if (!isRecord(value) || typeof value.type !== "string") { + return null; + } + const type = value.type.toUpperCase(); + return { + content: type === "RESPONSE" && typeof value.content === "string" ? value.content : "", + type, + }; +} + +function readFragmentContentIndex(path: string, fragmentCount: number): number | null { + const match = /^response\/fragments\/(-1|\d+)\/content$/.exec(path); + if (!match) { + return null; + } + return match[1] === "-1" ? fragmentCount - 1 : Number(match[1]); +} + +function joinPaths(basePath: string, childPath: string): string { + const normalizedBase = normalizePath(basePath); + const normalizedChild = normalizePath(childPath); + if (!normalizedBase) { + return normalizedChild; + } + if (!normalizedChild) { + return normalizedBase; + } + return `${normalizedBase}/${normalizedChild}`; +} + +function normalizePath(path: string): string { + return path.replace(/^\/+|\/+$/g, ""); +} + +function readId(value: unknown): string | null { + return typeof value === "string" || typeof value === "number" ? String(value) : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOwn(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} diff --git a/shared/src/index.ts b/shared/src/index.ts index e9299f7..47187b8 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -3,6 +3,7 @@ import brandConfig from './branding.json'; export * from './chatgptStream'; +export * from './deepseekStream'; export * from './networkCapture'; export * from './sse'; diff --git a/shared/src/networkCapture.ts b/shared/src/networkCapture.ts index fc36ad5..a816f23 100644 --- a/shared/src/networkCapture.ts +++ b/shared/src/networkCapture.ts @@ -1,6 +1,6 @@ -export const NETWORK_CAPTURE_ADAPTERS = ["chatgpt-delta-v1"] as const; +export const NETWORK_CAPTURE_ADAPTERS = ["chatgpt-delta-v1", "deepseek-chat-v0"] as const; export const NETWORK_CAPTURE_STRATEGIES = ["network-preferred"] as const; -export const NETWORK_CAPTURE_TRANSPORTS = ["fetch-sse"] as const; +export const NETWORK_CAPTURE_TRANSPORTS = ["fetch-sse", "xhr-sse"] as const; export const NETWORK_CAPTURE_METHODS = ["GET", "POST"] as const; export type NetworkCaptureAdapter = typeof NETWORK_CAPTURE_ADAPTERS[number]; From 9384cdb0558f945117fcac215004829e71b5d844 Mon Sep 17 00:00:00 2001 From: TW Date: Thu, 3 Sep 2026 21:25:51 +0800 Subject: [PATCH 2/2] feat: show capture source in tool activity - Record whether each tool call was captured from the network or DOM. - Show localized source badges in current and historical activity rows. - Cover source propagation and badge rendering in browser tests. --- .../src/content/dom_tool_activity.ts | 1 + .../src/content/network_tool_calls.ts | 2 ++ bridge-browser/src/content/tool_activity.ts | 11 +++++-- .../src/content/tool_activity_overlay.ts | 8 ++++- .../content/tool_activity_overlay_styles.ts | 6 +++- bridge-browser/src/modules/i18n.ts | 2 ++ bridge-browser/test/dom_tool_activity.test.ts | 1 + bridge-browser/test/tool_activity.test.ts | 1 + .../test/tool_activity_overlay.test.ts | 32 +++++++++++++++++-- 9 files changed, 58 insertions(+), 6 deletions(-) diff --git a/bridge-browser/src/content/dom_tool_activity.ts b/bridge-browser/src/content/dom_tool_activity.ts index 4c0e001..0b28e69 100644 --- a/bridge-browser/src/content/dom_tool_activity.ts +++ b/bridge-browser/src/content/dom_tool_activity.ts @@ -23,6 +23,7 @@ export class DomToolActivityController { this.tracker.capture({ identity: options.identity, payload: options.payload, + source: "dom", turnId: this.getTurnId(options.messageElement), }); } diff --git a/bridge-browser/src/content/network_tool_calls.ts b/bridge-browser/src/content/network_tool_calls.ts index 4de7264..81c7b7a 100644 --- a/bridge-browser/src/content/network_tool_calls.ts +++ b/bridge-browser/src/content/network_tool_calls.ts @@ -124,6 +124,7 @@ export class NetworkToolCallController { this.options.toolActivityTracker.capture({ identity, payload, + source: "network", turnId: activityTurnId, }); if (!this.options.requestRegistry.hasSeen(identity.requestKey)) { @@ -144,6 +145,7 @@ export class NetworkToolCallController { this.options.toolActivityTracker.captureProtocolError({ identity, message: getErrorMessage(error), + source: "network", turnId: activityTurnId, }); return identity; diff --git a/bridge-browser/src/content/tool_activity.ts b/bridge-browser/src/content/tool_activity.ts index 0c5592e..ca740be 100644 --- a/bridge-browser/src/content/tool_activity.ts +++ b/bridge-browser/src/content/tool_activity.ts @@ -10,6 +10,8 @@ export type ToolActivityStatus = | "failed" | "rejected"; +export type ToolActivitySource = "dom" | "network"; + export type ToolActivityDeliveryStatus = | "pending" | "waiting" @@ -23,6 +25,7 @@ export interface ToolActivityItem { message?: string; purpose?: string; requestKey: string; + source: ToolActivitySource; startedAt?: number; status: ToolActivityStatus; toolName: string; @@ -46,12 +49,14 @@ type ToolActivityListener = (snapshot: ToolActivitySnapshot) => void; interface CaptureActivityOptions { identity: ToolRequestIdentity; payload: ToolExecutionPayload; + source: ToolActivitySource; turnId: string; } interface CaptureProtocolErrorOptions { identity: ToolRequestIdentity; message: string; + source: ToolActivitySource; turnId: string; } @@ -82,7 +87,7 @@ export class ToolActivityTracker { } public capture(options: CaptureActivityOptions): void { - const { identity, payload, turnId } = options; + const { identity, payload, source, turnId } = options; if (this.items.has(identity.requestKey)) {return;} const turn = this.ensureTurn(turnId); @@ -91,6 +96,7 @@ export class ToolActivityTracker { detail: getPayloadDetail(payload), purpose: normalizeText(payload.purpose), requestKey: identity.requestKey, + source, status: "captured", toolName: payload.name, turnId, @@ -99,7 +105,7 @@ export class ToolActivityTracker { } public captureProtocolError(options: CaptureProtocolErrorOptions): void { - const { identity, message, turnId } = options; + const { identity, message, source, turnId } = options; if (this.items.has(identity.requestKey)) {return;} const turn = this.ensureTurn(turnId); @@ -108,6 +114,7 @@ export class ToolActivityTracker { completedAt: Date.now(), message, requestKey: identity.requestKey, + source, status: "failed", toolName: "invalid_tool_call", turnId, diff --git a/bridge-browser/src/content/tool_activity_overlay.ts b/bridge-browser/src/content/tool_activity_overlay.ts index f60130d..3ff63e2 100644 --- a/bridge-browser/src/content/tool_activity_overlay.ts +++ b/bridge-browser/src/content/tool_activity_overlay.ts @@ -271,10 +271,16 @@ function createActivityRow(item: ToolActivityItem): HTMLElement { const name = document.createElement("span"); name.className = "tool-name"; name.textContent = item.toolName; + const source = document.createElement("span"); + source.className = `source-badge ${item.source}`; + source.textContent = t(item.source === "network" ? "activity_source_network" : "activity_source_dom"); + const toolIdentity = document.createElement("div"); + toolIdentity.className = "tool-identity"; + toolIdentity.append(name, source); const status = document.createElement("span"); status.className = "status"; status.textContent = getItemStatusText(item); - top.append(name, status); + top.append(toolIdentity, status); content.appendChild(top); if (item.purpose) {content.appendChild(createTextLine("purpose", item.purpose));} diff --git a/bridge-browser/src/content/tool_activity_overlay_styles.ts b/bridge-browser/src/content/tool_activity_overlay_styles.ts index ca0bc42..081075e 100644 --- a/bridge-browser/src/content/tool_activity_overlay_styles.ts +++ b/bridge-browser/src/content/tool_activity_overlay_styles.ts @@ -43,8 +43,12 @@ export const TOOL_ACTIVITY_STYLE_TEXT = ` .row.failed .status-dot, .row.rejected .status-dot { background: #ef4444; } .row-content { min-width: 0; flex: 1; } .row-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } - .tool-name { overflow: hidden; color: #f3f4f6; font: 600 12px/1.4 "SFMono-Regular", Consolas, monospace; + .tool-identity { min-width: 0; display: flex; align-items: center; gap: 6px; } + .tool-name { min-width: 0; overflow: hidden; color: #f3f4f6; font: 600 12px/1.4 "SFMono-Regular", Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; } + .source-badge { flex: 0 0 auto; padding: 0 5px; border: 1px solid #555d69; border-radius: 999px; + color: #d1d5db; background: rgba(107, 114, 128, .16); font-size: 9px; font-weight: 650; line-height: 16px; } + .source-badge.network { border-color: #315d9e; color: #93c5fd; background: rgba(37, 99, 235, .18); } .status { flex: 0 0 auto; color: #aeb5c2; font-size: 10px; } .purpose, .detail, .message { overflow: hidden; margin-top: 3px; text-overflow: ellipsis; white-space: nowrap; } .purpose { color: #c4c9d2; } diff --git a/bridge-browser/src/modules/i18n.ts b/bridge-browser/src/modules/i18n.ts index 98529d5..3fdc258 100644 --- a/bridge-browser/src/modules/i18n.ts +++ b/bridge-browser/src/modules/i18n.ts @@ -82,6 +82,8 @@ const I18N_MESSAGES: Record = { activity_hide_history: { en: "Hide history", zh: "隐藏历史" }, activity_no_history: { en: "No previous tool activity", zh: "暂无之前的工具活动" }, activity_drag: { en: "Drag tool activity window", zh: "拖动工具活动窗口" }, + activity_source_dom: { en: "DOM", zh: "DOM" }, + activity_source_network: { en: "Network", zh: "网络" }, hitl_title: { en: "Approval Required", zh: "请求执行工具" }, label_tool: { en: "Tool Name", zh: "工具名称" }, diff --git a/bridge-browser/test/dom_tool_activity.test.ts b/bridge-browser/test/dom_tool_activity.test.ts index a9eaf5f..8b487d4 100644 --- a/bridge-browser/test/dom_tool_activity.test.ts +++ b/bridge-browser/test/dom_tool_activity.test.ts @@ -41,6 +41,7 @@ function testGroupsMessageCalls(): void { const snapshot = harness.getSnapshot(); assertEqual(snapshot.turns.length, 1, "calls from one message were split into multiple turns"); assertEqual(snapshot.items.length, 2, "not all DOM calls were registered"); + assert(snapshot.items.every((item) => item.source === "dom"), "DOM calls did not retain their source"); assertEqual(snapshot.turns[0]?.requestKeys.join(","), "dom-key-1,dom-key-2", "DOM call order changed"); } diff --git a/bridge-browser/test/tool_activity.test.ts b/bridge-browser/test/tool_activity.test.ts index 32712de..40660be 100644 --- a/bridge-browser/test/tool_activity.test.ts +++ b/bridge-browser/test/tool_activity.test.ts @@ -24,6 +24,7 @@ function testRetainsLatestEightTurns(): void { tracker.capture({ identity: { requestKey: `request-${index}` }, payload: { name: `tool-${index}` }, + source: "dom", turnId, }); } diff --git a/bridge-browser/test/tool_activity_overlay.test.ts b/bridge-browser/test/tool_activity_overlay.test.ts index e8110cd..3821e96 100644 --- a/bridge-browser/test/tool_activity_overlay.test.ts +++ b/bridge-browser/test/tool_activity_overlay.test.ts @@ -1,4 +1,7 @@ -import { ToolActivityTracker } from "../src/content/tool_activity"; +import { + ToolActivityTracker, + type ToolActivitySource, +} from "../src/content/tool_activity"; type OverlayConstructor = new (tracker: ToolActivityTracker) => unknown; @@ -186,6 +189,9 @@ async function main(): Promise { runTest("a new turn stays current while the prior turn enters detailed history", () => { testNewTurnUpdatesHistory(ToolActivityOverlay); }); + runTest("current and historical tools show their capture source", () => { + testCaptureSourceBadges(ToolActivityOverlay); + }); runTest("dragging moves the activity stack without losing viewport access", () => { testUnifiedBoundedDragging(ToolActivityOverlay); }); @@ -257,6 +263,22 @@ function testUnifiedBoundedDragging(Overlay: OverlayConstructor): void { assert(resizedRect.top >= 8 && resizedRect.bottom <= 392, "resize left the stack outside vertical bounds"); } +function testCaptureSourceBadges(Overlay: OverlayConstructor): void { + const harness = createHarness(Overlay); + const domKey = captureTurn(harness.tracker, "turn-1", "read_file", "dom"); + settleTurn(harness.tracker, domKey); + captureTurn(harness.tracker, "turn-2", "write_file", "network"); + + const currentBadge = getRequired(harness.panel, ".source-badge"); + assertEqual(currentBadge.getText(), "Network", "current network source badge was missing"); + assert(currentBadge.className.includes("network"), "network source badge styling was missing"); + + getRequired(harness.panel, ".history-button").click(); + const historyBadge = getRequired(harness.stack, ".history-panel").querySelector(".source-badge"); + assert(historyBadge, "historical DOM source badge was missing"); + assertEqual(historyBadge.getText(), "DOM", "historical DOM source badge had the wrong label"); +} + function createHarness(Overlay: OverlayConstructor): { host: FakeElement; panel: FakeElement; @@ -276,11 +298,17 @@ function createHarness(Overlay: OverlayConstructor): { return { host, panel, stack, tracker }; } -function captureTurn(tracker: ToolActivityTracker, turnId: string, toolName: string): string { +function captureTurn( + tracker: ToolActivityTracker, + turnId: string, + toolName: string, + source: ToolActivitySource = "dom" +): string { const requestKey = `request:${turnId}`; tracker.capture({ identity: { requestKey }, payload: { name: toolName, purpose: `Run ${toolName}` }, + source, turnId, }); return requestKey;