fix chat media previews for Windows SVG artifacts (#1095)

This commit is contained in:
paisley
2026-06-03 13:25:56 +08:00
committed by GitHub
parent 7435e9ce29
commit 840fe62182
9 changed files with 151 additions and 12 deletions

View File

@@ -58,6 +58,12 @@ const DIRECTORY_MIME_TYPE = 'application/x-directory';
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
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 {

View File

@@ -2401,6 +2401,12 @@ const DIRECTORY_MIME_TYPE = 'application/x-directory';
*/
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
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 {

View File

@@ -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)

View File

@@ -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<string>();
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) {

View File

@@ -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<string>();
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 `\.<ext>` 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) {

View File

@@ -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('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>').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);
}
});
});

View File

@@ -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)';

View File

@@ -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

View File

@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><path d="M0 0h1v1H0z"/></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<string, { preview: string | null; fileSize: number }>,
];
expect(status).toBe(200);
expect(payload[svgPath]).toEqual({
preview: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
fileSize: Buffer.byteLength(svg),
});
});
});