This commit is contained in:
Timothy Jaeryang Baek
2026-06-29 13:30:28 -05:00
parent 22a44e67a8
commit 0443ab3a61
8 changed files with 741 additions and 425 deletions
+9 -231
View File
@@ -2,7 +2,6 @@ import ast
import asyncio
import base64
import copy
import html
import inspect
import json
import logging
@@ -378,210 +377,6 @@ def get_citation_source_from_tool_result(
]
def split_content_and_whitespace(content):
content_stripped = content.rstrip()
original_whitespace = content[len(content_stripped) :] if len(content) > len(content_stripped) else ''
return content_stripped, original_whitespace
def is_opening_code_block(content):
backtick_segments = content.split('```')
# Even number of segments means the last backticks are opening a new block
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
_OPENAI_TOOL_DISPLAY_NAMES = {
'web_search_call': 'Web Search',
'file_search_call': 'File Search',
'computer_call': 'Computer Use',
}
def _render_openai_tool_call_handler(item: dict, done: bool) -> str:
"""Render an OpenAI Responses API server-side tool item as a <details> block.
Handles web_search_call, file_search_call, and computer_call items whose
schemas are defined in the openai-python SDK (generated from OpenAPI spec).
"""
item_type = item.get('type', '')
call_id = item.get('id', '')
display_name = _OPENAI_TOOL_DISPLAY_NAMES.get(item_type, item_type)
# Build a short summary of what the tool did
summary = ''
if item_type == 'web_search_call':
action = item.get('action', {})
if isinstance(action, dict):
atype = action.get('type', '')
if atype == 'search':
queries = action.get('queries') or []
query = action.get('query', '')
summary = (
f'Search: {", ".join(str(q) for q in queries)}'
if queries
else (f'Search: {query}' if query else '')
)
elif atype == 'open_page':
summary = f'Open page: {action.get("url", "")}' if action.get('url') else ''
elif atype == 'find_in_page':
summary = f'Find in page: {action.get("pattern", "")}' if action.get('pattern') else ''
elif item_type == 'file_search_call':
queries = item.get('queries', [])
if queries:
summary = f'Queries: {", ".join(str(q) for q in queries)}'
elif item_type == 'computer_call':
action = item.get('action')
actions = item.get('actions')
if isinstance(action, dict):
summary = f'Action: {action.get("type", "unknown")}'
elif isinstance(actions, list) and actions:
summary = f'Actions: {", ".join(a.get("type", "?") for a in actions if isinstance(a, dict))}'
escaped_name = html.escape(display_name)
if done:
return f'<details type="tool_calls" done="true" id="{call_id}" name="{escaped_name}" arguments="">\n<summary>Tool Executed</summary>\n{html.escape(summary)}\n</details>\n'
return f'<details type="tool_calls" done="false" id="{call_id}" name="{escaped_name}" arguments="">\n<summary>Executing...</summary>\n</details>\n'
def serialize_output(output: list) -> str:
"""
Convert OR-aligned output items to HTML for display.
For LLM consumption, use convert_output_to_messages() instead.
"""
parts: list[str] = []
# First pass: collect function_call_output items by call_id for lookup
tool_outputs = {}
for item in output:
if item.get('type') == 'function_call_output':
tool_outputs[item.get('call_id')] = item
# Second pass: render items in order
for idx, item in enumerate(output):
item_type = item.get('type', '')
if item_type == 'message':
for content_part in item.get('content', []):
if 'text' in content_part:
text = content_part.get('text', '').strip()
if text:
parts.append(text)
elif item_type == 'function_call':
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_parts: list[str] = []
for result_output in result_item.get('output', []):
if 'text' in result_output:
output_text = result_output.get('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', '')
parts.append(
f'<details type="tool_calls" done="true" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}" files="{html.escape(json.dumps(files)) if files else ""}" embeds="{html.escape(json.dumps(embeds))}">\n<summary>Tool Executed</summary>\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n</details>'
)
else:
parts.append(
f'<details type="tool_calls" done="false" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}">\n<summary>Executing...</summary>\n</details>'
)
elif item_type == 'function_call_output':
# Already handled inline with function_call above
pass
elif item_type in _OPENAI_TOOL_DISPLAY_NAMES:
status = item.get('status', 'in_progress')
done = status in ('completed', 'failed', 'incomplete') or idx != len(output) - 1
parts.append(_render_openai_tool_call_handler(item, done).rstrip('\n'))
elif item_type == 'reasoning':
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_parts.append(content_part.get('text', ''))
elif 'summary' in content_part: # Handle potential nested logic if any
pass
reasoning_content = ''.join(reasoning_parts).strip()
duration = item.get('duration')
status = item.get('status', 'in_progress')
# Infer completion: if this reasoning item is NOT the last item,
# render as done (a subsequent item means reasoning is complete)
is_last_item = idx == len(output) - 1
display = html.escape(
'\n'.join(
(f'> {line}' if not line.startswith('>') else line) for line in reasoning_content.splitlines()
)
)
if status == 'completed' or duration is not None or not is_last_item:
parts.append(
f'<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>'
)
else:
parts.append(
f'<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>'
)
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
# Re-split back into parts list after mutation
parts = [content] if content else []
# Render the code_interpreter item as a <details> block
# so the frontend Collapsible renders "Analyzing..."/"Analyzed".
code = item.get('code', '').strip()
lang = item.get('lang', 'python')
status = item.get('status', 'in_progress')
duration = item.get('duration')
is_last_item = idx == len(output) - 1
# Build inner content: code block
display = ''
if code:
display = f'```{lang}\n{code}\n```'
# Build output attribute as HTML-escaped JSON for CodeBlock.svelte
ci_output = item.get('output')
output_attr = ''
if ci_output:
if isinstance(ci_output, dict):
output_json = json.dumps(ci_output, ensure_ascii=False)
else:
output_json = json.dumps({'result': str(ci_output)}, ensure_ascii=False)
output_attr = f' output="{html.escape(output_json)}"'
if status == 'completed' or duration is not None or not is_last_item:
parts.append(
f'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>'
)
else:
parts.append(
f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>'
)
return '\n'.join(parts).strip()
def deep_merge(target, source):
"""
Merge source into target recursively (returning new structure).
@@ -3127,7 +2922,6 @@ def update_assistant_message_from_stream(assistant_message, raw):
output, meta = handle_responses_streaming_event(data, assistant_message.get('output', []))
if output:
assistant_message['output'] = output
assistant_message['content'] = serialize_output(output)
if meta and meta.get('usage'):
assistant_message['usage'] = merge_usage(assistant_message.get('usage'), meta['usage'])
continue
@@ -3527,18 +3321,16 @@ async def outlet_filter_handler(ctx):
'output'
)
if content_changed or output_changed:
# If output was modified, re-derive content from it
new_content = message.get('content', original_message.get('content', ''))
if output_changed:
new_content = serialize_output(message['output'])
message_update = {
'originalContent': original_message.get('content'),
**({'output': message['output']} if output_changed else {}),
}
if content_changed:
message_update['content'] = message.get('content', '')
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
outlet_message_id,
{
'content': new_content,
'originalContent': original_message.get('content'),
**({'output': message['output']} if output_changed else {}),
},
message_update,
)
if event_emitter:
@@ -3722,7 +3514,7 @@ async def non_streaming_chat_response_handler(response, ctx):
if ENABLE_API_OUTLET_FILTERS and (content or output):
usage = normalize_usage(response_data.get('usage', {}) or {})
ctx['assistant_message'] = {
'content': content or serialize_output(output),
**({'content': content} if content else {}),
**({'output': output} if output else {}),
**({'usage': usage} if usage else {}),
}
@@ -4242,7 +4034,6 @@ async def streaming_chat_response_handler(response, ctx):
processed_data = {
'output': full_output(),
'content': serialize_output(full_output()),
}
# print(data)
@@ -4412,7 +4203,6 @@ async def streaming_chat_response_handler(response, ctx):
)
data = {
'content': serialize_output(full_output() + pending_fc_items),
'output': full_output() + pending_fc_items,
}
delta_type = 'tool_call'
@@ -4474,7 +4264,6 @@ async def streaming_chat_response_handler(response, ctx):
]
data = {
'content': serialize_output(full_output()),
'output': full_output(),
}
delta_type = 'content'
@@ -4637,18 +4426,15 @@ async def streaming_chat_response_handler(response, ctx):
metadata['chat_id'],
metadata['message_id'],
{
'content': serialize_output(full_output()),
'output': full_output(),
},
)
data = {
'content': serialize_output(full_output()),
'output': full_output(),
}
delta_type = 'content'
else:
data = {
'content': serialize_output(full_output()),
'output': full_output(),
}
delta_type = 'content'
@@ -4791,7 +4577,6 @@ async def streaming_chat_response_handler(response, ctx):
{
'type': 'chat:completion',
'data': {
'content': serialize_output(full_output()),
'output': full_output(),
},
}
@@ -4948,7 +4733,7 @@ async def streaming_chat_response_handler(response, ctx):
display_files = []
for file_item in result.get('files', []):
if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'):
# LLM-only: add as input_image part (invisible to serialize_output)
# LLM-only: add as input_image part, not frontend display output.
output_parts.append({'type': 'input_image', 'image_url': file_item['url']})
else:
# Frontend display (MCP images, audio, etc.)
@@ -5054,7 +4839,6 @@ async def streaming_chat_response_handler(response, ctx):
{
'type': 'chat:completion',
'data': {
'content': serialize_output(output),
'output': frontend_output,
},
}
@@ -5178,7 +4962,6 @@ async def streaming_chat_response_handler(response, ctx):
{
'type': 'chat:completion',
'data': {
'content': serialize_output(output),
'output': output,
},
}
@@ -5305,7 +5088,6 @@ async def streaming_chat_response_handler(response, ctx):
{
'type': 'chat:completion',
'data': {
'content': serialize_output(output),
'output': output,
},
}
@@ -5352,7 +5134,6 @@ async def streaming_chat_response_handler(response, ctx):
)
data = {
'done': True,
'content': serialize_output(output),
'output': output,
'title': title,
**({'usage': usage} if usage else {}),
@@ -5366,7 +5147,6 @@ async def streaming_chat_response_handler(response, ctx):
metadata['message_id'],
{
'done': True,
'content': serialize_output(output),
'output': output,
**({'usage': usage} if usage else {}),
},
@@ -5409,7 +5189,6 @@ async def streaming_chat_response_handler(response, ctx):
)
ctx['assistant_message'] = {
'content': serialize_output(output),
'output': output,
**({'usage': usage} if usage else {}),
}
@@ -5437,7 +5216,6 @@ async def streaming_chat_response_handler(response, ctx):
metadata['message_id'],
{
'done': True,
'content': serialize_output(output),
'output': output,
},
)