diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 4eb56f1f..029b5d26 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -5420,6 +5420,125 @@ describe('chatStore history mapping', () => { expect(userMessages).toHaveLength(1) }) + it('dedupes a path-backed image after the server inlines it into replay content', () => { + const prompt = '检查这张截图' + const imagePath = 'C:\\Users\\tester\\Desktop\\screen.png' + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + messages: [ + { + id: 'live-user', + type: 'user_text', + content: prompt, + modelContent: `@"${imagePath}" ${prompt}`, + attachments: [{ + type: 'file', + name: 'screen.png', + path: imagePath, + }], + timestamp: 1, + }, + ], + chatState: 'thinking', + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'user_message_replay', + content: `${prompt}\n[Image source: ${imagePath}]`, + }) + + const userMessages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages + .filter((message) => message.type === 'user_text') + expect(userMessages).toHaveLength(1) + }) + + it('dedupes a prompt when data-only files replay with server-materialized upload paths', () => { + const prompt = '检查这两个附件' + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + messages: [ + { + id: 'live-user', + type: 'user_text', + content: prompt, + attachments: [ + { + type: 'file', + name: 'PROJECT.md', + data: 'data:text/markdown;base64,IyBQcm9qZWN0', + }, + { + type: 'file', + name: 'server.pem', + data: 'data:application/x-pem-file;base64,VEVTVA==', + }, + ], + timestamp: 1, + }, + ], + chatState: 'thinking', + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'user_message_replay', + content: [ + '@"C:\\Users\\tester\\.claude\\uploads\\sid\\5d3af295-b914-4d44-a686-d665dc46b189-PROJECT.md"', + '@"C:\\Users\\tester\\.claude\\uploads\\sid\\d81b63cd-978c-42ce-ad9a-3bcd049dc24e-server.pem"', + prompt, + ].join(' '), + }) + + const userMessages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages + .filter((message) => message.type === 'user_text') + expect(userMessages).toHaveLength(1) + expect(userMessages?.[0]).toMatchObject({ + content: prompt, + attachments: [ + { name: 'PROJECT.md', data: 'data:text/markdown;base64,IyBQcm9qZWN0' }, + { name: 'server.pem', data: 'data:application/x-pem-file;base64,VEVTVA==' }, + ], + }) + }) + + it('keeps a replay whose materialized upload filename does not match the current attachment', () => { + const prompt = '检查这个附件' + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + messages: [ + { + id: 'live-user', + type: 'user_text', + content: prompt, + attachments: [{ + type: 'file', + name: 'PROJECT.md', + data: 'data:text/markdown;base64,IyBQcm9qZWN0', + }], + timestamp: 1, + }, + ], + chatState: 'thinking', + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'user_message_replay', + content: `@"C:\\Users\\tester\\.claude\\uploads\\sid\\5d3af295-b914-4d44-a686-d665dc46b189-other.md" ${prompt}`, + }) + + const userMessages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages + .filter((message) => message.type === 'user_text') + expect(userMessages).toHaveLength(2) + }) + it('flushes pending text before appending an error message', () => { vi.useFakeTimers() diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index abdfb3d4..7bcc8ac3 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -3684,11 +3684,13 @@ function pathsReferToSameFile(left: string | undefined, right: string | undefine ) } -function extractRestoredUserDisplay(text: string): { +type RestoredUserDisplay = { content: string attachments?: UIAttachment[] modelContent?: string -} { +} + +function extractRestoredUserDisplay(text: string): RestoredUserDisplay { const leading = extractLeadingFileReferences(text) const workspace = parseWorkspaceReferenceHistoryPrompt(leading.content) if (!workspace) return leading @@ -3709,6 +3711,69 @@ function extractRestoredUserDisplay(text: string): { } } +// ConversationService stores data-only files as `${randomUUID()}-${sanitizedName}` +// and omits successfully inlined images from the replayed text content. +const MATERIALIZED_UPLOAD_NAME_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}-(.+)$/i + +function isLikelyInlineImageAttachment(attachment: UIAttachment): boolean { + if (attachment.type === 'image') return true + if (attachment.mimeType?.startsWith('image/')) return true + const candidate = attachment.path ?? attachment.name + return /\.(png|jpe?g|gif|webp)$/i.test(candidate) +} + +function replayAttachmentMatchesCurrent( + replayAttachment: UIAttachment, + currentAttachment: UIAttachment, +): boolean { + if (pathsReferToSameFile(replayAttachment.path, currentAttachment.path)) return true + if (currentAttachment.path || !replayAttachment.path) return false + + const replayName = getReferenceName(replayAttachment.path) + const materializedName = replayName.match(MATERIALIZED_UPLOAD_NAME_RE)?.[1] + if (!materializedName) return false + + const currentName = currentAttachment.name.replace(/[^a-zA-Z0-9._-]/g, '_') + return Boolean(currentName) && materializedName === currentName +} + +function replayAttachmentsMatchCurrent( + replayAttachments: UIAttachment[], + currentAttachments: UIAttachment[], +): boolean { + if (currentAttachments.length === 0) return false + + const unmatchedCurrent = new Set(currentAttachments.map((_, index) => index)) + for (const replayAttachment of replayAttachments) { + const matchingIndex = currentAttachments.findIndex((currentAttachment, index) => + unmatchedCurrent.has(index) && replayAttachmentMatchesCurrent(replayAttachment, currentAttachment), + ) + if (matchingIndex < 0) return false + unmatchedCurrent.delete(matchingIndex) + } + + return [...unmatchedCurrent].every((index) => + isLikelyInlineImageAttachment(currentAttachments[index]!), + ) +} + +function replayMatchesCurrentUserMessage( + message: Extract, + replayDisplay: RestoredUserDisplay, + replayModelContent: string, +): boolean { + const currentModelContent = (message.modelContent ?? message.content).trim() + if (currentModelContent === replayModelContent) return true + + const currentDisplay = extractRestoredUserDisplay(currentModelContent) + if (currentDisplay.content.trim() !== replayDisplay.content.trim()) return false + + return replayAttachmentsMatchCurrent( + replayDisplay.attachments ?? [], + message.attachments ?? [], + ) +} + export function appendReplayedUserMessage( messages: UIMessage[], content: string, @@ -3724,7 +3789,7 @@ export function appendReplayedUserMessage( if (!displayContent && !parsed.attachments?.length) return messages const modelContent = parsed.modelContent ?? sanitized - const currentTurnUserIndex = findCurrentTurnUserMessageIndex(messages, modelContent) + const currentTurnUserIndex = findCurrentTurnUserMessageIndex(messages, modelContent, parsed) if (currentTurnUserIndex >= 0) { const optimisticMessage = messages[currentTurnUserIndex] if (optimisticMessage?.type === 'user_text' && optimisticMessage.optimisticQueued) { @@ -3796,13 +3861,14 @@ function mapQueuedDisplayAttachments(attachments?: AttachmentRef[]): UIAttachmen function findCurrentTurnUserMessageIndex( messages: UIMessage[], modelContent: string, + replayDisplay: RestoredUserDisplay, ): number { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index] if (message?.type !== 'user_text') { continue } - return (message.modelContent ?? message.content).trim() === modelContent ? index : -1 + return replayMatchesCurrentUserMessage(message, replayDisplay, modelContent) ? index : -1 } return -1 }