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