refactor: convert project from JavaScript to TypeScript and switch to pnpm
- Convert all .js/.jsx files to .ts/.tsx - Add TypeScript configuration (tsconfig.json, tsconfig.node.json) - Update vite.config.js to vite.config.ts - Add TypeScript type definitions (@types/react, @types/react-dom, @types/node) - Replace npm with pnpm (pnpm-lock.yaml) - Add model configuration option for LLM API - Update package.json scripts for TypeScript build - All 52 tests passing
This commit is contained in:
+1
-1
@@ -8,6 +8,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Generated
-5513
File diff suppressed because it is too large
Load Diff
+7
-4
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "light-chat",
|
"name": "light-chat",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"description": "Lightweight chat app for connecting to LLM APIs",
|
"description": "Lightweight chat app for connecting to LLM APIs - TypeScript + pnpm",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^5.0.0",
|
"@capacitor/android": "^5.0.0",
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
"@testing-library/jest-dom": "^5.17.0",
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/react": "^14.0.0",
|
||||||
"@testing-library/user-event": "^14.5.0",
|
"@testing-library/user-event": "^14.5.0",
|
||||||
|
"@types/node": "^25.3.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"jsdom": "^22.0.0",
|
"jsdom": "^22.0.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
Generated
+3352
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import App from './App.jsx'
|
import App from './App'
|
||||||
|
|
||||||
// Mock only the components, not the hook
|
// Mock only the components, not the hook
|
||||||
vi.mock('./components/ChatWindow.jsx', () => ({
|
vi.mock('./components/ChatWindow', () => ({
|
||||||
ChatWindow: () => <div data-testid="chat-window">ChatWindow Mock</div>
|
ChatWindow: () => <div data-testid="chat-window">ChatWindow Mock</div>
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('./components/SessionDrawer.jsx', () => ({
|
vi.mock('./components/SessionDrawer', () => ({
|
||||||
SessionDrawer: ({ isOpen }) => isOpen ? <div data-testid="session-drawer">SessionDrawer Mock</div> : null
|
SessionDrawer: ({ isOpen }: { isOpen: boolean }) => isOpen ? <div data-testid="session-drawer">SessionDrawer Mock</div> : null
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
@@ -31,10 +31,12 @@ describe('App', () => {
|
|||||||
expect(screen.getByTestId('chat-window')).toBeInTheDocument()
|
expect(screen.getByTestId('chat-window')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle(/Open Menu/i))
|
await waitFor(() => {
|
||||||
|
screen.getByTitle(/Open Menu/i).click()
|
||||||
|
})
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('session-drawer')).toBeInTheDocument()
|
expect(screen.getByTestId('session-drawer')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { useSessions } from './hooks/useSessions'
|
||||||
|
import { ChatWindow } from './components/ChatWindow'
|
||||||
|
import { SessionDrawer } from './components/SessionDrawer'
|
||||||
import { useState } from 'react'
|
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
|
* Main App Component
|
||||||
@@ -80,4 +80,4 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { render, screen, waitFor, act } from '@testing-library/react'
|
import { render, screen, waitFor, act } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { ChatWindow } from '../components/ChatWindow.jsx'
|
import { ChatWindow } from './ChatWindow'
|
||||||
import { sendMessage } from '../utils/llmApi.js'
|
import { sendMessage } from '../utils/llmApi'
|
||||||
|
import type { Session, Config } from '../services/sessionService'
|
||||||
|
|
||||||
// Mock the llmApi module
|
// Mock the llmApi module
|
||||||
vi.mock('../utils/llmApi.js', () => ({
|
vi.mock('../utils/llmApi', () => ({
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
testConnection: vi.fn()
|
testConnection: vi.fn()
|
||||||
}))
|
}))
|
||||||
@@ -15,12 +16,17 @@ describe('ChatWindow', () => {
|
|||||||
session: {
|
session: {
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Test Session',
|
name: 'Test Session',
|
||||||
messages: []
|
messages: [],
|
||||||
},
|
endpoint: '',
|
||||||
|
systemPrompt: '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
} as Session,
|
||||||
config: {
|
config: {
|
||||||
endpoint: 'https://api.test.com',
|
endpoint: 'https://api.test.com',
|
||||||
systemPrompt: 'You are helpful'
|
systemPrompt: 'You are helpful',
|
||||||
},
|
model: 'local-swarm'
|
||||||
|
} as Config,
|
||||||
onAddMessage: vi.fn()
|
onAddMessage: vi.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,8 +50,8 @@ describe('ChatWindow', () => {
|
|||||||
const sessionWithMessages = {
|
const sessionWithMessages = {
|
||||||
...defaultProps.session,
|
...defaultProps.session,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'user', content: 'Hello' },
|
{ role: 'user' as const, content: 'Hello' },
|
||||||
{ role: 'assistant', content: 'Hi there!' }
|
{ role: 'assistant' as const, content: 'Hi there!' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +63,7 @@ describe('ChatWindow', () => {
|
|||||||
|
|
||||||
it('should handle form submission', async () => {
|
it('should handle form submission', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
sendMessage.mockResolvedValueOnce('Response')
|
vi.mocked(sendMessage).mockResolvedValueOnce('Response')
|
||||||
|
|
||||||
render(<ChatWindow {...defaultProps} />)
|
render(<ChatWindow {...defaultProps} />)
|
||||||
|
|
||||||
@@ -78,7 +84,7 @@ describe('ChatWindow', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const noEndpointProps = {
|
const noEndpointProps = {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
config: { endpoint: '', systemPrompt: '' }
|
config: { endpoint: '', systemPrompt: '', model: 'local-swarm' } as Config
|
||||||
}
|
}
|
||||||
render(<ChatWindow {...noEndpointProps} />)
|
render(<ChatWindow {...noEndpointProps} />)
|
||||||
|
|
||||||
@@ -98,4 +104,4 @@ describe('ChatWindow', () => {
|
|||||||
expect(input).toBeDisabled()
|
expect(input).toBeDisabled()
|
||||||
expect(input).toHaveAttribute('placeholder', 'Type your message...')
|
expect(input).toHaveAttribute('placeholder', 'Type your message...')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { sendMessage } from '../utils/llmApi.js'
|
import { sendMessage } from '../utils/llmApi'
|
||||||
|
import type { Session, Config } from '../services/sessionService'
|
||||||
|
|
||||||
|
interface ChatWindowProps {
|
||||||
|
session: Session | null
|
||||||
|
config: Config
|
||||||
|
onAddMessage: (role: 'user' | 'assistant', content: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ChatWindow - Main chat interface
|
* ChatWindow - Main chat interface
|
||||||
*/
|
*/
|
||||||
export function ChatWindow({ session, config, onAddMessage }) {
|
export function ChatWindow({ session, config, onAddMessage }: ChatWindowProps) {
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const messagesEndRef = useRef(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (messagesEndRef.current && typeof messagesEndRef.current.scrollIntoView === 'function') {
|
if (messagesEndRef.current && typeof messagesEndRef.current.scrollIntoView === 'function') {
|
||||||
@@ -20,7 +27,7 @@ export function ChatWindow({ session, config, onAddMessage }) {
|
|||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}, [session?.messages])
|
}, [session?.messages])
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!input.trim() || isLoading) return
|
if (!input.trim() || isLoading) return
|
||||||
|
|
||||||
@@ -45,13 +52,14 @@ export function ChatWindow({ session, config, onAddMessage }) {
|
|||||||
config.endpoint,
|
config.endpoint,
|
||||||
userMessage,
|
userMessage,
|
||||||
apiMessages,
|
apiMessages,
|
||||||
config.systemPrompt
|
config.systemPrompt,
|
||||||
|
config.model || 'local-swarm'
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add assistant response
|
// Add assistant response
|
||||||
onAddMessage('assistant', response)
|
onAddMessage('assistant', response)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Failed to get response')
|
setError(err instanceof Error ? err.message : 'Failed to get response')
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -224,4 +232,4 @@ export function ChatWindow({ session, config, onAddMessage }) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { SessionDrawer } from '../components/SessionDrawer.jsx'
|
import { SessionDrawer } from './SessionDrawer'
|
||||||
|
import type { Session, Config } from '../services/sessionService'
|
||||||
|
|
||||||
describe('SessionDrawer', () => {
|
describe('SessionDrawer', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
sessions: [],
|
sessions: [] as Session[],
|
||||||
currentSessionId: null,
|
currentSessionId: null,
|
||||||
onSelectSession: vi.fn(),
|
onSelectSession: vi.fn(),
|
||||||
onCreateSession: vi.fn(),
|
onCreateSession: vi.fn(),
|
||||||
onDeleteSession: vi.fn(),
|
onDeleteSession: vi.fn(),
|
||||||
config: { endpoint: '', systemPrompt: '' },
|
config: { endpoint: '', systemPrompt: '', model: 'local-swarm' } as Config,
|
||||||
onUpdateConfig: vi.fn()
|
onUpdateConfig: vi.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,15 +30,16 @@ describe('SessionDrawer', () => {
|
|||||||
render(<SessionDrawer {...defaultProps} />)
|
render(<SessionDrawer {...defaultProps} />)
|
||||||
|
|
||||||
const overlay = document.querySelector('[style*="position: fixed"]')
|
const overlay = document.querySelector('[style*="position: fixed"]')
|
||||||
fireEvent.click(overlay)
|
if (overlay) {
|
||||||
|
fireEvent.click(overlay)
|
||||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display sessions list', () => {
|
it('should display sessions list', () => {
|
||||||
const sessions = [
|
const sessions: Session[] = [
|
||||||
{ id: '1', name: 'Session 1', messages: [] },
|
{ id: '1', name: 'Session 1', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: '2', name: 'Session 2', messages: [] }
|
{ id: '2', name: 'Session 2', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }
|
||||||
]
|
]
|
||||||
render(<SessionDrawer {...defaultProps} sessions={sessions} currentSessionId="1" />)
|
render(<SessionDrawer {...defaultProps} sessions={sessions} currentSessionId="1" />)
|
||||||
|
|
||||||
@@ -46,7 +48,9 @@ describe('SessionDrawer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should call onSelectSession when session clicked', () => {
|
it('should call onSelectSession when session clicked', () => {
|
||||||
const sessions = [{ id: '1', name: 'Session 1', messages: [] }]
|
const sessions: Session[] = [
|
||||||
|
{ id: '1', name: 'Session 1', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }
|
||||||
|
]
|
||||||
render(<SessionDrawer {...defaultProps} sessions={sessions} />)
|
render(<SessionDrawer {...defaultProps} sessions={sessions} />)
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Session 1'))
|
fireEvent.click(screen.getByText('Session 1'))
|
||||||
@@ -72,6 +76,6 @@ describe('SessionDrawer', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Save Config/i }))
|
fireEvent.click(screen.getByRole('button', { name: /Save Config/i }))
|
||||||
|
|
||||||
expect(defaultProps.onUpdateConfig).toHaveBeenCalledWith({ endpoint: 'https://new.com', systemPrompt: '' })
|
expect(defaultProps.onUpdateConfig).toHaveBeenCalledWith({ endpoint: 'https://new.com', systemPrompt: '', model: 'local-swarm' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1,4 +1,17 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import type { Session, Config } from '../services/sessionService'
|
||||||
|
|
||||||
|
interface SessionDrawerProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
sessions: Session[]
|
||||||
|
currentSessionId: string | null
|
||||||
|
onSelectSession: (sessionId: string) => void
|
||||||
|
onCreateSession: () => void
|
||||||
|
onDeleteSession: (sessionId: string) => void
|
||||||
|
config: Config
|
||||||
|
onUpdateConfig: (config: Partial<Config>) => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SessionDrawer - Sidebar showing sessions list and config
|
* SessionDrawer - Sidebar showing sessions list and config
|
||||||
@@ -13,10 +26,10 @@ export function SessionDrawer({
|
|||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
config,
|
config,
|
||||||
onUpdateConfig
|
onUpdateConfig
|
||||||
}) {
|
}: SessionDrawerProps) {
|
||||||
const [localConfig, setLocalConfig] = useState(config)
|
const [localConfig, setLocalConfig] = useState<Config>(config)
|
||||||
|
|
||||||
const handleConfigChange = (field, value) => {
|
const handleConfigChange = (field: keyof Config, value: string) => {
|
||||||
setLocalConfig(prev => ({ ...prev, [field]: value }))
|
setLocalConfig(prev => ({ ...prev, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +37,7 @@ export function SessionDrawer({
|
|||||||
onUpdateConfig(localConfig)
|
onUpdateConfig(localConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -97,6 +110,17 @@ export function SessionDrawer({
|
|||||||
placeholder="https://api.openai.com/v1/chat/completions"
|
placeholder="https://api.openai.com/v1/chat/completions"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localConfig.model || 'local-swarm'}
|
||||||
|
onChange={(e) => handleConfigChange('model', e.target.value)}
|
||||||
|
placeholder="local-swarm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>
|
||||||
System Prompt
|
System Prompt
|
||||||
@@ -169,4 +193,4 @@ export function SessionDrawer({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { renderHook, act } from '@testing-library/react'
|
import { renderHook, act } from '@testing-library/react'
|
||||||
import { useSessions } from '../hooks/useSessions.js'
|
import { useSessions } from './useSessions'
|
||||||
import * as sessionService from '../services/sessionService.js'
|
|
||||||
|
|
||||||
// Mock localStorage
|
// Mock localStorage
|
||||||
const localStorageMock = (() => {
|
const localStorageMock = (() => {
|
||||||
let store = {}
|
let store: Record<string, string> = {}
|
||||||
return {
|
return {
|
||||||
getItem: vi.fn((key) => store[key] || null),
|
getItem: vi.fn((key) => store[key] || null),
|
||||||
setItem: vi.fn((key, value) => { store[key] = value }),
|
setItem: vi.fn((key, value) => { store[key] = value }),
|
||||||
@@ -25,9 +24,9 @@ describe('useSessions', () => {
|
|||||||
describe('initialization', () => {
|
describe('initialization', () => {
|
||||||
it('should load sessions and config from localStorage on mount', () => {
|
it('should load sessions and config from localStorage on mount', () => {
|
||||||
const sessions = [
|
const sessions = [
|
||||||
{ id: '1', name: 'Session 1', messages: [], endpoint: '', systemPrompt: '' }
|
{ id: '1', name: 'Session 1', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }
|
||||||
]
|
]
|
||||||
const config = { endpoint: 'https://api.test.com', systemPrompt: 'Be helpful' }
|
const config = { endpoint: 'https://api.test.com', systemPrompt: 'Be helpful', model: 'local-swarm' }
|
||||||
|
|
||||||
localStorageMock.getItem.mockImplementation((key) => {
|
localStorageMock.getItem.mockImplementation((key) => {
|
||||||
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
||||||
@@ -44,8 +43,8 @@ describe('useSessions', () => {
|
|||||||
|
|
||||||
it('should set first session as current when available', () => {
|
it('should set first session as current when available', () => {
|
||||||
const sessions = [
|
const sessions = [
|
||||||
{ id: '1', name: 'Session 1', messages: [] },
|
{ id: '1', name: 'Session 1', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: '2', name: 'Session 2', messages: [] }
|
{ id: '2', name: 'Session 2', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }
|
||||||
]
|
]
|
||||||
localStorageMock.getItem.mockImplementation((key) => {
|
localStorageMock.getItem.mockImplementation((key) => {
|
||||||
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
||||||
@@ -55,7 +54,7 @@ describe('useSessions', () => {
|
|||||||
const { result } = renderHook(() => useSessions())
|
const { result } = renderHook(() => useSessions())
|
||||||
|
|
||||||
expect(result.current.currentSessionId).toBe('1')
|
expect(result.current.currentSessionId).toBe('1')
|
||||||
expect(result.current.currentSession.id).toBe('1')
|
expect(result.current.currentSession!.id).toBe('1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not set current session when no sessions exist', () => {
|
it('should not set current session when no sessions exist', () => {
|
||||||
@@ -99,8 +98,8 @@ describe('useSessions', () => {
|
|||||||
describe('removeSession', () => {
|
describe('removeSession', () => {
|
||||||
it('should remove session and update current session', () => {
|
it('should remove session and update current session', () => {
|
||||||
const sessions = [
|
const sessions = [
|
||||||
{ id: '1', name: 'Session 1', messages: [] },
|
{ id: '1', name: 'Session 1', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: '2', name: 'Session 2', messages: [] }
|
{ id: '2', name: 'Session 2', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }
|
||||||
]
|
]
|
||||||
localStorageMock.getItem.mockImplementation((key) => {
|
localStorageMock.getItem.mockImplementation((key) => {
|
||||||
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
||||||
@@ -120,7 +119,7 @@ describe('useSessions', () => {
|
|||||||
|
|
||||||
it('should clear current session if deleted session was current and no others exist', () => {
|
it('should clear current session if deleted session was current and no others exist', () => {
|
||||||
const sessions = [
|
const sessions = [
|
||||||
{ id: '1', name: 'Session 1', messages: [] }
|
{ id: '1', name: 'Session 1', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }
|
||||||
]
|
]
|
||||||
localStorageMock.getItem.mockImplementation((key) => {
|
localStorageMock.getItem.mockImplementation((key) => {
|
||||||
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
if (key === 'light-chat-sessions') return JSON.stringify(sessions)
|
||||||
@@ -144,6 +143,8 @@ describe('useSessions', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
name: 'Session 1',
|
name: 'Session 1',
|
||||||
messages: [],
|
messages: [],
|
||||||
|
endpoint: '',
|
||||||
|
systemPrompt: '',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
@@ -158,8 +159,8 @@ describe('useSessions', () => {
|
|||||||
result.current.addMessage('user', 'Hello')
|
result.current.addMessage('user', 'Hello')
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.current.currentSession.messages).toHaveLength(1)
|
expect(result.current.currentSession!.messages).toHaveLength(1)
|
||||||
expect(result.current.currentSession.messages[0]).toEqual({ role: 'user', content: 'Hello' })
|
expect(result.current.currentSession!.messages[0]).toEqual({ role: 'user', content: 'Hello' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not add message if no current session', () => {
|
it('should not add message if no current session', () => {
|
||||||
@@ -188,4 +189,4 @@ describe('useSessions', () => {
|
|||||||
expect(result.current.config.endpoint).toBe('https://new.com')
|
expect(result.current.config.endpoint).toBe('https://new.com')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import * as sessionService from '../services/sessionService.js'
|
import * as sessionService from '../services/sessionService'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing chat sessions
|
* Custom hook for managing chat sessions
|
||||||
* Provides reactive state management following AGENTS.md guidelines
|
* Provides reactive state management following AGENTS.md guidelines
|
||||||
*/
|
*/
|
||||||
export function useSessions() {
|
export function useSessions() {
|
||||||
const [sessions, setSessions] = useState([])
|
const [sessions, setSessions] = useState<sessionService.Session[]>([])
|
||||||
const [currentSessionId, setCurrentSessionId] = useState(null)
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
|
||||||
const [config, setConfig] = useState({ endpoint: '', systemPrompt: '' })
|
const [config, setConfig] = useState<sessionService.Config>({ endpoint: '', systemPrompt: '', model: 'local-swarm' })
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
// Load data on mount
|
// Load data on mount
|
||||||
@@ -42,7 +42,10 @@ export function useSessions() {
|
|||||||
/**
|
/**
|
||||||
* Create a new session
|
* Create a new session
|
||||||
*/
|
*/
|
||||||
const createSession = useCallback((endpoint = config.endpoint, systemPrompt = config.systemPrompt) => {
|
const createSession = useCallback((
|
||||||
|
endpoint: string = config.endpoint,
|
||||||
|
systemPrompt: string = config.systemPrompt
|
||||||
|
) => {
|
||||||
const newSession = sessionService.addSession({ endpoint, systemPrompt })
|
const newSession = sessionService.addSession({ endpoint, systemPrompt })
|
||||||
setSessions(prev => [...prev, newSession])
|
setSessions(prev => [...prev, newSession])
|
||||||
setCurrentSessionId(newSession.id)
|
setCurrentSessionId(newSession.id)
|
||||||
@@ -52,7 +55,7 @@ export function useSessions() {
|
|||||||
/**
|
/**
|
||||||
* Delete a session
|
* Delete a session
|
||||||
*/
|
*/
|
||||||
const removeSession = useCallback((sessionId) => {
|
const removeSession = useCallback((sessionId: string) => {
|
||||||
const success = sessionService.deleteSession(sessionId)
|
const success = sessionService.deleteSession(sessionId)
|
||||||
if (success) {
|
if (success) {
|
||||||
setSessions(prev => prev.filter(s => s.id !== sessionId))
|
setSessions(prev => prev.filter(s => s.id !== sessionId))
|
||||||
@@ -67,7 +70,7 @@ export function useSessions() {
|
|||||||
/**
|
/**
|
||||||
* Update a session
|
* Update a session
|
||||||
*/
|
*/
|
||||||
const updateSession = useCallback((sessionId, updates) => {
|
const updateSession = useCallback((sessionId: string, updates: Partial<sessionService.Session>) => {
|
||||||
const updated = sessionService.updateSession(sessionId, updates)
|
const updated = sessionService.updateSession(sessionId, updates)
|
||||||
if (updated) {
|
if (updated) {
|
||||||
setSessions(prev => prev.map(s => s.id === sessionId ? updated : s))
|
setSessions(prev => prev.map(s => s.id === sessionId ? updated : s))
|
||||||
@@ -83,10 +86,10 @@ export function useSessions() {
|
|||||||
/**
|
/**
|
||||||
* Add message to current session
|
* Add message to current session
|
||||||
*/
|
*/
|
||||||
const addMessage = useCallback((role, content) => {
|
const addMessage = useCallback((role: 'user' | 'assistant', content: string) => {
|
||||||
if (!currentSessionId) return null
|
if (!currentSessionId) return null
|
||||||
|
|
||||||
const message = { role, content }
|
const message: sessionService.Message = { role, content }
|
||||||
const updated = sessionService.addMessage(currentSessionId, message)
|
const updated = sessionService.addMessage(currentSessionId, message)
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
@@ -99,7 +102,7 @@ export function useSessions() {
|
|||||||
/**
|
/**
|
||||||
* Update config
|
* Update config
|
||||||
*/
|
*/
|
||||||
const updateConfig = useCallback((updates) => {
|
const updateConfig = useCallback((updates: Partial<sessionService.Config>) => {
|
||||||
setConfig(prev => ({ ...prev, ...updates }))
|
setConfig(prev => ({ ...prev, ...updates }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -119,4 +122,4 @@ export function useSessions() {
|
|||||||
addMessage,
|
addMessage,
|
||||||
updateConfig
|
updateConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.jsx'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import * as sessionService from '../services/sessionService.js'
|
import * as sessionService from './sessionService'
|
||||||
|
import type { Session } from './sessionService'
|
||||||
|
|
||||||
// Mock localStorage
|
// Mock localStorage
|
||||||
const localStorageMock = (() => {
|
const localStorageMock = (() => {
|
||||||
let store = {}
|
let store: Record<string, string> = {}
|
||||||
return {
|
return {
|
||||||
getItem: vi.fn((key) => store[key] || null),
|
getItem: vi.fn((key) => store[key] || null),
|
||||||
setItem: vi.fn((key, value) => { store[key] = value }),
|
setItem: vi.fn((key, value) => { store[key] = value }),
|
||||||
@@ -27,8 +28,8 @@ describe('Session Service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return parsed sessions from localStorage', () => {
|
it('should return parsed sessions from localStorage', () => {
|
||||||
const testSessions = [
|
const testSessions: Session[] = [
|
||||||
{ id: '1', name: 'Test', messages: [], createdAt: Date.now(), updatedAt: Date.now() }
|
{ id: '1', name: 'Test', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }
|
||||||
]
|
]
|
||||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(testSessions))
|
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(testSessions))
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ describe('Session Service', () => {
|
|||||||
|
|
||||||
describe('saveSessions', () => {
|
describe('saveSessions', () => {
|
||||||
it('should save sessions to localStorage', () => {
|
it('should save sessions to localStorage', () => {
|
||||||
const sessions = [{ id: '1', name: 'Test' }]
|
const sessions = [{ id: '1', name: 'Test', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }]
|
||||||
sessionService.saveSessions(sessions)
|
sessionService.saveSessions(sessions)
|
||||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
'light-chat-sessions',
|
'light-chat-sessions',
|
||||||
@@ -57,14 +58,14 @@ describe('Session Service', () => {
|
|||||||
localStorageMock.setItem.mockImplementationOnce(() => {
|
localStorageMock.setItem.mockImplementationOnce(() => {
|
||||||
throw new Error('Storage full')
|
throw new Error('Storage full')
|
||||||
})
|
})
|
||||||
expect(() => sessionService.saveSessions([{ id: '1' }])).not.toThrow()
|
expect(() => sessionService.saveSessions([{ id: '1', name: 'Test', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }])).not.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('loadConfig', () => {
|
describe('loadConfig', () => {
|
||||||
it('should return default config when no data exists', () => {
|
it('should return default config when no data exists', () => {
|
||||||
const config = sessionService.loadConfig()
|
const config = sessionService.loadConfig()
|
||||||
expect(config).toEqual({ endpoint: '', systemPrompt: '' })
|
expect(config).toEqual({ endpoint: '', systemPrompt: '', model: 'local-swarm' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return parsed config from localStorage', () => {
|
it('should return parsed config from localStorage', () => {
|
||||||
@@ -72,13 +73,13 @@ describe('Session Service', () => {
|
|||||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(testConfig))
|
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(testConfig))
|
||||||
|
|
||||||
const config = sessionService.loadConfig()
|
const config = sessionService.loadConfig()
|
||||||
expect(config).toEqual(testConfig)
|
expect(config).toEqual({ ...testConfig, model: 'local-swarm' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('saveConfig', () => {
|
describe('saveConfig', () => {
|
||||||
it('should save config to localStorage', () => {
|
it('should save config to localStorage', () => {
|
||||||
const config = { endpoint: 'https://api.test.com', systemPrompt: 'Be helpful' }
|
const config = { endpoint: 'https://api.test.com', systemPrompt: 'Be helpful', model: 'local-swarm' }
|
||||||
sessionService.saveConfig(config)
|
sessionService.saveConfig(config)
|
||||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
'light-chat-config',
|
'light-chat-config',
|
||||||
@@ -111,15 +112,15 @@ describe('Session Service', () => {
|
|||||||
|
|
||||||
describe('deleteSession', () => {
|
describe('deleteSession', () => {
|
||||||
it('should return false when session not found', () => {
|
it('should return false when session not found', () => {
|
||||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify([{ id: 'other' }]))
|
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify([{ id: 'other', name: 'Test', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }]))
|
||||||
const result = sessionService.deleteSession('nonexistent')
|
const result = sessionService.deleteSession('nonexistent')
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should delete session and return true', () => {
|
it('should delete session and return true', () => {
|
||||||
const sessions = [
|
const sessions: Session[] = [
|
||||||
{ id: '1', name: 'Test 1', messages: [], createdAt: Date.now(), updatedAt: Date.now() },
|
{ id: '1', name: 'Test 1', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: '2', name: 'Test 2', messages: [], createdAt: Date.now(), updatedAt: Date.now() }
|
{ id: '2', name: 'Test 2', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }
|
||||||
]
|
]
|
||||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(sessions))
|
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(sessions))
|
||||||
|
|
||||||
@@ -136,10 +137,12 @@ describe('Session Service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should update session and return updated session', () => {
|
it('should update session and return updated session', () => {
|
||||||
const existing = {
|
const existing: Session = {
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Old',
|
name: 'Old',
|
||||||
messages: [],
|
messages: [],
|
||||||
|
endpoint: '',
|
||||||
|
systemPrompt: '',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
@@ -147,16 +150,18 @@ describe('Session Service', () => {
|
|||||||
|
|
||||||
const result = sessionService.updateSession('1', { name: 'New' })
|
const result = sessionService.updateSession('1', { name: 'New' })
|
||||||
|
|
||||||
expect(result.name).toBe('New')
|
expect(result!.name).toBe('New')
|
||||||
expect(result.id).toBe('1')
|
expect(result!.id).toBe('1')
|
||||||
expect(result.messages).toEqual([])
|
expect(result!.messages).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update updatedAt timestamp', () => {
|
it('should update updatedAt timestamp', () => {
|
||||||
const existing = {
|
const existing: Session = {
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Test',
|
name: 'Test',
|
||||||
messages: [],
|
messages: [],
|
||||||
|
endpoint: '',
|
||||||
|
systemPrompt: '',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
@@ -168,7 +173,7 @@ describe('Session Service', () => {
|
|||||||
|
|
||||||
const result = sessionService.updateSession('1', { name: 'New' })
|
const result = sessionService.updateSession('1', { name: 'New' })
|
||||||
|
|
||||||
expect(result.updatedAt).toBeGreaterThan(beforeUpdate)
|
expect(result!.updatedAt).toBeGreaterThan(beforeUpdate)
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -181,23 +186,25 @@ describe('Session Service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return session when found', () => {
|
it('should return session when found', () => {
|
||||||
const sessions = [
|
const sessions: Session[] = [
|
||||||
{ id: '1', name: 'Test' },
|
{ id: '1', name: 'Test', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: '2', name: 'Test 2' }
|
{ id: '2', name: 'Test 2', messages: [], endpoint: '', systemPrompt: '', createdAt: Date.now(), updatedAt: Date.now() }
|
||||||
]
|
]
|
||||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(sessions))
|
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(sessions))
|
||||||
|
|
||||||
const session = sessionService.getSession('2')
|
const session = sessionService.getSession('2')
|
||||||
expect(session.name).toBe('Test 2')
|
expect(session!.name).toBe('Test 2')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('addMessage', () => {
|
describe('addMessage', () => {
|
||||||
it('should add message to session', () => {
|
it('should add message to session', () => {
|
||||||
const session = {
|
const session: Session = {
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Test',
|
name: 'Test',
|
||||||
messages: [],
|
messages: [],
|
||||||
|
endpoint: '',
|
||||||
|
systemPrompt: '',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
@@ -205,8 +212,8 @@ describe('Session Service', () => {
|
|||||||
|
|
||||||
const result = sessionService.addMessage('1', { role: 'user', content: 'Hello' })
|
const result = sessionService.addMessage('1', { role: 'user', content: 'Hello' })
|
||||||
|
|
||||||
expect(result.messages).toHaveLength(1)
|
expect(result!.messages).toHaveLength(1)
|
||||||
expect(result.messages[0]).toEqual({ role: 'user', content: 'Hello' })
|
expect(result!.messages[0]).toEqual({ role: 'user', content: 'Hello' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return null for nonexistent session', () => {
|
it('should return null for nonexistent session', () => {
|
||||||
@@ -215,4 +222,4 @@ describe('Session Service', () => {
|
|||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -6,18 +6,44 @@
|
|||||||
const STORAGE_KEY = 'light-chat-sessions'
|
const STORAGE_KEY = 'light-chat-sessions'
|
||||||
const CONFIG_KEY = 'light-chat-config'
|
const CONFIG_KEY = 'light-chat-config'
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
endpoint: string
|
||||||
|
systemPrompt: string
|
||||||
|
messages: Message[]
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
endpoint: string
|
||||||
|
systemPrompt: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateSessionOptions {
|
||||||
|
endpoint?: string
|
||||||
|
systemPrompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate unique session ID
|
* Generate unique session ID
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function generateId() {
|
function generateId(): string {
|
||||||
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
return Date.now().toString(36) + Math.random().toString(36).substring(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default session structure
|
* Default session structure
|
||||||
*/
|
*/
|
||||||
function createSession(endpoint = '', systemPrompt = '') {
|
function createSession(endpoint: string = '', systemPrompt: string = ''): Session {
|
||||||
return {
|
return {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
name: `Session ${new Date().toLocaleTimeString()}`,
|
name: `Session ${new Date().toLocaleTimeString()}`,
|
||||||
@@ -33,7 +59,7 @@ function createSession(endpoint = '', systemPrompt = '') {
|
|||||||
* Load all sessions from storage
|
* Load all sessions from storage
|
||||||
* @returns {Array}
|
* @returns {Array}
|
||||||
*/
|
*/
|
||||||
export function loadSessions() {
|
export function loadSessions(): Session[] {
|
||||||
try {
|
try {
|
||||||
const data = localStorage.getItem(STORAGE_KEY)
|
const data = localStorage.getItem(STORAGE_KEY)
|
||||||
return data ? JSON.parse(data) : []
|
return data ? JSON.parse(data) : []
|
||||||
@@ -45,9 +71,9 @@ export function loadSessions() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Save sessions to storage
|
* Save sessions to storage
|
||||||
* @param {Array} sessions
|
* @param sessions
|
||||||
*/
|
*/
|
||||||
export function saveSessions(sessions) {
|
export function saveSessions(sessions: Session[]): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -57,23 +83,23 @@ export function saveSessions(sessions) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load API configuration
|
* Load API configuration
|
||||||
* @returns {Object} { endpoint, systemPrompt }
|
* @returns {Config}
|
||||||
*/
|
*/
|
||||||
export function loadConfig() {
|
export function loadConfig(): Config {
|
||||||
try {
|
try {
|
||||||
const data = localStorage.getItem(CONFIG_KEY)
|
const data = localStorage.getItem(CONFIG_KEY)
|
||||||
return data ? JSON.parse(data) : { endpoint: '', systemPrompt: '' }
|
return data ? { ...JSON.parse(data), model: 'local-swarm' } : { endpoint: '', systemPrompt: '', model: 'local-swarm' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load config:', error)
|
console.error('Failed to load config:', error)
|
||||||
return { endpoint: '', systemPrompt: '' }
|
return { endpoint: '', systemPrompt: '', model: 'local-swarm' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save API configuration
|
* Save API configuration
|
||||||
* @param {Object} config
|
* @param config
|
||||||
*/
|
*/
|
||||||
export function saveConfig(config) {
|
export function saveConfig(config: Config): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config))
|
localStorage.setItem(CONFIG_KEY, JSON.stringify(config))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -83,10 +109,10 @@ export function saveConfig(config) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new session
|
* Add a new session
|
||||||
* @param {Object} options
|
* @param options
|
||||||
* @returns {Object} new session
|
* @returns new session
|
||||||
*/
|
*/
|
||||||
export function addSession(options = {}) {
|
export function addSession(options: CreateSessionOptions = {}): Session {
|
||||||
const sessions = loadSessions()
|
const sessions = loadSessions()
|
||||||
const newSession = createSession(
|
const newSession = createSession(
|
||||||
options.endpoint,
|
options.endpoint,
|
||||||
@@ -99,10 +125,10 @@ export function addSession(options = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a session
|
* Delete a session
|
||||||
* @param {string} sessionId
|
* @param sessionId
|
||||||
* @returns {boolean}
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function deleteSession(sessionId) {
|
export function deleteSession(sessionId: string): boolean {
|
||||||
const sessions = loadSessions()
|
const sessions = loadSessions()
|
||||||
const filtered = sessions.filter(s => s.id !== sessionId)
|
const filtered = sessions.filter(s => s.id !== sessionId)
|
||||||
if (filtered.length === sessions.length) {
|
if (filtered.length === sessions.length) {
|
||||||
@@ -114,11 +140,11 @@ export function deleteSession(sessionId) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a session
|
* Update a session
|
||||||
* @param {string} sessionId
|
* @param sessionId
|
||||||
* @param {Object} updates
|
* @param updates
|
||||||
* @returns {Object|null} updated session or null
|
* @returns updated session or null
|
||||||
*/
|
*/
|
||||||
export function updateSession(sessionId, updates) {
|
export function updateSession(sessionId: string, updates: Partial<Session>): Session | null {
|
||||||
const sessions = loadSessions()
|
const sessions = loadSessions()
|
||||||
const index = sessions.findIndex(s => s.id === sessionId)
|
const index = sessions.findIndex(s => s.id === sessionId)
|
||||||
|
|
||||||
@@ -143,21 +169,21 @@ export function updateSession(sessionId, updates) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific session
|
* Get a specific session
|
||||||
* @param {string} sessionId
|
* @param sessionId
|
||||||
* @returns {Object|null}
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function getSession(sessionId) {
|
export function getSession(sessionId: string): Session | null {
|
||||||
const sessions = loadSessions()
|
const sessions = loadSessions()
|
||||||
return sessions.find(s => s.id === sessionId) || null
|
return sessions.find(s => s.id === sessionId) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add message to a session
|
* Add message to a session
|
||||||
* @param {string} sessionId
|
* @param sessionId
|
||||||
* @param {Object} message - { role: 'user'|'assistant', content: string }
|
* @param message - { role: 'user'|'assistant', content: string }
|
||||||
* @returns {Object|null} updated session
|
* @returns updated session
|
||||||
*/
|
*/
|
||||||
export function addMessage(sessionId, message) {
|
export function addMessage(sessionId: string, message: Message): Session | null {
|
||||||
const session = getSession(sessionId)
|
const session = getSession(sessionId)
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -166,4 +192,4 @@ export function addMessage(sessionId, message) {
|
|||||||
|
|
||||||
session.messages.push(message)
|
session.messages.push(message)
|
||||||
return updateSession(sessionId, { messages: session.messages })
|
return updateSession(sessionId, { messages: session.messages })
|
||||||
}
|
}
|
||||||
@@ -8,4 +8,10 @@ global.ResizeObserver = class ResizeObserver {
|
|||||||
observe() {}
|
observe() {}
|
||||||
unobserve() {}
|
unobserve() {}
|
||||||
disconnect() {}
|
disconnect() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
ResizeObserver: typeof ResizeObserver
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { sendMessage, testConnection } from '../utils/llmApi.js'
|
import { sendMessage, testConnection } from './llmApi'
|
||||||
|
|
||||||
describe('LLM API', () => {
|
describe('LLM API', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -19,7 +19,7 @@ describe('LLM API', () => {
|
|||||||
choices: [{ message: { content: 'Hello there!' } }]
|
choices: [{ message: { content: 'Hello there!' } }]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
) as any
|
||||||
|
|
||||||
const result = await sendMessage('https://api.example.com/chat', 'Hi')
|
const result = await sendMessage('https://api.example.com/chat', 'Hi')
|
||||||
|
|
||||||
@@ -39,14 +39,14 @@ describe('LLM API', () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ choices: [] })
|
json: () => Promise.resolve({ choices: [] })
|
||||||
})
|
})
|
||||||
)
|
) as any
|
||||||
|
|
||||||
await expect(sendMessage('https://api.example.com/chat', 'Hi'))
|
await expect(sendMessage('https://api.example.com/chat', 'Hi'))
|
||||||
.rejects.toThrow('Invalid response format: no content found')
|
.rejects.toThrow('Invalid response format: no content found')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should throw error on network failure', async () => {
|
it('should throw error on network failure', async () => {
|
||||||
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')))
|
global.fetch = vi.fn(() => Promise.reject(new Error('Network error'))) as any
|
||||||
|
|
||||||
await expect(sendMessage('https://api.example.com/chat', 'Hi'))
|
await expect(sendMessage('https://api.example.com/chat', 'Hi'))
|
||||||
.rejects.toThrow('Network error')
|
.rejects.toThrow('Network error')
|
||||||
@@ -60,12 +60,13 @@ describe('LLM API', () => {
|
|||||||
choices: [{ message: { content: 'Response' } }]
|
choices: [{ message: { content: 'Response' } }]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
) as any
|
||||||
|
|
||||||
await sendMessage('https://api.example.com/chat', 'Hi', [], 'You are helpful')
|
await sendMessage('https://api.example.com/chat', 'Hi', [], 'You are helpful', 'local-swarm')
|
||||||
|
|
||||||
const callBody = JSON.parse(global.fetch.mock.calls[0][1].body)
|
const callBody = JSON.parse((global.fetch as any).mock.calls[0][1].body)
|
||||||
expect(callBody.messages[0]).toEqual({ role: 'system', content: 'You are helpful' })
|
expect(callBody.messages[0]).toEqual({ role: 'system', content: 'You are helpful' })
|
||||||
|
expect(callBody.model).toBe('local-swarm')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should include message history', async () => {
|
it('should include message history', async () => {
|
||||||
@@ -76,7 +77,7 @@ describe('LLM API', () => {
|
|||||||
choices: [{ message: { content: 'Response' } }]
|
choices: [{ message: { content: 'Response' } }]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
) as any
|
||||||
|
|
||||||
const history = [
|
const history = [
|
||||||
{ role: 'user', content: 'Hello' },
|
{ role: 'user', content: 'Hello' },
|
||||||
@@ -85,7 +86,7 @@ describe('LLM API', () => {
|
|||||||
|
|
||||||
await sendMessage('https://api.example.com/chat', 'How are you?', history)
|
await sendMessage('https://api.example.com/chat', 'How are you?', history)
|
||||||
|
|
||||||
const callBody = JSON.parse(global.fetch.mock.calls[0][1].body)
|
const callBody = JSON.parse((global.fetch as any).mock.calls[0][1].body)
|
||||||
expect(callBody.messages[0]).toEqual({ role: 'user', content: 'Hello' })
|
expect(callBody.messages[0]).toEqual({ role: 'user', content: 'Hello' })
|
||||||
expect(callBody.messages[1]).toEqual({ role: 'assistant', content: 'Hi there!' })
|
expect(callBody.messages[1]).toEqual({ role: 'assistant', content: 'Hi there!' })
|
||||||
expect(callBody.messages[2]).toEqual({ role: 'user', content: 'How are you?' })
|
expect(callBody.messages[2]).toEqual({ role: 'user', content: 'How are you?' })
|
||||||
@@ -96,7 +97,7 @@ describe('LLM API', () => {
|
|||||||
it('should return true on successful response', async () => {
|
it('should return true on successful response', async () => {
|
||||||
global.fetch = vi.fn(() =>
|
global.fetch = vi.fn(() =>
|
||||||
Promise.resolve({ status: 200 })
|
Promise.resolve({ status: 200 })
|
||||||
)
|
) as any
|
||||||
|
|
||||||
const result = await testConnection('https://api.example.com/chat')
|
const result = await testConnection('https://api.example.com/chat')
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
@@ -105,7 +106,7 @@ describe('LLM API', () => {
|
|||||||
it('should return true on error response (not 404)', async () => {
|
it('should return true on error response (not 404)', async () => {
|
||||||
global.fetch = vi.fn(() =>
|
global.fetch = vi.fn(() =>
|
||||||
Promise.resolve({ status: 401 })
|
Promise.resolve({ status: 401 })
|
||||||
)
|
) as any
|
||||||
|
|
||||||
const result = await testConnection('https://api.example.com/chat')
|
const result = await testConnection('https://api.example.com/chat')
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
@@ -114,17 +115,17 @@ describe('LLM API', () => {
|
|||||||
it('should return false on 404', async () => {
|
it('should return false on 404', async () => {
|
||||||
global.fetch = vi.fn(() =>
|
global.fetch = vi.fn(() =>
|
||||||
Promise.resolve({ status: 404 })
|
Promise.resolve({ status: 404 })
|
||||||
)
|
) as any
|
||||||
|
|
||||||
const result = await testConnection('https://api.example.com/chat')
|
const result = await testConnection('https://api.example.com/chat')
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return false on network error', async () => {
|
it('should return false on network error', async () => {
|
||||||
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')))
|
global.fetch = vi.fn(() => Promise.reject(new Error('Network error'))) as any
|
||||||
|
|
||||||
const result = await testConnection('https://api.example.com/chat')
|
const result = await testConnection('https://api.example.com/chat')
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -3,25 +3,45 @@
|
|||||||
* Simple wrapper for OpenAI-compatible APIs
|
* Simple wrapper for OpenAI-compatible APIs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface APIResponse {
|
||||||
|
choices: Array<{
|
||||||
|
message: {
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_HEADERS = {
|
const DEFAULT_HEADERS = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to the LLM API and get a response
|
* Send a message to the LLM API and get a response
|
||||||
* @param {string} endpoint - API endpoint URL
|
* @param endpoint - API endpoint URL
|
||||||
* @param {string} message - User message
|
* @param message - User message
|
||||||
* @param {Array} messages - Full message history (optional, for multi-turn)
|
* @param messages - Full message history (optional, for multi-turn)
|
||||||
* @param {string} systemPrompt - System prompt (optional)
|
* @param systemPrompt - System prompt (optional)
|
||||||
* @returns {Promise<string>} - LLM response content
|
* @param model - Model name (optional, defaults to 'local-swarm')
|
||||||
|
* @returns - LLM response content
|
||||||
*/
|
*/
|
||||||
export async function sendMessage(endpoint, message, messages = [], systemPrompt = '') {
|
export async function sendMessage(
|
||||||
|
endpoint: string,
|
||||||
|
message: string,
|
||||||
|
messages: Message[] = [],
|
||||||
|
systemPrompt: string = '',
|
||||||
|
model: string = 'local-swarm'
|
||||||
|
): Promise<string> {
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
throw new Error('API endpoint is required')
|
throw new Error('API endpoint is required')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build messages array for API
|
// Build messages array for API
|
||||||
const apiMessages = []
|
const apiMessages: Array<{ role: string; content: string }> = []
|
||||||
|
|
||||||
if (systemPrompt) {
|
if (systemPrompt) {
|
||||||
apiMessages.push({ role: 'system', content: systemPrompt })
|
apiMessages.push({ role: 'system', content: systemPrompt })
|
||||||
@@ -43,7 +63,7 @@ export async function sendMessage(endpoint, message, messages = [], systemPrompt
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: DEFAULT_HEADERS,
|
headers: DEFAULT_HEADERS,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'gpt-3.5-turbo', // or appropriate model
|
model,
|
||||||
messages: apiMessages,
|
messages: apiMessages,
|
||||||
stream: false
|
stream: false
|
||||||
})
|
})
|
||||||
@@ -54,7 +74,7 @@ export async function sendMessage(endpoint, message, messages = [], systemPrompt
|
|||||||
throw new Error(`API request failed: ${response.status} - ${errorText}`)
|
throw new Error(`API request failed: ${response.status} - ${errorText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = (await response.json()) as APIResponse
|
||||||
|
|
||||||
// Extract content from response (OpenAI format)
|
// Extract content from response (OpenAI format)
|
||||||
const content = data.choices?.[0]?.message?.content
|
const content = data.choices?.[0]?.message?.content
|
||||||
@@ -72,17 +92,18 @@ export async function sendMessage(endpoint, message, messages = [], systemPrompt
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Test connection to API endpoint
|
* Test connection to API endpoint
|
||||||
* @param {string} endpoint - API endpoint URL
|
* @param endpoint - API endpoint URL
|
||||||
* @returns {Promise<boolean>} - Whether connection succeeded
|
* @param model - Model name (optional, defaults to 'local-swarm')
|
||||||
|
* @returns - Whether connection succeeded
|
||||||
*/
|
*/
|
||||||
export async function testConnection(endpoint) {
|
export async function testConnection(endpoint: string, model: string = 'local-swarm'): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Simple test - send minimal message
|
// Simple test - send minimal message
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: DEFAULT_HEADERS,
|
headers: DEFAULT_HEADERS,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'gpt-3.5-turbo',
|
model,
|
||||||
messages: [{ role: 'user', content: 'test' }],
|
messages: [{ role: 'user', content: 'test' }],
|
||||||
max_tokens: 1
|
max_tokens: 1
|
||||||
})
|
})
|
||||||
@@ -94,4 +115,4 @@ export async function testConnection(endpoint) {
|
|||||||
console.error('Connection test failed:', error)
|
console.error('Connection test failed:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Vitest support */
|
||||||
|
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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.ts',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user