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:
Danny Stocker 2025-11-30 16:50:37 +01:00
parent 39f38d19ad
commit 9df51ea38c
14 changed files with 438 additions and 5429 deletions

205
App.tsx
View file

@ -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
};
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;
return saved ? JSON.parse(saved) : {
baseUrl: 'http://85.239.243.227:8080',
apiKey: 'sk-5339243764b840e69188d672802082f4'
};
});
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;
}
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();
}
doc.text(lines, margin, y);
y += lines.length * 7 + 5;
});
doc.save(filename);
setIsExportOpen(false);
return;
default:
return;
}
// Download file
const blob = new Blob([content], { type: mimeType });
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}.${extension}`;
a.download = filename;
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>
);

View file

@ -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)

View file

@ -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)

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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`}
{!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}
/>
<div className="flex items-center gap-2">
{!isOffTheRecord && (
<button
onClick={onOpenExport}
className="p-2 text-sergio-400 hover:text-sergio-700 transition-colors"
title="Export conversation"
data-testid="export-btn"
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} />
<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>

View file

@ -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,55 +7,45 @@ 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>
<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>
</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 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>
<label className="block text-xs font-bold text-sergio-500 uppercase tracking-wider mb-1">
API URL
Open WebUI URL
</label>
<input
type="text"
value={formData.baseUrl}
onChange={e => setFormData({...formData, baseUrl: e.target.value})}
placeholder="http://localhost:3000"
className="w-full p-2 rounded-lg border border-sergio-200 focus:border-sergio-500 focus:ring-1 focus:ring-sergio-500 outline-none font-mono text-sm"
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>
@ -68,18 +58,51 @@ export function SettingsModal({ isOpen, onClose, settings, onSave }: Props) {
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"
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>
<div className="p-4 border-t border-sergio-100 flex justify-end">
{/* 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 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>

View file

@ -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,23 +68,33 @@ 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 => (
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'}
${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(); }}
>
@ -71,23 +103,27 @@ export function Sidebar({
<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 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"
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>
</>

View file

@ -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>

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
}

View file

@ -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 });
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 [];
}
}
// ========== 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)
})
});
}
}

View file

@ -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 {