diff --git a/README.md b/README.md index 82fa9c3c4..9395ebff7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ v1.24.1 IndexedDB v8 PWA v3.0 - i18n 19 locales — 2854 keys + i18n 19 locales — 2859 keys 5807+ tests / 527 files Codecov Coverage License MIT @@ -351,7 +351,7 @@ One-click encrypted export of your entire project library from **Settings → Da ### 🌐 Full Multi-Language Support -Shipped UI locales with **2854 i18n keys** across all 19 languages — zero hardcoded user-facing strings: +Shipped UI locales with **2859 i18n keys** across all 19 languages — zero hardcoded user-facing strings: - 🇩🇪 **German** (Deutsch) - 🇬🇧 **English** @@ -460,7 +460,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **PDF Export** | jsPDF | Client-side, configurable PDF document generation | | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | -| **i18n** | Custom React Context (`I18nContext.tsx`) | 2854 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence | +| **i18n** | Custom React Context (`I18nContext.tsx`) | 2859 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence | | **Testing** | Vitest 4.x (5807+ tests / 527 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | @@ -659,7 +659,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt **Current test metrics (2026-07-28):** - **5807+ unit tests** across **527 test files** — all passing - Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics) -- i18n: **2854 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta) +- i18n: **2859 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta) **CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist. diff --git a/components/settings/OpenRouterSection.tsx b/components/settings/OpenRouterSection.tsx index 7137d8f1d..7677cccf8 100644 --- a/components/settings/OpenRouterSection.tsx +++ b/components/settings/OpenRouterSection.tsx @@ -20,7 +20,7 @@ import { import { getApproxRpm, isCircuitOpen, - OPENROUTER_FREE_MODELS, + OPENROUTER_FREE_MODEL_FALLBACK, resetOpenRouterCircuit, } from '../../services/ai/providers/openrouterProvider'; import { logger } from '../../services/logger'; @@ -33,6 +33,18 @@ import { Spinner } from '../ui/Spinner'; const CUSTOM_MODEL_VALUE = '__custom__'; +// QNBS-v3: i18n label suffix for each OPENROUTER_FREE_MODEL_FALLBACK entry — only rendered when +// the live catalog has no `:free` models at all (offline, fetch failure, or a genuinely empty +// catalog). Live-fetched free models use the catalog's own `name` field instead. +const FALLBACK_MODEL_LABEL_SUFFIX: Record<(typeof OPENROUTER_FREE_MODEL_FALLBACK)[number], string> = + { + 'deepseek/deepseek-r1:free': 'deepseekR1', + 'meta-llama/llama-3.3-70b-instruct:free': 'llama3370b', + 'qwen/qwen2.5-72b-instruct:free': 'qwen2572b', + 'google/gemma-3-27b-it:free': 'gemma327b', + 'mistralai/mistral-7b-instruct:free': 'mistral7b', + }; + // ─── Sub-component: circuit breaker status indicator ───────────────────────── const CircuitBreakerStatus: FC<{ t: ReturnType['t'] }> = ({ t }) => { @@ -128,7 +140,9 @@ export const OpenRouterSection: FC = () => { }, []); const enabled = openRouterSettings?.enabled ?? false; - const preferredModel = openRouterSettings?.preferredModel ?? 'deepseek/deepseek-r1:free'; + // QNBS-v3: Derived from the offline fallback, not a re-typed literal — this is the component's + // local default before Redux settings load, not a primary model source. + const preferredModel = openRouterSettings?.preferredModel ?? OPENROUTER_FREE_MODEL_FALLBACK[0]; const [apiKeyInput, setApiKeyInput] = useState(''); const [storedKey, setStoredKey] = useState(null); @@ -142,7 +156,9 @@ export const OpenRouterSection: FC = () => { const [isModelsLoading, setIsModelsLoading] = useState(false); const [modelFetchError, setModelFetchError] = useState(null); const [isCustomModel, setIsCustomModel] = useState( - !OPENROUTER_FREE_MODELS.includes(preferredModel as (typeof OPENROUTER_FREE_MODELS)[number]), + !OPENROUTER_FREE_MODEL_FALLBACK.includes( + preferredModel as (typeof OPENROUTER_FREE_MODEL_FALLBACK)[number], + ), ); const [customModelInput, setCustomModelInput] = useState(isCustomModel ? preferredModel : ''); const saveMsgTimer = useRef | null>(null); @@ -223,16 +239,90 @@ export const OpenRouterSection: FC = () => { }; }, [fetchCatalog, storedKey]); - // Keep local custom-model state in sync with external Redux changes. + // QNBS-v3: Free-tier group is derived from the LIVE catalog, not the frozen fallback constant — + // OpenRouter's free tier rotates weekly, and a hardcoded primary list silently drops new free + // models from the UI (the OR-1 bug this refactor fixes). Only fall back to the static list when + // the live catalog has no `:free` models at all. + const liveFreeModels = useMemo(() => models.filter((m) => isOpenRouterFreeModel(m.id)), [models]); + const usingFallbackFreeModels = liveFreeModels.length === 0; + // QNBS-v3: ID-only, deliberately independent of `t` — this feeds knownOptionIds (the redux-sync + // effect, selectValue, commitCustomModel all depend on it), which must stay referentially stable + // across a render that only changed an unrelated piece of state. If it depended on `t` (via the + // label-bearing modelGroups) instead, an unstable translation-function reference would make the + // redux-sync effect re-run on every render and stomp the user's in-progress custom-model entry. + const freeModelIds = useMemo( + () => + usingFallbackFreeModels + ? [...OPENROUTER_FREE_MODEL_FALLBACK] + : liveFreeModels.map((m) => m.id), + [usingFallbackFreeModels, liveFreeModels], + ); + const paidModelIds = useMemo( + () => models.filter((m) => !isOpenRouterFreeModel(m.id)).map((m) => m.id), + [models], + ); + const freeModelOptions = useMemo( + () => + usingFallbackFreeModels + ? OPENROUTER_FREE_MODEL_FALLBACK.map((m) => ({ + value: m as string, + label: t(`settings.openRouter.freeModel.${FALLBACK_MODEL_LABEL_SUFFIX[m]}`), + })) + : liveFreeModels.map((m) => ({ value: m.id, label: m.name ?? m.id })), + [usingFallbackFreeModels, liveFreeModels, t], + ); + // QNBS-v3: OR-1 fix — filter solely by isOpenRouterFreeModel; the former extra `freeIds` set + // (membership in the hardcoded list) caused any live-fetched free model NOT on that list to be + // dropped from both the free AND paid groups instead of appearing in the free group. + const paidModelOptions = useMemo( + () => + models + .filter((m) => !isOpenRouterFreeModel(m.id)) + .map((m) => ({ value: m.id, label: m.name ?? m.id })), + [models], + ); + const modelGroups = useMemo(() => { + const groups: { label: string; options: { value: string; label: string }[] }[] = [ + { label: t('settings.openRouter.freeTierNote'), options: freeModelOptions }, + ]; + if (paidModelOptions.length > 0) { + groups.push({ label: t('settings.openRouter.paidModelNote'), options: paidModelOptions }); + } + groups.push({ + label: t('settings.openRouter.customModelLabel'), + options: [{ value: CUSTOM_MODEL_VALUE, label: t('settings.openRouter.customModelLabel') }], + }); + return groups; + }, [freeModelOptions, paidModelOptions, t]); + // QNBS-v3: OR-3 fix — the actual set of selectable values (free ∪ paid ∪ custom sentinel), NOT + // `new Set(models.map(...))` (which was mislabeled "knownPaidIds" and included free models too). + // `selectValue` previously trusted this broader-than-rendered set, so a live-fetched free model + // not on the hardcoded list could report as "known" while having no matching