feat: initial implementation of Light Chat app with React, Capacitor, and comprehensive tests
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
.vite/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.local.env
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Capacitor / Android / iOS
|
||||
android/
|
||||
ios/
|
||||
capacitor.config.json
|
||||
capacitor.config.local.json
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# Vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores
|
||||
stores/
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
@@ -0,0 +1,94 @@
|
||||
# Agent Guidelines for Light Chat
|
||||
|
||||
This document outlines the rules and principles for AI agents (or developers) working on the light_chat codebase.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Simplicity** - Keep the codebase simple and easy to understand
|
||||
2. **Modularity** - Separate concerns; each module has a single responsibility
|
||||
3. **Reusability** - Abstract common functionality for multiple use cases
|
||||
4. **Test Coverage** - All functions and components must have unit tests
|
||||
|
||||
## Git Practices
|
||||
|
||||
### Commit Standards
|
||||
- Write clear, descriptive commit messages (imperative mood: "Add feature" not "Added feature")
|
||||
- Use conventional commits format: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`
|
||||
- Make atomic commits - each commit should represent a single logical change
|
||||
- Reference issue numbers in commit messages when applicable
|
||||
|
||||
### Branch Strategy
|
||||
- Use `main` as the stable branch
|
||||
- Create feature branches: `feature/description` or `feat/description`
|
||||
- Use bugfix branches: `fix/description`
|
||||
- Delete branches after merging to keep repository clean
|
||||
|
||||
### Pull Requests
|
||||
- Keep PRs small and focused
|
||||
- Include a clear description of changes and rationale
|
||||
- Ensure all tests pass before requesting review
|
||||
- Request code review for all changes (unless trivial)
|
||||
- Address review feedback promptly
|
||||
|
||||
### Before Committing
|
||||
- Run tests locally to verify functionality
|
||||
- Check for unnecessary files (node_modules, build artifacts, env files)
|
||||
- Verify no secrets or sensitive data are committed
|
||||
- Rebase/squash commits if needed for clean history
|
||||
|
||||
### Code History
|
||||
- Avoid force pushes to shared branches
|
||||
- Regularly pull latest changes from main to avoid conflicts
|
||||
- Use `git status` to verify what you're committing
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### Function Design
|
||||
- Before creating a new function, check if a similar one exists that can be extended
|
||||
- Abstract shared logic into utility functions
|
||||
- Keep functions focused on a single task
|
||||
|
||||
### Component Architecture
|
||||
- Components should be small and composable
|
||||
- Separate UI from business logic
|
||||
- Use custom hooks for shared stateful logic
|
||||
|
||||
### Testing Requirements
|
||||
- Every new component needs test coverage
|
||||
- Mock external dependencies (API calls, storage, etc.)
|
||||
- Test both success and error cases
|
||||
- Use descriptive test names
|
||||
|
||||
## API Communication Pattern
|
||||
|
||||
The LLM API integration should be minimal:
|
||||
- Simple GET/POST requests
|
||||
- Extract "content" from responses
|
||||
- Handle errors gracefully
|
||||
- Allow custom endpoint configuration
|
||||
|
||||
## State Management
|
||||
|
||||
- Use React hooks (useState, useContext) for simplicity
|
||||
- Session data should be persistable
|
||||
- Maintain connection state (connected/disconnected)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
components/ # Reusable UI components
|
||||
hooks/ # Custom React hooks
|
||||
utils/ # Utility functions and API clients
|
||||
services/ # Business logic and API services
|
||||
stores/ # State management (if needed)
|
||||
tests/ # Unit tests
|
||||
```
|
||||
|
||||
## Workflow for New Features
|
||||
|
||||
1. Identify existing similar functionality
|
||||
2. Abstract common patterns if applicable
|
||||
3. Implement with test-driven development
|
||||
4. Write tests first, then code
|
||||
5. Keep it simple - avoid over-engineering
|
||||
@@ -0,0 +1,3 @@
|
||||
Lightweigh app that i will run on android. Built using react (not react-native), capacitor for the wrapper and ill run android studio myself in the end for the actual apk generation. It is a chat app to chat with locally (or remotely) hosted llms. When the user enters, if there is no memory of the last connection, it asks for a api endpoint (openai-like). the user provides it and they have a smal top left button to open a drawer with all sessions (also buttons to add/remove sessions). The actual chat window will be very minimal. The theme will be dark/black. there should be a "save conversation" button that exports it as some form of file (.log?.txt?.md?). the communication with the llm api should be something simple, if it was a bash program, i'd literally just wrap user prompts in a curl template and extract the "content" from the output, for a react app you figure the get/post. oh, also an option to add custom system prompts / inject my own prompts.
|
||||
|
||||
codebase rules: it needs to be simple. it must be modular. when making functions, think if there isn't a similar one that you can abstract for both usecases. it needs unit tests.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Light Chat
|
||||
|
||||
A lightweight Android chat application built with React and Capacitor for connecting to locally or remotely hosted LLMs.
|
||||
|
||||
## Features
|
||||
|
||||
- **LLM API Integration**: Connect to any OpenAI-compatible API endpoint
|
||||
- **Session Management**: Multiple chat sessions with add/remove functionality
|
||||
- **Dark Theme**: Sleek black/dark UI design
|
||||
- **Conversation Export**: Save chats as text, log, or markdown files
|
||||
- **Custom System Prompts**: Inject your own system prompts for personalized interactions
|
||||
- **Offline Ready**: Capacitor wrapper for native Android APK generation
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: React (Web)
|
||||
- **Mobile Wrapper**: Capacitor
|
||||
- **Build Tool**: Vite (recommended) or Create React App
|
||||
- **Testing**: Jest / React Testing Library
|
||||
- **Android Studio**: For final APK generation
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v18+)
|
||||
- npm or yarn
|
||||
- Android Studio (for APK building)
|
||||
- Capacitor CLI
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Build for Android
|
||||
|
||||
```bash
|
||||
# Build the web app
|
||||
npm run build
|
||||
|
||||
# Sync with Capacitor
|
||||
npx cap sync
|
||||
|
||||
# Open in Android Studio
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
The codebase follows these principles:
|
||||
|
||||
- **Simple**: Keep things straightforward
|
||||
- **Modular**: Separate concerns into distinct modules
|
||||
- **Reusable**: Abstract common functionality
|
||||
- **Tested**: Unit tests for all components
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding features:
|
||||
1. Look for existing similar functions to abstract
|
||||
2. Write unit tests
|
||||
3. Keep components small and focused
|
||||
4. Follow the modular architecture
|
||||
@@ -0,0 +1,13 @@
|
||||
import { CapacitorConfig } from '@capacitor/cli/config'
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.lightchat.app',
|
||||
appName: 'Light Chat',
|
||||
webDir: 'dist',
|
||||
bundledWebRuntime: false,
|
||||
server: {
|
||||
androidScheme: 'https'
|
||||
}
|
||||
}
|
||||
|
||||
export default config
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Light Chat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5498
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "light-chat",
|
||||
"version": "0.1.0",
|
||||
"description": "Lightweight chat app for connecting to LLM APIs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@capacitor/core": "^5.0.0",
|
||||
"@capacitor/cli": "^5.0.0",
|
||||
"@capacitor/android": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"vite": "^4.4.0",
|
||||
"vitest": "^0.34.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
"jsdom": "^22.0.0"
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react'
|
||||
import { useSessions } from './hooks/useSessions.js'
|
||||
import { ChatWindow } from './components/ChatWindow.jsx'
|
||||
import { SessionDrawer } from './components/SessionDrawer.jsx'
|
||||
|
||||
/**
|
||||
* Main App Component
|
||||
* Follows AGENTS.md: Simple, modular, focused
|
||||
*/
|
||||
export default function App() {
|
||||
const {
|
||||
sessions,
|
||||
currentSession,
|
||||
currentSessionId,
|
||||
config,
|
||||
isLoading,
|
||||
setCurrentSessionId,
|
||||
createSession,
|
||||
removeSession,
|
||||
addMessage,
|
||||
updateConfig
|
||||
} = useSessions()
|
||||
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: '100vh' }}>
|
||||
{/* Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsDrawerOpen(true)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '16px',
|
||||
zIndex: 100,
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px'
|
||||
}}
|
||||
title="Open Menu"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
<ChatWindow
|
||||
session={currentSession}
|
||||
config={config}
|
||||
onAddMessage={addMessage}
|
||||
/>
|
||||
|
||||
<SessionDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectSession={setCurrentSessionId}
|
||||
onCreateSession={createSession}
|
||||
onDeleteSession={removeSession}
|
||||
config={config}
|
||||
onUpdateConfig={updateConfig}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import App from './App.jsx'
|
||||
|
||||
// Mock only the components, not the hook
|
||||
vi.mock('./components/ChatWindow.jsx', () => ({
|
||||
ChatWindow: () => <div data-testid="chat-window">ChatWindow Mock</div>
|
||||
}))
|
||||
|
||||
vi.mock('./components/SessionDrawer.jsx', () => ({
|
||||
SessionDrawer: ({ isOpen }) => isOpen ? <div data-testid="session-drawer">SessionDrawer Mock</div> : null
|
||||
}))
|
||||
|
||||
describe('App', () => {
|
||||
it('should render app with menu button', async () => {
|
||||
render(<App />)
|
||||
|
||||
// Wait for loading to finish (hook will set isLoading false after mount)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-window')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Check menu button exists
|
||||
expect(screen.getByTitle(/Open Menu/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open session drawer when menu clicked', async () => {
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-window')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTitle(/Open Menu/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('session-drawer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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: '' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import * as sessionService from '../services/sessionService.js'
|
||||
|
||||
/**
|
||||
* Custom hook for managing chat sessions
|
||||
* Provides reactive state management following AGENTS.md guidelines
|
||||
*/
|
||||
export function useSessions() {
|
||||
const [sessions, setSessions] = useState([])
|
||||
const [currentSessionId, setCurrentSessionId] = useState(null)
|
||||
const [config, setConfig] = useState({ endpoint: '', systemPrompt: '' })
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Load data on mount
|
||||
useEffect(() => {
|
||||
const loadedSessions = sessionService.loadSessions()
|
||||
const loadedConfig = sessionService.loadConfig()
|
||||
setSessions(loadedSessions)
|
||||
setConfig(loadedConfig)
|
||||
setIsLoading(false)
|
||||
|
||||
// Set current session to first one if available
|
||||
if (loadedSessions.length > 0) {
|
||||
setCurrentSessionId(loadedSessions[0].id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keep localStorage in sync when sessions change
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
sessionService.saveSessions(sessions)
|
||||
}
|
||||
}, [sessions, isLoading])
|
||||
|
||||
// Keep localStorage in sync when config changes
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
sessionService.saveConfig(config)
|
||||
}
|
||||
}, [config, isLoading])
|
||||
|
||||
/**
|
||||
* Create a new session
|
||||
*/
|
||||
const createSession = useCallback((endpoint = config.endpoint, systemPrompt = config.systemPrompt) => {
|
||||
const newSession = sessionService.addSession({ endpoint, systemPrompt })
|
||||
setSessions(prev => [...prev, newSession])
|
||||
setCurrentSessionId(newSession.id)
|
||||
return newSession
|
||||
}, [config])
|
||||
|
||||
/**
|
||||
* Delete a session
|
||||
*/
|
||||
const removeSession = useCallback((sessionId) => {
|
||||
const success = sessionService.deleteSession(sessionId)
|
||||
if (success) {
|
||||
setSessions(prev => prev.filter(s => s.id !== sessionId))
|
||||
if (currentSessionId === sessionId) {
|
||||
const remaining = sessions.filter(s => s.id !== sessionId)
|
||||
setCurrentSessionId(remaining.length > 0 ? remaining[0].id : null)
|
||||
}
|
||||
}
|
||||
return success
|
||||
}, [currentSessionId, sessions])
|
||||
|
||||
/**
|
||||
* Update a session
|
||||
*/
|
||||
const updateSession = useCallback((sessionId, updates) => {
|
||||
const updated = sessionService.updateSession(sessionId, updates)
|
||||
if (updated) {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? updated : s))
|
||||
}
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Get current session object
|
||||
*/
|
||||
const currentSession = sessions.find(s => s.id === currentSessionId) || null
|
||||
|
||||
/**
|
||||
* Add message to current session
|
||||
*/
|
||||
const addMessage = useCallback((role, content) => {
|
||||
if (!currentSessionId) return null
|
||||
|
||||
const message = { role, content }
|
||||
const updated = sessionService.addMessage(currentSessionId, message)
|
||||
|
||||
if (updated) {
|
||||
setSessions(prev => prev.map(s => s.id === currentSessionId ? updated : s))
|
||||
}
|
||||
|
||||
return updated
|
||||
}, [currentSessionId])
|
||||
|
||||
/**
|
||||
* Update config
|
||||
*/
|
||||
const updateConfig = useCallback((updates) => {
|
||||
setConfig(prev => ({ ...prev, ...updates }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State
|
||||
sessions,
|
||||
currentSession,
|
||||
currentSessionId,
|
||||
config,
|
||||
isLoading,
|
||||
|
||||
// Actions
|
||||
setCurrentSessionId,
|
||||
createSession,
|
||||
removeSession,
|
||||
updateSession,
|
||||
addMessage,
|
||||
updateConfig
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useSessions } from '../hooks/useSessions.js'
|
||||
import * as sessionService from '../services/sessionService.js'
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {}
|
||||
return {
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => { store[key] = value }),
|
||||
removeItem: vi.fn((key) => { delete store[key] }),
|
||||
clear: vi.fn(() => { store = {} })
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
|
||||
describe('useSessions', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should load sessions and config from localStorage on mount', () => {
|
||||
const sessions = [
|
||||
{ id: '1', name: 'Session 1', messages: [], endpoint: '', systemPrompt: '' }
|
||||
]
|
||||
const config = { endpoint: 'https://api.test.com', systemPrompt: 'Be helpful' }
|
||||
|
||||
localStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
||||
if (key === 'light-chat-config') return JSON.stringify(config)
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSessions())
|
||||
|
||||
expect(result.current.sessions).toEqual(sessions)
|
||||
expect(result.current.config).toEqual(config)
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should set first session as current when available', () => {
|
||||
const sessions = [
|
||||
{ id: '1', name: 'Session 1', messages: [] },
|
||||
{ id: '2', name: 'Session 2', messages: [] }
|
||||
]
|
||||
localStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSessions())
|
||||
|
||||
expect(result.current.currentSessionId).toBe('1')
|
||||
expect(result.current.currentSession.id).toBe('1')
|
||||
})
|
||||
|
||||
it('should not set current session when no sessions exist', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
const { result } = renderHook(() => useSessions())
|
||||
|
||||
expect(result.current.currentSessionId).toBeNull()
|
||||
expect(result.current.currentSession).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create new session with config defaults', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
const { result } = renderHook(() => useSessions())
|
||||
|
||||
act(() => {
|
||||
result.current.createSession()
|
||||
})
|
||||
|
||||
expect(result.current.sessions).toHaveLength(1)
|
||||
expect(result.current.currentSessionId).toBe(result.current.sessions[0].id)
|
||||
})
|
||||
|
||||
it('should create session with custom options', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
const { result } = renderHook(() => useSessions())
|
||||
|
||||
act(() => {
|
||||
result.current.createSession('https://custom.com', 'Custom prompt')
|
||||
})
|
||||
|
||||
expect(result.current.sessions[0].endpoint).toBe('https://custom.com')
|
||||
expect(result.current.sessions[0].systemPrompt).toBe('Custom prompt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSession', () => {
|
||||
it('should remove session and update current session', () => {
|
||||
const sessions = [
|
||||
{ id: '1', name: 'Session 1', messages: [] },
|
||||
{ id: '2', name: 'Session 2', messages: [] }
|
||||
]
|
||||
localStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSessions())
|
||||
|
||||
act(() => {
|
||||
result.current.removeSession('1')
|
||||
})
|
||||
|
||||
expect(result.current.sessions).toHaveLength(1)
|
||||
expect(result.current.sessions[0].id).toBe('2')
|
||||
expect(result.current.currentSessionId).toBe('2')
|
||||
})
|
||||
|
||||
it('should clear current session if deleted session was current and no others exist', () => {
|
||||
const sessions = [
|
||||
{ id: '1', name: 'Session 1', messages: [] }
|
||||
]
|
||||
localStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSessions())
|
||||
|
||||
act(() => {
|
||||
result.current.removeSession('1')
|
||||
})
|
||||
|
||||
expect(result.current.sessions).toHaveLength(0)
|
||||
expect(result.current.currentSessionId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('addMessage', () => {
|
||||
it('should add message to current session', () => {
|
||||
const session = {
|
||||
id: '1',
|
||||
name: 'Session 1',
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
localStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === 'light-chat-sessions') return JSON.stringify([session])
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSessions())
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('user', 'Hello')
|
||||
})
|
||||
|
||||
expect(result.current.currentSession.messages).toHaveLength(1)
|
||||
expect(result.current.currentSession.messages[0]).toEqual({ role: 'user', content: 'Hello' })
|
||||
})
|
||||
|
||||
it('should not add message if no current session', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
const { result } = renderHook(() => useSessions())
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('user', 'Hello')
|
||||
})
|
||||
|
||||
expect(result.current.currentSession).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateConfig', () => {
|
||||
it('should update config', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
const { result } = renderHook(() => useSessions())
|
||||
|
||||
act(() => {
|
||||
result.current.updateConfig({ endpoint: 'https://new.com' })
|
||||
})
|
||||
|
||||
expect(result.current.config.endpoint).toBe('https://new.com')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
:root {
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-tertiary: #2a2a2a;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b0b0b0;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--border: #333333;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Session Management Service
|
||||
* Handles creation, storage, and retrieval of chat sessions
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'light-chat-sessions'
|
||||
const CONFIG_KEY = 'light-chat-config'
|
||||
|
||||
/**
|
||||
* Generate unique session ID
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default session structure
|
||||
*/
|
||||
function createSession(endpoint = '', systemPrompt = '') {
|
||||
return {
|
||||
id: generateId(),
|
||||
name: `Session ${new Date().toLocaleTimeString()}`,
|
||||
endpoint,
|
||||
systemPrompt,
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all sessions from storage
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function loadSessions() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY)
|
||||
return data ? JSON.parse(data) : []
|
||||
} catch (error) {
|
||||
console.error('Failed to load sessions:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sessions to storage
|
||||
* @param {Array} sessions
|
||||
*/
|
||||
export function saveSessions(sessions) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
|
||||
} catch (error) {
|
||||
console.error('Failed to save sessions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load API configuration
|
||||
* @returns {Object} { endpoint, systemPrompt }
|
||||
*/
|
||||
export function loadConfig() {
|
||||
try {
|
||||
const data = localStorage.getItem(CONFIG_KEY)
|
||||
return data ? JSON.parse(data) : { endpoint: '', systemPrompt: '' }
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error)
|
||||
return { endpoint: '', systemPrompt: '' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save API configuration
|
||||
* @param {Object} config
|
||||
*/
|
||||
export function saveConfig(config) {
|
||||
try {
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new session
|
||||
* @param {Object} options
|
||||
* @returns {Object} new session
|
||||
*/
|
||||
export function addSession(options = {}) {
|
||||
const sessions = loadSessions()
|
||||
const newSession = createSession(
|
||||
options.endpoint,
|
||||
options.systemPrompt
|
||||
)
|
||||
sessions.push(newSession)
|
||||
saveSessions(sessions)
|
||||
return newSession
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session
|
||||
* @param {string} sessionId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function deleteSession(sessionId) {
|
||||
const sessions = loadSessions()
|
||||
const filtered = sessions.filter(s => s.id !== sessionId)
|
||||
if (filtered.length === sessions.length) {
|
||||
return false
|
||||
}
|
||||
saveSessions(filtered)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a session
|
||||
* @param {string} sessionId
|
||||
* @param {Object} updates
|
||||
* @returns {Object|null} updated session or null
|
||||
*/
|
||||
export function updateSession(sessionId, updates) {
|
||||
const sessions = loadSessions()
|
||||
const index = sessions.findIndex(s => s.id === sessionId)
|
||||
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
sessions[index] = {
|
||||
...sessions[index],
|
||||
...updates,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
// Update name if messages exist and name is default
|
||||
if (sessions[index].messages.length > 0 && sessions[index].name.startsWith('Session')) {
|
||||
sessions[index].name = `Chat ${new Date(sessions[index].createdAt).toLocaleDateString()}`
|
||||
}
|
||||
|
||||
saveSessions(sessions)
|
||||
return sessions[index]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific session
|
||||
* @param {string} sessionId
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function getSession(sessionId) {
|
||||
const sessions = loadSessions()
|
||||
return sessions.find(s => s.id === sessionId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Add message to a session
|
||||
* @param {string} sessionId
|
||||
* @param {Object} message - { role: 'user'|'assistant', content: string }
|
||||
* @returns {Object|null} updated session
|
||||
*/
|
||||
export function addMessage(sessionId, message) {
|
||||
const session = getSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
session.messages.push(message)
|
||||
return updateSession(sessionId, { messages: session.messages })
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as sessionService from '../services/sessionService.js'
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {}
|
||||
return {
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => { store[key] = value }),
|
||||
removeItem: vi.fn((key) => { delete store[key] }),
|
||||
clear: vi.fn(() => { store = {} })
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
|
||||
describe('Session Service', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('loadSessions', () => {
|
||||
it('should return empty array when no data exists', () => {
|
||||
const sessions = sessionService.loadSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('should return parsed sessions from localStorage', () => {
|
||||
const testSessions = [
|
||||
{ id: '1', name: 'Test', messages: [], createdAt: Date.now(), updatedAt: Date.now() }
|
||||
]
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(testSessions))
|
||||
|
||||
const sessions = sessionService.loadSessions()
|
||||
expect(sessions).toEqual(testSessions)
|
||||
})
|
||||
|
||||
it('should return empty array on parse error', () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce('invalid json')
|
||||
const sessions = sessionService.loadSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveSessions', () => {
|
||||
it('should save sessions to localStorage', () => {
|
||||
const sessions = [{ id: '1', name: 'Test' }]
|
||||
sessionService.saveSessions(sessions)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'light-chat-sessions',
|
||||
JSON.stringify(sessions)
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle save error gracefully', () => {
|
||||
localStorageMock.setItem.mockImplementationOnce(() => {
|
||||
throw new Error('Storage full')
|
||||
})
|
||||
expect(() => sessionService.saveSessions([{ id: '1' }])).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('should return default config when no data exists', () => {
|
||||
const config = sessionService.loadConfig()
|
||||
expect(config).toEqual({ endpoint: '', systemPrompt: '' })
|
||||
})
|
||||
|
||||
it('should return parsed config from localStorage', () => {
|
||||
const testConfig = { endpoint: 'https://api.test.com', systemPrompt: 'Be helpful' }
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(testConfig))
|
||||
|
||||
const config = sessionService.loadConfig()
|
||||
expect(config).toEqual(testConfig)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveConfig', () => {
|
||||
it('should save config to localStorage', () => {
|
||||
const config = { endpoint: 'https://api.test.com', systemPrompt: 'Be helpful' }
|
||||
sessionService.saveConfig(config)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'light-chat-config',
|
||||
JSON.stringify(config)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addSession', () => {
|
||||
it('should create and return new session', () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify([]))
|
||||
|
||||
const session = sessionService.addSession({ endpoint: 'https://api.test.com' })
|
||||
|
||||
expect(session).toHaveProperty('id')
|
||||
expect(session.endpoint).toBe('https://api.test.com')
|
||||
expect(session.messages).toEqual([])
|
||||
expect(session).toHaveProperty('createdAt')
|
||||
expect(session).toHaveProperty('updatedAt')
|
||||
})
|
||||
|
||||
it('should use empty defaults when no options provided', () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify([]))
|
||||
|
||||
const session = sessionService.addSession()
|
||||
expect(session.endpoint).toBe('')
|
||||
expect(session.systemPrompt).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSession', () => {
|
||||
it('should return false when session not found', () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify([{ id: 'other' }]))
|
||||
const result = sessionService.deleteSession('nonexistent')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should delete session and return true', () => {
|
||||
const sessions = [
|
||||
{ id: '1', name: 'Test 1', messages: [], createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: '2', name: 'Test 2', messages: [], createdAt: Date.now(), updatedAt: Date.now() }
|
||||
]
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(sessions))
|
||||
|
||||
const result = sessionService.deleteSession('1')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSession', () => {
|
||||
it('should return null when session not found', () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify([]))
|
||||
const result = sessionService.updateSession('nonexistent', { name: 'New' })
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it('should update session and return updated session', () => {
|
||||
const existing = {
|
||||
id: '1',
|
||||
name: 'Old',
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify([existing]))
|
||||
|
||||
const result = sessionService.updateSession('1', { name: 'New' })
|
||||
|
||||
expect(result.name).toBe('New')
|
||||
expect(result.id).toBe('1')
|
||||
expect(result.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('should update updatedAt timestamp', () => {
|
||||
const existing = {
|
||||
id: '1',
|
||||
name: 'Test',
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify([existing]))
|
||||
|
||||
const beforeUpdate = existing.updatedAt
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(Date.now() + 1000)
|
||||
|
||||
const result = sessionService.updateSession('1', { name: 'New' })
|
||||
|
||||
expect(result.updatedAt).toBeGreaterThan(beforeUpdate)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSession', () => {
|
||||
it('should return null when session not found', () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify([]))
|
||||
const session = sessionService.getSession('nonexistent')
|
||||
expect(session).toBeNull()
|
||||
})
|
||||
|
||||
it('should return session when found', () => {
|
||||
const sessions = [
|
||||
{ id: '1', name: 'Test' },
|
||||
{ id: '2', name: 'Test 2' }
|
||||
]
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(sessions))
|
||||
|
||||
const session = sessionService.getSession('2')
|
||||
expect(session.name).toBe('Test 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('addMessage', () => {
|
||||
it('should add message to session', () => {
|
||||
const session = {
|
||||
id: '1',
|
||||
name: 'Test',
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify([session]))
|
||||
|
||||
const result = sessionService.addMessage('1', { role: 'user', content: 'Hello' })
|
||||
|
||||
expect(result.messages).toHaveLength(1)
|
||||
expect(result.messages[0]).toEqual({ role: 'user', content: 'Hello' })
|
||||
})
|
||||
|
||||
it('should return null for nonexistent session', () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify([]))
|
||||
const result = sessionService.addMessage('nonexistent', { role: 'user', content: 'Hello' })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock scrollIntoView
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* LLM API Client
|
||||
* Simple wrapper for OpenAI-compatible APIs
|
||||
*/
|
||||
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the LLM API and get a response
|
||||
* @param {string} endpoint - API endpoint URL
|
||||
* @param {string} message - User message
|
||||
* @param {Array} messages - Full message history (optional, for multi-turn)
|
||||
* @param {string} systemPrompt - System prompt (optional)
|
||||
* @returns {Promise<string>} - LLM response content
|
||||
*/
|
||||
export async function sendMessage(endpoint, message, messages = [], systemPrompt = '') {
|
||||
if (!endpoint) {
|
||||
throw new Error('API endpoint is required')
|
||||
}
|
||||
|
||||
// Build messages array for API
|
||||
const apiMessages = []
|
||||
|
||||
if (systemPrompt) {
|
||||
apiMessages.push({ role: 'system', content: systemPrompt })
|
||||
}
|
||||
|
||||
// Add history (convert our format to API format)
|
||||
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: 'gpt-3.5-turbo', // or appropriate model
|
||||
messages: apiMessages,
|
||||
stream: false
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`API request failed: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Extract content from response (OpenAI format)
|
||||
const content = data.choices?.[0]?.message?.content
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Invalid response format: no content found')
|
||||
}
|
||||
|
||||
return content
|
||||
} catch (error) {
|
||||
console.error('LLM API error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to API endpoint
|
||||
* @param {string} endpoint - API endpoint URL
|
||||
* @returns {Promise<boolean>} - Whether connection succeeded
|
||||
*/
|
||||
export async function testConnection(endpoint) {
|
||||
try {
|
||||
// Simple test - send minimal message
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: DEFAULT_HEADERS,
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
max_tokens: 1
|
||||
})
|
||||
})
|
||||
|
||||
// Even error responses might indicate endpoint is reachable
|
||||
return response.status !== 404 && response.status !== 0
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { sendMessage, testConnection } from '../utils/llmApi.js'
|
||||
|
||||
describe('LLM API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('sendMessage', () => {
|
||||
it('should throw error when endpoint is missing', async () => {
|
||||
await expect(sendMessage('', 'hello')).rejects.toThrow('API endpoint is required')
|
||||
})
|
||||
|
||||
it('should send POST request and extract content from response', async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
choices: [{ message: { content: 'Hello there!' } }]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const result = await sendMessage('https://api.example.com/chat', 'Hi')
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/chat',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
expect(result).toBe('Hello there!')
|
||||
})
|
||||
|
||||
it('should throw error when response has no content', async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ choices: [] })
|
||||
})
|
||||
)
|
||||
|
||||
await expect(sendMessage('https://api.example.com/chat', 'Hi'))
|
||||
.rejects.toThrow('Invalid response format: no content found')
|
||||
})
|
||||
|
||||
it('should throw error on network failure', async () => {
|
||||
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')))
|
||||
|
||||
await expect(sendMessage('https://api.example.com/chat', 'Hi'))
|
||||
.rejects.toThrow('Network error')
|
||||
})
|
||||
|
||||
it('should include system prompt when provided', async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
choices: [{ message: { content: 'Response' } }]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await sendMessage('https://api.example.com/chat', 'Hi', [], 'You are helpful')
|
||||
|
||||
const callBody = JSON.parse(global.fetch.mock.calls[0][1].body)
|
||||
expect(callBody.messages[0]).toEqual({ role: 'system', content: 'You are helpful' })
|
||||
})
|
||||
|
||||
it('should include message history', async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
choices: [{ message: { content: 'Response' } }]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const history = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there!' }
|
||||
]
|
||||
|
||||
await sendMessage('https://api.example.com/chat', 'How are you?', history)
|
||||
|
||||
const callBody = JSON.parse(global.fetch.mock.calls[0][1].body)
|
||||
expect(callBody.messages[0]).toEqual({ role: 'user', content: 'Hello' })
|
||||
expect(callBody.messages[1]).toEqual({ role: 'assistant', content: 'Hi there!' })
|
||||
expect(callBody.messages[2]).toEqual({ role: 'user', content: 'How are you?' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('should return true on successful response', async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ status: 200 })
|
||||
)
|
||||
|
||||
const result = await testConnection('https://api.example.com/chat')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true on error response (not 404)', async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ status: 401 })
|
||||
)
|
||||
|
||||
const result = await testConnection('https://api.example.com/chat')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false on 404', async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ status: 404 })
|
||||
)
|
||||
|
||||
const result = await testConnection('https://api.example.com/chat')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false on network error', async () => {
|
||||
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')))
|
||||
|
||||
const result = await testConnection('https://api.example.com/chat')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: './src/tests/setup.js',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user