diff --git a/App.tsx b/App.tsx index 3a4d605..a9d7a4e 100644 --- a/App.tsx +++ b/App.tsx @@ -1,515 +1,311 @@ - import React, { useState, useEffect, useRef } from 'react'; -import { GenerateContentResponse, Chat } from '@google/genai'; -import { Header } from './components/Header'; -import { Sidebar } from './components/Sidebar'; -import { MessageBubble } from './components/MessageBubble'; -import { InputArea } from './components/InputArea'; -import { ExportModal } from './components/ExportModal'; -import { createChatSession, sendMessageStream } from './services/gemini'; -import { Message, Session, Folder, Role, Language, AppMode, ExportFormat } from './types'; -import { TEXTS, INITIAL_SUGGESTIONS } from './constants'; -import { jsPDF } from 'jspdf'; +import { OpenWebUIClient } from './services/openwebui'; +import { Session, Message, Role, UserSettings } from './types'; +import { generateId } from './utils'; -const SESSIONS_KEY = 'if.emotion.sessions'; -const FOLDERS_KEY = 'if.emotion.folders'; -const SETTINGS_KEY = 'if.emotion.settings'; +// Components +import { JourneyHeader } from './components/JourneyHeader'; +import { Sidebar } from './components/Sidebar'; +import { ChatMessage } from './components/ChatMessage'; +import { ChatInput } from './components/ChatInput'; +import { SettingsModal } from './components/SettingsModal'; const App: React.FC = () => { - // Settings & UI State - const [language, setLanguage] = useState(Language.EN); - const [mode, setMode] = useState(AppMode.SIMPLE); - const [isOffTheRecord, setIsOffTheRecord] = useState(false); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - - // Modal State - const [exportModalOpen, setExportModalOpen] = useState(false); - const [exportTarget, setExportTarget] = useState<{ type: 'full' | 'single', data?: Message } | null>(null); + // Config + const [settings, setSettings] = useState(() => { + const saved = localStorage.getItem('if.emotion.settings'); + return saved ? JSON.parse(saved) : { + baseUrl: 'http://85.239.243.227:8080', + apiKey: 'sk-5339243764b840e69188d672802082f4' // Default from prompt + }; + }); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const clientRef = useRef(new OpenWebUIClient(settings)); - // Chat Data - const [chatSession, setChatSession] = useState(null); + // State const [sessions, setSessions] = useState([]); - const [folders, setFolders] = useState([]); const [currentSessionId, setCurrentSessionId] = useState(null); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isOffTheRecord, setIsOffTheRecord] = useState(false); + const [availableModels, setAvailableModels] = useState([]); const messagesEndRef = useRef(null); - const isInitialized = useRef(false); - // --- Initialization & Persistence --- - - // Detect Language Helper - const detectLanguage = (): Language => { - const browserLang = navigator.language.split('-')[0]; - if (browserLang === 'es') return Language.ES; - if (browserLang === 'fr') return Language.FR; - return Language.EN; - }; - - // Load initial state + // Initialize useEffect(() => { - if (isInitialized.current) return; - isInitialized.current = true; - - // Load Settings - const savedSettings = localStorage.getItem(SETTINGS_KEY); - if (savedSettings) { - const parsed = JSON.parse(savedSettings); - if (parsed.mode) setMode(parsed.mode); - setLanguage(detectLanguage()); - } else { - setLanguage(detectLanguage()); - } - - // Load Folders - const savedFolders = localStorage.getItem(FOLDERS_KEY); - if (savedFolders) { - try { - setFolders(JSON.parse(savedFolders)); - } catch (e) { - console.error("Failed to parse folders", e); - } - } - - // Load Sessions - const savedSessions = localStorage.getItem(SESSIONS_KEY); - if (savedSessions) { - try { - const parsed: Session[] = JSON.parse(savedSessions).map((s: any) => ({ - ...s, - updatedAt: new Date(s.updatedAt), - messages: s.messages.map((m: any) => ({ - ...m, - timestamp: new Date(m.timestamp) - })) - })); - setSessions(parsed); - // Load most recent session if available - if (parsed.length > 0) { - loadSession(parsed[0].id, parsed); - } else { - startNewSession(); - } - } catch (e) { - console.error("Failed to parse sessions", e); - startNewSession(); - } - } else { - startNewSession(); - } - }, []); - - // Persist Settings - useEffect(() => { - if (isInitialized.current) { - localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode })); - } - }, [mode]); - - // Persist Folders - useEffect(() => { - if (isInitialized.current) { - localStorage.setItem(FOLDERS_KEY, JSON.stringify(folders)); - } - }, [folders]); - - // Persist Sessions (Auto-save current messages to session) - useEffect(() => { - if (!isInitialized.current || !currentSessionId) return; - - // If Off the Record, we DO NOT update the session in storage with new messages - if (isOffTheRecord) return; - - setSessions(prevSessions => { - const updatedSessions = prevSessions.map(session => { - if (session.id === currentSessionId) { - // Update title based on first user message if still default - let title = session.title; - const firstUserMsg = messages.find(m => m.role === Role.USER); - if (firstUserMsg && (title === 'New Journey' || title === 'Nuevo Viaje' || title === 'Nouveau Voyage')) { - title = firstUserMsg.text.slice(0, 30) + (firstUserMsg.text.length > 30 ? '...' : ''); - } - - return { - ...session, - messages: messages, - title: title, - updatedAt: new Date() - }; - } - return session; - }); - - // Sort by recency - updatedSessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); - - localStorage.setItem(SESSIONS_KEY, JSON.stringify(updatedSessions)); - return updatedSessions; - }); - }, [messages, currentSessionId, isOffTheRecord]); - - - // --- Session & Folder Logic --- - - const createFolder = (name: string) => { - const newFolder: Folder = { - id: Date.now().toString(), - name - }; - setFolders(prev => [...prev, newFolder]); - }; - - const deleteFolder = (id: string) => { - // Ungroup sessions in this folder - setSessions(prev => prev.map(s => s.folderId === id ? { ...s, folderId: undefined } : s)); - setFolders(prev => prev.filter(f => f.id !== id)); - }; - - const moveSessionToFolder = (sessionId: string, folderId: string | undefined) => { - setSessions(prev => { - const updated = prev.map(s => s.id === sessionId ? { ...s, folderId } : s); - localStorage.setItem(SESSIONS_KEY, JSON.stringify(updated)); - return updated; - }); - }; - - const startNewSession = () => { - const newId = Date.now().toString(); - const geminiSession = createChatSession(); - setChatSession(geminiSession); - - const welcomeMsg: Message = { - id: 'welcome-' + newId, - role: Role.MODEL, - text: TEXTS.welcomeMessage[language], - timestamp: new Date(), - }; - - const newSession: Session = { - id: newId, - title: language === Language.ES ? 'Nuevo Viaje' : language === Language.FR ? 'Nouveau Voyage' : 'New Journey', - messages: [welcomeMsg], - updatedAt: new Date() - }; - + clientRef.current = new OpenWebUIClient(settings); + localStorage.setItem('if.emotion.settings', JSON.stringify(settings)); + loadModels(); if (!isOffTheRecord) { - setSessions(prev => [newSession, ...prev]); - localStorage.setItem(SESSIONS_KEY, JSON.stringify([newSession, ...sessions])); + loadSessions(); } - - setCurrentSessionId(newId); - setMessages([welcomeMsg]); - setIsSidebarOpen(false); // Close sidebar on mobile/action + }, [settings]); + + // Load Models + const loadModels = async () => { + const models = await clientRef.current.getModels(); + setAvailableModels(models); }; - const loadSession = (id: string, allSessions = sessions) => { - const session = allSessions.find(s => s.id === id); - if (session) { - setChatSession(createChatSession()); // Reset Gemini context contextually - setMessages(session.messages); - setCurrentSessionId(id); - setIsSidebarOpen(false); - } - }; - - const deleteSession = (id: string, e: React.MouseEvent) => { - e.stopPropagation(); // Prevent selection when deleting - const newSessions = sessions.filter(s => s.id !== id); - setSessions(newSessions); - localStorage.setItem(SESSIONS_KEY, JSON.stringify(newSessions)); - - if (currentSessionId === id) { - if (newSessions.length > 0) { - loadSession(newSessions[0].id, newSessions); - } else { - startNewSession(); - } - } - }; - - // --- Message Logic --- - - const handleSendMessage = async (text: string) => { - if (!chatSession) return; - - const userMsg: Message = { - id: Date.now().toString(), - role: Role.USER, - text, - timestamp: new Date(), - }; - - setMessages((prev) => [...prev, userMsg]); - setIsLoading(true); - + // Load Sessions + const loadSessions = async () => { try { - const stream = await sendMessageStream(chatSession, text); - - const botMsgId = (Date.now() + 1).toString(); - setMessages((prev) => [ - ...prev, - { - id: botMsgId, - role: Role.MODEL, - text: '', - timestamp: new Date(), - }, - ]); - - let fullText = ''; - for await (const chunk of stream) { - const content = chunk as GenerateContentResponse; - const textChunk = content.text; - if (textChunk) { - fullText += textChunk; - setMessages((prev) => - prev.map((msg) => - msg.id === botMsgId ? { ...msg, text: fullText } : msg - ) - ); - } + const list = await clientRef.current.getChats(); + setSessions(list); + if (list.length > 0 && !currentSessionId && !isOffTheRecord) { + loadSession(list[0].id); + } else if (list.length === 0 && !isOffTheRecord) { + // Create initial persistent session if none exist + startNewSession(); } - } catch (error) { - console.error('Error generating response:', error); - setMessages((prev) => [ - ...prev, - { - id: Date.now().toString(), - role: Role.MODEL, - text: TEXTS.errorMessage[language], - timestamp: new Date(), - isError: true, - }, - ]); + } catch (e) { + console.error("Failed to load sessions", e); + } + }; + + // Load Specific Session + const loadSession = async (id: string) => { + try { + setIsLoading(true); + const hist = await clientRef.current.getChatHistory(id); + setMessages(hist); + setCurrentSessionId(id); + setIsOffTheRecord(false); + setIsSidebarOpen(false); + } catch (e) { + console.error("Failed to load chat", e); } finally { setIsLoading(false); } }; - const handleDeleteMessage = (id: string) => { - setMessages((prev) => prev.filter(msg => msg.id !== id)); - }; - - const handleReaction = (id: string, reaction: string) => { - setMessages(prev => prev.map(msg => { - if (msg.id === id) { - const currentReactions = msg.reactions || []; - if (currentReactions.includes(reaction)) { - return { ...msg, reactions: currentReactions.filter(r => r !== reaction) }; - } else { - return { ...msg, reactions: [...currentReactions, reaction] }; - } - } - return msg; - })); - }; - - // --- Export Logic --- - - const openExportModal = (type: 'full' | 'single', data?: Message) => { - setExportTarget({ type, data }); - setExportModalOpen(true); - }; - - const handleExport = (format: ExportFormat) => { - const timestamp = new Date().toISOString().split('T')[0]; - let fileName = `if-emotion-${timestamp}`; - - if (format === 'pdf') { - const doc = new jsPDF(); - const lineHeight = 10; - let y = 15; - const margin = 15; - const pageWidth = doc.internal.pageSize.getWidth(); - const maxLineWidth = pageWidth - margin * 2; - - doc.setFont("helvetica", "bold"); - doc.text("if.emotion Journal", margin, y); - y += lineHeight; - doc.setFont("helvetica", "normal"); - doc.setFontSize(10); - doc.text(`Date: ${new Date().toLocaleString()}`, margin, y); - y += lineHeight * 2; - - const msgsToExport = exportTarget?.type === 'single' && exportTarget.data ? [exportTarget.data] : messages; - - msgsToExport.forEach(msg => { - const role = msg.role === Role.USER ? 'Me' : 'if.emotion'; - const time = msg.timestamp.toLocaleTimeString(); - - // Header - doc.setFont("helvetica", "bold"); - doc.text(`${role} [${time}]`, margin, y); - y += 7; - - // Body - doc.setFont("helvetica", "normal"); - const splitText = doc.splitTextToSize(msg.text, maxLineWidth); - - // Check page break - if (y + (splitText.length * 7) > doc.internal.pageSize.getHeight() - margin) { - doc.addPage(); - y = 15; - } - - doc.text(splitText, margin, y); - y += (splitText.length * 7) + 10; - }); - - doc.save(`${fileName}.pdf`); - setExportModalOpen(false); - return; + // Start New Session (Persistent) + const startNewSession = async () => { + try { + setIsLoading(true); + const title = `Journey ${new Date().toLocaleDateString()}`; + const session = await clientRef.current.createChat(title); + setSessions(prev => [session, ...prev]); + setCurrentSessionId(session.id); + setMessages([]); + setIsOffTheRecord(false); + setIsSidebarOpen(false); + } catch (e) { + console.error("Failed to create session", e); + } finally { + setIsLoading(false); } + }; - // Text based formats - let content = ''; - let mimeType = 'text/plain'; - - if (exportTarget?.type === 'single' && exportTarget.data) { - // Export Single Message - const m = exportTarget.data; - fileName += `-message`; - - if (format === 'json') { - content = JSON.stringify(m, null, 2); - mimeType = 'application/json'; - } else if (format === 'md') { - content = `**${m.role.toUpperCase()}** (${m.timestamp.toLocaleString()}):\n\n${m.text}`; - mimeType = 'text/markdown'; - } else { - content = `[${m.role.toUpperCase()}] ${m.text}`; - } - + // Toggle Privacy Mode + const handleTogglePrivacy = () => { + if (!isOffTheRecord) { + // Switching TO Privacy Mode + setIsOffTheRecord(true); + setCurrentSessionId(null); + setMessages([{ + id: generateId(), + role: Role.ASSISTANT, + content: "We are now off the record. Nothing we say here will be saved.", + timestamp: new Date() + }]); } else { - // Export Full Chat - if (format === 'json') { - const exportData = { - app: "if.emotion", - title: sessions.find(s => s.id === currentSessionId)?.title || "Session", - date: new Date().toISOString(), - messages: messages - }; - content = JSON.stringify(exportData, null, 2); - mimeType = 'application/json'; - } else if (format === 'md') { - content = `# if.emotion Journal\nDate: ${new Date().toLocaleString()}\n\n---\n\n`; - content += messages.map(m => `### ${m.role === Role.USER ? 'Me' : 'if.emotion'}\n*${m.timestamp.toLocaleString()}*\n\n${m.text}\n\n---\n`).join('\n'); - mimeType = 'text/markdown'; - } else { - content = messages.map(m => `[${m.role.toUpperCase()} - ${m.timestamp.toLocaleString()}]: ${m.text}`).join('\n\n'); - } + // Switching back to Normal + if (sessions.length > 0) { + loadSession(sessions[0].id); + } else { + startNewSession(); + } } - - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${fileName}.${format}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - setExportModalOpen(false); }; - // --- Render --- + // Send Message + const handleSend = async (text: string) => { + const userMsg: Message = { + id: generateId(), + role: Role.USER, + content: text, + timestamp: new Date() + }; - // Scroll to bottom when messages change + setMessages(prev => [...prev, userMsg]); + setIsLoading(true); + + try { + // If persistent, save user message first (optimistic UI handles display) + if (!isOffTheRecord && currentSessionId) { + await clientRef.current.addMessageToChat(currentSessionId, userMsg).catch(e => console.warn("Failed to persist user msg", e)); + } + + // Select model + const model = availableModels[0] || 'gpt-3.5-turbo'; // Fallback + + // Stream response + const streamReader = await clientRef.current.sendMessage( + currentSessionId, + text, + messages, // Context + model, + isOffTheRecord + ); + + const botMsgId = generateId(); + const botMsg: Message = { + id: botMsgId, + role: Role.ASSISTANT, + content: '', + timestamp: new Date() + }; + + setMessages(prev => [...prev, botMsg]); + + const decoder = new TextDecoder(); + let fullContent = ''; + + while (true) { + const { done, value } = await streamReader.read(); + if (done) break; + + const chunk = decoder.decode(value); + // OpenWebUI streaming format parsing depends on endpoint type (OpenAI compatible usually sends "data: JSON") + // Simple parser for standard SSE lines: + const lines = chunk.split('\n'); + for (const line of lines) { + if (line.startsWith('data: ')) { + const dataStr = line.slice(6); + if (dataStr === '[DONE]') continue; + try { + const data = JSON.parse(dataStr); + const content = data.choices?.[0]?.delta?.content || ''; + if (content) { + fullContent += content; + setMessages(prev => prev.map(m => m.id === botMsgId ? { ...m, content: fullContent } : m)); + } + } catch (e) { + // Ignore parse errors for partial chunks + } + } + } + } + + // If persistent, save bot message + if (!isOffTheRecord && currentSessionId) { + const completedBotMsg = { ...botMsg, content: fullContent }; + await clientRef.current.addMessageToChat(currentSessionId, completedBotMsg).catch(e => console.warn("Failed to persist bot msg", e)); + // Refresh session list to update timestamp + loadSessions(); + } + + } catch (e) { + console.error("Error sending message", e); + setMessages(prev => [...prev, { + id: generateId(), + role: Role.SYSTEM, + content: "The connection wavered. Please try again.", + timestamp: new Date(), + error: true + }]); + } finally { + setIsLoading(false); + } + }; + + // Delete Message (Silent) + const handleDeleteMessage = async (msgId: string) => { + // Optimistic delete + setMessages(prev => prev.filter(m => m.id !== msgId)); + + if (!isOffTheRecord && currentSessionId) { + try { + await clientRef.current.deleteMessage(currentSessionId, msgId); + } catch (e) { + console.error("Silent deletion failed remotely", e); + } + } + }; + + // Delete Session + const handleDeleteSession = async (id: string) => { + if (confirm("Are you sure you want to let this journey go?")) { + await clientRef.current.deleteChat(id); + setSessions(prev => prev.filter(s => s.id !== id)); + if (currentSessionId === id) { + const remaining = sessions.filter(s => s.id !== id); + if (remaining.length > 0) loadSession(remaining[0].id); + else startNewSession(); + } + } + }; + + // Scroll to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, isLoading]); return ( -
+
- {/* Sidebar */} + {/* Sidebar for Persistent Mode */} = 768)} + isOpen={isSidebarOpen} onClose={() => setIsSidebarOpen(false)} sessions={sessions} - folders={folders} currentSessionId={currentSessionId} onSelectSession={loadSession} onNewChat={startNewSession} - onNewFolder={createFolder} - onDeleteSession={deleteSession} - onMoveSession={moveSessionToFolder} - onDeleteFolder={deleteFolder} - language={language} + onDeleteSession={handleDeleteSession} /> -
= 768 ? 'md:ml-[280px]' : ''}`}> -
setIsSidebarOpen(!isSidebarOpen)} - mode={mode} - onToggleMode={() => setMode(prev => prev === AppMode.SIMPLE ? AppMode.ADVANCED : AppMode.SIMPLE)} - onExportSession={() => openExportModal('full')} - /> + {/* Main Content */} +
+ setIsSidebarOpen(true)} + onOpenSettings={() => setIsSettingsOpen(true)} + /> -
-
- {messages.map((msg) => ( - openExportModal('single', msg)} - language={language} - mode={mode} - /> - ))} - - {messages.length === 1 && ( -
- {INITIAL_SUGGESTIONS.map((suggestion, index) => ( - - ))} -
- )} +
+
+ {messages.map(msg => ( + + ))} + + {/* Thinking Indicator */} + {isLoading && ( +
+
+
+
+
+ )} + +
+
+
- {isLoading && ( -
-
-
-
-
-
-
-
-
- )} -
-
-
- - setIsOffTheRecord(!isOffTheRecord)} - /> +
- setExportModalOpen(false)} - onExport={handleExport} - language={language} - title={exportTarget?.type === 'single' ? (language === 'en' ? 'Export Message' : language === 'es' ? 'Exportar Mensaje' : 'Exporter Message') : undefined} + setIsSettingsOpen(false)} + settings={settings} + onSave={(s) => { setSettings(s); }} />
); }; -export default App; \ No newline at end of file +export default App; diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx new file mode 100644 index 0000000..36a034a --- /dev/null +++ b/components/ChatInput.tsx @@ -0,0 +1,71 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { SendHorizontal, Loader2 } from 'lucide-react'; + +interface Props { + onSend: (text: string) => void; + isLoading: boolean; + disabled: boolean; +} + +export function ChatInput({ onSend, isLoading, disabled }: Props) { + const [input, setInput] = useState(''); + const textareaRef = useRef(null); + + const handleSubmit = () => { + if (input.trim() && !isLoading && !disabled) { + onSend(input); + setInput(''); + if (textareaRef.current) textareaRef.current.style.height = 'auto'; + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + setInput(e.target.value); + e.target.style.height = 'auto'; + e.target.style.height = `${Math.min(e.target.scrollHeight, 150)}px`; + }; + + return ( +
+
+