fix: chat page key input + layout height; log reasoning_content in completions
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user