fix: chat page key input + layout height; log reasoning_content in completions

This commit is contained in:
2026-06-29 12:08:41 +02:00
parent ef3e894484
commit 1f5469bce1
3 changed files with 115 additions and 116 deletions

View File

@@ -146,8 +146,10 @@ export default function Layout({ currentUser }: { currentUser: CurrentUser | nul
</header>
{/* Content */}
<main style={{ flex: 1, overflowY: 'auto', padding: '24px 28px' }}>
<Outlet />
<main style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px', display: 'flex', flexDirection: 'column' }}>
<Outlet />
</div>
</main>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { getToken } from '../lib/api'
const CHAT_KEY_STORAGE = 'chat_api_key'
interface Message {
role: 'user' | 'assistant'
@@ -7,12 +8,14 @@ interface Message {
pending?: boolean
}
interface ModelOption {
id: string
owned_by: string
}
interface ModelOption { id: string }
function getChatKey() { return localStorage.getItem(CHAT_KEY_STORAGE) ?? '' }
function setChatKey(k: string) { localStorage.setItem(CHAT_KEY_STORAGE, k) }
export default function Chat() {
const [apiKey, setApiKey] = useState(getChatKey)
const [keyInput, setKeyInput] = useState('')
const [models, setModels] = useState<ModelOption[]>([])
const [model, setModel] = useState('')
const [messages, setMessages] = useState<Message[]>([])
@@ -20,24 +23,37 @@ export default function Chat() {
const [streaming, setStreaming] = useState(false)
const [error, setError] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
fetch('/v1/models', { headers: { Authorization: `Bearer ${getToken()}` } })
.then(r => r.json())
if (!apiKey) return
fetch('/v1/models', { headers: { Authorization: `Bearer ${apiKey}` } })
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(d => {
const list: ModelOption[] = d.data ?? []
setModels(list)
if (list.length > 0) setModel(list[0].id)
setError('')
})
.catch(() => {})
}, [])
.catch(() => {
setError('Could not load models — check your API key.')
setModels([])
})
}, [apiKey])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
function saveKey() {
const k = keyInput.trim()
if (!k) return
setChatKey(k)
setApiKey(k)
setKeyInput('')
setError('')
}
async function send() {
const text = input.trim()
if (!text || streaming || !model) return
@@ -57,7 +73,7 @@ export default function Chat() {
signal: abort.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`,
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
@@ -67,8 +83,7 @@ export default function Chat() {
})
if (!res.ok) {
const msg = await res.text().catch(() => res.statusText)
throw new Error(msg)
throw new Error(await res.text().catch(() => res.statusText))
}
const reader = res.body!.getReader()
@@ -97,15 +112,14 @@ export default function Chat() {
}
}
if (!reply) reply = '(empty response)'
setMessages([...next, { role: 'assistant', content: reply }])
setMessages([...next, { role: 'assistant', content: reply || '(empty response)' }])
} catch (e: any) {
if (e.name === 'AbortError') {
setMessages(prev => prev.map((m, i) =>
i === prev.length - 1 ? { ...m, pending: false } : m))
} else {
if (e.name !== 'AbortError') {
setError(e.message)
setMessages(next)
} else {
setMessages(prev => prev.map((m, i) =>
i === prev.length - 1 ? { ...m, pending: false } : m))
}
} finally {
setStreaming(false)
@@ -113,64 +127,70 @@ export default function Chat() {
}
}
function stop() {
abortRef.current?.abort()
}
function handleKey(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
}
const empty = messages.length === 0
// ── Key setup screen ────────────────────────────────────────────────────────
if (!apiKey) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 380, background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, padding: 24 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff', marginBottom: 6 }}>Enter an API key to use Test Chat</div>
<div style={{ fontSize: 12, color: 'rgba(237,237,240,0.4)', marginBottom: 16 }}>
Create a key on the API Keys page, then paste it here. It's stored in your browser only.
</div>
<input
type="password" value={keyInput} onChange={e => setKeyInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveKey()}
placeholder="sk-…" autoFocus
style={{
width: '100%', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.09)',
borderRadius: 6, padding: '8px 12px', fontSize: 13, color: '#ededf0', outline: 'none',
fontFamily: "'DM Mono',monospace", marginBottom: 10, boxSizing: 'border-box',
}}
/>
<button onClick={saveKey} disabled={!keyInput.trim()} style={{
width: '100%', background: '#7c3aed', border: 'none', borderRadius: 7,
padding: '9px 0', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer',
opacity: keyInput.trim() ? 1 : 0.4,
}}>Continue</button>
</div>
</div>
)
}
// ── Chat UI ─────────────────────────────────────────────────────────────────
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', maxWidth: 780, margin: '0 auto' }}>
{/* Model selector */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16, flexShrink: 0 }}>
<select
value={model}
onChange={e => setModel(e.target.value)}
disabled={streaming}
style={{
background: '#17171c', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 7,
padding: '7px 12px', fontSize: 13, color: '#ededf0', cursor: 'pointer', outline: 'none',
fontFamily: "'DM Mono',monospace", maxWidth: 380,
}}
>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, maxWidth: 780, width: '100%', margin: '0 auto' }}>
{/* Toolbar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14, flexShrink: 0 }}>
<select value={model} onChange={e => setModel(e.target.value)} disabled={streaming} style={{
flex: 1, background: '#17171c', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 7,
padding: '7px 12px', fontSize: 13, color: '#ededf0', cursor: 'pointer', outline: 'none',
fontFamily: "'DM Mono',monospace",
}}>
{models.length === 0
? <option value="">No models available</option>
: models.map(m => <option key={m.id} value={m.id}>{m.id}</option>)
}
</select>
{messages.length > 0 && (
<button
onClick={() => { setMessages([]); setError('') }}
disabled={streaming}
style={{
background: 'transparent', border: '1px solid rgba(255,255,255,0.09)',
borderRadius: 7, padding: '7px 12px', fontSize: 12,
color: 'rgba(237,237,240,0.4)', cursor: 'pointer',
}}
>
Clear
</button>
<button onClick={() => { setMessages([]); setError('') }} disabled={streaming} style={{
background: 'transparent', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 7,
padding: '7px 12px', fontSize: 12, color: 'rgba(237,237,240,0.4)', cursor: 'pointer',
}}>Clear</button>
)}
<button onClick={() => { setChatKey(''); setApiKey(''); setModels([]); setMessages([]) }} style={{
background: 'transparent', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 7,
padding: '7px 10px', fontSize: 11, color: 'rgba(237,237,240,0.25)', cursor: 'pointer',
}} title="Change API key">Key</button>
</div>
{/* Messages */}
<div style={{
flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column',
gap: 16, paddingBottom: 16,
}}>
{empty && (
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 8,
color: 'rgba(237,237,240,0.2)', fontSize: 13,
}}>
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14, paddingBottom: 14, minHeight: 0 }}>
{messages.length === 0 && !error && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'rgba(237,237,240,0.2)', fontSize: 13 }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
@@ -179,55 +199,35 @@ export default function Chat() {
)}
{messages.map((m, i) => (
<div key={i} style={{
display: 'flex',
flexDirection: m.role === 'user' ? 'row-reverse' : 'row',
gap: 10, alignItems: 'flex-start',
}}>
<div key={i} style={{ display: 'flex', flexDirection: m.role === 'user' ? 'row-reverse' : 'row', gap: 10, alignItems: 'flex-start' }}>
<div style={{
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
background: m.role === 'user'
? 'linear-gradient(135deg,#7c3aed,#2563eb)'
: 'rgba(255,255,255,0.07)',
background: m.role === 'user' ? 'linear-gradient(135deg,#7c3aed,#2563eb)' : 'rgba(255,255,255,0.07)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 600, color: 'rgba(237,237,240,0.7)',
}}>
{m.role === 'user' ? 'U' : 'AI'}
</div>
}}>{m.role === 'user' ? 'U' : 'AI'}</div>
<div style={{
maxWidth: '80%',
background: m.role === 'user' ? 'rgba(124,58,237,0.15)' : '#17171c',
border: `1px solid ${m.role === 'user' ? 'rgba(124,58,237,0.25)' : 'rgba(255,255,255,0.07)'}`,
borderRadius: m.role === 'user' ? '12px 4px 12px 12px' : '4px 12px 12px 12px',
padding: '10px 14px',
fontSize: 13.5, lineHeight: 1.6, color: '#ededf0',
padding: '10px 14px', fontSize: 13.5, lineHeight: 1.6, color: '#ededf0',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{m.content || (m.pending
? <span style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
{[0,1,2].map(j => (
<span key={j} style={{
width: 5, height: 5, borderRadius: '50%',
background: 'rgba(237,237,240,0.3)',
animation: `pulse 1s ${j * 0.2}s infinite`,
}}/>
))}
? <span style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
{[0,1,2].map(j => <span key={j} style={{ width: 5, height: 5, borderRadius: '50%', background: 'rgba(237,237,240,0.3)', display: 'inline-block' }}/>)}
</span>
: ''
)}
: '')}
</div>
</div>
))}
{error && (
<div style={{
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)',
borderRadius: 8, padding: '10px 14px', fontSize: 12.5, color: '#f87171',
}}>
<div style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: 8, padding: '10px 14px', fontSize: 12.5, color: '#f87171' }}>
{error}
</div>
)}
<div ref={bottomRef} />
</div>
@@ -235,10 +235,9 @@ export default function Chat() {
<div style={{
flexShrink: 0, display: 'flex', gap: 8, alignItems: 'flex-end',
background: '#17171c', border: '1px solid rgba(255,255,255,0.09)',
borderRadius: 10, padding: '10px 10px 10px 14px',
borderRadius: 10, padding: '10px 10px 10px 14px', marginTop: 4,
}}>
<textarea
ref={textareaRef}
value={input}
onChange={e => {
setInput(e.target.value)
@@ -252,26 +251,12 @@ export default function Chat() {
style={{
flex: 1, background: 'transparent', border: 'none', outline: 'none',
resize: 'none', fontSize: 13.5, color: '#ededf0', lineHeight: 1.55,
fontFamily: 'inherit', padding: 0, minHeight: 24, maxHeight: 160,
overflowY: 'auto',
fontFamily: 'inherit', padding: 0, minHeight: 24, maxHeight: 160, overflowY: 'auto',
}}
/>
{streaming
? (
<button onClick={stop} style={{
background: 'rgba(239,68,68,0.15)', border: '1px solid rgba(239,68,68,0.25)',
borderRadius: 7, padding: '6px 12px', fontSize: 12,
color: '#f87171', cursor: 'pointer', flexShrink: 0,
}}>Stop</button>
)
: (
<button onClick={send} disabled={!input.trim() || !model} style={{
background: '#7c3aed', border: 'none', borderRadius: 7,
padding: '6px 14px', fontSize: 13, fontWeight: 500,
color: 'white', cursor: 'pointer', flexShrink: 0,
opacity: !input.trim() || !model ? 0.4 : 1,
}}>Send</button>
)
? <button onClick={() => abortRef.current?.abort()} style={{ background: 'rgba(239,68,68,0.15)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: 7, padding: '6px 12px', fontSize: 12, color: '#f87171', cursor: 'pointer', flexShrink: 0 }}>Stop</button>
: <button onClick={send} disabled={!input.trim() || !model} style={{ background: '#7c3aed', border: 'none', borderRadius: 7, padding: '6px 14px', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer', flexShrink: 0, opacity: !input.trim() || !model ? 0.4 : 1 }}>Send</button>
}
</div>
</div>

View File

@@ -209,34 +209,46 @@ func marshalMessages(v any) json.RawMessage {
return b
}
// extractCompletion pulls the first choice's message content from a non-streaming response body.
// extractCompletion pulls the first choice's message content from a non-streaming
// response body. reasoning_content (Qwen3/DeepSeek-R1 thinking) is prepended when present.
func extractCompletion(body []byte) string {
var resp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(body, &resp); err != nil || len(resp.Choices) == 0 {
return ""
}
return resp.Choices[0].Message.Content
m := resp.Choices[0].Message
if m.ReasoningContent != "" {
return "<think>\n" + m.ReasoningContent + "\n</think>\n" + m.Content
}
return m.Content
}
// extractDelta pulls the assistant content delta from a streaming SSE data chunk.
// reasoning_content deltas are wrapped in <think> tags on first/last occurrence.
func extractDelta(data []byte) string {
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &chunk); err != nil || len(chunk.Choices) == 0 {
return ""
}
return chunk.Choices[0].Delta.Content
d := chunk.Choices[0].Delta
if d.ReasoningContent != "" {
return d.ReasoningContent
}
return d.Content
}
func clientIP(r *http.Request) string {