| | <script lang="ts"> |
| | import { Confetti } from 'svelte-confetti'; |
| | import { toast } from 'svelte-sonner'; |
| | import { getContext, onMount, onDestroy, tick } from 'svelte'; |
| | |
| | import { exportChatStats, exportSingleChatStats, downloadChatStats } from '$lib/apis/chats'; |
| | import { getVersion } from '$lib/apis'; |
| | |
| | import Modal from '$lib/components/common/Modal.svelte'; |
| | import Tooltip from '$lib/components/common/Tooltip.svelte'; |
| | import XMark from '$lib/components/icons/XMark.svelte'; |
| | import Spinner from '$lib/components/common/Spinner.svelte'; |
| | |
| | const i18n = getContext('i18n'); |
| | |
| | export let show = false; |
| | export let eventData = null; |
| | |
| | |
| | const handleMessage = async (event: MessageEvent) => { |
| | // Community sends: { type: 'verify:chat', data: { id: ... } } |
| | const chatId = event.data?.data?.id ?? event.data?.id; |
| | if (event.data?.type === 'verify:chat' && chatId) { |
| | try { |
| | const res = await exportSingleChatStats(localStorage.token, chatId); |
| | if (res && window.opener) { |
| | window.opener.postMessage( |
| | { |
| | type: 'verify:chat:response', |
| | data: res, |
| | chatId: chatId, |
| | requestId: event.data.requestId ?? null |
| | }, |
| | '*' |
| | ); |
| | } |
| | } catch (err: any) { |
| | console.error('Failed to verify chat:', err); |
| | if (window.opener) { |
| | window.opener.postMessage( |
| | { |
| | type: 'verify:chat:error', |
| | error: err?.detail || err?.message || 'Failed to verify chat', |
| | chatId: chatId, |
| | requestId: event.data.requestId ?? null |
| | }, |
| | '*' |
| | ); |
| | } |
| | } |
| | } |
| | }; |
| | |
| | |
| | $: if (show && window.opener) { |
| | window.opener.postMessage('loaded', '*'); |
| | } |
| | |
| | onMount(() => { |
| | window.addEventListener('message', handleMessage); |
| | }); |
| | |
| | onDestroy(() => { |
| | window.removeEventListener('message', handleMessage); |
| | }); |
| | |
| | |
| | let syncing = false; |
| | let downloading = false; |
| | let completed = false; |
| | let error = false; |
| | let errorMessage = ''; |
| | |
| | |
| | let processedItemsCount = 0; |
| | let total = 0; |
| | |
| | |
| | let downloadController: AbortController | null = null; |
| | |
| | |
| | let syncMode: 'incremental' | 'full' = 'incremental'; |
| | |
| | |
| | $: progressPercent = |
| | total > 0 ? Math.min(Math.round((processedItemsCount / total) * 100), 100) : 0; |
| | |
| | |
| | const postToOpener = (message: object) => { |
| | if (window.opener) { |
| | window.opener.postMessage({ ...message, requestId: eventData?.requestId ?? null }, '*'); |
| | } |
| | }; |
| | |
| | |
| | const resetState = () => { |
| | syncing = false; |
| | downloading = false; |
| | completed = false; |
| | error = false; |
| | errorMessage = ''; |
| | processedItemsCount = 0; |
| | total = 0; |
| | downloadController = null; |
| | }; |
| | |
| | |
| | const handleError = (message: string) => { |
| | console.error('Sync error:', message); |
| | errorMessage = message; |
| | error = true; |
| | syncing = false; |
| | downloading = false; |
| | postToOpener({ type: 'sync:error', error: message }); |
| | }; |
| | |
| | |
| | const cancelOperation = () => { |
| | if (downloadController) { |
| | downloadController.abort(); |
| | downloadController = null; |
| | } |
| | syncing = false; |
| | downloading = false; |
| | |
| | postToOpener({ type: 'sync:error', error: 'User cancelled the operation' }); |
| | }; |
| | |
| | |
| | const syncStats = async () => { |
| | if (window.opener) { |
| | window.opener.focus(); |
| | } |
| | postToOpener({ type: 'sync:start' }); |
| | |
| | syncing = true; |
| | error = false; |
| | errorMessage = ''; |
| | processedItemsCount = 0; |
| | total = 0; |
| | |
| | try { |
| | // Get version info |
| | const versionRes = await getVersion(localStorage.token).catch((err) => { |
| | console.error('Failed to get version:', err); |
| | return null; |
| | }); |
| | |
| | if (versionRes) { |
| | postToOpener({ type: 'sync:version', data: versionRes }); |
| | } |
| | |
| | |
| | let page = 1; |
| | let allItemsLoaded = false; |
| | |
| | while (!allItemsLoaded) { |
| | // Build search params, include updated_at for incremental mode |
| | const searchParams = { ...(eventData?.searchParams ?? {}) }; |
| | if (syncMode === 'incremental' && eventData?.lastSyncedChatUpdatedAt) { |
| | searchParams.updated_at = eventData.lastSyncedChatUpdatedAt; |
| | } |
| | |
| | const res = await exportChatStats(localStorage.token, page, searchParams).catch((err) => { |
| | throw new Error(err?.detail || err?.message || 'Failed to export chat stats'); |
| | }); |
| | |
| | if (!res) { |
| | throw new Error('Failed to fetch stats data'); |
| | } |
| | |
| | processedItemsCount += res.items.length; |
| | total = res.total; |
| | |
| | |
| | await tick(); |
| | |
| | if (window.opener && res.items.length > 0) { |
| | postToOpener({ type: 'sync:stats:chats', data: res }); |
| | } |
| | |
| | if (processedItemsCount >= total || res.items.length === 0) { |
| | allItemsLoaded = true; |
| | } else { |
| | page += 1; |
| | } |
| | } |
| | |
| | |
| | postToOpener({ type: 'sync:complete' }); |
| | syncing = false; |
| | completed = true; |
| | } catch (err: any) { |
| | handleError(err?.message || 'An unexpected error occurred'); |
| | } |
| | }; |
| | |
| | |
| | const downloadHandler = async () => { |
| | if (downloading) { |
| | cancelOperation(); |
| | return; |
| | } |
| | |
| | downloading = true; |
| | syncing = true; |
| | error = false; |
| | errorMessage = ''; |
| | processedItemsCount = 0; |
| | total = 0; |
| | |
| | try { |
| | // Get total count first (no filters for download - get all) |
| | const initialRes = await exportChatStats(localStorage.token, 1, {}).catch(() => null); |
| | |
| | if (initialRes?.total) { |
| | total = initialRes.total; |
| | } |
| | |
| | |
| | await tick(); |
| | |
| | |
| | const versionRes = await getVersion(localStorage.token).catch(() => null); |
| | const version = versionRes?.version ?? '0.0.0'; |
| | const filename = `open-webui-stats-${version}-${Date.now()}.json`; |
| | |
| | // Start streaming download |
| | const searchParams = eventData?.searchParams ?? {}; |
| | const [res, controller] = await downloadChatStats( |
| | localStorage.token, |
| | searchParams.updated_at |
| | ).catch((err) => { |
| | throw new Error( |
| | err?.detail || 'Failed to connect to the server. Please check your connection.' |
| | ); |
| | }); |
| | |
| | if (!res) { |
| | throw new Error('Failed to start download. The server may be unavailable.'); |
| | } |
| | |
| | downloadController = controller; |
| | const reader = res.body.getReader(); |
| | const decoder = new TextDecoder(); |
| | |
| | const items: any[] = []; |
| | let buffer = ''; |
| | |
| | while (true) { |
| | const { done, value } = await reader.read(); |
| | if (done) break; |
| | |
| | buffer += decoder.decode(value, { stream: true }); |
| | const lines = buffer.split('\n'); |
| | buffer = lines.pop() || ''; |
| | |
| | for (const line of lines) { |
| | if (line.trim() !== '') { |
| | try { |
| | items.push(JSON.parse(line)); |
| | processedItemsCount += 1; |
| | } catch (e) { |
| | console.error('Error parsing line:', e); |
| | } |
| | } |
| | } |
| | |
| | |
| | await tick(); |
| | } |
| | |
| | |
| | if (buffer.trim() !== '') { |
| | try { |
| | items.push(JSON.parse(buffer)); |
| | processedItemsCount += 1; |
| | } catch (e) { |
| | console.error('Error parsing buffer:', e); |
| | } |
| | } |
| | |
| | |
| | if (downloading) { |
| | const blob = new Blob([JSON.stringify(items)], { type: 'application/json' }); |
| | const url = window.URL.createObjectURL(blob); |
| | const a = document.createElement('a'); |
| | a.href = url; |
| | a.download = filename; |
| | document.body.appendChild(a); |
| | a.click(); |
| | document.body.removeChild(a); |
| | window.URL.revokeObjectURL(url); |
| | } |
| | } catch (err: any) { |
| | // Don't show error if user cancelled the download |
| | if (err?.name === 'AbortError' || err?.message?.includes('aborted')) { |
| | // User cancelled - just reset state silently |
| | } else { |
| | handleError(err?.message || 'Download failed. Please try again.'); |
| | toast.error(errorMessage); |
| | } |
| | } finally { |
| | downloading = false; |
| | syncing = false; |
| | downloadController = null; |
| | } |
| | }; |
| | |
| | |
| | const closeModal = () => { |
| | show = false; |
| | resetState(); |
| | }; |
| | </script> |
| |
|
| | <Modal bind:show size="md"> |
| | <div class="w-full"> |
| | {#if completed} |
| | <div class="px-5.5 py-5"> |
| | <div class="mb-1 text-xl font-medium">{$i18n.t('Sync Complete!')}</div> |
| | <div class="mb-3 text-xs text-gray-500"> |
| | {$i18n.t('Your usage stats have been successfully synced.')} |
| | </div> |
| | |
| | <Confetti x={[-0.5, 0.5]} y={[0.25, 1]} /> |
| | |
| | <div class="flex justify-end"> |
| | <button |
| | class="flex items-center justify-center gap-2 rounded-full bg-black px-4 py-2 text-sm text-white transition hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100" |
| | on:click={closeModal} |
| | > |
| | {$i18n.t('Done')} |
| | </button> |
| | </div> |
| | </div> |
| | {:else if error} |
| | <div class="px-5.5 py-5"> |
| | <div class="mb-1 text-xl font-medium">{$i18n.t('Sync Failed')}</div> |
| | <div class="mb-3 text-xs text-gray-500"> |
| | {errorMessage || $i18n.t('There was an error syncing your stats. Please try again.')} |
| | </div> |
| | |
| | <div class="flex justify-end"> |
| | <button |
| | class="flex items-center justify-center gap-2 rounded-full bg-black px-4 py-2 text-sm text-white transition hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100" |
| | on:click={() => { |
| | error = false; |
| | errorMessage = ''; |
| | }} |
| | > |
| | {$i18n.t('Try Again')} |
| | </button> |
| | </div> |
| | </div> |
| | {:else} |
| | <div class="flex justify-between px-5 pt-4 pb-0.5"> |
| | <div class="text-lg font-medium self-center">{$i18n.t('Sync Usage Stats')}</div> |
| | <button |
| | class="self-center" |
| | on:click={() => { |
| | show = false; |
| | }} |
| | disabled={syncing} |
| | > |
| | <XMark className={'size-5'} /> |
| | </button> |
| | </div> |
| |
|
| | <div class="px-5 pt-2 pb-5"> |
| | <div class="text-sm text-gray-500 dark:text-gray-400"> |
| | {$i18n.t('Do you want to sync your usage stats with Open WebUI Community?')} |
| | </div> |
| | |
| | <div class="mt-2 text-xs text-gray-500"> |
| | {$i18n.t( |
| | 'Participate in community leaderboards and evaluations! Syncing aggregated usage stats helps drive research and improvements to Open WebUI. Your privacy is paramount: no message content is ever shared.' |
| | )} |
| | </div> |
| | |
| | <div class="mt-3 text-xs text-gray-500"> |
| | <div class="font-medium text-gray-900 dark:text-gray-100 mb-1"> |
| | {$i18n.t('What is shared:')} |
| | </div> |
| | <ul class="list-disc list-inside space-y-0.5 ml-1 mb-2"> |
| | <li>{$i18n.t('Open WebUI version')}</li> |
| | <li>{$i18n.t('Model names and usage frequency')}</li> |
| | <li>{$i18n.t('Message counts and response timestamps')}</li> |
| | <li>{$i18n.t('Content lengths (character counts only)')}</li> |
| | <li>{$i18n.t('User ratings (thumbs up/down)')}</li> |
| | </ul> |
| | |
| | <div class="font-medium text-gray-900 dark:text-gray-100 mb-1"> |
| | {$i18n.t('What is NOT shared:')} |
| | </div> |
| | <ul class="list-disc list-inside space-y-0.5 ml-1"> |
| | <li>{$i18n.t('Your message text or inputs')}</li> |
| | <li>{$i18n.t('Model responses or outputs')}</li> |
| | <li>{$i18n.t('Uploaded files or images')}</li> |
| | </ul> |
| | </div> |
| | |
| | {#if eventData?.lastSyncedChatUpdatedAt} |
| | <div class="mt-3"> |
| | <Tooltip |
| | content={$i18n.t( |
| | 'Syncs only chats with updates after your last sync timestamp. Disable to re-sync all chats.' |
| | )} |
| | placement="top-start" |
| | > |
| | <label class="flex items-center gap-2 text-xs cursor-pointer"> |
| | <input |
| | type="checkbox" |
| | checked={syncMode === 'incremental'} |
| | on:change={(e) => (syncMode = e.target.checked ? 'incremental' : 'full')} |
| | disabled={syncing} |
| | class="w-4 h-4 rounded border-gray-300 dark:border-gray-600" |
| | /> |
| | <span class="text-gray-700 dark:text-gray-300" |
| | >{$i18n.t('Only sync new/updated chats')}</span |
| | > |
| | </label> |
| | </Tooltip> |
| | </div> |
| | {/if} |
| |
|
| | {#if syncing} |
| | <div class="mt-3 mx-1.5"> |
| | <div class="text-xs text-gray-500 mb-1 flex justify-between"> |
| | <div> |
| | {downloading ? $i18n.t('Downloading stats...') : $i18n.t('Syncing stats...')} |
| | </div> |
| | <div> |
| | {#if total > 0} |
| | {processedItemsCount}/{total} |
| | {/if} |
| | </div> |
| | </div> |
| | <div class="w-full bg-gray-200 rounded-full h-1.5 dark:bg-gray-700 overflow-hidden"> |
| | {#if total > 0} |
| | <div |
| | class="bg-gray-900 dark:bg-gray-100 h-1.5 rounded-full transition-all duration-300" |
| | style="width: {progressPercent}%" |
| | ></div> |
| | {:else} |
| | <div |
| | class="bg-gray-900 dark:bg-gray-100 h-1.5 w-0 rounded-full animate-pulse" |
| | ></div> |
| | {/if} |
| | </div> |
| | </div> |
| | {/if} |
| |
|
| | <div class="mt-5 flex justify-between items-center gap-2"> |
| | <div class="text-xs text-gray-400 text-center mr-auto"> |
| | <button |
| | class="hover:underline px-2" |
| | type="button" |
| | on:click={downloadHandler} |
| | disabled={syncing && !downloading} |
| | > |
| | {downloading ? $i18n.t('Stop Download') : $i18n.t('Download as JSON')} |
| | </button> |
| | </div> |
| |
|
| | <button |
| | 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) { |
| | cancelOperation(); |
| | } else { |
| | show = false; |
| | } |
| | }} |
| | > |
| | {$i18n.t('Cancel')} |
| | </button> |
| |
|
| | <button |
| | class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition-colors rounded-full" |
| | on:click={syncStats} |
| | disabled={syncing} |
| | > |
| | {#if syncing && !downloading} |
| | <div class="flex items-center gap-2"> |
| | <Spinner className="size-3" /> |
| | <span>{$i18n.t('Syncing...')}</span> |
| | </div> |
| | {:else} |
| | {$i18n.t('Sync')} |
| | {/if} |
| | </button> |
| | </div> |
| | </div> |
| | {/if} |
| | </div> |
| | </Modal> |
| |
|