Repository navigation
fix(gemini): preserve linked text and images in html and pdf exports (#110) #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
c84bf44
4df1a9d
502637d
da3e10b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) => { | ||
|
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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Match the complete The 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\n\n`; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Preserve parentheses in image URLs. If Source: Learnings |
||
| } | ||
| return ''; | ||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||
| }); | ||
|
gitar-bot[bot] marked this conversation as resolved.
Outdated
sourcery-ai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| // 1. Protect fenced code blocks (``` ... ``` or ~~~ ... ~~~) | ||
| const codeBlockPlaceholders = []; | ||
| let codeBlockCounter = 0; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)) | ||
|
|
@@ -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 ``; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Comment on lines
+529
to
+553
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 Medium 6. Released extension misses image fixes 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. |
||
| } | ||
| return match; | ||
| }, | ||
| ); | ||
|
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})`); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Generated links are malformed When a video or source title contains Escape Markdown delimiters in link titles and URLs before generating the link syntax. Also at Prompt for AI agentsIn `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`. |
||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
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 | ||
|
|
@@ -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])) { | ||
|
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) && | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 Medium 2. Candidate citations vanish from exports 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. |
||
| 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); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| } catch { | ||
| // Ignore malformed activity trails | ||
|
|
@@ -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 | ||
|
|
@@ -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) => { | ||
|
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 }); | ||
| } | ||
| }); | ||
| }); | ||
|
sourcery-ai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
|
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 | ||
|
|
@@ -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); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.