From aa61ef868a44b48b2d45479f2e7fab818846ed69 Mon Sep 17 00:00:00 2001 From: Danny Stocker Date: Sun, 30 Nov 2025 05:29:00 +0100 Subject: [PATCH] feat: Integrate with Open WebUI for AI Replaces the Gemini service integration with support for Open WebUI. This change simplifies the AI backend by leveraging an existing solution, allowing for more flexible API connections and reducing direct dependency on specific AI models. Updated dependencies, including React, to their latest versions to incorporate performance improvements and bug fixes. Refactored color schemes and typography in the HTML to better align with the application's theme. Adjusted type definitions for improved clarity and compatibility with the new backend integration. --- App.tsx | 718 +++++++++++------------------- components/ChatInput.tsx | 71 +++ components/ChatMessage.tsx | 47 ++ components/JourneyHeader.tsx | 57 +++ components/MessageActions.tsx | 21 + components/MessageBubble.tsx | 14 +- components/OffTheRecordToggle.tsx | 37 ++ components/SettingsModal.tsx | 67 +++ components/Sidebar.tsx | 272 +++-------- constants.ts | 170 ++----- index.html | 113 ++--- metadata.json | 2 +- package.json | 5 +- services/gemini.ts | 41 +- services/openwebui.ts | 163 +++++++ types.ts | 46 +- utils.ts | 67 ++- 17 files changed, 957 insertions(+), 954 deletions(-) create mode 100644 components/ChatInput.tsx create mode 100644 components/ChatMessage.tsx create mode 100644 components/JourneyHeader.tsx create mode 100644 components/MessageActions.tsx create mode 100644 components/OffTheRecordToggle.tsx create mode 100644 components/SettingsModal.tsx create mode 100644 services/openwebui.ts 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 ( +
+
+