Skip to content
21 changes: 3 additions & 18 deletions apps/desktop/src/components/settings/ai/llm/configure.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { HelpCircle } from "lucide-react";

import {
Accordion,
AccordionContent,
Expand All @@ -18,20 +16,7 @@ export function ConfigureProviders() {

return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="text-md font-semibold font-serif">
Configure Providers
</h3>
<a
href="https://hyprnote.com/docs/faq/local-llm-setup"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-neutral-400 hover:underline flex items-center gap-1"
>
<span>Local setup guide</span>
<HelpCircle className="size-3" />
</a>
</div>
<h3 className="text-md font-semibold font-serif">Configure Providers</h3>
<Accordion
type="single"
collapsible
Expand Down Expand Up @@ -111,9 +96,9 @@ function ProviderContext({
providerId === "hyprnote"
? "A curated set of models we continuously test to provide the **best performance & reliability**."
: providerId === "lmstudio"
? "- Ensure LM Studio server is **running.** (Default port is 1234)\n- Enable **CORS** in LM Studio config.\n\nSee our [setup guide](https://hyprnote.com/docs/faq/local-llm-setup/#lm-studio-setup) for detailed instructions."
? "- Ensure LM Studio server is **running.** (Default port is 1234)\n- Enable **CORS** in LM Studio config."
: providerId === "ollama"
? "- Ensure Ollama is **running** (`ollama serve`)\n- Pull a model first (`ollama pull llama3.2`)\n\nSee our [setup guide](https://hyprnote.com/docs/faq/local-llm-setup/#ollama-setup) for detailed instructions."
? "- Ensure Ollama is **running** (`ollama serve`)\n- Pull a model first (`ollama pull llama3.2`)"
: providerId === "custom"
? "We only support **OpenAI-compatible** endpoints for now."
: providerId === "openrouter"
Expand Down
18 changes: 17 additions & 1 deletion apps/desktop/src/components/settings/ai/llm/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { listOllamaModels } from "../shared/list-ollama";
import { listGenericModels, listOpenAIModels } from "../shared/list-openai";
import { listOpenRouterModels } from "../shared/list-openrouter";
import { ModelCombobox } from "../shared/model-combobox";
import { useLocalProviderStatus } from "../shared/use-local-provider-status";
import { HealthStatusIndicator, useConnectionHealth } from "./health";
import { PROVIDERS } from "./shared";

Expand All @@ -46,6 +47,9 @@ export function SelectProviderAndModel() {
const isConfigured = !!(current_llm_provider && current_llm_model);
const hasError = isConfigured && health.status === "error";

const { status: ollamaStatus } = useLocalProviderStatus("ollama");
const { status: lmStudioStatus } = useLocalProviderStatus("lmstudio");

const handleSelectProvider = settings.UI.useSetValueCallback(
"current_llm_provider",
(provider: string) => provider,
Expand Down Expand Up @@ -117,17 +121,29 @@ export function SelectProviderAndModel() {
<SelectContent>
{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 (
<SelectItem
key={provider.id}
value={provider.id}
disabled={!status?.listModels}
disabled={isDisabled}
>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
{provider.icon}
<span>{provider.displayName}</span>
{localStatus === "connected" && (
<span className="size-1.5 rounded-full bg-green-500" />
)}
</div>
</div>
</SelectItem>
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/components/settings/ai/llm/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -45,6 +50,17 @@ const _PROVIDERS = [
icon: <LmStudio size={16} />,
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",
Expand All @@ -53,6 +69,17 @@ const _PROVIDERS = [
icon: <Ollama size={16} />,
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",
Expand Down
107 changes: 100 additions & 7 deletions apps/desktop/src/components/settings/ai/shared/index.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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";
Expand All @@ -25,6 +28,10 @@ import {
type ProviderRequirement,
requiresEntitlement,
} from "./eligibility";
import {
type LocalProviderStatus,
useLocalProviderStatus,
} from "./use-local-provider-status";

export * from "./model-combobox";

Expand All @@ -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) {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -148,13 +163,30 @@ export function NonHyprProviderCard({
(config.disabled || locked) && "cursor-not-allowed opacity-30",
])}
>
<div className="flex items-center gap-2">
{config.icon}
<span>{config.displayName}</span>
{config.badge && (
<span className="text-xs text-neutral-500 font-light border border-neutral-300 rounded-full px-2">
{config.badge}
</span>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
{config.icon}
<span>{config.displayName}</span>
{config.badge && (
<span className="text-xs text-neutral-500 font-light border border-neutral-300 rounded-full px-2">
{config.badge}
</span>
)}
{localStatus && <StatusBadge status={localStatus} />}
</div>
{localStatus && localStatus !== "connected" && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
refetchStatus();
}}
disabled={localStatus === "checking"}
className="mr-2"
>
Connect
</Button>
)}
</div>
</AccordionTrigger>
Expand Down Expand Up @@ -190,6 +222,45 @@ export function NonHyprProviderCard({
)}
</form.Field>
)}
{config.links && (
<div className="flex items-center gap-4 text-xs">
{config.links.download && (
<a
href={config.links.download.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-neutral-600 hover:text-neutral-900 hover:underline"
>
{config.links.download.label}
<ExternalLink size={12} />
</a>
)}
{config.links.models && (
<div className="inline-flex items-center gap-4">
<a
href={config.links.models.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-neutral-600 hover:text-neutral-900 hover:underline"
>
{config.links.models.label}
<ExternalLink size={12} />
</a>
{config.links.setup && (
<a
href={config.links.setup.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-neutral-600 hover:text-neutral-900 hover:underline"
>
{config.links.setup.label}
<ExternalLink size={12} />
</a>
Comment on lines +248 to +258
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Removed local setup guide link from LLM configure page header

The old code had a prominent "Local setup guide" link in the ConfigureProviders header (linking to hyprnote.com/docs/faq/local-llm-setup). This was removed entirely from the header, and also the inline setup guide links in the provider context strings for Ollama and LM Studio were stripped (configure.tsx:80,82). The replacement is the new links.setup entries in shared.tsx:59-62,78-81 which render inside the accordion content via shared/index.tsx:248-258. However, these links are nested inside a config.links.models conditional block — the setup link only renders if config.links.models is also defined. This coupling is a bit fragile; if someone adds a provider with setup but no models link, the setup link won't appear.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

)}
</div>
)}
</div>
)}
{!showBaseUrl && config.baseUrl && (
<details className="flex flex-col gap-4 pt-2">
<summary className="text-xs cursor-pointer text-neutral-600 hover:text-neutral-900 hover:underline">
Expand Down Expand Up @@ -249,6 +320,28 @@ export function StyledStreamdown({
);
}

function StatusBadge({ status }: { status: LocalProviderStatus }) {
if (status === "checking") {
return <Spinner size={12} className="shrink-0 text-neutral-400" />;
}

if (status === "connected") {
return (
<span className="flex items-center gap-1 text-xs text-green-600 font-light">
<span className="size-1.5 rounded-full bg-green-500" />
Connected
</span>
);
}

return (
<span className="flex items-center gap-1 text-xs text-neutral-500 font-light">
<span className="size-1.5 rounded-full bg-neutral-400" />
Not Running
</span>
);
}

function useProvider(providerType: ProviderType, id: string) {
const rowId = providerRowId(providerType, id);
const providerRow = settings.UI.useRow(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
ollama: "http://127.0.0.1:11434/v1",
lmstudio: "http://127.0.0.1:1234/v1",
};

async function checkConnection(baseUrl: string): Promise<boolean> {
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();
Comment on lines +40 to +42
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Wrong key used to look up provider config — user-customized base URL is never read

The useLocalProviderStatus hook looks up the configured provider's base URL using configuredProviders[providerId] (e.g., configuredProviders["ollama"]), but the result table from settings.QUERIES.llmProviders uses row IDs in the format "llm:ollama" (as produced by providerRowId).

Root Cause and Impact

Every other place in the codebase that accesses this result table uses providerRowId(type, id) to construct the key — for example, select.tsx:219 uses configuredProviders[providerRowId("llm", provider.id)] and shared/index.tsx:75 uses configuredProviders[providerRowId(providerType, providerId)]. The row IDs are stored in ${providerType}:${id} format as confirmed by the transform logic at apps/desktop/src/store/tinybase/persister/settings/transform.ts:105 and the test data at persister.test.ts:126 ("llm:openai").

Because configuredProviders["ollama"] is always undefined, the expression configuredProviders[providerId]?.base_url is always undefined, and the code always falls back to DEFAULT_URLS[providerId].

Impact: If a user customizes the base URL for Ollama or LM Studio (e.g., changes the port or host), the connection status check will still ping the default URL (http://127.0.0.1:11434/v1 or http://127.0.0.1:1234/v1), giving incorrect connection status results.

Suggested change
const baseUrl = String(
configuredProviders[providerId]?.base_url || DEFAULT_URLS[providerId] || "",
).trim();
const rowId = `llm:${providerId}`;
const baseUrl = String(
configuredProviders[rowId]?.base_url || DEFAULT_URLS[providerId] || "",
).trim();
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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() };
}
Loading