feat: improve UI styling and add streaming support

UI Improvements:
- Modern gradient buttons with hover effects and shine animation
- Better typography and spacing throughout the app
- Improved session cards with message count and timestamps
- Better styled input fields with focus states
- Smooth transitions and animations
- Added emoji icons for better visual cues
- Improved empty states with icons and better messaging

New Features:
- Added streaming toggle button in API configuration
- Real-time streaming response support with live token display
- Stop button to cancel streaming requests
- Message count display in header
- STREAMING indicator badge when enabled
- AbortController support for request cancellation
- Export now includes streaming status

Technical Changes:
- Added sendMessageStream function to llmApi
- Added streaming field to Config interface
- Updated all components to support streaming mode
- All 52 tests passing
This commit is contained in:
2026-02-26 01:45:19 +01:00
parent 8214079093
commit 0789893e19
10 changed files with 785 additions and 142 deletions
+26 -8
View File
@@ -33,7 +33,28 @@ export default function App() {
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-secondary)'
}}>
Loading...
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '16px'
}}>
<div style={{
width: '40px',
height: '40px',
border: '3px solid var(--border)',
borderTopColor: 'var(--accent)',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
Loading...
</div>
</div>
)
}
@@ -43,17 +64,14 @@ export default function App() {
{/* Menu Button */}
<button
onClick={() => setIsDrawerOpen(true)}
className="icon-btn"
style={{
position: 'absolute',
top: '16px',
top: '12px',
left: '16px',
zIndex: 100,
width: '40px',
height: '40px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
fontSize: '20px'
}}
title="Open Menu"
+274 -62
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { sendMessage } from '../utils/llmApi'
import { sendMessage, sendMessageStream } from '../utils/llmApi'
import type { Session, Config } from '../services/sessionService'
interface ChatWindowProps {
@@ -15,7 +15,9 @@ 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') {
@@ -25,43 +27,91 @@ export function ChatWindow({ session, config, onAddMessage }: ChatWindowProps) {
useEffect(() => {
scrollToBottom()
}, [session?.messages])
}, [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')
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 || []
const response = await sendMessage(
config.endpoint,
userMessage,
apiMessages,
config.systemPrompt,
config.model || 'local-swarm'
)
// Add assistant response
onAddMessage('assistant', response)
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('')
}
}
@@ -72,17 +122,19 @@ export function ChatWindow({ session, config, onAddMessage }: ChatWindowProps) {
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
let content = `# Conversation Export\n`
content += `Date: ${new Date().toLocaleString()}\n`
content += `Endpoint: ${config.endpoint}\n`
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 += `**System Prompt:** ${config.systemPrompt}\n`
}
content += '\n---\n\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}]** ${new Date().toLocaleTimeString()}\n${msg.content}\n\n`
const role = msg.role === 'user' ? '👤 User' : '🤖 Assistant'
content += `### ${role}\n\n${msg.content}\n\n`
})
// Create and download file
@@ -97,6 +149,11 @@ export function ChatWindow({ session, config, onAddMessage }: ChatWindowProps) {
URL.revokeObjectURL(url)
}
const getMessageCount = () => {
if (!session) return 0
return session.messages.length
}
return (
<div style={{
display: 'flex',
@@ -108,55 +165,98 @@ export function ChatWindow({ session, config, onAddMessage }: ChatWindowProps) {
<header style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
padding: '16px 20px',
borderBottom: '1px solid var(--border)',
backgroundColor: 'var(--bg-secondary)'
backgroundColor: 'var(--bg-secondary)',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)'
}}>
<div style={{ flex: 1 }}>
<h1 style={{ fontSize: '16px', fontWeight: 600 }}>{session?.name || 'Light Chat'}</h1>
<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',
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>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={exportConversation}
disabled={!session || session.messages.length === 0}
className="secondary small"
title="Export conversation"
>
📥 Export
</button>
</div>
<button
onClick={exportConversation}
disabled={!session || session.messages.length === 0}
title="Export conversation"
style={{
padding: '6px 12px',
fontSize: '12px',
marginRight: '8px'
}}
>
Export
</button>
</header>
{/* Messages */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '16px',
padding: '20px',
display: 'flex',
flexDirection: 'column',
gap: '12px'
gap: '16px'
}}>
{!session ? (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-secondary)'
color: 'var(--text-muted)',
textAlign: 'center',
padding: '40px'
}}>
Select or create a session to start chatting
<div style={{ fontSize: '48px', marginBottom: '16px' }}>💬</div>
<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-secondary)'
color: 'var(--text-muted)',
textAlign: 'center',
padding: '40px'
}}>
Start the conversation by sending a message
<div style={{ fontSize: '48px', marginBottom: '16px' }}>👋</div>
<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) => (
@@ -164,44 +264,135 @@ export function ChatWindow({ session, config, onAddMessage }: ChatWindowProps) {
key={index}
style={{
display: 'flex',
flexDirection: msg.role === 'user' ? 'row' : 'row',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start'
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
animation: 'fadeIn 0.3s ease-out'
}}
>
<div
style={{
maxWidth: '80%',
padding: '8px 12px',
borderRadius: '8px',
padding: '14px 18px',
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(--accent)'
: 'var(--bg-secondary)',
border: '1px solid',
borderColor: msg.role === 'user' ? 'var(--accent)' : 'var(--border)'
color: msg.role === 'user' ? 'white' : 'var(--text-primary)',
boxShadow: 'var(--shadow)',
border: `1px solid ${msg.role === 'user' ? 'var(--accent)' : 'var(--border)'}`,
position: 'relative'
}}
>
<div style={{
fontSize: '12px',
color: msg.role === 'user' ? 'white' : 'var(--text-secondary)',
marginBottom: '4px'
fontWeight: 600,
marginBottom: '6px',
color: msg.role === 'user' ? 'rgba(255,255,255,0.8)' : 'var(--accent)',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
{msg.role === 'user' ? 'You' : 'Assistant'}
{msg.role === 'user' ? '👤 You' : '🤖 Assistant'}
</div>
<div style={{ whiteSpace: 'pre-wrap', wordWrap: 'break-word' }}>
<div style={{
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
fontSize: '14px',
lineHeight: 1.6
}}>
{msg.content}
</div>
</div>
</div>
))
)}
{isLoading && (
<div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>
{/* Streaming message */}
{streamingContent && (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
animation: 'fadeIn 0.3s ease-out'
}}
>
<div
style={{
maxWidth: '80%',
padding: '14px 18px',
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)',
position: 'relative'
}}
>
<div style={{
fontSize: '12px',
fontWeight: 600,
marginBottom: '6px',
color: 'var(--accent)',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
🤖 Assistant
<span style={{
display: 'inline-block',
width: '6px',
height: '6px',
backgroundColor: 'var(--accent)',
borderRadius: '50%',
animation: 'pulse 1s infinite'
}} />
</div>
<div style={{
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
fontSize: '14px',
lineHeight: 1.6
}}>
{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'
}}>
<span style={{
display: 'inline-block',
width: '8px',
height: '8px',
backgroundColor: 'var(--accent)',
borderRadius: '50%',
animation: 'pulse 1.5s infinite'
}} />
Thinking...
</div>
)}
{/* Error message */}
{error && (
<div style={{ color: 'var(--error)', fontSize: '14px' }}>
Error: {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} />
@@ -211,24 +402,45 @@ export function ChatWindow({ session, config, onAddMessage }: ChatWindowProps) {
<form
onSubmit={handleSubmit}
style={{
padding: '16px',
padding: '16px 20px',
borderTop: '1px solid var(--border)',
backgroundColor: 'var(--bg-secondary)',
display: 'flex',
gap: '8px'
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 first"}
placeholder={config.endpoint ? "Type your message..." : "Configure API endpoint in menu (☰) first"}
disabled={isLoading || !session}
style={{ flex: 1 }}
style={{
flex: 1,
padding: '14px 18px',
fontSize: '15px'
}}
/>
<button type="submit" disabled={isLoading || !input.trim() || !session}>
Send
</button>
{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>
)
+4 -4
View File
@@ -12,7 +12,7 @@ describe('SessionDrawer', () => {
onSelectSession: vi.fn(),
onCreateSession: vi.fn(),
onDeleteSession: vi.fn(),
config: { endpoint: '', systemPrompt: '', model: 'local-swarm' } as Config,
config: { endpoint: '', systemPrompt: '', model: 'local-swarm', streaming: false } as Config,
onUpdateConfig: vi.fn()
}
@@ -22,7 +22,7 @@ describe('SessionDrawer', () => {
it('should render when open', () => {
render(<SessionDrawer {...defaultProps} />)
expect(screen.getByText(/Sessions/)).toBeInTheDocument()
expect(screen.getByText(/Your Sessions/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /New Session/i })).toBeInTheDocument()
})
@@ -71,11 +71,11 @@ describe('SessionDrawer', () => {
it('should call onUpdateConfig when save config clicked', () => {
render(<SessionDrawer {...defaultProps} />)
const endpointInput = screen.getByPlaceholderText(/openai.com/i)
const endpointInput = screen.getByPlaceholderText(/192.168/i)
fireEvent.change(endpointInput, { target: { value: 'https://new.com' } })
fireEvent.click(screen.getByRole('button', { name: /Save Config/i }))
expect(defaultProps.onUpdateConfig).toHaveBeenCalledWith({ endpoint: 'https://new.com', systemPrompt: '', model: 'local-swarm' })
expect(defaultProps.onUpdateConfig).toHaveBeenCalledWith({ endpoint: 'https://new.com', systemPrompt: '', model: 'local-swarm', streaming: false })
})
})
+191 -43
View File
@@ -29,7 +29,7 @@ export function SessionDrawer({
}: SessionDrawerProps) {
const [localConfig, setLocalConfig] = useState<Config>(config)
const handleConfigChange = (field: keyof Config, value: string) => {
const handleConfigChange = (field: keyof Config, value: string | boolean) => {
setLocalConfig(prev => ({ ...prev, [field]: value }))
}
@@ -43,6 +43,15 @@ export function SessionDrawer({
}
}
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
return (
<>
{/* Overlay */}
@@ -54,7 +63,8 @@ export function SessionDrawer({
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
backgroundColor: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(4px)',
zIndex: 999
}}
onClick={onClose}
@@ -68,51 +78,90 @@ export function SessionDrawer({
top: 0,
left: 0,
bottom: 0,
width: '300px',
width: '340px',
backgroundColor: 'var(--bg-secondary)',
borderRight: '1px solid var(--border)',
transform: isOpen ? 'translateX(0)' : 'translateX(-100%)',
transition: 'transform 0.3s ease',
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
padding: '16px'
boxShadow: isOpen ? 'var(--shadow-lg)' : 'none'
}}
onKeyDown={handleKeyDown}
tabIndex={isOpen ? 0 : -1}
>
<div style={{ marginBottom: '20px' }}>
<h2 style={{ fontSize: '18px', marginBottom: '16px' }}>Sessions</h2>
{/* Header */}
<div style={{
padding: '20px',
borderBottom: '1px solid var(--border)',
backgroundColor: 'var(--bg-primary)'
}}>
<h2 style={{
fontSize: '20px',
fontWeight: 600,
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span>💬</span> Sessions
</h2>
<button
onClick={() => {
onCreateSession()
onClose()
}}
style={{ width: '100%', marginBottom: '12px' }}
style={{ width: '100%' }}
>
+ New Session
</button>
</div>
{/* Config Section */}
<div style={{ marginBottom: '20px' }}>
<h3 style={{ fontSize: '14px', marginBottom: '8px', color: 'var(--text-secondary)' }}>
<div style={{
padding: '20px',
borderBottom: '1px solid var(--border)',
backgroundColor: 'var(--bg-secondary)'
}}>
<h3 style={{
fontSize: '13px',
fontWeight: 600,
marginBottom: '16px',
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
API Configuration
</h3>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>
Endpoint
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '12px',
fontWeight: 500,
color: 'var(--text-secondary)'
}}>
Endpoint URL
</label>
<input
type="text"
value={localConfig.endpoint}
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
placeholder="https://api.openai.com/v1/chat/completions"
placeholder="http://192.168.1.100:17615/v1/chat/completions"
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>
Model
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '12px',
fontWeight: 500,
color: 'var(--text-secondary)'
}}>
Model Name
</label>
<input
type="text"
@@ -121,8 +170,15 @@ export function SessionDrawer({
placeholder="local-swarm"
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '12px',
fontWeight: 500,
color: 'var(--text-secondary)'
}}>
System Prompt
</label>
<textarea
@@ -130,45 +186,137 @@ export function SessionDrawer({
onChange={(e) => handleConfigChange('systemPrompt', e.target.value)}
placeholder="You are a helpful assistant..."
rows={3}
style={{ resize: 'vertical' }}
style={{ resize: 'vertical', minHeight: '80px' }}
/>
</div>
{/* Streaming Toggle */}
<div style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: 'var(--bg-tertiary)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div>
<label style={{
display: 'block',
fontSize: '13px',
fontWeight: 500,
color: 'var(--text-primary)',
marginBottom: '2px'
}}>
Streaming Response
</label>
<span style={{
fontSize: '11px',
color: 'var(--text-muted)'
}}>
Receive tokens as they are generated
</span>
</div>
<button
onClick={() => handleConfigChange('streaming', !localConfig.streaming)}
className={localConfig.streaming ? 'toggle active' : 'toggle'}
style={{
minWidth: '50px',
padding: '6px 12px',
fontSize: '12px'
}}
>
{localConfig.streaming ? 'ON' : 'OFF'}
</button>
</div>
</div>
<button onClick={saveConfig} style={{ width: '100%' }}>
Save Config
Save Configuration
</button>
</div>
{/* Sessions List */}
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{
flex: 1,
overflowY: 'auto',
padding: '12px'
}}>
<h3 style={{
fontSize: '13px',
fontWeight: 600,
marginBottom: '12px',
marginLeft: '8px',
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
Your Sessions
</h3>
{sessions.length === 0 ? (
<p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>
No sessions yet. Create one to start chatting.
</p>
<div style={{
textAlign: 'center',
padding: '40px 20px',
color: 'var(--text-muted)'
}}>
<div style={{ fontSize: '32px', marginBottom: '12px' }}>📝</div>
<p style={{ fontSize: '14px' }}>
No sessions yet.<br />Create one to start chatting.
</p>
</div>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{sessions.map(session => (
<li
<div
key={session.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
marginBottom: '4px',
borderRadius: '4px',
padding: '12px 14px',
borderRadius: 'var(--radius-md)',
backgroundColor: currentSessionId === session.id
? 'var(--bg-tertiary)'
: 'transparent',
cursor: 'pointer'
? 'var(--accent)'
: 'var(--bg-tertiary)',
cursor: 'pointer',
transition: 'var(--transition)',
border: `1px solid ${currentSessionId === session.id ? 'var(--accent)' : 'transparent'}`,
boxShadow: currentSessionId === session.id ? 'var(--shadow)' : 'none'
}}
onClick={() => {
onSelectSession(session.id)
onClose()
}}
>
<span style={{ fontSize: '14px', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{session.name}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '14px',
fontWeight: 500,
color: currentSessionId === session.id ? 'white' : 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginBottom: '4px'
}}>
{session.name}
</div>
<div style={{
fontSize: '11px',
color: currentSessionId === session.id ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
<span>{session.messages.length} messages</span>
<span></span>
<span>{formatDate(session.updatedAt)}</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation()
@@ -176,18 +324,18 @@ export function SessionDrawer({
onDeleteSession(session.id)
}
}}
className="danger small"
style={{
padding: '4px 8px',
fontSize: '12px',
backgroundColor: 'var(--error)',
marginLeft: '8px'
marginLeft: '10px',
opacity: 0.9
}}
title="Delete session"
>
Delete
🗑
</button>
</li>
</div>
))}
</ul>
</div>
)}
</div>
</div>
+1 -1
View File
@@ -26,7 +26,7 @@ describe('useSessions', () => {
const sessions = [
{ id: '1', name: 'Session 1', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }
]
const config = { endpoint: 'https://api.test.com', systemPrompt: 'Be helpful', model: 'local-swarm' }
const config = { endpoint: 'https://api.test.com', systemPrompt: 'Be helpful', model: 'local-swarm', streaming: false }
localStorageMock.getItem.mockImplementation((key) => {
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
+1 -1
View File
@@ -8,7 +8,7 @@ import * as sessionService from '../services/sessionService'
export function useSessions() {
const [sessions, setSessions] = useState<sessionService.Session[]>([])
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
const [config, setConfig] = useState<sessionService.Config>({ endpoint: '', systemPrompt: '', model: 'local-swarm' })
const [config, setConfig] = useState<sessionService.Config>({ endpoint: '', systemPrompt: '', model: 'local-swarm', streaming: false })
const [isLoading, setIsLoading] = useState(true)
// Load data on mount
+169 -14
View File
@@ -1,14 +1,25 @@
:root {
--bg-primary: #0a0a0a;
--bg-primary: #0d0d0d;
--bg-secondary: #1a1a1a;
--bg-tertiary: #2a2a2a;
--bg-hover: #333333;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--accent: #3b82f6;
--accent-hover: #2563eb;
--border: #333333;
--text-secondary: #a0a0a0;
--text-muted: #666666;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-light: #818cf8;
--border: #2d2d2d;
--border-light: #3d3d3d;
--success: #10b981;
--error: #ef4444;
--warning: #f59e0b;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
@@ -18,10 +29,10 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -31,36 +42,180 @@ body {
width: 100%;
}
/* Modern Button Styles */
button {
background: var(--accent);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
padding: 10px 20px;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.3px;
transition: var(--transition);
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: left 0.5s;
}
button:hover::before {
left: 100%;
}
button:hover {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
button:active {
transform: translateY(0);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
button:disabled::before {
display: none;
}
/* Secondary Button Style */
button.secondary {
background: var(--bg-tertiary);
border: 1px solid var(--border-light);
}
button.secondary:hover {
background: var(--bg-hover);
border-color: var(--accent);
}
/* Danger Button Style */
button.danger {
background: linear-gradient(135deg, var(--error) 0%, #dc2626 100%);
}
/* Icon Button Style */
button.icon-btn {
padding: 10px;
min-width: 40px;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
/* Small Button */
button.small {
padding: 6px 12px;
font-size: 12px;
border-radius: var(--radius-sm);
}
/* Toggle Button Style */
button.toggle {
background: var(--bg-tertiary);
border: 2px solid var(--border);
position: relative;
}
button.toggle.active {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
border-color: var(--accent);
}
/* Input Styles */
input, textarea {
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 4px;
padding: 12px 16px;
border-radius: var(--radius-md);
font-size: 14px;
width: 100%;
transition: var(--transition);
font-family: inherit;
}
input:focus, textarea:focus {
outline: none;
border-color: var(--accent);
}
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
input::placeholder, textarea::placeholder {
color: var(--text-muted);
}
input:disabled, textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-light);
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}
.animate-slideIn {
animation: slideIn 0.3s ease-out;
}
.animate-pulse {
animation: pulse 1.5s ease-in-out infinite;
}
+3 -3
View File
@@ -65,7 +65,7 @@ describe('Session Service', () => {
describe('loadConfig', () => {
it('should return default config when no data exists', () => {
const config = sessionService.loadConfig()
expect(config).toEqual({ endpoint: '', systemPrompt: '', model: 'local-swarm' })
expect(config).toEqual({ endpoint: '', systemPrompt: '', model: 'local-swarm', streaming: false })
})
it('should return parsed config from localStorage', () => {
@@ -73,13 +73,13 @@ describe('Session Service', () => {
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(testConfig))
const config = sessionService.loadConfig()
expect(config).toEqual({ ...testConfig, model: 'local-swarm' })
expect(config).toEqual({ ...testConfig, model: 'local-swarm', streaming: false })
})
})
describe('saveConfig', () => {
it('should save config to localStorage', () => {
const config = { endpoint: 'https://api.test.com', systemPrompt: 'Be helpful', model: 'local-swarm' }
const config = { endpoint: 'https://api.test.com', systemPrompt: 'Be helpful', model: 'local-swarm', streaming: false }
sessionService.saveConfig(config)
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'light-chat-config',
+6 -2
View File
@@ -25,6 +25,7 @@ export interface Config {
endpoint: string
systemPrompt: string
model: string
streaming: boolean
}
interface CreateSessionOptions {
@@ -88,10 +89,13 @@ export function saveSessions(sessions: Session[]): void {
export function loadConfig(): Config {
try {
const data = localStorage.getItem(CONFIG_KEY)
return data ? { ...JSON.parse(data), model: 'local-swarm' } : { endpoint: '', systemPrompt: '', model: 'local-swarm' }
const defaultConfig: Config = { endpoint: '', systemPrompt: '', model: 'local-swarm', streaming: false }
if (!data) return defaultConfig
const parsed = JSON.parse(data)
return { ...defaultConfig, ...parsed }
} catch (error) {
console.error('Failed to load config:', error)
return { endpoint: '', systemPrompt: '', model: 'local-swarm' }
return { endpoint: '', systemPrompt: '', model: 'local-swarm', streaming: false }
}
}
+110 -4
View File
@@ -3,7 +3,7 @@
* Simple wrapper for OpenAI-compatible APIs
*/
interface Message {
export interface Message {
role: string
content: string
}
@@ -21,12 +21,13 @@ const DEFAULT_HEADERS = {
}
/**
* Send a message to the LLM API and get a response
* Send a message to the LLM API and get a response (non-streaming)
* @param endpoint - API endpoint URL
* @param message - User message
* @param messages - Full message history (optional, for multi-turn)
* @param systemPrompt - System prompt (optional)
* @param model - Model name (optional, defaults to 'local-swarm')
* @param signal - AbortSignal for cancellation
* @returns - LLM response content
*/
export async function sendMessage(
@@ -34,7 +35,8 @@ export async function sendMessage(
message: string,
messages: Message[] = [],
systemPrompt: string = '',
model: string = 'local-swarm'
model: string = 'local-swarm',
signal?: AbortSignal
): Promise<string> {
if (!endpoint) {
throw new Error('API endpoint is required')
@@ -66,7 +68,8 @@ export async function sendMessage(
model,
messages: apiMessages,
stream: false
})
}),
signal
})
if (!response.ok) {
@@ -90,6 +93,109 @@ export async function sendMessage(
}
}
/**
* Send a message to the LLM API with streaming support
* @param endpoint - API endpoint URL
* @param message - User message
* @param messages - Full message history (optional, for multi-turn)
* @param systemPrompt - System prompt (optional)
* @param model - Model name (optional, defaults to 'local-swarm')
* @param onChunk - Callback for each chunk of the response
* @param signal - AbortSignal for cancellation
*/
export async function sendMessageStream(
endpoint: string,
message: string,
messages: Message[] = [],
systemPrompt: string = '',
model: string = 'local-swarm',
onChunk: (chunk: string) => void,
signal?: AbortSignal
): Promise<void> {
if (!endpoint) {
throw new Error('API endpoint is required')
}
// Build messages array for API
const apiMessages: Array<{ role: string; content: string }> = []
if (systemPrompt) {
apiMessages.push({ role: 'system', content: systemPrompt })
}
// Add history
if (messages.length > 0) {
apiMessages.push(...messages.map(msg => ({
role: msg.role,
content: msg.content
})))
}
// Add current user message
apiMessages.push({ role: 'user', content: message })
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: DEFAULT_HEADERS,
body: JSON.stringify({
model,
messages: apiMessages,
stream: true
}),
signal
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`API request failed: ${response.status} - ${errorText}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('Response body not available')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// Process complete lines
const lines = buffer.split('\n')
buffer = lines.pop() || '' // Keep incomplete line in buffer
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine || trimmedLine === 'data: [DONE]') continue
if (trimmedLine.startsWith('data: ')) {
try {
const json = JSON.parse(trimmedLine.slice(6))
const content = json.choices?.[0]?.delta?.content || json.choices?.[0]?.text
if (content) {
onChunk(content)
}
} catch (e) {
// Skip invalid JSON lines
}
}
}
}
} catch (error) {
if ((error as Error).name === 'AbortError') {
throw error
}
console.error('LLM API streaming error:', error)
throw error
}
}
/**
* Test connection to API endpoint
* @param endpoint - API endpoint URL