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
This commit is contained in:
Z User 2026-03-24 05:15:50 +00:00
parent 072d4948b6
commit 1f9535d683
19 changed files with 3014 additions and 10 deletions

View File

@ -14,6 +14,7 @@
<a href="/{{ settings.admin_path }}/endpoints">Endpoints</a>
<a href="/{{ settings.admin_path }}/documents">Documents</a>
<a href="/{{ settings.admin_path }}/comfyui">ComfyUI</a>
<a href="/{{ settings.admin_path }}/users">Users</a>
</div>
</nav>

View File

@ -14,6 +14,7 @@
<a href="/{{ settings.admin_path }}/endpoints">Endpoints</a>
<a href="/{{ settings.admin_path }}/documents">Documents</a>
<a href="/{{ settings.admin_path }}/comfyui">ComfyUI</a>
<a href="/{{ settings.admin_path }}/users">Users</a>
</div>
</nav>
@ -48,15 +49,14 @@
<li>Configure your API endpoints in <a href="/{{ settings.admin_path }}/endpoints">Endpoints</a></li>
<li>Upload documents in <a href="/{{ settings.admin_path }}/documents">Documents</a></li>
<li>Configure ComfyUI workflows in <a href="/{{ settings.admin_path }}/comfyui">ComfyUI</a></li>
<li>Connect open-webui to <code>http://localhost:8000/v1</code></li>
<li>Access the MOXIE UI at <a href="/"><code>http://localhost:8000/</code></a></li>
</ol>
</div>
<div class="info-section">
<h2>API Configuration</h2>
<p>Configure open-webui to use this endpoint:</p>
<code>Base URL: http://localhost:8000/v1</code>
<p>No API key required (leave blank)</p>
<h2>MOXIE UI</h2>
<p>Access the MOXIE chat interface:</p>
<a href="/" style="color: #4F9AC3;"><code>http://localhost:8000/</code></a>
</div>
</main>

View File

@ -14,6 +14,7 @@
<a href="/{{ settings.admin_path }}/endpoints">Endpoints</a>
<a href="/{{ settings.admin_path }}/documents">Documents</a>
<a href="/{{ settings.admin_path }}/comfyui">ComfyUI</a>
<a href="/{{ settings.admin_path }}/users">Users</a>
</div>
</nav>

View File

@ -14,6 +14,7 @@
<a href="/{{ settings.admin_path }}/endpoints">Endpoints</a>
<a href="/{{ settings.admin_path }}/documents">Documents</a>
<a href="/{{ settings.admin_path }}/comfyui">ComfyUI</a>
<a href="/{{ settings.admin_path }}/users">Users</a>
</div>
</nav>

View File

@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Management - MOXIE Admin</title>
<link rel="stylesheet" href="/{{ settings.admin_path }}/static/admin.css">
<style>
.user-card {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.user-card:hover {
border-color: #4F9AC3;
}
.admin-badge {
background: linear-gradient(135deg, #EA744C, #ECDC67);
color: #0B0C1C;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
}
.btn-small {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
}
.limit-input {
width: 80px;
padding: 0.25rem 0.5rem;
background: #0B0C1C;
border: 1px solid #333;
border-radius: 4px;
color: #F0F0F0;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="nav-brand">MOXIE Admin</div>
<div class="nav-links">
<a href="/{{ settings.admin_path }}/">Dashboard</a>
<a href="/{{ settings.admin_path }}/endpoints">Endpoints</a>
<a href="/{{ settings.admin_path }}/documents">Documents</a>
<a href="/{{ settings.admin_path }}/comfyui">ComfyUI</a>
<a href="/{{ settings.admin_path }}/users" class="active">Users</a>
</div>
</nav>
<main class="container">
<h1>User Management</h1>
<div class="info-section">
<p>Manage user accounts, permissions, and request limits.</p>
</div>
<div class="users-grid">
{% for user in users %}
<div class="user-card">
<div class="flex justify-between items-start mb-3">
<div>
<h3 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
{{ user.username }}
{% if user.is_admin %}
<span class="admin-badge">ADMIN</span>
{% endif %}
</h3>
<p style="margin: 0.25rem 0 0 0; color: #888; font-size: 0.875rem;">{{ user.email }}</p>
</div>
<div style="text-align: right;">
<div style="font-size: 0.75rem; color: #666;">Requests today</div>
<div style="font-size: 1.25rem; font-weight: bold; color: #4F9AC3;">{{ user.request_count }}/{{ user.request_limit }}</div>
</div>
</div>
<div class="flex justify-between items-center">
<div style="font-size: 0.875rem; color: #666;">
Created: {{ user.created_at[:10] if user.created_at else 'N/A' }}
</div>
<div class="flex gap-2 items-center">
{% if not user.is_admin %}
<button onclick="promoteUser('{{ user.id }}')" class="btn-small btn-primary">Make Admin</button>
{% else %}
{% if user.username != 'admin' %}
<button onclick="demoteUser('{{ user.id }}')" class="btn-small btn-secondary">Remove Admin</button>
{% endif %}
{% endif %}
<form onsubmit="updateLimit(event, '{{ user.id }}')" style="display: inline-flex; align-items: center; gap: 0.25rem;">
<input type="number" name="limit" value="{{ user.request_limit }}" min="1" max="999999" class="limit-input">
<button type="submit" class="btn-small btn-secondary">Set Limit</button>
</form>
{% if user.username != 'admin' %}
<button onclick="deleteUser('{{ user.id }}', '{{ user.username }}')" class="btn-small btn-danger">Delete</button>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% if not users %}
<div class="info-section">
<p>No users found.</p>
</div>
{% endif %}
</main>
<script>
async function promoteUser(userId) {
if (!confirm('Promote this user to admin?')) return;
try {
const response = await fetch(`/{{ settings.admin_path }}/users/${userId}/promote`, {
method: 'POST'
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to promote user');
}
} catch (error) {
alert('An error occurred');
}
}
async function demoteUser(userId) {
if (!confirm('Remove admin privileges from this user?')) return;
try {
const response = await fetch(`/{{ settings.admin_path }}/users/${userId}/demote`, {
method: 'POST'
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to demote user');
}
} catch (error) {
alert('An error occurred');
}
}
async function updateLimit(event, userId) {
event.preventDefault();
const limit = event.target.limit.value;
try {
const response = await fetch(`/{{ settings.admin_path }}/users/${userId}/limit`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `limit=${limit}`
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to update limit');
}
} catch (error) {
alert('An error occurred');
}
}
async function deleteUser(userId, username) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
try {
const response = await fetch(`/{{ settings.admin_path }}/users/${userId}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to delete user');
}
} catch (error) {
alert('An error occurred');
}
}
</script>
</body>
</html>

View File

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

7
moxie/auth/__init__.py Normal file
View File

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

562
moxie/auth/models.py Normal file
View File

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

View File

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

View File

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

View File

@ -35,3 +35,6 @@ loguru>=0.7.0
# ComfyUI
websockets>=12.0
# Auth
python-jose[cryptography]>=3.3.0

7
moxie/web/__init__.py Normal file
View File

@ -0,0 +1,7 @@
"""
MOXIE Web UI Module
OpenWebUI-like interface for MOXIE.
"""
from .routes import router as web_router
__all__ = ["web_router"]

825
moxie/web/routes.py Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -0,0 +1,622 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MOXIE Chat</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
background: '#0B0C1C',
surface: '#1A1A1A',
'balloon-red': '#EA744C',
'balloon-yellow': '#ECDC67',
'balloon-blue': '#4F9AC3',
'balloon-purple': '#6A4477',
'text-main': '#F0F0F0',
}
}
}
}
</script>
<style>
/* Custom scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #6A4477; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #EA744C; }
/* Typing animation */
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.typing-cursor::after {
content: '▋';
animation: blink 1s infinite;
color: #4F9AC3;
}
/* Message fade in */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message-fade-in {
animation: fadeIn 0.3s ease-out;
}
/* Thinking phase styling */
.thinking-content {
background: linear-gradient(135deg, rgba(106, 68, 119, 0.2), rgba(79, 154, 195, 0.2));
border-left: 3px solid #4F9AC3;
}
/* Generated image container */
.generated-image {
max-width: 100%;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(106, 68, 119, 0.3);
}
</style>
</head>
<body class="bg-background text-text-main h-screen flex overflow-hidden">
<!-- Sidebar -->
<aside id="sidebar" class="w-72 bg-surface/50 border-r border-balloon-purple/20 flex flex-col transition-all duration-300">
<!-- Sidebar header -->
<div class="p-4 border-b border-balloon-purple/20">
<div class="flex items-center gap-3 mb-4">
<img src="/static/moxie_logo.jpg" alt="MOXIE" class="w-10 h-10 rounded-full">
<div>
<h1 class="font-bold text-lg">MOXIE</h1>
<p class="text-xs text-text-main/50">AI Assistant</p>
</div>
</div>
<!-- New chat button -->
<button id="newChatBtn" class="w-full py-2.5 bg-gradient-to-r from-balloon-red to-balloon-purple hover:from-balloon-red/80 hover:to-balloon-purple/80 text-white font-medium rounded-lg transition-all duration-300 flex items-center justify-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
New Chat
</button>
</div>
<!-- Conversations list -->
<div class="flex-1 overflow-y-auto p-2">
<div class="text-xs text-text-main/40 px-2 py-2 uppercase tracking-wider">Conversations</div>
<div id="conversationsList" class="space-y-1">
<!-- Conversations loaded here -->
</div>
</div>
<!-- User section -->
<div class="p-4 border-t border-balloon-purple/20">
<div class="flex items-center gap-3 mb-3">
<div class="w-9 h-9 bg-balloon-purple/30 rounded-full flex items-center justify-center">
<span class="text-sm font-medium">{{ user.username[0].upper() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{{ user.username }}</div>
<div class="text-xs text-text-main/50">{{ rate_info.remaining }} requests left today</div>
</div>
</div>
<div class="flex gap-2">
<a href="/profile" class="flex-1 py-2 text-center text-sm bg-surface hover:bg-balloon-purple/20 rounded-lg transition-colors">Profile</a>
<button id="logoutBtn" class="flex-1 py-2 text-center text-sm bg-surface hover:bg-balloon-red/20 text-balloon-red rounded-lg transition-colors">Logout</button>
</div>
</div>
</aside>
<!-- Mobile sidebar toggle -->
<button id="sidebarToggle" class="fixed top-4 left-4 z-50 p-2 bg-surface rounded-lg lg:hidden">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<!-- Main chat area -->
<main class="flex-1 flex flex-col min-w-0">
<!-- Chat header -->
<header class="h-14 border-b border-balloon-purple/20 flex items-center justify-between px-6 bg-surface/30">
<div id="chatTitle" class="font-medium">New Chat</div>
<div id="statusIndicator" class="flex items-center gap-2 text-sm text-text-main/50">
<span class="w-2 h-2 bg-balloon-yellow rounded-full animate-pulse"></span>
Ready
</div>
</header>
<!-- Messages area -->
<div id="messagesContainer" class="flex-1 overflow-y-auto p-6">
<div id="welcomeMessage" class="max-w-2xl mx-auto text-center py-16">
<img src="/static/moxie_logo.jpg" alt="MOXIE" class="w-24 h-24 mx-auto rounded-full shadow-lg shadow-balloon-purple/30 mb-6">
<h2 class="text-2xl font-bold mb-2">Welcome to MOXIE!</h2>
<p class="text-text-main/50 mb-6">Start a conversation or ask me to create images, videos, or audio for you.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-left max-w-md mx-auto">
<div class="p-3 bg-surface/50 rounded-lg border border-balloon-purple/20 cursor-pointer hover:border-balloon-blue transition-colors suggestion-btn" data-msg="Tell me about yourself">
<div class="text-balloon-yellow text-lg mb-1">👋</div>
<div class="text-sm">Tell me about yourself</div>
</div>
<div class="p-3 bg-surface/50 rounded-lg border border-balloon-purple/20 cursor-pointer hover:border-balloon-blue transition-colors suggestion-btn" data-msg="Create an image of a sunset over mountains">
<div class="text-balloon-red text-lg mb-1">🎨</div>
<div class="text-sm">Create an image</div>
</div>
<div class="p-3 bg-surface/50 rounded-lg border border-balloon-purple/20 cursor-pointer hover:border-balloon-blue transition-colors suggestion-btn" data-msg="Search the web for latest AI news">
<div class="text-balloon-blue text-lg mb-1">🔍</div>
<div class="text-sm">Search the web</div>
</div>
<div class="p-3 bg-surface/50 rounded-lg border border-balloon-purple/20 cursor-pointer hover:border-balloon-blue transition-colors suggestion-btn" data-msg="Help me write a poem">
<div class="text-balloon-purple text-lg mb-1">✍️</div>
<div class="text-sm">Write something creative</div>
</div>
</div>
</div>
<div id="messagesList" class="max-w-3xl mx-auto space-y-6">
<!-- Messages loaded here -->
</div>
</div>
<!-- Input area -->
<div class="p-4 border-t border-balloon-purple/20 bg-surface/30">
<div class="max-w-3xl mx-auto">
<!-- Attachments preview -->
<div id="attachmentsPreview" class="hidden mb-3 flex flex-wrap gap-2">
<!-- Attachment previews shown here -->
</div>
<!-- Input form -->
<form id="chatForm" class="relative">
<div class="flex items-end gap-3 bg-surface rounded-2xl border border-balloon-purple/30 focus-within:border-balloon-blue transition-colors p-2">
<!-- File attachment button -->
<label class="p-2 hover:bg-balloon-purple/20 rounded-lg cursor-pointer transition-colors">
<input type="file" id="fileInput" class="hidden" multiple accept="image/*,.pdf,.doc,.docx,.txt,.md">
<svg class="w-5 h-5 text-text-main/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path>
</svg>
</label>
<!-- Text input -->
<textarea
id="messageInput"
rows="1"
placeholder="Message MOXIE..."
class="flex-1 bg-transparent resize-none outline-none text-text-main placeholder-text-main/30 max-h-32 py-2"
></textarea>
<!-- Send button -->
<button type="submit" id="sendBtn" class="p-2 bg-balloon-blue hover:bg-balloon-blue/80 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path>
</svg>
</button>
</div>
</form>
<div class="text-center text-xs text-text-main/30 mt-2">
MOXIE can make mistakes. Free accounts limited to 5 requests/day.
</div>
</div>
</div>
</main>
<script>
// State
let currentConversationId = null;
let isGenerating = false;
let attachments = [];
// DOM elements
const messagesList = document.getElementById('messagesList');
const messagesContainer = document.getElementById('messagesContainer');
const welcomeMessage = document.getElementById('welcomeMessage');
const chatForm = document.getElementById('chatForm');
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const fileInput = document.getElementById('fileInput');
const attachmentsPreview = document.getElementById('attachmentsPreview');
const conversationsList = document.getElementById('conversationsList');
const chatTitle = document.getElementById('chatTitle');
const newChatBtn = document.getElementById('newChatBtn');
const logoutBtn = document.getElementById('logoutBtn');
const sidebar = document.getElementById('sidebar');
const sidebarToggle = document.getElementById('sidebarToggle');
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadConversations();
setupEventListeners();
autoResizeTextarea();
});
function setupEventListeners() {
// Form submit
chatForm.addEventListener('submit', handleSubmit);
// Enter to send
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
});
// File input
fileInput.addEventListener('change', handleFileSelect);
// New chat
newChatBtn.addEventListener('click', startNewChat);
// Logout
logoutBtn.addEventListener('click', handleLogout);
// Sidebar toggle (mobile)
sidebarToggle.addEventListener('click', () => {
sidebar.classList.toggle('-translate-x-full');
});
// Suggestion buttons
document.querySelectorAll('.suggestion-btn').forEach(btn => {
btn.addEventListener('click', () => {
messageInput.value = btn.dataset.msg;
handleSubmit(new Event('submit'));
});
});
}
function autoResizeTextarea() {
messageInput.addEventListener('input', () => {
messageInput.style.height = 'auto';
messageInput.style.height = Math.min(messageInput.scrollHeight, 128) + 'px';
});
}
async function loadConversations() {
try {
const response = await fetch('/api/conversations');
const conversations = await response.json();
conversationsList.innerHTML = '';
conversations.forEach(conv => {
const el = document.createElement('div');
el.className = `p-3 rounded-lg cursor-pointer transition-colors ${
conv.id === currentConversationId
? 'bg-balloon-purple/20 border border-balloon-purple/30'
: 'hover:bg-surface/50'
}`;
el.innerHTML = `
<div class="flex items-center justify-between gap-2">
<span class="truncate text-sm">${escapeHtml(conv.title)}</span>
<button class="delete-conv opacity-0 hover:opacity-100 text-balloon-red p-1" data-id="${conv.id}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
`;
el.addEventListener('click', (e) => {
if (!e.target.closest('.delete-conv')) {
loadConversation(conv.id);
}
});
el.querySelector('.delete-conv').addEventListener('click', (e) => {
e.stopPropagation();
deleteConversation(conv.id);
});
conversationsList.appendChild(el);
});
} catch (error) {
console.error('Failed to load conversations:', error);
}
}
async function loadConversation(convId) {
currentConversationId = convId;
try {
const response = await fetch(`/api/conversations/${convId}/messages`);
const messages = await response.json();
messagesList.innerHTML = '';
welcomeMessage.classList.add('hidden');
messages.forEach(msg => {
appendMessage(msg.role, msg.content, msg.attachments);
});
// Update sidebar selection
loadConversations();
// Scroll to bottom
scrollToBottom();
} catch (error) {
console.error('Failed to load conversation:', error);
}
}
async function handleSubmit(e) {
e.preventDefault();
if (isGenerating) return;
const message = messageInput.value.trim();
if (!message && attachments.length === 0) return;
// Hide welcome message
welcomeMessage.classList.add('hidden');
// Add user message
appendMessage('user', message, attachments);
// Clear input
messageInput.value = '';
messageInput.style.height = 'auto';
attachments = [];
attachmentsPreview.classList.add('hidden');
attachmentsPreview.innerHTML = '';
// Start generation
isGenerating = true;
sendBtn.disabled = true;
updateStatus('Thinking...', true);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: message,
conversation_id: currentConversationId,
attachments: attachments
})
});
// Get conversation ID from header
currentConversationId = response.headers.get('X-Conversation-Id');
// Create assistant message container
const assistantMsg = appendMessage('assistant', '', null, true);
// Read stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
break;
}
try {
const parsed = JSON.parse(data);
if (parsed.content) {
appendToMessage(assistantMsg, parsed.content);
}
} catch (e) {
// Ignore parse errors
}
}
}
}
// Refresh conversations list
loadConversations();
} catch (error) {
console.error('Chat error:', error);
appendMessage('assistant', 'An error occurred. Please try again.', null);
} finally {
isGenerating = false;
sendBtn.disabled = false;
updateStatus('Ready', false);
}
}
function appendMessage(role, content, attachments, streaming = false) {
const wrapper = document.createElement('div');
wrapper.className = `message-fade-in ${role === 'user' ? 'flex justify-end' : ''}`;
const isThinking = content.includes('[Thinking...]') || content.includes('[Searching');
let contentHtml = '';
if (role === 'user') {
contentHtml = `
<div class="max-w-[80%] bg-balloon-purple/30 rounded-2xl rounded-tr-sm px-4 py-3">
<p class="whitespace-pre-wrap">${escapeHtml(content)}</p>
${attachments && attachments.length ? `<div class="mt-2 text-xs text-text-main/50">${attachments.length} attachment(s)</div>` : ''}
</div>
`;
} else {
contentHtml = `
<div class="flex gap-3 ${streaming ? 'typing-cursor' : ''}">
<img src="/static/moxie_logo.jpg" alt="MOXIE" class="w-8 h-8 rounded-full flex-shrink-0 mt-1">
<div class="flex-1 min-w-0">
<div class="prose prose-invert max-w-none ${isThinking ? 'thinking-content rounded-lg px-4 py-2' : ''}">
${renderMarkdown(content)}
</div>
</div>
</div>
`;
}
wrapper.innerHTML = contentHtml;
if (streaming) {
wrapper.id = 'streaming-message';
}
messagesList.appendChild(wrapper);
scrollToBottom();
return wrapper;
}
function appendToMessage(element, content) {
const contentDiv = element.querySelector('.prose');
if (contentDiv) {
// Check if it's a thinking phase message
const currentText = contentDiv.textContent;
// Handle thinking phase specially
if (content.includes('[Thinking') || content.includes('[Searching') || content.includes('[Generating')) {
contentDiv.classList.add('thinking-content', 'rounded-lg', 'px-4', 'py-2');
} else if (!content.includes('[') && contentDiv.classList.contains('thinking-content')) {
// Remove thinking style for regular content
contentDiv.classList.remove('thinking-content');
}
// Append content
const fullContent = currentText + content;
contentDiv.innerHTML = renderMarkdown(fullContent);
scrollToBottom();
}
}
function renderMarkdown(text) {
// Simple markdown rendering
let html = escapeHtml(text);
// Code blocks
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="bg-background/50 rounded-lg p-3 overflow-x-auto"><code>$2</code></pre>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code class="bg-background/50 px-1.5 py-0.5 rounded text-balloon-yellow">$1</code>');
// Bold
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Line breaks
html = html.replace(/\n/g, '<br>');
return html;
}
function scrollToBottom() {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
function updateStatus(text, loading) {
const indicator = document.getElementById('statusIndicator');
indicator.innerHTML = `
<span class="w-2 h-2 ${loading ? 'bg-balloon-yellow animate-pulse' : 'bg-balloon-blue'} rounded-full"></span>
${text}
`;
}
async function handleFileSelect(e) {
const files = Array.from(e.target.files);
for (const file of files) {
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
attachments.push(data.id);
// Show preview
const preview = document.createElement('div');
preview.className = 'relative group';
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.className = 'w-16 h-16 object-cover rounded-lg';
preview.appendChild(img);
} else {
preview.innerHTML = `
<div class="w-16 h-16 bg-surface rounded-lg flex items-center justify-center text-2xl">
📄
</div>
`;
}
// Remove button
const removeBtn = document.createElement('button');
removeBtn.className = 'absolute -top-2 -right-2 w-5 h-5 bg-balloon-red rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity';
removeBtn.innerHTML = '×';
removeBtn.onclick = () => {
attachments = attachments.filter(id => id !== data.id);
preview.remove();
if (attachments.length === 0) {
attachmentsPreview.classList.add('hidden');
}
};
preview.appendChild(removeBtn);
attachmentsPreview.appendChild(preview);
attachmentsPreview.classList.remove('hidden');
} catch (error) {
console.error('Failed to upload file:', error);
}
}
}
async function startNewChat() {
currentConversationId = null;
messagesList.innerHTML = '';
welcomeMessage.classList.remove('hidden');
chatTitle.textContent = 'New Chat';
loadConversations();
}
async function deleteConversation(convId) {
if (!confirm('Delete this conversation?')) return;
try {
await fetch(`/api/conversations/${convId}`, { method: 'DELETE' });
if (convId === currentConversationId) {
startNewChat();
}
loadConversations();
} catch (error) {
console.error('Failed to delete conversation:', error);
}
}
async function handleLogout() {
try {
await fetch('/logout');
window.location.href = '/';
} catch (error) {
console.error('Logout failed:', error);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>

View File

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MOXIE - AI Assistant</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
background: '#0B0C1C',
surface: '#1A1A1A',
'balloon-red': '#EA744C',
'balloon-yellow': '#ECDC67',
'balloon-blue': '#4F9AC3',
'balloon-purple': '#6A4477',
'text-main': '#F0F0F0',
}
}
}
}
</script>
<style>
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
.float-animation {
animation: float 6s ease-in-out infinite;
}
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.gradient-text {
background: linear-gradient(90deg, #EA744C, #ECDC67, #4F9AC3, #6A4477, #EA744C);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: gradient 8s linear infinite;
}
</style>
</head>
<body class="bg-background min-h-screen flex flex-col items-center justify-center text-text-main">
<!-- Background gradient effects -->
<div class="fixed inset-0 overflow-hidden pointer-events-none">
<div class="absolute top-1/4 left-1/4 w-96 h-96 bg-balloon-purple/20 rounded-full blur-3xl"></div>
<div class="absolute bottom-1/4 right-1/4 w-96 h-96 bg-balloon-blue/20 rounded-full blur-3xl"></div>
</div>
<!-- Main content -->
<div class="relative z-10 text-center px-4">
<!-- Logo -->
<div class="mb-8 float-animation">
<img src="/static/moxie_logo.jpg" alt="MOXIE Logo" class="w-48 h-48 mx-auto rounded-full shadow-2xl shadow-balloon-purple/30">
</div>
<!-- Title -->
<h1 class="text-6xl font-bold mb-4 gradient-text">MOXIE</h1>
<p class="text-xl text-text-main/70 mb-2">Your Uplifting AI Assistant</p>
<p class="text-sm text-text-main/50 mb-12 max-w-md mx-auto">
A fresh approach to AI. Powered by a neural network that lifts your ideas to new heights.
</p>
<!-- Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/login" class="px-8 py-3 bg-balloon-red hover:bg-balloon-red/80 text-white font-semibold rounded-lg transition-all duration-300 transform hover:scale-105 shadow-lg shadow-balloon-red/30">
Sign In
</a>
<a href="/signup" class="px-8 py-3 bg-surface hover:bg-surface/80 border border-balloon-blue text-balloon-blue font-semibold rounded-lg transition-all duration-300 transform hover:scale-105">
Create Account
</a>
</div>
<!-- Features -->
<div class="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
<div class="p-6 bg-surface/50 rounded-xl border border-balloon-yellow/20">
<div class="text-3xl mb-3">🧠</div>
<h3 class="text-lg font-semibold text-balloon-yellow mb-2">Intelligent</h3>
<p class="text-sm text-text-main/60">Advanced reasoning powered by multiple AI systems</p>
</div>
<div class="p-6 bg-surface/50 rounded-xl border border-balloon-blue/20">
<div class="text-3xl mb-3">🎨</div>
<h3 class="text-lg font-semibold text-balloon-blue mb-2">Creative</h3>
<p class="text-sm text-text-main/60">Generate images, videos, and audio seamlessly</p>
</div>
<div class="p-6 bg-surface/50 rounded-xl border border-balloon-purple/20">
<div class="text-3xl mb-3">📚</div>
<h3 class="text-lg font-semibold text-balloon-purple mb-2">Knowledgeable</h3>
<p class="text-sm text-text-main/60">Access to web search and your personal documents</p>
</div>
</div>
</div>
<!-- Footer -->
<footer class="absolute bottom-4 text-text-main/30 text-sm">
Powered by MOXIE AI
</footer>
</body>
</html>

View File

@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In - MOXIE</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
background: '#0B0C1C',
surface: '#1A1A1A',
'balloon-red': '#EA744C',
'balloon-yellow': '#ECDC67',
'balloon-blue': '#4F9AC3',
'balloon-purple': '#6A4477',
'text-main': '#F0F0F0',
}
}
}
}
</script>
</head>
<body class="bg-background min-h-screen flex items-center justify-center text-text-main">
<!-- Background effects -->
<div class="fixed inset-0 overflow-hidden pointer-events-none">
<div class="absolute top-1/3 left-1/4 w-96 h-96 bg-balloon-red/10 rounded-full blur-3xl"></div>
<div class="absolute bottom-1/3 right-1/4 w-96 h-96 bg-balloon-purple/10 rounded-full blur-3xl"></div>
</div>
<div class="relative z-10 w-full max-w-md px-4">
<!-- Logo -->
<div class="text-center mb-8">
<a href="/">
<img src="/static/moxie_logo.jpg" alt="MOXIE" class="w-20 h-20 mx-auto rounded-full shadow-lg shadow-balloon-purple/30 mb-4">
</a>
<h1 class="text-2xl font-bold">Welcome Back</h1>
<p class="text-text-main/50 text-sm mt-1">Sign in to continue to MOXIE</p>
</div>
<!-- Login form -->
<div class="bg-surface/80 backdrop-blur-sm rounded-2xl p-8 border border-balloon-purple/20">
<form id="loginForm" class="space-y-6">
{% if error %}
<div class="p-3 bg-balloon-red/20 border border-balloon-red/50 rounded-lg text-balloon-red text-sm">
{{ error }}
</div>
{% endif %}
<div>
<label for="username" class="block text-sm font-medium text-text-main/70 mb-2">Username</label>
<input
type="text"
id="username"
name="username"
required
class="w-full px-4 py-3 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue focus:ring-1 focus:ring-balloon-blue transition-colors"
placeholder="Enter your username"
>
</div>
<div>
<label for="password" class="block text-sm font-medium text-text-main/70 mb-2">Password</label>
<input
type="password"
id="password"
name="password"
required
class="w-full px-4 py-3 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue focus:ring-1 focus:ring-balloon-blue transition-colors"
placeholder="Enter your password"
>
</div>
<button
type="submit"
class="w-full py-3 bg-gradient-to-r from-balloon-red to-balloon-purple hover:from-balloon-red/80 hover:to-balloon-purple/80 text-white font-semibold rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-lg shadow-balloon-purple/20"
>
Sign In
</button>
</form>
<div class="mt-6 text-center text-sm text-text-main/50">
Don't have an account?
<a href="/signup" class="text-balloon-blue hover:text-balloon-yellow transition-colors">Create one</a>
</div>
</div>
<!-- Back to home -->
<div class="mt-4 text-center">
<a href="/" class="text-text-main/40 hover:text-text-main/60 text-sm transition-colors">
← Back to Home
</a>
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const username = form.username.value;
const password = form.password.value;
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Signing in...';
try {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const response = await fetch('/login', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success && data.redirect) {
window.location.href = data.redirect;
} else {
// Reload to show error
window.location.reload();
}
} catch (error) {
console.error('Login error:', error);
alert('An error occurred. Please try again.');
submitBtn.disabled = false;
submitBtn.textContent = 'Sign In';
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profile - MOXIE</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
background: '#0B0C1C',
surface: '#1A1A1A',
'balloon-red': '#EA744C',
'balloon-yellow': '#ECDC67',
'balloon-blue': '#4F9AC3',
'balloon-purple': '#6A4477',
'text-main': '#F0F0F0',
}
}
}
}
</script>
</head>
<body class="bg-background text-text-main min-h-screen">
<!-- Header -->
<header class="border-b border-balloon-purple/20 bg-surface/50">
<div class="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="/chat" class="flex items-center gap-3 hover:opacity-80 transition-opacity">
<img src="/static/moxie_logo.jpg" alt="MOXIE" class="w-8 h-8 rounded-full">
<span class="font-bold">MOXIE</span>
</a>
<a href="/chat" class="text-sm text-text-main/50 hover:text-text-main transition-colors">← Back to Chat</a>
</div>
</header>
<!-- Main content -->
<main class="max-w-4xl mx-auto px-6 py-8">
<!-- Profile header -->
<div class="flex items-center gap-6 mb-8">
<div class="w-20 h-20 bg-balloon-purple/30 rounded-full flex items-center justify-center text-3xl font-bold">
{{ user.username[0].upper() }}
</div>
<div>
<h1 class="text-2xl font-bold">{{ user.username }}</h1>
<p class="text-text-main/50">{{ user.email }}</p>
{% if user.is_admin %}
<span class="inline-block mt-1 px-2 py-0.5 bg-balloon-yellow/20 text-balloon-yellow text-xs rounded-full">Admin</span>
{% endif %}
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div class="bg-surface/50 rounded-xl p-5 border border-balloon-purple/20">
<div class="text-3xl font-bold text-balloon-blue">{{ rate_info.remaining }}</div>
<div class="text-sm text-text-main/50">Requests remaining today</div>
</div>
<div class="bg-surface/50 rounded-xl p-5 border border-balloon-purple/20">
<div class="text-3xl font-bold text-balloon-yellow">{{ user.request_limit }}</div>
<div class="text-sm text-text-main/50">Daily request limit</div>
</div>
<div class="bg-surface/50 rounded-xl p-5 border border-balloon-purple/20">
<div class="text-3xl font-bold text-balloon-purple">{{ documents|length }}</div>
<div class="text-sm text-text-main/50">Documents uploaded</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Change password -->
<div class="bg-surface/50 rounded-xl p-6 border border-balloon-purple/20">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-balloon-red" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
Change Password
</h2>
<form id="passwordForm" class="space-y-4">
<div>
<label class="block text-sm text-text-main/70 mb-1">Current Password</label>
<input type="password" name="current_password" required class="w-full px-4 py-2 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue transition-colors">
</div>
<div>
<label class="block text-sm text-text-main/70 mb-1">New Password</label>
<input type="password" name="new_password" required minlength="6" class="w-full px-4 py-2 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue transition-colors">
</div>
<button type="submit" class="w-full py-2 bg-balloon-red hover:bg-balloon-red/80 text-white font-medium rounded-lg transition-colors">
Update Password
</button>
</form>
</div>
<!-- Upload documents -->
<div class="bg-surface/50 rounded-xl p-6 border border-balloon-purple/20">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-balloon-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
My Documents
</h2>
<p class="text-sm text-text-main/50 mb-4">Upload documents to use in your conversations with MOXIE.</p>
<!-- Upload area -->
<div id="uploadArea" class="border-2 border-dashed border-balloon-purple/30 rounded-lg p-6 text-center cursor-pointer hover:border-balloon-blue transition-colors mb-4">
<input type="file" id="docInput" class="hidden" accept=".pdf,.doc,.docx,.txt,.md">
<svg class="w-10 h-10 mx-auto text-text-main/30 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<p class="text-sm text-text-main/50">Click to upload or drag and drop</p>
<p class="text-xs text-text-main/30 mt-1">PDF, DOC, DOCX, TXT, MD</p>
</div>
<!-- Documents list -->
<div id="documentsList" class="space-y-2">
{% for doc in documents %}
<div class="flex items-center justify-between p-3 bg-background/30 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-xl">📄</span>
<div>
<div class="text-sm font-medium">{{ doc.filename }}</div>
<div class="text-xs text-text-main/40">{{ doc.size|filesizeformat }}</div>
</div>
</div>
<button onclick="deleteDocument('{{ doc.id }}')" class="p-2 text-balloon-red hover:bg-balloon-red/20 rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
{% endfor %}
{% if not documents %}
<p class="text-center text-text-main/30 text-sm py-4">No documents uploaded yet</p>
{% endif %}
</div>
</div>
</div>
<!-- Account info -->
<div class="mt-6 bg-surface/50 rounded-xl p-6 border border-balloon-purple/20">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-balloon-yellow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Account Information
</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-text-main/50">Member since:</span>
<span class="ml-2">{{ user.created_at[:10] if user.created_at else 'N/A' }}</span>
</div>
<div>
<span class="text-text-main/50">Account type:</span>
<span class="ml-2">{{ 'Administrator' if user.is_admin else 'Free (5 req/day)' }}</span>
</div>
</div>
</div>
</main>
<script>
// Password change
document.getElementById('passwordForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const data = {
current_password: form.current_password.value,
new_password: form.new_password.value
};
try {
const response = await fetch('/api/profile/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
alert('Password updated successfully!');
form.reset();
} else {
alert(result.error || 'Failed to update password');
}
} catch (error) {
alert('An error occurred. Please try again.');
}
});
// Document upload
const uploadArea = document.getElementById('uploadArea');
const docInput = document.getElementById('docInput');
uploadArea.addEventListener('click', () => docInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('border-balloon-blue');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('border-balloon-blue');
});
uploadArea.addEventListener('drop', async (e) => {
e.preventDefault();
uploadArea.classList.remove('border-balloon-blue');
const files = e.dataTransfer.files;
for (const file of files) {
await uploadDocument(file);
}
});
docInput.addEventListener('change', async (e) => {
for (const file of e.target.files) {
await uploadDocument(file);
}
});
async function uploadDocument(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/profile/documents', {
method: 'POST',
body: formData
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to upload document');
}
} catch (error) {
alert('An error occurred during upload');
}
}
async function deleteDocument(docId) {
if (!confirm('Delete this document?')) return;
try {
const response = await fetch(`/api/profile/documents/${docId}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
}
} catch (error) {
alert('Failed to delete document');
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Account - MOXIE</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
background: '#0B0C1C',
surface: '#1A1A1A',
'balloon-red': '#EA744C',
'balloon-yellow': '#ECDC67',
'balloon-blue': '#4F9AC3',
'balloon-purple': '#6A4477',
'text-main': '#F0F0F0',
}
}
}
}
</script>
</head>
<body class="bg-background min-h-screen flex items-center justify-center text-text-main py-8">
<!-- Background effects -->
<div class="fixed inset-0 overflow-hidden pointer-events-none">
<div class="absolute top-1/3 right-1/4 w-96 h-96 bg-balloon-blue/10 rounded-full blur-3xl"></div>
<div class="absolute bottom-1/3 left-1/4 w-96 h-96 bg-balloon-yellow/10 rounded-full blur-3xl"></div>
</div>
<div class="relative z-10 w-full max-w-md px-4">
<!-- Logo -->
<div class="text-center mb-8">
<a href="/">
<img src="/static/moxie_logo.jpg" alt="MOXIE" class="w-20 h-20 mx-auto rounded-full shadow-lg shadow-balloon-purple/30 mb-4">
</a>
<h1 class="text-2xl font-bold">Create Account</h1>
<p class="text-text-main/50 text-sm mt-1">Join MOXIE and start exploring AI</p>
</div>
<!-- Signup form -->
<div class="bg-surface/80 backdrop-blur-sm rounded-2xl p-8 border border-balloon-blue/20">
<form id="signupForm" class="space-y-5">
{% if error %}
<div class="p-3 bg-balloon-red/20 border border-balloon-red/50 rounded-lg text-balloon-red text-sm">
{{ error }}
</div>
{% endif %}
<div>
<label for="username" class="block text-sm font-medium text-text-main/70 mb-2">Username</label>
<input
type="text"
id="username"
name="username"
required
minlength="3"
class="w-full px-4 py-3 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue focus:ring-1 focus:ring-balloon-blue transition-colors"
placeholder="Choose a username"
>
</div>
<div>
<label for="email" class="block text-sm font-medium text-text-main/70 mb-2">Email</label>
<input
type="email"
id="email"
name="email"
required
class="w-full px-4 py-3 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue focus:ring-1 focus:ring-balloon-blue transition-colors"
placeholder="Enter your email"
>
</div>
<div>
<label for="password" class="block text-sm font-medium text-text-main/70 mb-2">Password</label>
<input
type="password"
id="password"
name="password"
required
minlength="6"
class="w-full px-4 py-3 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue focus:ring-1 focus:ring-balloon-blue transition-colors"
placeholder="Create a password (min 6 characters)"
>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-text-main/70 mb-2">Confirm Password</label>
<input
type="password"
id="confirm_password"
name="confirm_password"
required
class="w-full px-4 py-3 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue focus:ring-1 focus:ring-balloon-blue transition-colors"
placeholder="Confirm your password"
>
</div>
<div class="text-xs text-text-main/40">
Free accounts are limited to 5 requests per day.
</div>
<button
type="submit"
class="w-full py-3 bg-gradient-to-r from-balloon-blue to-balloon-purple hover:from-balloon-blue/80 hover:to-balloon-purple/80 text-white font-semibold rounded-lg transition-all duration-300 transform hover:scale-[1.02] shadow-lg shadow-balloon-blue/20"
>
Create Account
</button>
</form>
<div class="mt-6 text-center text-sm text-text-main/50">
Already have an account?
<a href="/login" class="text-balloon-blue hover:text-balloon-yellow transition-colors">Sign in</a>
</div>
</div>
<!-- Back to home -->
<div class="mt-4 text-center">
<a href="/" class="text-text-main/40 hover:text-text-main/60 text-sm transition-colors">
← Back to Home
</a>
</div>
</div>
<script>
document.getElementById('signupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const username = form.username.value;
const email = form.email.value;
const password = form.password.value;
const confirm_password = form.confirm_password.value;
if (password !== confirm_password) {
alert('Passwords do not match');
return;
}
if (password.length < 6) {
alert('Password must be at least 6 characters');
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Creating account...';
try {
const formData = new FormData();
formData.append('username', username);
formData.append('email', email);
formData.append('password', password);
formData.append('confirm_password', confirm_password);
const response = await fetch('/signup', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success && data.redirect) {
window.location.href = data.redirect;
} else {
// Reload to show error
window.location.reload();
}
} catch (error) {
console.error('Signup error:', error);
alert('An error occurred. Please try again.');
submitBtn.disabled = false;
submitBtn.textContent = 'Create Account';
}
});
</script>
</body>
</html>