WIP Ollama manager

This commit is contained in:
Jody Doolittle 2025-07-08 16:54:30 -07:00
parent 1051f01808
commit d6a0d1f5d7
27 changed files with 4493 additions and 147 deletions

View file

@ -20,6 +20,8 @@
"onconsider",
"onfinalize",
"pglite",
"quantizations",
"rfilename",
"Roleplay",
"skeletonlabs",
"skio",

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

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

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

161
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,20 @@ 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
}
}
namespace SamplingConfig {
interface Call {
id: number
@ -322,6 +344,131 @@ 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 OllamaStopModel {
interface Call {
modelName: string
}
interface Response {
success: boolean
}
}
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
}>
error?: string
}
}
namespace OllamaHuggingFaceSiblingsList {
interface Call {
modelId: string
}
interface Response {
baseUrl: string,
siblings: Array<{
id: string
likes: number
trendingScore: number
private: boolean
downloads: number
tags: string[]
library_name: string
createdAt: string
modelId: string
rfilename?: string
size?: number
quantization?: string
}>
error?: string
}
}
// --- APP SETTINGS ---
namespace UpdateOllamaManagerEnabled {
interface Call {
enabled: boolean
}
interface Response {
success: boolean
enabled?: boolean
}
}
// --- WEIGHTS ---
namespace DeleteSamplingConfig {
interface Call {
@ -864,6 +1011,20 @@ declare global {
}
interface Response {}
}
// --- USER ---
namespace User {
interface Call {}
interface Response {
user:
| (SelectUser & {
activeConnection: SelectConnection | null
activeSamplingConfig: SelectSamplingConfig | null
activeContextConfig: SelectContextConfig | null
activePromptConfig: SelectPromptConfig | null
})
| undefined
}
}
}
export interface CharaImportMetadata {

View file

@ -57,11 +57,23 @@
},
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)
)
})
function openPanel({
key,
@ -168,24 +180,33 @@
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 })
})
socket.emit("user", {})
socket.emit("systemSettings", {})
})
onDestroy(() => {
socket.off("user")
socket.off("systemSettings")
socket.off("error")
})
</script>
@ -195,7 +216,7 @@
class="bg-surface-100-900 relative h-full max-h-[100dvh] w-full justify-between"
>
<div
class="relative flex h-svh min-w-full max-w-full flex-1 flex-col lg:flex-row lg:gap-2 overflow-hidden"
class="relative flex h-svh max-w-full min-w-full flex-1 flex-col overflow-hidden lg:flex-row lg:gap-2"
>
<!-- Left Sidebar -->
<aside class="desktop-sidebar">
@ -248,7 +269,7 @@
{/if}
</aside>
<!-- Main Content -->
<main class="flex flex-col h-full overflow-hidden">
<main class="flex h-full flex-col overflow-hidden">
<Header />
<div class="flex-1 overflow-auto">
{@render children?.()}
@ -428,6 +449,6 @@
/* w-[25%] max-w-[25%] */
.desktop-sidebar {
@apply hidden min-h-full max-h-full basis-1/4 overflow-x-hidden lg:block py-1;
@apply hidden max-h-full min-h-full basis-1/4 overflow-x-hidden py-1 lg:block;
}
</style>

View file

@ -0,0 +1,480 @@
<script lang="ts">
import * as Icons from "@lucide/svelte"
import * as skio from "sveltekit-io"
import { onMount, onDestroy } from "svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
import { toaster } from "$lib/client/utils/toaster"
import { OllamaModelSearchSource } from "$lib/shared/constants/OllamaModelSource"
interface OllamaModel {
name: string
size: number
digest: string
modified_at: string
details?: {
parameter_size: string
quantization_level: string
}
}
const socket = skio.get()
// State
let searchString = $state("")
let downloadingModels = $state(new Set<string>())
let installedModels: Sockets.OllamaModelsList.Response["models"] = $state(
[]
)
let selectedSource = $state(OllamaModelSearchSource.HUGGING_FACE)
let availableModels: Sockets.OllamaSearchAvailableModels.Response["models"] =
$state([])
let isSearching = $state(false)
// Hugging Face modal state
let showHuggingFaceModal = $state(false)
let selectedModelForDownload: string | null = $state(null)
let huggingFaceSiblings: Sockets.OllamaHuggingFaceSiblingsList.Response["siblings"] = $state([])
let isLoadingSiblings = $state(false)
// Check if model is already installed
function isModelInstalled(modelName: string): boolean {
return installedModels.some((model) => model.name.startsWith(modelName))
}
// Download/Pull a model (mock)
async function downloadModel(modelName: string) {
try {
downloadingModels.add(modelName)
toaster.info({ title: `Downloading ${modelName}...` })
// Simulate download delay
await new Promise((resolve) => setTimeout(resolve, 2000))
// Add to installed models
const newModel: OllamaModel = {
name: `${modelName}:latest`,
size: Math.floor(Math.random() * 5000000000) + 1000000000, // Random size 1-5GB
digest: `sha256:${Math.random().toString(36).substring(2, 15)}`,
modified_at: new Date().toISOString(),
details: {
parameter_size: "7B",
quantization_level: "Q4_0"
}
}
installedModels = [...installedModels, newModel]
toaster.success({ title: `${modelName} downloaded successfully` })
} catch (error) {
console.error("Download failed:", error)
toaster.error({ title: `Failed to download ${modelName}` })
} finally {
downloadingModels.delete(modelName)
}
}
// Search for available models
function searchAvailableModels() {
isSearching = true
socket.emit("ollamaSearchAvailableModels", {
search: searchString.trim(),
source: selectedSource
} as Sockets.OllamaSearchAvailableModels.Call)
}
// Open Hugging Face download modal
function openHuggingFaceModal(modelId: string) {
selectedModelForDownload = modelId
showHuggingFaceModal = true
isLoadingSiblings = true
huggingFaceSiblings = []
socket.emit("ollamaHuggingFaceSiblingsList", {
modelId: modelId
} as Sockets.OllamaHuggingFaceSiblingsList.Call)
}
// Close Hugging Face modal
function closeHuggingFaceModal() {
showHuggingFaceModal = false
selectedModelForDownload = null
huggingFaceSiblings = []
isLoadingSiblings = false
}
// Helper function to extract numeric value from quantization for sorting
function getQuantizationSortValue(quantization: string | undefined): number {
if (!quantization) return 0
// Extract the main number (e.g., "Q4_K_M" -> 4, "Q8_0" -> 8)
const match = quantization.match(/[Qq](\d+)/)
return match ? parseInt(match[1]) : 0
}
// Sort siblings by quantization level (highest to lowest)
let sortedSiblings = $derived([...huggingFaceSiblings].sort((a, b) => {
const aValue = getQuantizationSortValue(a.quantization)
const bValue = getQuantizationSortValue(b.quantization)
return bValue - aValue // Descending order
}))
// Debounced search when query or source changes
$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 if needed
if (message.success) {
// Refresh installed models to update UI
socket.emit("ollamaModelsList", {})
toaster.success({ title: "Model downloaded successfully" })
}
}
)
socket.on(
"ollamaHuggingFaceSiblingsList",
(message: Sockets.OllamaHuggingFaceSiblingsList.Response) => {
isLoadingSiblings = false
if (message.error) {
toaster.error({ title: message.error })
huggingFaceSiblings = []
} else {
huggingFaceSiblings = message.siblings || []
}
console.log("Hugging Face siblings (sorted):", $state.snapshot(huggingFaceSiblings))
}
)
// Load initial installed models
refreshInstalled()
})
onDestroy(() => {
socket.off("ollamaModelsList")
socket.off("ollamaSearchAvailableModels")
socket.off("ollamaPullModel")
socket.off("ollamaHuggingFaceSiblingsList")
})
</script>
<!-- Search for available 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 available models..."
class="input w-full pl-10"
aria-label="Model search"
bind:value={searchString}
/>
</div>
<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>
<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 bg-primary-500 rounded-full px-2 py-1 text-xs text-white"
>
<Icons.TrendingUp
size={12}
class="mr-1 inline"
/>
Popular
</span>
{/if}
{#if model.trendingScore && model.trendingScore > 0.7}
<span
class="badge rounded-full bg-orange-500 px-2 py-1 text-xs text-white"
>
<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.name)
} else {
downloadModel(model.name)
}
}}
disabled={downloadingModels.has(model.name) ||
isModelInstalled(model.name)}
>
{#if downloadingModels.has(model.name)}
<Icons.Loader2
size={14}
class="animate-spin"
/>
Installing...
{:else 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 -->
<Modal
open={showHuggingFaceModal}
onOpenChange={(e) => (showHuggingFaceModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl w-[50em] max-w-dvw-lg"
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 text-sm mb-2">
Choose a quantization level for <strong>{selectedModelForDownload}</strong>
</p>
<div class="bg-surface-200-800 p-3 rounded-lg">
<p class="text-xs text-surface-600-400">
<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 isLoadingSiblings}
<div class="p-6 text-center">
<Icons.Loader2 class="mx-auto mb-4 animate-spin" size={32} />
<p class="text-sm opacity-75">Loading available quantizations...</p>
</div>
{:else if sortedSiblings.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="space-y-3 max-h-96 overflow-y-auto">
{#each sortedSiblings as sibling, index}
<div class="card bg-surface-200-800 p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h5 class="font-semibold">
{sibling.quantization || 'Unknown file'}
</h5>
<!-- Add special labels -->
{#if index === 0 && sortedSiblings.length > 1}
<span class="badge bg-blue-500 text-white text-xs px-2 py-1 rounded">
Larger
</span>
{:else if index === sortedSiblings.length - 1 && sortedSiblings.length > 1}
<span class="badge bg-green-500 text-white text-xs px-2 py-1 rounded">
Smaller
</span>
{/if}
{#if sibling.quantization === 'Q4_K_M'}
<span class="badge bg-orange-500 text-white text-xs px-2 py-1 rounded">
Recommended
</span>
{/if}
</div>
<div class="flex items-center gap-4 text-xs text-surface-500">
{#if sibling.size}
<div class="flex items-center gap-1">
<Icons.HardDrive size={12} />
<span>{(sibling.size / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
</div>
{/if}
</div>
</div>
<button
class="btn btn-sm preset-filled-primary-500"
onclick={() => {
// TODO: Implement actual download logic
toaster.info({ title: `Download will be implemented for ${sibling.rfilename}` })
closeHuggingFaceModal()
}}
>
<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={closeHuggingFaceModal}
>
Cancel
</button>
</footer>
{/snippet}
</Modal>

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

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,270 @@
<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"
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 isConnected = $state(true)
let isCheckingUpdates = $state(false)
let isSavingBaseUrl = $state(false)
let installedModels: OllamaModel[] = $state([])
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="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

@ -4,7 +4,6 @@
import * as Icons from "@lucide/svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
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 {
@ -15,6 +14,7 @@
import { toaster } from "$lib/client/utils/toaster"
import { PromptFormats } from "$lib/shared/constants/PromptFormats"
import { TokenCounterOptions } from "$lib/shared/constants/TokenCounters"
import OllamaManager from "$lib/client/components/ollamaManager/OllamaManager.svelte"
interface Props {
onclose?: () => Promise<boolean> | undefined
@ -22,14 +22,19 @@
let { onclose = $bindable() }: Props = $props()
let userCtx: UserCtx = getContext("userCtx")
let systemSettingsCtx: SystemSettingsCtx = getContext("systemSettingsCtx")
const socket = skio.get()
const OAIChatPresets: {name:string, value: number, connectionDefaults: {
baseUrl: string,
promptFormat?: string,
tokenCounter?: string,
const OAIChatPresets: {
name: string
value: number
connectionDefaults: {
baseUrl: string
promptFormat?: string
tokenCounter?: string
extraJson: {
stream: boolean,
stream: boolean
prerenderPrompt: boolean
apiKey: string
}
@ -59,7 +64,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "ollama",
apiKey: "ollama"
}
}
},
@ -87,7 +92,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -101,7 +106,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -115,7 +120,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -129,7 +134,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -143,7 +148,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -157,7 +162,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -171,7 +176,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -185,7 +190,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -199,7 +204,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
},
@ -213,7 +218,7 @@
extraJson: {
stream: true,
prerenderPrompt: false,
apiKey: "",
apiKey: ""
}
}
}
@ -240,6 +245,7 @@
let newConnectionType = $state(CONNECTION_TYPES[0].value)
let newConnectionOAIChatPreset: number | undefined = $state()
let showDeleteModal = $state(false)
let showOllamaManager = $state(false)
function handleSelectChange(e: Event) {
socket.emit("setUserActiveConnection", {
@ -272,7 +278,7 @@
...(newConnectionType === CONNECTION_TYPE.OPENAI_CHAT
? OAIChatPresets.find(
(p) => p.value === newConnectionOAIChatPreset
)?.connectionDefaults
)?.connectionDefaults
: {})
}
socket.emit("createConnection", { connection: newConn })
@ -318,6 +324,14 @@
refreshModelsResult = null
socket.emit("refreshModels", { baseUrl: connection?.baseUrl })
}
function onOllamaManagerClick() {
if (systemSettingsCtx.settings.ollamaManagerEnabled) {
showOllamaManager = true
}
}
function onOllamaManagerClickClose() {
showOllamaManager = false
}
onMount(() => {
socket.on(
@ -344,20 +358,26 @@
toaster.success({ title: "Connection Updated" })
}
)
socket.on("deleteConnection", (msg: Sockets.DeleteConnection.Response) => {
toaster.success({ title: "Connection Deleted" })
connection = undefined
originalConnection = undefined
})
socket.on("createConnection", (msg: Sockets.CreateConnection.Response) => {
toaster.success({ title: "Connection Created" })
})
socket.on(
"deleteConnection",
(msg: Sockets.DeleteConnection.Response) => {
toaster.success({ title: "Connection Deleted" })
connection = undefined
originalConnection = undefined
}
)
socket.on(
"createConnection",
(msg: Sockets.CreateConnection.Response) => {
toaster.success({ title: "Connection Created" })
}
)
socket.emit("connectionsList", {})
if (userCtx.user?.activeConnectionId) {
socket.emit("connection", { id: userCtx.user.activeConnectionId })
}
onclose = handleOnClose
// If ollama, fetch models
if (connection?.type === "ollama" && connection.baseUrl) {
handleRefreshModels()
}
@ -375,109 +395,113 @@
})
</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">
{#if showOllamaManager && systemSettingsCtx.settings.ollamaManagerEnabled}
<OllamaManager onClose={() => (showOllamaManager = false)} close={onOllamaManagerClickClose} />
{:else}
<div class="text-foreground p-4">
<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>
<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}
<div class="mt-4 flex flex-col gap-2">
{#if connection.type === "ollama"}
{#if refreshModelsResult}
<div class="mt-1 text-sm">
{#if refreshModelsResult.models?.length}
<div>
Available Models: {refreshModelsResult.models.join(
", "
)}
</div>
{:else if refreshModelsResult.error}
<span class="text-error">
{refreshModelsResult.error}
</span>
{/if}
</div>
{/if}
<div>
{#if systemSettingsCtx.settings.ollamaManagerEnabled}
<button
type="button"
class="btn btn-sm preset-filled-secondary-500"
aria-label="Ollama Manager"
onclick={onOllamaManagerClick}
title="Ollama Manager"
>
Ollama
</button>
{/if}
</div>
{/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>
<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>
{/if}
<Modal
open={showConfirmModal}
@ -571,7 +595,9 @@
{@const connectionType = CONNECTION_TYPES.find(
(t) => t.value === newConnectionType
)}
<div class="bg-surface-500/25 flex flex-col gap-2 rounded p-4 mt-4">
<div
class="bg-surface-500/25 mt-4 flex flex-col gap-2 rounded p-4"
>
<span class="preset-filled-primary-500 p-2">
Difficulty: {connectionType?.difficulty}
</span>
@ -608,7 +634,8 @@
</header>
<article>
<p class="opacity-60">
Are you sure you want to delete this connection? This cannot be undone.
Are you sure you want to delete this connection? This cannot be
undone.
</p>
</article>
<footer class="flex justify-end gap-4">

View file

@ -5,15 +5,24 @@
import { appVersion, appVersionDisplay } 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"
@ -34,6 +43,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
@ -85,6 +101,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 class="about-section mt-6 rounded-lg bg-surface-500/25 p-4 shadow-md flex flex-col gap-2 items-start">

View file

@ -6,7 +6,6 @@ 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)
@ -18,8 +17,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 =
@ -411,6 +412,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
@ -288,6 +289,7 @@ class OllamaAdapter extends BaseConnectionAdapter {
}
const connectionDefaults = {
type: CONNECTION_TYPE.OLLAMA,
baseUrl: "http://localhost:11434/",
promptFormat: PromptFormats.VICUNA,
tokenCounter: TokenCounterOptions.ESTIMATE,

View file

@ -8,6 +8,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({
@ -184,6 +185,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

@ -564,3 +564,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,8 +1,12 @@
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"
import { Ollama } from "ollama"
import ollamaAdapter from "$lib/server/connectionAdapters/OllamaAdapter"
import { OllamaModelSearchSource } from "$lib/shared/constants/OllamaModelSource"
// --- CONNECTIONS SOCKET HANDLERS ---
@ -230,3 +234,457 @@ export async function refreshModels(
emitToUser("refreshModels", res)
}
}
// --- OLLAMA SPECIFIC FUNCTIONS ---
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 {
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) {
// Emit progress updates
if (chunk.status) {
emitToUser("ollamaPullProgress", {
modelName: message.modelName,
status: chunk.status,
completed: chunk.completed,
total: chunk.total
})
}
}
const res: Sockets.OllamaPullModel.Response = {
success: true
}
emitToUser("ollamaPullModel", res)
} catch (error: any) {
console.error("Ollama pull model error:", error)
const res = {
error: "Failed to download model"
}
emitToUser("error", res)
}
}
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=25&sort=trendingScore`
)
if (!response.ok) {
throw new Error(`Hugging Face API error: ${response.status}`)
}
const data = await response.json()
// console.log("Hugging Face response:", data)
// Transform Hugging Face response to our format
models = (data || []).map((model: any) => ({
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,
}))
}
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 ollamaHuggingFaceSiblingsList(
socket: any,
message: Sockets.OllamaHuggingFaceSiblingsList.Call,
emitToUser: (event: string, data: any) => void
) {
try {
const { modelId } = message
// Fetch the model details from Hugging Face API
const response = await fetch(`https://huggingface.co/api/models/${encodeURIComponent(modelId)}`)
if (!response.ok) {
throw new Error(`Hugging Face API error: ${response.status}`)
}
const modelData = await response.json()
console.log("Hugging Face model data:", modelData)
// Get siblings from the model data
const siblings = modelData.siblings || []
// Transform siblings to our format and extract quantization info
const transformedSiblings = siblings.map((sibling: any) => {
// Extract quantization level from filename if it's a GGUF file
let quantization: string | undefined
if (sibling.rfilename && sibling.rfilename.includes('.gguf')) {
// Common quantization patterns: Q4_0, Q4_K_M, Q5_K_S, Q8_0, etc.
const quantMatch = sibling.rfilename.match(/[Qq](\d+)(?:_[KkMmSs])?(?:_[MmSs])?/i)
if (quantMatch) {
quantization = quantMatch[0].toUpperCase()
}
}
return {
rfilename: sibling.rfilename,
size: sibling.size,
quantization: quantization
}
})
// Filter to only include GGUF files
const ggufSiblings = transformedSiblings.filter((sibling: any) =>
sibling.rfilename && sibling.rfilename.toLowerCase().includes('.gguf') && !!sibling.quantization
)
const res: Sockets.OllamaHuggingFaceSiblingsList.Response = {
baseUrl: `https://hf.com/${modelData.id || modelData.modelId}`,
siblings: ggufSiblings
}
emitToUser("ollamaHuggingFaceSiblingsList", res)
} catch (error: any) {
console.error("Ollama Hugging Face siblings list error:", error)
const res: Sockets.OllamaHuggingFaceSiblingsList.Response = {
siblings: [],
error: "Failed to fetch model siblings"
}
emitToUser("ollamaHuggingFaceSiblingsList", 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
}

View file

@ -6,7 +6,17 @@ import {
setUserActiveConnection,
testConnection,
refreshModels,
createConnection
createConnection,
ollamaSetBaseUrl,
ollamaModelsList,
ollamaDeleteModel,
ollamaListRunningModels,
ollamaPullModel,
ollamaVersion,
ollamaIsUpdateAvailable,
ollamaConnectModel,
ollamaSearchAvailableModels,
ollamaHuggingFaceSiblingsList
} from "./connections"
import {
sampling,
@ -91,6 +101,7 @@ import {
iterateNextHistoryEntry,
lorebookImport
} from "./lorebooks"
import { updateOllamaManagerEnabled, systemSettings } from "./systemSettings"
const userId = 1 // Replace with actual user id
@ -126,6 +137,23 @@ export function connectSockets(io: {
register(socket, setUserActiveConnection, emitToUser)
register(socket, testConnection, emitToUser)
register(socket, refreshModels, emitToUser)
register(socket, ollamaConnectModel, emitToUser)
register(socket, ollamaSearchAvailableModels, emitToUser)
register(socket, ollamaHuggingFaceSiblingsList, emitToUser)
// Ollama
register(socket, ollamaSetBaseUrl, emitToUser)
register(socket, ollamaModelsList, emitToUser)
register(socket, ollamaDeleteModel, emitToUser)
register(socket, ollamaListRunningModels, emitToUser)
register(socket, ollamaPullModel, emitToUser)
register(socket, ollamaVersion, emitToUser)
register(socket, ollamaIsUpdateAvailable, emitToUser)
// App Settings
register(socket, systemSettings, emitToUser)
register(socket, updateOllamaManagerEnabled, emitToUser)
// Characters
register(socket, characterList, emitToUser)
register(socket, character, emitToUser)
@ -193,7 +221,7 @@ export function connectSockets(io: {
register(socket, updateHistoryEntry, emitToUser)
register(socket, deleteHistoryEntry, emitToUser)
register(socket, iterateNextHistoryEntry, emitToUser)
register(socket, lorebookImport, emitToUser)
register(socket, lorebookImport, emitToUser)
console.log(`Socket connected: ${socket.id} for user ${userId}`)
})
}

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,7 +2,7 @@ import { db } from "$lib/server/db"
import { eq } from "drizzle-orm"
import * as schema from "$lib/server/db/schema"
export async function user(socket: any, message: {}, emitToUser: (event: string, data: any) => void) {
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),

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" },
]
}

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