diff --git a/Dockerfile b/Dockerfile index 1e8361fd2..9521c600e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,6 @@ ARG OLLAMA_API_BASE_URL='/ollama/api' ENV ENV=prod ENV OLLAMA_API_BASE_URL $OLLAMA_API_BASE_URL ENV WEBUI_AUTH "" -ENV WEBUI_DB_URL "" ENV WEBUI_JWT_SECRET_KEY "SECRET_KEY" WORKDIR /app diff --git a/backend/apps/ollama/main.py b/backend/apps/ollama/main.py index 98048ca53..a2a09fc79 100644 --- a/backend/apps/ollama/main.py +++ b/backend/apps/ollama/main.py @@ -59,9 +59,11 @@ def proxy(path): else: pass + r = None + try: # Make a request to the target server - target_response = requests.request( + r = requests.request( method=request.method, url=target_url, data=data, @@ -69,22 +71,37 @@ def proxy(path): stream=True, # Enable streaming for server-sent events ) - target_response.raise_for_status() + r.raise_for_status() # Proxy the target server's response to the client def generate(): - for chunk in target_response.iter_content(chunk_size=8192): + for chunk in r.iter_content(chunk_size=8192): yield chunk - response = Response(generate(), status=target_response.status_code) + response = Response(generate(), status=r.status_code) # Copy headers from the target server's response to the client's response - for key, value in target_response.headers.items(): + for key, value in r.headers.items(): response.headers[key] = value return response except Exception as e: - return jsonify({"detail": "Server Connection Error", "message": str(e)}), 400 + error_detail = "Ollama WebUI: Server Connection Error" + if r != None: + res = r.json() + if "error" in res: + error_detail = f"Ollama: {res['error']}" + print(res) + + return ( + jsonify( + { + "detail": error_detail, + "message": str(e), + } + ), + 400, + ) if __name__ == "__main__": diff --git a/backend/config.py b/backend/config.py index 14ad30e4b..1dabe48ae 100644 --- a/backend/config.py +++ b/backend/config.py @@ -30,7 +30,7 @@ if ENV == "prod": # WEBUI_VERSION #################################### -WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.21") +WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.33") #################################### # WEBUI_AUTH @@ -41,7 +41,7 @@ WEBUI_AUTH = True if os.environ.get("WEBUI_AUTH", "FALSE") == "TRUE" else False #################################### -# WEBUI_DB +# WEBUI_DB (Deprecated, Should be removed) #################################### diff --git a/package-lock.json b/package-lock.json index 7003749c0..bcfc72538 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "katex": "^0.16.9", "marked": "^9.1.0", "svelte-french-toast": "^1.2.0", + "tippy.js": "^6.3.7", "uuid": "^9.0.1" }, "devDependencies": { @@ -584,6 +585,15 @@ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.23.tgz", "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==" }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "25.0.5", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.5.tgz", @@ -3994,6 +4004,14 @@ "globrex": "^0.1.2" } }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4160,9 +4178,9 @@ } }, "node_modules/vite": { - "version": "4.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.11.tgz", - "integrity": "sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.1.tgz", + "integrity": "sha512-AXXFaAJ8yebyqzoNB9fu2pHoo/nWX+xZlaRwoeYUxEqBO+Zj4msE5G+BhGBll9lYEKv9Hfks52PAF2X7qDYXQA==", "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", @@ -4570,6 +4588,11 @@ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.23.tgz", "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==" }, + "@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" + }, "@rollup/plugin-commonjs": { "version": "25.0.5", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.5.tgz", @@ -6885,6 +6908,14 @@ "globrex": "^0.1.2" } }, + "tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "requires": { + "@popperjs/core": "^2.9.0" + } + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6991,9 +7022,9 @@ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" }, "vite": { - "version": "4.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.11.tgz", - "integrity": "sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.1.tgz", + "integrity": "sha512-AXXFaAJ8yebyqzoNB9fu2pHoo/nWX+xZlaRwoeYUxEqBO+Zj4msE5G+BhGBll9lYEKv9Hfks52PAF2X7qDYXQA==", "requires": { "esbuild": "^0.18.10", "fsevents": "~2.3.2", diff --git a/package.json b/package.json index 5b1a89eca..f90b99ad7 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "katex": "^0.16.9", "marked": "^9.1.0", "svelte-french-toast": "^1.2.0", + "tippy.js": "^6.3.7", "uuid": "^9.0.1" } -} \ No newline at end of file +} diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index bc3a6a9d2..172485ca2 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -161,7 +161,7 @@ <div class="ml-2 mt-2 mb-1 flex space-x-2"> {#each files as file, fileIdx} <div class=" relative group"> - <img src={file.url} alt="input" class=" h-16 w-16 rounded-xl bg-cover" /> + <img src={file.url} alt="input" class=" h-16 w-16 rounded-xl object-cover" /> <div class=" absolute -top-1 -right-1"> <button @@ -235,6 +235,30 @@ e.target.style.height = ''; e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px'; }} + on:paste={(e) => { + const clipboardData = e.clipboardData || window.clipboardData; + + if (clipboardData && clipboardData.items) { + for (const item of clipboardData.items) { + if (item.type.indexOf('image') !== -1) { + const blob = item.getAsFile(); + const reader = new FileReader(); + + reader.onload = function (e) { + files = [ + ...files, + { + type: 'image', + url: `${e.target.result}` + } + ]; + }; + + reader.readAsDataURL(blob); + } + } + } + }} /> <div class="self-end mb-2 flex space-x-0.5 mr-2"> diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index b08f10931..c6d534249 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -2,6 +2,7 @@ import { marked } from 'marked'; import { v4 as uuidv4 } from 'uuid'; + import tippy from 'tippy.js'; import hljs from 'highlight.js'; import 'highlight.js/styles/github-dark.min.css'; import auto_render from 'katex/dist/contrib/auto-render.mjs'; @@ -29,6 +30,35 @@ renderLatex(); hljs.highlightAll(); createCopyCodeBlockButton(); + + for (const message of messages) { + if (message.info) { + tippy(`#info-${message.id}`, { + content: `<span class="text-xs">token/s: ${ + `${ + Math.round( + ((message.info.eval_count ?? 0) / (message.info.eval_duration / 1000000000)) * 100 + ) / 100 + } tokens` ?? 'N/A' + }<br/> + total_duration: ${ + Math.round(((message.info.total_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A' + }ms<br/> + load_duration: ${ + Math.round(((message.info.load_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A' + }ms<br/> + prompt_eval_count: ${message.info.prompt_eval_count ?? 'N/A'}<br/> + prompt_eval_duration: ${ + Math.round(((message.info.prompt_eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A' + }ms<br/> + eval_count: ${message.info.eval_count ?? 'N/A'}<br/> + eval_duration: ${ + Math.round(((message.info.eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A' + }ms</span>`, + allowHTML: true + }); + } + } })(); } @@ -861,6 +891,33 @@ </svg> </button> + {#if message.info} + <button + class=" {messageIdx + 1 === messages.length + ? 'visible' + : 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition whitespace-pre-wrap" + on:click={() => { + console.log(message); + }} + id="info-{message.id}" + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-4 h-4" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" + /> + </svg> + </button> + {/if} + {#if messageIdx + 1 === messages.length} <button type="button" diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte index fe17475e6..67617218b 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -4,7 +4,7 @@ import { WEB_UI_VERSION, OLLAMA_API_BASE_URL } from '$lib/constants'; import toast from 'svelte-french-toast'; import { onMount } from 'svelte'; - import { config, models, settings, user } from '$lib/stores'; + import { config, info, models, settings, user } from '$lib/stores'; import { splitStream, getGravatarURL } from '$lib/utils'; import Advanced from './Settings/Advanced.svelte'; @@ -22,6 +22,7 @@ // General let API_BASE_URL = OLLAMA_API_BASE_URL; let theme = 'dark'; + let notificationEnabled = false; let system = ''; // Advanced @@ -51,6 +52,8 @@ // Addons let titleAutoGenerate = true; let speechAutoSend = false; + let responseAutoCopy = false; + let gravatarEmail = ''; let OPENAI_API_KEY = ''; @@ -108,6 +111,41 @@ saveSettings({ titleAutoGenerate: titleAutoGenerate }); }; + const toggleNotification = async () => { + const permission = await Notification.requestPermission(); + + if (permission === 'granted') { + notificationEnabled = !notificationEnabled; + saveSettings({ notificationEnabled: notificationEnabled }); + } else { + toast.error( + 'Response notifications cannot be activated as the website permissions have been denied. Please visit your browser settings to grant the necessary access.' + ); + } + }; + + const toggleResponseAutoCopy = async () => { + const permission = await navigator.clipboard + .readText() + .then(() => { + return 'granted'; + }) + .catch(() => { + return ''; + }); + + console.log(permission); + + if (permission === 'granted') { + responseAutoCopy = !responseAutoCopy; + saveSettings({ responseAutoCopy: responseAutoCopy }); + } else { + toast.error( + 'Clipboard write permission denied. Please check your browser settings to grant the necessary access.' + ); + } + }; + const toggleAuthHeader = async () => { authEnabled = !authEnabled; }; @@ -153,6 +191,13 @@ if (data.status) { if (!data.digest) { toast.success(data.status); + + if (data.status === 'success') { + const notification = new Notification(`Ollama`, { + body: `Model '${modelTag}' has been successfully downloaded.`, + icon: '/favicon.png' + }); + } } else { digest = data.digest; if (data.completed) { @@ -297,6 +342,8 @@ console.log(settings); theme = localStorage.theme ?? 'dark'; + notificationEnabled = settings.notificationEnabled ?? false; + API_BASE_URL = settings.API_BASE_URL ?? OLLAMA_API_BASE_URL; system = settings.system ?? ''; @@ -312,6 +359,8 @@ titleAutoGenerate = settings.titleAutoGenerate ?? true; speechAutoSend = settings.speechAutoSend ?? false; + responseAutoCopy = settings.responseAutoCopy ?? false; + gravatarEmail = settings.gravatarEmail ?? ''; OPENAI_API_KEY = settings.OPENAI_API_KEY ?? ''; @@ -509,8 +558,10 @@ {#if selectedTab === 'general'} <div class="flex flex-col space-y-3"> <div> - <div class=" py-1 flex w-full justify-between"> - <div class=" self-center text-sm font-medium">Theme</div> + <div class=" mb-1 text-sm font-medium">WebUI Settings</div> + + <div class=" py-0.5 flex w-full justify-between"> + <div class=" self-center text-xs font-medium">Theme</div> <button class="p-1 px-3 text-xs flex rounded transition" @@ -548,6 +599,26 @@ {/if} </button> </div> + + <div> + <div class=" py-0.5 flex w-full justify-between"> + <div class=" self-center text-xs font-medium">Notification</div> + + <button + class="p-1 px-3 text-xs flex rounded transition" + on:click={() => { + toggleNotification(); + }} + type="button" + > + {#if notificationEnabled === true} + <span class="ml-2 self-center">On</span> + {:else} + <span class="ml-2 self-center">Off</span> + {/if} + </button> + </div> + </div> </div> <hr class=" dark:border-gray-700" /> @@ -802,44 +873,68 @@ > <div class=" space-y-3"> <div> - <div class=" py-1 flex w-full justify-between"> - <div class=" self-center text-sm font-medium">Title Auto Generation</div> + <div class=" mb-1 text-sm font-medium">WebUI Add-ons</div> - <button - class="p-1 px-3 text-xs flex rounded transition" - on:click={() => { - toggleTitleAutoGenerate(); - }} - type="button" - > - {#if titleAutoGenerate === true} - <span class="ml-2 self-center">On</span> - {:else} - <span class="ml-2 self-center">Off</span> - {/if} - </button> + <div> + <div class=" py-0.5 flex w-full justify-between"> + <div class=" self-center text-xs font-medium">Title Auto Generation</div> + + <button + class="p-1 px-3 text-xs flex rounded transition" + on:click={() => { + toggleTitleAutoGenerate(); + }} + type="button" + > + {#if titleAutoGenerate === true} + <span class="ml-2 self-center">On</span> + {:else} + <span class="ml-2 self-center">Off</span> + {/if} + </button> + </div> </div> - </div> - <hr class=" dark:border-gray-700" /> + <div> + <div class=" py-0.5 flex w-full justify-between"> + <div class=" self-center text-xs font-medium">Voice Input Auto-Send</div> - <div> - <div class=" py-1 flex w-full justify-between"> - <div class=" self-center text-sm font-medium">Voice Input Auto-Send</div> + <button + class="p-1 px-3 text-xs flex rounded transition" + on:click={() => { + toggleSpeechAutoSend(); + }} + type="button" + > + {#if speechAutoSend === true} + <span class="ml-2 self-center">On</span> + {:else} + <span class="ml-2 self-center">Off</span> + {/if} + </button> + </div> + </div> - <button - class="p-1 px-3 text-xs flex rounded transition" - on:click={() => { - toggleSpeechAutoSend(); - }} - type="button" - > - {#if speechAutoSend === true} - <span class="ml-2 self-center">On</span> - {:else} - <span class="ml-2 self-center">Off</span> - {/if} - </button> + <div> + <div class=" py-0.5 flex w-full justify-between"> + <div class=" self-center text-xs font-medium"> + Response AutoCopy to Clipboard + </div> + + <button + class="p-1 px-3 text-xs flex rounded transition" + on:click={() => { + toggleResponseAutoCopy(); + }} + type="button" + > + {#if responseAutoCopy === true} + <span class="ml-2 self-center">On</span> + {:else} + <span class="ml-2 self-center">Off</span> + {/if} + </button> + </div> </div> </div> @@ -1029,6 +1124,17 @@ <hr class=" dark:border-gray-700" /> + <div> + <div class=" mb-2.5 text-sm font-medium">Ollama Version</div> + <div class="flex w-full"> + <div class="flex-1 text-xs text-gray-700 dark:text-gray-200"> + {$info?.ollama?.version ?? 'N/A'} + </div> + </div> + </div> + + <hr class=" dark:border-gray-700" /> + <div class="mt-2 text-xs text-gray-400 dark:text-gray-500"> Created by <a class=" text-gray-500 dark:text-gray-300 font-medium" diff --git a/src/lib/components/layout/Navbar.svelte b/src/lib/components/layout/Navbar.svelte index 9bf15a07d..bcd66ee9e 100644 --- a/src/lib/components/layout/Navbar.svelte +++ b/src/lib/components/layout/Navbar.svelte @@ -2,51 +2,102 @@ import { v4 as uuidv4 } from 'uuid'; import { goto } from '$app/navigation'; - import { chatId } from '$lib/stores'; + import { chatId, db, modelfiles } from '$lib/stores'; + import toast from 'svelte-french-toast'; export let title: string = 'Ollama Web UI'; + export let shareEnabled: boolean = false; + + const shareChat = async () => { + const chat = await $db.getChatById($chatId); + console.log('share', chat); + toast.success('Redirecting you to OllamaHub'); + + const url = 'https://ollamahub.com'; + // const url = 'http://localhost:5173'; + + const tab = await window.open(`${url}/chats/upload`, '_blank'); + window.addEventListener( + 'message', + (event) => { + if (event.origin !== url) return; + if (event.data === 'loaded') { + tab.postMessage( + JSON.stringify({ + chat: chat, + modelfiles: $modelfiles.filter((modelfile) => chat.models.includes(modelfile.tagName)) + }), + '*' + ); + } + }, + false + ); + }; </script> -<div - class=" fixed top-0 flex flex-row justify-center bg-white/95 dark:bg-gray-800/90 dark:text-gray-200 backdrop-blur-xl w-full z-30" +<nav + id="nav" + class=" fixed py-2.5 top-0 flex flex-row justify-center bg-white/95 dark:bg-gray-800/90 dark:text-gray-200 backdrop-blur-xl w-screen z-30" > - <div class="basis-full"> - <nav class="py-3" id="nav"> - <div class=" flex max-w-3xl mx-auto px-3"> - <div class="flex w-full max-w-full overflow-hidden text-ellipsis whitespace-nowrap"> - <div class="pr-2"> - <button - class=" cursor-pointer p-1 flex dark:hover:bg-gray-700 rounded-lg transition" - on:click={async () => { - console.log('newChat'); - goto('/'); - await chatId.set(uuidv4()); - }} + <div class=" flex max-w-3xl w-full mx-auto px-3"> + <div class="flex w-full max-w-full"> + <div class="pr-2 self-center"> + <button + class=" cursor-pointer p-1 flex dark:hover:bg-gray-700 rounded-lg transition" + on:click={async () => { + console.log('newChat'); + goto('/'); + await chatId.set(uuidv4()); + }} + > + <div class=" m-auto self-center"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + fill="currentColor" + class="w-5 h-5" > - <div class=" m-auto self-center"> - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 20 20" - fill="currentColor" - class="w-5 h-5" - > - <path - d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" - /> - <path - d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" - /> - </svg> - </div> - </button> + <path + d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" + /> + <path + d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" + /> + </svg> </div> - <div - class=" flex-1 self-center font-medium overflow-hidden text-ellipsis whitespace-nowrap w-[80vw] pr-4" - > - {title != '' ? title : 'Ollama Web UI'} - </div> - </div> + </button> </div> - </nav> + <div class=" flex-1 self-center font-medium text-ellipsis whitespace-nowrap overflow-hidden"> + {title != '' ? title : 'Ollama Web UI'} + </div> + + {#if shareEnabled} + <div class="pl-2"> + <button + class=" cursor-pointer p-2 flex dark:hover:bg-gray-700 rounded-lg transition border dark:border-gray-600" + on:click={async () => { + shareChat(); + }} + > + <div class=" m-auto self-center"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + fill="currentColor" + class="w-4 h-4" + > + <path + d="M9.25 13.25a.75.75 0 001.5 0V4.636l2.955 3.129a.75.75 0 001.09-1.03l-4.25-4.5a.75.75 0 00-1.09 0l-4.25 4.5a.75.75 0 101.09 1.03L9.25 4.636v8.614z" + /> + <path + d="M3.5 12.75a.75.75 0 00-1.5 0v2.5A2.75 2.75 0 004.75 18h10.5A2.75 2.75 0 0018 15.25v-2.5a.75.75 0 00-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5z" + /> + </svg> + </div> + </button> + </div> + {/if} + </div> </div> -</div> +</nav> diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 342e64943..9962b4ac7 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -1,6 +1,7 @@ import { writable } from 'svelte/store'; // Backend +export const info = writable({}); export const config = writable(undefined); export const user = writable(undefined); diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 220969949..2d9f1b31e 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -65,3 +65,38 @@ export const getGravatarURL = (email) => { // Grab the actual image URL return `https://www.gravatar.com/avatar/${hash}`; }; + +const copyToClipboard = (text) => { + if (!navigator.clipboard) { + var textArea = document.createElement('textarea'); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.position = 'fixed'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + var successful = document.execCommand('copy'); + var msg = successful ? 'successful' : 'unsuccessful'; + console.log('Fallback: Copying text command was ' + msg); + } catch (err) { + console.error('Fallback: Oops, unable to copy', err); + } + + document.body.removeChild(textArea); + return; + } + navigator.clipboard.writeText(text).then( + function () { + console.log('Async: Copying to clipboard was successful!'); + }, + function (err) { + console.error('Async: Could not copy text: ', err); + } + ); +}; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index e837741e5..4f4774232 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -6,6 +6,7 @@ import { config, + info, user, showSettings, settings, @@ -21,6 +22,7 @@ import toast from 'svelte-french-toast'; import { OLLAMA_API_BASE_URL, WEBUI_API_BASE_URL } from '$lib/constants'; + let requiredOllamaVersion = '0.1.16'; let loaded = false; const getModels = async () => { @@ -160,33 +162,116 @@ }; }; + const getOllamaVersion = async () => { + const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/version`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...($settings.authHeader && { Authorization: $settings.authHeader }), + ...($user && { Authorization: `Bearer ${localStorage.token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((error) => { + console.log(error); + if ('detail' in error) { + toast.error(error.detail); + } else { + toast.error('Server connection failed'); + } + return null; + }); + + console.log(res); + + return res?.version ?? '0'; + }; + + const setOllamaVersion = async (ollamaVersion) => { + await info.set({ ...$info, ollama: { version: ollamaVersion } }); + + if ( + ollamaVersion.localeCompare(requiredOllamaVersion, undefined, { + numeric: true, + sensitivity: 'case', + caseFirst: 'upper' + }) < 0 + ) { + toast.error(`Ollama Version: ${ollamaVersion}`); + } + }; + onMount(async () => { if ($config && $config.auth && $user === undefined) { await goto('/auth'); } - await settings.set(JSON.parse(localStorage.getItem('settings') ?? JSON.stringify($settings))); + await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}')); - let _models = await getModels(); - await models.set(_models); - let _db = await getDB(); - await db.set(_db); - - await modelfiles.set( - JSON.parse(localStorage.getItem('modelfiles') ?? JSON.stringify($modelfiles)) - ); + await models.set(await getModels()); + await modelfiles.set(JSON.parse(localStorage.getItem('modelfiles') ?? '[]')); modelfiles.subscribe(async () => { await models.set(await getModels()); }); + let _db = await getDB(); + await db.set(_db); + + await setOllamaVersion(await getOllamaVersion()); + await tick(); loaded = true; }); </script> {#if loaded} - <div class="app"> + <div class="app relative"> + {#if ($info?.ollama?.version ?? '0').localeCompare( requiredOllamaVersion, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' } ) < 0} + <div class="absolute w-full h-full flex z-50"> + <div + class="absolute rounded-xl w-full h-full backdrop-blur bg-gray-900/60 flex justify-center" + > + <div class="m-auto pb-44"> + <div class="text-center dark:text-white text-2xl font-medium z-50"> + Ollama Update Required + </div> + + <div class=" mt-4 text-center max-w-md text-sm dark:text-gray-200"> + Oops! It seems like your Ollama needs a little attention. <br + class=" hidden sm:flex" + /> + We encountered a connection issue or noticed that you're running an outdated version. Please + update to + <span class=" dark:text-white font-medium">{requiredOllamaVersion} or above</span>. + </div> + + <div class=" mt-6 mx-auto relative group w-fit"> + <button + class="relative z-20 flex px-5 py-2 rounded-full bg-gray-100 hover:bg-gray-200 transition font-medium text-sm" + on:click={async () => { + await setOllamaVersion(await getOllamaVersion()); + }} + > + Check Again + </button> + + <button + class="text-xs text-center w-full mt-2 text-gray-400 underline" + on:click={async () => { + await setOllamaVersion(requiredOllamaVersion); + }}>Close</button + > + </div> + </div> + </div> + </div> + {/if} + <div class=" text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-800 min-h-screen overflow-auto flex flex-row" > diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 7c0c87b4d..d0b83b80d 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -84,11 +84,45 @@ let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}'); console.log(_settings); settings.set({ - ...$settings, ..._settings }); }; + const copyToClipboard = (text) => { + if (!navigator.clipboard) { + var textArea = document.createElement('textarea'); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.position = 'fixed'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + var successful = document.execCommand('copy'); + var msg = successful ? 'successful' : 'unsuccessful'; + console.log('Fallback: Copying text command was ' + msg); + } catch (err) { + console.error('Fallback: Oops, unable to copy', err); + } + + document.body.removeChild(textArea); + return; + } + navigator.clipboard.writeText(text).then( + function () { + console.log('Async: Copying to clipboard was successful!'); + }, + function (err) { + console.error('Async: Could not copy text: ', err); + } + ); + }; + ////////////////////////// // Ollama functions ////////////////////////// @@ -213,12 +247,34 @@ responseMessage.context = data.context ?? null; responseMessage.info = { total_duration: data.total_duration, + load_duration: data.load_duration, + sample_count: data.sample_count, + sample_duration: data.sample_duration, prompt_eval_count: data.prompt_eval_count, prompt_eval_duration: data.prompt_eval_duration, eval_count: data.eval_count, eval_duration: data.eval_duration }; messages = messages; + + if ($settings.notificationEnabled && !document.hasFocus()) { + const notification = new Notification( + selectedModelfile + ? `${ + selectedModelfile.title.charAt(0).toUpperCase() + + selectedModelfile.title.slice(1) + }` + : `Ollama - ${model}`, + { + body: responseMessage.content, + icon: selectedModelfile?.imageUrl ?? '/favicon.png' + } + ); + } + + if ($settings.responseAutoCopy) { + copyToClipboard(responseMessage.content); + } } } } @@ -423,6 +479,18 @@ stopResponseFlag = false; await tick(); + + if ($settings.notificationEnabled && !document.hasFocus()) { + const notification = new Notification(`OpenAI ${model}`, { + body: responseMessage.content, + icon: '/favicon.png' + }); + } + + if ($settings.responseAutoCopy) { + copyToClipboard(responseMessage.content); + } + if (autoScroll) { window.scrollTo({ top: document.body.scrollHeight }); } @@ -566,7 +634,7 @@ }} /> -<Navbar {title} /> +<Navbar {title} shareEnabled={messages.length > 0} /> <div class="min-h-screen w-full flex justify-center"> <div class=" py-2.5 flex flex-col justify-between w-full"> <div class="max-w-2xl mx-auto w-full px-3 md:px-0 mt-10"> diff --git a/src/routes/(app)/c/[id]/+page.svelte b/src/routes/(app)/c/[id]/+page.svelte index f108ba222..bf7207fb3 100644 --- a/src/routes/(app)/c/[id]/+page.svelte +++ b/src/routes/(app)/c/[id]/+page.svelte @@ -82,10 +82,11 @@ : convertMessagesToHistory(chat.messages); title = chat.title; + let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}'); await settings.set({ - ...$settings, - system: chat.system ?? $settings.system, - options: chat.options ?? $settings.options + ..._settings, + system: chat.system ?? _settings.system, + options: chat.options ?? _settings.options }); autoScroll = true; @@ -101,6 +102,41 @@ } }; + const copyToClipboard = (text) => { + if (!navigator.clipboard) { + var textArea = document.createElement('textarea'); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.position = 'fixed'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + var successful = document.execCommand('copy'); + var msg = successful ? 'successful' : 'unsuccessful'; + console.log('Fallback: Copying text command was ' + msg); + } catch (err) { + console.error('Fallback: Oops, unable to copy', err); + } + + document.body.removeChild(textArea); + return; + } + navigator.clipboard.writeText(text).then( + function () { + console.log('Async: Copying to clipboard was successful!'); + }, + function (err) { + console.error('Async: Could not copy text: ', err); + } + ); + }; + ////////////////////////// // Ollama functions ////////////////////////// @@ -225,12 +261,34 @@ responseMessage.context = data.context ?? null; responseMessage.info = { total_duration: data.total_duration, + load_duration: data.load_duration, + sample_count: data.sample_count, + sample_duration: data.sample_duration, prompt_eval_count: data.prompt_eval_count, prompt_eval_duration: data.prompt_eval_duration, eval_count: data.eval_count, eval_duration: data.eval_duration }; messages = messages; + + if ($settings.notificationEnabled && !document.hasFocus()) { + const notification = new Notification( + selectedModelfile + ? `${ + selectedModelfile.title.charAt(0).toUpperCase() + + selectedModelfile.title.slice(1) + }` + : `Ollama - ${model}`, + { + body: responseMessage.content, + icon: selectedModelfile?.imageUrl ?? '/favicon.png' + } + ); + } + + if ($settings.responseAutoCopy) { + copyToClipboard(responseMessage.content); + } } } } @@ -435,6 +493,18 @@ stopResponseFlag = false; await tick(); + + if ($settings.notificationEnabled && !document.hasFocus()) { + const notification = new Notification(`OpenAI ${model}`, { + body: responseMessage.content, + icon: '/favicon.png' + }); + } + + if ($settings.responseAutoCopy) { + copyToClipboard(responseMessage.content); + } + if (autoScroll) { window.scrollTo({ top: document.body.scrollHeight }); } @@ -579,7 +649,7 @@ /> {#if loaded} - <Navbar {title} /> + <Navbar {title} shareEnabled={messages.length > 0} /> <div class="min-h-screen w-full flex justify-center"> <div class=" py-2.5 flex flex-col justify-between w-full"> <div class="max-w-2xl mx-auto w-full px-3 md:px-0 mt-10"> diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 199265536..7479f5598 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,7 +7,7 @@ import '../app.css'; import '../tailwind.css'; - + import 'tippy.js/dist/tippy.css'; let loaded = false; onMount(async () => {