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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user