231 lines
6.8 KiB
TypeScript
231 lines
6.8 KiB
TypeScript
import { useCallback } from "react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
getInferenceStatus,
|
|
listLoras,
|
|
listModels,
|
|
loadModel,
|
|
unloadModel,
|
|
} from "../api/chat-api";
|
|
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
|
import type { LoadModelResponse } from "../types/api";
|
|
import type {
|
|
ChatLoraSummary,
|
|
ChatModelSummary,
|
|
InferenceParams,
|
|
} from "../types/runtime";
|
|
|
|
const DEFAULT_MODEL_MAX_SEQ_LENGTH = 2048;
|
|
|
|
type SelectedModelInput = {
|
|
id: string;
|
|
isLora?: boolean;
|
|
};
|
|
|
|
const LORA_SUFFIX_RE = /_(\d{9,})$/;
|
|
|
|
function parseTrailingEpoch(input: string): number | undefined {
|
|
const match = input.match(LORA_SUFFIX_RE);
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
const parsed = Number.parseInt(match[1], 10);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
function stripTrailingEpoch(input: string): string {
|
|
const cleaned = input.replace(LORA_SUFFIX_RE, "").replace(/[_-]+$/, "").trim();
|
|
return cleaned || input;
|
|
}
|
|
|
|
function describeModel(model: {
|
|
is_lora?: boolean;
|
|
is_vision?: boolean;
|
|
}): string | undefined {
|
|
const tags: string[] = [];
|
|
if (model.is_lora) tags.push("LoRA");
|
|
if (model.is_vision) tags.push("Vision");
|
|
if (!model.is_lora && !model.is_vision) tags.push("Base");
|
|
return tags.join(" · ");
|
|
}
|
|
|
|
function toChatModelSummary(model: {
|
|
id: string;
|
|
name?: string | null;
|
|
is_lora?: boolean;
|
|
is_vision?: boolean;
|
|
}): ChatModelSummary {
|
|
return {
|
|
id: model.id,
|
|
name: model.name || model.id,
|
|
description: describeModel(model),
|
|
isLora: Boolean(model.is_lora),
|
|
isVision: Boolean(model.is_vision),
|
|
};
|
|
}
|
|
|
|
function toLoraSummary(lora: {
|
|
display_name: string;
|
|
adapter_path: string;
|
|
base_model?: string | null;
|
|
}): ChatLoraSummary {
|
|
const idTail = lora.adapter_path.split("/").filter(Boolean).at(-1) ?? "";
|
|
const updatedAt =
|
|
parseTrailingEpoch(lora.display_name) ?? parseTrailingEpoch(idTail);
|
|
|
|
return {
|
|
id: lora.adapter_path,
|
|
name: stripTrailingEpoch(lora.display_name),
|
|
baseModel: lora.base_model || "Unknown base model",
|
|
updatedAt,
|
|
};
|
|
}
|
|
|
|
function toFiniteNumber(value: unknown): number | undefined {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
return undefined;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function mergeRecommendedInference(
|
|
current: InferenceParams,
|
|
response: LoadModelResponse,
|
|
modelId: string,
|
|
): InferenceParams {
|
|
const inference = response.inference;
|
|
return {
|
|
...current,
|
|
checkpoint: modelId,
|
|
temperature:
|
|
toFiniteNumber(inference?.temperature) ?? current.temperature,
|
|
topP: toFiniteNumber(inference?.top_p) ?? current.topP,
|
|
topK: toFiniteNumber(inference?.top_k) ?? current.topK,
|
|
minP: toFiniteNumber(inference?.min_p) ?? current.minP,
|
|
};
|
|
}
|
|
|
|
export function useChatModelRuntime() {
|
|
const params = useChatRuntimeStore((state) => state.params);
|
|
const models = useChatRuntimeStore((state) => state.models);
|
|
const loras = useChatRuntimeStore((state) => state.loras);
|
|
const setModels = useChatRuntimeStore((state) => state.setModels);
|
|
const setLoras = useChatRuntimeStore((state) => state.setLoras);
|
|
const setParams = useChatRuntimeStore((state) => state.setParams);
|
|
const setModelsError = useChatRuntimeStore((state) => state.setModelsError);
|
|
const setCheckpoint = useChatRuntimeStore((state) => state.setCheckpoint);
|
|
const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint);
|
|
|
|
const refresh = useCallback(async () => {
|
|
setModelsError(null);
|
|
try {
|
|
const [listRes, statusRes, lorasRes] = await Promise.all([
|
|
listModels(),
|
|
getInferenceStatus(),
|
|
listLoras(),
|
|
]);
|
|
|
|
setModels(listRes.models.map(toChatModelSummary));
|
|
setLoras(lorasRes.loras.map(toLoraSummary));
|
|
|
|
if (statusRes.active_model) {
|
|
setCheckpoint(statusRes.active_model);
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Failed to load models";
|
|
setModelsError(message);
|
|
toast.error("Failed to refresh models", {
|
|
description: message,
|
|
});
|
|
}
|
|
}, [setCheckpoint, setLoras, setModels, setModelsError]);
|
|
|
|
const selectModel = useCallback(
|
|
async (selection: string | SelectedModelInput) => {
|
|
const modelId = typeof selection === "string" ? selection : selection.id;
|
|
if (!modelId || params.checkpoint === modelId) {
|
|
return;
|
|
}
|
|
|
|
const explicitIsLora =
|
|
typeof selection === "string" ? undefined : selection.isLora;
|
|
const model = models.find((entry) => entry.id === modelId);
|
|
const lora = loras.find((entry) => entry.id === modelId);
|
|
const isLora =
|
|
explicitIsLora ?? model?.isLora ?? (lora ? true : false);
|
|
const displayName = model?.name || lora?.name || modelId;
|
|
|
|
setModelsError(null);
|
|
try {
|
|
async function performLoad(): Promise<void> {
|
|
if (params.checkpoint) {
|
|
await unloadModel({ model_path: params.checkpoint });
|
|
}
|
|
|
|
const loadResponse = await loadModel({
|
|
model_path: modelId,
|
|
hf_token: null,
|
|
max_seq_length: DEFAULT_MODEL_MAX_SEQ_LENGTH,
|
|
load_in_4bit: true,
|
|
is_lora: isLora,
|
|
});
|
|
|
|
const currentParams = useChatRuntimeStore.getState().params;
|
|
setParams(mergeRecommendedInference(currentParams, loadResponse, modelId));
|
|
await refresh();
|
|
}
|
|
|
|
let description = "Base model selected.";
|
|
if (isLora) {
|
|
description = "Fine-tuned (LoRA) selected.";
|
|
}
|
|
|
|
await toast.promise(performLoad(), {
|
|
loading: `Loading ${displayName}`,
|
|
success: `${displayName} loaded`,
|
|
error: (err) =>
|
|
err instanceof Error ? err.message : "Failed to load model",
|
|
description,
|
|
});
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Failed to load model";
|
|
setModelsError(message);
|
|
}
|
|
},
|
|
[loras, models, params.checkpoint, refresh, setModelsError, setParams],
|
|
);
|
|
|
|
const ejectModel = useCallback(async () => {
|
|
if (!params.checkpoint) {
|
|
return;
|
|
}
|
|
setModelsError(null);
|
|
try {
|
|
async function performUnload(): Promise<void> {
|
|
await unloadModel({ model_path: params.checkpoint });
|
|
clearCheckpoint();
|
|
await refresh();
|
|
}
|
|
|
|
await toast.promise(performUnload(), {
|
|
loading: "Unloading model",
|
|
success: "Model unloaded",
|
|
error: (err) =>
|
|
err instanceof Error ? err.message : "Failed to unload model",
|
|
description: "Releases VRAM and resets inference state.",
|
|
});
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Failed to unload model";
|
|
setModelsError(message);
|
|
}
|
|
}, [clearCheckpoint, params.checkpoint, refresh, setModelsError]);
|
|
|
|
return {
|
|
refresh,
|
|
selectModel,
|
|
ejectModel,
|
|
};
|
|
}
|