Skip to content

Commit feea6d4

Browse files
Algorithm5838github-actions[bot]
authored andcommitted
fix: respect content edits for assistant messages with output
1 parent ca0a5de commit feea6d4

2 files changed

Lines changed: 80 additions & 7 deletions

File tree

backend/open_webui/utils/middleware.py

Lines changed: 39 additions & 7 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 (
@@ -481,9 +482,9 @@ def serialize_output(output: list) -> str:
481482
)
482483

483484
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'
485+
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'
485486
else:
486-
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
487+
content = f'{content}<details type="reasoning" done="false" id="{item.get("id", "")}">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
487488

488489
elif item_type == 'open_webui:code_interpreter':
489490
content_stripped, original_whitespace = split_content_and_whitespace(content)
@@ -519,9 +520,9 @@ def serialize_output(output: list) -> str:
519520
output_attr = f' output="{html.escape(output_json)}"'
520521

521522
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'
523+
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'
523524
else:
524-
content += f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>\n'
525+
content += f'<details type="code_interpreter" done="false" id="{item.get("id", "")}"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>\n'
525526

526527
return content.strip()
527528

@@ -2079,16 +2080,47 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
20792080
20802081
For assistant messages with 'output' field, produces properly formatted
20812082
OpenAI-style messages (tool_calls + tool results). Strips 'output' before LLM.
2083+
Respects content edits and dropped <details> blocks by filtering output items
2084+
against the stored content field before conversion.
20822085
"""
20832086
processed = []
20842087

20852088
for message in messages:
20862089
if message.get('role') == 'assistant' and message.get('output'):
2087-
# Use output items for clean OpenAI-format messages
2088-
output_messages = convert_output_to_messages(message['output'], raw=True)
2090+
# Drop output items for <details> blocks removed from content
2091+
filtered_output = filter_output_by_content(
2092+
message['output'], message.get('content', '')
2093+
)
2094+
2095+
# Use content for text (respects edits), strip <details> blocks
2096+
edited_text = re.sub(
2097+
r'<details\b[^>]*>.*?</details>', '',
2098+
message.get('content', ''), flags=re.S,
2099+
).strip()
2100+
2101+
# Replace the first message item's text with the edited content,
2102+
# preserving the natural order of structured items (reasoning before
2103+
# text, tool calls in sequence) via convert_output_to_messages.
2104+
used_edited_text = False
2105+
modified_output = []
2106+
for item in filtered_output:
2107+
if item.get('type') == 'message' and not used_edited_text:
2108+
modified_output.append({
2109+
**item,
2110+
'content': [{'type': 'output_text', 'text': edited_text}],
2111+
})
2112+
used_edited_text = True
2113+
else:
2114+
modified_output.append(item)
2115+
2116+
output_messages = convert_output_to_messages(modified_output, raw=True)
20892117
if output_messages:
2118+
if not used_edited_text and edited_text:
2119+
output_messages.append({'role': 'assistant', 'content': edited_text})
20902120
processed.extend(output_messages)
2091-
continue
2121+
elif edited_text:
2122+
processed.append({'role': 'assistant', 'content': edited_text})
2123+
continue
20922124

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

backend/open_webui/utils/misc.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,47 @@ 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+
# A type without id= in content is treated as legacy — keep all of that type.
290+
types_with_ids: set[str] = set()
291+
for m in re.finditer(r'<details\b(?=[^>]*\btype="([^"]+)")(?=[^>]*\bid=)', content):
292+
types_with_ids.add(m.group(1))
293+
294+
# Map output item type → <details> type attribute value
295+
DETAILS_TYPE = {
296+
'function_call': 'tool_calls',
297+
'function_call_output': 'tool_calls',
298+
'reasoning': 'reasoning',
299+
'open_webui:code_interpreter': 'code_interpreter',
300+
}
301+
302+
filtered = []
303+
for item in output:
304+
details_type = DETAILS_TYPE.get(item.get('type', ''))
305+
if details_type is None:
306+
filtered.append(item) # Not a details-backed type — pass through
307+
elif details_type not in types_present:
308+
pass # Type fully removed from content — drop
309+
elif details_type not in types_with_ids:
310+
filtered.append(item) # Legacy type (no id=) — keep all
311+
else:
312+
item_id = item.get('call_id') or item.get('id', '')
313+
if item_id in present_ids:
314+
filtered.append(item) # ID present — keep
315+
# else: user deleted the <details> block — drop
316+
317+
return filtered
318+
319+
279320
def get_last_user_message(messages: list[dict]) -> Optional[str]:
280321
message = get_last_user_message_item(messages)
281322
if message is None:

0 commit comments

Comments
 (0)