feat(coding-agent): add Hugging Face llama search
This commit is contained in:
@@ -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 { 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 { type LlamaUi, runWithProgress, showLlamaUi } from "./ui.ts";
|
||||
|
||||
@@ -18,6 +19,13 @@ function connectionErrorMessage(error: unknown): string {
|
||||
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> {
|
||||
const result = await ctx.modelRegistry.getProviderAuth(LLAMA_PROVIDER_ID);
|
||||
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 model = (await ui.input("Download llama.cpp model", "owner/repository[:quant]"))?.trim();
|
||||
if (!model) return;
|
||||
if (/\s/u.test(model) || !model.includes("/")) {
|
||||
ctx.ui.notify("Use owner/repository[:quant]", "error");
|
||||
return;
|
||||
const huggingFace = new HuggingFaceClient(await findHuggingFaceToken());
|
||||
const selected = await ui.searchModels((query, signal) => huggingFace.search(query, signal));
|
||||
if (!selected) return;
|
||||
const parsed = parseHuggingFaceModel(selected);
|
||||
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, {
|
||||
title: "Downloading model",
|
||||
model,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
type Component,
|
||||
Container,
|
||||
type Focusable,
|
||||
fuzzyFilter,
|
||||
Input,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
@@ -16,6 +18,7 @@ import { DynamicBorder } from "../../modes/interactive/components/dynamic-border
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme.ts";
|
||||
import type { LlamaModelInfo, LlamaProgress } from "./client.ts";
|
||||
import type { HuggingFaceModel } from "./huggingface.ts";
|
||||
|
||||
const DOWNLOAD_VALUE = "\0download";
|
||||
|
||||
@@ -58,12 +61,7 @@ function selectTheme(theme: Theme) {
|
||||
};
|
||||
}
|
||||
|
||||
function frame(
|
||||
theme: Theme,
|
||||
title: string,
|
||||
body: Array<Text | Spacer | SelectList | Input>,
|
||||
footer?: string,
|
||||
): Container {
|
||||
function frame(theme: Theme, title: string, body: Component[], footer?: string): Container {
|
||||
const container = new Container();
|
||||
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
||||
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>;
|
||||
confirm(title: string, message: string): Promise<boolean>;
|
||||
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>;
|
||||
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 {
|
||||
private readonly tui: TUI;
|
||||
private readonly theme: Theme;
|
||||
private readonly keybindings: KeybindingsManager;
|
||||
private readonly searchCache = new Map<string, HuggingFaceModel[]>();
|
||||
private content: Container;
|
||||
private inputHandler: { handleInput?(data: string): void } | undefined;
|
||||
private inputTarget: Focusable | undefined;
|
||||
@@ -173,7 +361,7 @@ class LlamaView implements LlamaUi, Focusable {
|
||||
return new Promise((resolve) => {
|
||||
const list = new SelectList(
|
||||
options.map((option) => ({ value: option, label: option })),
|
||||
options.length,
|
||||
Math.min(options.length, 12),
|
||||
selectTheme(this.theme),
|
||||
);
|
||||
list.onSelect = (item) => resolve(item.value);
|
||||
@@ -199,24 +387,35 @@ class LlamaView implements LlamaUi, Focusable {
|
||||
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) => {
|
||||
const input = new Input();
|
||||
input.onSubmit = (value) => resolve(value);
|
||||
input.onEscape = () => resolve(undefined);
|
||||
const component = new HuggingFaceSearch(
|
||||
this.tui,
|
||||
this.theme,
|
||||
this.keybindings,
|
||||
search,
|
||||
this.searchCache,
|
||||
resolve,
|
||||
);
|
||||
this.setContent(
|
||||
frame(
|
||||
this.theme,
|
||||
title,
|
||||
[new Spacer(1), new Text(this.theme.fg("dim", placeholder), 1, 0), input],
|
||||
`${keyHint("tui.input.submit", "submit")} • ${keyHint("tui.select.cancel", "cancel")}`,
|
||||
"Download model",
|
||||
[new Spacer(1), component],
|
||||
`${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "back")}`,
|
||||
),
|
||||
input,
|
||||
input,
|
||||
component,
|
||||
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> {
|
||||
if (!this.progressPromise) {
|
||||
this.progressPromise = new Promise((resolve) => {
|
||||
|
||||
Reference in New Issue
Block a user