Files
ollama-for-amd/app/ui/app/src/components/Settings.tsx

547 lines
21 KiB
TypeScript

import { useEffect, useState, useCallback } from "react";
import { Switch } from "@/components/ui/switch";
import { Text } from "@/components/ui/text";
import { Input } from "@/components/ui/input";
import { Field, Label, Description } from "@/components/ui/fieldset";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import {
WifiIcon,
FolderIcon,
BoltIcon,
WrenchIcon,
XMarkIcon,
CogIcon,
ArrowLeftIcon,
} from "@heroicons/react/20/solid";
import { Settings as SettingsType } from "@/gotypes";
import { useNavigate } from "@tanstack/react-router";
import { useUser } from "@/hooks/useUser";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getSettings, updateSettings } from "@/api";
function AnimatedDots() {
return (
<span className="inline-flex">
<span className="animate-pulse">.</span>
<span className="animate-pulse" style={{ animationDelay: "0.2s" }}>
.
</span>
<span className="animate-pulse" style={{ animationDelay: "0.4s" }}>
.
</span>
</span>
);
}
export default function Settings() {
const queryClient = useQueryClient();
const [showSaved, setShowSaved] = useState(false);
const [restartMessage, setRestartMessage] = useState(false);
const {
user,
isAuthenticated,
refreshUser,
isRefreshing,
refetchUser,
fetchConnectUrl,
isLoading,
disconnectUser,
} = useUser();
const [isAwaitingConnection, setIsAwaitingConnection] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [pollingInterval, setPollingInterval] = useState<number | null>(null);
const navigate = useNavigate();
const {
data: settingsData,
isLoading: loading,
error,
} = useQuery({
queryKey: ["settings"],
queryFn: getSettings,
});
const settings = settingsData?.settings || null;
const updateSettingsMutation = useMutation({
mutationFn: updateSettings,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setShowSaved(true);
setTimeout(() => setShowSaved(false), 1500);
},
});
useEffect(() => {
refetchUser();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const handleFocus = () => {
if (isAwaitingConnection && pollingInterval) {
// Stop polling when window gets focus
clearInterval(pollingInterval);
setPollingInterval(null);
// Reset awaiting connection state
setIsAwaitingConnection(false);
// Make one last refresh request
refreshUser();
}
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [isAwaitingConnection, refreshUser, pollingInterval]);
// Check if user is authenticated after refresh
useEffect(() => {
if (isAwaitingConnection && isAuthenticated) {
setIsAwaitingConnection(false);
setConnectionError(null);
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
}
}, [isAuthenticated, isAwaitingConnection, pollingInterval]);
// Cleanup interval on unmount
useEffect(() => {
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
};
}, [pollingInterval]);
const handleChange = useCallback(
(field: keyof SettingsType, value: boolean | string | number) => {
if (settings) {
const updatedSettings = new SettingsType({
...settings,
[field]: value,
});
// If context length is being changed, show restart message
if (field === "ContextLength" && value !== settings.ContextLength) {
setRestartMessage(true);
// Hide restart message after 3 seconds
setTimeout(() => setRestartMessage(false), 3000);
}
updateSettingsMutation.mutate(updatedSettings);
}
},
[settings, updateSettingsMutation],
);
const handleResetToDefaults = () => {
if (settings) {
const defaultSettings = new SettingsType({
Expose: false,
Browser: false,
Models: "",
Agent: false,
Tools: false,
ContextLength: 4096,
AirplaneMode: false,
});
updateSettingsMutation.mutate(defaultSettings);
}
};
const handleConnectOllamaAccount = async () => {
setConnectionError(null);
// If user is already authenticated, no need to connect
if (isAuthenticated) {
return;
}
try {
// If we don't have a user or user has no name, get connect URL
if (!user || !user?.name) {
const { data: connectUrl } = await fetchConnectUrl();
if (connectUrl) {
window.open(connectUrl, "_blank");
setIsAwaitingConnection(true);
// Start polling every 5 seconds
const interval = setInterval(() => {
refreshUser();
}, 5000);
setPollingInterval(interval);
} else {
setConnectionError("Failed to get connect URL");
}
}
} catch (error) {
console.error("Error connecting to Ollama account:", error);
setConnectionError(
error instanceof Error
? error.message
: "Failed to connect to Ollama account",
);
setIsAwaitingConnection(false);
}
};
if (loading) {
return null;
}
if (error || !settings) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-red-500">Failed to load settings</div>
</div>
);
}
const isWindows = navigator.platform.toLowerCase().includes("win");
return (
<main className="flex h-screen w-full flex-col select-none dark:bg-neutral-900">
<header
className="w-full flex flex-none justify-between h-[52px] py-2.5 items-center border-b border-neutral-200 dark:border-neutral-800 select-none"
onMouseDown={() => window.drag && window.drag()}
onDoubleClick={() => window.doubleClick && window.doubleClick()}
>
<h1
className={`${isWindows ? "pl-4" : "pl-24"} flex items-center font-rounded text-md font-medium dark:text-white`}
>
{isWindows && (
<button
onClick={() => navigate({ to: "/" })}
className="hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full p-1.5"
>
<ArrowLeftIcon className="w-5 h-5 dark:text-white" />
</button>
)}
Settings
</h1>
{!isWindows && (
<button
onClick={() => navigate({ to: "/" })}
className="p-1 hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full"
>
<XMarkIcon className="w-6 h-6 dark:text-white" />
</button>
)}
</header>
<div className="w-full p-6 overflow-y-auto flex-1 overscroll-contain">
<div className="space-y-4 max-w-2xl mx-auto">
{/* Connect Ollama Account */}
<div className="overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<Field>
{isLoading ? (
// Loading skeleton, this will only happen if the app started recently
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-4 bg-neutral-200 dark:bg-neutral-700 rounded animate-pulse w-24"></div>
<div className="h-3 bg-neutral-200 dark:bg-neutral-700 rounded animate-pulse w-32"></div>
</div>
<div className="h-10 w-10 bg-neutral-200 dark:bg-neutral-700 rounded-full animate-pulse"></div>
</div>
) : user && user.name ? (
<div className="flex items-center justify-between">
<div>
<div className="flex items-center space-x-2">
<Label className="text-sm font-medium text-neutral-900 dark:text-white">
{user?.name}
</Label>
</div>
<Description className="text-sm text-neutral-500 dark:text-neutral-400">
{user?.email}
</Description>
<div className="flex items-center space-x-2 mt-2">
{user?.plan === "free" && (
<Button
type="button"
color="dark"
className="px-3 py-2 text-sm font-medium bg-black/90 backdrop-blur-sm text-white rounded-lg border border-white/10 shadow-2xl transition-all duration-300 ease-out relative overflow-hidden group"
onClick={() =>
window.open(
"https://ollama.com/upgrade",
"_blank",
)
}
>
<div className="absolute inset-0 bg-gradient-to-r from-cyan-500/20 via-purple-500/20 to-green-500/20 opacity-60 group-hover:opacity-80 transition-opacity duration-300"></div>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-out"></div>
<span className="relative z-10 flex items-center space-x-2">
<span>Upgrade</span>
</span>
</Button>
)}
<Button
type="button"
color="white"
className="px-3 py-2 text-sm"
onClick={() =>
window.open("https://ollama.com/settings", "_blank")
}
>
Manage
</Button>
<Button
type="button"
color="zinc"
className="px-3 py-2 text-sm"
onClick={() => disconnectUser()}
>
Sign out
</Button>
</div>
</div>
{user?.avatarurl && (
<img
src={user.avatarurl}
alt={user?.name}
className="h-10 w-10 rounded-full bg-neutral-200 dark:bg-neutral-700 flex-shrink-0"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.className = "hidden";
}}
/>
)}
</div>
) : (
<div className="flex items-center justify-between">
<div>
<Label>Ollama account</Label>
<Description>Not connected</Description>
</div>
<Button
type="button"
color="white"
onClick={handleConnectOllamaAccount}
disabled={isRefreshing || isAwaitingConnection}
>
{isRefreshing || isAwaitingConnection ? (
<AnimatedDots />
) : (
"Sign In"
)}
</Button>
</div>
)}
</Field>
{connectionError && (
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<Text className="text-sm text-red-600 dark:text-red-400">
{connectionError}
</Text>
</div>
)}
</div>
</div>
{/* Local Configuration */}
<div className="relative overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
<div className="space-y-4 p-4">
{/* Expose Ollama */}
<Field>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start space-x-3 flex-1">
<WifiIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div>
<Label>Expose Ollama to the network</Label>
<Description>
Allow other devices or services to access Ollama.
</Description>
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={settings.Expose}
onChange={(checked) => handleChange("Expose", checked)}
/>
</div>
</div>
</Field>
{/* Model Directory */}
<Field>
<div className="flex items-start space-x-3">
<FolderIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div className="w-full">
<Label>Model location</Label>
<Description>Location where models are stored.</Description>
<div className="mt-2 flex items-center space-x-2">
<Input
value={settings.Models || ""}
onChange={(e) => handleChange("Models", e.target.value)}
readOnly
/>
<Button
type="button"
color="white"
className="px-2"
onClick={async () => {
if (window.webview?.selectModelsDirectory) {
try {
const directory =
await window.webview.selectModelsDirectory();
if (directory) {
handleChange("Models", directory);
}
} catch (error) {
console.error(
"Error selecting models directory:",
error,
);
}
}
}}
>
<FolderIcon className="w-4 h-4 mr-1" />
Browse
</Button>
</div>
</div>
</div>
</Field>
{/* Context Length */}
<Field>
<div className="flex items-start space-x-3">
<CogIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div className="w-full">
<Label>Context length</Label>
<Description>
Context length determines how much of your conversation
local LLMs can remember and use to generate responses.
</Description>
<div className="mt-3">
<Slider
value={(() => {
// Otherwise use the settings value
return settings.ContextLength || 4096;
})()}
onChange={(value) => {
handleChange("ContextLength", value);
}}
options={[
{ value: 4096, label: "4k" },
{ value: 8192, label: "8k" },
{ value: 16384, label: "16k" },
{ value: 32768, label: "32k" },
{ value: 65536, label: "64k" },
{ value: 131072, label: "128k" },
{ value: 262144, label: "256k" },
]}
/>
</div>
</div>
</div>
</Field>
{/* Airplane Mode */}
<Field>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start space-x-3 flex-1">
<svg
className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100"
viewBox="0 0 21.5508 17.9033"
fill="currentColor"
>
<path d="M21.5508 8.94727C21.542 7.91895 20.1445 7.17188 18.4658 7.17188L14.9238 7.17188C14.4316 7.17188 14.2471 7.09277 13.957 6.75879L8.05078 0.316406C7.86621 0.105469 7.6377 0 7.37402 0L6.35449 0C6.12598 0 5.99414 0.202148 6.1084 0.448242L9.14941 7.17188L4.68457 7.68164L3.09375 4.76367C2.97949 4.54395 2.78613 4.44727 2.49609 4.44727L2.11816 4.44727C1.88965 4.44727 1.74023 4.59668 1.74023 4.8252L1.74023 13.0693C1.74023 13.2979 1.88965 13.4385 2.11816 13.4385L2.49609 13.4385C2.78613 13.4385 2.97949 13.3418 3.09375 13.1309L4.68457 10.2129L9.14941 10.7227L6.1084 17.4463C5.99414 17.6836 6.12598 17.8945 6.35449 17.8945L7.37402 17.8945C7.6377 17.8945 7.86621 17.7803 8.05078 17.5781L13.957 11.127C14.2471 10.8018 14.4316 10.7227 14.9238 10.7227L18.4658 10.7227C20.1445 10.7227 21.542 9.9668 21.5508 8.94727Z" />
</svg>
<div>
<Label>Airplane mode</Label>
<Description>
Airplane mode keeps data local, disabling cloud models
and web search.
</Description>
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={settings.AirplaneMode}
onChange={(checked) =>
handleChange("AirplaneMode", checked)
}
/>
</div>
</div>
</Field>
</div>
</div>
{/* Agent Mode */}
{window.OLLAMA_TOOLS && (
<div className="overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
<div className="space-y-4 p-4">
<Field>
<div className="flex items-center justify-between">
<div className="flex items-start space-x-3">
<BoltIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div>
<Label>Enable Agent Mode</Label>
<Description>
Use multi-turn tools to fulfill user requests
</Description>
</div>
</div>
<Switch
checked={settings.Agent}
onChange={(checked) => handleChange("Agent", checked)}
/>
</div>
</Field>
{/* Tools Mode */}
<Field>
<div className="flex items-center justify-between">
<div className="flex items-start space-x-3">
<WrenchIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div>
<Label>Enable Tools Mode</Label>
<Description>
Use single-turn tools to fulfill user requests
</Description>
</div>
</div>
<Switch
checked={settings.Tools}
onChange={(checked) => handleChange("Tools", checked)}
/>
</div>
</Field>
</div>
</div>
)}
{/* Reset button */}
<div className="mt-6 flex justify-end px-4">
<Button
type="button"
color="white"
className="px-3"
onClick={handleResetToDefaults}
>
Reset to defaults
</Button>
</div>
</div>
{/* Saved indicator */}
{(showSaved || restartMessage) && (
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 transition-opacity duration-300 z-50">
<Badge
color="green"
className="!bg-green-500 !text-white dark:!bg-green-600"
>
Saved
</Badge>
</div>
)}
</div>
</main>
);
}