From e4447f5262c0bda0857ee93cafe09a9483455498 Mon Sep 17 00:00:00 2001 From: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:30:10 +0300 Subject: [PATCH] fix: respect content edits and deleted blocks in output --- backend/open_webui/utils/middleware.py | 47 ++++++++++++++++-- backend/open_webui/utils/misc.py | 66 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index e96faf3c1e..a1d201ca6c 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -98,6 +98,7 @@ from open_webui.utils.misc import ( convert_logit_bias_input_to_json, get_content_from_message, convert_output_to_messages, + filter_output_by_content, strip_empty_content_blocks, ) from open_webui.utils.tools import ( @@ -2095,16 +2096,52 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]: For assistant messages with 'output' field, produces properly formatted OpenAI-style messages (tool_calls + tool results). Strips 'output' before LLM. + Respects content edits and dropped
blocks by filtering output items + against the stored content field before conversion. """ processed = [] for message in messages: if message.get('role') == 'assistant' and message.get('output'): - # Use output items for clean OpenAI-format messages - output_messages = convert_output_to_messages(message['output'], raw=True) - if output_messages: - processed.extend(output_messages) - continue + # normalize; guard against None or list-typed content + content = message.get('content', '') + if not isinstance(content, str): + content = '' + + # Drop output items for
blocks removed from content + filtered_output = filter_output_by_content(message['output'], content) + + # Split content around
blocks; assign each text segment to + # the corresponding message item in order → preserves pre/post-tool placement + text_segs = [ + s.strip() + for s in re.split(r']*>.*?
', content, flags=re.S) + ] + seg_idx = 0 + modified_output = [] + for item in filtered_output: + if item.get('type') == 'message': + text = text_segs[seg_idx] if seg_idx < len(text_segs) else '' + seg_idx += 1 + modified_output.append({ + **item, + 'content': [{'type': 'output_text', 'text': text}], + }) + else: + modified_output.append(item) + + output_messages = convert_output_to_messages(modified_output, raw=True) + # Trailing segments not consumed by message items → append as plain text + trailing = '\n'.join(s for s in text_segs[seg_idx:] if s).strip() + if trailing: + output_messages.append({'role': 'assistant', 'content': trailing}) + elif not output_messages: + # No structured output at all; fall back to plain text from content + plain = '\n'.join(s for s in text_segs if s).strip() + if plain: + output_messages = [{'role': 'assistant', 'content': plain}] + processed.extend(output_messages) + continue # Strip 'output' field before adding (LLM shouldn't see it) clean_message = {k: v for k, v in message.items() if k != 'output'} diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 345165db28..87e2ce5fa0 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -276,6 +276,72 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]: return messages +def filter_output_by_content(output: list, content: str) -> list: + """ + Drop output items whose
block was removed from content. + + Matches by id attribute. Items with no ID are kept for backward + compatibility with content serialized before id= was added. + Items whose type is entirely absent from content are dropped wholesale. + Legacy: if a type is present with no id= attrs, keep the first N items + of that type where N = the number of blocks of that type remaining in + content, so individually deleted blocks are respected even without IDs. + """ + if not isinstance(output, list): + return [] + if not isinstance(content, str): + content = '' + + present_ids = set(re.findall(r']*\bid="([^"]+)"', content)) + present_types = set(re.findall(r']*\btype="([^"]+)"', content)) + + # Map output item type →
type attribute value + DETAILS_TYPE = { + 'function_call': 'tool_calls', + 'function_call_output': 'tool_calls', + 'reasoning': 'reasoning', + 'open_webui:code_interpreter': 'code_interpreter', + } + + # No
blocks: pass through if no structured item has an ID (pre-serialization legacy) + if not present_types: + if not any(item.get('call_id') or item.get('id') + for item in output if DETAILS_TYPE.get(item.get('type', ''))): + return list(output) + + # types with ≥1 id= in content; others treated as legacy (pre-id=) + types_with_ids = set(re.findall(r']*\btype="([^"]+)"[^>]*\bid="[^"]*"', content)) + types_with_ids |= set(re.findall(r']*\bid="[^"]*"[^>]*\btype="([^"]+)"', content)) + + # Legacy (no id=): count remaining blocks per type for positional matching + type_block_counts = {} + for t in re.findall(r']*\btype="([^"]+)"', content): + type_block_counts[t] = type_block_counts.get(t, 0) + 1 + + filtered = [] + seen_legacy = {} # kept count per legacy type + for item in output: + details_type = DETAILS_TYPE.get(item.get('type', '')) + if details_type is None: + filtered.append(item) # non-visual item (e.g. 'message'): always keep + continue + if details_type not in present_types: + continue # entire type removed from content: drop + if details_type not in types_with_ids: + # Legacy (no id=): keep first N items, N = block count in content + n = type_block_counts.get(details_type, 0) + if seen_legacy.get(details_type, 0) < n: + filtered.append(item) + seen_legacy[details_type] = seen_legacy.get(details_type, 0) + 1 + continue + item_id = item.get('call_id') or item.get('id', '') + if not item_id or item_id in present_ids: + filtered.append(item) # no ID (old format) or ID still present: keep + # else: ID not found in content → user deleted this block: drop + + return filtered + + def get_last_user_message(messages: list[dict]) -> Optional[str]: message = get_last_user_message_item(messages) if message is None: