mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-16 04:28:36 +00:00
fix: reconcile assistant output with edited content
This commit is contained in:
parent
f162d4de90
commit
9022b6df7b
4 changed files with 220 additions and 11 deletions
|
|
@ -37,6 +37,7 @@ from pydantic import BaseModel
|
|||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
from open_webui.utils.misc import reconcile_assistant_output
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -930,6 +931,16 @@ async def update_chat_by_id(
|
|||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if chat:
|
||||
updated_chat = {**chat.chat, **form_data.chat}
|
||||
|
||||
# Reconcile assistant message output with edited content so that
|
||||
# stale structured output does not linger in stored history.
|
||||
messages = updated_chat.get('history', {}).get('messages', {})
|
||||
for msg in messages.values():
|
||||
if msg.get('role') == 'assistant' and msg.get('output'):
|
||||
effective = reconcile_assistant_output(msg)
|
||||
if effective is not None:
|
||||
msg['output'] = effective
|
||||
|
||||
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ from open_webui.utils.session_pool import (
|
|||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.anthropic import is_anthropic_url, get_anthropic_models
|
||||
from open_webui.utils.misc import reconcile_assistant_output
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -866,7 +867,10 @@ def convert_to_responses_payload(payload: dict) -> dict:
|
|||
# Check for stored output items (from previous Responses API turn)
|
||||
stored_output = msg.get('output')
|
||||
if stored_output and isinstance(stored_output, list):
|
||||
input_items.extend(_normalize_stored_item(item) for item in stored_output)
|
||||
# Reconcile output with edited content before replay
|
||||
effective_output = reconcile_assistant_output(msg)
|
||||
active_output = effective_output if effective_output is not None else stored_output
|
||||
input_items.extend(_normalize_stored_item(item) for item in active_output)
|
||||
continue
|
||||
|
||||
if role == 'system':
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ from open_webui.utils.misc import (
|
|||
convert_logit_bias_input_to_json,
|
||||
get_content_from_message,
|
||||
convert_output_to_messages,
|
||||
output_id,
|
||||
reconcile_assistant_output,
|
||||
strip_empty_content_blocks,
|
||||
)
|
||||
from open_webui.utils.tools import (
|
||||
|
|
@ -164,11 +166,6 @@ DEFAULT_SOLUTION_TAGS = [('<|begin_of_solution|>', '<|end_of_solution|>')]
|
|||
DEFAULT_CODE_INTERPRETER_TAGS = [('<code_interpreter>', '</code_interpreter>')]
|
||||
|
||||
|
||||
def output_id(prefix: str) -> str:
|
||||
"""Generate OR-style ID: prefix + 24-char hex UUID."""
|
||||
return f'{prefix}_{uuid4().hex[:24]}'
|
||||
|
||||
|
||||
def _split_tool_calls(
|
||||
tool_calls: list[dict],
|
||||
) -> list[dict]:
|
||||
|
|
@ -536,13 +533,16 @@ def serialize_output(output: list) -> str:
|
|||
)
|
||||
)
|
||||
|
||||
item_id = item.get('id', '')
|
||||
id_attr = f' id="{item_id}"' if item_id else ''
|
||||
|
||||
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>'
|
||||
f'<details type="reasoning"{id_attr} 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>'
|
||||
f'<details type="reasoning"{id_attr} done="false">\n<summary>Thinking…</summary>\n{display}\n</details>'
|
||||
)
|
||||
|
||||
elif item_type == 'open_webui:code_interpreter':
|
||||
|
|
@ -581,13 +581,16 @@ def serialize_output(output: list) -> str:
|
|||
output_json = json.dumps({'result': str(ci_output)}, ensure_ascii=False)
|
||||
output_attr = f' output="{html.escape(output_json)}"'
|
||||
|
||||
item_id = item.get('id', '')
|
||||
id_attr = f' id="{item_id}"' if item_id else ''
|
||||
|
||||
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>'
|
||||
f'<details type="code_interpreter"{id_attr} 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>'
|
||||
f'<details type="code_interpreter"{id_attr} done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>'
|
||||
)
|
||||
|
||||
return '\n'.join(parts).strip()
|
||||
|
|
@ -2162,13 +2165,23 @@ 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.
|
||||
|
||||
Output is reconciled with edited content before conversion so that
|
||||
user edits (text changes, deleted <details> blocks) are respected.
|
||||
"""
|
||||
processed = []
|
||||
|
||||
for message in messages:
|
||||
if message.get('role') == 'assistant' and message.get('output'):
|
||||
# Reconcile output with edited content before conversion
|
||||
effective_output = reconcile_assistant_output(message)
|
||||
if effective_output is not None:
|
||||
active_output = effective_output
|
||||
else:
|
||||
active_output = message['output']
|
||||
|
||||
# Use output items for clean OpenAI-format messages
|
||||
output_messages = convert_output_to_messages(message['output'], raw=True)
|
||||
output_messages = convert_output_to_messages(active_output, raw=True)
|
||||
if output_messages:
|
||||
processed.extend(output_messages)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import logging
|
|||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Sequence, Union
|
||||
from collections import defaultdict
|
||||
import json
|
||||
import aiohttp
|
||||
import mimeparse
|
||||
|
|
@ -129,6 +130,11 @@ def get_content_from_message(message: dict) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def output_id(prefix: str) -> str:
|
||||
"""Generate OR-style ID: prefix + 24-char hex UUID."""
|
||||
return f'{prefix}_{uuid.uuid4().hex[:24]}'
|
||||
|
||||
|
||||
def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
||||
"""
|
||||
Convert OR-aligned output items to OpenAI Chat Completion-format messages.
|
||||
|
|
@ -282,6 +288,181 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
|||
return messages
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assistant output reconciliation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mapping from <details type="..."> values to structured output item types.
|
||||
DETAIL_TYPE_TO_OUTPUT_TYPES: dict[str, tuple[str, ...]] = {
|
||||
'tool_calls': ('function_call', 'function_call_output'),
|
||||
'reasoning': ('reasoning',),
|
||||
'code_interpreter': ('open_webui:code_interpreter',),
|
||||
}
|
||||
|
||||
# Regex to split content into alternating text / <details> segments.
|
||||
_DETAILS_SPLIT_RE = re.compile(r'(<details\b[^>]*>[\s\S]*?</details>)', re.IGNORECASE)
|
||||
|
||||
# Regexes to extract type and id attributes from a <details> opening tag.
|
||||
# Using two independent patterns so attribute order does not matter.
|
||||
_DETAIL_TYPE_RE = re.compile(r'\btype=["\']([^"\']*)["\']', re.IGNORECASE)
|
||||
_DETAIL_ID_RE = re.compile(r'\bid=["\']([^"\']*)["\']', re.IGNORECASE)
|
||||
|
||||
|
||||
def _parse_content_segments(content: str) -> tuple[list[tuple[str, Optional[str], Optional[str]]], list[str]]:
|
||||
"""Parse content into ordered segments of text and <details> blocks."""
|
||||
parts = _DETAILS_SPLIT_RE.split(content)
|
||||
segments = []
|
||||
for part in parts:
|
||||
if part and part.lstrip().startswith('<details'):
|
||||
# Search only the opening tag to avoid false positives from
|
||||
# id= or type= appearing inside code/body content.
|
||||
opening = re.match(r'<details\b[^>]*>', part.lstrip())
|
||||
tag = opening.group(0) if opening else ''
|
||||
tm = _DETAIL_TYPE_RE.search(tag)
|
||||
im = _DETAIL_ID_RE.search(tag)
|
||||
segments.append(('details', tm.group(1) if tm else '', im.group(1) if im else ''))
|
||||
else:
|
||||
segments.append(('text', None, None))
|
||||
return segments, parts
|
||||
|
||||
|
||||
def _build_lookup(output: list) -> tuple[dict, dict]:
|
||||
"""Build grouped lookup tables from structured output items."""
|
||||
lookup: dict[str, dict[str, list]] = {}
|
||||
ordinal: dict[str, list[list]] = defaultdict(list)
|
||||
|
||||
for detail_type in DETAIL_TYPE_TO_OUTPUT_TYPES:
|
||||
lookup[detail_type] = {}
|
||||
|
||||
fc_by_call_id: dict[str, dict] = {}
|
||||
fco_by_call_id: dict[str, dict] = {}
|
||||
for item in output:
|
||||
item_type = item.get('type', '')
|
||||
if item_type == 'function_call':
|
||||
call_id = item.get('call_id', '')
|
||||
if call_id:
|
||||
fc_by_call_id[call_id] = item
|
||||
elif item_type == 'function_call_output':
|
||||
call_id = item.get('call_id', '')
|
||||
if call_id:
|
||||
fco_by_call_id[call_id] = item
|
||||
|
||||
for call_id, fc_item in fc_by_call_id.items():
|
||||
bundle = [fc_item]
|
||||
fco = fco_by_call_id.get(call_id)
|
||||
if fco:
|
||||
bundle.append(fco)
|
||||
lookup['tool_calls'][call_id] = bundle
|
||||
ordinal['tool_calls'].append(bundle)
|
||||
|
||||
for item in output:
|
||||
item_type = item.get('type', '')
|
||||
item_id = item.get('id', '')
|
||||
|
||||
if item_type == 'reasoning':
|
||||
bundle = [item]
|
||||
if item_id:
|
||||
lookup['reasoning'][item_id] = bundle
|
||||
ordinal['reasoning'].append(bundle)
|
||||
|
||||
elif item_type == 'open_webui:code_interpreter':
|
||||
bundle = [item]
|
||||
if item_id:
|
||||
lookup['code_interpreter'][item_id] = bundle
|
||||
ordinal['code_interpreter'].append(bundle)
|
||||
|
||||
return lookup, ordinal
|
||||
|
||||
|
||||
def _make_message_item(text: str) -> dict:
|
||||
"""Create a message output item from plain text."""
|
||||
return {
|
||||
'type': 'message',
|
||||
'id': output_id('msg'),
|
||||
'status': 'completed',
|
||||
'role': 'assistant',
|
||||
'content': [{'type': 'output_text', 'text': text}],
|
||||
}
|
||||
|
||||
|
||||
def reconcile_assistant_output(message: dict) -> Optional[list]:
|
||||
"""Reconcile an assistant message's output with its edited content.
|
||||
|
||||
Returns None if no output exists, otherwise a list of effective output
|
||||
items that match the blocks still present in the edited content.
|
||||
"""
|
||||
output = message.get('output')
|
||||
if not output or not isinstance(output, list):
|
||||
return None
|
||||
|
||||
content = message.get('content')
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
return None
|
||||
|
||||
segments, raw_parts = _parse_content_segments(content)
|
||||
|
||||
lookup, ordinal = _build_lookup(output)
|
||||
|
||||
emitted_ids: set[str] = set()
|
||||
ordinal_counters: dict[str, int] = defaultdict(int)
|
||||
|
||||
effective_output: list = []
|
||||
pending_text: list[str] = []
|
||||
raw_idx = 0
|
||||
|
||||
def flush_text():
|
||||
if pending_text:
|
||||
joined = '\n'.join(pending_text).strip()
|
||||
if joined:
|
||||
effective_output.append(_make_message_item(joined))
|
||||
pending_text.clear()
|
||||
|
||||
for seg_type, detail_type, detail_id in segments:
|
||||
raw_part = raw_parts[raw_idx] if raw_idx < len(raw_parts) else ''
|
||||
raw_idx += 1
|
||||
|
||||
if seg_type == 'text':
|
||||
text = raw_part.strip()
|
||||
if text:
|
||||
pending_text.append(text)
|
||||
continue
|
||||
|
||||
if detail_type not in DETAIL_TYPE_TO_OUTPUT_TYPES:
|
||||
pending_text.append(raw_part)
|
||||
continue
|
||||
|
||||
bundle = None
|
||||
match_key = None
|
||||
|
||||
if detail_id and detail_id in lookup.get(detail_type, {}):
|
||||
if detail_id not in emitted_ids:
|
||||
bundle = lookup[detail_type][detail_id]
|
||||
match_key = detail_id
|
||||
|
||||
if bundle is None and not detail_id:
|
||||
idx = ordinal_counters[detail_type]
|
||||
ordinal_list = ordinal.get(detail_type, [])
|
||||
if idx < len(ordinal_list):
|
||||
candidate = ordinal_list[idx]
|
||||
candidate_id = candidate[0].get('id', '') or candidate[0].get('call_id', '')
|
||||
if candidate_id not in emitted_ids:
|
||||
bundle = candidate
|
||||
match_key = candidate_id
|
||||
ordinal_counters[detail_type] = idx + 1
|
||||
|
||||
if bundle is not None:
|
||||
flush_text()
|
||||
effective_output.extend(bundle)
|
||||
if match_key:
|
||||
emitted_ids.add(match_key)
|
||||
else:
|
||||
pending_text.append(raw_part)
|
||||
|
||||
flush_text()
|
||||
|
||||
return effective_output
|
||||
|
||||
|
||||
def get_last_user_message(messages: list[dict]) -> Optional[str]:
|
||||
message = get_last_user_message_item(messages)
|
||||
if message is None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue