fix(desktop): dedupe attachment message replay #1069

This commit is contained in:
程序员阿江(Relakkes) 2026-07-20 20:27:53 +08:00
parent 8995f56d1f
commit bf128cb9c0
2 changed files with 189 additions and 4 deletions

View File

@ -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()

View File

@ -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<UIMessage, { type: 'user_text' }>,
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
}