Skip to content
Merged
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
229 changes: 149 additions & 80 deletions GROK-PROVIDER-INTEGRATION-PLAN.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<img src="https://img.shields.io/badge/Version-v1.24.3-6366F1" alt="v1.24.3">
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2849_keys-0EA5E9" alt="i18n 19 locales — 2849 keys">
<img src="https://img.shields.io/badge/i18n-19_locales-2855_keys-0EA5E9" alt="i18n 19 locales — 2855 keys">
<img src="https://img.shields.io/badge/Tests-6477_%2F_529_files-22C55E" alt="6477 tests / 529 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
Expand Down Expand Up @@ -383,7 +383,7 @@ Infrastructure-level features that keep the app fast and extensible as projects

### 🌐 Full Multi-Language Support

Shipped UI locales with **2849 i18n keys** across all 19 languages — zero hardcoded user-facing strings:
Shipped UI locales with **2855 i18n keys** across all 19 languages — zero hardcoded user-facing strings:

- 🇩🇪 **German** (Deutsch)
- 🇬🇧 **English**
Expand Down Expand Up @@ -492,7 +492,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`) | 2849 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`) | 2855 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 (6477 tests / 529 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 |
Expand Down Expand Up @@ -694,7 +694,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
**Current test metrics (2026-07-30, CI-reported):**
- **6477 unit tests** across **529 test files** — all passing
- Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2849 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta)
- i18n: **2855 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.

Expand Down
70 changes: 66 additions & 4 deletions components/settings/AiProviderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export const AiProviderCard: FC<AiProviderCardProps> = ({
// imply a verified connection next to the "desktop app required" banner.
const ollamaUntestable = provider === 'ollama' && !isDesktop;
const [openaiKey, setOpenaiKey] = useState('');
// QNBS-v3: Grok's own key input state, mirroring OpenAI's pattern above.
const [grokKey, setGrokKey] = useState('');
const [isSavingGrokKey, setIsSavingGrokKey] = useState(false);
Comment thread
qnbs marked this conversation as resolved.
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'ok' | 'error'>('idle');
const [testError, setTestError] = useState('');
Expand All @@ -66,8 +69,26 @@ export const AiProviderCard: FC<AiProviderCardProps> = ({
.getApiKey('openai')
.then((k) => setOpenaiKey(k ?? ''))
.catch(() => {});
storageService
.getApiKey('grok')
.then((k) => setGrokKey(k ?? ''))
.catch(() => {});
}, []);

// QNBS-v3: save/clear via storageService, matching every other provider's key persistence.
const handleSaveGrokKey = useCallback(async () => {
setIsSavingGrokKey(true);
try {
if (grokKey.trim()) {
await storageService.saveApiKey('grok', grokKey.trim());
} else {
await storageService.clearApiKey('grok');
}
} finally {
setIsSavingGrokKey(false);
}
}, [grokKey]);

const handleSaveOpenAiKey = useCallback(async () => {
setIsSavingKey(true);
try {
Expand Down Expand Up @@ -171,13 +192,14 @@ export const AiProviderCard: FC<AiProviderCardProps> = ({
}, [provider, isDesktop, handleLoadOllamaModels, handleTest]);

const providers: { id: AIProvider; label: string }[] = [
{ id: 'gemini', label: 'Google Gemini' },
{ id: 'openai', label: 'OpenAI' },
{ id: 'ollama', label: 'Ollama (lokal)' },
{ id: 'gemini', label: t('settings.ai.provider.gemini') },
{ id: 'openai', label: t('settings.ai.provider.openai') },
{ id: 'ollama', label: t('settings.ai.provider.ollama') },
{ id: 'webllm', label: t('settings.ai.providerWebllm') },
{ id: 'onnx', label: t('settings.ai.providerOnnx') },
{ id: 'transformers', label: t('settings.ai.providerTransformers') },
{ id: 'anthropic', label: 'Anthropic Claude' },
{ id: 'anthropic', label: t('settings.ai.provider.anthropic') },
{ id: 'grok', label: t('settings.ai.provider.grok') },
];

const presetOptions: { id: LocalBackendPreset; labelKey: string }[] = [
Expand Down Expand Up @@ -322,6 +344,46 @@ export const AiProviderCard: FC<AiProviderCardProps> = ({
</div>
)}

{provider === 'grok' && (
<div className="space-y-3">
<label
htmlFor="grok-api-key"
className="text-sm font-medium text-[var(--sc-text-secondary)] block"
>
{t('settings.ai.grokKey')}
</label>
<div className="flex gap-2">
<Input
id="grok-api-key"
type="password"
placeholder="xai-..."
value={grokKey}
onChange={(e) => setGrokKey(e.target.value)}
className="flex-1 font-mono text-sm"
/>
<Button onClick={handleSaveGrokKey} disabled={isSavingGrokKey} variant="secondary">
{isSavingGrokKey ? <Spinner className="w-4 h-4" /> : t('settings.ai.save')}
</Button>
</div>
<p className="text-xs text-[var(--sc-text-muted)]">{t('settings.ai.keysEncrypted')}</p>
<label
htmlFor="grok-model"
className="text-sm font-medium text-[var(--sc-text-secondary)] block"
>
{t('settings.advancedAi.model')}
</label>
<Select
id="grok-model"
value={advancedAi.model}
onChange={(v) => onModelSelect?.(v)}
options={[
{ value: 'grok-3', label: 'Grok 3' },
{ value: 'grok-3-mini', label: 'Grok 3 Mini' },
]}
/>
</div>
)}

{provider === 'ollama' && (
<div className="space-y-3">
{!isDesktop && (
Expand Down
118 changes: 118 additions & 0 deletions docs/adr/0016-native-grok-and-claude-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# ADR 0016 — Native Grok provider + split Claude fix (desktop native-HTTP, web serverless proxy)

- **Status:** Accepted
- **Date:** 2026-07-30
- **Deciders:** Maintainer + Claude Code
- **Context tags:** ai, grok, anthropic, claude, cors, tauri, proxy, backend
- **Related:** [`GROK-PROVIDER-INTEGRATION-PLAN.md`](../../GROK-PROVIDER-INTEGRATION-PLAN.md) (execution plan this ADR formalizes) · extends [ADR 0012](0012-local-server-connectivity-tauri-http.md) · see also [ADR 0004](0004-csp-connect-src-byok-tradeoff.md)

## Context

README documents Grok (xAI, "Cloud 4") and Claude (Anthropic, "Cloud 3") as working cloud AI
providers. Neither was actually selectable as a primary provider with its own API key in
`components/settings/AiProviderCard.tsx`'s main flow, prompting an investigation into why.

Verification found **two structurally different problems**, not one:

- **Grok**: `services/aiProviderService.ts`'s `streamGrok()` already has a real, working
implementation — a genuine `fetch('https://api.x.ai/v1/chat/completions', ...)`, BYOK key
storage, and a working `/v1/models` connection test. It simply was never added to
`AiProviderCard.tsx`'s primary provider dropdown (only reachable as a hybrid-fallback-chain
option) or to the newer Vercel-AI-SDK layer (`services/ai/providerFactory.ts`'s
`providerToKind()`). **Pure UI/wiring gap.**
- **Claude**: `streamAnthropic()` unconditionally throws — *"Direct browser requests are blocked by
Anthropic (CORS). Please use a backend proxy or switch to Gemini/OpenAI/Ollama."* — on **every**
platform, including desktop, without ever checking which platform it's running on. A first
investigation pass concluded this needed a serverless proxy everywhere. **A second, more careful
pass corrected that**: `AiProviderCard.tsx` already has an `'anthropic'` entry in its primary
dropdown with a dedicated warning block, whose own shipped i18n copy already says *"...or use the
Tauri desktop app for Anthropic calls"* — a promise the code doesn't keep. CORS is a **browser**
security mechanism. It does not apply to Tauri's native HTTP plugin
(`@tauri-apps/plugin-http`), which `services/localServerHttp.ts` already uses to bypass exactly
this class of restriction for Ollama/LM Studio/vLLM (ADR-0012). `streamAnthropic()` never
attempts that escape hatch before throwing.

## Decision

### Grok — native UI wiring (no new architecture)

Add Grok to `AiProviderCard.tsx`'s primary provider dropdown with its own API-key field and model
selector, wired to the existing, already-functional `streamGrok()` backend. Add a `case 'grok'` to
`providerFactory.ts`'s `providerToKind()` (xAI's API is OpenAI-compatible) so the newer
Vercel-AI-SDK Writer-streaming path gets Grok too, not just legacy thunks. No new feature flag —
Grok becomes a regular provider option exactly like Gemini/OpenAI. No CSP change needed:
`https://api.x.ai` is already present in `src-tauri/tauri.conf.json`, and the web CSP's `https:`
scheme-source (ADR-0004) already covers it.

### Claude — two independent tracks, not one

**Track A (desktop, ships first, no new infrastructure):** fix `streamAnthropic()` to branch on
`isTauriRuntime()` before throwing. On desktop, call `https://api.anthropic.com` directly via the
same native-HTTP pattern ADR-0012 established for local servers — native networking is not subject
to browser CORS at all. Add `https://api.anthropic.com` to the Tauri CSP's `connect-src` (mirrors
the existing Grok entry). Desktop Settings UI swaps the warning-only block for a real API-key/model
flow. This is a narrow bug fix, not new architecture, and ships independently of Track B.

**Track B (web/PWA, ships second, the actual new architecture):** browser `fetch()` has no
native-HTTP escape hatch — Anthropic's API sends no CORS headers permitting direct browser access,
so the only way to make Claude work in a browser tab is a server-side relay. This is the app's
**first backend dependency** ever (previously a purely static SPA across GitHub Pages, Vercel, and
Cloudflare Pages, plus a Tauri desktop bundle with no local server of its own). A Vercel Function
(`api/claude-proxy.ts`) relays `{ apiKey, model, messages }` to `https://api.anthropic.com/v1/messages`
statelessly — it must never log, persist, or cache the API key or message content. A Cloudflare
Pages Function equivalent follows for that deploy target. **GitHub Pages cannot host a serverless
function at all** (pure static hosting) — Claude shows an honest "unavailable on this deployment"
state there rather than silently failing or defaulting to a hosted proxy the user didn't choose.

The proxy is public and unauthenticated by construction (it relays whatever API key the caller
supplies) — this makes it an externally-reachable resource-exhaustion surface (CWE-400) if shipped
without controls. Schema validation, a request body size limit, an origin check, rate limiting, and
timeouts on both legs (inbound and the outbound call to Anthropic) are **mandatory**, not optional
hardening to consider later.

### Trust-model consequence (Track B only)

Every other cloud provider in this app is a direct browser→provider call — the user's API key
never transits infrastructure WorldScript controls. Track B's proxy is the **first exception**: a
Claude request on the web now flows browser → WorldScript's Vercel/Cloudflare deployment →
Anthropic. The proxy is stateless (nothing is persisted or logged), but the key still passes
through code the maintainer runs, for the duration of that one request. This is documented
explicitly in `docs/SECURITY-THREAT-MODEL.md` and in README's privacy framing — it is not hidden
behind "just like BYOK for other providers," because it isn't quite the same guarantee.

### Ollama-in-PWA (adjacent follow-up, Issue #266, not part of this decision's core scope)

A related but separate question came up during this work: could the same proxy pattern let the PWA
reach the user's own local Ollama server? **No** — a hosted proxy can only reach itself, never a
user's private `localhost`, so this is not a variant of Track B. The actual answer (already
described by the maintainer in Issue #266's own comment thread) is an opt-in feature flag using
direct browser `fetch()`, matching NovelCrafter's real (non-magic) model: it works only when the
user configures their own Ollama server's `OLLAMA_ORIGINS` to the exact PWA origin. This narrowly
widens ADR-0012's "PWA stays desktop-only" decision for users who explicitly opt in and accept the
setup burden — it does not reverse that decision as the default. See the plan document's addendum
for the full design.

## Consequences

- **Positive:** Grok becomes fully usable with zero new infrastructure. Claude becomes usable on
desktop immediately (Track A) with a small, low-risk fix reusing an established pattern, and on
the web (Track B) once the proxy ships — closing a real gap between README's claims and actual
behavior for both providers.
Comment thread
qnbs marked this conversation as resolved.
- **Negative (accepted):** Track B introduces this app's first backend dependency, a genuine
architectural shift from "purely static SPA" — deliberately scoped as narrowly as possible (one
relay endpoint, no general backend-for-frontend layer) and documented as a trust-model change,
not glossed over.
- **Deliberately deferred:** a hardcoded fallback proxy URL for GitHub Pages (so that deployment
could have Claude too, at the cost of routing every such request through a fixed external host
the user didn't choose) is not part of this decision — it's called out in the plan as a choice
for the maintainer to make explicitly later, not a default.
- **Not yet resolved:** whether Claude's image-generation call site should get an equivalent fix
depends on whether Anthropic's API even offers image generation — verify before wiring UI for a
capability that may not exist.

## References

- [`GROK-PROVIDER-INTEGRATION-PLAN.md`](../../GROK-PROVIDER-INTEGRATION-PLAN.md) — full phased execution plan
- [[0012-local-server-connectivity-tauri-http]] — the native-HTTP pattern Track A reuses
- [[0004-csp-connect-src-byok-tradeoff]] — why the web CSP's `https:` scheme-source already covers Grok
- [Issue #266](https://github.com/qnbs/WorldScript-Studio/issues/266) — origin of both the ADR-0012 pattern and the Ollama-in-PWA follow-up
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ than editing history.
| [0013](0013-csp-wasm-and-blob-frames.md) | CSP `'wasm-unsafe-eval'` and `frame-src blob:` | Accepted |
| [0014](0014-worker-generation-duplication.md) | Two live worker generations (v1 and WorkerBus v2) | Superseded by 0015 |
| [0015](0015-worker-generation-consolidation.md) | Worker-generation consolidation: v1 retired, WorkerBus v2 is the sole generation | Accepted |
| [0016](0016-native-grok-and-claude-providers.md) | Native Grok provider + split Claude fix (desktop native-HTTP, web serverless proxy) | Accepted |

**Format:** Context → Decision → Consequences (incl. rejected alternatives). Keep each ADR to one
decision. Link related records with `[[slug]]`.
2 changes: 1 addition & 1 deletion docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

## 1. Current state (as audited)

- **19 locales × 21 modules**, ~2849 keys, full **key parity** enforced by
- **19 locales × 21 modules**, ~2855 keys, full **key parity** enforced by
`scripts/check-i18n-keys.mjs` (CI `quality` job) + bundled to `public/locales/<lang>/bundle.json`.
- Runtime: custom `contexts/I18nContext.tsx` — lazy `bundle.json` fetch, `localStorage` persistence
(`worldscript-language`), English fallback chain, cached `Intl.*` helpers, `Intl.Segmenter` word
Expand Down
6 changes: 6 additions & 0 deletions locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
"settings.ai.gpu.troubleshootLowEnd": "جهازك ذو موارد محدودة. سيستخدم الذكاء الاصطناعي المحلي نماذج أصغر. لأداء أفضل، أغلق علامات تبويب المتصفّح الأخرى أو فعّل الوضع الاقتصادي.",
"settings.ai.gpu.troubleshootQueue": "هناك مهام ذكاء اصطناعي متعدّدة في الطابور. تعمل واحدة فقط في كل مرة لتجنّب تصادمات ذاكرة الرسوميات. انتظر انتهاء المهمة الحالية.",
"settings.ai.gpu.waitingConsumers": "في الانتظار",
"settings.ai.grokKey": "Grok API Key",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"settings.ai.hybridFallbackHint": "إذا فشل المزوّد الأساسي، يجرّب WorldScript الاحتياطيات المدرجة (مولّدات المشاهد، أدوات الفهرس). يستخدم بثّ الكاتب مزوّدك الأساسي فقط.",
"settings.ai.hybridFallbackTitle": "الاحتياطي الهجين (أدوات الذكاء الاصطناعي للمشروع)",
"settings.ai.hybridFallbackToggle": "تفعيل سلسلة الاحتياطي",
Expand Down Expand Up @@ -214,6 +215,11 @@
"settings.ai.preset.lmStudio": "LM Studio",
"settings.ai.preset.ollamaDefault": "‏Ollama ‏(المنفذ الافتراضي)",
"settings.ai.preset.vllm": "vLLM",
"settings.ai.provider.anthropic": "Anthropic Claude",
"settings.ai.provider.gemini": "Google Gemini",
"settings.ai.provider.grok": "xAI Grok",
"settings.ai.provider.ollama": "Ollama (local)",
"settings.ai.provider.openai": "OpenAI",
"settings.ai.providerDescription": "اختر مزوّد الذكاء الاصطناعي الذي يجب أن يستخدمه WorldScript Studio.",
"settings.ai.providerOnnx": "ONNX ‏(WASM)",
"settings.ai.providerStatusConnected": "متصل",
Expand Down
Loading
Loading