Phase 12: LLM provider config + client + CSP

- Bring-your-own-key assistant config in a dedicated persisted store
  (src/stores/assistantStore.ts); partialize drops the key from localStorage
  when 'remember' is off. Key lives in localStorage only, so it's auto-excluded
  from Dexie backups.
- Provider-agnostic LLM client (src/lib/llm) over fetch, no SDK: Anthropic
  Messages + OpenAI-compatible Chat Completions (covers OpenAI/OpenRouter/
  Ollama/LM Studio via custom base URL). Structured JSON output parsed +
  Zod-validated; timeouts; typed error kinds; key never leaked in errors.
- Settings 'Assistant (AI)' section with provider/model/baseUrl/key, remember
  + enabled toggles, and Test connection.
- CSP connect-src widened to any https + localhost for the BYO endpoint.

10 client unit tests; settings e2e asserts config persists and the key is
never present in an exported backup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:04:20 +02:00
parent 0930136c46
commit 7fd5fcd9af
9 changed files with 523 additions and 2 deletions
+23
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import fs from 'node:fs';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -27,3 +28,25 @@ test('settings loads the sample campaign', async ({ page }) => {
await page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click();
await expect(page.getByText('Lia the Brave')).toBeVisible();
});
test('assistant config persists and the key is never exported', async ({ page }) => {
const SECRET = 'sk-test-DO-NOT-EXPORT-789';
await page.getByLabel('Settings').click();
await page.getByLabel('Enable AI assistant').check();
await page.getByLabel('API key', { exact: true }).fill(SECRET);
await page.getByLabel('Model').fill('claude-opus-4-8');
// Persists across reload (rememberKey defaults on)
await page.reload();
await page.getByLabel('Settings').click();
await expect(page.getByLabel('Enable AI assistant')).toBeChecked();
await expect(page.getByLabel('API key', { exact: true })).toHaveValue(SECRET);
// The backup export (Dexie only) must not contain the localStorage-held key
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export backup' }).click();
const download = await downloadPromise;
const path = await download.path();
const content = fs.readFileSync(path, 'utf8');
expect(content).not.toContain(SECRET);
});