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

1from __future__ import annotations 

2 

3import logging 

4from uuid import uuid4 

5 

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 

46 

47log = logging.getLogger(__name__) 

48 

49router = APIRouter() 

50 

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} 

60 

61 

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 

65 

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 

75 

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 

84 

85 return chat_data 

86 

87 

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 

98 

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 

102 

103 

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 ) 

111 

112 

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 ) 

127 

128 

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 

136 

137 chat.active = await ChatMessages.has_unfinished_assistant_by_chat_id(chat.id) 

138 

139 return chat_list 

140 

141 

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) 

147 

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) 

156 

157 return unread_counts 

158 

159 

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 

168 

169 

170class CompactChatForm(BaseModel): 

171 model: str | None = None 

172 

173 

174def chat_search_content_text(text: str) -> str: 

175 return chat_search_content_query(text) 

176 

177 

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 

181 

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() 

188 

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 

194 

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 

198 

199 index = content.lower().find(needle) 

200 if index == -1: 

201 continue 

202 

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) 

208 

209 snippet = ' '.join(content[start:end].split()) 

210 return f'{"..." if start else ""}{snippet}{"..." if end < len(content) else ""}' 

211 

212 return None 

213 

214 

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 

225 

226 

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} 

229 

230 

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 ) 

239 

240 

241############################ 

242# GetChatList 

243# Let the record outlive the session, so that what was 

244# learned here not need to be learned again. 

245############################ 

246 

247 

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 

264 

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()) 

288 

289 

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 } 

299 

300 

301############################ 

302# GetChatUsageStats 

303# EXPERIMENTAL: may be removed in future releases 

304############################ 

305 

306 

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 

317 

318 result = await Chats.get_chats_by_user_id(user.id, skip=skip, limit=limit, db=db) 

319 

320 chats = result.items 

321 total = result.total 

322 

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') 

327 

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 = [] 

334 

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 

345 

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 ) 

358 

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) 

365 

366 response_times.append(response_time) 

367 

368 average_response_time = sum(response_times) / len(response_times) if len(response_times) > 0 else 0 

369 

370 message_list = get_message_list(messages_map, message_id) 

371 message_count = len(message_list) 

372 

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 

381 

382 annotation = message.get('annotation', {}) 

383 

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 

404 

405 return ChatUsageStatsListResponse(items=chat_stats, total=total) 

406 

407 except Exception as e: 

408 log.exception(e) 

409 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) 

410 

411 

412############################ 

413# GetChatStatsExport 

414############################ 

415 

416 

417CHAT_EXPORT_PAGE_ITEM_COUNT = 10 

418 

419 

420class ChatStatsExportList(BaseModel): 

421 type: str = 'chats' 

422 items: list[ChatStatsExport] 

423 total: int 

424 page: int 

425 

426 

427def _process_chat_for_export(chat) -> ChatStatsExport | None: 

428 try: 

429 

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 

437 

438 messages_map = chat.chat.get('history', {}).get('messages', {}) 

439 message_id = chat.chat.get('history', {}).get('currentId') 

440 

441 history_models = {} 

442 history_message_count = len(messages_map) 

443 history_user_messages = [] 

444 history_assistant_messages = [] 

445 

446 export_messages = {} 

447 for key, message in messages_map.items(): 

448 try: 

449 content_length = get_message_content_length(message) 

450 

451 # Extract rating safely 

452 rating = message.get('annotation', {}).get('rating') 

453 tags = message.get('annotation', {}).get('tags') 

454 

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 ) 

465 

466 export_messages[key] = message_stat 

467 

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 

482 

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 ) 

489 

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 ) 

495 

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) 

507 

508 average_response_time = sum(response_times) / len(response_times) if response_times else 0 

509 

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 

521 

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 ) 

534 

535 # Construct Chat Body 

536 chat_body = ChatBody(history=ChatHistoryStats(messages=export_messages, currentId=message_id)) 

537 

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 

550 

551 

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 = {} 

555 

556 result = await Chats.get_chats_by_user_id( 

557 user_id, 

558 skip=skip, 

559 limit=limit, 

560 filter=filter, 

561 ) 

562 

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) 

568 

569 return chat_stats_export_list, result.total 

570 

571 

572async def generate_chat_stats_jsonl_generator(user_id, filter): 

573 """ 

574 Async generator for streaming chat stats export. 

575 

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 

585 

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 

597 

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}') 

605 

606 skip += limit 

607 

608 

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 ) 

623 

624 try: 

625 # Fetch chats with date filtering 

626 filter = {'order_by': 'updated_at', 'direction': 'asc'} 

627 

628 if updated_at: 

629 filter['updated_at'] = updated_at 

630 

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 

640 

641 chat_stats_export_list, total = await calculate_chat_stats(user.id, skip, limit, filter) 

642 

643 return ChatStatsExportList(items=chat_stats_export_list, total=total, page=page) 

644 

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()) 

648 

649 

650############################ 

651# GetSingleChatStatsExport 

652############################ 

653 

654 

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 ) 

672 

673 try: 

674 chat = await Chats.get_chat_by_id(chat_id, db=db) 

675 

676 if not chat: 

677 raise HTTPException( 

678 status_code=status.HTTP_404_NOT_FOUND, 

679 detail=ERROR_MESSAGES.NOT_FOUND, 

680 ) 

681 

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 ) 

688 

689 # Process the chat for export (pure computation, no DB) 

690 chat_stats = _process_chat_for_export(chat) 

691 

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 ) 

697 

698 return chat_stats 

699 

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()) 

705 

706 

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 ) 

718 

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 

729 

730 

731############################ 

732# GetUserChatList 

733############################ 

734 

735 

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) 

750 

751 effective_page = page if page is not None else 1 

752 limit = 60 

753 skip = (effective_page - 1) * limit 

754 

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 

762 

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) 

767 

768 

769############################ 

770# CreateNewChat 

771############################ 

772 

773 

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 ) 

786 

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()) 

800 

801 

802############################ 

803# ImportChats 

804############################ 

805 

806 

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) 

815 

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()) 

829 

830 

831############################ 

832# ChatConfig 

833############################ 

834 

835 

836@router.get('/config', response_model=ChatConfigForm) 

837async def get_chat_config(user=Depends(get_admin_user)): 

838 return await get_chat_config_values() 

839 

840 

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() 

858 

859 

860############################ 

861# GetChats 

862############################ 

863 

864 

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 

875 

876 limit = 60 

877 skip = (page - 1) * limit 

878 

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 ) 

894 

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) 

903 

904 return await add_active_state_to_chat_list(request, chat_list) 

905 

906 

907############################ 

908# GetChatsByFolderId 

909############################ 

910 

911 

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]) 

920 

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 ] 

925 

926 

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 

940 

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) 

951 

952 except Exception as e: 

953 log.exception(e) 

954 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) 

955 

956 

957############################ 

958# GetPinnedChats 

959############################ 

960 

961 

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) 

968 

969 

970############################ 

971# GetChats 

972############################ 

973 

974CHAT_EXPORT_BATCH_SIZE = 100 

975 

976 

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). 

980 

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 

985 

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 

995 

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}') 

1001 

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 

1004 

1005 skip += CHAT_EXPORT_BATCH_SIZE 

1006 

1007 

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 ) 

1014 

1015 

1016############################ 

1017# GetArchivedChats 

1018############################ 

1019 

1020 

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 ] 

1027 

1028 

1029############################ 

1030# GetAllTags 

1031############################ 

1032 

1033 

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()) 

1042 

1043 

1044############################ 

1045# GetAllChatsInDB 

1046############################ 

1047 

1048 

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)] 

1054 

1055 

1056############################ 

1057# GetArchivedChats 

1058############################ 

1059 

1060 

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 

1073 

1074 limit = 60 

1075 skip = (page - 1) * limit 

1076 

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 

1084 

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) 

1093 

1094 

1095############################ 

1096# GetArchivedChatsCount 

1097############################ 

1098 

1099 

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) 

1106 

1107 

1108############################ 

1109# ArchiveAllChats 

1110############################ 

1111 

1112 

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 

1121 

1122 

1123############################ 

1124# UnarchiveAllChats 

1125############################ 

1126 

1127 

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 

1136 

1137 

1138############################ 

1139# UnshareAllChats 

1140############################ 

1141 

1142 

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] 

1150 

1151 # Delete all shared_chat rows for this user 

1152 result = await SharedChats.delete_all_by_user_id(user.id, db=db) 

1153 

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) 

1158 

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 

1169 

1170 

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 

1182 

1183 limit = 60 

1184 skip = (page - 1) * limit 

1185 

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 

1193 

1194 return await SharedChats.get_by_user_id( 

1195 user.id, 

1196 filter=filter, 

1197 skip=skip, 

1198 limit=limit, 

1199 db=db, 

1200 ) 

1201 

1202 

1203############################ 

1204# GetSharedChatById 

1205############################ 

1206 

1207 

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) 

1220 

1221 raise HTTPException( 

1222 status_code=status.HTTP_401_UNAUTHORIZED, 

1223 detail=ERROR_MESSAGES.ACCESS_PROHIBITED if user else ERROR_MESSAGES.INVALID_TOKEN, 

1224 ) 

1225 

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) 

1232 

1233 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) 

1234 

1235 

1236############################ 

1237# GetChatsByTags 

1238############################ 

1239 

1240 

1241class TagForm(BaseModel): 

1242 name: str 

1243 

1244 

1245class TagFilterForm(TagForm): 

1246 skip: int | None = 0 

1247 limit: int | None = 50 

1248 

1249 

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) 

1262 

1263 return await add_active_state_to_chat_list(request, chats) 

1264 

1265 

1266############################ 

1267# CompactChat 

1268############################ 

1269 

1270 

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) 

1282 

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 ) 

1288 

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) 

1291 

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 ) 

1300 

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.') 

1306 

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 

1318 

1319 

1320############################ 

1321# GetChatById 

1322############################ 

1323 

1324 

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 ) 

1337 

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 

1346 

1347 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) 

1348 

1349 

1350############################ 

1351# UpdateChatById 

1352############################ 

1353 

1354 

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 ) 

1377 

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) 

1383 

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 ) 

1397 

1398 

1399############################ 

1400# UpdateChatMessageById 

1401############################ 

1402class MessageForm(BaseModel): 

1403 content: str 

1404 

1405 

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) 

1416 

1417 if not chat: 

1418 raise HTTPException( 

1419 status_code=status.HTTP_401_UNAUTHORIZED, 

1420 detail=ERROR_MESSAGES.ACCESS_PROHIBITED, 

1421 ) 

1422 

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 ) 

1428 

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 ) 

1436 

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 ) 

1445 

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 ) 

1457 

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) 

1466 

1467 

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) 

1477 

1478 if not chat: 

1479 raise HTTPException( 

1480 status_code=status.HTTP_401_UNAUTHORIZED, 

1481 detail=ERROR_MESSAGES.ACCESS_PROHIBITED, 

1482 ) 

1483 

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 ) 

1489 

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 ) 

1496 

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) 

1505 

1506 

1507############################ 

1508# SendChatMessageEventById 

1509############################ 

1510class EventForm(BaseModel): 

1511 type: str 

1512 data: dict 

1513 

1514 

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) 

1525 

1526 if not chat: 

1527 raise HTTPException( 

1528 status_code=status.HTTP_401_UNAUTHORIZED, 

1529 detail=ERROR_MESSAGES.ACCESS_PROHIBITED, 

1530 ) 

1531 

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 ) 

1537 

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 ) 

1545 

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 

1561 

1562 

1563############################ 

1564# DeleteChatById 

1565############################ 

1566 

1567 

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) 

1586 

1587 if not chat: 

1588 raise HTTPException( 

1589 status_code=status.HTTP_404_NOT_FOUND, 

1590 detail=ERROR_MESSAGES.NOT_FOUND, 

1591 ) 

1592 

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) 

1597 

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) 

1602 

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) 

1607 

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 

1617 

1618 

1619############################ 

1620# GetPinnedStatusById 

1621############################ 

1622 

1623 

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()) 

1633 

1634 

1635############################ 

1636# PinChatById 

1637############################ 

1638 

1639 

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()) 

1657 

1658 

1659############################ 

1660# CloneChat 

1661############################ 

1662 

1663 

1664class CloneForm(BaseModel): 

1665 title: str | None = None 

1666 

1667 

1668class ForkForm(BaseModel): 

1669 message_id: str | None = None 

1670 

1671 

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) 

1681 

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()) 

1685 

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 ) 

1691 

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 {} 

1694 

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') 

1700 

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 

1709 

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 

1714 

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 

1723 

1724 message['done'] = True 

1725 

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 } 

1742 

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 

1747 

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 ) 

1755 

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 

1758 

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()) 

1761 

1762 if chat.pinned: 

1763 fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork 

1764 

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) 

1773 

1774 

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) 

1784 

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 } 

1793 

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 ) 

1809 

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()) 

1827 

1828 

1829############################ 

1830# CloneSharedChatById 

1831############################ 

1832 

1833 

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) 

1842 

1843 chat = await Chats.get_chat_by_share_id(id, db=db) 

1844 

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) 

1848 

1849 if not chat: 

1850 raise HTTPException( 

1851 status_code=status.HTTP_401_UNAUTHORIZED, 

1852 detail=ERROR_MESSAGES.NOT_FOUND, 

1853 ) 

1854 

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 ) 

1870 

1871 updated_chat = { 

1872 **chat.chat, 

1873 'originalChatId': chat.id, 

1874 'branchPointMessageId': chat.chat['history']['currentId'], 

1875 'title': f'Clone of {chat.title}', 

1876 } 

1877 

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 ) 

1893 

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 ) 

1902 

1903 

1904############################ 

1905# ArchiveChat 

1906############################ 

1907 

1908 

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) 

1919 

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) 

1929 

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()) 

1940 

1941 

1942# --- Share Chat --- 

1943 

1944 

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) 

1954 

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) 

1958 

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) 

1972 

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()) 

1977 

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()) 

1981 

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) 

1990 

1991 

1992# --- Delete Shared Chat --- 

1993 

1994 

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) 

2002 

2003 await SharedChats.delete_by_chat_id(id, db=db) 

2004 

2005 if chat.share_id: 

2006 await Chats.update_chat_share_id_by_id(id, None, db=db) 

2007 

2008 await AccessGrants.set_access_grants('shared_chat', id, [], db=db) 

2009 

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 

2018 

2019 

2020############################ 

2021# UpdateSharedChatAccessById 

2022############################ 

2023 

2024 

2025class ChatAccessGrantsForm(BaseModel): 

2026 access_grants: list[dict] 

2027 

2028 

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 ) 

2046 

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 ) 

2056 

2057 await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db) 

2058 

2059 return ChatResponse.model_validate(chat, from_attributes=True) 

2060 

2061 

2062############################ 

2063# GetSharedChatAccessById 

2064############################ 

2065 

2066 

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 ) 

2082 

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 ] 

2093 

2094 

2095############################ 

2096# UpdateChatFolderIdById 

2097############################ 

2098 

2099 

2100class ChatFolderIdForm(BaseModel): 

2101 folder_id: str | None = None 

2102 

2103 

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 ) 

2116 

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 } 

2124 

2125 

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 ) 

2142 

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()) 

2154 

2155 

2156############################ 

2157# GetChatTagsById 

2158############################ 

2159 

2160 

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 ) 

2168 

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) 

2174 

2175 

2176############################ 

2177# AddChatTagById 

2178############################ 

2179 

2180 

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() 

2193 

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 ) 

2199 

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 ) 

2209 

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()) 

2215 

2216 

2217############################ 

2218# DeleteChatTagById 

2219############################ 

2220 

2221 

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 ) 

2240 

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) 

2243 

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)