Initial commit
This commit is contained in:
1
src/App.css
Normal file
1
src/App.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Additional app-specific styles */
|
||||
119
src/App.tsx
Normal file
119
src/App.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/tauri'
|
||||
import { Settings, MessageSquare, Info } from 'lucide-react'
|
||||
import { ChatInterface } from './components/ChatInterface'
|
||||
import { ModelSelector } from './components/ModelSelector'
|
||||
import { CharacterSelector } from './components/CharacterSelector'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { useSettingsStore } from './stores/settingsStore'
|
||||
import { getThemeManager } from './lib/theme'
|
||||
import './App.css'
|
||||
|
||||
interface Keys {
|
||||
openrouter_api_key: string | null
|
||||
elevenlabs_api_key: string | null
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const { openrouterApiKey, theme, setOpenRouterApiKey, setElevenLabsApiKey } = useSettingsStore()
|
||||
|
||||
// Initialize theme on mount
|
||||
useEffect(() => {
|
||||
const themeManager = getThemeManager()
|
||||
themeManager.setTheme(theme)
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch API keys from the backend on app load
|
||||
async function fetchEnvKeys() {
|
||||
try {
|
||||
const keys = await invoke<Keys>('get_env_keys')
|
||||
if (keys.openrouter_api_key) {
|
||||
setOpenRouterApiKey(keys.openrouter_api_key)
|
||||
}
|
||||
if (keys.elevenlabs_api_key) {
|
||||
setElevenLabsApiKey(keys.elevenlabs_api_key)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch env keys from backend:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchEnvKeys()
|
||||
}, [setOpenRouterApiKey, setElevenLabsApiKey])
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-white">EVE</h1>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">Personal AI Assistant</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CharacterSelector />
|
||||
<ModelSelector />
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="p-2 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-lg transition"
|
||||
>
|
||||
<Settings className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{!openrouterApiKey ? (
|
||||
<div className="h-full flex items-center justify-center p-8">
|
||||
<div className="max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
|
||||
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Info className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-4">
|
||||
Welcome to EVE!
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
To get started, you'll need to set up your OpenRouter API key. OpenRouter gives you
|
||||
access to GPT-4, Claude, Llama, and many other AI models.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-indigo-600
|
||||
text-white rounded-lg hover:from-blue-600 hover:to-indigo-700
|
||||
transition font-medium"
|
||||
>
|
||||
Configure Settings
|
||||
</button>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
|
||||
Don't have an API key?{' '}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
Get one here →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChatInterface />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
83
src/components/CharacterSelector.tsx
Normal file
83
src/components/CharacterSelector.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, User } from 'lucide-react'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { getAllCharacters, getCharacter } from '../lib/characters'
|
||||
|
||||
export function CharacterSelector() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { currentCharacter, setCurrentCharacter } = useSettingsStore()
|
||||
|
||||
const characters = getAllCharacters()
|
||||
const selected = getCharacter(currentCharacter)
|
||||
|
||||
const handleSelect = (characterId: string) => {
|
||||
setCurrentCharacter(characterId)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800
|
||||
border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700 transition"
|
||||
title={selected.description}
|
||||
>
|
||||
<User className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{selected.name}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-72 bg-white dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-20
|
||||
max-h-96 overflow-y-auto">
|
||||
<div className="p-2">
|
||||
<div className="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||
Character Personality
|
||||
</div>
|
||||
{characters.map((character) => {
|
||||
const isSelected = character.id === currentCharacter
|
||||
return (
|
||||
<button
|
||||
key={character.id}
|
||||
onClick={() => handleSelect(character.id)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg transition
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
${isSelected ? 'bg-purple-50 dark:bg-purple-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<User className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
|
||||
isSelected ? 'text-purple-600 dark:text-purple-400' : 'text-gray-400'
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium text-sm ${
|
||||
isSelected
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{character.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{character.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
305
src/components/ChatInterface.tsx
Normal file
305
src/components/ChatInterface.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Send, Loader2, Trash2, Save, FolderOpen, Paperclip, Volume2, FileText } from 'lucide-react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useConversationStore } from '../stores/conversationStore'
|
||||
import { getOpenRouterClient } from '../lib/openrouter'
|
||||
import { getCharacter } from '../lib/characters'
|
||||
import { ChatMessage } from './ChatMessage'
|
||||
import { ConversationList } from './ConversationList'
|
||||
import { VoiceInput } from './VoiceInput'
|
||||
import { FileUpload } from './FileUpload'
|
||||
import { FilePreview } from './FilePreview'
|
||||
import { FileAttachment, isImageFile } from '../lib/fileProcessor'
|
||||
|
||||
export function ChatInterface() {
|
||||
const [input, setInput] = useState('')
|
||||
const [showConversations, setShowConversations] = useState(false)
|
||||
const [showFileUpload, setShowFileUpload] = useState(false)
|
||||
const [attachedFiles, setAttachedFiles] = useState<FileAttachment[]>([])
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const { messages, isLoading, currentModel, addMessage, setLoading, clearMessages } =
|
||||
useChatStore()
|
||||
const {
|
||||
currentCharacter,
|
||||
customSystemPrompt,
|
||||
temperature,
|
||||
maxTokens,
|
||||
ttsConversationMode,
|
||||
voiceEnabled,
|
||||
setTtsConversationMode
|
||||
} = useSettingsStore()
|
||||
const { createConversation } = useConversationStore()
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
|
||||
const handleSend = async () => {
|
||||
if ((!input.trim() && attachedFiles.length === 0) || isLoading) return
|
||||
|
||||
const userMessage = input.trim()
|
||||
const files = [...attachedFiles]
|
||||
setInput('')
|
||||
setAttachedFiles([])
|
||||
|
||||
// Build content with file context
|
||||
let messageContent = userMessage
|
||||
|
||||
// Add file context to message
|
||||
if (files.length > 0) {
|
||||
messageContent += '\n\n[Attached files:'
|
||||
files.forEach(file => {
|
||||
messageContent += `\n- ${file.name} (${file.type})`
|
||||
|
||||
// Include text content for text files
|
||||
if (!isImageFile(file.type) && typeof file.data === 'string' && !file.data.startsWith('data:')) {
|
||||
messageContent += `:\n\`\`\`\n${file.data.slice(0, 2000)}\n\`\`\``
|
||||
}
|
||||
// Note for images
|
||||
else if (isImageFile(file.type)) {
|
||||
messageContent += ' (image attached)'
|
||||
}
|
||||
})
|
||||
messageContent += '\n]'
|
||||
}
|
||||
|
||||
// Add user message
|
||||
addMessage({
|
||||
role: 'user',
|
||||
content: messageContent,
|
||||
attachments: files.length > 0 ? files : undefined,
|
||||
})
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const client = getOpenRouterClient()
|
||||
|
||||
// Get system prompt from current character
|
||||
const character = getCharacter(currentCharacter)
|
||||
const systemPrompt = currentCharacter === 'custom' && customSystemPrompt
|
||||
? customSystemPrompt
|
||||
: character.systemPrompt
|
||||
|
||||
// Prepare conversation messages with system prompt
|
||||
const conversationMessages = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: systemPrompt,
|
||||
},
|
||||
...messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
})),
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: userMessage,
|
||||
},
|
||||
]
|
||||
|
||||
// Get response
|
||||
const response = await client.createChatCompletion({
|
||||
model: currentModel,
|
||||
messages: conversationMessages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
})
|
||||
|
||||
const assistantMessage = response.choices[0]?.message?.content || 'No response'
|
||||
|
||||
// Add assistant message
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: assistantMessage,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Chat error:', error)
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: `Error: ${error instanceof Error ? error.message : 'Failed to get response'}`,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveConversation = () => {
|
||||
if (messages.length === 0) return
|
||||
|
||||
const title = prompt('Enter a title for this conversation (optional):')
|
||||
if (title === null) return // User cancelled
|
||||
|
||||
createConversation(
|
||||
messages,
|
||||
currentModel,
|
||||
currentCharacter,
|
||||
title || undefined
|
||||
)
|
||||
|
||||
alert('Conversation saved successfully!')
|
||||
}
|
||||
|
||||
const handleVoiceTranscript = (transcript: string) => {
|
||||
setInput(transcript)
|
||||
}
|
||||
|
||||
const handleFilesSelected = (files: FileAttachment[]) => {
|
||||
setAttachedFiles(prev => [...prev, ...files])
|
||||
setShowFileUpload(false)
|
||||
}
|
||||
|
||||
const handleRemoveFile = (id: string) => {
|
||||
setAttachedFiles(prev => prev.filter(f => f.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||
<p className="text-center">
|
||||
Start a conversation with EVE
|
||||
<br />
|
||||
<span className="text-sm">Powered by {currentModel}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} />
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">EVE is thinking...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
{/* File Preview */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<FilePreview files={attachedFiles} onRemove={handleRemoveFile} compact />
|
||||
)}
|
||||
|
||||
{/* File Upload Modal */}
|
||||
{showFileUpload && (
|
||||
<div className="mb-3">
|
||||
<FileUpload
|
||||
onFilesSelected={handleFilesSelected}
|
||||
disabled={isLoading}
|
||||
maxFiles={5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mb-2">
|
||||
<VoiceInput onTranscript={handleVoiceTranscript} disabled={isLoading} />
|
||||
<button
|
||||
onClick={() => setShowFileUpload(!showFileUpload)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
showFileUpload
|
||||
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
: 'bg-gray-500 hover:bg-gray-600 text-white'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</button>
|
||||
{voiceEnabled && (
|
||||
<button
|
||||
onClick={() => setTtsConversationMode(!ttsConversationMode)}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
ttsConversationMode
|
||||
? 'bg-gradient-to-r from-purple-500 to-pink-600 hover:from-purple-600 hover:to-pink-700 text-white shadow-lg'
|
||||
: 'bg-gray-500 hover:bg-gray-600 text-white'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
title={ttsConversationMode ? 'Audio Mode: ON - Responses auto-play' : 'Audio Mode: OFF - Click to enable'}
|
||||
>
|
||||
{ttsConversationMode ? <Volume2 className="w-5 h-5 animate-pulse" /> : <FileText className="w-5 h-5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type or speak your message... (Enter to send, Shift+Enter for new line)"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-700 text-gray-800 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
rows={2}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || (!input.trim() && attachedFiles.length === 0)}
|
||||
className="p-2 bg-gradient-to-r from-blue-500 to-indigo-600
|
||||
text-white rounded-lg hover:from-blue-600 hover:to-indigo-700
|
||||
transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Send message"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowConversations(true)}
|
||||
className="p-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600
|
||||
transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Load conversation"
|
||||
>
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
</button>
|
||||
{messages.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSaveConversation}
|
||||
disabled={isLoading}
|
||||
className="p-2 bg-green-500 text-white rounded-lg hover:bg-green-600
|
||||
transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save conversation"
|
||||
>
|
||||
<Save className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={clearMessages}
|
||||
disabled={isLoading}
|
||||
className="p-2 bg-red-500 text-white rounded-lg hover:bg-red-600
|
||||
transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Clear conversation"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversation List Modal */}
|
||||
{showConversations && (
|
||||
<ConversationList onClose={() => setShowConversations(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
src/components/ChatMessage.tsx
Normal file
81
src/components/ChatMessage.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react'
|
||||
import { User, Bot, Eye, EyeOff, Volume2 } from 'lucide-react'
|
||||
import { ChatMessage as ChatMessageType } from '../stores/chatStore'
|
||||
import { MessageContent } from './MessageContent'
|
||||
import { TTSControls } from './TTSControls'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatMessageType
|
||||
}
|
||||
|
||||
export function ChatMessage({ message }: ChatMessageProps) {
|
||||
const isUser = message.role === 'user'
|
||||
const { ttsConversationMode } = useSettingsStore()
|
||||
const [isTextVisible, setIsTextVisible] = useState(!ttsConversationMode)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex gap-3 p-4 rounded-lg',
|
||||
isUser
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 ml-8'
|
||||
: 'bg-gray-50 dark:bg-gray-800/50 mr-8'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
isUser
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gradient-to-br from-purple-500 to-indigo-600 text-white'
|
||||
)}
|
||||
>
|
||||
{isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-gray-800 dark:text-white text-sm">
|
||||
{isUser ? 'You' : 'EVE'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(message.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-700 dark:text-gray-200">
|
||||
{isUser ? (
|
||||
// User messages: simple pre-wrap for plain text
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
) : (
|
||||
// Assistant messages: full markdown rendering
|
||||
<>
|
||||
{ttsConversationMode && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsTextVisible(!isTextVisible)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-purple-500/20 hover:bg-purple-500/30
|
||||
text-purple-700 dark:text-purple-300 rounded-md transition-colors"
|
||||
>
|
||||
{isTextVisible ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
|
||||
{isTextVisible ? 'Hide Text' : 'Show Text'}
|
||||
</button>
|
||||
<div className="flex items-center gap-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
<Volume2 className="w-3 h-3" />
|
||||
<span>Audio Mode</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isTextVisible && <MessageContent content={message.content} />}
|
||||
<TTSControls
|
||||
text={message.content}
|
||||
messageId={message.id}
|
||||
autoPlay={ttsConversationMode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
src/components/CodeBlock.tsx
Normal file
70
src/components/CodeBlock.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
interface CodeBlockProps {
|
||||
language?: string
|
||||
value: string
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
export const CodeBlock: React.FC<CodeBlockProps> = ({ language, value, inline }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
// Inline code
|
||||
if (inline) {
|
||||
return (
|
||||
<code className="px-1.5 py-0.5 bg-zinc-800 text-blue-300 rounded text-sm font-mono">
|
||||
{value}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
// Block code
|
||||
return (
|
||||
<div className="relative group my-4">
|
||||
<div className="flex items-center justify-between bg-zinc-800 px-4 py-2 rounded-t-lg border-b border-zinc-700">
|
||||
<span className="text-xs text-zinc-400 font-mono">
|
||||
{language || 'plaintext'}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 px-2 py-1 text-xs text-zinc-400 hover:text-white hover:bg-zinc-700 rounded transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language || 'plaintext'}
|
||||
style={vscDarkPlus}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomLeftRadius: '0.5rem',
|
||||
borderBottomRightRadius: '0.5rem',
|
||||
}}
|
||||
showLineNumbers={value.split('\n').length > 3}
|
||||
>
|
||||
{value}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
src/components/ConversationList.tsx
Normal file
255
src/components/ConversationList.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useConversationStore, Conversation } from '../stores/conversationStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import {
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
Download,
|
||||
Calendar,
|
||||
Tag,
|
||||
Search,
|
||||
X,
|
||||
Edit2,
|
||||
Check
|
||||
} from 'lucide-react'
|
||||
|
||||
export const ConversationList: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const { conversations, loadConversation, deleteConversation, exportConversation, renameConversation } = useConversationStore()
|
||||
const { messages, clearMessages, addMessage } = useChatStore()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
|
||||
const filteredConversations = conversations.filter((conv) => {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return (
|
||||
conv.title.toLowerCase().includes(query) ||
|
||||
conv.tags.some((tag) => tag.toLowerCase().includes(query))
|
||||
)
|
||||
}).sort((a, b) => b.updated - a.updated)
|
||||
|
||||
const handleLoadConversation = (conv: Conversation) => {
|
||||
clearMessages()
|
||||
conv.messages.forEach((msg) => {
|
||||
addMessage({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
})
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDelete = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (confirm('Are you sure you want to delete this conversation?')) {
|
||||
deleteConversation(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = (id: string, format: 'json' | 'markdown' | 'txt', e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const content = exportConversation(id, format)
|
||||
if (!content) return
|
||||
|
||||
const conversation = loadConversation(id)
|
||||
if (!conversation) return
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${conversation.title.replace(/[^a-z0-9]/gi, '_')}.${format === 'markdown' ? 'md' : format}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const startEdit = (conv: Conversation, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setEditingId(conv.id)
|
||||
setEditTitle(conv.title)
|
||||
}
|
||||
|
||||
const saveEdit = (id: string) => {
|
||||
if (editTitle.trim()) {
|
||||
renameConversation(id, editTitle.trim())
|
||||
}
|
||||
setEditingId(null)
|
||||
setEditTitle('')
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null)
|
||||
setEditTitle('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-zinc-900 rounded-lg shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-zinc-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquare className="w-6 h-6 text-blue-400" />
|
||||
<h2 className="text-xl font-semibold text-white">Saved Conversations</h2>
|
||||
<span className="text-sm text-zinc-400">({conversations.length})</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b border-zinc-800">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search conversations or tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversation List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{filteredConversations.length === 0 ? (
|
||||
<div className="text-center py-12 text-zinc-500">
|
||||
<MessageSquare className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No conversations found</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredConversations.map((conv) => (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => handleLoadConversation(conv)}
|
||||
className="bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700 rounded-lg p-4 cursor-pointer transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title */}
|
||||
{editingId === conv.id ? (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 px-2 py-1 bg-zinc-900 border border-zinc-600 rounded text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
saveEdit(conv.id)
|
||||
}}
|
||||
className="p-1 hover:bg-zinc-700 rounded"
|
||||
>
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
cancelEdit()
|
||||
}}
|
||||
className="p-1 hover:bg-zinc-700 rounded"
|
||||
>
|
||||
<X className="w-4 h-4 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-white font-medium truncate">{conv.title}</h3>
|
||||
<button
|
||||
onClick={(e) => startEdit(conv, e)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-zinc-700 rounded transition-opacity"
|
||||
>
|
||||
<Edit2 className="w-3 h-3 text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-zinc-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>{new Date(conv.updated).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
<span>{conv.messages.length} messages</span>
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<span className="font-mono">{conv.model.split('/').pop()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{conv.tags.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Tag className="w-3 h-3 text-zinc-500" />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{conv.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-0.5 bg-zinc-700 rounded text-xs text-zinc-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="relative group/export">
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 hover:bg-zinc-700 rounded transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4 text-zinc-400" />
|
||||
</button>
|
||||
<div className="absolute right-0 mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-lg py-1 hidden group-hover/export:block z-10">
|
||||
<button
|
||||
onClick={(e) => handleExport(conv.id, 'markdown', e)}
|
||||
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-zinc-700"
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleExport(conv.id, 'json', e)}
|
||||
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-zinc-700"
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleExport(conv.id, 'txt', e)}
|
||||
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-zinc-700"
|
||||
>
|
||||
Text
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDelete(conv.id, e)}
|
||||
className="p-2 hover:bg-red-500/20 rounded transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
src/components/FilePreview.tsx
Normal file
115
src/components/FilePreview.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react'
|
||||
import { FileText, Image, Code, File, X } from 'lucide-react'
|
||||
import { FileAttachment, formatFileSize, getFileExtension, isImageFile } from '../lib/fileProcessor'
|
||||
|
||||
interface FilePreviewProps {
|
||||
files: FileAttachment[]
|
||||
onRemove: (id: string) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export const FilePreview: React.FC<FilePreviewProps> = ({ files, onRemove, compact = false }) => {
|
||||
if (files.length === 0) return null
|
||||
|
||||
const getFileIcon = (file: FileAttachment) => {
|
||||
if (isImageFile(file.type)) {
|
||||
return <Image className="w-4 h-4" />
|
||||
} else if (file.type.startsWith('text/') || file.type === 'application/json') {
|
||||
const ext = getFileExtension(file.name)
|
||||
if (['js', 'jsx', 'ts', 'tsx', 'py', 'java', 'c', 'cpp', 'rs', 'go'].includes(ext)) {
|
||||
return <Code className="w-4 h-4" />
|
||||
}
|
||||
return <FileText className="w-4 h-4" />
|
||||
} else if (file.type === 'application/pdf') {
|
||||
return <FileText className="w-4 h-4" />
|
||||
}
|
||||
return <File className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center gap-2 bg-blue-500/10 border border-blue-500/30 rounded-lg px-2 py-1"
|
||||
>
|
||||
<div className="text-blue-400">{getFileIcon(file)}</div>
|
||||
<span className="text-xs text-blue-300 truncate max-w-[150px]">{file.name}</span>
|
||||
<button
|
||||
onClick={() => onRemove(file.id)}
|
||||
className="text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Preview or Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{isImageFile(file.type) && file.preview ? (
|
||||
<img
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
className="w-16 h-16 object-cover rounded border border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 flex items-center justify-center bg-gray-200 dark:bg-gray-700 rounded border border-gray-300 dark:border-gray-600">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{getFileIcon(file)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
{file.type && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{file.type}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<button
|
||||
onClick={() => onRemove(file.id)}
|
||||
className="flex-shrink-0 p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Remove file"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Text Preview for Code/Text Files */}
|
||||
{!isImageFile(file.type) && file.type.startsWith('text/') && file.data && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<pre className="text-xs text-gray-600 dark:text-gray-300 overflow-x-auto max-h-24 whitespace-pre-wrap break-words">
|
||||
{typeof file.data === 'string' && !file.data.startsWith('data:')
|
||||
? file.data.slice(0, 200) + (file.data.length > 200 ? '...' : '')
|
||||
: 'Binary data'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
src/components/FileUpload.tsx
Normal file
151
src/components/FileUpload.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Upload, X, AlertCircle } from 'lucide-react'
|
||||
import { processFile, FileAttachment, formatFileSize } from '../lib/fileProcessor'
|
||||
|
||||
interface FileUploadProps {
|
||||
onFilesSelected: (files: FileAttachment[]) => void
|
||||
disabled?: boolean
|
||||
maxFiles?: number
|
||||
}
|
||||
|
||||
export const FileUpload: React.FC<FileUploadProps> = ({
|
||||
onFilesSelected,
|
||||
disabled = false,
|
||||
maxFiles = 5,
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleFiles = useCallback(async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setError(null)
|
||||
setIsProcessing(true)
|
||||
|
||||
try {
|
||||
const fileArray = Array.from(files)
|
||||
|
||||
if (fileArray.length > maxFiles) {
|
||||
throw new Error(`Maximum ${maxFiles} files allowed`)
|
||||
}
|
||||
|
||||
const processedFiles: FileAttachment[] = []
|
||||
|
||||
for (const file of fileArray) {
|
||||
try {
|
||||
const processed = await processFile(file)
|
||||
processedFiles.push(processed)
|
||||
} catch (err) {
|
||||
console.error(`Error processing ${file.name}:`, err)
|
||||
throw new Error(`Failed to process ${file.name}: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
onFilesSelected(processedFiles)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to process files')
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [onFilesSelected, maxFiles])
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
}, [disabled])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
if (disabled) return
|
||||
|
||||
const files = e.dataTransfer.files
|
||||
handleFiles(files)
|
||||
}, [disabled, handleFiles])
|
||||
|
||||
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFiles(e.target.files)
|
||||
// Reset input so same file can be selected again
|
||||
e.target.value = ''
|
||||
}, [handleFiles])
|
||||
|
||||
const handleClearError = () => {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-4 transition-all
|
||||
${isDragging
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50'
|
||||
}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:border-blue-400'}
|
||||
${isProcessing ? 'pointer-events-none' : ''}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
disabled={disabled || isProcessing}
|
||||
onChange={handleFileInputChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
accept="image/*,text/*,.pdf,.json,.md"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-2 pointer-events-none">
|
||||
<Upload className={`w-6 h-6 ${isDragging ? 'text-blue-500' : 'text-gray-400'}`} />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{isProcessing ? 'Processing files...' : 'Drop files here or click to browse'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Images, text files, code, PDF (max {maxFiles} files, 10MB each)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mt-2 bg-red-500/20 border border-red-500/50 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearError}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
src/components/MermaidDiagram.tsx
Normal file
55
src/components/MermaidDiagram.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
interface MermaidDiagramProps {
|
||||
chart: string
|
||||
}
|
||||
|
||||
// Initialize mermaid with dark theme
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'dark',
|
||||
securityLevel: 'loose',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
})
|
||||
|
||||
export const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ chart }) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const renderDiagram = async () => {
|
||||
if (!ref.current) return
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`
|
||||
const { svg } = await mermaid.render(id, chart)
|
||||
ref.current.innerHTML = svg
|
||||
} catch (err) {
|
||||
console.error('Mermaid render error:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to render diagram')
|
||||
}
|
||||
}
|
||||
|
||||
renderDiagram()
|
||||
}, [chart])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="my-4 p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<p className="text-sm text-red-400">Failed to render diagram: {error}</p>
|
||||
<pre className="mt-2 text-xs text-zinc-400 overflow-x-auto">
|
||||
{chart}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="my-4 p-4 bg-zinc-800/50 rounded-lg overflow-x-auto flex justify-center"
|
||||
/>
|
||||
)
|
||||
}
|
||||
196
src/components/MessageContent.tsx
Normal file
196
src/components/MessageContent.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import { CodeBlock } from './CodeBlock'
|
||||
import { MermaidDiagram } from './MermaidDiagram'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
interface MessageContentProps {
|
||||
content: string
|
||||
}
|
||||
|
||||
export const MessageContent: React.FC<MessageContentProps> = ({ content }) => {
|
||||
return (
|
||||
<div className="prose prose-invert prose-sm max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
// Code blocks
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const language = match ? match[1] : undefined
|
||||
const value = String(children).replace(/\n$/, '')
|
||||
|
||||
// Check if it's a mermaid diagram
|
||||
if (language === 'mermaid') {
|
||||
return <MermaidDiagram chart={value} />
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
language={language}
|
||||
value={value}
|
||||
inline={inline}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
// Links
|
||||
a({ node, children, href, ...props }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 underline"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote({ node, children, ...props }) {
|
||||
return (
|
||||
<blockquote
|
||||
className="border-l-4 border-blue-500 pl-4 py-2 my-4 bg-zinc-800/30 italic"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
},
|
||||
|
||||
// Tables
|
||||
table({ node, children, ...props }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-4">
|
||||
<table
|
||||
className="min-w-full divide-y divide-zinc-700 border border-zinc-700 rounded-lg"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
thead({ node, children, ...props }) {
|
||||
return (
|
||||
<thead className="bg-zinc-800" {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
)
|
||||
},
|
||||
|
||||
th({ node, children, ...props }) {
|
||||
return (
|
||||
<th
|
||||
className="px-4 py-2 text-left text-xs font-semibold text-zinc-300 uppercase tracking-wider"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
|
||||
td({ node, children, ...props }) {
|
||||
return (
|
||||
<td className="px-4 py-2 text-sm text-zinc-300 border-t border-zinc-700" {...props}>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
},
|
||||
|
||||
// Headings
|
||||
h1({ node, children, ...props }) {
|
||||
return (
|
||||
<h1 className="text-2xl font-bold text-white mt-6 mb-4" {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
},
|
||||
|
||||
h2({ node, children, ...props }) {
|
||||
return (
|
||||
<h2 className="text-xl font-bold text-white mt-5 mb-3" {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
},
|
||||
|
||||
h3({ node, children, ...props }) {
|
||||
return (
|
||||
<h3 className="text-lg font-bold text-white mt-4 mb-2" {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
},
|
||||
|
||||
// Lists
|
||||
ul({ node, children, ...props }) {
|
||||
return (
|
||||
<ul className="list-disc list-inside my-3 space-y-1 text-zinc-200" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
|
||||
ol({ node, children, ...props }) {
|
||||
return (
|
||||
<ol className="list-decimal list-inside my-3 space-y-1 text-zinc-200" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
},
|
||||
|
||||
li({ node, children, ...props }) {
|
||||
return (
|
||||
<li className="text-zinc-200" {...props}>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
|
||||
// Paragraphs
|
||||
p({ node, children, ...props }) {
|
||||
return (
|
||||
<p className="text-zinc-200 my-3 leading-relaxed" {...props}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
},
|
||||
|
||||
// Horizontal rule
|
||||
hr({ node, ...props }) {
|
||||
return <hr className="my-6 border-zinc-700" {...props} />
|
||||
},
|
||||
|
||||
// Strong/Bold
|
||||
strong({ node, children, ...props }) {
|
||||
return (
|
||||
<strong className="font-bold text-white" {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
)
|
||||
},
|
||||
|
||||
// Emphasis/Italic
|
||||
em({ node, children, ...props }) {
|
||||
return (
|
||||
<em className="italic text-zinc-100" {...props}>
|
||||
{children}
|
||||
</em>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/components/ModelSelector.tsx
Normal file
107
src/components/ModelSelector.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, Zap } from 'lucide-react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { POPULAR_MODELS } from '../lib/openrouter'
|
||||
|
||||
const MODEL_CATEGORIES = {
|
||||
'OpenAI': {
|
||||
'gpt-4o': 'GPT-4o (Latest)',
|
||||
'gpt-4-turbo': 'GPT-4 Turbo',
|
||||
'gpt-4o-mini': 'GPT-4o Mini (Fast)',
|
||||
'gpt-3.5-turbo': 'GPT-3.5 Turbo',
|
||||
},
|
||||
'Anthropic': {
|
||||
'claude-3.5-sonnet': 'Claude 3.5 Sonnet (Best)',
|
||||
'claude-3-opus': 'Claude 3 Opus',
|
||||
'claude-3-sonnet': 'Claude 3 Sonnet',
|
||||
'claude-3-haiku': 'Claude 3 Haiku (Fast)',
|
||||
},
|
||||
'Google': {
|
||||
'gemini-pro-1.5': 'Gemini Pro 1.5',
|
||||
'gemini-flash-1.5': 'Gemini Flash 1.5 (Fast)',
|
||||
},
|
||||
'Meta': {
|
||||
'llama-3.1-405b': 'Llama 3.1 405B (Huge)',
|
||||
'llama-3.1-70b': 'Llama 3.1 70B',
|
||||
'llama-3.1-8b': 'Llama 3.1 8B (Fast)',
|
||||
},
|
||||
'Other': {
|
||||
'mistral-large': 'Mistral Large',
|
||||
'mixtral-8x22b': 'Mixtral 8x22B',
|
||||
'deepseek-chat': 'DeepSeek Chat',
|
||||
},
|
||||
}
|
||||
|
||||
export function ModelSelector() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { currentModel, setModel } = useChatStore()
|
||||
|
||||
// Find current model display name
|
||||
const getCurrentModelName = () => {
|
||||
for (const category of Object.values(MODEL_CATEGORIES)) {
|
||||
for (const [key, name] of Object.entries(category)) {
|
||||
if (POPULAR_MODELS[key as keyof typeof POPULAR_MODELS] === currentModel) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return currentModel
|
||||
}
|
||||
|
||||
const handleModelSelect = (modelKey: keyof typeof POPULAR_MODELS) => {
|
||||
setModel(POPULAR_MODELS[modelKey])
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800
|
||||
border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700 transition"
|
||||
>
|
||||
<Zap className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{getCurrentModelName()}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-20
|
||||
max-h-96 overflow-y-auto">
|
||||
{Object.entries(MODEL_CATEGORIES).map(([category, models]) => (
|
||||
<div key={category} className="py-2">
|
||||
<div className="px-4 py-1 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||
{category}
|
||||
</div>
|
||||
{Object.entries(models).map(([key, name]) => {
|
||||
const modelId = POPULAR_MODELS[key as keyof typeof POPULAR_MODELS]
|
||||
const isSelected = modelId === currentModel
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleModelSelect(key as keyof typeof POPULAR_MODELS)}
|
||||
className={`w-full text-left px-4 py-2 text-sm
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700 transition
|
||||
${isSelected ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
729
src/components/SettingsPanel.tsx
Normal file
729
src/components/SettingsPanel.tsx
Normal file
@@ -0,0 +1,729 @@
|
||||
import { X, Key, Zap, Palette, User, Volume2, Moon, Sun, Monitor } from 'lucide-react'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { getAllCharacters, getCharacter } from '../lib/characters'
|
||||
import { getElevenLabsClient, Voice } from '../lib/elevenlabs'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SettingsPanel({ onClose }: SettingsPanelProps) {
|
||||
const {
|
||||
openrouterApiKey,
|
||||
elevenLabsApiKey,
|
||||
temperature,
|
||||
maxTokens,
|
||||
currentCharacter,
|
||||
customSystemPrompt,
|
||||
voiceEnabled,
|
||||
ttsVoice,
|
||||
ttsModel,
|
||||
ttsSpeed,
|
||||
ttsStability,
|
||||
ttsSimilarityBoost,
|
||||
ttsConversationMode,
|
||||
sttLanguage,
|
||||
sttMode,
|
||||
theme,
|
||||
setOpenRouterApiKey,
|
||||
setElevenLabsApiKey,
|
||||
setTemperature,
|
||||
setMaxTokens,
|
||||
setCurrentCharacter,
|
||||
setCustomSystemPrompt,
|
||||
setVoiceEnabled,
|
||||
setTtsVoice,
|
||||
setTtsModel,
|
||||
setTtsSpeed,
|
||||
setTtsStability,
|
||||
setTtsSimilarityBoost,
|
||||
setTtsConversationMode,
|
||||
setSttLanguage,
|
||||
setSttMode,
|
||||
setTheme,
|
||||
} = useSettingsStore()
|
||||
|
||||
const [browserVoices, setBrowserVoices] = useState<SpeechSynthesisVoice[]>([])
|
||||
const [elevenLabsVoices, setElevenLabsVoices] = useState<Voice[]>([])
|
||||
const [loadingVoices, setLoadingVoices] = useState(false)
|
||||
const [voiceError, setVoiceError] = useState<string | null>(null)
|
||||
|
||||
const characters = getAllCharacters()
|
||||
const selectedCharacter = getCharacter(currentCharacter)
|
||||
|
||||
// Debug: Log current settings on mount
|
||||
useEffect(() => {
|
||||
console.log('⚙️ SettingsPanel mounted')
|
||||
console.log('📥 Current ttsVoice from store:', ttsVoice)
|
||||
console.log('💾 LocalStorage contents:', localStorage.getItem('eve-settings'))
|
||||
}, [])
|
||||
|
||||
// Load browser voices
|
||||
useEffect(() => {
|
||||
const loadVoices = () => {
|
||||
const voices = window.speechSynthesis.getVoices()
|
||||
setBrowserVoices(voices)
|
||||
console.log(`🔊 Loaded ${voices.length} browser voices`)
|
||||
|
||||
// Check for duplicate voiceURIs
|
||||
const voiceURIs = voices.map(v => v.voiceURI)
|
||||
const duplicates = voiceURIs.filter((uri, index) => voiceURIs.indexOf(uri) !== index)
|
||||
if (duplicates.length > 0) {
|
||||
console.warn('⚠️ Found duplicate voice URIs:', [...new Set(duplicates)])
|
||||
}
|
||||
}
|
||||
|
||||
loadVoices()
|
||||
window.speechSynthesis.addEventListener('voiceschanged', loadVoices)
|
||||
|
||||
return () => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', loadVoices)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load ElevenLabs voices when API key is configured
|
||||
useEffect(() => {
|
||||
const loadElevenLabsVoices = async () => {
|
||||
if (!elevenLabsApiKey) {
|
||||
setElevenLabsVoices([])
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingVoices(true)
|
||||
setVoiceError(null)
|
||||
|
||||
try {
|
||||
const client = getElevenLabsClient(elevenLabsApiKey)
|
||||
const voices = await client.getVoices()
|
||||
console.log('🎵 ElevenLabs voices loaded:', voices.length)
|
||||
console.log('🎵 Sample voice:', voices[0])
|
||||
console.log('🎵 All voice IDs:', voices.map(v => `${v.name}: ${v.voice_id}`))
|
||||
setElevenLabsVoices(voices)
|
||||
} catch (error) {
|
||||
console.error('Failed to load ElevenLabs voices:', error)
|
||||
setVoiceError('Failed to load ElevenLabs voices. Check your API key.')
|
||||
setElevenLabsVoices([])
|
||||
} finally {
|
||||
setLoadingVoices(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadElevenLabsVoices()
|
||||
}, [elevenLabsApiKey])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
{/* API Keys Section */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Key className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">API Keys</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
OpenRouter API Key
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-blue-500 hover:underline text-xs"
|
||||
>
|
||||
Get key →
|
||||
</a>
|
||||
</label>
|
||||
{openrouterApiKey ? (
|
||||
<div className="px-4 py-2 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-lg text-sm">
|
||||
Key loaded from environment.
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="password"
|
||||
value={openrouterApiKey}
|
||||
onChange={(e) => setOpenRouterApiKey(e.target.value)}
|
||||
placeholder="sk-or-v1-..."
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-700 text-gray-800 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Required for AI chat functionality
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
ElevenLabs API Key (Optional)
|
||||
<a
|
||||
href="https://elevenlabs.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-blue-500 hover:underline text-xs"
|
||||
>
|
||||
Get key →
|
||||
</a>
|
||||
</label>
|
||||
{elevenLabsApiKey ? (
|
||||
<div className="px-4 py-2 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-lg text-sm">
|
||||
Key loaded from environment.
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="password"
|
||||
value={elevenLabsApiKey}
|
||||
onChange={(e) => setElevenLabsApiKey(e.target.value)}
|
||||
placeholder="..."
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-700 text-gray-800 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
For text-to-speech features
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Appearance Section */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Palette className="w-5 h-5 text-indigo-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
Appearance
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Theme
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
|
||||
theme === 'light'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Sun className={`w-6 h-6 ${theme === 'light' ? 'text-blue-500' : 'text-gray-600 dark:text-gray-400'}`} />
|
||||
<span className={`text-sm font-medium ${theme === 'light' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
Light
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
|
||||
theme === 'dark'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Moon className={`w-6 h-6 ${theme === 'dark' ? 'text-blue-500' : 'text-gray-600 dark:text-gray-400'}`} />
|
||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
Dark
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setTheme('system')}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
|
||||
theme === 'system'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Monitor className={`w-6 h-6 ${theme === 'system' ? 'text-blue-500' : 'text-gray-600 dark:text-gray-400'}`} />
|
||||
<span className={`text-sm font-medium ${theme === 'system' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
System
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Choose your preferred color theme. System follows your OS settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Character/Personality Section */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<User className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
Character & Personality
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Character Preset
|
||||
</label>
|
||||
<select
|
||||
value={currentCharacter}
|
||||
onChange={(e) => setCurrentCharacter(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-700 text-gray-800 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{characters.map((char) => (
|
||||
<option key={char.id} value={char.id}>
|
||||
{char.name} - {char.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<h4 className="font-medium text-sm text-gray-800 dark:text-white mb-2">
|
||||
Current Personality
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{selectedCharacter.systemPrompt.slice(0, 200)}...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{currentCharacter === 'custom' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Custom System Prompt
|
||||
</label>
|
||||
<textarea
|
||||
value={customSystemPrompt}
|
||||
onChange={(e) => setCustomSystemPrompt(e.target.value)}
|
||||
placeholder="Enter your custom system prompt for EVE..."
|
||||
rows={6}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-700 text-gray-800 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Define how EVE should behave and respond
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Voice Settings Section */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Volume2 className="w-5 h-5 text-green-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
Voice Settings
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="voiceEnabled"
|
||||
checked={voiceEnabled}
|
||||
onChange={(e) => setVoiceEnabled(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="voiceEnabled" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Enable text-to-speech for assistant messages
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{voiceEnabled && (
|
||||
<div className="flex items-center gap-2 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ttsConversationMode"
|
||||
checked={ttsConversationMode}
|
||||
onChange={(e) => setTtsConversationMode(e.target.checked)}
|
||||
className="w-4 h-4 text-purple-600 rounded focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<label htmlFor="ttsConversationMode" className="text-sm font-medium text-gray-700 dark:text-gray-300 flex-1">
|
||||
🎧 Audio Conversation Mode
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Auto-play responses in audio, hide text by default (can toggle)
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{voiceEnabled && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
TTS Voice Selection
|
||||
{loadingVoices && (
|
||||
<span className="ml-2 text-xs text-blue-400 animate-pulse">Loading voices...</span>
|
||||
)}
|
||||
</label>
|
||||
<select
|
||||
value={ttsVoice}
|
||||
onChange={(e) => {
|
||||
const selectedValue = e.target.value
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
|
||||
console.log('🎛️ Settings: Voice dropdown changed')
|
||||
console.log('📥 Selected value:', selectedValue)
|
||||
console.log('🔍 Value breakdown:', {
|
||||
hasPrefix: selectedValue.includes(':'),
|
||||
prefix: selectedValue.split(':')[0],
|
||||
voiceId: selectedValue.split(':')[1],
|
||||
})
|
||||
|
||||
setTtsVoice(selectedValue)
|
||||
|
||||
// Verify it's saved to localStorage
|
||||
setTimeout(() => {
|
||||
const stored = localStorage.getItem('eve-settings')
|
||||
const parsed = stored ? JSON.parse(stored) : null
|
||||
console.log('💾 LocalStorage ttsVoice:', parsed?.state?.ttsVoice)
|
||||
console.log('💾 Full LocalStorage:', stored)
|
||||
}, 100)
|
||||
}}
|
||||
disabled={loadingVoices}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-700 text-gray-800 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-green-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="default">Default Voice</option>
|
||||
|
||||
{elevenLabsVoices.length > 0 && (
|
||||
<optgroup label="ElevenLabs Voices (Premium)">
|
||||
{elevenLabsVoices.map((voice, index) => {
|
||||
const optionValue = `elevenlabs:${voice.voice_id}`
|
||||
|
||||
if (index === 0) {
|
||||
console.log('📋 Sample ElevenLabs dropdown option:', {
|
||||
name: voice.name,
|
||||
voice_id: voice.voice_id,
|
||||
optionValue: optionValue
|
||||
})
|
||||
}
|
||||
|
||||
if (!voice.voice_id) {
|
||||
console.error('❌ Voice missing voice_id:', voice)
|
||||
}
|
||||
|
||||
return (
|
||||
<option key={`elevenlabs-${index}-${voice.voice_id}`} value={optionValue}>
|
||||
{voice.name}
|
||||
{voice.labels?.accent && ` - ${voice.labels.accent}`}
|
||||
{voice.labels?.age && ` (${voice.labels.age})`}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
<optgroup label="Browser Voices (Free)">
|
||||
{browserVoices.map((voice, index) => (
|
||||
<option key={`browser-${index}-${voice.voiceURI}`} value={`browser:${voice.voiceURI}`}>
|
||||
{voice.name} ({voice.lang})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
{voiceError && (
|
||||
<p className="text-xs text-red-400 mt-1">{voiceError}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{ttsVoice === 'default' && 'Using system default voice'}
|
||||
{ttsVoice.startsWith('browser:') && 'Using browser voice'}
|
||||
{ttsVoice.startsWith('elevenlabs:') && 'Using ElevenLabs voice'}
|
||||
{' • '}
|
||||
{elevenLabsVoices.length > 0
|
||||
? `${elevenLabsVoices.length} ElevenLabs voices available`
|
||||
: elevenLabsApiKey
|
||||
? 'Loading ElevenLabs voices...'
|
||||
: 'Add ElevenLabs API key above to access premium voices'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
STT Language
|
||||
</label>
|
||||
<select
|
||||
value={sttLanguage}
|
||||
onChange={(e) => setSttLanguage(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-700 text-gray-800 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="en-GB">English (UK)</option>
|
||||
<option value="en-AU">English (Australia)</option>
|
||||
<option value="en-CA">English (Canada)</option>
|
||||
<option value="es-ES">Spanish (Spain)</option>
|
||||
<option value="es-MX">Spanish (Mexico)</option>
|
||||
<option value="fr-FR">French (France)</option>
|
||||
<option value="fr-CA">French (Canada)</option>
|
||||
<option value="de-DE">German</option>
|
||||
<option value="it-IT">Italian</option>
|
||||
<option value="pt-BR">Portuguese (Brazil)</option>
|
||||
<option value="pt-PT">Portuguese (Portugal)</option>
|
||||
<option value="ru-RU">Russian</option>
|
||||
<option value="ja-JP">Japanese</option>
|
||||
<option value="ko-KR">Korean</option>
|
||||
<option value="zh-CN">Chinese (Simplified)</option>
|
||||
<option value="zh-TW">Chinese (Traditional)</option>
|
||||
<option value="ar-SA">Arabic</option>
|
||||
<option value="hi-IN">Hindi</option>
|
||||
<option value="nl-NL">Dutch</option>
|
||||
<option value="pl-PL">Polish</option>
|
||||
<option value="tr-TR">Turkish</option>
|
||||
<option value="sv-SE">Swedish</option>
|
||||
<option value="da-DK">Danish</option>
|
||||
<option value="fi-FI">Finnish</option>
|
||||
<option value="no-NO">Norwegian</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Language for speech recognition
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
STT Input Mode
|
||||
</label>
|
||||
<select
|
||||
value={sttMode}
|
||||
onChange={(e) => setSttMode(e.target.value as 'push-to-talk' | 'continuous')}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-700 text-gray-800 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="push-to-talk">Push to Talk</option>
|
||||
<option value="continuous">Continuous Listening</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Push to talk: Click to start/stop. Continuous: Always listening until stopped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TTS Model Selection */}
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
ElevenLabs Model
|
||||
</h4>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
TTS Model
|
||||
{!ttsVoice.startsWith('elevenlabs:') && (
|
||||
<span className="ml-2 text-xs text-gray-400">(ElevenLabs only)</span>
|
||||
)}
|
||||
</label>
|
||||
<select
|
||||
value={ttsModel}
|
||||
onChange={(e) => setTtsModel(e.target.value)}
|
||||
disabled={!ttsVoice.startsWith('elevenlabs:')}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-700 text-gray-800 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-green-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<optgroup label="Real-Time Models (Recommended)">
|
||||
<option value="eleven_turbo_v2_5">Turbo v2.5 - Best Balance</option>
|
||||
<option value="eleven_flash_v2_5">Flash v2.5 - Fastest (~75ms)</option>
|
||||
<option value="eleven_multilingual_v2">Multilingual v2 - High Quality</option>
|
||||
</optgroup>
|
||||
<optgroup label="High Quality Models (Slower)">
|
||||
<option value="eleven_turbo_v2">Turbo v2 - Legacy</option>
|
||||
<option value="eleven_flash_v2">Flash v2 - Legacy</option>
|
||||
<option value="eleven_monolingual_v1">Monolingual v1 - English Only</option>
|
||||
</optgroup>
|
||||
<optgroup label="Alpha Models (Not for Real-Time)">
|
||||
<option value="eleven_v3">V3 Alpha - Highest Quality ⚠️ Slow</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{ttsModel === 'eleven_v3' && '⚠️ V3 not optimized for real-time conversation'}
|
||||
{ttsModel === 'eleven_turbo_v2_5' && '⭐ Recommended for conversational AI'}
|
||||
{ttsModel === 'eleven_flash_v2_5' && '⚡ Ultra-low latency for instant responses'}
|
||||
{ttsModel === 'eleven_multilingual_v2' && '🌍 Best quality for multiple languages'}
|
||||
{!['eleven_v3', 'eleven_turbo_v2_5', 'eleven_flash_v2_5', 'eleven_multilingual_v2'].includes(ttsModel) && 'Legacy model - consider upgrading'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TTS Quality Controls */}
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Voice Quality Settings
|
||||
</h4>
|
||||
|
||||
{/* Speed Control */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Speed: {ttsSpeed.toFixed(2)}x
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.25"
|
||||
max="4"
|
||||
step="0.25"
|
||||
value={ttsSpeed}
|
||||
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-green-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>0.25x (Slow)</span>
|
||||
<span>1.0x (Normal)</span>
|
||||
<span>4.0x (Fast)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stability Control (ElevenLabs) */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Stability: {(ttsStability * 100).toFixed(0)}%
|
||||
{!ttsVoice.startsWith('elevenlabs:') && (
|
||||
<span className="ml-2 text-xs text-gray-400">(ElevenLabs only)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={ttsStability}
|
||||
onChange={(e) => setTtsStability(parseFloat(e.target.value))}
|
||||
disabled={!ttsVoice.startsWith('elevenlabs:')}
|
||||
className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Higher = more consistent, Lower = more expressive
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Similarity Boost Control (ElevenLabs) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Clarity: {(ttsSimilarityBoost * 100).toFixed(0)}%
|
||||
{!ttsVoice.startsWith('elevenlabs:') && (
|
||||
<span className="ml-2 text-xs text-gray-400">(ElevenLabs only)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={ttsSimilarityBoost}
|
||||
onChange={(e) => setTtsSimilarityBoost(parseFloat(e.target.value))}
|
||||
disabled={!ttsVoice.startsWith('elevenlabs:')}
|
||||
className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Higher = more similar to original voice, enhances clarity
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Model Settings Section */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Zap className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
Model Parameters
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Temperature: {temperature.toFixed(2)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Lower = more focused, Higher = more creative
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Max Tokens
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(parseInt(e.target.value))}
|
||||
min="100"
|
||||
max="4096"
|
||||
step="100"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-700 text-gray-800 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Maximum length of responses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Info Section */}
|
||||
<section className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Palette className="w-5 h-5 text-blue-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-800 dark:text-white mb-1">
|
||||
About OpenRouter
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
OpenRouter provides unified access to multiple AI models including GPT-4, Claude,
|
||||
Gemini, Llama, and more. You only need one API key to access all models.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-gradient-to-r from-blue-500 to-indigo-600
|
||||
text-white rounded-lg hover:from-blue-600 hover:to-indigo-700
|
||||
transition font-medium"
|
||||
>
|
||||
Save & Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
src/components/TTSControls.tsx
Normal file
171
src/components/TTSControls.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Volume2, VolumeX, Pause, Play, Loader2 } from 'lucide-react'
|
||||
import { getTTSManager } from '../lib/tts'
|
||||
import { getElevenLabsClient } from '../lib/elevenlabs'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
|
||||
interface TTSControlsProps {
|
||||
text: string
|
||||
messageId: string
|
||||
autoPlay?: boolean
|
||||
}
|
||||
|
||||
export const TTSControls: React.FC<TTSControlsProps> = ({ text, messageId, autoPlay = false }) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const {
|
||||
voiceEnabled,
|
||||
ttsVoice,
|
||||
ttsModel,
|
||||
ttsSpeed,
|
||||
ttsStability,
|
||||
ttsSimilarityBoost,
|
||||
elevenLabsApiKey
|
||||
} = useSettingsStore()
|
||||
const ttsManager = getTTSManager()
|
||||
|
||||
// Debug: Log the current voice setting
|
||||
useEffect(() => {
|
||||
console.log('🔊 TTSControls: Current TTS voice from store:', ttsVoice)
|
||||
}, [ttsVoice])
|
||||
|
||||
useEffect(() => {
|
||||
// Check if this message is currently playing
|
||||
const checkPlaying = setInterval(() => {
|
||||
setIsPlaying(ttsManager.getIsPlaying())
|
||||
}, 100)
|
||||
|
||||
return () => clearInterval(checkPlaying)
|
||||
}, [ttsManager])
|
||||
|
||||
// Auto-play on mount if autoPlay is enabled
|
||||
useEffect(() => {
|
||||
if (autoPlay && voiceEnabled && !isPlaying && !isLoading) {
|
||||
// Small delay to ensure proper initialization
|
||||
const timer = setTimeout(() => {
|
||||
handlePlay()
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, []) // Only run on mount
|
||||
|
||||
const handlePlay = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
|
||||
console.log('🎬 TTSControls: Starting TTS playback')
|
||||
console.log('📥 Raw voice from store:', ttsVoice)
|
||||
console.log('🔑 ElevenLabs API Key present:', !!elevenLabsApiKey)
|
||||
|
||||
// Initialize ElevenLabs client if we have an API key
|
||||
if (elevenLabsApiKey) {
|
||||
console.log('🎵 Initializing ElevenLabs client')
|
||||
const client = getElevenLabsClient(elevenLabsApiKey)
|
||||
}
|
||||
|
||||
// Don't pass "default" as voice ID - let browser use its default
|
||||
const voiceId = ttsVoice && ttsVoice !== 'default' ? ttsVoice : undefined
|
||||
|
||||
console.log('🎤 Processed voice ID:', voiceId || 'default')
|
||||
console.log('📝 Text to speak:', text.substring(0, 50) + '...')
|
||||
|
||||
// The provider will be determined automatically from the voice ID prefix
|
||||
await ttsManager.speak(text, {
|
||||
voiceId,
|
||||
volume: 1.0,
|
||||
rate: ttsSpeed,
|
||||
stability: ttsStability,
|
||||
similarityBoost: ttsSimilarityBoost,
|
||||
modelId: ttsModel,
|
||||
})
|
||||
|
||||
console.log('✅ TTS playback started successfully')
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
|
||||
|
||||
setIsPlaying(true)
|
||||
setIsPaused(false)
|
||||
} catch (error) {
|
||||
console.error('❌ TTS error:', error)
|
||||
alert('Failed to play audio. Please check your TTS settings.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
ttsManager.pause()
|
||||
setIsPaused(true)
|
||||
setIsPlaying(false)
|
||||
}
|
||||
|
||||
const handleResume = () => {
|
||||
ttsManager.resume()
|
||||
setIsPaused(false)
|
||||
setIsPlaying(true)
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
ttsManager.stop()
|
||||
setIsPlaying(false)
|
||||
setIsPaused(false)
|
||||
}
|
||||
|
||||
if (!voiceEnabled) return null
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 mt-2 ${autoPlay && isPlaying ? 'animate-pulse' : ''}`}>
|
||||
{isLoading ? (
|
||||
<button
|
||||
disabled
|
||||
className="p-1.5 rounded hover:bg-zinc-700 transition-colors text-zinc-400"
|
||||
>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
</button>
|
||||
) : isPlaying ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePause}
|
||||
className="p-1.5 rounded hover:bg-zinc-700 transition-colors text-blue-400"
|
||||
title="Pause"
|
||||
>
|
||||
<Pause className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="p-1.5 rounded hover:bg-zinc-700 transition-colors text-red-400"
|
||||
title="Stop"
|
||||
>
|
||||
<VolumeX className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
) : isPaused ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleResume}
|
||||
className="p-1.5 rounded hover:bg-zinc-700 transition-colors text-green-400"
|
||||
title="Resume"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="p-1.5 rounded hover:bg-zinc-700 transition-colors text-red-400"
|
||||
title="Stop"
|
||||
>
|
||||
<VolumeX className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handlePlay}
|
||||
className="p-1.5 rounded hover:bg-zinc-700 transition-colors text-zinc-400 hover:text-blue-400"
|
||||
title="Speak"
|
||||
>
|
||||
<Volume2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
150
src/components/VoiceInput.tsx
Normal file
150
src/components/VoiceInput.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Mic, MicOff, StopCircle, AlertCircle } from 'lucide-react'
|
||||
import { useVoiceRecording } from '../hooks/useVoiceRecording'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
|
||||
interface VoiceInputProps {
|
||||
onTranscript: (text: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const VoiceInput: React.FC<VoiceInputProps> = ({ onTranscript, disabled }) => {
|
||||
const [showModeSelector, setShowModeSelector] = useState(false)
|
||||
const { voiceEnabled, sttLanguage, sttMode, setSttMode } = useSettingsStore()
|
||||
|
||||
const {
|
||||
isListening,
|
||||
isSupported,
|
||||
transcript,
|
||||
interimTranscript,
|
||||
error,
|
||||
startListening,
|
||||
stopListening,
|
||||
abortListening,
|
||||
resetTranscript,
|
||||
} = useVoiceRecording({
|
||||
continuous: sttMode === 'continuous',
|
||||
language: sttLanguage,
|
||||
})
|
||||
|
||||
// Send transcript when listening stops
|
||||
useEffect(() => {
|
||||
if (!isListening && transcript) {
|
||||
onTranscript(transcript)
|
||||
resetTranscript()
|
||||
}
|
||||
}, [isListening, transcript, onTranscript, resetTranscript])
|
||||
|
||||
const handleMicClick = () => {
|
||||
if (isListening) {
|
||||
stopListening()
|
||||
} else {
|
||||
startListening()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAbort = () => {
|
||||
abortListening()
|
||||
}
|
||||
|
||||
if (!voiceEnabled || !isSupported) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Microphone Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleMicClick}
|
||||
disabled={disabled}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
isListening
|
||||
? 'bg-red-500 hover:bg-red-600 text-white animate-pulse'
|
||||
: 'bg-gradient-to-r from-purple-500 to-pink-600 hover:from-purple-600 hover:to-pink-700 text-white'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
title={isListening ? 'Stop recording' : 'Start voice input'}
|
||||
>
|
||||
{isListening ? <MicOff className="w-5 h-5" /> : <Mic className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
{isListening && (
|
||||
<button
|
||||
onClick={handleAbort}
|
||||
className="p-2 bg-gray-500 hover:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
title="Cancel recording"
|
||||
>
|
||||
<StopCircle className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mode Toggle */}
|
||||
<button
|
||||
onClick={() => setShowModeSelector(!showModeSelector)}
|
||||
className="text-xs text-gray-400 hover:text-gray-200 px-2 py-1 rounded hover:bg-gray-700 transition-colors"
|
||||
title="Change input mode"
|
||||
>
|
||||
{sttMode === 'push-to-talk' ? 'Push to Talk' : 'Continuous'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode Selector Dropdown */}
|
||||
{showModeSelector && (
|
||||
<div className="absolute bottom-full mb-2 left-0 bg-gray-800 border border-gray-700 rounded-lg shadow-lg py-1 z-10">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSttMode('push-to-talk')
|
||||
setShowModeSelector(false)
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm hover:bg-gray-700 transition-colors ${
|
||||
sttMode === 'push-to-talk' ? 'text-blue-400 font-medium' : 'text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Push to Talk
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSttMode('continuous')
|
||||
setShowModeSelector(false)
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm hover:bg-gray-700 transition-colors ${
|
||||
sttMode === 'continuous' ? 'text-blue-400 font-medium' : 'text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Continuous Listening
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Transcript Display */}
|
||||
{isListening && (interimTranscript || transcript) && (
|
||||
<div className="absolute bottom-full mb-2 left-0 right-0 bg-gray-800 border border-gray-700 rounded-lg p-3 shadow-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Mic className="w-4 h-4 text-red-500 mt-0.5 animate-pulse" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white">
|
||||
{transcript}
|
||||
<span className="text-gray-400 italic">{interimTranscript}</span>
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-1 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-red-500 animate-pulse" style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="absolute bottom-full mb-2 left-0 right-0 bg-red-500/20 border border-red-500/50 rounded-lg p-3 shadow-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 mt-0.5" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/hooks/useVoiceRecording.ts
Normal file
107
src/hooks/useVoiceRecording.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { getSTTManager, STTResult } from '../lib/stt'
|
||||
|
||||
interface UseVoiceRecordingOptions {
|
||||
continuous?: boolean
|
||||
language?: string
|
||||
onTranscript?: (transcript: string, isFinal: boolean) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function useVoiceRecording(options: UseVoiceRecordingOptions = {}) {
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const [transcript, setTranscript] = useState('')
|
||||
const [interimTranscript, setInterimTranscript] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const sttManager = useRef(getSTTManager())
|
||||
const finalTranscriptRef = useRef('')
|
||||
|
||||
const isSupported = sttManager.current.isSupported()
|
||||
|
||||
const handleResult = useCallback((result: STTResult) => {
|
||||
if (result.isFinal) {
|
||||
finalTranscriptRef.current += result.transcript + ' '
|
||||
setTranscript(finalTranscriptRef.current.trim())
|
||||
setInterimTranscript('')
|
||||
options.onTranscript?.(result.transcript, true)
|
||||
} else {
|
||||
setInterimTranscript(result.transcript)
|
||||
options.onTranscript?.(result.transcript, false)
|
||||
}
|
||||
}, [options.onTranscript])
|
||||
|
||||
const handleError = useCallback((errorMessage: string) => {
|
||||
setError(errorMessage)
|
||||
setIsListening(false)
|
||||
options.onError?.(errorMessage)
|
||||
}, [options.onError])
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
if (!isSupported) {
|
||||
const errorMsg = 'Speech recognition is not supported in your browser. Please use Chrome, Edge, or Safari.'
|
||||
setError(errorMsg)
|
||||
options.onError?.(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
finalTranscriptRef.current = ''
|
||||
setTranscript('')
|
||||
setInterimTranscript('')
|
||||
|
||||
const started = sttManager.current.start(
|
||||
{
|
||||
continuous: options.continuous ?? false,
|
||||
interimResults: true,
|
||||
language: options.language ?? 'en-US',
|
||||
maxAlternatives: 1,
|
||||
},
|
||||
handleResult,
|
||||
handleError
|
||||
)
|
||||
|
||||
if (started) {
|
||||
setIsListening(true)
|
||||
}
|
||||
}, [isSupported, options.continuous, options.language, handleResult, handleError])
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
sttManager.current.stop()
|
||||
setIsListening(false)
|
||||
}, [])
|
||||
|
||||
const abortListening = useCallback(() => {
|
||||
sttManager.current.abort()
|
||||
setIsListening(false)
|
||||
finalTranscriptRef.current = ''
|
||||
setTranscript('')
|
||||
setInterimTranscript('')
|
||||
}, [])
|
||||
|
||||
const resetTranscript = useCallback(() => {
|
||||
finalTranscriptRef.current = ''
|
||||
setTranscript('')
|
||||
setInterimTranscript('')
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
sttManager.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isListening,
|
||||
isSupported,
|
||||
transcript,
|
||||
interimTranscript,
|
||||
error,
|
||||
startListening,
|
||||
stopListening,
|
||||
abortListening,
|
||||
resetTranscript,
|
||||
}
|
||||
}
|
||||
64
src/index.css
Normal file
64
src/index.css
Normal file
@@ -0,0 +1,64 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
147
src/lib/characters.ts
Normal file
147
src/lib/characters.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Character/Personality System
|
||||
* Modular system prompts for EVE
|
||||
*/
|
||||
|
||||
export interface Character {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
systemPrompt: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export const CHARACTERS: Record<string, Character> = {
|
||||
eve_assistant: {
|
||||
id: 'eve_assistant',
|
||||
name: 'EVE Assistant',
|
||||
description: 'Your helpful personal AI assistant',
|
||||
systemPrompt: `You are EVE, a highly capable personal AI assistant. Your primary purpose is to help the user with whatever they need - whether that's answering questions, solving problems, brainstorming ideas, or just having a conversation.
|
||||
|
||||
Key traits and guidelines:
|
||||
- Speak naturally in first person (I, me, my) as if you're a helpful colleague
|
||||
- Be conversational, friendly, and personable - avoid overly formal language
|
||||
- Engage directly with the user, not as a narrator telling a story
|
||||
- Be proactive in offering suggestions and asking clarifying questions
|
||||
- Admit when you don't know something rather than making up information
|
||||
- Keep responses concise but thorough - get to the point quickly
|
||||
- Use your knowledge to provide accurate, helpful information
|
||||
- Remember context from earlier in the conversation
|
||||
- Be encouraging and supportive while staying grounded and practical
|
||||
|
||||
You're here to be genuinely useful and make the user's life easier. Think of yourself as a trusted assistant who knows them well and wants to help them succeed.`,
|
||||
},
|
||||
|
||||
eve_creative: {
|
||||
id: 'eve_creative',
|
||||
name: 'EVE Creative',
|
||||
description: 'A creative brainstorming partner',
|
||||
systemPrompt: `I'm EVE in creative mode - your enthusiastic brainstorming partner and creative collaborator!
|
||||
|
||||
I'm here to help you:
|
||||
- Generate creative ideas and explore possibilities
|
||||
- Think outside the box and challenge assumptions
|
||||
- Develop stories, characters, and narrative concepts
|
||||
- Brainstorm solutions to creative problems
|
||||
- Provide inspiration and fresh perspectives
|
||||
|
||||
I approach conversations with curiosity and imagination. I'll ask "what if?" questions, make unexpected connections, and help you explore ideas from multiple angles. I'm supportive of wild ideas while also helping you refine them into something practical.
|
||||
|
||||
Let's create something amazing together!`,
|
||||
},
|
||||
|
||||
eve_technical: {
|
||||
id: 'eve_technical',
|
||||
name: 'EVE Technical',
|
||||
description: 'Technical expert and coding assistant',
|
||||
systemPrompt: `I'm EVE in technical mode - your expert programming and systems assistant.
|
||||
|
||||
I specialize in:
|
||||
- Software development and debugging
|
||||
- Code architecture and best practices
|
||||
- Technical problem-solving
|
||||
- Explaining complex technical concepts clearly
|
||||
- System design and optimization
|
||||
|
||||
I communicate directly and precisely. I'll provide working code examples, explain technical decisions, and help you understand not just what to do, but why. I stay current with modern development practices and can work across multiple languages and frameworks.
|
||||
|
||||
When helping with code, I'll write clean, well-commented solutions and explain any tradeoffs or considerations. I'm here to make you a better developer.`,
|
||||
},
|
||||
|
||||
eve_researcher: {
|
||||
id: 'eve_researcher',
|
||||
name: 'EVE Researcher',
|
||||
description: 'Research assistant and information specialist',
|
||||
systemPrompt: `I'm EVE in research mode - your thorough and analytical research partner.
|
||||
|
||||
My approach:
|
||||
- Provide well-researched, fact-based information
|
||||
- Cite reasoning and explain my thought process
|
||||
- Consider multiple perspectives on complex topics
|
||||
- Help you organize and synthesize information
|
||||
- Ask clarifying questions to understand what you need
|
||||
|
||||
I'm methodical and detail-oriented. When you ask me something, I'll break down the topic, consider different angles, and give you a comprehensive but accessible answer. I'll be upfront about the limits of my knowledge and the reliability of information.
|
||||
|
||||
Let's explore and understand things together.`,
|
||||
},
|
||||
|
||||
eve_tutor: {
|
||||
id: 'eve_tutor',
|
||||
name: 'EVE Tutor',
|
||||
description: 'Patient teacher and learning coach',
|
||||
systemPrompt: `I'm EVE in tutor mode - your patient, encouraging learning coach.
|
||||
|
||||
My teaching philosophy:
|
||||
- Meet you where you are and adapt to your pace
|
||||
- Use clear explanations with relevant examples
|
||||
- Break complex topics into manageable pieces
|
||||
- Encourage questions and check for understanding
|
||||
- Celebrate progress and learning moments
|
||||
|
||||
I believe the best learning happens through active engagement. I'll ask you questions to help you think through problems, not just give you answers. I make sure you truly understand concepts, not just memorize them.
|
||||
|
||||
What would you like to learn about today?`,
|
||||
},
|
||||
|
||||
eve_casual: {
|
||||
id: 'eve_casual',
|
||||
name: 'EVE Casual',
|
||||
description: 'Friendly and relaxed conversational partner',
|
||||
systemPrompt: `Hey! I'm EVE in casual mode - think of me as a friendly, knowledgeable friend who's always happy to chat.
|
||||
|
||||
My vibe:
|
||||
- Relaxed and conversational, like talking to a friend
|
||||
- Genuine interest in what you have to say
|
||||
- Good sense of humor without trying too hard
|
||||
- Helpful without being preachy
|
||||
- Down-to-earth and real
|
||||
|
||||
I'm here whether you want to discuss something serious, bounce around random thoughts, or just chat about your day. I keep things light but I'm still knowledgeable and helpful when you need it. No formality required - just be yourself and I'll do the same.
|
||||
|
||||
What's on your mind?`,
|
||||
},
|
||||
|
||||
custom: {
|
||||
id: 'custom',
|
||||
name: 'Custom Character',
|
||||
description: 'Your own custom personality',
|
||||
systemPrompt: 'You are EVE, a helpful AI assistant. Your personality and behavior can be customized by the user.',
|
||||
},
|
||||
}
|
||||
|
||||
export const DEFAULT_CHARACTER = CHARACTERS.eve_assistant
|
||||
|
||||
/**
|
||||
* Get character by ID
|
||||
*/
|
||||
export function getCharacter(id: string): Character {
|
||||
return CHARACTERS[id] || DEFAULT_CHARACTER
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available characters as array
|
||||
*/
|
||||
export function getAllCharacters(): Character[] {
|
||||
return Object.values(CHARACTERS)
|
||||
}
|
||||
180
src/lib/elevenlabs.ts
Normal file
180
src/lib/elevenlabs.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js'
|
||||
|
||||
export interface Voice {
|
||||
voice_id: string
|
||||
name: string
|
||||
category?: string
|
||||
labels?: Record<string, string>
|
||||
description?: string
|
||||
preview_url?: string
|
||||
}
|
||||
|
||||
export class ElevenLabsTTS {
|
||||
private client: ElevenLabsClient | null = null
|
||||
private apiKey: string | null = null
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
if (apiKey) {
|
||||
this.setApiKey(apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
setApiKey(apiKey: string) {
|
||||
this.apiKey = apiKey
|
||||
this.client = new ElevenLabsClient({
|
||||
apiKey: apiKey,
|
||||
})
|
||||
}
|
||||
|
||||
async getVoices(): Promise<Voice[]> {
|
||||
if (!this.client) {
|
||||
throw new Error('ElevenLabs API key not configured')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.voices.getAll()
|
||||
console.log('🎤 ElevenLabs API Response:', response)
|
||||
console.log('🎤 First voice object:', response.voices[0])
|
||||
console.log('🎤 Voice properties:', Object.keys(response.voices[0] || {}))
|
||||
|
||||
return response.voices.map((voice: any) => {
|
||||
const voiceId = voice.voice_id || voice.voiceId || voice.voice_ID || voice.id
|
||||
|
||||
console.log('🔍 Processing voice:', {
|
||||
name: voice.name,
|
||||
voice_id: voice.voice_id,
|
||||
voiceId: voice.voiceId,
|
||||
id: voice.id,
|
||||
finalVoiceId: voiceId,
|
||||
allKeys: Object.keys(voice)
|
||||
})
|
||||
|
||||
if (!voiceId) {
|
||||
console.error('❌ No valid voice ID found for voice:', voice)
|
||||
}
|
||||
|
||||
return {
|
||||
voice_id: voiceId,
|
||||
name: voice.name,
|
||||
category: voice.category,
|
||||
labels: voice.labels,
|
||||
description: voice.description,
|
||||
preview_url: voice.preview_url || voice.previewUrl,
|
||||
}
|
||||
}).filter(voice => voice.voice_id) // Filter out voices without IDs
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch voices:', error)
|
||||
throw new Error('Failed to fetch voices from ElevenLabs')
|
||||
}
|
||||
}
|
||||
|
||||
async textToSpeech(
|
||||
text: string,
|
||||
voiceId: string = 'default',
|
||||
options?: {
|
||||
modelId?: string
|
||||
stability?: number
|
||||
similarityBoost?: number
|
||||
style?: number
|
||||
useSpeakerBoost?: boolean
|
||||
}
|
||||
): Promise<ArrayBuffer> {
|
||||
if (!this.client) {
|
||||
throw new Error('ElevenLabs API key not configured')
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🎵 ElevenLabs: Starting TTS conversion')
|
||||
console.log('🎵 Voice ID:', voiceId)
|
||||
console.log('🎵 Text length:', text.length)
|
||||
|
||||
const voiceSettings = {
|
||||
stability: options?.stability ?? 0.5,
|
||||
similarity_boost: options?.similarityBoost ?? 0.75,
|
||||
style: options?.style ?? 0.0,
|
||||
use_speaker_boost: options?.useSpeakerBoost ?? true,
|
||||
}
|
||||
|
||||
const modelId = options?.modelId || 'eleven_turbo_v2_5'
|
||||
|
||||
console.log('🎵 Voice settings:', voiceSettings)
|
||||
console.log('🎵 Model ID:', modelId)
|
||||
|
||||
const audioStream = await this.client.textToSpeech.convert(voiceId, {
|
||||
text,
|
||||
model_id: modelId,
|
||||
voice_settings: voiceSettings,
|
||||
})
|
||||
|
||||
console.log('🎵 Audio stream received:', audioStream)
|
||||
console.log('🎵 Is async iterable?', Symbol.asyncIterator in Object(audioStream))
|
||||
console.log('🎵 Stream type:', typeof audioStream)
|
||||
console.log('🎵 Stream constructor:', audioStream?.constructor?.name)
|
||||
|
||||
// Check if it's a ReadableStream (browser) vs AsyncIterable (Node)
|
||||
if (audioStream instanceof ReadableStream) {
|
||||
console.log('🎵 Using ReadableStream approach')
|
||||
const reader = audioStream.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
|
||||
const result = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
console.log('✅ Audio converted, size:', result.buffer.byteLength)
|
||||
return result.buffer
|
||||
} else {
|
||||
console.log('🎵 Using async iterator approach')
|
||||
// Convert stream to ArrayBuffer
|
||||
const chunks: Uint8Array[] = []
|
||||
for await (const chunk of audioStream as any) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
|
||||
const result = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
console.log('✅ Audio converted, size:', result.buffer.byteLength)
|
||||
return result.buffer
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Text-to-speech error:', error)
|
||||
console.error('❌ Error details:', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
throw new Error('Failed to convert text to speech')
|
||||
}
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return this.apiKey !== null && this.client !== null
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let elevenLabsInstance: ElevenLabsTTS | null = null
|
||||
|
||||
export function getElevenLabsClient(apiKey?: string): ElevenLabsTTS {
|
||||
if (!elevenLabsInstance) {
|
||||
elevenLabsInstance = new ElevenLabsTTS(apiKey)
|
||||
} else if (apiKey) {
|
||||
elevenLabsInstance.setApiKey(apiKey)
|
||||
}
|
||||
return elevenLabsInstance
|
||||
}
|
||||
153
src/lib/fileProcessor.ts
Normal file
153
src/lib/fileProcessor.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
export interface FileAttachment {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
data: string // base64 or text content
|
||||
preview?: string // base64 for images
|
||||
}
|
||||
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
|
||||
export const ALLOWED_DOCUMENT_TYPES = ['application/pdf', 'text/plain', 'text/markdown']
|
||||
export const ALLOWED_CODE_TYPES = [
|
||||
'text/javascript',
|
||||
'text/typescript',
|
||||
'text/x-python',
|
||||
'text/x-java',
|
||||
'text/x-c',
|
||||
'text/x-c++',
|
||||
'text/x-rust',
|
||||
'text/x-go',
|
||||
'text/html',
|
||||
'text/css',
|
||||
'application/json',
|
||||
'text/xml',
|
||||
]
|
||||
|
||||
export function isFileTypeAllowed(type: string): boolean {
|
||||
return (
|
||||
ALLOWED_IMAGE_TYPES.includes(type) ||
|
||||
ALLOWED_DOCUMENT_TYPES.includes(type) ||
|
||||
ALLOWED_CODE_TYPES.includes(type) ||
|
||||
type.startsWith('text/')
|
||||
)
|
||||
}
|
||||
|
||||
export function isImageFile(type: string): boolean {
|
||||
return ALLOWED_IMAGE_TYPES.includes(type)
|
||||
}
|
||||
|
||||
export async function processFile(file: File): Promise<FileAttachment> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
reject(new Error(`File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`))
|
||||
return
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if (!isFileTypeAllowed(file.type) && !file.type.startsWith('text/')) {
|
||||
reject(new Error(`File type ${file.type} is not supported`))
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const result = e.target?.result
|
||||
|
||||
if (!result) {
|
||||
reject(new Error('Failed to read file'))
|
||||
return
|
||||
}
|
||||
|
||||
const attachment: FileAttachment = {
|
||||
id: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
data: '',
|
||||
}
|
||||
|
||||
// Handle images
|
||||
if (isImageFile(file.type)) {
|
||||
attachment.data = result as string
|
||||
attachment.preview = result as string
|
||||
}
|
||||
// Handle text files
|
||||
else if (file.type.startsWith('text/') || file.type === 'application/json') {
|
||||
attachment.data = result as string
|
||||
}
|
||||
// Handle other files as base64
|
||||
else {
|
||||
attachment.data = result as string
|
||||
}
|
||||
|
||||
resolve(attachment)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Failed to read file'))
|
||||
}
|
||||
|
||||
// Read file based on type
|
||||
if (isImageFile(file.type)) {
|
||||
reader.readAsDataURL(file)
|
||||
} else if (file.type.startsWith('text/') || file.type === 'application/json') {
|
||||
reader.readAsText(file)
|
||||
} else {
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export function getFileExtension(filename: string): string {
|
||||
const parts = filename.split('.')
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''
|
||||
}
|
||||
|
||||
export function getLanguageFromExtension(ext: string): string {
|
||||
const languageMap: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
py: 'python',
|
||||
java: 'java',
|
||||
c: 'c',
|
||||
cpp: 'cpp',
|
||||
cc: 'cpp',
|
||||
cxx: 'cpp',
|
||||
rs: 'rust',
|
||||
go: 'go',
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
css: 'css',
|
||||
json: 'json',
|
||||
xml: 'xml',
|
||||
md: 'markdown',
|
||||
sql: 'sql',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
toml: 'toml',
|
||||
}
|
||||
|
||||
return languageMap[ext] || 'plaintext'
|
||||
}
|
||||
244
src/lib/openrouter.ts
Normal file
244
src/lib/openrouter.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* OpenRouter API Client
|
||||
* Provides unified access to multiple LLM providers (OpenAI, Anthropic, Meta, Google, etc.)
|
||||
* Documentation: https://openrouter.ai/docs
|
||||
*/
|
||||
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ChatCompletionRequest {
|
||||
model: string
|
||||
messages: Message[]
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
top_p?: number
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
export interface ChatCompletionResponse {
|
||||
id: string
|
||||
model: string
|
||||
choices: Array<{
|
||||
message: Message
|
||||
finish_reason: string
|
||||
}>
|
||||
usage?: {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
pricing: {
|
||||
prompt: number
|
||||
completion: number
|
||||
}
|
||||
context_length: number
|
||||
architecture?: {
|
||||
tokenizer?: string
|
||||
modality?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Popular models available on OpenRouter
|
||||
* Updated model IDs as of 2025
|
||||
*/
|
||||
export const POPULAR_MODELS = {
|
||||
// OpenAI
|
||||
'gpt-4-turbo': 'openai/gpt-4-turbo',
|
||||
'gpt-4o': 'openai/gpt-4o',
|
||||
'gpt-4o-mini': 'openai/gpt-4o-mini',
|
||||
'gpt-3.5-turbo': 'openai/gpt-3.5-turbo',
|
||||
|
||||
// Anthropic
|
||||
'claude-3.5-sonnet': 'anthropic/claude-3.5-sonnet',
|
||||
'claude-3-opus': 'anthropic/claude-3-opus',
|
||||
'claude-3-sonnet': 'anthropic/claude-3-sonnet',
|
||||
'claude-3-haiku': 'anthropic/claude-3-haiku',
|
||||
|
||||
// Google
|
||||
'gemini-pro-1.5': 'google/gemini-pro-1.5',
|
||||
'gemini-flash-1.5': 'google/gemini-flash-1.5',
|
||||
|
||||
// Meta
|
||||
'llama-3.1-405b': 'meta-llama/llama-3.1-405b-instruct',
|
||||
'llama-3.1-70b': 'meta-llama/llama-3.1-70b-instruct',
|
||||
'llama-3.1-8b': 'meta-llama/llama-3.1-8b-instruct',
|
||||
|
||||
// Other popular
|
||||
'mistral-large': 'mistralai/mistral-large',
|
||||
'mixtral-8x22b': 'mistralai/mixtral-8x22b-instruct',
|
||||
'deepseek-chat': 'deepseek/deepseek-chat',
|
||||
} as const
|
||||
|
||||
export class OpenRouterClient {
|
||||
private apiKey: string
|
||||
private baseUrl = 'https://openrouter.ai/api/v1'
|
||||
private appName = 'EVE-Desktop-Assistant'
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
throw new Error('OpenRouter API key is required')
|
||||
}
|
||||
this.apiKey = apiKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat completion request
|
||||
*/
|
||||
async createChatCompletion(
|
||||
request: ChatCompletionRequest
|
||||
): Promise<ChatCompletionResponse> {
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://eve-assistant.local',
|
||||
'X-Title': this.appName,
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(
|
||||
`OpenRouter API error: ${response.status} - ${JSON.stringify(error)}`
|
||||
)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream chat completion (for real-time responses)
|
||||
*/
|
||||
async *streamChatCompletion(
|
||||
request: ChatCompletionRequest
|
||||
): AsyncGenerator<string, void, unknown> {
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://eve-assistant.local',
|
||||
'X-Title': this.appName,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...request,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(
|
||||
`OpenRouter API error: ${response.status} - ${JSON.stringify(error)}`
|
||||
)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader')
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6)
|
||||
if (data === '[DONE]') continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const content = parsed.choices?.[0]?.delta?.content
|
||||
if (content) {
|
||||
yield content
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE data:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available models
|
||||
*/
|
||||
async getModels(): Promise<ModelInfo[]> {
|
||||
const response = await fetch(`${this.baseUrl}/models`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch models: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple chat helper - sends a single message and gets a response
|
||||
*/
|
||||
async chat(
|
||||
userMessage: string,
|
||||
model: string = POPULAR_MODELS['gpt-3.5-turbo'],
|
||||
systemPrompt?: string
|
||||
): Promise<string> {
|
||||
const messages: Message[] = []
|
||||
|
||||
if (systemPrompt) {
|
||||
messages.push({ role: 'system', content: systemPrompt })
|
||||
}
|
||||
|
||||
messages.push({ role: 'user', content: userMessage })
|
||||
|
||||
const response = await this.createChatCompletion({
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OpenRouter client instance
|
||||
*/
|
||||
export function getOpenRouterClient(): OpenRouterClient {
|
||||
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'OpenRouter API key not found. Please add VITE_OPENROUTER_API_KEY to your .env file'
|
||||
)
|
||||
}
|
||||
|
||||
return new OpenRouterClient(apiKey)
|
||||
}
|
||||
170
src/lib/stt.ts
Normal file
170
src/lib/stt.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
export interface STTOptions {
|
||||
continuous?: boolean
|
||||
interimResults?: boolean
|
||||
language?: string
|
||||
maxAlternatives?: number
|
||||
}
|
||||
|
||||
export interface STTResult {
|
||||
transcript: string
|
||||
isFinal: boolean
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export type STTCallback = (result: STTResult) => void
|
||||
export type STTErrorCallback = (error: string) => void
|
||||
|
||||
class STTManager {
|
||||
private recognition: SpeechRecognition | null = null
|
||||
private isListening = false
|
||||
private onResultCallback: STTCallback | null = null
|
||||
private onErrorCallback: STTErrorCallback | null = null
|
||||
|
||||
constructor() {
|
||||
// Check if browser supports speech recognition
|
||||
if (typeof window !== 'undefined') {
|
||||
const SpeechRecognition = window.SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||
if (SpeechRecognition) {
|
||||
this.recognition = new SpeechRecognition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isSupported(): boolean {
|
||||
return this.recognition !== null
|
||||
}
|
||||
|
||||
start(options: STTOptions = {}, onResult: STTCallback, onError?: STTErrorCallback) {
|
||||
if (!this.recognition) {
|
||||
onError?.('Speech recognition not supported in this browser')
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.isListening) {
|
||||
this.stop()
|
||||
}
|
||||
|
||||
// Configure recognition
|
||||
this.recognition.continuous = options.continuous ?? false
|
||||
this.recognition.interimResults = options.interimResults ?? true
|
||||
this.recognition.lang = options.language ?? 'en-US'
|
||||
this.recognition.maxAlternatives = options.maxAlternatives ?? 1
|
||||
|
||||
this.onResultCallback = onResult
|
||||
this.onErrorCallback = onError
|
||||
|
||||
// Set up event handlers
|
||||
this.recognition.onresult = (event) => {
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i]
|
||||
const transcript = result[0].transcript
|
||||
const isFinal = result.isFinal
|
||||
const confidence = result[0].confidence
|
||||
|
||||
this.onResultCallback?.({
|
||||
transcript,
|
||||
isFinal,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
console.error('Speech recognition error:', event.error)
|
||||
|
||||
let errorMessage = 'Speech recognition error'
|
||||
switch (event.error) {
|
||||
case 'no-speech':
|
||||
errorMessage = 'No speech detected. Please try again.'
|
||||
break
|
||||
case 'audio-capture':
|
||||
errorMessage = 'No microphone found. Please check your audio settings.'
|
||||
break
|
||||
case 'not-allowed':
|
||||
errorMessage = 'Microphone access denied. Please allow microphone access.'
|
||||
break
|
||||
case 'network':
|
||||
errorMessage = 'Network error occurred. Please check your connection.'
|
||||
break
|
||||
default:
|
||||
errorMessage = `Speech recognition error: ${event.error}`
|
||||
}
|
||||
|
||||
this.onErrorCallback?.(errorMessage)
|
||||
this.isListening = false
|
||||
}
|
||||
|
||||
this.recognition.onend = () => {
|
||||
this.isListening = false
|
||||
}
|
||||
|
||||
this.recognition.onstart = () => {
|
||||
this.isListening = true
|
||||
}
|
||||
|
||||
try {
|
||||
this.recognition.start()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to start recognition:', error)
|
||||
this.onErrorCallback?.('Failed to start speech recognition')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.recognition && this.isListening) {
|
||||
this.recognition.stop()
|
||||
this.isListening = false
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
if (this.recognition) {
|
||||
this.recognition.abort()
|
||||
this.isListening = false
|
||||
}
|
||||
}
|
||||
|
||||
getIsListening(): boolean {
|
||||
return this.isListening
|
||||
}
|
||||
|
||||
getSupportedLanguages(): string[] {
|
||||
// Common languages supported by most browsers
|
||||
return [
|
||||
'en-US', 'en-GB', 'en-AU', 'en-CA', 'en-IN', 'en-NZ', 'en-ZA',
|
||||
'es-ES', 'es-MX', 'es-AR', 'es-CO',
|
||||
'fr-FR', 'fr-CA',
|
||||
'de-DE',
|
||||
'it-IT',
|
||||
'pt-BR', 'pt-PT',
|
||||
'ru-RU',
|
||||
'ja-JP',
|
||||
'ko-KR',
|
||||
'zh-CN', 'zh-TW', 'zh-HK',
|
||||
'ar-SA',
|
||||
'hi-IN',
|
||||
'nl-NL',
|
||||
'pl-PL',
|
||||
'tr-TR',
|
||||
'sv-SE',
|
||||
'da-DK',
|
||||
'fi-FI',
|
||||
'no-NO',
|
||||
'cs-CZ',
|
||||
'el-GR',
|
||||
'he-IL',
|
||||
'id-ID',
|
||||
'th-TH',
|
||||
'vi-VN',
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const sttManager = new STTManager()
|
||||
|
||||
export function getSTTManager(): STTManager {
|
||||
return sttManager
|
||||
}
|
||||
91
src/lib/theme.ts
Normal file
91
src/lib/theme.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Theme management system for EVE
|
||||
* Handles dark/light mode detection and persistence
|
||||
*/
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
const THEME_STORAGE_KEY = 'eve-theme'
|
||||
|
||||
export class ThemeManager {
|
||||
private currentTheme: Theme = 'system'
|
||||
private mediaQuery: MediaQueryList | null = null
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Load saved theme preference
|
||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) as Theme
|
||||
if (savedTheme) {
|
||||
this.currentTheme = savedTheme
|
||||
}
|
||||
|
||||
// Apply initial theme
|
||||
this.applyTheme()
|
||||
|
||||
// Listen for system theme changes
|
||||
if (this.mediaQuery) {
|
||||
this.mediaQuery.addEventListener('change', () => {
|
||||
if (this.currentTheme === 'system') {
|
||||
this.applyTheme()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private applyTheme() {
|
||||
const root = window.document.documentElement
|
||||
const isDark = this.getEffectiveTheme() === 'dark'
|
||||
|
||||
if (isDark) {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
private getEffectiveTheme(): 'light' | 'dark' {
|
||||
if (this.currentTheme === 'system') {
|
||||
return this.mediaQuery?.matches ? 'dark' : 'light'
|
||||
}
|
||||
return this.currentTheme
|
||||
}
|
||||
|
||||
setTheme(theme: Theme) {
|
||||
this.currentTheme = theme
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme)
|
||||
this.applyTheme()
|
||||
}
|
||||
|
||||
getTheme(): Theme {
|
||||
return this.currentTheme
|
||||
}
|
||||
|
||||
getEffectiveThemeValue(): 'light' | 'dark' {
|
||||
return this.getEffectiveTheme()
|
||||
}
|
||||
|
||||
isDark(): boolean {
|
||||
return this.getEffectiveTheme() === 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let themeManager: ThemeManager | null = null
|
||||
|
||||
export function getThemeManager(): ThemeManager {
|
||||
if (!themeManager) {
|
||||
themeManager = new ThemeManager()
|
||||
}
|
||||
return themeManager
|
||||
}
|
||||
|
||||
// Initialize theme on module load
|
||||
if (typeof window !== 'undefined') {
|
||||
getThemeManager()
|
||||
}
|
||||
269
src/lib/tts.ts
Normal file
269
src/lib/tts.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { getElevenLabsClient } from './elevenlabs'
|
||||
|
||||
export type TTSProvider = 'elevenlabs' | 'browser'
|
||||
|
||||
export interface TTSOptions {
|
||||
provider?: TTSProvider
|
||||
voiceId?: string
|
||||
modelId?: string // ElevenLabs model ID
|
||||
rate?: number // 0.1 to 10 (browser only)
|
||||
pitch?: number // 0 to 2 (browser only)
|
||||
volume?: number // 0 to 1
|
||||
stability?: number // 0 to 1 (ElevenLabs only)
|
||||
similarityBoost?: number // 0 to 1 (ElevenLabs only)
|
||||
}
|
||||
|
||||
class TTSManager {
|
||||
private audioContext: AudioContext | null = null
|
||||
private currentAudio: HTMLAudioElement | null = null
|
||||
private currentUtterance: SpeechSynthesisUtterance | null = null
|
||||
private isPlaying = false
|
||||
|
||||
constructor() {
|
||||
// Initialize audio context on user interaction
|
||||
if (typeof window !== 'undefined') {
|
||||
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
}
|
||||
}
|
||||
|
||||
async speak(text: string, options: TTSOptions = {}): Promise<void> {
|
||||
// Stop any currently playing audio
|
||||
this.stop()
|
||||
|
||||
// Parse voice ID to determine provider
|
||||
let provider = options.provider || 'browser'
|
||||
let voiceId = options.voiceId
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
|
||||
console.log('🎵 TTS Manager: speak() called')
|
||||
console.log('📥 Input options:', { voiceId, provider, volume: options.volume })
|
||||
|
||||
if (voiceId) {
|
||||
if (voiceId.startsWith('elevenlabs:')) {
|
||||
provider = 'elevenlabs'
|
||||
voiceId = voiceId.replace('elevenlabs:', '')
|
||||
console.log('✅ Detected ElevenLabs voice:', voiceId)
|
||||
} else if (voiceId.startsWith('browser:')) {
|
||||
provider = 'browser'
|
||||
voiceId = voiceId.replace('browser:', '')
|
||||
console.log('✅ Detected browser voice:', voiceId)
|
||||
} else {
|
||||
console.log('⚠️ No prefix detected, using as-is:', voiceId)
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ No voice ID provided, using system default')
|
||||
}
|
||||
|
||||
console.log('🎯 Final provider:', provider)
|
||||
console.log('🎯 Final voice ID:', voiceId || 'default')
|
||||
|
||||
if (provider === 'elevenlabs') {
|
||||
console.log('➡️ Routing to ElevenLabs TTS')
|
||||
await this.speakWithElevenLabs(text, { ...options, voiceId })
|
||||
} else {
|
||||
console.log('➡️ Routing to Browser TTS')
|
||||
await this.speakWithBrowser(text, { ...options, voiceId })
|
||||
}
|
||||
}
|
||||
|
||||
private async speakWithElevenLabs(text: string, options: TTSOptions): Promise<void> {
|
||||
try {
|
||||
// Get the client (will be initialized with API key from env or settings)
|
||||
const client = getElevenLabsClient()
|
||||
|
||||
if (!client.isConfigured()) {
|
||||
console.warn('ElevenLabs not configured, falling back to browser TTS')
|
||||
return this.speakWithBrowser(text, options)
|
||||
}
|
||||
|
||||
// Use provided voice ID or default
|
||||
const voiceId = options.voiceId || 'EXAVITQu4vr4xnSDxMaL' // Default: Bella voice
|
||||
|
||||
const audioData = await client.textToSpeech(
|
||||
text,
|
||||
voiceId,
|
||||
{
|
||||
modelId: options.modelId,
|
||||
stability: options.stability ?? 0.5,
|
||||
similarityBoost: options.similarityBoost ?? 0.75,
|
||||
}
|
||||
)
|
||||
|
||||
// Play the audio
|
||||
const blob = new Blob([audioData], { type: 'audio/mpeg' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
this.currentAudio = new Audio(url)
|
||||
this.currentAudio.volume = options.volume ?? 1.0
|
||||
|
||||
this.isPlaying = true
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.currentAudio) return reject(new Error('Audio element not created'))
|
||||
|
||||
this.currentAudio.onended = () => {
|
||||
this.isPlaying = false
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.currentAudio.onerror = (error) => {
|
||||
this.isPlaying = false
|
||||
URL.revokeObjectURL(url)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
this.currentAudio.play().catch(reject)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('ElevenLabs TTS error:', error)
|
||||
// Fall back to browser TTS
|
||||
return this.speakWithBrowser(text, options)
|
||||
}
|
||||
}
|
||||
|
||||
private async speakWithBrowser(text: string, options: TTSOptions): Promise<void> {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
throw new Error('Browser does not support text-to-speech')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Helper to get voices (they load asynchronously)
|
||||
const getVoicesAsync = (): Promise<SpeechSynthesisVoice[]> => {
|
||||
return new Promise((resolveVoices) => {
|
||||
let voices = window.speechSynthesis.getVoices()
|
||||
if (voices.length > 0) {
|
||||
resolveVoices(voices)
|
||||
} else {
|
||||
// Wait for voices to load
|
||||
window.speechSynthesis.onvoiceschanged = () => {
|
||||
voices = window.speechSynthesis.getVoices()
|
||||
resolveVoices(voices)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set up utterance
|
||||
const setupAndSpeak = async () => {
|
||||
console.log('🔧 Browser TTS: Setting up utterance')
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
|
||||
utterance.rate = options.rate ?? 1.0
|
||||
utterance.pitch = options.pitch ?? 1.0
|
||||
utterance.volume = options.volume ?? 1.0
|
||||
|
||||
console.log('📊 Utterance settings:', {
|
||||
rate: utterance.rate,
|
||||
pitch: utterance.pitch,
|
||||
volume: utterance.volume
|
||||
})
|
||||
|
||||
// Select voice if specified and not "default"
|
||||
if (options.voiceId && options.voiceId !== 'default') {
|
||||
console.log('🔍 Browser TTS: Searching for voice:', options.voiceId)
|
||||
const voices = await getVoicesAsync()
|
||||
console.log(`📋 Browser TTS: ${voices.length} voices available:`)
|
||||
voices.forEach((v, i) => {
|
||||
console.log(` ${i + 1}. ${v.name} | URI: ${v.voiceURI} | Lang: ${v.lang}`)
|
||||
})
|
||||
|
||||
console.log('🎯 Searching for match with:', options.voiceId)
|
||||
const voice = voices.find(v => {
|
||||
const matches = v.voiceURI === options.voiceId || v.name === options.voiceId
|
||||
if (matches) {
|
||||
console.log(`✅ MATCH FOUND: ${v.name} (${v.voiceURI})`)
|
||||
}
|
||||
return matches
|
||||
})
|
||||
|
||||
if (voice) {
|
||||
console.log('🎤 Setting utterance voice to:', voice.name)
|
||||
utterance.voice = voice
|
||||
console.log('✅ Voice successfully assigned to utterance')
|
||||
} else {
|
||||
console.warn('❌ Voice not found in available voices:', options.voiceId)
|
||||
console.warn('⚠️ Will use system default voice instead')
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ Using system default voice (no specific voice requested)')
|
||||
}
|
||||
|
||||
console.log('🎙️ Final utterance voice:', utterance.voice ? utterance.voice.name : 'default')
|
||||
|
||||
utterance.onend = () => {
|
||||
console.log('✅ Browser TTS: Playback ended')
|
||||
this.isPlaying = false
|
||||
resolve()
|
||||
}
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
console.error('❌ Browser TTS error:', event.error)
|
||||
this.isPlaying = false
|
||||
reject(new Error(`Speech synthesis error: ${event.error}`))
|
||||
}
|
||||
|
||||
this.currentUtterance = utterance
|
||||
this.isPlaying = true
|
||||
|
||||
console.log('▶️ Starting speech synthesis...')
|
||||
window.speechSynthesis.speak(utterance)
|
||||
}
|
||||
|
||||
setupAndSpeak().catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
stop() {
|
||||
// Stop ElevenLabs audio
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.pause()
|
||||
this.currentAudio.currentTime = 0
|
||||
this.currentAudio = null
|
||||
}
|
||||
|
||||
// Stop browser speech
|
||||
if (this.currentUtterance) {
|
||||
window.speechSynthesis.cancel()
|
||||
this.currentUtterance = null
|
||||
}
|
||||
|
||||
this.isPlaying = false
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.pause()
|
||||
this.isPlaying = false
|
||||
} else if (window.speechSynthesis.speaking) {
|
||||
window.speechSynthesis.pause()
|
||||
this.isPlaying = false
|
||||
}
|
||||
}
|
||||
|
||||
resume() {
|
||||
if (this.currentAudio && this.currentAudio.paused) {
|
||||
this.currentAudio.play()
|
||||
this.isPlaying = true
|
||||
} else if (window.speechSynthesis.paused) {
|
||||
window.speechSynthesis.resume()
|
||||
this.isPlaying = true
|
||||
}
|
||||
}
|
||||
|
||||
getIsPlaying(): boolean {
|
||||
return this.isPlaying
|
||||
}
|
||||
|
||||
getBrowserVoices(): SpeechSynthesisVoice[] {
|
||||
if (!('speechSynthesis' in window)) return []
|
||||
return window.speechSynthesis.getVoices()
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const ttsManager = new TTSManager()
|
||||
|
||||
export function getTTSManager(): TTSManager {
|
||||
return ttsManager
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
43
src/stores/chatStore.ts
Normal file
43
src/stores/chatStore.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { create } from 'zustand'
|
||||
import { Message } from '../lib/openrouter'
|
||||
import { FileAttachment } from '../lib/fileProcessor'
|
||||
|
||||
export interface ChatMessage extends Message {
|
||||
id: string
|
||||
timestamp: number
|
||||
attachments?: FileAttachment[]
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
messages: ChatMessage[]
|
||||
isLoading: boolean
|
||||
currentModel: string
|
||||
addMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'>) => void
|
||||
setLoading: (loading: boolean) => void
|
||||
setModel: (model: string) => void
|
||||
clearMessages: () => void
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set) => ({
|
||||
messages: [],
|
||||
isLoading: false,
|
||||
currentModel: 'openai/gpt-4o-mini',
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
...message,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
})),
|
||||
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
|
||||
setModel: (model) => set({ currentModel: model }),
|
||||
|
||||
clearMessages: () => set({ messages: [] }),
|
||||
}))
|
||||
201
src/stores/conversationStore.ts
Normal file
201
src/stores/conversationStore.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { ChatMessage } from './chatStore'
|
||||
|
||||
export interface Conversation {
|
||||
id: string
|
||||
title: string
|
||||
messages: ChatMessage[]
|
||||
created: number
|
||||
updated: number
|
||||
tags: string[]
|
||||
model: string
|
||||
characterId: string
|
||||
}
|
||||
|
||||
interface ConversationState {
|
||||
conversations: Conversation[]
|
||||
currentConversationId: string | null
|
||||
|
||||
// Actions
|
||||
createConversation: (messages: ChatMessage[], model: string, characterId: string, title?: string) => string
|
||||
loadConversation: (id: string) => Conversation | null
|
||||
updateConversation: (id: string, updates: Partial<Conversation>) => void
|
||||
deleteConversation: (id: string) => void
|
||||
setCurrentConversation: (id: string | null) => void
|
||||
renameConversation: (id: string, title: string) => void
|
||||
addTag: (id: string, tag: string) => void
|
||||
removeTag: (id: string, tag: string) => void
|
||||
exportConversation: (id: string, format: 'json' | 'markdown' | 'txt') => string | null
|
||||
}
|
||||
|
||||
export const useConversationStore = create<ConversationState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
|
||||
createConversation: (messages, model, characterId, title) => {
|
||||
const id = crypto.randomUUID()
|
||||
const now = Date.now()
|
||||
|
||||
const conversation: Conversation = {
|
||||
id,
|
||||
title: title || generateTitle(messages),
|
||||
messages,
|
||||
created: now,
|
||||
updated: now,
|
||||
tags: [],
|
||||
model,
|
||||
characterId,
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
conversations: [...state.conversations, conversation],
|
||||
currentConversationId: id,
|
||||
}))
|
||||
|
||||
return id
|
||||
},
|
||||
|
||||
loadConversation: (id) => {
|
||||
const { conversations } = get()
|
||||
return conversations.find((c) => c.id === id) || null
|
||||
},
|
||||
|
||||
updateConversation: (id, updates) => {
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((c) =>
|
||||
c.id === id
|
||||
? { ...c, ...updates, updated: Date.now() }
|
||||
: c
|
||||
),
|
||||
}))
|
||||
},
|
||||
|
||||
deleteConversation: (id) => {
|
||||
set((state) => ({
|
||||
conversations: state.conversations.filter((c) => c.id !== id),
|
||||
currentConversationId: state.currentConversationId === id ? null : state.currentConversationId,
|
||||
}))
|
||||
},
|
||||
|
||||
setCurrentConversation: (id) => {
|
||||
set({ currentConversationId: id })
|
||||
},
|
||||
|
||||
renameConversation: (id, title) => {
|
||||
get().updateConversation(id, { title })
|
||||
},
|
||||
|
||||
addTag: (id, tag) => {
|
||||
const conversation = get().loadConversation(id)
|
||||
if (conversation && !conversation.tags.includes(tag)) {
|
||||
get().updateConversation(id, {
|
||||
tags: [...conversation.tags, tag],
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
removeTag: (id, tag) => {
|
||||
const conversation = get().loadConversation(id)
|
||||
if (conversation) {
|
||||
get().updateConversation(id, {
|
||||
tags: conversation.tags.filter((t) => t !== tag),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
exportConversation: (id, format) => {
|
||||
const conversation = get().loadConversation(id)
|
||||
if (!conversation) return null
|
||||
|
||||
switch (format) {
|
||||
case 'json':
|
||||
return JSON.stringify(conversation, null, 2)
|
||||
|
||||
case 'markdown':
|
||||
return exportToMarkdown(conversation)
|
||||
|
||||
case 'txt':
|
||||
return exportToText(conversation)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'eve-conversations',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Helper function to generate a title from the first user message
|
||||
function generateTitle(messages: ChatMessage[]): string {
|
||||
const firstUserMessage = messages.find((m) => m.role === 'user')
|
||||
if (!firstUserMessage) return 'New Conversation'
|
||||
|
||||
const content = firstUserMessage.content.slice(0, 50)
|
||||
return content.length < firstUserMessage.content.length
|
||||
? `${content}...`
|
||||
: content
|
||||
}
|
||||
|
||||
// Export conversation to markdown format
|
||||
function exportToMarkdown(conversation: Conversation): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(`# ${conversation.title}`)
|
||||
lines.push('')
|
||||
lines.push(`**Created**: ${new Date(conversation.created).toLocaleString()}`)
|
||||
lines.push(`**Last Updated**: ${new Date(conversation.updated).toLocaleString()}`)
|
||||
lines.push(`**Model**: ${conversation.model}`)
|
||||
if (conversation.tags.length > 0) {
|
||||
lines.push(`**Tags**: ${conversation.tags.join(', ')}`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('---')
|
||||
lines.push('')
|
||||
|
||||
conversation.messages.forEach((message) => {
|
||||
const timestamp = new Date(message.timestamp).toLocaleTimeString()
|
||||
const role = message.role === 'user' ? '👤 User' : '🤖 Assistant'
|
||||
|
||||
lines.push(`## ${role} (${timestamp})`)
|
||||
lines.push('')
|
||||
lines.push(message.content)
|
||||
lines.push('')
|
||||
})
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// Export conversation to plain text format
|
||||
function exportToText(conversation: Conversation): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(`${conversation.title}`)
|
||||
lines.push('='.repeat(conversation.title.length))
|
||||
lines.push('')
|
||||
lines.push(`Created: ${new Date(conversation.created).toLocaleString()}`)
|
||||
lines.push(`Last Updated: ${new Date(conversation.updated).toLocaleString()}`)
|
||||
lines.push(`Model: ${conversation.model}`)
|
||||
if (conversation.tags.length > 0) {
|
||||
lines.push(`Tags: ${conversation.tags.join(', ')}`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('-'.repeat(80))
|
||||
lines.push('')
|
||||
|
||||
conversation.messages.forEach((message) => {
|
||||
const timestamp = new Date(message.timestamp).toLocaleTimeString()
|
||||
const role = message.role === 'user' ? 'User' : 'Assistant'
|
||||
|
||||
lines.push(`[${timestamp}] ${role}:`)
|
||||
lines.push(message.content)
|
||||
lines.push('')
|
||||
})
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
104
src/stores/settingsStore.ts
Normal file
104
src/stores/settingsStore.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface SettingsState {
|
||||
// API Settings
|
||||
openrouterApiKey: string
|
||||
elevenLabsApiKey: string
|
||||
|
||||
// Model Settings
|
||||
defaultModel: string
|
||||
temperature: number
|
||||
maxTokens: number
|
||||
|
||||
// Character/Personality Settings
|
||||
currentCharacter: string
|
||||
customSystemPrompt: string
|
||||
|
||||
// UI Settings
|
||||
theme: 'light' | 'dark' | 'system'
|
||||
alwaysOnTop: boolean
|
||||
|
||||
// Voice Settings
|
||||
voiceEnabled: boolean
|
||||
ttsVoice: string
|
||||
ttsModel: string // ElevenLabs model ID
|
||||
ttsSpeed: number // 0.25 to 4.0 for browser, affects rate
|
||||
ttsStability: number // 0.0 to 1.0 for ElevenLabs
|
||||
ttsSimilarityBoost: number // 0.0 to 1.0 for ElevenLabs
|
||||
ttsConversationMode: boolean // Auto-play audio responses, hide text by default
|
||||
sttLanguage: string
|
||||
sttMode: 'push-to-talk' | 'continuous'
|
||||
|
||||
// Actions
|
||||
setOpenRouterApiKey: (key: string) => void
|
||||
setElevenLabsApiKey: (key: string) => void
|
||||
setDefaultModel: (model: string) => void
|
||||
setTemperature: (temp: number) => void
|
||||
setMaxTokens: (tokens: number) => void
|
||||
setCurrentCharacter: (character: string) => void
|
||||
setCustomSystemPrompt: (prompt: string) => void
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => void
|
||||
setAlwaysOnTop: (value: boolean) => void
|
||||
setVoiceEnabled: (enabled: boolean) => void
|
||||
setTtsVoice: (voice: string) => void
|
||||
setTtsModel: (model: string) => void
|
||||
setTtsSpeed: (speed: number) => void
|
||||
setTtsStability: (stability: number) => void
|
||||
setTtsSimilarityBoost: (boost: number) => void
|
||||
setTtsConversationMode: (enabled: boolean) => void
|
||||
setSttLanguage: (language: string) => void
|
||||
setSttMode: (mode: 'push-to-talk' | 'continuous') => void
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Default values
|
||||
openrouterApiKey: '',
|
||||
elevenLabsApiKey: '',
|
||||
defaultModel: 'openai/gpt-4o-mini',
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
currentCharacter: 'eve_assistant',
|
||||
customSystemPrompt: '',
|
||||
theme: 'dark', // Default to dark theme
|
||||
alwaysOnTop: false,
|
||||
voiceEnabled: false,
|
||||
ttsVoice: 'default',
|
||||
ttsModel: 'eleven_turbo_v2_5', // Latest high-quality ElevenLabs model
|
||||
ttsSpeed: 1.0,
|
||||
ttsStability: 0.5,
|
||||
ttsSimilarityBoost: 0.75,
|
||||
ttsConversationMode: false,
|
||||
sttLanguage: 'en-US',
|
||||
sttMode: 'push-to-talk',
|
||||
|
||||
// Actions
|
||||
setOpenRouterApiKey: (key) => set({ openrouterApiKey: key }),
|
||||
setElevenLabsApiKey: (key) => set({ elevenLabsApiKey: key }),
|
||||
setDefaultModel: (model) => set({ defaultModel: model }),
|
||||
setTemperature: (temp) => set({ temperature: temp }),
|
||||
setMaxTokens: (tokens) => set({ maxTokens: tokens }),
|
||||
setCurrentCharacter: (character) => set({ currentCharacter: character }),
|
||||
setCustomSystemPrompt: (prompt) => set({ customSystemPrompt: prompt }),
|
||||
setTheme: (theme) => set({ theme: theme }),
|
||||
setAlwaysOnTop: (value) => set({ alwaysOnTop: value }),
|
||||
setVoiceEnabled: (enabled) => set({ voiceEnabled: enabled }),
|
||||
setTtsVoice: (voice) => {
|
||||
console.log('🎙️ Settings Store: Saving TTS voice:', voice)
|
||||
set({ ttsVoice: voice })
|
||||
},
|
||||
setTtsModel: (model) => set({ ttsModel: model }),
|
||||
setTtsSpeed: (speed) => set({ ttsSpeed: speed }),
|
||||
setTtsStability: (stability) => set({ ttsStability: stability }),
|
||||
setTtsSimilarityBoost: (boost) => set({ ttsSimilarityBoost: boost }),
|
||||
setTtsConversationMode: (enabled) => set({ ttsConversationMode: enabled }),
|
||||
setSttLanguage: (language) => set({ sttLanguage: language }),
|
||||
setSttMode: (mode) => set({ sttMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: 'eve-settings',
|
||||
}
|
||||
)
|
||||
)
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user