mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
fix: preserve uploaded file content when regenerating a non-last answer (#10819)
handleRegenerate rebuilt the outbound message from the display-only
message.files metadata ({name, type: 'file'|'image'|..., content}),
which doesn't carry the base64/textContent payload sendMessage's
file-building loop expects. As a result, regenerating any answer whose
own question had an attachment silently dropped that attachment from
the resent message. This wasn't fork-specific, but forking a chat and
then regenerating an earlier (now non-last) answer is the natural way
to hit it.
Fix by reusing the original message's already-assembled `content`
verbatim (it already has the file text / image_url / audio_url /
video_url parts embedded from the first send) instead of trying to
reconstruct it from lossy display metadata.
Fixes #10806
Assisted-by: Claude:claude-sonnet-5
Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
11
core/http/react-ui/src/hooks/useChat.js
vendored
11
core/http/react-ui/src/hooks/useChat.js
vendored
@@ -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/')) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user