Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions apps/web/src/components/ollama-settings.tsx
Original file line number Diff line number Diff line change
@@ -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<ChatStatus | null>(null);
const [mode, setMode] = useState<Mode>("local");
const [host, setHost] = useState("");
const [error, setError] = useState<string | null>(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 (
<section aria-labelledby="ollama-heading" className="flex flex-col gap-4">
<div>
<h2 id="ollama-heading" className="text-lg font-medium">
Ollama
</h2>
<p className="text-sm text-muted-foreground">
Where the Ask panel and Dependabot notes find their language model. Saved on the server, so it applies in every browser.
</p>
</div>

<div role="radiogroup" aria-labelledby="ollama-heading" className="grid gap-3 sm:grid-cols-2">
{MODES.map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={mode === option.id}
disabled={locked}
onClick={() => {
setMode(option.id);
setSaved(false);
}}
className={cn(
"flex flex-col gap-1 rounded-xl border bg-card p-3 text-left text-card-foreground transition-colors outline-none hover:bg-accent/50 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60",
mode === option.id && "border-primary ring-2 ring-primary/40",
)}
>
<span className="text-sm font-medium">{option.label}</span>
<span className="text-xs text-muted-foreground">{option.description}</span>
</button>
))}
</div>

{mode === "server" ? (
<div className="flex flex-col gap-1.5">
<Label htmlFor={hostId}>Server address</Label>
<Input
id={hostId}
value={host}
disabled={locked}
placeholder="http://192.168.1.50:11434"
onChange={(event) => {
setHost(event.target.value);
setSaved(false);
}}
autoComplete="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
On that machine Ollama must listen beyond loopback (OLLAMA_HOST=0.0.0.0:11434). Ollama has no login: keep it off the internet.
</p>
</div>
) : null}

{locked ? (
<p className="text-sm text-muted-foreground">GITLIB_OLLAMA_HOST is set to {status?.host}, so it can only be changed there.</p>
) : (
<div className="flex items-center gap-3">
<Button type="button" onClick={save} disabled={saving || (mode === "server" && !host.trim())}>
{saving ? "Saving…" : "Save"}
</Button>
{saved ? <span className="text-sm text-muted-foreground">Saved.</span> : null}
</div>
)}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}

{status ? (
<p className="text-sm text-muted-foreground" aria-live="polite">
{!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}` : "."}`}
</p>
) : null}
</section>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/routes/settings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -62,6 +63,8 @@ function Settings() {
))}
</ul>
</section>

<OllamaSettings />
</div>
);
}
Expand Down
29 changes: 27 additions & 2 deletions apps/web/src/server/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<ChatStatus> => {
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 {
Expand All @@ -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;
}
});
36 changes: 33 additions & 3 deletions packages/core/src/app.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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`. */
Expand All @@ -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();
Expand Down
21 changes: 17 additions & 4 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Record<string, string | undefined>>;
Expand All @@ -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;
/**
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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();
Expand All @@ -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 {
Expand Down Expand Up @@ -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),
};
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
57 changes: 57 additions & 0 deletions packages/core/src/ollama-connection.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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);
Loading
Loading