henaiv2 / app.py
joashsam's picture
Update app.py
986405d verified
Raw
History Blame Contribute Delete
114 kB
# app.py - Complete HenAi Backend with Full Branching, Version Management, and All Features
# Includes: Chat, Media Search, Document Studio, Workspace, IDE, Terminal, Image Generation
import os
import json
import re
import uuid
from flask import Flask, render_template, request, jsonify, send_file, Response, g
from flask_cors import CORS
from werkzeug.utils import secure_filename
import requests
from datetime import datetime
from io import BytesIO
from docx import Document
from docx.shared import Inches
import base64
from binary_processor import BinaryProcessor
from docs import DocumentProcessor
import io
# ===========================================
# Authentication & User Storage Imports
# ===========================================
from auth_middleware import require_auth, optional_auth, get_current_user_id, get_current_user_email, get_current_username, is_admin
from user_storage import (
load_conversations as load_user_conversations,
save_conversations as save_user_conversations,
get_user_conversations_list,
load_workspace as load_user_workspace,
save_workspace as save_user_workspace,
load_user_profile,
save_user_profile,
update_user_last_active,
migrate_legacy_data
)
from session_manager import create_session, validate_session, delete_session, hash_password, verify_password, generate_user_id, is_valid_email, is_valid_username
# ===========================================
# HF Spaces Configuration
# ===========================================
if os.environ.get('SPACE_ID') or os.environ.get('HF_SPACE'):
UPLOAD_FOLDER = '/tmp/uploads'
INSTANCE_FOLDER = '/tmp/instance'
GENERATED_IMAGES_DIR = '/tmp/generated_images'
print("βœ“ Running on Hugging Face Spaces - using /tmp storage")
else:
UPLOAD_FOLDER = 'static/uploads'
INSTANCE_FOLDER = 'instance'
GENERATED_IMAGES_DIR = 'generated_images'
print("βœ“ Running locally - using static storage")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(INSTANCE_FOLDER, exist_ok=True)
os.makedirs(GENERATED_IMAGES_DIR, exist_ok=True)
# Import AI functions from models.py
from models import (
query_ai_with_fallback,
generate_chat_title,
is_code_generation_request,
execute_python_code,
search_web,
extract_web_content,
analyze_image_with_ai,
call_pollinations_ai
)
# Import workspace functions from workspace.py
from workspace import register_workspace_routes
# Import free image generator
from image import FreeImageGenerator, create_free_image_generator
# Import document creation utilities from mydocs.py
from mydocs import DocumentCreator
from vision import get_vision_model
# Import terminal blueprint
from terminal import create_terminal_blueprint
app = Flask(__name__)
app.secret_key = os.urandom(24)
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # Increased to 100MB for mobile uploads
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
app.config['TIMEOUT'] = None # No timeout for requests
CORS(app)
# Register terminal routes (will be modified by terminal.py changes)
app.register_blueprint(create_terminal_blueprint(app))
binary_processor = BinaryProcessor()
# Initialize free image generator (will be created per-user in routes)
# Global fallback for non-authenticated requests
image_generator = FreeImageGenerator(output_dir=GENERATED_IMAGES_DIR)
# ===========================================
# Helper Functions for User-Aware Operations
# ===========================================
def get_user_conversations_for_current_user():
"""Get conversations for the current authenticated user"""
user_id = get_current_user_id()
if user_id:
return load_user_conversations(user_id)
return {}
def save_user_conversations_for_current_user(conversations):
"""Save conversations for the current authenticated user"""
user_id = get_current_user_id()
if user_id:
return save_user_conversations(user_id, conversations)
return False
def get_user_image_generator_for_current_user():
"""Get image generator for the current user"""
user_id = get_current_user_id()
if user_id:
return create_free_image_generator(user_id=user_id)
return image_generator
# Allowed file extensions
ALLOWED_EXTENSIONS = {
'txt', 'md', 'py', 'js', 'html', 'css', 'json', 'xml', 'csv',
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp',
'mp3', 'wav', 'ogg', 'flac', 'm4a',
'mp4', 'avi', 'mov', 'mkv', 'webm',
'zip', 'rar', '7z', 'tar', 'gz', 'bz2',
'exe', 'dll', 'so', 'dylib',
'db', 'sqlite', 'sqlite3', 'sql',
'ttf', 'otf', 'woff', 'woff2',
'java', 'c', 'cpp', 'h', 'rb', 'php', 'go', 'rs', 'swift', 'kt'
}
# ===========================================
# Helper Functions
# ===========================================
def generate_message_id():
"""Generate a unique ID for a message"""
return str(uuid.uuid4())
def sanitize_filename(text):
if not text:
return "New Chat"
text = ''.join(char for char in text if char.isprintable() and char not in '\n\r\t')
replacements = {'/': '-', '\\': '-', ':': '-', '*': '-', '?': '-', '"': "'", '<': '-', '>': '-', '|': '-'}
for old, new in replacements.items():
text = text.replace(old, new)
text = text.strip()[:100]
return text if text else "New Chat"
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
# Note: load_conversations and save_conversations are now replaced by
# get_user_conversations_for_current_user() and save_user_conversations_for_current_user()
# The global 'conversations' variable is removed for user isolation.
# Legacy support - will be removed after migration
def load_conversations_legacy():
"""Legacy function - use get_user_conversations_for_current_user() instead"""
try:
if os.path.exists(CONVERSATIONS_FILE):
with open(CONVERSATIONS_FILE, 'r', encoding='utf-8') as f:
conversations = json.load(f)
for conv_id, conv_data in conversations.items():
if 'versions' not in conv_data:
conv_data['versions'] = {}
if 'current_version_index' not in conv_data:
conv_data['current_version_index'] = {}
if 'branch_root' not in conv_data:
conv_data['branch_root'] = None
if 'version_branches' not in conv_data:
conv_data['version_branches'] = {}
if 'pending_attachments' not in conv_data:
conv_data['pending_attachments'] = []
return conversations
except Exception as e:
print(f"Error loading conversations: {e}")
return {}
def save_conversations_legacy(conversations):
"""Legacy function - use save_user_conversations_for_current_user() instead"""
try:
with open(CONVERSATIONS_FILE, 'w', encoding='utf-8') as f:
json.dump(conversations, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"Error saving conversations: {e}")
def extract_text_from_file(file_content, filename):
try:
processed_output = binary_processor.process_file(file_content, filename)
if len(processed_output) > 50000:
processed_output = processed_output[:50000] + "\n\n[Content truncated due to size]"
return processed_output
except Exception as e:
print(f"Error in enhanced extraction: {e}")
return f"[Error extracting from {filename}: {str(e)}]"
def export_to_word(conversation_data):
doc = Document()
doc.add_heading(f'HenAi Chat: {conversation_data["title"]}', 0)
doc.add_paragraph(f'Exported on: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
doc.add_paragraph('-' * 50)
for msg in conversation_data['messages']:
if msg['role'] == 'user':
doc.add_heading('You:', level=2)
else:
doc.add_heading('HenAi:', level=2)
content = msg['content']
code_blocks = re.findall(r'```(\w+)?\n([\s\S]*?)```', content)
if code_blocks:
parts = re.split(r'```\w*\n[\s\S]*?```', content)
for i, part in enumerate(parts):
if part.strip():
doc.add_paragraph(part.strip())
if i < len(code_blocks):
lang, code = code_blocks[i]
doc.add_paragraph(f'[{lang.upper()} Code]')
doc.add_paragraph(code.strip())
else:
doc.add_paragraph(content)
doc.add_paragraph('')
return doc
def is_image_generation_request(message):
if not message:
return False
message_lower = message.lower()
analysis_keywords = [
'analyze', 'analyse', 'what is', 'what\'s', 'tell me about',
'describe', 'explain', 'read this', 'look at', 'examine',
'extract text', 'ocr', 'recognize', 'identify'
]
for keyword in analysis_keywords:
if keyword in message_lower:
return False
image_keywords = [
'generate image', 'create image', 'make image', 'draw image',
'generate picture', 'create picture', 'make picture', 'draw picture',
'generate photo', 'create photo', 'make photo',
'generate art', 'create art', 'make art',
'generate illustration', 'create illustration',
'ai image', 'ai art', 'ai generate',
'stable diffusion', 'dall-e', 'midjourney',
'flux', 'sdxl', 'sd1.5',
'image of', 'picture of', 'photo of',
'draw me', 'generate me', 'create me',
'make an image', 'make a picture', 'generate an image'
]
for keyword in image_keywords:
if keyword in message_lower:
return True
words = message_lower.split()
if len(words) <= 5 and any(word in ['image', 'picture', 'photo', 'art'] for word in words):
if not any(word in message_lower for word in ['this', 'the', 'that', 'attached']):
return True
return False
def extract_image_prompt(message):
message_lower = message.lower()
prefixes = [
'generate image of', 'create image of', 'make image of', 'draw image of',
'generate picture of', 'create picture of', 'make picture of', 'draw picture of',
'generate photo of', 'create photo of', 'make photo of',
'generate art of', 'create art of', 'make art of',
'ai image of', 'ai art of',
'image of', 'picture of', 'photo of',
'draw me', 'generate me', 'create me',
'generate an image of', 'create an image of', 'make an image of',
'generate a picture of', 'create a picture of', 'make a picture of'
]
prompt = message.strip()
for prefix in prefixes:
if message_lower.startswith(prefix):
prompt = message[len(prefix):].strip()
break
prompt = prompt.strip('.,!?;:')
if len(prompt) < 3:
prompt = message.strip()
return prompt
# ===========================================
# Routes
# ===========================================
# ===========================================
# AUTHENTICATION API ROUTES
# ===========================================
@app.route('/api/auth/register', methods=['POST'])
def api_register():
"""Register a new user"""
data = request.json
email = data.get('email', '').strip().lower()
username = data.get('username', '').strip()
password = data.get('password', '')
# Validation
if not email or not username or not password:
return jsonify({'success': False, 'error': 'All fields are required'}), 400
if not is_valid_email(email):
return jsonify({'success': False, 'error': 'Invalid email format'}), 400
if not is_valid_username(username):
return jsonify({'success': False, 'error': 'Username must be 3-30 characters (letters, numbers, underscore)'}), 400
if len(password) < 6:
return jsonify({'success': False, 'error': 'Password must be at least 6 characters'}), 400
# Check if user already exists with clear error messages
user_dir = f'/data/users'
if os.path.exists(user_dir):
for uid in os.listdir(user_dir):
profile_path = os.path.join(user_dir, uid, 'profile.json')
if os.path.exists(profile_path):
try:
with open(profile_path, 'r') as f:
profile = json.load(f)
if profile.get('email') == email:
return jsonify({'success': False, 'error': 'This email is already registered. Please use a different email or login.'}), 400
if profile.get('username') == username:
return jsonify({'success': False, 'error': f'Username "{username}" is already taken. Please choose a different username.'}), 400
except:
pass
# Create new user
user_id = generate_user_id()
hashed_password, salt = hash_password(password)
# Create user profile
profile = {
'user_id': user_id,
'username': username,
'email': email,
'password_hash': hashed_password,
'password_salt': salt,
'created_at': datetime.now().isoformat(),
'last_active': datetime.now().isoformat(),
'theme': 'dark',
'avatar': None
}
# Save profile
user_dir_path = f'/data/users/{user_id}'
os.makedirs(user_dir_path, exist_ok=True)
profile_path = os.path.join(user_dir_path, 'profile.json')
with open(profile_path, 'w') as f:
json.dump(profile, f, indent=2)
# Create empty conversations file
conversations_path = os.path.join(user_dir_path, 'conversations.json')
with open(conversations_path, 'w') as f:
json.dump({}, f)
# Create empty workspace file
workspace_path = os.path.join(user_dir_path, 'workspace.json')
with open(workspace_path, 'w') as f:
json.dump({"folders": []}, f)
# Create session
session_data = create_session(user_id)
return jsonify({
'success': True,
'user_id': user_id,
'username': username,
'email': email,
'session_token': session_data['session_token']
})
@app.route('/api/auth/login', methods=['POST'])
def api_login():
"""Login user"""
data = request.json
email = data.get('email', '').strip().lower()
password = data.get('password', '')
if not email or not password:
return jsonify({'success': False, 'error': 'Email and password required'}), 400
# Find user by email
user_dir = '/data/users'
if not os.path.exists(user_dir):
return jsonify({'success': False, 'error': 'Invalid credentials'}), 401
found_user = None
found_user_id = None
for user_id in os.listdir(user_dir):
profile_path = os.path.join(user_dir, user_id, 'profile.json')
if os.path.exists(profile_path):
try:
with open(profile_path, 'r') as f:
profile = json.load(f)
if profile.get('email') == email:
found_user = profile
found_user_id = user_id
break
except:
continue
if not found_user:
return jsonify({'success': False, 'error': 'Invalid credentials'}), 401
# Verify password
stored_hash = found_user.get('password_hash')
stored_salt = found_user.get('password_salt')
if not stored_hash or not stored_salt:
return jsonify({'success': False, 'error': 'Invalid credentials'}), 401
if not verify_password(password, stored_hash, stored_salt):
return jsonify({'success': False, 'error': 'Invalid credentials'}), 401
# Update last active
found_user['last_active'] = datetime.now().isoformat()
profile_path = os.path.join(user_dir, found_user_id, 'profile.json')
with open(profile_path, 'w') as f:
json.dump(found_user, f, indent=2)
# Create session
session_data = create_session(found_user_id)
return jsonify({
'success': True,
'user_id': found_user_id,
'username': found_user.get('username'),
'email': found_user.get('email'),
'session_token': session_data['session_token']
})
@app.route('/api/auth/verify_session', methods=['POST'])
def api_verify_session():
"""Verify a session token"""
data = request.json
session_token = data.get('session_token', '')
user_id = data.get('user_id', '')
if not session_token:
return jsonify({'success': False, 'error': 'Session token required'}), 400
validated_user_id = validate_session(session_token)
if validated_user_id and (not user_id or validated_user_id == user_id):
return jsonify({'success': True, 'user_id': validated_user_id})
return jsonify({'success': False, 'error': 'Invalid session'}), 401
@app.route('/api/auth/logout', methods=['POST'])
def api_logout():
"""Logout user - invalidate session"""
data = request.json
session_token = data.get('session_token', '')
if session_token:
delete_session(session_token)
return jsonify({'success': True})
@app.route('/api/auth/reset_password', methods=['POST'])
def api_reset_password():
"""Reset user password"""
data = request.json
email = data.get('email', '').strip().lower()
new_password = data.get('new_password', '')
if not email or not new_password:
return jsonify({'success': False, 'error': 'Email and new password required'}), 400
if len(new_password) < 6:
return jsonify({'success': False, 'error': 'Password must be at least 6 characters'}), 400
# Find user by email
user_dir = '/data/users'
if not os.path.exists(user_dir):
return jsonify({'success': False, 'error': 'User not found'}), 404
found_user_id = None
found_profile = None
for user_id in os.listdir(user_dir):
profile_path = os.path.join(user_dir, user_id, 'profile.json')
if os.path.exists(profile_path):
try:
with open(profile_path, 'r') as f:
profile = json.load(f)
if profile.get('email') == email:
found_user_id = user_id
found_profile = profile
break
except:
continue
if not found_user_id:
return jsonify({'success': False, 'error': 'User not found'}), 404
# Update password
new_hash, new_salt = hash_password(new_password)
found_profile['password_hash'] = new_hash
found_profile['password_salt'] = new_salt
profile_path = os.path.join(user_dir, found_user_id, 'profile.json')
with open(profile_path, 'w') as f:
json.dump(found_profile, f, indent=2)
return jsonify({'success': True})
@app.route('/api/auth/google', methods=['POST'])
def api_google_auth():
"""Google OAuth login/register"""
data = request.json
email = data.get('email', '').strip().lower()
name = data.get('name', '').strip()
google_id = data.get('google_id', '')
if not email:
return jsonify({'success': False, 'error': 'Email required'}), 400
# Find or create user
user_dir = '/data/users'
found_user_id = None
found_profile = None
if os.path.exists(user_dir):
for user_id in os.listdir(user_dir):
profile_path = os.path.join(user_dir, user_id, 'profile.json')
if os.path.exists(profile_path):
try:
with open(profile_path, 'r') as f:
profile = json.load(f)
if profile.get('email') == email:
found_user_id = user_id
found_profile = profile
break
except:
continue
if not found_user_id:
# Create new user
user_id = generate_user_id()
username = name.replace(' ', '_').lower()[:30]
# Ensure unique username
base_username = username
counter = 1
while True:
taken = False
for uid in os.listdir(user_dir) if os.path.exists(user_dir) else []:
profile_path = os.path.join(user_dir, uid, 'profile.json')
if os.path.exists(profile_path):
try:
with open(profile_path, 'r') as f:
profile = json.load(f)
if profile.get('username') == username:
taken = True
break
except:
continue
if not taken:
break
username = f"{base_username}{counter}"
counter += 1
profile = {
'user_id': user_id,
'username': username,
'email': email,
'google_id': google_id,
'created_at': datetime.now().isoformat(),
'last_active': datetime.now().isoformat(),
'theme': 'dark',
'avatar': None
}
user_dir_path = f'/data/users/{user_id}'
os.makedirs(user_dir_path, exist_ok=True)
profile_path = os.path.join(user_dir_path, 'profile.json')
with open(profile_path, 'w') as f:
json.dump(profile, f, indent=2)
# Create empty conversations and workspace
conversations_path = os.path.join(user_dir_path, 'conversations.json')
with open(conversations_path, 'w') as f:
json.dump({}, f)
workspace_path = os.path.join(user_dir_path, 'workspace.json')
with open(workspace_path, 'w') as f:
json.dump({"folders": []}, f)
found_user_id = user_id
found_profile = profile
else:
# Update last active and google_id if needed
found_profile['last_active'] = datetime.now().isoformat()
if google_id and not found_profile.get('google_id'):
found_profile['google_id'] = google_id
profile_path = os.path.join(user_dir, found_user_id, 'profile.json')
with open(profile_path, 'w') as f:
json.dump(found_profile, f, indent=2)
# Create session
session_data = create_session(found_user_id)
return jsonify({
'success': True,
'user_id': found_user_id,
'username': found_profile.get('username'),
'email': found_profile.get('email'),
'session_token': session_data['session_token']
})
@app.route('/')
@optional_auth
def index():
return render_template('index.html')
@app.route('/api/chat', methods=['POST'])
@require_auth
def chat():
data = request.json
message = data.get('message', '')
conversation_id = data.get('conversation_id')
regenerate = data.get('regenerate', False)
regenerate_from = data.get('regenerate_from')
attached_files = data.get('attached_files', [])
# Get user-specific conversations
user_id = get_current_user_id()
conversations = get_user_conversations_for_current_user()
# Create new conversation if needed
if not conversation_id or conversation_id not in conversations:
conversation_id = str(uuid.uuid4())
conversations[conversation_id] = {
'id': conversation_id,
'messages': [],
'title': 'New Chat',
'created_at': datetime.now().isoformat(),
'last_updated': datetime.now().isoformat(),
'versions': {},
'current_version_index': {},
'branch_root': None,
'version_branches': {},
'pending_attachments': []
}
conv = conversations[conversation_id]
for key in ['versions', 'current_version_index', 'version_branches', 'pending_attachments']:
if key not in conv:
conv[key] = {} if key != 'pending_attachments' else []
# Check for pending attachments
if conv.get('pending_attachments'):
if message:
if not attached_files:
attached_files = []
for pending_file in conv['pending_attachments']:
attached_files.append({
'name': pending_file['name'],
'content': pending_file['content']
})
conv['pending_attachments'] = []
save_user_conversations_for_current_user(conversations)
# Prepare message content - NO LIMIT ON FILE CONTENT SIZE
full_message = message
ai_message = message
display_message = message
if attached_files:
file_contexts = []
file_names = []
for file_info in attached_files:
file_names.append(file_info['name'])
# NO TRUNCATION - include full file content
file_contexts.append(f"\n\n--- BEGIN FILE: {file_info['name']} ---\n{file_info['content']}\n--- END FILE: {file_info['name']} ---\n")
ai_message = message + ''.join(file_contexts)
file_list = ", ".join(file_names)
display_message = message + (f"\n\nπŸ“Ž **Attached files:** {file_list}" if message else f"πŸ“Ž **Attached files:** {file_list}")
full_message = display_message
else:
ai_message = message
full_message = message
response_data = {
'response': '',
'code_execution': None,
'conversation_id': conversation_id,
'title': conv['title'],
'versions': conv.get('versions', {}),
'current_version_index': conv.get('current_version_index', {})
}
# ===========================================
# REGENERATION HANDLING (Sibling Branch)
# ===========================================
if regenerate and regenerate_from is not None:
msg_key = str(regenerate_from)
if msg_key not in conv['versions']:
conv['versions'][msg_key] = []
# Save current response as a version
if regenerate_from < len(conv['messages']):
current_response = conv['messages'][regenerate_from]['content']
current_version_idx = conv['current_version_index'].get(msg_key, 0)
if current_response not in conv['versions'][msg_key]:
conv['versions'][msg_key].append(current_response)
conv['current_version_index'][msg_key] = len(conv['versions'][msg_key]) - 1
current_version_idx = len(conv['versions'][msg_key]) - 1
# Save subsequent messages as a branch for this version
subsequent_messages = conv['messages'][regenerate_from + 1:]
if subsequent_messages:
version_branch_key = f"{msg_key}_v{current_version_idx}"
conv['version_branches'][version_branch_key] = subsequent_messages
# Truncate at the assistant message
if regenerate_from > 0 and regenerate_from - 1 < len(conv['messages']):
conv['messages'] = conv['messages'][:regenerate_from]
conv['branch_root'] = regenerate_from - 1
# Set new version index
new_version_idx = len(conv['versions'].get(msg_key, []))
conv['current_version_index'][msg_key] = new_version_idx
# Clear version indices for messages that no longer exist
indices_to_remove = []
for idx_key in list(conv.get('current_version_index', {}).keys()):
try:
if int(idx_key) >= regenerate_from:
indices_to_remove.append(idx_key)
except ValueError:
continue
for idx_key in indices_to_remove:
del conv['current_version_index'][idx_key]
# Build context for AI
context = []
for msg in conv['messages']:
if msg['role'] == 'user' and msg.get('attachments'):
if 'ai_content' in msg:
context.append({"role": msg['role'], "content": msg['ai_content']})
else:
user_content = msg['content']
if 'attachments' in msg and msg['attachments']:
file_context = ""
for file_info in msg['attachments']:
if 'content' in file_info:
file_context += file_info['content']
if file_context:
user_content = msg['content'] + file_context
context.append({"role": msg['role'], "content": user_content})
else:
context.append({"role": msg['role'], "content": msg['content']})
is_code_gen = is_code_generation_request(full_message)
prompt_for_ai = ai_message
if is_code_gen:
prompt_for_ai += "\n\nIMPORTANT: Provide the COMPLETE, FULLY FUNCTIONAL code with at least 500 lines. Do not abbreviate or use placeholders."
# Check if the original user message had attachments - if yes, force OpenRouter only
has_attachments_in_user = False
if regenerate_from > 0 and regenerate_from - 1 < len(conv['messages']):
user_msg = conv['messages'][regenerate_from - 1]
if user_msg.get('attachments') and len(user_msg.get('attachments', [])) > 0:
has_attachments_in_user = True
print(f"πŸ“Ž Regeneration: User message had attachments - forcing OpenRouter only")
if has_attachments_in_user:
from models import query_openrouter
ai_response = query_openrouter(prompt_for_ai, context, is_code_gen)
else:
# NO TIMEOUT - query_ai_with_fallback has timeout=None
ai_response = query_ai_with_fallback(prompt_for_ai, context, is_code_gen)
response_data['response'] = ai_response
# Add the new assistant message
conv['messages'].append({
'role': 'assistant',
'content': response_data['response'],
'timestamp': datetime.now().isoformat(),
'regenerated': True,
'version': new_version_idx + 1
})
# Update versions
if response_data['response'] not in conv['versions'][msg_key]:
conv['versions'][msg_key].append(response_data['response'])
conv['current_version_index'][msg_key] = len(conv['versions'][msg_key]) - 1
# ===========================================
# COMMAND HANDLING
# ===========================================
elif full_message.lower().startswith('/search'):
query = full_message[7:].strip()
if not query:
response_data['response'] = "Please provide a search query. Example: `/search artificial intelligence`"
else:
response_data['response'] = search_web(query)
elif full_message.lower().startswith('/extract'):
url = full_message[8:].strip()
if not url:
response_data['response'] = "Please provide a URL. Example: `/extract https://example.com`"
else:
response_data['response'] = extract_web_content(url)
elif full_message.lower().startswith('/code'):
code = full_message[5:].strip()
if not code:
response_data['response'] = "Please provide Python code. Example: `/code print('Hello World!')`"
else:
exec_result = execute_python_code(code)
response_data['code_execution'] = exec_result
if exec_result['success']:
response_data['response'] = f"βœ… **Code executed successfully!**\n\n```\n{exec_result['output']}\n```"
else:
response_data['response'] = f"❌ **Code execution failed!**\n\n```\n{exec_result['error']}\n```"
elif full_message.lower().startswith('/generate') or full_message.lower().startswith('/image'):
if full_message.lower().startswith('/generate'):
image_prompt = full_message[9:].strip()
else:
image_prompt = full_message[6:].strip()
if not image_prompt:
response_data['response'] = "Please provide an image description. Example: `/generate a beautiful sunset over mountains`"
else:
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"ai_gen_{timestamp}.png"
# Use user-specific image generator
user_image_gen = get_user_image_generator_for_current_user()
try:
image_path = user_image_gen.generate_huggingface(image_prompt, output_name=filename)
except Exception as e:
print(f"Hugging Face generation failed: {e}, falling back to local generation")
try:
image_path = user_image_gen.generate_local_sd(image_prompt, output_name=filename)
except Exception as e2:
print(f"Local generation also failed: {e2}")
raise e
image_filename = os.path.basename(image_path)
# Use user-specific image serving endpoint
image_html = f'''
<div style="text-align: center; margin: 15px 0;">
<img src="/api/generated_image/{image_filename}"
alt="Generated: {image_prompt}"
style="max-width: 100%; max-height: 400px; border-radius: 12px; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.3);"
onclick="window.open('/api/generated_image/{image_filename}', '_blank')">
<div style="margin-top: 8px; font-size: 0.75rem; color: var(--text-muted);">
<i class="fas fa-expand"></i> Click to view full size
</div>
</div>
'''
response_data['response'] = f"🎨 **Generated Image: \"{image_prompt}\"**\n\n{image_html}"
response_data['is_image_generation'] = True
response_data['image_path'] = image_path
response_data['image_prompt'] = image_prompt
except Exception as e:
print(f"Image generation error: {e}")
response_data['response'] = f"❌ **Image generation failed!**\n\nError: {str(e)}\n\nPlease try a different prompt."
elif full_message.lower().startswith('/help'):
response_data['response'] = """**πŸ“š HenAi Commands & Features**
**Commands:**
β€’ `/search <query>` - Search the web
β€’ `/extract <url>` - Extract content from a URL
β€’ `/code <python>` - Execute Python code
β€’ `/generate <description>` or `/image <description>` - Generate AI images (free!)
β€’ `/help` - Show this help
**Message Actions:**
β€’ πŸ“‹ **Copy** - Click copy on any message
β€’ ✏️ **Edit** - Click edit on your messages (creates new branch)
β€’ πŸ”„ **Regenerate** - Get new responses (creates sibling branch)
β€’ ↔️ **Toggle Versions** - Switch between chat versions
**Features:**
β€’ πŸ“Ž Multiple file attachments
β€’ πŸ’Ύ Auto-save conversations
β€’ 🏷️ Auto-titled chats
β€’ ✏️ Rename chats
β€’ πŸ“₯ Export chats to Word
β€’ πŸ”„ Version history with branching
β€’ 🎨 Free AI Image Generation"""
elif is_image_generation_request(full_message) and not attached_files:
image_prompt = extract_image_prompt(full_message)
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"ai_gen_{timestamp}.png"
# Use user-specific image generator
user_image_gen = get_user_image_generator_for_current_user()
try:
image_path = user_image_gen.generate_huggingface(image_prompt, output_name=filename)
except Exception as e:
print(f"Hugging Face generation failed: {e}, falling back to local generation")
try:
image_path = user_image_gen.generate_local_sd(image_prompt, output_name=filename)
except Exception as e2:
print(f"Local generation also failed: {e2}")
raise e
image_filename = os.path.basename(image_path)
image_html = f'''
<div style="text-align: center; margin: 15px 0;">
<img src="/api/generated_image/{image_filename}"
alt="Generated: {image_prompt}"
style="max-width: 100%; max-height: 400px; border-radius: 12px; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.3);"
onclick="window.open('/api/generated_image/{image_filename}', '_blank')">
<div style="margin-top: 8px; font-size: 0.75rem; color: var(--text-muted);">
<i class="fas fa-expand"></i> Click to view full size
</div>
</div>
'''
response_data['response'] = f"🎨 **Here's an image I generated for you:**\n\n{image_html}"
response_data['is_image_generation'] = True
response_data['image_path'] = image_path
response_data['image_prompt'] = image_prompt
except Exception as e:
print(f"Image generation error: {e}")
response_data['response'] = f"❌ **Image generation failed!**\n\nError: {str(e)}\n\nPlease try a different prompt."
# ===========================================
# NORMAL AI RESPONSE
# ===========================================
else:
# Build context from all messages - FULL CONTEXT, NO TRUNCATION
context = []
for msg in conv['messages']:
if msg['role'] == 'user' and msg.get('attachments'):
if 'ai_content' in msg:
context.append({"role": msg['role'], "content": msg['ai_content']})
else:
user_content = msg['content']
if 'attachments' in msg and msg['attachments']:
file_context = ""
for file_info in msg['attachments']:
if 'content' in file_info:
file_context += file_info['content']
if file_context:
user_content = msg['content'] + file_context
context.append({"role": msg['role'], "content": user_content})
else:
context.append({"role": msg['role'], "content": msg['content']})
print(f"Context messages count: {len(context)}")
is_code_gen = is_code_generation_request(full_message)
print(f"Code generation detection: {is_code_gen}")
prompt_for_ai = ai_message
if is_code_gen:
prompt_for_ai += "\n\nIMPORTANT: Provide the COMPLETE, FULLY FUNCTIONAL code with at least 500 lines. Do not abbreviate or use placeholders."
# Check if there are any attached files - if yes, force OpenRouter only (no Pollinations)
has_attachments = attached_files and len(attached_files) > 0
if has_attachments:
print(f"πŸ“Ž Attachments detected ({len(attached_files)} files) - forcing OpenRouter only (no Pollinations.ai)")
from models import query_openrouter
ai_response = query_openrouter(prompt_for_ai, context, is_code_gen)
else:
# NO TIMEOUT - query_ai_with_fallback has timeout=None
ai_response = query_ai_with_fallback(prompt_for_ai, context, is_code_gen)
response_data['response'] = ai_response
# ===========================================
# ADD MESSAGES TO CONVERSATION
# ===========================================
if not regenerate or regenerate_from is None:
stored_content = display_message if 'display_message' in locals() else full_message
ai_ready_content = ai_message if 'ai_message' in locals() else full_message
conv['messages'].append({
'role': 'user',
'content': stored_content,
'ai_content': ai_ready_content,
'timestamp': datetime.now().isoformat(),
'attachments': attached_files if attached_files else None,
'id': generate_message_id()
})
conv['branch_root'] = None
# Only add assistant message if not already added in regeneration
if not (regenerate and regenerate_from is not None):
conv['messages'].append({
'role': 'assistant',
'content': response_data['response'],
'timestamp': datetime.now().isoformat(),
'id': generate_message_id()
})
# Update conversation title
if conv['title'] == 'New Chat':
raw_title = generate_chat_title(conv['messages'])
conv['title'] = sanitize_filename(raw_title)
response_data['title'] = conv['title']
conv['last_updated'] = datetime.now().isoformat()
save_user_conversations_for_current_user(conversations)
return jsonify(response_data)
@app.route('/api/edit_message', methods=['POST'])
@require_auth
def edit_message():
data = request.json
conversation_id = data.get('conversation_id')
message_index = data.get('message_index')
new_content = data.get('new_content')
attached_files = data.get('attached_files', [])
user_id = get_current_user_id()
conversations = get_user_conversations_for_current_user()
if conversation_id not in conversations:
return jsonify({'error': 'Conversation not found'}), 404
conv = conversations[conversation_id]
if 'versions' not in conv:
conv['versions'] = {}
if 'current_version_index' not in conv:
conv['current_version_index'] = {}
if 'version_branches' not in conv:
conv['version_branches'] = {}
messages = conv['messages']
if message_index >= len(messages):
return jsonify({'error': 'Message not found'}), 404
# Check if message is user message
if messages[message_index]['role'] != 'user':
return jsonify({'error': 'Can only edit user messages'}), 400
full_content = new_content
if attached_files:
file_names = [file_info['name'] for file_info in attached_files]
file_list = ", ".join(file_names)
full_content = new_content + f"\n\n[Attached files: {file_list}]"
# Save current assistant response as version
if message_index + 1 < len(messages):
msg_key = str(message_index + 1)
if msg_key not in conv['versions']:
conv['versions'][msg_key] = []
old_response = messages[message_index + 1]['content']
current_version_idx = conv['current_version_index'].get(msg_key, 0)
if old_response not in conv['versions'][msg_key]:
conv['versions'][msg_key].append(old_response)
conv['current_version_index'][msg_key] = len(conv['versions'][msg_key]) - 1
current_version_idx = len(conv['versions'][msg_key]) - 1
# Save subsequent messages as a branch
subsequent_messages = messages[message_index + 2:]
if subsequent_messages:
version_branch_key = f"{msg_key}_v{current_version_idx}"
conv['version_branches'][version_branch_key] = subsequent_messages
# Store both display and AI-ready content
ai_ready_content = new_content
if attached_files:
file_context = ""
for file_info in attached_files:
if 'content' in file_info:
file_context += file_info['content']
if file_context:
ai_ready_content = new_content + file_context
# Update the user message
messages[message_index]['content'] = full_content
messages[message_index]['ai_content'] = ai_ready_content
messages[message_index]['attachments'] = attached_files if attached_files else None
messages[message_index]['edited'] = True
messages[message_index]['edited_at'] = datetime.now().isoformat()
# Remove all messages after the edited message
while len(messages) > message_index + 1:
messages.pop()
# Build context - FULL CONTEXT, NO TRUNCATION
context = []
for msg in messages:
if msg['role'] == 'user' and 'ai_content' in msg:
context.append({"role": msg['role'], "content": msg['ai_content']})
else:
context.append({"role": msg['role'], "content": msg['content']})
print(f"Edit context messages count: {len(context)}")
is_code_gen = is_code_generation_request(full_content)
if is_code_gen:
if not any(phrase in full_content.lower() for phrase in ['summarize', 'explain', 'what is', 'tell me about']):
full_content += "\n\nIMPORTANT: Provide the COMPLETE, FULLY FUNCTIONAL code with at least 500 lines. Do not abbreviate or use placeholders."
# Check if the edited user message has attachments - if yes, force OpenRouter only
has_attachments = attached_files and len(attached_files) > 0
if has_attachments:
print(f"πŸ“Ž Edit: Attachments detected ({len(attached_files)} files) - forcing OpenRouter only (no Pollinations.ai)")
from models import query_openrouter
ai_response = query_openrouter(full_content, context, is_code_gen)
else:
# NO TIMEOUT - query_ai_with_fallback has timeout=None
ai_response = query_ai_with_fallback(full_content, context, is_code_gen)
messages.append({
'role': 'assistant',
'content': ai_response,
'timestamp': datetime.now().isoformat(),
'id': generate_message_id(),
'edited_response': True
})
msg_key = str(message_index + 1)
if msg_key not in conv['versions']:
conv['versions'][msg_key] = []
if ai_response not in conv['versions'][msg_key]:
conv['versions'][msg_key].append(ai_response)
conv['current_version_index'][msg_key] = len(conv['versions'][msg_key]) - 1
if conv['title'] == 'New Chat':
conv['title'] = generate_chat_title(messages)
conv['last_updated'] = datetime.now().isoformat()
save_user_conversations_for_current_user(conversations)
return jsonify({
'success': True,
'new_response': ai_response,
'conversation_id': conversation_id,
'messages': messages,
'versions': conv.get('versions', {}),
'current_version_index': conv.get('current_version_index', {})
})
@app.route('/api/switch_version', methods=['POST'])
@require_auth
def switch_version():
data = request.json
conversation_id = data.get('conversation_id')
message_index = data.get('message_index')
version_index = data.get('version_index')
user_id = get_current_user_id()
conversations = get_user_conversations_for_current_user()
if conversation_id not in conversations:
return jsonify({'error': 'Conversation not found'}), 404
conv = conversations[conversation_id]
versions = conv.get('versions', {})
msg_key = str(message_index)
if msg_key not in versions or version_index >= len(versions[msg_key]):
return jsonify({'error': 'Version not found'}), 404
if 'current_version_index' not in conv:
conv['current_version_index'] = {}
previous_version_index = conv['current_version_index'].get(msg_key)
conv['current_version_index'][msg_key] = version_index
# Reconstruct conversation for this version
base_messages = conv['messages'][:message_index]
version_response = versions[msg_key][version_index]
version_branch_key = f"{msg_key}_v{version_index}"
branch_messages = conv.get('version_branches', {}).get(version_branch_key, [])
new_messages = list(base_messages)
if message_index < len(conv['messages']):
assistant_msg = dict(conv['messages'][message_index])
assistant_msg['content'] = version_response
new_messages.append(assistant_msg)
if branch_messages:
new_messages.extend(branch_messages)
elif version_index == previous_version_index:
subsequent = conv['messages'][message_index + 1:]
if 'version_branches' not in conv:
conv['version_branches'] = {}
conv['version_branches'][version_branch_key] = list(subsequent)
new_messages.extend(subsequent)
conv['messages'] = new_messages
conv['branch_root'] = message_index
conv['branch_version'] = version_index
save_user_conversations_for_current_user(conversations)
return jsonify({
'success': True,
'content': version_response,
'current_version_index': conv['current_version_index'],
'messages': conv['messages']
})
@app.route('/api/rename_chat', methods=['POST'])
@require_auth
def rename_chat():
data = request.json
conversation_id = data.get('conversation_id')
new_title = data.get('new_title')
user_id = get_current_user_id()
conversations = get_user_conversations_for_current_user()
if conversation_id not in conversations:
return jsonify({'error': 'Conversation not found'}), 404
conversations[conversation_id]['title'] = sanitize_filename(new_title)
save_user_conversations_for_current_user(conversations)
return jsonify({'success': True})
@app.route('/api/export_chat/<conversation_id>', methods=['GET'])
@require_auth
def export_chat(conversation_id):
user_id = get_current_user_id()
conversations = get_user_conversations_for_current_user()
if conversation_id not in conversations:
return jsonify({'error': 'Conversation not found'}), 404
doc = export_to_word(conversations[conversation_id])
file_stream = BytesIO()
doc.save(file_stream)
file_stream.seek(0)
title = conversations[conversation_id]['title']
title = ''.join(char for char in title if char.isprintable() and char not in '\n\r\t')
title = title.replace('/', '-').replace('\\', '-').replace(':', '-')
filename = f"HenAi_Chat_{title}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.docx"
return send_file(
file_stream,
as_attachment=True,
download_name=filename,
mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document'
)
@app.route('/api/get_version', methods=['POST'])
@require_auth
def get_version():
data = request.json
conversation_id = data.get('conversation_id')
message_index = data.get('message_index')
version_index = data.get('version_index')
user_id = get_current_user_id()
conversations = get_user_conversations_for_current_user()
if conversation_id not in conversations:
return jsonify({'error': 'Conversation not found'}), 404
conv = conversations[conversation_id]
versions = conv.get('versions', {})
msg_key = str(message_index)
if msg_key not in versions or version_index >= len(versions[msg_key]):
return jsonify({'error': 'Version not found'}), 404
return jsonify({
'success': True,
'content': versions[msg_key][version_index]
})
@app.route('/api/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({'error': 'No file uploaded'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if not allowed_file(file.filename):
return jsonify({'error': 'File type not allowed. Please upload text files, code files, or documents.'}), 400
filename = secure_filename(file.filename)
file_content = file.read()
try:
text_content = extract_text_from_file(file_content, filename)
file_extension = filename.rsplit('.', 1)[1].lower() if '.' in filename else 'txt'
formatted_content = f"\n\n--- FILE: {filename} ({file_extension}) ---\n{text_content}\n--- END FILE: {filename} ---\n\n"
return jsonify({
'success': True,
'filename': filename,
'content': formatted_content,
'preview': text_content[:500] + ('...' if len(text_content) > 500 else '')
})
except Exception as e:
return jsonify({'error': f'Error processing file: {str(e)}'}), 500
@app.route('/api/add_pending_attachment', methods=['POST'])
def add_pending_attachment():
data = request.json
conversation_id = data.get('conversation_id')
filename = data.get('filename')
content = data.get('content')
if not conversation_id or not filename:
return jsonify({'error': 'Missing conversation_id or filename'}), 400
if content:
content = content.encode('utf-8', errors='replace').decode('utf-8')
if conversation_id not in conversations:
return jsonify({'error': 'Conversation not found'}), 404
conv = conversations[conversation_id]
if 'pending_attachments' not in conv:
conv['pending_attachments'] = []
conv['pending_attachments'].append({
'name': filename,
'content': content,
'timestamp': datetime.now().isoformat(),
'source': 'archive_search'
})
conv['last_updated'] = datetime.now().isoformat()
try:
save_conversations(conversations)
except Exception as e:
print(f"Error saving: {e}")
return jsonify({'error': f'Failed to save attachment: {str(e)}'}), 500
return jsonify({
'success': True,
'conversation_id': conversation_id,
'filename': filename,
'attachments_count': len(conv['pending_attachments'])
})
@app.route('/api/attach_to_chat', methods=['POST'])
def attach_to_chat():
data = request.json
conversation_id = data.get('conversation_id')
filename = data.get('filename')
content = data.get('content')
if not conversation_id or not filename:
return jsonify({'error': 'Missing conversation_id or filename'}), 400
if content:
content = content.encode('utf-8', errors='replace').decode('utf-8')
if conversation_id not in conversations:
conversation_id = str(uuid.uuid4())
conversations[conversation_id] = {
'id': conversation_id,
'messages': [],
'title': 'New Chat',
'created_at': datetime.now().isoformat(),
'last_updated': datetime.now().isoformat(),
'versions': {},
'current_version_index': {},
'branch_root': None,
'pending_attachments': []
}
conv = conversations[conversation_id]
if 'pending_attachments' not in conv:
conv['pending_attachments'] = []
conv['pending_attachments'].append({
'name': filename,
'content': content,
'timestamp': datetime.now().isoformat(),
'source': 'archive_search'
})
conv['last_updated'] = datetime.now().isoformat()
try:
save_conversations(conversations)
except Exception as e:
print(f"Error saving: {e}")
if 'pending_attachments' in conv and conv['pending_attachments']:
conv['pending_attachments'].pop()
save_conversations(conversations)
return jsonify({
'success': True,
'conversation_id': conversation_id,
'filename': filename,
'attachments_count': len(conv['pending_attachments'])
})
@app.route('/api/conversations', methods=['GET'])
@require_auth
def get_conversations():
conv_list = get_user_conversations_list(get_current_user_id())
return jsonify(conv_list)
@app.route('/api/conversation/<conversation_id>', methods=['GET'])
@require_auth
def get_conversation(conversation_id):
user_id = get_current_user_id()
conversations = get_user_conversations_for_current_user()
if conversation_id in conversations:
conv_data = conversations[conversation_id]
messages = conv_data.get('messages', [])
for msg in messages:
if msg.get('media_data'):
msg['media_data'] = msg['media_data']
return jsonify({
**conv_data,
'versions': conv_data.get('versions', {}),
'current_version_index': conv_data.get('current_version_index', {}),
'version_branches': conv_data.get('version_branches', {}),
'branch_root': conv_data.get('branch_root'),
'branch_version': conv_data.get('branch_version'),
'pending_attachments': conv_data.get('pending_attachments', [])
})
return jsonify({'error': 'Conversation not found'}), 404
@app.route('/api/delete_conversation/<conversation_id>', methods=['DELETE'])
@require_auth
def delete_conversation(conversation_id):
user_id = get_current_user_id()
conversations = get_user_conversations_for_current_user()
if conversation_id in conversations:
del conversations[conversation_id]
save_user_conversations_for_current_user(conversations)
return jsonify({'success': True})
return jsonify({'error': 'Conversation not found'}), 404
@app.route('/api/remove_pending_attachment', methods=['POST'])
def remove_pending_attachment():
data = request.json
conversation_id = data.get('conversation_id')
attachment_index = data.get('attachment_index')
if conversation_id not in conversations:
return jsonify({'error': 'Conversation not found'}), 404
conv = conversations[conversation_id]
if 'pending_attachments' not in conv:
return jsonify({'error': 'No pending attachments'}), 404
if attachment_index >= len(conv['pending_attachments']):
return jsonify({'error': 'Attachment not found'}), 404
conv['pending_attachments'].pop(attachment_index)
if len(conv['pending_attachments']) == 0:
conv['pending_attachments'] = []
conv['last_updated'] = datetime.now().isoformat()
save_conversations(conversations)
return jsonify({'success': True, 'remaining': len(conv['pending_attachments'])})
@app.route('/api/clear_pending_attachments', methods=['POST'])
def clear_pending_attachments():
data = request.json
conversation_id = data.get('conversation_id')
if conversation_id not in conversations:
return jsonify({'error': 'Conversation not found'}), 404
conv = conversations[conversation_id]
conv['pending_attachments'] = []
conv['last_updated'] = datetime.now().isoformat()
save_conversations(conversations)
return jsonify({'success': True})
# ============= IDE ENDPOINTS (using workspace.py) =============
from workspace import load_workspace, save_workspace, build_folder_context, get_file_content_from_workspace
@app.route('/api/ide/chat', methods=['POST'])
def ide_chat():
data = request.json
message = data.get('message', '')
folder_path = data.get('folder_path', '')
selected_file = data.get('selected_file', '')
conversation_history = data.get('conversation_history', [])
include_full_folder = data.get('include_full_folder', True)
if not message:
return jsonify({'error': 'No message provided'}), 400
context_content = ""
if include_full_folder and folder_path:
workspace = load_workspace()
parts = folder_path.split('/') if folder_path else []
current = workspace
for part in parts:
found = False
for folder in current.get('folders', []):
if folder.get('name') == part:
current = folder
found = True
break
if not found:
break
context_content = build_folder_context(current, folder_path)
if selected_file:
file_content = get_file_content_from_workspace(selected_file)
if file_content:
context_content += f"\n\n--- SELECTED FILE: {selected_file} ---\n"
context_content += file_content
context_content += f"\n--- END FILE: {selected_file} ---\n"
system_prompt = """You are HenAi, an expert coding assistant integrated into the HenIde workspace.
You have access to the entire folder structure and file contents when the checkbox is enabled.
IMPORTANT RULES:
1. When providing code, output ONLY the code with no explanations before or after
2. Use proper code blocks with language specification
3. Understand the context of the entire folder structure
4. When asked to debug, explain what's wrong and provide the corrected code
5. When asked to generate new files, specify the filename and provide the complete code
6. Keep responses concise but thorough
Remember: Code output should be pure code with no additional text outside code blocks."""
messages = [{"role": "system", "content": system_prompt}]
for msg in conversation_history:
messages.append({"role": msg.get('role'), "content": msg.get('content')})
if context_content:
context_message = f"FOLDER CONTEXT:\n{context_content}\n\nUSER QUERY: {message}"
messages.append({"role": "user", "content": context_message})
else:
messages.append({"role": "user", "content": message})
is_code_gen = is_code_generation_request(message)
ai_response = query_ai_with_fallback(
messages[-1]['content'] if not context_content else context_message,
messages[:-1],
is_code_gen
)
return jsonify({
'success': True,
'response': ai_response
})
@app.route('/api/ide/create_file', methods=['POST'])
def ide_create_file():
data = request.json
folder_path = data.get('folder_path', '')
file_name = data.get('file_name', '').strip()
content = data.get('content', '')
if not file_name:
return jsonify({'error': 'File name required'}), 400
if not re.match(r'^[a-zA-Z0-9_\-\.]+$', file_name):
return jsonify({'error': 'Invalid file name. Use only letters, numbers, underscore, hyphen, and dot.'}), 400
workspace = load_workspace()
if folder_path:
parts = folder_path.split('/') if folder_path else []
current = workspace
for part in parts:
found = False
for folder in current.get('folders', []):
if folder.get('name') == part:
current = folder
found = True
break
if not found:
return jsonify({'error': f'Folder "{folder_path}" not found'}), 404
target_folder = current
else:
target_folder = workspace
if 'files' not in target_folder:
target_folder['files'] = []
for file_item in target_folder['files']:
if file_item.get('name') == file_name:
return jsonify({'error': 'File already exists'}), 400
new_file = {
'name': file_name,
'content': content,
'created_at': datetime.now().isoformat(),
'updated_at': datetime.now().isoformat()
}
target_folder['files'].insert(0, new_file)
save_workspace(workspace)
full_path = folder_path + '/' + file_name if folder_path else file_name
print(f"βœ… IDE: File created successfully - {full_path}")
return jsonify({
'success': True,
'file': new_file,
'full_path': full_path
})
@app.route('/api/ide/create_folder', methods=['POST'])
def ide_create_folder():
data = request.json
parent_path = data.get('parent_path', '')
folder_name = data.get('folder_name', '').strip()
if not folder_name:
return jsonify({'error': 'Folder name required'}), 400
if not re.match(r'^[a-zA-Z0-9_\-]+$', folder_name):
return jsonify({'error': 'Invalid folder name. Use only letters, numbers, underscore, and hyphen.'}), 400
workspace = load_workspace()
if parent_path:
parts = parent_path.split('/') if parent_path else []
current = workspace
for part in parts:
found = False
for folder in current.get('folders', []):
if folder.get('name') == part:
current = folder
found = True
break
if not found:
return jsonify({'error': f'Parent folder "{parent_path}" not found'}), 404
parent = current
else:
parent = workspace
if 'folders' not in parent:
parent['folders'] = []
for folder in parent['folders']:
if folder.get('name') == folder_name:
return jsonify({'error': 'Folder already exists'}), 400
new_folder = {
'name': folder_name,
'created_at': datetime.now().isoformat(),
'folders': [],
'files': []
}
parent['folders'].insert(0, new_folder)
save_workspace(workspace)
new_path = parent_path + '/' + folder_name if parent_path else folder_name
print(f"βœ… IDE: Folder created successfully - {new_path}")
return jsonify({
'success': True,
'folder': new_folder,
'full_path': new_path
})
@app.route('/api/ide/update_file', methods=['POST'])
def ide_update_file():
data = request.json
file_path = data.get('file_path', '')
content = data.get('content', '')
if not file_path:
return jsonify({'error': 'File path required'}), 400
workspace = load_workspace()
parts = file_path.split('/')
file_name = parts[-1]
folder_parts = parts[:-1] if len(parts) > 1 else []
current = workspace
for folder_name in folder_parts:
found = False
for folder in current.get('folders', []):
if folder.get('name') == folder_name:
current = folder
found = True
break
if not found:
return jsonify({'error': f'Folder "{folder_name}" not found in path'}), 404
files = current.get('files', [])
for i, file_item in enumerate(files):
if file_item.get('name') == file_name:
file_item['content'] = content
file_item['updated_at'] = datetime.now().isoformat()
files[i] = file_item
save_workspace(workspace)
print(f"βœ… IDE: File saved successfully - {file_path}")
return jsonify({'success': True, 'file': file_item})
return jsonify({'error': f'File "{file_name}" not found in folder'}), 404
@app.route('/api/ide/delete_file', methods=['POST'])
def ide_delete_file():
data = request.json
file_path = data.get('file_path', '')
if not file_path:
return jsonify({'error': 'File path required'}), 400
workspace = load_workspace()
parts = file_path.split('/')
file_name = parts[-1]
folder_parts = parts[:-1] if len(parts) > 1 else []
current = workspace
for folder_name in folder_parts:
found = False
for folder in current.get('folders', []):
if folder.get('name') == folder_name:
current = folder
found = True
break
if not found:
return jsonify({'error': f'Folder not found'}), 404
files = current.get('files', [])
for i, file_item in enumerate(files):
if file_item.get('name') == file_name:
del files[i]
save_workspace(workspace)
return jsonify({'success': True})
return jsonify({'error': 'File not found'}), 404
@app.route('/api/user/profile', methods=['GET'])
@require_auth
def get_user_profile():
"""Get current user's profile information"""
user_id = get_current_user_id()
if not user_id:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
profile = load_user_profile(user_id)
return jsonify({
'success': True,
'profile': {
'username': profile.get('username', 'User'),
'fullname': profile.get('fullname', profile.get('username', 'User')),
'email': profile.get('email', ''),
'avatar': profile.get('avatar', None),
'theme': profile.get('theme', 'dark'),
'user_id': user_id
}
})
@app.route('/api/user/profile', methods=['PUT'])
@require_auth
def update_user_profile():
"""Update current user's profile information"""
user_id = get_current_user_id()
if not user_id:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
data = request.json
profile = load_user_profile(user_id)
if 'username' in data:
profile['username'] = data['username']
if 'fullname' in data:
profile['fullname'] = data['fullname']
if 'avatar' in data:
profile['avatar'] = data['avatar']
if save_user_profile(user_id, profile):
return jsonify({'success': True, 'profile': profile})
else:
return jsonify({'success': False, 'error': 'Failed to save profile'}), 500
# ============= MEDIA API ENDPOINTS =============
from media import media_handler
@app.route('/api/media/search/image', methods=['POST'])
def search_image():
data = request.json
query = data.get('query', '').strip()
provider = data.get('provider')
if not query:
return jsonify({'error': 'No query provided'}), 400
if provider:
result = media_handler.search_images(query, provider)
else:
result = media_handler.search_with_fallback(query, 'image')
return jsonify(result)
@app.route('/api/media/search/video', methods=['POST'])
def search_video():
data = request.json
query = data.get('query', '').strip()
provider = data.get('provider')
if not query:
return jsonify({'error': 'No query provided'}), 400
if provider:
result = media_handler.search_videos(query, provider)
else:
result = media_handler.search_with_fallback(query, 'video')
return jsonify(result)
@app.route('/api/media/regenerate', methods=['POST'])
def regenerate_media():
data = request.json
query = data.get('query', '').strip()
media_type = data.get('media_type', 'image')
current_id = data.get('current_id')
provider = data.get('provider')
if not query:
return jsonify({'error': 'No query provided'}), 400
result = media_handler.regenerate_with_fallback(query, media_type, current_id, provider)
return jsonify(result)
@app.route('/api/media/analyze/video', methods=['POST'])
def analyze_video():
data = request.json
video_url = data.get('video_url', '')
video_name = data.get('video_name', 'video.mp4')
if not video_url:
return jsonify({'error': 'No video URL provided'}), 400
result = media_handler.analyze_video(video_url, video_name)
return jsonify(result)
@app.route('/api/media/analyze/image', methods=['POST'])
def analyze_image():
"""Analyze image from URL or uploaded file using OCR first, then Vision, then AI"""
image_content = None
image_name = None
image_url = None
# No timeout - let the request complete naturally
# Mobile devices may need more time for AI processing
# Check if it's a file upload or URL
if request.files and 'image_file' in request.files:
# Handle file upload
file = request.files['image_file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
image_name = secure_filename(file.filename)
image_content = file.read()
image_url = None
print(f"πŸ“ Received uploaded image: {image_name} ({len(image_content)} bytes)")
else:
# Handle URL
data = request.json or {}
image_url = data.get('image_url', '')
image_name = data.get('image_name', 'image.jpg')
if not image_url:
return jsonify({'error': 'No image URL or file provided'}), 400
# Download the image from URL
try:
print(f"πŸ“₯ Downloading image from URL: {image_url}")
response = requests.get(image_url, timeout=30, stream=True)
response.raise_for_status()
image_content = response.content
print(f"βœ… Downloaded {len(image_content)} bytes")
except requests.exceptions.RequestException as e:
print(f"❌ Failed to download image: {e}")
return jsonify({
'success': False,
'error': f'Failed to download image: {str(e)}'
}), 500
if not image_content:
return jsonify({'success': False, 'error': 'No image content to analyze'}), 400
temp_file_path = None
try:
import tempfile
import re
ext = image_name.split('.')[-1].lower() if '.' in image_name else 'jpg'
if ext not in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']:
ext = 'jpg'
with tempfile.NamedTemporaryFile(suffix=f'.{ext}', delete=False) as tmp:
tmp.write(image_content)
tmp.flush()
temp_file_path = tmp.name
print(f"πŸ“ Image saved to temp file: {temp_file_path}")
# STEP 1: Try EasyOCR first for text extraction
ocr_text = ""
try:
print("πŸ“ Running EasyOCR for text extraction...")
import easyocr
reader = easyocr.Reader(['en'], gpu=False)
result = reader.readtext(image_content)
if result:
ocr_text = ' '.join([item[1] for item in result])
print(f"βœ… EasyOCR extracted {len(ocr_text)} characters")
except ImportError:
print("⚠️ EasyOCR not installed, trying fallback OCR...")
try:
# Fallback to binary_processor OCR
processed_output = binary_processor.process_image(image_content, image_name)
if "EXTRACTED TEXT:" in processed_output:
ocr_match = re.search(r'EXTRACTED TEXT:\n(.*?)(?=\n--- END IMAGE ANALYSIS|--- END FILE)', processed_output, re.DOTALL)
if ocr_match:
ocr_text = ocr_match.group(1).strip()
print(f"βœ… Fallback OCR extracted {len(ocr_text)} characters")
elif "No text detected" not in processed_output:
lines = processed_output.split('\n')
for i, line in enumerate(lines):
if 'πŸ“ EXTRACTED TEXT:' in line and i + 1 < len(lines):
ocr_text = lines[i + 1].strip()
print(f"βœ… Fallback OCR extracted {len(ocr_text)} characters")
break
except Exception as e:
print(f"⚠️ Fallback OCR failed: {e}")
except Exception as e:
print(f"⚠️ EasyOCR error: {e}")
# STEP 2: Try Vision Model for image understanding
vision_analysis = ""
try:
from vision import get_vision_model
vision_model = get_vision_model()
print("πŸ” Analyzing image content with Vision Model...")
vision_analysis = vision_model.analyze_image(image_content)
if vision_analysis:
print(f"βœ… Vision Model analysis ({len(vision_analysis)} chars)")
except Exception as e:
print(f"⚠️ Vision Model error: {e}")
# STEP 3: Send both OCR and Vision results to AI for comprehensive analysis
clean_analysis = ""
# Prepare prompt for AI - enhanced for better understanding with proper synthesis
ai_prompt_parts = []
# Get image dimensions and basic info
try:
from PIL import Image
import io
pil_img = Image.open(io.BytesIO(image_content))
width, height = pil_img.size
img_info = f"Image dimensions: {width} by {height} pixels"
ai_prompt_parts.append(img_info)
except:
pass
if ocr_text and len(ocr_text.strip()) > 10:
# Clean and format OCR text
cleaned_ocr = re.sub(r'\s+', ' ', ocr_text[:2000]).strip()
ai_prompt_parts.append(f"TEXT FROM IMAGE:\n{cleaned_ocr}")
if vision_analysis and len(vision_analysis) > 10:
ai_prompt_parts.append(f"VISUAL DESCRIPTION:\n{vision_analysis}")
if not ai_prompt_parts:
# Fallback: use filename
name_without_ext = re.sub(r'\.[^.]+$', '', image_name)
clean_name = re.sub(r'[_\-\.]', ' ', name_without_ext)
clean_name = re.sub(r'\d+', '', clean_name).strip()
if clean_name and len(clean_name) > 3:
clean_analysis = f"This image appears to be related to {clean_name}. However, no detailed text or visual description could be extracted from the image."
else:
clean_analysis = "The image has been processed, but no readable text or recognizable content was detected. This could be due to the image quality, low contrast, or lack of clear visual elements."
print("⚠️ Using filename fallback")
else:
# Combine both sources for AI analysis with improved prompt
combined_context = "\n\n".join(ai_prompt_parts)
ai_analysis_prompt = f"""You are an expert image analyst. Based on the information below, write a clear, well-structured analysis.
INFORMATION FROM IMAGE:
{combined_context}
CRITICAL RULES:
1. NEVER use phrases like "The image contains text that reads" or "OCR text found" or "The image also contains"
2. NEVER just list the extracted text - instead, read it and describe what it means
3. Write in complete sentences with proper punctuation
4. Break long text into 2-3 short paragraphs
5. Each paragraph should be 2-4 sentences
6. Be descriptive but concise
EXAMPLE OF GOOD OUTPUT (for a government services portal):
"The image shows a government services dashboard with multiple service categories. These include licensing, permits, registration, and payments. A driving license renewal option is available for 650 Kenyan Shillings, with a processing time of 3-5 business days."
EXAMPLE OF GOOD OUTPUT (for a case study question):
"The image displays a case study discussion question about TrendSwift Fashion, an online clothing retailer. The company ships to 30 countries but faces challenges with disconnected systems. They use separate platforms for their website, warehouse robots, and delivery partners."
Now write your analysis following the GOOD EXAMPLE format. Keep it concise and well-organized:"""
try:
from models import query_openrouter
print("πŸ€– Sending to AI for comprehensive analysis...")
ai_response = query_openrouter(ai_analysis_prompt, context=None, is_code_generation=False)
if ai_response and len(ai_response) > 50:
# Post-process the AI response to clean it up
clean_analysis = ai_response
# First, remove any "The image contains text that reads:" patterns
clean_analysis = re.sub(r'The image contains text that reads:?\s*', '', clean_analysis, flags=re.IGNORECASE)
clean_analysis = re.sub(r'Also contains text:?\s*', '', clean_analysis, flags=re.IGNORECASE)
clean_analysis = re.sub(r'OCR text found:?\s*', '', clean_analysis, flags=re.IGNORECASE)
clean_analysis = re.sub(r'Text extracted from the image:?\s*', '', clean_analysis, flags=re.IGNORECASE)
clean_analysis = re.sub(r'TEXT FROM IMAGE:?\s*', '', clean_analysis, flags=re.IGNORECASE)
clean_analysis = re.sub(r'VISUAL DESCRIPTION:?\s*', '', clean_analysis, flags=re.IGNORECASE)
# Fix formatting - ensure proper sentence breaks
sentences = re.split(r'(?<=[.!?])\s+(?=[A-Z])', clean_analysis)
if len(sentences) > 1:
clean_analysis = ' '.join(sentences)
# Ensure first letter is capitalized
if clean_analysis and len(clean_analysis) > 0:
clean_analysis = clean_analysis[0].upper() + clean_analysis[1:] if len(clean_analysis) > 1 else clean_analysis.upper()
# Remove any remaining "the image also contains" patterns
clean_analysis = re.sub(r'\bThe image also contains text\b.*?\.', '', clean_analysis, flags=re.IGNORECASE)
clean_analysis = re.sub(r'\bAlso contains text\b.*?\.', '', clean_analysis, flags=re.IGNORECASE)
# Clean up multiple spaces
clean_analysis = re.sub(r'\s+', ' ', clean_analysis)
clean_analysis = re.sub(r'\n\s*\n', '\n\n', clean_analysis)
# If the analysis is too short or still has bad patterns, try to improve it
if len(clean_analysis) < 100 or 'contains text' in clean_analysis.lower():
if ocr_text and len(ocr_text) > 50:
ocr_lines = ocr_text.split('.')
meaningful_lines = [line.strip() for line in ocr_lines if len(line.strip()) > 20]
if meaningful_lines:
clean_analysis = '. '.join(meaningful_lines[:3]) + '.'
if len(clean_analysis) < 50:
clean_analysis = f"This image shows: {ocr_text[:200]}"
print(f"βœ… AI analysis complete ({len(clean_analysis)} chars)")
else:
# Fallback with better formatting
if vision_analysis and len(vision_analysis) > 20:
clean_analysis = vision_analysis
if ocr_text and len(ocr_text.strip()) > 10:
ocr_clean = re.sub(r'\s+', ' ', ocr_text[:300]).strip()
clean_analysis += f" {ocr_clean}"
elif ocr_text and len(ocr_text.strip()) > 10:
ocr_clean = re.sub(r'\s+', ' ', ocr_text[:500]).strip()
clean_analysis = ocr_clean
elif vision_analysis:
clean_analysis = vision_analysis
else:
clean_analysis = "The image has been analyzed, but no specific content could be identified. Please try a clearer image or different source."
except Exception as e:
print(f"⚠️ AI analysis failed: {e}, using fallback")
if vision_analysis and len(vision_analysis) > 20:
clean_analysis = vision_analysis
if ocr_text and len(ocr_text.strip()) > 10:
ocr_clean = re.sub(r'\s+', ' ', ocr_text[:300]).strip()
clean_analysis += f" {ocr_clean}"
elif ocr_text and len(ocr_text.strip()) > 10:
ocr_clean = re.sub(r'\s+', ' ', ocr_text[:500]).strip()
clean_analysis = ocr_clean
else:
clean_analysis = "The image has been analyzed, but no specific content could be identified. Please try a clearer image or different source."
# Final cleanup - ensure proper paragraph structure
if clean_analysis:
# Split into paragraphs (by double newline or by sentence grouping)
if '\n\n' not in clean_analysis and len(clean_analysis) > 300:
sentences = re.split(r'(?<=[.!?])\s+(?=[A-Z])', clean_analysis)
if len(sentences) > 3:
paragraphs = []
for i in range(0, len(sentences), 3):
para = ' '.join(sentences[i:i+3])
paragraphs.append(para)
clean_analysis = '\n\n'.join(paragraphs)
# Ensure proper capitalization
clean_analysis = re.sub(r'\. ([a-z])', lambda m: '. ' + m.group(1).upper(), clean_analysis)
clean_analysis = re.sub(r'\! ([a-z])', lambda m: '! ' + m.group(1).upper(), clean_analysis)
clean_analysis = re.sub(r'\? ([a-z])', lambda m: '? ' + m.group(1).upper(), clean_analysis)
# Ensure first letter is capitalized
if clean_analysis and len(clean_analysis) > 0:
clean_analysis = clean_analysis[0].upper() + clean_analysis[1:] if len(clean_analysis) > 1 else clean_analysis.upper()
if not clean_analysis or len(clean_analysis) < 10:
# Create a fallback analysis from OCR text if available
if ocr_text and len(ocr_text) > 20:
ocr_clean = re.sub(r'\s+', ' ', ocr_text[:300]).strip()
sentences = re.split(r'[.!?]+', ocr_clean)
meaningful = [s.strip() for s in sentences if len(s.strip()) > 15]
if meaningful:
clean_analysis = '. '.join(meaningful[:3]) + '.'
else:
clean_analysis = ocr_clean[:200]
elif vision_analysis and len(vision_analysis) > 20:
clean_analysis = vision_analysis
else:
clean_analysis = "The image has been analyzed, but no specific content could be identified. Please try a clearer image or different source."
# Clean up any remaining metadata patterns
metadata_patterns = [
r'Photographer:?\s*\S+',
r'Photo by\s+\S+',
r'Credit:?\s*\S+',
r'Source:?\s*\S+',
r'Copyright\s+[Β©]?\s*\S+',
r'Β©\s*\d{4}\s*\S+',
r'\[.*?\]',
r'\(.*?(credit|courtesy|source).*?\)',
r'Image courtesy of\s+\S+',
r'Sourced from\s+\S+',
r'^\w+:\s*$',
r'\*\*Image Analysis:.*?\*\*',
r'\*\*AI Analysis:\*\*',
r'\*\*OCR Extracted Text Found:\*\*',
r'\*\*Note:\*\*',
r'\*\*Image Details:\*\*',
r'\*\*Final Analysis:\*\*',
r'---.*?---',
]
for pattern in metadata_patterns:
clean_analysis = re.sub(pattern, '', clean_analysis, flags=re.IGNORECASE | re.MULTILINE)
clean_analysis = re.sub(r'\*\*([^*]+)\*\*', r'\1', clean_analysis)
clean_analysis = re.sub(r'`([^`]+)`', r'\1', clean_analysis)
clean_analysis = re.sub(r'#+\s*', '', clean_analysis)
clean_analysis = re.sub(r'\s+', ' ', clean_analysis)
clean_analysis = re.sub(r'\n{3,}', '\n\n', clean_analysis)
if clean_analysis and len(clean_analysis) > 0:
clean_analysis = clean_analysis[0].upper() + clean_analysis[1:] if len(clean_analysis) > 1 else clean_analysis.upper()
clean_analysis = re.sub(r'\s*[|;:]\s*$', '', clean_analysis)
clean_analysis = clean_analysis.strip()
if not clean_analysis or len(clean_analysis) < 5:
clean_analysis = "The image has been analyzed, but no specific content could be identified."
# Ensure analysis is a string and not None
if not clean_analysis:
clean_analysis = "The image has been analyzed, but no specific content could be identified."
if not isinstance(clean_analysis, str):
clean_analysis = str(clean_analysis)
# Return base64 image for preview
import base64
image_base64 = base64.b64encode(image_content).decode('utf-8')
image_data_url = f"data:image/{ext};base64,{image_base64}"
response_data = {
'success': True,
'image_url': image_url if image_url else '',
'image_name': image_name if image_name else 'image',
'analysis': clean_analysis[:2000],
'image_data': image_data_url if image_data_url else '',
'is_upload': image_url is None
}
print(f"βœ… Analysis complete: {clean_analysis[:100]}...")
return jsonify(response_data)
except Exception as e:
print(f"❌ Error analyzing image: {e}")
import traceback
traceback.print_exc()
error_message = str(e)
if len(error_message) > 200:
error_message = error_message[:200] + "..."
return jsonify({
'success': False,
'error': f'Error analyzing image: {error_message}',
'analysis': "The image could not be analyzed due to an error. Please try again with a different image."
}), 500
finally:
# Cancel the timeout alarm
import platform
if platform.system() != 'Windows':
try:
signal.alarm(0)
except:
pass
if temp_file_path:
try:
import os
os.unlink(temp_file_path)
except:
pass
@app.route('/api/media/download', methods=['POST'])
def download_media():
"""Download media directly through the app"""
data = request.json
url = data.get('url', '')
media_type = data.get('media_type', 'image')
if not url:
return jsonify({'error': 'No URL provided'}), 400
result = media_handler.download_media_direct(url, media_type)
if result.get('success'):
from flask import send_file
import io
return send_file(
io.BytesIO(result['content']),
mimetype=result.get('content_type', 'application/octet-stream'),
as_attachment=True,
download_name=url.split('/')[-1] or (media_type + '.file')
)
else:
return jsonify({'error': result.get('error', 'Download failed')}), 500
@app.route('/api/save_media_message', methods=['POST'])
def save_media_message():
data = request.json
conversation_id = data.get('conversation_id')
user_message = data.get('user_message', '')
assistant_response = data.get('assistant_response', '')
media_data = data.get('media_data')
is_error = data.get('is_error', False)
if not conversation_id or conversation_id not in conversations:
conversation_id = str(uuid.uuid4())
conversations[conversation_id] = {
'id': conversation_id,
'messages': [],
'title': 'New Chat',
'created_at': datetime.now().isoformat(),
'last_updated': datetime.now().isoformat(),
'versions': {},
'current_version_index': {},
'branch_root': None,
'pending_attachments': []
}
conv = conversations[conversation_id]
user_msg = {
'role': 'user',
'content': user_message,
'timestamp': datetime.now().isoformat(),
'attachments': None
}
conv['messages'].append(user_msg)
assistant_msg = {
'role': 'assistant',
'content': assistant_response,
'timestamp': datetime.now().isoformat(),
'media_data': media_data,
'is_media_result': True,
'is_error': is_error
}
conv['messages'].append(assistant_msg)
if conv['title'] == 'New Chat' and user_message:
raw_title = generate_chat_title(conv['messages'])
conv['title'] = sanitize_filename(raw_title)
conv['last_updated'] = datetime.now().isoformat()
save_conversations(conversations)
return jsonify({
'success': True,
'conversation_id': conversation_id,
'title': conv['title']
})
# ============= DOCS API ENDPOINTS =============
doc_processor = DocumentProcessor()
doc_creator = DocumentCreator()
@app.route('/api/docs/generate', methods=['POST'])
def docs_generate():
data = request.json
prompt = data.get('prompt', '')
doc_type = data.get('doc_type', 'word')
template_id = data.get('template_id')
if not prompt:
return jsonify({'error': 'No prompt provided'}), 400
# Create a better enhanced prompt for document generation
doc_type_names = {
'word': 'Word Document',
'pdf': 'PDF Document',
'ppt': 'PowerPoint Presentation',
'excel': 'Excel Spreadsheet',
'csv': 'CSV File',
'txt': 'Text File',
'image': 'Image Description'
}
doc_type_name = doc_type_names.get(doc_type, 'Document')
# Build a comprehensive system prompt for document generation
system_prompt = f"""You are an expert document creator. Generate a complete, well-formatted {doc_type_name} based on the user's request.
CRITICAL FORMATTING RULES:
1. Use # for main titles (H1)
2. Use ## for section headings (H2)
3. Use ### for subsections (H3)
4. Use - or * for bullet points
5. Use numbers (1., 2., 3.) for numbered lists
6. For tables, use markdown table format with | and -
7. For emphasis, use **bold** and *italic*
8. DO NOT include any markdown code block markers (```) around the entire document
9. DO NOT include phrases like "Here is your document" or "I've created"
10. Output ONLY the document content, no explanations before or after
11. Ensure the document is complete, professional, and well-structured
12. Use proper line breaks between sections
Generate a complete, ready-to-use {doc_type_name} now."""
# Add template-specific instructions
template_instructions = ""
if template_id:
template_instructions = f"\n\nUse the following template structure: {template_id} template format."
enhanced_prompt = f"{system_prompt}\n\nUSER REQUEST: {prompt}{template_instructions}\n\nGenerate the document now:"
# Try multiple times with retry logic
max_retries = 3
last_error = None
for attempt in range(max_retries):
try:
from models import query_openrouter
# Create context with system message
context = [{"role": "system", "content": system_prompt}]
print(f"πŸ”„ Document generation attempt {attempt + 1}/{max_retries} for {doc_type} document...")
response = query_openrouter(enhanced_prompt, context=context, is_code_generation=False)
if response and len(response) > 50:
# Clean up the response
import re
# Remove any markdown code block wrappers
cleaned = re.sub(r'^```[\w]*\n?', '', response)
cleaned = re.sub(r'\n?```$', '', cleaned)
# Remove any "Here is..." introductory phrases
cleaned = re.sub(r'^(Here is|I\'ve created|Below is|The following is).*?\n', '', cleaned, flags=re.IGNORECASE)
cleaned = cleaned.strip()
if len(cleaned) < 50:
# If cleaning removed too much, use original but filter
cleaned = response
cleaned = re.sub(r'^(Here is|I\'ve created|Below is).*?\n', '', cleaned, flags=re.IGNORECASE)
cleaned = cleaned.strip()
if len(cleaned) > 20: # Valid response
print(f"βœ… Document generation successful on attempt {attempt + 1}")
return jsonify({
'success': True,
'content': cleaned
})
else:
print(f"⚠️ Attempt {attempt + 1} returned short/empty response")
last_error = "Empty response from AI"
except Exception as e:
print(f"⚠️ Attempt {attempt + 1} failed: {e}")
last_error = str(e)
if attempt < max_retries - 1:
import time
time.sleep(2) # Wait 2 seconds before retry
continue
# If OpenRouter failed all retries, try Pollinations fallback
print("OpenRouter failed all retries, trying Pollinations.ai fallback...")
for attempt in range(max_retries):
try:
from models import call_pollinations_ai
messages = [{"role": "user", "content": enhanced_prompt}]
pollinations_response = call_pollinations_ai(messages)
if pollinations_response and len(pollinations_response) > 50:
# Clean up Pollinations response
import re
cleaned = re.sub(r'^```[\w]*\n?', '', pollinations_response)
cleaned = re.sub(r'\n?```$', '', cleaned)
cleaned = re.sub(r'^(Here is|I\'ve created|Below is).*?\n', '', cleaned, flags=re.IGNORECASE)
cleaned = cleaned.strip()
return jsonify({
'success': True,
'content': cleaned if len(cleaned) > 50 else pollinations_response
})
except Exception as poll_err:
print(f"Pollinations fallback attempt {attempt + 1} failed: {poll_err}")
if attempt < max_retries - 1:
import time
time.sleep(2)
# If all attempts failed
error_msg = f"AI generation failed after {max_retries} attempts. Last error: {last_error}"
print(f"❌ {error_msg}")
return jsonify({'error': error_msg}), 500
@app.route('/api/docs/create', methods=['POST'])
def docs_create():
data = request.json
doc_type = data.get('doc_type', 'word')
content = data.get('content', '')
filename = data.get('filename', 'document')
template_id = data.get('template_id')
if not content:
return jsonify({'error': 'No content provided'}), 400
try:
output_path = doc_creator.create_document(content, doc_type, filename, template_id)
if output_path and output_path.exists():
download_url = f'/api/docs/download/{output_path.name}'
return jsonify({
'success': True,
'filename': output_path.name,
'download_url': download_url
})
else:
return jsonify({'error': 'Failed to create document'}), 500
except Exception as e:
print(f"Error creating document: {e}")
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
@app.route('/api/docs/download/<filename>', methods=['GET'])
def docs_download(filename):
file_path = doc_creator.output_dir / filename
if file_path.exists():
return send_file(
file_path,
as_attachment=True,
download_name=filename,
mimetype='application/octet-stream'
)
return jsonify({'error': 'File not found'}), 404
@app.route('/api/docs/extract', methods=['POST'])
def docs_extract():
if 'file' not in request.files:
return jsonify({'error': 'No file uploaded'}), 400
file = request.files['file']
extract_type = request.form.get('extract_type', 'text')
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
filename = secure_filename(file.filename)
file_content = file.read()
try:
temp_path = doc_creator.temp_dir / filename
with open(temp_path, 'wb') as f:
f.write(file_content)
extracted_content = ""
suffix = os.path.splitext(filename)[1].lower()
if suffix in ['.docx', '.doc']:
if extract_type == 'text':
result = doc_processor.read_word_document(str(temp_path))
extracted_content = '\n'.join(result['paragraphs'])
elif extract_type == 'metadata':
result = doc_processor.read_word_document(str(temp_path))
extracted_content = f"Paragraphs: {result['metadata']['paragraph_count']}\nTables: {result['metadata']['table_count']}"
else:
extracted_content = doc_processor.extract_text_from_file(str(temp_path))
elif suffix == '.pdf':
if extract_type == 'text':
result = doc_processor.read_pdf(str(temp_path), extract_tables=False)
extracted_content = '\n'.join([page['text'] for page in result['pages']])
elif extract_type == 'metadata':
result = doc_processor.read_pdf(str(temp_path), extract_tables=False)
extracted_content = f"Pages: {result['page_count']}\nMetadata: {result.get('metadata', {})}"
else:
extracted_content = doc_processor.extract_text_from_file(str(temp_path))
elif suffix in ['.pptx', '.ppt']:
result = doc_processor.read_presentation(str(temp_path))
if extract_type == 'slides':
for i, slide in enumerate(result['slides']):
extracted_content += f"\n--- Slide {i+1} ---\n{slide['text_content']}\n"
elif extract_type == 'notes':
for i, slide in enumerate(result['slides']):
if slide.get('notes'):
extracted_content += f"\n--- Slide {i+1} Notes ---\n{slide['notes']}\n"
else:
for slide in result['slides']:
extracted_content += slide['text_content'] + '\n'
elif suffix in ['.xlsx', '.xls', '.csv']:
import pandas as pd
if suffix == '.csv':
df = pd.read_csv(str(temp_path))
else:
df = pd.read_excel(str(temp_path))
extracted_content = df.to_string()
elif suffix in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']:
extracted_content = doc_processor.extract_text_from_image(str(temp_path))
elif suffix in ['.mp4', '.avi', '.mov', '.mkv']:
info = doc_processor.extract_video_info(str(temp_path))
extracted_content = json.dumps(info, indent=2)
else:
extracted_content = doc_processor.extract_text_from_file(str(temp_path))
return jsonify({
'success': True,
'content': extracted_content[:50000],
'filename': filename
})
except Exception as e:
print(f"Error extracting: {e}")
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
@app.route('/api/docs/merge', methods=['POST'])
def docs_merge():
files = request.files.getlist('files')
output_name = request.form.get('output_name', 'merged_document')
if len(files) < 2:
return jsonify({'error': 'Need at least 2 files to merge'}), 400
try:
temp_files = []
for file in files:
if file.filename and file.filename.lower().endswith('.pdf'):
temp_path = doc_creator.temp_dir / secure_filename(file.filename)
file.save(str(temp_path))
temp_files.append(str(temp_path))
output_filename = f"{output_name}.pdf"
result_path = doc_processor.merge_pdfs(temp_files, output_filename)
for temp_file in temp_files:
if os.path.exists(temp_file):
os.unlink(temp_file)
download_url = f'/api/docs/download/{output_filename}'
return jsonify({
'success': True,
'filename': output_filename,
'download_url': download_url
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/docs/split', methods=['POST'])
def docs_split():
if 'file' not in request.files:
return jsonify({'error': 'No file uploaded'}), 400
file = request.files['file']
pages_per_file = int(request.form.get('pages_per_file', 1))
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if not file.filename.lower().endswith('.pdf'):
return jsonify({'error': 'File must be PDF'}), 400
try:
temp_path = doc_creator.temp_dir / secure_filename(file.filename)
file.save(str(temp_path))
split_dir = doc_creator.temp_dir / 'split_output'
split_files = doc_processor.split_pdf(str(temp_path), str(split_dir), pages_per_file)
result_files = []
for split_file in split_files:
filename = os.path.basename(split_file)
result_files.append({
'name': filename,
'url': f'/api/docs/download/{filename}'
})
import shutil
shutil.move(split_file, str(doc_creator.output_dir / filename))
os.unlink(temp_path)
return jsonify({
'success': True,
'files_count': len(result_files),
'files': result_files
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/docs/convert', methods=['POST'])
def docs_convert():
if 'file' not in request.files:
return jsonify({'error': 'No file uploaded'}), 400
file = request.files['file']
to_format = request.form.get('to_format', 'txt')
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
filename = secure_filename(file.filename)
file_content = file.read()
try:
temp_path = doc_creator.temp_dir / filename
with open(temp_path, 'wb') as f:
f.write(file_content)
ext_map = {
'txt': '.txt', 'docx': '.docx', 'pdf': '.pdf',
'xlsx': '.xlsx', 'csv': '.csv', 'html': '.html',
'jpg': '.jpeg', 'png': '.png', 'mp3': '.mp3'
}
output_ext = ext_map.get(to_format, '.txt')
output_filename = f"{os.path.splitext(filename)[0]}{output_ext}"
output_path = doc_processor.convert_document(str(temp_path), output_filename, output_format=to_format)
os.unlink(temp_path)
download_url = f'/api/docs/download/{os.path.basename(output_path)}'
return jsonify({
'success': True,
'filename': os.path.basename(output_path),
'format': to_format,
'download_url': download_url
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/docs/resize', methods=['POST'])
def docs_resize():
if 'file' not in request.files:
return jsonify({'error': 'No file uploaded'}), 400
file = request.files['file']
width = request.form.get('width')
height = request.form.get('height')
maintain_aspect = request.form.get('maintain_aspect', 'true').lower() == 'true'
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
filename = secure_filename(file.filename)
file_content = file.read()
try:
temp_path = doc_creator.temp_dir / filename
with open(temp_path, 'wb') as f:
f.write(file_content)
w = int(width) if width else None
h = int(height) if height else None
output_filename = f"resized_{filename}"
output_path = doc_processor.resize_image(
str(temp_path), output_filename,
width=w, height=h, maintain_aspect=maintain_aspect
)
from PIL import Image
img = Image.open(output_path)
new_width, new_height = img.size
os.unlink(temp_path)
download_url = f'/api/docs/download/{output_filename}'
return jsonify({
'success': True,
'filename': output_filename,
'width': new_width,
'height': new_height,
'download_url': download_url
})
except Exception as e:
return jsonify({'error': str(e)}), 500
# Register workspace routes
register_workspace_routes(app)
@app.route('/api/generated_image/<filename>')
@require_auth
def serve_generated_image(filename):
from flask import send_from_directory
import os
if '..' in filename or filename.startswith('/'):
return jsonify({'error': 'Invalid filename'}), 400
# Check if user owns this image
user_id = get_current_user_id()
user_image_path = get_user_image_path(user_id, filename)
if user_image_path and user_image_path.exists():
return send_from_directory(str(user_image_path.parent), filename)
# Fallback to global directory for backward compatibility
global_path = os.path.join(GENERATED_IMAGES_DIR, filename)
if os.path.exists(global_path):
return send_from_directory(GENERATED_IMAGES_DIR, filename)
return jsonify({'error': 'Image not found'}), 404
# ===========================================
# ADMIN ROUTES (for admin proxy)
# ===========================================
@app.route('/admin')
@require_auth
def admin_index():
"""Admin panel - requires admin authentication from proxy"""
if not is_admin():
return jsonify({'error': 'Admin access required'}), 403
return render_template('admin_dashboard.html')
@app.route('/admin/api/stats')
@require_auth
def admin_api_stats():
"""Get system statistics for admin dashboard"""
if not is_admin():
return jsonify({'error': 'Admin access required'}), 403
user_dir = '/data/users'
total_users = 0
total_conversations = 0
if os.path.exists(user_dir):
for user_id in os.listdir(user_dir):
total_users += 1
conv_path = os.path.join(user_dir, user_id, 'conversations.json')
if os.path.exists(conv_path):
try:
with open(conv_path, 'r') as f:
convs = json.load(f)
total_conversations += len(convs)
except:
pass
return jsonify({
'success': True,
'stats': {
'total_users': total_users,
'total_conversations': total_conversations,
'total_workspace_files': 0, # Would need to calculate
'total_generated_images': 0 # Would need to calculate
}
})
@app.route('/admin/api/users')
@require_auth
def admin_api_users():
"""Get list of all users for admin dashboard"""
if not is_admin():
return jsonify({'error': 'Admin access required'}), 403
user_dir = '/data/users'
users = []
if os.path.exists(user_dir):
for user_id in os.listdir(user_dir):
profile_path = os.path.join(user_dir, user_id, 'profile.json')
if os.path.exists(profile_path):
try:
with open(profile_path, 'r') as f:
profile = json.load(f)
users.append({
'user_id': user_id,
'username': profile.get('username', user_id[:8]),
'email': profile.get('email', ''),
'created_at': profile.get('created_at', ''),
'last_active': profile.get('last_active', '')
})
except:
pass
return jsonify({'success': True, 'users': users})
@app.route('/admin/api/users/<user_id>', methods=['DELETE'])
@require_auth
def admin_api_delete_user(user_id):
"""Delete a user and all their data"""
if not is_admin():
return jsonify({'error': 'Admin access required'}), 403
import shutil
user_dir = f'/data/users/{user_id}'
if os.path.exists(user_dir):
shutil.rmtree(user_dir)
return jsonify({'success': True})
return jsonify({'success': False, 'error': 'User not found'}), 404
if __name__ == '__main__':
print("\n" + "="*60)
print("πŸ” HenAi Server Started!")
print("="*60)
print("πŸ“ Visit: http://localhost:5000")
print("πŸ€– AI Mode: Hybrid - Pollinations.ai for conversations, OpenRouter for code")
print(" πŸ“ Conversations/Explanations: Pollinations.ai (fast, prioritized)")
print(" πŸ’» Code Generation: OpenRouter only (with multiple free models)")
print("πŸ–ΌοΈ Media Search: Images & Videos from Pixabay and Pexels")
print("🎬 Video Analysis: TwelveLabs API")
print("πŸ’‘ Commands: /search, /extract, /code, /image, /help")
print("πŸ“‹ Features: Copy, Edit, Regenerate, Version Toggle, Rename, Export")
print("πŸ“Ž File Support: Text, Code, Documents (PDF, DOCX, etc.)")
print("πŸ“„ Document Creation: Word, Excel, PowerPoint, PDF, Images")
print("πŸ”€ Branching: Full version history with sibling and child branches")
print("="*60 + "\n")
port = int(os.environ.get('PORT', 7860))
app.run(host='0.0.0.0', port=port, debug=False)