Coverage for open_webui/routers/chats.py: 79%
881 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1from __future__ import annotations
3import logging
4from uuid import uuid4
6from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
7from fastapi.responses import StreamingResponse
8from fastapi.security import HTTPAuthorizationCredentials
9from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
10from open_webui.constants import ERROR_MESSAGES
11from open_webui.events import EVENTS, publish_event
12from open_webui.internal.db import get_async_session
13from open_webui.models.access_grants import AccessGrants
14from open_webui.models.chat_messages import ChatMessages
15from open_webui.models.chats import (
16 AggregateChatStats,
17 ChatBody,
18 ChatForm,
19 ChatHistoryStats,
20 ChatImportForm,
21 ChatResponse,
22 Chats,
23 ChatsImportForm,
24 ChatStatsExport,
25 ChatTitleIdResponse,
26 ChatUsageStatsListResponse,
27 MessageStats,
28 chat_search_content_query,
29 chat_search_terms,
30)
31from open_webui.models.config import Config
32from open_webui.models.folders import Folders
33from open_webui.models.shared_chats import SharedChatResponse, SharedChats
34from open_webui.models.tags import TagModel, Tags
35from open_webui.socket.main import get_event_emitter
36from open_webui.tasks import get_response_streams_by_chat_id, has_active_tasks, stop_item_tasks
37from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
38from open_webui.utils.access_control.folders import has_folder_write_access
39from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user
40from open_webui.utils.chat_fork import build_fork_history
41from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
42from open_webui.utils.misc import get_message_list
43from open_webui.utils.models import get_all_models
44from pydantic import BaseModel
45from sqlalchemy.ext.asyncio import AsyncSession
47log = logging.getLogger(__name__)
49router = APIRouter()
51CHAT_CONFIG_KEYS = {
52 'CONTEXT_COMPACTION_MODEL': 'chat.context_compaction.model',
53 'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable',
54 'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold',
55 'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap',
56 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage',
57 'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template',
58 'ENABLE_TOOL_PERMISSIONS': 'chat.tool_permissions.enable',
59}
62def overlay_response_streams(chat_data: dict, response_streams: list[dict]) -> dict:
63 if not response_streams: 63 ↛ 66line 63 didn't jump to line 66 because the condition on line 63 was always true
64 return chat_data
66 messages = chat_data.get('chat', {}).get('history', {}).get('messages')
67 if isinstance(messages, dict):
68 for stream in response_streams:
69 message_id = stream.get('message_id')
70 message = messages.get(message_id)
71 if isinstance(message, dict):
72 message['content'] = stream.get('content', '')
73 message['output'] = stream.get('output') or []
74 message['done'] = False
76 legacy_messages = chat_data.get('chat', {}).get('messages')
77 if isinstance(legacy_messages, list):
78 streams_by_message_id = {stream.get('message_id'): stream for stream in response_streams}
79 for message in legacy_messages:
80 if isinstance(message, dict) and (stream := streams_by_message_id.get(message.get('id'))):
81 message['content'] = stream.get('content', '')
82 message['output'] = stream.get('output') or []
83 message['done'] = False
85 return chat_data
88async def get_optional_verified_user(
89 request: Request,
90 response: Response,
91 background_tasks: BackgroundTasks,
92 auth_token: HTTPAuthorizationCredentials | None = Depends(bearer_security),
93):
94 try:
95 user = await get_current_user(request, response, background_tasks, auth_token)
96 except HTTPException:
97 return None
99 if user.role not in {'user', 'admin'}: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 return None
101 return user
104async def is_open_shared_chat(shared, db: AsyncSession) -> bool:
105 return await AccessGrants.has_anyone_access(
106 resource_type='shared_chat',
107 resource_id=shared.chat_id,
108 permission='read',
109 db=db,
110 )
113async def can_read_shared_chat(user, shared, db: AsyncSession) -> bool:
114 if user.role == 'pending': 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
115 return False
116 if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: 116 ↛ 118line 116 didn't jump to line 118 because the condition on line 116 was always true
117 return True
118 if shared.user_id == user.id:
119 return True
120 return await AccessGrants.has_access(
121 user_id=user.id,
122 resource_type='shared_chat',
123 resource_id=shared.chat_id,
124 permission='read',
125 db=db,
126 )
129async def add_active_state_to_chat_list(
130 request: Request, chat_list: list[ChatTitleIdResponse]
131) -> list[ChatTitleIdResponse]:
132 for chat in chat_list:
133 chat.active = False
134 if not await has_active_tasks(request.app.state.redis, chat.id): 134 ↛ 137line 134 didn't jump to line 137 because the condition on line 134 was always true
135 continue
137 chat.active = await ChatMessages.has_unfinished_assistant_by_chat_id(chat.id)
139 return chat_list
142async def get_folder_unread_counts(user_id: str, db: AsyncSession | None = None) -> dict[str, int]:
143 user_folders = await Folders.get_folders_by_user_id(user_id, db=db)
144 parent_by_id = {folder.id: folder.parent_id for folder in user_folders}
145 unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
146 direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()), db=db)
148 for unread_folder_id, unread_count in direct_unread_counts.items(): 148 ↛ 149line 148 didn't jump to line 149 because the loop on line 148 never started
149 current_id = unread_folder_id
150 seen = set()
151 while current_id and current_id not in seen:
152 seen.add(current_id)
153 if current_id in unread_counts:
154 unread_counts[current_id] += unread_count
155 current_id = parent_by_id.get(current_id)
157 return unread_counts
160class ChatConfigForm(BaseModel):
161 CONTEXT_COMPACTION_MODEL: str | None = ''
162 ENABLE_CONTEXT_COMPACTION: bool
163 CONTEXT_COMPACTION_TOKEN_THRESHOLD: int
164 CONTEXT_COMPACTION_TOKEN_CAP: int | None = None
165 CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40
166 CONTEXT_COMPACTION_PROMPT_TEMPLATE: str
167 ENABLE_TOOL_PERMISSIONS: bool = False
170class CompactChatForm(BaseModel):
171 model: str | None = None
174def chat_search_content_text(text: str) -> str:
175 return chat_search_content_query(text)
178def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) -> str | None:
179 if not search_text: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 return None
182 history = chat.get('history', {})
183 messages = history.get('messages') if isinstance(history, dict) else None
184 if not messages:
185 messages = chat.get('messages', []) or []
186 if isinstance(messages, dict):
187 messages = messages.values()
189 needles = list(dict.fromkeys([search_text, *chat_search_terms(search_text)]))
190 for needle in needles:
191 for message in messages:
192 if not isinstance(message, dict): 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true
193 continue
195 content = message.get('content')
196 if not isinstance(content, str): 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true
197 continue
199 index = content.lower().find(needle)
200 if index == -1:
201 continue
203 start = max(index - max_length // 2, 0)
204 end = min(start + max_length, len(content))
205 if index + len(needle) > end: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
206 end = min(index + len(needle), len(content))
207 start = max(end - max_length, 0)
209 snippet = ' '.join(content[start:end].split())
210 return f'{"..." if start else ""}{snippet}{"..." if end < len(content) else ""}'
212 return None
215async def get_chat_config_values() -> dict:
216 values = await Config.get_many(*CHAT_CONFIG_KEYS.values())
217 config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
218 if config.get('CONTEXT_COMPACTION_MODEL') is None: 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 config['CONTEXT_COMPACTION_MODEL'] = ''
220 if config.get('CONTEXT_COMPACTION_TOKEN_CAP') is None: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 config['CONTEXT_COMPACTION_TOKEN_CAP'] = config.get('CONTEXT_COMPACTION_TOKEN_THRESHOLD', 80000)
222 if config.get('CONTEXT_COMPACTION_RETENTION_PERCENTAGE') is None: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true
223 config['CONTEXT_COMPACTION_RETENTION_PERCENTAGE'] = 40
224 return config
227def chat_config_updates(data: dict) -> dict:
228 return {CHAT_CONFIG_KEYS[field]: value for field, value in data.items() if field in CHAT_CONFIG_KEYS}
231async def require_chat_import_permission(request: Request, user, db: AsyncSession):
232 if user.role != 'admin' and not await has_permission( 232 ↛ 235line 232 didn't jump to line 235 because the condition on line 232 was never true
233 user.id, 'chat.import', await Config.get('user.permissions'), db=db
234 ):
235 raise HTTPException(
236 status_code=status.HTTP_403_FORBIDDEN,
237 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
238 )
241############################
242# GetChatList
243# Let the record outlive the session, so that what was
244# learned here not need to be learned again.
245############################
248@router.get('/', response_model=list[ChatTitleIdResponse])
249@router.get('/list', response_model=list[ChatTitleIdResponse])
250async def get_session_user_chat_list(
251 request: Request,
252 user=Depends(get_verified_user),
253 page: int | None = None,
254 include_pinned: bool | None = False,
255 include_folders: bool | None = False,
256 sort_by: str = 'updated_at',
257 sort_dir: str = 'desc',
258 db: AsyncSession = Depends(get_async_session),
259):
260 try:
261 if page is not None:
262 limit = 60
263 skip = (page - 1) * limit
265 chats = await Chats.get_chat_title_id_list_by_user_id(
266 user.id,
267 include_folders=include_folders,
268 include_pinned=include_pinned,
269 sort_by=sort_by,
270 sort_dir=sort_dir,
271 skip=skip,
272 limit=limit,
273 db=db,
274 )
275 else:
276 chats = await Chats.get_chat_title_id_list_by_user_id(
277 user.id,
278 include_folders=include_folders,
279 include_pinned=include_pinned,
280 sort_by=sort_by,
281 sort_dir=sort_dir,
282 db=db,
283 )
284 return await add_active_state_to_chat_list(request, chats)
285 except Exception as e:
286 log.exception(e)
287 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
290@router.post('/read')
291async def mark_chats_read_by_user_id(
292 user=Depends(get_verified_user),
293 db: AsyncSession = Depends(get_async_session),
294):
295 return {
296 'updated_count': await Chats.mark_chats_read_by_user_id(user.id, db=db),
297 'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
298 }
301############################
302# GetChatUsageStats
303# EXPERIMENTAL: may be removed in future releases
304############################
307@router.get('/stats/usage', response_model=ChatUsageStatsListResponse)
308async def get_session_user_chat_usage_stats(
309 items_per_page: int | None = 50,
310 page: int | None = 1,
311 user=Depends(get_verified_user),
312 db: AsyncSession = Depends(get_async_session),
313):
314 try:
315 limit = items_per_page
316 skip = (page - 1) * limit
318 result = await Chats.get_chats_by_user_id(user.id, skip=skip, limit=limit, db=db)
320 chats = result.items
321 total = result.total
323 chat_stats = []
324 for chat in chats:
325 messages_map = chat.chat.get('history', {}).get('messages', {})
326 message_id = chat.chat.get('history', {}).get('currentId')
328 if messages_map and message_id:
329 try:
330 history_models = {}
331 history_message_count = len(messages_map)
332 history_user_messages = []
333 history_assistant_messages = []
335 for message in messages_map.values():
336 if message.get('role', '') == 'user': 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true
337 history_user_messages.append(message)
338 elif message.get('role', '') == 'assistant': 338 ↛ 335line 338 didn't jump to line 335 because the condition on line 338 was always true
339 history_assistant_messages.append(message)
340 model = message.get('model', None)
341 if model: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true
342 if model not in history_models:
343 history_models[model] = 0
344 history_models[model] += 1
346 average_user_message_content_length = (
347 sum(len(message.get('content', '')) for message in history_user_messages)
348 / len(history_user_messages)
349 if len(history_user_messages) > 0
350 else 0
351 )
352 average_assistant_message_content_length = (
353 sum(len(message.get('content', '')) for message in history_assistant_messages)
354 / len(history_assistant_messages)
355 if len(history_assistant_messages) > 0
356 else 0
357 )
359 response_times = []
360 for message in history_assistant_messages:
361 user_message_id = message.get('parentId', None)
362 if user_message_id and user_message_id in messages_map: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true
363 user_message = messages_map[user_message_id]
364 response_time = message.get('timestamp', 0) - user_message.get('timestamp', 0)
366 response_times.append(response_time)
368 average_response_time = sum(response_times) / len(response_times) if len(response_times) > 0 else 0
370 message_list = get_message_list(messages_map, message_id)
371 message_count = len(message_list)
373 models = {}
374 for message in reversed(message_list):
375 if message.get('role') == 'assistant': 375 ↛ 374line 375 didn't jump to line 374 because the condition on line 375 was always true
376 model = message.get('model', None)
377 if model: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true
378 if model not in models:
379 models[model] = 0
380 models[model] += 1
382 annotation = message.get('annotation', {})
384 chat_stats.append(
385 {
386 'id': chat.id,
387 'models': models,
388 'message_count': message_count,
389 'history_models': history_models,
390 'history_message_count': history_message_count,
391 'history_user_message_count': len(history_user_messages),
392 'history_assistant_message_count': len(history_assistant_messages),
393 'average_response_time': average_response_time,
394 'average_user_message_content_length': average_user_message_content_length,
395 'average_assistant_message_content_length': average_assistant_message_content_length,
396 'tags': chat.meta.get('tags', []),
397 'last_message_at': message_list[-1].get('timestamp', None),
398 'updated_at': chat.updated_at,
399 'created_at': chat.created_at,
400 }
401 )
402 except Exception as e:
403 pass
405 return ChatUsageStatsListResponse(items=chat_stats, total=total)
407 except Exception as e:
408 log.exception(e)
409 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
412############################
413# GetChatStatsExport
414############################
417CHAT_EXPORT_PAGE_ITEM_COUNT = 10
420class ChatStatsExportList(BaseModel):
421 type: str = 'chats'
422 items: list[ChatStatsExport]
423 total: int
424 page: int
427def _process_chat_for_export(chat) -> ChatStatsExport | None:
428 try:
430 def get_message_content_length(message):
431 content = message.get('content', '')
432 if isinstance(content, str): 432 ↛ 434line 432 didn't jump to line 434 because the condition on line 432 was always true
433 return len(content)
434 elif isinstance(content, list):
435 return sum(len(item.get('text', '')) for item in content if item.get('type') == 'text')
436 return 0
438 messages_map = chat.chat.get('history', {}).get('messages', {})
439 message_id = chat.chat.get('history', {}).get('currentId')
441 history_models = {}
442 history_message_count = len(messages_map)
443 history_user_messages = []
444 history_assistant_messages = []
446 export_messages = {}
447 for key, message in messages_map.items():
448 try:
449 content_length = get_message_content_length(message)
451 # Extract rating safely
452 rating = message.get('annotation', {}).get('rating')
453 tags = message.get('annotation', {}).get('tags')
455 message_stat = MessageStats(
456 id=message.get('id'),
457 role=message.get('role'),
458 model=message.get('model'),
459 timestamp=message.get('timestamp'),
460 content_length=content_length,
461 token_count=None, # Populate if available, e.g. message.get("info", {}).get("token_count")
462 rating=rating,
463 tags=tags,
464 )
466 export_messages[key] = message_stat
468 # --- Aggregation Logic (copied/adapted from usage stats) ---
469 role = message.get('role', '')
470 if role == 'user': 470 ↛ 471line 470 didn't jump to line 471 because the condition on line 470 was never true
471 history_user_messages.append(message)
472 elif role == 'assistant': 472 ↛ 447line 472 didn't jump to line 447 because the condition on line 472 was always true
473 history_assistant_messages.append(message)
474 model = message.get('model')
475 if model: 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 if model not in history_models:
477 history_models[model] = 0
478 history_models[model] += 1
479 except Exception as e:
480 log.debug('Error processing message %s: %s', key, e)
481 continue
483 # Calculate Averages
484 average_user_message_content_length = (
485 sum(get_message_content_length(m) for m in history_user_messages) / len(history_user_messages)
486 if history_user_messages
487 else 0
488 )
490 average_assistant_message_content_length = (
491 sum(get_message_content_length(m) for m in history_assistant_messages) / len(history_assistant_messages)
492 if history_assistant_messages
493 else 0
494 )
496 # Response Times
497 response_times = []
498 for message in history_assistant_messages:
499 user_message_id = message.get('parentId', None)
500 if user_message_id and user_message_id in messages_map: 500 ↛ 501line 500 didn't jump to line 501 because the condition on line 500 was never true
501 user_message = messages_map[user_message_id]
502 # Ensure timestamps exist
503 t1 = message.get('timestamp')
504 t0 = user_message.get('timestamp')
505 if t1 and t0:
506 response_times.append(t1 - t0)
508 average_response_time = sum(response_times) / len(response_times) if response_times else 0
510 # Current Message List Logic (Main path)
511 message_list = get_message_list(messages_map, message_id)
512 message_count = len(message_list)
513 models = {}
514 for message in reversed(message_list):
515 if message.get('role') == 'assistant': 515 ↛ 514line 515 didn't jump to line 514 because the condition on line 515 was always true
516 model = message.get('model')
517 if model: 517 ↛ 518line 517 didn't jump to line 518 because the condition on line 517 was never true
518 if model not in models:
519 models[model] = 0
520 models[model] += 1
522 # Construct Aggregate Stats
523 stats = AggregateChatStats(
524 average_response_time=average_response_time,
525 average_user_message_content_length=average_user_message_content_length,
526 average_assistant_message_content_length=average_assistant_message_content_length,
527 models=models,
528 message_count=message_count,
529 history_models=history_models,
530 history_message_count=history_message_count,
531 history_user_message_count=len(history_user_messages),
532 history_assistant_message_count=len(history_assistant_messages),
533 )
535 # Construct Chat Body
536 chat_body = ChatBody(history=ChatHistoryStats(messages=export_messages, currentId=message_id))
538 return ChatStatsExport(
539 id=chat.id,
540 user_id=chat.user_id,
541 created_at=chat.created_at,
542 updated_at=chat.updated_at,
543 tags=chat.meta.get('tags', []),
544 stats=stats,
545 chat=chat_body,
546 )
547 except Exception as e:
548 log.exception(f'Error exporting stats for chat {chat.id}: {e}')
549 return None
552async def calculate_chat_stats(user_id, skip=0, limit=10, filter=None):
553 if filter is None: 553 ↛ 554line 553 didn't jump to line 554 because the condition on line 553 was never true
554 filter = {}
556 result = await Chats.get_chats_by_user_id(
557 user_id,
558 skip=skip,
559 limit=limit,
560 filter=filter,
561 )
563 chat_stats_export_list = []
564 for chat in result.items:
565 chat_stat = _process_chat_for_export(chat)
566 if chat_stat: 566 ↛ 564line 566 didn't jump to line 564 because the condition on line 566 was always true
567 chat_stats_export_list.append(chat_stat)
569 return chat_stats_export_list, result.total
572async def generate_chat_stats_jsonl_generator(user_id, filter):
573 """
574 Async generator for streaming chat stats export.
576 NOTE: We intentionally do NOT pass a shared db session here. Instead, we let
577 each batch create its own short-lived session via get_async_db_context(None).
578 This is critical for SQLite in low-resource environments because:
579 1. SQLite uses file-level locking
580 2. Holding a session open for the entire streaming duration blocks other requests
581 3. Short-lived sessions release locks between batches, allowing other operations
582 """
583 skip = 0
584 limit = CHAT_EXPORT_PAGE_ITEM_COUNT
586 while True:
587 # Each batch gets its own session that closes after the query
588 result = await Chats.get_chats_by_user_id(
589 user_id,
590 filter=filter,
591 skip=skip,
592 limit=limit,
593 db=None, # Let get_async_db_context create a fresh session per batch
594 )
595 if not result.items:
596 break
598 for chat in result.items:
599 try:
600 chat_stat = _process_chat_for_export(chat)
601 if chat_stat: 601 ↛ 598line 601 didn't jump to line 598 because the condition on line 601 was always true
602 yield chat_stat.model_dump_json() + '\n'
603 except Exception as e:
604 log.exception(f'Error processing chat {chat.id}: {e}')
606 skip += limit
609@router.get('/stats/export', response_model=ChatStatsExportList)
610async def export_chat_stats(
611 request: Request,
612 updated_at: int | None = None,
613 page: int | None = 1,
614 stream: bool = False,
615 user=Depends(get_verified_user),
616):
617 # Check if the user has permission to share/export chats
618 if (user.role != 'admin') and (not await Config.get('ui.enable_community_sharing')): 618 ↛ 619line 618 didn't jump to line 619 because the condition on line 618 was never true
619 raise HTTPException(
620 status_code=status.HTTP_401_UNAUTHORIZED,
621 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
622 )
624 try:
625 # Fetch chats with date filtering
626 filter = {'order_by': 'updated_at', 'direction': 'asc'}
628 if updated_at:
629 filter['updated_at'] = updated_at
631 if stream:
632 return StreamingResponse(
633 generate_chat_stats_jsonl_generator(user.id, filter),
634 media_type='application/x-ndjson',
635 headers={'Content-Disposition': f'attachment; filename=chat-stats-export-{user.id}.jsonl'},
636 )
637 else:
638 limit = CHAT_EXPORT_PAGE_ITEM_COUNT
639 skip = (page - 1) * limit
641 chat_stats_export_list, total = await calculate_chat_stats(user.id, skip, limit, filter)
643 return ChatStatsExportList(items=chat_stats_export_list, total=total, page=page)
645 except Exception as e:
646 log.debug('Error exporting chat stats: %s', e)
647 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
650############################
651# GetSingleChatStatsExport
652############################
655@router.get('/stats/export/{chat_id}', response_model=ChatStatsExport | None)
656async def export_single_chat_stats(
657 request: Request,
658 chat_id: str,
659 user=Depends(get_verified_user),
660 db: AsyncSession = Depends(get_async_session),
661):
662 """
663 Export stats for exactly one chat by ID.
664 Returns ChatStatsExport for the specified chat.
665 """
666 # Check if the user has permission to share/export chats
667 if (user.role != 'admin') and (not await Config.get('ui.enable_community_sharing')): 667 ↛ 668line 667 didn't jump to line 668 because the condition on line 667 was never true
668 raise HTTPException(
669 status_code=status.HTTP_401_UNAUTHORIZED,
670 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
671 )
673 try:
674 chat = await Chats.get_chat_by_id(chat_id, db=db)
676 if not chat:
677 raise HTTPException(
678 status_code=status.HTTP_404_NOT_FOUND,
679 detail=ERROR_MESSAGES.NOT_FOUND,
680 )
682 # Verify the chat belongs to the user (unless admin)
683 if chat.user_id != user.id and user.role != 'admin': 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true
684 raise HTTPException(
685 status_code=status.HTTP_401_UNAUTHORIZED,
686 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
687 )
689 # Process the chat for export (pure computation, no DB)
690 chat_stats = _process_chat_for_export(chat)
692 if not chat_stats: 692 ↛ 693line 692 didn't jump to line 693 because the condition on line 692 was never true
693 raise HTTPException(
694 status_code=status.HTTP_400_BAD_REQUEST,
695 detail='Failed to process chat stats',
696 )
698 return chat_stats
700 except HTTPException:
701 raise
702 except Exception as e:
703 log.debug('Error exporting single chat stats: %s', e)
704 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
707@router.delete('/', response_model=bool)
708async def delete_all_user_chats(
709 request: Request,
710 user=Depends(get_verified_user),
711 db: AsyncSession = Depends(get_async_session),
712):
713 if user.role == 'user' and not await has_permission(user.id, 'chat.delete', await Config.get('user.permissions')): 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true
714 raise HTTPException(
715 status_code=status.HTTP_401_UNAUTHORIZED,
716 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
717 )
719 result = await Chats.delete_chats_by_user_id(user.id, db=db)
720 if result: 720 ↛ 728line 720 didn't jump to line 728 because the condition on line 720 was always true
721 await publish_event(
722 request,
723 EVENTS.CHAT_DELETED_ALL,
724 actor=user,
725 subject_id=user.id,
726 subject_type='user',
727 )
728 return result
731############################
732# GetUserChatList
733############################
736@router.get('/list/user/{user_id}', response_model=list[ChatTitleIdResponse])
737async def get_user_chat_list_by_user_id(
738 request: Request,
739 user_id: str,
740 page: int | None = None,
741 query: str | None = None,
742 order_by: str | None = None,
743 direction: str | None = None,
744 user=Depends(get_admin_user),
745 db: AsyncSession = Depends(get_async_session),
746):
747 """List chat summaries for a given user (admin-only endpoint)."""
748 if not ENABLE_ADMIN_CHAT_ACCESS: 748 ↛ 749line 748 didn't jump to line 749 because the condition on line 748 was never true
749 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
751 effective_page = page if page is not None else 1
752 limit = 60
753 skip = (effective_page - 1) * limit
755 filter = {}
756 if query:
757 filter['query'] = query
758 if order_by:
759 filter['order_by'] = order_by
760 if direction:
761 filter['direction'] = direction
763 chats = await Chats.get_chat_list_by_user_id(
764 user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db
765 )
766 return await add_active_state_to_chat_list(request, chats)
769############################
770# CreateNewChat
771############################
774@router.post('/new', response_model=ChatResponse | None)
775async def create_new_chat(
776 request: Request,
777 form_data: ChatForm,
778 user=Depends(get_verified_user),
779 db: AsyncSession = Depends(get_async_session),
780):
781 if form_data.folder_id is not None and not await has_folder_write_access(user.id, form_data.folder_id, db=db):
782 raise HTTPException(
783 status_code=status.HTTP_404_NOT_FOUND,
784 detail=ERROR_MESSAGES.NOT_FOUND,
785 )
787 try:
788 chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db)
789 await publish_event(
790 request,
791 EVENTS.CHAT_CREATED,
792 actor=user,
793 subject_id=chat.id,
794 data={'title': chat.title, 'folder_id': chat.folder_id},
795 )
796 return ChatResponse.model_validate(chat, from_attributes=True)
797 except Exception as e:
798 log.exception(e)
799 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
802############################
803# ImportChats
804############################
807@router.post('/import', response_model=list[ChatResponse])
808async def import_chats(
809 request: Request,
810 form_data: ChatsImportForm,
811 user=Depends(get_verified_user),
812 db: AsyncSession = Depends(get_async_session),
813):
814 await require_chat_import_permission(request, user, db)
816 try:
817 chats = await Chats.import_chats(user.id, form_data.chats, db=db)
818 await publish_event(
819 request,
820 EVENTS.CHAT_IMPORTED,
821 actor=user,
822 subject_type='chat.import',
823 data={'count': len(chats), 'chat_ids': [chat.id for chat in chats]},
824 )
825 return chats
826 except Exception as e:
827 log.exception(e)
828 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
831############################
832# ChatConfig
833############################
836@router.get('/config', response_model=ChatConfigForm)
837async def get_chat_config(user=Depends(get_admin_user)):
838 return await get_chat_config_values()
841@router.post('/config', response_model=ChatConfigForm)
842async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)):
843 threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD))
844 token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold))
845 retention_percentage = min(50, max(10, int(form_data.CONTEXT_COMPACTION_RETENTION_PERCENTAGE)))
846 await Config.upsert(
847 chat_config_updates(
848 {
849 **form_data.model_dump(),
850 'CONTEXT_COMPACTION_MODEL': form_data.CONTEXT_COMPACTION_MODEL or '',
851 'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold,
852 'CONTEXT_COMPACTION_TOKEN_CAP': token_cap,
853 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage,
854 }
855 )
856 )
857 return await get_chat_config_values()
860############################
861# GetChats
862############################
865@router.get('/search', response_model=list[ChatTitleIdResponse])
866async def search_user_chats(
867 request: Request,
868 text: str,
869 page: int | None = None,
870 user=Depends(get_verified_user),
871 db: AsyncSession = Depends(get_async_session),
872):
873 if page is None:
874 page = 1
876 limit = 60
877 skip = (page - 1) * limit
879 search_text = chat_search_content_text(text)
880 chat_list = []
881 for chat in await Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db):
882 # Explicit fields: model_dump() would deep-copy the entire chat blob per row
883 chat_list.append(
884 ChatTitleIdResponse(
885 id=chat.id,
886 title=chat.title,
887 updated_at=chat.updated_at,
888 created_at=chat.created_at,
889 last_read_at=chat.last_read_at,
890 snippet=chat_search_snippet(chat.chat, search_text),
891 archived=chat.archived,
892 )
893 )
895 # Delete tag if no chat is found
896 words = text.strip().split(' ')
897 if page == 1 and len(words) == 1 and words[0].startswith('tag:'): 897 ↛ 898line 897 didn't jump to line 898 because the condition on line 897 was never true
898 tag_id = words[0].replace('tag:', '')
899 if len(chat_list) == 0:
900 if await Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db):
901 log.debug('deleting tag: %s', tag_id)
902 await Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db)
904 return await add_active_state_to_chat_list(request, chat_list)
907############################
908# GetChatsByFolderId
909############################
912@router.get('/folder/{folder_id}', response_model=list[ChatResponse])
913async def get_chats_by_folder_id(
914 folder_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
915):
916 folder_ids = [folder_id]
917 children_folders = await Folders.get_children_folders_by_id_and_user_id(folder_id, user.id, db=db)
918 if children_folders: 918 ↛ 919line 918 didn't jump to line 919 because the condition on line 918 was never true
919 folder_ids.extend([folder.id for folder in children_folders])
921 return [
922 ChatResponse.model_validate(chat, from_attributes=True)
923 for chat in await Chats.get_chats_by_folder_ids_and_user_id(folder_ids, user.id, db=db)
924 ]
927@router.get('/folder/{folder_id}/list', response_model=list[ChatTitleIdResponse])
928async def get_chat_list_by_folder_id(
929 request: Request,
930 folder_id: str,
931 page: int | None = 1,
932 sort_by: str = 'unread_updated_at',
933 sort_dir: str = 'desc',
934 user=Depends(get_verified_user),
935 db: AsyncSession = Depends(get_async_session),
936):
937 try:
938 limit = 10
939 skip = (page - 1) * limit
941 chats = await Chats.get_chats_by_folder_id_and_user_id(
942 folder_id,
943 user.id,
944 skip=skip,
945 limit=limit,
946 sort_by=sort_by,
947 sort_dir=sort_dir,
948 db=db,
949 )
950 return await add_active_state_to_chat_list(request, chats)
952 except Exception as e:
953 log.exception(e)
954 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
957############################
958# GetPinnedChats
959############################
962@router.get('/pinned', response_model=list[ChatTitleIdResponse])
963async def get_user_pinned_chats(
964 request: Request, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
965):
966 chats = await Chats.get_pinned_chats_by_user_id(user.id, db=db)
967 return await add_active_state_to_chat_list(request, chats)
970############################
971# GetChats
972############################
974CHAT_EXPORT_BATCH_SIZE = 100
977async def generate_chat_export_ndjson(user_id: str):
978 """
979 Async generator that streams all user chats as NDJSON (one JSON object per line).
981 Uses short-lived DB sessions per batch to avoid holding locks for the
982 entire duration, which is critical for SQLite environments.
983 """
984 skip = 0
986 while True:
987 result = await Chats.get_chats_by_user_id(
988 user_id,
989 skip=skip,
990 limit=CHAT_EXPORT_BATCH_SIZE,
991 db=None,
992 )
993 if not result.items:
994 break
996 for chat in result.items:
997 try:
998 yield ChatResponse.model_validate(chat, from_attributes=True).model_dump_json() + '\n'
999 except Exception as e:
1000 log.exception(f'Error serializing chat {chat.id}: {e}')
1002 if len(result.items) < CHAT_EXPORT_BATCH_SIZE: 1002 ↛ 1005line 1002 didn't jump to line 1005 because the condition on line 1002 was always true
1003 break
1005 skip += CHAT_EXPORT_BATCH_SIZE
1008@router.get('/all')
1009async def get_user_chats(user=Depends(get_verified_user)):
1010 return StreamingResponse(
1011 generate_chat_export_ndjson(user.id),
1012 media_type='application/x-ndjson',
1013 )
1016############################
1017# GetArchivedChats
1018############################
1021@router.get('/all/archived', response_model=list[ChatResponse])
1022async def get_user_archived_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
1023 return [
1024 ChatResponse.model_validate(chat, from_attributes=True)
1025 for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db)
1026 ]
1029############################
1030# GetAllTags
1031############################
1034@router.get('/all/tags', response_model=list[TagModel])
1035async def get_all_user_tags(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
1036 try:
1037 tags = await Tags.get_tags_by_user_id(user.id, db=db)
1038 return tags
1039 except Exception as e:
1040 log.exception(e)
1041 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
1044############################
1045# GetAllChatsInDB
1046############################
1049@router.get('/all/db', response_model=list[ChatResponse])
1050async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
1051 if not ENABLE_ADMIN_EXPORT: 1051 ↛ 1052line 1051 didn't jump to line 1052 because the condition on line 1051 was never true
1052 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
1053 return [ChatResponse.model_validate(chat, from_attributes=True) for chat in await Chats.get_chats(db=db)]
1056############################
1057# GetArchivedChats
1058############################
1061@router.get('/archived', response_model=list[ChatTitleIdResponse])
1062async def get_archived_session_user_chat_list(
1063 request: Request,
1064 page: int | None = None,
1065 query: str | None = None,
1066 order_by: str | None = None,
1067 direction: str | None = None,
1068 user=Depends(get_verified_user),
1069 db: AsyncSession = Depends(get_async_session),
1070):
1071 if page is None:
1072 page = 1
1074 limit = 60
1075 skip = (page - 1) * limit
1077 filter = {}
1078 if query:
1079 filter['query'] = query
1080 if order_by:
1081 filter['order_by'] = order_by
1082 if direction:
1083 filter['direction'] = direction
1085 chats = await Chats.get_archived_chat_list_by_user_id(
1086 user.id,
1087 filter=filter,
1088 skip=skip,
1089 limit=limit,
1090 db=db,
1091 )
1092 return await add_active_state_to_chat_list(request, chats)
1095############################
1096# GetArchivedChatsCount
1097############################
1100@router.get('/archived/count', response_model=int)
1101async def get_archived_session_user_chat_count(
1102 user=Depends(get_verified_user),
1103 db: AsyncSession = Depends(get_async_session),
1104):
1105 return await Chats.count_archived_chats_by_user_id(user.id, db=db)
1108############################
1109# ArchiveAllChats
1110############################
1113@router.post('/archive/all', response_model=bool)
1114async def archive_all_chats(
1115 request: Request, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
1116):
1117 result = await Chats.archive_all_chats_by_user_id(user.id, db=db)
1118 if result: 1118 ↛ 1120line 1118 didn't jump to line 1120 because the condition on line 1118 was always true
1119 await publish_event(request, EVENTS.CHAT_ARCHIVED, actor=user, subject_id=user.id, subject_type='user')
1120 return result
1123############################
1124# UnarchiveAllChats
1125############################
1128@router.post('/unarchive/all', response_model=bool)
1129async def unarchive_all_chats(
1130 request: Request, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
1131):
1132 result = await Chats.unarchive_all_chats_by_user_id(user.id, db=db)
1133 if result: 1133 ↛ 1135line 1133 didn't jump to line 1135 because the condition on line 1133 was always true
1134 await publish_event(request, EVENTS.CHAT_UNARCHIVED, actor=user, subject_id=user.id, subject_type='user')
1135 return result
1138############################
1139# UnshareAllChats
1140############################
1143@router.delete('/share/all', response_model=bool)
1144async def unshare_all_chats(
1145 request: Request, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
1146):
1147 # Collect chat_ids that have shares so we can clear share_id and access grants
1148 shared_list = await SharedChats.get_by_user_id(user.id, db=db)
1149 chat_ids = [s.chat_id for s in shared_list]
1151 # Delete all shared_chat rows for this user
1152 result = await SharedChats.delete_all_by_user_id(user.id, db=db)
1154 # Clear share_id on the original chats and remove access grants
1155 for chat_id in chat_ids: 1155 ↛ 1156line 1155 didn't jump to line 1156 because the loop on line 1155 never started
1156 await Chats.update_chat_share_id_by_id(chat_id, None, db=db)
1157 await AccessGrants.set_access_grants('shared_chat', chat_id, [], db=db)
1159 if result: 1159 ↛ 1168line 1159 didn't jump to line 1168 because the condition on line 1159 was always true
1160 await publish_event(
1161 request,
1162 EVENTS.CHAT_UNSHARED,
1163 actor=user,
1164 subject_id=user.id,
1165 subject_type='user',
1166 data={'count': len(chat_ids), 'chat_ids': chat_ids},
1167 )
1168 return result
1171@router.get('/shared', response_model=list[SharedChatResponse])
1172async def get_shared_session_user_chat_list(
1173 page: int | None = None,
1174 query: str | None = None,
1175 order_by: str | None = None,
1176 direction: str | None = None,
1177 user=Depends(get_verified_user),
1178 db: AsyncSession = Depends(get_async_session),
1179):
1180 if page is None:
1181 page = 1
1183 limit = 60
1184 skip = (page - 1) * limit
1186 filter = {}
1187 if query:
1188 filter['query'] = query
1189 if order_by:
1190 filter['order_by'] = order_by
1191 if direction:
1192 filter['direction'] = direction
1194 return await SharedChats.get_by_user_id(
1195 user.id,
1196 filter=filter,
1197 skip=skip,
1198 limit=limit,
1199 db=db,
1200 )
1203############################
1204# GetSharedChatById
1205############################
1208@router.get('/share/{share_id}', response_model=ChatResponse | None)
1209async def get_shared_chat_by_id(
1210 share_id: str, user=Depends(get_optional_verified_user), db: AsyncSession = Depends(get_async_session)
1211):
1212 shared = await SharedChats.get_by_id(share_id, db=db)
1213 if shared:
1214 if await is_open_shared_chat(shared, db=db) or (
1215 user is not None and await can_read_shared_chat(user, shared, db=db)
1216 ):
1217 chat = await Chats.get_chat_by_share_id(share_id, db=db)
1218 if chat: 1218 ↛ 1221line 1218 didn't jump to line 1221 because the condition on line 1218 was always true
1219 return ChatResponse.model_validate(chat, from_attributes=True)
1221 raise HTTPException(
1222 status_code=status.HTTP_401_UNAUTHORIZED,
1223 detail=ERROR_MESSAGES.ACCESS_PROHIBITED if user else ERROR_MESSAGES.INVALID_TOKEN,
1224 )
1226 # Fallback: admins can also access any chat directly by chat ID
1227 chat = None
1228 if user is not None and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
1229 chat = await Chats.get_chat_by_id(share_id, db=db)
1230 if chat: 1230 ↛ 1231line 1230 didn't jump to line 1231 because the condition on line 1230 was never true
1231 return ChatResponse.model_validate(chat, from_attributes=True)
1233 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
1236############################
1237# GetChatsByTags
1238############################
1241class TagForm(BaseModel):
1242 name: str
1245class TagFilterForm(TagForm):
1246 skip: int | None = 0
1247 limit: int | None = 50
1250@router.post('/tags', response_model=list[ChatTitleIdResponse])
1251async def get_user_chat_list_by_tag_name(
1252 request: Request,
1253 form_data: TagFilterForm,
1254 user=Depends(get_verified_user),
1255 db: AsyncSession = Depends(get_async_session),
1256):
1257 chats = await Chats.get_chat_list_by_user_id_and_tag_name(
1258 user.id, form_data.name, form_data.skip, form_data.limit, db=db
1259 )
1260 if len(chats) == 0:
1261 await Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db)
1263 return await add_active_state_to_chat_list(request, chats)
1266############################
1267# CompactChat
1268############################
1271@router.post('/{id}/compact')
1272async def compact_chat_by_id(
1273 request: Request,
1274 id: str,
1275 form_data: CompactChatForm | None = None,
1276 user=Depends(get_verified_user),
1277 db: AsyncSession = Depends(get_async_session),
1278):
1279 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
1280 if not chat:
1281 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
1283 if await has_active_tasks(request.app.state.redis, id): 1283 ↛ 1284line 1283 didn't jump to line 1284 because the condition on line 1283 was never true
1284 raise HTTPException(
1285 status_code=status.HTTP_409_CONFLICT,
1286 detail='Wait for the current response to finish before compacting.',
1287 )
1289 if not request.app.state.MODELS: 1289 ↛ 1292line 1289 didn't jump to line 1292 because the condition on line 1289 was always true
1290 await get_all_models(request, user=user)
1292 history = (chat.chat or {}).get('history') or {}
1293 messages_map = await Chats.get_messages_map_by_chat_id(id)
1294 current_message_id = chat.current_message_id or history.get('currentId')
1295 message_list = get_message_list(messages_map or history.get('messages') or {}, current_message_id)
1296 model_id = (form_data.model if form_data else None) or next(
1297 (message.get('model') for message in reversed(message_list) if message.get('model')),
1298 None,
1299 )
1301 if not model_id:
1302 chat_models = (chat.chat or {}).get('models') or []
1303 model_id = chat_models[0] if chat_models else None
1304 if not model_id:
1305 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No model found for context compaction.')
1307 result = await compact_chat_branch(request, user, chat, model_id, request.app.state.MODELS)
1308 result['context_usage'] = await get_chat_context_usage(chat, model_id)
1309 if result.get('compacted'): 1309 ↛ 1310line 1309 didn't jump to line 1310 because the condition on line 1309 was never true
1310 await publish_event(
1311 request,
1312 EVENTS.CHAT_COMPACTED,
1313 actor=user,
1314 subject_id=id,
1315 data={'dropped_messages': result.get('dropped_messages')},
1316 )
1317 return result
1320############################
1321# GetChatById
1322############################
1325@router.get('/{id}', response_model=ChatResponse | None)
1326async def get_chat_by_id(
1327 id: str,
1328 request: Request,
1329 user=Depends(get_verified_user),
1330 db: AsyncSession = Depends(get_async_session),
1331):
1332 chat = await Chats.get_chat_by_id_for_user(
1333 id,
1334 user,
1335 db=db,
1336 )
1338 if chat:
1339 data = ChatResponse.model_validate(chat, from_attributes=True).model_dump()
1340 data = overlay_response_streams(
1341 data,
1342 await get_response_streams_by_chat_id(request.app.state.redis, id),
1343 )
1344 data['context_usage'] = await get_chat_context_usage(chat)
1345 return data
1347 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
1350############################
1351# UpdateChatById
1352############################
1355@router.post('/{id}', response_model=ChatResponse | None)
1356async def update_chat_by_id(
1357 request: Request,
1358 id: str,
1359 form_data: ChatForm,
1360 user=Depends(get_verified_user),
1361 db: AsyncSession = Depends(get_async_session),
1362):
1363 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
1364 if chat:
1365 touch = 'history' in form_data.chat or 'messages' in form_data.chat
1366 chat = await Chats.update_chat_by_id(id, form_data.chat, db=db, touch=touch)
1367 if form_data.variables is not None:
1368 chat = (
1369 await Chats.update_chat_variables_by_id(
1370 id,
1371 form_data.variables,
1372 db=db,
1373 touch=False,
1374 )
1375 or chat
1376 )
1378 # Reconcile chat_message rows without inferring deletes from missing IDs.
1379 # Message deletion has its own endpoint below.
1380 messages = ((chat.chat or {}).get('history') or {}).get('messages') or {}
1381 if messages:
1382 await Chats.reconcile_messages_by_chat_id(id, user.id, messages)
1384 await publish_event(
1385 request,
1386 EVENTS.CHAT_UPDATED,
1387 actor=user,
1388 subject_id=id,
1389 data={'title': chat.title},
1390 )
1391 return ChatResponse.model_validate(chat, from_attributes=True)
1392 else:
1393 raise HTTPException(
1394 status_code=status.HTTP_401_UNAUTHORIZED,
1395 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1396 )
1399############################
1400# UpdateChatMessageById
1401############################
1402class MessageForm(BaseModel):
1403 content: str
1406@router.post('/{id}/messages/{message_id}', response_model=ChatResponse | None)
1407async def update_chat_message_by_id(
1408 request: Request,
1409 id: str,
1410 message_id: str,
1411 form_data: MessageForm,
1412 user=Depends(get_verified_user),
1413 db: AsyncSession = Depends(get_async_session),
1414):
1415 chat = await Chats.get_chat_by_id(id, db=db)
1417 if not chat:
1418 raise HTTPException(
1419 status_code=status.HTTP_401_UNAUTHORIZED,
1420 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1421 )
1423 if chat.user_id != user.id and user.role != 'admin': 1423 ↛ 1424line 1423 didn't jump to line 1424 because the condition on line 1423 was never true
1424 raise HTTPException(
1425 status_code=status.HTTP_401_UNAUTHORIZED,
1426 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1427 )
1429 chat = await Chats.upsert_message_to_chat_by_id_and_message_id(
1430 id,
1431 message_id,
1432 {
1433 'content': form_data.content,
1434 },
1435 )
1437 event_emitter = await get_event_emitter(
1438 {
1439 'user_id': chat.user_id,
1440 'chat_id': id,
1441 'message_id': message_id,
1442 },
1443 False,
1444 )
1446 if event_emitter: 1446 ↛ 1458line 1446 didn't jump to line 1458 because the condition on line 1446 was always true
1447 await event_emitter(
1448 {
1449 'type': 'chat:message',
1450 'data': {
1451 'chat_id': id,
1452 'message_id': message_id,
1453 'content': form_data.content,
1454 },
1455 }
1456 )
1458 await publish_event(
1459 request,
1460 EVENTS.MESSAGE_UPDATED,
1461 actor=user,
1462 subject_id=message_id,
1463 data={'chat_id': id, 'content_preview': form_data.content[:300]},
1464 )
1465 return ChatResponse.model_validate(chat, from_attributes=True)
1468@router.delete('/{id}/messages/{message_id}', response_model=ChatResponse | None)
1469async def delete_chat_message_by_id(
1470 request: Request,
1471 id: str,
1472 message_id: str,
1473 user=Depends(get_verified_user),
1474 db: AsyncSession = Depends(get_async_session),
1475):
1476 chat = await Chats.get_chat_by_id(id, db=db)
1478 if not chat:
1479 raise HTTPException(
1480 status_code=status.HTTP_401_UNAUTHORIZED,
1481 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1482 )
1484 if chat.user_id != user.id and user.role != 'admin': 1484 ↛ 1485line 1484 didn't jump to line 1485 because the condition on line 1484 was never true
1485 raise HTTPException(
1486 status_code=status.HTTP_401_UNAUTHORIZED,
1487 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1488 )
1490 chat = await Chats.delete_message_from_chat_by_id_and_message_id(id, message_id)
1491 if not chat: 1491 ↛ 1492line 1491 didn't jump to line 1492 because the condition on line 1491 was never true
1492 raise HTTPException(
1493 status_code=status.HTTP_401_UNAUTHORIZED,
1494 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1495 )
1497 await publish_event(
1498 request,
1499 EVENTS.MESSAGE_DELETED,
1500 actor=user,
1501 subject_id=message_id,
1502 data={'chat_id': id},
1503 )
1504 return ChatResponse.model_validate(chat, from_attributes=True)
1507############################
1508# SendChatMessageEventById
1509############################
1510class EventForm(BaseModel):
1511 type: str
1512 data: dict
1515@router.post('/{id}/messages/{message_id}/event', response_model=bool | None)
1516async def send_chat_message_event_by_id(
1517 request: Request,
1518 id: str,
1519 message_id: str,
1520 form_data: EventForm,
1521 user=Depends(get_verified_user),
1522 db: AsyncSession = Depends(get_async_session),
1523):
1524 chat = await Chats.get_chat_by_id(id, db=db)
1526 if not chat:
1527 raise HTTPException(
1528 status_code=status.HTTP_401_UNAUTHORIZED,
1529 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1530 )
1532 if chat.user_id != user.id and user.role != 'admin': 1532 ↛ 1533line 1532 didn't jump to line 1533 because the condition on line 1532 was never true
1533 raise HTTPException(
1534 status_code=status.HTTP_401_UNAUTHORIZED,
1535 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1536 )
1538 event_emitter = await get_event_emitter(
1539 {
1540 'user_id': chat.user_id,
1541 'chat_id': id,
1542 'message_id': message_id,
1543 }
1544 )
1546 try:
1547 if event_emitter: 1547 ↛ 1550line 1547 didn't jump to line 1550 because the condition on line 1547 was always true
1548 await event_emitter(form_data.model_dump())
1549 else:
1550 return False
1551 await publish_event(
1552 request,
1553 EVENTS.MESSAGE_EVENT_RECEIVED,
1554 actor=user,
1555 subject_id=message_id,
1556 data={'chat_id': id, 'event_type': form_data.type},
1557 )
1558 return True
1559 except Exception:
1560 return False
1563############################
1564# DeleteChatById
1565############################
1568@router.delete('/{id}', response_model=bool)
1569async def delete_chat_by_id(
1570 request: Request,
1571 id: str,
1572 user=Depends(get_verified_user),
1573 db: AsyncSession = Depends(get_async_session),
1574):
1575 # Authorize before any side effect: cancelling a chat's in-flight tasks must
1576 # not be reachable for a chat the caller may not delete.
1577 if user.role == 'admin': 1577 ↛ 1580line 1577 didn't jump to line 1580 because the condition on line 1577 was always true
1578 chat = await Chats.get_chat_by_id(id, db=db)
1579 else:
1580 if not await has_permission(user.id, 'chat.delete', await Config.get('user.permissions')):
1581 raise HTTPException(
1582 status_code=status.HTTP_401_UNAUTHORIZED,
1583 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1584 )
1585 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
1587 if not chat:
1588 raise HTTPException(
1589 status_code=status.HTTP_404_NOT_FOUND,
1590 detail=ERROR_MESSAGES.NOT_FOUND,
1591 )
1593 # Cancel any in-flight LLM tasks (streaming, title/tags generation) before
1594 # deleting the chat to prevent orphaned requests.
1595 await stop_item_tasks(request.app.state.redis, id)
1596 await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), chat.user_id, threshold=1, db=db)
1598 # Cascade to internal child chats spawned from this one.
1599 for child_id in await Chats.get_internal_chat_ids_by_parent_id(id, chat.user_id): 1599 ↛ 1600line 1599 didn't jump to line 1600 because the loop on line 1599 never started
1600 await stop_item_tasks(request.app.state.redis, child_id)
1601 await Chats.delete_chat_by_id_and_user_id(child_id, chat.user_id)
1603 if user.role == 'admin': 1603 ↛ 1606line 1603 didn't jump to line 1606 because the condition on line 1603 was always true
1604 result = await Chats.delete_chat_by_id(id, db=db)
1605 else:
1606 result = await Chats.delete_chat_by_id_and_user_id(id, user.id, db=db)
1608 if result: 1608 ↛ 1616line 1608 didn't jump to line 1616 because the condition on line 1608 was always true
1609 await publish_event(
1610 request,
1611 EVENTS.CHAT_DELETED,
1612 actor=user,
1613 subject_id=id,
1614 data={'owner_id': chat.user_id},
1615 )
1616 return result
1619############################
1620# GetPinnedStatusById
1621############################
1624@router.get('/{id}/pinned', response_model=bool | None)
1625async def get_pinned_status_by_id(
1626 id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
1627):
1628 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
1629 if chat:
1630 return chat.pinned
1631 else:
1632 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
1635############################
1636# PinChatById
1637############################
1640@router.post('/{id}/pin', response_model=ChatResponse | None)
1641async def pin_chat_by_id(
1642 request: Request, id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
1643):
1644 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
1645 if chat:
1646 chat = await Chats.toggle_chat_pinned_by_id(id, db=db)
1647 await publish_event(
1648 request,
1649 EVENTS.CHAT_PINNED if chat.pinned else EVENTS.CHAT_UNPINNED,
1650 actor=user,
1651 subject_id=id,
1652 subject_type='chat',
1653 )
1654 return chat
1655 else:
1656 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
1659############################
1660# CloneChat
1661############################
1664class CloneForm(BaseModel):
1665 title: str | None = None
1668class ForkForm(BaseModel):
1669 message_id: str | None = None
1672@router.post('/{id}/fork', response_model=ChatResponse | None)
1673async def fork_chat_by_id(
1674 request: Request,
1675 id: str,
1676 form_data: ForkForm | None = None,
1677 user=Depends(get_verified_user),
1678 db: AsyncSession = Depends(get_async_session),
1679):
1680 await require_chat_import_permission(request, user, db)
1682 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
1683 if not chat:
1684 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
1686 if await has_active_tasks(request.app.state.redis, id): 1686 ↛ 1687line 1686 didn't jump to line 1687 because the condition on line 1686 was never true
1687 raise HTTPException(
1688 status_code=status.HTTP_409_CONFLICT,
1689 detail='Wait for the current response to finish before forking.',
1690 )
1692 history = (chat.chat or {}).get('history') or {}
1693 messages_map = await Chats.get_messages_map_by_chat_id(id) or history.get('messages') or {}
1695 source_message_id = (
1696 (form_data.message_id if form_data else None) or chat.current_message_id or history.get('currentId')
1697 )
1698 if not source_message_id:
1699 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='chat has no messages to fork')
1701 try:
1702 fork_history, fork_messages = build_fork_history(messages_map, source_message_id)
1703 except ValueError as exc:
1704 detail = str(exc)
1705 raise HTTPException(
1706 status_code=status.HTTP_404_NOT_FOUND if detail == 'message not found' else status.HTTP_400_BAD_REQUEST,
1707 detail=detail,
1708 ) from exc
1710 # An unfinished message is stale unless it is awaiting tool approval
1711 for message in fork_history['messages'].values():
1712 if message.get('role') != 'assistant' or message.get('done') is not False: 1712 ↛ 1715line 1712 didn't jump to line 1715 because the condition on line 1712 was always true
1713 continue
1715 output = message.get('output')
1716 if isinstance(output, list) and any(
1717 isinstance(item, dict)
1718 and item.get('type') == 'function_call'
1719 and item.get('status') in {'pending', 'queued', 'requires_approval'}
1720 for item in output
1721 ):
1722 continue
1724 message['done'] = True
1726 updated_chat = {**(chat.chat or {})}
1727 updated_chat.pop('currentId', None)
1728 updated_chat.update(
1729 {
1730 'originalChatId': chat.id,
1731 'branchPointMessageId': source_message_id,
1732 'title': f'{chat.title} (fork)',
1733 'history': fork_history,
1734 'messages': fork_messages,
1735 }
1736 )
1737 meta = {
1738 **(chat.meta or {}),
1739 'forked_from': chat.id,
1740 'forked_from_message_id': source_message_id,
1741 }
1743 # The source chat's folder may no longer be writable by the caller.
1744 folder_id = chat.folder_id
1745 if folder_id is not None and not await has_folder_write_access(user.id, folder_id, db=db): 1745 ↛ 1746line 1745 didn't jump to line 1746 because the condition on line 1745 was never true
1746 folder_id = None
1748 fork = await Chats.insert_new_chat(
1749 str(uuid4()),
1750 user.id,
1751 ChatForm(chat=updated_chat, folder_id=folder_id),
1752 db=db,
1753 internal_meta=meta,
1754 )
1756 if fork and chat.variables: 1756 ↛ 1757line 1756 didn't jump to line 1757 because the condition on line 1756 was never true
1757 fork = await Chats.update_chat_variables_by_id(fork.id, chat.variables, db=db, touch=False) or fork
1759 if not fork: 1759 ↛ 1760line 1759 didn't jump to line 1760 because the condition on line 1759 was never true
1760 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
1762 if chat.pinned:
1763 fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork
1765 await publish_event(
1766 request,
1767 EVENTS.CHAT_CLONED,
1768 actor=user,
1769 subject_id=fork.id,
1770 data={'original_chat_id': id, 'forked_from_message_id': source_message_id},
1771 )
1772 return ChatResponse.model_validate(fork, from_attributes=True)
1775@router.post('/{id}/clone', response_model=ChatResponse | None)
1776async def clone_chat_by_id(
1777 request: Request,
1778 form_data: CloneForm,
1779 id: str,
1780 user=Depends(get_verified_user),
1781 db: AsyncSession = Depends(get_async_session),
1782):
1783 await require_chat_import_permission(request, user, db)
1785 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
1786 if chat:
1787 updated_chat = {
1788 **chat.chat,
1789 'originalChatId': chat.id,
1790 'branchPointMessageId': chat.chat['history']['currentId'],
1791 'title': form_data.title if form_data.title else f'Clone of {chat.title}',
1792 }
1794 chats = await Chats.import_chats(
1795 user.id,
1796 [
1797 ChatImportForm(
1798 **{
1799 'chat': updated_chat,
1800 'meta': chat.meta,
1801 'variables': chat.variables or {},
1802 'pinned': chat.pinned,
1803 'folder_id': chat.folder_id,
1804 }
1805 )
1806 ],
1807 db=db,
1808 )
1810 if chats: 1810 ↛ 1821line 1810 didn't jump to line 1821 because the condition on line 1810 was always true
1811 chat = chats[0]
1812 await publish_event(
1813 request,
1814 EVENTS.CHAT_CLONED,
1815 actor=user,
1816 subject_id=chat.id,
1817 data={'original_chat_id': id},
1818 )
1819 return ChatResponse.model_validate(chat, from_attributes=True)
1820 else:
1821 raise HTTPException(
1822 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1823 detail=ERROR_MESSAGES.DEFAULT(),
1824 )
1825 else:
1826 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
1829############################
1830# CloneSharedChatById
1831############################
1834@router.post('/{id}/clone/shared', response_model=ChatResponse | None)
1835async def clone_shared_chat_by_id(
1836 request: Request,
1837 id: str,
1838 user=Depends(get_verified_user),
1839 db: AsyncSession = Depends(get_async_session),
1840):
1841 await require_chat_import_permission(request, user, db)
1843 chat = await Chats.get_chat_by_share_id(id, db=db)
1845 # Fallback: admins can also access any chat directly by chat ID
1846 if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: 1846 ↛ 1849line 1846 didn't jump to line 1849 because the condition on line 1846 was always true
1847 chat = await Chats.get_chat_by_id(id, db=db)
1849 if not chat:
1850 raise HTTPException(
1851 status_code=status.HTTP_401_UNAUTHORIZED,
1852 detail=ERROR_MESSAGES.NOT_FOUND,
1853 )
1855 # Enforce access grants (owner and admins bypass)
1856 shared = await SharedChats.get_by_id(id, db=db)
1857 if shared and user.role != 'admin' and shared.user_id != user.id: 1857 ↛ 1858line 1857 didn't jump to line 1858 because the condition on line 1857 was never true
1858 has_grant = await is_open_shared_chat(shared, db=db) or await AccessGrants.has_access(
1859 user_id=user.id,
1860 resource_type='shared_chat',
1861 resource_id=shared.chat_id,
1862 permission='read',
1863 db=db,
1864 )
1865 if not has_grant:
1866 raise HTTPException(
1867 status_code=status.HTTP_401_UNAUTHORIZED,
1868 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1869 )
1871 updated_chat = {
1872 **chat.chat,
1873 'originalChatId': chat.id,
1874 'branchPointMessageId': chat.chat['history']['currentId'],
1875 'title': f'Clone of {chat.title}',
1876 }
1878 chats = await Chats.import_chats(
1879 user.id,
1880 [
1881 ChatImportForm(
1882 **{
1883 'chat': updated_chat,
1884 'meta': chat.meta,
1885 'variables': chat.variables or {},
1886 'pinned': chat.pinned,
1887 'folder_id': chat.folder_id,
1888 }
1889 )
1890 ],
1891 db=db,
1892 )
1894 if chats: 1894 ↛ 1898line 1894 didn't jump to line 1898 because the condition on line 1894 was always true
1895 chat = chats[0]
1896 return ChatResponse.model_validate(chat, from_attributes=True)
1897 else:
1898 raise HTTPException(
1899 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1900 detail=ERROR_MESSAGES.DEFAULT(),
1901 )
1904############################
1905# ArchiveChat
1906############################
1909@router.post('/{id}/archive', response_model=ChatResponse | None)
1910async def archive_chat_by_id(
1911 request: Request,
1912 id: str,
1913 user=Depends(get_verified_user),
1914 db: AsyncSession = Depends(get_async_session),
1915):
1916 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
1917 if chat:
1918 chat = await Chats.toggle_chat_archive_by_id(id, db=db)
1920 tag_ids = chat.meta.get('tags', [])
1921 if chat.archived:
1922 # Cancel any in-flight LLM tasks before archiving
1923 await stop_item_tasks(request.app.state.redis, id)
1924 # Archived chats are excluded from count — clean up orphans
1925 await Chats.delete_orphan_tags_for_user(tag_ids, user.id, db=db)
1926 else:
1927 # Unarchived — ensure tag rows exist
1928 await Tags.ensure_tags_exist(tag_ids, user.id, db=db)
1930 await publish_event(
1931 request,
1932 EVENTS.CHAT_ARCHIVED if chat.archived else EVENTS.CHAT_UNARCHIVED,
1933 actor=user,
1934 subject_id=id,
1935 subject_type='chat',
1936 )
1937 return ChatResponse.model_validate(chat, from_attributes=True)
1938 else:
1939 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
1942# --- Share Chat ---
1945@router.post('/{id}/share', response_model=ChatResponse | None)
1946async def share_chat_by_id(
1947 request: Request,
1948 id: str,
1949 user=Depends(get_verified_user),
1950 db: AsyncSession = Depends(get_async_session),
1951):
1952 if user.role != 'admin' and not await has_permission(user.id, 'chat.share', await Config.get('user.permissions')): 1952 ↛ 1953line 1952 didn't jump to line 1953 because the condition on line 1952 was never true
1953 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
1955 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
1956 if not chat:
1957 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
1959 # If a share already exists, re-snapshot it
1960 if chat.share_id:
1961 shared = await SharedChats.update(chat.share_id, db=db)
1962 if shared: 1962 ↛ 1974line 1962 didn't jump to line 1974 because the condition on line 1962 was always true
1963 chat = await Chats.get_chat_by_id(id, db=db)
1964 await publish_event(
1965 request,
1966 EVENTS.CHAT_SHARED,
1967 actor=user,
1968 subject_id=id,
1969 data={'share_id': chat.share_id, 'updated': True},
1970 )
1971 return ChatResponse.model_validate(chat, from_attributes=True)
1973 # Create a new share
1974 shared = await SharedChats.create(id, user.id, db=db)
1975 if not shared: 1975 ↛ 1976line 1975 didn't jump to line 1976 because the condition on line 1975 was never true
1976 raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
1978 chat = await Chats.update_chat_share_id_by_id(id, shared.id, db=db)
1979 if not chat: 1979 ↛ 1980line 1979 didn't jump to line 1980 because the condition on line 1979 was never true
1980 raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
1982 await publish_event(
1983 request,
1984 EVENTS.CHAT_SHARED,
1985 actor=user,
1986 subject_id=id,
1987 data={'share_id': shared.id},
1988 )
1989 return ChatResponse.model_validate(chat, from_attributes=True)
1992# --- Delete Shared Chat ---
1995@router.delete('/{id}/share', response_model=bool | None)
1996async def delete_shared_chat_by_id(
1997 request: Request, id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
1998):
1999 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
2000 if not chat:
2001 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
2003 await SharedChats.delete_by_chat_id(id, db=db)
2005 if chat.share_id:
2006 await Chats.update_chat_share_id_by_id(id, None, db=db)
2008 await AccessGrants.set_access_grants('shared_chat', id, [], db=db)
2010 await publish_event(
2011 request,
2012 EVENTS.CHAT_UNSHARED,
2013 actor=user,
2014 subject_id=id,
2015 data={'share_id': chat.share_id},
2016 )
2017 return True
2020############################
2021# UpdateSharedChatAccessById
2022############################
2025class ChatAccessGrantsForm(BaseModel):
2026 access_grants: list[dict]
2029@router.post('/shared/{id}/access/update', response_model=ChatResponse | None)
2030async def update_shared_chat_access_by_id(
2031 request: Request,
2032 id: str,
2033 form_data: ChatAccessGrantsForm,
2034 user=Depends(get_verified_user),
2035 db: AsyncSession = Depends(get_async_session),
2036):
2037 if user.role == 'admin': 2037 ↛ 2040line 2037 didn't jump to line 2040 because the condition on line 2037 was always true
2038 chat = await Chats.get_chat_by_id(id, db=db)
2039 else:
2040 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
2041 if not chat:
2042 raise HTTPException(
2043 status_code=status.HTTP_404_NOT_FOUND,
2044 detail=ERROR_MESSAGES.NOT_FOUND,
2045 )
2047 form_data.access_grants = await filter_allowed_access_grants(
2048 await Config.get('user.permissions'),
2049 user.id,
2050 user.role,
2051 form_data.access_grants,
2052 'sharing.public_chats',
2053 'sharing.open_chats',
2054 db=db,
2055 )
2057 await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db)
2059 return ChatResponse.model_validate(chat, from_attributes=True)
2062############################
2063# GetSharedChatAccessById
2064############################
2067@router.get('/shared/{id}/access', response_model=list)
2068async def get_shared_chat_access_by_id(
2069 id: str,
2070 user=Depends(get_verified_user),
2071 db: AsyncSession = Depends(get_async_session),
2072):
2073 if user.role == 'admin': 2073 ↛ 2076line 2073 didn't jump to line 2076 because the condition on line 2073 was always true
2074 chat = await Chats.get_chat_by_id(id, db=db)
2075 else:
2076 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
2077 if not chat:
2078 raise HTTPException(
2079 status_code=status.HTTP_404_NOT_FOUND,
2080 detail=ERROR_MESSAGES.NOT_FOUND,
2081 )
2083 grants = await AccessGrants.get_grants_by_resource('shared_chat', id, db=db)
2084 return [
2085 {
2086 'id': g.id,
2087 'principal_type': g.principal_type,
2088 'principal_id': g.principal_id,
2089 'permission': g.permission,
2090 }
2091 for g in grants
2092 ]
2095############################
2096# UpdateChatFolderIdById
2097############################
2100class ChatFolderIdForm(BaseModel):
2101 folder_id: str | None = None
2104@router.post('/{id}/unread')
2105async def mark_chat_unread_by_id(
2106 id: str,
2107 user=Depends(get_verified_user),
2108 db: AsyncSession = Depends(get_async_session),
2109):
2110 chat = await Chats.mark_chat_unread_by_id(id, user.id, db=db)
2111 if not chat:
2112 raise HTTPException(
2113 status_code=status.HTTP_404_NOT_FOUND,
2114 detail=ERROR_MESSAGES.NOT_FOUND,
2115 )
2117 folder_id = await Chats.get_chat_folder_id(id, user.id, db=db)
2118 return {
2119 'chat_id': id,
2120 'last_read_at': chat.last_read_at,
2121 'folder_id': folder_id,
2122 'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
2123 }
2126@router.post('/{id}/folder', response_model=ChatResponse | None)
2127async def update_chat_folder_id_by_id(
2128 request: Request,
2129 id: str,
2130 form_data: ChatFolderIdForm,
2131 user=Depends(get_verified_user),
2132 db: AsyncSession = Depends(get_async_session),
2133):
2134 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
2135 if chat:
2136 # None is allowed: it moves the chat out of any folder.
2137 if form_data.folder_id is not None and not await has_folder_write_access(user.id, form_data.folder_id, db=db):
2138 raise HTTPException(
2139 status_code=status.HTTP_404_NOT_FOUND,
2140 detail=ERROR_MESSAGES.NOT_FOUND,
2141 )
2143 chat = await Chats.update_chat_folder_id_by_id_and_user_id(id, user.id, form_data.folder_id, db=db)
2144 await publish_event(
2145 request,
2146 EVENTS.CHAT_FOLDER_UPDATED,
2147 actor=user,
2148 subject_id=id,
2149 data={'folder_id': form_data.folder_id},
2150 )
2151 return ChatResponse.model_validate(chat, from_attributes=True)
2152 else:
2153 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
2156############################
2157# GetChatTagsById
2158############################
2161@router.get('/{id}/tags', response_model=list[TagModel])
2162async def get_chat_tags_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
2163 chat = await Chats.get_chat_by_id_for_user(
2164 id,
2165 user,
2166 db=db,
2167 )
2169 if chat:
2170 tags = chat.meta.get('tags', [])
2171 return await Tags.get_tags_by_ids_and_user_id(tags, chat.user_id, db=db)
2172 else:
2173 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
2176############################
2177# AddChatTagById
2178############################
2181@router.post('/{id}/tags', response_model=list[TagModel])
2182async def add_tag_by_id_and_tag_name(
2183 request: Request,
2184 id: str,
2185 form_data: TagForm,
2186 user=Depends(get_verified_user),
2187 db: AsyncSession = Depends(get_async_session),
2188):
2189 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
2190 if chat:
2191 tags = chat.meta.get('tags', [])
2192 tag_id = form_data.name.replace(' ', '_').lower()
2194 if tag_id == 'none': 2194 ↛ 2195line 2194 didn't jump to line 2195 because the condition on line 2194 was never true
2195 raise HTTPException(
2196 status_code=status.HTTP_400_BAD_REQUEST,
2197 detail=ERROR_MESSAGES.DEFAULT("Tag name cannot be 'None'"),
2198 )
2200 if tag_id not in tags:
2201 await Chats.add_chat_tag_by_id_and_user_id_and_tag_name(id, user.id, form_data.name, db=db)
2202 await publish_event(
2203 request,
2204 EVENTS.CHAT_TAG_ADDED,
2205 actor=user,
2206 subject_id=id,
2207 data={'tag': form_data.name},
2208 )
2210 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
2211 tags = chat.meta.get('tags', [])
2212 return await Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db)
2213 else:
2214 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
2217############################
2218# DeleteChatTagById
2219############################
2222@router.delete('/{id}/tags', response_model=list[TagModel])
2223async def delete_tag_by_id_and_tag_name(
2224 request: Request,
2225 id: str,
2226 form_data: TagForm,
2227 user=Depends(get_verified_user),
2228 db: AsyncSession = Depends(get_async_session),
2229):
2230 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
2231 if chat:
2232 await Chats.delete_tag_by_id_and_user_id_and_tag_name(id, user.id, form_data.name, db=db)
2233 await publish_event(
2234 request,
2235 EVENTS.CHAT_TAG_REMOVED,
2236 actor=user,
2237 subject_id=id,
2238 data={'tag': form_data.name},
2239 )
2241 if await Chats.count_chats_by_tag_name_and_user_id(form_data.name, user.id, db=db) == 0:
2242 await Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db)
2244 chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
2245 tags = chat.meta.get('tags', [])
2246 return await Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db)
2247 else:
2248 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)