895807d6c9
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>
115 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|