Skip to content
Merged
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
16 changes: 16 additions & 0 deletions content/formatters/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,22 @@ export function markdownToHtml(mdText) {
return id;
});

// Fallback: normalize XML-style <Image alt="..." caption="..." src="..."/> tags into markdown images
// (Runs after code blocks are protected so code samples are not modified)
mdText = mdText.replace(/<Image\s+([^>]*?)(?:\/>|>(?:\s*<\/Image>)?)/gi, (match, attrsStr) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Match Image tags with no attributes.

For <Image/> or <Image></Image>, \s+ prevents the regex from matching. The raw tag then remains in the export instead of being removed when it has no src. Allow zero attributes while keeping a tag-name boundary.

The supplied change details state that tags without an allowed src are removed.

Proposed fix
--- "a/content/formatters/html.js"
+++ "b/content/formatters/html.js"
@@ -1421,7 +1421,7 @@
 
   // Fallback: normalize XML-style <Image alt="..." caption="..." src="..."/> tags into markdown images
   // (Runs after code blocks are protected so code samples are not modified)
-  mdText = mdText.replace(/<Image\s+([^>]*?)(?:\/>|>(?:\s*<\/Image>)?)/gi, (match, attrsStr) => {
+  mdText = mdText.replace(/<Image(?=\s|\/?>)\s*([^>]*?)(?:\/>|>(?:\s*<\/Image>)?)/gi, (match, attrsStr) => {
     const altMatch = attrsStr.match(/alt="([^"]*)"/i) || attrsStr.match(/alt='([^']*)'/i);
     const captionMatch =
       attrsStr.match(/caption="([^"]*)"/i) || attrsStr.match(/caption='([^']*)'/i);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mdText = mdText.replace(/<Image\s+([^>]*?)(?:\/>|>(?:\s*<\/Image>)?)/gi, (match, attrsStr) => {
mdText = mdText.replace(/<Image(?=\s|\/?>)\s*([^>]*?)(?:\/>|>(?:\s*<\/Image>)?)/gi, (match, attrsStr) => {

const altMatch = attrsStr.match(/alt="([^"]*)"/i) || attrsStr.match(/alt='([^']*)'/i);
const captionMatch =
attrsStr.match(/caption="([^"]*)"/i) || attrsStr.match(/caption='([^']*)'/i);
const srcMatch = attrsStr.match(/src="([^"]*)"/i) || attrsStr.match(/src='([^']*)'/i);
const src = srcMatch ? srcMatch[1] : '';
const rawAlt = altMatch ? altMatch[1] : captionMatch ? captionMatch[1] : 'Image';
const alt = rawAlt.replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]');
if (src && /^(?:https?:|data:image|blob:|\/|\.\/)/i.test(src)) {
return `\n\n![${alt}](${src})\n\n`;
}
return '';
});

// 3. Convert multi-line or bracket display math \[ ... \] or \\[ ... \\] to $$ ... $$
mdText = mdText.replace(/(?:\\{1,2}\[)([\s\S]+?)(?:\\{1,2}\])(?!\()/g, (match, math) => {
return `\n\n$$${math}$$\n\n`;
Expand Down
265 changes: 252 additions & 13 deletions packages/decant-core/ai/gemini.js
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,12 @@ export class GeminiParser extends ChatParser {
});
}

const modelText = this.findModelTextInApiItem(item, options);
const modelCand = this.findModelCandidateInApiItem(item);
let modelText = this.findModelTextInApiItem(item, options);
if (modelCand) {
modelText = this.resolveImageTags(modelText, modelCand);
modelText = this.resolveMediaPlaceholders(modelText, modelCand);
}
const researchExtras = this.extractDeepResearchExtras(item);
const combined = [modelText, researchExtras]
.map((t) => this.stripChipPlaceholders(t))
Expand All @@ -478,6 +483,117 @@ export class GeminiParser extends ChatParser {
return messages;
}

buildImageTagMap(cand) {
const map = new Map();
if (!cand || !Array.isArray(cand[12])) return map;
const imageEntries = cand[12][1];
if (!Array.isArray(imageEntries)) return map;

for (const entry of imageEntries) {
if (!Array.isArray(entry)) continue;
const url = entry[0]?.[0]?.[0] || entry[3]?.[0]?.[0];
if (!url || typeof url !== "string" || !url.startsWith("http")) continue;

const tagInfo = entry[7];
if (Array.isArray(tagInfo) && typeof tagInfo[0] === "string") {
const fullTag = tagInfo[0];
map.set(fullTag, url);
const tagIdMatch = fullTag.match(
/(?:image_agent_tag_\w+|lmdx_image\/\w+)/,
);
if (tagIdMatch) {
map.set(tagIdMatch[0], url);
}
}
}
return map;
}

resolveImageTags(text, cand) {
if (typeof text !== "string" || !text) return text;
const imageMap = this.buildImageTagMap(cand);

// Protect fenced and inline code blocks before tag replacement
const codePlaceholders = [];
let counter = 0;
const protectedText = text.replace(
/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]+?`)/g,
(match) => {
const id = `@@ACE_CODE_${counter++}@@`;
codePlaceholders.push({ id, content: match });
return id;
},
);

let replaced = protectedText.replace(
/<Image\s+([^>]*?)(?:\/>|>(?:\s*<\/Image>)?)/gi,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Medium

4. Image labels break at greater-than signs 🐞 Bug ≡ Correctness

resolveImageTags stops its attribute capture at the first > instead of respecting quoted
attribute values. When Gemini returns an alt or caption such as alt="a > b", the parser falls back
to the default label and leaves the remainder of the tag in the response, while the HTML fallback
has the same failure mode.

Dismiss ↗ | View ↗

(match, attrs) => {
const altMatch =
attrs.match(/alt="([^"]*)"/i) || attrs.match(/alt='([^']*)'/i);
const capMatch =
attrs.match(/caption="([^"]*)"/i) ||
attrs.match(/caption='([^']*)'/i);
const srcMatch =
attrs.match(/src="([^"]*)"/i) || attrs.match(/src='([^']*)'/i);

const src = srcMatch ? srcMatch[1] : "";
const rawAlt = altMatch
? altMatch[1]
: capMatch
? capMatch[1]
: "Image";
const alt = rawAlt
.replace(/\\/g, "\\\\")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]");

const resolvedUrl =
imageMap.get(src) || (src.startsWith("http") ? src : null);
if (resolvedUrl) {
return `![${alt}](${resolvedUrl})`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +529 to +553

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Medium

6. Released extension misses image fixes 🔗 Cross-repo conflict ☼ Reliability

The new resolveImageTags behavior exists only in the workspace parser, while the standalone
decant package still has the old candidate-processing path. When a release build runs before
decant-core is synchronized and published, the extension installs the old package and ships
without the Gemini image-resolution changes.

Dismiss ↗ | View ↗

}
return match;
},
);

for (let i = codePlaceholders.length - 1; i >= 0; i--) {
const { id, content } = codePlaceholders[i];
replaced = replaced.replace(id, () => content);
}
return replaced;
}

resolveMediaPlaceholders(text, cand) {
if (typeof text !== "string" || !text) return text;
if (!cand || !Array.isArray(cand[12])) return text;
const mediaEntries = cand[12][4];
if (!Array.isArray(mediaEntries)) return text;

let resolved = text;
for (const entry of mediaEntries) {
if (!Array.isArray(entry)) continue;
const tagUrl = entry[0]?.[0];
const videoDetail = entry[4]?.[0]?.[0];
if (typeof tagUrl === "string" && Array.isArray(videoDetail)) {
const rawTitle = videoDetail[0] || "Video";
const title = rawTitle
.replace(/\\/g, "\\\\")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]");
const url = videoDetail[2];
if (url && typeof url === "string" && url.startsWith("http")) {
const escaped = tagUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const replacement = `[${title}](${url})`;
resolved = resolved.replace(
new RegExp(`${escaped}(?![\\w/])`, "g"),
() => replacement,
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
return resolved;
}

// Deep-research turns render a short summary plus placeholder chip links
// (e.g. http://googleusercontent.com/immersive_entry_chip/0) whose real
// content — research plan, full report, citation map — lives in adjacent
Expand Down Expand Up @@ -632,15 +748,42 @@ export class GeminiParser extends ChatParser {
const seen = new Map();
try {
const trail = item[3]?.[4];
if (!Array.isArray(trail)) return [];
for (const entry of trail) {
const detail = entry?.[4]?.[2];
const url = Array.isArray(detail) ? detail[1] : null;
if (typeof url !== "string" || !url.startsWith("http")) continue;
if (seen.has(url)) continue;
const title =
typeof detail[2] === "string" && detail[2] ? detail[2] : url;
seen.set(url, title);
if (Array.isArray(trail)) {
for (const entry of trail) {
const detail = entry?.[4]?.[2];
const url = Array.isArray(detail) ? detail[1] : null;
if (typeof url !== "string" || !url.startsWith("http")) continue;
if (seen.has(url)) continue;
const title =
typeof detail[2] === "string" && detail[2] ? detail[2] : url;
seen.set(url, title);
}
}

// Also check candidate citations (cand[2]) of the active candidate only
const visibleCand = this.findModelCandidateInApiItem(item);
if (visibleCand && Array.isArray(visibleCand[2])) {
for (const citeGroup of visibleCand[2]) {
if (!Array.isArray(citeGroup)) continue;
for (const cite of citeGroup) {
if (!Array.isArray(cite)) continue;
const meta = cite[2];
const url =
Array.isArray(meta) &&
typeof meta[0] === "string" &&
meta[0].startsWith("http")
? meta[0]
: null;
if (url && !seen.has(url)) {
try {
const parsed = new URL(url);
seen.set(url, parsed.hostname.replace(/^www\./, "") || url);
} catch {
seen.set(url, url);
}
}
}
}
}
} catch {
// Ignore malformed activity trails
Expand Down Expand Up @@ -733,6 +876,33 @@ export class GeminiParser extends ChatParser {
return "";
}

findModelCandidateInApiItem(item) {
try {
if (Array.isArray(item[3])) {
const candidates = Array.isArray(item[3][0]) ? item[3][0] : item[3];
for (const cand of candidates) {
if (!Array.isArray(cand)) continue;
if (Array.isArray(cand[1]) && typeof cand[1][0] === "string") {
return cand;
}
if (typeof cand[1] === "string") {
return cand;
}
if (typeof cand[0] === "string" && cand[0].length > 50) {
return cand;
}
}
}
if (Array.isArray(item[1])) {
const candidate = item[1][0];
if (Array.isArray(candidate)) return candidate;
}
} catch {
// Fall through
}
return null;
}

extractTitleFromPage() {
if (typeof document !== "undefined" && document.title) {
const cleanedDocTitle = document.title
Expand Down Expand Up @@ -905,6 +1075,66 @@ export class GeminiParser extends ChatParser {
if (markdownDiv) {
const clone = markdownDiv.cloneNode(true);

// Extract sources from .sources-list before removal.
// Check both modelResponse and clone so sources in footers outside markdownDiv are not missed.
const domSources = [];
const sourceLists = [
...modelResponse.querySelectorAll(".sources-list"),
...clone.querySelectorAll(".sources-list"),
];
sourceLists.forEach((sl) => {
sl.querySelectorAll("a[href]").forEach((a) => {
const href = a.getAttribute("href")?.trim();
const title = a.textContent?.trim() || href;
if (
href &&
href.startsWith("http") &&
!domSources.some((s) => s.href === href)
) {
domSources.push({ href, title });
}
});
});

Comment thread
sourcery-ai[bot] marked this conversation as resolved.
// Convert citation chips and source buttons to links or text before generic button removal
clone
.querySelectorAll(
'button[aria-label*="View source"], button:has([class*="citation-chip"]), [class*="citation-chip"], citation-chip',
)
.forEach((btn) => {
const titleEl = btn.querySelector(
'[class*="citation-chip__title"], [class*="citation_title"]',
);
const ariaLabel = btn.getAttribute("aria-label") || "";
const label = titleEl
? titleEl.textContent.trim()
: ariaLabel.replace(/^View source:\s*/i, "").trim();
const anchor =
btn.querySelector("a[href]") || btn.closest("a[href]");
const href = anchor ? anchor.getAttribute("href") : null;

if (href && href.startsWith("http")) {
const link = clone.ownerDocument.createElement("a");
link.setAttribute("href", href);
link.textContent = label || href;
btn.replaceWith(link);
} else if (label) {
const textNode =
clone.ownerDocument.createTextNode(` [${label}]`);
btn.replaceWith(textNode);
} else {
btn.remove();
}
});

// Unwrap any <a> tags inside remaining buttons so links are not deleted
clone.querySelectorAll("button a[href]").forEach((a) => {
const btn = a.closest("button");
if (btn && btn.parentNode) {
btn.parentNode.insertBefore(a, btn);
}
});

// Remove UI buttons, thought overlays, follow-up suggestion
// widgets, and interactive toolbars.
// Note: .hide-from-message-actions is NOT removed — it wraps
Expand All @@ -916,17 +1146,26 @@ export class GeminiParser extends ChatParser {
)
.forEach((el) => el.remove());

// Unwrap response-element wrappers and message-action guards
// Unwrap response-element wrappers, image containers, and message-action guards
clone
.querySelectorAll("response-element, .hide-from-message-actions")
.querySelectorAll(
"response-element, image-container, picture, .hide-from-message-actions",
)
.forEach((el) => {
while (el.firstChild) {
el.parentNode.insertBefore(el.firstChild, el);
}
el.remove();
});

const text = convertToMarkdown(clone);
let text = convertToMarkdown(clone);
if (domSources.length > 0) {
const sourcesText = domSources
.map((s) => `- [${s.title}](${s.href})`)
.join("\n");
text += `\n\n**Sources consulted:**\n${sourcesText}`;
}

const trimmed = text.trim();
if (trimmed && !seenTexts.has(trimmed)) {
seenTexts.add(trimmed);
Expand Down
Loading
Loading