+layout.svelte 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. <script>
  2. import { io } from 'socket.io-client';
  3. import { spring } from 'svelte/motion';
  4. let loadingProgress = spring(0, {
  5. stiffness: 0.05
  6. });
  7. import { onMount, tick, setContext } from 'svelte';
  8. import {
  9. config,
  10. user,
  11. settings,
  12. theme,
  13. WEBUI_NAME,
  14. mobile,
  15. socket,
  16. activeUserIds,
  17. USAGE_POOL,
  18. chatId,
  19. chats,
  20. currentChatPage,
  21. tags,
  22. temporaryChatEnabled,
  23. isLastActiveTab,
  24. isApp,
  25. appVersion
  26. } from '$lib/stores';
  27. import { goto } from '$app/navigation';
  28. import { page } from '$app/stores';
  29. import { Toaster, toast } from 'svelte-sonner';
  30. import { getBackendConfig } from '$lib/apis';
  31. import { getSessionUser } from '$lib/apis/auths';
  32. import '../tailwind.css';
  33. import '../app.css';
  34. import 'tippy.js/dist/tippy.css';
  35. import { WEBUI_BASE_URL, WEBUI_HOSTNAME } from '$lib/constants';
  36. import i18n, { initI18n, getLanguages } from '$lib/i18n';
  37. import { bestMatchingLanguage } from '$lib/utils';
  38. import { getAllTags, getChatList } from '$lib/apis/chats';
  39. import NotificationToast from '$lib/components/NotificationToast.svelte';
  40. import AppControls from '$lib/components/app/AppControls.svelte';
  41. setContext('i18n', i18n);
  42. const bc = new BroadcastChannel('active-tab-channel');
  43. let loaded = false;
  44. const BREAKPOINT = 768;
  45. const setupSocket = async (enableWebsocket) => {
  46. const _socket = io(`${WEBUI_BASE_URL}` || undefined, {
  47. reconnection: true,
  48. reconnectionDelay: 1000,
  49. reconnectionDelayMax: 5000,
  50. randomizationFactor: 0.5,
  51. path: '/ws/socket.io',
  52. transports: enableWebsocket ? ['websocket'] : ['polling', 'websocket'],
  53. auth: { token: localStorage.token }
  54. });
  55. await socket.set(_socket);
  56. _socket.on('connect_error', (err) => {
  57. console.log('connect_error', err);
  58. });
  59. _socket.on('connect', () => {
  60. console.log('connected', _socket.id);
  61. });
  62. _socket.on('reconnect_attempt', (attempt) => {
  63. console.log('reconnect_attempt', attempt);
  64. });
  65. _socket.on('reconnect_failed', () => {
  66. console.log('reconnect_failed');
  67. });
  68. _socket.on('disconnect', (reason, details) => {
  69. console.log(`Socket ${_socket.id} disconnected due to ${reason}`);
  70. if (details) {
  71. console.log('Additional details:', details);
  72. }
  73. });
  74. _socket.on('user-list', (data) => {
  75. console.log('user-list', data);
  76. activeUserIds.set(data.user_ids);
  77. });
  78. _socket.on('usage', (data) => {
  79. console.log('usage', data);
  80. USAGE_POOL.set(data['models']);
  81. });
  82. };
  83. const chatEventHandler = async (event) => {
  84. const chat = $page.url.pathname.includes(`/c/${event.chat_id}`);
  85. let isFocused = document.visibilityState !== 'visible';
  86. if (window.electronAPI) {
  87. const res = await window.electronAPI.send({
  88. type: 'window:isFocused'
  89. });
  90. if (res) {
  91. isFocused = res.isFocused;
  92. }
  93. }
  94. if ((event.chat_id !== $chatId && !$temporaryChatEnabled) || isFocused) {
  95. await tick();
  96. const type = event?.data?.type ?? null;
  97. const data = event?.data?.data ?? null;
  98. if (type === 'chat:completion') {
  99. const { done, content, title } = data;
  100. if (done) {
  101. if ($isLastActiveTab) {
  102. if ($settings?.notificationEnabled ?? false) {
  103. new Notification(`${title} | Open WebUI`, {
  104. body: content,
  105. icon: `${WEBUI_BASE_URL}/static/favicon.png`
  106. });
  107. }
  108. }
  109. toast.custom(NotificationToast, {
  110. componentProps: {
  111. onClick: () => {
  112. goto(`/c/${event.chat_id}`);
  113. },
  114. content: content,
  115. title: title
  116. },
  117. duration: 15000,
  118. unstyled: true
  119. });
  120. }
  121. } else if (type === 'chat:title') {
  122. currentChatPage.set(1);
  123. await chats.set(await getChatList(localStorage.token, $currentChatPage));
  124. } else if (type === 'chat:tags') {
  125. tags.set(await getAllTags(localStorage.token));
  126. }
  127. }
  128. };
  129. const channelEventHandler = async (event) => {
  130. if (event.data?.type === 'typing') {
  131. return;
  132. }
  133. // check url path
  134. const channel = $page.url.pathname.includes(`/channels/${event.channel_id}`);
  135. let isFocused = document.visibilityState !== 'visible';
  136. if (window.electronAPI) {
  137. const res = await window.electronAPI.send({
  138. type: 'window:isFocused'
  139. });
  140. if (res) {
  141. isFocused = res.isFocused;
  142. }
  143. }
  144. if ((!channel || isFocused) && event?.user?.id !== $user?.id) {
  145. await tick();
  146. const type = event?.data?.type ?? null;
  147. const data = event?.data?.data ?? null;
  148. if (type === 'message') {
  149. if ($isLastActiveTab) {
  150. if ($settings?.notificationEnabled ?? false) {
  151. new Notification(`${data?.user?.name} (#${event?.channel?.name}) | Open WebUI`, {
  152. body: data?.content,
  153. icon: data?.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`
  154. });
  155. }
  156. }
  157. toast.custom(NotificationToast, {
  158. componentProps: {
  159. onClick: () => {
  160. goto(`/channels/${event.channel_id}`);
  161. },
  162. content: data?.content,
  163. title: event?.channel?.name
  164. },
  165. duration: 15000,
  166. unstyled: true
  167. });
  168. }
  169. }
  170. };
  171. onMount(async () => {
  172. if (window?.electronAPI) {
  173. const res = await window.electronAPI.send({
  174. type: 'version'
  175. });
  176. if (res) {
  177. isApp.set(true);
  178. appVersion.set(res.version);
  179. }
  180. }
  181. // Listen for messages on the BroadcastChannel
  182. bc.onmessage = (event) => {
  183. if (event.data === 'active') {
  184. isLastActiveTab.set(false); // Another tab became active
  185. }
  186. };
  187. // Set yourself as the last active tab when this tab is focused
  188. const handleVisibilityChange = () => {
  189. if (document.visibilityState === 'visible') {
  190. isLastActiveTab.set(true); // This tab is now the active tab
  191. bc.postMessage('active'); // Notify other tabs that this tab is active
  192. }
  193. };
  194. // Add event listener for visibility state changes
  195. document.addEventListener('visibilitychange', handleVisibilityChange);
  196. // Call visibility change handler initially to set state on load
  197. handleVisibilityChange();
  198. theme.set(localStorage.theme);
  199. mobile.set(window.innerWidth < BREAKPOINT);
  200. const onResize = () => {
  201. if (window.innerWidth < BREAKPOINT) {
  202. mobile.set(true);
  203. } else {
  204. mobile.set(false);
  205. }
  206. };
  207. window.addEventListener('resize', onResize);
  208. let backendConfig = null;
  209. try {
  210. backendConfig = await getBackendConfig();
  211. console.log('Backend config:', backendConfig);
  212. } catch (error) {
  213. console.error('Error loading backend config:', error);
  214. }
  215. // Initialize i18n even if we didn't get a backend config,
  216. // so `/error` can show something that's not `undefined`.
  217. initI18n();
  218. if (!localStorage.locale) {
  219. const languages = await getLanguages();
  220. const browserLanguages = navigator.languages
  221. ? navigator.languages
  222. : [navigator.language || navigator.userLanguage];
  223. const lang = backendConfig.default_locale
  224. ? backendConfig.default_locale
  225. : bestMatchingLanguage(languages, browserLanguages, 'en-US');
  226. $i18n.changeLanguage(lang);
  227. }
  228. if (backendConfig) {
  229. // Save Backend Status to Store
  230. await config.set(backendConfig);
  231. await WEBUI_NAME.set(backendConfig.name);
  232. if ($config) {
  233. await setupSocket($config.features?.enable_websocket ?? true);
  234. if (localStorage.token) {
  235. // Get Session User Info
  236. const sessionUser = await getSessionUser(localStorage.token).catch((error) => {
  237. toast.error(error);
  238. return null;
  239. });
  240. if (sessionUser) {
  241. // Save Session User to Store
  242. $socket.emit('user-join', { auth: { token: sessionUser.token } });
  243. $socket?.on('chat-events', chatEventHandler);
  244. $socket?.on('channel-events', channelEventHandler);
  245. await user.set(sessionUser);
  246. await config.set(await getBackendConfig());
  247. } else {
  248. // Redirect Invalid Session User to /auth Page
  249. localStorage.removeItem('token');
  250. await goto('/auth');
  251. }
  252. } else {
  253. // Don't redirect if we're already on the auth page
  254. // Needed because we pass in tokens from OAuth logins via URL fragments
  255. if ($page.url.pathname !== '/auth') {
  256. await goto('/auth');
  257. }
  258. }
  259. }
  260. } else {
  261. // Redirect to /error when Backend Not Detected
  262. await goto(`/error`);
  263. }
  264. await tick();
  265. if (
  266. document.documentElement.classList.contains('her') &&
  267. document.getElementById('progress-bar')
  268. ) {
  269. loadingProgress.subscribe((value) => {
  270. const progressBar = document.getElementById('progress-bar');
  271. if (progressBar) {
  272. progressBar.style.width = `${value}%`;
  273. }
  274. });
  275. await loadingProgress.set(100);
  276. document.getElementById('splash-screen')?.remove();
  277. const audio = new Audio(`/audio/greeting.mp3`);
  278. const playAudio = () => {
  279. audio.play();
  280. document.removeEventListener('click', playAudio);
  281. };
  282. document.addEventListener('click', playAudio);
  283. loaded = true;
  284. } else {
  285. document.getElementById('splash-screen')?.remove();
  286. loaded = true;
  287. }
  288. return () => {
  289. window.removeEventListener('resize', onResize);
  290. };
  291. });
  292. </script>
  293. <svelte:head>
  294. <title>{$WEBUI_NAME}</title>
  295. <link crossorigin="anonymous" rel="icon" href="{WEBUI_BASE_URL}/static/favicon.png" />
  296. <!-- rosepine themes have been disabled as it's not up to date with our latest version. -->
  297. <!-- feel free to make a PR to fix if anyone wants to see it return -->
  298. <!-- <link rel="stylesheet" type="text/css" href="/themes/rosepine.css" />
  299. <link rel="stylesheet" type="text/css" href="/themes/rosepine-dawn.css" /> -->
  300. </svelte:head>
  301. {#if loaded}
  302. {#if $isApp}
  303. <div class="flex flex-row h-screen">
  304. <AppControls />
  305. <div class="w-full flex-1 max-w-[calc(100%-4.5rem)]">
  306. <slot />
  307. </div>
  308. </div>
  309. {:else}
  310. <slot />
  311. {/if}
  312. {/if}
  313. <Toaster
  314. theme={$theme.includes('dark')
  315. ? 'dark'
  316. : $theme === 'system'
  317. ? window.matchMedia('(prefers-color-scheme: dark)').matches
  318. ? 'dark'
  319. : 'light'
  320. : 'light'}
  321. richColors
  322. position="top-right"
  323. />