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:
@@ -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 {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -664,7 +664,6 @@ export function preprocessMarkdown(text: string): string {
|
||||
return leading + transformed + trailing
|
||||
})
|
||||
.join('')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
25
apps/desktop/src/lib/markdown-whitespace.test.ts
Normal file
25
apps/desktop/src/lib/markdown-whitespace.test.ts
Normal 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', ['/tmp/image.png'])).toContain(prose)
|
||||
})
|
||||
})
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user