Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -30,6 +30,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 @@ -45,6 +46,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 @@ -116,17 +120,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 };
};
};

function useIsProviderConfigured(
Expand Down Expand Up @@ -95,6 +107,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 @@ -144,13 +159,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 @@ -186,6 +218,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>
)}
</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 @@ -245,6 +316,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(id: string) {
const providerRow = settings.UI.useRow("ai_providers", id, settings.STORE_ID);
const setProvider = settings.UI.useSetPartialRowCallback(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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 origin = new URL(baseUrl).origin;
const res = await tauriFetch(`${baseUrl}/models`, {
Comment on lines +19 to +20
Copy link
Contributor

Choose a reason for hiding this comment

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

Origin header construction creates invalid URL

The code constructs an origin from baseUrl, then sends it to an endpoint derived from the same baseUrl. This creates a mismatch:

  • Origin: http://127.0.0.1:11434 (extracted from baseUrl)
  • Request URL: http://127.0.0.1:11434/v1/models

The Origin header should match the origin of the page making the request, not the target server. In Tauri apps, this should typically be the app's origin (e.g., tauri://localhost).

// Remove this line - Tauri fetch handles Origin automatically
// headers: { Origin: origin },

// Or use a fixed Tauri origin if CORS checking is needed:
const res = await tauriFetch(`${baseUrl}/models`, {
  signal: controller.signal,
  // Origin header typically not needed in Tauri context
});

This could cause CORS preflight failures or unexpected behavior depending on how LM Studio/Ollama validate the Origin header.

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

signal: controller.signal,
headers: { Origin: origin },
});
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
? "checking"
: query.data
? "connected"
: "disconnected";
Comment on lines +59 to +63
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🟡 query.isLoading is false on refetch, so "Connect" button never shows loading feedback

When the user clicks the "Connect" button to recheck a local provider's status, there is no visual feedback because the "checking" state is derived from query.isLoading, which is only true on the initial fetch when no cached data exists.

Root Cause

In React Query, isLoading is true only when isPending && isFetching — i.e., there's no cached data and a fetch is in progress. After the first connection check fails (query.data = false), subsequent refetches (triggered by the "Connect" button via query.refetch()) set isFetching = true but keep isLoading = false because cached data (false) already exists.

The status derivation at use-local-provider-status.ts:59-63:

const status: LocalProviderStatus = query.isLoading
  ? "checking"
  : query.data
    ? "connected"
    : "disconnected";

After the first failed check, clicking "Connect" triggers refetchStatus()query.refetch(). During this refetch:

  • query.isLoading is false (cached data exists)
  • query.data is false (cached failed result)
  • So status stays "disconnected" throughout the refetch

This defeats the intended behavior at apps/desktop/src/components/settings/ai/shared/index.tsx:180-181 where disabled={localStatus === "checking"} was meant to disable the button during the recheck, and at line 171 where StatusBadge was meant to show a spinner. Neither fires because status never becomes "checking" on refetch.

Impact: The "Connect" button remains clickable with no loading indicator while the recheck is in progress, giving the user no feedback that anything is happening. Users may click it repeatedly.

Suggested change
const status: LocalProviderStatus = query.isLoading
? "checking"
: query.data
? "connected"
: "disconnected";
const status: LocalProviderStatus = query.isLoading || (query.isFetching && !query.data)
? "checking"
: query.data
? "connected"
: "disconnected";
Open in Devin Review

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


return { status, refetch: () => void query.refetch() };
}
Loading