feat(coding-agent): add llama.cpp router integration
This commit is contained in:
@@ -1482,6 +1482,8 @@ export type InlineExtension =
|
||||
/** Display name shown as `<inline:name>` in the startup Extensions list. */
|
||||
name: string;
|
||||
factory: ExtensionFactory;
|
||||
/** Omit this extension from the startup Extensions list. */
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -1649,6 +1651,7 @@ export interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionAction
|
||||
export interface Extension {
|
||||
path: string;
|
||||
resolvedPath: string;
|
||||
hidden?: boolean;
|
||||
sourceInfo: SourceInfo;
|
||||
handlers: Map<string, HandlerFn[]>;
|
||||
tools: Map<string, RegisteredTool>;
|
||||
|
||||
@@ -899,6 +899,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
const extensionPath = `<inline:${isNamed ? input.name : index + 1}>`;
|
||||
try {
|
||||
const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath);
|
||||
extension.hidden = isNamed && input.hidden;
|
||||
extensions.push(extension);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "failed to load extension";
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { InlineExtension } from "../core/extensions/types.ts";
|
||||
import llamaExtension from "./llama/index.ts";
|
||||
|
||||
export const builtInExtensions: InlineExtension[] = [{ name: "llama.cpp", factory: llamaExtension, hidden: true }];
|
||||
@@ -0,0 +1,330 @@
|
||||
export type LlamaModelStatus = "unloaded" | "loading" | "loaded" | "downloading" | "sleeping";
|
||||
|
||||
export interface LlamaModelInfo {
|
||||
id: string;
|
||||
aliases?: string[];
|
||||
status: {
|
||||
value: LlamaModelStatus;
|
||||
args?: string[];
|
||||
failed?: boolean;
|
||||
exit_code?: number;
|
||||
progress?: Record<string, { done: number; total: number }>;
|
||||
};
|
||||
architecture?: {
|
||||
input_modalities?: string[];
|
||||
output_modalities?: string[];
|
||||
};
|
||||
source?: string;
|
||||
meta?: {
|
||||
n_ctx?: number;
|
||||
n_ctx_train?: number;
|
||||
size?: number;
|
||||
ftype?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LlamaModelsResponse {
|
||||
data: LlamaModelInfo[];
|
||||
object?: string;
|
||||
}
|
||||
|
||||
export interface LlamaModelEvent {
|
||||
model: string;
|
||||
event: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface LlamaProgress {
|
||||
message: string;
|
||||
ratio?: number;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
function errorMessage(payload: unknown, fallback: string): string {
|
||||
if (typeof payload !== "object" || payload === null) return fallback;
|
||||
const error = (payload as { error?: unknown }).error;
|
||||
if (typeof error !== "object" || error === null) return fallback;
|
||||
const message = (error as { message?: unknown }).message;
|
||||
return typeof message === "string" && message ? message : fallback;
|
||||
}
|
||||
|
||||
function isModelInfo(value: unknown): value is LlamaModelInfo {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as { id?: unknown; status?: { value?: unknown } };
|
||||
return typeof candidate.id === "string" && typeof candidate.status?.value === "string";
|
||||
}
|
||||
|
||||
function linkSignal(source: AbortSignal | undefined, target: AbortController): () => void {
|
||||
if (!source) return () => {};
|
||||
if (source.aborted) {
|
||||
target.abort(source.reason);
|
||||
return () => {};
|
||||
}
|
||||
const abort = () => target.abort(source.reason);
|
||||
source.addEventListener("abort", abort, { once: true });
|
||||
return () => source.removeEventListener("abort", abort);
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason ?? new Error("Cancelled"));
|
||||
return;
|
||||
}
|
||||
const abort = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(signal?.reason ?? new Error("Cancelled"));
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function parseLoadProgress(data: unknown): LlamaProgress | undefined {
|
||||
if (typeof data !== "object" || data === null) return undefined;
|
||||
const progress = (data as { progress?: unknown }).progress;
|
||||
if (typeof progress !== "object" || progress === null) return undefined;
|
||||
const value = progress as { stages?: unknown; current?: unknown; stage?: unknown; value?: unknown };
|
||||
const stage =
|
||||
typeof value.current === "string" ? value.current : typeof value.stage === "string" ? value.stage : undefined;
|
||||
const stages = Array.isArray(value.stages)
|
||||
? value.stages.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
const stageRatio = typeof value.value === "number" ? Math.max(0, Math.min(1, value.value)) : undefined;
|
||||
let ratio = stageRatio;
|
||||
if (stage && stages.length > 0) {
|
||||
const index = stages.indexOf(stage);
|
||||
if (index >= 0) ratio = (index + (stageRatio ?? 0)) / stages.length;
|
||||
}
|
||||
return {
|
||||
message: stage ? `Loading ${stage.replaceAll("_", " ")}` : "Loading model",
|
||||
ratio,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDownloadProgress(data: unknown): LlamaProgress | undefined {
|
||||
if (typeof data !== "object" || data === null) return undefined;
|
||||
let done = 0;
|
||||
let total = 0;
|
||||
for (const value of Object.values(data as Record<string, unknown>)) {
|
||||
if (typeof value !== "object" || value === null) continue;
|
||||
const entry = value as { done?: unknown; total?: unknown };
|
||||
if (typeof entry.done !== "number" || typeof entry.total !== "number") continue;
|
||||
done += entry.done;
|
||||
total += entry.total;
|
||||
}
|
||||
if (total <= 0) return undefined;
|
||||
return {
|
||||
message: "Downloading model",
|
||||
ratio: done / total,
|
||||
detail: `${formatBytes(done)} / ${formatBytes(total)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = units[0]!;
|
||||
for (let index = 1; index < units.length && value >= 1024; index++) {
|
||||
value /= 1024;
|
||||
unit = units[index]!;
|
||||
}
|
||||
return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} ${unit}`;
|
||||
}
|
||||
|
||||
export function normalizeLlamaServerUrl(value: string): string {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("Server URL must use http or https");
|
||||
}
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
url.pathname = url.pathname.replace(/\/+$/u, "").replace(/\/v1$/u, "") || "/";
|
||||
return url.toString().replace(/\/$/u, "");
|
||||
}
|
||||
|
||||
export function llamaInferenceUrl(serverUrl: string): string {
|
||||
return `${normalizeLlamaServerUrl(serverUrl)}/v1`;
|
||||
}
|
||||
|
||||
export class LlamaClient {
|
||||
readonly serverUrl: string;
|
||||
private readonly apiKey: string | undefined;
|
||||
|
||||
constructor(serverUrl: string, apiKey?: string) {
|
||||
this.serverUrl = normalizeLlamaServerUrl(serverUrl);
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
private async request(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (init.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (this.apiKey) headers.set("Authorization", `Bearer ${this.apiKey}`);
|
||||
const timeout = AbortSignal.timeout(15_000);
|
||||
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
|
||||
const response = await fetch(`${this.serverUrl}${path}`, { ...init, headers, signal });
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = undefined;
|
||||
}
|
||||
if (!response.ok) throw new Error(errorMessage(payload, `llama.cpp returned HTTP ${response.status}`));
|
||||
return payload;
|
||||
}
|
||||
|
||||
async list(options: { reload?: boolean; signal?: AbortSignal } = {}): Promise<LlamaModelInfo[]> {
|
||||
const payload = await this.request(`/models${options.reload ? "?reload=1" : ""}`, { signal: options.signal });
|
||||
if (typeof payload !== "object" || payload === null || !Array.isArray((payload as { data?: unknown }).data)) {
|
||||
throw new Error("llama.cpp returned an invalid model catalog");
|
||||
}
|
||||
const data = (payload as { data: unknown[] }).data;
|
||||
if (!data.every(isModelInfo)) throw new Error("Server is not running in llama.cpp router mode");
|
||||
return data;
|
||||
}
|
||||
|
||||
async load(model: string, signal?: AbortSignal): Promise<void> {
|
||||
await this.request("/models/load", { method: "POST", body: JSON.stringify({ model }), signal });
|
||||
}
|
||||
|
||||
async unload(model: string, signal?: AbortSignal): Promise<void> {
|
||||
await this.request("/models/unload", { method: "POST", body: JSON.stringify({ model }), signal });
|
||||
}
|
||||
|
||||
async unloadAndWait(model: string, signal?: AbortSignal): Promise<void> {
|
||||
await this.unload(model, signal);
|
||||
while (true) {
|
||||
const entry = (await this.list({ signal })).find((candidate) => candidate.id === model);
|
||||
if (!entry || entry.status.value === "unloaded") return;
|
||||
await sleep(100, signal);
|
||||
}
|
||||
}
|
||||
|
||||
async download(model: string, signal?: AbortSignal): Promise<void> {
|
||||
await this.request("/models", { method: "POST", body: JSON.stringify({ model }), signal });
|
||||
}
|
||||
|
||||
async watch(onEvent: (event: LlamaModelEvent) => void, signal?: AbortSignal): Promise<void> {
|
||||
const headers = new Headers();
|
||||
if (this.apiKey) headers.set("Authorization", `Bearer ${this.apiKey}`);
|
||||
const response = await fetch(`${this.serverUrl}/models/sse`, { headers, signal });
|
||||
if (!response.ok || !response.body) throw new Error(`llama.cpp SSE returned HTTP ${response.status}`);
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
buffer += decoder.decode(chunk.value, { stream: true }).replaceAll("\r\n", "\n");
|
||||
let boundary = buffer.indexOf("\n\n");
|
||||
while (boundary >= 0) {
|
||||
const frame = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
const data = frame
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join("\n");
|
||||
if (data) {
|
||||
try {
|
||||
const event = JSON.parse(data) as LlamaModelEvent;
|
||||
if (event && typeof event.model === "string" && typeof event.event === "string") onEvent(event);
|
||||
} catch {
|
||||
// Ignore malformed events; catalog polling remains authoritative.
|
||||
}
|
||||
}
|
||||
boundary = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadAndWait(
|
||||
model: string,
|
||||
onProgress: (progress: LlamaProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LlamaModelInfo> {
|
||||
const watcher = new AbortController();
|
||||
const unlink = linkSignal(signal, watcher);
|
||||
let eventLoaded = false;
|
||||
let eventError: string | undefined;
|
||||
void this.watch((event) => {
|
||||
if (event.model !== model) return;
|
||||
if (event.event !== "model_status" && event.event !== "status_change") return;
|
||||
const data = event.data as { status?: unknown } | undefined;
|
||||
if (data?.status === "loaded") eventLoaded = true;
|
||||
if (data?.status === "unloaded") eventError = "Model failed to load";
|
||||
const progress = parseLoadProgress(event.data);
|
||||
if (progress) onProgress(progress);
|
||||
}, watcher.signal).catch(() => {});
|
||||
try {
|
||||
await this.load(model, signal);
|
||||
onProgress({ message: "Loading model" });
|
||||
while (true) {
|
||||
if (signal?.aborted) throw signal.reason ?? new Error("Cancelled");
|
||||
const entry = (await this.list({ signal })).find((candidate) => candidate.id === model);
|
||||
if (entry?.status.value === "loaded") return entry;
|
||||
if (eventLoaded && !entry) return { id: model, status: { value: "loaded" } };
|
||||
if (entry?.status.failed || eventError) {
|
||||
throw new Error(
|
||||
entry?.status.exit_code === undefined
|
||||
? (eventError ?? "Model failed to load")
|
||||
: `Model exited with code ${entry.status.exit_code}`,
|
||||
);
|
||||
}
|
||||
await sleep(250, signal);
|
||||
}
|
||||
} finally {
|
||||
unlink();
|
||||
watcher.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAndWait(
|
||||
model: string,
|
||||
onProgress: (progress: LlamaProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LlamaModelInfo[]> {
|
||||
const watcher = new AbortController();
|
||||
const unlink = linkSignal(signal, watcher);
|
||||
let finished = false;
|
||||
let failure: string | undefined;
|
||||
let sawDownloading = false;
|
||||
let polls = 0;
|
||||
void this.watch((event) => {
|
||||
if (event.model !== model) return;
|
||||
if (event.event === "download_finished") finished = true;
|
||||
if (event.event === "download_failed") failure = errorMessage(event.data, "Download failed");
|
||||
if (event.event === "download_progress") {
|
||||
sawDownloading = true;
|
||||
const progress = parseDownloadProgress(event.data);
|
||||
if (progress) onProgress(progress);
|
||||
}
|
||||
}, watcher.signal).catch(() => {});
|
||||
try {
|
||||
await this.download(model, signal);
|
||||
onProgress({ message: "Downloading model" });
|
||||
while (true) {
|
||||
if (signal?.aborted) throw signal.reason ?? new Error("Cancelled");
|
||||
if (failure) throw new Error(failure);
|
||||
const models = await this.list({ signal });
|
||||
polls++;
|
||||
const entry = models.find((candidate) => candidate.id === model);
|
||||
if (entry?.status.value === "downloading") {
|
||||
sawDownloading = true;
|
||||
const progress = parseDownloadProgress(entry.status.progress);
|
||||
if (progress) onProgress(progress);
|
||||
} else if (finished || (entry && (sawDownloading || polls >= 2))) {
|
||||
return this.list({ reload: true, signal });
|
||||
}
|
||||
await sleep(500, signal);
|
||||
}
|
||||
} finally {
|
||||
unlink();
|
||||
watcher.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "../../core/extensions/types.ts";
|
||||
import { LlamaClient, type LlamaModelInfo, normalizeLlamaServerUrl } from "./client.ts";
|
||||
import { createLlamaProvider, LLAMA_PROVIDER_ID } from "./provider.ts";
|
||||
import { type LlamaUi, runWithProgress, showLlamaUi } from "./ui.ts";
|
||||
|
||||
function modelIsLoaded(model: LlamaModelInfo): boolean {
|
||||
return model.status.value === "loaded" || model.status.value === "sleeping";
|
||||
}
|
||||
|
||||
function isConnectionError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const message = `${error.name} ${error.message}`.toLowerCase();
|
||||
return message.includes("fetch failed") || message.includes("timeout") || message.includes("network");
|
||||
}
|
||||
|
||||
function connectionErrorMessage(error: unknown): string {
|
||||
if (isConnectionError(error)) return "Could not connect to the server.";
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function configuredClient(ctx: ExtensionCommandContext): Promise<LlamaClient | undefined> {
|
||||
const result = await ctx.modelRegistry.getProviderAuth(LLAMA_PROVIDER_ID);
|
||||
if (!result) {
|
||||
ctx.ui.notify(`Configure llama.cpp with /login ${LLAMA_PROVIDER_ID}`, "warning");
|
||||
return undefined;
|
||||
}
|
||||
const configuredUrl = result.env?.LLAMA_BASE_URL;
|
||||
const serverUrl = normalizeLlamaServerUrl(
|
||||
typeof configuredUrl === "string" && configuredUrl ? configuredUrl : (result.auth.baseUrl ?? ""),
|
||||
);
|
||||
return new LlamaClient(serverUrl, result.auth.apiKey);
|
||||
}
|
||||
|
||||
export default function llamaExtension(pi: ExtensionAPI): void {
|
||||
const provider = createLlamaProvider();
|
||||
pi.registerProvider(provider.provider);
|
||||
|
||||
const syncCatalog = async (
|
||||
ctx: ExtensionCommandContext,
|
||||
client: LlamaClient,
|
||||
catalog?: LlamaModelInfo[],
|
||||
): Promise<LlamaModelInfo[]> => {
|
||||
const current = catalog ?? (await client.list());
|
||||
provider.setCatalog(current, client.serverUrl);
|
||||
await ctx.modelRegistry.refresh();
|
||||
return current;
|
||||
};
|
||||
|
||||
const loadModel = async (
|
||||
ctx: ExtensionCommandContext,
|
||||
ui: LlamaUi,
|
||||
client: LlamaClient,
|
||||
catalog: LlamaModelInfo[],
|
||||
target: LlamaModelInfo,
|
||||
): Promise<void> => {
|
||||
const loaded = catalog.filter((model) => model.id !== target.id && modelIsLoaded(model));
|
||||
let replace = false;
|
||||
if (loaded.length > 0) {
|
||||
const choice = await ui.select(`${loaded.length} model${loaded.length === 1 ? " is" : "s are"} loaded`, [
|
||||
"Unload all and load",
|
||||
"Keep loaded and load",
|
||||
"Cancel",
|
||||
]);
|
||||
if (!choice || choice === "Cancel") return;
|
||||
replace = choice === "Unload all and load";
|
||||
}
|
||||
|
||||
const restoreLoaded = async (): Promise<void> => {
|
||||
ctx.ui.notify("Restoring previously loaded models");
|
||||
for (const model of loaded) await client.loadAndWait(model.id, () => {});
|
||||
await syncCatalog(ctx, client);
|
||||
};
|
||||
if (replace) {
|
||||
for (const model of loaded) await client.unloadAndWait(model.id);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runWithProgress(ui, {
|
||||
title: "Loading model",
|
||||
model: target.id,
|
||||
initialMessage: "Starting…",
|
||||
cancelTitle: "Stop loading?",
|
||||
cancelMessage: `Stop loading ${target.id}?`,
|
||||
run: (signal, update) => client.loadAndWait(target.id, update, signal),
|
||||
cancel: () => client.unload(target.id),
|
||||
});
|
||||
if (result.cancelled) {
|
||||
if (replace) await restoreLoaded();
|
||||
return;
|
||||
}
|
||||
const refreshed = await syncCatalog(ctx, client);
|
||||
const loadedModel = refreshed.find((model) => model.id === target.id);
|
||||
ctx.ui.notify(
|
||||
loadedModel?.status.value === "loaded" ? `Loaded ${target.id}` : `Load started for ${target.id}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (replace) {
|
||||
try {
|
||||
await restoreLoaded();
|
||||
} catch {
|
||||
// Preserve the original load error.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const unloadModel = async (
|
||||
ctx: ExtensionCommandContext,
|
||||
ui: LlamaUi,
|
||||
client: LlamaClient,
|
||||
model: LlamaModelInfo,
|
||||
): Promise<void> => {
|
||||
if (!(await ui.confirm("Unload model?", `Unload ${model.id}?`))) return;
|
||||
await client.unloadAndWait(model.id);
|
||||
await syncCatalog(ctx, client);
|
||||
ctx.ui.notify(`Unloaded ${model.id}`);
|
||||
};
|
||||
|
||||
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 result = await runWithProgress(ui, {
|
||||
title: "Downloading model",
|
||||
model,
|
||||
initialMessage: "Starting…",
|
||||
cancelTitle: "Stop download?",
|
||||
cancelMessage: `Stop downloading ${model}?`,
|
||||
run: (signal, update) => client.downloadAndWait(model, update, signal),
|
||||
cancel: () => client.unload(model),
|
||||
});
|
||||
if (result.cancelled) return;
|
||||
await syncCatalog(ctx, client, result.value);
|
||||
ctx.ui.notify(`Downloaded ${model}`);
|
||||
};
|
||||
|
||||
pi.registerCommand("llama", {
|
||||
description: "Manage llama.cpp router models",
|
||||
handler: async (_args, ctx) => {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("/llama is available in interactive mode", "warning");
|
||||
return;
|
||||
}
|
||||
const client = await configuredClient(ctx);
|
||||
if (!client) return;
|
||||
await showLlamaUi(ctx, async (ui) => {
|
||||
const readCatalog = async (): Promise<LlamaModelInfo[] | undefined> => {
|
||||
while (true) {
|
||||
try {
|
||||
return await syncCatalog(ctx, client);
|
||||
} catch (error) {
|
||||
if ((await ui.connectionError(client.serverUrl, connectionErrorMessage(error))) === "close") {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let catalog = await readCatalog();
|
||||
if (!catalog) return;
|
||||
while (true) {
|
||||
const action = await ui.showModels(client.serverUrl, catalog);
|
||||
if (action.type === "close") return;
|
||||
let actionError: unknown;
|
||||
try {
|
||||
if (action.type === "download") await downloadModel(ctx, ui, client);
|
||||
else if (modelIsLoaded(action.model)) await unloadModel(ctx, ui, client, action.model);
|
||||
else if (action.model.status.value === "unloaded")
|
||||
await loadModel(ctx, ui, client, catalog, action.model);
|
||||
else ctx.ui.notify(`${action.model.id} is ${action.model.status.value}`, "warning");
|
||||
} catch (error) {
|
||||
actionError = error;
|
||||
}
|
||||
const refreshed = await readCatalog();
|
||||
if (!refreshed) return;
|
||||
catalog = refreshed;
|
||||
if (actionError && !isConnectionError(actionError)) {
|
||||
ctx.ui.notify(actionError instanceof Error ? actionError.message : String(actionError), "error");
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type {
|
||||
ApiKeyCredential,
|
||||
AuthContext,
|
||||
AuthResult,
|
||||
Model,
|
||||
Provider,
|
||||
ProviderStreamOptions,
|
||||
RefreshModelsContext,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { stream, streamSimple } from "@earendil-works/pi-ai/compat";
|
||||
import { LlamaClient, type LlamaModelInfo, llamaInferenceUrl, normalizeLlamaServerUrl } from "./client.ts";
|
||||
|
||||
export const LLAMA_PROVIDER_ID = "llama.cpp";
|
||||
export const DEFAULT_LLAMA_SERVER_URL = "http://127.0.0.1:8080";
|
||||
const DEFAULT_MAX_TOKENS = 16384;
|
||||
|
||||
function credentialServerUrl(credential: ApiKeyCredential | undefined): string | undefined {
|
||||
const value = credential?.env?.LLAMA_BASE_URL;
|
||||
return typeof value === "string" && value.trim() ? normalizeLlamaServerUrl(value) : undefined;
|
||||
}
|
||||
|
||||
async function resolveServerUrl(
|
||||
ctx: AuthContext,
|
||||
credential: ApiKeyCredential | undefined,
|
||||
): Promise<string | undefined> {
|
||||
const configured = credentialServerUrl(credential) ?? (await ctx.env("LLAMA_BASE_URL"))?.trim();
|
||||
return configured ? normalizeLlamaServerUrl(configured) : undefined;
|
||||
}
|
||||
|
||||
function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-completions"> {
|
||||
const reportedContextWindow = model.meta?.n_ctx ?? model.meta?.n_ctx_train;
|
||||
const contextWindow = reportedContextWindow && reportedContextWindow > 0 ? reportedContextWindow : 128000;
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
api: "openai-completions",
|
||||
provider: LLAMA_PROVIDER_ID,
|
||||
baseUrl: llamaInferenceUrl(serverUrl),
|
||||
reasoning: false,
|
||||
input: model.architecture?.input_modalities?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow,
|
||||
maxTokens: Math.min(DEFAULT_MAX_TOKENS, contextWindow),
|
||||
compat: {
|
||||
supportsStore: false,
|
||||
supportsDeveloperRole: false,
|
||||
supportsReasoningEffort: false,
|
||||
supportsUsageInStreaming: false,
|
||||
supportsStrictMode: false,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface LlamaProviderController {
|
||||
provider: Provider<"openai-completions">;
|
||||
setCatalog(models: readonly LlamaModelInfo[], serverUrl: string): void;
|
||||
}
|
||||
|
||||
export function createLlamaProvider(): LlamaProviderController {
|
||||
let models: readonly Model<"openai-completions">[] = [];
|
||||
|
||||
const setCatalog = (catalog: readonly LlamaModelInfo[], serverUrl: string): void => {
|
||||
models = catalog.filter((model) => model.status.value === "loaded").map((model) => toPiModel(model, serverUrl));
|
||||
};
|
||||
|
||||
const provider: Provider<"openai-completions"> = {
|
||||
id: LLAMA_PROVIDER_ID,
|
||||
name: "llama.cpp",
|
||||
baseUrl: llamaInferenceUrl(DEFAULT_LLAMA_SERVER_URL),
|
||||
auth: {
|
||||
apiKey: {
|
||||
name: "llama.cpp server",
|
||||
login: async (interaction): Promise<ApiKeyCredential> => {
|
||||
const enteredUrl = await interaction.prompt({
|
||||
type: "text",
|
||||
message: "llama.cpp server URL",
|
||||
placeholder: process.env.LLAMA_BASE_URL ?? DEFAULT_LLAMA_SERVER_URL,
|
||||
});
|
||||
const serverUrl = normalizeLlamaServerUrl(
|
||||
enteredUrl.trim() || process.env.LLAMA_BASE_URL || DEFAULT_LLAMA_SERVER_URL,
|
||||
);
|
||||
const apiKey = (
|
||||
await interaction.prompt({
|
||||
type: "secret",
|
||||
message: "API key (optional)",
|
||||
})
|
||||
).trim();
|
||||
await new LlamaClient(serverUrl, apiKey || undefined).list({ signal: interaction.signal });
|
||||
return {
|
||||
type: "api_key",
|
||||
key: apiKey || undefined,
|
||||
env: { LLAMA_BASE_URL: serverUrl },
|
||||
};
|
||||
},
|
||||
check: async ({ ctx, credential }) => {
|
||||
const serverUrl = await resolveServerUrl(ctx, credential);
|
||||
return serverUrl
|
||||
? { type: "api_key", source: credential ? "stored credential" : "LLAMA_BASE_URL" }
|
||||
: undefined;
|
||||
},
|
||||
resolve: async ({ ctx, credential }): Promise<AuthResult | undefined> => {
|
||||
const serverUrl = await resolveServerUrl(ctx, credential);
|
||||
if (!serverUrl) return undefined;
|
||||
const apiKey = credential?.key ?? (await ctx.env("LLAMA_API_KEY")) ?? "local";
|
||||
return {
|
||||
auth: { apiKey, baseUrl: llamaInferenceUrl(serverUrl) },
|
||||
env: { ...credential?.env, LLAMA_BASE_URL: serverUrl },
|
||||
source: credential ? "stored credential" : "LLAMA_BASE_URL",
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
getModels: () => models,
|
||||
refreshModels: async (context: RefreshModelsContext): Promise<void> => {
|
||||
if (!context.allowNetwork || context.signal?.aborted || context.credential?.type !== "api_key") return;
|
||||
const serverUrl = credentialServerUrl(context.credential);
|
||||
if (!serverUrl) return;
|
||||
const catalog = await new LlamaClient(serverUrl, context.credential.key).list({ signal: context.signal });
|
||||
setCatalog(catalog, serverUrl);
|
||||
},
|
||||
stream: (model, context, options) => stream(model, context, options as ProviderStreamOptions | undefined),
|
||||
streamSimple: (model, context, options) => streamSimple(model, context, options),
|
||||
};
|
||||
|
||||
return { provider, setCatalog };
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import {
|
||||
Container,
|
||||
type Focusable,
|
||||
Input,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Spacer,
|
||||
Text,
|
||||
type TUI,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import type { ExtensionCommandContext } from "../../core/extensions/types.ts";
|
||||
import type { KeybindingsManager } from "../../core/keybindings.ts";
|
||||
import { DynamicBorder } from "../../modes/interactive/components/dynamic-border.ts";
|
||||
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";
|
||||
|
||||
const DOWNLOAD_VALUE = "\0download";
|
||||
|
||||
export type LlamaManagerAction = { type: "model"; model: LlamaModelInfo } | { type: "download" } | { type: "close" };
|
||||
|
||||
interface ProgressState extends LlamaProgress {
|
||||
title: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function contextLabel(model: LlamaModelInfo): string | undefined {
|
||||
const context = model.meta?.n_ctx ?? model.meta?.n_ctx_train;
|
||||
if (context) return context >= 1000 ? `${Math.round(context / 1000)}k` : String(context);
|
||||
const args = model.status.args ?? [];
|
||||
for (let index = 0; index < args.length - 1; index++) {
|
||||
if (args[index] !== "--ctx-size" && args[index] !== "-c" && args[index] !== "-ctx") continue;
|
||||
const value = Number(args[index + 1]);
|
||||
if (Number.isFinite(value) && value > 0) return value >= 1000 ? `${Math.round(value / 1000)}k` : String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function modelDescription(model: LlamaModelInfo): string {
|
||||
const details: string[] = [];
|
||||
const loaded = model.status.value === "loaded" || model.status.value === "sleeping";
|
||||
if (loaded) details.push("loaded");
|
||||
else if (model.status.value !== "unloaded") details.push(model.status.value);
|
||||
const context = loaded ? contextLabel(model) : undefined;
|
||||
if (context) details.push(`${context} context`);
|
||||
return details.join(" · ");
|
||||
}
|
||||
|
||||
function selectTheme(theme: Theme) {
|
||||
return {
|
||||
selectedPrefix: (text: string) => theme.fg("accent", text),
|
||||
selectedText: (text: string) => theme.fg("accent", text),
|
||||
description: (text: string) => theme.fg("muted", text),
|
||||
scrollInfo: (text: string) => theme.fg("dim", text),
|
||||
noMatch: (text: string) => theme.fg("warning", text),
|
||||
};
|
||||
}
|
||||
|
||||
function frame(
|
||||
theme: Theme,
|
||||
title: string,
|
||||
body: Array<Text | Spacer | SelectList | Input>,
|
||||
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));
|
||||
for (const child of body) container.addChild(child);
|
||||
if (footer) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("dim", footer), 1, 0));
|
||||
}
|
||||
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
||||
return container;
|
||||
}
|
||||
|
||||
export interface LlamaUi {
|
||||
showModels(serverUrl: string, models: LlamaModelInfo[]): Promise<LlamaManagerAction>;
|
||||
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>;
|
||||
progress(state: ProgressState): Promise<void>;
|
||||
updateProgress(state: ProgressState): void;
|
||||
}
|
||||
|
||||
class LlamaView implements LlamaUi, Focusable {
|
||||
private readonly tui: TUI;
|
||||
private readonly theme: Theme;
|
||||
private readonly keybindings: KeybindingsManager;
|
||||
private content: Container;
|
||||
private inputHandler: { handleInput?(data: string): void } | undefined;
|
||||
private inputTarget: Focusable | undefined;
|
||||
private progressPromise: Promise<void> | undefined;
|
||||
private progressResolver: (() => void) | undefined;
|
||||
private showingProgress = false;
|
||||
private _focused = false;
|
||||
|
||||
constructor(tui: TUI, theme: Theme, keybindings: KeybindingsManager) {
|
||||
this.tui = tui;
|
||||
this.theme = theme;
|
||||
this.keybindings = keybindings;
|
||||
this.content = frame(theme, "llama.cpp models", [new Text(theme.fg("muted", "Loading…"), 1, 1)]);
|
||||
}
|
||||
|
||||
get focused(): boolean {
|
||||
return this._focused;
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
this._focused = value;
|
||||
if (this.inputTarget) this.inputTarget.focused = value;
|
||||
}
|
||||
|
||||
private setContent(
|
||||
content: Container,
|
||||
inputHandler?: { handleInput?(data: string): void },
|
||||
inputTarget?: Focusable,
|
||||
): void {
|
||||
if (this.inputTarget) this.inputTarget.focused = false;
|
||||
this.progressPromise = undefined;
|
||||
this.progressResolver = undefined;
|
||||
this.showingProgress = false;
|
||||
this.content = content;
|
||||
this.inputHandler = inputHandler;
|
||||
this.inputTarget = inputTarget;
|
||||
if (this.inputTarget) this.inputTarget.focused = this._focused;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
showModels(serverUrl: string, models: LlamaModelInfo[]): Promise<LlamaManagerAction> {
|
||||
const sorted = [...models].sort((left, right) => {
|
||||
const loaded = Number(right.status.value === "loaded") - Number(left.status.value === "loaded");
|
||||
return loaded || left.id.localeCompare(right.id);
|
||||
});
|
||||
const byId = new Map(sorted.map((model) => [model.id, model]));
|
||||
const items: SelectItem[] = [
|
||||
...sorted.map((model) => ({
|
||||
value: model.id,
|
||||
label: model.id,
|
||||
description: modelDescription(model),
|
||||
})),
|
||||
{ value: DOWNLOAD_VALUE, label: "Download model…", description: "Hugging Face owner/repository[:quant]" },
|
||||
];
|
||||
return new Promise((resolve) => {
|
||||
const list = new SelectList(items, Math.min(items.length, 12), selectTheme(this.theme), {
|
||||
minPrimaryColumnWidth: 36,
|
||||
maxPrimaryColumnWidth: 56,
|
||||
});
|
||||
list.onSelect = (item) => {
|
||||
if (item.value === DOWNLOAD_VALUE) resolve({ type: "download" });
|
||||
else {
|
||||
const model = byId.get(item.value);
|
||||
if (model) resolve({ type: "model", model });
|
||||
}
|
||||
};
|
||||
list.onCancel = () => resolve({ type: "close" });
|
||||
this.setContent(
|
||||
frame(
|
||||
this.theme,
|
||||
"llama.cpp models",
|
||||
[new Text(this.theme.fg("dim", serverUrl), 1, 0), new Spacer(1), list],
|
||||
`${keyHint("tui.select.confirm", "load/unload/download")} • ${keyHint("tui.select.cancel", "close")}`,
|
||||
),
|
||||
list,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
select(title: string, options: string[]): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const list = new SelectList(
|
||||
options.map((option) => ({ value: option, label: option })),
|
||||
options.length,
|
||||
selectTheme(this.theme),
|
||||
);
|
||||
list.onSelect = (item) => resolve(item.value);
|
||||
list.onCancel = () => resolve(undefined);
|
||||
this.setContent(
|
||||
frame(
|
||||
this.theme,
|
||||
title,
|
||||
[new Spacer(1), list],
|
||||
`${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`,
|
||||
),
|
||||
list,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async confirm(title: string, message: string): Promise<boolean> {
|
||||
return (await this.select(`${title}\n${message}`, ["Yes", "No"])) === "Yes";
|
||||
}
|
||||
|
||||
async connectionError(serverUrl: string, message: string): Promise<"retry" | "close"> {
|
||||
const choice = await this.select(`llama.cpp unavailable\n${serverUrl}\n\n${message}`, ["Retry", "Close"]);
|
||||
return choice === "Retry" ? "retry" : "close";
|
||||
}
|
||||
|
||||
input(title: string, placeholder: string): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const input = new Input();
|
||||
input.onSubmit = (value) => resolve(value);
|
||||
input.onEscape = () => resolve(undefined);
|
||||
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")}`,
|
||||
),
|
||||
input,
|
||||
input,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
progress(state: ProgressState): Promise<void> {
|
||||
if (!this.progressPromise) {
|
||||
this.progressPromise = new Promise((resolve) => {
|
||||
this.progressResolver = resolve;
|
||||
});
|
||||
}
|
||||
this.showingProgress = true;
|
||||
this.updateProgress(state);
|
||||
return this.progressPromise;
|
||||
}
|
||||
|
||||
updateProgress(state: ProgressState): void {
|
||||
if (!this.showingProgress) return;
|
||||
const body = [
|
||||
new Text(this.theme.fg("text", state.model), 1, 0),
|
||||
new Spacer(1),
|
||||
new Text(this.theme.fg("muted", state.message), 1, 0),
|
||||
];
|
||||
if (state.ratio !== undefined) {
|
||||
const available = 40;
|
||||
const filled = Math.round(Math.max(0, Math.min(1, state.ratio)) * available);
|
||||
body.push(
|
||||
new Text(
|
||||
this.theme.fg(
|
||||
"accent",
|
||||
`${"█".repeat(filled)}${"─".repeat(available - filled)} ${Math.round(state.ratio * 100)}%`,
|
||||
),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.detail) body.push(new Text(this.theme.fg("dim", state.detail), 1, 0));
|
||||
this.content = frame(this.theme, state.title, body, keyHint("tui.select.cancel", "stop"));
|
||||
this.inputHandler = undefined;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (this.progressResolver && this.keybindings.matches(data, "tui.select.cancel")) {
|
||||
const resolve = this.progressResolver;
|
||||
this.progressPromise = undefined;
|
||||
this.progressResolver = undefined;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.inputHandler?.handleInput?.(data);
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.content
|
||||
.render(width)
|
||||
.map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width, "") : line));
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.content.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
export async function showLlamaUi(ctx: ExtensionCommandContext, run: (ui: LlamaUi) => Promise<void>): Promise<void> {
|
||||
await ctx.ui.custom<void>((tui, theme, keybindings, done) => {
|
||||
const view = new LlamaView(tui, theme, keybindings);
|
||||
void run(view).then(
|
||||
() => done(),
|
||||
(error: unknown) => {
|
||||
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
||||
done();
|
||||
},
|
||||
);
|
||||
return view;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runWithProgress<T>(
|
||||
ui: LlamaUi,
|
||||
options: {
|
||||
title: string;
|
||||
model: string;
|
||||
initialMessage: string;
|
||||
cancelTitle: string;
|
||||
cancelMessage: string;
|
||||
run(signal: AbortSignal, update: (progress: LlamaProgress) => void): Promise<T>;
|
||||
cancel(): Promise<void>;
|
||||
},
|
||||
): Promise<{ cancelled: true } | { cancelled: false; value: T }> {
|
||||
const controller = new AbortController();
|
||||
const state: ProgressState = { title: options.title, model: options.model, message: options.initialMessage };
|
||||
const settled = options
|
||||
.run(controller.signal, (progress) => {
|
||||
Object.assign(state, progress);
|
||||
ui.updateProgress(state);
|
||||
})
|
||||
.then(
|
||||
(value) => ({ ok: true as const, value }),
|
||||
(error: unknown) => ({ ok: false as const, error }),
|
||||
);
|
||||
let completed = false;
|
||||
settled.finally(() => {
|
||||
completed = true;
|
||||
});
|
||||
|
||||
while (!completed) {
|
||||
const outcome = await Promise.race([
|
||||
settled.then(() => "settled" as const),
|
||||
ui.progress(state).then(() => "stop" as const),
|
||||
]);
|
||||
if (outcome === "settled") break;
|
||||
const stop = await ui.confirm(options.cancelTitle, options.cancelMessage);
|
||||
if (!stop || completed) continue;
|
||||
try {
|
||||
await options.cancel();
|
||||
} finally {
|
||||
controller.abort(new Error("Cancelled"));
|
||||
}
|
||||
await settled;
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
const result = await settled;
|
||||
if (!result.ok) throw result.error;
|
||||
return { cancelled: false, value: result.value };
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"
|
||||
import { SettingsManager } from "./core/settings-manager.ts";
|
||||
import { printTimings, resetTimings, time } from "./core/timings.ts";
|
||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { builtInExtensions } from "./extensions/index.ts";
|
||||
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
|
||||
@@ -471,6 +472,7 @@ export interface MainOptions {
|
||||
|
||||
export async function main(args: string[], options?: MainOptions) {
|
||||
resetTimings();
|
||||
const extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])];
|
||||
const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE);
|
||||
if (offlineMode) {
|
||||
process.env.PI_OFFLINE = "1";
|
||||
@@ -487,7 +489,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
applyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy);
|
||||
configureHttpDispatcher();
|
||||
|
||||
if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) {
|
||||
if (await handlePackageCommand(args, { extensionFactories })) {
|
||||
const exitCode = process.exitCode ?? 0;
|
||||
if (process.platform === "win32" && exitCode === 0 && args[0] === "update") {
|
||||
// We normally prefer process.exit(0) for package commands so bad extensions cannot keep
|
||||
@@ -500,7 +502,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await handleConfigCommand(args, { extensionFactories: options?.extensionFactories })) {
|
||||
if (await handleConfigCommand(args, { extensionFactories })) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -670,7 +672,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
noContextFiles: parsed.noContextFiles,
|
||||
systemPrompt: parsed.systemPrompt,
|
||||
appendSystemPrompt: parsed.appendSystemPrompt,
|
||||
extensionFactories: options?.extensionFactories,
|
||||
extensionFactories,
|
||||
},
|
||||
});
|
||||
const { settingsManager, modelRuntime, resourceLoader } = services;
|
||||
|
||||
@@ -1440,10 +1440,13 @@ export class InteractiveMode {
|
||||
const themesResult = this.session.resourceLoader.getThemes();
|
||||
const extensions =
|
||||
options?.extensions ??
|
||||
this.session.resourceLoader.getExtensions().extensions.map((extension) => ({
|
||||
path: extension.path,
|
||||
sourceInfo: extension.sourceInfo,
|
||||
}));
|
||||
this.session.resourceLoader
|
||||
.getExtensions()
|
||||
.extensions.filter((extension) => !extension.hidden)
|
||||
.map((extension) => ({
|
||||
path: extension.path,
|
||||
sourceInfo: extension.sourceInfo,
|
||||
}));
|
||||
const sourceInfos = new Map<string, SourceInfo>();
|
||||
for (const extension of extensions) {
|
||||
if (extension.sourceInfo) {
|
||||
|
||||
Reference in New Issue
Block a user