From 1f9535d6833b1d6919751ca571f64c2e1bec36f9 Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 24 Mar 2026 05:15:50 +0000 Subject: [PATCH] Add complete MOXIE web UI with authentication and user management - New web UI with OpenWebUI-like interface using Tailwind CSS - SQLite-based authentication with session management - User registration, login, and profile pages - Chat interface with conversation history - Streaming responses with visible thinking phase - File attachments support - User document uploads in profile - Rate limiting (5 requests/day for free users) - Admin panel user management with promote/demote/delete - Custom color theme matching balloon logo design - Compatible with Nuitka build system --- moxie/admin/templates/comfyui.html | 1 + moxie/admin/templates/dashboard.html | 10 +- moxie/admin/templates/documents.html | 1 + moxie/admin/templates/endpoints.html | 1 + moxie/admin/templates/users.html | 191 +++++++ moxie/api/admin.py | 73 +++ moxie/auth/__init__.py | 7 + moxie/auth/models.py | 562 ++++++++++++++++++ moxie/build.py | 15 + moxie/main.py | 27 +- moxie/requirements.txt | 3 + moxie/web/__init__.py | 7 + moxie/web/routes.py | 825 +++++++++++++++++++++++++++ moxie/web/static/moxie_logo.jpg | Bin 0 -> 42762 bytes moxie/web/templates/chat.html | 622 ++++++++++++++++++++ moxie/web/templates/landing.html | 104 ++++ moxie/web/templates/login.html | 137 +++++ moxie/web/templates/profile.html | 257 +++++++++ moxie/web/templates/signup.html | 181 ++++++ 19 files changed, 3014 insertions(+), 10 deletions(-) create mode 100644 moxie/admin/templates/users.html create mode 100644 moxie/auth/__init__.py create mode 100644 moxie/auth/models.py create mode 100644 moxie/web/__init__.py create mode 100644 moxie/web/routes.py create mode 100644 moxie/web/static/moxie_logo.jpg create mode 100644 moxie/web/templates/chat.html create mode 100644 moxie/web/templates/landing.html create mode 100644 moxie/web/templates/login.html create mode 100644 moxie/web/templates/profile.html create mode 100644 moxie/web/templates/signup.html diff --git a/moxie/admin/templates/comfyui.html b/moxie/admin/templates/comfyui.html index 6c8b9c7..6f35a0b 100755 --- a/moxie/admin/templates/comfyui.html +++ b/moxie/admin/templates/comfyui.html @@ -14,6 +14,7 @@ Endpoints Documents ComfyUI + Users diff --git a/moxie/admin/templates/dashboard.html b/moxie/admin/templates/dashboard.html index 8cb117e..6bee9e1 100755 --- a/moxie/admin/templates/dashboard.html +++ b/moxie/admin/templates/dashboard.html @@ -14,6 +14,7 @@ Endpoints Documents ComfyUI + Users @@ -48,15 +49,14 @@
  • Configure your API endpoints in Endpoints
  • Upload documents in Documents
  • Configure ComfyUI workflows in ComfyUI
  • -
  • Connect open-webui to http://localhost:8000/v1
  • +
  • Access the MOXIE UI at http://localhost:8000/
  • -

    API Configuration

    -

    Configure open-webui to use this endpoint:

    - Base URL: http://localhost:8000/v1 -

    No API key required (leave blank)

    +

    MOXIE UI

    +

    Access the MOXIE chat interface:

    + http://localhost:8000/
    diff --git a/moxie/admin/templates/documents.html b/moxie/admin/templates/documents.html index 5019ac2..6d0673d 100755 --- a/moxie/admin/templates/documents.html +++ b/moxie/admin/templates/documents.html @@ -14,6 +14,7 @@ Endpoints Documents ComfyUI + Users diff --git a/moxie/admin/templates/endpoints.html b/moxie/admin/templates/endpoints.html index f00562d..508e377 100755 --- a/moxie/admin/templates/endpoints.html +++ b/moxie/admin/templates/endpoints.html @@ -14,6 +14,7 @@ Endpoints Documents ComfyUI + Users diff --git a/moxie/admin/templates/users.html b/moxie/admin/templates/users.html new file mode 100644 index 0000000..3007c1f --- /dev/null +++ b/moxie/admin/templates/users.html @@ -0,0 +1,191 @@ + + + + + + User Management - MOXIE Admin + + + + + + +
    +

    User Management

    + +
    +

    Manage user accounts, permissions, and request limits.

    +
    + +
    + {% for user in users %} +
    +
    +
    +

    + {{ user.username }} + {% if user.is_admin %} + ADMIN + {% endif %} +

    +

    {{ user.email }}

    +
    +
    +
    Requests today
    +
    {{ user.request_count }}/{{ user.request_limit }}
    +
    +
    + +
    +
    + Created: {{ user.created_at[:10] if user.created_at else 'N/A' }} +
    + +
    + {% if not user.is_admin %} + + {% else %} + {% if user.username != 'admin' %} + + {% endif %} + {% endif %} + +
    + + +
    + + {% if user.username != 'admin' %} + + {% endif %} +
    +
    +
    + {% endfor %} +
    + + {% if not users %} +
    +

    No users found.

    +
    + {% endif %} +
    + + + + diff --git a/moxie/api/admin.py b/moxie/api/admin.py index f25e32b..123424a 100755 --- a/moxie/api/admin.py +++ b/moxie/api/admin.py @@ -13,6 +13,7 @@ from pydantic import BaseModel from loguru import logger from config import settings, get_data_dir, get_workflows_dir, save_config_to_db, load_config_from_db +from auth.models import get_auth_manager router = APIRouter() @@ -236,6 +237,78 @@ async def delete_comfyui_workflow(workflow_type: str): return {"status": "success"} +# ============================================================================ +# User Management Routes +# ============================================================================ + +@router.get("/users", response_class=HTMLResponse) +async def users_page(request: Request): + """User management page.""" + auth = get_auth_manager() + users = auth.get_all_users() + + return templates.TemplateResponse( + "users.html", + { + "request": request, + "users": users, + "settings": settings + } + ) + + +@router.post("/users/{user_id}/promote") +async def promote_user(user_id: str): + """Promote a user to admin.""" + auth = get_auth_manager() + success = auth.promote_to_admin(user_id) + + if success: + logger.info(f"User promoted to admin: {user_id}") + return {"status": "success"} + + raise HTTPException(status_code=400, detail="Cannot promote this user") + + +@router.post("/users/{user_id}/demote") +async def demote_user(user_id: str): + """Demote a user from admin.""" + auth = get_auth_manager() + success = auth.demote_from_admin(user_id) + + if success: + logger.info(f"User demoted from admin: {user_id}") + return {"status": "success"} + + raise HTTPException(status_code=400, detail="Cannot demote this user") + + +@router.post("/users/{user_id}/limit") +async def update_user_limit(user_id: str, limit: int = Form(...)): + """Update a user's daily request limit.""" + auth = get_auth_manager() + success = auth.update_user_limit(user_id, limit) + + if success: + logger.info(f"User limit updated: {user_id} -> {limit}") + return {"status": "success"} + + raise HTTPException(status_code=400, detail="Failed to update user limit") + + +@router.delete("/users/{user_id}") +async def delete_user(user_id: str): + """Delete a user.""" + auth = get_auth_manager() + success = auth.delete_user(user_id) + + if success: + logger.info(f"User deleted: {user_id}") + return {"status": "success"} + + raise HTTPException(status_code=400, detail="Cannot delete this user") + + @router.get("/status") async def get_status(request: Request): """Get system status.""" diff --git a/moxie/auth/__init__.py b/moxie/auth/__init__.py new file mode 100644 index 0000000..ac8c489 --- /dev/null +++ b/moxie/auth/__init__.py @@ -0,0 +1,7 @@ +""" +MOXIE Authentication Module +User management, session handling, and rate limiting. +""" +from .models import AuthManager, User, get_auth_manager + +__all__ = ["AuthManager", "User", "get_auth_manager"] diff --git a/moxie/auth/models.py b/moxie/auth/models.py new file mode 100644 index 0000000..f1745da --- /dev/null +++ b/moxie/auth/models.py @@ -0,0 +1,562 @@ +""" +Authentication Models and Database Management +SQLite-based user management with password hashing and rate limiting. +""" +import sqlite3 +import json +import secrets +import hashlib +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any +from pathlib import Path +from pydantic import BaseModel +from loguru import logger + +from config import get_db_path + + +class User(BaseModel): + """User model.""" + id: str + username: str + email: str + password_hash: str + is_admin: bool = False + created_at: str + request_count: int = 0 + request_limit: int = 5 + last_request_date: Optional[str] = None + + +class Session(BaseModel): + """Session model.""" + token: str + user_id: str + created_at: str + expires_at: str + + +class AuthManager: + """ + Handles user authentication, sessions, and rate limiting. + + Features: + - SQLite-based user storage + - Password hashing with salt + - Session token management + - Daily request rate limiting + """ + + def __init__(self): + self.db_path = get_db_path() + self._init_db() + self._ensure_admin_exists() + logger.info("Auth Manager initialized") + + def _init_db(self) -> None: + """Initialize the auth database schema.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + # Users table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER DEFAULT 0, + created_at TEXT, + request_count INTEGER DEFAULT 0, + request_limit INTEGER DEFAULT 5, + last_request_date TEXT + ) + """) + + # Sessions table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at TEXT, + expires_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + + # User documents table (for profile document uploads) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_documents ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + filename TEXT NOT NULL, + file_type TEXT, + content BLOB, + created_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + + conn.commit() + conn.close() + + def _ensure_admin_exists(self) -> None: + """Create default admin user if not exists.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute("SELECT id FROM users WHERE username = ?", ("admin",)) + if not cursor.fetchone(): + # Create default admin + admin_id = self._generate_id() + password_hash = self._hash_password("changeme") + now = datetime.utcnow().isoformat() + + cursor.execute(""" + INSERT INTO users (id, username, email, password_hash, is_admin, created_at, request_limit) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (admin_id, "admin", "admin@moxie.local", password_hash, 1, now, 999999)) + + conn.commit() + logger.info("Default admin user created (username: admin, password: changeme)") + + conn.close() + + def _generate_id(self) -> str: + """Generate a unique ID.""" + import uuid + return str(uuid.uuid4()) + + def _hash_password(self, password: str) -> str: + """Hash a password with salt.""" + salt = secrets.token_hex(16) + hash_val = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) + return f"{salt}:{hash_val.hex()}" + + def _verify_password(self, password: str, stored_hash: str) -> bool: + """Verify a password against stored hash.""" + try: + salt, hash_val = stored_hash.split(':') + new_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) + return new_hash.hex() == hash_val + except Exception: + return False + + def _generate_token(self) -> str: + """Generate a secure session token.""" + return secrets.token_urlsafe(32) + + def create_user(self, username: str, email: str, password: str) -> Optional[User]: + """Create a new user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + try: + user_id = self._generate_id() + password_hash = self._hash_password(password) + now = datetime.utcnow().isoformat() + + cursor.execute(""" + INSERT INTO users (id, username, email, password_hash, created_at) + VALUES (?, ?, ?, ?, ?) + """, (user_id, username, email, password_hash, now)) + + conn.commit() + logger.info(f"User created: {username}") + + return User( + id=user_id, + username=username, + email=email, + password_hash=password_hash, + is_admin=False, + created_at=now, + request_count=0, + request_limit=5 + ) + except sqlite3.IntegrityError as e: + logger.warning(f"Failed to create user: {e}") + return None + finally: + conn.close() + + def authenticate(self, username: str, password: str) -> Optional[User]: + """Authenticate a user by username and password.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, username, email, password_hash, is_admin, created_at, + request_count, request_limit, last_request_date + FROM users WHERE username = ? + """, (username,)) + + row = cursor.fetchone() + conn.close() + + if not row: + return None + + if not self._verify_password(password, row[3]): + return None + + return User( + id=row[0], + username=row[1], + email=row[2], + password_hash=row[3], + is_admin=bool(row[4]), + created_at=row[5], + request_count=row[6] or 0, + request_limit=row[7] or 5, + last_request_date=row[8] + ) + + def create_session(self, user_id: str, expires_days: int = 7) -> str: + """Create a new session for a user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + token = self._generate_token() + now = datetime.utcnow() + expires = now + timedelta(days=expires_days) + + cursor.execute(""" + INSERT INTO sessions (token, user_id, created_at, expires_at) + VALUES (?, ?, ?, ?) + """, (token, user_id, now.isoformat(), expires.isoformat())) + + conn.commit() + conn.close() + + return token + + def validate_session(self, token: str) -> Optional[User]: + """Validate a session token and return the user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + now = datetime.utcnow().isoformat() + + cursor.execute(""" + SELECT s.user_id, u.username, u.email, u.password_hash, u.is_admin, + u.created_at, u.request_count, u.request_limit, u.last_request_date + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ? AND s.expires_at > ? + """, (token, now)) + + row = cursor.fetchone() + conn.close() + + if not row: + return None + + return User( + id=row[0], + username=row[1], + email=row[2], + password_hash=row[3], + is_admin=bool(row[4]), + created_at=row[5], + request_count=row[6] or 0, + request_limit=row[7] or 5, + last_request_date=row[8] + ) + + def delete_session(self, token: str) -> None: + """Delete a session (logout).""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute("DELETE FROM sessions WHERE token = ?", (token,)) + + conn.commit() + conn.close() + + def check_rate_limit(self, user_id: str) -> Dict[str, Any]: + """ + Check if user can make a request. + Returns dict with allowed status and remaining requests. + """ + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + today = datetime.utcnow().date().isoformat() + + cursor.execute(""" + SELECT request_count, request_limit, last_request_date + FROM users WHERE id = ? + """, (user_id,)) + + row = cursor.fetchone() + if not row: + conn.close() + return {"allowed": False, "remaining": 0, "limit": 0} + + request_count, request_limit, last_request_date = row + + # Reset count if it's a new day + if last_request_date != today: + request_count = 0 + + # Admins have unlimited requests + cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) + is_admin = cursor.fetchone()[0] + + if is_admin: + conn.close() + return {"allowed": True, "remaining": 999999, "limit": 999999} + + allowed = request_count < request_limit + remaining = max(0, request_limit - request_count) + + conn.close() + + return { + "allowed": allowed, + "remaining": remaining, + "limit": request_limit + } + + def increment_request_count(self, user_id: str) -> None: + """Increment the request count for a user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + today = datetime.utcnow().date().isoformat() + + # Check if we need to reset for new day + cursor.execute(""" + SELECT last_request_date FROM users WHERE id = ? + """, (user_id,)) + + row = cursor.fetchone() + if row and row[0] != today: + # New day, reset count + cursor.execute(""" + UPDATE users SET request_count = 1, last_request_date = ? + WHERE id = ? + """, (today, user_id)) + else: + # Same day, increment + cursor.execute(""" + UPDATE users SET request_count = request_count + 1, last_request_date = ? + WHERE id = ? + """, (today, user_id)) + + conn.commit() + conn.close() + + def get_user(self, user_id: str) -> Optional[User]: + """Get a user by ID.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, username, email, password_hash, is_admin, created_at, + request_count, request_limit, last_request_date + FROM users WHERE id = ? + """, (user_id,)) + + row = cursor.fetchone() + conn.close() + + if not row: + return None + + return User( + id=row[0], + username=row[1], + email=row[2], + password_hash=row[3], + is_admin=bool(row[4]), + created_at=row[5], + request_count=row[6] or 0, + request_limit=row[7] or 5, + last_request_date=row[8] + ) + + def get_all_users(self) -> List[Dict[str, Any]]: + """Get all users (for admin).""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, username, email, is_admin, created_at, request_count, request_limit + FROM users ORDER BY created_at DESC + """) + + users = [] + for row in cursor.fetchall(): + users.append({ + "id": row[0], + "username": row[1], + "email": row[2], + "is_admin": bool(row[3]), + "created_at": row[4], + "request_count": row[5] or 0, + "request_limit": row[6] or 5 + }) + + conn.close() + return users + + def promote_to_admin(self, user_id: str) -> bool: + """Promote a user to admin.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute("UPDATE users SET is_admin = 1 WHERE id = ?", (user_id,)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + def demote_from_admin(self, user_id: str) -> bool: + """Demote a user from admin.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + # Prevent demoting the last admin + cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1") + admin_count = cursor.fetchone()[0] + + cursor.execute("SELECT username FROM users WHERE id = ?", (user_id,)) + row = cursor.fetchone() + if row and row[0] == "admin": + conn.close() + return False # Cannot demote the default admin + + if admin_count <= 1: + conn.close() + return False # Cannot demote the last admin + + cursor.execute("UPDATE users SET is_admin = 0 WHERE id = ?", (user_id,)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + def update_user_limit(self, user_id: str, limit: int) -> bool: + """Update a user's daily request limit.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute("UPDATE users SET request_limit = ? WHERE id = ?", (limit, user_id)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + def change_password(self, user_id: str, new_password: str) -> bool: + """Change a user's password.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + password_hash = self._hash_password(new_password) + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + def delete_user(self, user_id: str) -> bool: + """Delete a user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + # Prevent deleting the default admin + cursor.execute("SELECT username FROM users WHERE id = ?", (user_id,)) + row = cursor.fetchone() + if row and row[0] == "admin": + conn.close() + return False + + # Delete sessions first + cursor.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) + # Delete user documents + cursor.execute("DELETE FROM user_documents WHERE user_id = ?", (user_id,)) + # Delete user + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + # User document management + def add_user_document(self, user_id: str, filename: str, file_type: str, content: bytes) -> str: + """Add a document to a user's profile.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + doc_id = self._generate_id() + now = datetime.utcnow().isoformat() + + cursor.execute(""" + INSERT INTO user_documents (id, user_id, filename, file_type, content, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, (doc_id, user_id, filename, file_type, content, now)) + + conn.commit() + conn.close() + + return doc_id + + def get_user_documents(self, user_id: str) -> List[Dict[str, Any]]: + """Get all documents for a user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, filename, file_type, created_at, LENGTH(content) as size + FROM user_documents WHERE user_id = ? ORDER BY created_at DESC + """, (user_id,)) + + docs = [] + for row in cursor.fetchall(): + docs.append({ + "id": row[0], + "filename": row[1], + "file_type": row[2], + "created_at": row[3], + "size": row[4] + }) + + conn.close() + return docs + + def delete_user_document(self, user_id: str, doc_id: str) -> bool: + """Delete a user document.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute("DELETE FROM user_documents WHERE id = ? AND user_id = ?", (doc_id, user_id)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + +# Global auth manager instance +_auth_manager: Optional[AuthManager] = None + + +def get_auth_manager() -> AuthManager: + """Get or create the global auth manager instance.""" + global _auth_manager + if _auth_manager is None: + _auth_manager = AuthManager() + return _auth_manager diff --git a/moxie/build.py b/moxie/build.py index 85b74e3..eb229bd 100755 --- a/moxie/build.py +++ b/moxie/build.py @@ -32,10 +32,19 @@ BUILD_CONFIG = { "loguru", "websockets", "numpy", + "starlette", + "anyio", + "httptools", + "python_multipart", ], "include_data_dirs": [ ("admin/templates", "admin/templates"), ("admin/static", "admin/static"), + ("web/templates", "web/templates"), + ("web/static", "web/static"), + ], + "include_data_files": [ + ("moxie_logo.jpg", "moxie_logo.jpg"), ], } @@ -72,6 +81,12 @@ def build(): if src_path.exists(): cmd.append(f"--include-data-dir={src}={dst}") + # Add data files + for src, dst in BUILD_CONFIG.get("include_data_files", []): + src_path = PROJECT_ROOT / src + if src_path.exists(): + cmd.append(f"--include-data-file={src}={dst}") + # Add main module cmd.append(BUILD_CONFIG["main_module"]) diff --git a/moxie/main.py b/moxie/main.py index 2e32bf8..bb41200 100755 --- a/moxie/main.py +++ b/moxie/main.py @@ -18,8 +18,10 @@ from loguru import logger from config import settings, get_data_dir, get_workflows_dir from api.routes import router as api_router from api.admin import router as admin_router +from web.routes import router as web_router from core.orchestrator import Orchestrator from rag.store import RAGStore +from auth.models import get_auth_manager # Configure logging @@ -40,6 +42,10 @@ async def lifespan(app: FastAPI): get_data_dir() get_workflows_dir() + # Initialize auth manager + auth_manager = get_auth_manager() + logger.info("Auth Manager initialized") + # Initialize RAG store app.state.rag_store = RAGStore() logger.info("RAG Store initialized") @@ -50,6 +56,7 @@ async def lifespan(app: FastAPI): logger.success(f"MOXIE ready on http://{settings.host}:{settings.port}") logger.info(f"Admin UI: http://{settings.host}:{settings.port}/{settings.admin_path}") + logger.info(f"Web UI: http://{settings.host}:{settings.port}/") yield @@ -60,14 +67,14 @@ async def lifespan(app: FastAPI): # Create FastAPI app app = FastAPI( title="MOXIE", - description="OpenAI-compatible API that orchestrates multiple AI services", + description="AI Assistant with multiple backend support", version="1.0.0", lifespan=lifespan, docs_url=None, # Hide docs redoc_url=None, # Hide redoc ) -# CORS middleware for open-webui +# CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -85,9 +92,19 @@ if admin_static_path.exists(): name="admin-static" ) +# Static files for web UI +web_static_path = Path(__file__).parent / "web" / "static" +if web_static_path.exists(): + app.mount( + "/static", + StaticFiles(directory=str(web_static_path)), + name="web-static" + ) + # Include routers -app.include_router(api_router, prefix="/v1") -app.include_router(admin_router, prefix=f"/{settings.admin_path}", tags=["admin"]) +app.include_router(web_router, tags=["web"]) # Web UI routes at root +app.include_router(api_router, prefix="/v1", tags=["api"]) # OpenAI-compatible API +app.include_router(admin_router, prefix=f"/{settings.admin_path}", tags=["admin"]) # Admin panel @app.get("/health") @@ -96,7 +113,7 @@ async def health_check(): return {"status": "healthy", "service": "moxie"} -# Serve favicon to avoid 404s +# Serve favicon @app.get("/favicon.ico") async def favicon(): return {"status": "not found"} diff --git a/moxie/requirements.txt b/moxie/requirements.txt index 56190e7..984d9cb 100755 --- a/moxie/requirements.txt +++ b/moxie/requirements.txt @@ -35,3 +35,6 @@ loguru>=0.7.0 # ComfyUI websockets>=12.0 + +# Auth +python-jose[cryptography]>=3.3.0 diff --git a/moxie/web/__init__.py b/moxie/web/__init__.py new file mode 100644 index 0000000..45ea593 --- /dev/null +++ b/moxie/web/__init__.py @@ -0,0 +1,7 @@ +""" +MOXIE Web UI Module +OpenWebUI-like interface for MOXIE. +""" +from .routes import router as web_router + +__all__ = ["web_router"] diff --git a/moxie/web/routes.py b/moxie/web/routes.py new file mode 100644 index 0000000..df006a8 --- /dev/null +++ b/moxie/web/routes.py @@ -0,0 +1,825 @@ +""" +Web UI Routes +Landing page, chat interface, and user profile. +""" +import json +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional, List +from fastapi import APIRouter, Request, Response, Cookie, Form, UploadFile, File, HTTPException, Depends +from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel +from loguru import logger + +from config import settings +from auth.models import get_auth_manager, User +from core.orchestrator import Orchestrator + + +router = APIRouter() + +# Templates +templates = Jinja2Templates(directory=Path(__file__).parent / "templates") + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +async def get_current_user( + request: Request, + session_token: Optional[str] = Cookie(None) +) -> Optional[User]: + """Get the current logged-in user from session cookie.""" + if not session_token: + # Try header as fallback + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + session_token = auth_header[7:] + + if not session_token: + return None + + auth = get_auth_manager() + return auth.validate_session(session_token) + + +async def require_user( + request: Request, + session_token: Optional[str] = Cookie(None) +) -> User: + """Require a logged-in user, redirect to login if not.""" + user = await get_current_user(request, session_token) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +# ============================================================================ +# Page Routes +# ============================================================================ + +@router.get("/", response_class=HTMLResponse) +async def landing_page(request: Request): + """Landing page - redirects to chat if logged in, otherwise shows login.""" + session_token = request.cookies.get("session_token") + if session_token: + auth = get_auth_manager() + user = auth.validate_session(session_token) + if user: + return HTMLResponse( + status_code=302, + headers={"Location": "/chat"} + ) + + return templates.TemplateResponse( + "landing.html", + {"request": request, "settings": settings} + ) + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, error: Optional[str] = None): + """Login page.""" + return templates.TemplateResponse( + "login.html", + {"request": request, "settings": settings, "error": error} + ) + + +@router.get("/signup", response_class=HTMLResponse) +async def signup_page(request: Request, error: Optional[str] = None): + """Signup page.""" + return templates.TemplateResponse( + "signup.html", + {"request": request, "settings": settings, "error": error} + ) + + +@router.post("/login") +async def login_submit( + response: Response, + username: str = Form(...), + password: str = Form(...) +): + """Process login form.""" + auth = get_auth_manager() + user = auth.authenticate(username, password) + + if not user: + return templates.TemplateResponse( + "login.html", + {"request": {}, "settings": settings, "error": "Invalid username or password"}, + status_code=401 + ) + + # Create session + token = auth.create_session(user.id) + + # Set cookie and redirect + response = JSONResponse({"success": True, "redirect": "/chat"}) + response.set_cookie( + key="session_token", + value=token, + httponly=True, + max_age=7 * 24 * 60 * 60, # 7 days + samesite="lax" + ) + + return response + + +@router.post("/signup") +async def signup_submit( + request: Request, + username: str = Form(...), + email: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...) +): + """Process signup form.""" + if password != confirm_password: + return templates.TemplateResponse( + "signup.html", + {"request": request, "settings": settings, "error": "Passwords do not match"}, + status_code=400 + ) + + if len(password) < 6: + return templates.TemplateResponse( + "signup.html", + {"request": request, "settings": settings, "error": "Password must be at least 6 characters"}, + status_code=400 + ) + + auth = get_auth_manager() + user = auth.create_user(username, email, password) + + if not user: + return templates.TemplateResponse( + "signup.html", + {"request": request, "settings": settings, "error": "Username or email already exists"}, + status_code=400 + ) + + # Create session + token = auth.create_session(user.id) + + # Set cookie and redirect + response = JSONResponse({"success": True, "redirect": "/chat"}) + response.set_cookie( + key="session_token", + value=token, + httponly=True, + max_age=7 * 24 * 60 * 60, + samesite="lax" + ) + + return response + + +@router.get("/logout") +async def logout(response: Response, session_token: Optional[str] = Cookie(None)): + """Logout user.""" + if session_token: + auth = get_auth_manager() + auth.delete_session(session_token) + + response = JSONResponse({"success": True}) + response.delete_cookie("session_token") + return response + + +@router.get("/chat", response_class=HTMLResponse) +async def chat_page(request: Request, session_token: Optional[str] = Cookie(None)): + """Main chat interface.""" + auth = get_auth_manager() + user = None + + if session_token: + user = auth.validate_session(session_token) + + if not user: + return HTMLResponse( + status_code=302, + headers={"Location": "/login"} + ) + + rate_info = auth.check_rate_limit(user.id) + + return templates.TemplateResponse( + "chat.html", + { + "request": request, + "settings": settings, + "user": user, + "rate_info": rate_info + } + ) + + +@router.get("/profile", response_class=HTMLResponse) +async def profile_page(request: Request, session_token: Optional[str] = Cookie(None)): + """User profile page with document management.""" + auth = get_auth_manager() + user = None + + if session_token: + user = auth.validate_session(session_token) + + if not user: + return HTMLResponse( + status_code=302, + headers={"Location": "/login"} + ) + + documents = auth.get_user_documents(user.id) + rate_info = auth.check_rate_limit(user.id) + + return templates.TemplateResponse( + "profile.html", + { + "request": request, + "settings": settings, + "user": user, + "documents": documents, + "rate_info": rate_info + } + ) + + +# ============================================================================ +# API Routes (for chat functionality) +# ============================================================================ + +class ChatRequest(BaseModel): + """Chat request model.""" + message: str + conversation_id: Optional[str] = None + attachments: Optional[List[str]] = None + + +class ConversationUpdate(BaseModel): + """Conversation update model.""" + title: Optional[str] = None + + +@router.get("/api/user") +async def get_user_info(request: Request, session_token: Optional[str] = Cookie(None)): + """Get current user info.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + rate_info = auth.check_rate_limit(user.id) + + return { + "id": user.id, + "username": user.username, + "email": user.email, + "is_admin": user.is_admin, + "request_count": user.request_count, + "request_limit": user.request_limit, + "remaining_requests": rate_info["remaining"] + } + + +@router.get("/api/conversations") +async def get_conversations(request: Request, session_token: Optional[str] = Cookie(None)): + """Get all conversations for the current user.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + # Get conversations from database + from config import get_db_path + import sqlite3 + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, title, created_at, updated_at + FROM conversations WHERE user_id = ? + ORDER BY updated_at DESC + """, (user.id,)) + + conversations = [] + for row in cursor.fetchall(): + conversations.append({ + "id": row[0], + "title": row[1], + "created_at": row[2], + "updated_at": row[3] + }) + + conn.close() + return conversations + + +@router.post("/api/conversations") +async def create_conversation( + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Create a new conversation.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + from config import get_db_path + import sqlite3 + + conv_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + # Ensure conversations table exists + cursor.execute(""" + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT, + created_at TEXT, + updated_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + + cursor.execute(""" + INSERT INTO conversations (id, user_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + """, (conv_id, user.id, "New Chat", now, now)) + + conn.commit() + conn.close() + + return {"id": conv_id, "title": "New Chat", "created_at": now, "updated_at": now} + + +@router.get("/api/conversations/{conv_id}/messages") +async def get_conversation_messages( + conv_id: str, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Get all messages for a conversation.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + from config import get_db_path + import sqlite3 + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + # Verify ownership + cursor.execute("SELECT user_id FROM conversations WHERE id = ?", (conv_id,)) + row = cursor.fetchone() + if not row or row[0] != user.id: + conn.close() + return JSONResponse({"error": "Not found"}, status_code=404) + + # Get messages + cursor.execute(""" + SELECT id, role, content, attachments, created_at + FROM messages WHERE conversation_id = ? + ORDER BY created_at ASC + """, (conv_id,)) + + messages = [] + for row in cursor.fetchall(): + attachments = json.loads(row[3]) if row[3] else [] + messages.append({ + "id": row[0], + "role": row[1], + "content": row[2], + "attachments": attachments, + "created_at": row[4] + }) + + conn.close() + return messages + + +@router.patch("/api/conversations/{conv_id}") +async def update_conversation( + conv_id: str, + update: ConversationUpdate, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Update a conversation (e.g., rename).""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + from config import get_db_path + import sqlite3 + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + # Verify ownership + cursor.execute("SELECT user_id FROM conversations WHERE id = ?", (conv_id,)) + row = cursor.fetchone() + if not row or row[0] != user.id: + conn.close() + return JSONResponse({"error": "Not found"}, status_code=404) + + now = datetime.utcnow().isoformat() + + if update.title: + cursor.execute( + "UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?", + (update.title, now, conv_id) + ) + + conn.commit() + conn.close() + + return {"success": True} + + +@router.delete("/api/conversations/{conv_id}") +async def delete_conversation( + conv_id: str, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Delete a conversation and its messages.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + from config import get_db_path + import sqlite3 + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + # Verify ownership + cursor.execute("SELECT user_id FROM conversations WHERE id = ?", (conv_id,)) + row = cursor.fetchone() + if not row or row[0] != user.id: + conn.close() + return JSONResponse({"error": "Not found"}, status_code=404) + + # Delete messages first + cursor.execute("DELETE FROM messages WHERE conversation_id = ?", (conv_id,)) + # Delete conversation + cursor.execute("DELETE FROM conversations WHERE id = ?", (conv_id,)) + + conn.commit() + conn.close() + + return {"success": True} + + +@router.post("/api/chat") +async def send_chat_message( + chat_request: ChatRequest, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Send a chat message and get a streaming response.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + # Check rate limit + rate_info = auth.check_rate_limit(user.id) + if not rate_info["allowed"]: + return JSONResponse( + {"error": f"Daily limit reached. You have used {user.request_count}/{user.request_limit} requests today."}, + status_code=429 + ) + + # Get orchestrator + orchestrator: Orchestrator = request.app.state.orchestrator + + from config import get_db_path + import sqlite3 + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + # Ensure tables exist + cursor.execute(""" + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT, + created_at TEXT, + updated_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + attachments TEXT, + created_at TEXT, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) + ) + """) + + # Create conversation if needed + conv_id = chat_request.conversation_id + if not conv_id: + conv_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + + # Generate title from first message + title = chat_request.message[:50] + ("..." if len(chat_request.message) > 50 else "") + + cursor.execute(""" + INSERT INTO conversations (id, user_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + """, (conv_id, user.id, title, now, now)) + + # Save user message + user_msg_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + + cursor.execute(""" + INSERT INTO messages (id, conversation_id, role, content, attachments, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, (user_msg_id, conv_id, "user", chat_request.message, + json.dumps(chat_request.attachments or []), now)) + + # Update conversation timestamp + cursor.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id)) + + # Get previous messages for context + cursor.execute(""" + SELECT role, content FROM messages + WHERE conversation_id = ? + ORDER BY created_at ASC + """, (conv_id,)) + + messages = [{"role": row[0], "content": row[1]} for row in cursor.fetchall()] + + conn.commit() + conn.close() + + # Increment request count + auth.increment_request_count(user.id) + + # Return streaming response + return StreamingResponse( + stream_chat_response(request, orchestrator, messages, conv_id, user), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + "X-Conversation-Id": conv_id + } + ) + + +async def stream_chat_response( + request: Request, + orchestrator: Orchestrator, + messages: List[dict], + conv_id: str, + user: User +): + """Stream the chat response and save to database.""" + from config import get_db_path + import sqlite3 + + full_response = "" + + async for chunk in orchestrator.process_stream(messages=messages): + if "content" in chunk and chunk["content"]: + full_response += chunk["content"] + data = {"content": chunk["content"]} + yield f"data: {json.dumps(data)}\n\n" + + # Save assistant message to database + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + asst_msg_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + + cursor.execute(""" + INSERT INTO messages (id, conversation_id, role, content, created_at) + VALUES (?, ?, ?, ?, ?) + """, (asst_msg_id, conv_id, "assistant", full_response, now)) + + conn.commit() + conn.close() + + # Send done signal + yield "data: [DONE]\n\n" + + +# ============================================================================ +# File Upload Routes +# ============================================================================ + +@router.post("/api/upload") +async def upload_file( + file: UploadFile = File(...), + session_token: Optional[str] = Cookie(None) +): + """Upload a file for attachment to messages.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + # Read file + content = await file.read() + + # Save to temp location or database + from config import get_data_dir + uploads_dir = get_data_dir() / "uploads" / user.id + uploads_dir.mkdir(parents=True, exist_ok=True) + + file_id = str(uuid.uuid4()) + file_ext = Path(file.filename or "file").suffix + file_path = uploads_dir / f"{file_id}{file_ext}" + + with open(file_path, "wb") as f: + f.write(content) + + return { + "id": file_id, + "filename": file.filename, + "size": len(content), + "url": f"/api/files/{file_id}" + } + + +@router.get("/api/files/{file_id}") +async def get_file( + file_id: str, + session_token: Optional[str] = Cookie(None) +): + """Get an uploaded file.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + from config import get_data_dir + uploads_dir = get_data_dir() / "uploads" / user.id + + # Find file by ID + for file_path in uploads_dir.glob(f"{file_id}.*"): + from fastapi.responses import FileResponse + return FileResponse(file_path) + + return JSONResponse({"error": "File not found"}, status_code=404) + + +# ============================================================================ +# User Profile Document Routes +# ============================================================================ + +@router.post("/api/profile/documents") +async def upload_profile_document( + file: UploadFile = File(...), + session_token: Optional[str] = Cookie(None) +): + """Upload a document to user's profile.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + content = await file.read() + file_type = Path(file.filename or "file").suffix + + doc_id = auth.add_user_document(user.id, file.filename or "document", file_type, content) + + return { + "id": doc_id, + "filename": file.filename, + "size": len(content), + "file_type": file_type + } + + +@router.get("/api/profile/documents") +async def get_profile_documents( + session_token: Optional[str] = Cookie(None) +): + """Get all documents in user's profile.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + documents = auth.get_user_documents(user.id) + return documents + + +@router.delete("/api/profile/documents/{doc_id}") +async def delete_profile_document( + doc_id: str, + session_token: Optional[str] = Cookie(None) +): + """Delete a document from user's profile.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + success = auth.delete_user_document(user.id, doc_id) + + if success: + return {"success": True} + return JSONResponse({"error": "Document not found"}, status_code=404) + + +# ============================================================================ +# Password Change +# ============================================================================ + +class PasswordChange(BaseModel): + current_password: str + new_password: str + + +@router.post("/api/profile/password") +async def change_password( + data: PasswordChange, + session_token: Optional[str] = Cookie(None) +): + """Change user's password.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + # Verify current password + verified = auth.authenticate(user.username, data.current_password) + if not verified: + return JSONResponse({"error": "Current password is incorrect"}, status_code=400) + + # Change password + success = auth.change_password(user.id, data.new_password) + + if success: + return {"success": True} + return JSONResponse({"error": "Failed to change password"}, status_code=500) diff --git a/moxie/web/static/moxie_logo.jpg b/moxie/web/static/moxie_logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a96812f8bd885f764308108f27a828266df9e9f4 GIT binary patch literal 42762 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 LI4mmf*1-P&H53>~ literal 0 HcmV?d00001 diff --git a/moxie/web/templates/chat.html b/moxie/web/templates/chat.html new file mode 100644 index 0000000..f19d4a7 --- /dev/null +++ b/moxie/web/templates/chat.html @@ -0,0 +1,622 @@ + + + + + + MOXIE Chat + + + + + + + + + + + + +
    + +
    +
    New Chat
    +
    + + Ready +
    +
    + + +
    +
    + MOXIE +

    Welcome to MOXIE!

    +

    Start a conversation or ask me to create images, videos, or audio for you.

    +
    +
    +
    👋
    +
    Tell me about yourself
    +
    +
    +
    🎨
    +
    Create an image
    +
    +
    +
    🔍
    +
    Search the web
    +
    +
    +
    ✍️
    +
    Write something creative
    +
    +
    +
    +
    + +
    +
    + + +
    +
    + + + + +
    +
    + + + + + + + + +
    +
    + +
    + MOXIE can make mistakes. Free accounts limited to 5 requests/day. +
    +
    +
    +
    + + + + diff --git a/moxie/web/templates/landing.html b/moxie/web/templates/landing.html new file mode 100644 index 0000000..f13fae8 --- /dev/null +++ b/moxie/web/templates/landing.html @@ -0,0 +1,104 @@ + + + + + + MOXIE - AI Assistant + + + + + + +
    +
    +
    +
    + + +
    + +
    + MOXIE Logo +
    + + +

    MOXIE

    +

    Your Uplifting AI Assistant

    +

    + A fresh approach to AI. Powered by a neural network that lifts your ideas to new heights. +

    + + + + + +
    +
    +
    🧠
    +

    Intelligent

    +

    Advanced reasoning powered by multiple AI systems

    +
    +
    +
    🎨
    +

    Creative

    +

    Generate images, videos, and audio seamlessly

    +
    +
    +
    📚
    +

    Knowledgeable

    +

    Access to web search and your personal documents

    +
    +
    +
    + + +
    + Powered by MOXIE AI +
    + + diff --git a/moxie/web/templates/login.html b/moxie/web/templates/login.html new file mode 100644 index 0000000..0dfb57e --- /dev/null +++ b/moxie/web/templates/login.html @@ -0,0 +1,137 @@ + + + + + + Sign In - MOXIE + + + + + +
    +
    +
    +
    + +
    + +
    + + MOXIE + +

    Welcome Back

    +

    Sign in to continue to MOXIE

    +
    + + +
    +
    + {% if error %} +
    + {{ error }} +
    + {% endif %} + +
    + + +
    + +
    + + +
    + + +
    + +
    + Don't have an account? + Create one +
    +
    + + + +
    + + + + diff --git a/moxie/web/templates/profile.html b/moxie/web/templates/profile.html new file mode 100644 index 0000000..14117fa --- /dev/null +++ b/moxie/web/templates/profile.html @@ -0,0 +1,257 @@ + + + + + + Profile - MOXIE + + + + + +
    + +
    + + +
    + +
    +
    + {{ user.username[0].upper() }} +
    +
    +

    {{ user.username }}

    +

    {{ user.email }}

    + {% if user.is_admin %} + Admin + {% endif %} +
    +
    + + +
    +
    +
    {{ rate_info.remaining }}
    +
    Requests remaining today
    +
    +
    +
    {{ user.request_limit }}
    +
    Daily request limit
    +
    +
    +
    {{ documents|length }}
    +
    Documents uploaded
    +
    +
    + +
    + +
    +

    + + + + Change Password +

    +
    +
    + + +
    +
    + + +
    + +
    +
    + + +
    +

    + + + + My Documents +

    +

    Upload documents to use in your conversations with MOXIE.

    + + +
    + + + + +

    Click to upload or drag and drop

    +

    PDF, DOC, DOCX, TXT, MD

    +
    + + +
    + {% for doc in documents %} +
    +
    + 📄 +
    +
    {{ doc.filename }}
    +
    {{ doc.size|filesizeformat }}
    +
    +
    + +
    + {% endfor %} + {% if not documents %} +

    No documents uploaded yet

    + {% endif %} +
    +
    +
    + + +
    +

    + + + + Account Information +

    +
    +
    + Member since: + {{ user.created_at[:10] if user.created_at else 'N/A' }} +
    +
    + Account type: + {{ 'Administrator' if user.is_admin else 'Free (5 req/day)' }} +
    +
    +
    +
    + + + + diff --git a/moxie/web/templates/signup.html b/moxie/web/templates/signup.html new file mode 100644 index 0000000..e266efe --- /dev/null +++ b/moxie/web/templates/signup.html @@ -0,0 +1,181 @@ + + + + + + Create Account - MOXIE + + + + + +
    +
    +
    +
    + +
    + +
    + + MOXIE + +

    Create Account

    +

    Join MOXIE and start exploring AI

    +
    + + +
    +
    + {% if error %} +
    + {{ error }} +
    + {% endif %} + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + Free accounts are limited to 5 requests per day. +
    + + +
    + +
    + Already have an account? + Sign in +
    +
    + + + +
    + + + +