mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
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:
@@ -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
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Kopieren",
|
||||
"edit": "Bearbeiten",
|
||||
"editMessage": "Nachricht bearbeiten",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"regenerate": "Neu generieren",
|
||||
"jumpToLatest": "Jump to latest"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Copiar",
|
||||
"edit": "Editar",
|
||||
"editMessage": "Editar mensaje",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"regenerate": "Regenerar",
|
||||
"jumpToLatest": "Jump to latest"
|
||||
},
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Salin",
|
||||
"edit": "Edit",
|
||||
"editMessage": "Edit pesan",
|
||||
"save": "Simpan",
|
||||
"cancel": "Batal",
|
||||
"regenerate": "Hasilkan ulang",
|
||||
"jumpToLatest": "Lompat ke terbaru"
|
||||
},
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Copia",
|
||||
"edit": "Modifica",
|
||||
"editMessage": "Modifica messaggio",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"regenerate": "Rigenera",
|
||||
"jumpToLatest": "Torna in fondo"
|
||||
},
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "복사",
|
||||
"edit": "편집",
|
||||
"editMessage": "메시지 편집",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"regenerate": "다시 생성",
|
||||
"jumpToLatest": "Jump to latest"
|
||||
},
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "复制",
|
||||
"edit": "编辑",
|
||||
"editMessage": "编辑消息",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"regenerate": "重新生成",
|
||||
"jumpToLatest": "Jump to latest"
|
||||
},
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user