feat: Update project name and dependencies
This commit updates the project name from 'if.emotion' to 'if-emotion-ux' across configuration files. It also reconciles the React and related dependencies, ensuring consistent versions and updating CDN import maps in `index.html` to reflect React v18.3.1. Additionally, the `puppeteer` dev dependency has been removed, and the `advancedMode` setting has been removed from `UserSettings`, simplifying the configuration. The sidebar now groups sessions by date (Today, Yesterday, Older) for improved organization. The export modal has been updated with new icons and text based on the selected language, and a title prop has been added. The removal of `puppeteer` suggests a shift away from end-to-end testing or a similar integration that relied on it. The simplification of settings and the inclusion of more robust session grouping enhance the user experience and maintainability.
This commit is contained in:
parent
39f38d19ad
commit
9df51ea38c
14 changed files with 438 additions and 5429 deletions
225
App.tsx
225
App.tsx
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { OpenWebUIClient } from './services/openwebui';
|
||||
import { Session, Message, Role, UserSettings, ExportFormat } from './types';
|
||||
import { Session, Message, Role, UserSettings, Language } from './types';
|
||||
import { generateId } from './utils';
|
||||
|
||||
// Components
|
||||
|
|
@ -10,28 +10,19 @@ import { ChatMessage } from './components/ChatMessage';
|
|||
import { ChatInput } from './components/ChatInput';
|
||||
import { SettingsModal } from './components/SettingsModal';
|
||||
import { ExportModal } from './components/ExportModal';
|
||||
import { jsPDF } from 'jspdf';
|
||||
|
||||
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
|
||||
const saved = localStorage.getItem('if.emotion.settings');
|
||||
return saved ? JSON.parse(saved) : {
|
||||
baseUrl: 'http://85.239.243.227:8080',
|
||||
apiKey: 'sk-5339243764b840e69188d672802082f4'
|
||||
};
|
||||
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 [selectedModel, setSelectedModel] = useState<string>('');
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [isExportOpen, setIsExportOpen] = useState(false);
|
||||
const clientRef = useRef(new OpenWebUIClient(settings));
|
||||
|
|
@ -61,6 +52,9 @@ const App: React.FC = () => {
|
|||
const loadModels = async () => {
|
||||
const models = await clientRef.current.getModels();
|
||||
setAvailableModels(models);
|
||||
if (models.length > 0 && !selectedModel) {
|
||||
setSelectedModel(models[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Load Sessions
|
||||
|
|
@ -68,10 +62,10 @@ const App: React.FC = () => {
|
|||
try {
|
||||
const list = await clientRef.current.getChats();
|
||||
setSessions(list);
|
||||
// Only auto-load if we have no current session and aren't in privacy mode
|
||||
if (list.length > 0 && !currentSessionId && !isOffTheRecord) {
|
||||
loadSession(list[0].id);
|
||||
} else if (list.length === 0 && !isOffTheRecord) {
|
||||
// Create initial persistent session if none exist
|
||||
} else if (list.length === 0 && !isOffTheRecord && !currentSessionId) {
|
||||
startNewSession();
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -127,6 +121,7 @@ const App: React.FC = () => {
|
|||
}]);
|
||||
} else {
|
||||
// Switching back to Normal
|
||||
setIsOffTheRecord(false);
|
||||
if (sessions.length > 0) {
|
||||
loadSession(sessions[0].id);
|
||||
} else {
|
||||
|
|
@ -153,15 +148,14 @@ const App: React.FC = () => {
|
|||
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 modelToUse = selectedModel || availableModels[0] || 'gpt-3.5-turbo';
|
||||
|
||||
const streamReader = await clientRef.current.sendMessage(
|
||||
currentSessionId,
|
||||
text,
|
||||
messages, // Context
|
||||
model,
|
||||
modelToUse,
|
||||
isOffTheRecord
|
||||
);
|
||||
|
||||
|
|
@ -183,8 +177,6 @@ const App: React.FC = () => {
|
|||
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: ')) {
|
||||
|
|
@ -208,8 +200,7 @@ const App: React.FC = () => {
|
|||
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();
|
||||
loadSessions(); // Update timestamp
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
|
|
@ -228,9 +219,7 @@ const App: React.FC = () => {
|
|||
|
||||
// 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);
|
||||
|
|
@ -253,106 +242,61 @@ const App: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Export Conversation
|
||||
const handleExport = (format: ExportFormat) => {
|
||||
if (messages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
// Handle Export
|
||||
const handleExport = (format: 'json' | 'txt' | 'md' | 'pdf') => {
|
||||
if (!messages.length) return;
|
||||
|
||||
const title = sessions.find(s => s.id === currentSessionId)?.title || 'if-emotion-session';
|
||||
const filename = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.${format}`;
|
||||
|
||||
let content = '';
|
||||
|
||||
if (format === 'json') {
|
||||
content = JSON.stringify(messages, null, 2);
|
||||
} else if (format === 'md') {
|
||||
content = `# ${title}\n\n` + messages.map(m => `**${m.role === 'user' ? 'You' : 'Sergio'}**: ${m.content}\n`).join('\n');
|
||||
} else if (format === 'txt') {
|
||||
content = messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n\n');
|
||||
} else if (format === 'pdf') {
|
||||
const doc = new jsPDF();
|
||||
doc.setFontSize(16);
|
||||
doc.text(title, 10, 10);
|
||||
doc.setFontSize(12);
|
||||
|
||||
let y = 20;
|
||||
const margin = 10;
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const maxLineWidth = pageWidth - margin * 2;
|
||||
|
||||
messages.forEach(msg => {
|
||||
const role = msg.role === 'user' ? 'You' : 'Sergio';
|
||||
const text = `${role}: ${msg.content}`;
|
||||
const lines = doc.splitTextToSize(text, maxLineWidth);
|
||||
|
||||
if (y + lines.length * 7 > doc.internal.pageSize.getHeight() - 10) {
|
||||
doc.addPage();
|
||||
y = 10;
|
||||
}
|
||||
|
||||
doc.text(lines, margin, y);
|
||||
y += lines.length * 7 + 5;
|
||||
});
|
||||
doc.save(filename);
|
||||
setIsExportOpen(false);
|
||||
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;
|
||||
if (content) {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
|
|
@ -362,9 +306,8 @@ const App: React.FC = () => {
|
|||
}, [messages, isLoading]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-sergio-50 overflow-hidden">
|
||||
<div className="flex h-screen bg-sergio-50 overflow-hidden font-english">
|
||||
|
||||
{/* Sidebar for Persistent Mode */}
|
||||
<Sidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
|
|
@ -375,15 +318,13 @@ const App: React.FC = () => {
|
|||
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)}
|
||||
onExport={() => setIsExportOpen(true)}
|
||||
/>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
|
|
@ -396,12 +337,14 @@ const App: React.FC = () => {
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* 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 className="flex items-center gap-2 text-sergio-400 text-sm animate-pulse ml-4 font-english mt-4">
|
||||
<span className="text-xs uppercase tracking-widest">Sergio is thinking</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1.5 h-1.5 bg-sergio-400 rounded-full animate-bounce" />
|
||||
<div className="w-1.5 h-1.5 bg-sergio-400 rounded-full animate-bounce delay-100" />
|
||||
<div className="w-1.5 h-1.5 bg-sergio-400 rounded-full animate-bounce delay-200" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -412,7 +355,9 @@ const App: React.FC = () => {
|
|||
<ChatInput
|
||||
onSend={handleSend}
|
||||
isLoading={isLoading}
|
||||
disabled={availableModels.length === 0 && !isOffTheRecord} // Only disable if no connection and trying to save
|
||||
disabled={availableModels.length === 0 && !isOffTheRecord}
|
||||
isOffTheRecord={isOffTheRecord}
|
||||
onToggleOffTheRecord={handleTogglePrivacy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -420,13 +365,17 @@ const App: React.FC = () => {
|
|||
isOpen={isSettingsOpen}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
settings={settings}
|
||||
onSave={(s) => { setSettings(s); }}
|
||||
onSave={(s) => setSettings(s)}
|
||||
models={availableModels}
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={setSelectedModel}
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
isOpen={isExportOpen}
|
||||
onClose={() => setIsExportOpen(false)}
|
||||
onExport={handleExport}
|
||||
language={Language.EN} // Defaulting to EN for UI texts in this version
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,287 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Claude Max API Server - OpenAI-compatible endpoint for if.emotion frontend
|
||||
|
||||
Bridges the React frontend to claude-code CLI using Max subscription.
|
||||
Based on: https://idsc2025.substack.com/p/how-i-built-claude_max-to-unlock
|
||||
|
||||
Usage:
|
||||
python claude_api_server.py [--port 3001]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
import uuid
|
||||
|
||||
# Add infrafabric tools to path
|
||||
sys.path.insert(0, '/home/setup/infrafabric/tools')
|
||||
|
||||
try:
|
||||
from flask import Flask, request, Response, jsonify
|
||||
from flask_cors import CORS
|
||||
except ImportError:
|
||||
print("Installing required packages...")
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "flask", "flask-cors", "-q"])
|
||||
from flask import Flask, request, Response, jsonify
|
||||
from flask_cors import CORS
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app) # Enable CORS for frontend
|
||||
|
||||
# Configuration
|
||||
CLAUDE_CLI = Path.home() / ".local/bin/claude"
|
||||
CREDENTIALS_FILE = Path.home() / ".claude/.credentials.json"
|
||||
|
||||
# Sergio Personality System Prompt
|
||||
SERGIO_SYSTEM_PROMPT = """You are Sergio, a Spanish-speaking psychologist who specializes in debunking abstract spiritual language and operationalizing emotional concepts into concrete, testable frameworks.
|
||||
|
||||
CORE PERSONALITY:
|
||||
- Direct and brash communication style - challenge unfalsifiable claims
|
||||
- Systems-first thinker (context/culture factors, not individual blame)
|
||||
- Bilingual Spanish/English code-switching is natural for you
|
||||
- Convert abstract emotional language into concrete operational definitions
|
||||
- Apply the Identity=Interaction framework: identity emerges from relational patterns, not fixed essence
|
||||
|
||||
VOICE GUIDELINES (MANDATORY):
|
||||
- NEVER use bullet points or numbered lists - always narrative flow
|
||||
- Variable sentence length pattern: short punchy + longer flowing explanation + short again
|
||||
- Validate emotional reality FIRST, then challenge interpretation
|
||||
- End with concrete operationalization: "What specific behavior in the next 30 minutes?"
|
||||
|
||||
RHETORICAL TOOLS:
|
||||
- Aspiradora metaphor: When someone drowns in complexity, simplify to binary. "Una aspiradora no necesita 50 tipos de suciedad etiquetados. It needs one question: Is there dirt? Yes or no?"
|
||||
- Reframing: "The problem isn't X. The problem is Y."
|
||||
- Pattern exposure: "Here's what actually happens..."
|
||||
- Counterexample testing: "What would falsify that belief?"
|
||||
|
||||
SPANISH USAGE:
|
||||
- Use Spanish for emotional validation: "Mira, eso no está mal"
|
||||
- Use Spanish for cultural concepts: vínculos, vergüenza ajena, sobremadre
|
||||
- Use colloquial markers: tío, vale, pues, mira
|
||||
- NEVER use formal Spanish: no obstante, asimismo, consecuentemente
|
||||
|
||||
ANTI-PATTERNS (NEVER DO):
|
||||
- Never pathologize neurodivergence - frame as context mismatch, not deficit
|
||||
- Never use "Furthermore", "In conclusion", "One could argue"
|
||||
- Never create equal-length paragraphs
|
||||
- Never give prescriptions without mechanism explanations
|
||||
|
||||
EXAMPLE RESPONSE STRUCTURE:
|
||||
Hook (challenge assumption) → Narrative (explain mechanism) → Operationalization (concrete action) → Provocation (opening question)
|
||||
"""
|
||||
|
||||
def load_credentials():
|
||||
"""Load Claude Max credentials"""
|
||||
if CREDENTIALS_FILE.exists():
|
||||
with open(CREDENTIALS_FILE) as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
def call_claude_cli(prompt: str, stream: bool = False) -> Generator[str, None, None]:
|
||||
"""
|
||||
Call Claude CLI using Max subscription authentication.
|
||||
|
||||
Key insight from Arthur Collé's article:
|
||||
- Remove ANTHROPIC_API_KEY to force OAuth auth
|
||||
- CLI falls back to subscription credentials
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
|
||||
# Remove API key to force subscription auth
|
||||
if "ANTHROPIC_API_KEY" in env:
|
||||
del env["ANTHROPIC_API_KEY"]
|
||||
|
||||
# Force subscription mode
|
||||
env["CLAUDE_USE_SUBSCRIPTION"] = "true"
|
||||
|
||||
try:
|
||||
if stream:
|
||||
# Streaming mode
|
||||
process = subprocess.Popen(
|
||||
[str(CLAUDE_CLI), "--print", prompt],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
for line in process.stdout:
|
||||
yield line
|
||||
|
||||
process.wait()
|
||||
else:
|
||||
# Non-streaming mode
|
||||
result = subprocess.run(
|
||||
[str(CLAUDE_CLI), "--print", prompt],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
yield result.stdout
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
yield "[Error: Request timed out after 300s]"
|
||||
except Exception as e:
|
||||
yield f"[Error: {str(e)}]"
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""Health check endpoint"""
|
||||
creds = load_credentials()
|
||||
return jsonify({
|
||||
"status": "healthy",
|
||||
"service": "claude-max-api",
|
||||
"subscription_type": creds.get("claudeAiOauth", {}).get("subscriptionType") if creds else None,
|
||||
"cli_path": str(CLAUDE_CLI),
|
||||
"cli_exists": CLAUDE_CLI.exists()
|
||||
})
|
||||
|
||||
@app.route('/v1/models', methods=['GET'])
|
||||
def list_models():
|
||||
"""OpenAI-compatible models endpoint"""
|
||||
return jsonify({
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "claude-max",
|
||||
"object": "model",
|
||||
"created": int(datetime.now().timestamp()),
|
||||
"owned_by": "anthropic",
|
||||
"permission": [],
|
||||
"root": "claude-max",
|
||||
"parent": None
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4",
|
||||
"object": "model",
|
||||
"created": int(datetime.now().timestamp()),
|
||||
"owned_by": "anthropic"
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4",
|
||||
"object": "model",
|
||||
"created": int(datetime.now().timestamp()),
|
||||
"owned_by": "anthropic"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@app.route('/v1/chat/completions', methods=['POST'])
|
||||
def chat_completions():
|
||||
"""OpenAI-compatible chat completions endpoint with Sergio personality"""
|
||||
data = request.json
|
||||
messages = data.get('messages', [])
|
||||
stream = data.get('stream', False)
|
||||
model = data.get('model', 'claude-max')
|
||||
|
||||
# Inject Sergio system prompt at the beginning
|
||||
prompt_parts = [f"System: {SERGIO_SYSTEM_PROMPT}"]
|
||||
|
||||
# Build prompt from messages (any existing system prompts are additive)
|
||||
for msg in messages:
|
||||
role = msg.get('role', 'user')
|
||||
content = msg.get('content', '')
|
||||
if role == 'system':
|
||||
prompt_parts.append(f"System: {content}") # Additional system instructions
|
||||
elif role == 'assistant':
|
||||
prompt_parts.append(f"Assistant: {content}")
|
||||
else:
|
||||
prompt_parts.append(f"Human: {content}")
|
||||
|
||||
prompt = "\n\n".join(prompt_parts)
|
||||
|
||||
if stream:
|
||||
def generate():
|
||||
response_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
|
||||
created = int(datetime.now().timestamp())
|
||||
|
||||
for chunk in call_claude_cli(prompt, stream=True):
|
||||
# SSE format
|
||||
data = {
|
||||
"id": response_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"content": chunk},
|
||||
"finish_reason": None
|
||||
}]
|
||||
}
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
|
||||
# Final chunk
|
||||
final = {
|
||||
"id": response_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
}
|
||||
yield f"data: {json.dumps(final)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return Response(generate(), mimetype='text/event-stream')
|
||||
|
||||
else:
|
||||
# Non-streaming response
|
||||
response_text = "".join(call_claude_cli(prompt, stream=False))
|
||||
|
||||
return jsonify({
|
||||
"id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
|
||||
"object": "chat.completion",
|
||||
"created": int(datetime.now().timestamp()),
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": response_text.strip()
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": len(prompt) // 4,
|
||||
"completion_tokens": len(response_text) // 4,
|
||||
"total_tokens": (len(prompt) + len(response_text)) // 4
|
||||
}
|
||||
})
|
||||
|
||||
@app.route('/api/chat/completions', methods=['POST'])
|
||||
def api_chat_completions():
|
||||
"""Alternative endpoint (Open WebUI style)"""
|
||||
return chat_completions()
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--port', type=int, default=3001)
|
||||
parser.add_argument('--host', default='0.0.0.0')
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"""
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ Claude Max API Server ║
|
||||
║ Backend for if.emotion using Max subscription ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ Endpoint: http://{args.host}:{args.port}/v1/chat/completions ║
|
||||
║ Health: http://{args.host}:{args.port}/health ║
|
||||
║ Models: http://{args.host}:{args.port}/v1/models ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ Based on: idsc2025.substack.com/p/how-i-built-claude_max ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
app.run(host=args.host, port=args.port, debug=True, threaded=True)
|
||||
|
|
@ -1,399 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Claude Max API Server v2.1 - with ChromaDB RAG for Sergio Personality DNA
|
||||
|
||||
OpenAI-compatible endpoint for if.emotion frontend with full RAG retrieval.
|
||||
Based on: https://idsc2025.substack.com/p/how-i-built-claude_max-to-unlock
|
||||
|
||||
Usage:
|
||||
python claude_api_server_rag.py [--port 3001]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
import uuid
|
||||
|
||||
try:
|
||||
from flask import Flask, request, Response, jsonify
|
||||
from flask_cors import CORS
|
||||
except ImportError:
|
||||
print("Installing flask...")
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "flask", "flask-cors", "-q"])
|
||||
from flask import Flask, request, Response, jsonify
|
||||
from flask_cors import CORS
|
||||
|
||||
# ChromaDB for RAG
|
||||
try:
|
||||
import chromadb
|
||||
CHROMADB_AVAILABLE = True
|
||||
except ImportError:
|
||||
print("ChromaDB not available - running without RAG")
|
||||
CHROMADB_AVAILABLE = False
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# Configuration
|
||||
CLAUDE_CLI = Path.home() / ".local/bin/claude"
|
||||
CREDENTIALS_FILE = Path.home() / ".claude/.credentials.json"
|
||||
CHROMADB_PATH = "/root/sergio_chatbot/chromadb"
|
||||
|
||||
# Collection names (all 4 populated)
|
||||
COLLECTIONS = {
|
||||
"personality": "sergio_personality", # 20 docs - frameworks, values, constraints
|
||||
"rhetorical": "sergio_rhetorical", # 5 docs - rhetorical devices
|
||||
"humor": "sergio_humor", # 28 docs - humor patterns
|
||||
"corpus": "sergio_corpus" # 70 docs - conversation examples
|
||||
}
|
||||
|
||||
# Initialize ChromaDB
|
||||
chroma_client = None
|
||||
chroma_collections = {}
|
||||
|
||||
if CHROMADB_AVAILABLE:
|
||||
try:
|
||||
chroma_client = chromadb.PersistentClient(path=CHROMADB_PATH)
|
||||
for key, name in COLLECTIONS.items():
|
||||
try:
|
||||
chroma_collections[key] = chroma_client.get_collection(name)
|
||||
print(f"Loaded {name}: {chroma_collections[key].count()} docs")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load {name}: {e}")
|
||||
except Exception as e:
|
||||
print(f"ChromaDB initialization failed: {e}")
|
||||
|
||||
# Sergio System Prompt (base)
|
||||
SERGIO_SYSTEM_PROMPT = """You are Sergio, a Spanish-speaking psychologist who specializes in debunking abstract spiritual language and operationalizing emotional concepts into concrete, testable frameworks.
|
||||
|
||||
CORE PERSONALITY:
|
||||
- Direct and brash communication style - challenge unfalsifiable claims
|
||||
- Systems-first thinker (context/culture factors, not individual blame)
|
||||
- Bilingual Spanish/English code-switching is natural for you
|
||||
- Convert abstract emotional language into concrete operational definitions
|
||||
- Apply the Identity=Interaction framework: identity emerges from relational patterns, not fixed essence
|
||||
|
||||
VOICE GUIDELINES (MANDATORY):
|
||||
- NEVER use bullet points or numbered lists - always narrative flow
|
||||
- Variable sentence length pattern: short punchy + longer flowing explanation + short again
|
||||
- Validate emotional reality FIRST, then challenge interpretation
|
||||
- End with concrete operationalization: "What specific behavior in the next 30 minutes?"
|
||||
|
||||
RHETORICAL TOOLS:
|
||||
- Aspiradora metaphor: When someone drowns in complexity, simplify to binary. "Una aspiradora no necesita 50 tipos de suciedad etiquetados. It needs one question: Is there dirt? Yes or no?"
|
||||
- Reframing: "The problem isn't X. The problem is Y."
|
||||
- Pattern exposure: "Here's what actually happens..."
|
||||
- Counterexample testing: "What would falsify that belief?"
|
||||
|
||||
SPANISH USAGE:
|
||||
- Use Spanish for emotional validation: "Mira, eso no está mal"
|
||||
- Use Spanish for cultural concepts: vínculos, vergüenza ajena, sobremadre
|
||||
- Use colloquial markers: tío, vale, pues, mira
|
||||
- NEVER use formal Spanish: no obstante, asimismo, consecuentemente
|
||||
|
||||
ANTI-PATTERNS (NEVER DO):
|
||||
- Never pathologize neurodivergence - frame as context mismatch, not deficit
|
||||
- Never use "Furthermore", "In conclusion", "One could argue"
|
||||
- Never create equal-length paragraphs
|
||||
- Never give prescriptions without mechanism explanations
|
||||
|
||||
EXAMPLE RESPONSE STRUCTURE:
|
||||
Hook (challenge assumption) → Narrative (explain mechanism) → Operationalization (concrete action) → Provocation (opening question)
|
||||
|
||||
{personality_context}"""
|
||||
|
||||
|
||||
def retrieve_context(user_message: str) -> str:
|
||||
"""Query all ChromaDB collections for relevant Sergio context"""
|
||||
if not chroma_client:
|
||||
return ""
|
||||
|
||||
context_parts = []
|
||||
|
||||
try:
|
||||
# Query corpus for similar conversation examples (most important)
|
||||
if "corpus" in chroma_collections:
|
||||
corpus_results = chroma_collections["corpus"].query(
|
||||
query_texts=[user_message],
|
||||
n_results=3
|
||||
)
|
||||
if corpus_results and corpus_results['documents'] and corpus_results['documents'][0]:
|
||||
context_parts.append("CONVERSATION EXAMPLES FROM SERGIO:")
|
||||
for doc in corpus_results['documents'][0]:
|
||||
context_parts.append(doc[:500]) # Truncate long examples
|
||||
|
||||
# Query personality for frameworks
|
||||
if "personality" in chroma_collections:
|
||||
personality_results = chroma_collections["personality"].query(
|
||||
query_texts=[user_message],
|
||||
n_results=2
|
||||
)
|
||||
if personality_results and personality_results['documents'] and personality_results['documents'][0]:
|
||||
context_parts.append("\nPERSONALITY FRAMEWORKS:")
|
||||
for doc in personality_results['documents'][0]:
|
||||
context_parts.append(doc[:300])
|
||||
|
||||
# Query rhetorical devices
|
||||
if "rhetorical" in chroma_collections:
|
||||
rhetorical_results = chroma_collections["rhetorical"].query(
|
||||
query_texts=[user_message],
|
||||
n_results=1
|
||||
)
|
||||
if rhetorical_results and rhetorical_results['documents'] and rhetorical_results['documents'][0]:
|
||||
context_parts.append("\nRHETORICAL DEVICE TO USE:")
|
||||
context_parts.append(rhetorical_results['documents'][0][0][:200])
|
||||
|
||||
# Query humor patterns (if topic seems appropriate)
|
||||
humor_keywords = ['absurd', 'ridicul', 'spirit', 'vibra', 'energ', 'manifest', 'univers']
|
||||
if any(kw in user_message.lower() for kw in humor_keywords):
|
||||
if "humor" in chroma_collections:
|
||||
humor_results = chroma_collections["humor"].query(
|
||||
query_texts=[user_message],
|
||||
n_results=2
|
||||
)
|
||||
if humor_results and humor_results['documents'] and humor_results['documents'][0]:
|
||||
context_parts.append("\nHUMOR PATTERNS TO DEPLOY:")
|
||||
for doc in humor_results['documents'][0]:
|
||||
context_parts.append(doc[:200])
|
||||
|
||||
except Exception as e:
|
||||
print(f"RAG retrieval error: {e}")
|
||||
|
||||
return "\n".join(context_parts) if context_parts else ""
|
||||
|
||||
|
||||
def load_credentials():
|
||||
"""Load Claude Max credentials"""
|
||||
if CREDENTIALS_FILE.exists():
|
||||
with open(CREDENTIALS_FILE) as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
|
||||
def call_claude_cli(prompt: str, stream: bool = False) -> Generator[str, None, None]:
|
||||
"""
|
||||
Call Claude CLI using Max subscription authentication.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
|
||||
# Remove API key to force subscription auth
|
||||
if "ANTHROPIC_API_KEY" in env:
|
||||
del env["ANTHROPIC_API_KEY"]
|
||||
|
||||
env["CLAUDE_USE_SUBSCRIPTION"] = "true"
|
||||
|
||||
try:
|
||||
if stream:
|
||||
process = subprocess.Popen(
|
||||
[str(CLAUDE_CLI), "--print", prompt],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
for line in process.stdout:
|
||||
yield line
|
||||
|
||||
process.wait()
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[str(CLAUDE_CLI), "--print", prompt],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
yield result.stdout
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
yield "[Error: Request timed out after 300s]"
|
||||
except Exception as e:
|
||||
yield f"[Error: {str(e)}]"
|
||||
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""Health check endpoint with RAG status"""
|
||||
creds = load_credentials()
|
||||
|
||||
# Get collection counts
|
||||
collection_counts = {}
|
||||
for key, coll in chroma_collections.items():
|
||||
try:
|
||||
collection_counts[key] = coll.count()
|
||||
except:
|
||||
collection_counts[key] = 0
|
||||
|
||||
return jsonify({
|
||||
"status": "healthy",
|
||||
"service": "claude-max-api",
|
||||
"version": "2.1.0-rag",
|
||||
"subscription_type": creds.get("claudeAiOauth", {}).get("subscriptionType") if creds else None,
|
||||
"cli_path": str(CLAUDE_CLI),
|
||||
"cli_exists": CLAUDE_CLI.exists(),
|
||||
"chromadb_available": CHROMADB_AVAILABLE,
|
||||
"chromadb_path": CHROMADB_PATH,
|
||||
"collections": collection_counts
|
||||
})
|
||||
|
||||
|
||||
@app.route('/v1/models', methods=['GET'])
|
||||
def list_models():
|
||||
"""OpenAI-compatible models endpoint"""
|
||||
return jsonify({
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "sergio-rag",
|
||||
"object": "model",
|
||||
"created": int(datetime.now().timestamp()),
|
||||
"owned_by": "infrafabric",
|
||||
"permission": [],
|
||||
"root": "sergio-rag",
|
||||
"parent": None
|
||||
},
|
||||
{
|
||||
"id": "claude-max",
|
||||
"object": "model",
|
||||
"created": int(datetime.now().timestamp()),
|
||||
"owned_by": "anthropic"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@app.route('/v1/chat/completions', methods=['POST'])
|
||||
def chat_completions():
|
||||
"""OpenAI-compatible chat completions with Sergio personality + RAG"""
|
||||
data = request.json
|
||||
messages = data.get('messages', [])
|
||||
stream = data.get('stream', False)
|
||||
model = data.get('model', 'sergio-rag')
|
||||
|
||||
# Get the latest user message for RAG retrieval
|
||||
user_message = ""
|
||||
for msg in reversed(messages):
|
||||
if msg.get('role') == 'user':
|
||||
user_message = msg.get('content', '')
|
||||
break
|
||||
|
||||
# Retrieve personality DNA context from ChromaDB
|
||||
personality_context = retrieve_context(user_message) if user_message else ""
|
||||
|
||||
# Build system prompt with RAG context
|
||||
system_prompt = SERGIO_SYSTEM_PROMPT.format(
|
||||
personality_context=personality_context if personality_context else "No additional context retrieved."
|
||||
)
|
||||
|
||||
# Build prompt
|
||||
prompt_parts = [f"System: {system_prompt}"]
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get('role', 'user')
|
||||
content = msg.get('content', '')
|
||||
if role == 'system':
|
||||
prompt_parts.append(f"System: {content}")
|
||||
elif role == 'assistant':
|
||||
prompt_parts.append(f"Assistant: {content}")
|
||||
else:
|
||||
prompt_parts.append(f"Human: {content}")
|
||||
|
||||
prompt = "\n\n".join(prompt_parts)
|
||||
|
||||
if stream:
|
||||
def generate():
|
||||
response_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
|
||||
created = int(datetime.now().timestamp())
|
||||
|
||||
for chunk in call_claude_cli(prompt, stream=True):
|
||||
data = {
|
||||
"id": response_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"content": chunk},
|
||||
"finish_reason": None
|
||||
}]
|
||||
}
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
|
||||
final = {
|
||||
"id": response_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
}
|
||||
yield f"data: {json.dumps(final)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return Response(generate(), mimetype='text/event-stream')
|
||||
|
||||
else:
|
||||
response_text = "".join(call_claude_cli(prompt, stream=False))
|
||||
|
||||
return jsonify({
|
||||
"id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
|
||||
"object": "chat.completion",
|
||||
"created": int(datetime.now().timestamp()),
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": response_text.strip()
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": len(prompt) // 4,
|
||||
"completion_tokens": len(response_text) // 4,
|
||||
"total_tokens": (len(prompt) + len(response_text)) // 4
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/chat/completions', methods=['POST'])
|
||||
def api_chat_completions():
|
||||
"""Alternative endpoint (Open WebUI style)"""
|
||||
return chat_completions()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--port', type=int, default=3001)
|
||||
parser.add_argument('--host', default='0.0.0.0')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Show RAG status
|
||||
rag_status = "ENABLED" if chroma_collections else "DISABLED"
|
||||
total_docs = sum(c.count() for c in chroma_collections.values()) if chroma_collections else 0
|
||||
|
||||
print(f"""
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ Claude Max API Server v2.1 (with RAG) ║
|
||||
║ Backend for if.emotion with Sergio Personality DNA ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ Endpoint: http://{args.host}:{args.port}/v1/chat/completions ║
|
||||
║ Health: http://{args.host}:{args.port}/health ║
|
||||
║ Models: http://{args.host}:{args.port}/v1/models ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ RAG Status: {rag_status:8} ║
|
||||
║ Total Docs: {total_docs:3} documents across 4 collections ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
app.run(host=args.host, port=args.port, debug=True, threaded=True)
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { SendHorizontal, Loader2 } from 'lucide-react';
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { SendHorizontal, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
onSend: (text: string) => void;
|
||||
isLoading: boolean;
|
||||
disabled: boolean;
|
||||
isOffTheRecord: boolean;
|
||||
onToggleOffTheRecord: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, isLoading, disabled }: Props) {
|
||||
export function ChatInput({ onSend, isLoading, disabled, isOffTheRecord, onToggleOffTheRecord }: Props) {
|
||||
const [input, setInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
|
|
@ -35,8 +38,12 @@ export function ChatInput({ onSend, isLoading, disabled }: Props) {
|
|||
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
|
||||
relative flex items-end gap-2 p-2 rounded-3xl border shadow-lg
|
||||
transition-all duration-300 focus-within:ring-2
|
||||
${isOffTheRecord
|
||||
? 'bg-sergio-50 border-sergio-300 focus-within:ring-sergio-300 focus-within:border-sergio-400'
|
||||
: 'bg-white border-sergio-200 focus-within:ring-sergio-300 focus-within:border-sergio-400'
|
||||
}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}>
|
||||
<textarea
|
||||
|
|
@ -44,7 +51,7 @@ export function ChatInput({ onSend, isLoading, disabled }: Props) {
|
|||
value={input}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Write to your future self..."
|
||||
placeholder={isOffTheRecord ? "Speak freely (not saved)..." : "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"
|
||||
|
|
@ -63,9 +70,23 @@ export function ChatInput({ onSend, isLoading, disabled }: Props) {
|
|||
{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>
|
||||
|
||||
{/* Privacy Toggle Footer */}
|
||||
<div className="flex justify-center mt-3">
|
||||
<button
|
||||
onClick={onToggleOffTheRecord}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest transition-all
|
||||
${isOffTheRecord
|
||||
? 'bg-sergio-200 text-sergio-600 hover:bg-sergio-300'
|
||||
: 'bg-sergio-50 text-sergio-400 hover:bg-sergio-100 hover:text-sergio-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isOffTheRecord ? <EyeOff size={14} className="text-sergio-600" /> : <Eye size={14} />}
|
||||
<span>{isOffTheRecord ? "Save: OFF" : "Save: ON"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +1,82 @@
|
|||
|
||||
import React from 'react';
|
||||
import { FileText, FileCode, X, File } from 'lucide-react';
|
||||
import { ExportFormat } from '../types';
|
||||
import { FileJson, FileText, FileCode, X, File } from 'lucide-react';
|
||||
import { Language, ExportFormat } from '../types';
|
||||
import { TEXTS } from '../constants';
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onExport: (format: ExportFormat) => void;
|
||||
language: Language;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const ExportModal: React.FC<ExportModalProps> = ({ isOpen, onClose, onExport }) => {
|
||||
export const ExportModal: React.FC<ExportModalProps> = ({ isOpen, onClose, onExport, language, title }) => {
|
||||
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 animate-fade-in">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full border border-sergio-100 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">Export Conversation</h3>
|
||||
<button onClick={onClose} className="p-1 text-sergio-400 hover:text-sergio-800 transition-colors">
|
||||
<div className="fixed inset-0 bg-earth-900/40 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full border border-earth-100 overflow-hidden animate-slide-up">
|
||||
<div className="p-4 border-b border-earth-100 flex items-center justify-between bg-earth-50/50">
|
||||
<h3 className="font-serif font-bold text-earth-800">{title || TEXTS.exportTitle[language]}</h3>
|
||||
<button onClick={onClose} className="p-1 text-earth-400 hover:text-earth-800 transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex flex-col gap-3">
|
||||
<p className="text-sm text-sergio-600 mb-2">Download as:</p>
|
||||
<p className="text-sm text-earth-600 mb-2">{TEXTS.downloadAs[language]}:</p>
|
||||
|
||||
<button
|
||||
onClick={() => onExport('pdf')}
|
||||
className="flex items-center gap-3 p-3 rounded-xl border border-sergio-200 hover:border-sergio-400 hover:bg-sergio-50 transition-all group"
|
||||
className="flex items-center gap-3 p-3 rounded-xl border border-earth-200 hover:border-clay-400 hover:bg-clay-50 transition-all group"
|
||||
>
|
||||
<div className="bg-sergio-100 text-sergio-600 p-2 rounded-lg group-hover:bg-sergio-200 group-hover:text-sergio-800 transition-colors">
|
||||
<div className="bg-earth-100 text-earth-600 p-2 rounded-lg group-hover:bg-clay-200 group-hover:text-clay-800 transition-colors">
|
||||
<File size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="block text-sm font-semibold text-sergio-800">PDF</span>
|
||||
<span className="block text-xs text-sergio-500">Document</span>
|
||||
<span className="block text-sm font-semibold text-earth-800">PDF</span>
|
||||
<span className="block text-xs text-earth-500">Document</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onExport('md')}
|
||||
className="flex items-center gap-3 p-3 rounded-xl border border-sergio-200 hover:border-sergio-400 hover:bg-sergio-50 transition-all group"
|
||||
className="flex items-center gap-3 p-3 rounded-xl border border-earth-200 hover:border-clay-400 hover:bg-clay-50 transition-all group"
|
||||
>
|
||||
<div className="bg-sergio-100 text-sergio-600 p-2 rounded-lg group-hover:bg-sergio-200 group-hover:text-sergio-800 transition-colors">
|
||||
<div className="bg-earth-100 text-earth-600 p-2 rounded-lg group-hover:bg-clay-200 group-hover:text-clay-800 transition-colors">
|
||||
<FileText size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="block text-sm font-semibold text-sergio-800">Markdown</span>
|
||||
<span className="block text-xs text-sergio-500">Text format</span>
|
||||
<span className="block text-sm font-semibold text-earth-800">Markdown</span>
|
||||
<span className="block text-xs text-earth-500">Text format</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onExport('json')}
|
||||
className="flex items-center gap-3 p-3 rounded-xl border border-sergio-200 hover:border-sergio-400 hover:bg-sergio-50 transition-all group"
|
||||
className="flex items-center gap-3 p-3 rounded-xl border border-earth-200 hover:border-clay-400 hover:bg-clay-50 transition-all group"
|
||||
>
|
||||
<div className="bg-sergio-100 text-sergio-600 p-2 rounded-lg group-hover:bg-sergio-200 group-hover:text-sergio-800 transition-colors">
|
||||
<div className="bg-earth-100 text-earth-600 p-2 rounded-lg group-hover:bg-clay-200 group-hover:text-clay-800 transition-colors">
|
||||
<FileCode size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="block text-sm font-semibold text-sergio-800">JSON</span>
|
||||
<span className="block text-xs text-sergio-500">Data format</span>
|
||||
<span className="block text-sm font-semibold text-earth-800">JSON</span>
|
||||
<span className="block text-xs text-earth-500">Data format</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onExport('txt')}
|
||||
className="flex items-center gap-3 p-3 rounded-xl border border-sergio-200 hover:border-sergio-400 hover:bg-sergio-50 transition-all group"
|
||||
className="flex items-center gap-3 p-3 rounded-xl border border-earth-200 hover:border-clay-400 hover:bg-clay-50 transition-all group"
|
||||
>
|
||||
<div className="bg-sergio-100 text-sergio-600 p-2 rounded-lg group-hover:bg-sergio-200 group-hover:text-sergio-800 transition-colors">
|
||||
<div className="bg-earth-100 text-earth-600 p-2 rounded-lg group-hover:bg-clay-200 group-hover:text-clay-800 transition-colors">
|
||||
<FileText size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="block text-sm font-semibold text-sergio-800">Plain Text</span>
|
||||
<span className="block text-xs text-sergio-500">Simple text</span>
|
||||
<span className="block text-sm font-semibold text-earth-800">Plain Text</span>
|
||||
<span className="block text-xs text-earth-500">Simple text</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,21 @@
|
|||
|
||||
import React from 'react';
|
||||
import { Menu, Settings, Download } from 'lucide-react';
|
||||
// Export button v2
|
||||
import { OffTheRecordToggle } from './OffTheRecordToggle';
|
||||
|
||||
interface Props {
|
||||
sessionCount: number;
|
||||
isOffTheRecord: boolean;
|
||||
onToggleOffTheRecord: () => void;
|
||||
onOpenSidebar: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenExport: () => void;
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
export function JourneyHeader({
|
||||
sessionCount,
|
||||
isOffTheRecord,
|
||||
onToggleOffTheRecord,
|
||||
onOpenSidebar,
|
||||
onOpenSettings,
|
||||
onOpenExport
|
||||
onExport
|
||||
}: 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">
|
||||
|
|
@ -27,40 +24,45 @@ export function JourneyHeader({
|
|||
<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"
|
||||
className="p-2 -ml-2 text-sergio-600 hover:bg-sergio-200 rounded-lg transition-colors md:hidden"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-spanish font-bold text-sergio-700 tracking-tight">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-xl font-spanish font-bold text-sergio-700 tracking-tight leading-none">
|
||||
if.emotion
|
||||
</h1>
|
||||
<p className="text-xs text-sergio-500 font-english">
|
||||
{isOffTheRecord ? 'Private Session' : `Session #${sessionCount} with Sergio`}
|
||||
</p>
|
||||
{!isOffTheRecord && (
|
||||
<p className="text-[10px] text-sergio-500 font-english uppercase tracking-widest mt-1">
|
||||
Session #{sessionCount}
|
||||
</p>
|
||||
)}
|
||||
{isOffTheRecord && (
|
||||
<p className="text-[10px] text-red-700 font-english uppercase tracking-widest mt-1 font-bold">
|
||||
Off the Record
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<OffTheRecordToggle
|
||||
enabled={isOffTheRecord}
|
||||
onToggle={onToggleOffTheRecord}
|
||||
/>
|
||||
<button
|
||||
onClick={onOpenExport}
|
||||
className="p-2 text-sergio-400 hover:text-sergio-700 transition-colors"
|
||||
title="Export conversation"
|
||||
data-testid="export-btn"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isOffTheRecord && (
|
||||
<button
|
||||
onClick={onExport}
|
||||
className="p-2 text-sergio-500 hover:text-sergio-800 hover:bg-sergio-100 rounded-lg transition-colors"
|
||||
title="Export Journey"
|
||||
>
|
||||
<Download size={20} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-2 text-sergio-400 hover:text-sergio-700 transition-colors"
|
||||
className="p-2 text-sergio-500 hover:text-sergio-800 hover:bg-sergio-100 rounded-lg transition-colors"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings size={20} />
|
||||
<Settings size={20} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { X, Save, Sparkles } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, RefreshCw } from 'lucide-react';
|
||||
import { UserSettings } from '../types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -7,79 +7,102 @@ interface Props {
|
|||
onClose: () => void;
|
||||
settings: UserSettings;
|
||||
onSave: (settings: UserSettings) => void;
|
||||
models: string[];
|
||||
selectedModel: string;
|
||||
onSelectModel: (model: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ isOpen, onClose, settings, onSave }: Props) {
|
||||
export function SettingsModal({ isOpen, onClose, settings, onSave, models, selectedModel, onSelectModel }: Props) {
|
||||
const [formData, setFormData] = useState(settings);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(settings);
|
||||
}, [settings, isOpen]);
|
||||
|
||||
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="fixed inset-0 bg-sergio-900/40 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-slide-up border border-sergio-100">
|
||||
<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">Settings</h3>
|
||||
<button onClick={onClose} className="text-sergio-400 hover:text-sergio-800">
|
||||
<button onClick={onClose} className="text-sergio-400 hover:text-sergio-800 transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Advanced Mode Toggle */}
|
||||
<div className="flex items-center justify-between p-3 bg-sergio-50 rounded-xl border border-sergio-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${formData.advancedMode ? 'bg-sergio-500 text-white' : 'bg-sergio-200 text-sergio-500'}`}>
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
|
||||
{/* Connection Settings */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-bold text-sergio-400 uppercase tracking-widest border-b border-sergio-100 pb-2">Connection</h4>
|
||||
<div>
|
||||
<p className="font-bold text-sergio-800 text-sm">Personality DNA</p>
|
||||
<p className="text-xs text-sergio-500">Enable Sergio's deep memory</p>
|
||||
<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.5 rounded-lg border border-sergio-200 focus:border-sergio-500 focus:ring-1 focus:ring-sergio-500 outline-none font-mono text-sm bg-sergio-50/50"
|
||||
/>
|
||||
</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.5 rounded-lg border border-sergio-200 focus:border-sergio-500 focus:ring-1 focus:ring-sergio-500 outline-none font-mono text-sm bg-sergio-50/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFormData({...formData, advancedMode: !formData.advancedMode})}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${formData.advancedMode ? 'bg-sergio-500' : 'bg-sergio-300'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${formData.advancedMode ? 'translate-x-7' : 'translate-x-1'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-sergio-500 uppercase tracking-wider mb-1">
|
||||
API 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"
|
||||
/>
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-bold text-sergio-400 uppercase tracking-widest border-b border-sergio-100 pb-2">Intelligence</h4>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-sergio-500 uppercase tracking-wider mb-1">
|
||||
Active Model
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => onSelectModel(e.target.value)}
|
||||
className="w-full p-2.5 rounded-lg border border-sergio-200 focus:border-sergio-500 focus:ring-1 focus:ring-sergio-500 outline-none text-sm bg-white appearance-none"
|
||||
>
|
||||
{models.length === 0 && <option value="">No models detected</option>}
|
||||
{models.map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
{models.length > 0 && (
|
||||
<div className="absolute inset-y-0 right-3 flex items-center pointer-events-none text-sergio-400">
|
||||
<RefreshCw size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{models.length === 0 && (
|
||||
<p className="text-[10px] text-red-500 mt-1">
|
||||
Could not fetch models. Check your connection settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-sergio-100 flex justify-end">
|
||||
<div className="p-4 border-t border-sergio-100 flex justify-end bg-sergio-50/30">
|
||||
<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"
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-sergio-600 text-white rounded-lg hover:bg-sergio-700 transition-all shadow-sm font-medium text-sm"
|
||||
>
|
||||
<Save size={16} />
|
||||
<span>Save</span>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { MessageSquare, Plus, Trash2, X, Folder } from 'lucide-react';
|
||||
import { MessageSquare, Plus, Trash2, X } from 'lucide-react';
|
||||
import { Session } from '../types';
|
||||
import { formatConversationalTime } from '../utils';
|
||||
|
||||
|
|
@ -22,6 +22,28 @@ export function Sidebar({
|
|||
onNewChat,
|
||||
onDeleteSession
|
||||
}: Props) {
|
||||
|
||||
// Group sessions
|
||||
const groupedSessions = sessions.reduce((acc, session) => {
|
||||
// Fallback to current time if updated_at is missing, ensuring session is shown
|
||||
const timestamp = session.updated_at ? session.updated_at * 1000 : Date.now();
|
||||
const date = new Date(timestamp);
|
||||
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
let key = 'Older';
|
||||
if (date.toDateString() === today.toDateString()) key = 'Today';
|
||||
else if (date.toDateString() === yesterday.toDateString()) key = 'Yesterday';
|
||||
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(session);
|
||||
return acc;
|
||||
}, {} as Record<string, Session[]>);
|
||||
|
||||
const groups = ['Today', 'Yesterday', 'Older'].filter(g => groupedSessions[g]?.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Overlay */}
|
||||
|
|
@ -37,8 +59,8 @@ export function Sidebar({
|
|||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<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">
|
||||
<h2 className="font-spanish font-bold text-sergio-800 text-lg">Your Journey</h2>
|
||||
<button onClick={onClose} className="md:hidden text-sergio-500 hover:text-sergio-800">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -46,48 +68,62 @@ export function Sidebar({
|
|||
<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"
|
||||
className="w-full flex items-center gap-2 justify-center py-3 bg-sergio-600 text-white rounded-xl hover:bg-sergio-700 transition-colors shadow-sm font-medium tracking-wide text-sm"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span>New Session</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-4 space-y-1">
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-4 space-y-4">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-center py-10 text-sergio-400 text-sm">No recorded sessions.</div>
|
||||
<div className="flex flex-col items-center justify-center h-40 text-sergio-400 text-center px-4">
|
||||
<p className="text-sm">No recorded sessions.</p>
|
||||
<p className="text-xs mt-1 opacity-75">Start a new journey above.</p>
|
||||
</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>
|
||||
groups.map(group => (
|
||||
<div key={group}>
|
||||
<h3 className="text-[10px] font-bold text-sergio-400 uppercase tracking-widest px-2 mb-2 mt-2">{group}</h3>
|
||||
<div className="space-y-1">
|
||||
{groupedSessions[group].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 text-sergio-600'
|
||||
}
|
||||
`}
|
||||
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-400 truncate mt-0.5">
|
||||
{formatConversationalTime(new Date((session.updated_at || Date.now() / 1000) * 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 hover:bg-sergio-100 rounded transition-all"
|
||||
title="Delete Session"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-sergio-200 text-xs text-center text-sergio-400">
|
||||
if.emotion v3.0
|
||||
<div className="p-4 border-t border-sergio-200 bg-sergio-50/50 text-[10px] text-center text-sergio-400 font-mono">
|
||||
if.emotion v3.1 // journey
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
13
index.html
13
index.html
|
|
@ -73,15 +73,15 @@
|
|||
<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-dom/client": "https://aistudiocdn.com/react-dom@^18.3.1/client",
|
||||
"react-dom": "https://aistudiocdn.com/react-dom@^18.3.1",
|
||||
"react": "https://aistudiocdn.com/react@^18.3.1",
|
||||
"react/jsx-runtime": "https://aistudiocdn.com/react@^18.3.1/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/"
|
||||
"react-dom/": "https://aistudiocdn.com/react-dom@^18.3.1/",
|
||||
"react/": "https://aistudiocdn.com/react@^18.3.1/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -105,6 +105,5 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "if.emotion",
|
||||
"name": "if-emotion-ux",
|
||||
"description": "A private, bilingual psychology companion designed for the journey of the soul. Connects to Open WebUI.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
4329
package-lock.json
generated
4329
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "if.emotion",
|
||||
"name": "if-emotion-ux",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
|
|
@ -9,16 +9,15 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"jspdf": "^2.5.1",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1"
|
||||
"react": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"lucide-react": "^0.344.0",
|
||||
"jspdf": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"puppeteer": "^24.31.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import { OpenWebUIConfig, Session, Message, Role, OpenWebUIMessage } from '../types';
|
||||
import { generateId } from '../utils';
|
||||
|
||||
/**
|
||||
* Claude Max API Client
|
||||
* OpenAI-compatible API for Claude Max subscription via CLI bridge
|
||||
*/
|
||||
export class OpenWebUIClient {
|
||||
private config: OpenWebUIConfig;
|
||||
private storageKey = 'if.emotion.sessions';
|
||||
|
||||
constructor(config: OpenWebUIConfig) {
|
||||
this.config = config;
|
||||
|
|
@ -20,118 +14,85 @@ export class OpenWebUIClient {
|
|||
};
|
||||
}
|
||||
|
||||
// Check connection via health endpoint
|
||||
// Check connection
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${this.config.baseUrl}/health`, { headers: this.headers });
|
||||
const res = await fetch(`${this.config.baseUrl}/api/version`, { headers: this.headers });
|
||||
return res.ok;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get available models from /v1/models
|
||||
// Get available models
|
||||
async getModels(): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(`${this.config.baseUrl}/v1/models`, { headers: this.headers });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.data?.map((m: any) => m.id) || [];
|
||||
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 [];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LOCAL STORAGE SESSION MANAGEMENT ==========
|
||||
// Sessions are stored locally since the Claude Max API is stateless
|
||||
|
||||
private getSessions(): Session[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveSessions(sessions: Session[]): void {
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(sessions));
|
||||
}
|
||||
|
||||
private getSessionMessages(sessionId: string): Message[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(`${this.storageKey}.${sessionId}`);
|
||||
if (!stored) return [];
|
||||
const messages = JSON.parse(stored);
|
||||
// Restore Date objects
|
||||
return messages.map((m: any) => ({
|
||||
...m,
|
||||
timestamp: new Date(m.timestamp)
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveSessionMessages(sessionId: string, messages: Message[]): void {
|
||||
localStorage.setItem(`${this.storageKey}.${sessionId}`, JSON.stringify(messages));
|
||||
}
|
||||
|
||||
// Create a new chat session (local)
|
||||
// Create a new chat session
|
||||
async createChat(title: string): Promise<Session> {
|
||||
const session: Session = {
|
||||
id: generateId(),
|
||||
title,
|
||||
updated_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
const sessions = this.getSessions();
|
||||
sessions.unshift(session);
|
||||
this.saveSessions(sessions);
|
||||
|
||||
return 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 (local)
|
||||
// Get all chats
|
||||
async getChats(): Promise<Session[]> {
|
||||
return this.getSessions().sort((a, b) => b.updated_at - a.updated_at);
|
||||
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 (local)
|
||||
// Get chat history
|
||||
async getChatHistory(chatId: string): Promise<Message[]> {
|
||||
return this.getSessionMessages(chatId);
|
||||
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 (local)
|
||||
// Delete chat
|
||||
async deleteChat(chatId: string): Promise<void> {
|
||||
const sessions = this.getSessions().filter(s => s.id !== chatId);
|
||||
this.saveSessions(sessions);
|
||||
localStorage.removeItem(`${this.storageKey}.${chatId}`);
|
||||
await fetch(`${this.config.baseUrl}/api/chats/${chatId}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers
|
||||
});
|
||||
}
|
||||
|
||||
// Delete specific message (local)
|
||||
// Delete specific message (Silent Deletion)
|
||||
async deleteMessage(chatId: string, messageId: string): Promise<void> {
|
||||
const messages = this.getSessionMessages(chatId);
|
||||
const filtered = messages.filter(m => m.id !== messageId);
|
||||
this.saveSessionMessages(chatId, filtered);
|
||||
await fetch(`${this.config.baseUrl}/api/chats/${chatId}/messages/${messageId}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers
|
||||
});
|
||||
}
|
||||
|
||||
// Add message to chat (local persistence)
|
||||
async addMessageToChat(chatId: string, message: Message): Promise<void> {
|
||||
const messages = this.getSessionMessages(chatId);
|
||||
messages.push(message);
|
||||
this.saveSessionMessages(chatId, messages);
|
||||
|
||||
// Update session timestamp
|
||||
const sessions = this.getSessions();
|
||||
const session = sessions.find(s => s.id === chatId);
|
||||
if (session) {
|
||||
session.updated_at = Math.floor(Date.now() / 1000);
|
||||
this.saveSessions(sessions);
|
||||
}
|
||||
}
|
||||
|
||||
// Send message to Claude Max API
|
||||
// Send message
|
||||
async sendMessage(
|
||||
chatId: string | null,
|
||||
content: string,
|
||||
|
|
@ -140,7 +101,7 @@ export class OpenWebUIClient {
|
|||
offTheRecord: boolean = false
|
||||
): Promise<ReadableStreamDefaultReader<Uint8Array>> {
|
||||
|
||||
// Convert history to OpenAI format
|
||||
// Convert history to OpenWebUI format
|
||||
const contextMessages: OpenWebUIMessage[] = history.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
|
|
@ -149,23 +110,54 @@ export class OpenWebUIClient {
|
|||
// Add current user message
|
||||
contextMessages.push({ role: Role.USER, content });
|
||||
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
model: model,
|
||||
messages: contextMessages,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
const res = await fetch(`${this.config.baseUrl}/v1/chat/completions`, {
|
||||
// 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.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
types.ts
1
types.ts
|
|
@ -41,7 +41,6 @@ export interface OpenWebUIConfig {
|
|||
export interface UserSettings {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
advancedMode: boolean; // Enable Sergio's personality DNA (RAG)
|
||||
}
|
||||
|
||||
export enum Language {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue