fix(desktop): preserve Markdown whitespace through media extraction

Remove whole-document whitespace cleanup that erased hard breaks and fenced-code spacing across all five ingestion paths. Limit generated-image gap joining to the removed image itself. Keep soft newlines as soft breaks.

Investigated #97117 and the approaches in #97431 (@wooyongbin3-cpu) and #97175 (@Jackal991); both leave fenced-code whitespace and sibling ingestion paths exposed, so remove the destructive normalization instead.
This commit is contained in:
Teknium
2026-09-07 01:04:42 -07:00
parent 0ec65ee514
commit 3e1b7af21b
7 changed files with 33 additions and 22 deletions

View File

@@ -34,8 +34,6 @@ export function renderMediaTags(text: string): string {
(_match, lead: string, value: string, trailer: string) => `${lead}${mediaLink(value)}${trailer}`
)
.replace(MEDIA_TAG_RE, (_match, value: string) => mediaLink(value))
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
}
export function assistantTextPart(text: string, timestamp?: number): ChatMessagePart {

View File

@@ -105,13 +105,6 @@ function embeddedImageRemovalRange(text: string, dataStart: number, dataEnd: num
return { end, start }
}
function normalizeCleanedText(text: string): string {
return text
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function extractEmbeddedImages(text: string): EmbeddedImageExtraction {
if (!text || !text.includes(DATA_IMAGE_PREFIX)) {
return { cleanedText: text, images: [] }
@@ -150,7 +143,7 @@ export function extractEmbeddedImages(text: string): EmbeddedImageExtraction {
pieces.push(text.slice(appendCursor))
return { cleanedText: normalizeCleanedText(pieces.join('')), images }
return { cleanedText: pieces.join('').trim(), images }
}
export function embeddedImageUrls(text: string): string[] {

View File

@@ -82,17 +82,16 @@ export function stripGeneratedImageEchoes(text: string, sources: readonly string
return text
}
let next = text.replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, '').replace(/\[[^\]\n]*\]\(\s*#media:[^)\n]*\)/g, '')
// Join only the gap left by an image, never normalize unrelated Markdown or code.
let next = text
.replace(/([ \t]?)!\[[^\]\n]*\]\([^)\n]*\)([ \t]?)/g, (_match, before: string, after: string) => before || after)
.replace(/\[[^\]\n]*\]\(\s*#media:[^)\n]*\)/g, '')
for (const source of unique([...sources])) {
next = next.replace(new RegExp(String.raw`(^|[\s([{])<?${regexEscape(source)}>?(?=$|[\s)\]},.!?])`, 'g'), '$1')
}
return next
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]{2,}/g, ' ')
.trim()
return next.trim()
}
/** Strip generated-image echoes from text parts, dropping any part left empty.

View File

@@ -664,7 +664,6 @@ export function preprocessMarkdown(text: string): string {
return leading + transformed + trailing
})
.join('')
.replace(/[ \t]+\n/g, '\n')
}
/**

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { assistantTextPart, renderMediaTags } from './chat-messages/parts'
import { extractEmbeddedImages } from './embedded-images'
import { stripGeneratedImageEchoes } from './generated-images'
import { preprocessMarkdown } from './markdown-preprocess'
import { stripPreviewTargets } from './preview-targets'
describe('Markdown whitespace semantics', () => {
it('preserves hard and soft breaks and fenced whitespace through display preprocessing', () => {
for (const input of ['First line \nSecond line', 'Soft first\nSoft second', '```python\nvalue = 1 \nvalue = 2 \n```']) {
expect(preprocessMarkdown(input)).toBe(input)
}
})
it('preserves prose hard breaks when media and preview extraction runs', () => {
const prose = 'First line \nSecond line\n\n```python\nvalue = 1 \nvalue = 2 \n\n\nvalue = 3\n```'
const image = 'data:image/png;base64,' + 'A'.repeat(64)
expect(stripPreviewTargets(prose + '\n\n[Preview: x](#preview:test)')).toContain(prose)
expect(renderMediaTags(prose + '\n\nMEDIA: /tmp/image.png')).toContain(prose)
expect(assistantTextPart(prose)).toMatchObject({ type: 'text', text: prose })
expect(extractEmbeddedImages(prose + '\n\n' + image).cleanedText).toContain(prose)
expect(stripGeneratedImageEchoes(prose + '\n\n![result](/tmp/image.png)', ['/tmp/image.png'])).toContain(prose)
})
})

View File

@@ -1,11 +1,7 @@
const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?<href>#preview[:/][^)]+)\)/gi
export function stripPreviewTargets(text: string): string {
return text
.replace(PREVIEW_MARKDOWN_RE, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
return text.replace(PREVIEW_MARKDOWN_RE, '').trim()
}
export function extractPreviewTargets(text: string): string[] {

View File

@@ -41,6 +41,7 @@ The desktop app is organized as a chat-first window with a left sidebar for navi
The center of the app. You get:
- **Streaming responses** with live tool activity and structured tool-call summaries as the agent works.
- **Markdown line breaks** follow Markdown semantics: two trailing spaces create a hard line break; an ordinary newline stays a soft break. Media and preview extraction preserve these breaks and whitespace inside fenced code blocks.
- **The same conversation history** as every other Hermes surface — sessions started here resume in the CLI/TUI and vice versa.
- **Drag-and-drop files** anywhere in the chat area to attach them to your next message.
- **A right-hand preview rail** — render web pages, files, and tool outputs side by side while you keep chatting.