diff --git a/electron/api/routes/files.ts b/electron/api/routes/files.ts index ac20104..b4a9ed7 100644 --- a/electron/api/routes/files.ts +++ b/electron/api/routes/files.ts @@ -58,6 +58,12 @@ const DIRECTORY_MIME_TYPE = 'application/x-directory'; async function generateImagePreview(filePath: string, mimeType: string): Promise { try { + const { readFile } = await import('node:fs/promises'); + if (mimeType === 'image/svg+xml') { + const buf = await readFile(filePath); + return `data:${mimeType};base64,${buf.toString('base64')}`; + } + const img = nativeImage.createFromPath(filePath); if (img.isEmpty()) return null; const size = img.getSize(); @@ -68,7 +74,6 @@ async function generateImagePreview(filePath: string, mimeType: string): Promise : img.resize({ height: maxDim }); return `data:image/png;base64,${resized.toPNG().toString('base64')}`; } - const { readFile } = await import('node:fs/promises'); const buf = await readFile(filePath); return `data:${mimeType};base64,${buf.toString('base64')}`; } catch { diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 0917b9c..4532015 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -2401,6 +2401,12 @@ const DIRECTORY_MIME_TYPE = 'application/x-directory'; */ async function generateImagePreview(filePath: string, mimeType: string): Promise { try { + const { readFile: readFileAsync } = await import('fs/promises'); + if (mimeType === 'image/svg+xml') { + const buf = await readFileAsync(filePath); + return `data:${mimeType};base64,${buf.toString('base64')}`; + } + const img = nativeImage.createFromPath(filePath); if (img.isEmpty()) return null; const size = img.getSize(); @@ -2413,7 +2419,6 @@ async function generateImagePreview(filePath: string, mimeType: string): Promise return `data:image/png;base64,${resized.toPNG().toString('base64')}`; } // Small image — use original (async read to avoid blocking) - const { readFile: readFileAsync } = await import('fs/promises'); const buf = await readFileAsync(filePath); return `data:${mimeType};base64,${buf.toString('base64')}`; } catch { diff --git a/src/pages/Chat/message-utils.ts b/src/pages/Chat/message-utils.ts index e736a7b..e79a166 100644 --- a/src/pages/Chat/message-utils.ts +++ b/src/pages/Chat/message-utils.ts @@ -61,7 +61,7 @@ function cleanUserText(text: string): string { } /** - * Strip `MEDIA:/path/to/file.ext` artifact markers from assistant text so + * Strip `MEDIA:/path/to/file.ext` / `MEDIA:C:\path\file.ext` artifact markers from assistant text so * the chat bubble doesn't duplicate the file already surfaced as a card. * * Mirrors the regex in `chat/helpers.ts::extractRawFilePaths` (tagged @@ -77,10 +77,10 @@ function stripAssistantMediaTags(text: string): string { // are also stripped from the visible bubble. Without this, the bubble // would still leak the literal `MEDIA:/.../截屏 2026-05-06 17.46.51.png` // to the user when the underlying path detection succeeds. - const tagged = new RegExp(`(^|[\\s(\\[{>])(?:MEDIA|media):(?:\\/|~\\/)[^\\n"'()\\[\\],<>]*?\\.(?:${exts})(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g'); + const tagged = new RegExp(`(^|[\\s(\\[{>])(?:MEDIA|media):(?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>]*?\\.(?:${exts})(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g'); // Bare OpenClaw artifact paths emitted alongside `_attachedFiles` cards. // Scope to `.openclaw/media/` so normal absolute paths in prose stay visible. - const bareOpenClawMedia = new RegExp(`(^|[\\s(\\[{>])(?:(?:\\/|~\\/)[^\\n"'()\\[\\],<>]*?\\.openclaw\\/media\\/[^\\n"'()\\[\\],<>]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g'); + const bareOpenClawMedia = new RegExp(`(^|[\\s(\\[{>])(?:(?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>]*?\\.openclaw[\\\\/]media[\\\\/][^\\n"'()\\[\\],<>]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g'); return text .replace(tagged, (_, lead: string) => lead) .replace(bareOpenClawMedia, (_, lead: string) => lead) diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 368251d..8be6eef 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -927,7 +927,7 @@ function trimPathTerminators(filePath: string): string { * * Also recognises the `MEDIA:` / `media:` prefix the OpenClaw runtime * emits for produced artifacts (e.g. - * `MEDIA:/tmp/desktop_screenshot.png`) — without this the leading colon + * `MEDIA:/tmp/desktop_screenshot.png`, `MEDIA:C:\Users\me\out.svg`) — without this the leading colon * trips the URL guard on the unix regex below and the artifact never * surfaces as an attachment. Mirrors `chat/helpers.ts::extractRawFilePaths`. */ @@ -935,7 +935,7 @@ function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType: const refs: Array<{ filePath: string; mimeType: string }> = []; const seen = new Set(); const exts = 'png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|txt|csv|md|rtf|epub|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v'; - // Tagged media references (MEDIA:/path, media:~/path, ...). The agent + // Tagged media references (MEDIA:/path, media:~/path, MEDIA:C:\path, ...). The agent // runtime uses this prefix as an explicit "this is an artifact" marker, // so we want them recognised even though the leading colon would // normally look like a URL scheme. After matching we punch the entire @@ -946,7 +946,7 @@ function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType: // and other space-containing paths the agent emits with the explicit // `MEDIA:` marker still resolve. Newline and quote characters remain // path terminators so we don't accidentally swallow trailing prose. - const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g'); + const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g'); let workingText = text; let taggedMatch: RegExpExecArray | null; while ((taggedMatch = taggedRegex.exec(text)) !== null) { diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts index f6d5fce..a8d6bea 100644 --- a/src/stores/chat/helpers.ts +++ b/src/stores/chat/helpers.ts @@ -638,7 +638,7 @@ function extractMarkdownImageRefs(text: string): MarkdownImageRef[] { continue; } const normalizedPath = trimPathTerminators(target); - if (!normalizedPath.startsWith('/') && !normalizedPath.startsWith('~/')) continue; + if (!normalizedPath.startsWith('/') && !normalizedPath.startsWith('~/') && !/^[A-Za-z]:\\/.test(normalizedPath)) continue; if (seen.has(normalizedPath)) continue; seen.add(normalizedPath); refs.push({ @@ -664,7 +664,7 @@ function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType: const refs: Array<{ filePath: string; mimeType: string }> = []; const seen = new Set(); const exts = 'png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|html?|txt|csv|md|rtf|epub|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v'; - // Tagged media references (MEDIA:/path, media:~/path, ...). The agent + // Tagged media references (MEDIA:/path, media:~/path, MEDIA:C:\path, ...). The agent // runtime uses this prefix as an explicit "this is an artifact" marker, // so we want them recognised even though the leading colon would // normally look like a URL scheme. After matching we punch the entire @@ -677,7 +677,7 @@ function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType: // path terminators so we don't accidentally swallow trailing prose. // The non-greedy `*?` anchored to `\.` keeps the match minimal so // multiple `MEDIA:` markers in one paragraph still match independently. - const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g'); + const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g'); let workingText = text; let taggedMatch: RegExpExecArray | null; while ((taggedMatch = taggedRegex.exec(text)) !== null) { diff --git a/tests/e2e/chat-run-state-events.spec.ts b/tests/e2e/chat-run-state-events.spec.ts index ae5349d..bab1be2 100644 --- a/tests/e2e/chat-run-state-events.spec.ts +++ b/tests/e2e/chat-run-state-events.spec.ts @@ -172,4 +172,79 @@ test.describe('ClawX chat run state events', () => { await closeElectronApp(app); } }); + + test('hydrates Windows MEDIA SVG artifacts without leaking the marker text', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + const filePath = String.raw`C:\Users\Administrator\.openclaw\workspace\japan-kansai-4d3n-plan.svg`; + const svgPreview = `data:image/svg+xml;base64,${Buffer.from('').toString('base64')}`; + const history = [ + { + role: 'assistant', + id: 'windows-svg-artifact', + timestamp: Date.now() / 1000, + content: String.raw`SVG file is ready: + +MEDIA:C:\Users\Administrator\.openclaw\workspace\japan-kansai-4d3n-plan.svg`, + }, + ]; + + try { + await installIpcMocks(app, { + gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true }, + gatewayRpc: { + [stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: { + success: true, + result: { + sessions: [{ key: MAIN_SESSION_KEY, displayName: 'main' }], + }, + }, + [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: { + success: true, + result: { messages: history }, + }, + }, + hostApi: { + [stableStringify(['/api/gateway/status', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true }, + }, + }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { success: true, agents: [{ id: 'main', name: 'Main' }] }, + }, + }, + [stableStringify(['/api/files/thumbnails', 'POST'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { [filePath]: { preview: svgPreview, fileSize: 73 } }, + }, + }, + }, + }); + + const page = await getStableWindow(app); + try { + await page.reload(); + } catch (error) { + if (!String(error).includes('ERR_FILE_NOT_FOUND')) { + throw error; + } + } + + await expect(page.getByText('SVG file is ready:')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('MEDIA:C:')).toHaveCount(0); + await expect(page.locator('img[alt="japan-kansai-4d3n-plan.svg"]')).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/tests/unit/chat-assistant-media-display.test.ts b/tests/unit/chat-assistant-media-display.test.ts index c1c7230..1f7a9d6 100644 --- a/tests/unit/chat-assistant-media-display.test.ts +++ b/tests/unit/chat-assistant-media-display.test.ts @@ -17,6 +17,21 @@ describe('assistant media path display cleanup', () => { expect(extractText({ role: 'assistant', content: text })).toBe('Done:'); }); + it('strips MEDIA: tagged Windows artifact paths', () => { + const text = String.raw`SVG file is ready: + +MEDIA:C:\Users\Administrator\.openclaw\workspace\japan-kansai-4d3n-plan.svg`; + + expect(extractText({ role: 'assistant', content: text })).toBe('SVG file is ready:'); + }); + + it('strips bare Windows OpenClaw media paths when surfaced as attachment cards', () => { + const text = String.raw`Done: +C:\Users\alice\.openclaw\media\outbound\cat---abc.png`; + + expect(extractText({ role: 'assistant', content: text })).toBe('Done:'); + }); + it('strips markdown image syntax that cannot be rendered directly', () => { const text = '宇航员图片完成啦 🧑‍🚀✨\n\n![Astronaut with Milky Way in helmet visor](/api/chat/media/outgoing/agent%3Amain%3As-1/abc/full)'; diff --git a/tests/unit/chat-helpers-extract-paths.test.ts b/tests/unit/chat-helpers-extract-paths.test.ts index f5b586e..74784a3 100644 --- a/tests/unit/chat-helpers-extract-paths.test.ts +++ b/tests/unit/chat-helpers-extract-paths.test.ts @@ -40,6 +40,18 @@ describe('extractRawFilePaths', () => { ]); }); + it('captures MEDIA: tagged Windows artifact paths', () => { + const refs = extractRawFilePaths(String.raw`SVG file is ready: +MEDIA:C:\Users\Administrator\.openclaw\workspace\japan-kansai-4d3n-plan.svg`); + + expect(refs).toEqual([ + { + filePath: String.raw`C:\Users\Administrator\.openclaw\workspace\japan-kansai-4d3n-plan.svg`, + mimeType: 'image/svg+xml', + }, + ]); + }); + it('captures MEDIA: paths that contain ASCII spaces (macOS screenshot default name)', () => { // Regression: macOS' default screenshot filename is // "Screenshot YYYY-MM-DD at HH.MM.SS.png" (en locale) or diff --git a/tests/unit/files-routes.test.ts b/tests/unit/files-routes.test.ts index 7e6b5a0..28c47ba 100644 --- a/tests/unit/files-routes.test.ts +++ b/tests/unit/files-routes.test.ts @@ -4,7 +4,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IncomingMessage, ServerResponse } from 'http'; -import { mkdirSync, rmSync } from 'node:fs'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -35,6 +35,7 @@ function makeRes(): ServerResponse { } const STAGE_PATHS_URL = new URL('http://127.0.0.1:13210/api/files/stage-paths'); +const THUMBNAILS_URL = new URL('http://127.0.0.1:13210/api/files/thumbnails'); const ctx = {} as never; describe('handleFileRoutes — POST /api/files/stage-paths', () => { @@ -69,4 +70,30 @@ describe('handleFileRoutes — POST /api/files/stage-paths', () => { preview: null, }); }); + + it('returns SVG previews as data URLs from thumbnails', async () => { + const svgPath = join(testRootDir, 'plan.svg'); + const svg = ''; + writeFileSync(svgPath, svg); + + parseJsonBodyMock.mockResolvedValueOnce({ + paths: [{ filePath: svgPath, mimeType: 'image/svg+xml' }], + }); + + const { handleFileRoutes } = await import('@electron/api/routes/files'); + const handled = await handleFileRoutes(makeReq(), makeRes(), THUMBNAILS_URL, ctx); + + expect(handled).toBe(true); + expect(sendJsonMock).toHaveBeenCalledTimes(1); + const [, status, payload] = sendJsonMock.mock.calls[0] as [ + ServerResponse, + number, + Record, + ]; + expect(status).toBe(200); + expect(payload[svgPath]).toEqual({ + preview: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, + fileSize: Buffer.byteLength(svg), + }); + }); });