Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 16 additions & 34 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"test:ci": "npm run test:ci:web && npm run test:ci:android"
},
"devDependencies": {
"@playwright/test": "^1.54.0",
"@playwright/test": "^1.64.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
Expand All @@ -54,4 +54,4 @@
"xlsx": "^0.18.5",
"youtube-transcript": "^1.3.0"
}
}
}
22 changes: 22 additions & 0 deletions src/content/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,28 @@ export function setupBridgeEvents() {
};
window.addEventListener("bds:assistant-message-id", handlers["bds:assistant-message-id"]);

// The completion stream hands over the reply text before the renderer touches
// it. Keep the freshest copy per session so tag attributes can be parsed from
// the bytes the model actually wrote, not from the lossy DOM.
handlers["bds:raw-reply"] = (event) => {
let detail = event.detail;
if (typeof detail === "string") {
try { detail = JSON.parse(detail); } catch (e) { return; }
}
const rawText = detail?.rawText;
const sessionId = detail?.sessionId;
if (typeof rawText !== "string" || !rawText || !sessionId) return;

state.rawRepliesBySession.set(String(sessionId), rawText);
// Only the session on screen is ever consulted; keep the map from growing.
while (state.rawRepliesBySession.size > 5) {
const oldest = state.rawRepliesBySession.keys().next().value;
state.rawRepliesBySession.delete(oldest);
}
devLog("Parser", `stream supplied verbatim reply text for ${sessionId}`);
};
window.addEventListener("bds:raw-reply", handlers["bds:raw-reply"]);

// The MAIN world could not fetch the history at all (no token, http error,
// network failure). Surface it where the voice diagnostics can see it.
handlers["bds:history-msgs-error"] = (event) => {
Expand Down
63 changes: 63 additions & 0 deletions src/content/dom/message-text-katex-payload.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// @vitest-environment jsdom

import { describe, expect, it } from "vitest";
import { extractMessageMarkdown } from "./message-text.js";

/**
* A BDS tag attribute that merely contains two dollar characters is rendered
* by the page as inline math, so the DOM path sees a KaTeX subtree instead of
* the payload. The extractor used to return an empty string for a KaTeX
* subtree with no LaTeX annotation, which deleted the payload from the
* extracted text.
*/

function messageNode(build) {
const node = document.createElement("div");
const md = document.createElement("div");
md.className = "ds-markdown";
build(md);
node.appendChild(md);
return node;
}

function katexSubtree(tex) {
const span = document.createElement("span");
span.className = "katex";
const math = document.createElement("math");
const annotation = document.createElement("annotation");
annotation.setAttribute("encoding", "application/x-tex");
annotation.textContent = tex;
math.appendChild(annotation);
span.appendChild(math);
return span;
}

describe("KaTeX extraction never deletes payload content", () => {
it("recovers the tex source from the annotation", () => {
const text = extractMessageMarkdown(
messageNode((md) => {
md.appendChild(document.createTextNode("X"));
md.appendChild(katexSubtree("Y"));
md.appendChild(document.createTextNode("Z"));
}),
);

expect(text).toContain("Y");
expect(text).toContain("X");
expect(text).toContain("Z");
});

it("never collapses a bare KaTeX span to nothing", () => {
const text = extractMessageMarkdown(
messageNode(function (md) {
const span = document.createElement("span");
span.className = "katex";
span.textContent = "survives";
md.appendChild(span);
}),
);

expect(text.trim()).not.toBe("");
expect(text).toContain("survives");
});
});
12 changes: 9 additions & 3 deletions src/content/dom/message-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,16 @@ function katexToMarkdown(el) {
return el.classList.contains("katex-display") ? `\n$$${tex}$$\n` : `$${tex}$`;
}

// No LaTeX source to recover: emit the MathML token text as plain text rather
// than wrapping a garbled formula in math delimiters.
// No LaTeX source to recover. Emitting "" here deleted the whole subtree from
// the extracted text, so a BDS tag attribute that merely *contained* two `$`
// characters lost everything between them with no error anywhere (the KaTeX
// rendering of an unparseable formula is a red error span, not a formula, and
// the payload was never meant to be math). Never return empty: fall back to
// the MathML token text, then to the element text, so the payload at least
// survives as text instead of vanishing.
const math = el.querySelector("math");
return math ? String(math.textContent || "").trim() : "";
if (math) return String(math.textContent || "").trim();
return String(el.textContent || "").trim();
}

/** Marker kept so a diagram-only message is not dropped as empty content. */
Expand Down
38 changes: 36 additions & 2 deletions src/content/message-processor.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -747,8 +747,20 @@ export function processMessageNode(node, nodeIndex = -1, nodes = null, context =
}, Math.max(0, SETTLE_STALL_MS - timeSinceUpdate) + SETTLE_RECHECK_MARGIN_MS);
}

// The completion stream can hand over the reply text verbatim, which is the
// only copy that still contains escapes the Markdown renderer ate (`\/` ->
// `/`). It arrives after the stream ends, so it must take part in the hash or
// the settled message would never be re-parsed with the better source.
const sessionId = getCurrentConversationIdInline();
const streamedRawText = isLatestAssistant && sessionId
? state.rawRepliesBySession?.get(sessionId) || null
: null;
const parseSource = streamedRawText || rawText;

// Include settlement state in hash so transition to 'finished' triggers a final re-parse
const signature = simpleHash(rawText + (isSettled ? ":settled" : ":streaming"));
const signature = simpleHash(
parseSource + (isSettled ? ":settled" : ":streaming") + (streamedRawText ? ":verbatim" : ""),
);
const shouldForceCloseTags = isSettled && isStalled;

if (stateData.hash === signature && stateData.forceClosedTags === shouldForceCloseTags) {
Expand All @@ -761,7 +773,29 @@ export function processMessageNode(node, nodeIndex = -1, nodes = null, context =
stateData.hash = signature;
stateData.forceClosedTags = shouldForceCloseTags;

const parsed = parseBdsMessage(richText, shouldForceCloseTags);
// Parse twice, by intent:
//
// - TAGS AND ATTRIBUTES come from rawText. Attribute values are
// opaque payloads, but richText represents any `$tex$ $ span as the
// page's KaTeX DOM subtree, so a `$s='hi'; $s` payload arrives as HTML
// and corrupts the arguments. rawText collapses KaTeX back to
// `$tex$` (see katexToMarkdown), restoring the delimiters.
//
// - VISIBLE TEXT comes from richText. The overlay renders it with
// `marked.parse()`, which has no KaTeX plugin — it only passes the
// renderer's own `<span class="katex">` markup through. Feeding the
// overlay rawText would show literal `$tex$` instead of the formula.
//
// This fixes the `$...$` deletion bug on its own, and — when the completion
// stream handed over a verbatim copy — `\/` -> `/` as well. Bare URL ->
// autolink and `&` -> `&amp;` are still rewritten by the Markdown renderer
// before the DOM exists; they are covered by link-artifacts and HTML entity
// decoding respectively.
const parsed = parseBdsMessage(parseSource, shouldForceCloseTags);
if (parsed.visibleText !== undefined) {
// Keep the rendered KaTeX markup for the overlay only.
parsed.visibleText = parseBdsMessage(richText, shouldForceCloseTags).visibleText;
}
const preGateBlocks = parsed.renderableBlocks;

// --- BIDI DIRECTION (issue #181) ---
Expand Down
2 changes: 2 additions & 0 deletions src/content/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const state = {
chatMessagesBySession: new Map(),
/** @type {Map<string, string>} id of the reply the completion stream just produced, keyed by session ID — the same id DeepSeek's own read-aloud button uses */
assistantMessageIds: new Map(),
/** @type {Map<string, string>} verbatim reply text from the completion stream, keyed by session ID. The rendered DOM has already lost escapes like `\\/`, so BDS tag attributes must be parsed from this copy when it is available. */
rawRepliesBySession: new Map(),
/** Token price tracking — session-level totals */
pricing: {
/** @type {string|null} current model being used */
Expand Down
20 changes: 20 additions & 0 deletions src/injected/fetch-patch.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { mutatePayload } from "./payload-mutator.js";
import {
currentSessionIdFromUrl,
emitAssistantMessageId,
findMessageId,
findMessageIdInSse,
isReplyStreamUrl,
} from "./message-id.js";
import { extractReplyTextFromSse } from "./reply-text.js";

/**
* Patch window.fetch to intercept chat completion requests.
Expand Down Expand Up @@ -285,6 +287,24 @@ async function parseSSEUsage(response, modelName, captureMessageId) {
if (!captureMessageId) return;
const messageId = findMessageIdInSse(buffer);
if (messageId) emitAssistantMessageId(messageId);

// Hand the untouched reply text to the content script. The DOM this reply
// becomes is lossy — CommonMark has already eaten escapes like `\\/` by the
// time it renders — so tag attributes must be parsed from this copy.
const { content } = extractReplyTextFromSse(buffer);
if (content) emitRawReply(content, messageId);
}

/** Tell the content script which reply text this stream produced, verbatim. */
function emitRawReply(rawText, messageId) {
window.dispatchEvent(new CustomEvent("bds:raw-reply", {
detail: JSON.stringify({
messageId: messageId || null,
sessionId: currentSessionIdFromUrl(),
rawText,
timestamp: Date.now(),
}),
}));
}

function emitTokenUsage(inputTokens, outputTokens, modelName) {
Expand Down
Loading