diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py
index 9862b1edf5..6c1e2119a0 100644
--- a/backend/open_webui/routers/chats.py
+++ b/backend/open_webui/routers/chats.py
@@ -8,6 +8,7 @@ from fastapi.responses import StreamingResponse
from open_webui.utils.misc import get_message_list
+from open_webui.utils.middleware import serialize_output
from open_webui.socket.main import get_event_emitter
from open_webui.models.chats import (
ChatForm,
@@ -967,6 +968,14 @@ async def update_chat_by_id(
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if chat:
updated_chat = {**chat.chat, **form_data.chat}
+
+ # Re-derive content from output for assistant messages so that
+ # frontend edits to output items are always reflected in content.
+ # serialize_output() is the single source of truth for this conversion.
+ for msg in updated_chat.get('history', {}).get('messages', {}).values():
+ if msg.get('role') == 'assistant' and msg.get('output'):
+ msg['content'] = serialize_output(msg['output'])
+
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
return ChatResponse(**chat.model_dump())
else:
diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte
index cf512dccfb..ea6c89034d 100644
--- a/src/lib/components/chat/Messages.svelte
+++ b/src/lib/components/chat/Messages.svelte
@@ -159,11 +159,21 @@
if (!$temporaryChatEnabled) {
history = history;
await tick();
- await updateChatById(localStorage.token, chatId, {
+ const res = await updateChatById(localStorage.token, chatId, {
history: history,
messages: messages
});
+ // Refresh local message content from backend (e.g. re-derived via serialize_output)
+ if (res?.chat?.history?.messages) {
+ for (const [id, msg] of Object.entries(res.chat.history.messages)) {
+ if (history.messages[id] && (msg as any).content) {
+ history.messages[id].content = (msg as any).content;
+ }
+ }
+ history = history;
+ }
+
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
}
@@ -317,7 +327,7 @@
await updateChat();
};
- const editMessage = async (messageId, { content, files }, submit = true) => {
+ const editMessage = async (messageId, { content, files, output = undefined }, submit = true) => {
if ((selectedModels ?? []).filter((id) => id).length === 0) {
toast.error($i18n.t('Model not selected'));
return;
@@ -361,7 +371,7 @@
}
} else {
if (submit) {
- // New response message
+ // New response message (Save As Copy)
const responseMessageId = uuidv4();
const message = history.messages[messageId];
const parentId = message.parentId;
@@ -373,6 +383,7 @@
childrenIds: [],
files: undefined,
content: content,
+ output: output ?? undefined,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
@@ -392,6 +403,9 @@
// Edit response message
history.messages[messageId].originalContent = history.messages[messageId].content;
history.messages[messageId].content = content;
+ if (output !== undefined) {
+ history.messages[messageId].output = output;
+ }
await updateChat();
}
}
diff --git a/src/lib/components/chat/Messages/OutputEditView.svelte b/src/lib/components/chat/Messages/OutputEditView.svelte
new file mode 100644
index 0000000000..8a58feb220
--- /dev/null
+++ b/src/lib/components/chat/Messages/OutputEditView.svelte
@@ -0,0 +1,367 @@
+
+
+
+
+
+
+
+
+
+
+ {#if viewMode === 'json'}
+
+ {#if jsonError}
+
{jsonError}
+ {/if}
+ {:else}
+
+
+ {#each displayItems as di, idx}
+
+
+
+
+ {getItemLabel(di)}
+
+
+
+
+
+ {#if di.type === 'message'}
+
+
+
+
+
+
+
+ {/each}
+
+ {#if displayItems.length === 0}
+
+ {$i18n.t('No output items')}
+
+ {/if}
+
+ {/if}
+
diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte
index 2622c92029..8acec479c6 100644
--- a/src/lib/components/chat/Messages/ResponseMessage.svelte
+++ b/src/lib/components/chat/Messages/ResponseMessage.svelte
@@ -63,6 +63,7 @@
import RegenerateMenu from './ResponseMessage/RegenerateMenu.svelte';
import StatusHistory from './ResponseMessage/StatusHistory.svelte';
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
+ import OutputEditView from './OutputEditView.svelte';
interface MessageType {
id: string;
@@ -177,6 +178,7 @@
let edit = false;
let editedContent = '';
+ let editedOutput: any[] | null = null;
let editTextAreaElement: HTMLTextAreaElement;
let messageIndexEdit = false;
@@ -368,39 +370,70 @@
return restoredContent;
}
+ /** Extract plain text from output items for immediate display after edit.
+ * NOT a serialize_output port — just grabs text parts. Backend re-serializes
+ * the full rich content (with blocks) on save. */
+ function extractTextFromOutput(output: any[]): string {
+ return output
+ .filter((item) => item.type === 'message')
+ .flatMap((item) => (item.content ?? []).map((p: any) => p.text ?? ''))
+ .join('\n')
+ .trim();
+ }
+
const editMessageHandler = async () => {
edit = true;
- editedContent = preprocessForEditing(message.content);
+ if (message.output?.length) {
+ // Structured edit: use the block editor
+ editedOutput = structuredClone(message.output);
+ } else {
+ // Legacy text edit: use the textarea
+ editedContent = preprocessForEditing(message.content);
+ }
await tick();
- const messagesContainer = document.getElementById('messages-container');
- const savedScrollTop = messagesContainer?.scrollTop;
+ if (!editedOutput && editTextAreaElement) {
+ const messagesContainer = document.getElementById('messages-container');
+ const savedScrollTop = messagesContainer?.scrollTop;
- editTextAreaElement.style.height = '';
- editTextAreaElement.style.height = `${editTextAreaElement.scrollHeight}px`;
+ editTextAreaElement.style.height = '';
+ editTextAreaElement.style.height = `${editTextAreaElement.scrollHeight}px`;
- if (messagesContainer) messagesContainer.scrollTop = savedScrollTop;
+ if (messagesContainer) messagesContainer.scrollTop = savedScrollTop;
+ }
};
const editMessageConfirmHandler = async () => {
- const messageContent = postprocessAfterEditing(editedContent ? editedContent : '');
- editMessage(message.id, { content: messageContent }, false);
+ if (editedOutput) {
+ // Structured edit: keep original rich content for immediate display;
+ // backend will re-derive content from output on save.
+ editMessage(message.id, { content: message.content, output: editedOutput }, false);
+ } else {
+ // Legacy text edit
+ const messageContent = postprocessAfterEditing(editedContent ?? '');
+ editMessage(message.id, { content: messageContent }, false);
+ }
edit = false;
editedContent = '';
+ editedOutput = null;
await tick();
};
const saveAsCopyHandler = async () => {
- const messageContent = postprocessAfterEditing(editedContent ? editedContent : '');
-
- editMessage(message.id, { content: messageContent });
+ if (editedOutput) {
+ editMessage(message.id, { content: message.content, output: editedOutput });
+ } else {
+ const messageContent = postprocessAfterEditing(editedContent ?? '');
+ editMessage(message.id, { content: messageContent });
+ }
edit = false;
editedContent = '';
+ editedOutput = null;
await tick();
};
@@ -408,6 +441,7 @@
const cancelEditMessage = async () => {
edit = false;
editedContent = '';
+ editedOutput = null;
await tick();
};
@@ -709,34 +743,45 @@
{/if}
{#if edit === true}
-
-