diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 7848aa89c..ea48044bc 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -810,10 +810,8 @@ class ChatTable: query = db.query(Chat).filter_by(user_id=user_id) if filter: - if filter.get("start_time"): - query = query.filter(Chat.created_at >= filter.get("start_time")) - if filter.get("end_time"): - query = query.filter(Chat.created_at <= filter.get("end_time")) + if filter.get("updated_at"): + query = query.filter(Chat.updated_at > filter.get("updated_at")) order_by = filter.get("order_by") direction = filter.get("direction") diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 7f0172d0e..ccbae22e3 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -380,28 +380,35 @@ def calculate_chat_stats( return chat_stats_export_list, result.total -async def generate_chat_stats_jsonl_generator( - user_id, filter, db: Optional[Session] = None -): +def generate_chat_stats_jsonl_generator(user_id, filter): + """ + Synchronous generator for streaming chat stats export. + + NOTE: We intentionally do NOT pass a shared db session here. Instead, we let + each batch create its own short-lived session via get_db_context(None). + This is critical for SQLite in low-resource environments because: + 1. SQLite uses file-level locking + 2. Holding a session open for the entire streaming duration blocks other requests + 3. Short-lived sessions release locks between batches, allowing other operations + """ skip = 0 limit = CHAT_EXPORT_PAGE_ITEM_COUNT while True: - # Use asyncio.to_thread to make the blocking DB call non-blocking - result = await asyncio.to_thread( - Chats.get_chats_by_user_id, + # Each batch gets its own session that closes after the query + result = Chats.get_chats_by_user_id( user_id, filter=filter, skip=skip, limit=limit, - db=db, + db=None, # Let get_db_context create a fresh session per batch ) if not result.items: break for chat in result.items: try: - chat_stat = await asyncio.to_thread(_process_chat_for_export, chat) + chat_stat = _process_chat_for_export(chat) if chat_stat: yield chat_stat.model_dump_json() + "\n" except Exception as e: @@ -413,9 +420,7 @@ async def generate_chat_stats_jsonl_generator( @router.get("/stats/export", response_model=ChatStatsExportList) async def export_chat_stats( request: Request, - chat_id: Optional[str] = None, - start_time: Optional[int] = None, - end_time: Optional[int] = None, + updated_at: Optional[int] = None, page: Optional[int] = 1, stream: bool = False, user=Depends(get_verified_user), @@ -432,21 +437,14 @@ async def export_chat_stats( try: # Fetch chats with date filtering - filter = {"order_by": "created_at", "direction": "asc"} + filter = {"order_by": "updated_at", "direction": "asc"} - if chat_id: - chat = Chats.get_chat_by_id(chat_id, db=db) - if chat: - filter["start_time"] = chat.created_at - - if start_time: - filter["start_time"] = start_time - if end_time: - filter["end_time"] = end_time + if updated_at: + filter["updated_at"] = updated_at if stream: return StreamingResponse( - generate_chat_stats_jsonl_generator(user.id, filter, db=db), + generate_chat_stats_jsonl_generator(user.id, filter), media_type="application/x-ndjson", headers={ "Content-Disposition": f"attachment; filename=chat-stats-export-{user.id}.jsonl" @@ -471,6 +469,67 @@ async def export_chat_stats( ) +############################ +# GetSingleChatStatsExport +############################ + + +@router.get("/stats/export/{chat_id}", response_model=Optional[ChatStatsExport]) +async def export_single_chat_stats( + request: Request, + chat_id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + """ + Export stats for exactly one chat by ID. + Returns ChatStatsExport for the specified chat. + """ + # Check if the user has permission to share/export chats + if (user.role != "admin") and ( + not request.app.state.config.ENABLE_COMMUNITY_SHARING + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + try: + chat = Chats.get_chat_by_id(chat_id, db=db) + + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + # Verify the chat belongs to the user (unless admin) + if chat.user_id != user.id and user.role != "admin": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + # Process the chat for export + chat_stats = await asyncio.to_thread(_process_chat_for_export, chat) + + if not chat_stats: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Failed to process chat stats", + ) + + return chat_stats + + except HTTPException: + raise + except Exception as e: + log.debug(f"Error exporting single chat stats: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + @router.delete("/", response_model=bool) async def delete_all_user_chats( request: Request, diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 6b59cda20..b33072e89 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -1210,19 +1210,46 @@ export const exportChatStats = async (token: string, page: number = 1, params: o return res; }; +export const exportSingleChatStats = async (token: string, chatId: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/stats/export/${chatId}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const downloadChatStats = async ( token: string = '', - chat_id: string | null = null, - start_time: number | null = null, - end_time: number | null = null + updated_at: number | null = null ): Promise<[Response | null, AbortController]> => { const controller = new AbortController(); let error = null; let url = `${WEBUI_API_BASE_URL}/chats/stats/export?stream=true`; - if (chat_id) url += `&chat_id=${chat_id}`; - if (start_time) url += `&start_time=${start_time}`; - if (end_time) url += `&end_time=${end_time}`; + if (updated_at) url += `&updated_at=${updated_at}`; const res = await fetch(url, { signal: controller.signal, diff --git a/src/lib/components/chat/Settings/SyncStatsModal.svelte b/src/lib/components/chat/Settings/SyncStatsModal.svelte index 1776d3eda..e03888d47 100644 --- a/src/lib/components/chat/Settings/SyncStatsModal.svelte +++ b/src/lib/components/chat/Settings/SyncStatsModal.svelte @@ -1,15 +1,13 @@
{#if completed} -
+
+
{$i18n.t('Sync Complete!')}
+
+ {$i18n.t('Your usage stats have been successfully synced.')} +
+ -
- +
+ +
+
+ {:else if error} +
+
{$i18n.t('Sync Failed')}
+
+ {errorMessage || $i18n.t('There was an error syncing your stats. Please try again.')}
-
- {$i18n.t('Sync Complete!')} +
+
- -
- {$i18n.t('Your usage stats have been successfully synced with the Open WebUI Community.')} -
- -
{:else} -
-
{$i18n.t('Sync Usage Stats')}
+
+
{$i18n.t('Sync Usage Stats')}
+ {#if eventData?.lastSyncedChatUpdatedAt} +
+ + + +
+ {/if} + {#if syncing}
{downloading ? $i18n.t('Downloading stats...') : $i18n.t('Syncing stats...')}
-
{Math.round((processedItemsCount / total) * 100) || 0}%
+
+ {#if total > 0} + {processedItemsCount}/{total} + {:else} + {processedItemsCount} + {/if} +
-
-
+
+ {#if total > 0} +
+ {:else} +
+ {/if}
{/if} @@ -297,11 +449,10 @@
@@ -311,12 +462,7 @@ class="px-4 py-2 rounded-full text-sm font-medium bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 transition disabled:cursor-not-allowed" on:click={() => { if (syncing) { - cancelDownload(); - if (downloading) { - cancelDownload(); - } else { - syncing = false; - } + cancelOperation(); } else { show = false; } @@ -327,9 +473,7 @@