From 8651674cf58faad64413f1c9f740c2de815baa30 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Wed, 14 Oct 2026 07:09:05 +0000 Subject: [PATCH] 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. --- README.md | 13 ++++++++-- components/ApiKeyGate.tsx | 50 ++++++++++++++++++++++++++++++++++++-- lib/i18n-dict.ts | 10 ++++++++ lib/legal.ts | 4 +-- lib/providers.ts | 8 ++++++ package.json | 2 +- public/sw.js | 2 +- tests/providers-test.mjs | 51 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 132 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9e951a1..ddf1e63 100644 --- a/README.md +++ b/README.md @@ -1411,9 +1411,18 @@ Things I chose while building, with the reasoning: pre-blurred wallpaper — the scene is clearly visible through the glass, and the pixel-contrast scan still passes. +82. **First run offers the choice: free Ollama or a paid provider.** The gate (user-key installs) + now shows Ollama with a "Free" badge as the headline option, and below it an "or · Use a paid + provider instead" button that opens the same provider rows as Settings — with the framing that + those are billed by the provider directly (Freezmo never sees a card) and that keys stay in the + browser. Saving a provider key on that screen lists its models and switches study generation to + it automatically, so the gate steps aside without an Ollama key at all — pinned end to end in + the providers suite with a second, user-key-mode app instance. + 81. **Bring-your-own keys for cloud providers.** Besides the server's Ollama, Settings → Providers takes - keys for OpenAI, Anthropic (their official OpenAI-compatibility layer), DeepSeek, Groq, OpenRouter - and any custom OpenAI-compatible endpoint. One adapter (`lib/openai-compat`) covers them all — + keys for OpenAI, Anthropic (their official OpenAI-compatibility layer), Google Gemini (its + OpenAI-compatible endpoint), DeepSeek, Groq, OpenRouter and any custom OpenAI-compatible + endpoint. One adapter (`lib/openai-compat`) covers them all — model listing, streaming chat, JSON generation and vision — and every model flow can run on a provider: the chat picker lists each enabled provider's models (labelled, remembered across visits), and a "Study generation" choice moves decks, homework, grading and attachment diff --git a/components/ApiKeyGate.tsx b/components/ApiKeyGate.tsx index fe2f1a7..7dfdb89 100644 --- a/components/ApiKeyGate.tsx +++ b/components/ApiKeyGate.tsx @@ -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 ; } @@ -85,6 +101,7 @@ function GateScreen() { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [helpOpen, setHelpOpen] = useState(false); + const [providersOpen, setProvidersOpen] = useState(false); const [keyboardOpen, setKeyboardOpen] = useState(false); const inputRef = useRef(null); const rootRef = useRef(null); @@ -262,7 +279,14 @@ function GateScreen() { {t("gate.subtitle")}

-
+
+ {t("gate.ollamaLabel")} + + {t("gate.free")} + +
+ +
+
+ + {t("gate.or")} + +
+ + + + {providersOpen ? ( +
+

{t("gate.paidNote")}

+ +
+ ) : null} +

{t("gate.stays")}

diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index 4a8d6c7..7f43f92 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -561,6 +561,11 @@ const en: Record = { "results.false": "False", /* gate ------------------------------------------------------------ */ + "gate.free": "Free", + "gate.ollamaLabel": "Ollama", + "gate.or": "or", + "gate.paidOption": "Use a paid provider instead", + "gate.paidNote": "OpenAI, Anthropic, Gemini, DeepSeek, Groq, OpenRouter… Billed by the provider directly — Freezmo never sees a card. Your keys stay in this browser, and picking one switches study generation to it.", "gate.title": "Connect your Ollama key", "gate.subtitle": "One step to freeze your first deck.", "gate.apiKeyLabel": "Ollama API key", @@ -1542,6 +1547,11 @@ const cs: Record = { "results.false": "Nepravda", /* gate ------------------------------------------------------------ */ + "gate.free": "Zdarma", + "gate.ollamaLabel": "Ollama", + "gate.or": "nebo", + "gate.paidOption": "Použít placeného poskytovatele", + "gate.paidNote": "OpenAI, Anthropic, Gemini, DeepSeek, Groq, OpenRouter… Účtuje je přímo poskytovatel — Freezmo nikdy nevidí kartu. Klíče zůstávají v tomto prohlížeči a výběrem se na něj přesune generování studia.", "gate.title": "Připoj svůj klíč Ollama", "gate.subtitle": "Jeden krok k tvé první zmrazené sadě.", "gate.apiKeyLabel": "API klíč Ollama", diff --git a/lib/legal.ts b/lib/legal.ts index 8203d2f..4302356 100644 --- a/lib/legal.ts +++ b/lib/legal.ts @@ -60,7 +60,7 @@ const PRIVACY_EN: LegalDocument = { { heading: "Third parties", paragraphs: [ - "Model calls go to the Ollama endpoint this instance is configured for, or — when you pick a cloud model — to that provider (OpenAI, Anthropic, DeepSeek, Groq, OpenRouter or your own endpoint) with the key from your browser. The provider's own privacy terms apply to those calls. Reminders travel through your browser's push service (Google, Mozilla or Apple). There is nothing else: no ads, no analytics, no embeds.", + "Model calls go to the Ollama endpoint this instance is configured for, or — when you pick a cloud model — to that provider (OpenAI, Anthropic, Google Gemini, DeepSeek, Groq, OpenRouter or your own endpoint) with the key from your browser. The provider's own privacy terms apply to those calls. Reminders travel through your browser's push service (Google, Mozilla or Apple). There is nothing else: no ads, no analytics, no embeds.", ], }, { @@ -110,7 +110,7 @@ const PRIVACY_CS: LegalDocument = { { heading: "Třetí strany", paragraphs: [ - "Modelové dotazy jdou na Ollama endpoint, který je tato instance nastavená používat, nebo — když zvolíš cloudový model — danému poskytovateli (OpenAI, Anthropic, DeepSeek, Groq, OpenRouter nebo vlastní endpoint) s klíčem z tvého prohlížeče; na tato volání platí podmínky daného poskytovatele. Připomínky putují přes push službu tvého prohlížeče (Google, Mozilla nebo Apple). Nic jiného tu není: žádné reklamy, analytika ani vložený obsah.", + "Modelové dotazy jdou na Ollama endpoint, který je tato instance nastavená používat, nebo — když zvolíš cloudový model — danému poskytovateli (OpenAI, Anthropic, Google Gemini, DeepSeek, Groq, OpenRouter nebo vlastní endpoint) s klíčem z tvého prohlížeče; na tato volání platí podmínky daného poskytovatele. Připomínky putují přes push službu tvého prohlížeče (Google, Mozilla nebo Apple). Nic jiného tu není: žádné reklamy, analytika ani vložený obsah.", ], }, { diff --git a/lib/providers.ts b/lib/providers.ts index 4a0632f..e1a0d58 100644 --- a/lib/providers.ts +++ b/lib/providers.ts @@ -69,6 +69,14 @@ export const PROVIDERS: ProviderInfo[] = [ "qwen/qwen3-235b-a22b:free", ], }, + { + id: "gemini", + label: "Google Gemini", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + keyHint: "AIza…", + docs: "https://aistudio.google.com/app/apikey", + models: ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"], + }, { id: "custom", label: "Custom (OpenAI-compatible)", diff --git a/package.json b/package.json index 94c27bb..cf96c38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.4.7", + "version": "0.4.9", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index d3530e7..ce18a2c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v72"; +const CACHE_VERSION = "freezmo-v73"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/providers-test.mjs b/tests/providers-test.mjs index 15df52e..a5bc561 100644 --- a/tests/providers-test.mjs +++ b/tests/providers-test.mjs @@ -113,6 +113,7 @@ const providerSeed = { generation: { provider: "custom", textModel: "free-model-a", visionModel: "free-model-a" }, }; +let gateApp = null; try { /* 1. API: the listing route forwards the key and returns the models ---- */ { @@ -211,6 +212,7 @@ try { await page.locator("nav.tabbar").getByRole("button").click(); await page.getByRole("dialog").waitFor({ timeout: 6000 }); await page.waitForTimeout(600); + check("settings: Google Gemini is offered", (await page.locator('input[aria-label="Google Gemini API key"]').count()) === 1); const row = page.locator('input[aria-label="Custom (OpenAI-compatible) API key"]'); await row.scrollIntoViewIfNeeded(); await row.fill(PROVIDER_KEY); @@ -224,8 +226,57 @@ try { await context.close(); } + /* 5. First run: the gate offers the free Ollama path and paid providers - */ + { + const GATE_PORT = 3214; + check("gate app port is free", !(await portInUse(GATE_PORT))); + // No OLLAMA_AUTH: the default user-key mode, so the first-run gate shows. + gateApp = spawnApp(GATE_PORT, { + OLLAMA_BASE_URL: `http://127.0.0.1:${STUB_PORT}`, + OLLAMA_VERIFY_KEY: "false", + }); + await waitForHttp(`http://127.0.0.1:${GATE_PORT}/api/healthz`); + + const { context, page } = await makePage(browser, { width: 390, height: 844 }); + await seed(page, { + decks: [], + key: "", + extra: { + "freezmo.providers.v1": JSON.stringify({ keys: {}, models: {}, customBaseUrl: PROVIDER_BASE, generation: null }), + }, + }); + await page.goto(`http://127.0.0.1:${GATE_PORT}/`, { waitUntil: "networkidle" }); + await page.waitForTimeout(1400); + + check("gate: the free Ollama path is the headline", (await page.getByText("Free").count()) >= 1); + const paid = page.getByRole("button", { name: "Use a paid provider instead" }); + check("gate: the paid option is visible on first run", await paid.isVisible()); + await paid.click(); + await page.waitForTimeout(500); + check("gate: the provider rows open", (await page.locator('input[aria-label="OpenAI API key"]').count()) === 1); + + const gateKey = page.locator('input[aria-label="Custom (OpenAI-compatible) API key"]'); + await gateKey.scrollIntoViewIfNeeded(); + await gateKey.fill(PROVIDER_KEY); + await gateKey.locator("xpath=..").getByRole("button", { name: "Save key", exact: true }).click(); + await page.waitForTimeout(2200); + check( + "gate: a saved provider key lets the app through", + await page.getByRole("heading", { name: "Your icebox" }).isVisible(), + ); + check( + "gate: the provider became the study-generation model", + (await page.evaluate(() => { + const state = JSON.parse(localStorage.getItem("freezmo.providers.v1") ?? "{}"); + return state.generation?.provider === "custom" && state.generation?.textModel === "free-model-a"; + })), + ); + await context.close(); + } + check("stub saw both endpoints", seen.models >= 1 && seen.completions >= 1, JSON.stringify(seen)); } finally { + gateApp?.stop(); app.stop(); stub.close(); await browser.close();