feat: initial implementation of Light Chat app with React, Capacitor, and comprehensive tests
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { sendMessage } from '../utils/llmApi.js'
|
||||
|
||||
/**
|
||||
* ChatWindow - Main chat interface
|
||||
*/
|
||||
export function ChatWindow({ session, config, onAddMessage }) {
|
||||
const [input, setInput] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const messagesEndRef = useRef(null)
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (messagesEndRef.current && typeof messagesEndRef.current.scrollIntoView === 'function') {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [session?.messages])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
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)
|
||||
|
||||
// Add user message
|
||||
onAddMessage('user', userMessage)
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Prepare messages for API
|
||||
const apiMessages = session?.messages || []
|
||||
const response = await sendMessage(
|
||||
config.endpoint,
|
||||
userMessage,
|
||||
apiMessages,
|
||||
config.systemPrompt
|
||||
)
|
||||
|
||||
// Add assistant response
|
||||
onAddMessage('assistant', response)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to get response')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
content += `Date: ${new Date().toLocaleString()}\n`
|
||||
content += `Endpoint: ${config.endpoint}\n`
|
||||
if (config.systemPrompt) {
|
||||
content += `System Prompt: ${config.systemPrompt}\n`
|
||||
}
|
||||
content += '\n---\n\n'
|
||||
|
||||
session.messages.forEach(msg => {
|
||||
const role = msg.role === 'user' ? 'User' : 'Assistant'
|
||||
content += `**[${role}]** ${new Date().toLocaleTimeString()}\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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
backgroundColor: 'var(--bg-primary)'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
backgroundColor: 'var(--bg-secondary)'
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{ fontSize: '16px', fontWeight: 600 }}>{session?.name || 'Light Chat'}</h1>
|
||||
</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',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}>
|
||||
{!session ? (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
Select or create a session to start chatting
|
||||
</div>
|
||||
) : session.messages.length === 0 ? (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
Start the conversation by sending a message
|
||||
</div>
|
||||
) : (
|
||||
session.messages.map((msg, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: msg.role === 'user' ? 'row' : 'row',
|
||||
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '80%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: msg.role === 'user'
|
||||
? 'var(--accent)'
|
||||
: 'var(--bg-secondary)',
|
||||
border: '1px solid',
|
||||
borderColor: msg.role === 'user' ? 'var(--accent)' : 'var(--border)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: msg.role === 'user' ? 'white' : 'var(--text-secondary)',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{msg.role === 'user' ? 'You' : 'Assistant'}
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordWrap: 'break-word' }}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isLoading && (
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>
|
||||
Thinking...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{ color: 'var(--error)', fontSize: '14px' }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
padding: '16px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
display: 'flex',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={config.endpoint ? "Type your message..." : "Configure API endpoint first"}
|
||||
disabled={isLoading || !session}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="submit" disabled={isLoading || !input.trim() || !session}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ChatWindow } from '../components/ChatWindow.jsx'
|
||||
import { sendMessage } from '../utils/llmApi.js'
|
||||
|
||||
// Mock the llmApi module
|
||||
vi.mock('../utils/llmApi.js', () => ({
|
||||
sendMessage: vi.fn(),
|
||||
testConnection: vi.fn()
|
||||
}))
|
||||
|
||||
describe('ChatWindow', () => {
|
||||
const defaultProps = {
|
||||
session: {
|
||||
id: '1',
|
||||
name: 'Test Session',
|
||||
messages: []
|
||||
},
|
||||
config: {
|
||||
endpoint: 'https://api.test.com',
|
||||
systemPrompt: 'You are helpful'
|
||||
},
|
||||
onAddMessage: vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should render with empty state', () => {
|
||||
render(<ChatWindow {...defaultProps} />)
|
||||
expect(screen.getByText(/Test Session/)).toBeInTheDocument()
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('placeholder', 'Type your message...')
|
||||
expect(screen.getByRole('button', { name: /Export/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render messages', () => {
|
||||
const sessionWithMessages = {
|
||||
...defaultProps.session,
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there!' }
|
||||
]
|
||||
}
|
||||
|
||||
render(<ChatWindow {...defaultProps} session={sessionWithMessages} />)
|
||||
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hi there!')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle form submission', async () => {
|
||||
const user = userEvent.setup()
|
||||
sendMessage.mockResolvedValueOnce('Response')
|
||||
|
||||
render(<ChatWindow {...defaultProps} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Hello')
|
||||
await user.click(screen.getByRole('button', { name: /Send/i }))
|
||||
|
||||
expect(defaultProps.onAddMessage).toHaveBeenCalledWith('user', 'Hello')
|
||||
await waitFor(() => {
|
||||
expect(sendMessage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error when no endpoint configured', async () => {
|
||||
const user = userEvent.setup()
|
||||
const noEndpointProps = {
|
||||
...defaultProps,
|
||||
config: { endpoint: '', systemPrompt: '' }
|
||||
}
|
||||
render(<ChatWindow {...noEndpointProps} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Hello')
|
||||
await user.click(screen.getByRole('button', { name: /Send/i }))
|
||||
|
||||
expect(screen.getByText(/Please configure API endpoint/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable input when no session', () => {
|
||||
render(<ChatWindow {...defaultProps} session={null} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeDisabled()
|
||||
expect(input).toHaveAttribute('placeholder', 'Type your message...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* SessionDrawer - Sidebar showing sessions list and config
|
||||
*/
|
||||
export function SessionDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
onSelectSession,
|
||||
onCreateSession,
|
||||
onDeleteSession,
|
||||
config,
|
||||
onUpdateConfig
|
||||
}) {
|
||||
const [localConfig, setLocalConfig] = useState(config)
|
||||
|
||||
const handleConfigChange = (field, value) => {
|
||||
setLocalConfig(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const saveConfig = () => {
|
||||
onUpdateConfig(localConfig)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 999
|
||||
}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: '300px',
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
transform: isOpen ? 'translateX(0)' : 'translateX(-100%)',
|
||||
transition: 'transform 0.3s ease',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '16px'
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={isOpen ? 0 : -1}
|
||||
>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h2 style={{ fontSize: '18px', marginBottom: '16px' }}>Sessions</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
onCreateSession()
|
||||
onClose()
|
||||
}}
|
||||
style={{ width: '100%', marginBottom: '12px' }}
|
||||
>
|
||||
+ New Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Config Section */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3 style={{ fontSize: '14px', marginBottom: '8px', color: 'var(--text-secondary)' }}>
|
||||
API Configuration
|
||||
</h3>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>
|
||||
Endpoint
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localConfig.endpoint}
|
||||
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
|
||||
placeholder="https://api.openai.com/v1/chat/completions"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>
|
||||
System Prompt
|
||||
</label>
|
||||
<textarea
|
||||
value={localConfig.systemPrompt}
|
||||
onChange={(e) => handleConfigChange('systemPrompt', e.target.value)}
|
||||
placeholder="You are a helpful assistant..."
|
||||
rows={3}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={saveConfig} style={{ width: '100%' }}>
|
||||
Save Config
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{sessions.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>
|
||||
No sessions yet. Create one to start chatting.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{sessions.map(session => (
|
||||
<li
|
||||
key={session.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: currentSessionId === session.id
|
||||
? 'var(--bg-tertiary)'
|
||||
: 'transparent',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => {
|
||||
onSelectSession(session.id)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{session.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm('Delete this session?')) {
|
||||
onDeleteSession(session.id)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: 'var(--error)',
|
||||
marginLeft: '8px'
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { SessionDrawer } from '../components/SessionDrawer.jsx'
|
||||
|
||||
describe('SessionDrawer', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
sessions: [],
|
||||
currentSessionId: null,
|
||||
onSelectSession: vi.fn(),
|
||||
onCreateSession: vi.fn(),
|
||||
onDeleteSession: vi.fn(),
|
||||
config: { endpoint: '', systemPrompt: '' },
|
||||
onUpdateConfig: vi.fn()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should render when open', () => {
|
||||
render(<SessionDrawer {...defaultProps} />)
|
||||
expect(screen.getByText(/Sessions/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /New Session/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose when overlay clicked', () => {
|
||||
render(<SessionDrawer {...defaultProps} />)
|
||||
|
||||
const overlay = document.querySelector('[style*="position: fixed"]')
|
||||
fireEvent.click(overlay)
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should display sessions list', () => {
|
||||
const sessions = [
|
||||
{ id: '1', name: 'Session 1', messages: [] },
|
||||
{ id: '2', name: 'Session 2', messages: [] }
|
||||
]
|
||||
render(<SessionDrawer {...defaultProps} sessions={sessions} currentSessionId="1" />)
|
||||
|
||||
expect(screen.getByText('Session 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Session 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelectSession when session clicked', () => {
|
||||
const sessions = [{ id: '1', name: 'Session 1', messages: [] }]
|
||||
render(<SessionDrawer {...defaultProps} sessions={sessions} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Session 1'))
|
||||
|
||||
expect(defaultProps.onSelectSession).toHaveBeenCalledWith('1')
|
||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCreateSession when new session button clicked', () => {
|
||||
render(<SessionDrawer {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /New Session/i }))
|
||||
|
||||
expect(defaultProps.onCreateSession).toHaveBeenCalled()
|
||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onUpdateConfig when save config clicked', () => {
|
||||
render(<SessionDrawer {...defaultProps} />)
|
||||
|
||||
const endpointInput = screen.getByPlaceholderText(/openai.com/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: '' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user