36eebafeb3
- 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
440 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|