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
1 change: 1 addition & 0 deletions ui/src/components/ModelPicker/model-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ const PROVIDER_LABELS: Record<string, string> = {
qwen: "Qwen",
openrouter: "OpenRouter",
test: "Test",
browser: "Browser AI",
};

export function getProviderInfo(
Expand Down
138 changes: 138 additions & 0 deletions ui/src/components/WasmSetup/BrowserAiCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { CheckCircle2, Cpu, Download, ExternalLink, Loader2, XCircle } from "lucide-react";
import { Button } from "@/components/Button/Button";
import { cn } from "@/utils/cn";
import type { LanguageModelAvailability } from "@/services/browser-ai";

export interface BrowserAiState {
/** True if `globalThis.LanguageModel` is exposed by this browser. */
supported: boolean;
availability: LanguageModelAvailability;
/** 0..1, only meaningful while a download is in progress. */
downloadProgress: number | null;
/** True while we are actively triggering or awaiting a download. */
downloading: boolean;
error: string | null;
}

interface BrowserAiCardProps {
state: BrowserAiState;
onDownload: () => void;
className?: string;
}

export function BrowserAiCard({ state, onDownload, className }: BrowserAiCardProps) {
if (!state.supported) {
return (
<div className={cn("rounded-lg border border-border bg-muted/30 p-4", className)}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">Browser AI</p>
<p className="text-xs text-muted-foreground">
On-device model running locally in your browser
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<XCircle className="h-3.5 w-3.5" />
Not supported
</div>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Open this page in Chrome 148+ (or recent Edge / Brave / other Chromium).{" "}
<a
href="https://developer.chrome.com/docs/ai/get-started"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
docs
<ExternalLink className="ml-0.5 inline h-3 w-3" />
</a>
</p>
</div>
);
}

const isReady = state.availability === "available";
const isDownloading = state.downloading || state.availability === "downloading";
const isDownloadable = state.availability === "downloadable" && !isDownloading;
const progressPercent =
state.downloadProgress != null
? Math.max(0, Math.min(100, state.downloadProgress * 100))
: null;

return (
<div
className={cn(
"rounded-lg border p-4",
isReady
? "border-emerald-200 bg-emerald-50/60 dark:border-emerald-500/20 dark:bg-emerald-500/5"
: "border-sky-200 bg-sky-50/60 dark:border-sky-500/20 dark:bg-sky-500/5",
className
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">Browser AI</p>
<p className="text-xs text-muted-foreground">
Runs locally on-device. Private, free, no API key.
</p>
</div>
{isReady ? (
<div className="flex shrink-0 items-center gap-1.5 text-sm text-emerald-700 dark:text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
Ready
</div>
) : isDownloading ? (
<div className="flex shrink-0 items-center gap-1.5 text-xs text-sky-700 dark:text-sky-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{progressPercent != null ? `${progressPercent.toFixed(0)}%` : "Downloading"}
</div>
) : isDownloadable ? (
<Button
size="sm"
onClick={onDownload}
className="shrink-0 bg-sky-600 text-white hover:bg-sky-700 dark:bg-sky-600 dark:hover:bg-sky-500"
>
<Download className="mr-1.5 h-3.5 w-3.5" />
Download
</Button>
) : (
<div className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<Cpu className="h-3.5 w-3.5" />
Unavailable
</div>
)}
</div>

{isDownloading && progressPercent != null && (
<div
className="mt-3 h-1 w-full overflow-hidden rounded-full bg-sky-100 dark:bg-sky-500/10"
role="progressbar"
aria-valuenow={progressPercent}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Browser AI model download progress"
>
<div
className="h-full rounded-full bg-sky-600 transition-[width] duration-200 dark:bg-sky-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}

{state.error && (
<p className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
<XCircle className="mt-0.5 h-3 w-3 shrink-0" />
{state.error}
</p>
)}

{state.availability === "unavailable" && !state.error && (
<p className="mt-2 text-xs text-muted-foreground">
The browser exposes the API but reports the device as ineligible (typically not enough
memory, storage, or GPU). The model will appear here once your environment qualifies.
</p>
)}
</div>
);
}
67 changes: 54 additions & 13 deletions ui/src/components/WasmSetup/WasmSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { Input } from "@/components/Input/Input";
import { FormField } from "@/components/FormField/FormField";
import { HadrianIcon } from "@/components/HadrianIcon/HadrianIcon";
import { startOpenRouterOAuth, isInIframe } from "./openrouter-oauth";
import { BrowserAiCard, type BrowserAiState } from "./BrowserAiCard";
import { cn } from "@/utils/cn";

import { formatApiError } from "@/utils/formatApiError";
Expand Down Expand Up @@ -105,6 +106,30 @@ function initialEntries(): ProviderEntry[] {
return PROVIDER_TEMPLATES.map((t) => createEntry(t, 0));
}

// Bundle the browser AI state and download callback into a single optional
// prop. Previously the two were separate optionals with `onBrowserAiDownload
// ?? (() => {})` as a fallback, which let callers silently no-op the
// Download button by passing state without a callback. Bundling makes the
// pairing structural — a caller cannot supply the state without the
// handler — so the no-op fallback can go away entirely.
export interface BrowserAiProp {
state: BrowserAiState;
onDownload: () => void;
}

interface WasmSetupProps {
open: boolean;
onComplete: () => void;
oauthProviderName?: string | null;
oauthError?: string | null;
existingProviders?: DynamicProviderResponse[];
ollamaDetected?: boolean;
ollamaConnecting?: boolean;
ollamaConnected?: boolean;
onOllamaConnect?: () => void;
browserAi?: BrowserAiProp;
}

export function WasmSetup({
open,
onComplete,
Expand All @@ -115,17 +140,8 @@ export function WasmSetup({
ollamaConnecting,
ollamaConnected,
onOllamaConnect,
}: {
open: boolean;
onComplete: () => void;
oauthProviderName?: string | null;
oauthError?: string | null;
existingProviders?: DynamicProviderResponse[];
ollamaDetected?: boolean;
ollamaConnecting?: boolean;
ollamaConnected?: boolean;
onOllamaConnect?: () => void;
}) {
browserAi,
}: WasmSetupProps) {
const [step, setStep] = useState<Step>("welcome");
const [entries, setEntries] = useState<ProviderEntry[]>(initialEntries);

Expand Down Expand Up @@ -244,10 +260,12 @@ export function WasmSetup({
}
}, []);

const browserAiReady = browserAi?.state.availability === "available";
const savedCount =
entries.filter((e) => e.saved).length +
(hasExistingOpenRouter ? 1 : 0) +
(hasExistingOllama ? 1 : 0);
(hasExistingOllama ? 1 : 0) +
(browserAiReady ? 1 : 0);
const hasAnySaved = savedCount > 0;

return (
Expand All @@ -266,6 +284,7 @@ export function WasmSetup({
onOllamaConnect={onOllamaConnect}
existingProviders={existingProviders}
onDeleteExisting={handleDeleteExisting}
browserAi={browserAi}
/>
)}
{step === "providers" && (
Expand All @@ -290,6 +309,7 @@ export function WasmSetup({
onOllamaConnect={onOllamaConnect}
existingProviders={existingProviders}
onDeleteExisting={handleDeleteExisting}
browserAi={browserAi}
/>
)}
{step === "done" && <DoneStep savedCount={savedCount} onComplete={onComplete} />}
Expand All @@ -310,6 +330,7 @@ function WelcomeStep({
onOllamaConnect,
existingProviders,
onDeleteExisting,
browserAi,
}: {
onNext: () => void;
onReady: () => void;
Expand All @@ -323,8 +344,10 @@ function WelcomeStep({
onOllamaConnect?: () => void;
existingProviders?: DynamicProviderResponse[];
onDeleteExisting: (id: string) => void;
browserAi?: BrowserAiProp;
}) {
const hasProvider = hasExistingOpenRouter || hasExistingOllama;
const hasBrowserAiReady = browserAi?.state.availability === "available";
const hasProvider = hasExistingOpenRouter || hasExistingOllama || hasBrowserAiReady;
return (
<>
<ModalHeader>
Expand Down Expand Up @@ -457,6 +480,14 @@ function WelcomeStep({
</div>
)}

{browserAi && (
<BrowserAiCard
state={browserAi.state}
onDownload={browserAi.onDownload}
className="mt-3"
/>
)}

<p className="text-sm text-muted-foreground mt-4">
{hasProvider
? "You can also add API keys from OpenAI, Anthropic, or other providers."
Expand Down Expand Up @@ -524,6 +555,7 @@ function ProvidersStep({
onOllamaConnect,
existingProviders,
onDeleteExisting,
browserAi,
}: {
entries: ProviderEntry[];
onUpdate: (key: string, update: Partial<ProviderEntry>) => void;
Expand All @@ -545,6 +577,7 @@ function ProvidersStep({
onOllamaConnect?: () => void;
existingProviders?: DynamicProviderResponse[];
onDeleteExisting: (id: string) => void;
browserAi?: BrowserAiProp;
}) {
return (
<>
Expand Down Expand Up @@ -636,6 +669,14 @@ function ProvidersStep({
</div>
) : null}

{browserAi && (
<BrowserAiCard
state={browserAi.state}
onDownload={browserAi.onDownload}
className="mb-4"
/>
)}

<div className="space-y-5">
{entries.map((entry) => (
<ProviderKeyEntry
Expand Down
Loading
Loading