mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-25 14:53:32 +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)
|
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', () => {
|
it('flushes pending text before appending an error message', () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
|||||||
@ -3684,11 +3684,13 @@ function pathsReferToSameFile(left: string | undefined, right: string | undefine
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRestoredUserDisplay(text: string): {
|
type RestoredUserDisplay = {
|
||||||
content: string
|
content: string
|
||||||
attachments?: UIAttachment[]
|
attachments?: UIAttachment[]
|
||||||
modelContent?: string
|
modelContent?: string
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
function extractRestoredUserDisplay(text: string): RestoredUserDisplay {
|
||||||
const leading = extractLeadingFileReferences(text)
|
const leading = extractLeadingFileReferences(text)
|
||||||
const workspace = parseWorkspaceReferenceHistoryPrompt(leading.content)
|
const workspace = parseWorkspaceReferenceHistoryPrompt(leading.content)
|
||||||
if (!workspace) return leading
|
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(
|
export function appendReplayedUserMessage(
|
||||||
messages: UIMessage[],
|
messages: UIMessage[],
|
||||||
content: string,
|
content: string,
|
||||||
@ -3724,7 +3789,7 @@ export function appendReplayedUserMessage(
|
|||||||
if (!displayContent && !parsed.attachments?.length) return messages
|
if (!displayContent && !parsed.attachments?.length) return messages
|
||||||
|
|
||||||
const modelContent = parsed.modelContent ?? sanitized
|
const modelContent = parsed.modelContent ?? sanitized
|
||||||
const currentTurnUserIndex = findCurrentTurnUserMessageIndex(messages, modelContent)
|
const currentTurnUserIndex = findCurrentTurnUserMessageIndex(messages, modelContent, parsed)
|
||||||
if (currentTurnUserIndex >= 0) {
|
if (currentTurnUserIndex >= 0) {
|
||||||
const optimisticMessage = messages[currentTurnUserIndex]
|
const optimisticMessage = messages[currentTurnUserIndex]
|
||||||
if (optimisticMessage?.type === 'user_text' && optimisticMessage.optimisticQueued) {
|
if (optimisticMessage?.type === 'user_text' && optimisticMessage.optimisticQueued) {
|
||||||
@ -3796,13 +3861,14 @@ function mapQueuedDisplayAttachments(attachments?: AttachmentRef[]): UIAttachmen
|
|||||||
function findCurrentTurnUserMessageIndex(
|
function findCurrentTurnUserMessageIndex(
|
||||||
messages: UIMessage[],
|
messages: UIMessage[],
|
||||||
modelContent: string,
|
modelContent: string,
|
||||||
|
replayDisplay: RestoredUserDisplay,
|
||||||
): number {
|
): number {
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
const message = messages[index]
|
const message = messages[index]
|
||||||
if (message?.type !== 'user_text') {
|
if (message?.type !== 'user_text') {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return (message.modelContent ?? message.content).trim() === modelContent ? index : -1
|
return replayMatchesCurrentUserMessage(message, replayDisplay, modelContent) ? index : -1
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user