From 9df51ea38cb07a979d7811519fb36d6aaf0d8d5b Mon Sep 17 00:00:00 2001 From: Danny Stocker Date: Sun, 30 Nov 2025 16:50:37 +0100 Subject: [PATCH] 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. --- App.tsx | 235 +- backend/claude_api_server.py | 287 -- backend/claude_api_server_rag.py | 399 --- components/ChatInput.tsx | 39 +- components/ExportModal.tsx | 68 +- components/JourneyHeader.tsx | 62 +- components/SettingsModal.tsx | 129 +- components/Sidebar.tsx | 104 +- index.html | 13 +- metadata.json | 2 +- package-lock.json | 4329 ------------------------------ package.json | 11 +- services/openwebui.ts | 188 +- types.ts | 1 - 14 files changed, 438 insertions(+), 5429 deletions(-) delete mode 100644 backend/claude_api_server.py delete mode 100644 backend/claude_api_server_rag.py delete mode 100644 package-lock.json 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 (