diff --git a/core/http/react-ui/e2e/forking-chat.spec.js b/core/http/react-ui/e2e/forking-chat.spec.js index 6cb0dac34..b9424587c 100644 --- a/core/http/react-ui/e2e/forking-chat.spec.js +++ b/core/http/react-ui/e2e/forking-chat.spec.js @@ -33,6 +33,56 @@ const TWO_TURNS = [ { role: 'assistant', content: 'second answer' }, ] +test('saved message edits persist without sending a completion request', async ({ page }) => { + await mockModels(page) + let completionRequests = 0 + await page.route('**/v1/chat/completions', (route) => { + completionRequests++ + route.abort() + }) + await seedChat(page, TWO_TURNS) + await page.goto('/app/chat') + + const firstUser = page.locator('.chat-message-user').first() + await firstUser.hover() + await firstUser.getByTitle('Edit').click() + await firstUser.getByRole('textbox').fill('edited first question') + await firstUser.getByRole('button', { name: 'Save' }).click() + + const firstAssistant = page.locator('.chat-message-assistant').first() + await firstAssistant.hover() + await firstAssistant.getByTitle('Edit').click() + await firstAssistant.getByRole('textbox').fill('edited first answer') + await firstAssistant.getByRole('button', { name: 'Save' }).click() + + await expect(firstUser).toContainText('edited first question') + await expect(firstAssistant).toContainText('edited first answer') + await expect.poll(() => page.evaluate(() => { + const data = JSON.parse(localStorage.getItem('localai_chats_data')) + return data.chats[0].history.slice(0, 2).map(message => message.content) + })).toEqual(['edited first question', 'edited first answer']) + + await page.reload() + await expect(page.locator('.chat-message-user').first()).toContainText('edited first question') + await expect(page.locator('.chat-message-assistant').first()).toContainText('edited first answer') + expect(completionRequests).toBe(0) +}) + +test('cancelling a message edit leaves the original content unchanged', async ({ page }) => { + await mockModels(page) + await seedChat(page, TWO_TURNS) + await page.goto('/app/chat') + + const firstUser = page.locator('.chat-message-user').first() + await firstUser.hover() + await firstUser.getByTitle('Edit').click() + await firstUser.getByRole('textbox').fill('discard this draft') + await firstUser.getByRole('button', { name: 'Cancel' }).click() + + await expect(firstUser).toContainText('first question') + await expect(firstUser).not.toContainText('discard this draft') +}) + test('duplicate creates an independent copy and switches to it', async ({ page }) => { await mockModels(page) await seedChat(page, TWO_TURNS) @@ -112,6 +162,29 @@ const FILE_TURNS = [ { role: 'assistant', content: 'nope, that is it' }, ] +test('editing a file prompt preserves its content blocks and attachment metadata', async ({ page }) => { + await mockModels(page) + await seedChat(page, FILE_TURNS) + await page.goto('/app/chat') + + const firstUser = page.locator('.chat-message-user').first() + await firstUser.hover() + await firstUser.getByTitle('Edit').click() + await firstUser.getByRole('textbox').fill('edited file question') + await firstUser.getByRole('button', { name: 'Save' }).click() + + await expect.poll(() => page.evaluate(() => { + const data = JSON.parse(localStorage.getItem('localai_chats_data')) + return data.chats[0].history[0] + })).toEqual({ + ...FILE_TURNS[0], + content: [ + { type: 'text', text: 'edited file question' }, + FILE_TURNS[0].content[1], + ], + }) +}) + test('regenerating a non-last answer in a fork still sends the uploaded file content', async ({ page }) => { await mockModels(page) let sentMessages = null diff --git a/core/http/react-ui/public/locales/de/chat.json b/core/http/react-ui/public/locales/de/chat.json index 439213811..f686fae2f 100644 --- a/core/http/react-ui/public/locales/de/chat.json +++ b/core/http/react-ui/public/locales/de/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "Kopieren", + "edit": "Bearbeiten", + "editMessage": "Nachricht bearbeiten", + "save": "Speichern", + "cancel": "Abbrechen", "regenerate": "Neu generieren", "jumpToLatest": "Jump to latest" }, diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index 4bdc2a3d0..fd2cee84d 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "Copy", + "edit": "Edit", + "editMessage": "Edit message", + "save": "Save", + "cancel": "Cancel", "regenerate": "Regenerate", "branch": "Branch from here", "jumpToLatest": "Jump to latest" diff --git a/core/http/react-ui/public/locales/es/chat.json b/core/http/react-ui/public/locales/es/chat.json index d5ade75f9..cec9d9610 100644 --- a/core/http/react-ui/public/locales/es/chat.json +++ b/core/http/react-ui/public/locales/es/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "Copiar", + "edit": "Editar", + "editMessage": "Editar mensaje", + "save": "Guardar", + "cancel": "Cancelar", "regenerate": "Regenerar", "jumpToLatest": "Jump to latest" }, diff --git a/core/http/react-ui/public/locales/id/chat.json b/core/http/react-ui/public/locales/id/chat.json index b9216e325..8d2834c3b 100644 --- a/core/http/react-ui/public/locales/id/chat.json +++ b/core/http/react-ui/public/locales/id/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "Salin", + "edit": "Edit", + "editMessage": "Edit pesan", + "save": "Simpan", + "cancel": "Batal", "regenerate": "Hasilkan ulang", "jumpToLatest": "Lompat ke terbaru" }, diff --git a/core/http/react-ui/public/locales/it/chat.json b/core/http/react-ui/public/locales/it/chat.json index 393dc7b01..e95d18c8b 100644 --- a/core/http/react-ui/public/locales/it/chat.json +++ b/core/http/react-ui/public/locales/it/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "Copia", + "edit": "Modifica", + "editMessage": "Modifica messaggio", + "save": "Salva", + "cancel": "Annulla", "regenerate": "Rigenera", "jumpToLatest": "Torna in fondo" }, diff --git a/core/http/react-ui/public/locales/ko/chat.json b/core/http/react-ui/public/locales/ko/chat.json index c14404c5f..522f844e6 100644 --- a/core/http/react-ui/public/locales/ko/chat.json +++ b/core/http/react-ui/public/locales/ko/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "복사", + "edit": "편집", + "editMessage": "메시지 편집", + "save": "저장", + "cancel": "취소", "regenerate": "다시 생성", "jumpToLatest": "Jump to latest" }, diff --git a/core/http/react-ui/public/locales/zh-CN/chat.json b/core/http/react-ui/public/locales/zh-CN/chat.json index a96b306b6..38694e40a 100644 --- a/core/http/react-ui/public/locales/zh-CN/chat.json +++ b/core/http/react-ui/public/locales/zh-CN/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "复制", + "edit": "编辑", + "editMessage": "编辑消息", + "save": "保存", + "cancel": "取消", "regenerate": "重新生成", "jumpToLatest": "Jump to latest" }, diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 68a7e4d5a..d6efa8afb 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -3540,6 +3540,37 @@ button.collapsible-header:focus-visible { background: var(--color-primary-light); } +.chat-message-edit { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + min-width: min(32rem, 60vw); +} + +.chat-message-edit-input { + width: 100%; + min-height: 6rem; + resize: vertical; + border: 1px solid var(--color-primary-border); + border-radius: var(--radius-md); + background: var(--color-bg-primary); + color: var(--color-text-primary); + font: inherit; + line-height: 1.5; + padding: var(--spacing-sm); +} + +.chat-message-edit-input:focus { + outline: 2px solid var(--color-primary-light); + outline-offset: 1px; +} + +.chat-message-edit-actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-xs); +} + .chat-message-system { align-self: center; max-width: 90%; diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index df867ef43..1db1b7e47 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -287,6 +287,24 @@ function UserMessageContent({ content, files }) { ) } +function editableMessageText(message) { + if (typeof message.content === 'string') return message.content + if (!Array.isArray(message.content)) return null + const textBlock = message.content.find(block => block?.type === 'text') + return typeof textBlock?.text === 'string' ? textBlock.text : null +} + +function withEditedMessageText(message, text) { + if (typeof message.content === 'string') return { ...message, content: text } + const textIndex = message.content.findIndex(block => block?.type === 'text') + return { + ...message, + content: message.content.map((block, index) => + index === textIndex ? { ...block, text } : block + ), + } +} + export default function Chat() { const { model: urlModel } = useParams() const { addToast } = useOutletContext() @@ -329,6 +347,8 @@ export default function Chat() { const [clientMCPServers, setClientMCPServers] = useState(() => loadClientMCPServers()) const [confirmDialog, setConfirmDialog] = useState(null) const [completionGlowIdx, setCompletionGlowIdx] = useState(-1) + const [editingMessageIndex, setEditingMessageIndex] = useState(null) + const [messageEditDraft, setMessageEditDraft] = useState('') const prevStreamingRef = useRef(false) const { connect: mcpConnect, disconnect: mcpDisconnect, disconnectAll: mcpDisconnectAll, @@ -545,6 +565,33 @@ export default function Chat() { updateChatSettings(activeChat.id, { clientMCPServers: next }) }, [activeChat, updateChatSettings]) + const startMessageEdit = useCallback((index, message) => { + const text = editableMessageText(message) + if (text === null) return + setEditingMessageIndex(index) + setMessageEditDraft(text) + }, []) + + const cancelMessageEdit = useCallback(() => { + setEditingMessageIndex(null) + setMessageEditDraft('') + }, []) + + const saveMessageEdit = useCallback(() => { + if (!activeChat || isStreaming || editingMessageIndex === null || !messageEditDraft.trim()) return + const message = activeChat.history[editingMessageIndex] + if (!message || editableMessageText(message) === null) return + const history = activeChat.history.map((item, index) => + index === editingMessageIndex ? withEditedMessageText(item, messageEditDraft) : item + ) + updateChatSettings(activeChat.id, { history }) + cancelMessageEdit() + }, [activeChat, isStreaming, editingMessageIndex, messageEditDraft, updateChatSettings, cancelMessageEdit]) + + useEffect(() => { + cancelMessageEdit() + }, [activeChat?.id, isStreaming, cancelMessageEdit]) + // Load initial message from home page const homeDataProcessed = useRef(false) useEffect(() => { @@ -1170,40 +1217,80 @@ export default function Chat() { {msg.role === 'assistant' && activeChat.model && ( {activeChat.model} )} -