refac
This commit is contained in:
@@ -1990,6 +1990,12 @@ TASK_MODEL = os.getenv('TASK_MODEL', '')
|
||||
|
||||
TASK_MODEL_EXTERNAL = os.getenv('TASK_MODEL_EXTERNAL', '')
|
||||
|
||||
ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lower() == 'true'
|
||||
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000'))
|
||||
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '')
|
||||
|
||||
TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '')
|
||||
|
||||
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task:
|
||||
@@ -2864,6 +2870,9 @@ DEFAULT_CONFIG = {
|
||||
'auth.admin.email': ADMIN_EMAIL,
|
||||
'task.model.default': TASK_MODEL,
|
||||
'task.model.external': TASK_MODEL_EXTERNAL,
|
||||
'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION,
|
||||
'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD,
|
||||
'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,
|
||||
'task.image.prompt_template': IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""add context summary to chat message
|
||||
|
||||
Revision ID: 4c5ce3d2f27f
|
||||
Revises: 3ff2c63645b8
|
||||
Create Date: 2026-06-18 23:48:08.310063
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4c5ce3d2f27f'
|
||||
down_revision: Union[str, None] = '3ff2c63645b8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {column['name'] for column in inspector.get_columns('chat_message')}
|
||||
|
||||
if 'context_summary' not in columns:
|
||||
op.add_column('chat_message', sa.Column('context_summary', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {column['name'] for column in inspector.get_columns('chat_message')}
|
||||
|
||||
if 'context_summary' in columns:
|
||||
op.drop_column('chat_message', 'context_summary')
|
||||
@@ -109,6 +109,9 @@ class ChatMessage(Base):
|
||||
# Usage (tokens, timing, etc.)
|
||||
usage = Column(JSON, nullable=True)
|
||||
|
||||
# Context compaction checkpoint
|
||||
context_summary = Column(Text, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(BigInteger, index=True)
|
||||
updated_at = Column(BigInteger)
|
||||
@@ -143,6 +146,7 @@ class ChatMessageModel(BaseModel):
|
||||
status_history: Optional[list] = None
|
||||
error: Optional[dict | str] = None
|
||||
usage: Optional[dict] = None
|
||||
context_summary: Optional[str] = None
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
@@ -194,6 +198,8 @@ class ChatMessageTable:
|
||||
existing.status_history = data.get('status_history') or data.get('statusHistory')
|
||||
if 'error' in data:
|
||||
existing.error = data.get('error')
|
||||
if 'context_summary' in data or 'contextSummary' in data:
|
||||
existing.context_summary = data.get('context_summary') or data.get('contextSummary')
|
||||
# Extract and normalize usage
|
||||
usage = get_usage(data)
|
||||
if usage:
|
||||
@@ -225,6 +231,7 @@ class ChatMessageTable:
|
||||
status_history=data.get('status_history') or data.get('statusHistory'),
|
||||
error=data.get('error'),
|
||||
usage=usage,
|
||||
context_summary=data.get('context_summary') or data.get('contextSummary'),
|
||||
created_at=timestamp,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -251,6 +258,7 @@ class ChatMessageTable:
|
||||
'parent_id': 'parentId',
|
||||
'model_id': 'model',
|
||||
'status_history': 'statusHistory',
|
||||
'context_summary': 'contextSummary',
|
||||
'created_at': 'timestamp',
|
||||
}
|
||||
# DB-internal columns excluded from the reconstructed message dict.
|
||||
|
||||
@@ -31,12 +31,14 @@ from open_webui.models.folders import Folders
|
||||
from open_webui.models.shared_chats import SharedChatResponse, SharedChats
|
||||
from open_webui.models.tags import TagModel, Tags
|
||||
from open_webui.socket.main import get_event_emitter
|
||||
from open_webui.tasks import stop_item_tasks
|
||||
from open_webui.tasks import has_active_tasks, stop_item_tasks
|
||||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.context_compaction import compact_chat_branch
|
||||
from open_webui.utils.middleware import serialize_output
|
||||
from open_webui.utils.misc import get_message_list
|
||||
from open_webui.utils.models import get_all_models
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -44,6 +46,31 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CHAT_CONFIG_KEYS = {
|
||||
'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable',
|
||||
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold',
|
||||
'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template',
|
||||
}
|
||||
|
||||
|
||||
class ChatConfigForm(BaseModel):
|
||||
ENABLE_CONTEXT_COMPACTION: bool
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD: int
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE: str
|
||||
|
||||
|
||||
class CompactChatForm(BaseModel):
|
||||
model: str | None = None
|
||||
|
||||
|
||||
async def get_chat_config_values() -> dict:
|
||||
values = await Config.get_many(*CHAT_CONFIG_KEYS.values())
|
||||
return {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
|
||||
|
||||
|
||||
def chat_config_updates(data: dict) -> dict:
|
||||
return {CHAT_CONFIG_KEYS[field]: value for field, value in data.items() if field in CHAT_CONFIG_KEYS}
|
||||
|
||||
|
||||
async def require_chat_import_permission(request: Request, user, db: AsyncSession):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
@@ -612,6 +639,30 @@ async def import_chats(
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
|
||||
############################
|
||||
# ChatConfig
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/config', response_model=ChatConfigForm)
|
||||
async def get_chat_config(user=Depends(get_admin_user)):
|
||||
return await get_chat_config_values()
|
||||
|
||||
|
||||
@router.post('/config', response_model=ChatConfigForm)
|
||||
async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)):
|
||||
threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD))
|
||||
await Config.upsert(
|
||||
chat_config_updates(
|
||||
{
|
||||
**form_data.model_dump(),
|
||||
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold,
|
||||
}
|
||||
)
|
||||
)
|
||||
return await get_chat_config_values()
|
||||
|
||||
|
||||
############################
|
||||
# GetChats
|
||||
############################
|
||||
@@ -976,6 +1027,49 @@ async def get_user_chat_list_by_tag_name(
|
||||
return chats
|
||||
|
||||
|
||||
############################
|
||||
# CompactChat
|
||||
############################
|
||||
|
||||
|
||||
@router.post('/{id}/compact')
|
||||
async def compact_chat_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: CompactChatForm | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if await has_active_tasks(request.app.state.redis, id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Wait for the current response to finish before compacting.',
|
||||
)
|
||||
|
||||
if not request.app.state.MODELS:
|
||||
await get_all_models(request, user=user)
|
||||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(id)
|
||||
message_list = get_message_list(messages_map or history.get('messages') or {}, history.get('currentId'))
|
||||
model_id = (form_data.model if form_data else None) or next(
|
||||
(message.get('model') for message in reversed(message_list) if message.get('model')),
|
||||
None,
|
||||
)
|
||||
|
||||
if not model_id:
|
||||
chat_models = (chat.chat or {}).get('models') or []
|
||||
model_id = chat_models[0] if chat_models else None
|
||||
if not model_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No model found for context compaction.')
|
||||
|
||||
return await compact_chat_branch(request, user, chat, model_id, request.app.state.MODELS)
|
||||
|
||||
|
||||
############################
|
||||
# GetChatById
|
||||
############################
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.misc import get_content_from_message, get_last_user_message, get_message_list
|
||||
from open_webui.utils.task import (
|
||||
get_task_model_id,
|
||||
prompt_template,
|
||||
prompt_variables_template,
|
||||
replace_messages_variable,
|
||||
replace_prompt_variable,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONTEXT_COMPACTION_PROMPT = """### Task:
|
||||
Summarize the conversation history that will be compacted out of the active chat context.
|
||||
|
||||
### Instructions:
|
||||
- Preserve key decisions, user preferences, and constraints.
|
||||
- Preserve files, artifacts, tool results, and code changes that matter going forward.
|
||||
- Preserve the current task state, unresolved questions, and next steps.
|
||||
- Be factual and specific. Do not invent details.
|
||||
- Keep the summary concise, but complete enough for the assistant to continue without the removed messages.
|
||||
|
||||
### Previous Summary:
|
||||
{{PREVIOUS_SUMMARY}}
|
||||
|
||||
### Messages Being Compacted:
|
||||
{{COMPACTED_MESSAGES}}
|
||||
|
||||
### Recent Messages Kept In Context:
|
||||
{{RECENT_MESSAGES}}"""
|
||||
|
||||
|
||||
async def compact_messages_for_request(
|
||||
request,
|
||||
user,
|
||||
messages: list[dict],
|
||||
metadata: dict,
|
||||
model_id: str,
|
||||
models: dict,
|
||||
system_prompt: str = '',
|
||||
) -> tuple[list[dict], str | None, bool]:
|
||||
config = await _load_config()
|
||||
if not config['enable']:
|
||||
return messages, None, False
|
||||
|
||||
messages, previous_summary = _apply_latest_summary_checkpoint(messages)
|
||||
if (
|
||||
not _exceeds_token_threshold(messages, system_prompt, previous_summary, config['token_threshold'])
|
||||
or len(messages) <= 3
|
||||
):
|
||||
return messages, previous_summary, False
|
||||
|
||||
boundary = _find_compaction_boundary(messages)
|
||||
compacted_messages = messages[:boundary]
|
||||
recent_messages = messages[boundary:]
|
||||
if not compacted_messages or not recent_messages:
|
||||
return messages, previous_summary, False
|
||||
|
||||
summary = await _generate_summary(
|
||||
request,
|
||||
user,
|
||||
model_id,
|
||||
models,
|
||||
compacted_messages,
|
||||
recent_messages,
|
||||
previous_summary,
|
||||
config['prompt_template'],
|
||||
)
|
||||
|
||||
chat_id = metadata.get('chat_id')
|
||||
message_id = metadata.get('message_id')
|
||||
if chat_id and message_id and not chat_id.startswith(('local:', 'channel:')):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(chat_id, message_id, {'contextSummary': summary})
|
||||
|
||||
log.info(
|
||||
'Compacted chat context for chat=%s message=%s dropped=%d kept=%d summary_chars=%d',
|
||||
chat_id,
|
||||
message_id,
|
||||
len(compacted_messages),
|
||||
len(recent_messages),
|
||||
len(summary),
|
||||
)
|
||||
|
||||
return recent_messages, summary, True
|
||||
|
||||
|
||||
async def compact_chat_branch(request, user, chat: Any, model_id: str, models: dict) -> dict:
|
||||
config = await _load_config()
|
||||
if not config['enable']:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'disabled'}
|
||||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
current_id = history.get('currentId')
|
||||
if not current_id:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'empty'}
|
||||
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(chat.id)
|
||||
if not messages_map:
|
||||
messages_map = history.get('messages') or {}
|
||||
|
||||
messages, previous_summary = _apply_latest_summary_checkpoint(get_message_list(messages_map, current_id))
|
||||
if len(messages) <= 2:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'too_short'}
|
||||
|
||||
compacted_messages = messages[:-1]
|
||||
recent_messages = messages[-1:]
|
||||
summary = await _generate_summary(
|
||||
request,
|
||||
user,
|
||||
model_id,
|
||||
models,
|
||||
compacted_messages,
|
||||
recent_messages,
|
||||
previous_summary,
|
||||
config['prompt_template'],
|
||||
)
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(chat.id, current_id, {'contextSummary': summary})
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'compacted': True,
|
||||
'dropped_messages': len(compacted_messages),
|
||||
'kept_messages': len(recent_messages),
|
||||
'summary_chars': len(summary),
|
||||
}
|
||||
|
||||
|
||||
async def _load_config() -> dict:
|
||||
values = await Config.get_many(
|
||||
'chat.context_compaction.enable',
|
||||
'chat.context_compaction.token_threshold',
|
||||
'chat.context_compaction.prompt_template',
|
||||
)
|
||||
return {
|
||||
'enable': bool(values.get('chat.context_compaction.enable', False)),
|
||||
'token_threshold': int(values.get('chat.context_compaction.token_threshold', 80000) or 80000),
|
||||
'prompt_template': values.get('chat.context_compaction.prompt_template', '') or '',
|
||||
}
|
||||
|
||||
|
||||
def _apply_latest_summary_checkpoint(messages: list[dict]) -> tuple[list[dict], str | None]:
|
||||
summary = None
|
||||
summary_idx = None
|
||||
|
||||
for idx, message in enumerate(messages):
|
||||
value = message.get('contextSummary') or message.get('context_summary')
|
||||
if isinstance(value, str) and value.strip():
|
||||
summary = value
|
||||
summary_idx = idx
|
||||
|
||||
if summary_idx is None:
|
||||
return messages, None
|
||||
return messages[summary_idx:], summary
|
||||
|
||||
|
||||
def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary: str | None, threshold: int) -> bool:
|
||||
if threshold <= 0:
|
||||
return False
|
||||
|
||||
for idx in range(len(messages) - 1, -1, -1):
|
||||
usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage')
|
||||
if isinstance(usage, dict) and usage.get('input_tokens'):
|
||||
total = int(usage.get('input_tokens') or 0) + int(usage.get('output_tokens') or 0)
|
||||
return total + _estimate_messages_tokens(messages[idx + 1 :]) > threshold
|
||||
|
||||
estimated = _estimate_tokens(system_prompt) + _estimate_tokens(summary or '') + _estimate_messages_tokens(messages)
|
||||
return estimated > threshold
|
||||
|
||||
|
||||
def _find_compaction_boundary(messages: list[dict]) -> int:
|
||||
keep_count = max(2, len(messages) * 2 // 5)
|
||||
split = max(1, len(messages) - keep_count)
|
||||
|
||||
while split < len(messages) - 1:
|
||||
previous = messages[split - 1] if split > 0 else {}
|
||||
current = messages[split]
|
||||
if current.get('role') == 'tool' or previous.get('tool_calls') or previous.get('output'):
|
||||
split += 1
|
||||
continue
|
||||
break
|
||||
|
||||
return min(split, len(messages) - 2)
|
||||
|
||||
|
||||
async def _generate_summary(
|
||||
request,
|
||||
user,
|
||||
model_id: str,
|
||||
models: dict,
|
||||
compacted_messages: list[dict],
|
||||
recent_messages: list[dict],
|
||||
previous_summary: str | None,
|
||||
summary_prompt_template: str,
|
||||
) -> str:
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
|
||||
task_model_id = get_task_model_id(
|
||||
model_id,
|
||||
await Config.get('task.model.default'),
|
||||
await Config.get('task.model.external'),
|
||||
models,
|
||||
)
|
||||
if task_model_id not in models:
|
||||
task_model_id = model_id
|
||||
if task_model_id not in models:
|
||||
raise ValueError('No available model for context compaction')
|
||||
|
||||
summary_prompt_template = summary_prompt_template.strip() or DEFAULT_CONTEXT_COMPACTION_PROMPT
|
||||
all_messages = [*compacted_messages, *recent_messages]
|
||||
prompt = replace_prompt_variable(summary_prompt_template, get_last_user_message(all_messages) or '')
|
||||
prompt = replace_messages_variable(prompt, all_messages)
|
||||
prompt = replace_messages_variable(prompt, compacted_messages, 'COMPACTED_MESSAGES')
|
||||
prompt = replace_messages_variable(prompt, recent_messages, 'RECENT_MESSAGES')
|
||||
prompt = prompt_variables_template(prompt, {'{{PREVIOUS_SUMMARY}}': previous_summary or ''})
|
||||
prompt = await prompt_template(prompt, user)
|
||||
|
||||
max_tokens = models[task_model_id].get('info', {}).get('params', {}).get('max_tokens', 1000)
|
||||
payload = {
|
||||
'model': task_model_id,
|
||||
'messages': [{'role': 'user', 'content': prompt}],
|
||||
'stream': False,
|
||||
**(
|
||||
{'max_tokens': max_tokens}
|
||||
if models[task_model_id].get('owned_by') == 'ollama'
|
||||
else {'max_completion_tokens': max_tokens}
|
||||
),
|
||||
'metadata': {
|
||||
**(request.state.metadata if hasattr(request.state, 'metadata') else {}),
|
||||
'task': 'context_compaction',
|
||||
},
|
||||
}
|
||||
|
||||
response = await generate_chat_completion(request, form_data=payload, user=user)
|
||||
summary = _response_text(response).strip()
|
||||
if summary:
|
||||
return summary
|
||||
|
||||
parts = [previous_summary] if previous_summary else []
|
||||
for message in compacted_messages:
|
||||
content = get_content_from_message(message)
|
||||
if content:
|
||||
parts.append(f'- {message.get("role", "unknown")}: {content[:500]}')
|
||||
return '\n'.join(parts)[:4000]
|
||||
|
||||
|
||||
def _response_text(response: Any) -> str:
|
||||
if isinstance(response, list) and len(response) == 1:
|
||||
response = response[0]
|
||||
|
||||
if isinstance(response, JSONResponse):
|
||||
try:
|
||||
response = json.loads(response.body.decode('utf-8', 'replace'))
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
if not isinstance(response, dict):
|
||||
return ''
|
||||
|
||||
choices = response.get('choices') or []
|
||||
if choices:
|
||||
message = choices[0].get('message') or {}
|
||||
return message.get('content') or message.get('reasoning_content') or ''
|
||||
|
||||
parts = []
|
||||
for item in response.get('output') or []:
|
||||
for content in item.get('content') or []:
|
||||
if isinstance(content, dict):
|
||||
parts.append(content.get('text') or content.get('content') or '')
|
||||
return '\n'.join(part for part in parts if part)
|
||||
|
||||
|
||||
def _estimate_messages_tokens(messages: list[dict]) -> int:
|
||||
total = 0
|
||||
for message in messages:
|
||||
total += 4
|
||||
content = message.get('content')
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
total += _estimate_tokens(item)
|
||||
elif item.get('type') in {'image', 'image_url'}:
|
||||
total += 1000
|
||||
else:
|
||||
total += _estimate_tokens(item.get('text') or item.get('content') or item)
|
||||
else:
|
||||
total += _estimate_tokens(content)
|
||||
|
||||
total += _estimate_tokens(message.get('output'))
|
||||
total += _estimate_tokens(message.get('tool_calls'))
|
||||
total += _estimate_tokens(message.get('files'))
|
||||
return total
|
||||
|
||||
|
||||
def _estimate_tokens(value: Any) -> int:
|
||||
if value is None:
|
||||
return 0
|
||||
|
||||
if not isinstance(value, str):
|
||||
try:
|
||||
value = json.dumps(value, ensure_ascii=False)
|
||||
except Exception:
|
||||
value = str(value)
|
||||
|
||||
if not value:
|
||||
return 0
|
||||
|
||||
return max(1, len(value) // 4)
|
||||
@@ -77,6 +77,7 @@ from open_webui.utils.access_control import has_connection_access, has_permissio
|
||||
from open_webui.utils.access_control.files import get_accessible_folder_files
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.utils.code_interpreter import execute_code_jupyter
|
||||
from open_webui.utils.context_compaction import compact_messages_for_request
|
||||
from open_webui.utils.files import (
|
||||
convert_markdown_base64_images,
|
||||
get_file_url_from_base64,
|
||||
@@ -2170,7 +2171,10 @@ async def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[
|
||||
if not db_messages:
|
||||
return None
|
||||
|
||||
return [{k: v for k, v in msg.items() if k in ('role', 'content', 'output', 'files')} for msg in db_messages]
|
||||
return [
|
||||
{k: v for k, v in msg.items() if k in ('role', 'content', 'output', 'files', 'contextSummary')}
|
||||
for msg in db_messages
|
||||
]
|
||||
|
||||
|
||||
def get_reasoning_format(model: dict) -> str | None:
|
||||
@@ -2221,6 +2225,50 @@ def process_messages_with_output(
|
||||
return processed
|
||||
|
||||
|
||||
def strip_compaction_fields(messages: list[dict]) -> list[dict]:
|
||||
stripped = []
|
||||
for message in messages:
|
||||
clean = dict(message)
|
||||
clean.pop('contextSummary', None)
|
||||
clean.pop('context_summary', None)
|
||||
stripped.append(clean)
|
||||
return stripped
|
||||
|
||||
|
||||
def sanitize_tool_pairs(messages: list[dict]) -> list[dict]:
|
||||
tool_result_ids = {
|
||||
message.get('tool_call_id')
|
||||
for message in messages
|
||||
if message.get('role') == 'tool' and message.get('tool_call_id')
|
||||
}
|
||||
|
||||
tool_call_ids = {
|
||||
tool_call.get('id')
|
||||
for message in messages
|
||||
for tool_call in (message.get('tool_calls') or [])
|
||||
if message.get('role') == 'assistant' and tool_call.get('id')
|
||||
}
|
||||
|
||||
sanitized = []
|
||||
for message in messages:
|
||||
if message.get('role') == 'assistant' and message.get('tool_calls'):
|
||||
kept = [
|
||||
tool_call for tool_call in message.get('tool_calls') or [] if tool_call.get('id') in tool_result_ids
|
||||
]
|
||||
if kept:
|
||||
sanitized.append({**message, 'tool_calls': kept})
|
||||
else:
|
||||
clean = dict(message)
|
||||
clean.pop('tool_calls', None)
|
||||
clean.pop('reasoning_items', None)
|
||||
if clean.get('content'):
|
||||
sanitized.append(clean)
|
||||
elif message.get('role') != 'tool' or message.get('tool_call_id') in tool_call_ids:
|
||||
sanitized.append(message)
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
SKILL_MENTION_RE = re.compile(r'<\$([^|>]+)\|?[^>]*>')
|
||||
|
||||
|
||||
@@ -2368,7 +2416,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, assistant_message_id)
|
||||
if assistant_message and (assistant_message.get('content') or assistant_message.get('output')):
|
||||
db_messages.append(
|
||||
{k: v for k, v in assistant_message.items() if k in ('role', 'content', 'output', 'files')}
|
||||
{
|
||||
k: v
|
||||
for k, v in assistant_message.items()
|
||||
if k in ('role', 'content', 'output', 'files', 'contextSummary')
|
||||
}
|
||||
)
|
||||
|
||||
system_message = get_system_message(form_data.get('messages', []))
|
||||
@@ -2401,11 +2453,44 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
if regeneration_prompt:
|
||||
form_data['messages'].append({'role': 'user', 'content': regeneration_prompt})
|
||||
|
||||
if chat_id and user_message_id and not chat_id.startswith('local:') and not chat_id.startswith('channel:'):
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
compaction_models = {
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
else:
|
||||
compaction_models = request.app.state.MODELS
|
||||
|
||||
system_message = get_system_message(form_data.get('messages', []))
|
||||
system_prompt = get_content_from_message(system_message) if system_message else ''
|
||||
|
||||
try:
|
||||
form_data['messages'], context_summary, _ = await compact_messages_for_request(
|
||||
request,
|
||||
user,
|
||||
form_data.get('messages', []),
|
||||
metadata,
|
||||
form_data.get('model'),
|
||||
compaction_models,
|
||||
system_prompt,
|
||||
)
|
||||
if context_summary:
|
||||
form_data['messages'] = add_or_update_system_message(
|
||||
f'[CONVERSATION SUMMARY]\n{context_summary}',
|
||||
form_data['messages'],
|
||||
append=True,
|
||||
)
|
||||
except Exception:
|
||||
log.exception('Context compaction failed; continuing with full chat history')
|
||||
|
||||
form_data['messages'] = strip_compaction_fields(form_data.get('messages', []))
|
||||
|
||||
# Process messages with OR-aligned output items for clean LLM messages
|
||||
form_data['messages'] = process_messages_with_output(
|
||||
form_data.get('messages', []),
|
||||
reasoning_format=get_reasoning_format(model),
|
||||
)
|
||||
form_data['messages'] = sanitize_tool_pairs(form_data['messages'])
|
||||
|
||||
system_message = get_system_message(form_data.get('messages', []))
|
||||
if system_message: # Chat Controls/User Settings
|
||||
|
||||
@@ -194,7 +194,9 @@ def apply_content_filter(messages: list[dict], filter_str: str) -> list[dict]:
|
||||
return result
|
||||
|
||||
|
||||
def replace_messages_variable(template: str, messages: Optional[list[dict]] = None) -> str:
|
||||
def replace_messages_variable(
|
||||
template: str, messages: Optional[list[dict]] = None, variable_name: str = 'MESSAGES'
|
||||
) -> str:
|
||||
def replacement_function(match):
|
||||
# Groups: (1) filter for bare MESSAGES
|
||||
# (2) START count, (3) filter for START
|
||||
@@ -240,12 +242,13 @@ def replace_messages_variable(template: str, messages: Optional[list[dict]] = No
|
||||
|
||||
return get_messages_content(selected)
|
||||
|
||||
variable_pattern = re.escape(variable_name)
|
||||
template = re.sub(
|
||||
r'(?:'
|
||||
r'\{\{MESSAGES(?:\|(\w+:\d+))?\}\}'
|
||||
r'|\{\{MESSAGES:START:(\d+)(?:\|(\w+:\d+))?\}\}'
|
||||
r'|\{\{MESSAGES:END:(\d+)(?:\|(\w+:\d+))?\}\}'
|
||||
r'|\{\{MESSAGES:MIDDLETRUNCATE:(\d+)(?:\|(\w+:\d+))?\}\}'
|
||||
rf'\{{\{{{variable_pattern}(?:\|(\w+:\d+))?\}}\}}'
|
||||
rf'|\{{\{{{variable_pattern}:START:(\d+)(?:\|(\w+:\d+))?\}}\}}'
|
||||
rf'|\{{\{{{variable_pattern}:END:(\d+)(?:\|(\w+:\d+))?\}}\}}'
|
||||
rf'|\{{\{{{variable_pattern}:MIDDLETRUNCATE:(\d+)(?:\|(\w+:\d+))?\}}\}}'
|
||||
r')',
|
||||
replacement_function,
|
||||
template,
|
||||
|
||||
@@ -1,6 +1,63 @@
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { getTimeRange } from '$lib/utils';
|
||||
|
||||
export const getChatConfig = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/config`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateChatConfig = async (token: string, config: object) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const createNewChat = async (token: string, chat: object, folderId: string | null) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getModels, getTaskConfig, updateTaskConfig } from '$lib/apis';
|
||||
import { getChatConfig, updateChatConfig } from '$lib/apis/chats';
|
||||
import { config, settings } from '$lib/stores';
|
||||
import { createEventDispatcher, onMount, getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -36,8 +37,17 @@
|
||||
VOICE_MODE_PROMPT_TEMPLATE: ''
|
||||
};
|
||||
|
||||
let chatConfig = {
|
||||
ENABLE_CONTEXT_COMPACTION: false,
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD: 80000,
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE: ''
|
||||
};
|
||||
|
||||
const updateInterfaceHandler = async () => {
|
||||
taskConfig = await updateTaskConfig(localStorage.token, taskConfig);
|
||||
[taskConfig, chatConfig] = await Promise.all([
|
||||
updateTaskConfig(localStorage.token, taskConfig),
|
||||
updateChatConfig(localStorage.token, chatConfig)
|
||||
]);
|
||||
};
|
||||
|
||||
let workspaceModels = null;
|
||||
@@ -47,7 +57,10 @@
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
taskConfig = await getTaskConfig(localStorage.token);
|
||||
[taskConfig, chatConfig] = await Promise.all([
|
||||
getTaskConfig(localStorage.token),
|
||||
getChatConfig(localStorage.token)
|
||||
]);
|
||||
|
||||
workspaceModels = await getBaseModels(localStorage.token);
|
||||
baseModels = await getModels(localStorage.token, null, false);
|
||||
@@ -84,7 +97,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if models !== null && taskConfig}
|
||||
{#if models !== null && taskConfig && chatConfig}
|
||||
<form
|
||||
class="flex flex-col h-full justify-between space-y-3 text-sm"
|
||||
on:submit|preventDefault={() => {
|
||||
@@ -210,6 +223,63 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-3" />
|
||||
|
||||
<div class="mb-2.5 flex w-full items-center justify-between">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Context Compaction')}
|
||||
</div>
|
||||
|
||||
<Switch bind:state={chatConfig.ENABLE_CONTEXT_COMPACTION} />
|
||||
</div>
|
||||
|
||||
{#if chatConfig.ENABLE_CONTEXT_COMPACTION}
|
||||
<div class="mb-2.5">
|
||||
<div class=" mb-1 text-xs font-medium">{$i18n.t('Token Threshold')}</div>
|
||||
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Older messages are summarized when estimated context exceeds this token limit.'
|
||||
)}
|
||||
placement="top-start"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
bind:value={chatConfig.CONTEXT_COMPACTION_TOKEN_THRESHOLD}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="mb-2.5">
|
||||
<div class=" mb-1 text-xs font-medium">{$i18n.t('Context Compaction Prompt')}</div>
|
||||
|
||||
<Tooltip
|
||||
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
|
||||
placement="top-start"
|
||||
>
|
||||
<Textarea
|
||||
bind:value={chatConfig.CONTEXT_COMPACTION_PROMPT_TEMPLATE}
|
||||
placeholder={$i18n.t(
|
||||
'Leave empty to use the default prompt, or enter a custom prompt'
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Available variables')}:
|
||||
<code>{'{{PREVIOUS_SUMMARY}}'}</code>,
|
||||
<code>{'{{COMPACTED_MESSAGES}}'}</code>,
|
||||
<code>{'{{RECENT_MESSAGES}}'}</code>,
|
||||
<code>{'{{MESSAGES}}'}</code>,
|
||||
<code>{'{{CURRENT_DATE}}'}</code>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-3" />
|
||||
|
||||
<div class="mb-2.5 flex w-full items-center justify-between">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Title Generation')}
|
||||
|
||||
Reference in New Issue
Block a user