diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 919b892..7e377ee 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -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); +}); diff --git a/src/features/settings/AssistantSettings.tsx b/src/features/settings/AssistantSettings.tsx new file mode 100644 index 0000000..c240d42 --- /dev/null +++ b/src/features/settings/AssistantSettings.tsx @@ -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 = { + 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 ( +
+

Assistant (AI)

+

+ 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. +

+ +
+ + + + + + + + + + setConfig({ model: e.target.value })} placeholder="model id" aria-label="Model" /> + + + + setConfig({ baseUrl: e.target.value })} placeholder="https://…" aria-label="Base URL" /> + + + + setConfig({ apiKey: e.target.value })} + placeholder="sk-…" + autoComplete="off" + aria-label="API key" + /> + + + + + +
+ +
+ + {status && ( + + {status.text} + + )} +
+
+ ); +} diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index 98f5fe8..768a4fa 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -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 &&

{msg}

} + +

Danger zone

Permanently delete all campaigns and data on this device.

diff --git a/src/lib/llm/client.test.ts b/src/lib/llm/client.test.ts new file mode 100644 index 0000000..e75a5c8 --- /dev/null +++ b/src/lib/llm/client.test.ts @@ -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) { + 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; + 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; + 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'); + }); +}); diff --git a/src/lib/llm/client.ts b/src/lib/llm/client.ts new file mode 100644 index 0000000..4b8f914 --- /dev/null +++ b/src/lib/llm/client.ts @@ -0,0 +1,162 @@ +import type { CompleteOptions, LlmConfig, LlmErrorKind, LlmResult } from './types'; + +const DEFAULT_TIMEOUT = 30_000; +const DEFAULT_MAX_TOKENS = 1024; + +function err(error: LlmErrorKind, message: string): LlmResult { + 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 { + try { + return (await res.text()).slice(0, 300); + } catch { + return ''; + } +} + +interface Request { + url: string; + headers: Record; + 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; + 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(cfg: LlmConfig, opts: CompleteOptions): Promise> { + 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> { + return complete({ ...cfg, enabled: true }, { + system: 'You are a connectivity check. Reply with the single word: ok.', + user: 'ping', + maxTokens: 16, + timeoutMs: 15_000, + }); +} diff --git a/src/lib/llm/types.ts b/src/lib/llm/types.ts new file mode 100644 index 0000000..67fb822 --- /dev/null +++ b/src/lib/llm/types.ts @@ -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 = + | { ok: true; data: T } + | { ok: true; text: string } + | { ok: false; error: LlmErrorKind; message: string }; + +export interface CompleteOptions { + /** 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; + /** A short name describing the JSON shape (used by providers that require it). */ + schemaName?: string; + signal?: AbortSignal; + timeoutMs?: number; + maxTokens?: number; +} diff --git a/src/stores/assistantStore.ts b/src/stores/assistantStore.ts new file mode 100644 index 0000000..31ef783 --- /dev/null +++ b/src/stores/assistantStore.ts @@ -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) => 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()( + 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, + }; +} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index e043527..915d0cc 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 3701589..d4717ab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,9 +6,13 @@ import { fileURLToPath, URL } from 'node:url'; // Strict CSP injected at build time only. In dev, Vite's react-refresh needs // 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 = "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'"; function cspPlugin() {