Skip to content

Commit 5224265

Browse files
Algorithm5838github-actions[bot]
authored andcommitted
fix: respect content edits and deleted blocks in output
1 parent 7cfb260 commit 5224265

2 files changed

Lines changed: 108 additions & 5 deletions

File tree

backend/open_webui/utils/middleware.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
convert_logit_bias_input_to_json,
9999
get_content_from_message,
100100
convert_output_to_messages,
101+
filter_output_by_content,
101102
strip_empty_content_blocks,
102103
)
103104
from open_webui.utils.tools import (
@@ -2146,16 +2147,52 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
21462147
21472148
For assistant messages with 'output' field, produces properly formatted
21482149
OpenAI-style messages (tool_calls + tool results). Strips 'output' before LLM.
2150+
Respects content edits and dropped <details> blocks by filtering output items
2151+
against the stored content field before conversion.
21492152
"""
21502153
processed = []
21512154

21522155
for message in messages:
21532156
if message.get('role') == 'assistant' and message.get('output'):
2154-
# Use output items for clean OpenAI-format messages
2155-
output_messages = convert_output_to_messages(message['output'], raw=True)
2156-
if output_messages:
2157-
processed.extend(output_messages)
2158-
continue
2157+
# normalize; guard against None or list-typed content
2158+
content = message.get('content', '')
2159+
if not isinstance(content, str):
2160+
content = ''
2161+
2162+
# Drop output items for <details> blocks removed from content
2163+
filtered_output = filter_output_by_content(message['output'], content)
2164+
2165+
# Split content around <details> blocks; assign each text segment to
2166+
# the corresponding message item in order → preserves pre/post-tool placement
2167+
text_segs = [
2168+
s.strip()
2169+
for s in re.split(r'<details\b[^>]*>.*?</details>', content, flags=re.S)
2170+
]
2171+
seg_idx = 0
2172+
modified_output = []
2173+
for item in filtered_output:
2174+
if item.get('type') == 'message':
2175+
text = text_segs[seg_idx] if seg_idx < len(text_segs) else ''
2176+
seg_idx += 1
2177+
modified_output.append({
2178+
**item,
2179+
'content': [{'type': 'output_text', 'text': text}],
2180+
})
2181+
else:
2182+
modified_output.append(item)
2183+
2184+
output_messages = convert_output_to_messages(modified_output, raw=True)
2185+
# Trailing segments not consumed by message items → append as plain text
2186+
trailing = '\n'.join(s for s in text_segs[seg_idx:] if s).strip()
2187+
if trailing:
2188+
output_messages.append({'role': 'assistant', 'content': trailing})
2189+
elif not output_messages:
2190+
# No structured output at all; fall back to plain text from content
2191+
plain = '\n'.join(s for s in text_segs if s).strip()
2192+
if plain:
2193+
output_messages = [{'role': 'assistant', 'content': plain}]
2194+
processed.extend(output_messages)
2195+
continue
21592196

21602197
# Strip 'output' field before adding (LLM shouldn't see it)
21612198
clean_message = {k: v for k, v in message.items() if k != 'output'}

backend/open_webui/utils/misc.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,72 @@ def flush_pending():
283283
return messages
284284

285285

286+
def filter_output_by_content(output: list, content: str) -> list:
287+
"""
288+
Drop output items whose <details> block was removed from content.
289+
290+
Matches by id attribute. Items with no ID are kept for backward
291+
compatibility with content serialized before id= was added.
292+
Items whose type is entirely absent from content are dropped wholesale.
293+
Legacy: if a type is present with no id= attrs, keep the first N items
294+
of that type where N = the number of blocks of that type remaining in
295+
content, so individually deleted blocks are respected even without IDs.
296+
"""
297+
if not isinstance(output, list):
298+
return []
299+
if not isinstance(content, str):
300+
content = ''
301+
302+
present_ids = set(re.findall(r'<details\b[^>]*\bid="([^"]+)"', content))
303+
present_types = set(re.findall(r'<details\b[^>]*\btype="([^"]+)"', content))
304+
305+
# Map output item type → <details> type attribute value
306+
DETAILS_TYPE = {
307+
'function_call': 'tool_calls',
308+
'function_call_output': 'tool_calls',
309+
'reasoning': 'reasoning',
310+
'open_webui:code_interpreter': 'code_interpreter',
311+
}
312+
313+
# No <details> blocks: pass through if no structured item has an ID (pre-serialization legacy)
314+
if not present_types:
315+
if not any(item.get('call_id') or item.get('id')
316+
for item in output if DETAILS_TYPE.get(item.get('type', ''))):
317+
return list(output)
318+
319+
# types with ≥1 id= in content; others treated as legacy (pre-id=)
320+
types_with_ids = set(re.findall(r'<details\b[^>]*\btype="([^"]+)"[^>]*\bid="[^"]*"', content))
321+
types_with_ids |= set(re.findall(r'<details\b[^>]*\bid="[^"]*"[^>]*\btype="([^"]+)"', content))
322+
323+
# Legacy (no id=): count remaining blocks per type for positional matching
324+
type_block_counts = {}
325+
for t in re.findall(r'<details\b[^>]*\btype="([^"]+)"', content):
326+
type_block_counts[t] = type_block_counts.get(t, 0) + 1
327+
328+
filtered = []
329+
seen_legacy = {} # kept count per legacy type
330+
for item in output:
331+
details_type = DETAILS_TYPE.get(item.get('type', ''))
332+
if details_type is None:
333+
filtered.append(item) # non-visual item (e.g. 'message'): always keep
334+
continue
335+
if details_type not in present_types:
336+
continue # entire type removed from content: drop
337+
if details_type not in types_with_ids:
338+
# Legacy (no id=): keep first N items, N = block count in content
339+
n = type_block_counts.get(details_type, 0)
340+
if seen_legacy.get(details_type, 0) < n:
341+
filtered.append(item)
342+
seen_legacy[details_type] = seen_legacy.get(details_type, 0) + 1
343+
continue
344+
item_id = item.get('call_id') or item.get('id', '')
345+
if not item_id or item_id in present_ids:
346+
filtered.append(item) # no ID (old format) or ID still present: keep
347+
# else: ID not found in content → user deleted this block: drop
348+
349+
return filtered
350+
351+
286352
def get_last_user_message(messages: list[dict]) -> Optional[str]:
287353
message = get_last_user_message_item(messages)
288354
if message is None:

0 commit comments

Comments
 (0)