diff --git a/core/http/react-ui/e2e/forking-chat.spec.js b/core/http/react-ui/e2e/forking-chat.spec.js index c1625de7e..6cb0dac34 100644 --- a/core/http/react-ui/e2e/forking-chat.spec.js +++ b/core/http/react-ui/e2e/forking-chat.spec.js @@ -98,6 +98,53 @@ test('retry regenerates the first answer and drops the later turn', async ({ pag expect(contents.join('\n')).not.toContain('first answer') }) +const FILE_TURNS = [ + { + role: 'user', + content: [ + { type: 'text', text: 'what does the file say' }, + { type: 'text', text: '\n\n--- File: notes.txt ---\nthe secret is 42\n--- End of notes.txt ---' }, + ], + files: [{ name: 'notes.txt', type: 'file', content: 'the secret is 42' }], + }, + { role: 'assistant', content: 'the file says the secret is 42' }, + { role: 'user', content: 'anything else' }, + { role: 'assistant', content: 'nope, that is it' }, +] + +test('regenerating a non-last answer in a fork still sends the uploaded file content', async ({ page }) => { + await mockModels(page) + let sentMessages = null + await page.route('**/v1/chat/completions', (route) => { + sentMessages = route.request().postDataJSON()?.messages || [] + const sse = + `data: ${JSON.stringify({ choices: [{ delta: { content: 'REGENERATED file answer' } }] })}\n\n` + + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n` + + `data: [DONE]\n\n` + route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse }) + }) + await seedChat(page, FILE_TURNS) + await page.goto('/app/chat') + + // Fork after the second turn, then regenerate the FIRST (now non-last) answer. + const secondAssistant = page.locator('.chat-message-assistant').nth(1) + await secondAssistant.hover() + await secondAssistant.getByTitle('Branch from here').click() + await expect(page.locator('.chat-header-title')).toHaveText('Seeded Chat (fork)') + + const firstAssistant = page.locator('.chat-message-assistant').first() + await firstAssistant.hover() + await firstAssistant.getByTitle('Regenerate').click() + + await expect(page.locator('.chat-message-assistant')).toContainText(['REGENERATED file answer']) + + // The outbound payload for the regenerated turn must still carry the file text. + const contents = (sentMessages || []).map(m => + typeof m.content === 'string' ? m.content : JSON.stringify(m.content) + ) + expect(contents.join('\n')).toContain('the secret is 42') +}) + test('copy chat puts the whole conversation on the clipboard', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']) await mockModels(page) diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js index ee00ccec1..2bcf955ce 100644 --- a/core/http/react-ui/src/hooks/useChat.js +++ b/core/http/react-ui/src/hooks/useChat.js @@ -218,8 +218,15 @@ export function useChat(initialModel = '') { // Build user message content let messageContent - const userFiles = [] - if (files.length > 0) { + let userFiles = [] + if (options.prebuiltContent) { + // Caller (e.g. regenerate) already has the fully-assembled content from + // the original send — reuse it verbatim instead of rebuilding from + // display-only file metadata, which doesn't carry the base64/text + // payload needed to re-embed attachments. + messageContent = content + userFiles = files + } else if (files.length > 0) { messageContent = [{ type: 'text', text: content }] for (const file of files) { if (file.type?.startsWith('image/')) { diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index f75b7996f..df867ef43 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -809,9 +809,11 @@ export default function Chat() { if (history[i].role === 'user') { userIdx = i; break } } if (userIdx === -1) return - const userMsg = typeof history[userIdx].content === 'string' - ? history[userIdx].content - : history[userIdx].content?.[0]?.text || '' + // Reuse the original message's content verbatim (not re-extracted text): + // it already has any file text / image_url / audio_url / video_url parts + // embedded from when it was first sent, which display-only file metadata + // can't reconstruct. + const userContent = history[userIdx].content const userFiles = history[userIdx].files || [] // Drop the user turn and everything after it; sendMessage re-appends it. // Thread the truncated history through explicitly: updateChatSettings only @@ -819,7 +821,7 @@ export default function Chat() { // the stale pre-truncation history for the outbound API payload. const baseHistory = history.slice(0, userIdx) updateChatSettings(activeChat.id, { history: baseHistory }) - await sendMessage(userMsg, userFiles, { baseHistory }) + await sendMessage(userContent, userFiles, { baseHistory, prebuiltContent: true }) }, [activeChat, isStreaming, sendMessage, updateChatSettings]) const handleKeyDown = (e) => {