From 237bce48e82ec300e3051518cc359cb4e89adf76 Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:04:44 +0200 Subject: [PATCH] feat(ui): forking chat - retry any answer, copy, duplicate, branch (#10645) (#10654) * feat(ui): clone a chat into a new conversation (#10645) Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto * feat(ui): retry any assistant answer, not just the last (#10645) Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto * feat(ui): copy an entire chat to the clipboard (#10645) Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto * feat(ui): branch a new chat from any assistant answer (#10645) Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto * fix(ui): send truncated history on mid-conversation retry (#10645) Mid-conversation retry regenerated an answer with the downstream turns still in the model's context. handleRegenerate truncated the DOM history via updateChatSettings (a scheduled state update), but the synchronous sendMessage that followed read the stale, pre-truncation history from its closure to build the outbound API payload. Thread the intended base history explicitly through sendMessage's options.baseHistory so the request body matches the truncated view. Backward compatible: the normal send path (no baseHistory) is unchanged. Also guard two minor issues in Chat.jsx: the "Branch from here" button now renders under !isStreaming to match the retry button, and the duplicate toast only fires when forkChat returns a chat (not on a null result). Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- core/http/react-ui/e2e/forking-chat.spec.js | 133 ++++++++++++++++++ .../http/react-ui/public/locales/en/chat.json | 7 +- .../react-ui/src/components/ChatsMenu.jsx | 20 +++ core/http/react-ui/src/hooks/useChat.js | 27 +++- core/http/react-ui/src/pages/Chat.jsx | 74 ++++++---- 5 files changed, 227 insertions(+), 34 deletions(-) create mode 100644 core/http/react-ui/e2e/forking-chat.spec.js diff --git a/core/http/react-ui/e2e/forking-chat.spec.js b/core/http/react-ui/e2e/forking-chat.spec.js new file mode 100644 index 000000000..c1625de7e --- /dev/null +++ b/core/http/react-ui/e2e/forking-chat.spec.js @@ -0,0 +1,133 @@ +import { test, expect } from './coverage-fixtures.js' + +// Seeds two-message chat into localStorage so we don't need a live model. +async function seedChat(page, history) { + await page.addInitScript((h) => { + const chat = { + id: 'seed1', name: 'Seeded Chat', model: 'test-model', + history: h, systemPrompt: '', mcpMode: false, mcpServers: [], + clientMCPServers: [], temperature: null, topP: null, topK: null, + tokenUsage: { prompt: 0, completion: 0, total: 0 }, + contextSize: null, createdAt: Date.now(), updatedAt: Date.now(), + } + localStorage.setItem('localai_chats_data', JSON.stringify({ + chats: [chat], activeChatId: 'seed1', lastSaved: Date.now(), + })) + }, history) +} + +async function mockModels(page) { + await page.route('**/api/models/capabilities', (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }] }), + })) + await page.route('**/api/operations', (route) => route.fulfill({ + contentType: 'application/json', body: JSON.stringify({ operations: [] }), + })) +} + +const TWO_TURNS = [ + { role: 'user', content: 'first question' }, + { role: 'assistant', content: 'first answer' }, + { role: 'user', content: 'second question' }, + { role: 'assistant', content: 'second answer' }, +] + +test('duplicate creates an independent copy and switches to it', async ({ page }) => { + await mockModels(page) + await seedChat(page, TWO_TURNS) + await page.goto('/app/chat') + + // Open the chats menu (Ctrl/Cmd+K) and duplicate the seeded chat. + // Wait for the menu trigger to mount so its global keydown listener is armed + // before we dispatch the shortcut. + await page.getByTitle('Conversations (Ctrl/Cmd+K)').waitFor() + await page.keyboard.press('Control+k') + await page.getByTitle('Duplicate chat').first().click() + + // A new active chat named "Seeded Chat (fork)" with the same 4 messages. + await expect(page.locator('.chat-header-title')).toHaveText('Seeded Chat (fork)') + await expect(page.locator('.chat-message-user')).toHaveCount(2) + await expect(page.locator('.chat-message-assistant')).toHaveCount(2) +}) + +async function mockCompletion(page, replyText) { + await page.route('**/v1/chat/completions', (route) => { + const sse = + `data: ${JSON.stringify({ choices: [{ delta: { content: replyText } }] })}\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 }) + }) +} + +test('retry regenerates the first answer and drops the later turn', async ({ page }) => { + await mockModels(page) + // Capture the outbound request body so we can assert the model receives the + // truncated history (not the stale downstream turns). + let sentMessages = null + await page.route('**/v1/chat/completions', (route) => { + sentMessages = route.request().postDataJSON()?.messages || [] + const sse = + `data: ${JSON.stringify({ choices: [{ delta: { content: 'REGENERATED first 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, TWO_TURNS) + await page.goto('/app/chat') + + // Hover the FIRST assistant message and click its retry button. + const firstAssistant = page.locator('.chat-message-assistant').first() + await firstAssistant.hover() + await firstAssistant.getByTitle('Regenerate').click() + + // History is truncated to the first user turn, then the new answer streams in; + // the second Q/A turn is gone. + await expect(page.locator('.chat-message-assistant')).toContainText(['REGENERATED first answer']) + await expect(page.locator('.chat-message-user')).toHaveCount(1) + await expect(page.locator('.chat-message-assistant')).toHaveCount(1) + + // The OUTBOUND payload must also be truncated: the resent user turn is present, + // but the downstream turn and the stale first answer must be gone. + const contents = (sentMessages || []).map(m => + typeof m.content === 'string' ? m.content : JSON.stringify(m.content) + ) + expect(contents.join('\n')).toContain('first question') + expect(contents.join('\n')).not.toContain('second question') + expect(contents.join('\n')).not.toContain('first answer') +}) + +test('copy chat puts the whole conversation on the clipboard', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await mockModels(page) + await seedChat(page, TWO_TURNS) + await page.goto('/app/chat') + + // Wait for the menu trigger to mount so its global keydown listener is armed + // before we dispatch the shortcut (same mount-race guard as the duplicate test). + await page.getByTitle('Conversations (Ctrl/Cmd+K)').waitFor() + await page.keyboard.press('Control+k') + await page.getByTitle('Copy chat').first().click() + + const clip = await page.evaluate(() => navigator.clipboard.readText()) + expect(clip).toContain('# Seeded Chat') + expect(clip).toContain('first answer') + expect(clip).toContain('second answer') +}) + +test('branch from the first answer forks history up to that point', async ({ page }) => { + await mockModels(page) + await seedChat(page, TWO_TURNS) + await page.goto('/app/chat') + + const firstAssistant = page.locator('.chat-message-assistant').first() + await firstAssistant.hover() + await firstAssistant.getByTitle('Branch from here').click() + + // New active chat "Seeded Chat (fork)" contains only the first Q/A turn. + await expect(page.locator('.chat-header-title')).toHaveText('Seeded Chat (fork)') + await expect(page.locator('.chat-message-user')).toHaveCount(1) + await expect(page.locator('.chat-message-assistant')).toHaveCount(1) + await expect(page.locator('.chat-message-assistant')).toContainText(['first answer']) +}) diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index ffda226db..4bdc2a3d0 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -72,6 +72,7 @@ "actions": { "copy": "Copy", "regenerate": "Regenerate", + "branch": "Branch from here", "jumpToLatest": "Jump to latest" }, "streaming": { @@ -100,7 +101,9 @@ "toasts": { "selectModel": "Please select a model", "copied": "Copied to clipboard", - "copyFailed": "Could not copy to clipboard" + "copyFailed": "Could not copy to clipboard", + "chatCopied": "Chat copied to clipboard", + "forked": "Created a new chat" }, "menu": { "trigger": "Chats", @@ -110,6 +113,8 @@ "noMatch": "No conversations match your search", "noConversations": "No conversations yet", "rename": "Rename", + "duplicate": "Duplicate chat", + "copyChat": "Copy chat", "exportMarkdown": "Export as Markdown", "deleteChat": "Delete chat", "newChat": "New chat", diff --git a/core/http/react-ui/src/components/ChatsMenu.jsx b/core/http/react-ui/src/components/ChatsMenu.jsx index ed0e85dee..4b5d72821 100644 --- a/core/http/react-ui/src/components/ChatsMenu.jsx +++ b/core/http/react-ui/src/components/ChatsMenu.jsx @@ -24,6 +24,8 @@ const ChatsMenu = forwardRef(function ChatsMenu({ onDeleteAll, onRename, onExport, + onCopyChat, + onDuplicate, }, ref) { const { t } = useTranslation('chat') const [open, setOpen] = useState(false) @@ -230,6 +232,24 @@ const ChatsMenu = forwardRef(function ChatsMenu({ > + {onDuplicate && ( + + )} + {(chat.history?.length || 0) > 0 && onCopyChat && ( + + )} {(chat.history?.length || 0) > 0 && onExport && ( - {msg.role === 'assistant' && i === activeChat.history.length - 1 && !isStreaming && ( - )} + {msg.role === 'assistant' && !isStreaming && ( + + )}