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:
parent
072d4948b6
commit
1f9535d683
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
191
moxie/admin/templates/users.html
Normal file
191
moxie/admin/templates/users.html
Normal 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>
|
||||
@ -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
7
moxie/auth/__init__.py
Normal 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
562
moxie/auth/models.py
Normal 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
|
||||
@ -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"])
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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
7
moxie/web/__init__.py
Normal 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
825
moxie/web/routes.py
Normal 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)
|
||||
BIN
moxie/web/static/moxie_logo.jpg
Normal file
BIN
moxie/web/static/moxie_logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
622
moxie/web/templates/chat.html
Normal file
622
moxie/web/templates/chat.html
Normal 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>
|
||||
104
moxie/web/templates/landing.html
Normal file
104
moxie/web/templates/landing.html
Normal 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>
|
||||
137
moxie/web/templates/login.html
Normal file
137
moxie/web/templates/login.html
Normal 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>
|
||||
257
moxie/web/templates/profile.html
Normal file
257
moxie/web/templates/profile.html
Normal 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>
|
||||
181
moxie/web/templates/signup.html
Normal file
181
moxie/web/templates/signup.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user