First-run gate offers free Ollama vs paid providers; add Google Gemini
The gate (user-key installs) now headlines Ollama with a "Free" badge and adds an "or - Use a paid provider instead" option that opens the same provider rows as Settings, framed as billed by the provider directly. Saving a provider key there lists its models and switches study generation to it automatically, so the gate steps aside with no Ollama key at all. Gemini joins the registry via its OpenAI-compatible endpoint (generativelanguage.googleapis.com/v1beta/openai); the privacy policy names it (en+cs). Providers suite grows to 20 checks: the gate scenario runs against a second user-key-mode app instance (free badge, paid option, provider rows, pass-through, generation switch). All other gates green: contrast 0 findings, mobile 126/126, chat 102/102, wizard 95/95, bubbles 34/34, regression 44/44, i18n 13/13, native 33/33. Version 0.4.9, SW -> freezmo-v73.
This commit is contained in:
parent
4372423167
commit
8651674cf5
8 changed files with 132 additions and 8 deletions
|
|
@ -17,6 +17,9 @@ import {
|
|||
import { Spinner } from "@/components/ui";
|
||||
import { LOCALES, useI18n } from "@/lib/i18n";
|
||||
import { verifyOllamaKey } from "@/lib/client-api";
|
||||
import { ProvidersSection } from "@/components/ProvidersSection";
|
||||
import { PROVIDERS } from "@/lib/providers";
|
||||
import { modelsFor, setGeneration, useProviders } from "@/lib/providers-client";
|
||||
import { saveSettings, settingsStore } from "@/lib/settings";
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -40,13 +43,26 @@ export function ApiKeyGate({ children }: { children: React.ReactNode }) {
|
|||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
const { health } = useHealth();
|
||||
const providers = useProviders();
|
||||
|
||||
// Server-key and local-daemon installs never need a browser key.
|
||||
const needsKeyMode = health?.authMode !== "local" && health?.authMode !== "server";
|
||||
|
||||
// A paid provider is a valid first-run choice too. As soon as a key is saved
|
||||
// the study generation switches to that provider — Ollama stays untouched —
|
||||
// and the gate steps aside (the provider's models were listed on save).
|
||||
const firstEnabled = PROVIDERS.find((provider) => providers.keys[provider.id]);
|
||||
useEffect(() => {
|
||||
if (!firstEnabled || providers.generation) return;
|
||||
const models = modelsFor(providers, firstEnabled.id);
|
||||
if (models.length === 0) return;
|
||||
setGeneration({ provider: firstEnabled.id, textModel: models[0], visionModel: models[0] });
|
||||
}, [firstEnabled, providers]);
|
||||
const providerReady = Boolean(providers.generation && providers.keys[providers.generation.provider]);
|
||||
|
||||
// Until hydration finishes, render the page as usual so a key holder never
|
||||
// sees the gate flash. After that, a missing key replaces the app.
|
||||
if (!mounted || storedKey || !needsKeyMode) return <>{children}</>;
|
||||
if (!mounted || storedKey || providerReady || !needsKeyMode) return <>{children}</>;
|
||||
|
||||
return <GateScreen />;
|
||||
}
|
||||
|
|
@ -85,6 +101,7 @@ function GateScreen() {
|
|||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<GateError>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [providersOpen, setProvidersOpen] = useState(false);
|
||||
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -262,7 +279,14 @@ function GateScreen() {
|
|||
{t("gate.subtitle")}
|
||||
</p>
|
||||
|
||||
<form className="mt-6" onSubmit={submit} noValidate>
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[var(--text)]">{t("gate.ollamaLabel")}</span>
|
||||
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
|
||||
{t("gate.free")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form className="mt-2.5" onSubmit={submit} noValidate>
|
||||
<div className="relative">
|
||||
<label className="sr-only" htmlFor="gate-api-key">
|
||||
{t("gate.apiKeyLabel")}
|
||||
|
|
@ -341,6 +365,28 @@ function GateScreen() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center gap-3" aria-hidden>
|
||||
<span className="h-px flex-1 bg-[var(--hairline-soft)]" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-[var(--text-subtle)]">{t("gate.or")}</span>
|
||||
<span className="h-px flex-1 bg-[var(--hairline-soft)]" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProvidersOpen((open) => !open)}
|
||||
aria-expanded={providersOpen}
|
||||
className="btn-secondary mt-3 h-11 w-full rounded-[13px] text-[13.5px]"
|
||||
>
|
||||
{t("gate.paidOption")}
|
||||
</button>
|
||||
|
||||
{providersOpen ? (
|
||||
<div className="mt-3 space-y-2 text-left">
|
||||
<p className="text-[11.5px] leading-snug text-[var(--text-subtle)]">{t("gate.paidNote")}</p>
|
||||
<ProvidersSection />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mx-auto mt-4 max-w-[320px] text-balance text-[12px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("gate.stays")}
|
||||
</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue