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
This commit is contained in:
localai-org-maint-bot
2026-07-29 08:15:49 +00:00
parent 8089b2bf09
commit 915442a1ff
11 changed files with 251 additions and 28 deletions

View File

@@ -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

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "Kopieren",
"edit": "Bearbeiten",
"editMessage": "Nachricht bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"regenerate": "Neu generieren",
"jumpToLatest": "Jump to latest"
},

View File

@@ -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"

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "Copiar",
"edit": "Editar",
"editMessage": "Editar mensaje",
"save": "Guardar",
"cancel": "Cancelar",
"regenerate": "Regenerar",
"jumpToLatest": "Jump to latest"
},

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "Salin",
"edit": "Edit",
"editMessage": "Edit pesan",
"save": "Simpan",
"cancel": "Batal",
"regenerate": "Hasilkan ulang",
"jumpToLatest": "Lompat ke terbaru"
},

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "Copia",
"edit": "Modifica",
"editMessage": "Modifica messaggio",
"save": "Salva",
"cancel": "Annulla",
"regenerate": "Rigenera",
"jumpToLatest": "Torna in fondo"
},

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "복사",
"edit": "편집",
"editMessage": "메시지 편집",
"save": "저장",
"cancel": "취소",
"regenerate": "다시 생성",
"jumpToLatest": "Jump to latest"
},

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "复制",
"edit": "编辑",
"editMessage": "编辑消息",
"save": "保存",
"cancel": "取消",
"regenerate": "重新生成",
"jumpToLatest": "Jump to latest"
},

View File

@@ -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%;

View File

@@ -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 && (
<span className="chat-message-model">{activeChat.model}</span>
)}
<div className="chat-message-content">
{msg.role === 'user' ? (
<UserMessageContent content={msg.content} files={msg.files} />
) : (
<div dangerouslySetInnerHTML={{
__html: canvasMode
? renderMarkdownWithArtifacts(typeof msg.content === 'string' ? msg.content : '', i)
: renderMarkdown(typeof msg.content === 'string' ? msg.content : '')
}} />
)}
</div>
{editingMessageIndex === i ? (
<div className="chat-message-edit">
<textarea
autoFocus
className="chat-message-edit-input"
value={messageEditDraft}
onChange={(event) => setMessageEditDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') cancelMessageEdit()
}}
aria-label={t('actions.editMessage')}
/>
<div className="chat-message-edit-actions">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={saveMessageEdit}
disabled={!messageEditDraft.trim()}
>
{t('actions.save')}
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={cancelMessageEdit}
>
{t('actions.cancel')}
</button>
</div>
</div>
) : (
<div className="chat-message-content">
{msg.role === 'user' ? (
<UserMessageContent content={msg.content} files={msg.files} />
) : (
<div dangerouslySetInnerHTML={{
__html: canvasMode
? renderMarkdownWithArtifacts(typeof msg.content === 'string' ? msg.content : '', i)
: renderMarkdown(typeof msg.content === 'string' ? msg.content : '')
}} />
)}
</div>
)}
{msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.includes('Error:') && (
<a href="/app/traces?tab=backend" className="chat-error-trace-link">
<i className="fas fa-wave-square" /> {t('errors.viewTraces')}
</a>
)}
<div className="chat-message-actions">
<button onClick={() => copyMessage(msg.content)} title={t('actions.copy')}>
<i className="fas fa-copy" />
</button>
{msg.role === 'assistant' && !isStreaming && (
<button onClick={() => handleRegenerate(i)} title={t('actions.regenerate')}>
<i className="fas fa-rotate" />
{editingMessageIndex !== i && (
<div className="chat-message-actions">
<button onClick={() => copyMessage(msg.content)} title={t('actions.copy')}>
<i className="fas fa-copy" />
</button>
)}
{msg.role === 'assistant' && !isStreaming && (
<button
onClick={() => { forkChat(activeChat.id, i + 1); addToast(t('toasts.forked'), 'success', 2000) }}
title={t('actions.branch')}
>
<i className="fas fa-code-branch" />
</button>
)}
</div>
{(msg.role === 'user' || msg.role === 'assistant') &&
editableMessageText(msg) !== null && !isStreaming && (
<button onClick={() => startMessageEdit(i, msg)} title={t('actions.edit')}>
<i className="fas fa-pen" />
</button>
)}
{msg.role === 'assistant' && !isStreaming && (
<button onClick={() => handleRegenerate(i)} title={t('actions.regenerate')}>
<i className="fas fa-rotate" />
</button>
)}
{msg.role === 'assistant' && !isStreaming && (
<button
onClick={() => { forkChat(activeChat.id, i + 1); addToast(t('toasts.forked'), 'success', 2000) }}
title={t('actions.branch')}
>
<i className="fas fa-code-branch" />
</button>
)}
</div>
)}
</div>
</div>
)

View File

@@ -63,6 +63,10 @@ To get your first chat working:
1. Open the **Models** page and search for `qwen3-4b`. Click **Install** on the `qwen3-4b` entry and wait for the download to finish. (`qwen3-4b` is a small, CPU-friendly Qwen3 model that also supports tool calling, so you can reuse it later in the [Build your first agent]({{% relref "getting-started/first-agent" %}}) walkthrough.)
2. Open the **Chat** page, select `qwen3-4b` from the model dropdown, type a message, and send it. You should get a reply within a few seconds.
To correct an earlier prompt or response without running the model again, hover
over the saved message and select **Edit**. **Save** updates that conversation's
local history; **Cancel** discards the draft.
### Downloading models from the CLI
When starting LocalAI (either via Docker or via CLI) you can specify as argument a list of models to install automatically before starting the API, for example: