mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-22 14:30:53 +08:00
fix(desktop): dedupe attachment message replay #1069
This commit is contained in:
parent
8995f56d1f
commit
bf128cb9c0
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user