diff --git a/apps/desktop/src/components/settings/ai/llm/configure.tsx b/apps/desktop/src/components/settings/ai/llm/configure.tsx index 83804d84cf..7cdc2f1428 100644 --- a/apps/desktop/src/components/settings/ai/llm/configure.tsx +++ b/apps/desktop/src/components/settings/ai/llm/configure.tsx @@ -1,5 +1,3 @@ -import { HelpCircle } from "lucide-react"; - import { Accordion, AccordionContent, @@ -18,20 +16,7 @@ export function ConfigureProviders() { return (
-
-

- Configure Providers -

- - Local setup guide - - -
+

Configure Providers

provider, @@ -117,17 +121,29 @@ export function SelectProviderAndModel() { {PROVIDERS.map((provider) => { const status = configuredProviders[provider.id]; + const localStatus = + provider.id === "ollama" + ? ollamaStatus + : provider.id === "lmstudio" + ? lmStudioStatus + : null; + const isDisabled = + !status?.listModels || + (localStatus !== null && localStatus !== "connected"); return (
{provider.icon} {provider.displayName} + {localStatus === "connected" && ( + + )}
diff --git a/apps/desktop/src/components/settings/ai/llm/shared.tsx b/apps/desktop/src/components/settings/ai/llm/shared.tsx index a65f83f282..8e8422fd97 100644 --- a/apps/desktop/src/components/settings/ai/llm/shared.tsx +++ b/apps/desktop/src/components/settings/ai/llm/shared.tsx @@ -24,6 +24,11 @@ type Provider = { icon: ReactNode; baseUrl?: string; requirements: ProviderRequirement[]; + links?: { + download?: { label: string; url: string }; + models?: { label: string; url: string }; + setup?: { label: string; url: string }; + }; }; const _PROVIDERS = [ @@ -45,6 +50,17 @@ const _PROVIDERS = [ icon: , baseUrl: "http://127.0.0.1:1234/v1", requirements: [], + links: { + download: { + label: "Download LM Studio", + url: "https://lmstudio.ai/download", + }, + models: { label: "Available models", url: "https://lmstudio.ai/models" }, + setup: { + label: "Setup guide", + url: "https://hyprnote.com/docs/faq/local-llm-setup/#lm-studio-setup", + }, + }, }, { id: "ollama", @@ -53,6 +69,17 @@ const _PROVIDERS = [ icon: , baseUrl: "http://127.0.0.1:11434/v1", requirements: [], + links: { + download: { + label: "Download Ollama", + url: "https://ollama.com/download", + }, + models: { label: "Available models", url: "https://ollama.com/library" }, + setup: { + label: "Setup guide", + url: "https://hyprnote.com/docs/faq/local-llm-setup/#ollama-setup", + }, + }, }, { id: "openrouter", diff --git a/apps/desktop/src/components/settings/ai/shared/index.tsx b/apps/desktop/src/components/settings/ai/shared/index.tsx index a2354d2a9b..198ccbb524 100644 --- a/apps/desktop/src/components/settings/ai/shared/index.tsx +++ b/apps/desktop/src/components/settings/ai/shared/index.tsx @@ -1,5 +1,6 @@ import { Icon } from "@iconify-icon/react"; import { type AnyFieldApi, useForm } from "@tanstack/react-form"; +import { ExternalLink } from "lucide-react"; import type { ReactNode } from "react"; import { Streamdown } from "streamdown"; @@ -11,10 +12,12 @@ import { AccordionItem, AccordionTrigger, } from "@hypr/ui/components/ui/accordion"; +import { Button } from "@hypr/ui/components/ui/button"; import { InputGroup, InputGroupInput, } from "@hypr/ui/components/ui/input-group"; +import { Spinner } from "@hypr/ui/components/ui/spinner"; import { cn } from "@hypr/utils"; import { useBillingAccess } from "../../../../billing"; @@ -25,6 +28,10 @@ import { type ProviderRequirement, requiresEntitlement, } from "./eligibility"; +import { + type LocalProviderStatus, + useLocalProviderStatus, +} from "./use-local-provider-status"; export * from "./model-combobox"; @@ -38,6 +45,11 @@ type ProviderConfig = { baseUrl?: string; disabled?: boolean; requirements: ProviderRequirement[]; + links?: { + download?: { label: string; url: string }; + models?: { label: string; url: string }; + setup?: { label: string; url: string }; + }; }; export function providerRowId(providerType: ProviderType, providerId: string) { @@ -99,6 +111,9 @@ export function NonHyprProviderCard({ providers, ); + const { status: localStatus, refetch: refetchStatus } = + useLocalProviderStatus(config.id); + const requiredFields = getRequiredConfigFields(config.requirements); const showApiKey = requiredFields.includes("api_key"); const showBaseUrl = requiredFields.includes("base_url"); @@ -148,13 +163,30 @@ export function NonHyprProviderCard({ (config.disabled || locked) && "cursor-not-allowed opacity-30", ])} > -
- {config.icon} - {config.displayName} - {config.badge && ( - - {config.badge} - +
+
+ {config.icon} + {config.displayName} + {config.badge && ( + + {config.badge} + + )} + {localStatus && } +
+ {localStatus && localStatus !== "connected" && ( + )}
@@ -190,6 +222,45 @@ export function NonHyprProviderCard({ )} )} + {config.links && ( +
+ {config.links.download && ( + + {config.links.download.label} + + + )} + {config.links.models && ( + + )} +
+ )} {!showBaseUrl && config.baseUrl && (
@@ -249,6 +320,28 @@ export function StyledStreamdown({ ); } +function StatusBadge({ status }: { status: LocalProviderStatus }) { + if (status === "checking") { + return ; + } + + if (status === "connected") { + return ( + + + Connected + + ); + } + + return ( + + + Not Running + + ); +} + function useProvider(providerType: ProviderType, id: string) { const rowId = providerRowId(providerType, id); const providerRow = settings.UI.useRow( diff --git a/apps/desktop/src/components/settings/ai/shared/use-local-provider-status.ts b/apps/desktop/src/components/settings/ai/shared/use-local-provider-status.ts new file mode 100644 index 0000000000..f7880cbf58 --- /dev/null +++ b/apps/desktop/src/components/settings/ai/shared/use-local-provider-status.ts @@ -0,0 +1,65 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; + +import * as settings from "../../../../store/tinybase/store/settings"; + +export type LocalProviderStatus = "connected" | "disconnected" | "checking"; + +const LOCAL_PROVIDERS = new Set(["ollama", "lmstudio"]); + +const DEFAULT_URLS: Record = { + ollama: "http://127.0.0.1:11434/v1", + lmstudio: "http://127.0.0.1:1234/v1", +}; + +async function checkConnection(baseUrl: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2000); + try { + const res = await tauriFetch(`${baseUrl}/models`, { + signal: controller.signal, + }); + return res.ok; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +export function useLocalProviderStatus(providerId: string): { + status: LocalProviderStatus | null; + refetch: () => void; +} { + const isLocal = LOCAL_PROVIDERS.has(providerId); + + const configuredProviders = settings.UI.useResultTable( + settings.QUERIES.llmProviders, + settings.STORE_ID, + ); + const baseUrl = String( + configuredProviders[providerId]?.base_url || DEFAULT_URLS[providerId] || "", + ).trim(); + + const query = useQuery({ + enabled: isLocal && !!baseUrl, + queryKey: ["local-provider-status", providerId, baseUrl], + queryFn: () => checkConnection(baseUrl), + staleTime: 10_000, + refetchInterval: 15_000, + retry: false, + }); + + if (!isLocal) { + return { status: null, refetch: () => {} }; + } + + const status: LocalProviderStatus = + query.isLoading || (query.isFetching && !query.data) + ? "checking" + : query.data + ? "connected" + : "disconnected"; + + return { status, refetch: () => void query.refetch() }; +}