feat: Ollama models command, with load/pull/delete and unload (#16)

This commit was merged in pull request #16.
This commit is contained in:
2026-10-05 09:43:00 +00:00
parent 05fd9ff13b
commit 26e05dea5b
22 changed files with 1948 additions and 65 deletions
+75 -4
View File
@@ -1,5 +1,5 @@
import { getPreferenceValues } from "@vicinae/api";
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL } from "./constants";
import { getPreferenceValues, LocalStorage } from "@vicinae/api";
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL, STORAGE_MODEL_KEY } from "./constants";
/**
* Preferences, read and normalised.
@@ -26,10 +26,29 @@ export interface LamPreferences {
maxToolRounds?: string;
}
/** Where the model in use came from, so the UI can explain a divergence from the preference. */
export type ModelSource = "preference" | "selection";
/** Preferences with every optional value filled in, so callers never branch on undefined. */
export interface ResolvedPreferences {
apiKey: string;
model: string;
/**
* Whether {@link ResolvedPreferences.model} came from the preference or from a
* pick in the Models command.
*
* Carried rather than inferred, because the two disagree silently otherwise:
* the setup screen would show one model while the chat used another, and the
* user would have no way to tell which one to change.
*/
modelSource: ModelSource;
/**
* What the `Model` preference says, whatever is in use.
*
* Kept alongside `model` so a screen can answer the question a selection
* raises: what would happen if I reset it?
*/
preferenceModel: string;
host: string;
systemPrompt: string;
/**
@@ -166,10 +185,15 @@ function describeSpan(milliseconds: number): string {
export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
const thinking = (raw.thinking ?? "").trim();
const model = (raw.model ?? "").trim() || DEFAULT_MODEL;
return {
apiKey: (raw.apiKey ?? "").trim(),
model: (raw.model ?? "").trim() || DEFAULT_MODEL,
model,
// Anything read straight from Vicinae is the preference by definition. The
// selection is layered on top in `loadPreferences`.
modelSource: "preference",
preferenceModel: model,
host: (raw.ollamaHost ?? "").trim() || DEFAULT_HOST,
systemPrompt: (raw.systemPrompt ?? "").trim(),
place: (raw.location ?? "").trim(),
@@ -190,10 +214,57 @@ export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
};
}
export function getLamPreferences(): ResolvedPreferences {
/**
* Vicinae's own preferences, resolved and normalised. Not exported: everything
* outside this module should go through {@link loadPreferences}, which also
* applies the model chosen in the Models command.
*/
function getLamPreferences(): ResolvedPreferences {
return resolvePreferences(getPreferenceValues<LamPreferences>());
}
/**
* The model chosen in the Models command, if there is one.
*
* Never throws. Storage that cannot be read means no selection rather than a
* broken command, because the fallback (the preference) is always a valid
* answer.
*/
export async function readModelSelection(): Promise<string | undefined> {
const stored = await LocalStorage.getItem<string>(STORAGE_MODEL_KEY).catch(() => undefined);
const trimmed = (stored ?? "").trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/** Remembers a model as the one to use, taking precedence over the preference. */
export async function selectModel(model: string): Promise<void> {
await LocalStorage.setItem(STORAGE_MODEL_KEY, model.trim());
}
/** Drops the selection, handing the model back to the `Model` preference. */
export async function clearModelSelection(): Promise<void> {
await LocalStorage.removeItem(STORAGE_MODEL_KEY).catch(() => undefined);
}
/**
* The preferences the rest of the extension should actually use.
*
* The only reader anyone else needs: it applies the model chosen in the Models
* command on top of the preferences Vicinae holds. A second, synchronous reader
* would be a trap, because it looks right and silently ignores the selection,
* which is why `getLamPreferences` above is not exported.
*
* The model is the only thing a selection can override. Everything else here has
* a single home in Vicinae's own settings, and adding a shadow copy of those
* would make "where do I change this" a question with two answers.
*/
export async function loadPreferences(): Promise<ResolvedPreferences> {
const base = getLamPreferences();
const selected = await readModelSelection();
return selected ? { ...base, model: selected, modelSource: "selection" } : base;
}
/** True when the extension cannot work until the user supplies a key. */
export function needsSetup(preferences: ResolvedPreferences): boolean {
return preferences.apiKey.length === 0;