Skip to content

Commit b5aba9a

Browse files
committed
fix: assistant message editing and continuation
1 parent 306d01f commit b5aba9a

3 files changed

Lines changed: 144 additions & 36 deletions

File tree

backend/open_webui/routers/chats.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi.responses import StreamingResponse
77

88

9-
from open_webui.utils.misc import get_message_list
9+
from open_webui.utils.misc import get_message_list, reconcile_output_with_content
1010
from open_webui.socket.main import get_event_emitter
1111
from open_webui.models.chats import (
1212
ChatForm,
@@ -888,6 +888,15 @@ async def update_chat_by_id(
888888
chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
889889
if chat:
890890
updated_chat = {**chat.chat, **form_data.chat}
891+
892+
# Reconcile output with content for any edited assistant messages so
893+
# the DB is always consistent and process_messages_with_output can use
894+
# output as the source of truth without re-parsing content at read time.
895+
messages = updated_chat.get('history', {}).get('messages', {})
896+
for mid, msg in messages.items():
897+
if msg.get('role') == 'assistant' and msg.get('output'):
898+
messages[mid] = reconcile_output_with_content(msg)
899+
891900
chat = Chats.update_chat_by_id(id, updated_chat, db=db)
892901
return ChatResponse(**chat.model_dump())
893902
else:

backend/open_webui/utils/middleware.py

Lines changed: 55 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -481,9 +481,9 @@ def serialize_output(output: list) -> str:
481481
)
482482

483483
if status == 'completed' or duration is not None or not is_last_item:
484-
content = f'{content}<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>\n'
484+
content = f'{content}<details type="reasoning" done="true" id="{item.get("id", "")}" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>\n'
485485
else:
486-
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
486+
content = f'{content}<details type="reasoning" done="false" id="{item.get("id", "")}">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
487487

488488
elif item_type == 'open_webui:code_interpreter':
489489
content_stripped, original_whitespace = split_content_and_whitespace(content)
@@ -519,9 +519,9 @@ def serialize_output(output: list) -> str:
519519
output_attr = f' output="{html.escape(output_json)}"'
520520

521521
if status == 'completed' or duration is not None or not is_last_item:
522-
content += f'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>\n'
522+
content += f'<details type="code_interpreter" done="true" id="{item.get("id", "")}" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>\n'
523523
else:
524-
content += f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>\n'
524+
content += f'<details type="code_interpreter" done="false" id="{item.get("id", "")}"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>\n'
525525

526526
return content.strip()
527527

@@ -2066,15 +2066,23 @@ async def convert_url_images_to_base64(form_data):
20662066
return form_data
20672067

20682068

2069-
def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[dict]]:
2069+
def load_messages_from_db(
2070+
chat_id: str, message_id: str, require_done: bool = False
2071+
) -> Optional[list[dict]]:
20702072
"""
20712073
Load the message chain from DB up to message_id,
20722074
keeping only LLM-relevant fields (role, content, output).
2075+
2076+
If require_done is True, returns None when the anchor message is not
2077+
marked done — prevents loading a partial mid-stream output as context.
20732078
"""
20742079
messages_map = Chats.get_messages_map_by_chat_id(chat_id)
20752080
if not messages_map:
20762081
return None
20772082

2083+
if require_done and not messages_map.get(message_id, {}).get('done'):
2084+
return None
2085+
20782086
db_messages = get_message_list(messages_map, message_id)
20792087
if not db_messages:
20802088
return None
@@ -2088,6 +2096,8 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
20882096
20892097
For assistant messages with 'output' field, produces properly formatted
20902098
OpenAI-style messages (tool_calls + tool results). Strips 'output' before LLM.
2099+
The DB is kept consistent at write-time (update_chat_by_id), so output can
2100+
be used directly as the source of truth here.
20912101
"""
20922102
processed = []
20932103

@@ -2146,37 +2156,47 @@ async def process_chat_payload(request, form_data, user, metadata, model):
21462156
# Load messages from DB when available — DB preserves structured 'output' items
21472157
# which the frontend strips, causing tool calls to be merged into content.
21482158
chat_id = metadata.get('chat_id')
2159+
message_id = metadata.get('message_id')
21492160
parent_message_id = metadata.get('parent_message_id')
2150-
2151-
if chat_id and parent_message_id and not chat_id.startswith('local:'):
2152-
db_messages = load_messages_from_db(chat_id, parent_message_id)
2153-
if db_messages:
2154-
system_message = get_system_message(form_data.get('messages', []))
2155-
form_data['messages'] = [system_message, *db_messages] if system_message else db_messages
2156-
2157-
# Inject image files into content as image_url parts (mirrors frontend logic)
2158-
for message in form_data['messages']:
2159-
image_files = [
2160-
f
2161-
for f in message.get('files', [])
2162-
if f.get('type') == 'image' or (f.get('content_type') or '').startswith('image/')
2163-
]
2164-
if message.get('role') == 'user' and image_files:
2165-
text_content = message.get('content', '')
2166-
if isinstance(text_content, str):
2167-
message['content'] = [
2168-
{'type': 'text', 'text': text_content},
2169-
*[
2170-
{
2171-
'type': 'image_url',
2172-
'image_url': {'url': f['url']},
2173-
}
2174-
for f in image_files
2175-
if f.get('url')
2176-
],
2177-
]
2178-
# Strip files field — it's been incorporated into content
2179-
message.pop('files', None)
2161+
db_messages = None
2162+
2163+
if chat_id and not chat_id.startswith('local:'):
2164+
# For "Continue", message_id is the existing assistant message already in the
2165+
# DB — load from it so the reconciled output is included in context.
2166+
# For a new generation, message_id is a fresh UUID not yet saved, so
2167+
# load_messages_from_db returns None and we fall through to parent_message_id.
2168+
if message_id:
2169+
db_messages = load_messages_from_db(chat_id, message_id, require_done=True)
2170+
if db_messages is None and parent_message_id:
2171+
db_messages = load_messages_from_db(chat_id, parent_message_id)
2172+
2173+
if db_messages:
2174+
system_message = get_system_message(form_data.get('messages', []))
2175+
form_data['messages'] = [system_message, *db_messages] if system_message else db_messages
2176+
2177+
# Inject image files into content as image_url parts (mirrors frontend logic)
2178+
for message in form_data['messages']:
2179+
image_files = [
2180+
f
2181+
for f in message.get('files', [])
2182+
if f.get('type') == 'image' or (f.get('content_type') or '').startswith('image/')
2183+
]
2184+
if message.get('role') == 'user' and image_files:
2185+
text_content = message.get('content', '')
2186+
if isinstance(text_content, str):
2187+
message['content'] = [
2188+
{'type': 'text', 'text': text_content},
2189+
*[
2190+
{
2191+
'type': 'image_url',
2192+
'image_url': {'url': f['url']},
2193+
}
2194+
for f in image_files
2195+
if f.get('url')
2196+
],
2197+
]
2198+
# Strip files field — it's been incorporated into content
2199+
message.pop('files', None)
21802200

21812201
# Process messages with OR-aligned output items for clean LLM messages
21822202
form_data['messages'] = process_messages_with_output(form_data.get('messages', []))

backend/open_webui/utils/misc.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,85 @@ def flush_pending():
276276
return messages
277277

278278

279+
def filter_output_by_content(output: list, content: str) -> list:
280+
"""
281+
Drop output items whose <details> block was removed from content.
282+
Matches by id attribute. Falls back to keeping all items for types
283+
that don't yet carry id= (legacy content compatibility).
284+
"""
285+
present_ids = set(re.findall(r'<details\b[^>]*\bid="([^"]+)"', content))
286+
types_present = set(re.findall(r'<details\b[^>]*\btype="([^"]+)"', content))
287+
288+
# Types that carry id= in content (vs legacy types without id=).
289+
types_with_ids: set[str] = set()
290+
for m in re.finditer(r'<details\b(?=[^>]*\btype="([^"]+)")(?=[^>]*\bid=)', content):
291+
types_with_ids.add(m.group(1))
292+
293+
# Map output item type → <details> type attribute value
294+
DETAILS_TYPE = {
295+
'function_call': 'tool_calls',
296+
'function_call_output': 'tool_calls',
297+
'reasoning': 'reasoning',
298+
'open_webui:code_interpreter': 'code_interpreter',
299+
}
300+
301+
filtered = []
302+
for item in output:
303+
details_type = DETAILS_TYPE.get(item.get('type', ''))
304+
if details_type is None:
305+
filtered.append(item)
306+
elif details_type not in types_present:
307+
pass # type removed from content
308+
elif details_type not in types_with_ids:
309+
filtered.append(item)
310+
else:
311+
item_id = item.get('call_id') or item.get('id', '')
312+
if item_id in present_ids:
313+
filtered.append(item)
314+
# else: user removed this block
315+
316+
return filtered
317+
318+
319+
def reconcile_output_with_content(message: dict) -> dict:
320+
"""Align the 'output' list of an assistant message with a user-edited 'content'.
321+
322+
Called at write-time (update_chat_by_id) so the DB always holds a consistent
323+
output list. process_messages_with_output then needs no special-casing.
324+
"""
325+
content = message.get('content', '')
326+
output = message.get('output', [])
327+
328+
# Extract plain text by stripping <details> blocks that serialize_output wrote
329+
edited_text = re.sub(r'<details\b[^>]*>.*?</details>', '', content, flags=re.S).strip()
330+
331+
# Drop output items for <details> blocks the user removed from content
332+
filtered = filter_output_by_content(output, content)
333+
334+
# Update the 'message' output item's text to match the edit
335+
reconciled_output = []
336+
text_applied = False
337+
for item in filtered:
338+
if item.get('type') == 'message' and not text_applied:
339+
reconciled_output.append({
340+
**item,
341+
'content': [{'type': 'output_text', 'text': edited_text}],
342+
})
343+
text_applied = True
344+
else:
345+
reconciled_output.append(item)
346+
347+
# If there was no 'message' item, append one so the text is not lost
348+
if not text_applied and edited_text:
349+
reconciled_output.append({
350+
'type': 'message',
351+
'role': 'assistant',
352+
'content': [{'type': 'output_text', 'text': edited_text}],
353+
})
354+
355+
return {**message, 'output': reconciled_output}
356+
357+
279358
def get_last_user_message(messages: list[dict]) -> Optional[str]:
280359
message = get_last_user_message_item(messages)
281360
if message is None:

0 commit comments

Comments
 (0)