diff --git a/apps/web/src/components/ollama-settings.tsx b/apps/web/src/components/ollama-settings.tsx new file mode 100644 index 0000000..659a18d --- /dev/null +++ b/apps/web/src/components/ollama-settings.tsx @@ -0,0 +1,140 @@ +import { useEffect, useId, useState } from "react"; +import { Button } from "#/components/ui/button"; +import { Input } from "#/components/ui/input"; +import { Label } from "#/components/ui/label"; +import { cn } from "#/lib/utils"; +import { getChatStatus, saveOllamaConnection, type ChatStatus } from "#/server/chat"; + +const MODES = [ + { id: "local", label: "Local models", description: "Ollama running on this machine (http://127.0.0.1:11434)." }, + { id: "server", label: "Ollama server", description: "Ollama running on another machine, such as a homelab box." }, +] as const; + +type Mode = (typeof MODES)[number]["id"]; + +/** Settings for where the Ask panel finds Ollama: this machine, or a server at an address you type. */ +export function OllamaSettings() { + const hostId = useId(); + const [status, setStatus] = useState(null); + const [mode, setMode] = useState("local"); + const [host, setHost] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + const load = (initial: boolean) => + getChatStatus().then((next) => { + setStatus(next); + if (initial) { + setMode(next.mode); + setHost(next.mode === "server" ? next.host : ""); + } + }); + + useEffect(() => { + load(true).catch(() => setError("Could not read the current Ollama settings.")); + }, []); + + const save = async () => { + setSaving(true); + setError(null); + setSaved(false); + try { + const result = await saveOllamaConnection({ data: { mode, host } }); + if (!result.ok) setError(result.error); + else { + setSaved(true); + await load(false); + } + } catch { + setError("Could not save. See the server log."); + } finally { + setSaving(false); + } + }; + + const locked = status?.hostFromEnv ?? false; + + return ( +
+
+

+ Ollama +

+

+ Where the Ask panel and Dependabot notes find their language model. Saved on the server, so it applies in every browser. +

+
+ +
+ {MODES.map((option) => ( + + ))} +
+ + {mode === "server" ? ( +
+ + { + setHost(event.target.value); + setSaved(false); + }} + autoComplete="off" + spellCheck={false} + /> +

+ On that machine Ollama must listen beyond loopback (OLLAMA_HOST=0.0.0.0:11434). Ollama has no login: keep it off the internet. +

+
+ ) : null} + + {locked ? ( +

GITLIB_OLLAMA_HOST is set to {status?.host}, so it can only be changed there.

+ ) : ( +
+ + {saved ? Saved. : null} +
+ )} + {error ? ( +

+ {error} +

+ ) : null} + + {status ? ( +

+ {!status.enabled + ? "Chat is turned off (ollama.enabled = false in config.toml)." + : status.reachable + ? `Connected to ${status.host}: ${status.models.length} model${status.models.length === 1 ? "" : "s"} installed.` + : `Cannot reach ${status.host}${status.error ? `: ${status.error}` : "."}`} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 3817781..50dad12 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { Check } from "lucide-react"; +import { OllamaSettings } from "#/components/ollama-settings"; import { cn } from "#/lib/utils"; import { OPTIONAL_REPO_TABS, useRepoTabs } from "#/lib/repo-tabs"; import { THEMES, useTheme, type ThemeId } from "#/lib/themes"; @@ -62,6 +63,8 @@ function Settings() { ))} + + ); } diff --git a/apps/web/src/server/chat.ts b/apps/web/src/server/chat.ts index 39fa5b8..501b38f 100644 --- a/apps/web/src/server/chat.ts +++ b/apps/web/src/server/chat.ts @@ -6,6 +6,10 @@ export interface ChatStatus { /** False when the config turns chat off; nothing else is filled in then. */ enabled: boolean; host: string; + /** "local" is Ollama on this machine, "server" one at another address. */ + mode: "local" | "server"; + /** True when GITLIB_OLLAMA_HOST pins the host, so the Settings page cannot change it. */ + hostFromEnv: boolean; numCtx: number; reachable: boolean; /** Installed models, sorted. */ @@ -19,8 +23,8 @@ export interface ChatStatus { /** Whether the model server answers and what it has installed; asked each time the chat panel opens. */ export const getChatStatus = createServerFn({ method: "GET" }).handler(async (): Promise => { const app = getApp(); - const { host, model, numCtx } = app.config.ollama; - const base = { host, numCtx, models: [], defaultModel: null }; + const { host, model, numCtx, mode, hostFromEnv } = app.config.ollama; + const base = { host, mode, hostFromEnv, numCtx, models: [], defaultModel: null }; if (!app.chat) return { ...base, enabled: false, reachable: false, error: null }; try { @@ -38,3 +42,24 @@ export const getChatStatus = createServerFn({ method: "GET" }).handler(async (): throw error; } }); + +export interface OllamaConnectionInput { + mode: "local" | "server"; + host?: string; +} + +/** Saves where the chat panel finds Ollama; the result is the fresh status so Settings can show whether it answers. */ +export const saveOllamaConnection = createServerFn({ method: "POST" }) + .validator((input: OllamaConnectionInput): OllamaConnectionInput => { + if (!input || (input.mode !== "local" && input.mode !== "server")) throw new Error("Choose local or server."); + return { mode: input.mode, host: typeof input.host === "string" ? input.host : undefined }; + }) + .handler(async ({ data }): Promise<{ ok: true } | { ok: false; error: string }> => { + try { + getApp().setOllamaConnection(data); + return { ok: true }; + } catch (error) { + if (isGitLibError(error)) return { ok: false, error: error.message }; + throw error; + } + }); diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index c935eb5..c07b84d 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -1,7 +1,14 @@ import fs from "node:fs"; import path from "node:path"; import { buildChatSystemPrompt, ModelLane, OllamaBackend, type ChatBackend } from "./ai/index.js"; -import { loadConfig, type AppConfig, type Env } from "./config.js"; +import { DEFAULT_OLLAMA_HOST, loadConfig, type AppConfig, type Env, type OllamaConnectionMode } from "./config.js"; +import { + hostFor, + normalizeOllamaHost, + OllamaConnectionError, + writeOllamaConnection, + type OllamaConnection, +} from "./ollama-connection.js"; import { checkDependabot, DependabotBusyError, @@ -102,7 +109,12 @@ const DEPENDABOT_LEASE_MS = 15 * 60_000; export class GitLibApp { readonly search: RepoSearch; /** The language-model server behind the chat panel; null when `ollama.enabled = false`. */ - readonly chat: ChatBackend | null; + get chat(): ChatBackend | null { + return this.chatBackend; + } + private chatBackend: ChatBackend | null; + /** True when a backend was handed in (tests), so changing the connection never replaces it. */ + private readonly chatInjected: boolean; /** Where Dependabot pull requests come from; null when no GitHub token is set. */ readonly dependabot: DependabotBackend | null; /** What the Dependabot checks found, kept in `gitlib.db`. */ @@ -120,11 +132,29 @@ export class GitLibApp { dependabotBackend: DependabotBackend | null = null, ) { this.search = new RepoSearch(config.search, searchBackend); - this.chat = chatBackend !== undefined ? chatBackend : config.ollama.enabled ? new OllamaBackend(config.ollama) : null; + this.chatInjected = chatBackend !== undefined; + this.chatBackend = chatBackend !== undefined ? chatBackend : config.ollama.enabled ? new OllamaBackend(config.ollama) : null; this.dependabot = dependabotBackend; this.dependabotStore = new DependabotStore(config.dbPath); } + /** + * Points the chat panel (and the Dependabot notes) at Ollama on this machine or on another one, and remembers the + * choice in the app home. Takes effect at once; throws if GITLIB_OLLAMA_HOST pins the host. + */ + setOllamaConnection(input: { mode: OllamaConnectionMode; host?: string | null }): void { + const ollama = this.config.ollama; + if (ollama.hostFromEnv) { + throw new OllamaConnectionError("GITLIB_OLLAMA_HOST is set, so the server can only be changed there."); + } + const connection: OllamaConnection = + input.mode === "local" ? { mode: "local", host: null } : { mode: "server", host: normalizeOllamaHost(input.host ?? "") }; + writeOllamaConnection(this.config.appHome, connection); + ollama.host = hostFor(connection); + ollama.mode = ollama.host === DEFAULT_OLLAMA_HOST ? "local" : "server"; + if (!this.chatInjected && ollama.enabled) this.chatBackend = new OllamaBackend(ollama); + } + refresh(): RepoRecord[] { const discovered = discoverRepos(this.config.roots, this.config.ignoreDirs); const seenAt = utcNow(); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 99eb125..d01f291 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { parse as parseToml } from "smol-toml"; import { GitLibError } from "./errors.js"; import { expandUser, resolvePath } from "./fsutil.js"; +import { hostFor, readOllamaConnection } from "./ollama-connection.js"; export const DEFAULT_ROOTS = ["~/git"] as const; export const DEFAULT_VSCODE_COMMAND = ["code", "--new-window"] as const; @@ -31,6 +32,9 @@ export const DEFAULT_OLLAMA_NUM_CTX = 8192; export const MIN_OLLAMA_NUM_CTX = 512; export const MAX_OLLAMA_NUM_CTX = 262_144; +/** "local" is Ollama on this machine; "server" is one at another address. */ +export type OllamaConnectionMode = "local" | "server"; + export class ConfigError extends GitLibError {} export type Env = Readonly>; @@ -49,6 +53,10 @@ export interface OllamaConfig { /** When false, Ollama is never contacted and the chat panel says it is turned off. */ enabled: boolean; host: string; + /** "local" when `host` is this machine's own Ollama, "server" otherwise. */ + mode: OllamaConnectionMode; + /** True when GITLIB_OLLAMA_HOST sets the host, which the Settings page cannot override. */ + hostFromEnv: boolean; /** Model used when the client does not pick one; null means the first model the server lists. */ model: string | null; /** @@ -140,7 +148,7 @@ function loadSearchConfig(raw: unknown, env: Env, configPath: string): SearchCon return { enabled, host, apiKey: apiKey || null, indexName }; } -function loadOllamaConfig(raw: unknown, env: Env, configPath: string): OllamaConfig { +function loadOllamaConfig(raw: unknown, env: Env, configPath: string, appHome: string): OllamaConfig { if (raw !== undefined && (typeof raw !== "object" || raw === null || Array.isArray(raw))) { throw new ConfigError(`Config key 'ollama' must be a table in ${configPath}`); } @@ -161,7 +169,12 @@ function loadOllamaConfig(raw: unknown, env: Env, configPath: string): OllamaCon } // The host is not checked here: a typo in it should break the chat panel, not every command. - const host = (env["GITLIB_OLLAMA_HOST"] || string("host") || DEFAULT_OLLAMA_HOST).trim().replace(/\/+$/, ""); + // Precedence: the environment, then what the Settings page saved, then config.toml. + const hostFromEnv = Boolean(env["GITLIB_OLLAMA_HOST"]?.trim()); + const saved = hostFromEnv ? null : readOllamaConnection(appHome); + const host = ( + env["GITLIB_OLLAMA_HOST"] || (saved ? hostFor(saved) : string("host")) || DEFAULT_OLLAMA_HOST + ).trim().replace(/\/+$/, ""); const model = (env["GITLIB_OLLAMA_MODEL"] || string("model") || "").trim(); const fromEnv = env["GITLIB_OLLAMA_NUM_CTX"]?.trim(); @@ -178,7 +191,7 @@ function loadOllamaConfig(raw: unknown, env: Env, configPath: string): OllamaCon ); } - return { enabled, host, model: model || null, numCtx }; + return { enabled, host, mode: host === DEFAULT_OLLAMA_HOST ? "local" : "server", hostFromEnv, model: model || null, numCtx }; } export function loadConfig(env: Env = process.env): AppConfig { @@ -224,6 +237,6 @@ export function loadConfig(env: Env = process.env): AppConfig { vscodeCommand: vscodeCommandRaw, ignoreDirs: new Set(ignoreDirsRaw), search: loadSearchConfig(data["search"], env, configPath), - ollama: loadOllamaConfig(data["ollama"], env, configPath), + ollama: loadOllamaConfig(data["ollama"], env, configPath, appHome), }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 94d7195..d10dbd9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,7 @@ export * from "./dependabot/index.js"; export * from "./discovery.js"; export * from "./errors.js"; export * from "./models.js"; +export * from "./ollama-connection.js"; export * from "./planning/index.js"; export * from "./remotes.js"; export * from "./scanner.js"; diff --git a/packages/core/src/ollama-connection.ts b/packages/core/src/ollama-connection.ts new file mode 100644 index 0000000..ac8f4ff --- /dev/null +++ b/packages/core/src/ollama-connection.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import path from "node:path"; +import { DEFAULT_OLLAMA_HOST, type OllamaConnectionMode } from "./config.js"; +import { GitLibError } from "./errors.js"; + +/** What the Settings page saves: use Ollama on this machine, or on another one at `host`. */ +export interface OllamaConnection { + mode: OllamaConnectionMode; + /** The server's address; only used (and only kept) when `mode` is "server". */ + host: string | null; +} + +export class OllamaConnectionError extends GitLibError {} + +export function ollamaConnectionPath(appHome: string): string { + return path.join(appHome, "ollama-connection.json"); +} + +/** Tidies a typed address into `http(s)://host[:port]`; throws an error fit to show beside the field. */ +export function normalizeOllamaHost(input: string): string { + const text = input.trim(); + if (!text) throw new OllamaConnectionError("Enter the address of your Ollama server, e.g. http://192.168.1.50:11434."); + let url: URL; + try { + url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `http://${text}`); + } catch { + throw new OllamaConnectionError(`"${text}" is not a valid address. Try something like http://192.168.1.50:11434.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new OllamaConnectionError("The address must start with http:// or https://."); + } + if (url.username || url.password) throw new OllamaConnectionError("Leave the username and password out of the address."); + return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`; +} + +/** The saved choice, or null if none was saved or the file is unreadable (a bad file must not break startup). */ +export function readOllamaConnection(appHome: string): OllamaConnection | null { + try { + const raw: unknown = JSON.parse(fs.readFileSync(ollamaConnectionPath(appHome), "utf-8")); + if (!raw || typeof raw !== "object") return null; + const { mode, host } = raw as Record; + if (mode === "local") return { mode, host: null }; + if (mode === "server" && typeof host === "string" && host) return { mode, host }; + } catch { + // Missing or corrupt: fall back to the config file's host. + } + return null; +} + +export function writeOllamaConnection(appHome: string, connection: OllamaConnection): void { + fs.mkdirSync(appHome, { recursive: true }); + fs.writeFileSync(ollamaConnectionPath(appHome), `${JSON.stringify(connection, null, 2)}\n`, "utf-8"); +} + +/** The host a saved choice points at. */ +export const hostFor = (connection: OllamaConnection): string => + connection.mode === "local" ? DEFAULT_OLLAMA_HOST : (connection.host ?? DEFAULT_OLLAMA_HOST); diff --git a/packages/core/test/chat.test.ts b/packages/core/test/chat.test.ts index 878a72e..94dc5e5 100644 --- a/packages/core/test/chat.test.ts +++ b/packages/core/test/chat.test.ts @@ -48,6 +48,8 @@ describe("ollama config", () => { expect(loadConfig(fx.env()).ollama).toEqual({ enabled: true, host: "http://127.0.0.1:11434", + mode: "local", + hostFromEnv: false, model: null, numCtx: 8192, }); @@ -60,6 +62,8 @@ describe("ollama config", () => { expect(loadConfig(fx.env()).ollama).toEqual({ enabled: false, host: "http://gpu-box:11434", + mode: "server", + hostFromEnv: false, model: "qwen3:8b", numCtx: 4096, }); @@ -477,3 +481,27 @@ describe("OllamaBackend", () => { expect(seen).toEqual([{ type: "delta", text: "then silence" }]); }); }); + +describe("setOllamaConnection", () => { + const envFor = () => fx.env(); + + it("saves a server address, applies it at once and survives a restart", () => { + const app = createApplication(envFor()); + expect(app.config.ollama.mode).toBe("local"); + app.setOllamaConnection({ mode: "server", host: "192.168.1.164:11434/" }); + expect(app.config.ollama.host).toBe("http://192.168.1.164:11434"); + expect(app.chat?.label).toBe("http://192.168.1.164:11434"); + expect(loadConfig(envFor()).ollama).toMatchObject({ host: "http://192.168.1.164:11434", mode: "server" }); + + app.setOllamaConnection({ mode: "local" }); + expect(loadConfig(envFor()).ollama).toMatchObject({ host: "http://127.0.0.1:11434", mode: "local" }); + }); + + it("rejects a bad address and leaves GITLIB_OLLAMA_HOST in charge", () => { + const app = createApplication(envFor()); + expect(() => app.setOllamaConnection({ mode: "server", host: "ftp://x" })).toThrow(); + expect(() => app.setOllamaConnection({ mode: "server", host: "" })).toThrow(); + const pinned = createApplication({ ...envFor(), GITLIB_OLLAMA_HOST: "http://10.0.0.1:11434" }); + expect(() => pinned.setOllamaConnection({ mode: "local" })).toThrow(/GITLIB_OLLAMA_HOST/); + }); +});