feat(coding-agent): add Hugging Face llama search
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added built-in llama.cpp router support with `/login` connection setup and `/llama` model downloads, explicit loading, unloading, and live progress. See [llama.cpp](docs/llama-cpp.md).
|
- Added built-in llama.cpp router support with `/login` connection setup and `/llama` Hugging Face model search and downloads, explicit loading, unloading, and live progress. See [llama.cpp](docs/llama-cpp.md).
|
||||||
- Added extension registration for complete pi-ai providers, including native authentication, model refresh, filtering, and streaming behavior.
|
- Added extension registration for complete pi-ai providers, including native authentication, model refresh, filtering, and streaming behavior.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -73,9 +73,11 @@ Run:
|
|||||||
|
|
||||||
- Select an unloaded model to load it.
|
- Select an unloaded model to load it.
|
||||||
- Select a loaded model to unload it.
|
- Select a loaded model to unload it.
|
||||||
- Select **Download model…** and enter `owner/repository[:quant]` to download from Hugging Face.
|
- Select **Download model…**, search Hugging Face, then choose a repository and quantization. Exact `owner/repository[:quant]` values also work.
|
||||||
- Press Escape during a load or download to confirm cancellation.
|
- Press Escape during a load or download to confirm cancellation.
|
||||||
|
|
||||||
|
Hugging Face search uses `HF_TOKEN` when set, then checks `$HF_TOKEN_PATH`, `$HF_HOME/token`, `$XDG_CACHE_HOME/huggingface/token`, and `~/.cache/huggingface/token`. Search also works without authentication, subject to lower rate limits. Pi warns before downloading gated repositories and links to their access page. The llama.cpp server performs the download, so its process must also have `HF_TOKEN` when the selected repository requires access.
|
||||||
|
|
||||||
If other models are loaded, Pi asks whether to unload them first or keep them loaded. Pi does not silently unload models and never deletes model files. The router may be shared with other clients, so `/llama` always displays the router's current state.
|
If other models are loaded, Pi asks whether to unload them first or keep them loaded. Pi does not silently unload models and never deletes model files. The router may be shared with other clients, so `/llama` always displays the router's current state.
|
||||||
|
|
||||||
Only loaded models appear in `/model`. After loading a model, run `/model` to select it for the current Pi session.
|
Only loaded models appear in `/model`. After loading a model, run `/model` to select it for the current Pi session.
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const DEFAULT_HUGGING_FACE_URL = "https://huggingface.co";
|
||||||
|
const QUANTIZATION_PATTERN =
|
||||||
|
/(?:^|[-_.])((?:UD-)?(?:IQ\d(?:_[A-Z0-9]+)+|Q\d(?:_[A-Z0-9]+)+|BF16|F16|F32|MXFP\d(?:_[A-Z0-9]+)*))$/iu;
|
||||||
|
const SHARD_SUFFIX_PATTERN = /-\d{5}-of-\d{5}$/u;
|
||||||
|
|
||||||
|
export interface HuggingFaceModel {
|
||||||
|
id: string;
|
||||||
|
downloads: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HuggingFaceQuantization {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HuggingFaceModelDetails {
|
||||||
|
id: string;
|
||||||
|
gated: false | "auto" | "manual";
|
||||||
|
quantizations: HuggingFaceQuantization[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadError(payload: unknown, fallback: string): string {
|
||||||
|
if (typeof payload !== "object" || payload === null) return fallback;
|
||||||
|
const error = (payload as { error?: unknown }).error;
|
||||||
|
return typeof error === "string" && error ? error : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRateLimitDelay(value: string | null): number | undefined {
|
||||||
|
const match = value?.match(/(?:^|;)t=(\d+)/u);
|
||||||
|
return match ? Number(match[1]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readToken(path: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const token = (await readFile(path, "utf8")).trim();
|
||||||
|
return token || undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findHuggingFaceToken(env: NodeJS.ProcessEnv = process.env): Promise<string | undefined> {
|
||||||
|
const fromEnvironment = env.HF_TOKEN?.trim();
|
||||||
|
if (fromEnvironment) return fromEnvironment;
|
||||||
|
|
||||||
|
const paths = [
|
||||||
|
env.HF_TOKEN_PATH,
|
||||||
|
env.HF_HOME ? join(env.HF_HOME, "token") : undefined,
|
||||||
|
env.XDG_CACHE_HOME ? join(env.XDG_CACHE_HOME, "huggingface", "token") : undefined,
|
||||||
|
join(homedir(), ".cache", "huggingface", "token"),
|
||||||
|
].filter((path): path is string => Boolean(path));
|
||||||
|
for (const path of new Set(paths)) {
|
||||||
|
const token = await readToken(path);
|
||||||
|
if (token) return token;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HuggingFaceClient {
|
||||||
|
private readonly token: string | undefined;
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(token?: string, baseUrl = DEFAULT_HUGGING_FACE_URL) {
|
||||||
|
this.token = token;
|
||||||
|
this.baseUrl = baseUrl.replace(/\/+$/u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(path: string, signal?: AbortSignal): Promise<unknown> {
|
||||||
|
const headers = new Headers();
|
||||||
|
if (this.token) headers.set("Authorization", `Bearer ${this.token}`);
|
||||||
|
const timeout = AbortSignal.timeout(15_000);
|
||||||
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
headers,
|
||||||
|
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||||
|
});
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
payload = undefined;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const fallback = `Hugging Face returned HTTP ${response.status}`;
|
||||||
|
if (response.status === 429) {
|
||||||
|
const delay =
|
||||||
|
Number(response.headers.get("retry-after")) || parseRateLimitDelay(response.headers.get("ratelimit"));
|
||||||
|
throw new Error(
|
||||||
|
delay ? `Hugging Face rate limit reached; retry in ${delay}s` : "Hugging Face rate limit reached",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(payloadError(payload, fallback));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query: string, signal?: AbortSignal): Promise<HuggingFaceModel[]> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
search: query,
|
||||||
|
filter: "gguf",
|
||||||
|
sort: "downloads",
|
||||||
|
direction: "-1",
|
||||||
|
limit: "20",
|
||||||
|
});
|
||||||
|
const payload = await this.request(`/api/models?${params}`, signal);
|
||||||
|
if (!Array.isArray(payload)) throw new Error("Hugging Face returned invalid search results");
|
||||||
|
return payload.flatMap((value) => {
|
||||||
|
if (typeof value !== "object" || value === null || typeof (value as { id?: unknown }).id !== "string")
|
||||||
|
return [];
|
||||||
|
const model = value as { id: string; downloads?: unknown };
|
||||||
|
return [{ id: model.id, downloads: typeof model.downloads === "number" ? model.downloads : 0 }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async details(id: string, signal?: AbortSignal): Promise<HuggingFaceModelDetails> {
|
||||||
|
const encodedId = id.split("/").map(encodeURIComponent).join("/");
|
||||||
|
const payload = await this.request(`/api/models/${encodedId}?blobs=true`, signal);
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
throw new Error("Hugging Face returned invalid model details");
|
||||||
|
}
|
||||||
|
const model = payload as { id?: unknown; gated?: unknown; siblings?: unknown };
|
||||||
|
const sizes = new Map<string, { total: number; complete: boolean }>();
|
||||||
|
if (Array.isArray(model.siblings)) {
|
||||||
|
for (const value of model.siblings) {
|
||||||
|
if (typeof value !== "object" || value === null) continue;
|
||||||
|
const file = value as { rfilename?: unknown; size?: unknown };
|
||||||
|
if (typeof file.rfilename !== "string" || !file.rfilename.toLowerCase().endsWith(".gguf")) continue;
|
||||||
|
const filename = file.rfilename.split("/").at(-1)!;
|
||||||
|
if (filename.toLowerCase().startsWith("mmproj")) continue;
|
||||||
|
const stem = filename.slice(0, -5).replace(SHARD_SUFFIX_PATTERN, "");
|
||||||
|
const quantization = stem.match(QUANTIZATION_PATTERN)?.[1]?.toUpperCase();
|
||||||
|
if (!quantization) continue;
|
||||||
|
const current = sizes.get(quantization) ?? { total: 0, complete: true };
|
||||||
|
if (typeof file.size === "number") current.total += file.size;
|
||||||
|
else current.complete = false;
|
||||||
|
sizes.set(quantization, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const quantizations = [...sizes]
|
||||||
|
.map(([name, size]) => ({ name, size: size.complete ? size.total : undefined }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.name === "Q4_K_M") return -1;
|
||||||
|
if (right.name === "Q4_K_M") return 1;
|
||||||
|
return (
|
||||||
|
(left.size ?? Number.MAX_SAFE_INTEGER) - (right.size ?? Number.MAX_SAFE_INTEGER) ||
|
||||||
|
left.name.localeCompare(right.name)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: typeof model.id === "string" ? model.id : id,
|
||||||
|
gated: model.gated === "auto" || model.gated === "manual" ? model.gated : false,
|
||||||
|
quantizations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ExtensionAPI, ExtensionCommandContext } from "../../core/extensions/types.ts";
|
import type { ExtensionAPI, ExtensionCommandContext } from "../../core/extensions/types.ts";
|
||||||
import { LlamaClient, type LlamaModelInfo, normalizeLlamaServerUrl } from "./client.ts";
|
import { formatBytes, LlamaClient, type LlamaModelInfo, normalizeLlamaServerUrl } from "./client.ts";
|
||||||
|
import { findHuggingFaceToken, HuggingFaceClient } from "./huggingface.ts";
|
||||||
import { createLlamaProvider, LLAMA_PROVIDER_ID } from "./provider.ts";
|
import { createLlamaProvider, LLAMA_PROVIDER_ID } from "./provider.ts";
|
||||||
import { type LlamaUi, runWithProgress, showLlamaUi } from "./ui.ts";
|
import { type LlamaUi, runWithProgress, showLlamaUi } from "./ui.ts";
|
||||||
|
|
||||||
@@ -18,6 +19,13 @@ function connectionErrorMessage(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseHuggingFaceModel(value: string): { repository: string; quantization?: string } {
|
||||||
|
const colon = value.indexOf(":", value.indexOf("/") + 1);
|
||||||
|
return colon < 0
|
||||||
|
? { repository: value }
|
||||||
|
: { repository: value.slice(0, colon), quantization: value.slice(colon + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
async function configuredClient(ctx: ExtensionCommandContext): Promise<LlamaClient | undefined> {
|
async function configuredClient(ctx: ExtensionCommandContext): Promise<LlamaClient | undefined> {
|
||||||
const result = await ctx.modelRegistry.getProviderAuth(LLAMA_PROVIDER_ID);
|
const result = await ctx.modelRegistry.getProviderAuth(LLAMA_PROVIDER_ID);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -118,12 +126,37 @@ export default function llamaExtension(pi: ExtensionAPI): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadModel = async (ctx: ExtensionCommandContext, ui: LlamaUi, client: LlamaClient): Promise<void> => {
|
const downloadModel = async (ctx: ExtensionCommandContext, ui: LlamaUi, client: LlamaClient): Promise<void> => {
|
||||||
const model = (await ui.input("Download llama.cpp model", "owner/repository[:quant]"))?.trim();
|
const huggingFace = new HuggingFaceClient(await findHuggingFaceToken());
|
||||||
if (!model) return;
|
const selected = await ui.searchModels((query, signal) => huggingFace.search(query, signal));
|
||||||
if (/\s/u.test(model) || !model.includes("/")) {
|
if (!selected) return;
|
||||||
ctx.ui.notify("Use owner/repository[:quant]", "error");
|
const parsed = parseHuggingFaceModel(selected);
|
||||||
return;
|
ui.showStatus("Loading model details", parsed.repository);
|
||||||
|
const details = await huggingFace.details(parsed.repository);
|
||||||
|
if (details.gated) {
|
||||||
|
const approval = details.gated === "manual" ? "Manual approval is required" : "Accept the access terms";
|
||||||
|
const choice = await ui.select(
|
||||||
|
`Hugging Face access required\n${details.id}\n\n${approval} at:\nhttps://huggingface.co/${details.id}\n\nThe llama.cpp server needs HF_TOKEN with access.`,
|
||||||
|
["Continue", "Back"],
|
||||||
|
);
|
||||||
|
if (choice !== "Continue") return;
|
||||||
}
|
}
|
||||||
|
let quantization = parsed.quantization;
|
||||||
|
if (!quantization && details.quantizations.length > 0) {
|
||||||
|
const options = details.quantizations.map((entry) => {
|
||||||
|
const detail = [
|
||||||
|
entry.size === undefined ? undefined : formatBytes(entry.size),
|
||||||
|
entry.name === "Q4_K_M" ? "recommended" : undefined,
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.join(" · ");
|
||||||
|
return detail ? `${entry.name} · ${detail}` : entry.name;
|
||||||
|
});
|
||||||
|
const choice = await ui.select(`Select quantization\n${details.id}`, options);
|
||||||
|
if (!choice) return;
|
||||||
|
quantization = details.quantizations[options.indexOf(choice)]?.name;
|
||||||
|
if (!quantization) return;
|
||||||
|
}
|
||||||
|
const model = quantization ? `${details.id}:${quantization}` : details.id;
|
||||||
const result = await runWithProgress(ui, {
|
const result = await runWithProgress(ui, {
|
||||||
title: "Downloading model",
|
title: "Downloading model",
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
type Component,
|
||||||
Container,
|
Container,
|
||||||
type Focusable,
|
type Focusable,
|
||||||
|
fuzzyFilter,
|
||||||
Input,
|
Input,
|
||||||
type SelectItem,
|
type SelectItem,
|
||||||
SelectList,
|
SelectList,
|
||||||
@@ -16,6 +18,7 @@ import { DynamicBorder } from "../../modes/interactive/components/dynamic-border
|
|||||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
||||||
import type { Theme } from "../../modes/interactive/theme/theme.ts";
|
import type { Theme } from "../../modes/interactive/theme/theme.ts";
|
||||||
import type { LlamaModelInfo, LlamaProgress } from "./client.ts";
|
import type { LlamaModelInfo, LlamaProgress } from "./client.ts";
|
||||||
|
import type { HuggingFaceModel } from "./huggingface.ts";
|
||||||
|
|
||||||
const DOWNLOAD_VALUE = "\0download";
|
const DOWNLOAD_VALUE = "\0download";
|
||||||
|
|
||||||
@@ -58,12 +61,7 @@ function selectTheme(theme: Theme) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function frame(
|
function frame(theme: Theme, title: string, body: Component[], footer?: string): Container {
|
||||||
theme: Theme,
|
|
||||||
title: string,
|
|
||||||
body: Array<Text | Spacer | SelectList | Input>,
|
|
||||||
footer?: string,
|
|
||||||
): Container {
|
|
||||||
const container = new Container();
|
const container = new Container();
|
||||||
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
||||||
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
||||||
@@ -81,15 +79,205 @@ export interface LlamaUi {
|
|||||||
select(title: string, options: string[]): Promise<string | undefined>;
|
select(title: string, options: string[]): Promise<string | undefined>;
|
||||||
confirm(title: string, message: string): Promise<boolean>;
|
confirm(title: string, message: string): Promise<boolean>;
|
||||||
connectionError(serverUrl: string, message: string): Promise<"retry" | "close">;
|
connectionError(serverUrl: string, message: string): Promise<"retry" | "close">;
|
||||||
input(title: string, placeholder: string): Promise<string | undefined>;
|
searchModels(
|
||||||
|
search: (query: string, signal: AbortSignal) => Promise<HuggingFaceModel[]>,
|
||||||
|
): Promise<string | undefined>;
|
||||||
|
showStatus(title: string, message: string): void;
|
||||||
progress(state: ProgressState): Promise<void>;
|
progress(state: ProgressState): Promise<void>;
|
||||||
updateProgress(state: ProgressState): void;
|
updateProgress(state: ProgressState): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactCount(value: number): string {
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 100_000 ? 0 : 1)}k`;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
class HuggingFaceSearch extends Container implements Focusable {
|
||||||
|
private readonly tui: TUI;
|
||||||
|
private readonly theme: Theme;
|
||||||
|
private readonly keybindings: KeybindingsManager;
|
||||||
|
private readonly search: (query: string, signal: AbortSignal) => Promise<HuggingFaceModel[]>;
|
||||||
|
private readonly cache: Map<string, HuggingFaceModel[]>;
|
||||||
|
private readonly onSelectModel: (model: string | undefined) => void;
|
||||||
|
private readonly input = new Input();
|
||||||
|
private readonly resultsContainer = new Container();
|
||||||
|
private results: HuggingFaceModel[] = [];
|
||||||
|
private filteredResults: HuggingFaceModel[] = [];
|
||||||
|
private selectedIndex = 0;
|
||||||
|
private query = "";
|
||||||
|
private status = "Type at least 2 characters";
|
||||||
|
private debounce: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private request: AbortController | undefined;
|
||||||
|
private closed = false;
|
||||||
|
private _focused = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
tui: TUI,
|
||||||
|
theme: Theme,
|
||||||
|
keybindings: KeybindingsManager,
|
||||||
|
search: (query: string, signal: AbortSignal) => Promise<HuggingFaceModel[]>,
|
||||||
|
cache: Map<string, HuggingFaceModel[]>,
|
||||||
|
onSelectModel: (model: string | undefined) => void,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.tui = tui;
|
||||||
|
this.theme = theme;
|
||||||
|
this.keybindings = keybindings;
|
||||||
|
this.search = search;
|
||||||
|
this.cache = cache;
|
||||||
|
this.onSelectModel = onSelectModel;
|
||||||
|
this.addChild(new Text(theme.fg("dim", "Model name or owner/repository[:quant]"), 1, 0));
|
||||||
|
this.addChild(this.input);
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.addChild(this.resultsContainer);
|
||||||
|
this.updateResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
get focused(): boolean {
|
||||||
|
return this._focused;
|
||||||
|
}
|
||||||
|
|
||||||
|
set focused(value: boolean) {
|
||||||
|
this._focused = value;
|
||||||
|
this.input.focused = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateResults(): void {
|
||||||
|
this.resultsContainer.clear();
|
||||||
|
const maxVisible = 10;
|
||||||
|
const start = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredResults.length - maxVisible),
|
||||||
|
);
|
||||||
|
const end = Math.min(start + maxVisible, this.filteredResults.length);
|
||||||
|
for (let index = start; index < end; index++) {
|
||||||
|
const model = this.filteredResults[index];
|
||||||
|
if (!model) continue;
|
||||||
|
const prefix = index === this.selectedIndex ? "→ " : " ";
|
||||||
|
const details = `${compactCount(model.downloads)} downloads`;
|
||||||
|
this.resultsContainer.addChild(
|
||||||
|
new Text(
|
||||||
|
index === this.selectedIndex
|
||||||
|
? this.theme.fg("accent", `${prefix}${model.id} ${details}`)
|
||||||
|
: `${prefix}${model.id}${this.theme.fg("muted", ` ${details}`)}`,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (start > 0 || end < this.filteredResults.length) {
|
||||||
|
this.resultsContainer.addChild(
|
||||||
|
new Text(this.theme.fg("dim", ` (${this.selectedIndex + 1}/${this.filteredResults.length})`), 0, 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.filteredResults.length === 0) {
|
||||||
|
this.resultsContainer.addChild(new Text(this.theme.fg("dim", ` ${this.status}`), 0, 0));
|
||||||
|
} else if (this.status === "Searching Hugging Face…") {
|
||||||
|
this.resultsContainer.addChild(new Text(this.theme.fg("dim", ` ${this.status}`), 0, 0));
|
||||||
|
}
|
||||||
|
this.tui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterResults(): void {
|
||||||
|
if (this.query) {
|
||||||
|
const matches = new Set(fuzzyFilter(this.results, this.query, (model) => model.id).map((model) => model.id));
|
||||||
|
this.filteredResults = this.results.filter((model) => matches.has(model.id));
|
||||||
|
} else {
|
||||||
|
this.filteredResults = this.results;
|
||||||
|
}
|
||||||
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredResults.length - 1));
|
||||||
|
this.updateResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleSearch(): void {
|
||||||
|
if (this.debounce) clearTimeout(this.debounce);
|
||||||
|
this.request?.abort();
|
||||||
|
this.request = undefined;
|
||||||
|
if (this.query.length < 2) {
|
||||||
|
this.status = "Type at least 2 characters";
|
||||||
|
this.filterResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cached = this.cache.get(this.query.toLowerCase());
|
||||||
|
if (cached) {
|
||||||
|
this.results = cached;
|
||||||
|
this.status = cached.length === 0 ? "No GGUF models found" : "";
|
||||||
|
this.filterResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.status = "Searching Hugging Face…";
|
||||||
|
this.filterResults();
|
||||||
|
this.debounce = setTimeout(() => void this.runSearch(this.query), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runSearch(query: string): Promise<void> {
|
||||||
|
const request = new AbortController();
|
||||||
|
this.request = request;
|
||||||
|
try {
|
||||||
|
const results = await this.search(query, request.signal);
|
||||||
|
this.cache.set(query.toLowerCase(), results);
|
||||||
|
if (this.closed || request.signal.aborted || this.query !== query) return;
|
||||||
|
this.results = results;
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
this.status = results.length === 0 ? "No GGUF models found" : "";
|
||||||
|
this.filterResults();
|
||||||
|
} catch (error) {
|
||||||
|
if (this.closed || request.signal.aborted || this.query !== query) return;
|
||||||
|
this.results = [];
|
||||||
|
this.status = error instanceof Error ? error.message : String(error);
|
||||||
|
this.filterResults();
|
||||||
|
} finally {
|
||||||
|
if (this.request === request) this.request = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private close(model: string | undefined): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
if (this.debounce) clearTimeout(this.debounce);
|
||||||
|
this.request?.abort();
|
||||||
|
this.onSelectModel(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput(data: string): void {
|
||||||
|
if (this.keybindings.matches(data, "tui.select.up")) {
|
||||||
|
if (this.filteredResults.length > 0) {
|
||||||
|
this.selectedIndex = this.selectedIndex === 0 ? this.filteredResults.length - 1 : this.selectedIndex - 1;
|
||||||
|
this.updateResults();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.keybindings.matches(data, "tui.select.down")) {
|
||||||
|
if (this.filteredResults.length > 0) {
|
||||||
|
this.selectedIndex = this.selectedIndex === this.filteredResults.length - 1 ? 0 : this.selectedIndex + 1;
|
||||||
|
this.updateResults();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
||||||
|
const exact = /^[^/\s]+\/[^:\s]+(?::[^\s:]+)?$/u.test(this.query) ? this.query : undefined;
|
||||||
|
const selected = exact ?? this.filteredResults[this.selectedIndex]?.id;
|
||||||
|
if (selected) this.close(selected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
||||||
|
this.close(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.input.handleInput(data);
|
||||||
|
const query = this.input.getValue().trim();
|
||||||
|
if (query === this.query) return;
|
||||||
|
this.query = query;
|
||||||
|
this.scheduleSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class LlamaView implements LlamaUi, Focusable {
|
class LlamaView implements LlamaUi, Focusable {
|
||||||
private readonly tui: TUI;
|
private readonly tui: TUI;
|
||||||
private readonly theme: Theme;
|
private readonly theme: Theme;
|
||||||
private readonly keybindings: KeybindingsManager;
|
private readonly keybindings: KeybindingsManager;
|
||||||
|
private readonly searchCache = new Map<string, HuggingFaceModel[]>();
|
||||||
private content: Container;
|
private content: Container;
|
||||||
private inputHandler: { handleInput?(data: string): void } | undefined;
|
private inputHandler: { handleInput?(data: string): void } | undefined;
|
||||||
private inputTarget: Focusable | undefined;
|
private inputTarget: Focusable | undefined;
|
||||||
@@ -173,7 +361,7 @@ class LlamaView implements LlamaUi, Focusable {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const list = new SelectList(
|
const list = new SelectList(
|
||||||
options.map((option) => ({ value: option, label: option })),
|
options.map((option) => ({ value: option, label: option })),
|
||||||
options.length,
|
Math.min(options.length, 12),
|
||||||
selectTheme(this.theme),
|
selectTheme(this.theme),
|
||||||
);
|
);
|
||||||
list.onSelect = (item) => resolve(item.value);
|
list.onSelect = (item) => resolve(item.value);
|
||||||
@@ -199,24 +387,35 @@ class LlamaView implements LlamaUi, Focusable {
|
|||||||
return choice === "Retry" ? "retry" : "close";
|
return choice === "Retry" ? "retry" : "close";
|
||||||
}
|
}
|
||||||
|
|
||||||
input(title: string, placeholder: string): Promise<string | undefined> {
|
searchModels(
|
||||||
|
search: (query: string, signal: AbortSignal) => Promise<HuggingFaceModel[]>,
|
||||||
|
): Promise<string | undefined> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const input = new Input();
|
const component = new HuggingFaceSearch(
|
||||||
input.onSubmit = (value) => resolve(value);
|
this.tui,
|
||||||
input.onEscape = () => resolve(undefined);
|
this.theme,
|
||||||
|
this.keybindings,
|
||||||
|
search,
|
||||||
|
this.searchCache,
|
||||||
|
resolve,
|
||||||
|
);
|
||||||
this.setContent(
|
this.setContent(
|
||||||
frame(
|
frame(
|
||||||
this.theme,
|
this.theme,
|
||||||
title,
|
"Download model",
|
||||||
[new Spacer(1), new Text(this.theme.fg("dim", placeholder), 1, 0), input],
|
[new Spacer(1), component],
|
||||||
`${keyHint("tui.input.submit", "submit")} • ${keyHint("tui.select.cancel", "cancel")}`,
|
`${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "back")}`,
|
||||||
),
|
),
|
||||||
input,
|
component,
|
||||||
input,
|
component,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showStatus(title: string, message: string): void {
|
||||||
|
this.setContent(frame(this.theme, title, [new Spacer(1), new Text(this.theme.fg("muted", message), 1, 0)]));
|
||||||
|
}
|
||||||
|
|
||||||
progress(state: ProgressState): Promise<void> {
|
progress(state: ProgressState): Promise<void> {
|
||||||
if (!this.progressPromise) {
|
if (!this.progressPromise) {
|
||||||
this.progressPromise = new Promise((resolve) => {
|
this.progressPromise = new Promise((resolve) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
import { createEventBus } from "../src/core/event-bus.ts";
|
import { createEventBus } from "../src/core/event-bus.ts";
|
||||||
import { createExtensionRuntime, loadExtensionFromFactory } from "../src/core/extensions/loader.ts";
|
import { createExtensionRuntime, loadExtensionFromFactory } from "../src/core/extensions/loader.ts";
|
||||||
import { LlamaClient, type LlamaProgress, normalizeLlamaServerUrl } from "../src/extensions/llama/client.ts";
|
import { LlamaClient, type LlamaProgress, normalizeLlamaServerUrl } from "../src/extensions/llama/client.ts";
|
||||||
|
import { findHuggingFaceToken, HuggingFaceClient } from "../src/extensions/llama/huggingface.ts";
|
||||||
import llamaExtension from "../src/extensions/llama/index.ts";
|
import llamaExtension from "../src/extensions/llama/index.ts";
|
||||||
import { createLlamaProvider, LLAMA_PROVIDER_ID } from "../src/extensions/llama/provider.ts";
|
import { createLlamaProvider, LLAMA_PROVIDER_ID } from "../src/extensions/llama/provider.ts";
|
||||||
|
|
||||||
@@ -116,6 +117,46 @@ describe("llama.cpp extension", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("searches Hugging Face and reads quantizations plus access requirements", async () => {
|
||||||
|
const { url } = await listen((request, response) => {
|
||||||
|
expect(request.headers.authorization).toBe("Bearer hf-secret");
|
||||||
|
if (request.url?.startsWith("/api/models?")) {
|
||||||
|
const requestUrl = new URL(request.url, "http://localhost");
|
||||||
|
expect(requestUrl.searchParams.get("search")).toBe("qwen coder");
|
||||||
|
expect(requestUrl.searchParams.get("filter")).toBe("gguf");
|
||||||
|
expect(requestUrl.searchParams.get("sort")).toBe("downloads");
|
||||||
|
json(response, [{ id: "owner/model-GGUF", downloads: 1200 }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.url === "/api/models/owner/model-GGUF?blobs=true") {
|
||||||
|
json(response, {
|
||||||
|
id: "owner/model-GGUF",
|
||||||
|
gated: "manual",
|
||||||
|
siblings: [
|
||||||
|
{ rfilename: "model-Q5_K_M.gguf", size: 6000 },
|
||||||
|
{ rfilename: "model-Q4_K_M-00001-of-00002.gguf", size: 2000 },
|
||||||
|
{ rfilename: "model-Q4_K_M-00002-of-00002.gguf", size: 3000 },
|
||||||
|
{ rfilename: "mmproj-F16.gguf", size: 1000 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(404).end();
|
||||||
|
});
|
||||||
|
const client = new HuggingFaceClient("hf-secret", url);
|
||||||
|
|
||||||
|
expect(await client.search("qwen coder")).toEqual([{ id: "owner/model-GGUF", downloads: 1200 }]);
|
||||||
|
expect(await client.details("owner/model-GGUF")).toEqual({
|
||||||
|
id: "owner/model-GGUF",
|
||||||
|
gated: "manual",
|
||||||
|
quantizations: [
|
||||||
|
{ name: "Q4_K_M", size: 5000 },
|
||||||
|
{ name: "Q5_K_M", size: 6000 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(await findHuggingFaceToken({ HF_TOKEN: " hf-secret " })).toBe("hf-secret");
|
||||||
|
});
|
||||||
|
|
||||||
it("loads with SSE progress and waits for the loaded catalog state", async () => {
|
it("loads with SSE progress and waits for the loaded catalog state", async () => {
|
||||||
let status: "unloaded" | "loading" | "loaded" = "unloaded";
|
let status: "unloaded" | "loading" | "loaded" = "unloaded";
|
||||||
const streams = new Set<ServerResponse>();
|
const streams = new Set<ServerResponse>();
|
||||||
|
|||||||
Reference in New Issue
Block a user