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: () =>