mirror of
https://github.com/doolijb/serene-pub.git
synced 2026-07-10 00:08:25 +00:00
Merge branch 'feature/ollama-manager' into feature/0.4-refinements
This commit is contained in:
commit
a52376b4ed
49 changed files with 5863 additions and 2039 deletions
5
drizzle/0003_past_blue_marvel.sql
Normal file
5
drizzle/0003_past_blue_marvel.sql
Normal 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
|
|
@ -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",
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -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
183
src/app.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
31
src/lib/client/components/icons/OllamaIcon.svelte
Normal file
31
src/lib/client/components/icons/OllamaIcon.svelte
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
134
src/lib/client/components/modals/OllamaInstallModal.svelte
Normal file
134
src/lib/client/components/modals/OllamaInstallModal.svelte
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
141
src/lib/client/components/modals/OllamaManualPullModal.svelte
Normal file
141
src/lib/client/components/modals/OllamaManualPullModal.svelte
Normal 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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
295
src/lib/client/components/ollamaManager/OllamaSettingsTab.svelte
Normal file
295
src/lib/client/components/ollamaManager/OllamaSettingsTab.svelte
Normal 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>
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
267
src/lib/client/components/sidebars/OllamaSidebar.svelte
Normal file
267
src/lib/client/components/sidebars/OllamaSidebar.svelte
Normal 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>
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/"),
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
653
src/lib/server/sockets/ollama.ts
Normal file
653
src/lib/server/sockets/ollama.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
59
src/lib/server/sockets/systemSettings.ts
Normal file
59
src/lib/server/sockets/systemSettings.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
9
src/lib/shared/constants/OllamaModelSource.ts
Normal file
9
src/lib/shared/constants/OllamaModelSource.ts
Normal 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" },
|
||||
]
|
||||
}
|
||||
|
|
@ -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
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
7
static/ollama.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.4 KiB |
Loading…
Add table
Add a link
Reference in a new issue