diff --git a/App.tsx b/App.tsx index b4442b2..ae8ca54 100644 --- a/App.tsx +++ b/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(() => { - 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(''); 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 = ` - - - - if.emotion Chat Export - - - -

if.emotion Chat Export

- ${messages.map(m => ` -
-
${m.role === Role.USER ? 'You' : m.role === Role.ASSISTANT ? 'Sergio' : 'System'}
-
${m.content.replace(/\n/g, '
')}
-
- `).join('')} -
Exported: ${new Date().toLocaleString()}
- - - `; - 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 ( -
+
- {/* Sidebar for Persistent Mode */} setIsSidebarOpen(false)} @@ -375,15 +318,13 @@ const App: React.FC = () => { onDeleteSession={handleDeleteSession} /> - {/* Main Content */}
- setIsSidebarOpen(true)} onOpenSettings={() => setIsSettingsOpen(true)} - onOpenExport={() => setIsExportOpen(true)} + onExport={() => setIsExportOpen(true)} />
@@ -396,12 +337,14 @@ const App: React.FC = () => { /> ))} - {/* Thinking Indicator */} {isLoading && ( -
-
-
-
+
+ Sergio is thinking +
+
+
+
+
)} @@ -412,24 +355,30 @@ const App: React.FC = () => {
- setIsSettingsOpen(false)} settings={settings} - onSave={(s) => { setSettings(s); }} + onSave={(s) => setSettings(s)} + models={availableModels} + selectedModel={selectedModel} + onSelectModel={setSelectedModel} /> - - setIsExportOpen(false)} onExport={handleExport} + language={Language.EN} // Defaulting to EN for UI texts in this version />
); }; -export default App; +export default App; \ No newline at end of file diff --git a/backend/claude_api_server.py b/backend/claude_api_server.py deleted file mode 100644 index 279c966..0000000 --- a/backend/claude_api_server.py +++ /dev/null @@ -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) diff --git a/backend/claude_api_server_rag.py b/backend/claude_api_server_rag.py deleted file mode 100644 index 3fba697..0000000 --- a/backend/claude_api_server_rag.py +++ /dev/null @@ -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) diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 36a034a..77ecccc 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -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(null); @@ -35,8 +38,12 @@ export function ChatInput({ onSend, isLoading, disabled }: Props) { return (