UI Enhancements: - Add Export button to header with download icon - Export modal with 4 format options: PDF, Markdown, JSON, Plain Text - Settings modal with Personality DNA toggle and API configuration - Privacy mode (Off the Record) toggle in header - Improved header layout with proper button spacing Backend: - Add Claude API server for backend integration - Add RAG-enabled variant for future document retrieval Technical: - Add data-testid for Export button for testing - Update dependencies for deployment compatibility Deployed to Proxmox container 200 at 85.239.243.227 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { OpenWebUIClient } from './services/openwebui';
|
|
import { Session, Message, Role, UserSettings, ExportFormat } from './types';
|
|
import { generateId } from './utils';
|
|
|
|
// 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';
|
|
import { ExportModal } from './components/ExportModal';
|
|
|
|
const App: React.FC = () => {
|
|
// Config
|
|
const [settings, setSettings] = useState<UserSettings>(() => {
|
|
const defaultUrl = typeof window !== 'undefined' ? window.location.origin : 'https://85.239.243.227';
|
|
const defaults: UserSettings = {
|
|
baseUrl: defaultUrl, // Use same origin - nginx proxies /v1/ to Claude Max API
|
|
apiKey: 'claude-max', // Auth handled by Claude Max subscription
|
|
advancedMode: true // Enable Sergio's personality DNA by default
|
|
};
|
|
try {
|
|
const saved = localStorage.getItem('if.emotion.settings');
|
|
if (saved) {
|
|
const parsed = JSON.parse(saved);
|
|
// Merge with defaults to handle missing fields from old versions
|
|
return { ...defaults, ...parsed };
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to parse settings from localStorage', e);
|
|
}
|
|
return defaults;
|
|
});
|
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
const [isExportOpen, setIsExportOpen] = useState(false);
|
|
const clientRef = useRef(new OpenWebUIClient(settings));
|
|
|
|
// State
|
|
const [sessions, setSessions] = useState<Session[]>([]);
|
|
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);
|
|
|
|
// Initialize
|
|
useEffect(() => {
|
|
clientRef.current = new OpenWebUIClient(settings);
|
|
localStorage.setItem('if.emotion.settings', JSON.stringify(settings));
|
|
loadModels();
|
|
if (!isOffTheRecord) {
|
|
loadSessions();
|
|
}
|
|
}, [settings]);
|
|
|
|
// Load Models
|
|
const loadModels = async () => {
|
|
const models = await clientRef.current.getModels();
|
|
setAvailableModels(models);
|
|
};
|
|
|
|
// Load Sessions
|
|
const loadSessions = async () => {
|
|
try {
|
|
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 (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);
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
};
|
|
|
|
// 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 {
|
|
// Switching back to Normal
|
|
if (sessions.length > 0) {
|
|
loadSession(sessions[0].id);
|
|
} else {
|
|
startNewSession();
|
|
}
|
|
}
|
|
};
|
|
|
|
// Send Message
|
|
const handleSend = async (text: string) => {
|
|
const userMsg: Message = {
|
|
id: generateId(),
|
|
role: Role.USER,
|
|
content: text,
|
|
timestamp: new Date()
|
|
};
|
|
|
|
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 based on advanced mode setting
|
|
const model = settings.advancedMode ? 'sergio-rag' : 'claude-max';
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
};
|
|
|
|
// Export Conversation
|
|
const handleExport = (format: ExportFormat) => {
|
|
if (messages.length === 0) {
|
|
alert('No messages to export');
|
|
return;
|
|
}
|
|
|
|
const timestamp = new Date().toISOString().slice(0, 10);
|
|
const filename = `if-emotion-chat-${timestamp}`;
|
|
let content: string;
|
|
let mimeType: string;
|
|
let extension: string;
|
|
|
|
switch (format) {
|
|
case 'json':
|
|
content = JSON.stringify({
|
|
exported: new Date().toISOString(),
|
|
sessionId: currentSessionId,
|
|
messages: messages.map(m => ({
|
|
role: m.role,
|
|
content: m.content,
|
|
timestamp: m.timestamp
|
|
}))
|
|
}, null, 2);
|
|
mimeType = 'application/json';
|
|
extension = 'json';
|
|
break;
|
|
|
|
case 'md':
|
|
content = `# if.emotion Chat Export\n\n*Exported: ${new Date().toLocaleString()}*\n\n---\n\n`;
|
|
content += messages.map(m => {
|
|
const role = m.role === Role.USER ? '**You**' : m.role === Role.ASSISTANT ? '**Sergio**' : '*System*';
|
|
return `${role}\n\n${m.content}\n\n---\n`;
|
|
}).join('\n');
|
|
mimeType = 'text/markdown';
|
|
extension = 'md';
|
|
break;
|
|
|
|
case 'txt':
|
|
content = `if.emotion Chat Export\nExported: ${new Date().toLocaleString()}\n${'='.repeat(50)}\n\n`;
|
|
content += messages.map(m => {
|
|
const role = m.role === Role.USER ? 'You' : m.role === Role.ASSISTANT ? 'Sergio' : 'System';
|
|
return `[${role}]\n${m.content}\n\n`;
|
|
}).join('');
|
|
mimeType = 'text/plain';
|
|
extension = 'txt';
|
|
break;
|
|
|
|
case 'pdf':
|
|
// For PDF, create a printable HTML and trigger print dialog
|
|
const printWindow = window.open('', '_blank');
|
|
if (printWindow) {
|
|
const htmlContent = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>if.emotion Chat Export</title>
|
|
<style>
|
|
body { font-family: Georgia, serif; max-width: 800px; margin: 40px auto; padding: 20px; }
|
|
h1 { color: #5d4e37; border-bottom: 2px solid #5d4e37; padding-bottom: 10px; }
|
|
.message { margin: 20px 0; padding: 15px; border-radius: 8px; }
|
|
.user { background: #f5f0e8; }
|
|
.assistant { background: #e8ede5; }
|
|
.role { font-weight: bold; color: #5d4e37; margin-bottom: 8px; }
|
|
.meta { font-size: 12px; color: #888; margin-top: 20px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>if.emotion Chat Export</h1>
|
|
${messages.map(m => `
|
|
<div class="message ${m.role}">
|
|
<div class="role">${m.role === Role.USER ? 'You' : m.role === Role.ASSISTANT ? 'Sergio' : 'System'}</div>
|
|
<div>${m.content.replace(/\n/g, '<br>')}</div>
|
|
</div>
|
|
`).join('')}
|
|
<div class="meta">Exported: ${new Date().toLocaleString()}</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
printWindow.document.write(htmlContent);
|
|
printWindow.document.close();
|
|
printWindow.print();
|
|
}
|
|
setIsExportOpen(false);
|
|
return;
|
|
|
|
default:
|
|
return;
|
|
}
|
|
|
|
// Download file
|
|
const blob = new Blob([content], { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${filename}.${extension}`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
setIsExportOpen(false);
|
|
};
|
|
|
|
// Scroll to bottom
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages, isLoading]);
|
|
|
|
return (
|
|
<div className="flex h-screen bg-sergio-50 overflow-hidden">
|
|
|
|
{/* Sidebar for Persistent Mode */}
|
|
<Sidebar
|
|
isOpen={isSidebarOpen}
|
|
onClose={() => setIsSidebarOpen(false)}
|
|
sessions={sessions}
|
|
currentSessionId={currentSessionId}
|
|
onSelectSession={loadSession}
|
|
onNewChat={startNewSession}
|
|
onDeleteSession={handleDeleteSession}
|
|
/>
|
|
|
|
{/* 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)}
|
|
onOpenExport={() => setIsExportOpen(true)}
|
|
/>
|
|
|
|
<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>
|
|
|
|
<ChatInput
|
|
onSend={handleSend}
|
|
isLoading={isLoading}
|
|
disabled={availableModels.length === 0 && !isOffTheRecord} // Only disable if no connection and trying to save
|
|
/>
|
|
</div>
|
|
|
|
<SettingsModal
|
|
isOpen={isSettingsOpen}
|
|
onClose={() => setIsSettingsOpen(false)}
|
|
settings={settings}
|
|
onSave={(s) => { setSettings(s); }}
|
|
/>
|
|
|
|
<ExportModal
|
|
isOpen={isExportOpen}
|
|
onClose={() => setIsExportOpen(false)}
|
|
onExport={handleExport}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|