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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[ADR 0012](docs/adr/0012-local-server-connectivity-tauri-http.md) and
[`docs/LOCAL-AI.md`](docs/LOCAL-AI.md).

- **Desktop Ollama/LM Studio/vLLM discovery still broken in packaged builds after #269 (#266).**
Root cause: `vite.config.ts`'s `rollupOptions.external` unconditionally externalized every
`@tauri-apps/*` package from **every** `vite build`, including the exact build Tauri's
`beforeBuildCommand` invokes to produce the `.deb`/`.msi`. Since `services/localServerHttp.ts`'s
`@tauri-apps/plugin-http` import is dynamic, this left an unresolvable bare module specifier in
the shipped desktop bundle — every caller's catch classified the resulting load failure
identically to a genuinely-down server (no CORS noise, no discovery, even with Ollama/LM Studio
running). `tauri dev` was unaffected (Vite's dev server doesn't apply `rollupOptions`), so the
regression only surfaced in packaged builds. Fixed by extracting the existing Tauri-build
detection (`resolveViteBase.ts`'s `TAURI_ENV_PLATFORM`/`TAURI_PLATFORM` check) into a shared
`isTauriBuild()` export and making the external array conditional on it; the web/PWA build is
unaffected. `localServerHttp.ts`'s `resolveFetch()` also now classifies a plugin load failure as
a distinct `LocalServerError('plugin_unavailable')` (logged), instead of folding it into
`'unreachable'`. See the 2026-07-28 update in
[ADR 0012](docs/adr/0012-local-server-connectivity-tauri-http.md).

- **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
Expand Down
18 changes: 5 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

### Low-End Local Hardware (Current Environment)

**ONE Bash tool call per turn.** Concurrent shells (vitest, biome, tsc, vite, pnpm build) cause OOM and pool-worker timeouts.
- ONE Bash tool call per turn. Wait for the result. Then proceed.
- NO `run_in_background` for vitest, biome, tsc, vite, or any pnpm build command.
- NO parallel Agent tool calls that each issue shell commands.
- NO multiple Bash tool calls in the same response block.
- Chain sequential steps inside ONE Bash call using `&&` if needed.
- DevContainer / Codespaces configuration has been removed; this is a local-only development environment.

**Plan-mode exception — parallel exploration IS allowed.** The OOM risk is from concurrent *heavy shells* (vitest/biome/tsc/vite/build), not from read-only exploration. In **plan mode** (read-only research, no builds), you SHOULD launch parallel `Explore`/`Plan` subagents to map the codebase faster — these read files and run light `grep`/`ls`, which is safe. The ONE-Bash-per-turn rule still governs the **main loop's own heavy commands** at all times, and parallel agents must not each kick off vitest/biome/tsc/vite/build. Outside plan mode (implementation), keep agent spawns sequential when they issue heavy shells.
Shell-execution rules (one Bash call per turn, no parallel heavy shells, plan-mode exception) live in the user-global `~/.claude/CLAUDE.md` and apply here. Project-specific addition: DevContainer / Codespaces configuration has been removed; this is a local-only development environment.

## Commands

Expand Down Expand Up @@ -78,8 +70,6 @@ Conventional Commits format: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `ch

WorldScript Studio is an offline-first PWA — a React 19 SPA with Google Gemini AI, IndexedDB persistence, and optional Tauri desktop packaging. No backend; API keys are entered in the UI and encrypted at rest.

**Stack:** React 19, TypeScript (strict), Vite 8, Tailwind CSS 4.x, Redux Toolkit 2.x, pnpm 11, Node ≥ 22. Four internal workspace packages (`@domain/ai-core`, `@domain/ui`, `collab-transport`, `@domain/worker-bus` in `packages/`) are consumed as `workspace:*` deps.

**Live:** `https://worldscript-studio.vercel.app/` (Vercel, primary) · GitHub Pages: `https://qnbs.github.io/WorldScript-Studio/` · Cloudflare Pages: `wrangler.toml` · Vercel: `vercel.json`.

### Directory map
Expand Down Expand Up @@ -194,6 +184,8 @@ Wrap each major view root with `components/ui/ViewErrorBoundary.tsx` — provide

**Prompt assembly:** `services/ragPromptAssembly.ts` — `assembleRAGPrompt(opts)`. Templates from `services/promptLibrary.ts`.

**Heuristic-fallback layer:** `services/ai/heuristicFallback/` — when every provider in the chain fails terminally (offline, quota, Eco/Heuristics-only mode), `aiProviderService` calls `applyHeuristicFallback(task, ctx)` (`seam.ts`) before falling through to the generic local stub. It looks up a per-feature generator in `registry.ts` (keyed by task id: `outline`, `character.profile`, `world.profile`, `plotBoard.beat`) and builds a `HeuristicFallbackResult` from existing project data — no network call. Returns `null` when no generator is registered for a task, so wiring a new call site is always non-breaking. Generators self-register via `registerHeuristicGenerator(task, fn)` at module load (mirrors the `services/copilot/heuristicEngine.ts` pluggable-rule pattern). `useHeuristicFallback()` + `<AssistedModeBadge>` surface an "Assisted (offline)" badge wherever a result came from this layer; events also feed `telemetryService` as `backend: 'heuristic'`.

### DuckDB Analytics

`workers/duckdbWorker.ts` off main thread (OPFS → in-memory fallback). `duckdbClient.ts`: singleton, init retry 3× backoff. Schema: 10 tables + 5 views incl. `rag_chunks` (FLOAT[384]). Gate all paths behind `enableDuckDbAnalytics`. Dual-write via `duckdbListenerLoader.ts` (dynamically imported). `ragVectorMigration.ts`: FLOAT[64]→FLOAT[384] upgrade. `useDuckDb.ts` 30s timeout; `useAnalytics.ts` parallelizes 4 queries.
Expand Down Expand Up @@ -248,7 +240,7 @@ Production uses **rolldown** (not esbuild/rollup); CI E2E runs `vite dev` — pr

Experimental features are gated behind `features/featureFlags/featureFlagsSlice.ts` (**22 flags**). New installs get the **full feature set**: all flags default **on** except six user-opt-in flags. `enableCodexAutoTracking` + `enableCrossProjectSearch` were promoted to permanent core behaviour (v1.20 / v1.8); `enablePlotBoardV2` and `enableCloudSync` were retired — none of those four remain in the slice. UI: Settings → Experimental flags (`FeatureFlagsSection.tsx`). Do not use scattered `if (true)` hacks.

**Default on (17):** `enableStoryBibleAdvanced`, `enableBinderResearch`, `enableCompileWizard`, `enableProjectHealthScore`, `enableAppHealthPanel`, `enableDuckDbAnalytics`, `enableObjectsGroups`, `enableMindMaps`, `enableCharacterInterviews`, `enableLoraAdapters`, `enablePluginSystem`, `enableIdbAtRestEncryption` (B-1, passphrase UX complete — Settings › Privacy), `enableAdaptiveAiEngine`, `enableWebnnInference`, `enableComputeShaders`, `enableWorkerBusV2`, `enableRustCompute`. **User opt-in — default off (6):** `enableProForge` (experimental, token-heavy 8-stage agentic pipeline — flipped to opt-in in v1.24 post-release), `enableVoiceSupport` (requires browser mic permission), `enableVoiceWasm` (B-2, ~57 MB Whisper download), `enableGlobalCopilot` (ambient AI), `enableRtlLayout` (B-5, ar/he stubs only), `enableLocalFirstSync` (shadow Yjs projection, ADR-0008; Redux stays SoT). The Settings UI groups these by catalog tier; `features/featureCatalog.ts` **derives** each flag's `defaultOn` from the slice (no hand-keyed drift). Note: `enableCloudSync` was **retired** in v1.20 (no UI shipped; `CloudSyncBackend.create()` requires explicit-consent boolean instead).
**Default on (16):** `enableStoryBibleAdvanced`, `enableBinderResearch`, `enableCompileWizard`, `enableProjectHealthScore`, `enableAppHealthPanel`, `enableDuckDbAnalytics`, `enableObjectsGroups`, `enableMindMaps`, `enableCharacterInterviews`, `enableLoraAdapters`, `enablePluginSystem`, `enableIdbAtRestEncryption` (B-1, passphrase UX complete — Settings › Privacy), `enableAdaptiveAiEngine`, `enableComputeShaders`, `enableWorkerBusV2`, `enableRustCompute`. **User opt-in — default off (6):** `enableProForge` (experimental, token-heavy 8-stage agentic pipeline — flipped to opt-in in v1.24 post-release), `enableVoiceSupport` (requires browser mic permission), `enableVoiceWasm` (B-2, ~57 MB Whisper download), `enableGlobalCopilot` (ambient AI), `enableRtlLayout` (B-5, ar/he stubs only), `enableLocalFirstSync` (shadow Yjs projection, ADR-0008; Redux stays SoT). The Settings UI groups these by catalog tier; `features/featureCatalog.ts` **derives** each flag's `defaultOn` from the slice (no hand-keyed drift). Note: `enableCloudSync` was **retired** in v1.20 (no UI shipped; `CloudSyncBackend.create()` requires explicit-consent boolean instead).

### Command Center & shortcuts

Expand Down Expand Up @@ -407,7 +399,7 @@ Reference plugins: `wordCountOverlay.plugin.ts`, `sceneAppender.plugin.ts`. Gate

### Virtual scrolling

`NavigatorPanel.tsx` uses `useVirtualizer` (`@tanstack/react-virtual`): scrollable `<ul ref={scrollRef}>` + `position: relative`; sentinel `<li>` sets `height: totalSize`; items `position: absolute, transform: translateY(start)`. Items need `data-index` + `ref={measureElement}`. Use `estimateSize: () => 40, overscan: 3`. Never lift `overflow-y: auto` into a parent.
Feature-specific implementation patterns (Plot Board, ProForge Pipeline, scene-level services, LanguageTool, test mock patterns, Settings Navigation, cross-project & backup, Global AI Copilot, Voice Full Support, local inference, Plugin System, Cloud Sync, LoRA Adapter Inference, virtual scrolling) now live in nested `CLAUDE.md` files, loaded automatically only when working under that directory: `features/plotBoard/`, `services/proForge/`, `services/`, `tests/`, `components/`, `services/copilot/`, `services/voice/`, `services/cloudSync/`, `features/lora/`.

## Known Technical Debt

Expand Down
7 changes: 7 additions & 0 deletions components/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Settings Navigation

`components/SettingsView.tsx` uses `NAV_GROUPS` — typed array of `{ key: string; ids: readonly string[] }` — for semantic sidebar sections (Writing, AI Models, Appearance & Accessibility, Privacy & Data, Connections, System). When adding a new settings tab: add its `id` to the correct group in `NAV_GROUPS`; do not create a flat ungrouped entry.

# Virtual scrolling

`NavigatorPanel.tsx` uses `useVirtualizer` (`@tanstack/react-virtual`): scrollable `<ul ref={scrollRef}>` + `position: relative`; sentinel `<li>` sets `height: totalSize`; items `position: absolute, transform: translateY(start)`. Items need `data-index` + `ref={measureElement}`. Use `estimateSize: () => 40, overscan: 3`. Never lift `overflow-y: auto` into a parent.
9 changes: 8 additions & 1 deletion components/settings/AiProviderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,14 @@ export const AiProviderCard: FC<AiProviderCardProps> = ({
setTestStatus('ok');
} else {
setTestStatus('error');
setTestError(result.error ?? t('settings.ai.connectionFailed'));
// QNBS-v3: `kind` is a stable, i18n-mappable classification — prefer it over the raw
// `error` string (which is a technical/English message kept for logs) so every failure
// path across all providers renders localized text, not leaked English.
setTestError(
result.kind
? t(`settings.ai.testError.${result.kind}`, result.params)
: (result.error ?? t('settings.ai.connectionFailed')),
);
}
} catch (e) {
setTestStatus('error');
Expand Down
23 changes: 18 additions & 5 deletions config/resolveViteBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@

export const GITHUB_PAGES_BASE = '/WorldScript-Studio/';

/**
* True when the active `vite build` is the one Tauri's `beforeBuildCommand` hook invokes to
* produce the desktop bundle, as opposed to the web/PWA build.
*
* QNBS-v3: Tauri 2.x sets TAURI_ENV_PLATFORM for hook commands; the legacy Tauri 1.x name was
* TAURI_PLATFORM. Accept both so a CLI downgrade/upgrade can't silently reintroduce a
* platform-detection regression. Single source of truth — reused by both the `base` resolution
* below and `vite.config.ts`'s `rollupOptions.external` (a desktop build that externalized
* `@tauri-apps/*` left an unresolvable bare module specifier in the shipped bundle; see
* docs/adr/0012-local-server-connectivity-tauri-http.md).
*/
export function isTauriBuild(env: Record<string, string | undefined> = process.env): boolean {
// QNBS-v3: require a trimmed, non-empty value — an empty-string env var (e.g. a CI matrix
// that sets TAURI_ENV_PLATFORM="" to "unset" it) must not be read as "Tauri build".
return Boolean(env['TAURI_ENV_PLATFORM']?.trim()) || Boolean(env['TAURI_PLATFORM']?.trim());
}

/**
* Resolve the Vite `base` for the active build target.
* - Tauri desktop → './' (relative; assets must load from tauri://localhost/ root)
Expand All @@ -13,11 +30,7 @@ export const GITHUB_PAGES_BASE = '/WorldScript-Studio/';
* - default (GitHub Pages project page) → '/WorldScript-Studio/'
*/
export function resolveViteBase(env: Record<string, string | undefined> = process.env): string {
// QNBS-v3: Tauri 2.x sets TAURI_ENV_PLATFORM for hook commands (beforeBuildCommand); the legacy
// Tauri 1.x name was TAURI_PLATFORM. Accept both so a CLI downgrade/upgrade can't silently
// reintroduce the absolute-base blank-screen bug.
const isTauri = env['TAURI_ENV_PLATFORM'] !== undefined || env['TAURI_PLATFORM'] !== undefined;
if (isTauri) {
if (isTauriBuild(env)) {
return './';
}
const viteBase = env['VITE_BASE']?.trim();
Expand Down
29 changes: 29 additions & 0 deletions docs/adr/0012-local-server-connectivity-tauri-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,32 @@ modes were reported in #266:
- **Allow PWA status-only probing with Private Network Access permission** — rejected (product
decision): keeps CORS noise possible, adds a second-class UX path, and conflicts with the
privacy-first "desktop-only" policy for localhost servers.

## Update (2026-07-28): build-pipeline gap left the desktop path broken in packaged builds

This ADR's decision (route local-server HTTP through `@tauri-apps/plugin-http`) was correct, but
the packaged desktop build never actually exercised it. `vite.config.ts`'s `rollupOptions.external`
unconditionally externalized every `@tauri-apps/*` package from **every** `vite build`, including
the exact build Tauri's `beforeBuildCommand` invokes to produce the `.deb`/`.msi`. Since
`services/localServerHttp.ts`'s `@tauri-apps/plugin-http` import is dynamic (`await import(...)`),
externalizing it left an unresolvable bare module specifier in the shipped bundle — confirmed with
a real build + real-Chromium repro, producing `TypeError: Failed to resolve module specifier
'@tauri-apps/plugin-http'` the instant `resolveFetch()` ran, before any network request. Every
caller's catch classified this identically to a genuinely-down server, matching the exact symptom
reported on issue #266 after #269 merged: no CORS console noise (nothing reached the network
layer), and no Ollama/LM Studio discovery despite both running.

Root cause: `resolveViteBase.ts` already had the right Tauri-vs-web build detection (via
`TAURI_ENV_PLATFORM`/`TAURI_PLATFORM`), used for the `base` config, but `rollupOptions.external`
was never given the same treatment. `tauri dev` was unaffected (Vite's dev server doesn't apply
`rollupOptions`), so the regression only surfaced in packaged builds — and the unit test suite
mocks `@tauri-apps/plugin-http` via `vi.mock`, which bypasses real module resolution entirely and
structurally cannot catch this class of bug.

Fix: extracted the Tauri-build check into a shared `isTauriBuild()` export in `resolveViteBase.ts`
and made `rollupOptions.external` conditional on it — the desktop build now bundles
`@tauri-apps/plugin-http` correctly; the web/PWA build is unaffected (those code paths are gated
by `isTauriRuntime()` and never exercised there). `services/localServerHttp.ts`'s `resolveFetch()`
also now wraps the dynamic import in its own try/catch, classifying a load failure as a distinct
`LocalServerError('plugin_unavailable')` and logging it, so a future regression of this class fails
loudly and distinctly instead of silently misclassifying as "unreachable."
7 changes: 7 additions & 0 deletions features/lora/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# LoRA Adapter Inference

**Wiring (C-3):** `AIRequestOptions.loraModelPath?: string` — when set and `provider === 'ollama'`, `streamProvider()` substitutes it as the Ollama model identifier. `selectActiveLoraOllamaTag` (`features/lora/loraSelectors.ts`) returns active adapter's `ollamaModelTag` or null.

**Prerequisite:** `ollama create <tag> -f Modelfile` before setting `ollamaModelTag`. Training is a Python sidecar.

**Gating:** `enableLoraAdapters` flag. UI: Settings → AI → Fine-Tuning.
9 changes: 9 additions & 0 deletions features/plotBoard/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Plot Board

**plotBoardSlice:** `features/plotBoard/plotBoardSlice.ts` — ephemeral viewport/UI state only (zoom/pan/mode/draw). NOT undo-able; persists to `localStorage`. Story content (connections, subplots, tensionOverrides) lives in `projectSlice` — use selectors from `features/project/projectSelectors.ts` and dispatch `projectActions.add/removePlotConnection`, `add/deletePlotSubplot`, `setPlotTensionOverride`.

**plotBoardService:** `services/plotBoardService.ts` — `computeTensionCurve(sections, overrides)`, `autoLayoutScenes(sections)`, `exportBoardAsSvg(svgEl)`.

**Plot Board AI:** `features/project/thunks/plotBoardAiThunks.ts` — `suggestNextBeatThunk` calls `assembleRAGPrompt` then dispatches to AI. Hook: `hooks/usePlotBoardAi.ts`.

**PlotMinimap:** `components/scene-board/PlotMinimap.tsx` — viewport overview overlay. `plotLayoutUtils.ts` provides grid-snap helpers.
10 changes: 10 additions & 0 deletions locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,16 @@
"settings.ai.temperature.creative": "2 – إبداعي",
"settings.ai.temperature.precise": "0 – دقيق",
"settings.ai.testConnection": "اختبار الاتصال",
"settings.ai.testError.noApiKey": "لم يتم تعيين مفتاح API لـ {{provider}}",
"settings.ai.testError.httpError": "HTTP {{status}}",
"settings.ai.testError.timeout": "انتهت مهلة الاتصال ({{url}})",
"settings.ai.testError.unreachable": "غير قابل للوصول ({{url}})",
"settings.ai.testError.pluginUnavailable": "شبكة الخادم المحلي غير متوفرة: فشل تحميل مكوّن HTTP لسطح المكتب.",
"settings.ai.testError.desktopRequired": "تتوفر Ollama والخوادم المحلية المتوافقة مع OpenAI فقط في تطبيق سطح المكتب. تحظر المتصفحات الاتصالات المباشرة من صفحات الويب بـ localhost (CORS وPrivate Network Access).",
"settings.ai.testError.backendProxyRequired": "يتطلب Claude وكيل خلفية (قيود CORS)",
"settings.ai.testError.noWebgpu": "WebGPU غير متوفر في هذا المتصفح — يحتاج WebLLM إلى WebGPU (جرّب Chrome/Edge أو فعّل العلامات).",
"settings.ai.testError.unknownProvider": "مزوّد غير معروف",
"settings.ai.testError.unexpected": "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى.",
"settings.ai.title": "إعداد الذكاء الاصطناعي",
"settings.ai.transformers.hint": "يشغّل أي نموذج HuggingFace متوافق مع Xenova محليًا عبر WebAssembly أو WebGPU.",
"settings.ai.transformers.modelId": "معرّف النموذج ‏(HuggingFace)",
Expand Down
Loading
Loading