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
+114
View File
@@ -0,0 +1,114 @@
import { useState } from 'react';
import { useAssistantStore, getLlmConfig } from '@/stores/assistantStore';
import { testConnection } from '@/lib/llm/client';
import type { LlmProvider } from '@/lib/llm/types';
import { Input, Select, Field } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
const PROVIDER_DEFAULTS: Record<LlmProvider, { baseUrl: string; model: string; keyLabel: string }> = {
anthropic: { baseUrl: 'https://api.anthropic.com', model: 'claude-opus-4-8', keyLabel: 'API key (x-api-key)' },
'openai-compatible': { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o', keyLabel: 'API key (Bearer token)' },
};
export function AssistantSettings() {
const cfg = useAssistantStore();
const setConfig = useAssistantStore((s) => s.setConfig);
const [status, setStatus] = useState<{ ok: boolean; text: string } | null>(null);
const [testing, setTesting] = useState(false);
const onProvider = (provider: LlmProvider) => {
const d = PROVIDER_DEFAULTS[provider];
// Swap base URL/model to the new provider's defaults only if the current values
// are still a known default (don't clobber a custom endpoint the user typed).
const knownUrls = Object.values(PROVIDER_DEFAULTS).map((p) => p.baseUrl);
const knownModels = Object.values(PROVIDER_DEFAULTS).map((p) => p.model);
setConfig({
provider,
...(knownUrls.includes(cfg.baseUrl) || !cfg.baseUrl ? { baseUrl: d.baseUrl } : {}),
...(knownModels.includes(cfg.model) || !cfg.model ? { model: d.model } : {}),
});
};
const runTest = async () => {
setTesting(true);
setStatus(null);
const res = await testConnection(getLlmConfig());
setStatus(res.ok ? { ok: true, text: 'Connection successful.' } : { ok: false, text: res.message });
setTesting(false);
};
return (
<section className="mb-8">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Assistant (AI)</h2>
<p className="mb-3 max-w-2xl text-sm text-muted">
Optional. Bring your own provider and key to enable AI-grounded tips (encounter balancing,
level-up routes). The assistant always works without this it falls back to deterministic
suggestions. Your key is stored only in this browser and is never included in backups.
</p>
<div className="grid max-w-2xl gap-3 sm:grid-cols-2">
<Field label="Enabled">
<label className="flex h-10 items-center gap-2 text-sm text-ink">
<input
type="checkbox"
checked={cfg.enabled}
onChange={(e) => setConfig({ enabled: e.target.checked })}
aria-label="Enable AI assistant"
/>
Use AI suggestions
</label>
</Field>
<Field label="Provider">
<Select value={cfg.provider} onChange={(e) => onProvider(e.target.value as LlmProvider)} aria-label="Provider">
<option value="anthropic">Anthropic (Claude)</option>
<option value="openai-compatible">OpenAI-compatible (OpenAI / OpenRouter / Ollama / LM Studio)</option>
</Select>
</Field>
<Field label="Model">
<Input value={cfg.model} onChange={(e) => setConfig({ model: e.target.value })} placeholder="model id" aria-label="Model" />
</Field>
<Field label="Base URL">
<Input value={cfg.baseUrl} onChange={(e) => setConfig({ baseUrl: e.target.value })} placeholder="https://…" aria-label="Base URL" />
</Field>
<Field label={PROVIDER_DEFAULTS[cfg.provider].keyLabel}>
<Input
type="password"
value={cfg.apiKey}
onChange={(e) => setConfig({ apiKey: e.target.value })}
placeholder="sk-…"
autoComplete="off"
aria-label="API key"
/>
</Field>
<Field label="Remember key">
<label className="flex h-10 items-center gap-2 text-sm text-ink">
<input
type="checkbox"
checked={cfg.rememberKey}
onChange={(e) => setConfig({ rememberKey: e.target.checked })}
aria-label="Remember API key"
/>
Save key in this browser
</label>
</Field>
</div>
<div className="mt-3 flex items-center gap-3">
<Button variant="secondary" onClick={runTest} disabled={testing || !cfg.apiKey}>
{testing ? 'Testing…' : 'Test connection'}
</Button>
{status && (
<span className={cn('text-sm', status.ok ? 'text-success' : 'text-danger')} aria-live="polite">
{status.text}
</span>
)}
</div>
</section>
);
}
+3
View File
@@ -4,6 +4,7 @@ import { useUiStore, type Theme } from '@/stores/uiStore';
import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup';
import { pickTextFile } from '@/lib/io/file';
import { seedSampleCampaign } from '@/lib/sample';
import { AssistantSettings } from './AssistantSettings';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
@@ -64,6 +65,8 @@ export function SettingsPage() {
{msg && <p className={cn('mt-2 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>}
</section>
<AssistantSettings />
<section className="rounded-lg border border-danger/40 bg-danger/5 p-4">
<h2 className="mb-1 text-sm font-semibold text-danger">Danger zone</h2>
<p className="mb-3 text-sm text-muted">Permanently delete all campaigns and data on this device.</p>