Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 14 additions & 0 deletions content/formatters/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,20 @@ export function markdownToHtml(mdText) {
return `\n\n<details class="thinking-block"><summary class="thinking-summary"><svg class="thinking-icon" viewBox="0 0 24 24" width="14" height="14"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm1 14.5h-2v-2h2zm0-4h-2V7h2z"/></svg> Thinking Process</summary><div class="thinking-content">\n\n${thinkContent.trim()}\n\n</div>\n</details>\n\n`;
});

// Fallback: normalize XML-style <Image alt="..." caption="..." src="..."/> tags into markdown images
mdText = mdText.replace(/<Image\s+([^>]*?)(?:\/>|>(?:<\/Image>)?)/gi, (match, attrsStr) => {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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);

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 the complete src attribute name.

The /src=/ search also matches data-src. For <Image data-src="https://example.com/preview.png" src="https://example.com/full.png"/>, the fallback renders the preview URL instead of src. Match an attribute-name boundary so the fallback selects only src. Based on learnings, attribute extraction must distinguish overlapping attribute names.

Source: Learnings

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

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

Preserve parentheses in image URLs.

If src is https://example.com/diagram(1).png, the generated Markdown reaches inlineParse, whose image pattern at Line 1270 stops at the first ). The rendered image then receives the truncated URL https://example.com/diagram(1, and .png) appears as text. Encode Markdown-significant URL characters before emitting the image, or pass the image to the renderer without a Markdown round trip. Based on learnings, generated Markdown destinations with parentheses need protection.

Source: Learnings

}
return '';
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
});
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Outdated
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated

// 1. Protect fenced code blocks (``` ... ``` or ~~~ ... ~~~)
const codeBlockPlaceholders = [];
let codeBlockCounter = 0;
Expand Down
231 changes: 218 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 @@
});
}

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,86 @@
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);

return text.replace(
/<Image\s+([^>]*?)(?:\/>|>(?:<\/Image>)?)/gi,
(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 alt = altMatch
? altMatch[1]
: capMatch
? capMatch[1]
: "Image";

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;
},
);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}

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 title = videoDetail[0] || "Video";
const url = videoDetail[2];
if (url && typeof url === "string" && url.startsWith("http")) {
resolved = resolved.split(tagUrl).join(`[${title}](${url})`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Generated links are malformed

When a video or source title contains ], or a URL contains an unescaped ), resolveMediaPlaceholders interpolates titles and URLs into Markdown link syntax without escaping delimiters, so inlineParse fails to recognize the link and the export shows raw Markdown instead of a clickable link.

Escape Markdown delimiters in link titles and URLs before generating the link syntax.

Also at packages/decant-core/ai/gemini.js:1130.

Prompt for AI agents
In `packages/decant-core/ai/gemini.js` at line 559:

**Generated links are malformed**

When a video or source title contains `]`, or a URL contains an unescaped `)`, `resolveMediaPlaceholders` interpolates titles and URLs into Markdown link syntax without escaping delimiters, so `inlineParse` fails to recognize the link and the export shows raw Markdown instead of a clickable link.

Escape Markdown delimiters in link titles and URLs before generating the link syntax.

Also at `packages/decant-core/ai/gemini.js:1130`.

}
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 +717,44 @@
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])
const candidates = this.getApiCandidates(item);
for (const cand of candidates) {
if (Array.isArray(cand[2])) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
for (const citeGroup of cand[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) &&

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

2. Candidate citations vanish from exports 🐞 Bug ≡ Correctness

extractActivitySources iterates through the elements of each candidate citation tuple and then
looks for a URL at cite[2], rather than reading the tuple's URL-bearing field. For citations with
the shape present in the API fixture, no candidate source is appended unless it also appears in the
separate activity trail.

Dismiss ↗ | View ↗

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);
}
}
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
} catch {
// Ignore malformed activity trails
Expand Down Expand Up @@ -733,6 +847,33 @@
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 +1046,61 @@
if (markdownDiv) {
const clone = markdownDiv.cloneNode(true);

// Extract sources from .sources-list before removal
const domSources = [];
clone.querySelectorAll(".sources-list").forEach((sl) => {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
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.
Outdated

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 +1112,26 @@
)
.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