Files
open-webui/backend/open_webui/models/chats.py
T
Classic298 699d512e2f perf: drop redundant session.refresh calls after commit across the model layer (#27381)
Both session factories run with expire_on_commit=False, so ORM objects keep their attribute values after commit. Every session.refresh issued right after a commit therefore re-SELECTed a row whose values the session already held, including full chat JSON blobs and user settings, purely to overwrite identical data. Fifty such calls existed across the model layer, covering nearly every write path in the app (chat inserts, title updates, pin/archive toggles, user role and settings updates, tool, prompt, function, model, file, tag, feedback, memory, automation and grant writes).

All fifty are removed. The only refreshes with an actual job were the two update-then-reload paths in tools and skills, where a Core UPDATE statement bypasses the identity map; those now use session.get(..., populate_existing=True), which guarantees a fresh row in one SELECT whether or not the row was already present in the session (the previous code issued get plus refresh, two SELECTs, on the default configuration).

Benchmark (real SQLite DB, per write):

| write path | before | after |
| --- | --- | --- |
| chat title update, ~600 KB chat blob | 2.08 ms | 1.24 ms |
| user role update, small row | 1.21 ms | 0.68 ms |

On Postgres each removed refresh is additionally a network round trip. The chat-blob case also skips re-parsing the entire JSON document per write.

Functionally verified against a fresh database: user insert, role and settings updates, chat insert (including the server-default meta column, which is always provided client-side), title update and pin toggle, tool insert and the Core-update reload path, tag insert and the prompt insert flow that pins version_id after history creation all return correct values and persist correctly.
2026-07-23 18:08:00 -05:00

2238 lines
86 KiB
Python

"""Chat models, forms, and database operations."""
from __future__ import annotations
import json
import logging
import time
import uuid
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
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 pydantic import BaseModel, ConfigDict
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
and_,
delete,
func,
or_,
select,
text,
update,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.sql import exists
from sqlalchemy.sql.expression import bindparam
log = logging.getLogger(__name__)
ACTIVE_CHAT_GAP_SECONDS = 30 * 60
class Chat(Base): # database table mapping for chat entity
__tablename__ = 'chat'
id = Column(String, primary_key=True, unique=True)
user_id = Column(String, index=True) # owner user id
title = Column(Text) # user-visible conversation title
chat = Column(JSON)
created_at = Column(BigInteger, index=True) # conversation creation timestamp
updated_at = Column(BigInteger, index=True) # conversation modification timestamp
share_id = Column(Text, unique=True, nullable=True) # public share link token
archived = Column(Boolean, default=False) # hidden from main chat list
pinned = Column(Boolean, default=False, nullable=True)
meta = Column(JSON, server_default='{}')
folder_id = Column(Text, nullable=True)
tasks = Column(JSON, nullable=True)
summary = Column(Text, nullable=True)
current_message_id = Column(Text, nullable=True)
last_read_at = Column(BigInteger, nullable=True)
__table_args__ = (
# Performance indexes for common queries
Index('folder_id_idx', 'folder_id'),
Index('user_id_pinned_idx', 'user_id', 'pinned'),
Index('user_id_archived_idx', 'user_id', 'archived'),
Index('updated_at_user_id_idx', 'updated_at', 'user_id'),
Index('folder_id_user_id_idx', 'folder_id', 'user_id'),
)
def is_internal_chat(meta: dict | None) -> bool:
return bool(meta and meta.get('internal') is True)
class ChatModel(BaseModel):
model_config = ConfigDict(from_attributes=True) # allows ORM model binding
id: str
user_id: str
title: str
chat: dict
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
share_id: str | None = None
archived: bool = False
pinned: bool | None = False
meta: dict = {}
folder_id: str | None = None
tasks: list | None = None
summary: str | None = None
current_message_id: str | None = None
last_read_at: int | None = None
class ChatFile(Base):
__tablename__ = 'chat_file'
id = Column(Text, unique=True, primary_key=True)
user_id = Column(Text, nullable=False)
chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False)
message_id = Column(Text, nullable=True)
file_id = Column(Text, ForeignKey('file.id', ondelete='CASCADE'), nullable=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'),)
class ChatFileModel(BaseModel):
id: str
user_id: str
chat_id: str
message_id: str | None = None
file_id: str
created_at: int
updated_at: int
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
class ChatForm(BaseModel):
chat: dict
folder_id: str | None = None
class ChatImportForm(ChatForm):
meta: dict | None = {}
pinned: bool | None = False
current_message_id: str | None = None
created_at: int | None = None
updated_at: int | None = None
class ChatsImportForm(BaseModel):
chats: list[ChatImportForm]
class ChatTitleMessagesForm(BaseModel):
title: str
messages: list[dict]
class ChatTitleForm(BaseModel):
title: str
class ChatResponse(BaseModel):
id: str
user_id: str
title: str
chat: dict
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
share_id: str | None = None # id of the chat to be shared
archived: bool
pinned: bool | None = False
meta: dict = {}
folder_id: str | None = None
tasks: list | None = None
summary: str | None = None
current_message_id: str | None = None
context_usage: dict | None = None
class ChatTitleIdResponse(BaseModel):
id: str
title: str
updated_at: int
created_at: int
last_read_at: int | None = None
snippet: str | None = None
active: bool = False
class SharedChatResponse(BaseModel):
id: str
title: str
share_id: str | None = None
updated_at: int
created_at: int
class ChatListResponse(BaseModel):
items: list[ChatModel]
total: int
class ChatUsageStatsResponse(BaseModel):
id: str # chat id
models: dict = {} # models used in the chat with their usage counts
message_count: int # number of messages in the chat
history_models: dict = {} # models used in the chat history with their usage counts
history_message_count: int # number of messages in the chat history
history_user_message_count: int # number of user messages in the chat history
history_assistant_message_count: int # number of assistant messages in the chat history
average_response_time: float # average response time of assistant messages in seconds
average_user_message_content_length: float # average length of user message contents
average_assistant_message_content_length: float # average length of assistant message contents
tags: list[str] = [] # tags associated with the chat
last_message_at: int # timestamp of the last message
updated_at: int
created_at: int
model_config = ConfigDict(extra='allow')
class ChatUsageStatsListResponse(BaseModel):
items: list[ChatUsageStatsResponse]
total: int
model_config = ConfigDict(extra='allow')
class MessageStats(BaseModel):
id: str
role: str
model: str | None = None
content_length: int
token_count: int | None = None
timestamp: int | None = None
rating: int | None = None # Derived from message.annotation.rating
tags: list[str | None] = None # Derived from message.annotation.tags
class ChatHistoryStats(BaseModel):
messages: dict[str, MessageStats]
currentId: str | None = None
class ChatBody(BaseModel):
history: ChatHistoryStats
class AggregateChatStats(BaseModel):
average_response_time: float
average_user_message_content_length: float
average_assistant_message_content_length: float
models: dict[str, int]
message_count: int
history_models: dict[str, int]
history_message_count: int
history_user_message_count: int
history_assistant_message_count: int
class ChatStatsExport(BaseModel):
id: str
user_id: str
created_at: int
updated_at: int
tags: list[str] = []
stats: AggregateChatStats
chat: ChatBody
class ChatTable:
def _clean_null_bytes(self, obj):
"""Recursively remove null bytes from strings in dict/list structures."""
return sanitize_data_for_db(obj)
def get_current_message_id(self, chat: dict | None) -> str | None:
chat = chat or {}
history = chat.get('history') if isinstance(chat.get('history'), dict) else {}
current_id = history.get('currentId') or chat.get('currentId') or chat.get('branchPointMessageId')
if current_id:
return current_id
messages = chat.get('messages')
if isinstance(messages, list):
for message in reversed(messages):
if isinstance(message, dict) and message.get('id'):
return message['id']
return None
def _sanitize_chat_row(self, chat_item):
"""
Clean a Chat SQLAlchemy model's title + chat JSON,
and return True if anything changed.
"""
changed = False
# Clean title
if chat_item.title:
cleaned = self._clean_null_bytes(chat_item.title)
if cleaned != chat_item.title:
chat_item.title = cleaned
changed = True
# Clean JSON
if chat_item.chat:
cleaned = self._clean_null_bytes(chat_item.chat)
if cleaned != chat_item.chat:
chat_item.chat = cleaned
changed = True
return changed
def _repair_chat_current_id(self, chat: dict) -> bool:
history = chat.get('history')
if not isinstance(history, dict):
return False
messages = history.get('messages')
if not isinstance(messages, dict):
return False
current_id = history.get('currentId')
current_message = messages.get(current_id)
output = []
if isinstance(current_message, dict):
output = current_message.get('output') or []
output_role = next(
(item.get('role') for item in output if isinstance(item, dict) and item.get('role')),
None,
)
current_is_bad_leaf = (
isinstance(current_message, dict)
and output_role == 'assistant'
and current_message.get('parentId') is None
and not current_message.get('timestamp')
and len(messages) > 1
)
if (
isinstance(current_message, dict)
and current_message.get('id')
and current_message.get('role')
and not current_is_bad_leaf
):
return False
latest_leaf_id = None
latest_timestamp = -1
for message_id, message in messages.items():
if not isinstance(message, dict) or not message.get('role'):
continue
children_ids = message.get('childrenIds') if isinstance(message.get('childrenIds'), list) else []
timestamp = message.get('timestamp') or 0
if len(children_ids) == 0 and timestamp > latest_timestamp:
latest_leaf_id = message_id
latest_timestamp = timestamp
if not latest_leaf_id or latest_leaf_id == current_id:
return False
history['currentId'] = latest_leaf_id
return True
async def insert_new_chat(
self,
id: str,
user_id: str,
form_data: ChatForm,
db: AsyncSession | None = None,
*,
internal_meta: dict | None = None,
) -> ChatModel | None:
async with get_async_db_context(db) as session:
chat = ChatModel(
**{
'id': id,
'user_id': user_id,
'title': self._clean_null_bytes(
form_data.chat['title'] if 'title' in form_data.chat else 'New Chat'
),
'chat': self._clean_null_bytes(form_data.chat),
'folder_id': form_data.folder_id,
'meta': internal_meta or {},
'current_message_id': self.get_current_message_id(form_data.chat),
'created_at': int(time.time()),
'updated_at': int(time.time()),
'last_read_at': int(time.time()),
}
)
chat_item = Chat(**chat.model_dump())
session.add(chat_item)
await session.commit()
# Dual-write initial messages to chat_message table
try:
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
if not messages and isinstance(form_data.chat.get('messages'), list):
messages = {
message.get('id'): message
for message in form_data.chat['messages']
if isinstance(message, dict) and message.get('id')
}
for message_id, message in messages.items():
if isinstance(message, dict) and message.get('role'):
await ChatMessages.upsert_message(
message_id=message_id,
chat_id=id,
user_id=user_id,
data=message,
)
except Exception as e:
log.warning(f'Failed to write initial messages to chat_message table: {e}')
return ChatModel.model_validate(chat_item) if chat_item else None
async def get_internal_chat_ids_by_parent_id(self, parent_chat_id: str, user_id: str) -> list[str]:
async with get_async_db_context() as session:
result = await session.execute(
select(Chat.id).where(
Chat.user_id == user_id,
Chat.meta['internal'].as_boolean().is_(True),
Chat.meta['parent_chat_id'].as_string() == parent_chat_id,
)
)
return list(result.scalars().all())
async def get_internal_chat_by_note_id(
self, note_id: str, user_id: str, db: AsyncSession | None = None
) -> ChatModel | None:
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat)
.where(
Chat.user_id == user_id,
Chat.meta['internal'].as_boolean().is_(True),
Chat.meta['type'].as_string() == 'note',
Chat.meta['note_id'].as_string() == note_id,
)
.order_by(Chat.updated_at.desc(), Chat.created_at.desc())
)
chat = result.scalars().first()
return ChatModel.model_validate(chat) if chat else None
async def get_internal_chats_by_note_id(
self, note_id: str, user_id: str, db: AsyncSession | None = None
) -> list[ChatModel]:
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat)
.where(
Chat.user_id == user_id,
Chat.meta['internal'].as_boolean().is_(True),
Chat.meta['type'].as_string() == 'note',
Chat.meta['note_id'].as_string() == note_id,
)
.order_by(Chat.updated_at.desc(), Chat.created_at.desc())
)
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel:
id = str(uuid.uuid4())
chat = ChatModel(
**{
'id': id,
'user_id': user_id,
'title': self._clean_null_bytes(form_data.chat['title'] if 'title' in form_data.chat else 'New Chat'),
'chat': self._clean_null_bytes(form_data.chat),
'meta': form_data.meta,
'pinned': form_data.pinned,
'folder_id': form_data.folder_id,
'current_message_id': form_data.current_message_id or self.get_current_message_id(form_data.chat),
'created_at': (form_data.created_at if form_data.created_at else int(time.time())),
'updated_at': (form_data.updated_at if form_data.updated_at else int(time.time())),
}
)
return chat
async def import_chats(
self,
user_id: str,
chat_import_forms: list[ChatImportForm],
db: AsyncSession | None = None,
) -> list[ChatModel]:
async with get_async_db_context(db) as session:
# Validate folder_id references — clear any that don't exist
folder_ids = {f.folder_id for f in chat_import_forms if f.folder_id}
existing = set()
for fid in folder_ids:
if await Folders.get_folder_by_id_and_user_id(fid, user_id, db=session):
existing.add(fid)
cleared = 0
for form in chat_import_forms:
if form.folder_id and form.folder_id not in existing:
form.folder_id = None
cleared += 1
if cleared:
log.info('Import: cleared %d dangling folder_id(s) for user %s', cleared, user_id)
chats = []
for form_data in chat_import_forms:
chat = self._chat_import_form_to_chat_model(user_id, form_data)
chats.append(Chat(**chat.model_dump()))
session.add_all(chats)
await session.commit()
# Dual-write messages to chat_message table
for form_data, chat_obj in zip(chat_import_forms, chats):
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
if not messages and isinstance(form_data.chat.get('messages'), list):
messages = {
message.get('id'): message
for message in form_data.chat['messages']
if isinstance(message, dict) and message.get('id')
}
for message_id, message in messages.items():
if isinstance(message, dict) and message.get('role'):
try:
await ChatMessages.upsert_message(
message_id=message_id,
chat_id=chat_obj.id,
user_id=user_id,
data=message,
)
except Exception as e:
log.warning(f'Failed to write imported message {message_id} for chat {chat_obj.id}: {e}')
return [ChatModel.model_validate(chat) for chat in chats]
async def update_chat_by_id(
self,
id: str,
chat: dict,
db: AsyncSession | None = None,
*,
touch: bool = True,
) -> ChatModel | None:
"""Persist updated chat content, sanitizing null bytes."""
try: # load the chat record for in-place mutation
async with get_async_db_context(db) as session:
chat_item = await session.get(Chat, id)
if chat_item is None:
return None
chat_item.chat = self._clean_null_bytes(chat)
chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat'
if any(key in chat for key in ('history', 'messages', 'currentId', 'branchPointMessageId')):
chat_item.current_message_id = self.get_current_message_id(chat)
if touch:
chat_item.updated_at = int(time.time())
await session.commit()
return ChatModel.model_validate(chat_item)
except Exception:
return
async def update_chat_last_read_at_by_id(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool:
try:
async with get_async_db_context(db) as session:
chat = await session.get(Chat, id)
if chat and chat.user_id == user_id:
chat.last_read_at = int(time.time())
await session.commit()
return True
return False
except Exception:
return False
async def update_chat_title_by_id(self, id: str, title: str) -> ChatModel | None:
try:
async with get_async_db_context() as session:
chat_item = await session.get(Chat, id)
if chat_item is None:
return None
clean_title = self._clean_null_bytes(title)
chat_item.title = clean_title
chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title}
await session.commit()
return ChatModel.model_validate(chat_item)
except Exception:
return None
async def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> ChatModel | None:
async with get_async_db_context() as session:
chat = await session.get(Chat, id)
if chat is None:
return None
old_tags = chat.meta.get('tags', [])
new_tags = [t for t in tags if t.replace(' ', '_').lower() != 'none']
new_tag_ids = [t.replace(' ', '_').lower() for t in new_tags]
# Single meta update
chat.meta = {**chat.meta, 'tags': new_tag_ids}
await session.commit()
# Batch-create any missing tag rows
await Tags.ensure_tags_exist(new_tags, user.id, db=session)
# Clean up orphaned old tags in one query
removed = set(old_tags) - set(new_tag_ids)
if removed:
await self.delete_orphan_tags_for_user(list(removed), user.id, db=session)
return ChatModel.model_validate(chat)
async def get_chat_title_by_id(self, id: str) -> str | None:
async with get_async_db_context() as session:
result = await session.execute(select(Chat.title).filter_by(id=id))
row = result.first()
if row is None:
return None
return row[0] or 'New Chat'
@staticmethod
def get_unresolved_parent_ids(messages_map: dict) -> set[str]:
"""Return parent IDs referenced by messages but absent from the map.
An empty set means the message graph is fully connected.
"""
return {
msg['parentId']
for msg in messages_map.values()
if msg.get('parentId') and msg['parentId'] not in messages_map
}
@staticmethod
def merge_history(existing_history: dict | None, incoming_history: dict | None) -> dict:
existing = (existing_history or {}).get('messages') or {}
incoming = (incoming_history or {}).get('messages') or {}
merged = {**existing, **incoming}
merged = {message_id: message for message_id, message in merged.items() if isinstance(message, dict)}
for message in merged.values():
message['childrenIds'] = []
for message_id, message in merged.items():
parent_id = message.get('parentId')
if parent_id in merged:
merged[parent_id]['childrenIds'].append(message_id)
current_id = (incoming_history or {}).get('currentId')
if current_id not in merged:
current_id = (existing_history or {}).get('currentId')
if current_id not in merged:
current_id = None
return {**(existing_history or {}), **(incoming_history or {}), 'messages': merged, 'currentId': current_id}
@staticmethod
def delete_message_from_history(history: dict, message_id: str) -> set[str]:
messages = history.get('messages') or {}
message = messages.get(message_id)
if not isinstance(message, dict):
return set()
parent_id = message.get('parentId')
child_ids = [child_id for child_id in (message.get('childrenIds') or []) if child_id in messages]
grandchild_ids = [
grandchild_id
for child_id in child_ids
for grandchild_id in (messages.get(child_id, {}).get('childrenIds') or [])
if grandchild_id in messages
]
if parent_id in messages:
messages[parent_id]['childrenIds'] = [
child_id for child_id in (messages[parent_id].get('childrenIds') or []) if child_id != message_id
] + grandchild_ids
for grandchild_id in grandchild_ids:
messages[grandchild_id]['parentId'] = parent_id
deleted_ids = {message_id, *child_ids}
for deleted_id in deleted_ids:
messages.pop(deleted_id, None)
current_id = parent_id
child_ids = (
[child_id for child_id, child in messages.items() if child.get('parentId') is None]
if current_id is None
else messages.get(current_id, {}).get('childrenIds', [])
)
while child_ids:
current_id = child_ids[-1]
child_ids = messages.get(current_id, {}).get('childrenIds', [])
history['currentId'] = current_id if current_id in messages else None
return deleted_ids
async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
"""Write messages to the ``chat_message`` table so future lookups
use the fast path. Errors are logged but never raised.
"""
for message_id, message in messages.items():
if not isinstance(message, dict) or not message.get('role'):
continue
try:
await ChatMessages.upsert_message(
message_id=message_id,
chat_id=chat_id,
user_id=user_id,
data=message,
)
except Exception as e:
log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e)
async def reconcile_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
"""Sync ``chat_message`` rows with the committed JSON blob.
Upserts current messages via ``backfill_messages_by_chat_id``.
Best-effort: errors are logged but never raised.
"""
try:
await self.backfill_messages_by_chat_id(chat_id, user_id, messages)
except Exception as e:
log.warning('Failed to reconcile chat_message rows for chat %s: %s', chat_id, e)
async def get_messages_map_by_chat_id(self, id: str) -> dict | None:
"""Message map for walking history (see ``get_message_list``).
Prefer ``chat_message`` rows to avoid loading the large embedded
history; fall back to the legacy JSON when no rows exist.
When rows exist but the parent-link graph has gaps (e.g. migration
failures), missing messages are merged from the legacy history
and backfilled so future requests self-heal.
"""
# Fast path: build from normalized chat_message rows.
messages_map = await ChatMessages.get_messages_map_by_chat_id(id)
if messages_map is not None:
unresolved_ids = self.get_unresolved_parent_ids(messages_map)
if not unresolved_ids:
return messages_map
# Graph has gaps — enrich from the legacy embedded history.
log.info(
'Chat %s: %d unresolved parent reference(s) in chat_message — enriching from legacy history',
id,
len(unresolved_ids),
)
chat = await self.get_chat_by_id(id)
if chat:
history_messages = chat.chat.get('history', {}).get('messages', {}) or {}
missing_messages = {
message_id: history_messages[message_id]
for message_id in unresolved_ids
if message_id in history_messages
}
if missing_messages:
messages_map.update(missing_messages)
# Backfill so future requests use the fast path.
await self.backfill_messages_by_chat_id(id, chat.user_id, missing_messages)
return messages_map
# No rows — fall back to the legacy embedded history.
chat = await self.get_chat_by_id(id)
if chat is None:
return None
history_messages = chat.chat.get('history', {}).get('messages', {}) or {}
# Backfill so future requests use the fast path.
if history_messages:
await self.backfill_messages_by_chat_id(id, chat.user_id, history_messages)
return history_messages
async def get_message_by_id_and_message_id(self, id: str, message_id: str) -> dict | None:
chat = await self.get_chat_by_id(id)
if chat is None:
return None
return chat.chat.get('history', {}).get('messages', {}).get(message_id, {})
async def upsert_message_to_chat_by_id_and_message_id(
self, id: str, message_id: str, message: dict, *, touch: bool = True
) -> ChatModel | None:
chat = await self.get_chat_by_id(id)
if chat is None:
return None
# Sanitize message content for null characters before upserting
if isinstance(message.get('content'), str):
message['content'] = sanitize_text_for_db(message['content'])
user_id = chat.user_id
chat = chat.chat
history = chat.get('history', {})
messages = history.setdefault('messages', {})
if message_id in messages:
messages[message_id] = {
**messages[message_id],
**message,
}
else:
message_parent_id = message.get('parentId')
parent_id = message_parent_id
if parent_id is None:
for existing_id, existing_message in messages.items():
if message_id in existing_message.get('childrenIds', []):
parent_id = existing_id
break
parent = messages.get(parent_id) if parent_id else None
output = message.get('output') or []
output_role = next(
(item.get('role') for item in output if isinstance(item, dict) and item.get('role')),
None,
)
role = message.get('role') or output_role
if not role:
parent_role = parent.get('role') if parent else None
if parent_role == 'user':
role = 'assistant'
elif parent_role == 'assistant':
role = 'user'
else:
role = 'assistant'
messages[message_id] = {
**message,
'id': message.get('id') or message_id,
'parentId': message_parent_id if message_parent_id is not None else parent_id,
'childrenIds': message.get('childrenIds') if isinstance(message.get('childrenIds'), list) else [],
'role': role,
'timestamp': message.get('timestamp') or int(time.time()),
}
history['currentId'] = message_id
chat['history'] = history
# Dual-write to chat_message table
try:
await ChatMessages.upsert_message(
message_id=message_id,
chat_id=id,
user_id=user_id,
data=messages[message_id],
)
except Exception as e:
log.warning(f'Failed to write to chat_message table: {e}')
return await self.update_chat_by_id(id, chat, touch=touch)
async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None:
chat_model = await self.get_chat_by_id(id)
if chat_model is None:
return None
chat = chat_model.chat
history = chat.get('history', {})
deleted_ids = self.delete_message_from_history(history, message_id)
if not deleted_ids:
return chat_model
messages = history.get('messages') or {}
chat['history'] = history
updated_chat = await self.update_chat_by_id(id, chat)
await self.backfill_messages_by_chat_id(id, chat_model.user_id, messages)
await ChatMessages.delete_message_ids_by_chat_id(id, deleted_ids)
return updated_chat
async def add_message_status_to_chat_by_id_and_message_id(
self, id: str, message_id: str, status: dict
) -> ChatModel | None:
chat = await self.get_chat_by_id(id)
if chat is None:
return None
chat = chat.chat
history = chat.get('history', {})
if message_id in history.get('messages', {}):
status_history = history['messages'][message_id].get('statusHistory', [])
status_history.append(status)
history['messages'][message_id]['statusHistory'] = status_history
chat['history'] = history
return await self.update_chat_by_id(id, chat, touch=False)
async def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files: list[dict]) -> list[dict]:
async with get_async_db_context() as session:
chat = await self.get_chat_by_id(id, db=session)
if chat is None:
return None
chat = chat.chat
history = chat.get('history', {})
message_files = []
if message_id in history.get('messages', {}):
message_files = history['messages'][message_id].get('files', [])
message_files = message_files + files
history['messages'][message_id]['files'] = message_files
chat['history'] = history
await self.update_chat_by_id(id, chat, db=session)
return message_files
async def insert_shared_chat_by_chat_id(self, chat_id: str, db: AsyncSession | None = None) -> ChatModel | None:
"""Create a shared snapshot for a chat. Returns the original chat with share_id set."""
from open_webui.models.shared_chats import SharedChats
async with get_async_db_context(db) as session:
chat = await session.get(Chat, chat_id)
if not chat:
return None
# If already shared, just update the existing snapshot
if chat.share_id:
return await self.update_shared_chat_by_chat_id(chat_id, db=session)
shared = await SharedChats.create(chat_id, chat.user_id, db=session)
if not shared:
return None
# Set share_id on the original chat
chat.share_id = shared.id
await session.commit()
return ChatModel.model_validate(chat) # return the updated original
# refresh helper
async def update_shared_chat_by_chat_id(
self,
chat_id: str,
db: AsyncSession | None = None,
) -> ChatModel | None:
"""Refresh the shared snapshot with current chat content."""
from open_webui.models.shared_chats import SharedChats
async with get_async_db_context(db) as session:
record = await session.get(Chat, chat_id)
if not record or not record.share_id:
return await self.insert_shared_chat_by_chat_id(chat_id, db=session)
await SharedChats.update(record.share_id, db=session)
return ChatModel.model_validate(record)
# unreachable — context manager above always returns
return
async def delete_shared_chat_by_chat_id(self, chat_id: str, db: AsyncSession | None = None) -> bool:
"""Delete shared snapshot for a chat."""
from open_webui.models.shared_chats import SharedChats
try:
return await SharedChats.delete_by_chat_id(chat_id, db=db)
except Exception:
return False
async def unarchive_all_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> bool:
try:
async with get_async_db_context(db) as session:
await session.execute(update(Chat).filter_by(user_id=user_id).values(archived=False))
await session.commit()
return True
except Exception:
return False
async def update_chat_share_id_by_id(
self, id: str, share_id: str | None, db: AsyncSession | None = None
) -> ChatModel | None:
try:
async with get_async_db_context(db) as session:
chat = await session.get(Chat, id)
chat.share_id = share_id
await session.commit()
return ChatModel.model_validate(chat)
except Exception:
return None
async def toggle_chat_pinned_by_id(self, id: str, db: AsyncSession | None = None) -> ChatModel | None:
try:
async with get_async_db_context(db) as session:
chat = await session.get(Chat, id)
chat.pinned = not chat.pinned
chat.updated_at = int(time.time())
chat.last_read_at = int(time.time())
await session.commit()
return ChatModel.model_validate(chat)
except Exception:
return None
async def toggle_chat_archive_by_id(self, id: str, db: AsyncSession | None = None) -> ChatModel | None:
try:
async with get_async_db_context(db) as session:
chat = await session.get(Chat, id)
chat.archived = not chat.archived
chat.folder_id = None
chat.updated_at = int(time.time())
chat.last_read_at = int(time.time())
await session.commit()
return ChatModel.model_validate(chat)
except Exception:
return None
async def archive_all_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> bool:
try:
async with get_async_db_context(db) as session:
await session.execute(update(Chat).filter_by(user_id=user_id).values(archived=True))
await session.commit()
return True
except Exception:
return False
async def get_archived_chat_list_by_user_id(
self,
user_id: str,
filter: dict | None = None,
skip: int = 0,
limit: int = 50,
db: AsyncSession | None = None,
) -> list[ChatTitleIdResponse]:
async with get_async_db_context(db) as session:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at).filter_by(
user_id=user_id, archived=True
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if filter:
query_key = filter.get('query')
if query_key:
stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%'))
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by and direction:
if not getattr(Chat, order_by, None):
raise ValueError('Invalid order_by field')
if direction.lower() == 'asc':
stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id)
elif direction.lower() == 'desc':
stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id)
else:
raise ValueError('Invalid direction for ordering')
else:
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
all_chats = result.all()
return [
ChatTitleIdResponse.model_validate(
{
'id': chat[0],
'title': chat[1],
'updated_at': chat[2],
'created_at': chat[3],
}
)
for chat in all_chats
]
async def count_archived_chats_by_user_id(
self,
user_id: str,
db: AsyncSession | None = None,
) -> int:
async with get_async_db_context(db) as session:
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True)
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
return result.scalar() or 0
async def get_shared_chat_list_by_user_id(
self,
user_id: str,
filter: dict | None = None,
skip: int = 0,
limit: int = 50,
db: AsyncSession | None = None,
) -> list[SharedChatResponse]:
"""Delegate to SharedChats for listing shared chats by user."""
from open_webui.models.shared_chats import SharedChats
return await SharedChats.get_by_user_id(user_id, filter=filter, skip=skip, limit=limit, db=db)
async def get_chat_list_by_user_id(
self,
user_id: str,
include_archived: bool = False,
filter: dict | None = None,
skip: int = 0,
limit: int = 50,
db: AsyncSession | None = None,
) -> list[ChatTitleIdResponse]:
async with get_async_db_context(db) as session:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if not include_archived:
stmt = stmt.filter_by(archived=False)
if filter:
query_key = filter.get('query')
if query_key:
stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%'))
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by and direction and getattr(Chat, order_by):
if direction.lower() == 'asc':
stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id)
elif direction.lower() == 'desc':
stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id)
else:
raise ValueError('Invalid direction for ordering')
else:
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
all_chats = result.all()
return [
ChatTitleIdResponse.model_validate(
{
'id': chat[0],
'title': chat[1],
'updated_at': chat[2],
'created_at': chat[3],
'last_read_at': chat[4],
}
)
for chat in all_chats
]
async def get_chat_title_id_list_by_user_id(
self,
user_id: str,
include_archived: bool = False,
include_folders: bool = False,
include_pinned: bool = False,
skip: int | None = None,
limit: int | None = None,
db: AsyncSession | None = None,
) -> list[ChatTitleIdResponse]:
async with get_async_db_context(db) as session:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if not include_folders:
stmt = stmt.filter_by(folder_id=None)
if not include_pinned:
stmt = stmt.filter(or_(Chat.pinned == False, Chat.pinned == None))
if not include_archived:
stmt = stmt.filter_by(archived=False)
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
all_chats = result.all()
return [
ChatTitleIdResponse.model_validate(
{
'id': chat[0],
'title': chat[1],
'updated_at': chat[2],
'created_at': chat[3],
'last_read_at': chat[4],
}
)
for chat in all_chats
]
async def get_chat_list_by_chat_ids(
self,
chat_ids: list[str],
skip: int = 0,
limit: int = 50,
db: AsyncSession | None = None,
) -> list[ChatModel]:
async with get_async_db_context(db) as session:
stmt = select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
async def get_chat_metas_by_chat_ids(
self,
chat_ids: list[str],
include_archived: bool = False,
db: AsyncSession | None = None,
) -> list[dict]:
async with get_async_db_context(db) as session:
stmt = select(Chat.meta).filter(Chat.id.in_(chat_ids))
if not include_archived:
stmt = stmt.filter_by(archived=False)
result = await session.execute(stmt)
return [meta for meta in result.scalars().all() if isinstance(meta, dict)]
async def get_chats_by_model_id(
self,
model_id: str,
filter: dict | None = None,
skip: int = 0,
limit: int = 50,
db: AsyncSession | None = None,
) -> dict:
from open_webui.models.users import User
async with get_async_db_context(db) as session:
chat_ids = (
select(ChatMessage.chat_id).filter(ChatMessage.model_id == model_id).group_by(ChatMessage.chat_id)
)
if filter:
if filter.get('start_date'):
chat_ids = chat_ids.filter(ChatMessage.created_at >= filter.get('start_date'))
if filter.get('end_date'):
chat_ids = chat_ids.filter(ChatMessage.created_at <= filter.get('end_date'))
chat_ids = chat_ids.subquery()
stmt = (
select(Chat.id, Chat.user_id, Chat.title, Chat.updated_at, User.name.label('user_name'))
.join(chat_ids, chat_ids.c.chat_id == Chat.id)
.outerjoin(User, User.id == Chat.user_id)
.where(Chat.meta['internal'].as_boolean().is_not(True))
)
order_by = filter.get('order_by') if filter else None
direction = filter.get('direction') if filter else None
is_asc = direction == 'asc'
if order_by == 'title':
primary_sort = Chat.title.asc() if is_asc else Chat.title.desc()
elif order_by == 'user_name':
primary_sort = User.name.asc() if is_asc else User.name.desc()
else:
primary_sort = Chat.updated_at.asc() if is_asc else Chat.updated_at.desc()
stmt = stmt.order_by(primary_sort, Chat.id.asc())
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return {
'items': [
{
'chat_id': chat.id,
'user_id': chat.user_id,
'user_name': chat.user_name,
'first_message': chat.title,
'updated_at': chat.updated_at,
}
for chat in result.all()
],
'total': total,
}
# retrieve conversation
async def get_chat_by_id(
self,
id: str,
db: AsyncSession | None = None,
) -> ChatModel | None:
"""Fetch a chat by PK, auto-sanitizing null bytes on read."""
try:
async with get_async_db_context(db) as session:
chat_item = await session.get(Chat, id)
if chat_item is None:
return None
repaired_history = self._repair_chat_current_id(chat_item.chat or {})
if repaired_history:
flag_modified(chat_item, 'chat')
if self._sanitize_chat_row(chat_item) or repaired_history:
await session.commit()
return ChatModel.model_validate(chat_item)
except Exception:
return None
async def get_chat_by_share_id(self, id: str, db: AsyncSession | None = None) -> ChatModel | None:
"""Look up a shared chat snapshot by its share token."""
from open_webui.models.shared_chats import SharedChats
try:
shared = await SharedChats.get_by_id(id, db=db)
if shared:
# Return a ChatModel-compatible view of the snapshot
return ChatModel(
id=shared.id,
user_id=shared.user_id,
title=shared.title,
chat=shared.chat,
created_at=shared.created_at,
updated_at=shared.updated_at,
share_id=shared.id,
)
return None
except Exception:
return None
async def get_chat_by_id_and_user_id(
self, id: str, user_id: str, db: AsyncSession | None = None
) -> ChatModel | None:
try:
async with get_async_db_context(db) as session:
result = await session.execute(select(Chat).filter_by(id=id, user_id=user_id))
chat = result.scalars().first()
if not chat:
return None
repaired_history = self._repair_chat_current_id(chat.chat or {})
if repaired_history:
flag_modified(chat, 'chat')
if self._sanitize_chat_row(chat) or repaired_history:
await session.commit()
return ChatModel.model_validate(chat)
except Exception:
return None
async def is_chat_owner(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool:
"""
Lightweight ownership check — uses EXISTS subquery instead of loading
the full Chat row (which includes the potentially large JSON blob).
"""
try:
async with get_async_db_context(db) as session:
result = await session.execute(select(exists().where(and_(Chat.id == id, Chat.user_id == user_id))))
return result.scalar()
except Exception:
return False
async def get_chat_folder_id(self, id: str, user_id: str, db: AsyncSession | None = None) -> str | None:
"""
Fetch only the folder_id column for a chat, without loading the full
JSON blob. Returns None if chat doesn't exist or doesn't belong to user.
"""
try:
async with get_async_db_context(db) as session:
result = await session.execute(select(Chat.folder_id).filter_by(id=id, user_id=user_id))
row = result.first()
return row[0] if row else None
except Exception:
return None
async def get_chats(self, skip: int = 0, limit: int = 50, db: AsyncSession | None = None) -> list[ChatModel]:
async with get_async_db_context(db) as session:
stmt = select(Chat).where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
async def get_user_usage_chat_stats(self, user_id: str, db: AsyncSession | None = None) -> dict:
async with get_async_db_context(db) as session:
chat_filter = (Chat.user_id == user_id, Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(select(func.count(Chat.id).label('total_chats')).where(*chat_filter))
total_chats = int(result.scalar() or 0)
messages_stmt = (
select(ChatMessage.chat_id, ChatMessage.created_at)
.join(Chat, Chat.id == ChatMessage.chat_id)
.where(*chat_filter, ChatMessage.created_at.isnot(None))
.order_by(ChatMessage.chat_id, ChatMessage.created_at.asc())
)
messages_result = await session.execute(messages_stmt)
last_message_at_by_chat: dict[str, int] = {}
active_seconds_by_chat: dict[str, int] = {}
for chat_id, created_at in messages_result.all():
timestamp = int(created_at / 1000) if created_at > 10_000_000_000 else int(created_at)
last_message_at = last_message_at_by_chat.get(chat_id)
if last_message_at is not None:
delta = timestamp - last_message_at
if 0 < delta <= ACTIVE_CHAT_GAP_SECONDS:
active_seconds_by_chat[chat_id] = active_seconds_by_chat.get(chat_id, 0) + delta
last_message_at_by_chat[chat_id] = timestamp
return {
'total_chats': total_chats,
'longest_chat_seconds': max(active_seconds_by_chat.values(), default=0),
}
# list user conversations
async def get_chats_by_user_id(
self,
user_id: str,
filter: dict | None = None,
skip: int | None = None,
limit: int | None = None,
db: AsyncSession | None = None,
) -> ChatListResponse:
async with get_async_db_context(db) as session:
stmt = select(Chat).filter_by(user_id=user_id)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if filter:
if filter.get('updated_at'):
stmt = stmt.filter(Chat.updated_at > filter.get('updated_at'))
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by and direction:
if hasattr(Chat, order_by):
if direction.lower() == 'asc':
stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id)
elif direction.lower() == 'desc':
stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id)
else:
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
else:
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip is not None:
stmt = stmt.offset(skip)
if limit is not None:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
all_chats = result.scalars().all()
return ChatListResponse(
**{
'items': [ChatModel.model_validate(chat) for chat in all_chats],
'total': total,
}
)
# list pinned chats
async def get_pinned_chats_by_user_id(
self, user_id: str, db: AsyncSession | None = None
) -> list[ChatTitleIdResponse]:
async with get_async_db_context(db) as session:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id, pinned=True, archived=False
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
all_chats = result.all()
return [
ChatTitleIdResponse.model_validate(
{
'id': chat[0],
'title': chat[1],
'updated_at': chat[2],
'created_at': chat[3],
'last_read_at': chat[4],
}
)
for chat in all_chats
]
async def get_archived_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> list[ChatModel]:
async with get_async_db_context(db) as session:
stmt = select(Chat).filter_by(user_id=user_id, archived=True)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
# search user conversations
async def get_chats_by_user_id_and_search_text(
self,
user_id: str,
search_text: str,
include_archived: bool = False,
skip: int = 0,
limit: int = 60,
db: AsyncSession | None = None,
) -> list[ChatModel]:
"""
Filters chats based on a search query using Python, allowing pagination using skip and limit.
"""
search_text = sanitize_text_for_db(search_text).lower().strip()
if not search_text:
return await self.get_chat_list_by_user_id(
user_id, include_archived, filter={}, skip=skip, limit=limit, db=db
)
search_text_words = search_text.split(' ')
# search_text might contain 'tag:tag_name' format so we need to extract the tag_name
tag_ids = [
word.replace('tag:', '').replace(' ', '_').lower() for word in search_text_words if word.startswith('tag:')
]
# Extract folder names
folders = await Folders.search_folders_by_names(
user_id,
[word.replace('folder:', '') for word in search_text_words if word.startswith('folder:')],
)
folder_ids = [folder.id for folder in folders]
is_pinned = None
if 'pinned:true' in search_text_words:
is_pinned = True
elif 'pinned:false' in search_text_words:
is_pinned = False
is_archived = None
if 'archived:true' in search_text_words:
is_archived = True
elif 'archived:false' in search_text_words:
is_archived = False
is_shared = None
if 'shared:true' in search_text_words:
is_shared = True
elif 'shared:false' in search_text_words:
is_shared = False
search_text_words = [
word
for word in search_text_words
if (
not word.startswith('tag:')
and not word.startswith('folder:')
and not word.startswith('pinned:')
and not word.startswith('archived:')
and not word.startswith('shared:')
)
]
search_text = ' '.join(search_text_words)
async with get_async_db_context(db) as session:
stmt = select(Chat).filter(Chat.user_id == user_id)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if is_archived is not None:
stmt = stmt.filter(Chat.archived == is_archived)
elif not include_archived:
stmt = stmt.filter(Chat.archived == False)
if is_pinned is not None:
stmt = stmt.filter(Chat.pinned == is_pinned)
if is_shared is not None:
if is_shared:
stmt = stmt.filter(Chat.share_id.isnot(None))
else:
stmt = stmt.filter(Chat.share_id.is_(None))
if folder_ids:
stmt = stmt.filter(Chat.folder_id.in_(folder_ids))
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
# Check if the database dialect is either 'sqlite' or 'postgresql'
bind = await session.connection()
dialect_name = bind.dialect.name
if dialect_name == 'sqlite':
# SQLite case: using JSON1 extension for JSON searching
sqlite_content_sql = (
'EXISTS ('
' SELECT 1 '
" FROM json_each(Chat.chat, '$.messages') AS message "
" WHERE LOWER(message.value->>'content') LIKE '%' || :content_key || '%'"
')'
)
sqlite_content_clause = text(sqlite_content_sql)
stmt = stmt.filter(
or_(Chat.title.ilike(bindparam('title_key')), sqlite_content_clause).params(
title_key=f'%{search_text}%', content_key=search_text
)
)
# Check if there are any tags to filter
if 'none' in tag_ids:
stmt = stmt.filter(
text("""
NOT EXISTS (
SELECT 1
FROM json_each(Chat.meta, '$.tags') AS tag
)
""")
)
elif tag_ids:
stmt = stmt.filter(
and_(
*[
text(f"""
EXISTS (
SELECT 1
FROM json_each(Chat.meta, '$.tags') AS tag
WHERE tag.value = :tag_id_{tag_idx}
)
""").params(**{f'tag_id_{tag_idx}': tag_id})
for tag_idx, tag_id in enumerate(tag_ids)
]
)
)
elif dialect_name == 'postgresql':
# Safety filter: JSON field must not contain \u0000
stmt = stmt.filter(text("Chat.chat::text NOT LIKE '%\\\\u0000%'"))
# Safety filter: title must not contain actual null bytes
stmt = stmt.filter(text("Chat.title::text NOT LIKE '%\\x00%'"))
postgres_content_sql = """
EXISTS (
SELECT 1
FROM chat_message AS message
WHERE message.chat_id = Chat.id
AND message.user_id = Chat.user_id
AND json_typeof(message.content) = 'string'
AND LOWER(message.content #>> '{}') LIKE '%' || :content_key || '%'
)
OR EXISTS (
SELECT 1
FROM json_each(Chat.chat#>'{history,messages}') AS history_message
WHERE json_typeof(history_message.value->'content') = 'string'
AND LOWER(history_message.value->>'content') LIKE '%' || :content_key || '%'
)
OR EXISTS (
SELECT 1
FROM json_array_elements(Chat.chat->'messages') AS legacy_message
WHERE json_typeof(legacy_message->'content') = 'string'
AND LOWER(legacy_message->>'content') LIKE '%' || :content_key || '%'
)
"""
postgres_content_clause = text(postgres_content_sql)
stmt = stmt.filter(
or_(
Chat.title.ilike(bindparam('title_key')),
postgres_content_clause,
)
).params(title_key=f'%{search_text}%', content_key=search_text.lower())
if 'none' in tag_ids:
stmt = stmt.filter(
text("""
NOT EXISTS (
SELECT 1
FROM json_array_elements_text(Chat.meta->'tags') AS tag
)
""")
)
elif tag_ids:
stmt = stmt.filter(
and_(
*[
text(f"""
EXISTS (
SELECT 1
FROM json_array_elements_text(Chat.meta->'tags') AS tag
WHERE tag = :tag_id_{tag_idx}
)
""").params(**{f'tag_id_{tag_idx}': tag_id})
for tag_idx, tag_id in enumerate(tag_ids)
]
)
)
else:
raise NotImplementedError(f'Unsupported dialect: {dialect_name}')
# Perform pagination at the SQL level
stmt = stmt.offset(skip).limit(limit)
result = await session.execute(stmt)
all_chats = result.scalars().all()
log.info(f'The number of chats: {len(all_chats)}')
# Validate and return chats
return [ChatModel.model_validate(chat) for chat in all_chats]
async def get_chats_by_folder_id_and_user_id(
self,
folder_id: str,
user_id: str,
skip: int = 0,
limit: int = 60,
db: AsyncSession | None = None,
) -> list[ChatTitleIdResponse]:
async with get_async_db_context(db) as session:
stmt = (
select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at)
.filter_by(folder_id=folder_id, user_id=user_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(Chat.updated_at.desc(), Chat.id)
)
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
all_chats = result.all()
return [
ChatTitleIdResponse.model_validate(
{
'id': chat[0],
'title': chat[1],
'updated_at': chat[2],
'created_at': chat[3],
'last_read_at': chat[4],
}
)
for chat in all_chats
]
async def get_all_chats_by_folder_id(
self,
folder_id: str,
skip: int = 0,
limit: int = 60,
sort_by: str = 'updated_at',
sort_dir: str = 'desc',
db: AsyncSession | None = None,
) -> list[dict]:
"""Get chats in a folder across ALL users. Returns dicts with user_id."""
async with get_async_db_context(db) as session:
sort_column = Chat.title if sort_by == 'title' else Chat.updated_at
order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc()
stmt = (
select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at)
.filter_by(folder_id=folder_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(order_clause, Chat.id)
)
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
all_chats = result.all()
return [
{
'id': chat[0],
'title': chat[1],
'user_id': chat[2],
'updated_at': chat[3],
'created_at': chat[4],
'last_read_at': chat[5],
}
for chat in all_chats
]
async def count_all_chats_by_folder_id(
self,
folder_id: str,
db: AsyncSession | None = None,
) -> int:
async with get_async_db_context(db) as session:
stmt = (
select(func.count(Chat.id))
.filter_by(folder_id=folder_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
)
result = await session.execute(stmt)
return result.scalar_one()
async def get_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
) -> list[ChatModel]:
async with get_async_db_context(db) as session:
stmt = (
select(Chat)
.filter(Chat.folder_id.in_(folder_ids), Chat.user_id == user_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(Chat.updated_at.desc())
)
result = await session.execute(stmt)
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
async def update_chat_folder_id_by_id_and_user_id(
self, id: str, user_id: str, folder_id: str, db: AsyncSession | None = None
) -> ChatModel | None:
try:
async with get_async_db_context(db) as session:
chat = await session.get(Chat, id)
chat.folder_id = folder_id
chat.updated_at = int(time.time())
chat.last_read_at = int(time.time())
chat.pinned = False
await session.commit()
return ChatModel.model_validate(chat)
except Exception:
return None
async def get_chat_tags_by_id_and_user_id(
self, id: str, user_id: str, db: AsyncSession | None = None
) -> list[TagModel]:
async with get_async_db_context(db) as session:
stmt = select(Chat.meta).where(Chat.id == id)
result = await session.execute(stmt)
meta = result.scalar_one_or_none()
tag_ids = (meta or {}).get('tags', [])
return await Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=session)
async def get_chat_list_by_user_id_and_tag_name(
self,
user_id: str,
tag_name: str,
skip: int = 0,
limit: int = 50,
db: AsyncSession | None = None,
) -> list[ChatTitleIdResponse]:
async with get_async_db_context(db) as session:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
tag_id = tag_name.replace(' ', '_').lower()
bind = await session.connection()
dialect_name = bind.dialect.name
log.info(f'DB dialect name: {dialect_name}')
if dialect_name == 'sqlite':
stmt = stmt.filter(
text(f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)")
).params(tag_id=tag_id)
elif dialect_name == 'postgresql':
stmt = stmt.filter(
text("EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)")
).params(tag_id=tag_id)
else:
raise NotImplementedError(f'Unsupported dialect: {dialect_name}')
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
all_chats = result.all()
return [
ChatTitleIdResponse.model_validate(
{
'id': chat[0],
'title': chat[1],
'updated_at': chat[2],
'created_at': chat[3],
'last_read_at': chat[4],
}
)
for chat in all_chats
]
async def add_chat_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str, db: AsyncSession | None = None
) -> ChatModel | None:
tag_id = tag_name.replace(' ', '_').lower()
await Tags.ensure_tags_exist([tag_name], user_id, db=db)
try:
async with get_async_db_context(db) as session:
chat = await session.get(Chat, id)
if tag_id not in chat.meta.get('tags', []):
chat.meta = {
**chat.meta,
'tags': list(set(chat.meta.get('tags', []) + [tag_id])),
}
await session.commit()
return ChatModel.model_validate(chat)
except Exception:
return None
async def count_chats_by_tag_name_and_user_id(
self, tag_name: str, user_id: str, db: AsyncSession | None = None
) -> int:
async with get_async_db_context(db) as session:
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=False)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
tag_id = tag_name.replace(' ', '_').lower()
bind = await session.connection()
dialect_name = bind.dialect.name
if dialect_name == 'sqlite':
stmt = stmt.filter(
text("EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)")
).params(tag_id=tag_id)
elif dialect_name == 'postgresql':
stmt = stmt.filter(
text("EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)")
).params(tag_id=tag_id)
else:
raise NotImplementedError(f'Unsupported dialect: {dialect_name}')
result = await session.execute(stmt)
return result.scalar()
async def delete_orphan_tags_for_user(
self,
tag_ids: list[str],
user_id: str,
threshold: int = 0,
db: AsyncSession | None = None,
) -> None:
"""Delete tag rows from *tag_ids* that appear in at most *threshold*
non-archived chats for *user_id*. One query to find orphans, one to
delete them.
Use threshold=0 after a tag is already removed from a chat's meta.
Use threshold=1 when the chat itself is about to be deleted (the
referencing chat still exists at query time).
"""
if not tag_ids:
return
async with get_async_db_context(db) as session:
orphans = []
for tag_id in tag_ids:
count = await self.count_chats_by_tag_name_and_user_id(tag_id, user_id, db=session)
if count <= threshold:
orphans.append(tag_id)
await Tags.delete_tags_by_ids_and_user_id(orphans, user_id, db=session)
async def count_chats_by_folder_id_and_user_id(
self, folder_id: str, user_id: str, db: AsyncSession | None = None
) -> int:
async with get_async_db_context(db) as session:
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, folder_id=folder_id)
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
count = result.scalar()
log.info(f"Count of chats for folder '{folder_id}': {count}")
return count
async def count_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
) -> int:
if not folder_ids:
return 0
async with get_async_db_context(db) as session:
stmt = select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids))
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
count = result.scalar()
log.info(f"Count of chats for folders '{folder_ids}': {count}")
return count
async def delete_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str, db: AsyncSession | None = None
) -> bool:
try:
async with get_async_db_context(db) as session:
chat = await session.get(Chat, id)
tags = chat.meta.get('tags', [])
tag_id = tag_name.replace(' ', '_').lower()
tags = [tag for tag in tags if tag != tag_id]
chat.meta = {
**chat.meta,
'tags': list(set(tags)),
}
await session.commit()
return True
except Exception:
return False
async def delete_chat_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
try:
async with get_async_db_context(db) as session:
await session.execute(update(AutomationRun).filter_by(chat_id=id).values(chat_id=None))
await session.execute(delete(ChatMessage).filter_by(chat_id=id))
await session.execute(delete(Chat).filter_by(id=id))
await session.commit()
return True and await self.delete_shared_chat_by_chat_id(id, db=session)
except Exception:
return False
async def delete_chat_by_id_and_user_id(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool:
try:
async with get_async_db_context(db) as session:
await session.execute(update(AutomationRun).filter_by(chat_id=id).values(chat_id=None))
await session.execute(delete(ChatMessage).filter_by(chat_id=id))
await session.execute(delete(Chat).filter_by(id=id, user_id=user_id))
await session.commit()
return True and await self.delete_shared_chat_by_chat_id(id, db=session)
except Exception:
return False
async def delete_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> bool:
try:
async with get_async_db_context(db) as session:
await self.delete_shared_chats_by_user_id(user_id, db=session)
chat_id_subquery = select(Chat.id).filter_by(user_id=user_id).scalar_subquery()
await session.execute(
update(AutomationRun)
.filter(AutomationRun.chat_id.in_(select(Chat.id).filter_by(user_id=user_id)))
.values(chat_id=None)
)
await session.execute(
delete(ChatMessage).filter(ChatMessage.chat_id.in_(select(Chat.id).filter_by(user_id=user_id)))
)
await session.execute(delete(Chat).filter_by(user_id=user_id))
await session.commit()
return True
except Exception:
return False
async def delete_chats_by_user_id_and_folder_id(
self, user_id: str, folder_id: str, db: AsyncSession | None = None
) -> bool:
try:
async with get_async_db_context(db) as session:
chat_ids_stmt = select(Chat.id).filter_by(user_id=user_id, folder_id=folder_id)
await session.execute(
update(AutomationRun).filter(AutomationRun.chat_id.in_(chat_ids_stmt)).values(chat_id=None)
)
await session.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(chat_ids_stmt)))
await session.execute(delete(Chat).filter_by(user_id=user_id, folder_id=folder_id))
await session.commit()
return True
except Exception:
return False
async def move_chats_by_user_id_and_folder_id(
self,
user_id: str,
folder_id: str,
new_folder_id: str | None,
db: AsyncSession | None = None,
) -> bool:
try:
async with get_async_db_context(db) as session:
await session.execute(
update(Chat).filter_by(user_id=user_id, folder_id=folder_id).values(folder_id=new_folder_id)
)
await session.commit()
return True
except Exception:
return False
async def delete_shared_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> bool:
"""Delete all shared chat snapshots created by a user."""
from open_webui.models.shared_chats import SharedChat as SharedChatTable
from open_webui.models.shared_chats import SharedChats
try:
async with get_async_db_context(db) as session:
# Delete shared_chat rows for this user's chats
await session.execute(delete(SharedChatTable).filter_by(user_id=user_id))
# Clear share_id on all of this user's chats
await session.execute(update(Chat).filter_by(user_id=user_id).values(share_id=None))
await session.commit()
return True
except Exception:
return False
async def insert_chat_files(
self,
chat_id: str,
message_id: str,
file_ids: list[str],
user_id: str,
db: AsyncSession | None = None,
) -> list[ChatFileModel | None]:
if not file_ids:
return None
chat_message_file_ids = {
item.id for item in await self.get_chat_files_by_chat_id_and_message_id(chat_id, message_id, db=db)
}
# Remove duplicates and existing file_ids
file_ids = list({file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids})
if not file_ids:
return None
# Only link files the caller can read; blocks forging a chat_file row to another user's file.
from open_webui.models.files import Files
from open_webui.models.users import Users
from open_webui.utils.access_control.files import has_access_to_file
user = await Users.get_user_by_id(user_id, db=db)
accessible_file_ids = []
for file_id in file_ids:
file = await Files.get_file_by_id(file_id, db=db)
if not file:
continue
if (
file.user_id == user_id
or (user and user.role == 'admin')
or (user and await has_access_to_file(file_id, 'read', user, db=db))
):
accessible_file_ids.append(file_id)
file_ids = accessible_file_ids
if not file_ids:
return None
try:
async with get_async_db_context(db) as session:
now = int(time.time())
chat_files = [
ChatFileModel(
id=str(uuid.uuid4()),
user_id=user_id,
chat_id=chat_id,
message_id=message_id,
file_id=file_id,
created_at=now,
updated_at=now,
)
for file_id in file_ids
]
results = [ChatFile(**chat_file.model_dump()) for chat_file in chat_files]
session.add_all(results)
await session.commit()
return chat_files
except Exception:
return None
async def get_chat_files_by_chat_id_and_message_id(
self, chat_id: str, message_id: str, db: AsyncSession | None = None
) -> list[ChatFileModel]:
async with get_async_db_context(db) as session:
result = await session.execute(
select(ChatFile).filter_by(chat_id=chat_id, message_id=message_id).order_by(ChatFile.created_at.asc())
)
all_chat_files = result.scalars().all()
return [ChatFileModel.model_validate(chat_file) for chat_file in all_chat_files]
async def delete_chat_file(self, chat_id: str, file_id: str, db: AsyncSession | None = None) -> bool:
try:
async with get_async_db_context(db) as session:
await session.execute(delete(ChatFile).filter_by(chat_id=chat_id, file_id=file_id))
await session.commit()
return True
except Exception:
return False
async def get_shared_chat_ids_by_file_id(self, file_id: str, db: AsyncSession | None = None) -> list[str]:
"""Return IDs of chats that contain this file and have an active share link."""
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat.id)
.join(ChatFile, Chat.id == ChatFile.chat_id)
.filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None))
)
return [row[0] for row in result.all()]
async def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> ChatModel | None:
"""Update the tasks list on a chat."""
try:
async with get_async_db_context() as session:
chat = await session.get(Chat, id)
if chat is None:
return None
chat.tasks = tasks
await session.commit()
return ChatModel.model_validate(chat)
except Exception:
return None
async def get_chat_tasks_by_id(self, id: str) -> list[dict]:
"""Read the tasks list from a chat (lightweight column query)."""
async with get_async_db_context() as session:
result = await session.execute(select(Chat.tasks).filter_by(id=id))
row = result.first()
if row is None or row[0] is None:
return []
return row[0]
Chats = ChatTable() # singleton chats repository