Skip to content

Commit de49d33

Browse files
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
1 parent b13c429 commit de49d33

11 files changed

Lines changed: 251 additions & 28 deletions

File tree

core/http/react-ui/e2e/forking-chat.spec.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,56 @@ const TWO_TURNS = [
3333
{ role: 'assistant', content: 'second answer' },
3434
]
3535

36+
test('saved message edits persist without sending a completion request', async ({ page }) => {
37+
await mockModels(page)
38+
let completionRequests = 0
39+
await page.route('**/v1/chat/completions', (route) => {
40+
completionRequests++
41+
route.abort()
42+
})
43+
await seedChat(page, TWO_TURNS)
44+
await page.goto('/app/chat')
45+
46+
const firstUser = page.locator('.chat-message-user').first()
47+
await firstUser.hover()
48+
await firstUser.getByTitle('Edit').click()
49+
await firstUser.getByRole('textbox').fill('edited first question')
50+
await firstUser.getByRole('button', { name: 'Save' }).click()
51+
52+
const firstAssistant = page.locator('.chat-message-assistant').first()
53+
await firstAssistant.hover()
54+
await firstAssistant.getByTitle('Edit').click()
55+
await firstAssistant.getByRole('textbox').fill('edited first answer')
56+
await firstAssistant.getByRole('button', { name: 'Save' }).click()
57+
58+
await expect(firstUser).toContainText('edited first question')
59+
await expect(firstAssistant).toContainText('edited first answer')
60+
await expect.poll(() => page.evaluate(() => {
61+
const data = JSON.parse(localStorage.getItem('localai_chats_data'))
62+
return data.chats[0].history.slice(0, 2).map(message => message.content)
63+
})).toEqual(['edited first question', 'edited first answer'])
64+
65+
await page.reload()
66+
await expect(page.locator('.chat-message-user').first()).toContainText('edited first question')
67+
await expect(page.locator('.chat-message-assistant').first()).toContainText('edited first answer')
68+
expect(completionRequests).toBe(0)
69+
})
70+
71+
test('cancelling a message edit leaves the original content unchanged', async ({ page }) => {
72+
await mockModels(page)
73+
await seedChat(page, TWO_TURNS)
74+
await page.goto('/app/chat')
75+
76+
const firstUser = page.locator('.chat-message-user').first()
77+
await firstUser.hover()
78+
await firstUser.getByTitle('Edit').click()
79+
await firstUser.getByRole('textbox').fill('discard this draft')
80+
await firstUser.getByRole('button', { name: 'Cancel' }).click()
81+
82+
await expect(firstUser).toContainText('first question')
83+
await expect(firstUser).not.toContainText('discard this draft')
84+
})
85+
3686
test('duplicate creates an independent copy and switches to it', async ({ page }) => {
3787
await mockModels(page)
3888
await seedChat(page, TWO_TURNS)
@@ -112,6 +162,29 @@ const FILE_TURNS = [
112162
{ role: 'assistant', content: 'nope, that is it' },
113163
]
114164

165+
test('editing a file prompt preserves its content blocks and attachment metadata', async ({ page }) => {
166+
await mockModels(page)
167+
await seedChat(page, FILE_TURNS)
168+
await page.goto('/app/chat')
169+
170+
const firstUser = page.locator('.chat-message-user').first()
171+
await firstUser.hover()
172+
await firstUser.getByTitle('Edit').click()
173+
await firstUser.getByRole('textbox').fill('edited file question')
174+
await firstUser.getByRole('button', { name: 'Save' }).click()
175+
176+
await expect.poll(() => page.evaluate(() => {
177+
const data = JSON.parse(localStorage.getItem('localai_chats_data'))
178+
return data.chats[0].history[0]
179+
})).toEqual({
180+
...FILE_TURNS[0],
181+
content: [
182+
{ type: 'text', text: 'edited file question' },
183+
FILE_TURNS[0].content[1],
184+
],
185+
})
186+
})
187+
115188
test('regenerating a non-last answer in a fork still sends the uploaded file content', async ({ page }) => {
116189
await mockModels(page)
117190
let sentMessages = null

core/http/react-ui/public/locales/de/chat.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
},
7272
"actions": {
7373
"copy": "Kopieren",
74+
"edit": "Bearbeiten",
75+
"editMessage": "Nachricht bearbeiten",
76+
"save": "Speichern",
77+
"cancel": "Abbrechen",
7478
"regenerate": "Neu generieren",
7579
"jumpToLatest": "Jump to latest"
7680
},

core/http/react-ui/public/locales/en/chat.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
},
7272
"actions": {
7373
"copy": "Copy",
74+
"edit": "Edit",
75+
"editMessage": "Edit message",
76+
"save": "Save",
77+
"cancel": "Cancel",
7478
"regenerate": "Regenerate",
7579
"branch": "Branch from here",
7680
"jumpToLatest": "Jump to latest"

core/http/react-ui/public/locales/es/chat.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
},
7272
"actions": {
7373
"copy": "Copiar",
74+
"edit": "Editar",
75+
"editMessage": "Editar mensaje",
76+
"save": "Guardar",
77+
"cancel": "Cancelar",
7478
"regenerate": "Regenerar",
7579
"jumpToLatest": "Jump to latest"
7680
},

core/http/react-ui/public/locales/id/chat.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
},
7272
"actions": {
7373
"copy": "Salin",
74+
"edit": "Edit",
75+
"editMessage": "Edit pesan",
76+
"save": "Simpan",
77+
"cancel": "Batal",
7478
"regenerate": "Hasilkan ulang",
7579
"jumpToLatest": "Lompat ke terbaru"
7680
},

core/http/react-ui/public/locales/it/chat.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
},
7272
"actions": {
7373
"copy": "Copia",
74+
"edit": "Modifica",
75+
"editMessage": "Modifica messaggio",
76+
"save": "Salva",
77+
"cancel": "Annulla",
7478
"regenerate": "Rigenera",
7579
"jumpToLatest": "Torna in fondo"
7680
},

core/http/react-ui/public/locales/ko/chat.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
},
7272
"actions": {
7373
"copy": "복사",
74+
"edit": "편집",
75+
"editMessage": "메시지 편집",
76+
"save": "저장",
77+
"cancel": "취소",
7478
"regenerate": "다시 생성",
7579
"jumpToLatest": "Jump to latest"
7680
},

core/http/react-ui/public/locales/zh-CN/chat.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
},
7272
"actions": {
7373
"copy": "复制",
74+
"edit": "编辑",
75+
"editMessage": "编辑消息",
76+
"save": "保存",
77+
"cancel": "取消",
7478
"regenerate": "重新生成",
7579
"jumpToLatest": "Jump to latest"
7680
},

core/http/react-ui/src/App.css

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3540,6 +3540,37 @@ button.collapsible-header:focus-visible {
35403540
background: var(--color-primary-light);
35413541
}
35423542

3543+
.chat-message-edit {
3544+
display: flex;
3545+
flex-direction: column;
3546+
gap: var(--spacing-sm);
3547+
min-width: min(32rem, 60vw);
3548+
}
3549+
3550+
.chat-message-edit-input {
3551+
width: 100%;
3552+
min-height: 6rem;
3553+
resize: vertical;
3554+
border: 1px solid var(--color-primary-border);
3555+
border-radius: var(--radius-md);
3556+
background: var(--color-bg-primary);
3557+
color: var(--color-text-primary);
3558+
font: inherit;
3559+
line-height: 1.5;
3560+
padding: var(--spacing-sm);
3561+
}
3562+
3563+
.chat-message-edit-input:focus {
3564+
outline: 2px solid var(--color-primary-light);
3565+
outline-offset: 1px;
3566+
}
3567+
3568+
.chat-message-edit-actions {
3569+
display: flex;
3570+
justify-content: flex-end;
3571+
gap: var(--spacing-xs);
3572+
}
3573+
35433574
.chat-message-system {
35443575
align-self: center;
35453576
max-width: 90%;

core/http/react-ui/src/pages/Chat.jsx

Lines changed: 115 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,24 @@ function UserMessageContent({ content, files }) {
287287
)
288288
}
289289

290+
function editableMessageText(message) {
291+
if (typeof message.content === 'string') return message.content
292+
if (!Array.isArray(message.content)) return null
293+
const textBlock = message.content.find(block => block?.type === 'text')
294+
return typeof textBlock?.text === 'string' ? textBlock.text : null
295+
}
296+
297+
function withEditedMessageText(message, text) {
298+
if (typeof message.content === 'string') return { ...message, content: text }
299+
const textIndex = message.content.findIndex(block => block?.type === 'text')
300+
return {
301+
...message,
302+
content: message.content.map((block, index) =>
303+
index === textIndex ? { ...block, text } : block
304+
),
305+
}
306+
}
307+
290308
export default function Chat() {
291309
const { model: urlModel } = useParams()
292310
const { addToast } = useOutletContext()
@@ -329,6 +347,8 @@ export default function Chat() {
329347
const [clientMCPServers, setClientMCPServers] = useState(() => loadClientMCPServers())
330348
const [confirmDialog, setConfirmDialog] = useState(null)
331349
const [completionGlowIdx, setCompletionGlowIdx] = useState(-1)
350+
const [editingMessageIndex, setEditingMessageIndex] = useState(null)
351+
const [messageEditDraft, setMessageEditDraft] = useState('')
332352
const prevStreamingRef = useRef(false)
333353
const {
334354
connect: mcpConnect, disconnect: mcpDisconnect, disconnectAll: mcpDisconnectAll,
@@ -545,6 +565,33 @@ export default function Chat() {
545565
updateChatSettings(activeChat.id, { clientMCPServers: next })
546566
}, [activeChat, updateChatSettings])
547567

568+
const startMessageEdit = useCallback((index, message) => {
569+
const text = editableMessageText(message)
570+
if (text === null) return
571+
setEditingMessageIndex(index)
572+
setMessageEditDraft(text)
573+
}, [])
574+
575+
const cancelMessageEdit = useCallback(() => {
576+
setEditingMessageIndex(null)
577+
setMessageEditDraft('')
578+
}, [])
579+
580+
const saveMessageEdit = useCallback(() => {
581+
if (!activeChat || isStreaming || editingMessageIndex === null || !messageEditDraft.trim()) return
582+
const message = activeChat.history[editingMessageIndex]
583+
if (!message || editableMessageText(message) === null) return
584+
const history = activeChat.history.map((item, index) =>
585+
index === editingMessageIndex ? withEditedMessageText(item, messageEditDraft) : item
586+
)
587+
updateChatSettings(activeChat.id, { history })
588+
cancelMessageEdit()
589+
}, [activeChat, isStreaming, editingMessageIndex, messageEditDraft, updateChatSettings, cancelMessageEdit])
590+
591+
useEffect(() => {
592+
cancelMessageEdit()
593+
}, [activeChat?.id, isStreaming, cancelMessageEdit])
594+
548595
// Load initial message from home page
549596
const homeDataProcessed = useRef(false)
550597
useEffect(() => {
@@ -1170,40 +1217,80 @@ export default function Chat() {
11701217
{msg.role === 'assistant' && activeChat.model && (
11711218
<span className="chat-message-model">{activeChat.model}</span>
11721219
)}
1173-
<div className="chat-message-content">
1174-
{msg.role === 'user' ? (
1175-
<UserMessageContent content={msg.content} files={msg.files} />
1176-
) : (
1177-
<div dangerouslySetInnerHTML={{
1178-
__html: canvasMode
1179-
? renderMarkdownWithArtifacts(typeof msg.content === 'string' ? msg.content : '', i)
1180-
: renderMarkdown(typeof msg.content === 'string' ? msg.content : '')
1181-
}} />
1182-
)}
1183-
</div>
1220+
{editingMessageIndex === i ? (
1221+
<div className="chat-message-edit">
1222+
<textarea
1223+
autoFocus
1224+
className="chat-message-edit-input"
1225+
value={messageEditDraft}
1226+
onChange={(event) => setMessageEditDraft(event.target.value)}
1227+
onKeyDown={(event) => {
1228+
if (event.key === 'Escape') cancelMessageEdit()
1229+
}}
1230+
aria-label={t('actions.editMessage')}
1231+
/>
1232+
<div className="chat-message-edit-actions">
1233+
<button
1234+
type="button"
1235+
className="btn btn-primary btn-sm"
1236+
onClick={saveMessageEdit}
1237+
disabled={!messageEditDraft.trim()}
1238+
>
1239+
{t('actions.save')}
1240+
</button>
1241+
<button
1242+
type="button"
1243+
className="btn btn-secondary btn-sm"
1244+
onClick={cancelMessageEdit}
1245+
>
1246+
{t('actions.cancel')}
1247+
</button>
1248+
</div>
1249+
</div>
1250+
) : (
1251+
<div className="chat-message-content">
1252+
{msg.role === 'user' ? (
1253+
<UserMessageContent content={msg.content} files={msg.files} />
1254+
) : (
1255+
<div dangerouslySetInnerHTML={{
1256+
__html: canvasMode
1257+
? renderMarkdownWithArtifacts(typeof msg.content === 'string' ? msg.content : '', i)
1258+
: renderMarkdown(typeof msg.content === 'string' ? msg.content : '')
1259+
}} />
1260+
)}
1261+
</div>
1262+
)}
11841263
{msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.includes('Error:') && (
11851264
<a href="/app/traces?tab=backend" className="chat-error-trace-link">
11861265
<i className="fas fa-wave-square" /> {t('errors.viewTraces')}
11871266
</a>
11881267
)}
1189-
<div className="chat-message-actions">
1190-
<button onClick={() => copyMessage(msg.content)} title={t('actions.copy')}>
1191-
<i className="fas fa-copy" />
1192-
</button>
1193-
{msg.role === 'assistant' && !isStreaming && (
1194-
<button onClick={() => handleRegenerate(i)} title={t('actions.regenerate')}>
1195-
<i className="fas fa-rotate" />
1268+
{editingMessageIndex !== i && (
1269+
<div className="chat-message-actions">
1270+
<button onClick={() => copyMessage(msg.content)} title={t('actions.copy')}>
1271+
<i className="fas fa-copy" />
11961272
</button>
1197-
)}
1198-
{msg.role === 'assistant' && !isStreaming && (
1199-
<button
1200-
onClick={() => { forkChat(activeChat.id, i + 1); addToast(t('toasts.forked'), 'success', 2000) }}
1201-
title={t('actions.branch')}
1202-
>
1203-
<i className="fas fa-code-branch" />
1204-
</button>
1205-
)}
1206-
</div>
1273+
{(msg.role === 'user' || msg.role === 'assistant') &&
1274+
editableMessageText(msg) !== null && !isStreaming && (
1275+
<button onClick={() => startMessageEdit(i, msg)} title={t('actions.edit')}>
1276+
<i className="fas fa-pen" />
1277+
</button>
1278+
)}
1279+
{msg.role === 'assistant' && !isStreaming && (
1280+
<button onClick={() => handleRegenerate(i)} title={t('actions.regenerate')}>
1281+
<i className="fas fa-rotate" />
1282+
</button>
1283+
)}
1284+
{msg.role === 'assistant' && !isStreaming && (
1285+
<button
1286+
onClick={() => { forkChat(activeChat.id, i + 1); addToast(t('toasts.forked'), 'success', 2000) }}
1287+
title={t('actions.branch')}
1288+
>
1289+
<i className="fas fa-code-branch" />
1290+
</button>
1291+
)}
1292+
</div>
1293+
)}
12071294
</div>
12081295
</div>
12091296
)

0 commit comments

Comments
 (0)