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