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:
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/');
|
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 page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click();
|
||||||
await expect(page.getByText('Lia the Brave')).toBeVisible();
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup';
|
||||||
import { pickTextFile } from '@/lib/io/file';
|
import { pickTextFile } from '@/lib/io/file';
|
||||||
import { seedSampleCampaign } from '@/lib/sample';
|
import { seedSampleCampaign } from '@/lib/sample';
|
||||||
|
import { AssistantSettings } from './AssistantSettings';
|
||||||
import { Page, PageHeader } from '@/components/ui/Page';
|
import { Page, PageHeader } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
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>}
|
{msg && <p className={cn('mt-2 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AssistantSettings />
|
||||||
|
|
||||||
<section className="rounded-lg border border-danger/40 bg-danger/5 p-4">
|
<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>
|
<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>
|
<p className="mb-3 text-sm text-muted">Permanently delete all campaigns and data on this device.</p>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { complete } from './client';
|
||||||
|
import type { LlmConfig } from './types';
|
||||||
|
|
||||||
|
const base: LlmConfig = {
|
||||||
|
enabled: true, provider: 'anthropic', model: 'claude-opus-4-8',
|
||||||
|
baseUrl: 'https://api.anthropic.com', apiKey: 'secret-key-123', rememberKey: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockFetch(impl: (url: string, init: RequestInit) => Response | Promise<Response>) {
|
||||||
|
const fn = vi.fn(impl);
|
||||||
|
vi.stubGlobal('fetch', fn);
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
function anthropicReply(text: string) {
|
||||||
|
return new Response(JSON.stringify({ content: [{ type: 'text', text }] }), { status: 200 });
|
||||||
|
}
|
||||||
|
function openaiReply(text: string) {
|
||||||
|
return new Response(JSON.stringify({ choices: [{ message: { content: text } }] }), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
describe('complete — guards', () => {
|
||||||
|
it('short-circuits when disabled without calling fetch', async () => {
|
||||||
|
const fn = mockFetch(() => anthropicReply('hi'));
|
||||||
|
const r = await complete({ ...base, enabled: false }, { system: 's', user: 'u' });
|
||||||
|
expect(r).toEqual({ ok: false, error: 'disabled', message: expect.any(String) });
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('reports no-key without calling fetch', async () => {
|
||||||
|
const fn = mockFetch(() => anthropicReply('hi'));
|
||||||
|
const r = await complete({ ...base, apiKey: ' ' }, { system: 's', user: 'u' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error).toBe('no-key');
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complete — request shapes', () => {
|
||||||
|
it('builds the Anthropic body + headers', async () => {
|
||||||
|
const fn = mockFetch(() => anthropicReply('hello'));
|
||||||
|
const r = await complete(base, { system: 'sys', user: 'hi' });
|
||||||
|
expect(r).toEqual({ ok: true, text: 'hello' });
|
||||||
|
const [url, init] = fn.mock.calls[0]!;
|
||||||
|
expect(url).toBe('https://api.anthropic.com/v1/messages');
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect(headers['x-api-key']).toBe('secret-key-123');
|
||||||
|
expect(headers['anthropic-version']).toBe('2023-06-01');
|
||||||
|
expect(headers['anthropic-dangerous-direct-browser-access']).toBe('true');
|
||||||
|
const body = JSON.parse(init.body as string);
|
||||||
|
expect(body.model).toBe('claude-opus-4-8');
|
||||||
|
expect(body.messages).toEqual([{ role: 'user', content: 'hi' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the OpenAI body + headers, with json mode when a schema is given', async () => {
|
||||||
|
const fn = mockFetch(() => openaiReply('{"n":5}'));
|
||||||
|
const cfg: LlmConfig = { ...base, provider: 'openai-compatible', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' };
|
||||||
|
const r = await complete(cfg, { system: 'sys', user: 'hi', schema: z.object({ n: z.number() }) });
|
||||||
|
expect(r).toEqual({ ok: true, data: { n: 5 } });
|
||||||
|
const [url, init] = fn.mock.calls[0]!;
|
||||||
|
expect(url).toBe('https://api.openai.com/v1/chat/completions');
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect(headers.authorization).toBe('Bearer secret-key-123');
|
||||||
|
const body = JSON.parse(init.body as string);
|
||||||
|
expect(body.response_format).toEqual({ type: 'json_object' });
|
||||||
|
expect(body.messages[0].role).toBe('system');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complete — structured output', () => {
|
||||||
|
const schema = z.object({ name: z.string() });
|
||||||
|
it('parses JSON wrapped in markdown fences', async () => {
|
||||||
|
mockFetch(() => anthropicReply('```json\n{"name":"Goblin"}\n```'));
|
||||||
|
const r = await complete(base, { system: 's', user: 'u', schema });
|
||||||
|
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
|
||||||
|
});
|
||||||
|
it('returns parse error on malformed JSON', async () => {
|
||||||
|
mockFetch(() => anthropicReply('not json at all'));
|
||||||
|
const r = await complete(base, { system: 's', user: 'u', schema });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error).toBe('parse');
|
||||||
|
});
|
||||||
|
it('returns parse error when JSON does not match the schema', async () => {
|
||||||
|
mockFetch(() => anthropicReply('{"wrong":1}'));
|
||||||
|
const r = await complete(base, { system: 's', user: 'u', schema });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error).toBe('parse');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complete — errors never leak the key', () => {
|
||||||
|
it('maps non-2xx to http', async () => {
|
||||||
|
mockFetch(() => new Response('rate limited', { status: 429 }));
|
||||||
|
const r = await complete(base, { system: 's', user: 'u' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.error).toBe('http');
|
||||||
|
expect(r.message).not.toContain('secret-key-123');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('maps fetch rejection to network', async () => {
|
||||||
|
mockFetch(() => { throw new TypeError('Failed to fetch'); });
|
||||||
|
const r = await complete(base, { system: 's', user: 'u' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.error).toBe('network');
|
||||||
|
expect(r.message).not.toContain('secret-key-123');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('maps abort/timeout', async () => {
|
||||||
|
mockFetch((_url, init) => new Promise((_resolve, reject) => {
|
||||||
|
init.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
||||||
|
}));
|
||||||
|
const r = await complete(base, { system: 's', user: 'u', timeoutMs: 10 });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error).toBe('timeout');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { CompleteOptions, LlmConfig, LlmErrorKind, LlmResult } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = 30_000;
|
||||||
|
const DEFAULT_MAX_TOKENS = 1024;
|
||||||
|
|
||||||
|
function err<T>(error: LlmErrorKind, message: string): LlmResult<T> {
|
||||||
|
return { ok: false, error, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimSlash(url: string): string {
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonInstruction(): string {
|
||||||
|
return '\n\nRespond with ONLY a single valid JSON object — no prose, no markdown code fences — matching the requested shape exactly.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort extraction of a JSON object from a model response. */
|
||||||
|
function tryParseJson(text: string): unknown {
|
||||||
|
const trimmed = text.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
|
||||||
|
const start = trimmed.indexOf('{');
|
||||||
|
const end = trimmed.lastIndexOf('}');
|
||||||
|
const candidate = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed;
|
||||||
|
try {
|
||||||
|
return JSON.parse(candidate);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeText(res: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
return (await res.text()).slice(0, 300);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Request {
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnthropic(cfg: LlmConfig, opts: CompleteOptions, wantJson: boolean): Request {
|
||||||
|
return {
|
||||||
|
url: `${trimSlash(cfg.baseUrl)}/v1/messages`,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-api-key': cfg.apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'anthropic-dangerous-direct-browser-access': 'true',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
model: cfg.model,
|
||||||
|
max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||||
|
system: wantJson ? opts.system + jsonInstruction() : opts.system,
|
||||||
|
messages: [{ role: 'user', content: opts.user }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenai(cfg: LlmConfig, opts: CompleteOptions, wantJson: boolean): Request {
|
||||||
|
return {
|
||||||
|
url: `${trimSlash(cfg.baseUrl)}/chat/completions`,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
authorization: `Bearer ${cfg.apiKey}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
model: cfg.model,
|
||||||
|
max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: wantJson ? opts.system + jsonInstruction() : opts.system },
|
||||||
|
{ role: 'user', content: opts.user },
|
||||||
|
],
|
||||||
|
...(wantJson ? { response_format: { type: 'json_object' } } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(provider: LlmConfig['provider'], json: unknown): string {
|
||||||
|
const j = json as Record<string, unknown>;
|
||||||
|
if (provider === 'anthropic') {
|
||||||
|
const content = j.content as Array<{ type?: string; text?: string }> | undefined;
|
||||||
|
return content?.find((b) => b.type === 'text')?.text ?? '';
|
||||||
|
}
|
||||||
|
const choices = j.choices as Array<{ message?: { content?: string } }> | undefined;
|
||||||
|
return choices?.[0]?.message?.content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-agnostic completion. Supports Anthropic Messages and OpenAI-compatible
|
||||||
|
* Chat Completions via fetch (no SDK). With `opts.schema`, requests JSON and
|
||||||
|
* validates it — returning `{ok:true,data}`; otherwise returns `{ok:true,text}`.
|
||||||
|
* Never throws and never includes the API key in an error message.
|
||||||
|
*/
|
||||||
|
export async function complete<T>(cfg: LlmConfig, opts: CompleteOptions<T>): Promise<LlmResult<T>> {
|
||||||
|
if (!cfg.enabled) return err('disabled', 'The assistant is turned off in Settings.');
|
||||||
|
if (!cfg.apiKey.trim()) return err('no-key', 'No API key configured in Settings.');
|
||||||
|
if (!cfg.baseUrl.trim()) return err('network', 'No provider base URL configured.');
|
||||||
|
|
||||||
|
const wantJson = !!opts.schema;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const onParentAbort = () => controller.abort();
|
||||||
|
if (opts.signal) opts.signal.addEventListener('abort', onParentAbort);
|
||||||
|
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req = cfg.provider === 'anthropic' ? buildAnthropic(cfg, opts, wantJson) : buildOpenai(cfg, opts, wantJson);
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(req.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: req.headers,
|
||||||
|
body: JSON.stringify(req.body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (controller.signal.aborted) return err('timeout', 'The request timed out.');
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (/content security policy|csp/i.test(msg)) {
|
||||||
|
return err('csp', 'Blocked by content security policy. Check the provider base URL.');
|
||||||
|
}
|
||||||
|
return err('network', `Could not reach the provider. Check the base URL and your connection.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return err('http', `Provider returned HTTP ${res.status}. ${await safeText(res)}`.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = await res.json();
|
||||||
|
} catch {
|
||||||
|
return err('parse', 'Provider returned a non-JSON response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = extractText(cfg.provider, json);
|
||||||
|
if (!wantJson) return { ok: true, text };
|
||||||
|
|
||||||
|
const parsed = tryParseJson(text);
|
||||||
|
if (parsed === undefined) return err('parse', 'The model did not return valid JSON.');
|
||||||
|
const result = opts.schema!.safeParse(parsed);
|
||||||
|
if (!result.success) return err('parse', 'The model output did not match the expected shape.');
|
||||||
|
return { ok: true, data: result.data };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (opts.signal) opts.signal.removeEventListener('abort', onParentAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight reachability/auth check used by the Settings "Test connection" button. */
|
||||||
|
export async function testConnection(cfg: LlmConfig): Promise<LlmResult<string>> {
|
||||||
|
return complete({ ...cfg, enabled: true }, {
|
||||||
|
system: 'You are a connectivity check. Reply with the single word: ok.',
|
||||||
|
user: 'ping',
|
||||||
|
maxTokens: 16,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ZodType } from 'zod';
|
||||||
|
|
||||||
|
export type LlmProvider = 'anthropic' | 'openai-compatible';
|
||||||
|
|
||||||
|
export interface LlmConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
provider: LlmProvider;
|
||||||
|
model: string;
|
||||||
|
/** Base URL without a trailing slash. Anthropic: https://api.anthropic.com.
|
||||||
|
* OpenAI-compatible: e.g. https://api.openai.com/v1, https://openrouter.ai/api/v1,
|
||||||
|
* http://localhost:11434/v1 (Ollama), http://localhost:1234/v1 (LM Studio). */
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
/** When false, the key is kept in memory only and never written to localStorage. */
|
||||||
|
rememberKey: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LlmErrorKind =
|
||||||
|
| 'disabled'
|
||||||
|
| 'no-key'
|
||||||
|
| 'timeout'
|
||||||
|
| 'network'
|
||||||
|
| 'csp'
|
||||||
|
| 'http'
|
||||||
|
| 'parse';
|
||||||
|
|
||||||
|
export type LlmResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: true; text: string }
|
||||||
|
| { ok: false; error: LlmErrorKind; message: string };
|
||||||
|
|
||||||
|
export interface CompleteOptions<T = unknown> {
|
||||||
|
/** System prompt — must lead with the grounding/system constraint. */
|
||||||
|
system: string;
|
||||||
|
/** User message. */
|
||||||
|
user: string;
|
||||||
|
/** When provided, structured JSON output is requested and validated against it. */
|
||||||
|
schema?: ZodType<T>;
|
||||||
|
/** A short name describing the JSON shape (used by providers that require it). */
|
||||||
|
schemaName?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeoutMs?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { LlmConfig } from '@/lib/llm/types';
|
||||||
|
|
||||||
|
interface AssistantState extends LlmConfig {
|
||||||
|
setConfig: (patch: Partial<LlmConfig>) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: LlmConfig = {
|
||||||
|
enabled: false,
|
||||||
|
provider: 'anthropic',
|
||||||
|
model: 'claude-opus-4-8',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
apiKey: '',
|
||||||
|
rememberKey: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAssistantStore = create<AssistantState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
...DEFAULTS,
|
||||||
|
setConfig: (patch) => set(patch),
|
||||||
|
reset: () => set(DEFAULTS),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'ttrpg-assistant',
|
||||||
|
// Persist everything EXCEPT the key when the user opts out of remembering it.
|
||||||
|
// The key then lives in memory for the tab only and is re-entered each launch.
|
||||||
|
partialize: (state) => {
|
||||||
|
const { setConfig, reset, apiKey, rememberKey, ...rest } = state;
|
||||||
|
void setConfig;
|
||||||
|
void reset;
|
||||||
|
return rememberKey ? { ...rest, apiKey, rememberKey } : { ...rest, rememberKey };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Non-hook accessor for code outside React (mirrors the LlmConfig shape). */
|
||||||
|
export function getLlmConfig(): LlmConfig {
|
||||||
|
const s = useAssistantStore.getState();
|
||||||
|
return {
|
||||||
|
enabled: s.enabled,
|
||||||
|
provider: s.provider,
|
||||||
|
model: s.model,
|
||||||
|
baseUrl: s.baseUrl,
|
||||||
|
apiKey: s.apiKey,
|
||||||
|
rememberKey: s.rememberKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/encounter.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/encounter.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
||||||
+5
-1
@@ -6,9 +6,13 @@ import { fileURLToPath, URL } from 'node:url';
|
|||||||
|
|
||||||
// Strict CSP injected at build time only. In dev, Vite's react-refresh needs
|
// Strict CSP injected at build time only. In dev, Vite's react-refresh needs
|
||||||
// inline scripts, so we leave the placeholder empty there.
|
// inline scripts, so we leave the placeholder empty there.
|
||||||
|
// connect-src allows any HTTPS endpoint plus localhost so the bring-your-own-key
|
||||||
|
// assistant can reach cloud providers (Anthropic/OpenAI/OpenRouter/…) and local
|
||||||
|
// models (Ollama/LM Studio). Everything else stays locked to 'self'.
|
||||||
const PROD_CSP =
|
const PROD_CSP =
|
||||||
"default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; " +
|
"default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; " +
|
||||||
"script-src 'self'; connect-src 'self'; font-src 'self' data:; worker-src 'self' blob:; " +
|
"script-src 'self'; connect-src 'self' https: http://localhost:* http://127.0.0.1:*; " +
|
||||||
|
"font-src 'self' data:; worker-src 'self' blob:; " +
|
||||||
"manifest-src 'self'; object-src 'none'; base-uri 'self'";
|
"manifest-src 'self'; object-src 'none'; base-uri 'self'";
|
||||||
|
|
||||||
function cspPlugin() {
|
function cspPlugin() {
|
||||||
|
|||||||
Reference in New Issue
Block a user