Add power-user AI Chat page; centralize demo-key model pinning

AI Chat is a plain scratchpad for talking to a model directly — no story
context, scripts or world state — for poking at models, prompts and endpoints
without starting an adventure. Power users only: the router 404s (rather than
403s) for everyone else and the nav link is hidden. The conversation lives in
localStorage, so there's no new table or migration.

is_power_user() now also returns True in local mode: it's the operator's own
machine and their own key, the same reasoning that makes the provider debug log
local-only.

Alongside that, the rule keeping the shared demo key off paid models now lives
in exactly one place. It had been duplicated into the chat router, which is how
one copy eventually drifts:

- resolve_provider_config() takes an optional model_override and is the only
  place the whitelist is applied, so turns, AI Chat and the connection test all
  inherit it. An override is a per-request preference, never a grant.
- ProviderConfig.__post_init__ refuses to exist when api_key is the demo key
  and the model isn't whitelisted. It keys on the key itself rather than the
  using_demo flag, so a mislabelled config can't slip past, and it raises so a
  future path that bypasses the resolver fails loudly instead of billing.
- The demo branch still pins endpoint_url too — a user-controlled endpoint
  would leak the key itself, which is worse than spending it.

Provider gained chat(messages, ...) beside generate(), both delegating to a
shared _stream(url, body); completion-mode endpoints get the messages flattened
into a labelled transcript. Settings' /models fetch moved to
list_endpoint_models() and is shared with /api/chat/config.

Tests: 10 new in tests/test_chat.py (70 total). These deliberately do not stub
resolve_provider_config — the point is to exercise the real BYOK-vs-demo
decision and assert on what the provider actually received: off-whitelist
override pinned, off-whitelist Settings.model pinned, redirected endpoint
pinned, BYOK passed through untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FGY1yvzSeKgTtRfeVtDmx
This commit is contained in:
parththakkar106
2026-07-26 20:16:56 +05:30
co-authored by Claude Opus 5
parent 57c24a07d8
commit 1dd31086c1
17 changed files with 969 additions and 20 deletions
+6
View File
@@ -58,6 +58,12 @@ export default function App() {
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Settings
</NavLink>
{/* Power-user tooling, not part of the game — hidden for everyone else. */}
{me?.power_user && (
<NavLink to="/chat" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
AI Chat
</NavLink>
)}
{me?.multi_user && (
<div className="nav-account">
{me.is_guest ? (
+4
View File
@@ -138,6 +138,10 @@ export const api = {
exportStoryCards: (owner) => request(`/story-cards/export?${new URLSearchParams(owner)}`),
importStoryCards: (payload) => request('/story-cards/import', { method: 'POST', body: JSON.stringify(payload) }),
// AI Chat (power users only — 404 for everyone else)
getChatConfig: () => request('/chat/config'),
chatStream: (payload, handlers, signal) => streamSSE('/chat/stream', payload, handlers, signal),
// Debug
getDebugRequests: () => request('/debug/requests'),
+99
View File
@@ -1624,6 +1624,98 @@ button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: aut
.icon-row button.active { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); }
.art-hint { font-size: 0.76rem; color: var(--text-dim); line-height: 1.45; margin: 0; }
/* ---------- AI Chat (power users) ----------
A plain chat scratchpad, styled as a transcript rather than as the story
page: UI font, role labels, and a sticky composer at the bottom. */
.chat-page { max-width: 820px; display: flex; flex-direction: column; }
.chat-meta {
font-size: 0.78rem;
margin: -10px 0 16px;
word-break: break-word;
}
.chat-options {
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px 4px;
margin-bottom: 18px;
background: var(--bg-panel);
}
.chat-transcript { flex: 1; display: flex; flex-direction: column; gap: 16px; padding-bottom: 20px; }
.chat-msg {
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 14px 12px;
background: var(--bg-panel);
}
.chat-msg.user { border-left: 3px solid var(--player); background: var(--bg-input); }
.chat-msg.assistant { border-left: 3px solid var(--accent-dim); }
.chat-msg-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.chat-msg .chat-role { color: var(--accent-dim); font-weight: 600; }
.chat-msg.user .chat-role { color: var(--player); }
.chat-model-tag { text-transform: none; letter-spacing: 0; font-size: 0.74rem; }
.chat-msg-actions {
margin-left: auto;
display: flex;
gap: 12px;
opacity: 0;
transition: opacity 0.15s;
}
.chat-msg:hover .chat-msg-actions, .chat-msg:focus-within .chat-msg-actions { opacity: 1; }
.chat-msg-body {
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.6;
font-size: 0.95rem;
}
.chat-msg .cursor {
color: var(--accent-bright);
text-shadow: 0 0 8px var(--accent-glow);
animation: blink 1s steps(1) infinite;
}
/* Same collapsed thinking block as the story page, re-scoped for the transcript. */
.chat-msg .reasoning { margin: 0 0 8px; font-size: 0.82rem; line-height: 1.5; color: var(--text-dim); }
.chat-msg .reasoning summary { cursor: pointer; user-select: none; opacity: 0.8; }
.chat-msg .reasoning summary:hover { opacity: 1; }
.chat-msg .reasoning .reasoning-text {
margin-top: 6px;
padding: 8px 12px;
border-left: 2px solid var(--border);
white-space: pre-wrap;
opacity: 0.85;
max-height: 260px;
overflow-y: auto;
}
.chat-composer {
position: sticky;
bottom: 0;
display: flex;
gap: 10px;
align-items: flex-end;
padding: 14px 0;
background: linear-gradient(to top, var(--bg) 55%, transparent);
}
.chat-composer textarea {
flex: 1;
min-height: 44px;
max-height: 220px;
resize: none;
overflow-y: auto;
}
.chat-composer button { flex: none; height: 40px; }
@media (prefers-reduced-motion: reduce) {
.enter, .skeleton-card, .sk, .toast, .thinking i,
.story .action, .page {
@@ -1793,4 +1885,11 @@ button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: aut
font-size: 2.5rem;
padding: 4px 8px 0 0;
}
/* ---------- AI Chat ---------- */
/* No hover on touch, so the per-message copy/delete links stay visible. */
.chat-msg-actions { opacity: 1; }
.chat-msg { padding: 9px 11px 11px; }
.chat-composer { padding: 10px 0 14px; }
.chat-composer textarea { max-height: 140px; }
}
+3
View File
@@ -10,6 +10,7 @@ import Play from './pages/Play.jsx'
import Scripts from './pages/Scripts.jsx'
import ScriptEditor from './pages/ScriptEditor.jsx'
import Settings from './pages/Settings.jsx'
import Chat from './pages/Chat.jsx'
import './index.css'
const router = createBrowserRouter([
@@ -25,6 +26,8 @@ const router = createBrowserRouter([
{ path: 'scripts', element: <Scripts /> },
{ path: 'scripts/:id', element: <ScriptEditor /> },
{ path: 'settings', element: <Settings /> },
// Power users only — the page redirects home and the API 404s otherwise.
{ path: 'chat', element: <Chat /> },
],
},
])
+326
View File
@@ -0,0 +1,326 @@
/* AI Chat — a plain scratchpad for talking to a model, with none of the game's
context assembly in the way. Power users only (the backend 404s the routes
for everyone else, and the nav link is hidden).
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 { useNavigate, useOutletContext } from 'react-router-dom'
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 (
<details className="reasoning" open={streaming || undefined}>
<summary>💭 Reasoning{streaming ? '…' : ''}</summary>
<div className="reasoning-text">{text}</div>
</details>
)
}
function Message({ message, onDelete }) {
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard?.writeText(message.content).then(
() => { setCopied(true); setTimeout(() => setCopied(false), 1500) },
() => {},
)
}
return (
<div className={`chat-msg ${message.role}`}>
<div className="chat-msg-head">
<span className="chat-role">{ROLE_LABEL[message.role] || message.role}</span>
{message.model && <span className="dim chat-model-tag">{message.model}</span>}
<span className="chat-msg-actions">
<button className="linklike" onClick={copy}>{copied ? 'copied' : 'copy'}</button>
<button className="linklike" onClick={onDelete}>delete</button>
</span>
</div>
<ReasoningBlock text={message.reasoning} />
<div className="chat-msg-body">{message.content}</div>
</div>
)
}
export default function Chat() {
const { me } = useOutletContext() ?? {}
const navigate = useNavigate()
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)
// me is null until /auth/me resolves; only bounce once we know.
useEffect(() => {
if (me && !me.power_user) navigate('/', { replace: true })
}, [me, navigate])
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()
}
}
if (me && !me.power_user) return null
const waitingForFirstToken = streaming === '' && reasoningStream === null
const canRegenerate = !busy && messages.some((m) => m.role === 'user')
return (
<div className="page chat-page">
<div className="page-header">
<h1>AI Chat</h1>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button className="linklike" onClick={() => setShowOptions((o) => !o)}>
{showOptions ? 'hide options' : 'options'}
</button>
<button onClick={regenerate} disabled={!canRegenerate}>Regenerate</button>
<button className="danger" onClick={clear} disabled={busy || !messages.length}>Clear</button>
</div>
</div>
<div className="chat-meta dim">
{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…'}
</div>
{showOptions && (
<div className="chat-options">
<label className="field">
<span className="label">System prompt (sent first, every turn — empty = none)</span>
<textarea rows={3} value={system} placeholder="You are a helpful assistant."
onChange={(e) => setSystem(e.target.value)} />
</label>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
<label className="field" style={{ flex: '2 1 240px' }}>
<span className="label">Model {config?.using_demo ? '(demo whitelist)' : '(empty = Settings default)'}</span>
<input type="text" list="chat-models" value={model} placeholder={config?.model || 'model slug'}
onChange={(e) => setModel(e.target.value)} />
<datalist id="chat-models">
{(config?.models || []).map((m) => <option key={m} value={m} />)}
</datalist>
</label>
<label className="field" style={{ flex: '1 1 110px' }}>
<span className="label">Temperature</span>
<input type="number" step="0.1" min="0" max="5" value={temperature}
placeholder={config?.temperature ?? ''}
onChange={(e) => setTemperature(e.target.value)} />
</label>
<label className="field" style={{ flex: '1 1 130px' }}>
<span className="label">Max tokens</span>
<input type="number" min="1" value={maxTokens}
placeholder={config?.max_tokens ?? ''}
onChange={(e) => setMaxTokens(e.target.value)} />
</label>
</div>
{config?.models_error && (
<div className="dim" style={{ fontSize: '0.82rem' }}>
Couldn't list models from the endpoint: {config.models_error}
</div>
)}
</div>
)}
<div className="chat-transcript">
{!messages.length && streaming === null && (
<div className="empty">
Nothing here yet — no story, no scripts, no world state. Just you and the model.
</div>
)}
{messages.map((m, i) => (
<Message key={i} message={m} onDelete={() => deleteAt(i)} />
))}
{streaming !== null && (
<div className="chat-msg assistant">
<div className="chat-msg-head">
<span className="chat-role">AI</span>
<span className="dim chat-model-tag">{model.trim() || config?.model}</span>
</div>
<ReasoningBlock text={reasoningStream} streaming />
{waitingForFirstToken
? <div className="thinking" role="status"><i /><i /><i /><span>Thinking</span></div>
: <div className="chat-msg-body">{streaming}<span className="cursor">▋</span></div>}
</div>
)}
</div>
<div className="chat-composer">
<textarea ref={inputRef} rows={1} value={input} onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown} placeholder="Message the model… (Enter to send, Shift+Enter for a new line)" />
{busy
? <button className="danger" onClick={stop}>Stop</button>
: <button className="primary" onClick={submit} disabled={!input.trim()}>Send</button>}
</div>
</div>
)
}