From 7f7d0d89aedfaf639095d2f6fcc66d0f4d54f392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 4 Jun 2026 16:47:38 +0800 Subject: [PATCH] fix: restore persisted chat images (#721) Normalize image blocks loaded from transcript history back into renderable data URLs and keep generated image metadata out of the visible user bubble. Image source metadata is applied in order so multi-image messages retain the correct paths and names. Tested: bun run test -- --run src/stores/chatStore.test.ts Tested: bun run check:desktop Confidence: high Scope-risk: narrow (cherry picked from commit 82381e8d647fa19e20853a96b9681fb548c17f0a) --- desktop/src/stores/chatStore.test.ts | 128 +++++++++++++++++++++++++++ desktop/src/stores/chatStore.ts | 87 ++++++++++++++++-- 2 files changed, 206 insertions(+), 9 deletions(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 2e187ba9..83a5f307 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -439,6 +439,134 @@ describe('chatStore history mapping', () => { ]) }) + it('restores persisted image user messages as renderable attachments without exposing image metadata text', () => { + const messages: MessageEntry[] = [ + { + id: 'image-user-1', + type: 'user', + timestamp: '2026-06-04T08:07:15.803Z', + content: [ + { type: 'text', text: '解释一下这张图片讲了什么东西' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: 'JPEGBASE64', + }, + }, + { + type: 'text', + text: '[Image source: /Users/test/.claude/uploads/session-1/pasted-image.jpeg]', + }, + ], + }, + ] + + const mapped = mapHistoryMessagesToUiMessages(messages) + + expect(mapped).toMatchObject([ + { + id: 'image-user-1', + type: 'user_text', + content: '解释一下这张图片讲了什么东西', + modelContent: [ + '解释一下这张图片讲了什么东西', + '[Image source: /Users/test/.claude/uploads/session-1/pasted-image.jpeg]', + ].join('\n'), + attachments: [{ + type: 'image', + name: 'pasted-image.jpeg', + path: '/Users/test/.claude/uploads/session-1/pasted-image.jpeg', + data: 'data:image/jpeg;base64,JPEGBASE64', + mimeType: 'image/jpeg', + }], + }, + ]) + }) + + it('restores multiple persisted images with their matching source paths in order', () => { + const mapped = mapHistoryMessagesToUiMessages([ + { + id: 'multi-image-user-1', + type: 'user', + timestamp: '2026-06-04T08:07:15.803Z', + content: [ + { type: 'text', text: '对比这两张图' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: 'FIRSTJPEG', + }, + }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'SECONDPNG', + }, + }, + { + type: 'text', + text: '[Image source: /Users/test/.claude/uploads/session-1/first-pasted-image.jpeg]', + }, + { + type: 'text', + text: '[Image source: /Users/test/.claude/uploads/session-1/second-pasted-image.png]', + }, + ], + }, + ]) + + expect(mapped).toMatchObject([ + { + id: 'multi-image-user-1', + type: 'user_text', + content: '对比这两张图', + attachments: [ + { + type: 'image', + name: 'first-pasted-image.jpeg', + path: '/Users/test/.claude/uploads/session-1/first-pasted-image.jpeg', + data: 'data:image/jpeg;base64,FIRSTJPEG', + mimeType: 'image/jpeg', + }, + { + type: 'image', + name: 'second-pasted-image.png', + path: '/Users/test/.claude/uploads/session-1/second-pasted-image.png', + data: 'data:image/png;base64,SECONDPNG', + mimeType: 'image/png', + }, + ], + }, + ]) + }) + + it('keeps image-looking text visible when history has no image block', () => { + const mapped = mapHistoryMessagesToUiMessages([ + { + id: 'plain-text-user-1', + type: 'user', + timestamp: '2026-06-04T08:07:15.803Z', + content: [ + { type: 'text', text: '[Image source: /tmp/example.png]' }, + ], + }, + ]) + + expect(mapped).toMatchObject([ + { + id: 'plain-text-user-1', + type: 'user_text', + content: '[Image source: /tmp/example.png]', + }, + ]) + }) + it('restores /goal local command output from transcript history', () => { const messages: MessageEntry[] = [ { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 11f01147..e1de0a2c 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -1914,10 +1914,10 @@ function updateOptimisticSessionTitle(sessionId: string, content: string): void useTabStore.getState().updateTabTitle(sessionId, title) } -// ─── History mapping helpers (unchanged from original) ───────── +// ─── History mapping helpers ───────── type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown } -type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string } +type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string; media_type?: string }; mimeType?: string; media_type?: string; name?: string } const TASK_NOTIFICATION_RE = /^\s*[\s\S]*<\/task-notification>$/i const GOAL_EVENT_ACTIONS = new Set([ @@ -1940,6 +1940,60 @@ function isTeammateMessage(text: string): boolean { return text.includes('') } +const SIMPLE_IMAGE_SOURCE_RE = /^\[Image source: (.+)\]$/ +const DETAILED_IMAGE_SOURCE_RE = /^\[Image: source: (.+?)(?:, original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by \d+(?:\.\d+)? to map to original image\.)?\]$/ +const IMAGE_RESIZE_METADATA_RE = /^\[Image: original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by \d+(?:\.\d+)? to map to original image\.\]$/ + +function getHistoryImageMediaType(block: UserHistoryBlock): string { + const mediaType = block.source?.media_type ?? block.mimeType ?? block.media_type + return mediaType?.startsWith('image/') ? mediaType : 'image/png' +} + +function normalizeHistoryImageData(data: string | undefined, mediaType: string): string | undefined { + const trimmed = data?.trim() + if (!trimmed) return undefined + if (/^data:image\//i.test(trimmed)) return trimmed + return `data:${mediaType};base64,${trimmed}` +} + +function extractImageMetadataSourcePath(text: string): string | undefined { + const trimmed = text.trim() + const simpleMatch = trimmed.match(SIMPLE_IMAGE_SOURCE_RE) + if (simpleMatch?.[1]) return simpleMatch[1] + const detailedMatch = trimmed.match(DETAILED_IMAGE_SOURCE_RE) + if (detailedMatch?.[1]) return detailedMatch[1] + return undefined +} + +function isGeneratedImageMetadataText(text: string): boolean { + return Boolean(extractImageMetadataSourcePath(text)) || IMAGE_RESIZE_METADATA_RE.test(text.trim()) +} + +function normalizeHistoryImageAttachment(block: UserHistoryBlock): UIAttachment { + const mediaType = getHistoryImageMediaType(block) + return { + type: 'image', + name: block.name || 'image', + data: normalizeHistoryImageData(block.source?.data, mediaType), + mimeType: mediaType, + } +} + +function applyImageMetadataSourcePaths(attachments: UIAttachment[], sourcePaths: string[]): void { + let imageIndex = 0 + for (const sourcePath of sourcePaths) { + const attachment = attachments + .slice(imageIndex) + .find((candidate) => candidate.type === 'image') + if (!attachment) return + imageIndex = attachments.indexOf(attachment) + 1 + attachment.path = sourcePath + if (!attachment.name || attachment.name === 'image') { + attachment.name = getReferenceName(sourcePath) + } + } +} + function extractHistoryTextBlocks(content: unknown): string[] { if (typeof content === 'string') return [content] if (!Array.isArray(content)) return [] @@ -2643,16 +2697,27 @@ export function mapHistoryMessagesToUiMessages( continue } if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) { - const textParts: string[] = [] + const visibleTextParts: string[] = [] + const modelTextParts: string[] = [] const attachments: UIAttachment[] = [] + const imageSourcePaths: string[] = [] + const hasImageBlock = (msg.content as UserHistoryBlock[]).some((block) => block.type === 'image') for (const block of msg.content as UserHistoryBlock[]) { if (block.type === 'text' && block.text && isTeammateMessage(block.text)) { + modelTextParts.push(block.text) if (!includeTeammateMessages) continue - textParts.push(...extractVisibleTeammateMessageContents(block.text)) + visibleTextParts.push(...extractVisibleTeammateMessageContents(block.text)) } else if (block.type === 'text' && block.text) { - textParts.push(block.text) + modelTextParts.push(block.text) + const imageSourcePath = hasImageBlock ? extractImageMetadataSourcePath(block.text) : undefined + if (imageSourcePath) { + imageSourcePaths.push(imageSourcePath) + } + if (!hasImageBlock || !isGeneratedImageMetadataText(block.text)) { + visibleTextParts.push(block.text) + } } - else if (block.type === 'image') attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type }) + else if (block.type === 'image') attachments.push(normalizeHistoryImageAttachment(block)) else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' }) else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), @@ -2664,15 +2729,19 @@ export function mapHistoryMessagesToUiMessages( parentToolUseId: msg.parentToolUseId, }) } - if (textParts.length > 0 || attachments.length > 0) { - const parsed = extractLeadingFileReferences(textParts.join('\n')) + applyImageMetadataSourcePaths(attachments, imageSourcePaths) + if (visibleTextParts.length > 0 || attachments.length > 0) { + const visibleText = visibleTextParts.join('\n') + const modelText = modelTextParts.join('\n') + const parsed = extractLeadingFileReferences(visibleText) + const modelContent = modelText !== visibleText ? modelText : parsed.modelContent const allAttachments = [...(parsed.attachments ?? []), ...attachments] uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: parsed.content, ...(msg.id ? { transcriptMessageId: msg.id } : {}), - ...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}), + ...(modelContent ? { modelContent } : {}), attachments: allAttachments.length > 0 ? allAttachments : undefined, timestamp, })