diff --git a/react-app/src/App.jsx b/react-app/src/App.jsx index 026d0d6..ffe23a2 100644 --- a/react-app/src/App.jsx +++ b/react-app/src/App.jsx @@ -1,9 +1,8 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useChat } from './hooks/useChat'; -import { sendMessage, stopGeneration, initializeModels, generateImage, generateVideo } from './utils/api'; +import { sendMessage, stopGeneration, initializeModels, generateImage, generateVideo, generateAudio } from './utils/api'; import { getSelectedModel, saveSelectedModel, getTheme, saveTheme } from './utils/storage'; import Sidebar from './components/Sidebar'; -import ChatHeader from './components/ChatHeader'; import MessageArea from './components/MessageArea'; import ChatInput from './components/ChatInput'; import KeyboardShortcutsModal from './components/KeyboardShortcutsModal'; @@ -25,62 +24,45 @@ function App() { addMessage, updateMessage, removeMessagesAfter, - clearAllChats + clearAllChats, } = useChat(); const [sidebarOpen, setSidebarOpen] = useState(false); - const [selectedModel, setSelectedModel] = useState('openai'); - const [selectedImageModel, setSelectedImageModel] = useState('flux'); - const [selectedVideoModel, setSelectedVideoModel] = useState('veo'); - const [theme, setTheme] = useState('light'); + const [selectedModel, setSelectedModel] = useState('openai'); + const [selectedImageModel, setSelectedImageModel] = useState('flux'); + const [selectedVideoModel, setSelectedVideoModel] = useState('veo'); + const [selectedAudioModel, setSelectedAudioModel] = useState('openai-audio'); + const [theme, setTheme] = useState('dark'); const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false); - const [models, setModels] = useState({}); - const [imageModels, setImageModels] = useState({}); - const [videoModels, setVideoModels] = useState({}); + + const [models, setModels] = useState({}); + const [imageModels, setImageModels] = useState({}); + const [videoModels, setVideoModels] = useState({}); + const [audioModels, setAudioModels] = useState({}); const [modelsLoaded, setModelsLoaded] = useState(false); - const [confirmModal, setConfirmModal] = useState({ - isOpen: false, - title: '', - message: '', - onConfirm: null, - isDangerous: false - }); + + const [confirmModal, setConfirmModal] = useState({ isOpen: false, title: '', message: '', onConfirm: null, isDangerous: false }); const [mode, setMode] = useState('chat'); const [sessionSettings, setSessionSettings] = useState({ systemPrompt: 'You are a helpful AI assistant who speaks concisely and helpfully.', maxTokens: 2000, temperature: 0.7, - topP: 1 + topP: 1, }); - const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); - const [isTutorialOpen, setIsTutorialOpen] = useState(false); + const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); + const [isTutorialOpen, setIsTutorialOpen] = useState(false); const [isGenerationOptionsOpen, setIsGenerationOptionsOpen] = useState(false); - const [generationOptionsMode, setGenerationOptionsMode] = useState('imagine'); + const [generationOptionsMode, setGenerationOptionsMode] = useState('imagine'); const [imageGenerationOptions, setImageGenerationOptions] = useState({ - width: 1024, - height: 1024, - seed: 42, - enhance: false, - nologo: false, - nofeed: false, - safe: false, - quality: 'medium' + width: 1024, height: 1024, seed: 42, enhance: false, nologo: false, nofeed: false, safe: false, quality: 'medium', }); const [videoGenerationOptions, setVideoGenerationOptions] = useState({ - seed: 42, - nologo: false, - nofeed: false, - duration: 4, - aspectRatio: '16:9', - audio: false + seed: 42, nologo: false, nofeed: false, duration: 4, aspectRatio: '16:9', audio: false, }); - // Show tutorial on first visit + // Tutorial on first visit useEffect(() => { - const hasSeenTutorial = localStorage.getItem('hasSeenTutorial'); - if (!hasSeenTutorial) { - setIsTutorialOpen(true); - } + if (!localStorage.getItem('hasSeenTutorial')) setIsTutorialOpen(true); }, []); const handleCloseTutorial = useCallback(() => { @@ -88,220 +70,109 @@ function App() { localStorage.setItem('hasSeenTutorial', 'true'); }, []); - // Debug mode changes - useEffect(() => { - }, [mode]); - - // Initialize models on mount + // Load models useEffect(() => { - const init = async () => { - const { textModels, imageModels, videoModels } = await initializeModels(); + initializeModels().then(({ textModels, imageModels, videoModels, audioModels }) => { setModels(textModels); setImageModels(imageModels); setVideoModels(videoModels); + setAudioModels(audioModels); setModelsLoaded(true); - }; - init(); + }); }, []); + // Load persisted preferences useEffect(() => { - const savedModel = getSelectedModel(); + const savedModel = getSelectedModel(); const savedImageModel = localStorage.getItem('selectedImageModel') || 'flux'; const savedVideoModel = localStorage.getItem('selectedVideoModel') || 'veo'; - const savedTheme = getTheme(); + const savedAudioModel = localStorage.getItem('selectedAudioModel') || 'openai-audio'; + const savedTheme = getTheme(); setSelectedModel(savedModel); setSelectedImageModel(savedImageModel); setSelectedVideoModel(savedVideoModel); + setSelectedAudioModel(savedAudioModel); setTheme(savedTheme); - - // Apply theme to document - if (savedTheme === 'dark') { - document.body.classList.add('dark'); - document.body.classList.remove('light'); - } else { - document.body.classList.remove('dark'); - document.body.classList.add('light'); - } + applyTheme(savedTheme); }, []); + const applyTheme = (t) => { + document.body.classList.toggle('dark', t === 'dark'); + document.body.classList.toggle('light', t !== 'dark'); + }; + // Keyboard shortcuts useEffect(() => { - const handleKeyDown = (e) => { - // Ctrl+K: Focus input - if ((e.ctrlKey || e.metaKey) && e.key === 'k') { - e.preventDefault(); - document.getElementById('messageInput')?.focus(); - } - // Ctrl+N: New chat - if ((e.ctrlKey || e.metaKey) && e.key === 'n') { - e.preventDefault(); - addChat(); - } - // Ctrl+B: Toggle sidebar - if ((e.ctrlKey || e.metaKey) && e.key === 'b') { - e.preventDefault(); - setSidebarOpen(prev => !prev); - } - // Ctrl+Shift+L: Toggle theme - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'L') { - e.preventDefault(); - handleThemeToggle(); - } - // Esc: Close modals - if (e.key === 'Escape') { - setIsShortcutsModalOpen(false); - } + const onKey = (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); document.getElementById('messageInput')?.focus(); } + if ((e.ctrlKey || e.metaKey) && e.key === 'n') { e.preventDefault(); addChat(); } + if ((e.ctrlKey || e.metaKey) && e.key === 'b') { e.preventDefault(); setSidebarOpen(p => !p); } + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'L') { e.preventDefault(); handleThemeToggle(); } + if (e.key === 'Escape') setIsShortcutsModalOpen(false); }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); }, [addChat]); - const handleModelChange = useCallback((model) => { - setSelectedModel(model); - saveSelectedModel(model); - }, []); - - const handleImageModelChange = useCallback((model) => { - setSelectedImageModel(model); - localStorage.setItem('selectedImageModel', model); - }, []); - - const handleVideoModelChange = useCallback((model) => { - setSelectedVideoModel(model); - localStorage.setItem('selectedVideoModel', model); - }, []); + const handleModelChange = useCallback((m) => { setSelectedModel(m); saveSelectedModel(m); }, []); + const handleImageModelChange = useCallback((m) => { setSelectedImageModel(m); localStorage.setItem('selectedImageModel', m); }, []); + const handleVideoModelChange = useCallback((m) => { setSelectedVideoModel(m); localStorage.setItem('selectedVideoModel', m); }, []); + const handleAudioModelChange = useCallback((m) => { setSelectedAudioModel(m); localStorage.setItem('selectedAudioModel', m); }, []); const handleThemeToggle = useCallback(() => { - const newTheme = theme === 'dark' ? 'light' : 'dark'; - setTheme(newTheme); - saveTheme(newTheme); - - if (newTheme === 'dark') { - document.body.classList.add('dark'); - document.body.classList.remove('light'); - } else { - document.body.classList.remove('dark'); - document.body.classList.add('light'); - } + const next = theme === 'dark' ? 'light' : 'dark'; + setTheme(next); + saveTheme(next); + applyTheme(next); }, [theme]); const handleExportChat = () => { - const activeChat = getActiveChat(); - if (!activeChat || !activeChat.messages.length) { - alert('No messages to export'); - return; - } - - // Create export data - const exportData = { - title: activeChat.title, - timestamp: new Date().toISOString(), - messages: activeChat.messages.map(msg => ({ - role: msg.role, - content: msg.content, - timestamp: msg.timestamp - })) - }; - - // Download as JSON - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `chat-export-${Date.now()}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + const chat = getActiveChat(); + if (!chat?.messages.length) { if (window?.showToast) window.showToast('No messages to export', 'error'); return; } + const blob = new Blob([JSON.stringify({ title: chat.title, timestamp: new Date().toISOString(), messages: chat.messages.map(m => ({ role: m.role, content: m.content, timestamp: m.timestamp })) }, null, 2)], { type: 'application/json' }); + const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: `chat-${Date.now()}.json` }); + document.body.appendChild(a); a.click(); document.body.removeChild(a); }; - const handleClearAll = () => { - setConfirmModal({ - isOpen: true, - title: 'Clear All Chats', - message: 'Are you sure you want to delete all chats? This action cannot be undone.', - onConfirm: () => clearAllChats(), - isDangerous: true - }); - }; + const handleClearAll = () => setConfirmModal({ isOpen: true, title: 'Clear All Chats', message: 'Delete all chats? This cannot be undone.', onConfirm: clearAllChats, isDangerous: true }); - const handleSessionSettingsChange = useCallback((field, value) => { - setSessionSettings(prev => ({ - ...prev, - [field]: value - })); - }, []); + const handleSessionSettingsChange = useCallback((field, value) => setSessionSettings(p => ({ ...p, [field]: value })), []); - const handleOpenGenerationOptions = useCallback((mode) => { - setGenerationOptionsMode(mode); - setIsGenerationOptionsOpen(true); - }, []); + const handleOpenGenerationOptions = useCallback((m) => { setGenerationOptionsMode(m); setIsGenerationOptionsOpen(true); }, []); const handleGenerationOptionsApply = useCallback((options) => { - if (generationOptionsMode === 'imagine') { - setImageGenerationOptions(options); - } else if (generationOptionsMode === 'video') { - setVideoGenerationOptions(options); - } + if (generationOptionsMode === 'imagine') setImageGenerationOptions(options); + else if (generationOptionsMode === 'video') setVideoGenerationOptions(options); }, [generationOptionsMode]); + // ── Send text message ──────────────────────────────────────── const handleSendMessage = useCallback(async ({ text = '', attachment = null } = {}) => { - const trimmedContent = typeof text === 'string' ? text.trim() : ''; - - if ((!trimmedContent && !attachment) || isGenerating) return; + const trimmed = typeof text === 'string' ? text.trim() : ''; + if ((!trimmed && !attachment) || isGenerating) return; const attachments = attachment ? [{ name: attachment.name, - mimeType: attachment.mimeType || attachment.file?.type || 'application/octet-stream', + mimeType: attachment.mimeType || 'application/octet-stream', data: attachment.base64 || attachment.data || '', size: attachment.size, preview: attachment.preview || null, - isImage: attachment.isImage ?? (attachment.mimeType ? attachment.mimeType.startsWith('image/') : false) + isImage: attachment.isImage ?? (attachment.mimeType?.startsWith('image/') || false), }] : []; if (attachments[0] && !attachments[0].data && attachment?.preview?.startsWith('data:')) { - const commaIndex = attachment.preview.indexOf(','); - attachments[0].data = commaIndex >= 0 ? attachment.preview.slice(commaIndex + 1) : attachment.preview; + const ci = attachment.preview.indexOf(','); + attachments[0].data = ci >= 0 ? attachment.preview.slice(ci + 1) : attachment.preview; } - const messageMetadata = attachments.length ? { - attachments, - ...(attachments[0]?.isImage && attachments[0]?.preview ? { - image: { - src: attachments[0].preview, - name: attachments[0].name - } - } : {}) - } : {}; - - const isImageAttachment = attachments[0]?.isImage; - const attachmentLabel = isImageAttachment ? 'image' : 'file'; - const attachmentArticle = isImageAttachment ? 'an' : 'a'; - const messageContent = trimmedContent || (attachments.length - ? attachments[0]?.name - ? `Shared ${attachmentArticle} ${attachmentLabel}: ${attachments[0].name}` - : `Shared ${attachmentArticle} ${attachmentLabel}` - : ''); - - // Add user message and get the updated chat - const updatedChat = addMessage('user', messageContent, null, messageMetadata); - - // Set generating state + const messageContent = trimmed || (attachments[0]?.name ? `Shared file: ${attachments[0].name}` : 'Shared a file'); + const updatedChat = addMessage('user', messageContent, null, attachments.length ? { attachments, ...(attachments[0]?.isImage && attachments[0]?.preview ? { image: { src: attachments[0].preview, name: attachments[0].name } } : {}) } : {}); + setIsGenerating(true); + if (!updatedChat) { setIsGenerating(false); if (window?.showToast) window.showToast('Could not find active chat', 'error'); return; } - if (!updatedChat) { - console.error("Could not find active chat to send message."); - setIsGenerating(false); - // Show toast notification for error - if (window?.showToast) window.showToast("Could not find active chat to send message.", "error"); - return; - } + const assistantId = Date.now().toString(36) + Math.random().toString(36).slice(2); + addMessage('assistant', '', assistantId, { isStreaming: true }); - // Create assistant message placeholder - const assistantMessageId = Date.now().toString(36) + Math.random().toString(36).substr(2); - addMessage('assistant', '', assistantMessageId, { isStreaming: true }); - const runtimeMessages = sessionSettings.systemPrompt?.trim() ? [{ role: 'system', content: sessionSettings.systemPrompt.trim() }, ...updatedChat.messages] : updatedChat.messages; @@ -309,257 +180,109 @@ function App() { try { await sendMessage( runtimeMessages, - // onChunk - (chunk, fullContent, fullReasoning) => { - // Update the message content in real-time for streaming - updateMessage(assistantMessageId, { - content: fullContent, - reasoning: fullReasoning, - isStreaming: true - }); - }, - // onComplete - (fullContent, fullReasoning) => { - updateMessage(assistantMessageId, { - content: fullContent, - reasoning: fullReasoning, - isStreaming: false - }); - setIsGenerating(false); - }, - // onError - (error) => { - console.error('Message generation error:', error); - if (error.message === 'User aborted') { - updateMessage(assistantMessageId, { - content: '**Message stopped by user**', - isStreaming: false, - isError: false - }); - } else { - updateMessage(assistantMessageId, { - content: 'An error occurred', - isStreaming: false, - isError: true - }); - } + (_, fullContent) => updateMessage(assistantId, { content: fullContent, isStreaming: true }), + (fullContent) => { updateMessage(assistantId, { content: fullContent, isStreaming: false }); setIsGenerating(false); }, + (error) => { + const msg = error.message === 'User aborted' ? '**Message stopped by user**' : `Error: ${error.message}`; + updateMessage(assistantId, { content: msg, isStreaming: false, isError: error.message !== 'User aborted' }); setIsGenerating(false); }, - // modelId - pass the selected model selectedModel, - { - maxTokens: sessionSettings.maxTokens, - temperature: sessionSettings.temperature, - topP: sessionSettings.topP - } + { maxTokens: sessionSettings.maxTokens, temperature: sessionSettings.temperature, topP: sessionSettings.topP }, ); } catch (error) { - console.error('Message generation error:', error); - if (error.message === 'User aborted') { - updateMessage(assistantMessageId, { - content: '**Message stopped by user**', - isStreaming: false, - isError: false - }); - } else { - updateMessage(assistantMessageId, { - content: 'An error occurred', - isStreaming: false, - isError: true - }); - } + const msg = error.message === 'User aborted' ? '**Message stopped by user**' : `Error: ${error.message}`; + updateMessage(assistantId, { content: msg, isStreaming: false, isError: error.message !== 'User aborted' }); setIsGenerating(false); } }, [isGenerating, addMessage, updateMessage, selectedModel, sessionSettings]); - const handleStopGeneration = useCallback(() => { - stopGeneration(); - setIsGenerating(false); - }, []); + const handleStopGeneration = useCallback(() => { stopGeneration(); setIsGenerating(false); }, []); + // ── Image generation ───────────────────────────────────────── const handleGenerateImage = useCallback(async (prompt) => { if (!prompt.trim() || isGenerating) return; - - // Add user message with the prompt const updatedChat = addMessage('user', `/imagine ${prompt}`); - - // Set generating state setIsGenerating(true); - - if (!updatedChat) { - console.error("Could not find active chat to generate image."); - setIsGenerating(false); - // Show toast notification for error - if (window?.showToast) window.showToast("Could not find active chat to generate image.", "error"); - return; - } - - // Create assistant message placeholder for the image - const assistantMessageId = Date.now().toString(36) + Math.random().toString(36).substr(2); - addMessage('assistant', 'Generating image...', assistantMessageId); - + if (!updatedChat) { setIsGenerating(false); return; } + const aid = Date.now().toString(36) + Math.random().toString(36).slice(2); + addMessage('assistant', 'Generating image…', aid); try { - - // Generate the image with selected model and options - const imageData = await generateImage(prompt, { - model: selectedImageModel, - ...imageGenerationOptions - }); - - // Update the message with the generated image - updateMessage(assistantMessageId, { - content: '', - imageUrl: imageData.url, - imagePrompt: imageData.prompt, - imageModel: imageData.model, - isStreaming: false - }); - - setIsGenerating(false); + const img = await generateImage(prompt, { model: selectedImageModel, ...imageGenerationOptions }); + updateMessage(aid, { content: '', imageUrl: img.url, imagePrompt: img.prompt, imageModel: img.model, isStreaming: false }); } catch (error) { - console.error('Image generation error:', error); - updateMessage(assistantMessageId, { - content: 'An error occurred', - isStreaming: false, - isError: true - }); - setIsGenerating(false); - // Show toast notification for error - if (window?.showToast) window.showToast("Image generation failed", "error"); + updateMessage(aid, { content: `Image generation failed: ${error.message}`, isStreaming: false, isError: true }); + if (window?.showToast) window.showToast('Image generation failed', 'error'); } + setIsGenerating(false); }, [isGenerating, selectedImageModel, imageGenerationOptions, addMessage, updateMessage]); + // ── Video generation ───────────────────────────────────────── const handleGenerateVideo = useCallback(async (prompt) => { if (!prompt.trim() || isGenerating) return; - - // Add user message with the prompt const updatedChat = addMessage('user', `/video ${prompt}`); - - // Set generating state setIsGenerating(true); - - if (!updatedChat) { - console.error("Could not find active chat to generate video."); - setIsGenerating(false); - // Show toast notification for error - if (window?.showToast) window.showToast("Could not find active chat to generate video.", "error"); - return; - } - - // Create assistant message placeholder for the video - const assistantMessageId = Date.now().toString(36) + Math.random().toString(36).substr(2); - addMessage('assistant', 'Generating video... This may take a minute.', assistantMessageId); - + if (!updatedChat) { setIsGenerating(false); return; } + const aid = Date.now().toString(36) + Math.random().toString(36).slice(2); + addMessage('assistant', 'Generating video… This may take a minute.', aid); try { - // Generate the video with selected model and options - const videoData = await generateVideo(prompt, { - model: selectedVideoModel, - ...videoGenerationOptions - }); - - // Update the message with the generated video - updateMessage(assistantMessageId, { - content: '', - videoUrl: videoData.url, - videoPrompt: videoData.prompt, - videoModel: videoData.model, - isStreaming: false - }); - - setIsGenerating(false); + const vid = await generateVideo(prompt, { model: selectedVideoModel, ...videoGenerationOptions }); + updateMessage(aid, { content: '', videoUrl: vid.url, videoPrompt: vid.prompt, videoModel: vid.model, isStreaming: false }); } catch (error) { - console.error('Video generation error:', error); - updateMessage(assistantMessageId, { - content: 'An error occurred while generating video', - isStreaming: false, - isError: true - }); - setIsGenerating(false); - // Show toast notification for error - if (window?.showToast) window.showToast("Video generation failed", "error"); + updateMessage(aid, { content: `Video generation failed: ${error.message}`, isStreaming: false, isError: true }); + if (window?.showToast) window.showToast('Video generation failed', 'error'); } + setIsGenerating(false); }, [isGenerating, selectedVideoModel, videoGenerationOptions, addMessage, updateMessage]); - const handleRegenerateMessage = async () => { - const activeChat = getActiveChat(); - if (!activeChat || isGenerating) return; - - const messages = activeChat.messages; - if (messages.length < 2) return; - - // Find the last user message - let lastUserMessage = null; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === 'user') { - lastUserMessage = messages[i]; - break; - } + // ── Audio generation ───────────────────────────────────────── + const handleGenerateAudio = useCallback(async (text, options = {}) => { + if (!text.trim() || isGenerating) return; + const updatedChat = addMessage('user', `/audio ${text}`); + setIsGenerating(true); + if (!updatedChat) { setIsGenerating(false); return; } + const aid = Date.now().toString(36) + Math.random().toString(36).slice(2); + addMessage('assistant', 'Generating audio…', aid); + try { + const aud = await generateAudio(text, { model: selectedAudioModel, ...options }); + updateMessage(aid, { content: '', audioUrl: aud.url, audioText: aud.text, audioVoice: aud.voice, audioModel: aud.model, isStreaming: false }); + } catch (error) { + updateMessage(aid, { content: `Audio generation failed: ${error.message}`, isStreaming: false, isError: true }); + if (window?.showToast) window.showToast('Audio generation failed', 'error'); } - - if (!lastUserMessage) return; - - // Remove all messages after the last user message - removeMessagesAfter(lastUserMessage.timestamp); - - // Wait a bit for state to update + setIsGenerating(false); + }, [isGenerating, selectedAudioModel, addMessage, updateMessage]); + + // ── Regenerate ────────────────────────────────────────────── + const handleRegenerateMessage = useCallback(async () => { + const chat = getActiveChat(); + if (!chat || isGenerating) return; + const lastUser = [...chat.messages].reverse().find(m => m.role === 'user'); + if (!lastUser) return; + removeMessagesAfter(lastUser.timestamp); setTimeout(() => { - // Regenerate the response by re-processing the messages - const updatedChat = getActiveChat(); - // Create assistant message - const assistantMessageId = Date.now().toString(36) + Math.random().toString(36).substr(2); - addMessage('assistant', '', assistantMessageId, { isStreaming: true }); - + const updated = getActiveChat(); + const aid = Date.now().toString(36) + Math.random().toString(36).slice(2); + addMessage('assistant', '', aid, { isStreaming: true }); setIsGenerating(true); - const runtimeMessages = sessionSettings.systemPrompt?.trim() - ? [{ role: 'system', content: sessionSettings.systemPrompt.trim() }, ...updatedChat.messages] - : updatedChat.messages; - + ? [{ role: 'system', content: sessionSettings.systemPrompt.trim() }, ...updated.messages] + : updated.messages; sendMessage( runtimeMessages, - (chunk, fullContent) => { - updateMessage(assistantMessageId, { - content: fullContent, - isStreaming: true - }); - }, - (fullContent) => { - updateMessage(assistantMessageId, { - content: fullContent, - isStreaming: false - }); - setIsGenerating(false); - }, - (error) => { - console.error('Message regeneration error:', error); - if (error.message === 'User aborted') { - updateMessage(assistantMessageId, { - content: '**Message stopped by user**', - isStreaming: false, - isError: false - }); - } else { - updateMessage(assistantMessageId, { - content: 'An error occurred', - isStreaming: false, - isError: true - }); - } + (_, full) => updateMessage(aid, { content: full, isStreaming: true }), + (full) => { updateMessage(aid, { content: full, isStreaming: false }); setIsGenerating(false); }, + (error) => { + updateMessage(aid, { content: error.message === 'User aborted' ? '**Message stopped by user**' : `Error: ${error.message}`, isStreaming: false, isError: error.message !== 'User aborted' }); setIsGenerating(false); }, selectedModel, - { - maxTokens: sessionSettings.maxTokens, - temperature: sessionSettings.temperature, - topP: sessionSettings.topP - } + { maxTokens: sessionSettings.maxTokens, temperature: sessionSettings.temperature, topP: sessionSettings.topP }, ); }, 100); - }; + }, [getActiveChat, isGenerating, removeMessagesAfter, addMessage, updateMessage, selectedModel, sessionSettings]); - const activeChat = getActiveChat(); - const activeMessages = activeChat?.messages || []; - const isChatEmpty = activeMessages.length === 0; + const activeMessages = getActiveChat()?.messages || []; return (
@@ -570,43 +293,47 @@ function App() { onNewChat={addChat} onDeleteChat={deleteChat} onThemeToggle={handleThemeToggle} + theme={theme} onOpenSettings={() => setIsSettingsPanelOpen(true)} + onExportChat={handleExportChat} + onClearAll={handleClearAll} /> - -
- + - + {}} onModeChange={setMode} selectedModel={selectedModel} selectedImageModel={selectedImageModel} selectedVideoModel={selectedVideoModel} + selectedAudioModel={selectedAudioModel} mode={mode} models={models} imageModels={imageModels} videoModels={videoModels} + audioModels={audioModels} modelsLoaded={modelsLoaded} onModelChange={handleModelChange} onImageModelChange={handleImageModelChange} onVideoModelChange={handleVideoModelChange} + onAudioModelChange={handleAudioModelChange} onOpenGenerationOptions={handleOpenGenerationOptions} />
- setIsShortcutsModalOpen(false)} - /> + setIsShortcutsModalOpen(false)} /> setConfirmModal({ ...confirmModal, isOpen: false })} + onClose={() => setConfirmModal(p => ({ ...p, isOpen: false }))} onConfirm={confirmModal.onConfirm} title={confirmModal.title} message={confirmModal.message} @@ -626,10 +353,7 @@ function App() { isDangerous={confirmModal.isDangerous} /> - + {}, onGenerateImage, onGenerateVideo, + onGenerateAudio, onModeChange, selectedModel, selectedImageModel, selectedVideoModel, + selectedAudioModel, mode = 'chat', models = {}, imageModels = {}, videoModels = {}, + audioModels = {}, modelsLoaded = false, onModelChange, onImageModelChange, onVideoModelChange, - onOpenGenerationOptions + onAudioModelChange, + onOpenGenerationOptions, }) => { const [inputValue, setInputValue] = useState(''); const [isAttachMenuOpen, setIsAttachMenuOpen] = useState(false); @@ -29,6 +42,7 @@ const ChatInput = ({ const [selectedAttachment, setSelectedAttachment] = useState(null); const [modelSearchTerm, setModelSearchTerm] = useState(''); const [isDragging, setIsDragging] = useState(false); + const [selectedVoice, setSelectedVoice] = useState('nova'); const inputRef = useRef(null); const attachMenuRef = useRef(null); const modelDropdownRef = useRef(null); @@ -36,143 +50,104 @@ const ChatInput = ({ const dropZoneRef = useRef(null); const { isListening, startListening, stopListening, hasSpeechRecognition } = useSpeech(); - const isImagineMode = inputValue.includes('/imagine'); - const isCanvasMode = inputValue.includes('/code'); - const isVideoMode = inputValue.includes('/video'); - - // Determine active model based on mode const getActiveModelId = () => { if (mode === 'imagine') return selectedImageModel; - if (mode === 'video') return selectedVideoModel; + if (mode === 'video') return selectedVideoModel; + if (mode === 'audio') return selectedAudioModel; return selectedModel; }; - + const getActiveModelsMap = () => { if (mode === 'imagine') return imageModels; - if (mode === 'video') return videoModels; + if (mode === 'video') return videoModels; + if (mode === 'audio') return audioModels; return models; }; - - const activeModelId = getActiveModelId(); - const activeModelsMap = getActiveModelsMap(); - const modelLabel = activeModelsMap?.[activeModelId]?.description || activeModelsMap?.[activeModelId]?.name || activeModelId || 'Select model'; - const handleModelBadgeClick = () => { - if (!modelsLoaded) return; - setIsModelDropdownOpen(!isModelDropdownOpen); - }; - - const handleModelSelect = (modelId) => { - if (mode === 'imagine') { - onImageModelChange?.(modelId); - } else if (mode === 'video') { - onVideoModelChange?.(modelId); - } else { - onModelChange?.(modelId); - } - setIsModelDropdownOpen(false); - }; + const activeModelId = getActiveModelId(); + const activeModelsMap = getActiveModelsMap(); + const modelLabel = activeModelsMap?.[activeModelId]?.description + || activeModelsMap?.[activeModelId]?.name + || activeModelId + || 'Select model'; + // Auto-resize textarea useEffect(() => { - const textarea = inputRef.current; - if (textarea) { - textarea.style.height = 'auto'; - textarea.style.height = `${textarea.scrollHeight}px`; - } + const ta = inputRef.current; + if (ta) { ta.style.height = 'auto'; ta.style.height = `${ta.scrollHeight}px`; } }, [inputValue]); useEffect(() => { - if (isListening) { - setInputValue('Listening...'); - } else if (inputValue === 'Listening...') { - setInputValue(''); - } - }, [isListening, inputValue]); + if (isListening) setInputValue('Listening…'); + else if (inputValue === 'Listening…') setInputValue(''); + }, [isListening]); - // Close attach menu when clicking outside the menu area useEffect(() => { - const handleClickOutside = (event) => { - if (attachMenuRef.current && !attachMenuRef.current.contains(event.target)) { - setIsAttachMenuOpen(false); - } - if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target)) { - setIsModelDropdownOpen(false); - } + const handleClickOutside = (e) => { + if (attachMenuRef.current && !attachMenuRef.current.contains(e.target)) setIsAttachMenuOpen(false); + if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target)) setIsModelDropdownOpen(false); }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - // Auto-focus input on mount and after sending - useEffect(() => { + useEffect(() => { inputRef.current?.focus(); }, []); + + const handleModeChange = (newMode) => { + if (onModeChange) onModeChange(newMode); + setInputValue(''); inputRef.current?.focus(); - }, []); + }; + + const handleModelSelect = (modelId) => { + if (mode === 'imagine') onImageModelChange?.(modelId); + else if (mode === 'video') onVideoModelChange?.(modelId); + else if (mode === 'audio') onAudioModelChange?.(modelId); + else onModelChange?.(modelId); + setIsModelDropdownOpen(false); + }; const handleSend = () => { - const trimmedInput = inputValue.trim(); - if ((trimmedInput || selectedAttachment) && !isListening) { - if (inputValue.trim().startsWith('/imagine ')) { - const prompt = inputValue.trim().substring(9); - if (prompt && onGenerateImage) { - onGenerateImage(prompt); - setInputValue(''); - setSelectedAttachment(null); - setIsUserTyping(false); - // Reset to chat mode after sending - if (onModeChange) { - onModeChange('chat'); - } - // Refocus input after sending - setTimeout(() => inputRef.current?.focus(), 0); - return; - } - } + const trimmed = inputValue.trim(); + if ((!trimmed && !selectedAttachment) || isListening || isGenerating) return; - if (inputValue.trim().startsWith('/video ')) { - const prompt = inputValue.trim().substring(7); - if (prompt && onGenerateVideo) { - onGenerateVideo(prompt); - setInputValue(''); - setSelectedAttachment(null); - setIsUserTyping(false); - // Reset to chat mode after sending - if (onModeChange) { - onModeChange('chat'); - } - // Refocus input after sending - setTimeout(() => inputRef.current?.focus(), 0); - return; - } - } + if (mode === 'imagine' && trimmed) { + onGenerateImage?.(trimmed); + setInputValue(''); + setSelectedAttachment(null); + setIsUserTyping(false); + setTimeout(() => inputRef.current?.focus(), 0); + return; + } - // Pass both text and image data if present - onSend({ - text: inputValue, - attachment: selectedAttachment - }); - + if (mode === 'video' && trimmed) { + onGenerateVideo?.(trimmed); setInputValue(''); - if (selectedAttachment) { - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - } setSelectedAttachment(null); setIsUserTyping(false); - // Reset to chat mode after sending - if (onModeChange) { - onModeChange('chat'); - } - // Refocus input after sending setTimeout(() => inputRef.current?.focus(), 0); + return; } - }; - const handleKeyDown = (event) => { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault(); - handleSend(); + if (mode === 'audio' && trimmed) { + onGenerateAudio?.(trimmed, { voice: selectedVoice, model: activeModelId }); + setInputValue(''); + setSelectedAttachment(null); + setIsUserTyping(false); + setTimeout(() => inputRef.current?.focus(), 0); + return; } + + onSend({ text: inputValue, attachment: selectedAttachment }); + setInputValue(''); + if (selectedAttachment && fileInputRef.current) fileInputRef.current.value = ''; + setSelectedAttachment(null); + setIsUserTyping(false); + setTimeout(() => inputRef.current?.focus(), 0); + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } setIsUserTyping(true); }; @@ -183,152 +158,74 @@ const ChatInput = ({ startListening((transcript) => { setInputValue(transcript); setIsUserTyping(false); - onSend({ text: transcript }); + if (mode === 'chat') onSend({ text: transcript }); }); } }; - const handleFileUpload = () => { - fileInputRef.current?.click(); - setIsAttachMenuOpen(false); - }; - const handleFileChange = (event) => { const file = event.target.files?.[0]; if (!file) return; - const reader = new FileReader(); reader.onload = (e) => { const result = typeof e.target.result === 'string' ? e.target.result : ''; - const commaIndex = result.indexOf(','); - const base64Data = commaIndex >= 0 ? result.slice(commaIndex + 1) : result; + const commaIdx = result.indexOf(','); + const base64 = commaIdx >= 0 ? result.slice(commaIdx + 1) : result; const isImage = file.type.startsWith('image/'); - - setSelectedAttachment({ - file, - preview: isImage ? result : null, - base64: base64Data, - name: file.name, - mimeType: file.type || 'application/octet-stream', - size: file.size, - isImage - }); + setSelectedAttachment({ file, preview: isImage ? result : null, base64, name: file.name, mimeType: file.type || 'application/octet-stream', size: file.size, isImage }); }; reader.readAsDataURL(file); + setIsAttachMenuOpen(false); }; - const handlePaste = (event) => { - const items = event.clipboardData?.items; + const handlePaste = (e) => { + const items = e.clipboardData?.items; if (!items) return; - for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type.startsWith('image/')) { - event.preventDefault(); + e.preventDefault(); const file = item.getAsFile(); - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - const result = typeof e.target.result === 'string' ? e.target.result : ''; - const commaIndex = result.indexOf(','); - const base64Data = commaIndex >= 0 ? result.slice(commaIndex + 1) : result; - - setSelectedAttachment({ - file, - preview: result, - base64: base64Data, - name: file.name || 'pasted-image.png', - mimeType: file.type || 'image/png', - size: file.size, - isImage: true - }); - }; - reader.readAsDataURL(file); - } + if (!file) break; + const reader = new FileReader(); + reader.onload = (ev) => { + const result = typeof ev.target.result === 'string' ? ev.target.result : ''; + const commaIdx = result.indexOf(','); + setSelectedAttachment({ file, preview: result, base64: commaIdx >= 0 ? result.slice(commaIdx + 1) : result, name: file.name || 'pasted-image.png', mimeType: file.type || 'image/png', size: file.size, isImage: true }); + }; + reader.readAsDataURL(file); break; } } }; - const handleDragOver = (event) => { - event.preventDefault(); - event.stopPropagation(); - setIsDragging(true); - }; - - const handleDragLeave = (event) => { - event.preventDefault(); - event.stopPropagation(); - if (event.currentTarget === dropZoneRef.current) { - setIsDragging(false); - } - }; - - const handleDrop = (event) => { - event.preventDefault(); - event.stopPropagation(); - setIsDragging(false); - - const files = event.dataTransfer?.files; - if (!files || files.length === 0) return; - - const file = files[0]; + const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }; + const handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === dropZoneRef.current) setIsDragging(false); }; + const handleDrop = (e) => { + e.preventDefault(); e.stopPropagation(); setIsDragging(false); + const file = e.dataTransfer?.files?.[0]; + if (!file) return; const reader = new FileReader(); - reader.onload = (e) => { - const result = typeof e.target.result === 'string' ? e.target.result : ''; - const commaIndex = result.indexOf(','); - const base64Data = commaIndex >= 0 ? result.slice(commaIndex + 1) : result; + reader.onload = (ev) => { + const result = typeof ev.target.result === 'string' ? ev.target.result : ''; + const commaIdx = result.indexOf(','); const isImage = file.type.startsWith('image/'); - - setSelectedAttachment({ - file, - preview: isImage ? result : null, - base64: base64Data, - name: file.name, - mimeType: file.type || 'application/octet-stream', - size: file.size, - isImage - }); + setSelectedAttachment({ file, preview: isImage ? result : null, base64: commaIdx >= 0 ? result.slice(commaIdx + 1) : result, name: file.name, mimeType: file.type || 'application/octet-stream', size: file.size, isImage }); }; reader.readAsDataURL(file); }; - const removeSelectedAttachment = () => { - setSelectedAttachment(null); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - - const handleImageGen = () => { - setInputValue('/imagine '); - setIsAttachMenuOpen(false); - if (onModeChange) { - onModeChange('imagine'); - } - inputRef.current?.focus(); - }; - - const handleVideoGen = () => { - setInputValue('/video '); - setIsAttachMenuOpen(false); - if (onModeChange) { - onModeChange('video'); - } - inputRef.current?.focus(); + const getPlaceholder = () => { + if (mode === 'imagine') return 'Describe the image you want to create…'; + if (mode === 'video') return 'Describe the video you want to generate…'; + if (mode === 'audio') return 'Enter text to convert to speech…'; + return 'Ask anything…'; }; - const handleCanvas = () => { - setInputValue('/code '); - setIsAttachMenuOpen(false); - if (onModeChange) { - onModeChange('code'); - } - inputRef.current?.focus(); - }; + const canSend = (inputValue.trim() || selectedAttachment) && !isGenerating && !isListening; return ( -
)} +
- {/* Image Preview Above Input */} + {/* Attachment preview */} {selectedAttachment && ( -
-
+
+
{selectedAttachment.isImage && selectedAttachment.preview ? ( - Preview + Preview ) : ( -
+
- - - - - + + - - {selectedAttachment.name?.split('.').pop()?.toUpperCase() || 'FILE'} -
)} - -
{selectedAttachment.name}
)} - -
-
-
+ +
+ {/* Mode pills */} +
+ {MODES.map(m => ( + + ))} +
+ + {/* Text area */} +