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, })