Files
ttrpg_manager/src/features/settings/AssistantSettings.tsx
T
NilsBriggen 895807d6c9 Polish sprint 2/2: layouts, UX consistency, pf2e conditions
Layout:
- CombatantRow: action controls (damage/AC/move/remove) grouped into one
  right-aligned wrapper so they wrap together instead of scattering on narrow
  widths.
- top bar: the campaign switcher now shrinks/truncates instead of overflowing
  on tablet widths.
- character sheet header: stat coins go full-width grid on mobile; the name
  truncates and scales down on small screens.

UX consistency:
- player Cast and resource Spend/Regain no longer fail silently — Cast shows the
  reason, resource −/+ disable at their bounds.
- the AI encounter builder now catches load failures and reports them; level-up
  advisor shows the human error message, not the error-kind enum.
- section headers standardized on the .smallcaps design token across 16 files
  (replacing an ad-hoc uppercase class).
- player HP bar uses the shared Meter primitive.

pf2e depth: condition-effects table gains drained, dazzled, enfeebled, fatigued.
FeatCard: dropped a dead text-xl wrapper left from the emoji purge.

Left as documented (functional, off-theme, refactor-risky): the native
confirm/prompt in Settings cloud-conflict and the session-password flow.

Gate: 293 unit + 35 e2e + 2 realtime; tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:38:24 +02:00

115 lines
4.6 KiB
TypeScript

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 smallcaps">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>
);
}