diff --git a/Dockerfile b/Dockerfile index eb8848dd..7d1e0d5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:20-alpine WORKDIR /app -ENV ADAPTER='docker-node' +ENV PUBLIC_ADAPTER='docker-node' COPY . . diff --git a/README.md b/README.md index b64bdd4e..dc70bb18 100644 --- a/README.md +++ b/README.md @@ -20,27 +20,9 @@ A minimal web-UI for talking to [Ollama](https://github.com/jmorganca/ollama/) s - ⚡️ [Live demo](https://hollama.fernando.is) - _No sign-up required_ - 🖥️ Download for [macOS, Windows & Linux](https://github.com/fmaclen/hollama/releases) -- 🐳 [Self-hosting](#self-host-docker) with Docker +- 🐳 [Self-hosting](SELF_HOSTING.md) with Docker - 🐞 [Contribute](CONTRIBUTING.md) | ![session](tests/docs.test.ts-snapshots/session.png) | ![settings](tests/docs.test.ts-snapshots/settings.png) | | ------------------------------------------------------------ | -------------------------------------------------------- | | ![session-new](tests/docs.test.ts-snapshots/session-new.png) | ![knowledge](tests/docs.test.ts-snapshots/knowledge.png) | - -### Self-host (Docker) - -To host your own Hollama server, [install Docker](https://www.docker.com/products/docker-desktop/) and run the command below in your favorite terminal: - -```shell -docker run --rm -d -p 4173:4173 ghcr.io/fmaclen/hollama:latest -``` - -Then visit [http://localhost:4173](http://localhost:4173). - -#### Connecting to an Ollama server - -If you are using the publicly hosted version or your Docker server is on a separate device than the Ollama server you'll have to set the domain in `OLLAMA_ORIGINS`. [Learn more in Ollama's docs](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server). - -```bash -OLLAMA_ORIGINS=https://hollama.fernando.is ollama serve -``` diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md new file mode 100644 index 00000000..17b0857b --- /dev/null +++ b/SELF_HOSTING.md @@ -0,0 +1,43 @@ +# Self-hosting (with Docker) + +- [Getting started](#getting-started) +- [Updating to the latest version](#updating-to-the-latest-version) +- [Connecting to an Ollama server hosted elsewhere](#connecting-to-an-ollama-server-hosted-elsewhere) + +## Getting started + +To host your own Hollama server, [install Docker](https://www.docker.com/products/docker-desktop/) and run the command below in your favorite terminal: + +```shell +docker run --rm -d -p 4173:4173 --name hollama ghcr.io/fmaclen/hollama:latest +``` + +Then visit [http://localhost:4173](http://localhost:4173) + +## Updating to the latest version + +To update, first stop the container: + +```shell +docker stop hollama +``` + +Then pull the latest version: + +```shell +docker pull ghcr.io/fmaclen/hollama:latest +``` + +Finally, start the container again: + +```shell +docker run --rm -d -p 4173:4173 --name hollama ghcr.io/fmaclen/hollama:latest +``` + +## Connecting to an Ollama server hosted elsewhere + +If you are using the publicly hosted version or your Docker server is on a separate device than the Ollama server you'll have to set the domain in `OLLAMA_ORIGINS`. [Learn more in Ollama's docs](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server). + +```bash +OLLAMA_ORIGINS=https://hollama.fernando.is ollama serve +``` diff --git a/package-lock.json b/package-lock.json index fd62e948..6feb991b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", "prettier-plugin-tailwindcss": "^0.5.9", + "semver": "^7.6.3", "svelte": "^4.2.7", "svelte-check": "^3.6.0", "svelte-i18next": "^2.2.2", @@ -8122,13 +8123,10 @@ } }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index 3cfff04d..84999cac 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "lint": "prettier --check . && eslint .", "format": "prettier --write .", "electron": "electron .", - "electron:build": "cross-env ADAPTER=electron-node vite build && electron-builder" + "electron:build": "cross-env PUBLIC_ADAPTER=electron-node vite build && electron-builder" }, "devDependencies": { "@playwright/test": "^1.43.0", @@ -49,6 +49,7 @@ "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", "prettier-plugin-tailwindcss": "^0.5.9", + "semver": "^7.6.3", "svelte": "^4.2.7", "svelte-check": "^3.6.0", "svelte-i18next": "^2.2.2", diff --git a/src/app.pcss b/src/app.pcss index 8eee666d..abe87196 100644 --- a/src/app.pcss +++ b/src/app.pcss @@ -4,7 +4,7 @@ @layer base { :root { - --color-primary: 14 100% 65%; + --color-primary: 14 80% 60%; --color-shade-0: 0 0% 100%; --color-shade-1: 0 0% 96%; @@ -29,7 +29,7 @@ } [data-color-theme='dark'] { - --color-primary: 14 100% 65%; + --color-primary: 14 80% 60%; --color-shade-0: 0 0% 10%; --color-shade-1: 0 0% 14%; @@ -53,7 +53,3 @@ --color-positive-muted: 120 100% 10%; } } - -a:not(.button)[target='_blank']:after { - content: ' ↗'; -} diff --git a/src/lib/components/Badge.svelte b/src/lib/components/Badge.svelte index 0b2aebc3..739204b7 100644 --- a/src/lib/components/Badge.svelte +++ b/src/lib/components/Badge.svelte @@ -17,8 +17,6 @@ diff --git a/src/lib/components/FieldSelectModel.svelte b/src/lib/components/FieldSelectModel.svelte index 026c8b3f..e63e79b7 100644 --- a/src/lib/components/FieldSelectModel.svelte +++ b/src/lib/components/FieldSelectModel.svelte @@ -5,14 +5,14 @@ export let disabled: boolean = false; - let value: string = $settingsStore?.ollamaModel || ''; - $: if ($settingsStore) $settingsStore.ollamaModel = value; + let value: string = $settingsStore.ollamaModel || ''; + $: $settingsStore.ollamaModel = value; ({ value: m.name, option: m.name }))} - disabled={disabled || !$settingsStore?.ollamaModels.length} + options={$settingsStore.ollamaModels.map((m) => ({ value: m.name, option: m.name }))} + disabled={disabled || !$settingsStore.ollamaModels.length} bind:value /> diff --git a/src/lib/components/FieldTextEditor.svelte b/src/lib/components/FieldTextEditor.svelte index 6e59fa77..8175bea5 100644 --- a/src/lib/components/FieldTextEditor.svelte +++ b/src/lib/components/FieldTextEditor.svelte @@ -16,7 +16,7 @@ let editorValue: string; // Re-render text editor when theme changes - $: if (container && $settingsStore?.userTheme) renderTextEditor(); + $: if (container) renderTextEditor(); // REF https://thememirror.net/create const hollamaThemeLight = createTheme({ @@ -75,7 +75,7 @@ updateValue, EditorView.lineWrapping, Prec.highest(overrideModEnterKeymap), - $settingsStore?.userTheme === 'dark' ? hollamaThemeDark : hollamaThemeLight + $settingsStore.userTheme === 'dark' ? hollamaThemeDark : hollamaThemeLight ], parent: container }); diff --git a/src/lib/components/P.svelte b/src/lib/components/P.svelte new file mode 100644 index 00000000..380e15ff --- /dev/null +++ b/src/lib/components/P.svelte @@ -0,0 +1,13 @@ +

+ +

+ + diff --git a/src/lib/github.ts b/src/lib/github.ts new file mode 100644 index 00000000..3912652b --- /dev/null +++ b/src/lib/github.ts @@ -0,0 +1,2 @@ +export const GITHUB_RELEASES_API = 'https://api.github.com/repos/fmaclen/hollama/releases'; +export const GITHUB_RELEASES_URL = 'https://github.com/fmaclen/hollama/releases'; diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index cc5b1bbe..81d06409 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -24,7 +24,19 @@ i18next.init({ success: 'Success', error: 'Error', pullingModel: 'Pulling model', + pullModelPlaceholder: 'Model tag (e.g. llama3.1)', modelWasDownloaded: '{{model}} was downloaded', + automatcallyCheckForUpdates: 'Automatically check for updates', + checkNow: 'Check now', + checkingForUpdates: 'Checking for updates...', + couldntCheckForUpdates: "Couldn't check for updates automatically", + isCurrentVersionLatest: 'You are on the latest version', + isLatestVersion: 'A newer version is available', + refreshToUpdate: 'Refresh to update', + howToUpdateDocker: 'How to update Docker container?', + goToDownloads: 'Go to downloads', + goToReleases: 'Go to releases', + releaseHistory: 'Release history', sessionsPage: { new: 'New session', empty: 'No sessions', @@ -54,24 +66,22 @@ i18next.init({ server: 'Server', availableModels: 'Available models', pullModel: 'Pull model', - pullModelPlaceholder: 'Model tag (e.g. llama3.1)', downloadModel: 'Download model', disconnected: 'disconnected', connected: 'connected', dangerZone: 'Danger zone', deleteAllSessions: 'Delete all sessions', deleteAllKnowledge: 'Delete all knowledge', - deleteServerSettings: 'Delete server settings', + deleteAllSettings: 'Delete all settings', version: 'Version', allowConnections: 'Change your server settings to allow connections from', - seeDocs: 'see docs', + seeDocs: 'See docs', checkBrowserExtensions: 'Also check no browser extensions are blocking the connection', tryingToConnectNotLocalhost: - 'If you want to connect to an Ollama server that is not available on localhost or 127.0.0.1 you will need to', - createTunnel: 'create a tunnel', - or: 'or', - allowMixedContent: 'allow mixed content', - browseModels: 'Browse the list of available models in', + 'If you want to connect to an Ollama server that is not available on localhost or 127.0.0.1 try', + creatingTunnel: 'Creating a tunnel', + allowMixedContent: 'Allow mixed content', + browseModels: 'Browse the list of available models', ollamaLibrary: "Ollama's library" }, dialogs: { @@ -81,9 +91,9 @@ i18next.init({ }, errors: { genericError: 'Sorry, something went wrong.\n```\n${{error}}\n```', - somethingWentWrong: 'Sorry, something went wrong.', - notFound: 'The page you are looking for does not exist.', - internalServerError: 'There was an internal server error. Please try again later.', + somethingWentWrong: 'Sorry, something went wrong', + notFound: 'The page you are looking for does not exist', + internalServerError: 'There was an internal server error, please try again later', cantConnectToOllamaServer: "Can't connect to Ollama server", couldntConnectToOllamaServer: "Couldn't connect to Ollama server", ollamaConnectionError: "Couldn't connect to Ollama. Is the [server running](/settings)?" diff --git a/src/lib/store.ts b/src/lib/store.ts index 8c16d461..93cc812d 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -1,28 +1,30 @@ -import { browser } from '$app/environment'; +import type { ModelResponse } from 'ollama/browser'; +import { browser, version } from '$app/environment'; import { writable } from 'svelte/store'; import type { Session } from '$lib/sessions'; -import type { OllamaModel } from './ollama'; import type { Knowledge } from './knowledge'; +import { env } from '$env/dynamic/public'; +import type { HollamaMetadata } from '../routes/api/metadata/+server'; -function createLocalStorageStore(key: string, initialValue: T | null = null) { - const localStorageValue: string | null = browser ? window.localStorage.getItem(key) : null; - let value: T | null = initialValue; - const store = writable(initialValue); +function createLocalStorageStore(key: string, defaultValue: T) { + const initialValue: T = browser + ? JSON.parse(localStorage.getItem(key) || 'null') || defaultValue + : defaultValue; - // Read existing value from localStorage - if (localStorageValue) { - value = JSON.parse(localStorageValue); - store.set(value); - } + const store = writable(initialValue); - // Write value to localStorage - store.subscribe((newValue) => { + store.subscribe((value) => { if (browser) { - window.localStorage.setItem(key, JSON.stringify(newValue)); + localStorage.setItem(key, JSON.stringify(value)); } }); - return store; + return { + ...store, + reset: () => { + store.set(defaultValue); + } + }; } export function sortStore(store: T[]) { @@ -39,20 +41,41 @@ export function deleteStoreItem(store: T[], id: string } export const LOCAL_STORAGE_PREFIX = 'hollama'; +export enum StorageKey { + HollamaSettings = `${LOCAL_STORAGE_PREFIX}-settings`, + HollamaSessions = `${LOCAL_STORAGE_PREFIX}-sessions`, + HollamaKnowledge = `${LOCAL_STORAGE_PREFIX}-knowledge` +} export interface Settings { ollamaServer: string | null; ollamaModel: string | null; - ollamaModels: OllamaModel[]; - userTheme?: 'light' | 'dark'; + ollamaModels: ModelResponse[]; + ollamaServerStatus: 'connected' | 'disconnected'; + lastUpdateCheck: number | null; + autoCheckForUpdates: boolean; + userTheme: 'light' | 'dark'; + hollamaMetadata: HollamaMetadata; } -export enum StorageKey { - HollamaSettings = `${LOCAL_STORAGE_PREFIX}-settings`, - HollamaSessions = `${LOCAL_STORAGE_PREFIX}-sessions`, - HollamaKnowledge = `${LOCAL_STORAGE_PREFIX}-knowledge` -} +const defaultSettings: Settings = { + ollamaServer: 'http://localhost:11434', + ollamaModel: null, + ollamaModels: [], + ollamaServerStatus: 'disconnected', + lastUpdateCheck: null, + autoCheckForUpdates: false, + userTheme: 'light', + hollamaMetadata: { + currentVersion: version, + isDesktop: env.PUBLIC_ADAPTER === 'electron-node', + isDocker: env.PUBLIC_ADAPTER === 'docker-node' + } +}; -export const settingsStore = createLocalStorageStore(StorageKey.HollamaSettings); -export const sessionsStore = createLocalStorageStore(StorageKey.HollamaSessions); -export const knowledgeStore = createLocalStorageStore(StorageKey.HollamaKnowledge); +export const settingsStore = createLocalStorageStore( + StorageKey.HollamaSettings, + defaultSettings +); +export const sessionsStore = createLocalStorageStore(StorageKey.HollamaSessions, []); +export const knowledgeStore = createLocalStorageStore(StorageKey.HollamaKnowledge, []); diff --git a/src/lib/updates.ts b/src/lib/updates.ts new file mode 100644 index 00000000..54efae69 --- /dev/null +++ b/src/lib/updates.ts @@ -0,0 +1,106 @@ +import semver from 'semver'; +import { getUnixTime } from 'date-fns'; + +import { get, writable } from 'svelte/store'; +import { version } from '$app/environment'; +import { settingsStore } from '$lib/store'; +import { GITHUB_RELEASES_API } from './github'; +import type { HollamaMetadata } from '../routes/api/metadata/+server'; + +const HOLLAMA_DEV_VERSION_SUFFIX = '-dev'; +const HOLLAMA_METADATA_ENDPOINT = '/api/metadata'; +const ONE_WEEK_IN_SECONDS = 604800; + +export interface UpdateStatus { + canRefreshToUpdate: boolean; + isCurrentVersionLatest: boolean; + isCheckingForUpdates: boolean; + showSidebarNotification: boolean; + couldntCheckForUpdates: boolean; + latestVersion: string; +} + +export const updateStatusStore = writable({ + canRefreshToUpdate: false, + isCurrentVersionLatest: false, + isCheckingForUpdates: false, + showSidebarNotification: false, + couldntCheckForUpdates: false, + latestVersion: '' +}); + +// In development and test environments we append a '-dev' suffix to the version +// to indicate that it's a development version. This function strips the suffix +// so it can be compared using `semver` +function isCurrentVersionLatest(currentVersion: string, latestVersion: string): boolean { + return ( + currentVersion === latestVersion || + semver.gt( + currentVersion.replace(HOLLAMA_DEV_VERSION_SUFFIX, ''), + latestVersion.replace(HOLLAMA_DEV_VERSION_SUFFIX, '') + ) + ); +} + +export async function checkForUpdates(isUserInitiated = false): Promise { + const settings = get(settingsStore); + if (!(settings.autoCheckForUpdates === false)) settings.autoCheckForUpdates = true; + + // If the user hasn't initiated the check we check if the last update check + // was made more than a week ago + const oneWeekAgoInSeconds = getUnixTime(new Date()) - ONE_WEEK_IN_SECONDS; + if (!settings.lastUpdateCheck) settings.lastUpdateCheck = oneWeekAgoInSeconds - 1; + if (!isUserInitiated && settings.lastUpdateCheck > oneWeekAgoInSeconds) return; + + const updateStatus = get(updateStatusStore); + updateStatus.isCheckingForUpdates = true; + + // The server may have been already updated, so we fetch the latest metadata + let hollamaMetadata: Response; + + try { + hollamaMetadata = await fetch(HOLLAMA_METADATA_ENDPOINT); + const metadata = (await hollamaMetadata.json()) as HollamaMetadata; + settings.hollamaMetadata = metadata; + } catch (_) { + console.error('Failed to fetch Hollama server metadata'); + updateStatus.couldntCheckForUpdates = true; + } + + // Determine if the server has been updated, and if so, which version is the latest + updateStatus.latestVersion = settings.hollamaMetadata.currentVersion; + updateStatus.isCurrentVersionLatest = isCurrentVersionLatest(version, updateStatus.latestVersion); + updateStatus.canRefreshToUpdate = !updateStatus.isCurrentVersionLatest; + updateStatus.showSidebarNotification = !updateStatus.isCurrentVersionLatest; + + if (updateStatus.canRefreshToUpdate) { + // The server has been updated, so we let the user know they can refresh to update + updateStatusStore.set(updateStatus); + updateStatus.isCheckingForUpdates = false; + } else { + // The server hasn't been updated, so we check if Github has a newer version + let githubReleases: Response; + + try { + githubReleases = await fetch(GITHUB_RELEASES_API); + const releases = await githubReleases.json(); + if (releases[0]?.tag_name && releases[0].tag_name !== '') + updateStatus.latestVersion = releases[0].tag_name; + } catch (_) { + console.error('Failed to fetch GitHub releases'); + updateStatus.couldntCheckForUpdates = true; + } + + updateStatus.isCurrentVersionLatest = isCurrentVersionLatest( + settings.hollamaMetadata.currentVersion, + updateStatus.latestVersion + ); + updateStatus.showSidebarNotification = !updateStatus.isCurrentVersionLatest; + updateStatus.isCheckingForUpdates = false; + updateStatusStore.set(updateStatus); + } + + // Update the settings store with today's date so we don't check again for updates + settings.lastUpdateCheck = getUnixTime(new Date()); + settingsStore.set(settings); +} diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index 8ae10214..0013872e 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -7,11 +7,11 @@ {#if $page.status === 404} Error {$page.status} - — {$i18n.t('errors.notFound')} + — {$i18n.t('errors.notFound')} + {:else if $page.status !== 200} Error {$page.status} - — {$i18n.t('errors.internalServerError')} + — {$i18n.t('errors.internalServerError')} + {/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d3b375e6..c6492855 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,14 +1,17 @@ @@ -54,7 +62,13 @@ {#each SITEMAP as [href, text]} - + {#if href === '/knowledge'} {:else if href === '/sessions'} @@ -105,11 +119,6 @@ @apply lg:max-h-10 lg:min-w-10; } - .layout__homepage { - @apply col-start-3 row-start-1 flex items-center; - @apply lg:py-4; - } - .layout__button, .layout__a { @apply flex w-auto flex-grow flex-col items-center gap-x-2 gap-y-0.5 py-3 text-xs font-medium text-muted transition-colors duration-150; @@ -128,6 +137,15 @@ @apply lg:px-0 lg:py-6; } + .layout__a--notification { + @apply relative; + } + .layout__a--notification::before { + content: ''; + @apply absolute left-1/2 top-2 h-2 w-2 translate-x-2 rounded-full bg-warning; + @apply lg:left-0 lg:top-1/2 lg:-translate-x-3 lg:-translate-y-1/2; + } + .layout__button { @apply lg:mt-auto; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 8aaea96a..0fdb15d7 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,14 +3,13 @@ import { goto } from '$app/navigation'; import { beforeUpdate } from 'svelte'; - // FIXME: This behavior may belong in hooks.client.ts beforeUpdate(() => { - if (!$settingsStore?.ollamaServer) { - // FIXME: - // This should only rediect to /settings if the server is not connected - goto('/settings'); - } else { + // If the server is connected, redirect to the sessions page + if ($settingsStore.ollamaServerStatus === 'connected') { goto('/sessions'); + } else { + // If the server is not connected, redirect to the settings page + goto('/settings'); } }); diff --git a/src/routes/api/metadata/+server.ts b/src/routes/api/metadata/+server.ts new file mode 100644 index 00000000..0619b2bd --- /dev/null +++ b/src/routes/api/metadata/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import { version } from '$app/environment'; +import { env } from '$env/dynamic/public'; + +export interface HollamaMetadata { + currentVersion: string; + isDocker: boolean; + isDesktop: boolean; +} + +/** @type {import('./$types').RequestHandler} */ +export async function GET() { + return json({ + currentVersion: version, + isDesktop: env.PUBLIC_ADAPTER === 'electron-node', + isDocker: env.PUBLIC_ADAPTER === 'docker-node' + } as HollamaMetadata); +} diff --git a/src/routes/knowledge/[id]/+page.svelte b/src/routes/knowledge/[id]/+page.svelte index 52d038eb..6649d351 100644 --- a/src/routes/knowledge/[id]/+page.svelte +++ b/src/routes/knowledge/[id]/+page.svelte @@ -1,4 +1,5 @@ - + +
-
-

Ollama

- - - - {$i18n.t(`settingsPage.${serverStatus}`)} - - - - - {#if ollamaURL && serverStatus === 'disconnected'} -
- {/if} - - - - - - - - - - -
-

- {$i18n.t('settingsPage.browseModels')} - {$i18n.t('settingsPage.ollamaLibrary')} -

-
-
-
-
- -
-

{$i18n.t('settingsPage.dangerZone')}

- - - -
- -
-

- {$i18n.t('settingsPage.version')} - -

-
+ + +
@@ -248,34 +24,4 @@ .settings__container { @apply my-auto flex flex-col gap-y-4; } - - .about, - .version { - @apply container mx-auto flex max-w-[80ch] flex-col gap-y-2 p-4; - @apply lg:p-6; - } - - .version { - @apply last:py-0 last:text-muted; - } - - .code { - @apply rounded-md p-1 text-active; - } - - .p { - @apply text-sm; - - strong { - @apply font-medium leading-none; - } - } - - .field-help { - @apply my-2 flex flex-col gap-y-3 px-0.5 text-muted; - } - - a { - @apply text-link; - } diff --git a/src/routes/settings/DangerZone.svelte b/src/routes/settings/DangerZone.svelte new file mode 100644 index 00000000..c15bb4bd --- /dev/null +++ b/src/routes/settings/DangerZone.svelte @@ -0,0 +1,33 @@ + + +
+

{$i18n.t('settingsPage.dangerZone')}

+ + + +
diff --git a/src/routes/settings/Ollama.svelte b/src/routes/settings/Ollama.svelte new file mode 100644 index 00000000..7966fe41 --- /dev/null +++ b/src/routes/settings/Ollama.svelte @@ -0,0 +1,206 @@ + + +
+

Ollama

+ + + + {$i18n.t(`settingsPage.${$settingsStore.ollamaServerStatus}`)} + + + + + {#if ollamaURL && $settingsStore.ollamaServerStatus === 'disconnected'} + +

+ {$i18n.t('settingsPage.allowConnections')} + {ollamaURL.origin} + +

+

+ {$i18n.t('settingsPage.checkBrowserExtensions')} +

+ {#if ollamaURL.protocol === 'https:'} +

+ + {@html $i18n.t('settingsPage.tryingToConnectNotLocalhost')} + + {$i18n.t('settingsPage.creatingTunnel')} + + + {$i18n.t('settingsPage.allowMixedContent')} + . +

+ {/if} +
+ {/if} +
+
+ + + + + + + + + +

+ {$i18n.t('settingsPage.browseModels')} + +

+
+
+
+
diff --git a/src/routes/settings/Version.svelte b/src/routes/settings/Version.svelte new file mode 100644 index 00000000..9697e710 --- /dev/null +++ b/src/routes/settings/Version.svelte @@ -0,0 +1,104 @@ + + +
+

Current version

+ +
+
+ {version} + + + + +
+ + + {#if $updateStatusStore.isCheckingForUpdates} +

{$i18n.t('checkingForUpdates')}

+ {:else if $updateStatusStore.couldntCheckForUpdates} +

+ {$i18n.t('couldntCheckForUpdates')} + +

+ {:else if $updateStatusStore.isCurrentVersionLatest} +

+ {$i18n.t('isCurrentVersionLatest')} + +

+ {:else if $updateStatusStore.latestVersion} +

+ {$i18n.t('isLatestVersion')} + {$updateStatusStore.latestVersion} + {#if $updateStatusStore.canRefreshToUpdate} + + {:else if $settingsStore.hollamaMetadata.isDocker} + + {:else} + + {/if} +

+ {/if} +
+
+
+ + diff --git a/svelte.config.js b/svelte.config.js index 3f352a76..2383919b 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -23,7 +23,7 @@ const config = { // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // If your environment is not supported or you settled on a specific environment, switch out the adapter. // See https://kit.svelte.dev/docs/adapters for more information about adapters. - adapter: ['docker-node', 'electron-node'].includes(process.env.ADAPTER) + adapter: ['docker-node', 'electron-node'].includes(process.env.PUBLIC_ADAPTER) ? adapterNode(adapterConfig) : adapterCloudflare(adapterConfig), version: { diff --git a/tailwind.config.js b/tailwind.config.js index 291d5cc4..9249023d 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -62,6 +62,9 @@ const config = { }, '.text-link': { '@apply underline underline-offset-4 hover:text-accent': {} + }, + '.badge': { + '@apply inline-flex max-w-max items-center rounded-md px-2 py-0.5 font-mono text-xs': {} } }); } diff --git a/tests/docs.test.ts-snapshots/knowledge.png b/tests/docs.test.ts-snapshots/knowledge.png index 20efad3b..94cdd3b0 100644 Binary files a/tests/docs.test.ts-snapshots/knowledge.png and b/tests/docs.test.ts-snapshots/knowledge.png differ diff --git a/tests/docs.test.ts-snapshots/motd.png b/tests/docs.test.ts-snapshots/motd.png index 17d1d4be..9eb37289 100644 Binary files a/tests/docs.test.ts-snapshots/motd.png and b/tests/docs.test.ts-snapshots/motd.png differ diff --git a/tests/docs.test.ts-snapshots/session-new.png b/tests/docs.test.ts-snapshots/session-new.png index d092a8b2..252ab42a 100644 Binary files a/tests/docs.test.ts-snapshots/session-new.png and b/tests/docs.test.ts-snapshots/session-new.png differ diff --git a/tests/docs.test.ts-snapshots/session.png b/tests/docs.test.ts-snapshots/session.png index dadae5b2..b7c71954 100644 Binary files a/tests/docs.test.ts-snapshots/session.png and b/tests/docs.test.ts-snapshots/session.png differ diff --git a/tests/docs.test.ts-snapshots/settings.png b/tests/docs.test.ts-snapshots/settings.png index 3b03f288..b868308b 100644 Binary files a/tests/docs.test.ts-snapshots/settings.png and b/tests/docs.test.ts-snapshots/settings.png differ diff --git a/tests/knowledge.test.ts b/tests/knowledge.test.ts index 29c7edaf..933d25a9 100644 --- a/tests/knowledge.test.ts +++ b/tests/knowledge.test.ts @@ -169,7 +169,7 @@ test('all knowledge can be deleted', async ({ page }) => { await page.getByText('Knowledge', { exact: true }).click(); await expect(page.getByText('No knowledge')).toBeVisible(); await expect(page.getByTestId('knowledge-item')).toHaveCount(0); - expect(await page.evaluate(() => window.localStorage.getItem('hollama-knowledge'))).toBe('null'); + expect(await page.evaluate(() => window.localStorage.getItem('hollama-knowledge'))).toBe('[]'); }); test('can use knowledge as system prompt in the session', async ({ page }) => { diff --git a/tests/session.test.ts b/tests/session.test.ts index 1e597f4d..4c979350 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -321,7 +321,7 @@ test.describe('Session', () => { await page.getByText('Sessions', { exact: true }).click(); await expect(page.getByText('No sessions')).toBeVisible(); await expect(page.getByTestId('session-item')).toHaveCount(0); - expect(await page.evaluate(() => window.localStorage.getItem('hollama-sessions'))).toBe('null'); + expect(await page.evaluate(() => window.localStorage.getItem('hollama-sessions'))).toBe('[]'); }); test('can copy the raw text of a message or code snippets to clipboard', async ({ page }) => { diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 71d03363..10e2edae 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -54,25 +54,13 @@ test('handles server status updates correctly', async ({ page }) => { await expect(page.getByText('disconnected')).toHaveClass(/badge--warning/); }); -test('settings can be deleted', async ({ page }) => { - const modelSelect = page.getByLabel('Available models'); - +test('deletes all settings and resets to default values', async ({ page }) => { await page.goto('/'); + const modelSelect = page.getByLabel('Available models'); await expect(modelSelect).toHaveValue(''); - // Stage the settings store with a model - await page.evaluate( - (modelName: string) => - window.localStorage.setItem( - 'hollama-settings', - JSON.stringify({ - ollamaServer: 'http://localhost:3000', - ollamaModel: modelName - }) - ), - MOCK_API_TAGS_RESPONSE.models[1].name - ); - + await page.getByLabel('Server').fill('http://localhost:3000'); + await page.getByLabel('Available models').selectOption(MOCK_API_TAGS_RESPONSE.models[1].name); await page.reload(); await expect(page.getByLabel('Server')).toHaveValue('http://localhost:3000'); await expect(modelSelect).toHaveValue(MOCK_API_TAGS_RESPONSE.models[1].name); @@ -86,7 +74,7 @@ test('settings can be deleted', async ({ page }) => { // Click the delete button page.on('dialog', (dialog) => dialog.accept('Are you sure you want to delete server settings?')); - await page.getByText('Delete server settings').click(); + await page.getByText('Delete all settings').click(); // Wait for page reload await page.waitForFunction(() => { diff --git a/tests/version.test.ts b/tests/version.test.ts new file mode 100644 index 00000000..ff315dc7 --- /dev/null +++ b/tests/version.test.ts @@ -0,0 +1,205 @@ +import { expect, test } from '@playwright/test'; +import { GITHUB_RELEASES_API } from '$lib/github'; +import { mockTagsResponse } from './utils'; + +const currentVersion = process.env.npm_package_version; +const MOCK_NEWER_VERSION = '999.0.0'; + +test.beforeEach(async ({ page }) => { + await mockTagsResponse(page); + + // NOTE: This is an intentionally broken response from the Github releases API + // to ensure we don't hit the real API. + // Every test should mock it's own response based on the test's needs. + await page.route(GITHUB_RELEASES_API, (route) => route.fulfill()); +}); + +test('performs manual update check regardless of auto-update preference', async ({ page }) => { + await page.route(GITHUB_RELEASES_API, (route) => + route.fulfill({ + json: [{ tag_name: MOCK_NEWER_VERSION }] + }) + ); + + await page.goto('/settings'); + const autoUpdateCheckbox = page.getByLabel('Automatically check for updates'); + const checkNowButton = page.getByRole('button', { name: 'Check now' }); + await autoUpdateCheckbox.uncheck(); + await checkNowButton.click(); + await expect(page.getByText(`A newer version is available ${MOCK_NEWER_VERSION}`)).toBeVisible(); + + await autoUpdateCheckbox.check(); + await page.reload(); + await checkNowButton.click(); + await expect(page.getByText(`A newer version is available ${MOCK_NEWER_VERSION}`)).toBeVisible(); +}); + +test('shows "up-to-date" message when on latest version', async ({ page }) => { + await page.route(GITHUB_RELEASES_API, (route) => + route.fulfill({ json: [{ tag_name: currentVersion }] }) + ); + + await page.goto('/settings'); + await expect(page.getByText('You are on the latest version')).not.toBeVisible(); + + await page.getByRole('button', { name: 'Check now' }).click(); + await expect(page.getByText('You are on the latest version')).toBeVisible(); +}); + +test('displays Docker-specific update instructions in Docker environment', async ({ page }) => { + await page.route('**/api/metadata', (route) => + route.fulfill({ + json: { + currentVersion: currentVersion, + isDesktop: false, + isDocker: true + } + }) + ); + + await page.route(GITHUB_RELEASES_API, (route) => + route.fulfill({ json: [{ tag_name: MOCK_NEWER_VERSION }] }) + ); + + await page.goto('/settings'); + const checkNowButton = page.getByRole('button', { name: 'Check now' }); + await checkNowButton.click(); + await expect(page.getByText('How to update Docker container?')).toBeVisible(); +}); + +test('shows download link for updates in Desktop environment', async ({ page }) => { + await page.route('**/api/metadata', (route) => + route.fulfill({ + json: { + currentVersion: currentVersion, + isDesktop: true, + isDocker: false + } + }) + ); + + await page.route(GITHUB_RELEASES_API, (route) => + route.fulfill({ json: [{ tag_name: MOCK_NEWER_VERSION }] }) + ); + + await page.goto('/settings'); + const checkNowButton = page.getByRole('button', { name: 'Check now' }); + await checkNowButton.click(); + await expect(page.getByText('Go to downloads')).toBeVisible(); +}); + +test('shows error message when update check fails', async ({ page }) => { + await page.route(GITHUB_RELEASES_API, (route) => route.abort('failed')); + await page.goto('/settings'); + const checkNowButton = page.getByRole('button', { name: 'Check now' }); + await checkNowButton.click(); + await expect(page.getByText("Couldn't check for updates automatically")).toBeVisible(); + await expect(page.getByRole('link', { name: 'Go to releases' })).toBeVisible(); +}); + +test('performs automatic update check on navigation when enabled', async ({ page }) => { + await page.route('**/api/metadata', (route) => + route.fulfill({ + json: { + currentVersion: currentVersion, + isDesktop: true, + isDocker: false + } + }) + ); + + await page.route(GITHUB_RELEASES_API, (route) => + route.fulfill({ json: [{ tag_name: currentVersion }] }) + ); + + await page.goto('/settings'); + const autoUpdateCheckbox = page.getByLabel('Automatically check for updates'); + let localStorageValue = await page.evaluate(() => + window.localStorage.getItem('hollama-settings') + ); + expect(autoUpdateCheckbox).not.toBeChecked(); + expect(localStorageValue).toContain('"autoCheckForUpdates":false'); + + // Check it toggles the local storage setting + await autoUpdateCheckbox.click(); + localStorageValue = await page.evaluate(() => window.localStorage.getItem('hollama-settings')); + await expect(autoUpdateCheckbox).toBeChecked(); + expect(localStorageValue).toContain('"autoCheckForUpdates":true'); + + await page.route('**/api/metadata', (route) => + route.fulfill({ + json: { + currentVersion: MOCK_NEWER_VERSION, + isDesktop: true, + isDocker: false + } + }) + ); + + // HACK: Due to weird Playwright behavior (likely a race condition), we can't + // we can't use a click event to navigate to the knowledge page, so we have to + // use a page.goto() which refreshes the page. Then we can click on another section. + await page.goto('/knowledge'); + localStorageValue = await page.evaluate(() => window.localStorage.getItem('hollama-settings')); + expect(localStorageValue).toContain('"autoCheckForUpdates":true'); + expect(localStorageValue).toContain('"lastUpdateCheck":null'); + await expect(autoUpdateCheckbox).not.toBeVisible(); + + const settingsLink = page.locator('.layout__a', { hasText: 'Settings' }); + await expect(settingsLink).not.toHaveClass(/ layout__a--notification/); + + await page.locator('.layout__a', { hasText: 'Motd' }).click(); + await expect(autoUpdateCheckbox).not.toBeVisible(); + await expect(settingsLink).toHaveClass(/ layout__a--notification/); + await expect(page.getByText('A newer version is available')).not.toBeVisible(); + + localStorageValue = await page.evaluate(() => window.localStorage.getItem('hollama-settings')); + expect(localStorageValue).not.toContain('"lastUpdateCheck":null'); + + await settingsLink.click(); + await expect(page.getByText('A newer version is available')).toBeVisible(); + await expect(settingsLink).not.toHaveClass(/ layout__a--notification/); + await expect(autoUpdateCheckbox).toBeVisible(); + localStorageValue = await page.evaluate(() => window.localStorage.getItem('hollama-settings')); +}); + +test('skips automatic update check on navigation when disabled', async ({ page }) => { + await page.route('**/api/metadata', (route) => + route.fulfill({ + json: { + currentVersion: currentVersion, + isDesktop: true, + isDocker: false + } + }) + ); + + await page.route(GITHUB_RELEASES_API, (route) => + route.fulfill({ json: [{ tag_name: MOCK_NEWER_VERSION }] }) + ); + + await page.goto('/sessions'); + let localStorageValue = await page.evaluate(() => + window.localStorage.getItem('hollama-settings') + ); + expect(localStorageValue).toContain('"autoCheckForUpdates":false'); + expect(localStorageValue).toContain('"lastUpdateCheck":null'); + + const settingsLink = page.locator('.layout__a', { hasText: 'Settings' }); + await expect(settingsLink).not.toHaveClass(/ layout__a--notification/); + + await page.locator('.layout__a', { hasText: 'Knowledge' }).click(); + await expect(settingsLink).not.toHaveClass(/ layout__a--notification/); + + await settingsLink.click(); + await expect(page.getByLabel('Automatically check for updates')).not.toBeChecked(); + await expect(page.getByText('A newer version is available')).not.toBeVisible(); + + // Confirmm that there was an update available + await page.getByText('Check now').click(); + await expect(page.getByText('A newer version is available')).toBeVisible(); + + localStorageValue = await page.evaluate(() => window.localStorage.getItem('hollama-settings')); + expect(localStorageValue).toContain('"autoCheckForUpdates":false'); + expect(localStorageValue).not.toContain('"lastUpdateCheck":null'); +});