feat: Ollama models command, with load/pull/delete and unload #16
@@ -144,10 +144,19 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
|
|||||||
| Action | Notes |
|
| Action | Notes |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Use This Model** | The primary action. Immediate, and survives restarts. |
|
| **Use This Model** | The primary action. Immediate, and survives restarts. |
|
||||||
|
| **Unload Model** | Only on a row that is loaded. No confirmation, because nothing is deleted. |
|
||||||
| **Copy Model Name** | For pasting into the `Model` preference. |
|
| **Copy Model Name** | For pasting into the `Model` preference. |
|
||||||
| **Delete Model** | Confirmed first. A server you run only. |
|
| **Delete Model** | Confirmed first. A server you run only. |
|
||||||
| **Pull a Model…** | Confirmed by the download itself. A server you run only. |
|
| **Pull a Model…** | Confirmed by the download itself. A server you run only. |
|
||||||
|
|
||||||
|
### Loaded models
|
||||||
|
|
||||||
|
Installed and loaded are different states, and the list shows both. `GET /api/ps` reports what is holding memory right now, and those rows get a green `loaded` tag, so "why is my GPU busy" has an answer in the same place as "what do I have".
|
||||||
|
|
||||||
|
**Unload** frees the memory without deleting anything, which is why it needs no confirmation: the worst case is that the next question pays to load the model again. It is `POST /api/generate` with `keep_alive: 0` and no prompt, which is how Ollama expresses "release this now", and what its own `ollama stop` does. The reply reports `done_reason: "unload"`, and the toast says how much was freed.
|
||||||
|
|
||||||
|
A host that does not serve `/api/ps` (older builds answer 404) simply shows no loaded tags. That check resolving to nothing is not an error, and it must not take the model list down with it.
|
||||||
|
|
||||||
### Why a pick is not written to the preference
|
### Why a pick is not written to the preference
|
||||||
|
|
||||||
It cannot be. `@vicinae/api` exposes `getPreferenceValues` and nothing that sets one, so the only way to change the preference would be to write Vicinae's own config file. That does not work: preferences are read once at startup and held in memory, so an edit to `~/.config/vicinae/settings.json` is invisible until Vicinae restarts. This was measured rather than assumed. Pointing `ollamaHost` at a local listener that logged every request, then launching a command, showed the requests still going to the old host.
|
It cannot be. `@vicinae/api` exposes `getPreferenceValues` and nothing that sets one, so the only way to change the preference would be to write Vicinae's own config file. That does not work: preferences are read once at startup and held in memory, so an edit to `~/.config/vicinae/settings.json` is invisible until Vicinae restarts. This was measured rather than assumed. Pointing `ollamaHost` at a local listener that logged every request, then launching a command, showed the requests still going to the old host.
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@
|
|||||||
"name": "models",
|
"name": "models",
|
||||||
"title": "Ollama Models",
|
"title": "Ollama Models",
|
||||||
"subtitle": "Lam Agent",
|
"subtitle": "Lam Agent",
|
||||||
"description": "Browse the models your Ollama host can reach, pick the one Lam should use, and pull or delete models on a server you run. A pick takes effect immediately; a pull keeps downloading until it finishes, so leave the launcher open.",
|
"description": "Browse the models your Ollama host can reach, pick the one Lam should use, unload one that is holding memory, and pull or delete models on a server you run. A pick takes effect immediately; a pull keeps downloading until it finishes, so leave the launcher open.",
|
||||||
"mode": "view"
|
"mode": "view"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import {
|
|||||||
describePullProgress,
|
describePullProgress,
|
||||||
isSelfHosted,
|
isSelfHosted,
|
||||||
listModels,
|
listModels,
|
||||||
|
listRunningModels,
|
||||||
modelCapabilities,
|
modelCapabilities,
|
||||||
normalizeHost,
|
normalizeHost,
|
||||||
parseNdjsonStream,
|
parseNdjsonStream,
|
||||||
pullModel,
|
pullModel,
|
||||||
supportsVision,
|
supportsVision,
|
||||||
|
unloadModel,
|
||||||
webFetch,
|
webFetch,
|
||||||
webSearch,
|
webSearch,
|
||||||
} from "../lib/ollama";
|
} from "../lib/ollama";
|
||||||
@@ -490,3 +492,111 @@ describe("web tool paths", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("listRunningModels", () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Trimmed from a real `GET /api/ps` on Ollama 0.35.1 with one model in VRAM. */
|
||||||
|
const realPayload = {
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: "ornith-1.5:9b",
|
||||||
|
model: "ornith-1.5:9b",
|
||||||
|
size: 10413733314,
|
||||||
|
size_vram: 10413733314,
|
||||||
|
expires_at: "2026-10-05T17:44:11.125356+08:00",
|
||||||
|
context_length: 131072,
|
||||||
|
details: { parameter_size: "9.0B" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reads what is in memory, including when it will go on its own", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(realPayload), { status: 200 }));
|
||||||
|
|
||||||
|
const running = await listRunningModels({ host: "http://localhost:11434", apiKey: "k" });
|
||||||
|
|
||||||
|
expect(running).toEqual([
|
||||||
|
{
|
||||||
|
name: "ornith-1.5:9b",
|
||||||
|
parameterSize: "9.0B",
|
||||||
|
sizeVram: 10413733314,
|
||||||
|
expiresAt: Date.parse("2026-10-05T17:44:11.125356+08:00"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/ps");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty, not an error, when the server does not serve /api/ps", async () => {
|
||||||
|
// Older builds answer 404. Taking the whole model list down because the
|
||||||
|
// *running* list is unavailable would be a bad trade.
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }));
|
||||||
|
|
||||||
|
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty when the request fails outright", async () => {
|
||||||
|
fetchMock.mockRejectedValueOnce(new Error("connection refused"));
|
||||||
|
|
||||||
|
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copes with a model holding no VRAM figure, as a CPU-offloaded one would", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ models: [{ name: "cpu-model:1b", size_vram: 0 }] }), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([
|
||||||
|
{ name: "cpu-model:1b", parameterSize: undefined, sizeVram: 0, expiresAt: undefined },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unloadModel", () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts keep_alive 0 with no prompt, which is how Ollama is told to release a model", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ done: true, done_reason: "unload" }), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await unloadModel({ host: "http://localhost:11434", apiKey: "k", model: "ornith-1.5:9b" });
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("http://localhost:11434/api/generate");
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
// No `prompt` key at all, so this can never generate a completion.
|
||||||
|
expect(JSON.parse(init.body)).toEqual({
|
||||||
|
model: "ornith-1.5:9b",
|
||||||
|
keep_alive: 0,
|
||||||
|
stream: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a refusal rather than pretending the memory came back", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
unloadModel({ host: "http://localhost:11434", apiKey: "k", model: "x:1" }),
|
||||||
|
).rejects.toThrow(/HTTP 500/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -263,6 +263,108 @@ export interface DeleteModelOptions {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A model the server has in memory right now, as reported by `GET /api/ps`.
|
||||||
|
*
|
||||||
|
* Distinct from {@link ModelInfo}, which is what is on disk. A model can be
|
||||||
|
* installed without being loaded, and loaded models are the ones holding VRAM.
|
||||||
|
*/
|
||||||
|
export interface RunningModel {
|
||||||
|
name: string;
|
||||||
|
parameterSize?: string;
|
||||||
|
/**
|
||||||
|
* Bytes held in memory. Equal to the on-disk size when the model is fully
|
||||||
|
* offloaded to the GPU, and less when part of it is running on the CPU.
|
||||||
|
*/
|
||||||
|
sizeVram?: number;
|
||||||
|
/** When the server will drop it on its own, as a Unix timestamp. */
|
||||||
|
expiresAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListRunningModelsOptions {
|
||||||
|
host: string;
|
||||||
|
apiKey: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The models holding memory on this server.
|
||||||
|
*
|
||||||
|
* Resolves to an empty list rather than throwing. Nothing here is worth failing
|
||||||
|
* over: a server that does not serve `/api/ps` (older builds answer 404) simply
|
||||||
|
* has nothing known to be loaded, and taking the whole model list down because
|
||||||
|
* the *running* list is unavailable would be a bad trade.
|
||||||
|
*/
|
||||||
|
export async function listRunningModels(options: ListRunningModelsOptions): Promise<RunningModel[]> {
|
||||||
|
const host = normalizeHost(options.host);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${host}/api/ps`, {
|
||||||
|
headers: authHeaders(options.apiKey),
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
models?: Array<{
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
size_vram?: number;
|
||||||
|
expires_at?: string;
|
||||||
|
details?: { parameter_size?: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (payload.models ?? [])
|
||||||
|
.map((model) => ({
|
||||||
|
name: model.name ?? model.model ?? "",
|
||||||
|
parameterSize: model.details?.parameter_size,
|
||||||
|
sizeVram: typeof model.size_vram === "number" ? model.size_vram : undefined,
|
||||||
|
expiresAt: model.expires_at ? Date.parse(model.expires_at) : undefined,
|
||||||
|
}))
|
||||||
|
.filter((model) => model.name.length > 0);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnloadModelOptions {
|
||||||
|
host: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops a model out of memory, freeing the VRAM it was holding.
|
||||||
|
*
|
||||||
|
* `POST /api/generate` with `keep_alive: 0` and no prompt is how Ollama says
|
||||||
|
* "release this now"; its own `ollama stop` does the same thing. Probed against
|
||||||
|
* 0.35.1: the reply is `{"done":true,"done_reason":"unload"}` and `/api/ps`
|
||||||
|
* comes back empty. The `DELETE` form of the same route answers 405, so there is
|
||||||
|
* no delete-shaped way to do this.
|
||||||
|
*
|
||||||
|
* Nothing is deleted, so a mistake costs a reload. That is why this needs no
|
||||||
|
* confirmation while `deleteModel` does.
|
||||||
|
*/
|
||||||
|
export async function unloadModel(options: UnloadModelOptions): Promise<void> {
|
||||||
|
const host = normalizeHost(options.host);
|
||||||
|
|
||||||
|
const response = await fetch(`${host}/api/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||||
|
// No `prompt`, so this never generates anything, and `stream: false` because
|
||||||
|
// there is exactly one reply to read.
|
||||||
|
body: JSON.stringify({ model: options.model, keep_alive: 0, stream: false }),
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw describeHttpError(response.status, await safeText(response), host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a model from a server the user runs.
|
* Removes a model from a server the user runs.
|
||||||
*
|
*
|
||||||
|
|||||||
+69
-6
@@ -12,7 +12,16 @@ import {
|
|||||||
} from "@vicinae/api";
|
} from "@vicinae/api";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { usePreferences } from "./lib/hooks/usePreferences";
|
import { usePreferences } from "./lib/hooks/usePreferences";
|
||||||
import { deleteModel, describePullProgress, isSelfHosted, listModels, pullModel } from "./lib/ollama";
|
import {
|
||||||
|
deleteModel,
|
||||||
|
describePullProgress,
|
||||||
|
isSelfHosted,
|
||||||
|
listModels,
|
||||||
|
listRunningModels,
|
||||||
|
pullModel,
|
||||||
|
unloadModel,
|
||||||
|
type RunningModel,
|
||||||
|
} from "./lib/ollama";
|
||||||
import { clearModelSelection, selectModel, type ResolvedPreferences } from "./lib/preferences";
|
import { clearModelSelection, selectModel, type ResolvedPreferences } from "./lib/preferences";
|
||||||
import type { ModelInfo } from "./lib/types";
|
import type { ModelInfo } from "./lib/types";
|
||||||
import { errorMessage, formatBytes } from "./lib/utils";
|
import { errorMessage, formatBytes } from "./lib/utils";
|
||||||
@@ -20,10 +29,12 @@ import { errorMessage, formatBytes } from "./lib/utils";
|
|||||||
/**
|
/**
|
||||||
* Browse, pick, and manage the models the configured Ollama host can reach.
|
* Browse, pick, and manage the models the configured Ollama host can reach.
|
||||||
*
|
*
|
||||||
* Three jobs in one command, because they are three answers to the same question
|
* Four jobs in one command, because they are four answers to the same question
|
||||||
* ("what models do I have?"): pick one, delete one, pull a new one. The last two
|
* ("what models do I have?"): pick one, unload one, delete one, pull a new one.
|
||||||
* only appear on a host the user runs, since there is nothing to pull onto
|
* Unloading is the odd one out and the reason a row needs two states rather than
|
||||||
* Ollama Cloud and nothing to delete from it.
|
* one: a model can be installed without being loaded, and only the loaded ones
|
||||||
|
* are holding memory. Delete and pull only appear on a host the user runs, since
|
||||||
|
* there is nothing to pull onto Ollama Cloud and nothing to delete from it.
|
||||||
*
|
*
|
||||||
* ## Where a pick is stored
|
* ## Where a pick is stored
|
||||||
*
|
*
|
||||||
@@ -70,6 +81,8 @@ interface ModelsScreenProps {
|
|||||||
|
|
||||||
function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
|
function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
|
||||||
const [models, setModels] = useState<ModelInfo[] | undefined>(undefined);
|
const [models, setModels] = useState<ModelInfo[] | undefined>(undefined);
|
||||||
|
/** By name, so a row can ask whether it is the one holding memory. */
|
||||||
|
const [running, setRunning] = useState<Map<string, RunningModel>>(new Map());
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
@@ -81,10 +94,19 @@ function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setModels(await listModels({ host: preferences.host, apiKey: preferences.apiKey }));
|
// Both at once: what is installed and what is loaded are different
|
||||||
|
// questions, and the second must not hold up the first.
|
||||||
|
const [installed, loaded] = await Promise.all([
|
||||||
|
listModels({ host: preferences.host, apiKey: preferences.apiKey }),
|
||||||
|
listRunningModels({ host: preferences.host, apiKey: preferences.apiKey }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setModels(installed);
|
||||||
|
setRunning(new Map(loaded.map((model) => [model.name, model])));
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
} catch (thrown) {
|
} catch (thrown) {
|
||||||
setModels([]);
|
setModels([]);
|
||||||
|
setRunning(new Map());
|
||||||
setError(errorMessage(thrown));
|
setError(errorMessage(thrown));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -95,6 +117,38 @@ function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases a model from memory.
|
||||||
|
*
|
||||||
|
* No confirmation, unlike deleting: nothing is removed from disk, and the
|
||||||
|
* worst case is that the next question pays to load it again. The toast reports
|
||||||
|
* what was freed, because that is the whole reason to do this.
|
||||||
|
*/
|
||||||
|
const unload = useCallback(
|
||||||
|
async (model: ModelInfo, loaded: RunningModel) => {
|
||||||
|
try {
|
||||||
|
await unloadModel({ host: preferences.host, apiKey: preferences.apiKey, model: model.name });
|
||||||
|
} catch (thrown) {
|
||||||
|
await showToast({
|
||||||
|
style: Toast.Style.Failure,
|
||||||
|
title: `Could not unload ${model.name}`,
|
||||||
|
message: errorMessage(thrown),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const freed = formatBytes(loaded.sizeVram);
|
||||||
|
await showToast({
|
||||||
|
style: Toast.Style.Success,
|
||||||
|
title: `Unloaded ${model.name}`,
|
||||||
|
message: freed ? `${freed} of memory freed` : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
void load();
|
||||||
|
},
|
||||||
|
[load, preferences.host, preferences.apiKey],
|
||||||
|
);
|
||||||
|
|
||||||
const choose = useCallback(
|
const choose = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
if (name === preferences.model) {
|
if (name === preferences.model) {
|
||||||
@@ -238,6 +292,7 @@ function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
|
|||||||
{loaded.map((model) => {
|
{loaded.map((model) => {
|
||||||
const isCurrent = model.name === preferences.model;
|
const isCurrent = model.name === preferences.model;
|
||||||
const size = model.remoteHost ? undefined : formatBytes(model.size);
|
const size = model.remoteHost ? undefined : formatBytes(model.size);
|
||||||
|
const inMemory = running.get(model.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List.Item
|
<List.Item
|
||||||
@@ -247,6 +302,7 @@ function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
|
|||||||
title={model.name}
|
title={model.name}
|
||||||
subtitle={[model.parameterSize, size].filter(Boolean).join(" · ") || undefined}
|
subtitle={[model.parameterSize, size].filter(Boolean).join(" · ") || undefined}
|
||||||
accessories={[
|
accessories={[
|
||||||
|
...(inMemory ? [{ tag: { value: "loaded", color: "green" as const } }] : []),
|
||||||
...(isCurrent ? [{ tag: { value: "current", color: "blue" as const } }] : []),
|
...(isCurrent ? [{ tag: { value: "current", color: "blue" as const } }] : []),
|
||||||
...(model.capabilities?.includes("vision") ? [{ tag: "vision" }] : []),
|
...(model.capabilities?.includes("vision") ? [{ tag: "vision" }] : []),
|
||||||
{ tag: model.remoteHost ? "cloud" : selfHosted ? "local" : "cloud" },
|
{ tag: model.remoteHost ? "cloud" : selfHosted ? "local" : "cloud" },
|
||||||
@@ -258,6 +314,13 @@ function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
|
|||||||
icon={isCurrent ? Icon.CheckCircle : Icon.Stars}
|
icon={isCurrent ? Icon.CheckCircle : Icon.Stars}
|
||||||
onAction={() => void choose(model.name)}
|
onAction={() => void choose(model.name)}
|
||||||
/>
|
/>
|
||||||
|
{inMemory ? (
|
||||||
|
<Action
|
||||||
|
title="Unload Model"
|
||||||
|
icon={Icon.Eject}
|
||||||
|
onAction={() => void unload(model, inMemory)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Action.CopyToClipboard title="Copy Model Name" content={model.name} />
|
<Action.CopyToClipboard title="Copy Model Name" content={model.name} />
|
||||||
{selfHosted ? (
|
{selfHosted ? (
|
||||||
<Action
|
<Action
|
||||||
|
|||||||
Reference in New Issue
Block a user