From 915442a1ffd48c3ca55466f472162ce2bc794f71 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:15:49 +0000 Subject: [PATCH] feat(chat): edit saved conversation messages Add inline edit, save, and cancel controls for stored user and assistant messages without triggering inference. Preserve structured message attachments and cancel edits when streaming starts. Assisted-by: Codex:gpt-5 --- core/http/react-ui/e2e/forking-chat.spec.js | 73 +++++++++ .../http/react-ui/public/locales/de/chat.json | 4 + .../http/react-ui/public/locales/en/chat.json | 4 + .../http/react-ui/public/locales/es/chat.json | 4 + .../http/react-ui/public/locales/id/chat.json | 4 + .../http/react-ui/public/locales/it/chat.json | 4 + .../http/react-ui/public/locales/ko/chat.json | 4 + .../react-ui/public/locales/zh-CN/chat.json | 4 + core/http/react-ui/src/App.css | 31 ++++ core/http/react-ui/src/pages/Chat.jsx | 143 ++++++++++++++---- docs/content/getting-started/quickstart.md | 4 + 11 files changed, 251 insertions(+), 28 deletions(-) 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} )} -