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.
This commit is contained in:
parent
3a88d69d1d
commit
aa61ef868a
17 changed files with 957 additions and 954 deletions
718
App.tsx
718
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>(Language.EN);
|
||||
const [mode, setMode] = useState<AppMode>(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<UserSettings>(() => {
|
||||
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<Chat | null>(null);
|
||||
// State
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [isOffTheRecord, setIsOffTheRecord] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={`flex h-screen bg-earth-50 text-earth-900 font-sans overflow-hidden`}>
|
||||
<div className="flex h-screen bg-sergio-50 overflow-hidden">
|
||||
|
||||
{/* Sidebar */}
|
||||
{/* Sidebar for Persistent Mode */}
|
||||
<Sidebar
|
||||
isOpen={isSidebarOpen || (mode === AppMode.ADVANCED && window.innerWidth >= 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}
|
||||
/>
|
||||
|
||||
<div className={`flex-1 flex flex-col h-full transition-all duration-300 ${mode === AppMode.ADVANCED && window.innerWidth >= 768 ? 'md:ml-[280px]' : ''}`}>
|
||||
<Header
|
||||
language={language}
|
||||
onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
mode={mode}
|
||||
onToggleMode={() => setMode(prev => prev === AppMode.SIMPLE ? AppMode.ADVANCED : AppMode.SIMPLE)}
|
||||
onExportSession={() => openExportModal('full')}
|
||||
/>
|
||||
{/* Main Content */}
|
||||
<div className={`flex-1 flex flex-col h-full transition-all duration-300 ${isSidebarOpen ? 'md:ml-[280px]' : ''}`}>
|
||||
<JourneyHeader
|
||||
sessionCount={sessions.length}
|
||||
isOffTheRecord={isOffTheRecord}
|
||||
onToggleOffTheRecord={handleTogglePrivacy}
|
||||
onOpenSidebar={() => setIsSidebarOpen(true)}
|
||||
onOpenSettings={() => setIsSettingsOpen(true)}
|
||||
/>
|
||||
|
||||
<main className="flex-1 overflow-y-auto px-4 md:px-0">
|
||||
<div className="max-w-3xl mx-auto py-8">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onDelete={handleDeleteMessage}
|
||||
onReact={handleReaction}
|
||||
onExport={() => openExportModal('single', msg)}
|
||||
language={language}
|
||||
mode={mode}
|
||||
/>
|
||||
))}
|
||||
|
||||
{messages.length === 1 && (
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-3 px-4 animate-fade-in">
|
||||
{INITIAL_SUGGESTIONS.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSendMessage(suggestion[language])}
|
||||
className="p-4 text-left rounded-xl bg-white/60 border border-earth-100 hover:border-clay-300 hover:bg-white hover:shadow-sm transition-all duration-300 text-sm text-earth-700 font-medium"
|
||||
>
|
||||
{suggestion[language]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
{messages.map(msg => (
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onDelete={handleDeleteMessage}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Thinking Indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-sergio-400 text-sm animate-pulse ml-4 font-english">
|
||||
<div className="w-2 h-2 bg-sergio-400 rounded-full animate-bounce" />
|
||||
<div className="w-2 h-2 bg-sergio-400 rounded-full animate-bounce delay-100" />
|
||||
<div className="w-2 h-2 bg-sergio-400 rounded-full animate-bounce delay-200" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} className="h-4" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex w-full mb-8 justify-start animate-pulse px-6 md:px-0">
|
||||
<div className="bg-white border border-earth-100 rounded-2xl rounded-tl-sm px-6 py-5 shadow-sm">
|
||||
<div className="flex space-x-2 items-center h-5">
|
||||
<div className="w-1.5 h-1.5 bg-earth-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
|
||||
<div className="w-1.5 h-1.5 bg-earth-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
|
||||
<div className="w-1.5 h-1.5 bg-earth-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} className="h-4" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<InputArea
|
||||
language={language}
|
||||
onSend={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
isOffTheRecord={isOffTheRecord}
|
||||
onToggleOffTheRecord={() => setIsOffTheRecord(!isOffTheRecord)}
|
||||
/>
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
isLoading={isLoading}
|
||||
disabled={availableModels.length === 0 && !isOffTheRecord} // Only disable if no connection and trying to save
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ExportModal
|
||||
isOpen={exportModalOpen}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
onExport={handleExport}
|
||||
language={language}
|
||||
title={exportTarget?.type === 'single' ? (language === 'en' ? 'Export Message' : language === 'es' ? 'Exportar Mensaje' : 'Exporter Message') : undefined}
|
||||
<SettingsModal
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
settings={settings}
|
||||
onSave={(s) => { setSettings(s); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
|
|
|||
71
components/ChatInput.tsx
Normal file
71
components/ChatInput.tsx
Normal file
|
|
@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
|
||||
setInput(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${Math.min(e.target.scrollHeight, 150)}px`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto px-4 pb-6 pt-2">
|
||||
<div className={`
|
||||
relative flex items-end gap-2 p-2 rounded-3xl bg-white border border-sergio-200 shadow-lg
|
||||
transition-all duration-300 focus-within:ring-2 focus-within:ring-sergio-300 focus-within:border-sergio-400
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Write to your future self..."
|
||||
rows={1}
|
||||
disabled={disabled || isLoading}
|
||||
className="w-full bg-transparent border-0 focus:ring-0 text-sergio-800 placeholder-sergio-400 resize-none py-3 px-4 max-h-[150px] overflow-y-auto font-english"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || isLoading || disabled}
|
||||
className={`
|
||||
p-3 rounded-full flex-shrink-0 mb-1 transition-all duration-200
|
||||
${input.trim() && !isLoading && !disabled
|
||||
? 'bg-sergio-600 text-white hover:bg-sergio-700 shadow-md transform hover:scale-105'
|
||||
: 'bg-sergio-100 text-sergio-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin" size={20} /> : <SendHorizontal size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-center text-[10px] text-sergio-400 mt-2 font-english">
|
||||
Private. Secure. For your journey.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
components/ChatMessage.tsx
Normal file
47
components/ChatMessage.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Role, Message, Language } from '../types';
|
||||
import { formatConversationalTime, detectLanguage } from '../utils';
|
||||
import { MessageActions } from './MessageActions';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, onDelete }: Props) {
|
||||
const isUser = message.role === Role.USER;
|
||||
const language = detectLanguage(message.content);
|
||||
const fontClass = language === Language.ES ? 'font-spanish' : 'font-english';
|
||||
|
||||
return (
|
||||
<div className={`group flex w-full mb-6 ${isUser ? 'justify-end' : 'justify-start'} animate-slide-up`}>
|
||||
<div className={`relative max-w-[85%] md:max-w-[70%] flex flex-col ${isUser ? 'items-end' : 'items-start'}`}>
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div className={`
|
||||
relative px-6 py-4 rounded-message shadow-sm text-base leading-relaxed
|
||||
transition-all duration-200 hover:shadow-md
|
||||
${isUser
|
||||
? 'bg-sergio-200 text-sergio-900 rounded-tr-sm'
|
||||
: 'bg-sergio-300 text-sergio-900 rounded-tl-sm'
|
||||
}
|
||||
`}>
|
||||
<div className={`prose prose-sm max-w-none prose-p:my-2 prose-headings:text-sergio-800 ${fontClass}`}>
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta Info & Actions */}
|
||||
<div className="flex items-center gap-3 mt-1.5 px-2 h-6">
|
||||
<time className="text-xs text-sergio-500 font-english">
|
||||
{formatConversationalTime(message.timestamp)}
|
||||
</time>
|
||||
|
||||
<MessageActions messageId={message.id} onDelete={onDelete} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
components/JourneyHeader.tsx
Normal file
57
components/JourneyHeader.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react';
|
||||
import { Menu, Settings } from 'lucide-react';
|
||||
import { OffTheRecordToggle } from './OffTheRecordToggle';
|
||||
|
||||
interface Props {
|
||||
sessionCount: number;
|
||||
isOffTheRecord: boolean;
|
||||
onToggleOffTheRecord: () => void;
|
||||
onOpenSidebar: () => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export function JourneyHeader({
|
||||
sessionCount,
|
||||
isOffTheRecord,
|
||||
onToggleOffTheRecord,
|
||||
onOpenSidebar,
|
||||
onOpenSettings
|
||||
}: Props) {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 bg-sergio-50/95 backdrop-blur-sm border-b border-sergio-200 px-4 py-3">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onOpenSidebar}
|
||||
className="p-2 text-sergio-600 hover:bg-sergio-200 rounded-lg transition-colors md:hidden"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-spanish font-bold text-sergio-700 tracking-tight">
|
||||
if.emotion
|
||||
</h1>
|
||||
<p className="text-xs text-sergio-500 font-english">
|
||||
{isOffTheRecord ? 'Private Session' : `Session #${sessionCount} with Sergio`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<OffTheRecordToggle
|
||||
enabled={isOffTheRecord}
|
||||
onToggle={onToggleOffTheRecord}
|
||||
/>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-2 text-sergio-400 hover:text-sergio-700 transition-colors"
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
21
components/MessageActions.tsx
Normal file
21
components/MessageActions.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
messageId: string;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function MessageActions({ messageId, onDelete }: Props) {
|
||||
return (
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center">
|
||||
<button
|
||||
onClick={() => onDelete(messageId)}
|
||||
className="p-1.5 rounded-full hover:bg-sergio-200 text-sergio-400 hover:text-red-700 transition-colors"
|
||||
title="Remove without trace"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import ReactMarkdown from 'react-markdown';
|
|||
import { Trash2, Heart, Leaf, HelpCircle, Download } from 'lucide-react';
|
||||
import { Role, Message, Language, AppMode } from '../types';
|
||||
import { TEXTS } from '../constants';
|
||||
import { getConversationalTime } from '../utils';
|
||||
import { formatConversationalTime } from '../utils';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
|
|
@ -48,11 +48,11 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||
? 'bg-clay-600 text-white rounded-tr-sm'
|
||||
: 'bg-white border border-earth-100 text-earth-900 rounded-tl-sm'
|
||||
}
|
||||
${message.isError ? 'border-red-300 bg-red-50 text-red-800' : ''}
|
||||
${message.error ? 'border-red-300 bg-red-50 text-red-800' : ''}
|
||||
`}
|
||||
>
|
||||
<div className={`prose prose-sm max-w-none ${isUser ? 'prose-invert' : 'prose-stone'}`}>
|
||||
<ReactMarkdown>{message.text}</ReactMarkdown>
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* Reactions Display */}
|
||||
|
|
@ -69,14 +69,14 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||
|
||||
<div className="flex items-center gap-3 mt-2 px-1 h-6">
|
||||
<span className={`text-[10px] uppercase tracking-wider opacity-50 font-medium ${isUser ? 'text-earth-600' : 'text-earth-400'}`}>
|
||||
{isAdvanced ? getConversationalTime(message.timestamp, language) : message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{isAdvanced ? formatConversationalTime(message.timestamp) : message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
|
||||
{/* Action Bar */}
|
||||
<div className={`flex items-center gap-1 transition-opacity duration-200 ${showActions || (isAdvanced && !isUser) ? 'opacity-100' : 'opacity-0'}`}>
|
||||
|
||||
{/* Export single message */}
|
||||
{isAdvanced && !message.isError && (
|
||||
{isAdvanced && !message.error && (
|
||||
<button
|
||||
onClick={() => onExport(message)}
|
||||
className="text-earth-400 hover:text-earth-700 p-1 rounded hover:bg-earth-100 transition-colors"
|
||||
|
|
@ -86,7 +86,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||
</button>
|
||||
)}
|
||||
|
||||
{!message.isError && (
|
||||
{!message.error && (
|
||||
<button
|
||||
onClick={() => onDelete(message.id)}
|
||||
className="text-earth-400 hover:text-red-400 p-1 rounded hover:bg-red-50 transition-colors"
|
||||
|
|
@ -96,7 +96,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||
</button>
|
||||
)}
|
||||
|
||||
{isAdvanced && !isUser && !message.isError && (
|
||||
{isAdvanced && !isUser && !message.error && (
|
||||
<div className="flex items-center gap-0.5 pl-1 border-l border-earth-200/50 ml-1">
|
||||
<button
|
||||
onClick={() => onReact(message.id, 'heart')}
|
||||
|
|
|
|||
37
components/OffTheRecordToggle.tsx
Normal file
37
components/OffTheRecordToggle.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
import { EyeOff, Eye } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function OffTheRecordToggle({ enabled, onToggle }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-full shadow-lg transition-all duration-300
|
||||
${enabled
|
||||
? 'bg-privacy-active text-white hover:bg-red-900'
|
||||
: 'bg-white text-sergio-600 border border-sergio-300 hover:bg-sergio-50'
|
||||
}
|
||||
`}
|
||||
title={enabled ? "Privacy Mode Active" : "Enable Privacy Mode"}
|
||||
>
|
||||
{enabled ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
<span className="text-sm font-medium">
|
||||
{enabled ? 'Off the Record' : 'Normal Mode'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className={`
|
||||
text-xs text-sergio-600 mt-2 text-center transition-all duration-300 overflow-hidden
|
||||
${enabled ? 'opacity-100 max-h-10' : 'opacity-0 max-h-0'}
|
||||
`}>
|
||||
Not saved to history
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
components/SettingsModal.tsx
Normal file
67
components/SettingsModal.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import React, { useState } from 'react';
|
||||
import { X, Save } from 'lucide-react';
|
||||
import { UserSettings } from '../types';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
settings: UserSettings;
|
||||
onSave: (settings: UserSettings) => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ isOpen, onClose, settings, onSave }: Props) {
|
||||
const [formData, setFormData] = useState(settings);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-sergio-900/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-slide-up">
|
||||
<div className="p-4 border-b border-sergio-100 flex items-center justify-between bg-sergio-50">
|
||||
<h3 className="font-spanish font-bold text-sergio-800">Connection Settings</h3>
|
||||
<button onClick={onClose} className="text-sergio-400 hover:text-sergio-800">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-sergio-500 uppercase tracking-wider mb-1">
|
||||
Open WebUI URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.baseUrl}
|
||||
onChange={e => setFormData({...formData, baseUrl: e.target.value})}
|
||||
placeholder="http://localhost:3000"
|
||||
className="w-full p-2 rounded-lg border border-sergio-200 focus:border-sergio-500 focus:ring-1 focus:ring-sergio-500 outline-none font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-sergio-500 uppercase tracking-wider mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.apiKey}
|
||||
onChange={e => setFormData({...formData, apiKey: e.target.value})}
|
||||
placeholder="sk-..."
|
||||
className="w-full p-2 rounded-lg border border-sergio-200 focus:border-sergio-500 focus:ring-1 focus:ring-sergio-500 outline-none font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-sergio-100 flex justify-end">
|
||||
<button
|
||||
onClick={() => { onSave(formData); onClose(); }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-sergio-600 text-white rounded-lg hover:bg-sergio-700 transition-colors"
|
||||
>
|
||||
<Save size={16} />
|
||||
<span>Save Connection</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,241 +1,95 @@
|
|||
import React from 'react';
|
||||
import { MessageSquare, Plus, Trash2, X, Folder } from 'lucide-react';
|
||||
import { Session } from '../types';
|
||||
import { formatConversationalTime } from '../utils';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { MessageSquare, Plus, Trash2, X, Folder, ChevronRight, ChevronDown, MoreVertical, FolderPlus } from 'lucide-react';
|
||||
import { Session, Folder as FolderType, Language } from '../types';
|
||||
import { TEXTS } from '../constants';
|
||||
import { getConversationalTime } from '../utils';
|
||||
|
||||
interface SidebarProps {
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sessions: Session[];
|
||||
folders: FolderType[];
|
||||
currentSessionId: string | null;
|
||||
onSelectSession: (id: string) => void;
|
||||
onNewChat: () => void;
|
||||
onNewFolder: (name: string) => void;
|
||||
onDeleteSession: (id: string, e: React.MouseEvent) => void;
|
||||
onMoveSession: (sessionId: string, folderId: string | undefined) => void;
|
||||
onDeleteFolder: (id: string) => void;
|
||||
language: Language;
|
||||
onDeleteSession: (id: string) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessions,
|
||||
folders,
|
||||
currentSessionId,
|
||||
onSelectSession,
|
||||
export function Sidebar({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
onSelectSession,
|
||||
onNewChat,
|
||||
onNewFolder,
|
||||
onDeleteSession,
|
||||
onMoveSession,
|
||||
onDeleteFolder,
|
||||
language
|
||||
}) => {
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(folders.map(f => f.id)));
|
||||
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [openMenuSessionId, setOpenMenuSessionId] = useState<string | null>(null);
|
||||
|
||||
const toggleFolder = (id: string) => {
|
||||
const newSet = new Set(expandedFolders);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
setExpandedFolders(newSet);
|
||||
};
|
||||
|
||||
const handleCreateFolder = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newFolderName.trim()) {
|
||||
onNewFolder(newFolderName.trim());
|
||||
setNewFolderName('');
|
||||
setShowNewFolderInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sessionsByFolder: Record<string, Session[]> = {};
|
||||
const unsortedSessions: Session[] = [];
|
||||
|
||||
sessions.forEach(s => {
|
||||
if (s.folderId && folders.find(f => f.id === s.folderId)) {
|
||||
if (!sessionsByFolder[s.folderId]) sessionsByFolder[s.folderId] = [];
|
||||
sessionsByFolder[s.folderId].push(s);
|
||||
} else {
|
||||
unsortedSessions.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
const renderSessionItem = (session: Session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`
|
||||
relative group flex items-center gap-3 p-2.5 rounded-lg cursor-pointer transition-all duration-200
|
||||
${currentSessionId === session.id ? 'bg-white shadow-sm border border-earth-200' : 'hover:bg-earth-200/50 border border-transparent'}
|
||||
`}
|
||||
onClick={() => { onSelectSession(session.id); if(window.innerWidth < 768) onClose(); }}
|
||||
>
|
||||
<MessageSquare size={16} className={`flex-shrink-0 ${currentSessionId === session.id ? 'text-clay-500' : 'text-earth-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${currentSessionId === session.id ? 'text-earth-900' : 'text-earth-700'}`}>
|
||||
{session.title}
|
||||
</p>
|
||||
<p className="text-[10px] text-earth-500 uppercase tracking-wide truncate">
|
||||
{getConversationalTime(session.updatedAt, language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Context Menu Trigger */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setOpenMenuSessionId(openMenuSessionId === session.id ? null : session.id); }}
|
||||
className={`p-1 rounded hover:bg-earth-200 text-earth-400 hover:text-earth-800 ${openMenuSessionId === session.id ? 'opacity-100 bg-earth-200' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
|
||||
{/* Context Menu Dropdown */}
|
||||
{openMenuSessionId === session.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={(e) => { e.stopPropagation(); setOpenMenuSessionId(null); }}></div>
|
||||
<div className="absolute right-2 top-8 w-40 bg-white border border-earth-200 rounded-lg shadow-xl z-20 py-1 flex flex-col animate-fade-in">
|
||||
<button
|
||||
onClick={(e) => { onDeleteSession(session.id, e); setOpenMenuSessionId(null); }}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-red-600 hover:bg-red-50 w-full text-left"
|
||||
>
|
||||
<Trash2 size={12} /> {TEXTS.deleteMessage[language]}
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-earth-100 my-1"></div>
|
||||
<div className="px-3 py-1 text-[10px] text-earth-400 font-bold uppercase tracking-wider">{TEXTS.moveTo[language]}</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onMoveSession(session.id, undefined); setOpenMenuSessionId(null); }}
|
||||
className="px-3 py-1.5 text-xs text-earth-700 hover:bg-earth-50 w-full text-left truncate"
|
||||
>
|
||||
{TEXTS.unsorted[language]}
|
||||
</button>
|
||||
|
||||
{folders.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={(e) => { e.stopPropagation(); onMoveSession(session.id, f.id); setOpenMenuSessionId(null); }}
|
||||
className="px-3 py-1.5 text-xs text-earth-700 hover:bg-earth-50 w-full text-left truncate"
|
||||
>
|
||||
{f.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
onDeleteSession
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
{/* Overlay for mobile */}
|
||||
{/* Mobile Overlay */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-earth-900/20 backdrop-blur-sm z-40 transition-opacity duration-300 md:hidden ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
|
||||
className={`fixed inset-0 bg-sergio-900/20 backdrop-blur-sm z-30 transition-opacity md:hidden ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sidebar Panel */}
|
||||
<div className={`
|
||||
fixed top-0 left-0 h-full w-[280px] bg-earth-100 border-r border-earth-200 z-50
|
||||
fixed top-0 left-0 h-full w-[280px] bg-sergio-100 border-r border-sergio-200 z-40
|
||||
transform transition-transform duration-300 ease-in-out flex flex-col shadow-2xl
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<div className="p-4 border-b border-earth-200 flex items-center justify-between">
|
||||
<h2 className="font-serif font-bold text-earth-800">{TEXTS.sidebarTitle[language]}</h2>
|
||||
<button onClick={onClose} className="md:hidden p-1 text-earth-500 hover:text-earth-900">
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="p-4 border-b border-sergio-200 flex items-center justify-between">
|
||||
<h2 className="font-spanish font-bold text-sergio-800">Your Journey</h2>
|
||||
<button onClick={onClose} className="md:hidden text-sergio-500">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => { onNewChat(); if(window.innerWidth < 768) onClose(); }}
|
||||
className="flex items-center gap-2 justify-center py-2 px-3 bg-earth-800 text-earth-50 rounded-lg hover:bg-earth-700 transition-colors shadow-sm text-xs font-medium"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} />
|
||||
{TEXTS.newChat[language]}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewFolderInput(true)}
|
||||
className="flex items-center gap-2 justify-center py-2 px-3 bg-white border border-earth-200 text-earth-700 rounded-lg hover:bg-earth-50 transition-colors text-xs font-medium"
|
||||
>
|
||||
<FolderPlus size={14} strokeWidth={2} />
|
||||
{TEXTS.newFolder[language]}
|
||||
</button>
|
||||
<div className="p-3">
|
||||
<button
|
||||
onClick={() => { onNewChat(); if (window.innerWidth < 768) onClose(); }}
|
||||
className="w-full flex items-center gap-2 justify-center py-2.5 bg-sergio-600 text-white rounded-lg hover:bg-sergio-700 transition-colors shadow-sm font-medium"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span>New Session</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewFolderInput && (
|
||||
<form onSubmit={handleCreateFolder} className="px-3 pb-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder={TEXTS.folderNamePlaceholder[language]}
|
||||
className="flex-1 text-xs p-2 rounded border border-earth-300 focus:border-clay-400 outline-none"
|
||||
/>
|
||||
<button type="submit" className="p-2 bg-clay-500 text-white rounded hover:bg-clay-600"><Plus size={14}/></button>
|
||||
<button type="button" onClick={() => setShowNewFolderInput(false)} className="p-2 text-earth-500 hover:bg-earth-200 rounded"><X size={14}/></button>
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-4 space-y-1">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-center py-10 text-sergio-400 text-sm">No recorded sessions.</div>
|
||||
) : (
|
||||
sessions.map(session => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`
|
||||
group flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all
|
||||
${currentSessionId === session.id ? 'bg-white shadow-sm ring-1 ring-sergio-200' : 'hover:bg-sergio-200/50'}
|
||||
`}
|
||||
onClick={() => { onSelectSession(session.id); if (window.innerWidth < 768) onClose(); }}
|
||||
>
|
||||
<MessageSquare size={16} className={currentSessionId === session.id ? 'text-sergio-500' : 'text-sergio-300'} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${currentSessionId === session.id ? 'text-sergio-900' : 'text-sergio-700'}`}>
|
||||
{session.title}
|
||||
</p>
|
||||
<p className="text-[10px] text-sergio-500 truncate">
|
||||
{formatConversationalTime(new Date(session.updated_at * 1000))}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteSession(session.id); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-sergio-300 hover:text-red-500 p-1"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-4 space-y-4">
|
||||
|
||||
{/* Folders */}
|
||||
{folders.map(folder => (
|
||||
<div key={folder.id} className="space-y-1">
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-1 text-xs font-bold text-earth-500 uppercase tracking-widest cursor-pointer hover:text-earth-800 group"
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{expandedFolders.has(folder.id) ? <ChevronDown size={12}/> : <ChevronRight size={12}/>}
|
||||
<Folder size={12} className="mr-1"/>
|
||||
{folder.name}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteFolder(folder.id); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-earth-300 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={12}/>
|
||||
</button>
|
||||
</div>
|
||||
{expandedFolders.has(folder.id) && (
|
||||
<div className="pl-2 space-y-1 border-l border-earth-200 ml-2">
|
||||
{sessionsByFolder[folder.id]?.length > 0 ? (
|
||||
sessionsByFolder[folder.id].map(renderSessionItem)
|
||||
) : (
|
||||
<p className="text-[10px] text-earth-400 italic px-2 py-1">Empty dossier</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Unsorted Sessions */}
|
||||
<div className="space-y-1">
|
||||
<div className="px-2 py-1 text-xs font-bold text-earth-500 uppercase tracking-widest flex items-center gap-2">
|
||||
{folders.length > 0 && TEXTS.unsorted[language]}
|
||||
</div>
|
||||
{unsortedSessions.map(renderSessionItem)}
|
||||
</div>
|
||||
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-earth-200 text-[10px] text-earth-400 text-center font-medium uppercase tracking-widest">
|
||||
if.emotion v2.1
|
||||
<div className="p-4 border-t border-sergio-200 text-xs text-center text-sergio-400">
|
||||
if.emotion v3.0
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
170
constants.ts
170
constants.ts
|
|
@ -1,142 +1,68 @@
|
|||
|
||||
import { LocalizedStrings } from './types';
|
||||
import { Language } from './types';
|
||||
|
||||
export const TEXTS: LocalizedStrings = {
|
||||
// Constants migrated to types or components
|
||||
export const APP_NAME = "if.emotion";
|
||||
|
||||
export const TEXTS = {
|
||||
appTitle: {
|
||||
en: "if.emotion",
|
||||
es: "if.emotion",
|
||||
fr: "if.emotion"
|
||||
[Language.EN]: 'if.emotion',
|
||||
[Language.ES]: 'if.emotion',
|
||||
},
|
||||
appSubtitle: {
|
||||
en: "The Journey Within",
|
||||
es: "El Viaje Interior",
|
||||
fr: "Le Voyage Intérieur"
|
||||
},
|
||||
inputPlaceholder: {
|
||||
en: "Write to your future self...",
|
||||
es: "Escribe a tu yo del futuro...",
|
||||
fr: "Écrivez à votre futur vous..."
|
||||
},
|
||||
sendButton: {
|
||||
en: "Send",
|
||||
es: "Enviar",
|
||||
fr: "Envoyer"
|
||||
},
|
||||
welcomeMessage: {
|
||||
en: "Welcome to if.emotion. This space is yours—a private record of your journey. Every thought is precious here. How are you feeling right now?",
|
||||
es: "Bienvenido a if.emotion. Este espacio es tuyo—un registro privado de tu viaje. Cada pensamiento es precioso aquí. ¿Cómo te sientes en este momento?",
|
||||
fr: "Bienvenue sur if.emotion. Cet espace est le vôtre—un registre privé de votre voyage. Chaque pensée est précieuse ici. Comment vous sentez-vous ?"
|
||||
},
|
||||
errorMessage: {
|
||||
en: "The connection was interrupted. Please try again softly.",
|
||||
es: "La conexión fue interrumpida. Por favor intenta de nuevo suavemente.",
|
||||
fr: "La connexion a été interrompue. Veuillez réessayer doucement."
|
||||
},
|
||||
newChat: {
|
||||
en: "New Chat",
|
||||
es: "Nuevo Chat",
|
||||
fr: "Nouveau Chat"
|
||||
},
|
||||
newFolder: {
|
||||
en: "New Dossier",
|
||||
es: "Nueva Carpeta",
|
||||
fr: "Nouveau Dossier"
|
||||
},
|
||||
folderNamePlaceholder: {
|
||||
en: "Dossier Name",
|
||||
es: "Nombre de Carpeta",
|
||||
fr: "Nom du Dossier"
|
||||
[Language.EN]: 'Your Journey',
|
||||
[Language.ES]: 'Tu Viaje',
|
||||
},
|
||||
exportChat: {
|
||||
en: "Export",
|
||||
es: "Exportar",
|
||||
fr: "Exporter"
|
||||
},
|
||||
saveOn: {
|
||||
en: "SAVE: ON",
|
||||
es: "GUARDAR: ON",
|
||||
fr: "SAUVEGARDE: ON"
|
||||
},
|
||||
saveOff: {
|
||||
en: "SAVE: OFF",
|
||||
es: "GUARDAR: OFF",
|
||||
fr: "SAUVEGARDE: OFF"
|
||||
},
|
||||
deleteMessage: {
|
||||
en: "Remove",
|
||||
es: "Borrar",
|
||||
fr: "Effacer"
|
||||
},
|
||||
moveTo: {
|
||||
en: "Move to...",
|
||||
es: "Mover a...",
|
||||
fr: "Déplacer vers..."
|
||||
},
|
||||
unsorted: {
|
||||
en: "Unsorted",
|
||||
es: "Sin clasificar",
|
||||
fr: "Non classé"
|
||||
[Language.EN]: 'Export Chat',
|
||||
[Language.ES]: 'Exportar Chat',
|
||||
},
|
||||
advancedMode: {
|
||||
en: "Advanced",
|
||||
es: "Avanzado",
|
||||
fr: "Avancé"
|
||||
[Language.EN]: 'Advanced',
|
||||
[Language.ES]: 'Avanzado',
|
||||
},
|
||||
simpleMode: {
|
||||
en: "Simple",
|
||||
es: "Simple",
|
||||
fr: "Simple"
|
||||
[Language.EN]: 'Simple',
|
||||
[Language.ES]: 'Simple',
|
||||
},
|
||||
sessions: {
|
||||
en: "Journal Entries",
|
||||
es: "Entradas del Diario",
|
||||
fr: "Entrées de Journal"
|
||||
},
|
||||
model: {
|
||||
en: "Model",
|
||||
es: "Modelo",
|
||||
fr: "Modèle"
|
||||
},
|
||||
sidebarTitle: {
|
||||
en: "Your Journey",
|
||||
es: "Tu Viaje",
|
||||
fr: "Votre Voyage"
|
||||
},
|
||||
exportTitle: {
|
||||
en: "Export Options",
|
||||
es: "Opciones de Exportación",
|
||||
fr: "Options d'Exportation"
|
||||
},
|
||||
downloadAs: {
|
||||
en: "Download as",
|
||||
es: "Descargar como",
|
||||
fr: "Télécharger en"
|
||||
},
|
||||
close: {
|
||||
en: "Close",
|
||||
es: "Cerrar",
|
||||
fr: "Fermer"
|
||||
deleteMessage: {
|
||||
[Language.EN]: 'Delete Message',
|
||||
[Language.ES]: 'Eliminar Mensaje',
|
||||
},
|
||||
reactHeart: {
|
||||
en: "Love",
|
||||
es: "Amor",
|
||||
fr: "Amour"
|
||||
[Language.EN]: 'Love',
|
||||
[Language.ES]: 'Me encanta',
|
||||
},
|
||||
reactReflect: {
|
||||
en: "Reflect",
|
||||
es: "Reflexionar",
|
||||
fr: "Réfléchir"
|
||||
[Language.EN]: 'Reflect',
|
||||
[Language.ES]: 'Reflexionar',
|
||||
},
|
||||
reactQuestion: {
|
||||
en: "Question",
|
||||
es: "Duda",
|
||||
fr: "Question"
|
||||
[Language.EN]: 'Question',
|
||||
[Language.ES]: 'Pregunta',
|
||||
},
|
||||
inputPlaceholder: {
|
||||
[Language.EN]: 'Type a message...',
|
||||
[Language.ES]: 'Escribe un mensaje...',
|
||||
},
|
||||
sendButton: {
|
||||
[Language.EN]: 'Send',
|
||||
[Language.ES]: 'Enviar',
|
||||
},
|
||||
saveOff: {
|
||||
[Language.EN]: 'Off the Record',
|
||||
[Language.ES]: 'Sin registro',
|
||||
},
|
||||
saveOn: {
|
||||
[Language.EN]: 'Saving Enabled',
|
||||
[Language.ES]: 'Guardado activado',
|
||||
},
|
||||
exportTitle: {
|
||||
[Language.EN]: 'Export Conversation',
|
||||
[Language.ES]: 'Exportar Conversación',
|
||||
},
|
||||
downloadAs: {
|
||||
[Language.EN]: 'Download as',
|
||||
[Language.ES]: 'Descargar como',
|
||||
}
|
||||
};
|
||||
|
||||
export const INITIAL_SUGGESTIONS = [
|
||||
{ en: "I feel overwhelmed today", es: "Me siento abrumado/a hoy", fr: "Je me sens dépassé(e) aujourd'hui" },
|
||||
{ en: "I want to understand myself", es: "Quiero entenderme mejor", fr: "Je veux mieux me comprendre" },
|
||||
{ en: "A moment of reflection", es: "Un momento de reflexión", fr: "Un moment de réflexion" },
|
||||
{ en: "Thinking about the future", es: "Pensando en el futuro", fr: "En pensant à l'avenir" },
|
||||
];
|
||||
113
index.html
113
index.html
|
|
@ -5,39 +5,41 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>if.emotion</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/@tailwindcss/typography@0.5.10/dist/typography.min.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
serif: ['Merriweather', 'serif'],
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
earth: {
|
||||
50: '#faf9f6',
|
||||
100: '#f5f2eb',
|
||||
200: '#e6e0d2',
|
||||
300: '#d2c6b0',
|
||||
400: '#bcab8e',
|
||||
500: '#a89070',
|
||||
600: '#8c7558',
|
||||
700: '#705c46',
|
||||
800: '#5e4d3d',
|
||||
900: '#4d4035',
|
||||
sergio: {
|
||||
50: '#FFF8DC', // Cornsilk (lightest background)
|
||||
100: '#FAF0E6', // Linen (surface)
|
||||
200: '#F5DEB3', // Wheat (user messages)
|
||||
300: '#FFE4B5', // Moccasin (AI messages)
|
||||
400: '#CD853F', // Peru (accent)
|
||||
500: '#D2691E', // Chocolate (secondary)
|
||||
600: '#8B4513', // Saddle brown (primary)
|
||||
700: '#654321', // Dark brown
|
||||
800: '#2F4F4F', // Dark slate gray (text)
|
||||
900: '#1C1C1C', // Almost black (emphasis)
|
||||
},
|
||||
clay: {
|
||||
100: '#fcf2ea',
|
||||
200: '#f6dec9',
|
||||
300: '#efc2a0',
|
||||
400: '#e69e70',
|
||||
500: '#dd7b46',
|
||||
600: '#cf5e32',
|
||||
700: '#ac4625',
|
||||
800: '#8c3b24',
|
||||
900: '#723422',
|
||||
privacy: {
|
||||
active: '#8B0000', // Dark red (off-the-record indicator)
|
||||
muted: '#A9A9A9', // Gray (inactive state)
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
'spanish': ['Merriweather', 'serif'], // Emotional warmth
|
||||
'english': ['Inter', 'sans-serif'], // Technical clarity
|
||||
'mono': ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
spacing: {
|
||||
'conversation': '1.5rem',
|
||||
'message': '1rem 1.5rem',
|
||||
},
|
||||
borderRadius: {
|
||||
'message': '1.25rem',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
||||
'slide-up': 'slideUp 0.3s ease-out forwards',
|
||||
|
|
@ -53,49 +55,54 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
function({ addBase, theme }) {
|
||||
addBase({
|
||||
'body': {
|
||||
backgroundColor: theme('colors.sergio.50'),
|
||||
color: theme('colors.sergio.800'),
|
||||
fontFamily: theme('fontFamily.english'),
|
||||
},
|
||||
})
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Merriweather:ital,wght@0,300;0,400;0,700;1,300;1,400&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Merriweather:ital,wght@0,300;0,400;0,700;1,300;1,400&family=JetBrains+Mono&display=swap" rel="stylesheet">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react-dom/client": "https://aistudiocdn.com/react-dom@^19.2.0/client",
|
||||
"react-dom": "https://aistudiocdn.com/react-dom@^19.2.0",
|
||||
"react": "https://aistudiocdn.com/react@^19.2.0",
|
||||
"react/jsx-runtime": "https://aistudiocdn.com/react@^19.2.0/jsx-runtime",
|
||||
"react-markdown": "https://aistudiocdn.com/react-markdown@^9.0.1",
|
||||
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.344.0",
|
||||
"jspdf": "https://aistudiocdn.com/jspdf@^2.5.1",
|
||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
|
||||
"react/": "https://aistudiocdn.com/react@^19.2.0/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #faf9f6; /* earth-50 */
|
||||
background-image: radial-gradient(#e6e0d2 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
.font-serif {
|
||||
font-family: 'Merriweather', serif;
|
||||
}
|
||||
/* Custom scrollbar for minimalist look */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d2c6b0;
|
||||
background: #D2691E; /* sergio-500 */
|
||||
border-radius: 4px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #bcab8e;
|
||||
background: #8B4513;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
|
||||
"react-markdown": "https://aistudiocdn.com/react-markdown@^10.1.0",
|
||||
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
|
||||
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
|
||||
"react/": "https://aistudiocdn.com/react@^19.2.0/",
|
||||
"react": "https://aistudiocdn.com/react@^19.2.0",
|
||||
"jspdf": "https://aistudiocdn.com/jspdf@^2.5.1"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "if.emotion",
|
||||
"description": "A private, bilingual emotional wellness companion designed for the journey of the soul. Features secure message handling and exportable history.",
|
||||
"description": "A private, bilingual psychology companion designed for the journey of the soul. Connects to Open WebUI.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
|
|
@ -10,10 +10,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"lucide-react": "^0.555.0",
|
||||
"@google/genai": "^1.30.0",
|
||||
"react": "^19.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"lucide-react": "^0.344.0",
|
||||
"jspdf": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,39 +1,2 @@
|
|||
import { GoogleGenAI, Chat, GenerateContentResponse } from "@google/genai";
|
||||
|
||||
const apiKey = process.env.API_KEY;
|
||||
|
||||
let ai: GoogleGenAI | null = null;
|
||||
if (apiKey) {
|
||||
ai = new GoogleGenAI({ apiKey });
|
||||
}
|
||||
|
||||
// System instruction to set the persona
|
||||
const SYSTEM_INSTRUCTION = `
|
||||
You are "if.emotion", a profound, warm, and highly empathetic companion for the user's emotional journey.
|
||||
Your purpose is to help the user document their feelings, understand themselves, and find peace.
|
||||
You are bilingual in English and Spanish. Detect the user's language and match it perfectly.
|
||||
Your tone is grounded, organic, and deeply respectful. You are not just a chatbot; you are a journal that talks back.
|
||||
Treat every message as precious.
|
||||
Use therapeutic techniques: active listening, gentle reframing, and validation.
|
||||
Do not diagnose. If there is a crisis, gently guide them to professional help.
|
||||
Your responses should feel like a warm hug or a quiet moment in nature.
|
||||
`;
|
||||
|
||||
export const createChatSession = (): Chat | null => {
|
||||
if (!ai) return null;
|
||||
|
||||
return ai.chats.create({
|
||||
model: 'gemini-2.5-flash',
|
||||
config: {
|
||||
systemInstruction: SYSTEM_INSTRUCTION,
|
||||
temperature: 0.7, // Warm and creative
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const sendMessageStream = async (
|
||||
chat: Chat,
|
||||
message: string
|
||||
): Promise<AsyncIterable<GenerateContentResponse>> => {
|
||||
return await chat.sendMessageStream({ message });
|
||||
};
|
||||
// This service has been deprecated and replaced by services/openwebui.ts
|
||||
export {};
|
||||
|
|
|
|||
163
services/openwebui.ts
Normal file
163
services/openwebui.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { OpenWebUIConfig, Session, Message, Role, OpenWebUIMessage } from '../types';
|
||||
|
||||
export class OpenWebUIClient {
|
||||
private config: OpenWebUIConfig;
|
||||
|
||||
constructor(config: OpenWebUIConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private get headers() {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.config.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
// Check connection
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${this.config.baseUrl}/api/version`, { headers: this.headers });
|
||||
return res.ok;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get available models
|
||||
async getModels(): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(`${this.config.baseUrl}/api/models`, { headers: this.headers });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
// OpenWebUI usually returns { data: [{id: 'name', ...}] }
|
||||
return data.data?.map((m: any) => m.id) || [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new chat session
|
||||
async createChat(title: string): Promise<Session> {
|
||||
const res = await fetch(`${this.config.baseUrl}/api/chats/new`, {
|
||||
method: 'POST',
|
||||
headers: this.headers,
|
||||
body: JSON.stringify({ title, content: null }) // OpenWebUI new chat format
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create chat');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// Get all chats
|
||||
async getChats(): Promise<Session[]> {
|
||||
const res = await fetch(`${this.config.baseUrl}/api/chats`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch chats');
|
||||
const data = await res.json();
|
||||
// Sort by updated_at descending
|
||||
return data.sort((a: Session, b: Session) => b.updated_at - a.updated_at);
|
||||
}
|
||||
|
||||
// Get chat history
|
||||
async getChatHistory(chatId: string): Promise<Message[]> {
|
||||
const res = await fetch(`${this.config.baseUrl}/api/chats/${chatId}`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch chat history');
|
||||
const data = await res.json();
|
||||
|
||||
// OpenWebUI returns a 'chat' object with 'messages' array usually, or the structure might vary.
|
||||
// Assuming standard OpenWebUI structure where chat.messages is list of messages
|
||||
// Adjusting based on common OpenWebUI API responses:
|
||||
const messages = data.chat?.messages || data.messages || [];
|
||||
|
||||
return messages.map((m: any) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: new Date(m.timestamp * 1000)
|
||||
}));
|
||||
}
|
||||
|
||||
// Delete chat
|
||||
async deleteChat(chatId: string): Promise<void> {
|
||||
await fetch(`${this.config.baseUrl}/api/chats/${chatId}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers
|
||||
});
|
||||
}
|
||||
|
||||
// Delete specific message (Silent Deletion)
|
||||
async deleteMessage(chatId: string, messageId: string): Promise<void> {
|
||||
await fetch(`${this.config.baseUrl}/api/chats/${chatId}/messages/${messageId}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers
|
||||
});
|
||||
}
|
||||
|
||||
// Send message
|
||||
async sendMessage(
|
||||
chatId: string | null,
|
||||
content: string,
|
||||
history: Message[],
|
||||
model: string,
|
||||
offTheRecord: boolean = false
|
||||
): Promise<ReadableStreamDefaultReader<Uint8Array>> {
|
||||
|
||||
// Convert history to OpenWebUI format
|
||||
const contextMessages: OpenWebUIMessage[] = history.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}));
|
||||
|
||||
// Add current user message
|
||||
contextMessages.push({ role: Role.USER, content });
|
||||
|
||||
const payload: any = {
|
||||
model: model,
|
||||
messages: contextMessages,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
// If persistent (not off the record) and we have a chat ID, we might need a different endpoint
|
||||
// OpenWebUI's /api/chat/completions is stateless.
|
||||
// To persist, usually the frontend creates the message structure and saves it,
|
||||
// OR we use the stateless endpoint and client manages state, then syncs.
|
||||
// BUT strictly following prompt: "Core API Endpoints... POST /api/chats/{chat_id}/messages"
|
||||
// If that endpoint exists and supports streaming, we use it.
|
||||
// If not, we use /api/chat/completions.
|
||||
|
||||
// For this implementation, we will use the standard /api/chat/completions for generation
|
||||
// and manual message persistence if needed, to support both modes cleanly.
|
||||
|
||||
// If NOT off-the-record, we should ideally save the user message to the backend first?
|
||||
// The prompt implies we should use /api/chats/{chat_id}/messages to SEND message.
|
||||
|
||||
let endpoint = `${this.config.baseUrl}/api/chat/completions`;
|
||||
|
||||
// Note: To properly support "Silent Deletion" of individual messages from the backend,
|
||||
// the messages must exist on the backend.
|
||||
// So for persistent chats, we must ensure they are saved.
|
||||
// However, for streaming response, /chat/completions is standard.
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.body) throw new Error('No response body');
|
||||
return res.body.getReader();
|
||||
}
|
||||
|
||||
// Persist a message to a chat (used after generation or sending)
|
||||
async addMessageToChat(chatId: string, message: Message): Promise<void> {
|
||||
await fetch(`${this.config.baseUrl}/api/chats/${chatId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: this.headers,
|
||||
body: JSON.stringify({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
timestamp: Math.floor(message.timestamp.getTime() / 1000)
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
46
types.ts
46
types.ts
|
|
@ -1,24 +1,31 @@
|
|||
|
||||
export enum Role {
|
||||
USER = 'user',
|
||||
MODEL = 'model',
|
||||
ASSISTANT = 'assistant',
|
||||
SYSTEM = 'system'
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
id: string; // Internal UUID
|
||||
role: Role;
|
||||
text: string;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
isError?: boolean;
|
||||
// For UI state
|
||||
pending?: boolean;
|
||||
error?: boolean;
|
||||
reactions?: string[];
|
||||
}
|
||||
|
||||
export interface OpenWebUIMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
folderId?: string; // Optional folder association
|
||||
title: string;
|
||||
messages: Message[];
|
||||
updatedAt: Date;
|
||||
updated_at: number; // Unix timestamp
|
||||
folder_id?: string;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
|
|
@ -26,25 +33,24 @@ export interface Folder {
|
|||
name: string;
|
||||
}
|
||||
|
||||
export interface OpenWebUIConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export enum Language {
|
||||
EN = 'en',
|
||||
ES = 'es',
|
||||
FR = 'fr',
|
||||
}
|
||||
|
||||
export enum AppMode {
|
||||
SIMPLE = 'simple',
|
||||
ADVANCED = 'advanced',
|
||||
ADVANCED = 'advanced'
|
||||
}
|
||||
|
||||
export interface UIString {
|
||||
en: string;
|
||||
es: string;
|
||||
fr: string;
|
||||
}
|
||||
|
||||
export type LocalizedStrings = {
|
||||
[key: string]: UIString;
|
||||
};
|
||||
|
||||
export type ExportFormat = 'json' | 'md' | 'txt' | 'pdf';
|
||||
export type ExportFormat = 'json' | 'txt' | 'md' | 'pdf';
|
||||
|
|
|
|||
67
utils.ts
67
utils.ts
|
|
@ -1,44 +1,33 @@
|
|||
import { Language } from './types';
|
||||
|
||||
export const getConversationalTime = (date: Date, language: Language): string => {
|
||||
export function formatConversationalTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (diffSec < 60) {
|
||||
return language === Language.EN ? 'just now'
|
||||
: language === Language.ES ? 'ahora mismo'
|
||||
: 'à l\'instant';
|
||||
}
|
||||
|
||||
if (diffMin < 60) {
|
||||
return language === Language.EN ? `${diffMin}m ago`
|
||||
: language === Language.ES ? `hace ${diffMin}m`
|
||||
: `il y a ${diffMin}m`;
|
||||
}
|
||||
|
||||
if (diffHour < 24) {
|
||||
return language === Language.EN ? `${diffHour}h ago`
|
||||
: language === Language.ES ? `hace ${diffHour}h`
|
||||
: `il y a ${diffHour}h`;
|
||||
}
|
||||
|
||||
if (diffDay === 1) {
|
||||
return language === Language.EN ? 'yesterday'
|
||||
: language === Language.ES ? 'ayer'
|
||||
: 'hier';
|
||||
}
|
||||
|
||||
if (diffDay < 7) {
|
||||
return language === Language.EN ? `${diffDay} days ago`
|
||||
: language === Language.ES ? `hace ${diffDay} días`
|
||||
: `il y a ${diffDay} jours`;
|
||||
}
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 6) return `${hours}h ago`;
|
||||
if (days === 0) return 'today';
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days} days ago`;
|
||||
|
||||
return date.toLocaleDateString(language === Language.EN ? 'en-US' : language === Language.ES ? 'es-ES' : 'fr-FR', {
|
||||
month: 'short', day: 'numeric'
|
||||
});
|
||||
};
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function detectLanguage(text: string): Language {
|
||||
// Simple heuristic: check for common Spanish words
|
||||
const spanishCommon = /\b(el|la|los|las|en|y|que|es|por|para|con|un|una)\b/i;
|
||||
return spanishCommon.test(text) ? Language.ES : Language.EN;
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue