mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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)
This commit is contained in:
parent
fc5924f1f1
commit
7f7d0d89ae
@ -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[] = [
|
||||
{
|
||||
|
||||
@ -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 = /^<task-notification>\s*[\s\S]*<\/task-notification>$/i
|
||||
const GOAL_EVENT_ACTIONS = new Set<GoalEventAction>([
|
||||
@ -1940,6 +1940,60 @@ function isTeammateMessage(text: string): boolean {
|
||||
return text.includes('<teammate-message') && text.includes('</teammate-message>')
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user