Merge branch 'feature/ollama-manager' into feature/0.4-refinements

This commit is contained in:
Jody Doolittle 2025-07-27 20:12:52 -07:00
commit a52376b4ed
49 changed files with 5863 additions and 2039 deletions

View file

@ -0,0 +1,5 @@
CREATE TABLE "system_settings" (
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "system_settings_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"ollama_manager_enabled" boolean DEFAULT false NOT NULL,
"ollama_base_url" text DEFAULT 'http://localhost:11434/' NOT NULL
);

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,13 @@
"tag": "0002_awesome_thena",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1751942961856,
"tag": "0003_past_blue_marvel",
"breakpoints": true
},
{
"idx": 3,
"version": "7",

View file

@ -40,11 +40,11 @@
"@enviro/metadata": "^1.5.1",
"@fontsource/fira-mono": "^5.0.0",
"@neoconfetti/svelte": "^2.0.0",
"@skeletonlabs/skeleton": "^3.1.3",
"@skeletonlabs/skeleton-svelte": "^1.2.3",
"@skeletonlabs/skeleton": "^3.1.4",
"@skeletonlabs/skeleton-svelte": "^1.2.4",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@sveltejs/kit": "^2.22.2",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
@ -64,7 +64,7 @@
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.25.0",
"svelte": "^5.35.4",
"svelte-check": "^4.0.0",
"sveltekit-sse": "^0.13.19",
"tailwindcss": "^4.0.0",

183
src/app.d.ts vendored
View file

@ -6,6 +6,7 @@ import type { Schema } from "inspector/promises"
import type { P } from "ollama/dist/shared/ollama.d792a03f.mjs"
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions/completions"
import { FileAcceptDetails } from "../node_modules/@zag-js/file-upload/dist/index.d"
import type { ListResponse } from "ollama"
// for information about these interfaces
declare global {
@ -71,6 +72,13 @@ declare global {
theme: string
}
interface SystemSettingsCtx {
settings: {
ollamaManagerEnabled: boolean
ollamaManagerBaseUrl: string
}
}
// Model select and insert
type SelectUser = typeof schema.users.$inferSelect
type InsertUser = typeof schema.users.$inferInsert
@ -114,6 +122,27 @@ declare global {
type InsertLorebookBinding = typeof schema.lorebookBindings.$inferInsert
namespace Sockets {
namespace SystemSettings {
interface Call {}
interface Response {
systemSettings: {
ollamaManagerEnabled: boolean
ollamaManagerBaseUrl: string
}
}
}
namespace Error {
interface Response {
error: string,
description?: string,
}
}
namespace Success {
interface Response {
title: string,
description?: string,
}
}
namespace SamplingConfig {
interface Call {
id: number
@ -322,6 +351,145 @@ declare global {
error: string | null
}
}
// --- OLLAMA ---
namespace OllamaSetBaseUrl {
interface Call {
baseUrl: string
}
interface Response {
success: boolean
}
}
namespace OllamaModelsList {
interface Call {}
interface Response {
models: any[]
}
}
namespace OllamaDeleteModel {
interface Call {
modelName: string
}
interface Response {
success: boolean
}
}
namespace OllamaConnectModel {
interface Call {
modelName: string
}
interface Response {
success: boolean
}
}
namespace OllamaListRunningModels {
interface Call {}
interface Response {
models: ListResponse["models"]
}
}
namespace OllamaPullModel {
interface Call {
modelName: string
}
interface Response {
success: boolean
error?: string
progress?: any
}
}
namespace OllamaPullProgress {
interface Response {
downloadingQuants: {
[key: string]: {
modelName: string
status: string
isDone: boolean
files: { [key: string]: { total: number; completed: number } }
}
}
}
}
namespace OllamaStopModel {
interface Call {
modelName: string
}
interface Response {
success: boolean
}
}
namespace OllamaCancelPull {
interface Call {
modelName: string
}
interface Response {
success: boolean
modelName?: string
error?: string
}
}
namespace OllamaVersion {
interface Call {}
interface Response {
version?: string
}
}
namespace OllamaIsUpdateAvailable {
interface Call {}
interface Response {
updateAvailable: boolean
currentVersion?: string
latestVersion?: string
error?: string
}
}
namespace OllamaSearchAvailableModels {
interface Call {
search: string
source: string
}
interface Response {
models: Array<{
name: string
description?: string
size?: string
tags?: string[]
popular?: boolean
url?: string
downloads?: number
updatedAtStr?: string
createdAt?: Date
likes?: number
trendingScore?: number
pullOptions?: { label: string; pull: string}[]
}>
error?: string
}
}
namespace OllamaGetDownloadProgress {
interface Call {}
interface Response {
// This endpoint doesn't return a direct response,
// it triggers ollamaPullProgress events for each active download
}
}
namespace OllamaClearDownloadHistory {
interface Call {}
interface Response {
success: boolean
}
}
// --- APP SETTINGS ---
namespace UpdateOllamaManagerEnabled {
interface Call {
enabled: boolean
}
interface Response {
success: boolean
enabled?: boolean
}
}
// --- WEIGHTS ---
namespace DeleteSamplingConfig {
interface Call {
@ -864,6 +1032,7 @@ declare global {
}
interface Response {}
}
<<<<<<< HEAD
// TAGS
namespace TagsList {
interface Call {}
@ -923,6 +1092,20 @@ declare global {
interface Response {
characterId: number
tagId: number
=======
// --- USER ---
namespace User {
interface Call {}
interface Response {
user:
| (SelectUser & {
activeConnection: SelectConnection | null
activeSamplingConfig: SelectSamplingConfig | null
activeContextConfig: SelectContextConfig | null
activePromptConfig: SelectPromptConfig | null
})
| undefined
>>>>>>> feature/ollama-manager
}
}
}

View file

@ -6,6 +6,7 @@
import { onMount, setContext, onDestroy } from "svelte"
import SamplingSidebar from "./sidebars/SamplingSidebar.svelte"
import ConnectionsSidebar from "./sidebars/ConnectionsSidebar.svelte"
import OllamaSidebar from "./sidebars/OllamaSidebar.svelte"
import ContextSidebar from "./sidebars/ContextSidebar.svelte"
import LorebooksSidebar from "./sidebars/LorebooksSidebar.svelte"
import PersonasSidebar from "./sidebars/PersonasSidebar.svelte"
@ -18,6 +19,7 @@
import SettingsSidebar from "$lib/client/components/sidebars/SettingsSidebar.svelte"
import type { Snippet } from "svelte"
import { Theme } from "$lib/client/consts/Theme"
import OllamaIcon from "./icons/OllamaIcon.svelte"
interface Props {
children?: Snippet
@ -38,16 +40,7 @@
onLeftPanelClose: undefined,
onRightPanelClose: undefined,
onMobilePanelClose: undefined,
leftNav: {
sampling: {
icon: Icons.SlidersHorizontal,
title: "Sampling"
},
connections: { icon: Icons.Cable, title: "Connections" },
contexts: { icon: Icons.BookOpenText, title: "Contexts" },
prompts: { icon: Icons.MessageCircle, title: "Prompts" },
settings: { icon: Icons.Settings, title: "Settings" }
},
leftNav: {},
rightNav: {
personas: { icon: Icons.UserCog, title: "Personas" },
characters: { icon: Icons.Users, title: "Characters" },
@ -57,11 +50,42 @@
},
digest: {}
})
// TODO use setTheme socket call
let themeCtx: ThemeCtx = $state({
mode: (localStorage.getItem("mode") as "light" | "dark") || "dark",
theme: localStorage.getItem("theme") || Theme.HAMLINDIGO
})
let systemSettingsCtx: SystemSettingsCtx = $state({
settings: {
ollamaManagerEnabled: false,
ollamaManagerBaseUrl: ""
}
})
$effect(() => {
console.log(
"Layout systemSettingsCtx",
$state.snapshot(systemSettingsCtx)
)
})
// Update leftNav based on Ollama Manager setting
$effect(() => {
const baseLeftNav = {
sampling: {
icon: Icons.SlidersHorizontal,
title: "Sampling"
},
connections: { icon: Icons.Cable, title: "Connections" },
...(systemSettingsCtx.settings.ollamaManagerEnabled && {
ollama: { icon: OllamaIcon, title: "Ollama Manager" }
}),
contexts: { icon: Icons.BookOpenText, title: "Contexts" },
prompts: { icon: Icons.MessageCircle, title: "Prompts" },
settings: { icon: Icons.Settings, title: "Settings" }
}
panelsCtx.leftNav = baseLeftNav
})
function openPanel({
key,
@ -137,18 +161,18 @@
}: {
panel: "left" | "right" | "mobile"
}) {
let res: boolean
let res: boolean = true // Default to allowing close
if (panel === "mobile") {
res = await panelsCtx.onMobilePanelClose!()
res = panelsCtx.onMobilePanelClose ? await panelsCtx.onMobilePanelClose() : true
panelsCtx.mobilePanel = res ? null : panelsCtx.mobilePanel
} else if (panel === "left") {
res = await panelsCtx.onLeftPanelClose!()
res = panelsCtx.onLeftPanelClose ? await panelsCtx.onLeftPanelClose() : true
panelsCtx.leftPanel = res ? null : panelsCtx.leftPanel
} else if (panel === "right") {
res = await panelsCtx.onRightPanelClose!()
res = panelsCtx.onRightPanelClose ? await panelsCtx.onRightPanelClose() : true
panelsCtx.rightPanel = res ? null : panelsCtx.rightPanel
}
return res!
return res
}
function handleMobilePanelClick(key: string) {
@ -168,25 +192,45 @@
document.documentElement.setAttribute("data-theme", theme)
})
setContext("panelsCtx", panelsCtx as PanelsCtx)
setContext("userCtx", userCtx)
setContext("themeCtx", themeCtx)
onMount(() => {
socket.on("user", (message) => {
setContext("panelsCtx", panelsCtx as PanelsCtx)
setContext("userCtx", userCtx)
setContext("themeCtx", themeCtx)
setContext("systemSettingsCtx", systemSettingsCtx)
socket.on("user", (message: Sockets.User.Response) => {
userCtx.user = message.user
})
socket.on(
"systemSettings",
(message: Sockets.SystemSettings.Response) => {
systemSettingsCtx.settings = message.systemSettings
}
)
socket.on("error", (message: Sockets.Error.Response) => {
toaster.error({ title: "Error", description: message.error })
toaster.error({
title: message.error,
description: message.description
})
})
socket.on("success", (message: Sockets.Success.Response) => {
toaster.success({
title: message.title,
description: message.description
})
})
socket.emit("user", {})
socket.emit("systemSettings", {})
})
onDestroy(() => {
socket.off("user")
socket.off("systemSettings")
socket.off("error")
socket.off("success")
})
</script>
@ -230,6 +274,10 @@
<ConnectionsSidebar
bind:onclose={panelsCtx.onLeftPanelClose}
/>
{:else if panelsCtx.leftPanel === "ollama"}
<OllamaSidebar
bind:onclose={panelsCtx.onLeftPanelClose}
/>
{:else if panelsCtx.leftPanel === "contexts"}
<ContextSidebar
bind:onclose={panelsCtx.onLeftPanelClose}
@ -337,6 +385,10 @@
<ConnectionsSidebar
bind:onclose={panelsCtx.onMobilePanelClose}
/>
{:else if panelsCtx.mobilePanel === "ollama"}
<OllamaSidebar
bind:onclose={panelsCtx.onMobilePanelClose}
/>
{:else if panelsCtx.mobilePanel === "contexts"}
<ContextSidebar
bind:onclose={panelsCtx.onMobilePanelClose}

File diff suppressed because one or more lines are too long

View file

@ -17,10 +17,17 @@
</script>
<Modal
<<<<<<< HEAD
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
=======
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>>>>>>> feature/ollama-manager
>
{#snippet content()}
<header class="flex justify-between">

View file

@ -17,10 +17,17 @@
</script>
<Modal
<<<<<<< HEAD
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
=======
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>>>>>>> feature/ollama-manager
>
{#snippet content()}
<header class="flex justify-between">

View file

@ -17,10 +17,17 @@
</script>
<Modal
<<<<<<< HEAD
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
=======
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>>>>>>> feature/ollama-manager
>
{#snippet content()}
<header class="flex justify-between">

View file

@ -17,10 +17,17 @@
</script>
<Modal
<<<<<<< HEAD
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
=======
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>>>>>>> feature/ollama-manager
>
{#snippet content()}
<header class="flex justify-between">

View file

@ -0,0 +1,175 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean
selectedModelForDownload: string | null
selectedModel: Sockets.OllamaSearchAvailableModels.Response["models"][0] | null
onClose: () => void
onDownload: (modelId: string, pullOption: { label: string; pull: string }) => void
}
let {
open = $bindable(),
selectedModelForDownload,
selectedModel,
onClose,
onDownload
}: Props = $props()
// Helper function to extract numeric value from quantization for sorting
function getQuantizationSortValue(label: string | undefined): number {
if (!label) return 0
// Extract the main number (e.g., "Q4_K_M" -> 4, "Q8_0" -> 8)
const match = label.match(/[Qq](\d+)/)
return match ? parseInt(match[1]) : 0
}
// Sort pullOptions by quantization level (highest to lowest)
let sortedPullOptions = $derived(
// selectedModel?.pullOptions ?
// [...selectedModel.pullOptions].sort((a, b) => {
// const aValue = getQuantizationSortValue(a.label)
// const bValue = getQuantizationSortValue(b.label)
// return bValue - aValue // Descending order
// }) : []
selectedModel?.pullOptions || []
)
</script>
<Modal
{open}
onOpenChange={(e) => (open = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl w-[50em] max-w-dvw-lg border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Select Quantization</h2>
</header>
<article class="space-y-4">
<div>
<p class="text-surface-500 mb-2 text-sm">
Choose a quantization level for <strong>
{selectedModelForDownload}
</strong>
</p>
<div class="bg-surface-200-800 rounded-lg p-3">
<p class="text-surface-600-400 text-xs">
<strong>About Quantizations:</strong>
These are compressed variants of the model that reduce file
size and memory usage while maintaining good performance.
Higher numbers (Q8) preserve more quality but use more resources,
while lower numbers (Q4) are more efficient but with slight
quality trade-offs.
</p>
</div>
</div>
{#if !selectedModel}
<div class="p-6 text-center">
<Icons.Loader2
class="mx-auto mb-4 animate-spin"
size={32}
/>
<p class="text-sm opacity-75">
Loading model information...
</p>
</div>
{:else if sortedPullOptions.length === 0}
<div class="p-6 text-center">
<Icons.AlertCircle
class="text-surface-500 mx-auto mb-4"
size={48}
/>
<p class="text-sm opacity-75">
No GGUF quantizations are available for this model.
</p>
</div>
{:else}
<div class="max-h-96 space-y-3 overflow-y-auto">
{#each sortedPullOptions as pullOption, index}
<div class="card bg-surface-200-800 p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="mb-2 flex items-center gap-2">
<h5 class="font-semibold">
{pullOption.label ||
"Unknown file"}
</h5>
<!-- Add special labels -->
<!-- {#if index === 0 && sortedPullOptions.length > 1}
<span
class="badge rounded bg-blue-500 px-2 py-1 text-xs text-white"
>
Larger
</span>
{:else if index === sortedPullOptions.length - 1 && sortedPullOptions.length > 1}
<span
class="badge rounded bg-green-500 px-2 py-1 text-xs text-white"
>
Smaller
</span>
{/if} -->
{#if pullOption.label.includes("Q4_K_M")}
<span
class="badge rounded bg-orange-500 px-2 py-1 text-xs text-white"
>
Recommended
</span>
{/if}
</div>
<!-- <div
class="text-surface-500 flex items-center gap-4 text-xs"
>
{#if pullOption.size}
<div
class="flex items-center gap-1"
>
<Icons.HardDrive size={12} />
<span>
{(
pullOption.size /
(1024 * 1024 * 1024)
).toFixed(2)} GB
</span>
</div>
{/if}
</div> -->
</div>
<button
class="btn btn-sm preset-filled-primary-500"
onclick={() => {
if (
selectedModelForDownload &&
pullOption.label
) {
onDownload(
selectedModelForDownload,
pullOption
)
}
}}
>
<Icons.Download size={14} />
Download
</button>
</div>
</div>
{/each}
</div>
{/if}
</article>
<footer class="flex justify-end gap-4">
<button class="btn preset-tonal" onclick={onClose}>
Cancel
</button>
</footer>
{/snippet}
</Modal>

View file

@ -17,10 +17,17 @@
</script>
<Modal
<<<<<<< HEAD
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
=======
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>>>>>>> feature/ollama-manager
>
{#snippet content()}
<header class="flex justify-between">

View file

@ -57,10 +57,17 @@
</script>
<Modal
<<<<<<< HEAD
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
=======
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>>>>>>> feature/ollama-manager
>
{#snippet content()}
<header class="flex justify-between">

View file

@ -0,0 +1,231 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { Modal, Progress } from "@skeletonlabs/skeleton-svelte"
interface DownloadProgress {
modelName: string
status: string
files: {
[key: string]: { total: number; completed: number }
}
}
interface Props {
open: boolean
downloadingQuants: {
[key: string]: {
modelName: string
status: string
files: {
[key: string]: { total: number; completed: number }
}
}
}
onCancel?: (modelName: string) => void
onClose?: () => void
}
let { open = $bindable(), downloadingQuants, onCancel, onClose }: Props = $props()
// Check if model download is complete
function isComplete(progress: DownloadProgress) {
const files = Object.values(progress.files)
return files.length > 0 && files.every(file => file.total > 0 && file.completed >= file.total)
}
// Check if download is done (complete, canceled, or error)
function isDone(progress: DownloadProgress) {
const status = progress.status.toLowerCase()
return status === "canceled" || status === "success" || status === "error" || isComplete(progress)
}
// Check if all downloads are done
function areAllDownloadsDone() {
const downloads = Object.values(downloadingQuants)
return downloads.length > 0 && downloads.every(download => isDone(download))
}
// Handle modal close
function handleClose() {
if (onClose) {
onClose()
}
open = false
}
// Get file count and completion status
function getFileStats(progress: DownloadProgress) {
const files = Object.values(progress.files)
const completedFiles = files.filter(file => file.total > 0 && file.completed >= file.total).length
const totalFiles = files.length
return { completed: completedFiles, total: totalFiles }
}
</script>
<Modal
{open}
onOpenChange={(e) => {
if (!e.open && areAllDownloadsDone()) {
handleClose()
}
}}
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-2xl border border-surface-300-700 w-[40em] max-w-dvw-lg max-h-[90dvh]"
backdropClasses="backdrop-blur-md bg-black/20"
>
{#snippet content()}
<header class="border-surface-300-700 border-b pb-4">
<div class="flex items-center gap-3">
<div class="bg-primary-500/10 rounded-full p-2">
<Icons.Download size={20} class="text-primary-500" />
</div>
<div>
<h2 class="h3 font-bold">Model Downloads</h2>
<p class="text-surface-500 text-sm">
{Object.keys(downloadingQuants).length} model{Object.keys(
downloadingQuants
).length !== 1
? "s"
: ""} downloading
</p>
</div>
</div>
</header>
<article class="max-h-80 space-y-6 overflow-y-auto pr-2">
{#each Object.entries(downloadingQuants) as [key, progress]}
{#if key === "undefined"}
<!-- Skip undefined keys -->
{:else}
{@const fileStats = getFileStats(progress)}
<div
class="bg-surface-200-800 border-surface-300-700 rounded-lg border p-4"
>
<div class="flex items-start gap-4">
<div
class="bg-primary-500/10 mt-1 rounded-full p-2"
>
{#if isDone(progress)}
{#if progress.status.toLowerCase() === "canceled"}
<Icons.X
size={16}
class="text-orange-500"
/>
{:else if progress.status.toLowerCase() === "error"}
<Icons.AlertTriangle
size={16}
class="text-red-500"
/>
{:else}
<Icons.Check
size={16}
class="text-green-500"
/>
{/if}
{:else}
<Icons.Download
size={16}
class="text-primary-500 animate-pulse"
/>
{/if}
</div>
<div class="min-w-0 flex-1">
<div
class="mb-3 flex items-center justify-between"
>
<h4
class="text-surface-900-100 truncate font-semibold"
>
{progress.modelName}
</h4>
<div class="flex items-center gap-2 ml-2">
{#if onCancel && !isDone(progress)}
<button
class="btn btn-sm preset-filled-error-500"
onclick={() => onCancel?.(progress.modelName)}
title="Cancel download"
>
<Icons.X size={14} />
Cancel
</button>
{/if}
</div>
</div>
<div class="space-y-3">
<!-- Individual file progress bars -->
<div class="space-y-3">
{#each Object.entries(progress.files) as [fileName, fileProgress]}
<div class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-surface-500 truncate font-mono max-w-[60%]" title={fileName}>
{fileName}
</span>
<span class="text-surface-400 font-mono">
{fileProgress.total > 0 ?
`${((fileProgress.completed / fileProgress.total) * 100).toFixed(1)}%` :
'0%'
}
</span>
</div>
<div class="w-full">
<Progress
value={fileProgress.completed}
max={fileProgress.total}
/>
</div>
{#if fileProgress.total > 0}
<div class="flex justify-end text-[10px] text-surface-400 font-mono">
{(fileProgress.completed / (1024 * 1024)).toFixed(1)}MB /
{(fileProgress.total / (1024 * 1024)).toFixed(1)}MB
</div>
{/if}
</div>
{/each}
</div>
<div
class="flex items-center justify-between text-xs pt-2 border-t border-surface-300-700"
>
<div class="flex items-center gap-2">
<div
class="h-2 w-2 rounded-full {isDone(progress) ? '' : 'animate-pulse'} {
progress.status.toLowerCase() === 'canceled' ? 'bg-orange-500' :
progress.status.toLowerCase() === 'error' ? 'bg-red-500' :
progress.status.toLowerCase() === 'success' || isComplete(progress) ? 'bg-green-500' :
'bg-blue-500'
}"
></div>
<span
class="text-surface-600-400 font-medium"
>
{progress.status}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
{/each}
</article>
<footer class="border-surface-300-700 border-t pt-4">
<div
class="text-surface-500 flex items-center justify-between text-xs"
>
<!-- <div class="flex items-center gap-2">
<Icons.Info size={14} />
<span>Downloads will continue in the background</span>
</div> -->
{#if areAllDownloadsDone()}
<button
class="btn btn-sm preset-filled-primary-500"
onclick={handleClose}
>
<Icons.X size={14} />
Close
</button>
{/if}
</div>
</footer>
{/snippet}
</Modal>

View file

@ -0,0 +1,134 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean
modelName: string
onclose: () => void
onconfirm: (modelName: string) => void
}
let { open = $bindable(), modelName, onclose, onconfirm }: Props = $props()
let inputValue = $state(modelName)
let isLoading = $state(false)
// Function to clean the model name
function cleanModelName(input: string): string {
let cleaned = input.trim()
// Remove "ollama pull " prefix if present
if (cleaned.toLowerCase().startsWith("ollama pull ")) {
cleaned = cleaned.substring(12).trim()
}
// Remove "ollama run " prefix if present
if (cleaned.toLowerCase().startsWith("ollama run ")) {
cleaned = cleaned.substring(11).trim()
}
return cleaned
}
function handleConfirm() {
const cleanedName = cleanModelName(inputValue)
if (cleanedName) {
isLoading = true
onconfirm(cleanedName)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
handleConfirm()
}
}
// Reset input when modal opens
$effect(() => {
if (open) {
inputValue = modelName
isLoading = false
}
})
</script>
<Modal
{open}
onOpenChange={(e) => {
if (!e.open) {
onclose()
}
}}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl w-[30em] max-w-dvw-lg border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<div class="mb-6">
<div class="mb-2 flex items-center gap-3">
<div class="bg-primary-500/10 rounded-full p-2">
<Icons.Download size={20} class="text-primary-500" />
</div>
<h3 class="text-foreground text-lg font-bold">Install Model</h3>
</div>
<p class="text-muted-foreground text-sm">
Enter the model name to download. You can use formats like "ollama pull model", "ollama run model", or just "model".
</p>
</div>
<div class="mb-6 space-y-3">
<div>
<label
class="text-foreground mb-1 block text-sm font-medium"
for="modelNameInput"
>
Model Name
</label>
<input
id="modelNameInput"
type="text"
bind:value={inputValue}
onkeydown={handleKeydown}
placeholder="e.g., llama2, ollama pull llama2, ollama run llama2"
class="input w-full"
disabled={isLoading}
/>
</div>
{#if inputValue.trim()}
<div class="bg-surface-100-900 rounded border p-3">
<div class="text-muted-foreground text-xs font-medium mb-1">
Will install:
</div>
<div class="text-foreground font-mono text-sm">
{cleanModelName(inputValue)}
</div>
</div>
{/if}
</div>
<div class="flex gap-3">
<button
class="btn btn-secondary flex-1"
onclick={onclose}
disabled={isLoading}
>
Cancel
</button>
<button
class="btn btn-primary flex-1"
onclick={handleConfirm}
disabled={isLoading || !inputValue.trim()}
>
{#if isLoading}
<Icons.Loader2 size={14} class="animate-spin" />
Installing...
{:else}
<Icons.Download size={14} />
Install
{/if}
</button>
</div>
{/snippet}
</Modal>

View file

@ -9,12 +9,25 @@
onContinue: () => void
}
<<<<<<< HEAD
let { open = $bindable(), modelName, onClose, onContinue }: Props = $props()
=======
let {
open = $bindable(),
modelName,
onClose,
onContinue
}: Props = $props()
>>>>>>> feature/ollama-manager
// Generate the Ollama library URL for the model
let ollamaUrl = $derived(() => {
if (!modelName) return ""
<<<<<<< HEAD
=======
>>>>>>> feature/ollama-manager
// Extract the base model name (remove any version/tag info)
const baseModelName = modelName.split(":")[0]
return `https://ollama.com/library/${baseModelName}/tags`
@ -34,6 +47,7 @@
<Icons.X size={16} />
</button>
</header>
<<<<<<< HEAD
<article class="space-y-4">
<div
@ -78,6 +92,32 @@
usage)
</em>
</p>
=======
<article class="space-y-4">
<div class="bg-primary-100 dark:bg-primary-900 border-primary-300 dark:border-primary-700 rounded-lg border p-4">
<div class="flex items-start gap-3">
<Icons.Info size={20} class="text-primary-600 mt-0.5 flex-shrink-0" />
<div class="flex-1">
<h3 class="font-semibold text-primary-800 dark:text-primary-200 mb-2">
Choose Your Model Version
</h3>
<p class="text-primary-700-300 text-sm mb-3">
To download <strong>{modelName}</strong>, you'll need to select a specific version from the Ollama library.
</p>
<div class="space-y-2 text-sm">
<p class="text-primary-700-300">
<strong>Step 1:</strong> Click the button below to open the model's page on Ollama.com
</p>
<p class="text-primary-700-300">
<strong>Step 2:</strong> Browse the available versions and copy the full name of the one you want
</p>
<p class="text-primary-700-300">
<strong>Step 3:</strong> Come back here and paste the name in the next screen
</p>
<p class="text-primary-700-300"><em>(Q4_K_M is generally recommended for a balance between performance and resource usage)</em></p>
>>>>>>> feature/ollama-manager
</div>
<div class="mt-4">
@ -95,9 +135,17 @@
</div>
</div>
</article>
<<<<<<< HEAD
<footer class="flex justify-end gap-3">
<button class="btn preset-tonal" onclick={onClose}>Cancel</button>
=======
<footer class="flex justify-end gap-3">
<button class="btn preset-tonal" onclick={onClose}>
Cancel
</button>
>>>>>>> feature/ollama-manager
<button class="btn preset-filled-primary-500" onclick={onContinue}>
<Icons.ArrowRight size={16} />
Continue to Download

View file

@ -0,0 +1,141 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
interface Props {
open: boolean
modelName: string
exampleModelName?: string
onclose: () => void
onconfirm: (modelName: string) => void
}
let {
open = $bindable(),
modelName,
exampleModelName = "llama3.1",
onclose,
onconfirm }: Props = $props()
let inputValue = $state(modelName)
let isLoading = $state(false)
// Function to clean the model name
function cleanModelName(input: string): string {
let cleaned = input.trim()
// Remove "ollama pull " prefix if present
if (cleaned.toLowerCase().startsWith("ollama pull ")) {
cleaned = cleaned.substring(12).trim()
}
// Remove "ollama run " prefix if present
if (cleaned.toLowerCase().startsWith("ollama run ")) {
cleaned = cleaned.substring(11).trim()
}
return cleaned
}
function handleConfirm() {
const cleanedName = cleanModelName(inputValue)
if (cleanedName) {
isLoading = true
onconfirm(cleanedName)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
handleConfirm()
}
}
// Reset input when modal opens
$effect(() => {
if (open) {
inputValue = modelName
isLoading = false
}
})
</script>
<Modal
{open}
onOpenChange={(e) => {
if (!e.open) {
onclose()
}
}}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl w-[30em] max-w-dvw-lg border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<div class="mb-6">
<div class="mb-2 flex items-center gap-3">
<h3 class="text-foreground text-lg font-bold">Install Model</h3>
</div>
<p class="text-muted-foreground text-sm">
Enter the model name to download. You can use formats like:
<br/> <code class="code">ollama pull {exampleModelName}</code>
<br/> <code class="code">ollama run {exampleModelName}</code>
<br/> or <code class="code">{exampleModelName}</code>
</p>
</div>
<div class="mb-6 space-y-3">
<div>
<label
class="text-foreground mb-1 block text-sm font-medium"
for="modelNameInput"
>
Model Name or Pull Command
</label>
<input
id="modelNameInput"
type="text"
bind:value={inputValue}
onkeydown={handleKeydown}
placeholder="{exampleModelName}, ollama pull {exampleModelName}"
class="input w-full"
disabled={isLoading}
/>
</div>
{#if inputValue.trim()}
<div class="bg-surface-100-900 rounded border p-3">
<div class="text-muted-foreground text-xs font-medium mb-1">
Will install:
</div>
<div class="text-foreground font-mono text-sm">
{cleanModelName(inputValue)}
</div>
</div>
{/if}
</div>
<div class="flex gap-3">
<button
class="btn preset-filled-surface-500 flex-1"
onclick={onclose}
disabled={isLoading}
>
Cancel
</button>
<button
class="btn preset-filled-primary-500 flex-1"
onclick={handleConfirm}
disabled={isLoading || !inputValue.trim()}
>
{#if isLoading}
<Icons.Loader2 size={14} class="animate-spin" />
Installing...
{:else}
<Icons.Download size={14} />
Install
{/if}
</button>
</div>
{/snippet}
</Modal>

View file

@ -17,10 +17,17 @@
</script>
<Modal
<<<<<<< HEAD
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
=======
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>>>>>>> feature/ollama-manager
>
{#snippet content()}
<header class="flex justify-between">

View file

@ -17,10 +17,17 @@
</script>
<Modal
<<<<<<< HEAD
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
=======
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>>>>>>> feature/ollama-manager
>
{#snippet content()}
<header class="flex justify-between">

View file

@ -21,10 +21,17 @@
</script>
<Modal
<<<<<<< HEAD
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
=======
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>>>>>>> feature/ollama-manager
>
{#snippet content()}
<header class="flex justify-between">

View file

@ -17,10 +17,17 @@
</script>
<Modal
<<<<<<< HEAD
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
backdropClasses="backdrop-blur-sm"
=======
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>>>>>>> feature/ollama-manager
>
{#snippet content()}
<header class="flex justify-between">

View file

@ -0,0 +1,411 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import * as skio from "sveltekit-io"
import { onMount, onDestroy } from "svelte"
import { toaster } from "$lib/client/utils/toaster"
import { OllamaModelSearchSource } from "$lib/shared/constants/OllamaModelSource"
import HuggingFaceQuantizationModal from "$lib/client/components/modals/HuggingFaceQuantizationModal.svelte"
import OllamaManualPullModal from "$lib/client/components/modals/OllamaManualPullModal.svelte"
import OllamaInstructionModal from "$lib/client/components/modals/OllamaInstructionModal.svelte"
interface OllamaModel {
name: string
size: number
digest: string
modified_at: string
details?: {
parameter_size: string
quantization_level: string
}
}
interface Props {
// Callback when a download starts - to switch tabs
onDownloadStart?: (modelName: string) => void
}
let { onDownloadStart }: Props = $props()
const socket = skio.get()
let searchString = $state("")
let installedModels: Sockets.OllamaModelsList.Response["models"] = $state(
[]
)
let selectedSource = $state(OllamaModelSearchSource.HUGGING_FACE)
let availableModels: Sockets.OllamaSearchAvailableModels.Response["models"] =
$state([])
let isSearching = $state(false)
let showHuggingFaceModal = $state(false)
let showOllamaInstructionModal = $state(false)
let showOllamaManualPullModal = $state(false)
let selectedModelForDownload: string | null = $state(null)
let selectedModel: Sockets.OllamaSearchAvailableModels.Response["models"][0] | null = $state(null)
// Track which models are being downloaded locally (for UI state only)
let currentlyDownloading = $state(new Set<string>())
function isModelInstalled(modelName: string): boolean {
return installedModels.some((model) => model.name.startsWith(modelName))
}
function searchAvailableModels() {
isSearching = true
socket.emit("ollamaSearchAvailableModels", {
search: searchString.trim(),
source: selectedSource
} as Sockets.OllamaSearchAvailableModels.Call)
}
function openHuggingFaceModal(model: Sockets.OllamaSearchAvailableModels.Response["models"][0]) {
selectedModelForDownload = model.name
selectedModel = model
showHuggingFaceModal = true
}
function closeHuggingFaceModal() {
showHuggingFaceModal = false
selectedModelForDownload = null
selectedModel = null
}
function downloadHuggingFaceQuantization(
modelId: string,
pullOption: { label: string; pull: string }
) {
console.log("Downloading Hugging Face quantization:", pullOption.pull)
// Track this model as currently downloading
currentlyDownloading.add(modelId)
// Emit the pull request to Ollama
socket.emit("ollamaPullModel", {
modelName: pullOption.pull
} as Sockets.OllamaPullModel.Call)
// Close modal and switch to downloads tab
closeHuggingFaceModal()
onDownloadStart?.(pullOption.pull)
}
function openOllamaManualPullModal(modelName: string) {
selectedModelForDownload = modelName
showOllamaManualPullModal = true
}
function openOllamaInstructionModal(modelName: string) {
selectedModelForDownload = modelName
showOllamaInstructionModal = true
}
function closeOllamaInstructionModal() {
showOllamaInstructionModal = false
selectedModelForDownload = null
}
function handleInstructionContinue() {
showOllamaInstructionModal = false
showOllamaManualPullModal = true
}
function closeOllamaManualPullModal() {
showOllamaManualPullModal = false
selectedModelForDownload = null
}
function handleOllamaInstallConfirm(cleanedModelName: string) {
console.log("Installing Ollama model:", cleanedModelName)
// Track this model as currently downloading
currentlyDownloading.add(cleanedModelName)
// Emit the pull request to Ollama
socket.emit("ollamaPullModel", {
modelName: cleanedModelName
} as Sockets.OllamaPullModel.Call)
// Close modal and switch to downloads tab
closeOllamaManualPullModal()
onDownloadStart?.(cleanedModelName)
}
$effect(() => {
const _search = searchString.trim()
const _source = selectedSource
const timeoutId = setTimeout(() => {
searchAvailableModels()
}, 500) // 500ms delay
return () => clearTimeout(timeoutId)
})
async function refreshInstalled() {
socket.emit("ollamaModelsList", {})
}
onMount(() => {
// Socket event listeners
socket.on(
"ollamaModelsList",
(message: Sockets.OllamaModelsList.Response) => {
installedModels = message.models
}
)
socket.on(
"ollamaSearchAvailableModels",
(message: Sockets.OllamaSearchAvailableModels.Response) => {
isSearching = false
if (message.error) {
toaster.error({ title: message.error })
availableModels = []
} else {
availableModels = message.models || []
}
}
)
socket.on(
"ollamaPullModel",
(message: Sockets.OllamaPullModel.Response) => {
// Handle model pull completion/error only - no progress handling
console.log("Pull model response:", message)
if (message.success) {
socket.emit("ollamaModelsList", {})
toaster.success({ title: "Model downloaded successfully" })
closeHuggingFaceModal()
} else if (message.error) {
toaster.error({ title: message.error })
}
}
)
// Load initial installed models
refreshInstalled()
})
onDestroy(() => {
socket.off("ollamaModelsList")
socket.off("ollamaSearchAvailableModels")
socket.off("ollamaPullModel")
socket.off("ollamaCancelPull")
})
</script>
<!-- Search for available models -->
<div class="px-4 py-2 flex flex-col gap-2">
<div class="flex gap-2">
<button
class="btn preset-filled-primary-500 flex-1"
onclick={() => {
showOllamaManualPullModal = true
}}
>
<Icons.Download size={16} />
Manual Download
</button>
<select
id="source"
name="source"
aria-label="Model search source"
class="select bg-background border-muted w-fit rounded border"
bind:value={selectedSource}
>
{#each OllamaModelSearchSource.options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="relative flex-1">
<Icons.Search
class="text-surface-500 absolute top-1/2 left-3 -translate-y-1/2 transform"
size={16}
/>
<input
type="text"
placeholder="Search available models..."
class="input w-full pl-10"
aria-label="Model search"
bind:value={searchString}
/>
</div>
</div>
<div class="space-y-3 p-4">
{#if isSearching}
<div class="p-6 text-center">
<Icons.Loader2 class="mx-auto mb-4 animate-spin" size={32} />
<p class="text-sm opacity-75">Searching for models...</p>
</div>
{:else if availableModels.length === 0}
<div class="p-6 text-center">
<Icons.Search class="text-surface-500 mx-auto mb-4" size={48} />
<h3 class="h4 mb-2">No models found</h3>
<p class="mb-4 text-sm opacity-75">
No available models match your search for "{searchString}".
</p>
</div>
{:else}
{#each availableModels as model}
<div class="card preset-tonal p-4">
<div class="flex flex-col gap-2">
<!-- Header with name and badges -->
<div class="flex items-start justify-between">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-lg font-semibold">
{model.name}
</h4>
</div>
</div>
<div>
{#if model.popular}
<span
class="badge preset-filled-tertiary-500 rounded-full px-2 py-1 text-x"
>
<Icons.TrendingUp
size={12}
class="mr-1 inline"
/>
Popular
</span>
{/if}
{#if model.trendingScore && model.trendingScore > 0.7}
<span
class="badge rounded-full preset-filled-secondary-500 px-2 py-1 text-xs"
>
<Icons.Flame size={12} class="mr-1 inline" />
Trending
</span>
{/if}
</div>
<!-- Description -->
<p class="text-surface-500 mb-3 line-clamp-2 text-sm">
{model.description || "No description available"}
</p>
<!-- Tags -->
{#if model.tags && model.tags.length > 0}
<div class="mb-3 flex flex-wrap gap-1">
{#each model.tags.slice(0, 4) as tag}
<span
class="badge bg-surface-200-800 text-surface-700-300 rounded px-2 py-1 text-xs"
>
{tag}
</span>
{/each}
{#if model.tags.length > 4}
<span class="text-surface-500 text-xs">
+{model.tags.length - 4} more
</span>
{/if}
</div>
{/if}
<!-- Metadata row -->
<div
class="text-surface-500 flex flex-wrap items-center gap-4 text-xs"
>
{#if model.size}
<div class="flex items-center gap-1">
<Icons.HardDrive size={12} />
<span>{model.size}</span>
</div>
{/if}
{#if model.downloads}
<div class="flex items-center gap-1">
<Icons.Download size={12} />
<span>
{model.downloads.toLocaleString()} downloads
</span>
</div>
{/if}
{#if model.likes}
<div class="flex items-center gap-1">
<Icons.Heart size={12} />
<span>
{model.likes.toLocaleString()} likes
</span>
</div>
{/if}
{#if model.updatedAtStr}
<div class="flex items-center gap-1">
<Icons.Clock size={12} />
<span>Updated {model.updatedAtStr}</span>
</div>
{/if}
</div>
<div class="flex min-w-[100px] gap-2">
<button
class="btn btn-sm {isModelInstalled(model.name)
? 'preset-filled-success-500'
: 'preset-filled-primary-500'}"
onclick={() => {
if (
selectedSource ===
OllamaModelSearchSource.HUGGING_FACE
) {
openHuggingFaceModal(model)
} else if (
selectedSource ===
OllamaModelSearchSource.OLLAMA_DB
) {
openOllamaInstructionModal(model.name)
} else {
openOllamaManualPullModal(model.name)
}
}}
>
{#if isModelInstalled(model.name)}
<Icons.Check size={14} />
Installed
{:else}
<Icons.Download size={14} />
Install
{/if}
</button>
{#if model.url}
<a
href={model.url}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm preset-filled-secondary-500 text-center"
>
<Icons.ExternalLink size={14} />
View
</a>
{/if}
</div>
</div>
</div>
{/each}
{/if}
</div>
<!-- Hugging Face Download Modal -->
<HuggingFaceQuantizationModal
bind:open={showHuggingFaceModal}
{selectedModelForDownload}
{selectedModel}
onClose={closeHuggingFaceModal}
onDownload={downloadHuggingFaceQuantization}
/>
<!-- Ollama Install Modal -->
<OllamaManualPullModal
open={showOllamaManualPullModal}
modelName={selectedModelForDownload || ""}
onclose={closeOllamaManualPullModal}
onconfirm={handleOllamaInstallConfirm}
/>
<!-- Ollama Instruction Modal -->
<OllamaInstructionModal
bind:open={showOllamaInstructionModal}
modelName={selectedModelForDownload || ""}
onClose={closeOllamaInstructionModal}
onContinue={handleInstructionContinue}
/>

View file

@ -0,0 +1,314 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { Progress } from "@skeletonlabs/skeleton-svelte"
import { onDestroy, onMount } from "svelte"
import * as skio from "sveltekit-io"
interface DownloadProgress {
modelName: string
status: string
files: {
[key: string]: { total: number; completed: number }
}
}
const socket = skio.get()
// Download progress state managed by this component
let downloadingQuants: {
[key: string]: {
modelName: string
status: string
isDone: boolean
files: {
[key: string]: { total: number; completed: number }
}
}
} = $state({})
let downloadingCount = $derived(
Object.keys(downloadingQuants).filter(
(key) => key !== "undefined" && !downloadingQuants[key].isDone
).length
)
let doneCount = $derived(
Object.keys(downloadingQuants).filter(
(key) => key !== "undefined" && downloadingQuants[key].isDone
).length
)
function isComplete(progress: DownloadProgress) {
const files = Object.values(progress.files)
return (
files.length > 0 &&
files.every(
(file) => file.total > 0 && file.completed >= file.total
)
)
}
// Get file count and completion status
function getFileStats(progress: DownloadProgress) {
const files = Object.values(progress.files)
const completedFiles = files.filter(
(file) => file.total > 0 && file.completed >= file.total
).length
const totalFiles = files.length
return { completed: completedFiles, total: totalFiles }
}
function cancelDownload(modelName: string) {
socket.emit("ollamaCancelPull", {
modelName
} as Sockets.OllamaCancelPull.Call)
}
function clearDownloadHistory() {
socket.emit(
"ollamaClearDownloadHistory",
{} as Sockets.OllamaClearDownloadHistory.Call
)
}
onMount(() => {
socket.on(
"ollamaPullProgress",
(message: Sockets.OllamaPullProgress.Response) => {
// Server sends the entire downloadingQuants object
downloadingQuants = message.downloadingQuants || {}
}
)
socket.on(
"ollamaClearDownloadHistory",
(message: Sockets.OllamaClearDownloadHistory.Response) => {
if (message.success) {
downloadingQuants = {}
}
}
)
// Request current download progress from server after setting up listeners
socket.emit("ollamaGetDownloadProgress", {})
})
onDestroy(() => {
socket.off("ollamaPullProgress")
socket.off("ollamaClearDownloadHistory")
})
</script>
<div class="flex h-full flex-col">
<div class="p-4">
{#if !downloadingCount && !doneCount}
<!-- No downloads state -->
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<Icons.Download
class="text-muted-foreground mx-auto mb-4 h-12 w-12"
/>
<h3 class="text-foreground mb-2 text-lg font-semibold">
No Active Downloads
</h3>
<p class="text-muted-foreground text-sm">
Model downloads will appear here when started from the
Available tab.
</p>
</div>
</div>
{/if}
{#if downloadingCount}
<!-- Downloads header -->
<div class="flex flex-col gap-2 mb-6">
<div>
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<div>
<h3 class="text-foreground text-lg font-bold">
Active Downloads
</h3>
<p class="text-muted-foreground text-sm">
{downloadingCount} model{downloadingCount !==
1
? "s"
: ""} downloading
</p>
</div>
</div>
</div>
</div>
<!-- Downloads list -->
<div class="space-y-4">
{#each Object.entries(downloadingQuants).filter(([key, progress]) => !progress.isDone) as [key, progress]}
{@render downloadItem(key, progress)}
{/each}
</div>
</div>
{/if}
{#if doneCount}
<!-- Downloads header -->
<div class="flex flex-col gap-2">
<div>
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<div>
<h3 class="text-foreground text-lg font-bold">
Completed Downloads
</h3>
<p class="text-muted-foreground text-sm">
{doneCount} model{doneCount !== 1
? "s"
: ""} completed
</p>
</div>
</div>
<!-- Clear history button -->
{#if Object.values(downloadingQuants).some((p) => p.isDone)}
<button
class="btn btn-sm preset-filled-surface-500"
onclick={clearDownloadHistory}
title="Clear completed downloads"
>
<Icons.Trash2 size={14} />
Clear History
</button>
{/if}
</div>
</div>
<!-- Downloads list -->
<div class="space-y-4">
{#each Object.entries(downloadingQuants).filter(([key, progress]) => progress.isDone) as [key, progress]}
{@render downloadItem(key, progress)}
{/each}
</div>
</div>
{/if}
</div>
</div>
{#snippet downloadItem(key, progress)}
{#if key !== "undefined"}
{@const fileStats = getFileStats(progress)}
<div
class="bg-surface-100-900 border-surface-300-700 rounded-lg border p-4"
>
<div class="flex items-start gap-4">
<div class="bg-primary-500/10 mt-1 rounded-full p-2">
{#if progress.isDone}
{#if progress.status.toLowerCase() === "cancelled"}
<Icons.X size={16} class="text-orange-500" />
{:else if progress.status.toLowerCase() === "error"}
<Icons.AlertTriangle
size={16}
class="text-red-500"
/>
{:else}
<Icons.Check size={16} class="text-green-500" />
{/if}
{:else}
<Icons.Download
size={16}
class="text-primary-500 animate-pulse"
/>
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="mb-3 flex items-center justify-between">
<h4 class="text-foreground truncate font-semibold">
{progress.modelName}
</h4>
<div class="ml-2 flex items-center gap-2">
{#if !progress.isDone}
<button
class="btn btn-sm preset-filled-error-500"
onclick={() =>
cancelDownload?.(progress.modelName)}
title="Cancel download"
>
<Icons.X size={14} />
Cancel
</button>
{/if}
</div>
</div>
<div class="space-y-3">
<!-- Individual file progress bars -->
<div class="space-y-3">
{#each Object.entries(progress.files) as [fileName, fileProgress]}
<div class="space-y-1">
<div
class="flex items-center justify-between text-xs"
>
<span
class="text-muted-foreground max-w-[60%] truncate font-mono"
title={fileName}
>
{fileName}
</span>
<span
class="text-muted-foreground font-mono"
>
{fileProgress.total > 0
? `${((fileProgress.completed / fileProgress.total) * 100).toFixed(1)}%`
: "0%"}
</span>
</div>
<div class="w-full">
<Progress
value={fileProgress.completed}
max={fileProgress.total}
/>
</div>
{#if fileProgress.total > 0}
<div
class="text-muted-foreground flex justify-end font-mono text-[10px]"
>
{(
fileProgress.completed /
(1024 * 1024)
).toFixed(1)}MB /
{(
fileProgress.total /
(1024 * 1024)
).toFixed(1)}MB
</div>
{/if}
</div>
{/each}
</div>
<div
class="border-surface-300-700 flex items-center justify-between border-t pt-2 text-xs"
>
<div class="flex items-center gap-2">
<div
class="h-2 w-2 rounded-full {progress.isDone
? ''
: 'animate-pulse'} {progress.status.toLowerCase() ===
'canceled'
? 'bg-orange-500'
: ["error", "cancelled"].includes(progress.status.toLowerCase())
? 'bg-red-500'
: progress.status.toLowerCase() ===
'success' ||
isComplete(progress)
? 'bg-green-500'
: 'bg-blue-500'}"
></div>
<span class="text-muted-foreground font-medium">
{progress.status}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
{/snippet}

View file

@ -0,0 +1,397 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import * as skio from "sveltekit-io"
import { onMount, onDestroy, getContext } from "svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
import { toaster } from "$lib/client/utils/toaster"
import type { ListResponse, ModelDetails, ModelResponse } from "ollama"
import { CONNECTION_TYPE } from "$lib/shared/constants/ConnectionTypes"
interface OllamaModel {
name: string
size: number
digest: string
modified_at: string
details?: {
parameter_size: string
quantization_level: string
}
}
const socket = skio.get()
// State
let installedModels: OllamaModel[] = $state([])
let isConnected = $state(true)
let isLoading = $state(false)
let searchQuery = $state("")
let runningModels: ListResponse["models"] = $state([])
let userCtx: UserCtx = $state(getContext("userCtx"))
let showDeleteModal = $state(false)
let modelToDelete: OllamaModel | null = $state(null)
// Context
let systemSettingsCtx: SystemSettingsCtx = $state(
getContext("systemSettingsCtx")
)
// Filtered models based on search
let filteredModels = $derived(
installedModels
.filter((model) =>
model.name.toLowerCase().includes(searchQuery.toLowerCase())
)
.sort((a, b) => {
// Sort if the model is currently connected
if (currentConnectionModelName === a.name) return -1
if (currentConnectionModelName === b.name) return 1
// Sort by name, then by size
if (a.name < b.name) return -1
if (a.name > b.name) return 1
return a.size - b.size
})
)
let currentConnectionModelName: string | null = $derived.by(() => {
if (userCtx?.user?.activeConnection?.type === CONNECTION_TYPE.OLLAMA) {
const currentName = userCtx.user.activeConnection.model
return currentName
}
return null
})
$effect(() => {
console.log(
"Current connection model name:",
currentConnectionModelName
)
})
// Format file size
function formatSize(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"]
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString()
}
function isModelRunning(model: OllamaModel): boolean {
const res = runningModels.some((runningModel) => {
return runningModel.name === model.name
})
return res
}
// Check Ollama connection and refresh models
async function refreshModels() {
isLoading = true
socket.emit("ollamaModelsList", {})
socket.emit("ollamaListRunningModels", {})
socket.emit("connectionsList", {})
}
// Delete a model
async function deleteModel(model: OllamaModel) {
if (!isConnected) {
toaster.error({ title: "Not connected to Ollama" })
return
}
if (currentConnectionModelName === model.name) {
toaster.error({
title: "Cannot delete connected model",
description:
"Please choose a different connection before deleting it."
})
return
}
socket.emit("ollamaDeleteModel", { modelName: model.name })
}
// Delete modal handlers
function handleDeleteClick(model: OllamaModel) {
modelToDelete = model
showDeleteModal = true
}
function handleDeleteModalConfirm() {
if (modelToDelete) {
deleteModel(modelToDelete)
}
showDeleteModal = false
modelToDelete = null
}
function handleDeleteModalCancel() {
showDeleteModal = false
modelToDelete = null
}
function connectToModel(model: OllamaModel) {
if (!isConnected) {
toaster.error({ title: "Not connected to Ollama" })
return
}
if (currentConnectionModelName === model.name) {
toaster.error({
title: "Already connected to this model",
description: "Please choose a different model to connect."
})
return
}
socket.emit("ollamaConnectModel", { modelName: model.name })
}
// View model website
function viewModelWebsite(model: OllamaModel) {
const modelName = model.name.split(":")[0] // Remove version if present
// Determine if ollama.com or huggingface.co
if (modelName.includes("hf.co")) {
window.open("https://" + modelName, "_blank")
} else {
window.open(`https://ollama.com/library/${modelName}`, "_blank")
}
}
onMount(() => {
// Socket event listeners
socket.on(
"ollamaModelsList",
(message: Sockets.OllamaModelsList.Response) => {
installedModels = message.models
isLoading = false
console.log("ollamaModelsList", message.models)
}
)
socket.on(
"ollamaDeleteModel",
(message: Sockets.OllamaDeleteModel.Response) => {
if (message.success) {
refreshModels()
toaster.success({ title: "Model deleted successfully" })
} else {
toaster.error({ title: "Failed to delete model" })
}
}
)
socket.on(
"ollamaListRunningModels",
(message: Sockets.OllamaListRunningModels.Response) => {
runningModels = message.models
}
)
socket.on(
"ollamaStopModel",
(message: Sockets.OllamaStopModel.Response) => {
if (message.success) {
toaster.success({ title: "Model stopped successfully" })
refreshModels()
}
}
)
socket.on(
"ollamaConnectModel",
(message: Sockets.OllamaConnectModel.Response) => {
if (message.success) {
toaster.success({ title: "Model connected successfully" })
refreshModels()
}
}
)
// Initial load
refreshModels()
})
onDestroy(() => {
socket.off("ollamaModelsList")
socket.off("ollamaDeleteModel")
socket.off("ollamaListRunningModels")
socket.off("ollamaStopModel")
socket.off("ollamaConnectModel")
})
</script>
<!-- Search for installed models -->
<div class="px-4 py-2">
<div class="flex gap-2">
<div class="relative flex-1">
<Icons.Search
class="text-surface-500 absolute top-1/2 left-3 -translate-y-1/2 transform"
size={16}
/>
<input
type="text"
placeholder="Search installed models..."
class="input w-full pl-10"
bind:value={searchQuery}
/>
</div>
<button
class="btn preset-filled-surface-500"
onclick={refreshModels}
title="Refresh models"
>
<Icons.RefreshCw size={16} />
</button>
</div>
</div>
{#if !isConnected}
<div class="p-6 text-center">
<Icons.AlertCircle class="text-error-500 mx-auto mb-4" size={48} />
<h3 class="h4 mb-2">Cannot connect to Ollama</h3>
<p class="mb-4 text-sm opacity-75">
Make sure Ollama is running and accessible at the configured URL.
</p>
<button class="btn preset-filled-primary-500" onclick={refreshModels}>
<Icons.RefreshCw size={16} />
Try Again
</button>
</div>
{:else if isLoading}
<div class="p-6 text-center">
<Icons.Loader2 class="mx-auto mb-4 animate-spin" size={32} />
<p class="text-sm opacity-75">Loading installed models...</p>
</div>
{:else if filteredModels.length === 0}
<div class="p-6 text-center">
<Icons.Package class="text-surface-500 mx-auto mb-4" size={48} />
<h3 class="h4 mb-2">No models installed</h3>
<p class="mb-4 text-sm opacity-75">
Install models from the Available tab to get started.
</p>
</div>
{:else}
<div class="space-y-3 p-4">
{#each filteredModels as model}
{@const isRunning = isModelRunning(model)}
{@const isConnected = currentConnectionModelName === model.name}
<div class="card preset-tonal flex flex-col gap-2 p-4">
<div class="flex items-center justify-between">
<h4 class="font-semibold">
{#if isConnected}
<Icons.Check
size={14}
class="text-success-500 inline-block"
/>
{/if}
{model.name}
</h4>
</div>
<div class="text-surface-600 space-y-1 text-sm">
<div class="flex justify-between">
<span>Size:</span>
<span>{formatSize(model.size)}</span>
</div>
<div class="flex justify-between">
<span>Modified:</span>
<span>{formatDate(model.modified_at)}</span>
</div>
{#if model.details}
<div class="flex justify-between">
<span>Parameters:</span>
<span>{model.details.parameter_size}</span>
</div>
{/if}
{#if isRunning}
<div class="flex justify-between">
<span>Status:</span>
<span
class="preset-filled-success-500 rounded-xl px-2 py-1"
>
Running
</span>
</div>
{/if}
</div>
<div class="flex justify-between gap-2">
<div class="flex gap-2">
<button
class="btn btn-sm preset-filled-success-500"
title="Connect to this model"
aria-label="Connect to model"
disabled={isConnected}
onclick={() => connectToModel(model)}
>
{#if isConnected}
<Icons.Check size={14} /> Connected
{:else}
<Icons.Cable size={14} /> Connect
{/if}
</button>
<button
class="btn btn-sm preset-filled-surface-500"
onclick={() => viewModelWebsite(model)}
title="View model website"
>
<Icons.ExternalLink size={14} /> View
</button>
</div>
<button
class="btn btn-sm preset-filled-error-500"
onclick={() => handleDeleteClick(model)}
title="Delete model"
>
<Icons.Trash2 size={14} /> Delete
</button>
</div>
</div>
{/each}
</div>
{/if}
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<header class="flex justify-between">
<h2 class="h2">Delete Model</h2>
</header>
<article>
<p class="opacity-60">
Are you sure you want to delete "{modelToDelete}" from Ollama?
This action cannot be undone.
</p>
<p class="opacity-60">
Any associated connections to this model will be removed.
</p>
</article>
<footer class="flex justify-end gap-4">
<button
class="btn preset-filled-surface-500"
onclick={handleDeleteModalCancel}
>
Cancel
</button>
<button
class="btn preset-filled-error-500"
onclick={handleDeleteModalConfirm}
>
Delete
</button>
</footer>
{/snippet}
</Modal>

View file

@ -0,0 +1,295 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { toaster } from "$lib/client/utils/toaster"
import * as skio from "sveltekit-io"
import { onMount, onDestroy, getContext } from "svelte"
import OllamaIcon from "../icons/OllamaIcon.svelte"
interface OllamaModel {
name: string
size: number
digest: string
modified_at: string
details?: {
parameter_size: string
quantization_level: string
}
}
const socket = skio.get()
// State
let currentVersion = $state("")
let updateAvailable = $state(false)
let latestVersion = $state("")
let isCheckingUpdates = $state(false)
let isSavingBaseUrl = $state(false)
let showDeleteModal = $state(false)
let modelToDelete: OllamaModel | null = $state(null)
let baseUrlField = $state("")
// Context
let systemSettingsCtx: SystemSettingsCtx = $state(
getContext("systemSettingsCtx")
)
$effect(() => {
// Update baseUrl when system settings change
baseUrlField = systemSettingsCtx.settings.ollamaManagerBaseUrl
})
// Settings functions
function checkOllamaVersion() {
socket.emit("ollamaVersion", {})
}
function checkForUpdates() {
isCheckingUpdates = true
socket.emit("ollamaIsUpdateAvailable", {})
}
function saveBaseUrl() {
if (!baseUrlField.trim()) {
toaster.error({ title: "Base URL cannot be empty" })
return
}
isSavingBaseUrl = true
socket.emit("ollamaSetBaseUrl", { baseUrl: baseUrlField.trim() })
}
function handleSaveBaseUrl() {
if (!baseUrlField.trim()) {
toaster.error({ title: "Base URL cannot be empty" })
return
}
saveBaseUrl()
}
// Model management functions
function handleDeleteModel(model: OllamaModel) {
modelToDelete = model
showDeleteModal = true
}
function handleDeleteModalConfirm() {
if (modelToDelete) {
socket.emit("ollamaDeleteModel", { modelName: modelToDelete.name })
}
showDeleteModal = false
modelToDelete = null
}
function handleDeleteModalCancel() {
showDeleteModal = false
modelToDelete = null
}
// Format file size
function formatSize(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"]
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString()
}
onMount(() => {
// Socket event listeners
socket.on(
"ollamaSetBaseUrl",
(message: Sockets.OllamaSetBaseUrl.Response) => {
isSavingBaseUrl = false
if (message.success) {
toaster.success({
title: "Ollama URL updated successfully"
})
} else {
toaster.error({ title: "Failed to update Ollama URL" })
}
}
)
socket.on(
"ollamaVersion",
(message: Sockets.OllamaVersion.Response) => {
currentVersion = message.version || "Unknown"
}
)
socket.on(
"ollamaIsUpdateAvailable",
(message: Sockets.OllamaIsUpdateAvailable.Response) => {
isCheckingUpdates = false
updateAvailable = message.updateAvailable
latestVersion = message.latestVersion || ""
if (message.error) {
toaster.error({
title: "Failed to check for updates",
description: message.error
})
}
}
)
// Load version info when component mounts
checkOllamaVersion()
checkForUpdates()
})
onDestroy(() => {
socket.off("ollamaSetBaseUrl")
socket.off("ollamaVersion")
socket.off("ollamaIsUpdateAvailable")
})
</script>
<div class="space-y-6 p-4">
<!-- Version Information -->
<div class="text-center mt-8">
<OllamaIcon class="text-muted-foreground mx-auto mb-4 h-16 w-16" />
<span class="h5">Ollama</span>
<!-- Links to documentation and GitHub -->
<div class="flex items-center justify-center gap-4 mb-6">
<a
href="https://ollama.ai/docs"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-primary-500 transition-colors text-xs flex items-center gap-1"
>
<Icons.BookOpen class="h-3 w-3" />
Documentation
</a>
<div class="text-muted-foreground"></div>
<a
href="https://github.com/ollama/ollama"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-primary-500 transition-colors text-xs flex items-center gap-1"
>
<Icons.Github class="h-3 w-3" />
GitHub
</a>
</div>
</div>
<div class="card bg-surface-100-800 flex flex-col gap-4 p-4">
<div>
<label class="block text-sm font-medium" for="baseUrl">
Ollama Base URL
</label>
<div class="flex gap-2">
<input
id="baseUrl"
name="baseUrl"
type="url"
class="input flex-1"
placeholder="http://localhost:11434"
bind:value={baseUrlField}
/>
<button
class="btn preset-filled-primary-500"
onclick={handleSaveBaseUrl}
disabled={isSavingBaseUrl}
>
<Icons.Save size={14} />
Save
</button>
</div>
<p class="text-surface-500 mt-1 text-xs">
The URL where Ollama is running. Usually http://localhost:11434
</p>
</div>
<div class="space-y-3">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-surface-600">Current Version:</span>
<span class="font-mono">{currentVersion || "Unknown"}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-surface-600">Latest Version:</span>
<span class="text-warning-500 font-mono">
{latestVersion}
</span>
</div>
</div>
{#if updateAvailable}
<div
class="bg-warning-100 dark:bg-warning-900 border-warning-300 dark:border-warning-700 rounded-lg border p-3"
>
<div class="mb-2 flex items-center gap-2">
<Icons.AlertTriangle
size={16}
class="text-warning-600"
/>
<span
class="text-warning-800 dark:text-warning-200 font-medium"
>
Update Available
</span>
</div>
<p
class="text-warning-700 dark:text-warning-300 mb-3 text-sm"
>
A new version of Ollama is available. Download it to get
the latest features and bug fixes.
</p>
<a
href="https://github.com/ollama/ollama/releases/latest"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm preset-filled-warning-500"
>
<Icons.Download size={14} />
Download Update
</a>
</div>
{:else if currentVersion}
<div
class="bg-success-100 dark:bg-success-900 border-success-300 dark:border-success-700 rounded-lg border p-3"
>
<div class="flex items-center gap-2">
<Icons.Check size={16} class="text-success-600" />
<span
class="text-success-800 dark:text-success-200 font-medium"
>
You're up to date
</span>
</div>
</div>
{/if}
<div class="flex gap-2">
<button
class="btn btn-sm preset-filled-surface-500"
onclick={checkOllamaVersion}
>
<Icons.RefreshCw size={14} />
Check Version
</button>
<button
class="btn btn-sm preset-filled-surface-500"
onclick={checkForUpdates}
disabled={isCheckingUpdates}
>
{#if isCheckingUpdates}
<Icons.Loader2 size={14} class="animate-spin" />
Checking...
{:else}
<Icons.Search size={14} />
Check for Updates
{/if}
</button>
</div>
</div>
</div>
</div>

View file

@ -351,7 +351,7 @@
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
@ -422,7 +422,7 @@
importingLorebook = null
importingLorebookCharacter = null
}}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}

View file

@ -354,7 +354,7 @@
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}

View file

@ -5,7 +5,6 @@
import { Modal } from "@skeletonlabs/skeleton-svelte"
import { z } from "zod"
import OllamaForm from "$lib/client/connectionForms/OllamaForm.svelte"
// import ChatGPTForm from "$lib/client/connectionForms/ChatGPTForm.svelte"
import OpenAIForm from "$lib/client/connectionForms/OpenAIForm.svelte"
import LmStudioForm from "$lib/client/connectionForms/LMStudioForm.svelte"
import {
@ -23,6 +22,8 @@
let { onclose = $bindable() }: Props = $props()
let userCtx: UserCtx = getContext("userCtx")
let systemSettingsCtx: SystemSettingsCtx = getContext("systemSettingsCtx")
const socket = skio.get()
const OAIChatPresets: {
@ -422,7 +423,7 @@
socket.emit("connection", { id: userCtx.user.activeConnectionId })
}
onclose = handleOnClose
// If ollama, fetch models
if (connection?.type === "ollama" && connection.baseUrl) {
handleRefreshModels()
}
@ -441,63 +442,37 @@
</script>
<div class="text-foreground p-4">
<div class="mt-2 mb-2 flex gap-2 sm:mt-0">
<button
type="button"
class="btn btn-sm preset-filled-primary-500"
onclick={handleNew}
>
<Icons.Plus size={16} />
</button>
<button
type="button"
class="btn btn-sm preset-filled-secondary-500"
onclick={handleReset}
disabled={!unsavedChanges}
>
<Icons.RefreshCcw size={16} />
</button>
<button
type="button"
class="btn btn-sm preset-filled-error-500"
onclick={handleDelete}
disabled={!connection}
>
<Icons.X size={16} />
</button>
</div>
<div
class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center"
class:hidden={!connectionsList.length}
>
<select
class="select bg-background border-muted rounded border"
onchange={handleSelectChange}
bind:value={userCtx!.user!.activeConnectionId}
disabled={unsavedChanges}
>
{#each connectionsList as c}
<option value={c.id}>
{c.name} ({CONNECTION_TYPE.options.find(
(t) => t.value === c.type
)!.label})
</option>
{/each}
</select>
</div>
{#if !!connection}
{#key connection.id}
<div class="my-4 flex">
<div class="mt-2 mb-2 flex justify-between gap-2 sm:mt-0">
<div class="gap-2">
<button
type="button"
class="btn btn-sm preset-filled-success-500 w-full"
onclick={handleUpdate}
disabled={!unsavedChanges}
class="btn btn-sm preset-filled-primary-500"
onclick={handleNew}
title="Create New Connection"
aria-label="Create New Connection"
>
<Icons.Save size={16} />
Save
<Icons.Plus size={16} />
</button>
<button
type="button"
class="btn btn-sm preset-filled-secondary-500"
onclick={handleReset}
disabled={!unsavedChanges}
title="Reset Changes"
aria-label="Reset Changes"
>
<Icons.RefreshCcw size={16} />
</button>
<button
type="button"
class="btn btn-sm preset-filled-error-500"
onclick={handleDelete}
disabled={!connection}
>
<Icons.X size={16} />
</button>
</div>
<<<<<<< HEAD
<div class="flex flex-col gap-1">
<label class="font-semibold" for="name">Name</label>
<input
@ -553,14 +528,72 @@
{#if !connectionsList.length}
<div class="text-muted-foreground py-8 text-center">
No connections found. Create a new connection to get started.
=======
>>>>>>> feature/ollama-manager
</div>
{/if}
</div>
<div
class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center"
class:hidden={!connectionsList.length}
>
<select
class="select bg-background border-muted rounded border"
onchange={handleSelectChange}
bind:value={userCtx!.user!.activeConnectionId}
disabled={unsavedChanges}
>
{#each connectionsList as c}
<option value={c.id}>
{c.name} ({CONNECTION_TYPE.options.find(
(t) => t.value === c.type
)!.label})
</option>
{/each}
</select>
</div>
{#if !!connection}
{#key connection.id}
<div class="my-4 flex">
<button
type="button"
class="btn btn-sm preset-filled-success-500 w-full"
onclick={handleUpdate}
disabled={!unsavedChanges}
>
<Icons.Save size={16} />
Save
</button>
</div>
<div class="flex flex-col gap-1">
<label class="font-semibold" for="name">Name</label>
<input
id="name"
type="text"
bind:value={connection.name}
class="input"
/>
</div>
{#if connection.type === CONNECTION_TYPE.OLLAMA}
<OllamaForm bind:connection />
{:else if connection.type === CONNECTION_TYPE.OPENAI_CHAT}
<OpenAIForm bind:connection />
{:else if connection.type === CONNECTION_TYPE.LM_STUDIO}
<LmStudioForm bind:connection />
{:else if connection.type === CONNECTION_TYPE.LLAMACPP_COMPLETION}
<LlamaCppForm bind:connection />
{/if}
{/key}
{/if}
{#if !connectionsList.length}
<div class="text-muted-foreground py-8 text-center">
No connections found. Create a new connection to get started.
</div>
{/if}
</div>
<Modal
open={showConfirmModal}
onOpenChange={(e) => (showConfirmModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
@ -592,7 +625,7 @@
<Modal
open={showNewConnectionModal}
onOpenChange={(e) => (showNewConnectionModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
@ -693,7 +726,7 @@
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}

View file

@ -552,7 +552,7 @@
showImportModal = e.open
if (!e.open) importingBook = undefined
}}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
@ -610,7 +610,7 @@
showDeleteConfirmationModal = e.open
if (!e.open) deletingLorebookId = undefined
}}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}

View file

@ -0,0 +1,267 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import { getContext, onMount, onDestroy } from "svelte"
import { Tabs } from "@skeletonlabs/skeleton-svelte"
import type { ValueChangeDetails } from "@zag-js/tabs"
import OllamaInstalledTab from "../ollamaManager/OllamaInstalledTab.svelte"
import OllamaAvailableTab from "../ollamaManager/OllamaAvailableTab.svelte"
import OllamaSettingsTab from "../ollamaManager/OllamaSettingsTab.svelte"
import OllamaDownloadsTab from "../ollamaManager/OllamaDownloadsTab.svelte"
import * as skio from "sveltekit-io"
import { toaster } from "$lib/client/utils/toaster"
import OllamaIcon from "../icons/OllamaIcon.svelte"
interface Props {
onclose?: () => Promise<boolean> | undefined
}
let { onclose = $bindable() }: Props = $props()
const socket = skio.get()
// State
let activeTab = $state<
"installed" | "available" | "downloads" | "settings"
>("installed")
let themeCtx: ThemeCtx = $state(getContext("themeCtx"))
let isConnected = $state(false)
let systemSettingsCtx: SystemSettingsCtx = $state(
getContext("systemSettingsCtx")
)
let isSavingBaseUrl = $state(false)
let baseUrlField = $state("")
// Handle tab switching
function handleTabChange(e: ValueChangeDetails): void {
activeTab = e.value as
| "installed"
| "available"
| "downloads"
| "settings"
}
// Handle download start - switch to downloads tab
function handleDownloadStart(modelName: string) {
activeTab = "downloads"
}
// Check connection to Ollama
function checkConnection() {
socket.emit("ollamaVersion", {})
}
// Save base URL
function handleSaveBaseUrl() {
if (!systemSettingsCtx.settings.ollamaManagerBaseUrl.trim()) {
toaster.error({ title: "Base URL cannot be empty" })
return
}
isSavingBaseUrl = true
socket.emit("ollamaSetBaseUrl", {
baseUrl: baseUrlField.trim()
})
}
$effect(() => {
baseUrlField = systemSettingsCtx.settings.ollamaManagerBaseUrl
})
onMount(() => {
socket.on(
"ollamaVersion",
(message: Sockets.OllamaVersion.Response) => {
// We only want to set isConnected if it hasn't been set yet
// We don't want to display the initial setup screen if the user
// is working in the settings tab, etc.
if (!isConnected) {
isConnected = !!message.version
}
}
)
socket.on(
"ollamaSetBaseUrl",
(message: Sockets.OllamaSetBaseUrl.Response) => {
isSavingBaseUrl = false
if (message.success) {
toaster.success({
title: "Ollama URL updated successfully"
})
// Try to reconnect after URL change
checkConnection()
} else {
toaster.error({ title: "Failed to update Ollama URL" })
}
}
)
// Check initial connection
checkConnection()
})
onDestroy(() => {
socket.off("ollamaVersion")
socket.off("ollamaSetBaseUrl")
})
// Sidebar close handler
onclose = async () => {
return true // Allow closing
}
</script>
<div class="flex h-full flex-col">
<!-- Check if Ollama Manager is enabled -->
{#if !systemSettingsCtx.settings.ollamaManagerEnabled}
<div class="flex flex-1 items-center justify-center p-4">
<div class="text-center">
<Icons.AlertCircle
class="text-warning-500 mx-auto mb-4 h-12 w-12"
/>
<h3 class="text-foreground mb-2 text-lg font-semibold">
Ollama Manager Disabled
</h3>
<p class="text-muted-foreground text-sm">
Enable Ollama Manager in Settings to use this feature.
</p>
</div>
</div>
{:else if !isConnected}
<!-- Connection setup -->
<div class="flex items-center justify-center p-4 mt-10">
<div class="w-full max-w-md space-y-6">
<div class="text-center">
<OllamaIcon class="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 class="text-foreground mb-2 text-lg font-semibold">
Connect to Ollama
</h3>
<p class="text-muted-foreground mb-4 text-sm">
Connect to your Ollama server to manage AI models locally.
</p>
</div>
<!-- Don't have Ollama installed? -->
<div class="bg-surface-200 dark:bg-surface-800 rounded-lg border p-4">
<div class="flex items-start gap-3">
<Icons.Download class="text-primary-500 mt-0.5 h-5 w-5 flex-shrink-0" />
<div class="flex-1">
<h4 class="text-foreground mb-1 text-sm font-medium">
Don't have Ollama installed?
</h4>
<p class="text-muted-foreground mb-3 text-xs">
Download and install Ollama to run AI models locally on your machine. It's easy!
</p>
<a
href="https://ollama.ai/download"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm preset-filled-primary-500 inline-flex items-center gap-2"
>
<Icons.ExternalLink class="h-4 w-4" />
Download Ollama
</a>
</div>
</div>
</div>
<!-- Connection form -->
<div class="space-y-3">
<div>
<label
class="text-foreground mb-1 block text-sm font-medium"
for="ollamaBaseUrl"
>
Ollama Server URL
</label>
<input
id="ollamaBaseUrl"
type="text"
bind:value={baseUrlField}
placeholder="http://localhost:11434"
class="input w-full"
/>
</div>
<div class="flex gap-2">
<button
class="btn preset-filled-primary-500 flex-1"
onclick={handleSaveBaseUrl}
disabled={isSavingBaseUrl}
>
{#if isSavingBaseUrl}
<Icons.Loader2 class="h-4 w-4 animate-spin" />
Connecting...
{:else}
<Icons.Save class="h-4 w-4" />
Save & Connect
{/if}
</button>
<button
class="btn preset-filled-secondary-500"
onclick={checkConnection}
title="Test connection to Ollama server"
>
<Icons.RefreshCw class="h-4 w-4" />
Test
</button>
</div>
</div>
</div>
</div>
{:else}
<!-- Main Ollama Manager Content -->
<div class="flex-1 overflow-y-auto">
<Tabs value={activeTab} onValueChange={handleTabChange}>
{#snippet list()}
<Tabs.Control value="installed">
<Icons.Package size={20} class="inline" />
{#if activeTab === "installed"}
Installed
{/if}
</Tabs.Control>
<Tabs.Control value="available">
<Icons.Search size={20} class="inline" />
{#if activeTab === "available"}
Available
{/if}
</Tabs.Control>
<Tabs.Control value="downloads">
<Icons.Download size={20} class="inline" />
{#if activeTab === "downloads"}
Downloads
{/if}
</Tabs.Control>
<Tabs.Control value="settings">
<Icons.Settings size={20} class="inline" />
{#if activeTab === "settings"}
Settings
{/if}
</Tabs.Control>
{/snippet}
{#snippet content()}
<Tabs.Panel value="installed">
{#if activeTab === "installed"}
<OllamaInstalledTab />
{/if}
</Tabs.Panel>
<Tabs.Panel value="available">
{#if activeTab === "available"}
<OllamaAvailableTab onDownloadStart={handleDownloadStart} />
{/if}
</Tabs.Panel>
<Tabs.Panel value="downloads">
{#if activeTab === "downloads"}
<OllamaDownloadsTab />
{/if}
</Tabs.Panel>
<Tabs.Panel value="settings">
{#if activeTab === "settings"}
<OllamaSettingsTab />
{/if}
</Tabs.Panel>
{/snippet}
</Tabs>
</div>
{/if}
</div>

View file

@ -235,7 +235,7 @@
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}

View file

@ -8,15 +8,24 @@
} from "$lib/shared/constants/version"
import * as Icons from "@lucide/svelte"
import { page } from "$app/state"
import * as skio from "sveltekit-io"
interface Props {
onclose?: () => Promise<boolean> | undefined
}
let { onclose = $bindable() }: Props = $props()
const socket = skio.get()
let isDarkMode = $state(false)
let selectedTheme: string = $state("")
let themeCtx: ThemeCtx = $state(getContext("themeCtx"))
let systemSettingsCtx: SystemSettingsCtx = $state(getContext("systemSettingsCtx"))
$effect(() => {
const _s = {...$state.snapshot(systemSettingsCtx)}
console.log("SettingsSidebar systemSettingsCtx", $state.snapshot(systemSettingsCtx))
})
$effect(() => {
isDarkMode = themeCtx.mode === "dark"
@ -37,6 +46,13 @@
// TODO use setTheme socket call
}
async function onOllamaManagerEnabledClick(event: { checked: boolean }) {
const res: Sockets.UpdateOllamaManagerEnabled.Call = {
enabled: event.checked
}
socket.emit("updateOllamaManagerEnabled", res)
}
onMount(() => {
onclose = async () => {
return true
@ -87,6 +103,14 @@
></Switch>
<label for="dark-mode" class="font-semibold">Dark Mode</label>
</div>
<div class="flex gap-2">
<Switch
name="dark-mode"
checked={systemSettingsCtx.settings.ollamaManagerEnabled}
onCheckedChange={onOllamaManagerEnabledClick}
></Switch>
<label for="dark-mode" class="font-semibold">Enable Ollama Manager</label>
</div>
</div>
<div
@ -114,6 +138,24 @@
<Icons.GitBranch size={16} />
<span>Repository</span>
</a>
<a
href="https://github.com/doolijb/serene-pub/wiki"
target="_blank"
rel="noopener noreferrer"
class="btn preset-filled-surface-500"
>
<Icons.BookOpen size={16} />
<span>Wiki</span>
</a>
<a
href="https://discord.gg/3kUx3MDcSa"
target="_blank"
rel="noopener noreferrer"
class="btn preset-filled-tertiary-500"
>
<Icons.MessageSquare size={16} />
<span>Discord</span>
</a>
<a
href="https://github.com/doolijb/serene-pub/issues"
target="_blank"

View file

@ -5,7 +5,6 @@ import * as skio from "sveltekit-io"
export async function loadSocketsClient({ domain }: { domain: string }) {
const res = await axios.get("/api/sockets-endpoint")
const serverUrl = new URL(res.data.endpoint)
console.log("Server URL:", serverUrl)
const host = `${serverUrl.protocol}//${domain}:${serverUrl.port}`
console.log("Connecting to socket server at:", host)
@ -17,8 +16,4 @@ export async function loadSocketsClient({ domain }: { domain: string }) {
if (typeof io.to !== "function") {
io.to = () => ({ emit: () => {} })
}
if (dev) {
console.log("Client socket initialized:", host)
}
}

View file

@ -16,6 +16,7 @@ import {
LMStudioClient,
type OngoingPrediction
} from "@lmstudio/sdk"
import { CONNECTION_TYPE } from "$lib/shared/constants/ConnectionTypes"
class LMStudioAdapter extends BaseConnectionAdapter {
private _client?: LMStudioClient
@ -331,6 +332,7 @@ class LMStudioAdapter extends BaseConnectionAdapter {
}
const connectionDefaults = {
type: CONNECTION_TYPE.LM_STUDIO,
baseUrl: "ws://localhost:1234",
promptFormat: PromptFormats.VICUNA,
tokenCounter: TokenCounterOptions.ESTIMATE, // Use Gemma tokenizer for better accuracy with Gemma models

View file

@ -10,6 +10,7 @@ import {
} from "./BaseConnectionAdapter"
import axios from "axios"
import { Readable } from "stream"
import { CONNECTION_TYPE } from "$lib/shared/constants/ConnectionTypes"
// GET /health
export type HealthResponse =
@ -436,6 +437,7 @@ class LlamaCppAdapter extends BaseConnectionAdapter {
}
const connectionDefaults = {
type: CONNECTION_TYPE.LLAMACPP_COMPLETION,
baseUrl: "http://localhost:8080/",
promptFormat: PromptFormats.VICUNA,
tokenCounter: TokenCounterOptions.ESTIMATE,

View file

@ -10,6 +10,7 @@ import {
type AdapterExports,
type BaseChat
} from "./BaseConnectionAdapter"
import { CONNECTION_TYPE } from "$lib/shared/constants/ConnectionTypes"
class OllamaAdapter extends BaseConnectionAdapter {
private _client?: Ollama
@ -292,6 +293,7 @@ class OllamaAdapter extends BaseConnectionAdapter {
}
const connectionDefaults = {
type: CONNECTION_TYPE.OLLAMA,
baseUrl: "http://localhost:11434/",
promptFormat: PromptFormats.VICUNA,
tokenCounter: TokenCounterOptions.ESTIMATE,

View file

@ -11,6 +11,7 @@ import type {
ChatCompletionCreateParamsBase,
ChatCompletionMessageParam
} from "../../../../node_modules/openai/src/resources/chat/completions/completions"
import { CONNECTION_TYPE } from "$lib/shared/constants/ConnectionTypes"
export class OpenAIChatAdapter extends BaseConnectionAdapter {
constructor({
@ -189,6 +190,7 @@ export class OpenAIChatAdapter extends BaseConnectionAdapter {
}
const connectionDefaults = {
type: CONNECTION_TYPE.OPENAI_CHAT,
baseUrl: "",
promptFormat: PromptFormats.VICUNA,
tokenCounter: TokenCounterOptions.ESTIMATE,

View file

@ -300,6 +300,19 @@ Story history:
console.error("Error syncing database defaults:", error)
}
try {
const res = await db.query.systemSettings.findFirst({where: (s, {eq}) => eq(s.id, 1)})
if (!res) {
await db.insert(schema.systemSettings).values({
id: 1,
ollamaManagerEnabled: true,
ollamaManagerBaseUrl: "http://localhost:11434/"
})
}
} catch (error) {
console.error("Error syncing system settings:", error)
}
const tables = [
"chat_messages",
"chats",

View file

@ -666,3 +666,12 @@ export const chatLorebooksRelations = relations(chatLorebooks, ({ one }) => ({
references: [lorebooks.id]
})
}))
/**
* Singleton table for system-wide settings
*/
export const systemSettings = pgTable("system_settings", {
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
ollamaManagerEnabled: boolean("ollama_manager_enabled").notNull().default(false),
ollamaManagerBaseUrl: text("ollama_base_url").notNull().default("http://localhost:11434/"),
})

View file

@ -1,9 +1,10 @@
import { db } from "$lib/server/db"
import { eq } from "drizzle-orm"
import { and, eq } from "drizzle-orm"
import * as schema from "$lib/server/db/schema"
import { user as loadUser, user } from "./users"
import { getConnectionAdapter } from "../utils/getConnectionAdapter"
// --- CONNECTIONS SOCKET HANDLERS ---
export async function connectionsList(
@ -230,3 +231,5 @@ export async function refreshModels(
emitToUser("refreshModels", res)
}
}

View file

@ -91,6 +91,7 @@ import {
iterateNextHistoryEntry,
lorebookImport
} from "./lorebooks"
<<<<<<< HEAD
import {
tagsList,
createTag,
@ -100,6 +101,23 @@ import {
addTagToCharacter,
removeTagFromCharacter
} from "./tags"
=======
import { updateOllamaManagerEnabled, systemSettings } from "./systemSettings"
import {
ollamaConnectModel,
ollamaSearchAvailableModels,
ollamaSetBaseUrl,
ollamaModelsList,
ollamaDeleteModel,
ollamaListRunningModels,
ollamaPullModel,
ollamaVersion,
ollamaIsUpdateAvailable,
ollamaCancelPull,
ollamaGetDownloadProgress,
ollamaClearDownloadHistory
} from "./ollama"
>>>>>>> feature/ollama-manager
const userId = 1 // Replace with actual user id
@ -119,6 +137,7 @@ export function connectSockets(io: {
// Users
register(socket, user, emitToUser)
// SamplingConfig
register(socket, sampling, emitToUser)
register(socket, samplingConfigsList, emitToUser)
@ -126,6 +145,7 @@ export function connectSockets(io: {
register(socket, createSamplingConfig, emitToUser)
register(socket, deleteSamplingConfig, emitToUser)
register(socket, updateSamplingConfig, emitToUser)
// Connections
register(socket, connectionsList, emitToUser)
register(socket, connection, emitToUser)
@ -135,18 +155,39 @@ export function connectSockets(io: {
register(socket, setUserActiveConnection, emitToUser)
register(socket, testConnection, emitToUser)
register(socket, refreshModels, emitToUser)
// Ollama Manager
register(socket, ollamaConnectModel, emitToUser)
register(socket, ollamaSearchAvailableModels, emitToUser)
register(socket, ollamaSetBaseUrl, emitToUser)
register(socket, ollamaModelsList, emitToUser)
register(socket, ollamaDeleteModel, emitToUser)
register(socket, ollamaListRunningModels, emitToUser)
register(socket, ollamaPullModel, emitToUser)
register(socket, ollamaCancelPull, emitToUser)
register(socket, ollamaVersion, emitToUser)
register(socket, ollamaIsUpdateAvailable, emitToUser)
register(socket, ollamaGetDownloadProgress, emitToUser)
register(socket, ollamaClearDownloadHistory, emitToUser)
// App Settings
register(socket, systemSettings, emitToUser)
register(socket, updateOllamaManagerEnabled, emitToUser)
// Characters
register(socket, characterList, emitToUser)
register(socket, character, emitToUser)
register(socket, createCharacter, emitToUser)
register(socket, updateCharacter, emitToUser)
register(socket, deleteCharacter, emitToUser)
// Personas
register(socket, personaList, emitToUser)
register(socket, persona, emitToUser)
register(socket, createPersona, emitToUser)
register(socket, updatePersona, emitToUser)
register(socket, deletePersona, emitToUser)
// Context Configs
register(socket, contextConfigsList, emitToUser)
register(socket, contextConfig, emitToUser)
@ -154,6 +195,7 @@ export function connectSockets(io: {
register(socket, updateContextConfig, emitToUser)
register(socket, deleteContextConfig, emitToUser)
register(socket, setUserActiveContextConfig, emitToUser)
// Prompt Configs
register(socket, promptConfigsList, emitToUser)
register(socket, promptConfig, emitToUser)
@ -161,6 +203,7 @@ export function connectSockets(io: {
register(socket, updatePromptConfig, emitToUser)
register(socket, deletePromptConfig, emitToUser)
register(socket, setUserActivePromptConfig, emitToUser)
// Chats
register(socket, chatsList, emitToUser)
register(socket, createChat, emitToUser)
@ -179,6 +222,7 @@ export function connectSockets(io: {
register(socket, chatMessageSwipeRight, emitToUser)
register(socket, chatMessageSwipeLeft, emitToUser)
register(socket, toggleChatCharacterActive, emitToUser)
// Lorebooks
register(socket, lorebookList, emitToUser)
register(socket, lorebook, emitToUser)
@ -203,6 +247,7 @@ export function connectSockets(io: {
register(socket, deleteHistoryEntry, emitToUser)
register(socket, iterateNextHistoryEntry, emitToUser)
register(socket, lorebookImport, emitToUser)
<<<<<<< HEAD
// Tags
register(socket, tagsList, emitToUser)
register(socket, createTag, emitToUser)
@ -211,6 +256,8 @@ export function connectSockets(io: {
register(socket, tagRelatedData, emitToUser)
register(socket, addTagToCharacter, emitToUser)
register(socket, removeTagFromCharacter, emitToUser)
=======
>>>>>>> feature/ollama-manager
console.log(`Socket connected: ${socket.id} for user ${userId}`)
})
}

View file

@ -0,0 +1,653 @@
import { db } from "$lib/server/db"
import { and, eq } from "drizzle-orm"
import * as schema from "$lib/server/db/schema"
import { user as loadUser } from "./users"
import { connectionsList } from "./connections"
import { Ollama } from "ollama"
import ollamaAdapter from "$lib/server/connectionAdapters/OllamaAdapter"
import { OllamaModelSearchSource } from "$lib/shared/constants/OllamaModelSource"
import { emit } from "process"
// --- OLLAMA SPECIFIC FUNCTIONS ---
let cancelingPulls: string[] = []
// Global download progress tracking
let downloadingQuants: {
[key: string]: {
modelName: string
status: string
isDone: boolean // Indicates if it's "done" processing regardless of success or not
files: { [key: string]: { total: number; completed: number } }
}
} = {}
// Function to emit download progress to all connected clients
function emitDownloadProgress(emitToAll: (event: string, data: any) => void) {
emitToAll("ollamaDownloadProgress", { downloadingQuants })
}
// Function to get current download progress
export async function ollamaGetDownloadProgress(
socket: any,
message: {},
emitToUser: (event: string, data: any) => void
) {
// Send current download progress as complete state
emitToUser("ollamaPullProgress", {
downloadingQuants
})
}
export async function ollamaSetBaseUrl(
socket: any,
message: Sockets.OllamaSetBaseUrl.Call,
emitToUser: (event: string, data: any) => void
) {
try {
// This would typically update the active Ollama connection's baseUrl
// For now, we'll just validate the URL format
const url = new URL(message.baseUrl)
if (!["http:", "https:"].includes(url.protocol)) {
emitToUser("error", {
error: "Invalid URL protocol"
})
throw new Error("Invalid URL protocol")
}
await db.update(schema.systemSettings).set({
ollamaManagerBaseUrl: message.baseUrl
})
const res: Sockets.OllamaSetBaseUrl.Response = {
success: true
}
emitToUser("ollamaSetBaseUrl", res)
} catch (error: any) {
console.error("Ollama set base URL error:", error)
const res = {
error: "Failed to set base URL"
}
emitToUser("error", res)
}
}
export async function ollamaModelsList(
socket: any,
message: Sockets.OllamaModelsList.Call,
emitToUser: (event: string, data: any) => void
) {
try {
const { ollamaManagerBaseUrl: baseUrl } =
(await db.query.systemSettings.findFirst())!
const ollama = new Ollama({
host: baseUrl
})
const result = await ollama.list()
const res: Sockets.OllamaModelsList.Response = {
models: result.models || []
}
emitToUser("ollamaModelsList", res)
} catch (error: any) {
console.error("Ollama models list error:", error)
const res = {
error: "Failed to list models"
}
emitToUser("error", res)
}
}
export async function ollamaDeleteModel(
socket: any,
message: Sockets.OllamaDeleteModel.Call,
emitToUser: (event: string, data: any) => void
) {
try {
const { ollamaManagerBaseUrl: baseUrl } =
(await db.query.systemSettings.findFirst())!
const ollama = new Ollama({
host: baseUrl
})
await ollama.delete({ model: message.modelName })
const res: Sockets.OllamaDeleteModel.Response = {
success: true
}
emitToUser("ollamaDeleteModel", res)
await db
.delete(schema.connections)
.where(
and(
eq(schema.connections.type, "ollama"),
eq(schema.connections.model, message.modelName)
)
)
} catch (error: any) {
console.error("Ollama delete model error:", error)
const res: Sockets.OllamaDeleteModel.Response = {
success: false
}
emitToUser("error", { error: "Failed to delete model" })
}
}
export async function ollamaConnectModel(
socket: any,
message: Sockets.OllamaConnectModel.Call,
emitToUser: (event: string, data: any) => void
) {
const userId = 1
try {
let existingConnection = await db.query.connections.findFirst({
where: (c, { eq }) =>
and(eq(c.type, "ollama"), eq(c.model, message.modelName))
})
if (!existingConnection) {
// Parse and create a shorter name for the connection
const connectionName: string = message.modelName
.split("/")
.pop()! as string
// Create a new connection if it doesn't exist
const data = {
...ollamaAdapter.connectionDefaults,
name: connectionName,
model: message.modelName
}
console.log("Creating connection", data)
const [newConnection] = await db
.insert(schema.connections)
.values(data as InsertConnection)
.returning()
existingConnection = newConnection
}
await db
.update(schema.users)
.set({
activeConnectionId: existingConnection.id
})
.where(eq(schema.users.id, userId))
await loadUser(socket, {}, emitToUser)
await connectionsList(socket, {}, emitToUser)
const res: Sockets.OllamaConnectModel.Response = {
success: true
}
emitToUser("ollamaConnectModel", res)
} catch (error: any) {
console.error("Ollama connect model error:", error)
const res = {
error: "Failed to connect to model"
}
emitToUser("error", res)
}
}
export async function ollamaListRunningModels(
socket: any,
message: Sockets.OllamaListRunningModels.Call,
emitToUser: (event: string, data: any) => void
) {
try {
const { ollamaManagerBaseUrl: baseUrl } =
(await db.query.systemSettings.findFirst())!
const ollama = new Ollama({
host: baseUrl
})
const result = await ollama.ps()
const res: Sockets.OllamaListRunningModels.Response = {
models: result.models || []
}
emitToUser("ollamaListRunningModels", res)
} catch (error: any) {
console.error("Ollama list running models error:", error)
const res = {
error: "Failed to list running models"
}
emitToUser("error", res)
}
}
export async function ollamaPullModel(
socket: any,
message: Sockets.OllamaPullModel.Call,
emitToUser: (event: string, data: any) => void
) {
try {
// Remove from cancelingPulls if it exists
if (cancelingPulls.includes(message.modelName)) {
cancelingPulls = cancelingPulls.filter(
(name) => name !== message.modelName
)
}
// Initialize download tracking
downloadingQuants[message.modelName] = {
modelName: message.modelName,
status: "starting",
isDone: false,
files: {}
}
const { ollamaManagerBaseUrl: baseUrl } =
(await db.query.systemSettings.findFirst())!
const ollama = new Ollama({
host: baseUrl
})
// For streaming progress, we could implement progress callbacks
const stream = await ollama.pull({
model: message.modelName,
stream: true
})
for await (const chunk of stream) {
if (cancelingPulls.includes(message.modelName)) {
cancelingPulls = cancelingPulls.filter(
(name) => name !== message.modelName
)
stream.abort()
// Update server state
if (downloadingQuants[message.modelName]) {
downloadingQuants[message.modelName].status = "cancelled"
downloadingQuants[message.modelName].isDone = true
}
// Emit cancellation with full state
emitToUser("ollamaPullProgress", {
downloadingQuants
})
return
}
// Emit progress updates and update server state
if (chunk.status) {
// Update server-side tracking
if (downloadingQuants[message.modelName]) {
let fileName: string | undefined
if (
chunk.status.includes("pulling ") &&
!chunk.status.includes("pulling manifest")
) {
fileName = chunk.status.split("pulling ")[1]
}
downloadingQuants[message.modelName].status = chunk.status
if (fileName) {
downloadingQuants[message.modelName].files[fileName] = {
total: chunk.total || 0,
completed: chunk.completed || 0
}
}
}
// Emit the entire downloadingQuants object for full state sync
emitToUser("ollamaPullProgress", {
downloadingQuants
})
}
}
// Update status to success
if (downloadingQuants[message.modelName]) {
downloadingQuants[message.modelName].status = "success"
downloadingQuants[message.modelName].isDone = true
}
// Emit final progress with full state
emitToUser("ollamaPullProgress", {
downloadingQuants
})
const res: Sockets.OllamaPullModel.Response = {
success: true
}
emitToUser("ollamaPullModel", res)
const successRes: Sockets.Success.Response = {
title: "Ollama Manager",
description: `Model ${message.modelName} downloaded successfully!`
}
} catch (error: any) {
console.error("Ollama pull model error:", error)
// Update server state for error
if (downloadingQuants[message.modelName]) {
downloadingQuants[message.modelName].status = "error"
downloadingQuants[message.modelName].isDone = true
}
// Emit error progress with full state
emitToUser("ollamaPullProgress", {
downloadingQuants
})
const res: Sockets.OllamaPullModel.Response = {
success: false,
error: "Failed to download model"
}
emitToUser("ollamaPullModel", res)
const errorRes: Sockets.Error.Response = {
error: "Ollama Manager",
description: "Failed to download model"
}
emitToUser("error", errorRes)
}
}
export async function ollamaVersion(
socket: any,
message: Sockets.OllamaVersion.Call,
emitToUser: (event: string, data: any) => void
) {
try {
const { ollamaManagerBaseUrl: baseUrl } =
(await db.query.systemSettings.findFirst())!
// const ollama = new Ollama({
// host: baseUrl
// })
const response = await fetch(`${baseUrl}/api/version`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result = await response.json()
const res: Sockets.OllamaVersion.Response = {
version: result.version
}
emitToUser("ollamaVersion", res)
} catch (error: any) {
console.error("Ollama version error:", error)
const res = {
error: "Failed to connect to Ollama or get version"
}
emitToUser("error", res)
}
}
export async function ollamaIsUpdateAvailable(
socket: any,
message: Sockets.OllamaIsUpdateAvailable.Call,
emitToUser: (event: string, data: any) => void
) {
try {
// Get current version using direct HTTP request
const { ollamaManagerBaseUrl: baseUrl } =
(await db.query.systemSettings.findFirst())!
const versionResponse = await fetch(`${baseUrl}/api/version`)
if (!versionResponse.ok) {
throw new Error(
`HTTP ${versionResponse.status}: ${versionResponse.statusText}`
)
}
const versionResult = await versionResponse.json()
const currentVersion = versionResult.version
// Fetch the latest version from Ollama's GitHub releases API
const githubResponse = await fetch(
"https://api.github.com/repos/ollama/ollama/releases/latest"
)
if (!githubResponse.ok) {
throw new Error(`GitHub API error: ${githubResponse.status}`)
}
const latestRelease = await githubResponse.json()
const latestVersion = latestRelease.tag_name
// Compare versions (remove 'v' prefix if present)
const currentVersionClean = currentVersion.replace(/^v/, "")
const latestVersionClean = latestVersion.replace(/^v/, "")
// Simple version comparison (works for semantic versioning)
const updateAvailable =
compareVersions(latestVersionClean, currentVersionClean) > 0
const res: Sockets.OllamaIsUpdateAvailable.Response = {
updateAvailable,
currentVersion: currentVersion,
latestVersion: latestVersion
}
emitToUser("ollamaIsUpdateAvailable", res)
} catch (error: any) {
console.error("Ollama update check error:", error)
const res = {
error: "Failed to check for updates"
}
emitToUser("error", res)
}
}
export async function ollamaSearchAvailableModels(
socket: any,
message: Sockets.OllamaSearchAvailableModels.Call,
emitToUser: (event: string, data: any) => void
) {
try {
const { search, source } = message
let models: Array<{
name: string
description?: string
size?: string
tags?: string[]
popular?: boolean
url?: string
downloads?: number
updatedAtStr?: string
createdAt?: Date
likes?: number
trendingScore?: number
}> = []
if (source === OllamaModelSearchSource.OLLAMA_DB) {
const response = await fetch(
`https://ollamadb.dev/api/v1/models?limit=25&search=${encodeURIComponent(search)}`
)
if (!response.ok) {
throw new Error(`OllamaDB API error: ${response.status}`)
}
const data = await response.json()
// console.log("OllamaDB response:", data)
// Transform ollamadb.dev response to our format
models = (data.models || []).map((model: any) => ({
name: model.model_identifier || model.model_name,
description: model.description,
size: model.size,
url: model.url,
downloads: model.pulls,
updatedAtStr: model.last_updated_str
}))
} else if (source === OllamaModelSearchSource.HUGGING_FACE) {
const response = await fetch(
`https://huggingface.co/api/models?search=${encodeURIComponent(search)}&filter=gguf&limit=50&sort=trendingScore&full=True&config=True"`
)
if (!response.ok) {
throw new Error(`Hugging Face API error: ${response.status}`)
}
const data = await response.json()
console.log("Hugging Face response:", data)
const textTags = [
"text-generation",
"text-classification",
"fill-mask",
"question-answering",
"summarization",
"translation",
"token-classification",
"conversational",
"image-text-to-text",
]
// Filter out private and gated models
const filteredData = (data || []).filter((model: any) => {
// Exclude private models
if (model.private === true) {
return false
}
// Exclude gated models (both boolean true and 'auto')
if (model.gated !== false) {
return false
}
// Exclude models that don't match the search or aren't text-based
if (!textTags.includes(model.pipeline_tag)) {
return false
}
return true
})
// Transform Hugging Face response to our format
models = filteredData.map((model: any) => {
const ggufSiblings = model.siblings.filter(
(sibling: { rfilename: string }) =>
sibling.rfilename.endsWith(".gguf")
)
const pullOptions: { label: string; pull: string }[] =
ggufSiblings
.filter(
(sibling: { rfilename: string }) =>
sibling.rfilename
.split("-")
.pop()
?.startsWith("Q") &&
sibling.rfilename.includes(".gguf")
)
.map((sibling: { rfilename: string }) => {
const quant = sibling.rfilename
.replace(".gguf", "")
.split("-")
.pop()
let pull = `hf.co/${model.id}:${quant}`
return { label: quant, pull }
})
return {
name: model.id || model.modelId,
description: model.description || model.pipeline_tag,
size: undefined, // Hugging Face doesn't provide size in search
tags: model.tags || [],
popular: model.likes > 100 || false,
url: `https://hf.co/${model.id || model.modelId}`,
createdAt: model.createdAt,
downloads: model.downloads,
likes: model.likes,
trendingScore: model.trendingScore,
pullOptions: pullOptions
}
})
// Filter out models that don't have pull options
models = models.filter((model) => model.pullOptions.length > 0)
}
const res: Sockets.OllamaSearchAvailableModels.Response = {
models
}
emitToUser("ollamaSearchAvailableModels", res)
} catch (error: any) {
console.error("Ollama search available models error:", error)
const res: Sockets.OllamaSearchAvailableModels.Response = {
models: [],
error: "Failed to search available models"
}
emitToUser("ollamaSearchAvailableModels", res)
}
}
export async function ollamaClearDownloadHistory(
socket: any,
message: Sockets.OllamaClearDownloadHistory.Call,
emitToUser: (event: string, data: any) => void
) {
try {
// Clear the download progress tracking
Object.keys(downloadingQuants).forEach((modelName) => {
if (downloadingQuants[modelName].isDone) {
delete downloadingQuants[modelName]
}
})
cancelingPulls = []
const res: Sockets.OllamaClearDownloadHistory.Response = {
success: true
}
emitToUser("ollamaClearDownloadHistory", res)
} catch (error: any) {
console.error("Ollama clear download history error:", error)
const res: Sockets.Error.Response = {
error: "Failed to clear download history"
}
emitToUser("error", res)
}
}
// Helper function to compare semantic versions
function compareVersions(version1: string, version2: string): number {
const v1parts = version1.split(".").map(Number)
const v2parts = version2.split(".").map(Number)
const maxLength = Math.max(v1parts.length, v2parts.length)
for (let i = 0; i < maxLength; i++) {
const v1part = v1parts[i] || 0
const v2part = v2parts[i] || 0
if (v1part > v2part) return 1
if (v1part < v2part) return -1
}
return 0
}
export async function ollamaCancelPull(
socket: any,
message: { modelName: string },
emitToUser: (event: string, data: any) => void
) {
try {
// Add the model to the canceling pulls array
if (!cancelingPulls.includes(message.modelName)) {
cancelingPulls.push(message.modelName)
}
// If the model is currently downloading, update its status
if (downloadingQuants[message.modelName]) {
downloadingQuants[message.modelName].status = "cancelled"
downloadingQuants[message.modelName].isDone = true
}
const res = {
success: true,
modelName: message.modelName
}
emitToUser("ollamaCancelPull", res)
} catch (error: any) {
console.error("Ollama cancel pull error:", error)
const res = {
success: false,
error: "Failed to cancel model download"
}
emitToUser("ollamaCancelPull", res)
}
}

View file

@ -0,0 +1,59 @@
import { db } from "$lib/server/db"
import * as schema from "$lib/server/db/schema"
import { eq } from "drizzle-orm"
export async function systemSettings(
socket: any,
message: Sockets.SystemSettings.Call,
emitToUser: (event: string, data: any) => void
) {
try {
const settings = await db.query.systemSettings.findFirst({
where: eq(schema.systemSettings.id, 1),
columns: {
id: false, // We don't need the ID in the response
}
})
if (!settings) {
throw new Error("System settings not found")
}
const res: Sockets.SystemSettings.Response = {
systemSettings: settings
}
emitToUser("systemSettings", res)
} catch (error: any) {
console.error("Error fetching system settings:", error)
emitToUser("error", {
error: "Failed to fetch system settings"
})
}
}
export async function updateOllamaManagerEnabled(
socket: any,
message: Sockets.UpdateOllamaManagerEnabled.Call,
emitToUser: (event: string, data: any) => void
) {
try {
await db.update(schema.systemSettings).set({
ollamaManagerEnabled: message.enabled
}).where(eq(schema.systemSettings.id, 1))
const res: Sockets.UpdateOllamaManagerEnabled.Response = {
success: true,
enabled: message.enabled
}
emitToUser("updateOllamaManagerEnabled", res)
await systemSettings(socket, {}, emitToUser) // Refresh system settings after update
} catch (error: any) {
console.error("Update Ollama Manager enabled error:", error)
const res = {
error: "Failed to update Ollama Manager setting"
}
emitToUser("error", res)
}
}

View file

@ -2,6 +2,7 @@ import { db } from "$lib/server/db"
import { eq } from "drizzle-orm"
import * as schema from "$lib/server/db/schema"
<<<<<<< HEAD
export async function user(
socket: any,
message: {},
@ -18,6 +19,20 @@ export async function user(
}
})
socket.server.to("user_" + userId).emit("user", { user })
=======
export async function user(socket: any, message: Sockets.User.Call, emitToUser: (event: string, data: any) => void) {
const userId = 1 // Replace with actual user id
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.id, userId),
with: {
activeConnection: true,
activeSamplingConfig: true,
activeContextConfig: true,
activePromptConfig: true
}
})
socket.server.to("user_" + userId).emit("user", { user })
>>>>>>> feature/ollama-manager
}
const getUser = user

View file

@ -0,0 +1,9 @@
export class OllamaModelSearchSource {
static OLLAMA_DB = "ollamadb"
static HUGGING_FACE = "huggingface"
static options = [
{ value: OllamaModelSearchSource.HUGGING_FACE, label: "Hugging Face" },
{ value: OllamaModelSearchSource.OLLAMA_DB, label: "ollamadb.dev" },
]
}

View file

@ -692,7 +692,7 @@
<Modal
open={showDeleteMessageModal}
onOpenChange={onOpenMessageDeleteChange}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
@ -724,7 +724,7 @@
<Modal
open={showDraftCompiledPromptModal}
onOpenChange={(details) => (showDraftCompiledPromptModal = details.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-full w-[60em]"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-full w-[60em] border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
@ -901,7 +901,7 @@
<Modal
open={showAvatarModal}
onOpenChange={(e) => (showAvatarModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-md flex flex-col items-center"
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-md flex flex-col items-center border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}

7
static/ollama-dark.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

7
static/ollama.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB