fix chat media previews for Windows SVG artifacts (#1095)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user