From 1f5b0d816fb19a66f4e3c1a686578d78fcdaf1d2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 24 Jul 2026 01:19:28 -0400 Subject: [PATCH] refac --- backend/open_webui/config.py | 5 +++ backend/open_webui/models/chats.py | 7 ++- backend/open_webui/retrieval/utils.py | 7 ++- backend/open_webui/routers/chats.py | 6 +++ .../open_webui/utils/context_compaction.py | 19 ++++++-- backend/open_webui/utils/middleware.py | 28 +++++------- backend/open_webui/utils/misc.py | 43 +++++++++++++++---- .../admin/Settings/Interface.svelte | 17 ++++++++ src/lib/i18n/locales/en-GB/translation.json | 2 + 9 files changed, 103 insertions(+), 31 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 116ac77ade..6dfec3c42e 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2143,6 +2143,10 @@ CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THR _CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP') CONTEXT_COMPACTION_TOKEN_CAP = int(_CONTEXT_COMPACTION_TOKEN_CAP) if _CONTEXT_COMPACTION_TOKEN_CAP else None +CONTEXT_COMPACTION_RETENTION_PERCENTAGE = min( + 50, max(10, int(os.getenv('CONTEXT_COMPACTION_RETENTION_PERCENTAGE', '40'))) +) + CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '') TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '') @@ -3054,6 +3058,7 @@ DEFAULT_CONFIG = { 'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION, 'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD, 'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP, + 'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE, 'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE, 'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE, 'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE, diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index ac2844149b..b2e252f588 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -13,7 +13,7 @@ from open_webui.models.automations import AutomationRun from open_webui.models.chat_messages import ChatMessage, ChatMessages from open_webui.models.folders import Folders from open_webui.models.tags import Tag, TagModel, Tags -from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db +from open_webui.utils.misc import get_output_text, sanitize_data_for_db, sanitize_text_for_db from pydantic import BaseModel, ConfigDict, field_validator from sqlalchemy import ( JSON, @@ -840,6 +840,11 @@ class ChatTable: if chat is None: return None + if not message.get('content'): + output_text = get_output_text(message.get('output')) + if output_text: + message['content'] = output_text + # Sanitize message content for null characters before upserting if isinstance(message.get('content'), str): message['content'] = sanitize_text_for_db(message['content']) diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 2a6ac5c3e1..b402c12e03 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -51,7 +51,7 @@ from open_webui.retrieval.web.utils import get_web_loader from open_webui.utils.access_control.files import has_access_to_file from open_webui.utils.access_control.folders import has_folder_access from open_webui.utils.headers import include_user_info_headers -from open_webui.utils.misc import get_message_list +from open_webui.utils.misc import get_content_from_message, get_message_list log = logging.getLogger(__name__) @@ -1409,7 +1409,10 @@ async def get_sources_from_items( # Reconstruct the message list in order message_list = get_message_list(messages_map, message_id) message_history = '\n'.join( - [f'#### {m.get("role", "user").capitalize()}\n{m.get("content")}\n' for m in message_list] + [ + f'#### {m.get("role", "user").capitalize()}\n{get_content_from_message(m) or ""}\n' + for m in message_list + ] ) # User has access to the chat diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index a136d3679d..1a707607e0 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -55,6 +55,7 @@ CHAT_CONFIG_KEYS = { 'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable', 'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold', 'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap', + 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage', 'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template', } @@ -76,6 +77,7 @@ class ChatConfigForm(BaseModel): ENABLE_CONTEXT_COMPACTION: bool CONTEXT_COMPACTION_TOKEN_THRESHOLD: int CONTEXT_COMPACTION_TOKEN_CAP: int | None = None + CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40 CONTEXT_COMPACTION_PROMPT_TEMPLATE: str @@ -125,6 +127,8 @@ async def get_chat_config_values() -> dict: config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values} if config.get('CONTEXT_COMPACTION_TOKEN_CAP') is None: config['CONTEXT_COMPACTION_TOKEN_CAP'] = config.get('CONTEXT_COMPACTION_TOKEN_THRESHOLD', 80000) + if config.get('CONTEXT_COMPACTION_RETENTION_PERCENTAGE') is None: + config['CONTEXT_COMPACTION_RETENTION_PERCENTAGE'] = 40 return config @@ -738,12 +742,14 @@ async def get_chat_config(user=Depends(get_admin_user)): async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)): threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD)) token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold)) + retention_percentage = min(50, max(10, int(form_data.CONTEXT_COMPACTION_RETENTION_PERCENTAGE))) await Config.upsert( chat_config_updates( { **form_data.model_dump(), 'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold, 'CONTEXT_COMPACTION_TOKEN_CAP': token_cap, + 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage, } ) ) diff --git a/backend/open_webui/utils/context_compaction.py b/backend/open_webui/utils/context_compaction.py index 910fa5b7b6..1a9b5e9f96 100644 --- a/backend/open_webui/utils/context_compaction.py +++ b/backend/open_webui/utils/context_compaction.py @@ -56,7 +56,7 @@ async def compact_messages_for_request( if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold) or len(messages) <= 3: return messages, previous_summary, False - boundary = _find_compaction_boundary(messages) + boundary = _find_compaction_boundary(messages, config['retention_percentage']) compacted_messages = messages[:boundary] recent_messages = messages[boundary:] if not compacted_messages or not recent_messages: @@ -196,6 +196,7 @@ async def _load_config() -> dict: 'chat.context_compaction.enable', 'chat.context_compaction.token_threshold', 'chat.context_compaction.token_cap', + 'chat.context_compaction.retention_percentage', 'chat.context_compaction.prompt_template', ) token_threshold = _parse_positive_int(values.get('chat.context_compaction.token_threshold')) or 80000 @@ -203,6 +204,9 @@ async def _load_config() -> dict: 'enable': bool(values.get('chat.context_compaction.enable', False)), 'token_threshold': token_threshold, 'token_cap': _parse_positive_int(values.get('chat.context_compaction.token_cap')) or token_threshold, + 'retention_percentage': _clamp_retention_percentage( + values.get('chat.context_compaction.retention_percentage') + ), 'prompt_template': values.get('chat.context_compaction.prompt_template', '') or '', } @@ -215,6 +219,14 @@ def _parse_positive_int(value: Any) -> int | None: return parsed if parsed > 0 else None +def _clamp_retention_percentage(value: Any) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = 40 + return min(50, max(10, parsed)) + + def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: dict) -> int: configured_threshold = _parse_positive_int((metadata.get('params') or {}).get('compact_token_threshold')) return min(configured_threshold or global_threshold, global_cap) @@ -297,8 +309,9 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary: return estimated > threshold -def _find_compaction_boundary(messages: list[dict]) -> int: - keep_count = max(2, len(messages) * 2 // 5) +def _find_compaction_boundary(messages: list[dict], retention_percentage: int = 40) -> int: + retention_percentage = _clamp_retention_percentage(retention_percentage) + keep_count = max(2, len(messages) * retention_percentage // 100) target = max(1, len(messages) - keep_count) boundaries = [idx for idx, message in enumerate(messages) if message.get('role') == 'user'][1:] return next((idx for idx in reversed(boundaries) if idx <= target), 0) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index d7f00959a5..72e32d68b8 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -107,6 +107,7 @@ from open_webui.utils.misc import ( get_last_user_message, get_last_user_message_item, get_message_list, + get_output_text, get_system_message, is_string_allowed, merge_system_messages, @@ -137,17 +138,6 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -def _last_output_text(output: list | None) -> str: - for item in reversed(output or []): - if item.get('type') != 'message': - continue - parts = item.get('content') or [] - text = ''.join(str(part.get('text') or '') for part in parts if part.get('type') == 'output_text') - if text: - return text - return '' - - async def publish_chat_finished_event( request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None ): @@ -155,7 +145,7 @@ async def publish_chat_finished_event( if getattr(request.state, 'internal', False) is True or not chat_id or chat_id.startswith(('channel:', 'local:')): return - content = content or _last_output_text(output) + content = content or get_output_text(output) webui_url = await Config.get('webui.url') await publish_event( request, @@ -3398,7 +3388,7 @@ async def outlet_filter_handler(ctx): message_list = [ { 'role': m.get('role'), - 'content': m.get('content', ''), + 'content': m.get('content') or get_output_text(m.get('output')), } for m in form_messages ] @@ -3431,7 +3421,7 @@ async def outlet_filter_handler(ctx): { 'id': m.get('id'), 'role': m.get('role'), - 'content': m.get('content', ''), + 'content': m.get('content') or get_output_text(m.get('output')), 'info': m.get('info'), 'timestamp': m.get('timestamp'), **({'output': m['output']} if m.get('output') else {}), @@ -3483,17 +3473,21 @@ async def outlet_filter_handler(ctx): outlet_message_id = message.get('id') if outlet_message_id and outlet_message_id in messages_map: original_message = messages_map[outlet_message_id] - content_changed = original_message.get('content') != message.get('content') + original_content = original_message.get('content') or get_output_text( + original_message.get('output') + ) + message_content = message.get('content') or get_output_text(message.get('output')) + content_changed = original_content != message_content output_changed = message.get('output') and message.get('output') != original_message.get( 'output' ) if content_changed or output_changed: message_update = { - 'originalContent': original_message.get('content'), + 'originalContent': original_content, **({'output': message['output']} if output_changed else {}), } if content_changed: - message_update['content'] = message.get('content', '') + message_update['content'] = message_content or '' await Chats.upsert_message_to_chat_by_id_and_message_id( chat_id, outlet_message_id, diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index ead329b278..074f7c859b 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -160,13 +160,40 @@ def get_last_user_message_item(messages: list[dict]) -> dict | None: def get_content_from_message(message: dict) -> str | None: - if isinstance(message.get('content'), list): - for item in message['content']: - if item['type'] == 'text': - return item['text'] - else: - return message.get('content') - return None + content = message.get('content') + if isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get('type') == 'text': + return item.get('text') + elif content: + return content + + output_text = get_output_text(message.get('output')) + return output_text or (content if isinstance(content, str) else None) + + +def get_output_text(output: list | None) -> str: + if not isinstance(output, list): + return '' + + texts = [] + for item in output: + if not isinstance(item, dict) or item.get('type') != 'message': + continue + + parts = item.get('content') or [] + if not isinstance(parts, list): + continue + + text = ''.join( + str(part.get('text')) + for part in parts + if isinstance(part, dict) and part.get('text') is not None + ) + if text.strip(): + texts.append(text) + + return '\n'.join(texts) def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: @@ -213,7 +240,7 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: # All tool_calls were orphans — keep the message only if it # carries meaningful text or reasoning content. - content = message.get('content', '') + content = get_content_from_message(message) or '' has_meaningful_content = content.strip() if isinstance(content, str) else content if has_meaningful_content or message.get('reasoning_content'): reconciled_messages.append({key: value for key, value in message.items() if key != 'tool_calls'}) diff --git a/src/lib/components/admin/Settings/Interface.svelte b/src/lib/components/admin/Settings/Interface.svelte index 5d56aee356..197491e637 100644 --- a/src/lib/components/admin/Settings/Interface.svelte +++ b/src/lib/components/admin/Settings/Interface.svelte @@ -44,6 +44,7 @@ ENABLE_CONTEXT_COMPACTION: false, CONTEXT_COMPACTION_TOKEN_THRESHOLD: 80000, CONTEXT_COMPACTION_TOKEN_CAP: 80000, + CONTEXT_COMPACTION_RETENTION_PERCENTAGE: 40, CONTEXT_COMPACTION_PROMPT_TEMPLATE: '' }; @@ -273,6 +274,22 @@ /> + + + +