Files
2026-07-13 13:08:41 +08:00

625 lines
22 KiB
TypeScript

"use client";
import { useState, useCallback, useRef } from "react";
import {
Check,
ChevronsUpDown,
Loader2,
Download,
X,
HardDrive,
} from "lucide-react";
import { Button } from "./ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "./ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "./ui/dialog";
import { cn } from "@/lib/utils";
import { notify } from "@/components/ui/sonner";
import {
getDefaultOllamaUrl,
getReachableOllamaModels,
getOllamaLibraryModels,
pullOllamaModel,
OllamaLibraryModel,
OllamaPullProgressEvent,
} from "@/utils/providerUtils";
interface CombinedModel {
name: string;
parameters?: string;
size?: string;
description?: string;
isPulled: boolean;
tested?: boolean;
}
interface OllamaConfigProps {
ollamaModel: string;
ollamaUrl: string;
onInputChange: (value: string | boolean, field: string) => void;
}
export default function OllamaConfig({
ollamaModel,
ollamaUrl,
onInputChange,
}: OllamaConfigProps) {
const [combinedModels, setCombinedModels] = useState<CombinedModel[]>([]);
const [ollamaModelsLoading, setOllamaModelsLoading] = useState(false);
const [modelsChecked, setModelsChecked] = useState(false);
const [modelsCheckError, setModelsCheckError] = useState<string | null>(null);
const [resolvedOllamaUrl, setResolvedOllamaUrl] = useState<string | null>(null);
const [openModelSelect, setOpenModelSelect] = useState(false);
const [pullDialogOpen, setPullDialogOpen] = useState(false);
const [pullingModel, setPullingModel] = useState<string | null>(null);
const [pullStatus, setPullStatus] = useState("");
const [pullProgress, setPullProgress] = useState<number | null>(null);
const [pullCompleted, setPullCompleted] = useState<number | null>(null);
const [pullTotal, setPullTotal] = useState<number | null>(null);
const [pullError, setPullError] = useState<string | null>(null);
const [pullDone, setPullDone] = useState(false);
const [pullCancelled, setPullCancelled] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const pullRequestIdRef = useRef(0);
const isElectronRuntime =
typeof window !== "undefined" && !!window.electron;
const defaultOllamaUrl = getDefaultOllamaUrl();
const trimmedOllamaUrl = ollamaUrl.trim();
const activeOllamaUrl = trimmedOllamaUrl || resolvedOllamaUrl || "";
const fetchOllamaModels = useCallback(async () => {
setOllamaModelsLoading(true);
setModelsCheckError(null);
try {
const requestedUrl = ollamaUrl.trim();
const reachable = await getReachableOllamaModels(requestedUrl);
const [pulledModels, libraryModels] = await Promise.all([
Promise.resolve(reachable.models),
getOllamaLibraryModels(),
]);
setResolvedOllamaUrl(reachable.resolvedUrl);
if (reachable.resolvedUrl !== requestedUrl) {
onInputChange(reachable.resolvedUrl, "ollama_url");
}
const pulledNames = new Set(pulledModels.map((m) => m.name));
const libraryOnly: CombinedModel[] = libraryModels
.filter((lm: OllamaLibraryModel) => !pulledNames.has(lm.name))
.map((lm: OllamaLibraryModel) => ({
name: lm.name,
parameters: lm.parameters || undefined,
size: lm.size,
description: lm.description,
isPulled: false,
tested: true,
}));
const pulled: CombinedModel[] = pulledModels.map((model) => {
const libraryMatch = libraryModels.find((lm: OllamaLibraryModel) => lm.name === model.name);
return {
name: model.name,
parameters: model.parameters || libraryMatch?.parameters || undefined,
size: libraryMatch?.size
|| (model.size ? `${(model.size / 1024 / 1024 / 1024).toFixed(1)} GB` : undefined),
description: libraryMatch?.description,
isPulled: true,
tested: !!libraryMatch,
};
});
const combined = [...pulled, ...libraryOnly];
setCombinedModels(combined);
setModelsChecked(true);
const currentStillAvailable = pulledNames.has(ollamaModel);
if (pulled.length > 0) {
if (!currentStillAvailable) {
onInputChange(pulled[0].name, "ollama_model");
}
} else {
onInputChange("", "ollama_model");
}
notify.success(
"Connected to Ollama",
pulled.length > 0
? `Found ${pulled.length} downloaded model${pulled.length === 1 ? "" : "s"}. ${libraryOnly.length} more available in library.`
: "Ollama is reachable. Browse the library to download models."
);
if (reachable.usedFallback) {
notify.success(
"Using in-container Ollama",
requestedUrl
? "host.docker.internal did not respond, so Presenton switched Ollama URL to localhost."
: "host.docker.internal did not respond, so this check used localhost."
);
}
if (!reachable.usedFallback && requestedUrl && reachable.resolvedUrl !== requestedUrl) {
notify.success(
"Updated Ollama URL",
`Using ${reachable.resolvedUrl} for Ollama checks.`
);
}
} catch (error) {
setCombinedModels([]);
setModelsChecked(true);
setResolvedOllamaUrl(null);
onInputChange("", "ollama_model");
setModelsCheckError(
error instanceof Error ? error.message : "Check the Ollama URL and try again."
);
notify.error(
"Could not connect to Ollama",
error instanceof Error ? error.message : "Check the Ollama URL and try again."
);
} finally {
setOllamaModelsLoading(false);
}
}, [ollamaUrl, ollamaModel, onInputChange]);
const handlePullModel = useCallback(
(modelName: string) => {
const requestId = pullRequestIdRef.current + 1;
pullRequestIdRef.current = requestId;
setPullingModel(modelName);
setPullStatus("Starting pull...");
setPullProgress(null);
setPullCompleted(null);
setPullTotal(null);
setPullError(null);
setPullDone(false);
setPullCancelled(false);
setPullDialogOpen(true);
const controller = new AbortController();
abortControllerRef.current = controller;
pullOllamaModel(
modelName,
activeOllamaUrl,
(event: OllamaPullProgressEvent) => {
if (
pullRequestIdRef.current !== requestId ||
controller.signal.aborted
) {
return;
}
switch (event.type) {
case "status":
setPullStatus(event.status || "Processing...");
break;
case "progress":
setPullStatus(event.status || "Downloading...");
setPullProgress(event.progress ?? null);
setPullCompleted(event.completed ?? null);
setPullTotal(event.total ?? null);
break;
case "complete":
setPullStatus("Model downloaded successfully!");
setPullProgress(100);
setPullDone(true);
setPullCancelled(false);
abortControllerRef.current = null;
setCombinedModels((prev) =>
prev.map((m) =>
m.name === modelName ? { ...m, isPulled: true } : m
)
);
onInputChange(modelName, "ollama_model");
notify.success("Model downloaded", `${modelName} is ready to use.`);
break;
case "error":
setPullError(event.detail || "Pull failed");
setPullDone(true);
setPullCancelled(false);
abortControllerRef.current = null;
notify.error("Pull failed", event.detail || "Unknown error");
break;
}
},
controller.signal
).catch(() => {
if (
controller.signal.aborted &&
pullRequestIdRef.current === requestId
) {
setPullStatus("Pull cancelled");
setPullError(null);
setPullDone(true);
setPullCancelled(true);
abortControllerRef.current = null;
}
});
},
[activeOllamaUrl, onInputChange]
);
const handleCancelPull = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
setPullStatus("Cancelling...");
}, []);
const handleClosePullDialog = useCallback(() => {
if (!pullDone) {
handleCancelPull();
}
setPullDialogOpen(false);
}, [pullDone, handleCancelPull]);
const handleModelSelect = useCallback(
(model: CombinedModel) => {
if (model.isPulled) {
onInputChange(model.name, "ollama_model");
setOpenModelSelect(false);
} else {
setOpenModelSelect(false);
handlePullModel(model.name);
}
},
[onInputChange, handlePullModel]
);
const formatBytes = (bytes: number): string => {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
}
return `${(bytes / 1024).toFixed(0)} KB`;
};
const pulledModels = combinedModels.filter((m) => m.isPulled);
const libraryModels = combinedModels.filter((m) => !m.isPulled);
const selectedModel = combinedModels.find((m) => m.name === ollamaModel);
const compactSize = (value?: string) => (value || "Unknown").replace(/\s+/g, "");
const normalizeParameters = (value?: string) =>
(value || "").replace(/\s+/g, "").toUpperCase();
const hasKnownParameters = (model: CombinedModel) => {
const normalized = normalizeParameters(model.parameters);
return normalized !== "" && normalized !== "UNKNOWN";
};
const modelParameterBadge = (model: CombinedModel) =>
normalizeParameters(model.parameters);
const modelSizeBadge = (model: CombinedModel) => compactSize(model.size);
const modelSupportTitle = (model: CombinedModel) =>
model.tested === false ? "Experimental" : "Recommended";
const modelSupportBadgeClass = (model: CombinedModel) =>
model.tested === false
? "border-[#FDE2B4] bg-[#FFF7E8] text-[#9A5B00]"
: "border-[#CFEBD5] bg-[#ECFDF0] text-[#1C7A34]";
const modelNameWithParameters = (model: CombinedModel) => {
const rawName = model.name;
const normalizedParams = normalizeParameters(model.parameters);
if (!normalizedParams || normalizedParams === "UNKNOWN") {
return rawName;
}
const suffixMatchesParams = new RegExp(
`:${normalizedParams.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`,
"i"
).test(rawName);
if (suffixMatchesParams) {
return `${rawName.replace(/:[^:]+$/, "")}:${normalizedParams}`;
}
if (rawName.includes(":")) {
return `${rawName} (${normalizedParams})`;
}
return `${rawName}:${normalizedParams}`;
};
const modelInlineLabel = (model: CombinedModel) =>
`${modelNameWithParameters(model)} ${compactSize(model.size)}`;
const renderModelBadges = (model: CombinedModel) => (
<div className="mt-1 flex items-center gap-1.5">
<span
title={modelSupportTitle(model)}
aria-label={modelSupportTitle(model)}
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
modelSupportBadgeClass(model)
)}
>
<Check className="h-3 w-3" aria-hidden="true" />
</span>
{hasKnownParameters(model) && (
<span className="rounded-full border border-[#E7E8EC] bg-[#F7F8FA] px-1.5 py-0.5 text-[10px] font-medium text-[#5F6062]">
{modelParameterBadge(model)}
</span>
)}
<span className="rounded-full border border-[#E7E8EC] bg-[#F7F8FA] px-1.5 py-0.5 text-[10px] font-medium text-[#5F6062]">
{modelSizeBadge(model)}
</span>
</div>
);
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Ollama URL
</label>
<input
type="text"
placeholder={
isElectronRuntime
? "http://localhost:11434"
: "http://host.docker.internal:11434"
}
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={ollamaUrl}
onChange={(event) => {
onInputChange(event.target.value, "ollama_url");
onInputChange("", "ollama_model");
setCombinedModels([]);
setModelsChecked(false);
setModelsCheckError(null);
setResolvedOllamaUrl(null);
}}
/>
<p className="mt-2 text-sm text-gray-500">
Required for generation. Use {defaultOllamaUrl}
{!isElectronRuntime
? ", or click Check models to detect localhost when Ollama runs in the same container."
: "."}
</p>
<Button
type="button"
variant="outline"
className="mt-4"
disabled={ollamaModelsLoading}
onClick={fetchOllamaModels}
>
{ollamaModelsLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Checking models...
</>
) : (
"Check models"
)}
</Button>
</div>
{modelsChecked && combinedModels.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Choose an Ollama model
</label>
<Popover open={openModelSelect} onOpenChange={setOpenModelSelect}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openModelSelect}
className="h-auto min-h-14 w-full justify-between rounded-xl border-[#E8E8E9] bg-white px-4 py-3 text-left hover:bg-[#FAFAFC]"
>
<div className="min-w-0">
<span
className="block truncate text-sm font-medium text-[#191919]"
title={selectedModel ? modelInlineLabel(selectedModel) : "Select a model"}
>
{selectedModel ? modelInlineLabel(selectedModel) : "Select a model"}
</span>
{selectedModel && renderModelBadges(selectedModel)}
</div>
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="rounded-xl border border-[#EDEEEF] p-0 shadow-[0px_10px_30px_rgba(16,19,35,0.08)]"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search model..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
{pulledModels.length > 0 && (
<CommandGroup heading="Downloaded">
{pulledModels.map((model) => (
<CommandItem
key={model.name}
value={model.name}
onSelect={() => handleModelSelect(model)}
className="cursor-pointer rounded-lg py-2.5 aria-selected:bg-[#F6F4FF]"
>
<Check
className={cn(
"mr-2 h-4 w-4",
ollamaModel === model.name
? "opacity-100"
: "opacity-0"
)}
/>
<div className="min-w-0 flex-1">
<span
className="block truncate text-sm font-medium text-[#191919]"
title={modelInlineLabel(model)}
>
{modelInlineLabel(model)}
</span>
{renderModelBadges(model)}
</div>
<span className="ml-2 inline-flex items-center gap-1 rounded-full border border-[#CFEBD5] bg-[#ECFDF0] px-2 py-0.5 text-[10px] font-semibold text-[#1C7A34]">
<HardDrive className="w-3 h-3" />
Downloaded
</span>
</CommandItem>
))}
</CommandGroup>
)}
{libraryModels.length > 0 && (
<CommandGroup heading="Available in Library">
{libraryModels.map((model) => (
<CommandItem
key={model.name}
value={model.name}
onSelect={() => handleModelSelect(model)}
className="cursor-pointer rounded-lg py-2.5 aria-selected:bg-[#F5F8FF]"
>
<Download className="mr-2 h-4 w-4 text-blue-500" />
<div className="flex-1 min-w-0">
<span
className="block truncate text-sm font-medium text-[#191919]"
title={modelInlineLabel(model)}
>
{modelInlineLabel(model)}
</span>
{renderModelBadges(model)}
{model.description && (
<span className="mt-1 block truncate text-[11px] text-[#75777D]">
{model.description}
</span>
)}
</div>
<span className="ml-2 inline-flex items-center gap-1 rounded-full border border-[#D8E5FF] bg-[#EEF4FF] px-2 py-0.5 text-[10px] font-semibold text-[#2456C3]">
<Download className="h-3 w-3" />
Download
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{modelsChecked && combinedModels.length === 0 && (
<p className="text-sm text-gray-500">
{modelsCheckError
? modelsCheckError
: "Ollama is reachable, but no models are installed. Pull a model in Ollama, then check again."}
</p>
)}
<Dialog
open={pullDialogOpen}
onOpenChange={(open) => {
if (!open) handleClosePullDialog();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5" />
{pullDone && pullCancelled
? "Download Cancelled"
: pullDone && !pullError
? "Download Complete"
: pullError
? "Download Failed"
: `Downloading ${pullingModel}`}
</DialogTitle>
<DialogDescription>
{pullDone && pullCancelled
? `${pullingModel} download was cancelled.`
: pullDone && !pullError
? `${pullingModel} is ready to use.`
: pullError
? pullError
: pullStatus}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{!pullDone && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600">
<span className="truncate mr-2">{pullStatus}</span>
{pullProgress !== null && (
<span className="shrink-0 font-medium">
{pullProgress.toFixed(1)}%
</span>
)}
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
<div
className="bg-blue-500 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${pullProgress ?? 0}%` }}
/>
</div>
{pullCompleted !== null && pullTotal !== null && (
<p className="text-xs text-gray-500">
{formatBytes(pullCompleted)} / {formatBytes(pullTotal)}
</p>
)}
</div>
)}
{pullDone && pullError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-700">{pullError}</p>
</div>
)}
{pullDone && pullCancelled && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-700">
Download was cancelled before completion.
</p>
</div>
)}
{pullDone && !pullError && !pullCancelled && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-sm text-green-700">
{pullingModel} has been downloaded and selected as your active
model.
</p>
</div>
)}
<div className="flex justify-end">
{!pullDone ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancelPull}
>
<X className="mr-1.5 h-3.5 w-3.5" />
Cancel
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setPullDialogOpen(false)}
>
Close
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}