mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-12 23:02:35 +00:00
fix: respect content edits and deleted blocks in output
This commit is contained in:
parent
f102060a6d
commit
e4447f5262
2 changed files with 108 additions and 5 deletions
|
|
@ -98,6 +98,7 @@ from open_webui.utils.misc import (
|
|||
convert_logit_bias_input_to_json,
|
||||
get_content_from_message,
|
||||
convert_output_to_messages,
|
||||
filter_output_by_content,
|
||||
strip_empty_content_blocks,
|
||||
)
|
||||
from open_webui.utils.tools import (
|
||||
|
|
@ -2095,16 +2096,52 @@ 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.
|
||||
Respects content edits and dropped <details> blocks by filtering output items
|
||||
against the stored content field before conversion.
|
||||
"""
|
||||
processed = []
|
||||
|
||||
for message in messages:
|
||||
if message.get('role') == 'assistant' and message.get('output'):
|
||||
# Use output items for clean OpenAI-format messages
|
||||
output_messages = convert_output_to_messages(message['output'], raw=True)
|
||||
if output_messages:
|
||||
processed.extend(output_messages)
|
||||
continue
|
||||
# normalize; guard against None or list-typed content
|
||||
content = message.get('content', '')
|
||||
if not isinstance(content, str):
|
||||
content = ''
|
||||
|
||||
# Drop output items for <details> blocks removed from content
|
||||
filtered_output = filter_output_by_content(message['output'], content)
|
||||
|
||||
# Split content around <details> blocks; assign each text segment to
|
||||
# the corresponding message item in order → preserves pre/post-tool placement
|
||||
text_segs = [
|
||||
s.strip()
|
||||
for s in re.split(r'<details\b[^>]*>.*?</details>', content, flags=re.S)
|
||||
]
|
||||
seg_idx = 0
|
||||
modified_output = []
|
||||
for item in filtered_output:
|
||||
if item.get('type') == 'message':
|
||||
text = text_segs[seg_idx] if seg_idx < len(text_segs) else ''
|
||||
seg_idx += 1
|
||||
modified_output.append({
|
||||
**item,
|
||||
'content': [{'type': 'output_text', 'text': text}],
|
||||
})
|
||||
else:
|
||||
modified_output.append(item)
|
||||
|
||||
output_messages = convert_output_to_messages(modified_output, raw=True)
|
||||
# Trailing segments not consumed by message items → append as plain text
|
||||
trailing = '\n'.join(s for s in text_segs[seg_idx:] if s).strip()
|
||||
if trailing:
|
||||
output_messages.append({'role': 'assistant', 'content': trailing})
|
||||
elif not output_messages:
|
||||
# No structured output at all; fall back to plain text from content
|
||||
plain = '\n'.join(s for s in text_segs if s).strip()
|
||||
if plain:
|
||||
output_messages = [{'role': 'assistant', 'content': plain}]
|
||||
processed.extend(output_messages)
|
||||
continue
|
||||
|
||||
# Strip 'output' field before adding (LLM shouldn't see it)
|
||||
clean_message = {k: v for k, v in message.items() if k != 'output'}
|
||||
|
|
|
|||
|
|
@ -276,6 +276,72 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
|||
return messages
|
||||
|
||||
|
||||
def filter_output_by_content(output: list, content: str) -> list:
|
||||
"""
|
||||
Drop output items whose <details> block was removed from content.
|
||||
|
||||
Matches by id attribute. Items with no ID are kept for backward
|
||||
compatibility with content serialized before id= was added.
|
||||
Items whose type is entirely absent from content are dropped wholesale.
|
||||
Legacy: if a type is present with no id= attrs, keep the first N items
|
||||
of that type where N = the number of blocks of that type remaining in
|
||||
content, so individually deleted blocks are respected even without IDs.
|
||||
"""
|
||||
if not isinstance(output, list):
|
||||
return []
|
||||
if not isinstance(content, str):
|
||||
content = ''
|
||||
|
||||
present_ids = set(re.findall(r'<details\b[^>]*\bid="([^"]+)"', content))
|
||||
present_types = set(re.findall(r'<details\b[^>]*\btype="([^"]+)"', content))
|
||||
|
||||
# Map output item type → <details> type attribute value
|
||||
DETAILS_TYPE = {
|
||||
'function_call': 'tool_calls',
|
||||
'function_call_output': 'tool_calls',
|
||||
'reasoning': 'reasoning',
|
||||
'open_webui:code_interpreter': 'code_interpreter',
|
||||
}
|
||||
|
||||
# No <details> blocks: pass through if no structured item has an ID (pre-serialization legacy)
|
||||
if not present_types:
|
||||
if not any(item.get('call_id') or item.get('id')
|
||||
for item in output if DETAILS_TYPE.get(item.get('type', ''))):
|
||||
return list(output)
|
||||
|
||||
# types with ≥1 id= in content; others treated as legacy (pre-id=)
|
||||
types_with_ids = set(re.findall(r'<details\b[^>]*\btype="([^"]+)"[^>]*\bid="[^"]*"', content))
|
||||
types_with_ids |= set(re.findall(r'<details\b[^>]*\bid="[^"]*"[^>]*\btype="([^"]+)"', content))
|
||||
|
||||
# Legacy (no id=): count remaining blocks per type for positional matching
|
||||
type_block_counts = {}
|
||||
for t in re.findall(r'<details\b[^>]*\btype="([^"]+)"', content):
|
||||
type_block_counts[t] = type_block_counts.get(t, 0) + 1
|
||||
|
||||
filtered = []
|
||||
seen_legacy = {} # kept count per legacy type
|
||||
for item in output:
|
||||
details_type = DETAILS_TYPE.get(item.get('type', ''))
|
||||
if details_type is None:
|
||||
filtered.append(item) # non-visual item (e.g. 'message'): always keep
|
||||
continue
|
||||
if details_type not in present_types:
|
||||
continue # entire type removed from content: drop
|
||||
if details_type not in types_with_ids:
|
||||
# Legacy (no id=): keep first N items, N = block count in content
|
||||
n = type_block_counts.get(details_type, 0)
|
||||
if seen_legacy.get(details_type, 0) < n:
|
||||
filtered.append(item)
|
||||
seen_legacy[details_type] = seen_legacy.get(details_type, 0) + 1
|
||||
continue
|
||||
item_id = item.get('call_id') or item.get('id', '')
|
||||
if not item_id or item_id in present_ids:
|
||||
filtered.append(item) # no ID (old format) or ID still present: keep
|
||||
# else: ID not found in content → user deleted this block: drop
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
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