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 && ( + + )}