From 2beefa0aa4ccc9d579233a497b3fb819f5e8a358 Mon Sep 17 00:00:00 2001 From: sachin-detrax <84434145+sachin-detrax@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:49:06 +0530 Subject: [PATCH] feat(models): add any Hugging Face GGUF, not just the curated catalog The local model list was a fixed array of nine models compiled into models-catalog.ts. Anything else on Hugging Face was unreachable without running llama-server by hand or waiting for a maintainer to ship a new release. Models are now open-ended. A user-added entry is a full LocalModelDef with a `custom-` prefixed id, persisted in localModels.customModels (config v33 -> v34, older files inherit []). loadConfig registers them in a catalog registry, so getLocalModelDef / isKnownLocalModelId resolve curated and custom entries through one lookup and every existing consumer -- daemon start, installer, TUI rows, CLI -- picked them up without changes. Entry points: TUI "+ Add a model from Hugging Face..." pinned under Local text models. Enter opens a prompt taking either a reference (resolve and add) or free text (search, then pick by digit). CLI models add / models search . /models add and /models search from the prompt. References accepted: repo URLs, /resolve/ and /blob/ file URLs, hf://owner/repo[@rev]/file.gguf, a pasted `hf download ...` command (one- or two-argument, trailing flags dropped), and bare owner/name. Naming only a repo picks a 4-bit quant and any mmproj projector, which makes vision repos come out vision-capable. HF_TOKEN is honoured for gated repos, including on the weights download. hf:// is parsed by hand rather than with `new URL`: that puts the owner in the host slot and lowercases it, and HF owners are case-sensitive, so hf://Qwen/... would silently 404. Two rendering defects surfaced while testing this, both pre-existing and both able to shred the panel on any daemon failure: - daemonError / errorLine are view-model fields rendered as a single in a one-row slot, but a failed llama-server start stored a 4KB, ~25-line log tail. Ink overlaps lines when a frame outgrows its budget rather than clipping: 48 rows into a 24-row budget. Flattened on write and on read, with wrap="truncate-end" on the one-row slots -- flattening alone still wrapped a long single line. - llm-panel computed its list budget without subtracting the modals and banners it renders above the list, unlike local-models-panel: 42 rows into 24, and 66 with both defects together. Flattening the message hid the real cause behind "did not become healthy", so extractLoadFailure now lifts the diagnostic line out of the log tail into the message head. Full detail still reaches the runtime feed and the L logs tab. Routing between "add this model" and "set this base URL" goes through looksLikeHuggingFaceReference, stricter than the parser: a bare owner/name is ambiguous with 192.168.1.5/api and stays behind the explicit `add` verb. An inline regex here had already drifted once -- it knew about huggingface.co but not hf://, so /models hf://... was silently persisted as http://hf://... instead of adding the model. Tests: HF reference parsing and quant/projector selection, add-row placement and prompt key handling (including panel hotkeys not stealing characters mid-URL and digits only picking when results show), rendered frames, and frame-height regressions asserting height <= budget. Closes #27 Co-Authored-By: Claude Opus 5 (1M context) --- src/cli/models-command.ts | 18 + src/cli/models-handlers.ts | 84 +++- src/config/config-schema.ts | 159 ++++++- src/config/custom-models-store.ts | 58 +++ src/config/load-config.ts | 5 + src/local-llm/daemon-lifecycle.ts | 29 +- src/local-llm/download-file.ts | 8 + src/local-llm/huggingface.test.ts | 216 +++++++++ src/local-llm/huggingface.ts | 445 ++++++++++++++++++ src/local-llm/index.ts | 18 + src/local-llm/models-catalog.ts | 42 +- src/tui/commands/slash-command-handler.ts | 35 +- src/tui/components/llm-mode-rows.tsx | 9 +- .../llm-panel-frame-height.test.tsx | 177 +++++++ .../llm-panel-huggingface-render.test.tsx | 89 ++++ src/tui/components/llm-panel-modals.tsx | 53 ++- src/tui/components/llm-panel.tsx | 91 +++- src/tui/components/local-models-panel.tsx | 25 +- src/tui/llm-panel/llm-panel-actions.ts | 40 +- .../llm-panel/llm-panel-huggingface.test.ts | 174 +++++++ .../llm-panel/llm-panel-key-bindings.test.ts | 4 +- .../llm-panel/llm-panel-modal-key-bindings.ts | 46 ++ .../llm-panel/llm-panel-primary-actions.ts | 3 + src/tui/llm-panel/llm-panel-reducer.ts | 67 +++ src/tui/llm-panel/llm-panel-row-builders.ts | 13 + src/tui/llm-panel/llm-panel-selectors.test.ts | 2 + src/tui/llm-panel/llm-panel-selectors.ts | 12 + src/tui/llm-panel/llm-panel-state.ts | 24 + .../local-models/local-models-orchestrator.ts | 136 +++++- .../local-models/local-models-panel-state.ts | 26 + src/tui/local-models/local-models-reducer.ts | 18 +- src/tui/persist-user-local-models-config.ts | 5 + src/tui/submit-handler.ts | 6 + src/tui/tui-app.tsx | 13 + src/tui/tui-command.ts | 6 + 35 files changed, 2104 insertions(+), 52 deletions(-) create mode 100644 src/config/custom-models-store.ts create mode 100644 src/local-llm/huggingface.test.ts create mode 100644 src/local-llm/huggingface.ts create mode 100644 src/tui/components/llm-panel-frame-height.test.tsx create mode 100644 src/tui/components/llm-panel-huggingface-render.test.tsx create mode 100644 src/tui/llm-panel/llm-panel-huggingface.test.ts diff --git a/src/cli/models-command.ts b/src/cli/models-command.ts index 7d25b8c..a1cb515 100644 --- a/src/cli/models-command.ts +++ b/src/cli/models-command.ts @@ -1,11 +1,13 @@ import { getConfig } from "../config/index.js"; import { + runLocalModelsAdd, runLocalModelsDevices, runLocalModelsList, runLocalModelsListEmbeddings, runLocalModelsPull, runLocalModelsPullEmbedding, runLocalModelsRemove, + runLocalModelsSearch, runLocalModelsStart, runLocalModelsStatus, runLocalModelsStop, @@ -32,6 +34,16 @@ const HELP = " (stops daemon first; does not auto-restart)", " remove Delete a downloaded model (refuses if active + daemon running)", "", + "Hugging Face (any GGUF, not just the curated catalog):", + " search Search GGUF repos on Hugging Face", + " add Register a repo/file as a custom model, then `pull` it", + " Accepts repo URLs, /resolve/ or /blob/ file URLs,", + " hf://owner/repo[@rev]/file.gguf, a pasted", + " `hf download …` command, and bare owner/name ids.", + " Picks a 4-bit quant and any mmproj projector", + " automatically when you name only a repo.", + " Export HF_TOKEN for gated or private repos.", + "", "GPU subcommands:", " devices List GPU devices (llama-server --list-devices); active marked with *", " use-device Set the managed daemon's GPU (auto-picks best discrete by default)", @@ -45,6 +57,8 @@ const HELP = "", "Examples:", " atomic-agent models list", + " atomic-agent models search qwen3 coder", + " atomic-agent models add https://huggingface.co/unsloth/Qwen3.5-4B-GGUF", " atomic-agent models pull qwen-3.5-4b", " atomic-agent models use qwen-3.5-4b", " atomic-agent models pull-embedding nomic-embed-text-v1.5", @@ -79,6 +93,10 @@ export async function modelsCommand(args: string[]): Promise { return runLocalModelsUpdate(); case "remove": return runLocalModelsRemove(args[1]); + case "add": + return runLocalModelsAdd(args[1]); + case "search": + return runLocalModelsSearch(args.slice(1)); case "devices": return runLocalModelsDevices(); case "use-device": diff --git a/src/cli/models-handlers.ts b/src/cli/models-handlers.ts index a1c79d7..846114f 100644 --- a/src/cli/models-handlers.ts +++ b/src/cli/models-handlers.ts @@ -1,6 +1,10 @@ import { join } from "node:path"; import { getConfig, resetConfigCache } from "../config/index.js"; import { ensureUserConfigFileSync, writeUserConfigFileSync } from "../config/config-file.js"; +import { + addCustomModel, + removeCustomModel, +} from "../config/custom-models-store.js"; import type { UserConfigFile } from "../config/config-schema.js"; import { checkForBackendUpdate, @@ -18,10 +22,12 @@ import { isKnownLocalModelId, isMmprojDownloaded, isModelDownloaded, + listLocalModels, listVulkanDevices, - LOCAL_MODELS_CATALOG, readBackendVersion, removeModel, + resolveCustomModelFromHuggingFace, + searchHuggingFaceGgufModels, resolveChatTemplatePath, resolveManagedDevice, resolveMmprojFilePath, @@ -63,7 +69,7 @@ export async function runLocalModelsList(): Promise { process.stdout.write( "ID | FAMILY | SIZE | CONTEXT | DL | ACTIVE\n", ); - for (const m of LOCAL_MODELS_CATALOG) { + for (const m of listLocalModels()) { const dl = isModelDownloaded(dataDir, m) ? "yes" : "no"; const active = cfg.localModels.managed.modelId === m.id && cfg.localModels.mode === "managed" ? "*" : " "; @@ -77,7 +83,7 @@ export async function runLocalModelsList(): Promise { export async function runLocalModelsPull(idArg: string | undefined): Promise { if (!idArg || !isKnownLocalModelId(idArg)) { process.stderr.write( - `unknown model id. Valid: ${LOCAL_MODELS_CATALOG.map((m) => m.id).join(", ")}\n`, + `unknown model id. Valid: ${listLocalModels().map((m) => m.id).join(", ")}\n`, ); return 1; } @@ -118,7 +124,7 @@ export async function runLocalModelsPull(idArg: string | undefined): Promise { if (!idArg || !isKnownLocalModelId(idArg)) { process.stderr.write( - `unknown model id. Valid: ${LOCAL_MODELS_CATALOG.map((m) => m.id).join(", ")}\n`, + `unknown model id. Valid: ${listLocalModels().map((m) => m.id).join(", ")}\n`, ); return 1; } @@ -582,7 +588,7 @@ export async function runLocalModelsUpdate(): Promise { export async function runLocalModelsRemove(idArg: string | undefined): Promise { if (!idArg || !isKnownLocalModelId(idArg)) { process.stderr.write( - `unknown model id. Valid: ${LOCAL_MODELS_CATALOG.map((m) => m.id).join(", ")}\n`, + `unknown model id. Valid: ${listLocalModels().map((m) => m.id).join(", ")}\n`, ); return 1; } @@ -601,6 +607,72 @@ export async function runLocalModelsRemove(idArg: string | undefined): Promise` — register a GGUF from any Hugging Face + * repo as a `custom-…` catalog entry. Does not download: the user chains + * `models pull ` (or Enter in the TUI) exactly as for curated models. + */ +export async function runLocalModelsAdd(refArg: string | undefined): Promise { + if (!refArg) { + process.stderr.write( + "usage: atomic-agent models add \n" + + " e.g. atomic-agent models add https://huggingface.co/unsloth/Qwen3.5-4B-GGUF\n", + ); + return 1; + } + try { + const def = await resolveCustomModelFromHuggingFace(refArg); + addCustomModel(def); + process.stdout.write( + `added ${def.id}\n` + + ` file: ${def.filename} (${def.sizeLabel})\n` + + ` source: ${def.huggingFaceUrl}\n` + + (def.supportsVision ? ` mmproj: ${def.mmprojFilename}\n` : "") + + `\nnext: atomic-agent models pull ${def.id} && atomic-agent models use ${def.id}\n`, + ); + return 0; + } catch (e) { + process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`); + return 1; + } +} + +/** `models search ` — GGUF repos on Hugging Face, most downloaded first. */ +export async function runLocalModelsSearch( + args: readonly string[], +): Promise { + const query = args.join(" ").trim(); + if (query.length === 0) { + process.stderr.write("usage: atomic-agent models search \n"); + return 1; + } + try { + const hits = await searchHuggingFaceGgufModels(query); + if (hits.length === 0) { + process.stdout.write(`no GGUF repos matched ${JSON.stringify(query)}\n`); + return 0; + } + process.stdout.write("REPO | DOWNLOADS | LIKES\n"); + for (const hit of hits) { + process.stdout.write( + `${hit.repoId.padEnd(50)} | ${String(hit.downloads).padStart(9)} | ${hit.likes}\n`, + ); + } + process.stdout.write( + `\nadd one with: atomic-agent models add ${hits[0]!.repoId}\n`, + ); + return 0; + } catch (e) { + process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`); + return 1; + } +} diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index bf38be4..e75398c 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -3,7 +3,10 @@ import { parseUserLlmFileConfig, type UserLlmFileConfig, } from "./llm-config.js"; -import { isKnownLocalModelId } from "../local-llm/models-catalog.js"; +import { + isKnownLocalModelId, + type LocalModelDef, +} from "../local-llm/models-catalog.js"; import { MCP_SERVER_NAME_MAX_LENGTH, MCP_SERVER_NAME_RE, @@ -126,6 +129,8 @@ export interface AtomicAgentConfig { * healthy. Disabled / unreachable ⇒ FTS5-only recall path. */ embeddings: UserManagedEmbeddingLlmConfig; + /** User-added Hugging Face models. Mirrors the user config block. */ + customModels: LocalModelDef[]; }; paths: { stateDir: string; @@ -844,6 +849,14 @@ export interface UserConfigFile { * `{ enabled: false, modelId: null, port: 19092 }`. */ embeddings: UserManagedEmbeddingLlmConfig; + /** + * User-added GGUF models pulled from arbitrary Hugging Face repos + * (config v34). Each entry is a full `LocalModelDef` with a + * `custom-` prefixed id; `loadConfig()` registers them so the + * curated catalog and these resolve through one lookup. Older files + * are upgraded with `[]`. + */ + customModels: LocalModelDef[]; }; log: { level: LogLevel }; agent: { @@ -1316,7 +1329,9 @@ export interface UserConfigFile { // v31: skills gains `clawhub` (ClawHub registry — the primary skill // marketplace). Older files inherit it enabled against the public registry // with suspicious skills hidden. -export const USER_CONFIG_VERSION = 33 as const; +// v33→v34 added `localModels.customModels` (GGUF models the user pointed +// at on Hugging Face). Older files inherit `[]` transparently. +export const USER_CONFIG_VERSION = 34 as const; /** * Config v21+ flips the full memory-v2 fabric on by default. Upgrades @@ -1429,6 +1444,7 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [ 30, 31, 32, + 33, USER_CONFIG_VERSION, ]; @@ -1452,6 +1468,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { port: 19092, url: "http://127.0.0.1:19092", }, + customModels: [], }, log: { level: "info" }, agent: { @@ -1759,10 +1776,17 @@ export function parseLocalLlmMode(raw: unknown, field: string): LocalLlmMode { ); } -function parseOptionalManagedModelId(raw: unknown, field: string): string | null { +function parseOptionalManagedModelId( + raw: unknown, + field: string, + customModels: readonly LocalModelDef[], +): string | null { if (raw === null || raw === undefined) return null; const s = parseNonEmptyString(raw, field); - if (!isKnownLocalModelId(s)) { + // The custom list comes from the same file, so it is checked directly + // rather than through the module registry — a config that adds a model + // and selects it in one write must validate before it is ever loaded. + if (!isKnownLocalModelId(s) && !customModels.some((m) => m.id === s)) { throw new ConfigValidationError( field, `unknown managed local model id: ${JSON.stringify(s)}`, @@ -1771,6 +1795,107 @@ function parseOptionalManagedModelId(raw: unknown, field: string): string | null return s; } +/** + * Validate one user-added model entry. Only the fields the runtime + * actually reads are enforced; the rest are cosmetic and defaulted so a + * hand-edited config can stay terse. + */ +export function parseCustomLocalModel( + raw: unknown, + field: string, +): LocalModelDef { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected an object"); + } + const o = raw as Record; + const id = parseNonEmptyString(o.id, `${field}.id`); + if (!id.startsWith("custom-")) { + throw new ConfigValidationError( + `${field}.id`, + `custom model ids must start with "custom-", got ${JSON.stringify(id)}`, + ); + } + // The id becomes a directory name under `/models/`. + if (!/^custom-[a-z0-9._-]+$/.test(id)) { + throw new ConfigValidationError( + `${field}.id`, + `custom model id must match /^custom-[a-z0-9._-]+$/, got ${JSON.stringify(id)}`, + ); + } + const filename = parseNonEmptyString(o.filename, `${field}.filename`); + const huggingFaceUrl = parseUrl(o.huggingFaceUrl, `${field}.huggingFaceUrl`); + const supportsVision = parseBool( + o.supportsVision ?? false, + `${field}.supportsVision`, + ); + const fileSizeGb = parseNonNegativeNumber( + o.fileSizeGb ?? 0, + `${field}.fileSizeGb`, + ); + const def: LocalModelDef = { + id: id as LocalModelDef["id"], + name: typeof o.name === "string" && o.name.length > 0 ? o.name : id, + filename, + huggingFaceUrl, + fileSizeGb, + sizeLabel: + typeof o.sizeLabel === "string" && o.sizeLabel.length > 0 + ? o.sizeLabel + : `${fileSizeGb.toFixed(1)} GB`, + description: + typeof o.description === "string" ? o.description : "Custom model", + maxContextLength: parseNonNegativeInt( + o.maxContextLength ?? 0, + `${field}.maxContextLength`, + ), + contextLabel: + typeof o.contextLabel === "string" && o.contextLabel.length > 0 + ? o.contextLabel + : "auto", + minRamGb: parseNonNegativeNumber(o.minRamGb ?? 1, `${field}.minRamGb`), + recommendedRamGb: parseNonNegativeNumber( + o.recommendedRamGb ?? 2, + `${field}.recommendedRamGb`, + ), + family: "custom", + supportsVision, + }; + if (supportsVision) { + return { + ...def, + mmprojUrl: parseUrl(o.mmprojUrl, `${field}.mmprojUrl`), + mmprojFilename: parseNonEmptyString( + o.mmprojFilename, + `${field}.mmprojFilename`, + ), + mmprojFileSizeGb: parseNonNegativeNumber( + o.mmprojFileSizeGb ?? 0, + `${field}.mmprojFileSizeGb`, + ), + }; + } + return def; +} + +export function parseCustomLocalModels( + raw: unknown, + field: string, +): LocalModelDef[] { + if (raw === undefined || raw === null) return []; + if (!Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected an array"); + } + const out = raw.map((entry, i) => parseCustomLocalModel(entry, `${field}[${i}]`)); + const seen = new Set(); + for (const def of out) { + if (seen.has(def.id)) { + throw new ConfigValidationError(field, `duplicate custom model id: ${def.id}`); + } + seen.add(def.id); + } + return out; +} + export function parseBrowserChannel(raw: unknown, field: string): BrowserChannel { if (raw === "chrome" || raw === "msedge" || raw === "chromium") return raw; throw new ConfigValidationError( @@ -1863,6 +1988,23 @@ export function parseBoundedPositiveInt( * Parse a non-negative integer (includes `0`). Used for caps that * accept `0` as "feature disabled", e.g. `memory.reflection.maxNotesPerCall`. */ +/** Non-negative float — sizes in GB are fractional. */ +export function parseNonNegativeNumber(raw: unknown, field: string): number { + const value = + typeof raw === "number" + ? raw + : typeof raw === "string" + ? Number.parseFloat(raw) + : NaN; + if (!Number.isFinite(value) || value < 0) { + throw new ConfigValidationError( + field, + `expected non-negative number, got ${JSON.stringify(raw)}`, + ); + } + return value; +} + export function parseNonNegativeInt(raw: unknown, field: string): number { const value = typeof raw === "number" @@ -2590,12 +2732,20 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { (obj.analytics as Record | undefined) ?? {}; const mcp = (obj.mcp as Record | undefined) ?? {}; + // Parsed before `managed.modelId` so a config that adds a custom model + // and activates it in the same write validates. + const customModels = parseCustomLocalModels( + localModels.customModels, + "localModels.customModels", + ); + const rawManaged = (localModels.managed as Record | undefined) ?? {}; const managed: UserManagedLocalLlmConfig = { modelId: parseOptionalManagedModelId( rawManaged.modelId, "localModels.managed.modelId", + customModels, ), port: parsePositiveInt( rawManaged.port ?? USER_CONFIG_DEFAULTS.localModels.managed.port, @@ -2685,6 +2835,7 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { ), managed, embeddings: embeddingsDaemon, + customModels, }, log: { level: parseLogLevel(log.level ?? USER_CONFIG_DEFAULTS.log.level, "log.level"), diff --git a/src/config/custom-models-store.ts b/src/config/custom-models-store.ts new file mode 100644 index 0000000..5ed62d9 --- /dev/null +++ b/src/config/custom-models-store.ts @@ -0,0 +1,58 @@ +/** + * Add / remove user-added Hugging Face models in the user config, and + * keep the catalog registry in sync. Shared by the CLI (`models add`) + * and the TUI (`/models add`) so both surfaces write the same shape. + */ + +import type { LocalModelDef } from "../local-llm/models-catalog.js"; +import { setCustomLocalModels } from "../local-llm/models-catalog.js"; +import { ensureUserConfigFileSync, writeUserConfigFileSync } from "./config-file.js"; +import { parseUserConfigFile } from "./config-schema.js"; +import { getConfig, resetConfigCache } from "./config-cache.js"; + +function writeCustomModels(defs: LocalModelDef[]): void { + const path = getConfig().paths.userConfigFile; + const prev = ensureUserConfigFileSync(path); + // Dropping the active model would leave `managed.modelId` dangling and + // the file would fail its own validation on the next read. + const activeId = prev.localModels.managed.modelId; + const activeSurvives = + activeId === null || + !activeId.startsWith("custom-") || + defs.some((m) => m.id === activeId); + const validated = parseUserConfigFile({ + ...prev, + localModels: { + ...prev.localModels, + customModels: defs, + managed: { + ...prev.localModels.managed, + modelId: activeSurvives ? activeId : null, + }, + }, + }); + writeUserConfigFileSync(path, validated); + // Registry first, cache second: a caller that reads the catalog before + // the next `getConfig()` still sees the new entry. + setCustomLocalModels(validated.localModels.customModels); + resetConfigCache(); +} + +/** + * Persist `def`, replacing any existing entry with the same id (re-adding + * the same repo+file is an idempotent refresh, not a duplicate). + */ +export function addCustomModel(def: LocalModelDef): void { + const prev = getConfig(); + const kept = prev.localModels.customModels.filter((m) => m.id !== def.id); + writeCustomModels([...kept, def]); +} + +/** Drop a custom model from the config. Returns false if it wasn't there. */ +export function removeCustomModel(id: string): boolean { + const prev = getConfig(); + const kept = prev.localModels.customModels.filter((m) => m.id !== id); + if (kept.length === prev.localModels.customModels.length) return false; + writeCustomModels(kept); + return true; +} diff --git a/src/config/load-config.ts b/src/config/load-config.ts index f01f148..13b59b0 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -12,6 +12,7 @@ import { ensureUserConfigFileSync, getUserConfigPath, } from "./config-file.js"; +import { setCustomLocalModels } from "../local-llm/models-catalog.js"; import { loadDotenvFromStateDir } from "./load-dotenv.js"; import { resolveLlmProviderApiKey } from "./resolve-llm-api-key.js"; import type { UserLlmFileConfig } from "./llm-config.js"; @@ -92,6 +93,9 @@ export function loadConfig(): AtomicAgentConfig { loadDotenvFromStateDir(stateDir); const userConfigFile = getUserConfigPath(stateDir); const user = ensureUserConfigFileSync(userConfigFile); + // Publish user-added Hugging Face models to the catalog registry so + // `getLocalModelDef` / `isKnownLocalModelId` resolve them everywhere. + setCustomLocalModels(user.localModels.customModels); const grammarsDir = resolveAssetDir("ATOMIC_AGENT_GRAMMARS_DIR", "grammars"); const browserChannel: BrowserChannel = readBrowserChannel( @@ -153,6 +157,7 @@ export function loadConfig(): AtomicAgentConfig { mode: user.localModels.mode, managed: { ...user.localModels.managed }, embeddings: { ...user.localModels.embeddings }, + customModels: [...user.localModels.customModels], }, paths: { stateDir, diff --git a/src/local-llm/daemon-lifecycle.ts b/src/local-llm/daemon-lifecycle.ts index 6f29907..40ab9ea 100644 --- a/src/local-llm/daemon-lifecycle.ts +++ b/src/local-llm/daemon-lifecycle.ts @@ -275,11 +275,38 @@ async function waitForHealthOkWithLog(dataDir: string, port: number, timeoutMs: } catch { tail = "(no log)"; } + const cause = extractLoadFailure(tail); throw new Error( - `llama-server did not become healthy within ${timeoutMs}ms. Log tail:\n${tail}`, + `llama-server did not become healthy within ${timeoutMs}ms` + + `${cause ? `: ${cause}` : ""}. Log tail:\n${tail}`, ); } +/** + * Pull the one line worth showing out of a llama-server log tail. + * + * The timeout message is the symptom; the log says *why* — most often an + * architecture the bundled llama.cpp build cannot read, which is the + * common outcome of pointing at an arbitrary Hugging Face GGUF. Callers + * surface only the first line of an error in one-row status slots, so + * that line has to carry the diagnosis or the operator is left with + * "did not become healthy" and nowhere to go. + */ +export function extractLoadFailure(logTail: string): string | null { + // Ordered by specificity: the architecture line names the actual + // problem, the generic ones are fallbacks. + const patterns = [ + /unknown model architecture:\s*'[^']*'/i, + /error loading model:\s*(.+)/i, + /failed to load model[^\n]*/i, + ]; + for (const pattern of patterns) { + const hit = logTail.match(pattern); + if (hit) return hit[0].trim().replace(/\s+/g, " "); + } + return null; +} + export async function startDaemon(opts: DaemonStartOptions): Promise<{ pid: number }> { const pidPath = resolvePidFilePath(opts.dataDir); const existing = readRunningPid(opts.dataDir); diff --git a/src/local-llm/download-file.ts b/src/local-llm/download-file.ts index a51f844..88b82dc 100644 --- a/src/local-llm/download-file.ts +++ b/src/local-llm/download-file.ts @@ -42,6 +42,14 @@ export async function downloadFile( if (isGitHub) { const token = (process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "").trim(); if (token) headers.Authorization = `Bearer ${token}`; + } else if (url.includes("huggingface.co")) { + // Gated / private repos 401 without this; public ones ignore it. + const token = ( + process.env.HF_TOKEN || + process.env.HUGGING_FACE_HUB_TOKEN || + "" + ).trim(); + if (token) headers.Authorization = `Bearer ${token}`; } const res = await fetch(url, { diff --git a/src/local-llm/huggingface.test.ts b/src/local-llm/huggingface.test.ts new file mode 100644 index 0000000..6aefd75 --- /dev/null +++ b/src/local-llm/huggingface.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "vitest"; +import { + buildCustomModelDef, + buildCustomModelId, + looksLikeHuggingFaceReference, + parseHuggingFaceModelRef, + pickDefaultGgufFile, + pickMmprojFile, + type HuggingFaceFile, +} from "./huggingface.js"; + +const GB = 1024 * 1024 * 1024; + +describe("parseHuggingFaceModelRef", () => { + it("parses a /resolve/ file URL", () => { + expect( + parseHuggingFaceModelRef( + "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf", + ), + ).toEqual({ + repoId: "unsloth/Qwen3.5-4B-GGUF", + revision: "main", + filePath: "Qwen3.5-4B-Q4_K_M.gguf", + }); + }); + + it("parses a /blob/ URL on a non-default revision", () => { + expect( + parseHuggingFaceModelRef( + "https://huggingface.co/org/repo/blob/v2/sub/dir/model.gguf?download=true", + ), + ).toEqual({ repoId: "org/repo", revision: "v2", filePath: "sub/dir/model.gguf" }); + }); + + it("parses a bare repo page, a tree URL, hf.co and a bare id", () => { + const expected = { repoId: "org/repo", revision: "main", filePath: null }; + expect(parseHuggingFaceModelRef("https://huggingface.co/org/repo")).toEqual(expected); + expect(parseHuggingFaceModelRef("https://huggingface.co/org/repo/tree/main")).toEqual( + expected, + ); + expect(parseHuggingFaceModelRef("hf.co/org/repo")).toEqual(expected); + expect(parseHuggingFaceModelRef(" org/repo ")).toEqual(expected); + }); + + it("parses the hf:// scheme, preserving owner case", () => { + expect( + parseHuggingFaceModelRef( + "hf://owao/Nanbeige4.2-3B-GGUF/nanbeige4.2-3b-IQ3_M.gguf", + ), + ).toEqual({ + repoId: "owao/Nanbeige4.2-3B-GGUF", + revision: "main", + filePath: "nanbeige4.2-3b-IQ3_M.gguf", + }); + // `new URL()` would lowercase this owner into `qwen`. + expect(parseHuggingFaceModelRef("hf://Qwen/Qwen3-4B-GGUF").repoId).toBe( + "Qwen/Qwen3-4B-GGUF", + ); + }); + + it("parses hf:// with a revision and a nested file path", () => { + expect(parseHuggingFaceModelRef("hf://org/repo@v2/sub/m.gguf")).toEqual({ + repoId: "org/repo", + revision: "v2", + filePath: "sub/m.gguf", + }); + expect(parseHuggingFaceModelRef("hf://models/org/repo")).toEqual({ + repoId: "org/repo", + revision: "main", + filePath: null, + }); + }); + + it("accepts a whole pasted `hf download` command", () => { + const expected = { + repoId: "owao/Nanbeige4.2-3B-GGUF", + revision: "main", + filePath: "nanbeige4.2-3b-IQ3_M.gguf", + }; + expect( + parseHuggingFaceModelRef( + "hf download hf://owao/Nanbeige4.2-3B-GGUF/nanbeige4.2-3b-IQ3_M.gguf", + ), + ).toEqual(expected); + // Two-argument form, and with trailing CLI flags. + expect( + parseHuggingFaceModelRef( + "hf download owao/Nanbeige4.2-3B-GGUF nanbeige4.2-3b-IQ3_M.gguf", + ), + ).toEqual(expected); + expect( + parseHuggingFaceModelRef( + "huggingface-cli download owao/Nanbeige4.2-3B-GGUF nanbeige4.2-3b-IQ3_M.gguf --local-dir ./m", + ), + ).toEqual(expected); + }); + + it("rejects non-HF URLs, datasets, and incomplete references", () => { + expect(() => parseHuggingFaceModelRef("https://example.com/org/repo")).toThrow(); + expect(() => parseHuggingFaceModelRef("https://huggingface.co/org")).toThrow(); + expect(() => parseHuggingFaceModelRef("hf://datasets/org/repo")).toThrow( + /dataset/, + ); + expect(() => parseHuggingFaceModelRef("hf://org")).toThrow(); + expect(() => parseHuggingFaceModelRef("")).toThrow(); + expect(() => parseHuggingFaceModelRef("hf download")).toThrow(); + }); + + it("still rejects free text so the caller can fall back to search", () => { + // The add-prompt distinguishes reference from query by this throw. + expect(() => parseHuggingFaceModelRef("qwen3 coder")).toThrow(); + expect(() => parseHuggingFaceModelRef("small vision model")).toThrow(); + }); +}); + +describe("file selection", () => { + const files: HuggingFaceFile[] = [ + { path: "Model-Q8_0.gguf", sizeBytes: 8 * GB }, + { path: "Model-Q4_K_M.gguf", sizeBytes: 4 * GB }, + { path: "mmproj-F16.gguf", sizeBytes: GB }, + { path: "Model-Q2_K-00002-of-00002.gguf", sizeBytes: GB }, + ]; + + it("prefers the 4-bit quant over smaller shards and projectors", () => { + expect(pickDefaultGgufFile(files)?.path).toBe("Model-Q4_K_M.gguf"); + }); + + it("falls back to the smallest non-projector, non-tail-shard file", () => { + const noQuantHints: HuggingFaceFile[] = [ + { path: "big.gguf", sizeBytes: 9 * GB }, + { path: "small.gguf", sizeBytes: 2 * GB }, + { path: "mmproj.gguf", sizeBytes: GB }, + ]; + expect(pickDefaultGgufFile(noQuantHints)?.path).toBe("small.gguf"); + }); + + it("returns null when every candidate is a projector", () => { + expect(pickDefaultGgufFile([{ path: "mmproj-F16.gguf", sizeBytes: GB }])).toBeNull(); + }); + + it("finds the projector", () => { + expect(pickMmprojFile(files)?.path).toBe("mmproj-F16.gguf"); + expect(pickMmprojFile([{ path: "a.gguf", sizeBytes: GB }])).toBeNull(); + }); +}); + +describe("buildCustomModelId", () => { + it("produces a filesystem-safe, custom-prefixed id", () => { + const id = buildCustomModelId("unsloth/Qwen3.5-4B-GGUF", "Qwen3.5-4B-Q4_K_M.gguf"); + expect(id).toBe("custom-unsloth-qwen3.5-4b-gguf-qwen3.5-4b-q4_k_m"); + expect(id).toMatch(/^custom-[a-z0-9._-]+$/); + }); +}); + +describe("buildCustomModelDef", () => { + it("wires the download URL, vision fields and RAM estimates", () => { + const def = buildCustomModelDef({ + repoId: "unsloth/Qwen3.5-4B-GGUF", + revision: "main", + file: { path: "Qwen3.5-4B-Q4_K_M.gguf", sizeBytes: 4 * GB }, + mmproj: { path: "mmproj-F16.gguf", sizeBytes: GB }, + }); + expect(def.huggingFaceUrl).toBe( + "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf", + ); + expect(def.filename).toBe("Qwen3.5-4B-Q4_K_M.gguf"); + expect(def.family).toBe("custom"); + expect(def.supportsVision).toBe(true); + expect(def.mmprojFilename).toBe("mmproj-F16.gguf"); + // Context is left to the auto-fit path rather than guessed. + expect(def.maxContextLength).toBe(0); + expect(def.minRamGb).toBe(5); + expect(def.recommendedRamGb).toBe(8); + }); + + it("omits mmproj fields when the repo has no projector", () => { + const def = buildCustomModelDef({ + repoId: "org/repo", + revision: "main", + file: { path: "m.gguf", sizeBytes: GB / 2 }, + mmproj: null, + }); + expect(def.supportsVision).toBe(false); + expect(def.mmprojUrl).toBeUndefined(); + expect(def.sizeLabel).toBe("512 MB"); + }); +}); + +describe("looksLikeHuggingFaceReference", () => { + it("claims only unambiguous Hugging Face references", () => { + for (const yes of [ + "hf://owao/Nanbeige4.2-3B-GGUF/x.gguf", + "hf download hf://org/repo/x.gguf", + "huggingface-cli download org/repo x.gguf", + "https://huggingface.co/org/repo", + "hf.co/org/repo", + ]) { + expect(looksLikeHuggingFaceReference(yes)).toBe(true); + } + }); + + it("leaves llama-server addresses alone", () => { + // These must stay routable as base URLs — claiming them would + // silently misconfigure the runtime instead of adding a model. + for (const no of [ + "http://127.0.0.1:8080", + "192.168.1.5/api", + "localhost:8080/v1", + "owner/name", + "qwen3 coder", + "http://myhf.co.internal:8080", + ]) { + expect(looksLikeHuggingFaceReference(no)).toBe(false); + } + }); +}); diff --git a/src/local-llm/huggingface.ts b/src/local-llm/huggingface.ts new file mode 100644 index 0000000..8c8d637 --- /dev/null +++ b/src/local-llm/huggingface.ts @@ -0,0 +1,445 @@ +/** + * Hugging Face model discovery: turn a pasted URL / repo id — or a + * search query — into a `LocalModelDef` the rest of the local-LLM + * stack already knows how to download, activate and serve. + * + * Everything here is derived from two public JSON endpoints: + * - `GET /api/models?search=…&filter=gguf` — repo search + * - `GET /api/models//tree/` — file listing + sizes + * + * Gated / private repos work when `HF_TOKEN` (or + * `HUGGING_FACE_HUB_TOKEN`) is exported — the same token is attached by + * `downloadFile` for the actual GGUF fetch. + */ + +import type { LocalModelDef, LocalModelId } from "./models-catalog.js"; + +const HF_API = "https://huggingface.co/api"; +const HF_HOSTS = new Set(["huggingface.co", "www.huggingface.co", "hf.co"]); + +/** A repo (+ optional file) the user pointed at. */ +export interface HuggingFaceModelRef { + /** `owner/name`. */ + repoId: string; + /** Git revision — branch, tag or sha. Defaults to `main`. */ + revision: string; + /** Path of a specific `.gguf` inside the repo, when the user named one. */ + filePath: string | null; +} + +export interface HuggingFaceFile { + path: string; + sizeBytes: number; +} + +export interface HuggingFaceSearchHit { + repoId: string; + downloads: number; + likes: number; +} + +export function huggingFaceToken(): string | null { + const raw = ( + process.env.HF_TOKEN || + process.env.HUGGING_FACE_HUB_TOKEN || + "" + ).trim(); + return raw.length > 0 ? raw : null; +} + +/** + * Strip a copied `hf`/`huggingface-cli` command down to its argument, and + * drop any trailing flags (`--local-dir …`). People paste the whole line + * off a model card, not just the reference inside it. + */ +function stripDownloadCommand(raw: string): string { + return raw + .replace(/^(?:hf|huggingface-cli|huggingface_hub)\s+download\s+/i, "") + .split(/\s+--/)[0]! + .trim(); +} + +/** + * `hf://owner/repo[@revision]/path/to/file.gguf` — the `HfFileSystem` + * scheme the `hf` CLI accepts. + * + * Parsed by hand rather than with `new URL`: that would put `owner` in + * the host slot and lowercase it, and Hugging Face owners are + * case-sensitive (`Qwen/…` is not `qwen/…`). + */ +function parseHfSchemeRef(raw: string): HuggingFaceModelRef { + const segments = raw.replace(/^hf:\/\//i, "").split("/").filter(Boolean); + const head = segments[0]?.toLowerCase(); + if (head === "datasets" || head === "spaces") { + throw new Error( + `hf://${head}/… is a ${head.replace(/s$/, "")}, not a model repo`, + ); + } + if (head === "models") segments.shift(); + const [owner, repoAndRevision, ...fileSegments] = segments; + if (!owner || !repoAndRevision) { + throw new Error( + `hf:// reference is missing /: ${JSON.stringify(raw)}`, + ); + } + // `repo@refs/pr/1` — the revision runs to the first path separator, so + // anything after the `@` on this segment plus following segments up to + // the filename would be ambiguous; only the simple `repo@rev` form is + // supported, which is what the CLI prints. + const at = repoAndRevision.lastIndexOf("@"); + const name = at > 0 ? repoAndRevision.slice(0, at) : repoAndRevision; + const revision = at > 0 ? repoAndRevision.slice(at + 1) : "main"; + return { + repoId: `${owner}/${name}`, + revision: revision || "main", + filePath: fileSegments.length > 0 ? fileSegments.join("/") : null, + }; +} + +/** + * Parse anything a user is likely to paste: + * https://huggingface.co//resolve//.gguf + * https://huggingface.co//blob//.gguf + * https://huggingface.co//tree/ + * https://huggingface.co/ + * hf.co/ + * hf://[@rev]/.gguf + * hf download hf:///.gguf (command pasted whole) + * hf download .gguf + * / + * + * Throws `Error` with a user-facing message on anything else — callers + * use that to distinguish "this is a reference" from "this is a search + * query", so it must keep rejecting free text. + */ +export function parseHuggingFaceModelRef(raw: string): HuggingFaceModelRef { + const command = stripDownloadCommand(raw.trim()); + const trimmed = command.replace(/[?#].*$/, ""); + if (trimmed.length === 0) throw new Error("empty model reference"); + + if (/^hf:\/\//i.test(trimmed)) return parseHfSchemeRef(trimmed); + + // ` ` — the two-argument `hf download` form. Narrow on + // purpose: the first token must look like a repo id, so an ordinary + // two-word search ("qwen3 coder") still falls through and throws. + const tokens = trimmed.split(/\s+/); + if ( + tokens.length === 2 && + /^[\w.-]+\/[\w.-]+$/.test(tokens[0]!) && + /\.gguf$/i.test(tokens[1]!) + ) { + return { repoId: tokens[0]!, revision: "main", filePath: tokens[1]! }; + } + + const bare = /^[\w.-]+\/[\w.-]+$/.exec(trimmed); + if (bare) { + return { repoId: trimmed, revision: "main", filePath: null }; + } + + let url: URL; + try { + url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`); + } catch { + throw new Error( + `not a Hugging Face model URL or / id: ${JSON.stringify(raw)}`, + ); + } + if (!HF_HOSTS.has(url.hostname.toLowerCase())) { + throw new Error(`not a huggingface.co URL: ${JSON.stringify(raw)}`); + } + // Optional `/models/` prefix used by some share links. + const segs = url.pathname.split("/").filter(Boolean); + if (segs[0] === "models") segs.shift(); + const [owner, name, verb, revision, ...rest] = segs; + if (!owner || !name) { + throw new Error(`URL is missing /: ${JSON.stringify(raw)}`); + } + const repoId = `${owner}/${name}`; + if (verb === "resolve" || verb === "blob") { + const filePath = rest.join("/"); + if (!filePath) { + throw new Error(`URL is missing a file path: ${JSON.stringify(raw)}`); + } + return { repoId, revision: revision || "main", filePath }; + } + if (verb === "tree") { + return { repoId, revision: revision || "main", filePath: null }; + } + return { repoId, revision: "main", filePath: null }; +} + +/** + * True when text is *unambiguously* a Hugging Face reference — it names + * the host, the `hf://` scheme, or a pasted `hf download` command. + * + * Deliberately stricter than `parseHuggingFaceModelRef`, which also + * accepts a bare `owner/name`: `192.168.1.5/api` satisfies that shape + * and is far more likely to be a llama-server address. Callers that must + * choose between "add this model" and "set this base URL" use this, so + * the two never drift apart the way a second inline regex would. + */ +export function looksLikeHuggingFaceReference(text: string): boolean { + const trimmed = text.trim(); + return ( + /^hf:\/\//i.test(trimmed) || + /^(?:hf|huggingface-cli|huggingface_hub)\s+download\s+/i.test(trimmed) || + /(?:^|\/\/|\.)(?:huggingface\.co|hf\.co)(?:$|[/:])/i.test(trimmed) + ); +} + +async function fetchHfJson(path: string, timeoutMs = 15_000): Promise { + const token = huggingFaceToken(); + const res = await fetch(`${HF_API}${path}`, { + headers: { + "User-Agent": "atomic-agent/local-llm", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (res.status === 401 || res.status === 403) { + // HF returns 401 for private *and* nonexistent repos — it does not + // leak which — so the message has to cover both. + throw new Error( + `Hugging Face returned ${res.status} — no such repo, or it is gated/private. ` + + `Check the id; if it is gated, accept its licence on huggingface.co and export HF_TOKEN.`, + ); + } + if (res.status === 404) { + throw new Error("Hugging Face returned 404 — no such model repo/revision."); + } + if (!res.ok) { + throw new Error(`Hugging Face API error: HTTP ${res.status} ${res.statusText}`); + } + return res.json(); +} + +/** Search GGUF-tagged model repos, most-downloaded first. */ +export async function searchHuggingFaceGgufModels( + query: string, + limit = 20, +): Promise { + const q = query.trim(); + if (q.length === 0) throw new Error("empty search query"); + const params = new URLSearchParams({ + search: q, + filter: "gguf", + sort: "downloads", + direction: "-1", + limit: String(limit), + }); + const raw = await fetchHfJson(`/models?${params.toString()}`); + if (!Array.isArray(raw)) return []; + return raw.flatMap((entry) => { + const o = entry as Record; + const repoId = typeof o.id === "string" ? o.id : null; + if (!repoId) return []; + return [ + { + repoId, + downloads: typeof o.downloads === "number" ? o.downloads : 0, + likes: typeof o.likes === "number" ? o.likes : 0, + }, + ]; + }); +} + +/** List the `.gguf` files in a repo revision, with real (LFS) sizes. */ +export async function listHuggingFaceGgufFiles( + repoId: string, + revision = "main", +): Promise { + const raw = await fetchHfJson( + `/models/${repoId}/tree/${encodeURIComponent(revision)}?recursive=true`, + ); + if (!Array.isArray(raw)) return []; + return raw.flatMap((entry) => { + const o = entry as Record; + const path = typeof o.path === "string" ? o.path : null; + if (!path || !path.toLowerCase().endsWith(".gguf")) return []; + const lfs = o.lfs as Record | undefined; + const size = + typeof lfs?.size === "number" + ? lfs.size + : typeof o.size === "number" + ? o.size + : 0; + return [{ path, sizeBytes: size }]; + }); +} + +export function isMmprojFile(path: string): boolean { + return /(^|\/)mmproj[^/]*\.gguf$/i.test(path); +} + +/** + * Multi-part GGUFs are named `…-00001-of-00003.gguf`; llama.cpp loads + * the whole set when pointed at the FIRST shard, so later shards are + * never selectable on their own. + * + * ponytail: we only *select* the first shard — the downloader still + * fetches a single file, so sharded repos need the remaining parts + * pulled by hand. Fetch the full set here if sharded repos become common. + */ +function isNonFirstShard(path: string): boolean { + const m = /-(\d{5})-of-\d{5}\.gguf$/i.exec(path); + return m !== null && m[1] !== "00001"; +} + +const QUANT_PREFERENCE = [ + "q4_k_xl", + "q4_k_m", + "q4_k_s", + "q4_0", + "q5_k_m", + "q8_0", +]; + +/** + * Pick the weights file when the user named a repo but not a file: + * the best-known 4-bit quant, else the smallest remaining candidate + * (small is the safer default — it loads on more machines). + */ +export function pickDefaultGgufFile( + files: readonly HuggingFaceFile[], +): HuggingFaceFile | null { + const candidates = files.filter( + (f) => !isMmprojFile(f.path) && !isNonFirstShard(f.path), + ); + if (candidates.length === 0) return null; + for (const quant of QUANT_PREFERENCE) { + const hit = candidates.find((f) => f.path.toLowerCase().includes(quant)); + if (hit) return hit; + } + return [...candidates].sort((a, b) => a.sizeBytes - b.sizeBytes)[0]!; +} + +/** Smallest mmproj projector in the repo, when one exists. */ +export function pickMmprojFile( + files: readonly HuggingFaceFile[], +): HuggingFaceFile | null { + const projectors = files.filter((f) => isMmprojFile(f.path)); + if (projectors.length === 0) return null; + return [...projectors].sort((a, b) => a.sizeBytes - b.sizeBytes)[0]!; +} + +const BYTES_PER_GB = 1024 * 1024 * 1024; + +export function resolveHuggingFaceFileUrl( + repoId: string, + revision: string, + filePath: string, +): string { + const encoded = filePath.split("/").map(encodeURIComponent).join("/"); + return `https://huggingface.co/${repoId}/resolve/${encodeURIComponent(revision)}/${encoded}`; +} + +/** + * Stable, filesystem-safe id for a user-added model. `/models//` + * is created verbatim from this, so it must survive Windows path rules — + * hence the aggressive character filter. + */ +export function buildCustomModelId( + repoId: string, + filePath: string, +): LocalModelId { + const base = filePath.split("/").pop()!.replace(/\.gguf$/i, ""); + const slug = `${repoId}-${base}` + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-|-$/g, ""); + return `custom-${slug.slice(0, 80)}`; +} + +function formatSize(bytes: number): string { + if (bytes <= 0) return "unknown"; + const gb = bytes / BYTES_PER_GB; + return gb >= 1 ? `${gb.toFixed(1)} GB` : `${Math.round(bytes / (1024 * 1024))} MB`; +} + +/** + * Assemble the catalog entry. Metadata the curated catalog hand-writes + * (context window, RAM envelope) is not exposed by the HF API without + * parsing GGUF headers, so it is estimated: + * + * ponytail: RAM bounds are weights × 1.2 / × 1.5 + 2 GB, and + * `maxContextLength: 0` hands the context decision to + * `resolveEffectiveContextSize` (fit-to-VRAM auto). Both are advisory — + * read the real values from the GGUF header if the estimates mislead. + */ +export function buildCustomModelDef(input: { + repoId: string; + revision: string; + file: HuggingFaceFile; + mmproj: HuggingFaceFile | null; +}): LocalModelDef { + const { repoId, revision, file, mmproj } = input; + const fileSizeGb = file.sizeBytes / BYTES_PER_GB; + const filename = file.path.split("/").pop()!; + return { + id: buildCustomModelId(repoId, file.path), + name: `${repoId} · ${filename}`, + filename, + huggingFaceUrl: resolveHuggingFaceFileUrl(repoId, revision, file.path), + fileSizeGb, + sizeLabel: formatSize(file.sizeBytes), + description: `Custom model from huggingface.co/${repoId}`, + maxContextLength: 0, + contextLabel: "auto", + minRamGb: Math.max(1, Math.ceil(fileSizeGb * 1.2)), + recommendedRamGb: Math.max(2, Math.ceil(fileSizeGb * 1.5) + 2), + family: "custom", + supportsVision: mmproj !== null, + ...(mmproj + ? { + mmprojUrl: resolveHuggingFaceFileUrl(repoId, revision, mmproj.path), + mmprojFilename: mmproj.path.split("/").pop()!, + mmprojFileSizeGb: mmproj.sizeBytes / BYTES_PER_GB, + } + : {}), + }; +} + +/** + * End-to-end: pasted URL / repo id → catalog entry ready to download. + * Resolves the weights file (explicit, or best-guess quant) and any + * mmproj projector sitting next to it in the same repo. + */ +export async function resolveCustomModelFromHuggingFace( + reference: string, +): Promise { + const ref = parseHuggingFaceModelRef(reference); + const files = await listHuggingFaceGgufFiles(ref.repoId, ref.revision); + if (files.length === 0) { + throw new Error( + `no .gguf files in huggingface.co/${ref.repoId} @ ${ref.revision} — ` + + `pick a GGUF conversion of the model (usually a "-GGUF" repo).`, + ); + } + let file: HuggingFaceFile | null; + if (ref.filePath) { + file = files.find((f) => f.path === ref.filePath) ?? null; + if (!file) { + throw new Error( + `${ref.filePath} not found in ${ref.repoId} @ ${ref.revision}`, + ); + } + if (isMmprojFile(file.path)) { + throw new Error( + `${ref.filePath} is an mmproj projector, not model weights — ` + + `paste the repo URL instead and the projector is picked up automatically.`, + ); + } + } else { + file = pickDefaultGgufFile(files); + if (!file) { + throw new Error(`no usable .gguf weights in ${ref.repoId}`); + } + } + return buildCustomModelDef({ + repoId: ref.repoId, + revision: ref.revision, + file, + mmproj: pickMmprojFile(files), + }); +} diff --git a/src/local-llm/index.ts b/src/local-llm/index.ts index 3a1d129..d09938b 100644 --- a/src/local-llm/index.ts +++ b/src/local-llm/index.ts @@ -3,6 +3,9 @@ export { DEFAULT_LLAMACPP_MODEL_ID, getLocalModelDef, isKnownLocalModelId, + listLocalModels, + listCustomLocalModels, + setCustomLocalModels, EMBEDDING_MODELS_CATALOG, DEFAULT_EMBEDDING_MODEL_ID, getEmbeddingModelDef, @@ -59,6 +62,21 @@ export { removeEmbeddingModel, } from "./model-installer.js"; export { resolveChatTemplatePath } from "./chat-templates.js"; +export { + buildCustomModelDef, + buildCustomModelId, + listHuggingFaceGgufFiles, + looksLikeHuggingFaceReference, + parseHuggingFaceModelRef, + pickDefaultGgufFile, + pickMmprojFile, + resolveCustomModelFromHuggingFace, + resolveHuggingFaceFileUrl, + searchHuggingFaceGgufModels, + type HuggingFaceFile, + type HuggingFaceModelRef, + type HuggingFaceSearchHit, +} from "./huggingface.js"; export { parseListDevices, pickBestDevice, diff --git a/src/local-llm/models-catalog.ts b/src/local-llm/models-catalog.ts index 5055205..daabe01 100644 --- a/src/local-llm/models-catalog.ts +++ b/src/local-llm/models-catalog.ts @@ -3,7 +3,7 @@ * desktop local LLM models; `family` replaces UI-only icon fields. */ -export type LocalModelId = +export type CuratedLocalModelId = | "qwen-3.5-4b" | "qwen-3.5-9b" | "qwen-3.5-35b" @@ -14,6 +14,15 @@ export type LocalModelId = | "gemma-4-26b-a4b" | "gemma-4-31b"; +/** + * Chat model identifier: a curated catalog entry, or a user-added model + * pulled from an arbitrary Hugging Face repo (`custom-`, minted by + * `buildCustomModelId`). The `custom-` prefix is load-bearing — it keeps + * the typed wall against `EmbeddingModelId` intact (no embedding id can + * ever satisfy this type) while leaving the id space open. + */ +export type LocalModelId = CuratedLocalModelId | `custom-${string}`; + /** * Memory-v2 phase 1B. Embedding model identifiers. A separate union * from `LocalModelId` so the type system prevents an embedding-only @@ -42,7 +51,7 @@ export interface LocalModelDef { contextLabel: string; minRamGb: number; recommendedRamGb: number; - family: "qwen" | "gemma"; + family: "qwen" | "gemma" | "custom"; chatTemplateAsset?: string; tag?: string; /** @@ -253,14 +262,39 @@ export const LOCAL_MODELS_CATALOG: readonly LocalModelDef[] = [ export const DEFAULT_LLAMACPP_MODEL_ID: LocalModelId = "qwen-3.5-4b"; +/** + * User-added models (`localModels.customModels` in the user config), + * mirrored here so every existing catalog consumer — daemon start, the + * installer, the TUI rows, the CLI — resolves them through the same + * `getLocalModelDef` / `isKnownLocalModelId` pair. + * + * Populated by `loadConfig()`; a module-level registry rather than a + * `getConfig()` call because `config-schema` imports this file and the + * cycle would be worse than the mutable module state. + */ +let customModels: readonly LocalModelDef[] = []; + +export function setCustomLocalModels(defs: readonly LocalModelDef[]): void { + customModels = defs; +} + +export function listCustomLocalModels(): readonly LocalModelDef[] { + return customModels; +} + +/** Curated catalog + user-added models, in that order. */ +export function listLocalModels(): readonly LocalModelDef[] { + return [...LOCAL_MODELS_CATALOG, ...customModels]; +} + export function getLocalModelDef(id: LocalModelId): LocalModelDef { - const found = LOCAL_MODELS_CATALOG.find((m) => m.id === id); + const found = listLocalModels().find((m) => m.id === id); if (!found) throw new Error(`unknown local model id: ${id}`); return found; } export function isKnownLocalModelId(raw: string): raw is LocalModelId { - return LOCAL_MODELS_CATALOG.some((m) => m.id === raw); + return listLocalModels().some((m) => m.id === raw); } /** diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index 7286e7d..9ff73f3 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -1,4 +1,5 @@ import type { TuiAction } from "../tui-action.js"; +import { looksLikeHuggingFaceReference } from "../../local-llm/index.js"; import { normalizeLocalLlmBaseUrl } from "../persist-user-local-models-config.js"; import { isThemeName, THEME_NAMES } from "../theme/theme.js"; import { parseSlashCommand } from "./slash-command-parser.js"; @@ -53,6 +54,10 @@ export interface SlashDispatchResult { readonly skillHubInstallId?: string; readonly localModelsPullModelId?: string; readonly localModelsUseModelId?: string; + /** `/models add ` — register a custom Hugging Face model. */ + readonly localModelsAddRef?: string; + /** `/models search ` — list GGUF repos on Hugging Face. */ + readonly localModelsSearchQuery?: string; readonly triggerLocalModelsStatus?: boolean; /** * Theme name the caller should activate via `setActiveTheme` before @@ -274,6 +279,8 @@ function pureActions( skillDisableName: undefined, localModelsPullModelId: undefined, localModelsUseModelId: undefined, + localModelsAddRef: undefined, + localModelsSearchQuery: undefined, triggerLocalModelsStatus: false, setThemeName: undefined, telegramVerb: undefined, @@ -316,6 +323,8 @@ function dispatchThemeSub(rawArgs: string): SlashDispatchResult { * - `pull ` — open the tab and kick off a pull for the given id. * - `use ` — open the tab and set the given id as active. * - `status` — emit the managed-runtime status line in the feed. + * - `search ` — list matching GGUF repos on Hugging Face. + * - `add ` — register any Hugging Face GGUF as a custom model. * - `` — persist the base URL for external mode (back-compat). */ function dispatchModelsSub(rawArgs: string, commandName: string): SlashDispatchResult { @@ -353,13 +362,37 @@ function dispatchModelsSub(rawArgs: string, commandName: string): SlashDispatchR if (bits[0] === "status") { return pureActions([], { triggerLocalModelsStatus: true }); } + if (bits[0] === "search" && bits.length > 1) { + return pureActions([], { localModelsSearchQuery: bits.slice(1).join(" ") }); + } + // A pasted Hugging Face reference is routed to `add` with or without the + // verb — it is never a llama-server base URL, so the back-compat branch + // below would otherwise silently misconfigure the runtime. The predicate + // lives next to the parser so the two cannot drift apart. + const addRef = + bits[0] === "add" && bits.length > 1 + ? bits.slice(1).join(" ") + : looksLikeHuggingFaceReference(argPart) + ? argPart + : null; + if (addRef) { + return { + ...pureActions([ + { type: "ui_mode_set", mode: "debug" }, + { type: "tab_changed", tab: "llm" }, + { type: "llm_focus_set", focus: "local" }, + ]), + localModelsAddRef: addRef, + }; + } try { const url = normalizeLocalLlmBaseUrl(argPart); return { ...pureActions([]), persistLlamaUrl: url }; } catch { return pureActions([], { systemMessage: - "usage: /models | /models pull | /models use | /models status | /models ", + "usage: /models | /models pull | /models use | /models status\n" + + " /models search | /models add | /models ", }); } } diff --git a/src/tui/components/llm-mode-rows.tsx b/src/tui/components/llm-mode-rows.tsx index e7d3eab..547de69 100644 --- a/src/tui/components/llm-mode-rows.tsx +++ b/src/tui/components/llm-mode-rows.tsx @@ -38,6 +38,9 @@ export function LlmModeRows({ function sectionTitle(kind: LlmPanelRow["kind"]): string { switch (kind) { case "localTextModel": + // Shares the text-models heading on purpose so it renders as the last + // row of that section rather than starting a section of its own. + case "localAddHuggingFace": return "Local text models"; case "localEmbeddingModel": return "Local embeddings"; @@ -122,7 +125,9 @@ function LocalRows({ rows: readonly LlmPanelRow[]; state: TuiState; }): ReactElement { - const textRows = rows.filter((row) => row.kind === "localTextModel"); + const textRows = rows.filter( + (row) => row.kind === "localTextModel" || row.kind === "localAddHuggingFace", + ); const embeddingRows = rows.filter((row) => row.kind === "localEmbeddingModel"); return ( @@ -223,6 +228,8 @@ function renderRowText(row: LlmPanelRow, state: TuiState): string { switch (row.kind) { case "localTextModel": return `${row.model.id} ${row.model.def.sizeLabel} [${localModelStatus(row.model)}]`; + case "localAddHuggingFace": + return "+ Add a model from Hugging Face…"; case "localEmbeddingModel": return `${row.model.id} ${row.model.def.sizeLabel} [${row.model.downloaded ? "downloaded" : "remote"}]`; case "localDaemon": diff --git a/src/tui/components/llm-panel-frame-height.test.tsx b/src/tui/components/llm-panel-frame-height.test.tsx new file mode 100644 index 0000000..66360aa --- /dev/null +++ b/src/tui/components/llm-panel-frame-height.test.tsx @@ -0,0 +1,177 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { LOCAL_MODELS_CATALOG } from "../../local-llm/index.js"; +import { extractLoadFailure } from "../../local-llm/daemon-lifecycle.js"; +import { createInitialTuiState, type TuiState } from "../tui-state.js"; +import { fakeSession } from "../test-fixtures.js"; +import { LlmPanel } from "./llm-panel.js"; + +/** + * Ink overlaps lines when a frame is taller than its budget — it does not + * clip — so a panel that overruns `maxRows` renders as visible garbage. + * These are regression tests for frames that used to overrun. + */ +function frameHeight(node: Parameters[0]): number { + return (render(node).lastFrame() ?? "").split("\n").length; +} + +/** A real `llama-server` failure: one sentence plus a 4KB log tail. */ +const MULTILINE_DAEMON_ERROR = [ + "llama-server did not become healthy within 30000ms. Log tail:", + ...Array.from( + { length: 24 }, + (_, i) => `0.00.${i} E llama_model_load: error loading model: unknown model architecture: 'nanbeige'`, + ), +].join("\n"); + +function baseState(over: Partial = {}): TuiState { + const base = createInitialTuiState(fakeSession()); + return { + ...base, + uiMode: "debug", + activeTab: "llm", + llmPanel: { ...base.llmPanel, mode: "local" }, + localModelsPanel: { + ...base.localModelsPanel, + totalRamGb: 64, + rows: LOCAL_MODELS_CATALOG.map((def) => ({ + id: def.id, + def, + downloaded: false, + active: false, + mmprojStatus: "missing" as const, + })), + ...over, + }, + }; +} + +describe("LlmPanel frame height", () => { + const MAX = 24; + + it("stays within budget with a multi-line daemon error", () => { + expect( + frameHeight( + , + ), + ).toBeLessThanOrEqual(MAX); + }); + + it("stays within budget with a multi-line catalog error", () => { + expect( + frameHeight( + , + ), + ).toBeLessThanOrEqual(MAX); + }); + + it("stays within budget while the Hugging Face prompt is open", () => { + const base = baseState(); + const state: TuiState = { + ...base, + llmPanel: { + ...base.llmPanel, + huggingFacePrompt: { + buffer: "qwen3 coder", + busy: false, + error: null, + results: Array.from({ length: 9 }, (_, i) => ({ + repoId: `owner/Repo-${i}-GGUF`, + downloads: 1000 - i, + })), + }, + }, + }; + expect(frameHeight()).toBeLessThanOrEqual( + MAX, + ); + }); + + it("stays within budget with the prompt open AND a daemon error", () => { + const base = baseState({ daemonError: MULTILINE_DAEMON_ERROR }); + const state: TuiState = { + ...base, + llmPanel: { + ...base.llmPanel, + huggingFacePrompt: { + buffer: "qwen3 coder", + busy: false, + error: null, + results: Array.from({ length: 9 }, (_, i) => ({ + repoId: `owner/Repo-${i}-GGUF`, + downloads: 1000 - i, + })), + }, + }, + }; + expect(frameHeight()).toBeLessThanOrEqual( + MAX, + ); + }); + + it("stays within budget with active download banners", () => { + const pull = (kind: "chat" | "embedding", id: string) => ({ + kind, + modelId: id as never, + label: id, + percent: 40, + transferredBytes: 1, + totalBytes: 2, + error: null, + }); + // The banner group emits one shared bottom margin on top of three + // rows per banner — an easy row to forget in the estimate. + expect( + frameHeight( + , + ), + ).toBeLessThanOrEqual(MAX); + expect( + frameHeight( + , + ), + ).toBeLessThanOrEqual(MAX); + }); + + it("stays within budget while the daemon is starting", () => { + expect( + frameHeight(), + ).toBeLessThanOrEqual(MAX); + }); + + it("still shows the first line of the error, and points at the log tab", () => { + const frame = + render( + , + ).lastFrame() ?? ""; + expect(frame).toContain("did not become healthy within 30000ms"); + expect(frame).not.toContain("unknown model architecture"); + }); +}); + +describe("extractLoadFailure", () => { + it("names the architecture the build cannot read", () => { + expect( + extractLoadFailure( + "0.00.195 E llama_model_load: error loading model: unknown model architecture: 'nanbeige'\n" + + "0.00.195 E llama_model_load_from_file_impl: failed to load model\n", + ), + ).toBe("unknown model architecture: 'nanbeige'"); + }); + + it("falls back through less specific lines, then to null", () => { + expect(extractLoadFailure("E error loading model: bad magic")).toBe( + "error loading model: bad magic", + ); + expect(extractLoadFailure("srv failed to load model, '/x/y.gguf'")).toContain( + "failed to load model", + ); + expect(extractLoadFailure("nothing interesting here")).toBeNull(); + }); +}); diff --git a/src/tui/components/llm-panel-huggingface-render.test.tsx b/src/tui/components/llm-panel-huggingface-render.test.tsx new file mode 100644 index 0000000..b360512 --- /dev/null +++ b/src/tui/components/llm-panel-huggingface-render.test.tsx @@ -0,0 +1,89 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { LOCAL_MODELS_CATALOG } from "../../local-llm/index.js"; +import { createInitialTuiState, type TuiState } from "../tui-state.js"; +import { fakeSession } from "../test-fixtures.js"; +import { LlmPanel } from "./llm-panel.js"; + +function stateWithCatalog(overrides: Partial = {}): TuiState { + const base = createInitialTuiState(fakeSession()); + return { + ...base, + uiMode: "debug", + activeTab: "llm", + llmPanel: { ...base.llmPanel, mode: "local", ...overrides }, + localModelsPanel: { + ...base.localModelsPanel, + totalRamGb: 64, + rows: LOCAL_MODELS_CATALOG.slice(0, 2).map((def) => ({ + id: def.id, + def, + downloaded: false, + active: false, + mmprojStatus: "missing" as const, + })), + }, + }; +} + +describe("LlmPanel — Hugging Face affordance", () => { + it("renders the add row as the last entry under Local text models", () => { + const { lastFrame } = render(); + const lines = (lastFrame() ?? "").split("\n").map((l) => l.trim()); + const addIdx = lines.findIndex((l) => l.includes("Add a model from Hugging Face")); + const headerIdx = lines.findIndex((l) => l.includes("Local text models")); + const embeddingIdx = lines.findIndex((l) => l.includes("Local embeddings")); + + expect(addIdx).toBeGreaterThan(headerIdx); + expect(addIdx).toBeLessThan(embeddingIdx); + expect(lines[addIdx]).toContain("Enter: paste a URL or search"); + // Directly under the last model, not floated to the section top. + expect(lines[addIdx - 1]).toContain(LOCAL_MODELS_CATALOG[1]!.id); + }); + + it("renders the prompt with a search result pick list", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Add a model from Hugging Face"); + expect(frame).toContain("qwen3 coder"); + expect(frame).toContain("press a number to add"); + expect(frame).toContain("1 unsloth/Qwen3-Coder-30B-GGUF"); + expect(frame).toContain("2 Qwen/Qwen3-4B-GGUF"); + expect(frame).toContain("Enter submit"); + }); + + it("surfaces a resolution error inside the prompt", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + // The typed text survives the error so it can be corrected in place. + expect(frame).toContain("Qwen/Qwen3-8B"); + expect(frame).toContain("no .gguf files"); + }); +}); diff --git a/src/tui/components/llm-panel-modals.tsx b/src/tui/components/llm-panel-modals.tsx index 30e65c4..45ae4aa 100644 --- a/src/tui/components/llm-panel-modals.tsx +++ b/src/tui/components/llm-panel-modals.tsx @@ -1,10 +1,13 @@ import { Box, Text } from "ink"; -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import { ProvidersWizard } from "./providers-wizard.js"; export function LlmPanelModals({ state }: { state: TuiState }): ReactElement | null { + if (state.llmPanel.huggingFacePrompt) { + return ; + } if (state.providersPanel.wizard) { return ; } @@ -56,6 +59,52 @@ export function LlmPanelModals({ state }: { state: TuiState }): ReactElement | n return null; } +/** + * One field, two intents: a URL / `owner/name` is resolved and added on + * Enter; anything else is searched and comes back as a numbered pick list. + */ +function HuggingFacePrompt({ + prompt, +}: { + prompt: NonNullable; +}): ReactElement { + return ( + + + Paste a model URL, hf://…, an `hf download` command, or owner/name + — or type words to search. + + + {"> "} + {prompt.buffer} + {prompt.busy ? "" : "▌"} + + {prompt.busy ? ( + working… + ) : null} + {prompt.error ? {prompt.error} : null} + {prompt.results.length > 0 ? ( + + press a number to add: + {prompt.results.map((hit, i) => ( + + + {" "} + {i + 1} + {" "} + {hit.repoId}{" "} + + ({hit.downloads.toLocaleString()} downloads) + + + ))} + + ) : null} + Enter submit · Esc cancel + + ); +} + function PromptBox({ tone, title, @@ -63,7 +112,7 @@ function PromptBox({ }: { tone: "accent" | "danger"; title: string; - children: ReactElement | readonly ReactElement[]; + children: ReactNode; }): ReactElement { const color = tone === "danger" ? theme.colors.error : theme.colors.accentSoft; return ( diff --git a/src/tui/components/llm-panel.tsx b/src/tui/components/llm-panel.tsx index 4a094dd..d8b372e 100644 --- a/src/tui/components/llm-panel.tsx +++ b/src/tui/components/llm-panel.tsx @@ -6,7 +6,10 @@ import { selectLlmActiveRouteSummary, selectLlmPanelRows, } from "../llm-panel/llm-panel-selectors.js"; -import type { LocalModelsPanelState } from "../local-models/local-models-panel-state.js"; +import { + toStatusLine, + type LocalModelsPanelState, +} from "../local-models/local-models-panel-state.js"; import { LlmModeRows } from "./llm-mode-rows.js"; import { LlmPanelModals } from "./llm-panel-modals.js"; @@ -29,6 +32,57 @@ const COMPACT_HEADER_ROWS = 3; */ const FULL_HEADER_MIN_LIST = 3; +/** + * Height of whatever overlay is currently drawn above the list. + * `LlmPanelModals` early-returns, so at most one modal is ever open — + * hence the if/else chain rather than a sum. + * + * ponytail: these are static estimates, not measurements. Ink can only + * measure after layout, which is too late for a budget decision, so the + * numbers are deliberately generous and the list absorbs the slack. + * Keep them in step when a modal's markup changes; the frame-height + * tests fail loudly if one drifts. + */ +function estimateOverlayRows(state: TuiState): number { + let rows = 0; + const hf = state.llmPanel.huggingFacePrompt; + if (hf) { + // border(2) + title + 2-line hint + input + "Enter submit" footer. + rows += 7; + if (hf.busy || hf.error) rows += 1; + if (hf.results.length > 0) rows += hf.results.length + 2; + } else if (state.providersPanel.wizard) { + rows += PROVIDERS_WIZARD_ROWS; + } else if ( + state.providersPanel.removeConfirm || + state.localModelsPanel.embeddingOnboardingPrompt || + state.localModelsPanel.removeConfirmId || + state.localModelsPanel.embeddingRemoveConfirmId || + state.llmPanel.stopLocalDaemonsPrompt + ) { + // border(2) + title + one wrapped body line + key hint. + rows += 6; + } + if (isDaemonStarting(state.localModelsPanel)) rows += STARTING_BANNER_ROWS; + if (state.llmPanel.mode === "local") { + const pulls = [ + state.localModelsPanel.pull, + state.localModelsPanel.embeddingPull, + ].filter((pull) => pull !== null); + // `+ 1` for the wrapper's bottom margin, which is emitted once for + // the whole group rather than per banner. + if (pulls.length > 0) rows += pulls.length * DOWNLOAD_BANNER_ROWS + 1; + } + return rows; +} + +/** Rows the providers wizard occupies; it is the tallest modal. */ +const PROVIDERS_WIZARD_ROWS = 14; +/** Title + hint + spacer emitted by `StartingBanner`. */ +const STARTING_BANNER_ROWS = 5; +/** Label + model line + progress bar per in-flight download. */ +const DOWNLOAD_BANNER_ROWS = 3; + export function LlmPanel({ state, maxRows = 12, @@ -43,9 +97,15 @@ export function LlmPanel({ // `maxRows` is the TOTAL budget for the tab content. Split it between // the fixed header and the (windowed) list, collapsing the verbose // RouteCard when the terminal is too short to afford it. - const useFull = maxRows >= FULL_HEADER_ROWS + FULL_HEADER_MIN_LIST; + // Modals and banners render ABOVE the list and are part of the same + // frame, so their height has to come out of the same budget. Leaving + // them unbudgeted is what lets a tall overlay push the frame past the + // terminal, and Ink overlaps lines rather than clipping them. + const overlayRows = estimateOverlayRows(state); + const useFull = + maxRows - overlayRows >= FULL_HEADER_ROWS + FULL_HEADER_MIN_LIST; const headerRows = useFull ? FULL_HEADER_ROWS : COMPACT_HEADER_ROWS; - const listBudget = Math.max(1, maxRows - headerRows); + const listBudget = Math.max(1, maxRows - headerRows - overlayRows); return ( @@ -69,10 +129,19 @@ export function LlmPanel({ - + {/* This panel — not the Models tab — is where the first-run wizard + lands after "Local models", so the Hugging Face escape hatch is + advertised here or nobody finds it. Folded into the existing + hint line rather than added as a second one: the panel runs its + compact footer at ordinary terminal heights, and an extra line + would overflow the budget (Ink garbles, it does not clip). */} + {useFull ? "j/k move · Enter selected action · ←/→ switch Local/Cloud · n add provider · c configure · r refresh" : "j/k · Enter · ←/→ mode · r"} + {state.llmPanel.mode === "local" + ? " · /models add adds any GGUF" + : ""} @@ -189,11 +258,14 @@ function StatusLines({ ) { lines.push("local catalog: loading"); } + // Flattened again at the point of render, not only on the way into + // state: this slot is one row tall by construction, and it must hold + // that whatever a future writer puts in the field. if (state.localModelsPanel.errorLine) { - lines.push(`local catalog: ${state.localModelsPanel.errorLine}`); + lines.push(`local catalog: ${toStatusLine(state.localModelsPanel.errorLine)}`); } if (state.localModelsPanel.daemonError) { - lines.push(`local daemon: ${state.localModelsPanel.daemonError}`); + lines.push(`local daemon: ${toStatusLine(state.localModelsPanel.daemonError)}`); } } else { if (state.providersPanel.busy) lines.push("cloud providers: updating"); @@ -203,7 +275,12 @@ function StatusLines({ } return ( - {lines[0] ?? "status: ready"} + {/* `truncate-end` is what actually guarantees one row: flattening + newlines is not enough, since a long single line wraps and + overruns the budget just as badly on a narrow terminal. */} + + {lines[0] ?? "status: ready"} + ); } diff --git a/src/tui/components/local-models-panel.tsx b/src/tui/components/local-models-panel.tsx index b1f789c..5069d41 100644 --- a/src/tui/components/local-models-panel.tsx +++ b/src/tui/components/local-models-panel.tsx @@ -12,6 +12,7 @@ import { type LocalModelRow, type LocalModelsPanelState, type RamFit, + toStatusLine, } from "../local-models/local-models-panel-state.js"; import type { LocalModelDef } from "../../local-llm/index.js"; @@ -79,11 +80,11 @@ interface LocalModelsPanelProps { /** * Rows consumed by the full status footer (top margin + mode line + - * chat daemon + embedding daemon + data-dir + hotkey hint). Collapsed to - * a 1-line daemon status + short hint when the window is short — Ink - * garbles a frame taller than the terminal, it does not clip it. + * chat daemon + embedding daemon + data-dir + hotkey hint + Hugging Face + * hint). Collapsed to a 1-line daemon status + short hint when the window + * is short — Ink garbles a frame taller than the terminal, not clips it. */ -const FULL_FOOTER_ROWS = 7; +const FULL_FOOTER_ROWS = 8; /** Rows consumed by the collapsed footer: daemon line + short hint. */ const COMPACT_FOOTER_ROWS = 3; /** Minimum list rows we want before bothering to keep the full footer. */ @@ -407,7 +408,9 @@ export function LocalModelsPanel({ {panel.errorLine ? ( - {panel.errorLine} + + {toStatusLine(panel.errorLine)} + ) : null} mode: {panel.configMode} @@ -419,7 +422,9 @@ export function LocalModelsPanel({ {renderDaemonLine(panel)} {renderEmbeddingDaemonLine(panel.embeddingDaemon)} {panel.daemonError ? ( - daemon: {panel.daemonError} + + daemon: {toStatusLine(panel.daemonError)} + ) : null} {panel.dataDir ? ( @@ -431,12 +436,18 @@ export function LocalModelsPanel({ j/k move · Enter pull/activate (embedding: *row + Enter starts server) · g gguf · i info · d remove · s chat+embedding · E embeddings on/off · G gpu · B · r · L + + not listed? /models search <query> · /models add + <huggingface-url> adds any GGUF + ) : ( {panel.errorLine ? ( - {panel.errorLine} + + {toStatusLine(panel.errorLine)} + ) : null} {renderDaemonLine(panel)} diff --git a/src/tui/llm-panel/llm-panel-actions.ts b/src/tui/llm-panel/llm-panel-actions.ts index ef2decc..4c99ac7 100644 --- a/src/tui/llm-panel/llm-panel-actions.ts +++ b/src/tui/llm-panel/llm-panel-actions.ts @@ -1,4 +1,8 @@ -import type { LlmPanelMode, LlmPanelSection } from "./llm-panel-state.js"; +import type { + LlmHuggingFaceHit, + LlmPanelMode, + LlmPanelSection, +} from "./llm-panel-state.js"; export type LlmPanelAction = | { type: "llm_mode_set"; mode: LlmPanelMode } @@ -7,18 +11,32 @@ export type LlmPanelAction = | { type: "llm_cursor_set"; cursor: number; mode?: LlmPanelMode } | { type: "llm_focus_set"; focus: LlmPanelSection; cursor?: number } | { type: "llm_stop_local_daemons_prompt_opened"; providerId: string } - | { type: "llm_stop_local_daemons_prompt_closed" }; + | { type: "llm_stop_local_daemons_prompt_closed" } + | { type: "llm_hf_prompt_opened" } + | { type: "llm_hf_prompt_buffer_changed"; buffer: string } + | { type: "llm_hf_prompt_busy_set"; busy: boolean } + | { type: "llm_hf_prompt_failed"; error: string } + | { type: "llm_hf_prompt_results_set"; results: readonly LlmHuggingFaceHit[] } + | { type: "llm_hf_prompt_closed" }; + +const LLM_PANEL_ACTION_TYPES: ReadonlySet = new Set([ + "llm_mode_set", + "llm_mode_set_to_active_route", + "llm_mode_toggled", + "llm_cursor_set", + "llm_focus_set", + "llm_stop_local_daemons_prompt_opened", + "llm_stop_local_daemons_prompt_closed", + "llm_hf_prompt_opened", + "llm_hf_prompt_buffer_changed", + "llm_hf_prompt_busy_set", + "llm_hf_prompt_failed", + "llm_hf_prompt_results_set", + "llm_hf_prompt_closed", +]); export function isLlmPanelAction( action: { type: string }, ): action is LlmPanelAction { - return ( - action.type === "llm_mode_set" || - action.type === "llm_mode_set_to_active_route" || - action.type === "llm_mode_toggled" || - action.type === "llm_cursor_set" || - action.type === "llm_focus_set" || - action.type === "llm_stop_local_daemons_prompt_opened" || - action.type === "llm_stop_local_daemons_prompt_closed" - ); + return LLM_PANEL_ACTION_TYPES.has(action.type); } diff --git a/src/tui/llm-panel/llm-panel-huggingface.test.ts b/src/tui/llm-panel/llm-panel-huggingface.test.ts new file mode 100644 index 0000000..bab0203 --- /dev/null +++ b/src/tui/llm-panel/llm-panel-huggingface.test.ts @@ -0,0 +1,174 @@ +import type { Key } from "ink"; +import { describe, expect, it, vi } from "vitest"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import { createInitialTuiState, type TuiState } from "../tui-state.js"; +import { fakeSession } from "../test-fixtures.js"; +import { handleLlmPanelKey } from "./llm-panel-key-bindings.js"; +import { reduceLlmPanelAction } from "./llm-panel-reducer.js"; +import { selectLlmPanelRows } from "./llm-panel-selectors.js"; + +function emptyKey(overrides: Partial = {}): Key { + return { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + ...overrides, + }; +} + +function callbacks(overrides: Partial = {}): TuiAppCallbacks { + return { + onApprovalDecision: vi.fn(), + onAbort: vi.fn(), + onQuit: vi.fn(), + onMessageSubmitted: vi.fn(), + ...overrides, + }; +} + +function baseState(): TuiState { + const base = createInitialTuiState(fakeSession()); + return { + ...base, + uiMode: "debug" as const, + activeTab: "llm" as const, + llmPanel: { ...base.llmPanel, mode: "local" as const }, + }; +} + +/** Drive a key through the panel and return the resulting state. */ +function press(state: TuiState, input: string, key: Key = emptyKey()) { + const dispatched: TuiAction[] = []; + const sent: string[] = []; + const handled = handleLlmPanelKey(input, key, { + state, + dispatch: (action) => dispatched.push(action), + callbacks: callbacks({ + onLlmHuggingFaceSubmitted: (text) => { + sent.push(text); + }, + }), + }); + let next = state; + for (const action of dispatched) next = reduceLlmPanelAction(next, action) ?? next; + return { handled, dispatched, next, sent }; +} + +describe("Hugging Face add row", () => { + it("is pinned as the last local text row, under the models", () => { + const rows = selectLlmPanelRows(baseState(), "local"); + const kinds = rows.map((row) => row.kind); + const addIndex = kinds.indexOf("localAddHuggingFace"); + expect(addIndex).toBeGreaterThanOrEqual(0); + // Every text model precedes it; every embedding row follows it. + expect(kinds.slice(0, addIndex).every((k) => k === "localTextModel")).toBe(true); + expect(kinds.slice(addIndex + 1).every((k) => k === "localEmbeddingModel")).toBe( + true, + ); + }); + + it("opens the prompt when Enter lands on the row", () => { + const rows = selectLlmPanelRows(baseState(), "local"); + const cursor = rows.findIndex((row) => row.kind === "localAddHuggingFace"); + const state = { + ...baseState(), + llmPanel: { ...baseState().llmPanel, localCursor: cursor }, + }; + const { next } = press(state, "", emptyKey({ return: true })); + expect(next.llmPanel.huggingFacePrompt).toEqual({ + buffer: "", + busy: false, + error: null, + results: [], + }); + }); +}); + +describe("Hugging Face prompt keys", () => { + function opened(overrides = {}): TuiState { + const base = baseState(); + return { + ...base, + llmPanel: { + ...base.llmPanel, + huggingFacePrompt: { + buffer: "", + busy: false, + error: null, + results: [], + ...overrides, + }, + }, + }; + } + + it("captures printable characters, including digits and slashes", () => { + let state = opened(); + for (const ch of "unsloth/Qwen3-8B-GGUF") state = press(state, ch).next; + expect(state.llmPanel.huggingFacePrompt?.buffer).toBe("unsloth/Qwen3-8B-GGUF"); + }); + + it("does not let panel hotkeys steal characters from the buffer", () => { + // `s`, `n`, `c`, `r`, `e` are all panel hotkeys outside the modal. + let state = opened(); + for (const ch of "scner") state = press(state, ch).next; + expect(state.llmPanel.huggingFacePrompt?.buffer).toBe("scner"); + }); + + it("backspaces and submits the trimmed buffer", () => { + let state = opened({ buffer: "org/repoX" }); + state = press(state, "", emptyKey({ backspace: true })).next; + expect(state.llmPanel.huggingFacePrompt?.buffer).toBe("org/repo"); + const { sent } = press(state, "", emptyKey({ return: true })); + expect(sent).toEqual(["org/repo"]); + }); + + it("ignores Enter on an empty buffer", () => { + const { sent } = press(opened(), "", emptyKey({ return: true })); + expect(sent).toEqual([]); + }); + + it("treats digits as a pick only when results are showing", () => { + const withResults = opened({ + buffer: "qwen", + results: [ + { repoId: "unsloth/Qwen3-8B-GGUF", downloads: 10 }, + { repoId: "Qwen/Qwen3-4B-GGUF", downloads: 5 }, + ], + }); + expect(press(withResults, "2").sent).toEqual(["Qwen/Qwen3-4B-GGUF"]); + // Out of range falls through to being typed. + expect(press(withResults, "7").next.llmPanel.huggingFacePrompt?.buffer).toBe( + "qwen7", + ); + // No results: a digit is just a character (repo names contain them). + expect(press(opened({ buffer: "Qwen" }), "3").next.llmPanel.huggingFacePrompt + ?.buffer).toBe("Qwen3"); + }); + + it("swallows keys while busy but still allows Esc", () => { + const busy = opened({ buffer: "org/repo", busy: true }); + expect(press(busy, "x").next.llmPanel.huggingFacePrompt?.buffer).toBe("org/repo"); + expect(press(busy, "", emptyKey({ return: true })).sent).toEqual([]); + expect( + press(busy, "", emptyKey({ escape: true })).next.llmPanel.huggingFacePrompt, + ).toBeNull(); + }); + + it("closes on Esc", () => { + const { next } = press(opened({ buffer: "abc" }), "", emptyKey({ escape: true })); + expect(next.llmPanel.huggingFacePrompt).toBeNull(); + }); +}); diff --git a/src/tui/llm-panel/llm-panel-key-bindings.test.ts b/src/tui/llm-panel/llm-panel-key-bindings.test.ts index 5ac66fa..8cb8f88 100644 --- a/src/tui/llm-panel/llm-panel-key-bindings.test.ts +++ b/src/tui/llm-panel/llm-panel-key-bindings.test.ts @@ -47,7 +47,9 @@ describe("handleLlmPanelKey", () => { callbacks: callbacks(), }); expect(handled).toBe(true); - expect(dispatched).toEqual([{ type: "llm_cursor_set", cursor: 0 }]); + // The fixture seeds one chat model; row 1 is the pinned + // "+ Add a model from Hugging Face…" call to action. + expect(dispatched).toEqual([{ type: "llm_cursor_set", cursor: 1 }]); }); it("switches panel modes without changing the active route", () => { diff --git a/src/tui/llm-panel/llm-panel-modal-key-bindings.ts b/src/tui/llm-panel/llm-panel-modal-key-bindings.ts index 2493b7f..dccd615 100644 --- a/src/tui/llm-panel/llm-panel-modal-key-bindings.ts +++ b/src/tui/llm-panel/llm-panel-modal-key-bindings.ts @@ -14,6 +14,52 @@ export function handleLlmModalKey( }, ): boolean | null { const { state, dispatch, callbacks } = ctx; + + // Checked first: this modal captures every printable character, so any + // handler above it would steal letters out of a pasted URL. + const hfPrompt = state.llmPanel.huggingFacePrompt; + if (hfPrompt) { + if (hfPrompt.busy) { + // Esc still aborts the wait; everything else is swallowed so keys + // typed during a lookup cannot queue up behind it. + if (key.escape) dispatch({ type: "llm_hf_prompt_closed" }); + return true; + } + if (key.escape) { + dispatch({ type: "llm_hf_prompt_closed" }); + return true; + } + // Digit picks apply only to a rendered result list — otherwise digits + // are ordinary characters in a repo name (`Qwen3-8B`). + if (hfPrompt.results.length > 0 && /^[1-9]$/.test(input)) { + const hit = hfPrompt.results[Number(input) - 1]; + if (hit) { + void callbacks.onLlmHuggingFaceSubmitted?.(hit.repoId); + return true; + } + } + if (key.return) { + const query = hfPrompt.buffer.trim(); + if (query.length > 0) void callbacks.onLlmHuggingFaceSubmitted?.(query); + return true; + } + if (key.backspace || key.delete) { + dispatch({ + type: "llm_hf_prompt_buffer_changed", + buffer: hfPrompt.buffer.slice(0, -1), + }); + return true; + } + if (input && input.length > 0 && !key.ctrl && !key.meta) { + dispatch({ + type: "llm_hf_prompt_buffer_changed", + buffer: hfPrompt.buffer + input, + }); + return true; + } + return true; + } + if (state.providersPanel.wizard !== null) { const result = handleProvidersWizardKey(input, key, state.providersPanel.wizard); if (!result.handled) return false; diff --git a/src/tui/llm-panel/llm-panel-primary-actions.ts b/src/tui/llm-panel/llm-panel-primary-actions.ts index 491d3e1..a80da69 100644 --- a/src/tui/llm-panel/llm-panel-primary-actions.ts +++ b/src/tui/llm-panel/llm-panel-primary-actions.ts @@ -24,6 +24,9 @@ export function triggerLlmPrimary( case "localEmbeddingModel": triggerLocalEmbeddingModel(row.model, state, callbacks); return; + case "localAddHuggingFace": + dispatch({ type: "llm_hf_prompt_opened" }); + return; case "localDaemon": triggerDaemonAction(state, callbacks); return; diff --git a/src/tui/llm-panel/llm-panel-reducer.ts b/src/tui/llm-panel/llm-panel-reducer.ts index b727656..b3f62e5 100644 --- a/src/tui/llm-panel/llm-panel-reducer.ts +++ b/src/tui/llm-panel/llm-panel-reducer.ts @@ -69,6 +69,73 @@ export function reduceLlmPanelAction( ...state, llmPanel: { ...panel, stopLocalDaemonsPrompt: null }, }; + case "llm_hf_prompt_opened": + return { + ...state, + llmPanel: { + ...panel, + huggingFacePrompt: { + buffer: "", + busy: false, + error: null, + results: [], + }, + }, + }; + case "llm_hf_prompt_buffer_changed": + if (!panel.huggingFacePrompt) return state; + return { + ...state, + llmPanel: { + ...panel, + huggingFacePrompt: { + ...panel.huggingFacePrompt, + buffer: action.buffer, + error: null, + }, + }, + }; + case "llm_hf_prompt_busy_set": + if (!panel.huggingFacePrompt) return state; + return { + ...state, + llmPanel: { + ...panel, + huggingFacePrompt: { ...panel.huggingFacePrompt, busy: action.busy }, + }, + }; + case "llm_hf_prompt_failed": + if (!panel.huggingFacePrompt) return state; + return { + ...state, + llmPanel: { + ...panel, + huggingFacePrompt: { + ...panel.huggingFacePrompt, + busy: false, + error: action.error, + }, + }, + }; + case "llm_hf_prompt_results_set": + if (!panel.huggingFacePrompt) return state; + return { + ...state, + llmPanel: { + ...panel, + huggingFacePrompt: { + ...panel.huggingFacePrompt, + busy: false, + error: null, + results: action.results, + }, + }, + }; + case "llm_hf_prompt_closed": + return { + ...state, + llmPanel: { ...panel, huggingFacePrompt: null }, + }; default: return state; } diff --git a/src/tui/llm-panel/llm-panel-row-builders.ts b/src/tui/llm-panel/llm-panel-row-builders.ts index 0794e91..4c93246 100644 --- a/src/tui/llm-panel/llm-panel-row-builders.ts +++ b/src/tui/llm-panel/llm-panel-row-builders.ts @@ -20,12 +20,25 @@ import type { LlmPanelRow } from "./llm-panel-selectors.js"; export function selectLocalRows(state: TuiState): readonly LlmPanelRow[] { const rows: LlmPanelRow[] = []; for (const model of state.localModelsPanel.rows) rows.push(localTextRow(state, model)); + // Pinned directly under the text models: the curated catalog is a + // starting point, not the whole choice, and this is the row that says so. + rows.push(addHuggingFaceRow()); for (const model of state.localModelsPanel.embeddingRows) { rows.push(localEmbeddingRow(state, model)); } return rows; } +function addHuggingFaceRow(): LlmPanelRow { + return { + kind: "localAddHuggingFace", + id: "local-add-huggingface", + mode: "local", + primaryAction: "add", + enterEffect: "Enter: paste a URL or search", + }; +} + export function selectCloudRows(state: TuiState): readonly LlmPanelRow[] { const providers = state.providersPanel.rows.filter( (row) => row.kind !== "llama-server", diff --git a/src/tui/llm-panel/llm-panel-selectors.test.ts b/src/tui/llm-panel/llm-panel-selectors.test.ts index 23af8b2..e4b2566 100644 --- a/src/tui/llm-panel/llm-panel-selectors.test.ts +++ b/src/tui/llm-panel/llm-panel-selectors.test.ts @@ -67,6 +67,8 @@ describe("llm-panel selectors", () => { expect(selectLlmPanelRows(state, "local").map((row) => row.kind)).toEqual([ "localTextModel", + // Pinned call to action, always present between the two sections. + "localAddHuggingFace", "localEmbeddingModel", ]); const cloudRows = selectLlmPanelRows(state, "cloud"); diff --git a/src/tui/llm-panel/llm-panel-selectors.ts b/src/tui/llm-panel/llm-panel-selectors.ts index f8bdb4e..a92240f 100644 --- a/src/tui/llm-panel/llm-panel-selectors.ts +++ b/src/tui/llm-panel/llm-panel-selectors.ts @@ -25,6 +25,18 @@ export type LlmPanelRow = primaryAction: "download" | "use" | "enable" | "start" | "current"; enterEffect: string; } + | { + /** + * Call-to-action row pinned under the local text models: opens the + * "add from Hugging Face" prompt. Carries no model — it is the one + * row whose Enter creates a catalog entry rather than acting on one. + */ + kind: "localAddHuggingFace"; + id: "local-add-huggingface"; + mode: "local"; + primaryAction: "add"; + enterEffect: string; + } | { kind: "localDaemon"; id: "local-runtime"; diff --git a/src/tui/llm-panel/llm-panel-state.ts b/src/tui/llm-panel/llm-panel-state.ts index b47813b..7671de8 100644 --- a/src/tui/llm-panel/llm-panel-state.ts +++ b/src/tui/llm-panel/llm-panel-state.ts @@ -6,12 +6,35 @@ export interface LlmStopLocalDaemonsPrompt { providerId: string; } +/** One search hit rendered as a numbered pick inside the prompt. */ +export interface LlmHuggingFaceHit { + repoId: string; + downloads: number; +} + +/** + * "Add a model from Hugging Face…" prompt. One buffer serving two + * intents: text that parses as a URL / `owner/name` is resolved and + * added directly; anything else is treated as a search query and the + * top hits come back in `results` for a digit pick. Keeping both on one + * field avoids a mode toggle the operator would have to learn. + */ +export interface LlmHuggingFacePromptState { + buffer: string; + /** True while a network call is in flight; keys are ignored meanwhile. */ + busy: boolean; + error: string | null; + /** Populated when the last submit was a query rather than a reference. */ + results: readonly LlmHuggingFaceHit[]; +} + export interface LlmPanelState { mode: LlmPanelMode; localCursor: number; cloudCursor: number; syncModeToActiveRoute: boolean; stopLocalDaemonsPrompt: LlmStopLocalDaemonsPrompt | null; + huggingFacePrompt: LlmHuggingFacePromptState | null; } export function createInitialLlmPanelState(): LlmPanelState { @@ -21,5 +44,6 @@ export function createInitialLlmPanelState(): LlmPanelState { cloudCursor: 0, syncModeToActiveRoute: false, stopLocalDaemonsPrompt: null, + huggingFacePrompt: null, }; } diff --git a/src/tui/local-models/local-models-orchestrator.ts b/src/tui/local-models/local-models-orchestrator.ts index 2965282..0bd18f5 100644 --- a/src/tui/local-models/local-models-orchestrator.ts +++ b/src/tui/local-models/local-models-orchestrator.ts @@ -1,6 +1,10 @@ import { totalmem } from "node:os"; import { getConfig, resetConfigCache } from "../../config/index.js"; +import { + addCustomModel, + removeCustomModel, +} from "../../config/custom-models-store.js"; import { checkForBackendUpdate, DEFAULT_EMBEDDING_MODEL_ID, @@ -21,10 +25,13 @@ import { isKnownLocalModelId, isMmprojDownloaded, isModelDownloaded, + listLocalModels, listVulkanDevices, - LOCAL_MODELS_CATALOG, + parseHuggingFaceModelRef, probeNvidiaVramMiB, readBackendVersion, + resolveCustomModelFromHuggingFace, + searchHuggingFaceGgufModels, readLogTail, removeEmbeddingModel as removeEmbeddingModelFiles, removeModel, @@ -188,7 +195,7 @@ export class LocalModelsOrchestrator { try { const cfg = getConfig(); const dataDir = cfg.paths.localModelsDataDir; - const rows = LOCAL_MODELS_CATALOG.map((def) => ({ + const rows = listLocalModels().map((def) => ({ id: def.id, def, downloaded: isModelDownloaded(dataDir, def), @@ -636,6 +643,128 @@ export class LocalModelsOrchestrator { } } + /** + * `/models add ` — resolve a Hugging Face repo into a + * catalog entry and persist it, so it shows up as an ordinary row the + * operator can pull with Enter. Nothing is downloaded here; the + * download is the operator's next keystroke. + */ + async addCustomModelFromHuggingFace(reference: string): Promise { + this.bus.emit({ + type: "runtime_info", + line: `local-llm: resolving ${reference} on Hugging Face…`, + }); + try { + const def = await resolveCustomModelFromHuggingFace(reference); + addCustomModel(def); + await this.refresh(); + this.bus.emit({ + type: "runtime_info", + line: + `local-llm: added ${def.id} — ${def.filename} (${def.sizeLabel})` + + `${def.supportsVision ? " + mmproj" : ""}. ` + + `Select it in the Models tab and press Enter to download.`, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + this.bus.emit({ type: "local_models_error_set", message: msg }); + this.bus.emit({ type: "runtime_info", line: `local-llm: add failed — ${msg}` }); + } + } + + /** + * Submit handler for the "Add a model from Hugging Face" prompt. Input + * that parses as a URL / `owner/name` is resolved and added straight + * away; anything else is a search whose hits go back into the prompt as + * a numbered pick list. Errors stay inside the modal so the operator can + * correct the text instead of losing it. + */ + async submitHuggingFacePrompt(text: string): Promise { + const query = text.trim(); + if (query.length === 0) return; + this.bus.emit({ type: "llm_hf_prompt_busy_set", busy: true }); + + let looksLikeRef = true; + try { + parseHuggingFaceModelRef(query); + } catch { + looksLikeRef = false; + } + + if (looksLikeRef) { + try { + const def = await resolveCustomModelFromHuggingFace(query); + addCustomModel(def); + this.bus.emit({ type: "llm_hf_prompt_closed" }); + await this.refresh(); + this.bus.emit({ + type: "runtime_info", + line: + `local-llm: added ${def.id} — ${def.filename} (${def.sizeLabel})` + + `${def.supportsVision ? " + mmproj" : ""}. ` + + `Select it under Local text models and press Enter to download.`, + }); + } catch (e) { + this.bus.emit({ + type: "llm_hf_prompt_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + return; + } + + try { + const hits = await searchHuggingFaceGgufModels(query, 9); + if (hits.length === 0) { + this.bus.emit({ + type: "llm_hf_prompt_failed", + error: `no GGUF repos matched ${JSON.stringify(query)}`, + }); + return; + } + this.bus.emit({ + type: "llm_hf_prompt_results_set", + results: hits.map((h) => ({ repoId: h.repoId, downloads: h.downloads })), + }); + } catch (e) { + this.bus.emit({ + type: "llm_hf_prompt_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + } + + /** `/models search ` — list GGUF repos into the runtime feed. */ + async searchHuggingFace(query: string): Promise { + this.bus.emit({ + type: "runtime_info", + line: `local-llm: searching Hugging Face for ${JSON.stringify(query)}…`, + }); + try { + const hits = await searchHuggingFaceGgufModels(query, 10); + if (hits.length === 0) { + this.bus.emit({ + type: "runtime_info", + line: `local-llm: no GGUF repos matched ${JSON.stringify(query)}`, + }); + return; + } + for (const hit of hits) { + this.bus.emit({ + type: "runtime_info", + line: ` ${hit.repoId} (${hit.downloads.toLocaleString()} downloads, ${hit.likes} likes)`, + }); + } + this.bus.emit({ + type: "runtime_info", + line: `local-llm: add one with /models add ${hits[0]!.repoId}`, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + this.bus.emit({ type: "local_models_error_set", message: msg }); + } + } + /** * Delete a model's on-disk files. If the daemon is currently serving * the model we are about to remove, stop it first — otherwise the @@ -671,6 +800,9 @@ export class LocalModelsOrchestrator { } try { await removeModel(dataDir, id); + // A custom model is user data, not catalog — removing its files + // without removing the entry leaves a permanently empty row. + if (id.startsWith("custom-")) removeCustomModel(id); this.bus.emit({ type: "runtime_info", line: `local-llm: ${def.name} removed`, diff --git a/src/tui/local-models/local-models-panel-state.ts b/src/tui/local-models/local-models-panel-state.ts index d6329dd..56937e3 100644 --- a/src/tui/local-models/local-models-panel-state.ts +++ b/src/tui/local-models/local-models-panel-state.ts @@ -7,6 +7,32 @@ import type { export type LocalModelsPanelMode = "list" | "detail" | "backendUpdate" | "pullProgress"; +/** Longest status text a one-row slot can hold before it wraps. */ +const STATUS_LINE_MAX_CHARS = 160; + +/** + * Flatten an error message into something a single-row status slot can + * render. `daemonError` / `errorLine` are view-model fields — every + * renderer prints them as one `` inside a fixed row budget — but + * their sources are not one-liners: a failed `llama-server` start + * carries a 4 KB, ~25-line log tail. Ink overlaps lines when a frame + * outgrows its budget rather than clipping, so an unflattened message + * corrupts the whole panel. + * + * Nothing is lost: the full text is also emitted to the runtime feed, + * and the raw log lives in the LLM logs tab (`L`). + */ +export function toStatusLine(message: string): string { + const firstLine = message.split("\n").find((line) => line.trim().length > 0) ?? ""; + const collapsed = firstLine.trim().replace(/\s+/g, " "); + const hadMore = message.trimEnd().includes("\n"); + const clipped = + collapsed.length > STATUS_LINE_MAX_CHARS + ? `${collapsed.slice(0, STATUS_LINE_MAX_CHARS - 1)}…` + : collapsed; + return hadMore ? `${clipped} (press L for the full log)` : clipped; +} + /** * Memory-v2 phase 1B (revised). The panel renders chat and embedding * catalogs on a **single screen** with one shared cursor. Indices diff --git a/src/tui/local-models/local-models-reducer.ts b/src/tui/local-models/local-models-reducer.ts index e61cb7d..ffcfb99 100644 --- a/src/tui/local-models/local-models-reducer.ts +++ b/src/tui/local-models/local-models-reducer.ts @@ -1,7 +1,7 @@ import type { TuiAction } from "../tui-action.js"; import type { TuiState } from "../tui-state.js"; import { isLocalModelsAction } from "./local-models-actions.js"; -import { totalRowCount } from "./local-models-panel-state.js"; +import { toStatusLine, totalRowCount } from "./local-models-panel-state.js"; function clampCursor(cursor: number, len: number): number { if (len <= 0) return 0; @@ -167,7 +167,7 @@ export function reduceLocalModelsAction(state: TuiState, action: TuiAction): Tui mode: "list", embeddingPull: null, loading: false, - errorLine: action.error, + errorLine: toStatusLine(action.error), }, }; } @@ -178,7 +178,7 @@ export function reduceLocalModelsAction(state: TuiState, action: TuiAction): Tui mode: "list", pull: null, loading: false, - errorLine: action.error, + errorLine: toStatusLine(action.error), }, }; case "local_models_backend_check_started": @@ -194,7 +194,10 @@ export function reduceLocalModelsAction(state: TuiState, action: TuiAction): Tui }, }; case "local_models_error_set": - return { ...state, localModelsPanel: { ...p, errorLine: action.message } }; + return { + ...state, + localModelsPanel: { ...p, errorLine: toStatusLine(action.message) }, + }; case "local_models_error_cleared": return { ...state, localModelsPanel: { ...p, errorLine: null } }; case "local_models_mode_set": @@ -220,7 +223,12 @@ export function reduceLocalModelsAction(state: TuiState, action: TuiAction): Tui case "local_models_daemon_error_set": return { ...state, - localModelsPanel: { ...p, daemonError: action.message, daemonPhase: "idle" }, + localModelsPanel: { + ...p, + daemonError: + action.message === null ? null : toStatusLine(action.message), + daemonPhase: "idle", + }, }; case "local_llm_logs_loaded": return { diff --git a/src/tui/persist-user-local-models-config.ts b/src/tui/persist-user-local-models-config.ts index 59eadf8..a699ff9 100644 --- a/src/tui/persist-user-local-models-config.ts +++ b/src/tui/persist-user-local-models-config.ts @@ -41,6 +41,8 @@ export function persistUserLocalModelsConfig(partial: { * "operator wants hybrid recall on with this model" mutation. */ embeddings?: Partial; + /** Full replacement for `localModels.customModels`. */ + customModels?: UserConfigFile["localModels"]["customModels"]; }): void { const path = getConfig().paths.userConfigFile; const prev = ensureUserConfigFileSync(path); @@ -50,6 +52,9 @@ export function persistUserLocalModelsConfig(partial: { ...prev.localModels, ...(partial.url !== undefined ? { url: partial.url } : {}), ...(partial.mode !== undefined ? { mode: partial.mode } : {}), + ...(partial.customModels !== undefined + ? { customModels: partial.customModels } + : {}), managed: { ...prev.localModels.managed, ...(partial.managed ?? {}), diff --git a/src/tui/submit-handler.ts b/src/tui/submit-handler.ts index 998936c..190f430 100644 --- a/src/tui/submit-handler.ts +++ b/src/tui/submit-handler.ts @@ -177,6 +177,12 @@ export function runSlashCommand( ) { callbacks.onLocalModelsSetActiveRequested?.(result.localModelsUseModelId); } + if (result.localModelsAddRef) { + void callbacks.onLocalModelsAddCustomRequested?.(result.localModelsAddRef); + } + if (result.localModelsSearchQuery) { + void callbacks.onLocalModelsHfSearchRequested?.(result.localModelsSearchQuery); + } if (result.triggerLocalModelsStatus) void callbacks.onLocalModelsStatusRequested?.(); if (result.telegramVerb) { runTelegramVerb(result.telegramVerb, callbacks); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index 69fa0ae..8d84bc6 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -126,6 +126,19 @@ export interface TuiAppCallbacks { onLocalModelsDeviceCycleRequested?(): void | Promise; onLocalModelsRemoveConfirmed?(modelId: import("../local-llm/index.js").LocalModelId): void; onLocalModelsStatusRequested?(): void | Promise; + /** + * `/models add ` — resolve a Hugging Face repo into + * a `custom-…` catalog entry so it joins the Models tab row list. + */ + onLocalModelsAddCustomRequested?(reference: string): void | Promise; + /** `/models search ` — list GGUF repos from Hugging Face. */ + onLocalModelsHfSearchRequested?(query: string): void | Promise; + /** + * Submit from the "Add a model from Hugging Face" prompt on the LLM + * tab. One string carrying either a reference (resolve + add) or a + * search query (list hits back into the prompt). + */ + onLlmHuggingFaceSubmitted?(text: string): void | Promise; /** Ask the orchestrator to (re)start the llama-server daemon. */ onLocalModelsDaemonStartRequested?(): void | Promise; /** Ask the orchestrator to stop the llama-server daemon. */ diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 0107feb..d58dcfd 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -264,6 +264,12 @@ export async function tuiCommand(args: string[]): Promise { onLocalModelsRemoveConfirmed: (id) => void orchestrator.localModels.removeLocalModel(id), onLocalModelsStatusRequested: () => orchestrator.localModels.emitStatusLine(), + onLocalModelsAddCustomRequested: (reference) => + orchestrator.localModels.addCustomModelFromHuggingFace(reference), + onLocalModelsHfSearchRequested: (query) => + orchestrator.localModels.searchHuggingFace(query), + onLlmHuggingFaceSubmitted: (text) => + orchestrator.localModels.submitHuggingFacePrompt(text), onLocalModelsDaemonStartRequested: () => void orchestrator.localModels.startDaemon(), onLocalModelsDaemonStopRequested: () =>