From 4727365c8cb8b552826b65ba88e8c159e31b3a69 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:48:32 +0200 Subject: [PATCH 1/2] docs(claude-md): fix stale locale-count drift (17->19 locales, 20->21 modules) CLAUDE.md's i18n section and directory map both undercounted the locale roster by 2 (missing ru and ko, added in a prior PR's squashed commits but never reflected here) and the per-locale module count by 1 (20 -> 21). Also corrected the Beta/near-production grouping to match i18n/locales.ts (the actual SSOT) -- el/ja/pt/zh are near-production, not beta. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c5ba180f..d8162c6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ pnpm run token:audit # audit-tokens.mjs — design-token usage gate (CI b **PR review-comment policy — the CodeAnt Correction Loop (proactive, automatic, every PR):** Fix ALL inline comments (CodeAnt AI + any bot/human) on every PR, **without being asked**. Validate findings against the *current* code (anchors may be stale); implement real **root-cause** fixes (code **+ tests + i18n + docs** in lockstep) or reply with evidence if a false positive. **Never add a new `biome-ignore`** — the suppression ratchet (`scripts/check-suppressions.mjs`) fails the quality gate; refactor so the rule passes honestly. Reply to each thread citing the resolving commit (`POST .../comments//replies`), resolve it (GraphQL `resolveReviewThread`), leave **0 unresolved**. Then commit, push, and **re-trigger** (`gh pr comment --body "@codeant-ai review"`). **Iron rule — loop until quiescent:** a push triggers a fresh review that often raises NEW findings (a "wave"); repeat the full cycle until **BOTH** a fresh review yields **0 new comments** AND **0 threads unresolved**. Only then merge (auto-squash; admin-squash only after CI is green + loop quiescent). Full canonical procedure: [`docs/CODEANT-REVIEW-LOOP.md`](docs/CODEANT-REVIEW-LOOP.md). -**PR-size limit — keep every PR under ~100 changed files so CodeAnt actually reviews it.** CodeAnt does **not** post inline review comments on PRs that exceed ~100 changed files (the >100-file check hangs/skips). Since any i18n-touching change fans out across 17 locale source files + 17 rebuilt `bundle.json` per module, a multi-feature branch crosses 100 fast. **Before pushing, run `git diff --name-only ...HEAD | wc -l`.** If it is over ~100, split the work into the **fewest** stacked PRs that each stay clearly under the limit — group by which locale module-files they touch so the per-PR fan-out stays small (e.g. P0 batch touching `writer.json`; P1/P2 batch touching `common.json`/`dashboard.json`). Stack them (PR2 base = PR1's branch) so each PR's incremental diff — what CodeAnt sees — is small; when PR1 merges, PR2 auto-retargets to `main`. **Do not** make more PRs than needed: if everything fits under ~100 in one (or two) PRs, use that. Keep commits atomic per concern regardless of how they are bundled into PRs. +**PR-size limit — keep every PR under ~100 changed files so CodeAnt actually reviews it.** CodeAnt does **not** post inline review comments on PRs that exceed ~100 changed files (the >100-file check hangs/skips). Since any i18n-touching change fans out across 19 locale source files + 19 rebuilt `bundle.json` per module, a multi-feature branch crosses 100 fast. **Before pushing, run `git diff --name-only ...HEAD | wc -l`.** If it is over ~100, split the work into the **fewest** stacked PRs that each stay clearly under the limit — group by which locale module-files they touch so the per-PR fan-out stays small (e.g. P0 batch touching `writer.json`; P1/P2 batch touching `common.json`/`dashboard.json`). Stack them (PR2 base = PR1's branch) so each PR's incremental diff — what CodeAnt sees — is small; when PR1 merges, PR2 auto-retargets to `main`. **Do not** make more PRs than needed: if everything fits under ~100 in one (or two) PRs, use that. Keep commits atomic per concern regardless of how they are bundled into PRs. **E2E notes:** Do NOT use `networkidle` waits (HMR keeps WebSocket open). Scope sidebar navigation via `#sidebar`. Shared helpers: `tests/e2e/helpers.ts`. Mobile E2E: set `RUN_MOBILE_E2E=1` locally (off by default). @@ -102,8 +102,8 @@ services/ → External adapters; key sub-dirs: packages/ → Internal workspace packages: ai-core (WebLLM + inference worker), ui, collab-transport (vendor fork of y-webrtc 10.3.0 with RTCDataChannel E2E encryption), worker-bus (typed worker pool, circuit breakers, dead-letter queue — see § WorkerBus below) -locales/ → i18n source JSON (de/en/es/fr/it/ar/he/el/ja/pt/zh/fi/sv/hu/is/eu/fa × 20 modules); runtime: public/locales//bundle.json - ar/ + he/ + fa/ — RTL (fa is Arabic-script Persian); el/ja/pt/zh/fi/sv/hu/is/eu — Beta locales (P1-5 + Phase X) +locales/ → i18n source JSON — 19 locales (de/en/es/fr/it/ar/he/el/ja/pt/zh/fi/sv/hu/is/eu/fa/ru/ko × 21 modules); runtime: public/locales//bundle.json + ar/ + he/ + fa/ — RTL (fa is Arabic-script Persian); el/ja/pt/zh — Near-production; fi/sv/hu/is/eu/ru/ko — Beta (Phase X + Tier-1 2026 expansion). SSOT: i18n/locales.ts tests/ → unit/ (Vitest) + e2e/ (Playwright); shared E2E helpers in tests/e2e/helpers.ts types/ → Supplemental TypeScript definitions (duckdb-wasm-worker.d.ts, tauri-plugins.d.ts) types.ts → Core shared interfaces and types (root level) @@ -252,7 +252,7 @@ Experimental features are gated behind `features/featureFlags/featureFlagsSlice. ### i18n -Custom React Context in `I18nContext.tsx` — not i18next. Source locales: **de, en, es, fr, it** (core), **ar, he, fa** (RTL; fa = Persian/Arabic script), **el, ja, pt, zh, fi, sv, hu, is, eu** (Beta). All 17 ship as `public/locales//bundle.json` rebuilt by `pnpm run i18n:bundle` or auto via `pnpm run i18n:check`. All user-facing strings must use `t('key.path')` from `useTranslation()`. New keys: add to **all 17** locale trees (`node scripts/check-i18n-keys.mjs --fix`), then `pnpm run i18n:bundle`. The `/i18n-key` skill targets the **5 core** locales only; update Beta/RTL locales manually afterward. See [`docs/LANGUAGE-EXPANSION-2026.md`](docs/LANGUAGE-EXPANSION-2026.md) for the fi/sv/hu/is/eu/fa rollout and the user-run bulk-translate workflow. +Custom React Context in `I18nContext.tsx` — not i18next. SSOT for locale metadata: `i18n/locales.ts` (`LOCALES` — code/name/status/script/LanguageTool tier; everything else derives from it). **19** source locales: **de, en, es, fr, it** (production), **ja, zh, pt, el** (near-production — full key parity, `help.json` still falls back to English), **ar, he, fa** (RTL beta; fa = Persian/Arabic script), **fi, sv, hu, is, eu, ru, ko** (beta). All 19 ship as `public/locales//bundle.json` rebuilt by `pnpm run i18n:bundle` or auto via `pnpm run i18n:check`. All user-facing strings must use `t('key.path')` from `useTranslation()`. New keys: add to **all 19** locale trees (`node scripts/check-i18n-keys.mjs --fix`), then `pnpm run i18n:bundle`. The `/i18n-key` skill targets the **5 production** locales only; update the remaining 14 manually afterward. See [`docs/LANGUAGE-EXPANSION-2026.md`](docs/LANGUAGE-EXPANSION-2026.md) for the fi/sv/hu/is/eu/fa rollout and the user-run bulk-translate workflow (ru/ko followed the same Tier-1 2026 expansion pattern). **RTL stubs (B-5):** `locales/ar/` + `locales/he/` are English-fallback stubs behind `enableRtlLayout`. Full content is v2.0 community task. From c00bce630f34feffbde62ccc4d383d6dad266876 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:49:07 +0200 Subject: [PATCH 2/2] fix(ai): reconcile Ollama status badge with the desktop-only banner (#266) The generic connection-status badge and the Ollama "desktop app required" banner in AiProviderCard were driven by two independent state signals that were never reconciled: testStatus (async, defaults to 'idle' -> "Ready") and isDesktop (sync, drives the banner). Since the auto-test effect and the manual "Test connection" button are both disabled for Ollama in a browser, testStatus can never leave 'idle' there -- so the badge always read "Ready" right next to a banner saying local servers aren't available, which is exactly the inconsistency reported against the live app. Fix: added a derived `ollamaUntestable` flag (provider === 'ollama' && !isDesktop) that the status badge checks first, rendering a new, distinct "Not available in browser" label instead of falling through to the idle-default "Ready" text. Desktop is unaffected -- the real test result (ok/error/loading) still renders there. New i18n key settings.ai.providerStatusUnavailableBrowser added to all 19 locales. Also fixes the scan endpoint order restpunkt: scanLocalOpenAiCompatibleEndpoints now tries Ollama's native /api/tags first, falling back to the OpenAI-compat /v1/models shim only on a non-timeout failure -- mirrors the native-first approach testOllamaConnection/listOllamaModels already use, so an older Ollama install without the compat shim isn't missed by the scan. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 12 ++++++ components/settings/AiProviderCard.tsx | 31 ++++++++++---- locales/ar/settings.json | 1 + locales/de/settings.json | 1 + locales/el/settings.json | 1 + locales/en/settings.json | 1 + locales/es/settings.json | 1 + locales/eu/settings.json | 1 + locales/fa/settings.json | 1 + locales/fi/settings.json | 1 + locales/fr/settings.json | 1 + locales/he/settings.json | 1 + locales/hu/settings.json | 1 + locales/is/settings.json | 1 + locales/it/settings.json | 1 + locales/ja/settings.json | 1 + locales/ko/settings.json | 1 + locales/pt/settings.json | 1 + locales/ru/settings.json | 1 + locales/sv/settings.json | 1 + locales/zh/settings.json | 1 + public/locales/ar/bundle.json | 1 + public/locales/de/bundle.json | 1 + public/locales/el/bundle.json | 1 + public/locales/en/bundle.json | 1 + public/locales/es/bundle.json | 1 + public/locales/eu/bundle.json | 1 + public/locales/fa/bundle.json | 1 + public/locales/fi/bundle.json | 1 + public/locales/fr/bundle.json | 1 + public/locales/he/bundle.json | 1 + public/locales/hu/bundle.json | 1 + public/locales/is/bundle.json | 1 + public/locales/it/bundle.json | 1 + public/locales/ja/bundle.json | 1 + public/locales/ko/bundle.json | 1 + public/locales/pt/bundle.json | 1 + public/locales/ru/bundle.json | 1 + public/locales/sv/bundle.json | 1 + public/locales/zh/bundle.json | 1 + services/aiProviderService.ts | 28 ++++++++++--- tests/unit/aiProviderService.test.ts | 46 +++++++++++++++++++++ tests/unit/settings/AiProviderCard.test.tsx | 29 +++++++++++++ 43 files changed, 169 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0b3954c..1af96b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,6 +141,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `'unreachable'`. See the 2026-07-28 update in [ADR 0012](docs/adr/0012-local-server-connectivity-tauri-http.md). +- **Misleading "Ready" status badge for Ollama in the browser (#266).** The generic connection-status + badge in `AiProviderCard` and the "desktop app required" banner were driven by two independent, + never-reconciled state signals — `testStatus` (defaults to `idle` → "Ready"/"Bereit") and + `isDesktop`. Since the auto-test effect and the manual "Test connection" button are both disabled + for Ollama in a browser, `testStatus` could never leave its idle default there, so the badge always + read "Ready" right next to a banner saying the opposite. It now shows a distinct "Not available in + browser" label instead whenever `provider === 'ollama' && !isDesktop`, localized in all 19 locales; + desktop is unaffected. Also: `scanLocalOpenAiCompatibleEndpoints()` now tries Ollama's native + `/api/tags` first, falling back to the OpenAI-compat `/v1/models` shim only on failure (mirrors + `testOllamaConnection`/`listOllamaModels`'s existing native-first approach), so an older Ollama + install without the compat shim isn't missed by the scan. + - **Feature-catalog / slice default drift made structurally impossible.** `features/featureCatalog.ts` now covers all **22** flags (was 16) and **derives** each entry's `defaultOn` from the slice's `defaultFeatureFlagsState` instead of hand-keying it — the class of bug where the catalog said diff --git a/components/settings/AiProviderCard.tsx b/components/settings/AiProviderCard.tsx index 1d866798..faf99281 100644 --- a/components/settings/AiProviderCard.tsx +++ b/components/settings/AiProviderCard.tsx @@ -40,6 +40,11 @@ export const AiProviderCard: FC = ({ // QNBS-v3 (T0): canonical detection (`__TAURI_INTERNALS__`-aware); `__TAURI__` alone read as web // in the real desktop shell, hiding desktop-only provider affordances. const isDesktop = isTauriRuntime(); + // QNBS-v3: for Ollama in a browser, the auto-test effect and the manual "Test connection" + // button are both disabled (see below) — testStatus can never leave 'idle' here, so the + // generic status badge must not render its idle→"Ready" label, which would misleadingly + // imply a verified connection next to the "desktop app required" banner. + const ollamaUntestable = provider === 'ollama' && !isDesktop; const [openaiKey, setOpenaiKey] = useState(''); const [ollamaModels, setOllamaModels] = useState([]); const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'ok' | 'error'>('idle'); @@ -205,19 +210,27 @@ export const AiProviderCard: FC = ({ - {testStatus === 'ok' && t('settings.ai.providerStatusConnected')} - {testStatus === 'error' && t('settings.ai.providerStatusDisconnected')} - {testStatus === 'idle' && t('settings.ai.providerStatusReady')} + {ollamaUntestable ? ( + t('settings.ai.providerStatusUnavailableBrowser') + ) : ( + <> + {testStatus === 'ok' && t('settings.ai.providerStatusConnected')} + {testStatus === 'error' && t('settings.ai.providerStatusDisconnected')} + {testStatus === 'idle' && t('settings.ai.providerStatusReady')} + + )} - {testStatus === 'error' && testError && ( + {!ollamaUntestable && testStatus === 'error' && testError && (

{testError}

)} diff --git a/locales/ar/settings.json b/locales/ar/settings.json index 3653537d..a1daf5e4 100644 --- a/locales/ar/settings.json +++ b/locales/ar/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "غير متاح", "settings.ai.providerStatusLabel": "حالة الاتصال", "settings.ai.providerStatusReady": "جاهز", + "settings.ai.providerStatusUnavailableBrowser": "غير متوفر في المتصفح", "settings.ai.providerTitle": "مزوّد الذكاء الاصطناعي", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM ‏(المتصفّح)", diff --git a/locales/de/settings.json b/locales/de/settings.json index 6db15e27..91dda507 100644 --- a/locales/de/settings.json +++ b/locales/de/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Nicht erreichbar", "settings.ai.providerStatusLabel": "Verbindungsstatus", "settings.ai.providerStatusReady": "Bereit", + "settings.ai.providerStatusUnavailableBrowser": "Im Browser nicht verfügbar", "settings.ai.providerTitle": "KI-Anbieter", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (Browser)", diff --git a/locales/el/settings.json b/locales/el/settings.json index c66e4d32..d4012eab 100644 --- a/locales/el/settings.json +++ b/locales/el/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Δυσεύρετος", "settings.ai.providerStatusLabel": "Κατάσταση σύνδεσης", "settings.ai.providerStatusReady": "Ετοιμος", + "settings.ai.providerStatusUnavailableBrowser": "Μη διαθέσιμο στο πρόγραμμα περιήγησης", "settings.ai.providerTitle": "Πάροχος AI", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (πρόγραμμα περιήγησης)", diff --git a/locales/en/settings.json b/locales/en/settings.json index 4e8f42f9..6288ed23 100644 --- a/locales/en/settings.json +++ b/locales/en/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Unavailable", "settings.ai.providerStatusLabel": "Connection Status", "settings.ai.providerStatusReady": "Ready", + "settings.ai.providerStatusUnavailableBrowser": "Not available in browser", "settings.ai.providerTitle": "AI Provider", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (browser)", diff --git a/locales/es/settings.json b/locales/es/settings.json index fd165e92..806487af 100644 --- a/locales/es/settings.json +++ b/locales/es/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "No disponible", "settings.ai.providerStatusLabel": "Estado de conexión", "settings.ai.providerStatusReady": "Listo", + "settings.ai.providerStatusUnavailableBrowser": "No disponible en el navegador", "settings.ai.providerTitle": "Proveedor de IA", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (navegador)", diff --git a/locales/eu/settings.json b/locales/eu/settings.json index 4fceeeff..473e7f4c 100644 --- a/locales/eu/settings.json +++ b/locales/eu/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Ez dago erabilgarri", "settings.ai.providerStatusLabel": "Konexioaren egoera", "settings.ai.providerStatusReady": "Prest", + "settings.ai.providerStatusUnavailableBrowser": "Ez dago erabilgarri nabigatzailean", "settings.ai.providerTitle": "AI hornitzailea", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (arakatzailea)", diff --git a/locales/fa/settings.json b/locales/fa/settings.json index 87f6e34c..2bb70c85 100644 --- a/locales/fa/settings.json +++ b/locales/fa/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "در دسترس نیست", "settings.ai.providerStatusLabel": "وضعیت اتصال", "settings.ai.providerStatusReady": "آماده است", + "settings.ai.providerStatusUnavailableBrowser": "در مرورگر در دسترس نیست", "settings.ai.providerTitle": "ارائه دهنده هوش مصنوعی", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (مرورگر)", diff --git a/locales/fi/settings.json b/locales/fi/settings.json index f5fc1e71..58464432 100644 --- a/locales/fi/settings.json +++ b/locales/fi/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Ei saatavilla", "settings.ai.providerStatusLabel": "Yhteyden tila", "settings.ai.providerStatusReady": "Valmis", + "settings.ai.providerStatusUnavailableBrowser": "Ei saatavilla selaimessa", "settings.ai.providerTitle": "AI Provider", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (selain)", diff --git a/locales/fr/settings.json b/locales/fr/settings.json index 0ce44366..f16a69db 100644 --- a/locales/fr/settings.json +++ b/locales/fr/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Indisponible", "settings.ai.providerStatusLabel": "État de la connexion", "settings.ai.providerStatusReady": "Prêt", + "settings.ai.providerStatusUnavailableBrowser": "Non disponible dans le navigateur", "settings.ai.providerTitle": "Fournisseur d'IA", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (navigateur)", diff --git a/locales/he/settings.json b/locales/he/settings.json index 7294a6c6..ac6311f2 100644 --- a/locales/he/settings.json +++ b/locales/he/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "לא זמין", "settings.ai.providerStatusLabel": "סטטוס חיבור", "settings.ai.providerStatusReady": "מוכן", + "settings.ai.providerStatusUnavailableBrowser": "לא זמין בדפדפן", "settings.ai.providerTitle": "ספק AI", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM ‏(דפדפן)", diff --git a/locales/hu/settings.json b/locales/hu/settings.json index 1dd06f5b..c51258b6 100644 --- a/locales/hu/settings.json +++ b/locales/hu/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Nem elérhető", "settings.ai.providerStatusLabel": "Kapcsolat állapota", "settings.ai.providerStatusReady": "Kész", + "settings.ai.providerStatusUnavailableBrowser": "Böngészőben nem érhető el", "settings.ai.providerTitle": "AI szolgáltató", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (böngésző)", diff --git a/locales/is/settings.json b/locales/is/settings.json index c37b1270..83660cc1 100644 --- a/locales/is/settings.json +++ b/locales/is/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Ekki tiltækt", "settings.ai.providerStatusLabel": "Tengingarstaða", "settings.ai.providerStatusReady": "Tilbúið", + "settings.ai.providerStatusUnavailableBrowser": "Ekki í boði í vafra", "settings.ai.providerTitle": "AI veitandi", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (vafri)", diff --git a/locales/it/settings.json b/locales/it/settings.json index 34c40da8..8e2f3a84 100644 --- a/locales/it/settings.json +++ b/locales/it/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Non disponibile", "settings.ai.providerStatusLabel": "Stato della connessione", "settings.ai.providerStatusReady": "Pronto", + "settings.ai.providerStatusUnavailableBrowser": "Non disponibile nel browser", "settings.ai.providerTitle": "Provider IA", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (browser)", diff --git a/locales/ja/settings.json b/locales/ja/settings.json index 2f2cc921..06e49e07 100644 --- a/locales/ja/settings.json +++ b/locales/ja/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "利用不可", "settings.ai.providerStatusLabel": "接続ステータス", "settings.ai.providerStatusReady": "準備ができて", + "settings.ai.providerStatusUnavailableBrowser": "ブラウザでは利用できません", "settings.ai.providerTitle": "AIプロバイダー", "settings.ai.providerTransformers": "トランスフォーマー.js", "settings.ai.providerWebllm": "WebLLM (ブラウザ)", diff --git a/locales/ko/settings.json b/locales/ko/settings.json index 93b8d290..2636739c 100644 --- a/locales/ko/settings.json +++ b/locales/ko/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "없는", "settings.ai.providerStatusLabel": "연결 상태", "settings.ai.providerStatusReady": "준비가 된", + "settings.ai.providerStatusUnavailableBrowser": "브라우저에서 사용할 수 없음", "settings.ai.providerTitle": "AI 제공업체", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM(브라우저)", diff --git a/locales/pt/settings.json b/locales/pt/settings.json index 335fe588..4651616f 100644 --- a/locales/pt/settings.json +++ b/locales/pt/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Indisponível", "settings.ai.providerStatusLabel": "Status da conexão", "settings.ai.providerStatusReady": "Preparar", + "settings.ai.providerStatusUnavailableBrowser": "Não disponível no navegador", "settings.ai.providerTitle": "IA Provider", "settings.ai.providerTransformers": "Transformadores.js", "settings.ai.providerWebllm": "WebLLM (navegador)", diff --git a/locales/ru/settings.json b/locales/ru/settings.json index ee30af12..0b4595a4 100644 --- a/locales/ru/settings.json +++ b/locales/ru/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Недоступно", "settings.ai.providerStatusLabel": "Статус соединения", "settings.ai.providerStatusReady": "Готовый", + "settings.ai.providerStatusUnavailableBrowser": "Недоступно в браузере", "settings.ai.providerTitle": "Поставщик ИИ", "settings.ai.providerTransformers": "Трансформеры.js", "settings.ai.providerWebllm": "WebLLM (браузер)", diff --git a/locales/sv/settings.json b/locales/sv/settings.json index 1f089506..0e7479c5 100644 --- a/locales/sv/settings.json +++ b/locales/sv/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "Inte tillgänglig", "settings.ai.providerStatusLabel": "Anslutningsstatus", "settings.ai.providerStatusReady": "Redo", + "settings.ai.providerStatusUnavailableBrowser": "Inte tillgängligt i webbläsaren", "settings.ai.providerTitle": "AI-leverantör", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (webbläsare)", diff --git a/locales/zh/settings.json b/locales/zh/settings.json index 4e2c53a8..98f9d0c2 100644 --- a/locales/zh/settings.json +++ b/locales/zh/settings.json @@ -230,6 +230,7 @@ "settings.ai.providerStatusDisconnected": "不可用", "settings.ai.providerStatusLabel": "连接状态", "settings.ai.providerStatusReady": "准备好", + "settings.ai.providerStatusUnavailableBrowser": "浏览器中不可用", "settings.ai.providerTitle": "人工智能提供商", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM(浏览器)", diff --git a/public/locales/ar/bundle.json b/public/locales/ar/bundle.json index 46837986..fef14a60 100644 --- a/public/locales/ar/bundle.json +++ b/public/locales/ar/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "غير متاح", "settings.ai.providerStatusLabel": "حالة الاتصال", "settings.ai.providerStatusReady": "جاهز", + "settings.ai.providerStatusUnavailableBrowser": "غير متوفر في المتصفح", "settings.ai.providerTitle": "مزوّد الذكاء الاصطناعي", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM ‏(المتصفّح)", diff --git a/public/locales/de/bundle.json b/public/locales/de/bundle.json index e6115b28..e46fda22 100644 --- a/public/locales/de/bundle.json +++ b/public/locales/de/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Nicht erreichbar", "settings.ai.providerStatusLabel": "Verbindungsstatus", "settings.ai.providerStatusReady": "Bereit", + "settings.ai.providerStatusUnavailableBrowser": "Im Browser nicht verfügbar", "settings.ai.providerTitle": "KI-Anbieter", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (Browser)", diff --git a/public/locales/el/bundle.json b/public/locales/el/bundle.json index 297adeea..e37d0607 100644 --- a/public/locales/el/bundle.json +++ b/public/locales/el/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Δυσεύρετος", "settings.ai.providerStatusLabel": "Κατάσταση σύνδεσης", "settings.ai.providerStatusReady": "Ετοιμος", + "settings.ai.providerStatusUnavailableBrowser": "Μη διαθέσιμο στο πρόγραμμα περιήγησης", "settings.ai.providerTitle": "Πάροχος AI", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (πρόγραμμα περιήγησης)", diff --git a/public/locales/en/bundle.json b/public/locales/en/bundle.json index 19e580ff..c7143f0f 100644 --- a/public/locales/en/bundle.json +++ b/public/locales/en/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Unavailable", "settings.ai.providerStatusLabel": "Connection Status", "settings.ai.providerStatusReady": "Ready", + "settings.ai.providerStatusUnavailableBrowser": "Not available in browser", "settings.ai.providerTitle": "AI Provider", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (browser)", diff --git a/public/locales/es/bundle.json b/public/locales/es/bundle.json index 3d0027ff..217267e9 100644 --- a/public/locales/es/bundle.json +++ b/public/locales/es/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "No disponible", "settings.ai.providerStatusLabel": "Estado de conexión", "settings.ai.providerStatusReady": "Listo", + "settings.ai.providerStatusUnavailableBrowser": "No disponible en el navegador", "settings.ai.providerTitle": "Proveedor de IA", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (navegador)", diff --git a/public/locales/eu/bundle.json b/public/locales/eu/bundle.json index bf6e485e..b0ac3da7 100644 --- a/public/locales/eu/bundle.json +++ b/public/locales/eu/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Ez dago erabilgarri", "settings.ai.providerStatusLabel": "Konexioaren egoera", "settings.ai.providerStatusReady": "Prest", + "settings.ai.providerStatusUnavailableBrowser": "Ez dago erabilgarri nabigatzailean", "settings.ai.providerTitle": "AI hornitzailea", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (arakatzailea)", diff --git a/public/locales/fa/bundle.json b/public/locales/fa/bundle.json index 405c580e..51f95b5d 100644 --- a/public/locales/fa/bundle.json +++ b/public/locales/fa/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "در دسترس نیست", "settings.ai.providerStatusLabel": "وضعیت اتصال", "settings.ai.providerStatusReady": "آماده است", + "settings.ai.providerStatusUnavailableBrowser": "در مرورگر در دسترس نیست", "settings.ai.providerTitle": "ارائه دهنده هوش مصنوعی", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (مرورگر)", diff --git a/public/locales/fi/bundle.json b/public/locales/fi/bundle.json index bd8f2d51..bba50bfe 100644 --- a/public/locales/fi/bundle.json +++ b/public/locales/fi/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Ei saatavilla", "settings.ai.providerStatusLabel": "Yhteyden tila", "settings.ai.providerStatusReady": "Valmis", + "settings.ai.providerStatusUnavailableBrowser": "Ei saatavilla selaimessa", "settings.ai.providerTitle": "AI Provider", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (selain)", diff --git a/public/locales/fr/bundle.json b/public/locales/fr/bundle.json index 68d46246..88a63806 100644 --- a/public/locales/fr/bundle.json +++ b/public/locales/fr/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Indisponible", "settings.ai.providerStatusLabel": "État de la connexion", "settings.ai.providerStatusReady": "Prêt", + "settings.ai.providerStatusUnavailableBrowser": "Non disponible dans le navigateur", "settings.ai.providerTitle": "Fournisseur d'IA", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (navigateur)", diff --git a/public/locales/he/bundle.json b/public/locales/he/bundle.json index d829774d..da33b39e 100644 --- a/public/locales/he/bundle.json +++ b/public/locales/he/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "לא זמין", "settings.ai.providerStatusLabel": "סטטוס חיבור", "settings.ai.providerStatusReady": "מוכן", + "settings.ai.providerStatusUnavailableBrowser": "לא זמין בדפדפן", "settings.ai.providerTitle": "ספק AI", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM ‏(דפדפן)", diff --git a/public/locales/hu/bundle.json b/public/locales/hu/bundle.json index 8a78c116..3b838e72 100644 --- a/public/locales/hu/bundle.json +++ b/public/locales/hu/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Nem elérhető", "settings.ai.providerStatusLabel": "Kapcsolat állapota", "settings.ai.providerStatusReady": "Kész", + "settings.ai.providerStatusUnavailableBrowser": "Böngészőben nem érhető el", "settings.ai.providerTitle": "AI szolgáltató", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (böngésző)", diff --git a/public/locales/is/bundle.json b/public/locales/is/bundle.json index eb30df88..feec2bc7 100644 --- a/public/locales/is/bundle.json +++ b/public/locales/is/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Ekki tiltækt", "settings.ai.providerStatusLabel": "Tengingarstaða", "settings.ai.providerStatusReady": "Tilbúið", + "settings.ai.providerStatusUnavailableBrowser": "Ekki í boði í vafra", "settings.ai.providerTitle": "AI veitandi", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (vafri)", diff --git a/public/locales/it/bundle.json b/public/locales/it/bundle.json index 06e39039..0f09d26e 100644 --- a/public/locales/it/bundle.json +++ b/public/locales/it/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Non disponibile", "settings.ai.providerStatusLabel": "Stato della connessione", "settings.ai.providerStatusReady": "Pronto", + "settings.ai.providerStatusUnavailableBrowser": "Non disponibile nel browser", "settings.ai.providerTitle": "Provider IA", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (browser)", diff --git a/public/locales/ja/bundle.json b/public/locales/ja/bundle.json index 3dfc59d1..8f3344b7 100644 --- a/public/locales/ja/bundle.json +++ b/public/locales/ja/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "利用不可", "settings.ai.providerStatusLabel": "接続ステータス", "settings.ai.providerStatusReady": "準備ができて", + "settings.ai.providerStatusUnavailableBrowser": "ブラウザでは利用できません", "settings.ai.providerTitle": "AIプロバイダー", "settings.ai.providerTransformers": "トランスフォーマー.js", "settings.ai.providerWebllm": "WebLLM (ブラウザ)", diff --git a/public/locales/ko/bundle.json b/public/locales/ko/bundle.json index 28b94a93..85a81529 100644 --- a/public/locales/ko/bundle.json +++ b/public/locales/ko/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "없는", "settings.ai.providerStatusLabel": "연결 상태", "settings.ai.providerStatusReady": "준비가 된", + "settings.ai.providerStatusUnavailableBrowser": "브라우저에서 사용할 수 없음", "settings.ai.providerTitle": "AI 제공업체", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM(브라우저)", diff --git a/public/locales/pt/bundle.json b/public/locales/pt/bundle.json index f647dc67..fa1ef177 100644 --- a/public/locales/pt/bundle.json +++ b/public/locales/pt/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Indisponível", "settings.ai.providerStatusLabel": "Status da conexão", "settings.ai.providerStatusReady": "Preparar", + "settings.ai.providerStatusUnavailableBrowser": "Não disponível no navegador", "settings.ai.providerTitle": "IA Provider", "settings.ai.providerTransformers": "Transformadores.js", "settings.ai.providerWebllm": "WebLLM (navegador)", diff --git a/public/locales/ru/bundle.json b/public/locales/ru/bundle.json index 245aa4c0..f4ffe614 100644 --- a/public/locales/ru/bundle.json +++ b/public/locales/ru/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Недоступно", "settings.ai.providerStatusLabel": "Статус соединения", "settings.ai.providerStatusReady": "Готовый", + "settings.ai.providerStatusUnavailableBrowser": "Недоступно в браузере", "settings.ai.providerTitle": "Поставщик ИИ", "settings.ai.providerTransformers": "Трансформеры.js", "settings.ai.providerWebllm": "WebLLM (браузер)", diff --git a/public/locales/sv/bundle.json b/public/locales/sv/bundle.json index 1cb7f639..38d3ec0e 100644 --- a/public/locales/sv/bundle.json +++ b/public/locales/sv/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "Inte tillgänglig", "settings.ai.providerStatusLabel": "Anslutningsstatus", "settings.ai.providerStatusReady": "Redo", + "settings.ai.providerStatusUnavailableBrowser": "Inte tillgängligt i webbläsaren", "settings.ai.providerTitle": "AI-leverantör", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM (webbläsare)", diff --git a/public/locales/zh/bundle.json b/public/locales/zh/bundle.json index ab6e8835..b66a09ce 100644 --- a/public/locales/zh/bundle.json +++ b/public/locales/zh/bundle.json @@ -1838,6 +1838,7 @@ "settings.ai.providerStatusDisconnected": "不可用", "settings.ai.providerStatusLabel": "连接状态", "settings.ai.providerStatusReady": "准备好", + "settings.ai.providerStatusUnavailableBrowser": "浏览器中不可用", "settings.ai.providerTitle": "人工智能提供商", "settings.ai.providerTransformers": "Transformers.js", "settings.ai.providerWebllm": "WebLLM(浏览器)", diff --git a/services/aiProviderService.ts b/services/aiProviderService.ts index afb8190f..a4da3b00 100644 --- a/services/aiProviderService.ts +++ b/services/aiProviderService.ts @@ -706,18 +706,34 @@ export interface LocalEndpointScanResult { * QNBS-v3: Schneller Desktop-Check typischer lokaler /v1-Endpunkte — keine Secrets, nur * Erreichbarkeit. #266: routed through localServerFetch (Tauri plugin-http on desktop) so the * scan works inside the WebView, with per-endpoint state classification for actionable UI badges. + * Ollama tries its native /api/tags first (present since early versions) before falling back to + * the OpenAI-compat /v1/models shim (only on Ollama ≥0.1.24) — mirrors the native-first approach + * testOllamaConnection/listOllamaModels already use, so an older Ollama install isn't missed. */ export async function scanLocalOpenAiCompatibleEndpoints(): Promise { const candidates = [ - { labelKey: 'settings.ai.scanLabelOllama', baseUrl: 'http://localhost:11434' }, - { labelKey: 'settings.ai.scanLabelLmStudio', baseUrl: 'http://localhost:1234' }, - { labelKey: 'settings.ai.scanLabelVllm', baseUrl: 'http://localhost:8000' }, + { labelKey: 'settings.ai.scanLabelOllama', baseUrl: 'http://localhost:11434', ollama: true }, + { labelKey: 'settings.ai.scanLabelLmStudio', baseUrl: 'http://localhost:1234', ollama: false }, + { labelKey: 'settings.ai.scanLabelVllm', baseUrl: 'http://localhost:8000', ollama: false }, ]; return Promise.all( - candidates.map(async ({ labelKey, baseUrl }): Promise => { + candidates.map(async ({ labelKey, baseUrl, ollama }): Promise => { try { - const root = normalizeOpenAiCompatibleBaseUrl(baseUrl); - const res = await localServerFetch(`${root}/models`, { timeoutMs: 2800 }); + let res: Response; + if (ollama) { + try { + res = await localServerFetch(`${baseUrl}/api/tags`, { timeoutMs: 2800 }); + } catch (nativeErr) { + if (nativeErr instanceof LocalServerError && nativeErr.kind === 'timeout') { + throw nativeErr; + } + const root = normalizeOpenAiCompatibleBaseUrl(baseUrl); + res = await localServerFetch(`${root}/models`, { timeoutMs: 2800 }); + } + } else { + const root = normalizeOpenAiCompatibleBaseUrl(baseUrl); + res = await localServerFetch(`${root}/models`, { timeoutMs: 2800 }); + } const ok = res.ok || res.status === 401; return { labelKey, baseUrl, ok, state: ok ? 'ok' : 'http', status: res.status }; } catch (err) { diff --git a/tests/unit/aiProviderService.test.ts b/tests/unit/aiProviderService.test.ts index 5b2cc25b..ae55fbe7 100644 --- a/tests/unit/aiProviderService.test.ts +++ b/tests/unit/aiProviderService.test.ts @@ -732,6 +732,52 @@ describe('scanLocalOpenAiCompatibleEndpoints', () => { expect(r.state).toBe('unreachable'); } }); + + it('tries native /api/tags for Ollama first and never falls back to /v1/models when it succeeds', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ models: [] }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + const results = await scanLocalOpenAiCompatibleEndpoints(); + const ollamaResult = results.find((r) => r.labelKey === 'settings.ai.scanLabelOllama'); + expect(ollamaResult?.ok).toBe(true); + const ollamaCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes('11434'), + ) as unknown[][]; + expect(ollamaCalls).toHaveLength(1); + expect(String(ollamaCalls[0]?.[0])).toContain('/api/tags'); + }); + + it('falls back to /v1/models for Ollama when the native /api/tags attempt fails (non-timeout)', async () => { + const fetchMock = vi.fn().mockImplementation((url: unknown) => { + if (String(url).includes('/api/tags')) { + return Promise.reject(new TypeError('ECONNREFUSED')); + } + return Promise.resolve(new Response(JSON.stringify({ data: [] }), { status: 200 })); + }); + vi.stubGlobal('fetch', fetchMock); + const results = await scanLocalOpenAiCompatibleEndpoints(); + const ollamaResult = results.find((r) => r.labelKey === 'settings.ai.scanLabelOllama'); + expect(ollamaResult?.ok).toBe(true); + const ollamaCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes('11434'), + ) as unknown[][]; + expect(ollamaCalls).toHaveLength(2); + expect(String(ollamaCalls[0]?.[0])).toContain('/api/tags'); + expect(String(ollamaCalls[1]?.[0])).toContain('/models'); + }); + + it('does not attempt a second request for LM Studio/vLLM (only Ollama has a native fallback)', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ data: [] }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + await scanLocalOpenAiCompatibleEndpoints(); + const lmStudioCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes('1234')); + const vllmCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes('8000')); + expect(lmStudioCalls).toHaveLength(1); + expect(vllmCalls).toHaveLength(1); + }); }); // ─── Service-level request deduplication ───────────────────────────────────── diff --git a/tests/unit/settings/AiProviderCard.test.tsx b/tests/unit/settings/AiProviderCard.test.tsx index 1eca98cd..fd29d620 100644 --- a/tests/unit/settings/AiProviderCard.test.tsx +++ b/tests/unit/settings/AiProviderCard.test.tsx @@ -158,6 +158,35 @@ describe('AiProviderCard — ollama provider (#266)', () => { expect(testAIConnection).not.toHaveBeenCalled(); }); + it('PWA: status badge shows "unavailable in browser", never the idle "Ready" label (matches the desktop-only banner instead of contradicting it)', () => { + setDesktopRuntime(false); + render( + , + ); + expect(screen.getByText('settings.ai.providerStatusUnavailableBrowser')).toBeTruthy(); + expect(screen.queryByText('settings.ai.providerStatusReady')).toBeNull(); + }); + + it('desktop: status badge never shows "unavailable in browser" for ollama (real test result renders instead)', async () => { + setDesktopRuntime(true); + render( + , + ); + // The auto-test effect runs the mocked testAIConnection (ok:true); wait for it to settle. + await waitFor(() => { + expect(screen.getByText('settings.ai.providerStatusConnected')).toBeTruthy(); + }); + expect(screen.queryByText('settings.ai.providerStatusUnavailableBrowser')).toBeNull(); + }); + it('desktop: auto-loads models and tests the connection when ollama is selected', async () => { setDesktopRuntime(true); render(