diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py
index 19e23cf73..6db969261 100644
--- a/backend/open_webui/utils/middleware.py
+++ b/backend/open_webui/utils/middleware.py
@@ -455,7 +455,7 @@ def serialize_output(output: list) -> str:
Convert OR-aligned output items to HTML for display.
For LLM consumption, use convert_output_to_messages() instead.
"""
- content = ''
+ parts: list[str] = []
# First pass: collect function_call_output items by call_id for lookup
tool_outputs = {}
@@ -472,53 +472,48 @@ def serialize_output(output: list) -> str:
if 'text' in content_part:
text = content_part.get('text', '').strip()
if text:
- content = f'{content}{text}\n'
+ parts.append(text)
elif item_type == 'function_call':
- # Render tool call inline with its result (if available)
- if content and not content.endswith('\n'):
- content += '\n'
-
call_id = item.get('call_id', '')
name = item.get('name', '')
arguments = item.get('arguments', '')
result_item = tool_outputs.get(call_id)
if result_item:
- result_text = ''
+ result_parts: list[str] = []
for result_output in result_item.get('output', []):
if 'text' in result_output:
output_text = result_output.get('text', '')
- result_text += str(output_text) if not isinstance(output_text, str) else output_text
+ result_parts.append(str(output_text) if not isinstance(output_text, str) else output_text)
+ result_text = ''.join(result_parts)
files = result_item.get('files')
embeds = result_item.get('embeds', '')
- content += f'\nTool Executed
\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n \n'
+ parts.append(f'\nTool Executed
\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n ')
else:
- content += f'\nExecuting...
\n \n'
+ parts.append(f'\nExecuting...
\n ')
elif item_type == 'function_call_output':
# Already handled inline with function_call above
pass
elif item_type in _OPENAI_TOOL_DISPLAY_NAMES:
- if content and not content.endswith('\n'):
- content += '\n'
status = item.get('status', 'in_progress')
done = status in ('completed', 'failed', 'incomplete') or idx != len(output) - 1
- content += _render_openai_tool_call_handler(item, done)
+ parts.append(_render_openai_tool_call_handler(item, done).rstrip('\n'))
elif item_type == 'reasoning':
- reasoning_content = ''
+ reasoning_parts: list[str] = []
# Check for 'summary' (new structure) or 'content' (legacy/fallback)
source_list = item.get('summary', []) or item.get('content', [])
for content_part in source_list:
if 'text' in content_part:
- reasoning_content += content_part.get('text', '')
+ reasoning_parts.append(content_part.get('text', ''))
elif 'summary' in content_part: # Handle potential nested logic if any
pass
- reasoning_content = reasoning_content.strip()
+ reasoning_content = ''.join(reasoning_parts).strip()
duration = item.get('duration')
status = item.get('status', 'in_progress')
@@ -527,9 +522,6 @@ def serialize_output(output: list) -> str:
# render as done (a subsequent item means reasoning is complete)
is_last_item = idx == len(output) - 1
- if content and not content.endswith('\n'):
- content += '\n'
-
display = html.escape(
'\n'.join(
(f'> {line}' if not line.startswith('>') else line) for line in reasoning_content.splitlines()
@@ -537,19 +529,22 @@ def serialize_output(output: list) -> str:
)
if status == 'completed' or duration is not None or not is_last_item:
- content = f'{content}\nThought for {duration or 0} seconds
\n{display}\n \n'
+ parts.append(f'\nThought for {duration or 0} seconds
\n{display}\n ')
else:
- content = f'{content}\nThinking…
\n{display}\n \n'
+ parts.append(f'\nThinking…
\n{display}\n ')
elif item_type == 'open_webui:code_interpreter':
+ # Code interpreter needs to inspect/mutate prior accumulated content
+ # to strip trailing unclosed code fences — materialize only here.
+ content = '\n'.join(parts)
content_stripped, original_whitespace = split_content_and_whitespace(content)
if is_opening_code_block(content_stripped):
content = content_stripped.rstrip('`').rstrip() + original_whitespace
else:
content = content_stripped + original_whitespace
- if content and not content.endswith('\n'):
- content += '\n'
+ # Re-split back into parts list after mutation
+ parts = [content] if content else []
# Render the code_interpreter item as a block
# so the frontend Collapsible renders "Analyzing..."/"Analyzed".
@@ -575,11 +570,11 @@ def serialize_output(output: list) -> str:
output_attr = f' output="{html.escape(output_json)}"'
if status == 'completed' or duration is not None or not is_last_item:
- content += f'\nAnalyzed
\n{display}\n \n'
+ parts.append(f'\nAnalyzed
\n{display}\n ')
else:
- content += f'\nAnalyzing…
\n{display}\n \n'
+ parts.append(f'\nAnalyzing…
\n{display}\n ')
- return content.strip()
+ return '\n'.join(parts).strip()
def deep_merge(target, source):