2026-05-21 14:01:57 +04:00
|
|
|
"""Chat models, forms, and database operations."""
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-08-05 00:47:49 -05:00
|
|
|
import re
|
2023-12-25 21:44:28 -08:00
|
|
|
import time
|
2024-08-28 00:10:27 +02:00
|
|
|
import uuid
|
2026-08-24 18:37:39 -04:00
|
|
|
from typing import Any, Literal
|
2026-06-01 13:56:55 -07:00
|
|
|
|
2026-05-21 15:29:49 +04:00
|
|
|
# local imports
|
2026-08-20 13:13:51 -07:00
|
|
|
from open_webui.env import ENABLE_ADMIN_CHAT_ACCESS
|
2026-04-12 14:22:11 -05:00
|
|
|
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
2026-08-20 13:13:51 -07:00
|
|
|
from open_webui.models.access_grants import AccessGrants
|
2026-04-02 08:09:57 -05:00
|
|
|
from open_webui.models.automations import AutomationRun
|
2026-05-12 17:10:15 +09:00
|
|
|
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
|
2026-07-24 01:19:28 -04:00
|
|
|
from open_webui.utils.misc import get_output_text, sanitize_data_for_db, sanitize_text_for_db
|
2026-07-24 00:42:11 -04:00
|
|
|
from pydantic import BaseModel, ConfigDict, field_validator
|
2025-12-21 23:17:53 +04:00
|
|
|
from sqlalchemy import (
|
2026-05-12 17:10:15 +09:00
|
|
|
JSON,
|
2025-12-21 23:17:53 +04:00
|
|
|
BigInteger,
|
|
|
|
|
Boolean,
|
|
|
|
|
Column,
|
|
|
|
|
ForeignKey,
|
2026-05-12 17:10:15 +09:00
|
|
|
Index,
|
2025-12-21 23:17:53 +04:00
|
|
|
String,
|
|
|
|
|
Text,
|
|
|
|
|
UniqueConstraint,
|
2026-05-12 17:10:15 +09:00
|
|
|
and_,
|
|
|
|
|
delete,
|
2026-07-26 19:34:41 -04:00
|
|
|
exists,
|
2026-05-12 17:10:15 +09:00
|
|
|
func,
|
|
|
|
|
or_,
|
|
|
|
|
select,
|
|
|
|
|
text,
|
|
|
|
|
update,
|
2025-12-21 23:17:53 +04:00
|
|
|
)
|
2026-05-12 17:10:15 +09:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-06-29 11:33:32 -05:00
|
|
|
from sqlalchemy.orm.attributes import flag_modified
|
2026-07-26 23:49:03 -04:00
|
|
|
from sqlalchemy.sql import case, exists
|
2026-05-12 17:10:15 +09:00
|
|
|
from sqlalchemy.sql.expression import bindparam
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2025-02-25 15:36:25 +01:00
|
|
|
log = logging.getLogger(__name__)
|
2026-07-20 01:33:47 -04:00
|
|
|
ACTIVE_CHAT_GAP_SECONDS = 30 * 60
|
2026-08-05 00:47:49 -05:00
|
|
|
CHAT_SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def chat_search_content_query(text: str) -> str:
|
|
|
|
|
words = sanitize_text_for_db(text).lower().strip().split()
|
|
|
|
|
return ' '.join(word for word in words if not word.startswith(CHAT_SEARCH_FILTER_PREFIXES)).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def chat_search_terms(text: str) -> list[str]:
|
|
|
|
|
return list(dict.fromkeys(re.findall(r'[a-z0-9]+', text.lower())))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def chat_search_message_content_match_sql(dialect_name: str, key: str) -> str:
|
|
|
|
|
if dialect_name == 'sqlite':
|
|
|
|
|
return f"""
|
|
|
|
|
(
|
|
|
|
|
EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM json_each(Chat.chat, '$.history.messages') AS history_message
|
|
|
|
|
WHERE LOWER(history_message.value->>'content') LIKE '%' || :{key} || '%'
|
|
|
|
|
)
|
|
|
|
|
OR EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM json_each(Chat.chat, '$.messages') AS legacy_message
|
|
|
|
|
WHERE LOWER(legacy_message.value->>'content') LIKE '%' || :{key} || '%'
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
if dialect_name == 'postgresql':
|
|
|
|
|
return f"""
|
|
|
|
|
(
|
|
|
|
|
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 '%' || :{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 '%' || :{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 '%' || :{key} || '%'
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
raise NotImplementedError(f'Unsupported dialect: {dialect_name}')
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2025-02-26 22:18:18 -08:00
|
|
|
|
2026-07-26 23:49:03 -04:00
|
|
|
def chat_list_order(sort_by: str = 'updated_at', sort_dir: str = 'desc', user_id: str | None = None):
|
|
|
|
|
if sort_by != 'unread_updated_at':
|
|
|
|
|
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()
|
|
|
|
|
return order_clause, Chat.id
|
|
|
|
|
|
|
|
|
|
unfinished_assistant = (
|
|
|
|
|
select(ChatMessage.id)
|
|
|
|
|
.where(ChatMessage.chat_id == Chat.id)
|
|
|
|
|
.where(ChatMessage.role == 'assistant')
|
|
|
|
|
.where(ChatMessage.done.is_(False))
|
|
|
|
|
.exists()
|
|
|
|
|
)
|
|
|
|
|
conditions = [Chat.updated_at > func.coalesce(Chat.last_read_at, 0), ~unfinished_assistant]
|
|
|
|
|
if user_id is not None:
|
|
|
|
|
conditions.append(Chat.user_id == user_id)
|
|
|
|
|
|
|
|
|
|
unread = case(
|
|
|
|
|
(and_(*conditions), 1),
|
|
|
|
|
else_=0,
|
|
|
|
|
)
|
|
|
|
|
return unread.desc(), Chat.updated_at.desc(), Chat.id
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 15:29:49 +04:00
|
|
|
class Chat(Base): # database table mapping for chat entity
|
2026-03-17 17:58:01 -05:00
|
|
|
__tablename__ = 'chat'
|
2024-04-20 18:24:18 -05:00
|
|
|
|
2025-11-22 20:34:49 -05:00
|
|
|
id = Column(String, primary_key=True, unique=True)
|
2026-05-21 15:29:49 +04:00
|
|
|
user_id = Column(String, index=True) # owner user id
|
2026-05-21 14:01:57 +04:00
|
|
|
title = Column(Text) # user-visible conversation title
|
2024-10-08 22:02:48 -07:00
|
|
|
chat = Column(JSON)
|
2024-04-20 18:24:18 -05:00
|
|
|
|
2026-05-21 15:29:49 +04:00
|
|
|
created_at = Column(BigInteger, index=True) # conversation creation timestamp
|
|
|
|
|
updated_at = Column(BigInteger, index=True) # conversation modification timestamp
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
share_id = Column(Text, unique=True, nullable=True) # public share link token
|
|
|
|
|
archived = Column(Boolean, default=False) # hidden from main chat list
|
2024-10-10 23:22:53 -07:00
|
|
|
pinned = Column(Boolean, default=False, nullable=True)
|
|
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
meta = Column(JSON, server_default='{}')
|
2026-07-24 00:42:11 -04:00
|
|
|
variables = Column(JSON, nullable=True)
|
2024-10-16 21:05:03 -07:00
|
|
|
folder_id = Column(Text, nullable=True)
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2026-03-29 18:01:04 -05:00
|
|
|
tasks = Column(JSON, nullable=True)
|
|
|
|
|
summary = Column(Text, nullable=True)
|
2026-07-23 02:54:56 -04:00
|
|
|
current_message_id = Column(Text, nullable=True)
|
2026-03-29 18:01:04 -05:00
|
|
|
|
2026-04-01 04:00:18 -05:00
|
|
|
last_read_at = Column(BigInteger, nullable=True)
|
fix: index the chat queries that make large SQLite instances unusable (#27663)
The timer scheduler polls once a second and cancels on every message send and chat open, the sidebar lists chats ordered by `updated_at`, and the folder badges count unread chats per folder. None of those could be served by an index, so each call read most of the `chat` table, and because `meta` sits after the chat payload column SQLite had to walk every row's overflow pages to get there. On a large history that stalls the sidebar, every chat switch and every send, and the idle poll alone burns about a quarter of a CPU core.
Timers now keep their due time in a dedicated `chat.timer_at` column behind a partial index, and the chat list, unread and unfinished-reply queries each get an index matching their filter and ordering. Existing pending timers are backfilled from their meta by the migration. Dropping the `internal` and `type` checks also makes a forked timer chat inert, where a fork used to copy `meta` verbatim and become a second claim target that could fire a duplicate timer.
Measured on SQLite, same rows returned:
| query | before | after |
|---|---|---|
| idle timer poll (2000 chats, 0.43 GB) | 170 ms | 0.04 ms |
| cancel on send and chat open (4000 chats, 377 MB) | 200 ms | 0.04 ms |
| sidebar chat list (15000 chats, 1.26 GB) | 157 ms | 1.8 ms |
| folder unread badges (15000 chats, 1.4 GB) | 54 ms | 0.2 ms |
PostgreSQL 17 serves all of them as index-only scans with no sort node. Exercised through fresh install, upgrade with seeded data, downgrade and re-upgrade on SQLite and PostgreSQL 17.
Fixes #27622
2026-08-23 23:11:54 +02:00
|
|
|
timer_at = Column(BigInteger, nullable=True) # ns due time, set only while a timer chat waits to be claimed
|
2026-04-01 04:00:18 -05:00
|
|
|
|
2025-08-19 03:24:10 +04:00
|
|
|
__table_args__ = (
|
|
|
|
|
# Performance indexes for common queries
|
2026-03-17 17:58:01 -05:00
|
|
|
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'),
|
fix: index the chat queries that make large SQLite instances unusable (#27663)
The timer scheduler polls once a second and cancels on every message send and chat open, the sidebar lists chats ordered by `updated_at`, and the folder badges count unread chats per folder. None of those could be served by an index, so each call read most of the `chat` table, and because `meta` sits after the chat payload column SQLite had to walk every row's overflow pages to get there. On a large history that stalls the sidebar, every chat switch and every send, and the idle poll alone burns about a quarter of a CPU core.
Timers now keep their due time in a dedicated `chat.timer_at` column behind a partial index, and the chat list, unread and unfinished-reply queries each get an index matching their filter and ordering. Existing pending timers are backfilled from their meta by the migration. Dropping the `internal` and `type` checks also makes a forked timer chat inert, where a fork used to copy `meta` verbatim and become a second claim target that could fire a duplicate timer.
Measured on SQLite, same rows returned:
| query | before | after |
|---|---|---|
| idle timer poll (2000 chats, 0.43 GB) | 170 ms | 0.04 ms |
| cancel on send and chat open (4000 chats, 377 MB) | 200 ms | 0.04 ms |
| sidebar chat list (15000 chats, 1.26 GB) | 157 ms | 1.8 ms |
| folder unread badges (15000 chats, 1.4 GB) | 54 ms | 0.2 ms |
PostgreSQL 17 serves all of them as index-only scans with no sort node. Exercised through fresh install, upgrade with seeded data, downgrade and re-upgrade on SQLite and PostgreSQL 17.
Fixes #27622
2026-08-23 23:11:54 +02:00
|
|
|
Index('user_id_updated_at_id_idx', 'user_id', updated_at.desc(), 'id'),
|
|
|
|
|
Index(
|
|
|
|
|
'timer_at_idx',
|
|
|
|
|
'timer_at',
|
|
|
|
|
sqlite_where=text('timer_at IS NOT NULL'),
|
|
|
|
|
postgresql_where=text('timer_at IS NOT NULL'),
|
|
|
|
|
),
|
|
|
|
|
# timer_at key column turns the IS NOT NULL into a seek, so this beats the plain user_id indexes
|
|
|
|
|
Index(
|
|
|
|
|
'user_id_timer_at_idx',
|
|
|
|
|
'user_id',
|
|
|
|
|
'timer_at',
|
|
|
|
|
sqlite_where=text('timer_at IS NOT NULL'),
|
|
|
|
|
postgresql_where=text('timer_at IS NOT NULL'),
|
|
|
|
|
),
|
|
|
|
|
# covering index: lets SQLite serve count_unread_by_folder_ids without reading chat rows
|
|
|
|
|
Index('user_id_folder_unread_idx', 'user_id', 'folder_id', 'archived', 'updated_at', 'last_read_at', 'id'),
|
2025-08-19 03:24:10 +04:00
|
|
|
)
|
|
|
|
|
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2026-07-14 00:10:28 -04:00
|
|
|
def is_internal_chat(meta: dict | None) -> bool:
|
|
|
|
|
return bool(meta and meta.get('internal') is True)
|
|
|
|
|
|
|
|
|
|
|
2023-12-25 21:44:28 -08:00
|
|
|
class ChatModel(BaseModel):
|
2026-05-21 15:29:49 +04:00
|
|
|
model_config = ConfigDict(from_attributes=True) # allows ORM model binding
|
2023-12-25 21:44:28 -08:00
|
|
|
id: str
|
|
|
|
|
user_id: str
|
|
|
|
|
title: str
|
2024-10-08 22:02:48 -07:00
|
|
|
chat: dict
|
2024-04-20 18:24:18 -05:00
|
|
|
|
|
|
|
|
created_at: int # timestamp in epoch
|
|
|
|
|
updated_at: int # timestamp in epoch
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
share_id: str | None = None
|
2024-04-20 17:03:39 -05:00
|
|
|
archived: bool = False
|
2026-05-12 17:10:15 +09:00
|
|
|
pinned: bool | None = False
|
2024-10-10 23:22:53 -07:00
|
|
|
|
|
|
|
|
meta: dict = {}
|
2026-07-24 00:42:11 -04:00
|
|
|
variables: dict = {}
|
2026-05-12 17:10:15 +09:00
|
|
|
folder_id: str | None = None
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
tasks: list | None = None
|
|
|
|
|
summary: str | None = None
|
2026-07-23 02:54:56 -04:00
|
|
|
current_message_id: str | None = None
|
2026-03-29 18:01:04 -05:00
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
last_read_at: int | None = None
|
fix: index the chat queries that make large SQLite instances unusable (#27663)
The timer scheduler polls once a second and cancels on every message send and chat open, the sidebar lists chats ordered by `updated_at`, and the folder badges count unread chats per folder. None of those could be served by an index, so each call read most of the `chat` table, and because `meta` sits after the chat payload column SQLite had to walk every row's overflow pages to get there. On a large history that stalls the sidebar, every chat switch and every send, and the idle poll alone burns about a quarter of a CPU core.
Timers now keep their due time in a dedicated `chat.timer_at` column behind a partial index, and the chat list, unread and unfinished-reply queries each get an index matching their filter and ordering. Existing pending timers are backfilled from their meta by the migration. Dropping the `internal` and `type` checks also makes a forked timer chat inert, where a fork used to copy `meta` verbatim and become a second claim target that could fire a duplicate timer.
Measured on SQLite, same rows returned:
| query | before | after |
|---|---|---|
| idle timer poll (2000 chats, 0.43 GB) | 170 ms | 0.04 ms |
| cancel on send and chat open (4000 chats, 377 MB) | 200 ms | 0.04 ms |
| sidebar chat list (15000 chats, 1.26 GB) | 157 ms | 1.8 ms |
| folder unread badges (15000 chats, 1.4 GB) | 54 ms | 0.2 ms |
PostgreSQL 17 serves all of them as index-only scans with no sort node. Exercised through fresh install, upgrade with seeded data, downgrade and re-upgrade on SQLite and PostgreSQL 17.
Fixes #27622
2026-08-23 23:11:54 +02:00
|
|
|
timer_at: int | None = None
|
2026-04-01 04:00:18 -05:00
|
|
|
|
2026-07-24 00:42:11 -04:00
|
|
|
@field_validator('variables', mode='before')
|
|
|
|
|
@classmethod
|
|
|
|
|
def normalize_variables(cls, value):
|
|
|
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2025-12-21 23:17:53 +04:00
|
|
|
class ChatFile(Base):
|
2026-03-17 17:58:01 -05:00
|
|
|
__tablename__ = 'chat_file'
|
2025-12-21 23:17:53 +04:00
|
|
|
|
|
|
|
|
id = Column(Text, unique=True, primary_key=True)
|
|
|
|
|
user_id = Column(Text, nullable=False)
|
|
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False)
|
2025-12-21 23:17:53 +04:00
|
|
|
message_id = Column(Text, nullable=True)
|
2026-03-17 17:58:01 -05:00
|
|
|
file_id = Column(Text, ForeignKey('file.id', ondelete='CASCADE'), nullable=False)
|
2025-12-21 23:17:53 +04:00
|
|
|
|
|
|
|
|
created_at = Column(BigInteger, nullable=False)
|
|
|
|
|
updated_at = Column(BigInteger, nullable=False)
|
|
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
__table_args__ = (UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'),)
|
2025-12-21 23:17:53 +04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChatFileModel(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
user_id: str
|
|
|
|
|
|
|
|
|
|
chat_id: str
|
2026-05-12 17:10:15 +09:00
|
|
|
message_id: str | None = None
|
2025-12-21 23:17:53 +04:00
|
|
|
file_id: str
|
|
|
|
|
|
|
|
|
|
created_at: int
|
|
|
|
|
updated_at: int
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
|
|
|
|
|
2023-12-25 21:44:28 -08:00
|
|
|
####################
|
|
|
|
|
# Forms
|
|
|
|
|
####################
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChatForm(BaseModel):
|
|
|
|
|
chat: dict
|
2026-07-24 00:42:11 -04:00
|
|
|
variables: dict | None = None
|
2026-05-12 17:10:15 +09:00
|
|
|
folder_id: str | None = None
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2024-10-14 15:29:43 -07:00
|
|
|
|
2024-10-17 20:13:28 -07:00
|
|
|
class ChatImportForm(ChatForm):
|
2026-05-12 17:10:15 +09:00
|
|
|
meta: dict | None = {}
|
|
|
|
|
pinned: bool | None = False
|
2026-07-23 02:54:56 -04:00
|
|
|
current_message_id: str | None = None
|
2026-05-12 17:10:15 +09:00
|
|
|
created_at: int | None = None
|
|
|
|
|
updated_at: int | None = None
|
2024-10-17 20:13:28 -07:00
|
|
|
|
|
|
|
|
|
2025-11-21 03:49:49 -05:00
|
|
|
class ChatsImportForm(BaseModel):
|
|
|
|
|
chats: list[ChatImportForm]
|
|
|
|
|
|
|
|
|
|
|
2024-10-12 23:12:31 +07:00
|
|
|
class ChatTitleMessagesForm(BaseModel):
|
|
|
|
|
title: str
|
|
|
|
|
messages: list[dict]
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2024-10-14 15:29:43 -07:00
|
|
|
|
2023-12-26 10:41:55 -08:00
|
|
|
class ChatTitleForm(BaseModel):
|
|
|
|
|
title: str
|
|
|
|
|
|
|
|
|
|
|
2023-12-26 01:27:43 -08:00
|
|
|
class ChatResponse(BaseModel):
|
2023-12-25 21:44:28 -08:00
|
|
|
id: str
|
2023-12-26 01:27:43 -08:00
|
|
|
user_id: str
|
|
|
|
|
title: str
|
|
|
|
|
chat: dict
|
2024-04-20 18:24:18 -05:00
|
|
|
updated_at: int # timestamp in epoch
|
|
|
|
|
created_at: int # timestamp in epoch
|
2026-05-12 17:10:15 +09:00
|
|
|
share_id: str | None = None # id of the chat to be shared
|
2024-04-20 19:32:32 -05:00
|
|
|
archived: bool
|
2026-05-12 17:10:15 +09:00
|
|
|
pinned: bool | None = False
|
2024-10-10 23:22:53 -07:00
|
|
|
meta: dict = {}
|
2026-07-24 00:42:11 -04:00
|
|
|
variables: dict = {}
|
2026-05-12 17:10:15 +09:00
|
|
|
folder_id: str | None = None
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
tasks: list | None = None
|
|
|
|
|
summary: str | None = None
|
2026-07-23 02:54:56 -04:00
|
|
|
current_message_id: str | None = None
|
2026-07-14 23:08:41 -04:00
|
|
|
context_usage: dict | None = None
|
2026-03-29 18:01:04 -05:00
|
|
|
|
2026-07-24 00:42:11 -04:00
|
|
|
@field_validator('variables', mode='before')
|
|
|
|
|
@classmethod
|
|
|
|
|
def normalize_variables(cls, value):
|
|
|
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
2023-12-25 21:44:28 -08:00
|
|
|
|
|
|
|
|
class ChatTitleIdResponse(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
title: str
|
2024-04-20 18:24:18 -05:00
|
|
|
updated_at: int
|
|
|
|
|
created_at: int
|
2026-05-12 17:10:15 +09:00
|
|
|
last_read_at: int | None = None
|
2026-06-29 05:14:34 -05:00
|
|
|
snippet: str | None = None
|
2026-07-16 21:57:43 -04:00
|
|
|
active: bool = False
|
2023-12-25 21:44:28 -08:00
|
|
|
|
|
|
|
|
|
2026-01-29 18:51:02 +04:00
|
|
|
class SharedChatResponse(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
title: str
|
2026-05-12 17:10:15 +09:00
|
|
|
share_id: str | None = None
|
2026-01-29 18:51:02 +04:00
|
|
|
updated_at: int
|
|
|
|
|
created_at: int
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 12:22:40 -05:00
|
|
|
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
|
2026-03-17 17:58:01 -05:00
|
|
|
history_assistant_message_count: int # number of assistant messages in the chat history
|
2025-12-10 12:22:40 -05:00
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
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
|
2025-12-10 12:22:40 -05:00
|
|
|
|
|
|
|
|
tags: list[str] = [] # tags associated with the chat
|
|
|
|
|
|
|
|
|
|
last_message_at: int # timestamp of the last message
|
|
|
|
|
updated_at: int
|
|
|
|
|
created_at: int
|
|
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
model_config = ConfigDict(extra='allow')
|
2025-12-10 12:22:40 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChatUsageStatsListResponse(BaseModel):
|
|
|
|
|
items: list[ChatUsageStatsResponse]
|
|
|
|
|
total: int
|
2026-03-17 17:58:01 -05:00
|
|
|
model_config = ConfigDict(extra='allow')
|
2025-12-10 12:22:40 -05:00
|
|
|
|
|
|
|
|
|
2025-12-25 18:11:17 -05:00
|
|
|
class MessageStats(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
role: str
|
2026-05-12 17:10:15 +09:00
|
|
|
model: str | None = None
|
2025-12-25 18:11:17 -05:00
|
|
|
content_length: int
|
2026-05-12 17:10:15 +09:00
|
|
|
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
|
2025-12-25 18:11:17 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChatHistoryStats(BaseModel):
|
|
|
|
|
messages: dict[str, MessageStats]
|
2026-05-12 17:10:15 +09:00
|
|
|
currentId: str | None = None
|
2025-12-25 18:11:17 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2023-12-25 21:44:28 -08:00
|
|
|
class ChatTable:
|
2025-11-22 20:50:27 -05:00
|
|
|
def _clean_null_bytes(self, obj):
|
2025-12-21 13:14:29 +01:00
|
|
|
"""Recursively remove null bytes from strings in dict/list structures."""
|
|
|
|
|
return sanitize_data_for_db(obj)
|
2025-11-22 20:50:27 -05:00
|
|
|
|
2026-07-23 02:54:56 -04:00
|
|
|
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
|
|
|
|
|
|
2025-11-22 20:50:27 -05:00
|
|
|
def _sanitize_chat_row(self, chat_item):
|
|
|
|
|
"""
|
|
|
|
|
Clean a Chat SQLAlchemy model's title + chat JSON,
|
|
|
|
|
and return True if anything changed.
|
perf: stop rescanning the whole chat JSON on every streamed event write (#28820)
Every streamed event that persists to a chat (status updates, citations,
file attachments, message content) serialized the entire conversation JSON
three times: a null-byte check of the stored row, a second sanitize of the
whole blob after merging in the event payload and the flush of the UPDATE
itself. The middle pass rescans megabytes of already-clean history for null
bytes that can only come from the small incoming payload, so long chats pay
for their full history on every single event.
The write paths now sanitize just the incoming message, message id and
status dict and keep the row-level sanitize, so legacy rows with null bytes
still self-heal as before. Median per-event write time (sqlite, orjson):
1 MB chat 15.0 ms to 11.1 ms, 4 MB 65.2 ms to 52.4 ms, 10 MB 159.9 ms to
124.5 ms, roughly 20 percent less per event. As a side effect the
chat_message dual write now receives the sanitized message; previously null
bytes in non-content fields were cleaned in the blob but written raw to
chat_message, which failed that insert on PostgreSQL. Verified byte-identical
rows against the previous implementation across nine scenarios covering null
bytes in every input, legacy dirty rows, a missing title and a NULL chat
column.
2026-08-20 02:52:05 +02:00
|
|
|
|
|
|
|
|
The message write paths (upsert/status/delete) rely on this
|
|
|
|
|
leaving the blob clean and sanitize only the data they add.
|
2025-11-22 20:50:27 -05:00
|
|
|
"""
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-08 18:47:57 -06:00
|
|
|
@staticmethod
|
|
|
|
|
def _last_descendant_id(messages: dict, message_id: str) -> str:
|
|
|
|
|
seen_ids = set()
|
|
|
|
|
while message_id in messages and message_id not in seen_ids:
|
|
|
|
|
seen_ids.add(message_id)
|
|
|
|
|
message = messages[message_id]
|
|
|
|
|
child_ids = message.get('childrenIds') if isinstance(message, dict) else []
|
|
|
|
|
child_ids = child_ids if isinstance(child_ids, list) else []
|
|
|
|
|
next_id = next((child_id for child_id in reversed(child_ids) if child_id in messages), None)
|
|
|
|
|
if not next_id:
|
|
|
|
|
break
|
|
|
|
|
message_id = next_id
|
|
|
|
|
return message_id
|
|
|
|
|
|
2026-06-29 11:33:32 -05:00
|
|
|
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
|
|
|
|
|
):
|
2026-08-08 18:47:57 -06:00
|
|
|
if current_message.get('contextSummary') or current_message.get('context_summary'):
|
|
|
|
|
last_descendant_id = self._last_descendant_id(messages, current_id)
|
|
|
|
|
if last_descendant_id != current_id:
|
|
|
|
|
history['currentId'] = last_descendant_id
|
|
|
|
|
return True
|
|
|
|
|
|
2026-06-29 11:33:32 -05:00
|
|
|
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
|
|
|
|
|
|
2026-04-12 18:12:59 -05:00
|
|
|
async def insert_new_chat(
|
2026-07-14 00:10:28 -04:00
|
|
|
self,
|
|
|
|
|
id: str,
|
|
|
|
|
user_id: str,
|
|
|
|
|
form_data: ChatForm,
|
|
|
|
|
db: AsyncSession | None = None,
|
|
|
|
|
*,
|
|
|
|
|
internal_meta: dict | None = None,
|
fix: index the chat queries that make large SQLite instances unusable (#27663)
The timer scheduler polls once a second and cancels on every message send and chat open, the sidebar lists chats ordered by `updated_at`, and the folder badges count unread chats per folder. None of those could be served by an index, so each call read most of the `chat` table, and because `meta` sits after the chat payload column SQLite had to walk every row's overflow pages to get there. On a large history that stalls the sidebar, every chat switch and every send, and the idle poll alone burns about a quarter of a CPU core.
Timers now keep their due time in a dedicated `chat.timer_at` column behind a partial index, and the chat list, unread and unfinished-reply queries each get an index matching their filter and ordering. Existing pending timers are backfilled from their meta by the migration. Dropping the `internal` and `type` checks also makes a forked timer chat inert, where a fork used to copy `meta` verbatim and become a second claim target that could fire a duplicate timer.
Measured on SQLite, same rows returned:
| query | before | after |
|---|---|---|
| idle timer poll (2000 chats, 0.43 GB) | 170 ms | 0.04 ms |
| cancel on send and chat open (4000 chats, 377 MB) | 200 ms | 0.04 ms |
| sidebar chat list (15000 chats, 1.26 GB) | 157 ms | 1.8 ms |
| folder unread badges (15000 chats, 1.4 GB) | 54 ms | 0.2 ms |
PostgreSQL 17 serves all of them as index-only scans with no sort node. Exercised through fresh install, upgrade with seeded data, downgrade and re-upgrade on SQLite and PostgreSQL 17.
Fixes #27622
2026-08-23 23:11:54 +02:00
|
|
|
timer_at: int | None = None,
|
2026-05-12 17:10:15 +09:00
|
|
|
) -> ChatModel | None:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2024-07-03 23:32:39 -07:00
|
|
|
chat = ChatModel(
|
|
|
|
|
**{
|
2026-03-17 17:58:01 -05:00
|
|
|
'id': id,
|
|
|
|
|
'user_id': user_id,
|
|
|
|
|
'title': self._clean_null_bytes(
|
|
|
|
|
form_data.chat['title'] if 'title' in form_data.chat else 'New Chat'
|
2024-07-03 23:32:39 -07:00
|
|
|
),
|
2026-03-17 17:58:01 -05:00
|
|
|
'chat': self._clean_null_bytes(form_data.chat),
|
|
|
|
|
'folder_id': form_data.folder_id,
|
2026-07-14 00:10:28 -04:00
|
|
|
'meta': internal_meta or {},
|
fix: index the chat queries that make large SQLite instances unusable (#27663)
The timer scheduler polls once a second and cancels on every message send and chat open, the sidebar lists chats ordered by `updated_at`, and the folder badges count unread chats per folder. None of those could be served by an index, so each call read most of the `chat` table, and because `meta` sits after the chat payload column SQLite had to walk every row's overflow pages to get there. On a large history that stalls the sidebar, every chat switch and every send, and the idle poll alone burns about a quarter of a CPU core.
Timers now keep their due time in a dedicated `chat.timer_at` column behind a partial index, and the chat list, unread and unfinished-reply queries each get an index matching their filter and ordering. Existing pending timers are backfilled from their meta by the migration. Dropping the `internal` and `type` checks also makes a forked timer chat inert, where a fork used to copy `meta` verbatim and become a second claim target that could fire a duplicate timer.
Measured on SQLite, same rows returned:
| query | before | after |
|---|---|---|
| idle timer poll (2000 chats, 0.43 GB) | 170 ms | 0.04 ms |
| cancel on send and chat open (4000 chats, 377 MB) | 200 ms | 0.04 ms |
| sidebar chat list (15000 chats, 1.26 GB) | 157 ms | 1.8 ms |
| folder unread badges (15000 chats, 1.4 GB) | 54 ms | 0.2 ms |
PostgreSQL 17 serves all of them as index-only scans with no sort node. Exercised through fresh install, upgrade with seeded data, downgrade and re-upgrade on SQLite and PostgreSQL 17.
Fixes #27622
2026-08-23 23:11:54 +02:00
|
|
|
'timer_at': timer_at,
|
2026-07-24 00:42:11 -04:00
|
|
|
'variables': form_data.variables or {},
|
2026-07-23 02:54:56 -04:00
|
|
|
'current_message_id': self.get_current_message_id(form_data.chat),
|
2026-03-17 17:58:01 -05:00
|
|
|
'created_at': int(time.time()),
|
|
|
|
|
'updated_at': int(time.time()),
|
2026-06-16 18:41:44 -04:00
|
|
|
'last_read_at': int(time.time()),
|
2024-07-03 23:32:39 -07:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-22 20:50:27 -05:00
|
|
|
chat_item = Chat(**chat.model_dump())
|
2026-05-21 14:01:57 +04:00
|
|
|
session.add(chat_item)
|
|
|
|
|
await session.commit()
|
2026-02-01 07:04:13 +04:00
|
|
|
|
|
|
|
|
# Dual-write initial messages to chat_message table
|
|
|
|
|
try:
|
2026-07-23 02:54:56 -04:00
|
|
|
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')
|
|
|
|
|
}
|
2026-02-01 07:04:13 +04:00
|
|
|
for message_id, message in messages.items():
|
2026-03-17 17:58:01 -05:00
|
|
|
if isinstance(message, dict) and message.get('role'):
|
2026-04-12 14:22:11 -05:00
|
|
|
await ChatMessages.upsert_message(
|
2026-02-01 07:04:13 +04:00
|
|
|
message_id=message_id,
|
|
|
|
|
chat_id=id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
data=message,
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
2026-03-17 17:58:01 -05:00
|
|
|
log.warning(f'Failed to write initial messages to chat_message table: {e}')
|
2026-02-01 07:04:13 +04:00
|
|
|
|
2025-11-22 20:50:27 -05:00
|
|
|
return ChatModel.model_validate(chat_item) if chat_item else None
|
2024-10-17 20:13:28 -07:00
|
|
|
|
2026-07-14 00:10:28 -04:00
|
|
|
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())
|
|
|
|
|
|
2026-07-15 21:43:47 -04:00
|
|
|
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,
|
|
|
|
|
)
|
2026-07-15 22:34:52 -04:00
|
|
|
.order_by(Chat.updated_at.desc(), Chat.created_at.desc())
|
2026-07-15 21:43:47 -04:00
|
|
|
)
|
|
|
|
|
chat = result.scalars().first()
|
|
|
|
|
return ChatModel.model_validate(chat) if chat else None
|
|
|
|
|
|
2026-07-15 22:34:52 -04:00
|
|
|
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()]
|
|
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel:
|
2025-11-21 03:49:49 -05:00
|
|
|
id = str(uuid.uuid4())
|
|
|
|
|
chat = ChatModel(
|
|
|
|
|
**{
|
2026-03-17 17:58:01 -05:00
|
|
|
'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,
|
2026-07-24 00:42:11 -04:00
|
|
|
'variables': form_data.variables or {},
|
2026-03-17 17:58:01 -05:00
|
|
|
'pinned': form_data.pinned,
|
|
|
|
|
'folder_id': form_data.folder_id,
|
2026-07-23 02:54:56 -04:00
|
|
|
'current_message_id': form_data.current_message_id or self.get_current_message_id(form_data.chat),
|
2026-03-17 17:58:01 -05:00
|
|
|
'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())),
|
2025-11-21 03:49:49 -05:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return chat
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def import_chats(
|
2026-01-06 02:19:57 +04:00
|
|
|
self,
|
|
|
|
|
user_id: str,
|
|
|
|
|
chat_import_forms: list[ChatImportForm],
|
2026-05-12 17:10:15 +09:00
|
|
|
db: AsyncSession | None = None,
|
2025-11-21 03:49:49 -05:00
|
|
|
) -> list[ChatModel]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-06-01 13:34:50 -07:00
|
|
|
# Validate folder_id references — clear any that don't exist
|
2026-06-01 13:56:55 -07:00
|
|
|
folder_ids = {f.folder_id for f in chat_import_forms if f.folder_id}
|
2026-06-01 13:34:50 -07:00
|
|
|
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)
|
|
|
|
|
|
2025-11-21 03:49:49 -05:00
|
|
|
chats = []
|
2024-10-17 20:13:28 -07:00
|
|
|
|
2025-11-23 19:47:21 -05:00
|
|
|
for form_data in chat_import_forms:
|
2025-11-21 03:49:49 -05:00
|
|
|
chat = self._chat_import_form_to_chat_model(user_id, form_data)
|
|
|
|
|
chats.append(Chat(**chat.model_dump()))
|
|
|
|
|
|
2026-05-21 17:48:28 +04:00
|
|
|
session.add_all(chats)
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2026-02-01 07:04:13 +04:00
|
|
|
|
|
|
|
|
# Dual-write messages to chat_message table
|
2026-07-27 04:05:51 -04:00
|
|
|
for form_data, imported_chat in zip(chat_import_forms, chats):
|
2026-07-23 02:54:56 -04:00
|
|
|
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')
|
|
|
|
|
}
|
2026-05-09 03:19:48 +09:00
|
|
|
for message_id, message in messages.items():
|
|
|
|
|
if isinstance(message, dict) and message.get('role'):
|
|
|
|
|
try:
|
2026-04-12 14:22:11 -05:00
|
|
|
await ChatMessages.upsert_message(
|
2026-02-01 07:04:13 +04:00
|
|
|
message_id=message_id,
|
2026-07-27 04:05:51 -04:00
|
|
|
chat_id=imported_chat.id,
|
2026-02-01 07:04:13 +04:00
|
|
|
user_id=user_id,
|
|
|
|
|
data=message,
|
|
|
|
|
)
|
2026-05-09 03:19:48 +09:00
|
|
|
except Exception as e:
|
2026-07-27 04:05:51 -04:00
|
|
|
log.warning(
|
|
|
|
|
f'Failed to write imported message {message_id} for chat {imported_chat.id}: {e}'
|
|
|
|
|
)
|
2026-02-01 07:04:13 +04:00
|
|
|
|
2025-11-21 03:49:49 -05:00
|
|
|
return [ChatModel.model_validate(chat) for chat in chats]
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
async def update_chat_by_id(
|
2026-06-01 13:56:55 -07:00
|
|
|
self,
|
|
|
|
|
id: str,
|
|
|
|
|
chat: dict,
|
|
|
|
|
db: AsyncSession | None = None,
|
2026-07-14 01:13:40 -04:00
|
|
|
*,
|
|
|
|
|
touch: bool = True,
|
2026-05-21 14:01:57 +04:00
|
|
|
) -> ChatModel | None:
|
2026-08-25 13:18:38 -04:00
|
|
|
"""Patch top-level chat keys; history is merged so stale writers don't drop messages."""
|
|
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-08-25 13:18:38 -04:00
|
|
|
chat_item = await session.get(
|
|
|
|
|
Chat,
|
|
|
|
|
id,
|
|
|
|
|
populate_existing=True,
|
|
|
|
|
with_for_update=session.bind.dialect.name == 'postgresql',
|
|
|
|
|
)
|
2026-05-19 20:37:53 +04:00
|
|
|
if chat_item is None:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-08-25 13:18:38 -04:00
|
|
|
stored = chat_item.chat or {}
|
|
|
|
|
updated = {**stored, **chat}
|
|
|
|
|
if 'history' in chat:
|
|
|
|
|
# The caller built its history from an earlier read; merge so messages saved since then survive.
|
|
|
|
|
updated['history'] = self.merge_history(stored.get('history'), chat['history'])
|
|
|
|
|
|
|
|
|
|
updated = self._clean_null_bytes(updated)
|
|
|
|
|
chat_item.chat = updated
|
|
|
|
|
chat_item.title = updated.get('title', 'New Chat')
|
2026-07-23 02:54:56 -04:00
|
|
|
if any(key in chat for key in ('history', 'messages', 'currentId', 'branchPointMessageId')):
|
2026-08-25 13:18:38 -04:00
|
|
|
chat_item.current_message_id = self.get_current_message_id(updated)
|
2025-11-22 20:50:27 -05:00
|
|
|
|
2026-07-14 01:13:40 -04:00
|
|
|
if touch:
|
|
|
|
|
chat_item.updated_at = int(time.time())
|
2025-11-22 20:50:27 -05:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2024-07-03 23:32:39 -07:00
|
|
|
|
2024-10-08 22:02:48 -07:00
|
|
|
return ChatModel.model_validate(chat_item)
|
2024-08-28 00:10:27 +02:00
|
|
|
except Exception:
|
2026-05-21 14:01:57 +04:00
|
|
|
return
|
2024-03-31 22:02:40 +01:00
|
|
|
|
2026-07-24 00:42:11 -04:00
|
|
|
async def update_chat_variables_by_id(
|
|
|
|
|
self,
|
|
|
|
|
id: str,
|
|
|
|
|
variables: dict | None,
|
|
|
|
|
db: AsyncSession | None = None,
|
|
|
|
|
*,
|
|
|
|
|
touch: bool = True,
|
|
|
|
|
) -> ChatModel | None:
|
|
|
|
|
try:
|
|
|
|
|
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.variables = variables if isinstance(variables, dict) else {}
|
|
|
|
|
if touch:
|
|
|
|
|
chat_item.updated_at = int(time.time())
|
|
|
|
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
return ChatModel.model_validate(chat_item)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-07-26 23:49:03 -04:00
|
|
|
async def update_chat_last_read_at_by_id(
|
|
|
|
|
self, id: str, user_id: str, db: AsyncSession | None = None
|
|
|
|
|
) -> tuple[int, bool] | None:
|
2026-04-01 04:00:18 -05:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
chat = await session.get(Chat, id)
|
2026-04-01 04:00:18 -05:00
|
|
|
if chat and chat.user_id == user_id:
|
2026-07-26 23:16:58 -04:00
|
|
|
last_read_at = int(time.time())
|
2026-07-26 23:49:03 -04:00
|
|
|
was_unread = chat.last_read_at is None or chat.updated_at > chat.last_read_at
|
2026-07-26 23:16:58 -04:00
|
|
|
chat.last_read_at = last_read_at
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2026-07-26 23:49:03 -04:00
|
|
|
return last_read_at, was_unread
|
|
|
|
|
return None
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def mark_chat_unread_by_id(
|
|
|
|
|
self, id: str, user_id: str, db: AsyncSession | None = None
|
|
|
|
|
) -> ChatTitleIdResponse | None:
|
|
|
|
|
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 = 0
|
|
|
|
|
await session.commit()
|
|
|
|
|
return ChatTitleIdResponse(
|
|
|
|
|
id=chat.id,
|
|
|
|
|
title=chat.title,
|
|
|
|
|
updated_at=chat.updated_at,
|
|
|
|
|
created_at=chat.created_at,
|
|
|
|
|
last_read_at=chat.last_read_at,
|
|
|
|
|
)
|
2026-07-26 23:16:58 -04:00
|
|
|
return None
|
2026-04-01 04:00:18 -05:00
|
|
|
except Exception:
|
2026-07-26 23:16:58 -04:00
|
|
|
return None
|
2026-04-01 04:00:18 -05:00
|
|
|
|
2026-07-26 23:49:03 -04:00
|
|
|
async def mark_chats_read_by_folder_ids(
|
|
|
|
|
self, user_id: str, folder_ids: list[str], db: AsyncSession | None = None
|
|
|
|
|
) -> int:
|
|
|
|
|
if not folder_ids:
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
update(Chat)
|
|
|
|
|
.where(
|
|
|
|
|
Chat.user_id == user_id,
|
|
|
|
|
Chat.folder_id.in_(folder_ids),
|
|
|
|
|
Chat.archived == False,
|
|
|
|
|
Chat.meta['internal'].as_boolean().is_not(True),
|
|
|
|
|
)
|
|
|
|
|
.values(last_read_at=Chat.updated_at)
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return result.rowcount or 0
|
|
|
|
|
|
2026-07-26 23:55:37 -04:00
|
|
|
async def mark_chats_read_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> int:
|
2026-07-26 23:54:16 -04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
update(Chat)
|
|
|
|
|
.where(
|
|
|
|
|
Chat.user_id == user_id,
|
|
|
|
|
Chat.archived == False,
|
|
|
|
|
Chat.meta['internal'].as_boolean().is_not(True),
|
|
|
|
|
)
|
|
|
|
|
.values(last_read_at=Chat.updated_at)
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return result.rowcount or 0
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def update_chat_title_by_id(self, id: str, title: str) -> ChatModel | None:
|
2026-04-01 15:06:16 +03:00
|
|
|
try:
|
2026-05-21 17:48:28 +04:00
|
|
|
async with get_async_db_context() as session:
|
2026-08-25 13:18:38 -04:00
|
|
|
chat_item = await session.get(
|
|
|
|
|
Chat,
|
|
|
|
|
id,
|
|
|
|
|
populate_existing=True,
|
|
|
|
|
with_for_update=session.bind.dialect.name == 'postgresql',
|
|
|
|
|
)
|
2026-04-01 15:06:16 +03:00
|
|
|
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}
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2026-04-01 15:06:16 +03:00
|
|
|
return ChatModel.model_validate(chat_item)
|
|
|
|
|
except Exception:
|
2024-12-19 01:00:32 -08:00
|
|
|
return None
|
|
|
|
|
|
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
2026-07-27 08:21:59 +02:00
|
|
|
async def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> None:
|
|
|
|
|
"""Replace a chat's tags. Runs after every completion with tag
|
|
|
|
|
generation enabled, so only the meta column is read and written,
|
|
|
|
|
never the chat blob."""
|
2026-05-21 17:48:28 +04:00
|
|
|
async with get_async_db_context() as session:
|
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
2026-07-27 08:21:59 +02:00
|
|
|
row = (await session.execute(select(Chat.meta).filter_by(id=id))).one_or_none()
|
|
|
|
|
if row is None:
|
2026-02-16 00:41:36 -06:00
|
|
|
return None
|
|
|
|
|
|
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
2026-07-27 08:21:59 +02:00
|
|
|
meta = row[0] or {}
|
|
|
|
|
old_tags = meta.get('tags', [])
|
2026-03-17 17:58:01 -05:00
|
|
|
new_tags = [t for t in tags if t.replace(' ', '_').lower() != 'none']
|
|
|
|
|
new_tag_ids = [t.replace(' ', '_').lower() for t in new_tags]
|
2024-12-19 01:00:32 -08:00
|
|
|
|
2026-02-16 00:41:36 -06:00
|
|
|
# Single meta update
|
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
2026-07-27 08:21:59 +02:00
|
|
|
await session.execute(update(Chat).filter_by(id=id).values(meta={**meta, 'tags': new_tag_ids}))
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2024-12-19 01:00:32 -08:00
|
|
|
|
2026-02-16 00:41:36 -06:00
|
|
|
# Batch-create any missing tag rows
|
2026-05-21 14:01:57 +04:00
|
|
|
await Tags.ensure_tags_exist(new_tags, user.id, db=session)
|
2024-12-19 01:00:32 -08:00
|
|
|
|
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
2026-07-27 08:21:59 +02:00
|
|
|
# Clean up orphaned old tags
|
2026-02-16 00:41:36 -06:00
|
|
|
removed = set(old_tags) - set(new_tag_ids)
|
|
|
|
|
if removed:
|
2026-05-21 14:01:57 +04:00
|
|
|
await self.delete_orphan_tags_for_user(list(removed), user.id, db=session)
|
2024-12-19 01:00:32 -08:00
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def get_chat_title_by_id(self, id: str) -> str | None:
|
2026-05-21 17:48:28 +04:00
|
|
|
async with get_async_db_context() as session:
|
2026-05-21 14:01:57 +04:00
|
|
|
result = await session.execute(select(Chat.title).filter_by(id=id))
|
2026-04-12 14:22:11 -05:00
|
|
|
row = result.first()
|
|
|
|
|
if row is None:
|
2026-02-19 23:41:46 +01:00
|
|
|
return None
|
2026-04-12 14:22:11 -05:00
|
|
|
return row[0] or 'New Chat'
|
2024-12-19 15:14:09 -08:00
|
|
|
|
2026-05-11 01:46:33 +09:00
|
|
|
@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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 13:15:29 -05:00
|
|
|
@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 {}
|
2026-08-25 13:18:38 -04:00
|
|
|
merged = {
|
|
|
|
|
message_id: {**message, 'childrenIds': []}
|
|
|
|
|
for message_id, message in {**existing, **incoming}.items()
|
|
|
|
|
if isinstance(message, dict)
|
|
|
|
|
}
|
2026-06-29 13:15:29 -05:00
|
|
|
|
|
|
|
|
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', [])
|
|
|
|
|
)
|
2026-08-17 09:13:51 +02:00
|
|
|
visited_ids = set()
|
|
|
|
|
while child_ids and child_ids[-1] not in visited_ids:
|
2026-06-29 13:15:29 -05:00
|
|
|
current_id = child_ids[-1]
|
2026-08-17 09:13:51 +02:00
|
|
|
visited_ids.add(current_id)
|
2026-06-29 13:15:29 -05:00
|
|
|
child_ids = messages.get(current_id, {}).get('childrenIds', [])
|
|
|
|
|
history['currentId'] = current_id if current_id in messages else None
|
|
|
|
|
return deleted_ids
|
|
|
|
|
|
2026-07-27 04:05:51 -04:00
|
|
|
@staticmethod
|
|
|
|
|
def upsert_message_to_history(history: dict, message_id: str, message: dict) -> dict:
|
|
|
|
|
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,
|
2026-07-27 04:38:46 -04:00
|
|
|
'childrenIds': (message.get('childrenIds') if isinstance(message.get('childrenIds'), list) else []),
|
2026-07-27 04:05:51 -04:00
|
|
|
'role': role,
|
|
|
|
|
'timestamp': message.get('timestamp') or int(time.time()),
|
|
|
|
|
}
|
2026-08-08 18:47:57 -06:00
|
|
|
history['currentId'] = message_id
|
2026-07-27 04:05:51 -04:00
|
|
|
return messages[message_id]
|
|
|
|
|
|
2026-05-11 02:29:13 +09:00
|
|
|
async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
|
2026-05-11 01:46:33 +09:00
|
|
|
"""Write messages to the ``chat_message`` table so future lookups
|
|
|
|
|
use the fast path. Errors are logged but never raised.
|
|
|
|
|
"""
|
2026-08-19 19:46:49 +02:00
|
|
|
writable = {
|
|
|
|
|
message_id: message
|
|
|
|
|
for message_id, message in messages.items()
|
|
|
|
|
if isinstance(message, dict) and message.get('role')
|
|
|
|
|
}
|
|
|
|
|
try:
|
|
|
|
|
await ChatMessages.upsert_messages(chat_id, user_id, writable)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log.warning('Backfill failed for chat %s: %s', chat_id, e)
|
2026-05-11 01:46:33 +09:00
|
|
|
|
2026-06-01 13:56:55 -07:00
|
|
|
async def reconcile_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
|
2026-05-19 20:37:53 +04:00
|
|
|
"""Sync ``chat_message`` rows with the committed JSON blob.
|
|
|
|
|
|
2026-06-29 13:15:29 -05:00
|
|
|
Upserts current messages via ``backfill_messages_by_chat_id``.
|
|
|
|
|
Best-effort: errors are logged but never raised.
|
2026-05-19 20:37:53 +04:00
|
|
|
"""
|
|
|
|
|
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)
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def get_messages_map_by_chat_id(self, id: str) -> dict | None:
|
2026-05-09 07:52:15 +09:00
|
|
|
"""Message map for walking history (see ``get_message_list``).
|
|
|
|
|
|
2026-05-11 01:46:33 +09:00
|
|
|
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.
|
2026-05-09 07:52:15 +09:00
|
|
|
"""
|
|
|
|
|
# Fast path: build from normalized chat_message rows.
|
|
|
|
|
messages_map = await ChatMessages.get_messages_map_by_chat_id(id)
|
2026-05-11 01:46:33 +09:00
|
|
|
|
2026-05-09 07:52:15 +09:00
|
|
|
if messages_map is not None:
|
2026-05-11 01:46:33 +09:00
|
|
|
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(
|
2026-05-11 02:29:13 +09:00
|
|
|
'Chat %s: %d unresolved parent reference(s) in chat_message — enriching from legacy history',
|
|
|
|
|
id,
|
|
|
|
|
len(unresolved_ids),
|
2026-05-11 01:46:33 +09:00
|
|
|
)
|
|
|
|
|
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)
|
|
|
|
|
|
2026-05-09 07:52:15 +09:00
|
|
|
return messages_map
|
|
|
|
|
|
2026-05-11 01:46:33 +09:00
|
|
|
# No rows — fall back to the legacy embedded history.
|
2026-04-12 14:22:11 -05:00
|
|
|
chat = await self.get_chat_by_id(id)
|
2024-12-19 01:00:32 -08:00
|
|
|
if chat is None:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-05-11 01:46:33 +09:00
|
|
|
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
|
2024-12-19 01:00:32 -08:00
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def get_message_by_id_and_message_id(self, id: str, message_id: str) -> dict | None:
|
2026-08-13 19:59:11 -06:00
|
|
|
messages_map = await ChatMessages.get_messages_map_by_chat_id(id)
|
|
|
|
|
if messages_map and message_id in messages_map:
|
|
|
|
|
return messages_map[message_id]
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
chat = await self.get_chat_by_id(id)
|
2024-12-28 19:31:03 -08:00
|
|
|
if chat is None:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
return chat.chat.get('history', {}).get('messages', {}).get(message_id, {})
|
2024-12-28 19:31:03 -08:00
|
|
|
|
2026-08-24 18:37:39 -04:00
|
|
|
async def get_message_metadata(
|
2026-08-24 18:35:04 -04:00
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
metadata_key: Literal['files', 'sources', 'embeds'],
|
2026-08-24 18:37:39 -04:00
|
|
|
) -> Any | None:
|
|
|
|
|
"""Read one message metadata field without rebuilding the whole history."""
|
2026-08-24 18:35:04 -04:00
|
|
|
async with get_async_db_context() as db:
|
|
|
|
|
# Read the column directly; some stored rows cannot be validated as full ChatMessageModel objects.
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(getattr(ChatMessage, metadata_key)).where(ChatMessage.id == f'{chat_id}-{message_id}')
|
|
|
|
|
)
|
|
|
|
|
metadata_row = result.first()
|
|
|
|
|
|
|
|
|
|
if metadata_row is not None:
|
2026-08-24 18:37:39 -04:00
|
|
|
return metadata_row[0]
|
2026-08-24 18:35:04 -04:00
|
|
|
|
|
|
|
|
chat = await self.get_chat_by_id(chat_id)
|
|
|
|
|
if chat is None:
|
2026-08-24 18:37:39 -04:00
|
|
|
return None
|
2026-08-24 18:35:04 -04:00
|
|
|
|
|
|
|
|
message = chat.chat.get('history', {}).get('messages', {}).get(message_id, {})
|
2026-08-24 18:37:39 -04:00
|
|
|
return message.get(metadata_key)
|
2026-08-24 18:35:04 -04:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def upsert_message_to_chat_by_id_and_message_id(
|
2026-07-14 01:13:40 -04:00
|
|
|
self, id: str, message_id: str, message: dict, *, touch: bool = True
|
2026-05-12 17:10:15 +09:00
|
|
|
) -> ChatModel | None:
|
2026-07-24 01:19:28 -04:00
|
|
|
if not message.get('content'):
|
|
|
|
|
output_text = get_output_text(message.get('output'))
|
|
|
|
|
if output_text:
|
|
|
|
|
message['content'] = output_text
|
|
|
|
|
|
perf: stop rescanning the whole chat JSON on every streamed event write (#28820)
Every streamed event that persists to a chat (status updates, citations,
file attachments, message content) serialized the entire conversation JSON
three times: a null-byte check of the stored row, a second sanitize of the
whole blob after merging in the event payload and the flush of the UPDATE
itself. The middle pass rescans megabytes of already-clean history for null
bytes that can only come from the small incoming payload, so long chats pay
for their full history on every single event.
The write paths now sanitize just the incoming message, message id and
status dict and keep the row-level sanitize, so legacy rows with null bytes
still self-heal as before. Median per-event write time (sqlite, orjson):
1 MB chat 15.0 ms to 11.1 ms, 4 MB 65.2 ms to 52.4 ms, 10 MB 159.9 ms to
124.5 ms, roughly 20 percent less per event. As a side effect the
chat_message dual write now receives the sanitized message; previously null
bytes in non-content fields were cleaned in the blob but written raw to
chat_message, which failed that insert on PostgreSQL. Verified byte-identical
rows against the previous implementation across nine scenarios covering null
bytes in every input, legacy dirty rows, a missing title and a NULL chat
column.
2026-08-20 02:52:05 +02:00
|
|
|
message = self._clean_null_bytes(message)
|
|
|
|
|
message_id = self._clean_null_bytes(message_id)
|
2025-06-13 13:05:33 +02:00
|
|
|
|
2026-07-27 04:05:51 -04:00
|
|
|
try:
|
|
|
|
|
async with get_async_db_context() as session:
|
2026-08-25 13:18:38 -04:00
|
|
|
chat_item = await session.get(
|
|
|
|
|
Chat,
|
|
|
|
|
id,
|
|
|
|
|
populate_existing=True,
|
|
|
|
|
with_for_update=session.bind.dialect.name == 'postgresql',
|
|
|
|
|
)
|
2026-07-27 04:05:51 -04:00
|
|
|
if chat_item is None:
|
|
|
|
|
return None
|
2026-06-29 11:00:53 -05:00
|
|
|
|
2026-07-27 04:05:51 -04:00
|
|
|
self._sanitize_chat_row(chat_item)
|
|
|
|
|
chat = chat_item.chat or {}
|
|
|
|
|
self._repair_chat_current_id(chat)
|
2024-12-19 01:00:32 -08:00
|
|
|
|
2026-07-27 04:05:51 -04:00
|
|
|
history = chat.get('history', {})
|
|
|
|
|
saved_message = self.upsert_message_to_history(history, message_id, message)
|
|
|
|
|
chat['history'] = history
|
perf: stop rescanning the whole chat JSON on every streamed event write (#28820)
Every streamed event that persists to a chat (status updates, citations,
file attachments, message content) serialized the entire conversation JSON
three times: a null-byte check of the stored row, a second sanitize of the
whole blob after merging in the event payload and the flush of the UPDATE
itself. The middle pass rescans megabytes of already-clean history for null
bytes that can only come from the small incoming payload, so long chats pay
for their full history on every single event.
The write paths now sanitize just the incoming message, message id and
status dict and keep the row-level sanitize, so legacy rows with null bytes
still self-heal as before. Median per-event write time (sqlite, orjson):
1 MB chat 15.0 ms to 11.1 ms, 4 MB 65.2 ms to 52.4 ms, 10 MB 159.9 ms to
124.5 ms, roughly 20 percent less per event. As a side effect the
chat_message dual write now receives the sanitized message; previously null
bytes in non-content fields were cleaned in the blob but written raw to
chat_message, which failed that insert on PostgreSQL. Verified byte-identical
rows against the previous implementation across nine scenarios covering null
bytes in every input, legacy dirty rows, a missing title and a NULL chat
column.
2026-08-20 02:52:05 +02:00
|
|
|
chat_item.chat = chat # chat is a fresh dict when the column was empty
|
|
|
|
|
chat_item.title = chat.get('title', 'New Chat')
|
|
|
|
|
chat_item.current_message_id = self.get_current_message_id(chat)
|
2026-07-27 04:05:51 -04:00
|
|
|
flag_modified(chat_item, 'chat')
|
2024-12-19 01:00:32 -08:00
|
|
|
|
2026-07-27 04:05:51 -04:00
|
|
|
if touch:
|
|
|
|
|
chat_item.updated_at = int(time.time())
|
2026-02-01 07:04:13 +04:00
|
|
|
|
2026-07-27 04:05:51 -04:00
|
|
|
await session.commit()
|
|
|
|
|
updated_chat = ChatModel.model_validate(chat_item)
|
2026-07-27 04:09:02 -04:00
|
|
|
user_id = chat_item.user_id
|
2026-07-27 04:05:51 -04:00
|
|
|
|
2026-07-27 04:09:02 -04:00
|
|
|
# Dual-write to chat_message table
|
|
|
|
|
try:
|
|
|
|
|
await ChatMessages.upsert_message(
|
|
|
|
|
message_id=message_id,
|
|
|
|
|
chat_id=id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
data=saved_message,
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log.warning(f'Failed to write to chat_message table: {e}')
|
2026-02-01 07:04:13 +04:00
|
|
|
|
2026-07-27 04:09:02 -04:00
|
|
|
return updated_chat
|
2026-07-27 04:05:51 -04:00
|
|
|
except Exception:
|
|
|
|
|
return None
|
2024-12-19 01:00:32 -08:00
|
|
|
|
2026-06-29 13:15:29 -05:00
|
|
|
async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None:
|
2026-07-27 04:09:02 -04:00
|
|
|
try:
|
|
|
|
|
async with get_async_db_context() as session:
|
2026-08-25 13:18:38 -04:00
|
|
|
chat_item = await session.get(
|
|
|
|
|
Chat,
|
|
|
|
|
id,
|
|
|
|
|
populate_existing=True,
|
|
|
|
|
with_for_update=session.bind.dialect.name == 'postgresql',
|
|
|
|
|
)
|
2026-07-27 04:09:02 -04:00
|
|
|
if chat_item is None:
|
|
|
|
|
return None
|
2026-06-29 13:15:29 -05:00
|
|
|
|
2026-07-27 04:09:02 -04:00
|
|
|
self._sanitize_chat_row(chat_item)
|
|
|
|
|
chat = chat_item.chat or {}
|
|
|
|
|
self._repair_chat_current_id(chat)
|
2026-06-29 13:15:29 -05:00
|
|
|
|
2026-07-27 04:09:02 -04:00
|
|
|
history = chat.get('history', {})
|
|
|
|
|
deleted_ids = self.delete_message_from_history(history, message_id)
|
|
|
|
|
if not deleted_ids:
|
perf: stop rescanning the whole chat JSON on every streamed event write (#28820)
Every streamed event that persists to a chat (status updates, citations,
file attachments, message content) serialized the entire conversation JSON
three times: a null-byte check of the stored row, a second sanitize of the
whole blob after merging in the event payload and the flush of the UPDATE
itself. The middle pass rescans megabytes of already-clean history for null
bytes that can only come from the small incoming payload, so long chats pay
for their full history on every single event.
The write paths now sanitize just the incoming message, message id and
status dict and keep the row-level sanitize, so legacy rows with null bytes
still self-heal as before. Median per-event write time (sqlite, orjson):
1 MB chat 15.0 ms to 11.1 ms, 4 MB 65.2 ms to 52.4 ms, 10 MB 159.9 ms to
124.5 ms, roughly 20 percent less per event. As a side effect the
chat_message dual write now receives the sanitized message; previously null
bytes in non-content fields were cleaned in the blob but written raw to
chat_message, which failed that insert on PostgreSQL. Verified byte-identical
rows against the previous implementation across nine scenarios covering null
bytes in every input, legacy dirty rows, a missing title and a NULL chat
column.
2026-08-20 02:52:05 +02:00
|
|
|
chat_item.chat = chat
|
|
|
|
|
chat_item.title = chat.get('title', 'New Chat')
|
|
|
|
|
chat_item.current_message_id = self.get_current_message_id(chat)
|
2026-07-27 04:09:02 -04:00
|
|
|
flag_modified(chat_item, 'chat')
|
|
|
|
|
await session.commit()
|
|
|
|
|
return ChatModel.model_validate(chat_item)
|
|
|
|
|
|
|
|
|
|
messages = history.get('messages') or {}
|
|
|
|
|
chat['history'] = history
|
perf: stop rescanning the whole chat JSON on every streamed event write (#28820)
Every streamed event that persists to a chat (status updates, citations,
file attachments, message content) serialized the entire conversation JSON
three times: a null-byte check of the stored row, a second sanitize of the
whole blob after merging in the event payload and the flush of the UPDATE
itself. The middle pass rescans megabytes of already-clean history for null
bytes that can only come from the small incoming payload, so long chats pay
for their full history on every single event.
The write paths now sanitize just the incoming message, message id and
status dict and keep the row-level sanitize, so legacy rows with null bytes
still self-heal as before. Median per-event write time (sqlite, orjson):
1 MB chat 15.0 ms to 11.1 ms, 4 MB 65.2 ms to 52.4 ms, 10 MB 159.9 ms to
124.5 ms, roughly 20 percent less per event. As a side effect the
chat_message dual write now receives the sanitized message; previously null
bytes in non-content fields were cleaned in the blob but written raw to
chat_message, which failed that insert on PostgreSQL. Verified byte-identical
rows against the previous implementation across nine scenarios covering null
bytes in every input, legacy dirty rows, a missing title and a NULL chat
column.
2026-08-20 02:52:05 +02:00
|
|
|
chat_item.chat = chat
|
|
|
|
|
chat_item.title = chat.get('title', 'New Chat')
|
|
|
|
|
chat_item.current_message_id = self.get_current_message_id(chat)
|
2026-07-27 04:09:02 -04:00
|
|
|
flag_modified(chat_item, 'chat')
|
|
|
|
|
chat_item.updated_at = int(time.time())
|
|
|
|
|
await session.commit()
|
|
|
|
|
updated_chat = ChatModel.model_validate(chat_item)
|
|
|
|
|
user_id = chat_item.user_id
|
2026-06-29 13:15:29 -05:00
|
|
|
|
2026-07-27 04:09:02 -04:00
|
|
|
await self.backfill_messages_by_chat_id(id, user_id, messages)
|
|
|
|
|
await ChatMessages.delete_message_ids_by_chat_id(id, deleted_ids)
|
2026-06-29 13:15:29 -05:00
|
|
|
|
2026-07-27 04:09:02 -04:00
|
|
|
return updated_chat
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
2026-06-29 13:15:29 -05:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def add_message_status_to_chat_by_id_and_message_id(
|
2024-12-24 18:03:14 -07:00
|
|
|
self, id: str, message_id: str, status: dict
|
2026-05-12 17:10:15 +09:00
|
|
|
) -> ChatModel | None:
|
2026-07-27 04:09:02 -04:00
|
|
|
try:
|
perf: stop rescanning the whole chat JSON on every streamed event write (#28820)
Every streamed event that persists to a chat (status updates, citations,
file attachments, message content) serialized the entire conversation JSON
three times: a null-byte check of the stored row, a second sanitize of the
whole blob after merging in the event payload and the flush of the UPDATE
itself. The middle pass rescans megabytes of already-clean history for null
bytes that can only come from the small incoming payload, so long chats pay
for their full history on every single event.
The write paths now sanitize just the incoming message, message id and
status dict and keep the row-level sanitize, so legacy rows with null bytes
still self-heal as before. Median per-event write time (sqlite, orjson):
1 MB chat 15.0 ms to 11.1 ms, 4 MB 65.2 ms to 52.4 ms, 10 MB 159.9 ms to
124.5 ms, roughly 20 percent less per event. As a side effect the
chat_message dual write now receives the sanitized message; previously null
bytes in non-content fields were cleaned in the blob but written raw to
chat_message, which failed that insert on PostgreSQL. Verified byte-identical
rows against the previous implementation across nine scenarios covering null
bytes in every input, legacy dirty rows, a missing title and a NULL chat
column.
2026-08-20 02:52:05 +02:00
|
|
|
status = self._clean_null_bytes(status)
|
2026-07-27 04:09:02 -04:00
|
|
|
async with get_async_db_context() as session:
|
2026-08-25 13:18:38 -04:00
|
|
|
chat_item = await session.get(
|
|
|
|
|
Chat,
|
|
|
|
|
id,
|
|
|
|
|
populate_existing=True,
|
|
|
|
|
with_for_update=session.bind.dialect.name == 'postgresql',
|
|
|
|
|
)
|
2026-07-27 04:09:02 -04:00
|
|
|
if chat_item is None:
|
|
|
|
|
return None
|
2024-12-24 18:03:14 -07:00
|
|
|
|
2026-07-27 04:09:02 -04:00
|
|
|
self._sanitize_chat_row(chat_item)
|
|
|
|
|
chat = chat_item.chat or {}
|
|
|
|
|
self._repair_chat_current_id(chat)
|
|
|
|
|
history = chat.get('history', {})
|
2024-12-24 18:03:14 -07:00
|
|
|
|
2026-07-27 04:09:02 -04:00
|
|
|
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
|
2024-12-24 18:03:14 -07:00
|
|
|
|
2026-07-27 04:09:02 -04:00
|
|
|
chat['history'] = history
|
perf: stop rescanning the whole chat JSON on every streamed event write (#28820)
Every streamed event that persists to a chat (status updates, citations,
file attachments, message content) serialized the entire conversation JSON
three times: a null-byte check of the stored row, a second sanitize of the
whole blob after merging in the event payload and the flush of the UPDATE
itself. The middle pass rescans megabytes of already-clean history for null
bytes that can only come from the small incoming payload, so long chats pay
for their full history on every single event.
The write paths now sanitize just the incoming message, message id and
status dict and keep the row-level sanitize, so legacy rows with null bytes
still self-heal as before. Median per-event write time (sqlite, orjson):
1 MB chat 15.0 ms to 11.1 ms, 4 MB 65.2 ms to 52.4 ms, 10 MB 159.9 ms to
124.5 ms, roughly 20 percent less per event. As a side effect the
chat_message dual write now receives the sanitized message; previously null
bytes in non-content fields were cleaned in the blob but written raw to
chat_message, which failed that insert on PostgreSQL. Verified byte-identical
rows against the previous implementation across nine scenarios covering null
bytes in every input, legacy dirty rows, a missing title and a NULL chat
column.
2026-08-20 02:52:05 +02:00
|
|
|
chat_item.chat = chat
|
|
|
|
|
chat_item.title = chat.get('title', 'New Chat')
|
|
|
|
|
chat_item.current_message_id = self.get_current_message_id(chat)
|
2026-07-27 04:09:02 -04:00
|
|
|
flag_modified(chat_item, 'chat')
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
return ChatModel.model_validate(chat_item)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
2024-12-24 18:03:14 -07:00
|
|
|
|
2026-08-25 13:18:38 -04:00
|
|
|
async def add_message_files_by_id_and_message_id(
|
|
|
|
|
self, id: str, message_id: str, files: list[dict]
|
|
|
|
|
) -> list[dict] | None:
|
2026-05-21 17:48:28 +04:00
|
|
|
async with get_async_db_context() as session:
|
2026-08-25 13:18:38 -04:00
|
|
|
chat_item = await session.get(
|
|
|
|
|
Chat,
|
|
|
|
|
id,
|
|
|
|
|
populate_existing=True,
|
|
|
|
|
with_for_update=session.bind.dialect.name == 'postgresql',
|
|
|
|
|
)
|
|
|
|
|
if chat_item is None:
|
2026-01-06 02:19:57 +04:00
|
|
|
return None
|
2025-11-19 02:16:09 -05:00
|
|
|
|
2026-08-25 13:18:38 -04:00
|
|
|
chat = chat_item.chat or {}
|
2026-03-17 17:58:01 -05:00
|
|
|
history = chat.get('history', {})
|
2025-11-19 02:16:09 -05:00
|
|
|
|
2026-01-06 02:19:57 +04:00
|
|
|
message_files = []
|
2025-11-19 02:16:09 -05:00
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
if message_id in history.get('messages', {}):
|
|
|
|
|
message_files = history['messages'][message_id].get('files', [])
|
2026-01-06 02:19:57 +04:00
|
|
|
message_files = message_files + files
|
2026-03-17 17:58:01 -05:00
|
|
|
history['messages'][message_id]['files'] = message_files
|
2025-11-19 02:16:09 -05:00
|
|
|
|
2026-08-25 13:18:38 -04:00
|
|
|
# Written here rather than through update_chat_by_id: with session sharing off that opens a second
|
|
|
|
|
# connection, which then blocks on the lock this one holds.
|
2026-03-17 17:58:01 -05:00
|
|
|
chat['history'] = history
|
2026-08-25 13:18:38 -04:00
|
|
|
chat_item.chat = self._clean_null_bytes(chat)
|
|
|
|
|
# History was mutated in place, so the new blob compares equal to the loaded one.
|
|
|
|
|
flag_modified(chat_item, 'chat')
|
|
|
|
|
chat_item.updated_at = int(time.time())
|
|
|
|
|
await session.commit()
|
2026-01-06 02:19:57 +04:00
|
|
|
return message_files
|
2025-11-19 02:16:09 -05:00
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def insert_shared_chat_by_chat_id(self, chat_id: str, db: AsyncSession | None = None) -> ChatModel | None:
|
2026-04-17 10:16:32 +09:00
|
|
|
"""Create a shared snapshot for a chat. Returns the original chat with share_id set."""
|
|
|
|
|
from open_webui.models.shared_chats import SharedChats
|
|
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
chat = await session.get(Chat, chat_id)
|
2025-12-31 14:38:57 +01:00
|
|
|
if not chat:
|
|
|
|
|
return None
|
2026-04-17 10:16:32 +09:00
|
|
|
|
|
|
|
|
# If already shared, just update the existing snapshot
|
2024-07-03 23:32:39 -07:00
|
|
|
if chat.share_id:
|
2026-05-21 14:01:57 +04:00
|
|
|
return await self.update_shared_chat_by_chat_id(chat_id, db=session)
|
2024-07-07 23:01:15 -07:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
shared = await SharedChats.create(chat_id, chat.user_id, db=session)
|
2026-04-17 10:16:32 +09:00
|
|
|
if not shared:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Set share_id on the original chat
|
|
|
|
|
chat.share_id = shared.id
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
|
|
|
|
return ChatModel.model_validate(chat) # return the updated original
|
2026-06-01 13:56:55 -07:00
|
|
|
|
2026-05-21 15:29:49 +04:00
|
|
|
# refresh helper
|
2026-05-21 14:01:57 +04:00
|
|
|
async def update_shared_chat_by_chat_id(
|
2026-06-01 13:56:55 -07:00
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
db: AsyncSession | None = None,
|
2026-05-21 14:01:57 +04:00
|
|
|
) -> ChatModel | None:
|
2026-05-21 15:29:49 +04:00
|
|
|
"""Refresh the shared snapshot with current chat content."""
|
|
|
|
|
from open_webui.models.shared_chats import SharedChats
|
2026-04-17 10:16:32 +09:00
|
|
|
|
2026-05-21 15:29:49 +04:00
|
|
|
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
|
2024-04-02 07:42:37 -07:00
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def delete_shared_chat_by_chat_id(self, chat_id: str, db: AsyncSession | None = None) -> bool:
|
2026-04-17 10:16:32 +09:00
|
|
|
"""Delete shared snapshot for a chat."""
|
|
|
|
|
from open_webui.models.shared_chats import SharedChats
|
2024-07-06 08:10:58 -07:00
|
|
|
|
2026-04-17 10:16:32 +09:00
|
|
|
try:
|
2026-06-01 19:25:35 +03:00
|
|
|
return await SharedChats.delete_by_chat_id(chat_id, db=db)
|
2024-08-14 13:38:19 +01:00
|
|
|
except Exception:
|
2025-09-28 13:25:34 -04:00
|
|
|
return False
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def unarchive_all_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> bool:
|
2025-09-28 13:25:34 -04:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
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()
|
2025-09-28 13:25:34 -04:00
|
|
|
return True
|
|
|
|
|
except Exception:
|
2024-04-02 06:33:59 -07:00
|
|
|
return False
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def update_chat_share_id_by_id(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, id: str, share_id: str | None, db: AsyncSession | None = None
|
|
|
|
|
) -> ChatModel | None:
|
2024-03-31 22:02:40 +01:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
chat = await session.get(Chat, id)
|
2024-07-03 23:32:39 -07:00
|
|
|
chat.share_id = share_id
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2024-07-03 23:32:39 -07:00
|
|
|
return ChatModel.model_validate(chat)
|
2024-08-14 13:38:19 +01:00
|
|
|
except Exception:
|
2024-03-31 22:02:40 +01:00
|
|
|
return None
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def toggle_chat_pinned_by_id(self, id: str, db: AsyncSession | None = None) -> ChatModel | None:
|
2024-10-10 23:22:53 -07:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
chat = await session.get(Chat, id)
|
2024-10-10 23:22:53 -07:00
|
|
|
chat.pinned = not chat.pinned
|
|
|
|
|
chat.updated_at = int(time.time())
|
2026-06-16 18:41:44 -04:00
|
|
|
chat.last_read_at = int(time.time())
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2024-10-10 23:22:53 -07:00
|
|
|
return ChatModel.model_validate(chat)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def toggle_chat_archive_by_id(self, id: str, db: AsyncSession | None = None) -> ChatModel | None:
|
2024-04-20 17:03:39 -05:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
chat = await session.get(Chat, id)
|
2024-07-03 23:32:39 -07:00
|
|
|
chat.archived = not chat.archived
|
2025-11-23 00:05:27 -05:00
|
|
|
chat.folder_id = None
|
2024-10-10 23:22:53 -07:00
|
|
|
chat.updated_at = int(time.time())
|
2026-06-16 18:41:44 -04:00
|
|
|
chat.last_read_at = int(time.time())
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2024-07-03 23:32:39 -07:00
|
|
|
return ChatModel.model_validate(chat)
|
2024-08-14 13:38:19 +01:00
|
|
|
except Exception:
|
2024-04-20 17:03:39 -05:00
|
|
|
return None
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def archive_all_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> bool:
|
2024-05-26 02:00:31 -07:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
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()
|
2024-07-03 23:32:39 -07:00
|
|
|
return True
|
2024-08-14 13:38:19 +01:00
|
|
|
except Exception:
|
2024-05-26 02:00:31 -07:00
|
|
|
return False
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def get_archived_chat_list_by_user_id(
|
2025-05-25 00:48:30 +04:00
|
|
|
self,
|
|
|
|
|
user_id: str,
|
2026-05-12 17:10:15 +09:00
|
|
|
filter: dict | None = None,
|
2025-05-25 00:48:30 +04:00
|
|
|
skip: int = 0,
|
|
|
|
|
limit: int = 50,
|
2026-05-12 17:10:15 +09:00
|
|
|
db: AsyncSession | None = None,
|
2026-02-19 23:48:23 +01:00
|
|
|
) -> list[ChatTitleIdResponse]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-04-12 18:12:59 -05:00
|
|
|
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at).filter_by(
|
|
|
|
|
user_id=user_id, archived=True
|
|
|
|
|
)
|
2026-07-14 00:10:28 -04:00
|
|
|
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
2025-05-25 00:48:30 +04:00
|
|
|
|
|
|
|
|
if filter:
|
2026-03-17 17:58:01 -05:00
|
|
|
query_key = filter.get('query')
|
2026-01-29 18:51:02 +04:00
|
|
|
if query_key:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%'))
|
2026-01-29 18:51:02 +04:00
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
order_by = filter.get('order_by')
|
|
|
|
|
direction = filter.get('direction')
|
2026-01-29 18:51:02 +04:00
|
|
|
|
|
|
|
|
if order_by and direction:
|
|
|
|
|
if not getattr(Chat, order_by, None):
|
2026-03-17 17:58:01 -05:00
|
|
|
raise ValueError('Invalid order_by field')
|
2026-01-29 18:51:02 +04:00
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
if direction.lower() == 'asc':
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id)
|
2026-03-17 17:58:01 -05:00
|
|
|
elif direction.lower() == 'desc':
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id)
|
2026-01-29 18:51:02 +04:00
|
|
|
else:
|
2026-03-17 17:58:01 -05:00
|
|
|
raise ValueError('Invalid direction for ordering')
|
2026-01-29 18:51:02 +04:00
|
|
|
else:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
|
2026-02-19 23:48:23 +01:00
|
|
|
|
2026-01-29 18:51:02 +04:00
|
|
|
if skip:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.offset(skip)
|
2026-01-29 18:51:02 +04:00
|
|
|
if limit:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.limit(limit)
|
2026-01-29 18:51:02 +04:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
result = await session.execute(stmt)
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chats = result.all()
|
2026-02-19 23:48:23 +01:00
|
|
|
return [
|
|
|
|
|
ChatTitleIdResponse.model_validate(
|
|
|
|
|
{
|
2026-03-17 17:58:01 -05:00
|
|
|
'id': chat[0],
|
|
|
|
|
'title': chat[1],
|
|
|
|
|
'updated_at': chat[2],
|
|
|
|
|
'created_at': chat[3],
|
2026-02-19 23:48:23 +01:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
for chat in all_chats
|
|
|
|
|
]
|
2026-01-29 18:51:02 +04:00
|
|
|
|
2026-06-16 20:57:27 -04:00
|
|
|
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:
|
2026-07-14 00:10:28 -04:00
|
|
|
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)))
|
2026-06-16 20:57:27 -04:00
|
|
|
return result.scalar() or 0
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def get_shared_chat_list_by_user_id(
|
2026-01-29 18:51:02 +04:00
|
|
|
self,
|
|
|
|
|
user_id: str,
|
2026-05-12 17:10:15 +09:00
|
|
|
filter: dict | None = None,
|
2026-01-29 18:51:02 +04:00
|
|
|
skip: int = 0,
|
|
|
|
|
limit: int = 50,
|
2026-05-12 17:10:15 +09:00
|
|
|
db: AsyncSession | None = None,
|
2026-02-19 22:50:03 +01:00
|
|
|
) -> list[SharedChatResponse]:
|
2026-04-17 10:16:32 +09:00
|
|
|
"""Delegate to SharedChats for listing shared chats by user."""
|
|
|
|
|
from open_webui.models.shared_chats import SharedChats
|
2025-05-25 00:48:30 +04:00
|
|
|
|
2026-06-01 19:25:35 +03:00
|
|
|
return await SharedChats.get_by_user_id(user_id, filter=filter, skip=skip, limit=limit, db=db)
|
2024-04-20 18:24:18 -05:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def get_chat_list_by_user_id(
|
2024-05-26 02:00:31 -07:00
|
|
|
self,
|
|
|
|
|
user_id: str,
|
|
|
|
|
include_archived: bool = False,
|
2026-05-12 17:10:15 +09:00
|
|
|
filter: dict | None = None,
|
2024-05-26 02:00:31 -07:00
|
|
|
skip: int = 0,
|
|
|
|
|
limit: int = 50,
|
2026-05-12 17:10:15 +09:00
|
|
|
db: AsyncSession | None = None,
|
2026-04-01 05:55:48 -05:00
|
|
|
) -> list[ChatTitleIdResponse]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-04-12 18:12:59 -05:00
|
|
|
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
|
|
|
|
|
user_id=user_id
|
|
|
|
|
)
|
2026-07-14 00:10:28 -04:00
|
|
|
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
2024-07-03 23:32:39 -07:00
|
|
|
if not include_archived:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter_by(archived=False)
|
2024-10-14 21:21:45 -07:00
|
|
|
|
2025-05-25 01:44:53 +04:00
|
|
|
if filter:
|
2026-03-17 17:58:01 -05:00
|
|
|
query_key = filter.get('query')
|
2025-05-25 01:44:53 +04:00
|
|
|
if query_key:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%'))
|
2025-05-25 01:44:53 +04:00
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
order_by = filter.get('order_by')
|
|
|
|
|
direction = filter.get('direction')
|
2025-05-25 01:44:53 +04:00
|
|
|
|
|
|
|
|
if order_by and direction and getattr(Chat, order_by):
|
2026-03-17 17:58:01 -05:00
|
|
|
if direction.lower() == 'asc':
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id)
|
2026-03-17 17:58:01 -05:00
|
|
|
elif direction.lower() == 'desc':
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id)
|
2025-05-25 01:44:53 +04:00
|
|
|
else:
|
2026-03-17 17:58:01 -05:00
|
|
|
raise ValueError('Invalid direction for ordering')
|
2025-05-25 01:44:53 +04:00
|
|
|
else:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
|
2026-04-01 05:55:48 -05:00
|
|
|
|
2024-10-14 21:21:45 -07:00
|
|
|
if skip:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.offset(skip)
|
2024-10-14 21:21:45 -07:00
|
|
|
if limit:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.limit(limit)
|
2024-10-14 21:21:45 -07:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
result = await session.execute(stmt)
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chats = result.all()
|
2026-04-01 05:55:48 -05:00
|
|
|
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
|
|
|
|
|
]
|
2024-07-22 14:45:47 -04:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def get_chat_title_id_list_by_user_id(
|
2024-07-22 14:08:15 -04:00
|
|
|
self,
|
|
|
|
|
user_id: str,
|
|
|
|
|
include_archived: bool = False,
|
2025-09-24 11:27:19 -05:00
|
|
|
include_folders: bool = False,
|
2025-10-14 18:06:29 -05:00
|
|
|
include_pinned: bool = False,
|
2026-07-26 23:49:03 -04:00
|
|
|
sort_by: str = 'updated_at',
|
|
|
|
|
sort_dir: str = 'desc',
|
2026-05-12 17:10:15 +09:00
|
|
|
skip: int | None = None,
|
|
|
|
|
limit: int | None = None,
|
|
|
|
|
db: AsyncSession | None = None,
|
2024-08-14 13:46:31 +01:00
|
|
|
) -> list[ChatTitleIdResponse]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-04-12 18:12:59 -05:00
|
|
|
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
|
|
|
|
|
user_id=user_id
|
|
|
|
|
)
|
2026-07-14 00:10:28 -04:00
|
|
|
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
2025-09-24 11:27:19 -05:00
|
|
|
|
|
|
|
|
if not include_folders:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter_by(folder_id=None)
|
2025-09-24 11:27:19 -05:00
|
|
|
|
2025-10-14 18:06:29 -05:00
|
|
|
if not include_pinned:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
2024-10-15 03:11:03 -07:00
|
|
|
|
2024-07-22 14:08:15 -04:00
|
|
|
if not include_archived:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter_by(archived=False)
|
2024-07-22 14:08:15 -04:00
|
|
|
|
2026-07-26 23:49:03 -04:00
|
|
|
stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir))
|
2024-08-26 12:27:00 +02:00
|
|
|
|
|
|
|
|
if skip:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.offset(skip)
|
2024-10-08 23:37:37 -07:00
|
|
|
if limit:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.limit(limit)
|
2024-08-26 12:27:00 +02:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
result = await session.execute(stmt)
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chats = result.all()
|
2024-08-26 12:27:00 +02:00
|
|
|
|
2024-07-24 11:25:07 +01:00
|
|
|
return [
|
|
|
|
|
ChatTitleIdResponse.model_validate(
|
|
|
|
|
{
|
2026-03-17 17:58:01 -05:00
|
|
|
'id': chat[0],
|
|
|
|
|
'title': chat[1],
|
|
|
|
|
'updated_at': chat[2],
|
|
|
|
|
'created_at': chat[3],
|
2026-04-01 04:00:18 -05:00
|
|
|
'last_read_at': chat[4],
|
2024-07-24 11:25:07 +01:00
|
|
|
}
|
2024-07-22 14:45:47 -04:00
|
|
|
)
|
2024-07-24 11:25:07 +01:00
|
|
|
for chat in all_chats
|
|
|
|
|
]
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def get_chat_list_by_chat_ids(
|
2026-01-06 02:19:57 +04:00
|
|
|
self,
|
|
|
|
|
chat_ids: list[str],
|
|
|
|
|
skip: int = 0,
|
|
|
|
|
limit: int = 50,
|
2026-05-12 17:10:15 +09:00
|
|
|
db: AsyncSession | None = None,
|
2024-08-14 13:46:31 +01:00
|
|
|
) -> list[ChatModel]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-07-14 00:10:28 -04:00
|
|
|
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()))
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chats = result.scalars().all()
|
2024-07-03 23:32:39 -07:00
|
|
|
return [ChatModel.model_validate(chat) for chat in all_chats]
|
2026-06-01 13:56:55 -07:00
|
|
|
|
2026-06-16 23:24:27 +02:00
|
|
|
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)]
|
|
|
|
|
|
2026-06-29 01:38:41 -05:00
|
|
|
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:
|
2026-06-29 13:35:39 -05:00
|
|
|
chat_ids = (
|
|
|
|
|
select(ChatMessage.chat_id).filter(ChatMessage.model_id == model_id).group_by(ChatMessage.chat_id)
|
|
|
|
|
)
|
2026-06-29 01:38:41 -05:00
|
|
|
|
|
|
|
|
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)
|
2026-07-14 00:10:28 -04:00
|
|
|
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
2026-06-29 01:38:41 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 15:29:49 +04:00
|
|
|
# retrieve conversation
|
2026-05-21 14:01:57 +04:00
|
|
|
async def get_chat_by_id(
|
2026-06-01 13:56:55 -07:00
|
|
|
self,
|
|
|
|
|
id: str,
|
|
|
|
|
db: AsyncSession | None = None,
|
2026-05-21 14:01:57 +04:00
|
|
|
) -> ChatModel | None:
|
|
|
|
|
"""Fetch a chat by PK, auto-sanitizing null bytes on read."""
|
2024-04-02 07:04:29 -07:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
chat_item = await session.get(Chat, id)
|
2025-11-22 20:50:27 -05:00
|
|
|
if chat_item is None:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-06-29 11:33:32 -05:00
|
|
|
repaired_history = self._repair_chat_current_id(chat_item.chat or {})
|
|
|
|
|
if repaired_history:
|
2026-08-08 18:47:57 -06:00
|
|
|
chat_item.current_message_id = self.get_current_message_id(chat_item.chat)
|
2026-06-29 11:33:32 -05:00
|
|
|
flag_modified(chat_item, 'chat')
|
|
|
|
|
if self._sanitize_chat_row(chat_item) or repaired_history:
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2025-11-22 20:50:27 -05:00
|
|
|
|
|
|
|
|
return ChatModel.model_validate(chat_item)
|
2024-08-14 13:38:19 +01:00
|
|
|
except Exception:
|
2024-04-02 07:04:29 -07:00
|
|
|
return None
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def get_chat_by_share_id(self, id: str, db: AsyncSession | None = None) -> ChatModel | None:
|
2026-04-17 10:16:32 +09:00
|
|
|
"""Look up a shared chat snapshot by its share token."""
|
|
|
|
|
from open_webui.models.shared_chats import SharedChats
|
2024-07-03 23:32:39 -07:00
|
|
|
|
2026-04-17 10:16:32 +09:00
|
|
|
try:
|
2026-06-01 19:25:35 +03:00
|
|
|
shared = await SharedChats.get_by_id(id, db=db)
|
2026-04-17 10:16:32 +09:00
|
|
|
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
|
2024-08-28 00:10:27 +02:00
|
|
|
except Exception:
|
2024-04-07 01:21:12 -07:00
|
|
|
return None
|
|
|
|
|
|
2026-04-12 18:12:59 -05:00
|
|
|
async def get_chat_by_id_and_user_id(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, id: str, user_id: str, db: AsyncSession | None = None
|
|
|
|
|
) -> ChatModel | None:
|
2023-12-25 21:44:28 -08:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
result = await session.execute(select(Chat).filter_by(id=id, user_id=user_id))
|
2026-04-12 14:22:11 -05:00
|
|
|
chat = result.scalars().first()
|
2026-06-29 11:33:32 -05:00
|
|
|
if not chat:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
repaired_history = self._repair_chat_current_id(chat.chat or {})
|
|
|
|
|
if repaired_history:
|
2026-08-08 18:47:57 -06:00
|
|
|
chat.current_message_id = self.get_current_message_id(chat.chat)
|
2026-06-29 11:33:32 -05:00
|
|
|
flag_modified(chat, 'chat')
|
|
|
|
|
if self._sanitize_chat_row(chat) or repaired_history:
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
return ChatModel.model_validate(chat)
|
2024-08-14 13:38:19 +01:00
|
|
|
except Exception:
|
2023-12-25 21:44:28 -08:00
|
|
|
return None
|
|
|
|
|
|
2026-08-20 13:13:51 -07:00
|
|
|
async def get_chat_by_id_for_user(
|
|
|
|
|
self,
|
|
|
|
|
id: str,
|
|
|
|
|
user,
|
|
|
|
|
db: AsyncSession | None = None,
|
|
|
|
|
) -> ChatModel | None:
|
|
|
|
|
chat = await self.get_chat_by_id_and_user_id(id, user.id, db=db)
|
|
|
|
|
if chat:
|
|
|
|
|
return chat
|
|
|
|
|
|
|
|
|
|
chat = await self.get_chat_by_id(id, db=db)
|
|
|
|
|
if not chat:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
if user.role == 'admin' and (ENABLE_ADMIN_CHAT_ACCESS or is_internal_chat(chat.meta)):
|
|
|
|
|
return chat
|
|
|
|
|
|
|
|
|
|
if await AccessGrants.has_access(
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
resource_type='shared_chat',
|
|
|
|
|
resource_id=id,
|
|
|
|
|
permission='read',
|
|
|
|
|
db=db,
|
|
|
|
|
):
|
|
|
|
|
return chat
|
|
|
|
|
|
|
|
|
|
if chat.folder_id:
|
|
|
|
|
from open_webui.utils.access_control.folders import has_folder_access
|
|
|
|
|
|
|
|
|
|
folder = await Folders.get_folder_by_id(chat.folder_id, db=db)
|
|
|
|
|
if folder and await has_folder_access(user.id, folder, 'read', db):
|
|
|
|
|
return chat
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def is_chat_owner(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool:
|
perf: eliminate 2 redundant full chat deserialization on every message send (#21596)
* perf: eliminate 2 redundant full chat deserialization on every message send (#162)
Problem:
Every message send triggered get_chat_by_id_and_user_id which loads the
entire Chat row — including the potentially massive JSON blob containing
the full conversation history — even when the caller only needed a
simple yes/no ownership check or a single column value.
Two call sites in the message-send hot path were doing this:
1. main.py ownership verification: loaded the entire chat object including
all message history JSON, then checked `if chat is None`. The JSON blob
was immediately discarded — only the existence of the row mattered.
2. middleware.py folder check: loaded the entire chat object including all
message history JSON, then read only `chat.folder_id` — a plain column
on the chat table that requires zero JSON parsing.
Fix:
- Added `chat_exists_by_id_and_user_id()`: uses SQL EXISTS subquery which
returns a boolean without loading any row data. The database can satisfy
this from the primary key index alone.
- Added `get_chat_folder_id()`: queries only the `folder_id` column via
`db.query(Chat.folder_id)`, which tells SQLAlchemy to SELECT only that
single column instead of the entire row.
Both new methods preserve the same error handling semantics (return
False/None on exception) and user_id filtering (ownership check) as
the original get_chat_by_id_and_user_id.
Impact:
- Best case (typical): eliminates deserializing 2 full chat JSON blobs per
message send. For long conversations (hundreds of messages with tool
calls, images, file attachments), this blob can be multiple megabytes.
- Worst case: no regression — the new queries are strictly cheaper than
the old ones (less data transferred, less Python object construction,
no Pydantic model_validate overhead).
- The 3 remaining full chat loads in process_chat_payload (load_messages_from_db,
add_file_context, chat_image_generation_handler) are left untouched as
they genuinely need the full history and require separate analysis.
* Address maintainer feedback: rename method and inline call (#166)
- Rename chat_exists_by_id_and_user_id -> is_chat_owner
- Remove intermediate chat_owned variable; call is_chat_owner directly in if condition
2026-02-21 21:53:31 +01:00
|
|
|
"""
|
|
|
|
|
Lightweight ownership check — uses EXISTS subquery instead of loading
|
|
|
|
|
the full Chat row (which includes the potentially large JSON blob).
|
|
|
|
|
"""
|
|
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
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))))
|
2026-04-12 14:22:11 -05:00
|
|
|
return result.scalar()
|
perf: eliminate 2 redundant full chat deserialization on every message send (#21596)
* perf: eliminate 2 redundant full chat deserialization on every message send (#162)
Problem:
Every message send triggered get_chat_by_id_and_user_id which loads the
entire Chat row — including the potentially massive JSON blob containing
the full conversation history — even when the caller only needed a
simple yes/no ownership check or a single column value.
Two call sites in the message-send hot path were doing this:
1. main.py ownership verification: loaded the entire chat object including
all message history JSON, then checked `if chat is None`. The JSON blob
was immediately discarded — only the existence of the row mattered.
2. middleware.py folder check: loaded the entire chat object including all
message history JSON, then read only `chat.folder_id` — a plain column
on the chat table that requires zero JSON parsing.
Fix:
- Added `chat_exists_by_id_and_user_id()`: uses SQL EXISTS subquery which
returns a boolean without loading any row data. The database can satisfy
this from the primary key index alone.
- Added `get_chat_folder_id()`: queries only the `folder_id` column via
`db.query(Chat.folder_id)`, which tells SQLAlchemy to SELECT only that
single column instead of the entire row.
Both new methods preserve the same error handling semantics (return
False/None on exception) and user_id filtering (ownership check) as
the original get_chat_by_id_and_user_id.
Impact:
- Best case (typical): eliminates deserializing 2 full chat JSON blobs per
message send. For long conversations (hundreds of messages with tool
calls, images, file attachments), this blob can be multiple megabytes.
- Worst case: no regression — the new queries are strictly cheaper than
the old ones (less data transferred, less Python object construction,
no Pydantic model_validate overhead).
- The 3 remaining full chat loads in process_chat_payload (load_messages_from_db,
add_file_context, chat_image_generation_handler) are left untouched as
they genuinely need the full history and require separate analysis.
* Address maintainer feedback: rename method and inline call (#166)
- Rename chat_exists_by_id_and_user_id -> is_chat_owner
- Remove intermediate chat_owned variable; call is_chat_owner directly in if condition
2026-02-21 21:53:31 +01:00
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def get_chat_folder_id(self, id: str, user_id: str, db: AsyncSession | None = None) -> str | None:
|
perf: eliminate 2 redundant full chat deserialization on every message send (#21596)
* perf: eliminate 2 redundant full chat deserialization on every message send (#162)
Problem:
Every message send triggered get_chat_by_id_and_user_id which loads the
entire Chat row — including the potentially massive JSON blob containing
the full conversation history — even when the caller only needed a
simple yes/no ownership check or a single column value.
Two call sites in the message-send hot path were doing this:
1. main.py ownership verification: loaded the entire chat object including
all message history JSON, then checked `if chat is None`. The JSON blob
was immediately discarded — only the existence of the row mattered.
2. middleware.py folder check: loaded the entire chat object including all
message history JSON, then read only `chat.folder_id` — a plain column
on the chat table that requires zero JSON parsing.
Fix:
- Added `chat_exists_by_id_and_user_id()`: uses SQL EXISTS subquery which
returns a boolean without loading any row data. The database can satisfy
this from the primary key index alone.
- Added `get_chat_folder_id()`: queries only the `folder_id` column via
`db.query(Chat.folder_id)`, which tells SQLAlchemy to SELECT only that
single column instead of the entire row.
Both new methods preserve the same error handling semantics (return
False/None on exception) and user_id filtering (ownership check) as
the original get_chat_by_id_and_user_id.
Impact:
- Best case (typical): eliminates deserializing 2 full chat JSON blobs per
message send. For long conversations (hundreds of messages with tool
calls, images, file attachments), this blob can be multiple megabytes.
- Worst case: no regression — the new queries are strictly cheaper than
the old ones (less data transferred, less Python object construction,
no Pydantic model_validate overhead).
- The 3 remaining full chat loads in process_chat_payload (load_messages_from_db,
add_file_context, chat_image_generation_handler) are left untouched as
they genuinely need the full history and require separate analysis.
* Address maintainer feedback: rename method and inline call (#166)
- Rename chat_exists_by_id_and_user_id -> is_chat_owner
- Remove intermediate chat_owned variable; call is_chat_owner directly in if condition
2026-02-21 21:53:31 +01:00
|
|
|
"""
|
|
|
|
|
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:
|
2026-05-21 14:01:57 +04:00
|
|
|
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))
|
2026-04-12 14:22:11 -05:00
|
|
|
row = result.first()
|
|
|
|
|
return row[0] if row else None
|
perf: eliminate 2 redundant full chat deserialization on every message send (#21596)
* perf: eliminate 2 redundant full chat deserialization on every message send (#162)
Problem:
Every message send triggered get_chat_by_id_and_user_id which loads the
entire Chat row — including the potentially massive JSON blob containing
the full conversation history — even when the caller only needed a
simple yes/no ownership check or a single column value.
Two call sites in the message-send hot path were doing this:
1. main.py ownership verification: loaded the entire chat object including
all message history JSON, then checked `if chat is None`. The JSON blob
was immediately discarded — only the existence of the row mattered.
2. middleware.py folder check: loaded the entire chat object including all
message history JSON, then read only `chat.folder_id` — a plain column
on the chat table that requires zero JSON parsing.
Fix:
- Added `chat_exists_by_id_and_user_id()`: uses SQL EXISTS subquery which
returns a boolean without loading any row data. The database can satisfy
this from the primary key index alone.
- Added `get_chat_folder_id()`: queries only the `folder_id` column via
`db.query(Chat.folder_id)`, which tells SQLAlchemy to SELECT only that
single column instead of the entire row.
Both new methods preserve the same error handling semantics (return
False/None on exception) and user_id filtering (ownership check) as
the original get_chat_by_id_and_user_id.
Impact:
- Best case (typical): eliminates deserializing 2 full chat JSON blobs per
message send. For long conversations (hundreds of messages with tool
calls, images, file attachments), this blob can be multiple megabytes.
- Worst case: no regression — the new queries are strictly cheaper than
the old ones (less data transferred, less Python object construction,
no Pydantic model_validate overhead).
- The 3 remaining full chat loads in process_chat_payload (load_messages_from_db,
add_file_context, chat_image_generation_handler) are left untouched as
they genuinely need the full history and require separate analysis.
* Address maintainer feedback: rename method and inline call (#166)
- Rename chat_exists_by_id_and_user_id -> is_chat_owner
- Remove intermediate chat_owned variable; call is_chat_owner directly in if condition
2026-02-21 21:53:31 +01:00
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-07-26 19:34:41 -04:00
|
|
|
async def count_unread_by_folder_ids(
|
|
|
|
|
self,
|
|
|
|
|
user_id: str,
|
|
|
|
|
folder_ids: list[str],
|
|
|
|
|
db: AsyncSession | None = None,
|
|
|
|
|
) -> dict[str, int]:
|
|
|
|
|
if not folder_ids:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
unfinished_assistant = (
|
|
|
|
|
select(ChatMessage.id)
|
|
|
|
|
.where(ChatMessage.chat_id == Chat.id)
|
|
|
|
|
.where(ChatMessage.role == 'assistant')
|
|
|
|
|
.where(ChatMessage.done.is_(False))
|
|
|
|
|
.exists()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
select(Chat.folder_id, func.count(Chat.id))
|
|
|
|
|
.where(
|
|
|
|
|
Chat.user_id == user_id,
|
|
|
|
|
Chat.folder_id.in_(folder_ids),
|
|
|
|
|
Chat.archived == False,
|
|
|
|
|
Chat.updated_at > func.coalesce(Chat.last_read_at, 0),
|
|
|
|
|
~unfinished_assistant,
|
|
|
|
|
)
|
|
|
|
|
.group_by(Chat.folder_id)
|
|
|
|
|
)
|
|
|
|
|
return {folder_id: count for folder_id, count in result.all() if folder_id}
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def get_chats(self, skip: int = 0, limit: int = 50, db: AsyncSession | None = None) -> list[ChatModel]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-07-14 00:10:28 -04:00
|
|
|
stmt = select(Chat).where(Chat.meta['internal'].as_boolean().is_not(True))
|
|
|
|
|
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chats = result.scalars().all()
|
2024-07-03 23:32:39 -07:00
|
|
|
return [ChatModel.model_validate(chat) for chat in all_chats]
|
2026-06-01 13:56:55 -07:00
|
|
|
|
2026-07-20 01:33:47 -04:00
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 15:29:49 +04:00
|
|
|
# list user conversations
|
2026-04-12 14:22:11 -05:00
|
|
|
async def get_chats_by_user_id(
|
2025-12-25 18:11:17 -05:00
|
|
|
self,
|
|
|
|
|
user_id: str,
|
2026-05-12 17:10:15 +09:00
|
|
|
filter: dict | None = None,
|
|
|
|
|
skip: int | None = None,
|
|
|
|
|
limit: int | None = None,
|
|
|
|
|
db: AsyncSession | None = None,
|
2025-12-10 12:22:40 -05:00
|
|
|
) -> ChatListResponse:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = select(Chat).filter_by(user_id=user_id)
|
2026-07-14 00:10:28 -04:00
|
|
|
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
2025-12-25 18:11:17 -05:00
|
|
|
|
|
|
|
|
if filter:
|
2026-03-17 17:58:01 -05:00
|
|
|
if filter.get('updated_at'):
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(Chat.updated_at > filter.get('updated_at'))
|
2025-12-25 18:11:17 -05:00
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
order_by = filter.get('order_by')
|
|
|
|
|
direction = filter.get('direction')
|
2025-12-25 18:32:13 -05:00
|
|
|
|
|
|
|
|
if order_by and direction:
|
|
|
|
|
if hasattr(Chat, order_by):
|
2026-03-17 17:58:01 -05:00
|
|
|
if direction.lower() == 'asc':
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id)
|
2026-03-17 17:58:01 -05:00
|
|
|
elif direction.lower() == 'desc':
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id)
|
2025-12-25 18:32:13 -05:00
|
|
|
else:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
|
2025-12-25 18:32:13 -05:00
|
|
|
|
|
|
|
|
else:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
|
2025-12-10 12:22:40 -05:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
|
2026-04-12 14:22:11 -05:00
|
|
|
total = count_result.scalar()
|
2025-12-10 12:22:40 -05:00
|
|
|
|
|
|
|
|
if skip is not None:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.offset(skip)
|
2025-12-10 12:22:40 -05:00
|
|
|
if limit is not None:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.limit(limit)
|
2025-12-10 12:22:40 -05:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
result = await session.execute(stmt)
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chats = result.scalars().all()
|
2025-12-10 12:22:40 -05:00
|
|
|
|
|
|
|
|
return ChatListResponse(
|
|
|
|
|
**{
|
2026-03-17 17:58:01 -05:00
|
|
|
'items': [ChatModel.model_validate(chat) for chat in all_chats],
|
|
|
|
|
'total': total,
|
2025-12-10 12:22:40 -05:00
|
|
|
}
|
|
|
|
|
)
|
2026-06-01 13:56:55 -07:00
|
|
|
|
2026-05-21 15:29:49 +04:00
|
|
|
# list pinned chats
|
2026-04-12 18:12:59 -05:00
|
|
|
async def get_pinned_chats_by_user_id(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, user_id: str, db: AsyncSession | None = None
|
2026-04-12 18:12:59 -05:00
|
|
|
) -> list[ChatTitleIdResponse]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-07-14 00:10:28 -04:00
|
|
|
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
|
2024-10-10 23:22:53 -07:00
|
|
|
)
|
2026-07-14 00:10:28 -04:00
|
|
|
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
|
|
|
|
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chats = result.all()
|
2026-02-19 23:48:23 +01:00
|
|
|
return [
|
|
|
|
|
ChatTitleIdResponse.model_validate(
|
|
|
|
|
{
|
2026-03-17 17:58:01 -05:00
|
|
|
'id': chat[0],
|
|
|
|
|
'title': chat[1],
|
|
|
|
|
'updated_at': chat[2],
|
|
|
|
|
'created_at': chat[3],
|
2026-04-01 04:00:18 -05:00
|
|
|
'last_read_at': chat[4],
|
2026-02-19 23:48:23 +01:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
for chat in all_chats
|
|
|
|
|
]
|
2024-10-10 23:22:53 -07:00
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def get_archived_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> list[ChatModel]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-07-14 00:10:28 -04:00
|
|
|
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()))
|
2026-04-12 14:22:11 -05:00
|
|
|
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
|
2026-06-01 13:56:55 -07:00
|
|
|
|
2026-05-21 15:29:49 +04:00
|
|
|
# search user conversations
|
2026-08-05 00:47:49 -05:00
|
|
|
async def get_chats_by_user_id_and_search_text( # noqa: C901
|
2024-10-08 23:37:37 -07:00
|
|
|
self,
|
|
|
|
|
user_id: str,
|
|
|
|
|
search_text: str,
|
|
|
|
|
include_archived: bool = False,
|
|
|
|
|
skip: int = 0,
|
|
|
|
|
limit: int = 60,
|
2026-05-12 17:10:15 +09:00
|
|
|
db: AsyncSession | None = None,
|
2024-10-08 23:37:37 -07:00
|
|
|
) -> list[ChatModel]:
|
|
|
|
|
"""
|
|
|
|
|
Filters chats based on a search query using Python, allowing pagination using skip and limit.
|
|
|
|
|
"""
|
2025-12-21 13:14:29 +01:00
|
|
|
search_text = sanitize_text_for_db(search_text).lower().strip()
|
2024-10-14 17:31:52 -07:00
|
|
|
|
2024-10-08 23:37:37 -07:00
|
|
|
if not search_text:
|
2026-04-12 18:12:59 -05:00
|
|
|
return await self.get_chat_list_by_user_id(
|
|
|
|
|
user_id, include_archived, filter={}, skip=skip, limit=limit, db=db
|
|
|
|
|
)
|
2024-10-08 23:37:37 -07:00
|
|
|
|
2026-08-05 00:47:49 -05:00
|
|
|
search_text_words = search_text.split()
|
2024-10-14 17:31:52 -07:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
# search_text might contain 'tag:tag_name' format so we need to extract the tag_name
|
2024-10-14 19:02:08 -07:00
|
|
|
tag_ids = [
|
2026-03-17 17:58:01 -05:00
|
|
|
word.replace('tag:', '').replace(' ', '_').lower() for word in search_text_words if word.startswith('tag:')
|
2024-10-14 19:02:08 -07:00
|
|
|
]
|
2024-10-14 22:57:11 -07:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
# Extract folder names
|
|
|
|
|
folders = await Folders.search_folders_by_names(
|
2025-08-10 02:10:18 +04:00
|
|
|
user_id,
|
2026-03-17 17:58:01 -05:00
|
|
|
[word.replace('folder:', '') for word in search_text_words if word.startswith('folder:')],
|
2025-08-10 02:10:18 +04:00
|
|
|
)
|
|
|
|
|
folder_ids = [folder.id for folder in folders]
|
|
|
|
|
|
2025-08-06 20:55:58 +04:00
|
|
|
is_pinned = None
|
2026-03-17 17:58:01 -05:00
|
|
|
if 'pinned:true' in search_text_words:
|
2025-08-06 20:55:58 +04:00
|
|
|
is_pinned = True
|
2026-03-17 17:58:01 -05:00
|
|
|
elif 'pinned:false' in search_text_words:
|
2025-08-06 20:55:58 +04:00
|
|
|
is_pinned = False
|
|
|
|
|
|
|
|
|
|
is_archived = None
|
2026-03-17 17:58:01 -05:00
|
|
|
if 'archived:true' in search_text_words:
|
2025-08-06 20:55:58 +04:00
|
|
|
is_archived = True
|
2026-03-17 17:58:01 -05:00
|
|
|
elif 'archived:false' in search_text_words:
|
2025-08-06 20:55:58 +04:00
|
|
|
is_archived = False
|
|
|
|
|
|
|
|
|
|
is_shared = None
|
2026-03-17 17:58:01 -05:00
|
|
|
if 'shared:true' in search_text_words:
|
2025-08-06 20:55:58 +04:00
|
|
|
is_shared = True
|
2026-03-17 17:58:01 -05:00
|
|
|
elif 'shared:false' in search_text_words:
|
2025-08-06 20:55:58 +04:00
|
|
|
is_shared = False
|
|
|
|
|
|
2026-08-05 00:47:49 -05:00
|
|
|
search_text_words = [word for word in search_text_words if not word.startswith(CHAT_SEARCH_FILTER_PREFIXES)]
|
2024-10-14 17:31:52 -07:00
|
|
|
|
2026-08-05 00:47:49 -05:00
|
|
|
phrase_query = ' '.join(search_text_words).strip()
|
|
|
|
|
search_terms = chat_search_terms(phrase_query)
|
2024-10-14 17:31:52 -07:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = select(Chat).filter(Chat.user_id == user_id)
|
2026-07-14 00:10:28 -04:00
|
|
|
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
2024-10-08 23:37:37 -07:00
|
|
|
|
2025-08-06 20:55:58 +04:00
|
|
|
if is_archived is not None:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(Chat.archived == is_archived)
|
2025-08-06 20:55:58 +04:00
|
|
|
elif not include_archived:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(Chat.archived == False)
|
2024-10-08 23:37:37 -07:00
|
|
|
|
2025-08-06 20:55:58 +04:00
|
|
|
if is_pinned is not None:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(Chat.pinned == is_pinned)
|
2025-08-06 20:55:58 +04:00
|
|
|
|
|
|
|
|
if is_shared is not None:
|
|
|
|
|
if is_shared:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(Chat.share_id.isnot(None))
|
2025-08-06 20:55:58 +04:00
|
|
|
else:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(Chat.share_id.is_(None))
|
2025-08-06 20:55:58 +04:00
|
|
|
|
2025-08-10 02:10:18 +04:00
|
|
|
if folder_ids:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(Chat.folder_id.in_(folder_ids))
|
2025-08-10 02:10:18 +04:00
|
|
|
|
2024-10-11 00:00:13 -07:00
|
|
|
# Check if the database dialect is either 'sqlite' or 'postgresql'
|
2026-05-21 14:01:57 +04:00
|
|
|
bind = await session.connection()
|
2026-04-12 14:22:11 -05:00
|
|
|
dialect_name = bind.dialect.name
|
2026-08-05 00:47:49 -05:00
|
|
|
|
|
|
|
|
search_params = {}
|
|
|
|
|
exact_match_clause = None
|
|
|
|
|
if phrase_query:
|
|
|
|
|
exact_match_clause = or_(
|
|
|
|
|
Chat.title.ilike(bindparam('phrase_title_key')),
|
|
|
|
|
text(chat_search_message_content_match_sql(dialect_name, 'phrase_content_key')),
|
2025-06-13 13:05:33 +02:00
|
|
|
)
|
2026-08-05 00:47:49 -05:00
|
|
|
search_params.update(
|
|
|
|
|
{
|
|
|
|
|
'phrase_title_key': f'%{phrase_query}%',
|
|
|
|
|
'phrase_content_key': phrase_query,
|
|
|
|
|
}
|
2024-10-11 00:00:13 -07:00
|
|
|
)
|
2024-10-14 17:31:52 -07:00
|
|
|
|
2026-08-05 00:47:49 -05:00
|
|
|
term_clauses = []
|
|
|
|
|
for term_idx, term in enumerate(search_terms):
|
|
|
|
|
title_key = f'term_title_key_{term_idx}'
|
|
|
|
|
content_key = f'term_content_key_{term_idx}'
|
|
|
|
|
term_clauses.append(
|
|
|
|
|
or_(
|
|
|
|
|
Chat.title.ilike(bindparam(title_key)),
|
|
|
|
|
text(chat_search_message_content_match_sql(dialect_name, content_key)),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
search_params[title_key] = f'%{term}%'
|
|
|
|
|
search_params[content_key] = term
|
|
|
|
|
|
|
|
|
|
if term_clauses:
|
|
|
|
|
stmt = stmt.filter(or_(exact_match_clause, and_(*term_clauses)))
|
|
|
|
|
else:
|
|
|
|
|
stmt = stmt.filter(exact_match_clause)
|
|
|
|
|
|
|
|
|
|
if dialect_name == 'sqlite':
|
2026-04-12 14:22:11 -05:00
|
|
|
# Check if there are any tags to filter
|
2026-03-17 17:58:01 -05:00
|
|
|
if 'none' in tag_ids:
|
2026-07-20 22:11:42 -04:00
|
|
|
stmt = stmt.filter(
|
|
|
|
|
text("""
|
2024-10-19 21:16:59 -07:00
|
|
|
NOT EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM json_each(Chat.meta, '$.tags') AS tag
|
|
|
|
|
)
|
2026-07-20 22:11:42 -04:00
|
|
|
""")
|
|
|
|
|
)
|
2024-10-19 21:16:59 -07:00
|
|
|
elif tag_ids:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(
|
2024-10-14 17:31:52 -07:00
|
|
|
and_(
|
|
|
|
|
*[
|
2026-02-21 15:35:34 -06:00
|
|
|
text(f"""
|
2024-10-14 17:31:52 -07:00
|
|
|
EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM json_each(Chat.meta, '$.tags') AS tag
|
2024-10-14 22:57:11 -07:00
|
|
|
WHERE tag.value = :tag_id_{tag_idx}
|
2024-10-14 17:31:52 -07:00
|
|
|
)
|
2026-03-17 17:58:01 -05:00
|
|
|
""").params(**{f'tag_id_{tag_idx}': tag_id})
|
2024-10-14 22:57:11 -07:00
|
|
|
for tag_idx, tag_id in enumerate(tag_ids)
|
2024-10-14 17:31:52 -07:00
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
elif dialect_name == 'postgresql':
|
2025-11-22 20:34:49 -05:00
|
|
|
# Safety filter: JSON field must not contain \u0000
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(text("Chat.chat::text NOT LIKE '%\\\\u0000%'"))
|
2025-11-22 20:34:49 -05:00
|
|
|
|
|
|
|
|
# Safety filter: title must not contain actual null bytes
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(text("Chat.title::text NOT LIKE '%\\x00%'"))
|
2025-11-22 20:34:49 -05:00
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
if 'none' in tag_ids:
|
2026-07-20 22:11:42 -04:00
|
|
|
stmt = stmt.filter(
|
|
|
|
|
text("""
|
2024-10-19 21:16:59 -07:00
|
|
|
NOT EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM json_array_elements_text(Chat.meta->'tags') AS tag
|
|
|
|
|
)
|
2026-07-20 22:11:42 -04:00
|
|
|
""")
|
|
|
|
|
)
|
2024-10-19 21:16:59 -07:00
|
|
|
elif tag_ids:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.filter(
|
2024-10-14 17:31:52 -07:00
|
|
|
and_(
|
|
|
|
|
*[
|
2026-02-21 15:35:34 -06:00
|
|
|
text(f"""
|
2024-10-14 17:31:52 -07:00
|
|
|
EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM json_array_elements_text(Chat.meta->'tags') AS tag
|
2024-10-14 22:57:11 -07:00
|
|
|
WHERE tag = :tag_id_{tag_idx}
|
2024-10-14 17:31:52 -07:00
|
|
|
)
|
2026-03-17 17:58:01 -05:00
|
|
|
""").params(**{f'tag_id_{tag_idx}': tag_id})
|
2024-10-14 22:57:11 -07:00
|
|
|
for tag_idx, tag_id in enumerate(tag_ids)
|
2024-10-14 17:31:52 -07:00
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
)
|
2024-10-11 00:00:13 -07:00
|
|
|
else:
|
2026-04-12 14:22:11 -05:00
|
|
|
raise NotImplementedError(f'Unsupported dialect: {dialect_name}')
|
2024-10-08 23:37:37 -07:00
|
|
|
|
2026-08-05 00:47:49 -05:00
|
|
|
if exact_match_clause is not None:
|
|
|
|
|
stmt = stmt.order_by(case((exact_match_clause, 0), else_=1), Chat.updated_at.desc(), Chat.id)
|
|
|
|
|
else:
|
|
|
|
|
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
|
|
|
|
|
|
|
|
|
|
if search_params:
|
|
|
|
|
stmt = stmt.params(**search_params)
|
|
|
|
|
|
2024-10-11 00:00:13 -07:00
|
|
|
# Perform pagination at the SQL level
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.offset(skip).limit(limit)
|
2026-05-21 14:01:57 +04:00
|
|
|
result = await session.execute(stmt)
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chats = result.scalars().all()
|
2024-10-08 23:37:37 -07:00
|
|
|
|
2026-08-02 22:39:10 +02:00
|
|
|
log.info('The number of chats: %s', len(all_chats))
|
2024-10-14 21:21:45 -07:00
|
|
|
|
2024-10-11 00:00:13 -07:00
|
|
|
# Validate and return chats
|
|
|
|
|
return [ChatModel.model_validate(chat) for chat in all_chats]
|
2024-10-08 23:37:37 -07:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def get_chats_by_folder_id_and_user_id(
|
2026-01-06 02:19:57 +04:00
|
|
|
self,
|
|
|
|
|
folder_id: str,
|
|
|
|
|
user_id: str,
|
|
|
|
|
skip: int = 0,
|
|
|
|
|
limit: int = 60,
|
2026-07-26 23:49:03 -04:00
|
|
|
sort_by: str = 'updated_at',
|
|
|
|
|
sort_dir: str = 'desc',
|
2026-05-12 17:10:15 +09:00
|
|
|
db: AsyncSession | None = None,
|
2026-04-01 05:55:48 -05:00
|
|
|
) -> list[ChatTitleIdResponse]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-04-12 14:22:11 -05:00
|
|
|
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)
|
2026-07-14 00:10:28 -04:00
|
|
|
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
2026-04-12 14:22:11 -05:00
|
|
|
)
|
2026-07-26 23:49:03 -04:00
|
|
|
stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir))
|
2026-04-01 05:55:48 -05:00
|
|
|
|
2025-09-26 20:48:17 -05:00
|
|
|
if skip:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.offset(skip)
|
2025-09-26 20:48:17 -05:00
|
|
|
if limit:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.limit(limit)
|
2025-09-26 20:48:17 -05:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
result = await session.execute(stmt)
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chats = result.all()
|
2026-04-01 05:55:48 -05:00
|
|
|
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
|
|
|
|
|
]
|
2024-10-16 21:05:03 -07:00
|
|
|
|
2026-06-15 23:34:24 +02:00
|
|
|
async def get_all_chats_by_folder_id(
|
|
|
|
|
self,
|
|
|
|
|
folder_id: str,
|
|
|
|
|
skip: int = 0,
|
|
|
|
|
limit: int = 60,
|
2026-07-21 13:53:30 -04:00
|
|
|
sort_by: str = 'updated_at',
|
|
|
|
|
sort_dir: str = 'desc',
|
2026-07-26 23:49:03 -04:00
|
|
|
unread_for_user_id: str | None = None,
|
2026-06-15 23:34:24 +02:00
|
|
|
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:
|
|
|
|
|
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)
|
2026-07-14 00:10:28 -04:00
|
|
|
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
2026-06-15 23:34:24 +02:00
|
|
|
)
|
2026-07-26 23:49:03 -04:00
|
|
|
stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir, unread_for_user_id))
|
2026-06-15 23:34:24 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
]
|
|
|
|
|
|
2026-07-21 13:53:30 -04:00
|
|
|
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()
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def get_chats_by_folder_ids_and_user_id(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
|
2024-10-19 02:42:12 -07:00
|
|
|
) -> list[ChatModel]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-04-12 14:22:11 -05:00
|
|
|
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)
|
2026-07-14 00:10:28 -04:00
|
|
|
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
2026-04-12 14:22:11 -05:00
|
|
|
.order_by(Chat.updated_at.desc())
|
|
|
|
|
)
|
2024-10-19 02:42:12 -07:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
result = await session.execute(stmt)
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chats = result.scalars().all()
|
2024-10-19 02:42:12 -07:00
|
|
|
return [ChatModel.model_validate(chat) for chat in all_chats]
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def update_chat_folder_id_by_id_and_user_id(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, id: str, user_id: str, folder_id: str, db: AsyncSession | None = None
|
|
|
|
|
) -> ChatModel | None:
|
2024-10-16 21:05:03 -07:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
chat = await session.get(Chat, id)
|
2024-10-16 21:05:03 -07:00
|
|
|
chat.folder_id = folder_id
|
|
|
|
|
chat.updated_at = int(time.time())
|
2026-06-16 18:41:44 -04:00
|
|
|
chat.last_read_at = int(time.time())
|
2024-10-18 14:18:13 -07:00
|
|
|
chat.pinned = False
|
2026-07-26 18:43:34 -04:00
|
|
|
if folder_id is not None:
|
|
|
|
|
# Folder listings only show unarchived chats, so moving an archived
|
|
|
|
|
# chat into a folder would otherwise have no visible effect: the chat
|
|
|
|
|
# stays in the archived list and never appears in the folder.
|
|
|
|
|
chat.archived = False
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2024-10-16 21:05:03 -07:00
|
|
|
return ChatModel.model_validate(chat)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-04-12 18:12:59 -05:00
|
|
|
async def get_chat_tags_by_id_and_user_id(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, id: str, user_id: str, db: AsyncSession | None = None
|
2026-04-12 18:12:59 -05:00
|
|
|
) -> list[TagModel]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-04-17 05:35:58 +02:00
|
|
|
stmt = select(Chat.meta).where(Chat.id == id)
|
2026-05-21 14:01:57 +04:00
|
|
|
result = await session.execute(stmt)
|
2026-04-17 05:35:58 +02:00
|
|
|
meta = result.scalar_one_or_none()
|
|
|
|
|
tag_ids = (meta or {}).get('tags', [])
|
2026-05-21 14:01:57 +04:00
|
|
|
return await Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=session)
|
2024-10-10 23:22:53 -07:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def get_chat_list_by_user_id_and_tag_name(
|
2026-01-06 02:19:57 +04:00
|
|
|
self,
|
|
|
|
|
user_id: str,
|
|
|
|
|
tag_name: str,
|
|
|
|
|
skip: int = 0,
|
|
|
|
|
limit: int = 50,
|
2026-05-12 17:10:15 +09:00
|
|
|
db: AsyncSession | None = None,
|
2026-04-01 05:55:48 -05:00
|
|
|
) -> list[ChatTitleIdResponse]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-04-12 18:12:59 -05:00
|
|
|
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
|
|
|
|
|
user_id=user_id
|
|
|
|
|
)
|
2026-07-14 00:10:28 -04:00
|
|
|
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
2026-03-17 17:58:01 -05:00
|
|
|
tag_id = tag_name.replace(' ', '_').lower()
|
2024-10-10 23:22:53 -07:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
bind = await session.connection()
|
2026-04-12 14:22:11 -05:00
|
|
|
dialect_name = bind.dialect.name
|
2026-08-02 22:39:10 +02:00
|
|
|
log.info('DB dialect name: %s', dialect_name)
|
2026-04-12 14:22:11 -05:00
|
|
|
if dialect_name == 'sqlite':
|
|
|
|
|
stmt = stmt.filter(
|
2026-03-17 17:58:01 -05:00
|
|
|
text(f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)")
|
2024-10-10 23:22:53 -07:00
|
|
|
).params(tag_id=tag_id)
|
2026-04-12 14:22:11 -05:00
|
|
|
elif dialect_name == 'postgresql':
|
|
|
|
|
stmt = stmt.filter(
|
2026-03-17 17:58:01 -05:00
|
|
|
text("EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)")
|
2024-10-10 23:22:53 -07:00
|
|
|
).params(tag_id=tag_id)
|
|
|
|
|
else:
|
2026-04-12 14:22:11 -05:00
|
|
|
raise NotImplementedError(f'Unsupported dialect: {dialect_name}')
|
2024-10-10 23:22:53 -07:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
|
2026-04-01 05:55:48 -05:00
|
|
|
|
|
|
|
|
if skip:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.offset(skip)
|
2026-04-01 05:55:48 -05:00
|
|
|
if limit:
|
2026-04-12 14:22:11 -05:00
|
|
|
stmt = stmt.limit(limit)
|
2026-04-01 05:55:48 -05:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
result = await session.execute(stmt)
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chats = result.all()
|
2026-04-01 05:55:48 -05:00
|
|
|
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
|
|
|
|
|
]
|
2024-10-10 23:22:53 -07:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def add_chat_tag_by_id_and_user_id_and_tag_name(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, id: str, user_id: str, tag_name: str, db: AsyncSession | None = None
|
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
2026-07-27 08:21:59 +02:00
|
|
|
) -> None:
|
|
|
|
|
"""Add one tag to a chat's meta. Meta-column-only, never the blob."""
|
2026-03-17 17:58:01 -05:00
|
|
|
tag_id = tag_name.replace(' ', '_').lower()
|
2026-06-01 19:25:35 +03:00
|
|
|
await Tags.ensure_tags_exist([tag_name], user_id, db=db)
|
2024-10-10 23:22:53 -07:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
2026-07-27 08:21:59 +02:00
|
|
|
row = (await session.execute(select(Chat.meta).filter_by(id=id))).one_or_none()
|
|
|
|
|
if row is None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
meta = row[0] or {}
|
|
|
|
|
if tag_id not in meta.get('tags', []):
|
|
|
|
|
await session.execute(
|
|
|
|
|
update(Chat)
|
|
|
|
|
.filter_by(id=id)
|
|
|
|
|
.values(meta={**meta, 'tags': list(set(meta.get('tags', []) + [tag_id]))})
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
2024-10-10 23:22:53 -07:00
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-04-12 18:12:59 -05:00
|
|
|
async def count_chats_by_tag_name_and_user_id(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, tag_name: str, user_id: str, db: AsyncSession | None = None
|
2026-04-12 18:12:59 -05:00
|
|
|
) -> int:
|
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
2026-07-27 08:21:59 +02:00
|
|
|
tag_id = tag_name.replace(' ', '_').lower()
|
|
|
|
|
counts = await self.count_chats_by_tag_ids_and_user_id([tag_id], user_id, db=db)
|
|
|
|
|
return counts.get(tag_id, 0)
|
2024-10-10 23:22:53 -07:00
|
|
|
|
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
2026-07-27 08:21:59 +02:00
|
|
|
async def count_chats_by_tag_ids_and_user_id(
|
|
|
|
|
self, tag_ids: list[str], user_id: str, db: AsyncSession | None = None
|
|
|
|
|
) -> dict[str, int]:
|
|
|
|
|
"""Per-tag chat counts in one round trip (one scalar subquery per tag)."""
|
|
|
|
|
if not tag_ids:
|
|
|
|
|
return {}
|
|
|
|
|
async with get_async_db_context(db) as session:
|
2026-05-21 14:01:57 +04:00
|
|
|
bind = await session.connection()
|
2026-04-12 14:22:11 -05:00
|
|
|
dialect_name = bind.dialect.name
|
2024-10-10 23:22:53 -07:00
|
|
|
|
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
2026-07-27 08:21:59 +02:00
|
|
|
columns = []
|
|
|
|
|
for index, tag_id in enumerate(tag_ids):
|
|
|
|
|
tag_id = tag_id.replace(' ', '_').lower()
|
|
|
|
|
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))
|
|
|
|
|
param = f'tag_id_{index}'
|
|
|
|
|
if dialect_name == 'sqlite':
|
|
|
|
|
stmt = stmt.filter(
|
|
|
|
|
text(f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :{param})")
|
|
|
|
|
).params(**{param: tag_id})
|
|
|
|
|
elif dialect_name == 'postgresql':
|
|
|
|
|
stmt = stmt.filter(
|
|
|
|
|
text(
|
|
|
|
|
f"EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :{param})"
|
|
|
|
|
)
|
|
|
|
|
).params(**{param: tag_id})
|
|
|
|
|
else:
|
|
|
|
|
raise NotImplementedError(f'Unsupported dialect: {dialect_name}')
|
|
|
|
|
columns.append(stmt.scalar_subquery().label(f'count_{index}'))
|
|
|
|
|
|
|
|
|
|
row = (await session.execute(select(*columns))).one()
|
|
|
|
|
return dict(zip(tag_ids, row))
|
2024-10-10 23:22:53 -07:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def delete_orphan_tags_for_user(
|
2026-02-16 00:41:36 -06:00
|
|
|
self,
|
|
|
|
|
tag_ids: list[str],
|
|
|
|
|
user_id: str,
|
|
|
|
|
threshold: int = 0,
|
2026-05-12 17:10:15 +09:00
|
|
|
db: AsyncSession | None = None,
|
2026-02-16 00:41:36 -06:00
|
|
|
) -> 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
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
2026-07-27 08:21:59 +02:00
|
|
|
counts = await self.count_chats_by_tag_ids_and_user_id(tag_ids, user_id, db=session)
|
|
|
|
|
orphans = [tag_id for tag_id in tag_ids if counts.get(tag_id, 0) <= threshold]
|
2026-05-21 14:01:57 +04:00
|
|
|
await Tags.delete_tags_by_ids_and_user_id(orphans, user_id, db=session)
|
2025-09-24 09:04:54 -05:00
|
|
|
|
2026-04-12 18:12:59 -05:00
|
|
|
async def count_chats_by_folder_id_and_user_id(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, folder_id: str, user_id: str, db: AsyncSession | None = None
|
2026-04-12 18:12:59 -05:00
|
|
|
) -> int:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-07-14 00:10:28 -04:00
|
|
|
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)))
|
2026-04-12 14:22:11 -05:00
|
|
|
count = result.scalar()
|
2025-09-24 09:04:54 -05:00
|
|
|
|
2026-08-02 22:39:10 +02:00
|
|
|
log.info("Count of chats for folder '%s': %s", folder_id, count)
|
2025-09-24 09:04:54 -05:00
|
|
|
return count
|
|
|
|
|
|
2026-06-17 00:25:35 +02:00
|
|
|
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:
|
2026-07-14 00:10:28 -04:00
|
|
|
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)))
|
2026-06-17 00:25:35 +02:00
|
|
|
count = result.scalar()
|
|
|
|
|
|
2026-08-02 22:39:10 +02:00
|
|
|
log.info("Count of chats for folders '%s': %s", folder_ids, count)
|
2026-06-17 00:25:35 +02:00
|
|
|
return count
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def delete_tag_by_id_and_user_id_and_tag_name(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, id: str, user_id: str, tag_name: str, db: AsyncSession | None = None
|
2024-10-10 23:22:53 -07:00
|
|
|
) -> bool:
|
|
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
chat = await session.get(Chat, id)
|
2026-03-17 17:58:01 -05:00
|
|
|
tags = chat.meta.get('tags', [])
|
|
|
|
|
tag_id = tag_name.replace(' ', '_').lower()
|
2024-10-10 23:22:53 -07:00
|
|
|
|
|
|
|
|
tags = [tag for tag in tags if tag != tag_id]
|
|
|
|
|
chat.meta = {
|
|
|
|
|
**chat.meta,
|
2026-03-17 17:58:01 -05:00
|
|
|
'tags': list(set(tags)),
|
2024-10-10 23:22:53 -07:00
|
|
|
}
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2024-10-10 23:22:53 -07:00
|
|
|
return True
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def delete_chat_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
|
2024-04-27 18:24:59 -04:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
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()
|
2024-04-27 18:24:59 -04:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
return True and await self.delete_shared_chat_by_chat_id(id, db=session)
|
2024-08-14 13:38:19 +01:00
|
|
|
except Exception:
|
2024-04-27 18:24:59 -04:00
|
|
|
return False
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def delete_chat_by_id_and_user_id(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool:
|
2023-12-26 01:27:43 -08:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
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()
|
2024-07-03 23:32:39 -07:00
|
|
|
|
2026-05-21 14:01:57 +04:00
|
|
|
return True and await self.delete_shared_chat_by_chat_id(id, db=session)
|
2024-08-14 13:38:19 +01:00
|
|
|
except Exception:
|
2023-12-26 01:27:43 -08:00
|
|
|
return False
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def delete_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> bool:
|
2023-12-28 23:17:58 -08:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
await self.delete_shared_chats_by_user_id(user_id, db=session)
|
2024-07-03 23:32:39 -07:00
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
chat_id_subquery = select(Chat.id).filter_by(user_id=user_id).scalar_subquery()
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.execute(
|
2026-04-12 18:12:59 -05:00
|
|
|
update(AutomationRun)
|
|
|
|
|
.filter(AutomationRun.chat_id.in_(select(Chat.id).filter_by(user_id=user_id)))
|
|
|
|
|
.values(chat_id=None)
|
2026-04-02 08:09:57 -05:00
|
|
|
)
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.execute(
|
2026-04-12 14:22:11 -05:00
|
|
|
delete(ChatMessage).filter(ChatMessage.chat_id.in_(select(Chat.id).filter_by(user_id=user_id)))
|
2026-02-13 15:00:39 -06:00
|
|
|
)
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.execute(delete(Chat).filter_by(user_id=user_id))
|
|
|
|
|
await session.commit()
|
2024-07-06 08:10:58 -07:00
|
|
|
|
2024-07-03 23:32:39 -07:00
|
|
|
return True
|
2024-08-14 13:38:19 +01:00
|
|
|
except Exception:
|
2024-04-02 07:55:56 -07:00
|
|
|
return False
|
|
|
|
|
|
2026-04-12 18:12:59 -05:00
|
|
|
async def delete_chats_by_user_id_and_folder_id(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, user_id: str, folder_id: str, db: AsyncSession | None = None
|
2026-04-12 18:12:59 -05:00
|
|
|
) -> bool:
|
2024-10-17 18:24:58 -07:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-04-12 14:22:11 -05:00
|
|
|
chat_ids_stmt = select(Chat.id).filter_by(user_id=user_id, folder_id=folder_id)
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.execute(
|
2026-04-12 14:22:11 -05:00
|
|
|
update(AutomationRun).filter(AutomationRun.chat_id.in_(chat_ids_stmt)).values(chat_id=None)
|
2026-04-02 08:09:57 -05:00
|
|
|
)
|
2026-05-21 14:01:57 +04:00
|
|
|
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()
|
2024-10-17 18:24:58 -07:00
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def move_chats_by_user_id_and_folder_id(
|
2026-01-06 02:19:57 +04:00
|
|
|
self,
|
|
|
|
|
user_id: str,
|
|
|
|
|
folder_id: str,
|
2026-05-12 17:10:15 +09:00
|
|
|
new_folder_id: str | None,
|
|
|
|
|
db: AsyncSession | None = None,
|
2025-11-23 00:01:49 -05:00
|
|
|
) -> bool:
|
|
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
await session.execute(
|
2026-04-12 14:22:11 -05:00
|
|
|
update(Chat).filter_by(user_id=user_id, folder_id=folder_id).values(folder_id=new_folder_id)
|
|
|
|
|
)
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2025-11-23 00:01:49 -05:00
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def delete_shared_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> bool:
|
2026-04-17 10:16:32 +09:00
|
|
|
"""Delete all shared chat snapshots created by a user."""
|
2026-05-12 17:10:15 +09:00
|
|
|
from open_webui.models.shared_chats import SharedChat as SharedChatTable
|
|
|
|
|
from open_webui.models.shared_chats import SharedChats
|
2026-04-17 10:16:32 +09:00
|
|
|
|
2024-04-02 07:55:56 -07:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2026-04-17 10:16:32 +09:00
|
|
|
# Delete shared_chat rows for this user's chats
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.execute(delete(SharedChatTable).filter_by(user_id=user_id))
|
2026-04-17 10:16:32 +09:00
|
|
|
|
|
|
|
|
# Clear share_id on all of this user's chats
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.execute(update(Chat).filter_by(user_id=user_id).values(share_id=None))
|
|
|
|
|
await session.commit()
|
2024-04-02 07:55:56 -07:00
|
|
|
|
2024-07-03 23:32:39 -07:00
|
|
|
return True
|
2024-08-14 13:38:19 +01:00
|
|
|
except Exception:
|
2023-12-28 23:17:58 -08:00
|
|
|
return False
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def insert_chat_files(
|
2026-01-06 02:19:57 +04:00
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
file_ids: list[str],
|
|
|
|
|
user_id: str,
|
2026-05-12 17:10:15 +09:00
|
|
|
db: AsyncSession | None = None,
|
|
|
|
|
) -> list[ChatFileModel | None]:
|
2025-12-21 23:17:53 +04:00
|
|
|
if not file_ids:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-04-17 05:28:34 +02:00
|
|
|
chat_message_file_ids = {
|
2026-05-29 00:42:17 +02:00
|
|
|
item.id for item in await self.get_chat_files_by_chat_id_and_message_id(chat_id, message_id, db=db)
|
2026-04-17 05:28:34 +02:00
|
|
|
}
|
2025-12-21 23:17:53 +04:00
|
|
|
# Remove duplicates and existing file_ids
|
2026-04-17 05:28:34 +02:00
|
|
|
file_ids = list({file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids})
|
2025-12-21 23:17:53 +04:00
|
|
|
if not file_ids:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-05-29 00:42:17 +02:00
|
|
|
# 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
|
|
|
|
|
|
2025-12-21 23:17:53 +04:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
2025-12-21 23:17:53 +04:00
|
|
|
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
|
|
|
|
|
]
|
|
|
|
|
|
2026-03-17 17:58:01 -05:00
|
|
|
results = [ChatFile(**chat_file.model_dump()) for chat_file in chat_files]
|
2025-12-21 23:17:53 +04:00
|
|
|
|
2026-05-21 17:48:28 +04:00
|
|
|
session.add_all(results)
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2025-12-21 23:17:53 +04:00
|
|
|
|
|
|
|
|
return chat_files
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def get_chat_files_by_chat_id_and_message_id(
|
2026-05-12 17:10:15 +09:00
|
|
|
self, chat_id: str, message_id: str, db: AsyncSession | None = None
|
2025-12-21 23:17:53 +04:00
|
|
|
) -> list[ChatFileModel]:
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
result = await session.execute(
|
2026-04-12 18:12:59 -05:00
|
|
|
select(ChatFile).filter_by(chat_id=chat_id, message_id=message_id).order_by(ChatFile.created_at.asc())
|
2025-12-21 23:17:53 +04:00
|
|
|
)
|
2026-04-12 14:22:11 -05:00
|
|
|
all_chat_files = result.scalars().all()
|
2026-03-17 17:58:01 -05:00
|
|
|
return [ChatFileModel.model_validate(chat_file) for chat_file in all_chat_files]
|
2025-12-21 23:17:53 +04:00
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def delete_chat_file(self, chat_id: str, file_id: str, db: AsyncSession | None = None) -> bool:
|
2025-12-21 23:17:53 +04:00
|
|
|
try:
|
2026-05-21 14:01:57 +04:00
|
|
|
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()
|
2025-12-21 23:17:53 +04:00
|
|
|
return True
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def get_shared_chat_ids_by_file_id(self, file_id: str, db: AsyncSession | None = None) -> list[str]:
|
2026-04-17 10:16:32 +09:00
|
|
|
"""Return IDs of chats that contain this file and have an active share link."""
|
2026-05-21 14:01:57 +04:00
|
|
|
async with get_async_db_context(db) as session:
|
|
|
|
|
result = await session.execute(
|
2026-04-17 10:16:32 +09:00
|
|
|
select(Chat.id)
|
2025-12-21 23:29:54 +04:00
|
|
|
.join(ChatFile, Chat.id == ChatFile.chat_id)
|
|
|
|
|
.filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None))
|
|
|
|
|
)
|
2026-04-17 10:16:32 +09:00
|
|
|
return [row[0] for row in result.all()]
|
2025-12-21 23:29:54 +04:00
|
|
|
|
2026-05-12 17:10:15 +09:00
|
|
|
async def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> ChatModel | None:
|
2026-03-29 18:01:04 -05:00
|
|
|
"""Update the tasks list on a chat."""
|
|
|
|
|
try:
|
2026-05-21 17:48:28 +04:00
|
|
|
async with get_async_db_context() as session:
|
2026-05-21 14:01:57 +04:00
|
|
|
chat = await session.get(Chat, id)
|
2026-03-29 18:01:04 -05:00
|
|
|
if chat is None:
|
|
|
|
|
return None
|
|
|
|
|
chat.tasks = tasks
|
2026-05-21 14:01:57 +04:00
|
|
|
await session.commit()
|
2026-03-29 18:01:04 -05:00
|
|
|
return ChatModel.model_validate(chat)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
async def get_chat_tasks_by_id(self, id: str) -> list[dict]:
|
2026-03-29 18:01:04 -05:00
|
|
|
"""Read the tasks list from a chat (lightweight column query)."""
|
2026-05-21 17:48:28 +04:00
|
|
|
async with get_async_db_context() as session:
|
2026-05-21 14:01:57 +04:00
|
|
|
result = await session.execute(select(Chat.tasks).filter_by(id=id))
|
2026-04-12 14:22:11 -05:00
|
|
|
row = result.first()
|
|
|
|
|
if row is None or row[0] is None:
|
2026-03-29 18:01:04 -05:00
|
|
|
return []
|
2026-04-12 14:22:11 -05:00
|
|
|
return row[0]
|
2026-03-29 18:01:04 -05:00
|
|
|
|
2023-12-25 21:44:28 -08:00
|
|
|
|
2026-05-21 15:29:49 +04:00
|
|
|
Chats = ChatTable() # singleton chats repository
|