/* AI Chat — a plain scratchpad for talking to the configured model, with none of the game's context assembly in the way. Useful for checking a model or a prompt without starting an adventure. Deliberately client-side: the conversation lives in localStorage, not the database. Nothing here is part of an adventure, so there's nothing worth a migration — and a refresh still keeps what you were poking at. */ import { useCallback, useEffect, useRef, useState } from 'react' import { api } from '../api' import { useToast } from '../components' const STORAGE_KEY = 'aidnd.chat.v1' function load() { try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') return { messages: Array.isArray(saved.messages) ? saved.messages : [], system: typeof saved.system === 'string' ? saved.system : '', model: typeof saved.model === 'string' ? saved.model : '', temperature: saved.temperature ?? '', maxTokens: saved.maxTokens ?? '', } } catch { return { messages: [], system: '', model: '', temperature: '', maxTokens: '' } } } const ROLE_LABEL = { user: 'You', assistant: 'AI', system: 'System' } function ReasoningBlock({ text, streaming }) { if (!text) return null return (
💭 Reasoning{streaming ? '…' : ''}
{text}
) } function Message({ message, onDelete }) { const [copied, setCopied] = useState(false) const copy = () => { navigator.clipboard?.writeText(message.content).then( () => { setCopied(true); setTimeout(() => setCopied(false), 1500) }, () => {}, ) } return (
{ROLE_LABEL[message.role] || message.role} {message.model && {message.model}}
{message.content}
) } export default function Chat() { const toast = useToast() const initial = useRef(load()).current const [messages, setMessages] = useState(initial.messages) const [system, setSystem] = useState(initial.system) const [model, setModel] = useState(initial.model) const [temperature, setTemperature] = useState(initial.temperature) const [maxTokens, setMaxTokens] = useState(initial.maxTokens) const [input, setInput] = useState('') const [config, setConfig] = useState(null) const [showOptions, setShowOptions] = useState(false) // Streaming reply in progress: null when idle, else the text so far ('' before // the first token). `busy` covers the whole request, including the wait. const [streaming, setStreaming] = useState(null) const [reasoningStream, setReasoningStream] = useState(null) const [busy, setBusy] = useState(false) const abortRef = useRef(null) const inputRef = useRef(null) const pinnedRef = useRef(true) useEffect(() => { api.getChatConfig().then(setConfig).catch(() => setConfig(null)) }, []) useEffect(() => { localStorage.setItem( STORAGE_KEY, JSON.stringify({ messages, system, model, temperature, maxTokens }), ) }, [messages, system, model, temperature, maxTokens]) // Grow the composer with its content (CSS caps the height, then it scrolls). useEffect(() => { const el = inputRef.current if (!el) return el.style.height = 'auto' el.style.height = `${el.scrollHeight}px` }, [input]) useEffect(() => { const onScroll = () => { pinnedRef.current = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120 } window.addEventListener('scroll', onScroll, { passive: true }) return () => window.removeEventListener('scroll', onScroll) }, []) useEffect(() => { if (pinnedRef.current) window.scrollTo({ top: document.documentElement.scrollHeight }) }, [messages, streaming, reasoningStream]) // Abort any in-flight stream when leaving the page. useEffect(() => () => abortRef.current?.abort(), []) const send = useCallback(async (history) => { const controller = new AbortController() abortRef.current = controller setBusy(true) setStreaming('') setReasoningStream(null) pinnedRef.current = true const payload = { messages: [ ...(system.trim() ? [{ role: 'system', content: system.trim() }] : []), ...history.map(({ role, content }) => ({ role, content })), ], } if (model.trim()) payload.model = model.trim() if (temperature !== '' && temperature !== null) payload.temperature = Number(temperature) if (maxTokens !== '' && maxTokens !== null) payload.max_tokens = Number(maxTokens) let reasoning = '' try { await api.chatStream(payload, (event) => { if (event.type === 'chunk') { setStreaming((prev) => (prev ?? '') + event.text) } else if (event.type === 'reasoning') { reasoning += event.text setReasoningStream((prev) => (prev ?? '') + event.text) } else if (event.type === 'note') { toast(event.detail) } else if (event.type === 'done') { setMessages((prev) => [...prev, { role: 'assistant', content: event.text, reasoning: event.reasoning || reasoning || undefined, model: event.model, }]) } else if (event.type === 'error') { toast(event.detail, 'error') } }, controller.signal) } catch (err) { if (err.name !== 'AbortError') toast(err.message, 'error') } finally { abortRef.current = null setBusy(false) setStreaming(null) setReasoningStream(null) } }, [system, model, temperature, maxTokens, toast]) const submit = () => { const text = input.trim() if (!text || busy) return const history = [...messages, { role: 'user', content: text }] setMessages(history) setInput('') send(history) } const regenerate = () => { if (busy) return // Drop trailing assistant replies and re-send from the last user message. let history = [...messages] while (history.length && history[history.length - 1].role === 'assistant') history.pop() if (!history.length) return setMessages(history) send(history) } const stop = () => { abortRef.current?.abort() // Keep whatever streamed in — a cut-off reply is often the thing you wanted. const partial = streaming?.trim() if (partial) { setMessages((prev) => [...prev, { role: 'assistant', content: partial, reasoning: reasoningStream || undefined, model: config?.model, stopped: true, }]) } } const clear = () => { if (busy || !messages.length) return setMessages([]) toast('Conversation cleared') } const deleteAt = (index) => setMessages((prev) => prev.filter((_, i) => i !== index)) const onKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() submit() } } const waitingForFirstToken = streaming === '' && reasoningStream === null const canRegenerate = !busy && messages.some((m) => m.role === 'user') return (

AI Chat

{config ? <> {/* The override wins when set, so show what a send would actually use. */} {model.trim() || config.model || '(no model set)'} · {config.endpoint_url} {config.using_demo && ' · shared demo key (whitelisted models only)'} {config.api_mode === 'completion' && ' · completion mode (messages are flattened)'} : 'Loading provider config…'}
{showOptions && (