272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
import { getPreferenceValues, LocalStorage } from "@vicinae/api";
|
|
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL, STORAGE_MODEL_KEY } from "./constants";
|
|
|
|
/**
|
|
* Preferences, read and normalised.
|
|
*
|
|
* Declared locally rather than relying on the `vicinae-env.d.ts` that the
|
|
* `vici` toolchain generates, so this file typechecks and unit-tests without a
|
|
* codegen step. The manifest in `package.json` remains the source of truth; if
|
|
* a preference is added there, mirror it here.
|
|
*/
|
|
|
|
export interface LamPreferences {
|
|
apiKey?: string;
|
|
model?: string;
|
|
ollamaHost?: string;
|
|
systemPrompt?: string;
|
|
location?: string;
|
|
ipGeolocation?: boolean;
|
|
webSearchEnabled?: boolean;
|
|
thinking?: string;
|
|
showToolCalls?: boolean;
|
|
showReasoning?: boolean;
|
|
resumeTimeout?: string;
|
|
autoDeleteAfter?: string;
|
|
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;
|
|
/**
|
|
* The place the user says they are, verbatim, or an empty string.
|
|
*
|
|
* The only exact location this extension has. Everything else is inferred from
|
|
* the device timezone and locale, which is accurate to a region rather than a
|
|
* city, and a timezone name is not a city at all.
|
|
*/
|
|
place: string;
|
|
/** Whether to resolve a rough place from the IP address. Off unless asked for. */
|
|
ipGeolocation: boolean;
|
|
webEnabled: boolean;
|
|
/** Undefined when reasoning is off, which is what the API client expects. */
|
|
think?: string;
|
|
/** Whether the transcript lists the tools Lam called. */
|
|
showToolCalls: boolean;
|
|
/** Whether the transcript includes the model's thinking trace. */
|
|
showReasoning: boolean;
|
|
/** Idle time after which opening the command starts a fresh chat. Undefined means never. */
|
|
resumeTimeoutMs?: number;
|
|
/**
|
|
* Age past which a conversation is deleted the next time a command runs.
|
|
*
|
|
* Undefined means the sweep is off entirely, which is the default. Every path
|
|
* that cannot answer the question confidently resolves here, so a preference
|
|
* that is missing, empty, or unrecognised deletes nothing.
|
|
*/
|
|
autoDeleteMs?: number;
|
|
/**
|
|
* Rounds of tool calls one turn may spend. Undefined means unlimited.
|
|
*
|
|
* Note the opposite convention to {@link ResolvedPreferences.autoDeleteMs}:
|
|
* there, "unrecognised" falls back to doing nothing, and here it falls back to
|
|
* the cap. See {@link resolveMaxToolRounds}.
|
|
*/
|
|
maxToolRounds?: number;
|
|
}
|
|
|
|
const MINUTE = 60_000;
|
|
const HOUR = 60 * MINUTE;
|
|
const DAY = 24 * HOUR;
|
|
|
|
/** Keep in sync with the `resumeTimeout` dropdown in `package.json`. */
|
|
const RESUME_TIMEOUTS: Record<string, number> = {
|
|
"15m": 15 * MINUTE,
|
|
"1h": HOUR,
|
|
"6h": 6 * HOUR,
|
|
"1d": DAY,
|
|
};
|
|
|
|
/** `undefined` for "never" and for anything unrecognised, which both mean "always reopen the last chat". */
|
|
export function resolveResumeTimeout(value: string | undefined): number | undefined {
|
|
return RESUME_TIMEOUTS[(value ?? "").trim()];
|
|
}
|
|
|
|
/** Keep in sync with the `autoDeleteAfter` dropdown in `package.json`. */
|
|
const AUTO_DELETE_WINDOWS: Record<string, number> = {
|
|
"1d": DAY,
|
|
"7d": 7 * DAY,
|
|
"30d": 30 * DAY,
|
|
"90d": 90 * DAY,
|
|
};
|
|
|
|
/**
|
|
* `undefined` for "disabled" and for anything unrecognised.
|
|
*
|
|
* Nothing here can turn an unrecognised value into a deletion: the fallback is
|
|
* the off switch, which is the only safe reading of a preference this codebase
|
|
* does not understand.
|
|
*/
|
|
export function resolveAutoDelete(value: string | undefined): number | undefined {
|
|
return AUTO_DELETE_WINDOWS[(value ?? "").trim().toLowerCase()];
|
|
}
|
|
|
|
/**
|
|
* Round limits, keyed by the preference values in `package.json`.
|
|
*
|
|
* `undefined` here means unlimited, so the table cannot be indexed the way the
|
|
* other two are: an absent key and an "unlimited" key would be indistinguishable.
|
|
*/
|
|
const TOOL_ROUNDS: Record<string, number | undefined> = {
|
|
"4": 4,
|
|
"8": 8,
|
|
"16": 16,
|
|
unlimited: undefined,
|
|
};
|
|
|
|
/**
|
|
* The tool-call round cap, where `undefined` means unlimited.
|
|
*
|
|
* Falls back to the default cap rather than to unlimited, unlike
|
|
* {@link resolveResumeTimeout} and {@link resolveAutoDelete}, which both treat an
|
|
* unrecognised value as "off". Those two are safe when they do nothing; this one
|
|
* guards a loop, so the safe reading of a value this codebase does not recognise
|
|
* is the cap and not the absence of one.
|
|
*/
|
|
export function resolveMaxToolRounds(value: string | undefined): number | undefined {
|
|
const key = (value ?? "").trim().toLowerCase();
|
|
return key in TOOL_ROUNDS ? TOOL_ROUNDS[key] : DEFAULT_MAX_TOOL_ROUNDS;
|
|
}
|
|
|
|
/** Human-readable form of {@link ResolvedPreferences.autoDeleteMs}, for the setup screen. */
|
|
export function describeAutoDelete(milliseconds: number | undefined): string {
|
|
if (milliseconds === undefined) return "Disabled (conversations are kept)";
|
|
|
|
return `After ${describeSpan(milliseconds)}`;
|
|
}
|
|
|
|
/** Human-readable form of {@link ResolvedPreferences.maxToolRounds}, for the setup screen. */
|
|
export function describeMaxToolRounds(rounds: number | undefined): string {
|
|
if (rounds === undefined) return "Unlimited (no cap on tool calls)";
|
|
return `${rounds} round${rounds === 1 ? "" : "s"} per answer`;
|
|
}
|
|
|
|
/** Human-readable form of {@link ResolvedPreferences.resumeTimeoutMs}, for the setup screen. */
|
|
export function describeResumeTimeout(milliseconds: number | undefined): string {
|
|
if (milliseconds === undefined) return "Never (always reopen the last chat)";
|
|
|
|
return `After ${describeSpan(milliseconds)}`;
|
|
}
|
|
|
|
/** "15 minutes", "6 hours", "7 days". Shared so the two windows phrase themselves the same way. */
|
|
function describeSpan(milliseconds: number): string {
|
|
const minutes = Math.round(milliseconds / MINUTE);
|
|
if (minutes < 60) return `${minutes} minutes`;
|
|
|
|
const hours = Math.round(minutes / 60);
|
|
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"}`;
|
|
|
|
const days = Math.round(hours / 24);
|
|
return `${days} day${days === 1 ? "" : "s"}`;
|
|
}
|
|
|
|
export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
|
|
const thinking = (raw.thinking ?? "").trim();
|
|
const model = (raw.model ?? "").trim() || DEFAULT_MODEL;
|
|
|
|
return {
|
|
apiKey: (raw.apiKey ?? "").trim(),
|
|
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(),
|
|
// Defaults to off: this is the one setting that sends something about the
|
|
// user to a third party, so it is a deliberate choice rather than a default.
|
|
ipGeolocation: raw.ipGeolocation ?? false,
|
|
// Defaults to on: the preference is a checkbox the user has to actively untick.
|
|
webEnabled: raw.webSearchEnabled ?? true,
|
|
think: thinking && thinking !== "off" ? thinking : undefined,
|
|
showToolCalls: raw.showToolCalls ?? true,
|
|
showReasoning: raw.showReasoning ?? true,
|
|
// Falls back to the manifest's declared default, so a missing preference
|
|
// behaves the same whether Vicinae substitutes it or not.
|
|
resumeTimeoutMs: resolveResumeTimeout(raw.resumeTimeout ?? "1h"),
|
|
// Defaults to off, the one setting here that destroys data.
|
|
autoDeleteMs: resolveAutoDelete(raw.autoDeleteAfter ?? "disabled"),
|
|
maxToolRounds: resolveMaxToolRounds(raw.maxToolRounds ?? String(DEFAULT_MAX_TOOL_ROUNDS)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|