Files
light_chat/src/components/ChatWindow.tsx
T
sleepy 36eebafeb3 style: darken purple accent colors to 1/3 brightness
- Accent: #5b21b6 → #1e0b3d
- Accent-hover: #6d28d9 → #240d48
- Accent-light: #8b5cf6 → #2e1f52

Very dark purple now used for:
- Assistant name in chat bubbles
- STREAMING badge background
- Code block language label background
- Toggle button active state
- Session drawer section headers
- Input focus border
- Button hover state background

All 52 tests passing, build successful
2026-02-26 02:15:26 +01:00

440 lines
13 KiB
TypeScript

import { useState, useRef, useEffect } from 'react'
import { sendMessage, sendMessageStream } from '../utils/llmApi'
import type { Session, Config } from '../services/sessionService'
import { MarkdownContent } from './MarkdownContent'
interface ChatWindowProps {
session: Session | null
config: Config
onAddMessage: (role: 'user' | 'assistant', content: string) => void
}
/**
* ChatWindow - Main chat interface
*/
export function ChatWindow({ session, config, onAddMessage }: ChatWindowProps) {
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [streamingContent, setStreamingContent] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const scrollToBottom = () => {
if (messagesEndRef.current && typeof messagesEndRef.current.scrollIntoView === 'function') {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}
useEffect(() => {
scrollToBottom()
}, [session?.messages, streamingContent])
// Cleanup abort controller on unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim() || isLoading) return
if (!config.endpoint) {
setError('Please configure API endpoint in the drawer')
return
}
const userMessage = input.trim()
setInput('')
setError(null)
setStreamingContent('')
// Add user message
onAddMessage('user', userMessage)
setIsLoading(true)
// Create abort controller for this request
abortControllerRef.current = new AbortController()
try {
// Prepare messages for API
const apiMessages = session?.messages || []
if (config.streaming) {
// Streaming mode
let fullContent = ''
await sendMessageStream(
config.endpoint,
userMessage,
apiMessages,
config.systemPrompt,
config.model || 'local-swarm',
(chunk) => {
fullContent += chunk
setStreamingContent(fullContent)
},
abortControllerRef.current.signal
)
// Add complete message after streaming
onAddMessage('assistant', fullContent)
setStreamingContent('')
} else {
// Non-streaming mode
const response = await sendMessage(
config.endpoint,
userMessage,
apiMessages,
config.systemPrompt,
config.model || 'local-swarm',
abortControllerRef.current.signal
)
onAddMessage('assistant', response)
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
// Request was cancelled, don't show error
return
}
setError(err instanceof Error ? err.message : 'Failed to get response')
} finally {
setIsLoading(false)
abortControllerRef.current = null
}
}
const handleCancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
setIsLoading(false)
setStreamingContent('')
}
}
const exportConversation = () => {
if (!session || session.messages.length === 0) {
alert('No messages to export')
return
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
let content = `# Conversation Export\n\n`
content += `Date: ${new Date().toLocaleString()}\n`
content += `Endpoint: ${config.endpoint}\n`
content += `Model: ${config.model || 'local-swarm'}\n`
if (config.systemPrompt) {
content += `System Prompt: ${config.systemPrompt}\n`
}
content += `Streaming: ${config.streaming ? 'Enabled' : 'Disabled'}\n\n`
content += `---\n\n`
session.messages.forEach(msg => {
const role = msg.role === 'user' ? 'User' : 'Assistant'
content += `## ${role}\n\n${msg.content}\n\n`
})
// Create and download file
const blob = new Blob([content], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `conversation-${timestamp}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const getMessageCount = () => {
if (!session) return 0
return session.messages.length
}
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
backgroundColor: 'var(--bg-primary)'
}}>
{/* Header */}
<header style={{
display: 'flex',
alignItems: 'center',
padding: '16px 20px',
borderBottom: '1px solid var(--border)',
backgroundColor: 'var(--bg-secondary)',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
minHeight: '64px',
position: 'relative'
}}>
{/* Menu button area spacer */}
<div style={{
position: 'absolute',
left: '16px',
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
alignItems: 'center',
height: '100%'
}}>
{/* This space is reserved for the menu button in App */}
</div>
{/* Centered Session Name */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center'
}}>
<h1 style={{
fontSize: '18px',
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: '4px'
}}>
{session?.name || 'Light Chat'}
</h1>
{session && (
<div style={{
fontSize: '12px',
color: 'var(--text-muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px'
}}>
<span>{getMessageCount()} messages</span>
{config.streaming && (
<span style={{
backgroundColor: 'var(--accent)',
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '10px',
fontWeight: 600
}}>
STREAMING
</span>
)}
</div>
)}
</div>
{/* Export button on the right */}
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={exportConversation}
disabled={!session || session.messages.length === 0}
className="secondary small"
title="Export conversation"
>
Export
</button>
</div>
</header>
{/* Messages */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '20px',
display: 'flex',
flexDirection: 'column',
gap: '16px'
}}>
{!session ? (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
textAlign: 'center',
padding: '40px'
}}>
<h2 style={{ fontSize: '20px', marginBottom: '8px', color: 'var(--text-primary)' }}>
Welcome to Light Chat
</h2>
<p>Open the menu to create a session</p>
</div>
) : session.messages.length === 0 ? (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
textAlign: 'center',
padding: '40px'
}}>
<h2 style={{ fontSize: '20px', marginBottom: '8px', color: 'var(--text-primary)' }}>
Start the conversation
</h2>
<p>Type your message below to begin chatting</p>
</div>
) : (
session.messages.map((msg, index) => (
<div
key={index}
style={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start'
}}
>
<div
style={{
maxWidth: '80%',
padding: '16px 20px',
borderRadius: msg.role === 'user'
? 'var(--radius-lg) var(--radius-lg) 4px var(--radius-lg)'
: 'var(--radius-lg) var(--radius-lg) var(--radius-lg) 4px',
backgroundColor: msg.role === 'user'
? 'var(--bg-tertiary)'
: 'var(--bg-secondary)',
color: 'var(--text-primary)',
boxShadow: 'var(--shadow)',
border: `1px solid ${msg.role === 'user' ? 'var(--border-light)' : 'var(--border)'}`
}}
>
<div style={{
fontSize: '12px',
fontWeight: 600,
marginBottom: '8px',
color: msg.role === 'user' ? 'var(--text-secondary)' : 'var(--accent)'
}}>
{msg.role === 'user' ? 'You' : 'Assistant'}
</div>
<div style={{ fontSize: '14px', lineHeight: 1.7 }}>
<MarkdownContent content={msg.content} />
</div>
</div>
</div>
))
)}
{/* Streaming message */}
{streamingContent && (
<div
style={{
display: 'flex',
justifyContent: 'flex-start'
}}
>
<div
style={{
maxWidth: '80%',
padding: '16px 20px',
borderRadius: 'var(--radius-lg) var(--radius-lg) var(--radius-lg) 4px',
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
boxShadow: 'var(--shadow)',
border: '1px solid var(--accent)'
}}
>
<div style={{
fontSize: '12px',
fontWeight: 600,
marginBottom: '8px',
color: 'var(--accent)',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
Assistant
</div>
<div style={{ fontSize: '14px', lineHeight: 1.7 }}>
<MarkdownContent content={streamingContent} />
</div>
</div>
</div>
)}
{/* Loading indicator (non-streaming) */}
{isLoading && !streamingContent && (
<div style={{
color: 'var(--text-muted)',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 0'
}}>
Thinking...
</div>
)}
{/* Error message */}
{error && (
<div style={{
color: 'var(--error)',
fontSize: '14px',
padding: '12px 16px',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--error)'
}}>
{error}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Form */}
<form
onSubmit={handleSubmit}
style={{
padding: '16px 20px',
borderTop: '1px solid var(--border)',
backgroundColor: 'var(--bg-secondary)',
display: 'flex',
gap: '12px',
alignItems: 'flex-end'
}}
>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={config.endpoint ? "Type your message..." : "Configure API endpoint in menu first"}
disabled={isLoading || !session}
style={{
flex: 1,
padding: '14px 18px',
fontSize: '15px'
}}
/>
{isLoading ? (
<button
type="button"
onClick={handleCancel}
className="danger"
style={{ padding: '14px 20px' }}
>
Stop
</button>
) : (
<button
type="submit"
disabled={!input.trim() || !session}
style={{ padding: '14px 24px' }}
>
Send
</button>
)}
</form>
</div>
)
}