clean old
This commit is contained in:
parent
e344431ca1
commit
5efd333fab
@ -1 +0,0 @@
|
||||
# Moxiegen Backend Application
|
||||
@ -1 +0,0 @@
|
||||
# API module
|
||||
@ -1,206 +0,0 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.auth import get_current_admin_user, get_password_hash
|
||||
from app.models.models import User, AIEndpoint, ChatMessage, UploadedFile
|
||||
from app.schemas.schemas import (
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
AIEndpointCreate,
|
||||
AIEndpointUpdate,
|
||||
AIEndpointResponse,
|
||||
AdminStats
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["Admin"])
|
||||
|
||||
|
||||
# User Management
|
||||
@router.get("/users", response_model=List[UserResponse])
|
||||
def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_user = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
users = db.query(User).offset(skip).limit(limit).all()
|
||||
return users
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=UserResponse)
|
||||
def get_user(
|
||||
user_id: int,
|
||||
current_user = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/users/{user_id}", response_model=UserResponse)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
user_data: UserUpdate,
|
||||
current_user = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user_data.email:
|
||||
existing_user = db.query(User).filter(
|
||||
User.email == user_data.email,
|
||||
User.id != user_id
|
||||
).first()
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
user.email = user_data.email
|
||||
|
||||
if user_data.username:
|
||||
existing_user = db.query(User).filter(
|
||||
User.username == user_data.username,
|
||||
User.id != user_id
|
||||
).first()
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
user.username = user_data.username
|
||||
|
||||
if user_data.password:
|
||||
user.hashed_password = get_password_hash(user_data.password)
|
||||
|
||||
if user_data.role:
|
||||
user.role = user_data.role
|
||||
|
||||
if user_data.is_active is not None:
|
||||
user.is_active = user_data.is_active
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
current_user = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"message": "User deleted successfully"}
|
||||
|
||||
|
||||
# AI Endpoint Management
|
||||
@router.get("/endpoints", response_model=List[AIEndpointResponse])
|
||||
def list_endpoints(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_user = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
endpoints = db.query(AIEndpoint).offset(skip).limit(limit).all()
|
||||
return endpoints
|
||||
|
||||
|
||||
@router.post("/endpoints", response_model=AIEndpointResponse)
|
||||
def create_endpoint(
|
||||
endpoint_data: AIEndpointCreate,
|
||||
current_user = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# If this is set as default, unset other defaults
|
||||
if endpoint_data.is_default:
|
||||
db.query(AIEndpoint).filter(AIEndpoint.is_default == True).update({"is_default": False})
|
||||
|
||||
new_endpoint = AIEndpoint(**endpoint_data.model_dump())
|
||||
db.add(new_endpoint)
|
||||
db.commit()
|
||||
db.refresh(new_endpoint)
|
||||
return new_endpoint
|
||||
|
||||
|
||||
@router.get("/endpoints/{endpoint_id}", response_model=AIEndpointResponse)
|
||||
def get_endpoint(
|
||||
endpoint_id: int,
|
||||
current_user = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
endpoint = db.query(AIEndpoint).filter(AIEndpoint.id == endpoint_id).first()
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=404, detail="Endpoint not found")
|
||||
return endpoint
|
||||
|
||||
|
||||
@router.put("/endpoints/{endpoint_id}", response_model=AIEndpointResponse)
|
||||
def update_endpoint(
|
||||
endpoint_id: int,
|
||||
endpoint_data: AIEndpointUpdate,
|
||||
current_user = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
endpoint = db.query(AIEndpoint).filter(AIEndpoint.id == endpoint_id).first()
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=404, detail="Endpoint not found")
|
||||
|
||||
update_data = endpoint_data.model_dump(exclude_unset=True)
|
||||
|
||||
# If setting as default, unset other defaults
|
||||
if update_data.get("is_default"):
|
||||
db.query(AIEndpoint).filter(
|
||||
AIEndpoint.is_default == True,
|
||||
AIEndpoint.id != endpoint_id
|
||||
).update({"is_default": False})
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(endpoint, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(endpoint)
|
||||
return endpoint
|
||||
|
||||
|
||||
@router.delete("/endpoints/{endpoint_id}")
|
||||
def delete_endpoint(
|
||||
endpoint_id: int,
|
||||
current_user = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
endpoint = db.query(AIEndpoint).filter(AIEndpoint.id == endpoint_id).first()
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=404, detail="Endpoint not found")
|
||||
|
||||
db.delete(endpoint)
|
||||
db.commit()
|
||||
return {"message": "Endpoint deleted successfully"}
|
||||
|
||||
|
||||
# Admin Statistics
|
||||
@router.get("/stats", response_model=AdminStats)
|
||||
def get_stats(
|
||||
current_user = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
total_users = db.query(User).count()
|
||||
total_endpoints = db.query(AIEndpoint).count()
|
||||
total_messages = db.query(ChatMessage).count()
|
||||
total_files = db.query(UploadedFile).count()
|
||||
active_users = db.query(User).filter(User.is_active == True).count()
|
||||
|
||||
return AdminStats(
|
||||
total_users=total_users,
|
||||
total_endpoints=total_endpoints,
|
||||
total_messages=total_messages,
|
||||
total_files=total_files,
|
||||
active_users=active_users
|
||||
)
|
||||
@ -1,126 +0,0 @@
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.auth import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
get_current_user
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.models.models import User
|
||||
from app.schemas.schemas import (
|
||||
UserCreate,
|
||||
UserLogin,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
Token
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
# Check if email already exists
|
||||
db_user = db.query(User).filter(User.email == user_data.email).first()
|
||||
if db_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Check if username already exists
|
||||
db_user = db.query(User).filter(User.username == user_data.username).first()
|
||||
if db_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already taken"
|
||||
)
|
||||
|
||||
# Create new user
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
new_user = User(
|
||||
email=user_data.email,
|
||||
username=user_data.username,
|
||||
hashed_password=hashed_password,
|
||||
role="user",
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(login_data: UserLogin, db: Session = Depends(get_db)):
|
||||
# Find user by email
|
||||
user = db.query(User).filter(User.email == login_data.email).first()
|
||||
|
||||
if not user or not verify_password(login_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": str(user.id), "email": user.email, "role": user.role},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
def update_me(
|
||||
user_data: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if user_data.email:
|
||||
existing_user = db.query(User).filter(
|
||||
User.email == user_data.email,
|
||||
User.id != current_user.id
|
||||
).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
current_user.email = user_data.email
|
||||
|
||||
if user_data.username:
|
||||
existing_user = db.query(User).filter(
|
||||
User.username == user_data.username,
|
||||
User.id != current_user.id
|
||||
).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already taken"
|
||||
)
|
||||
current_user.username = user_data.username
|
||||
|
||||
if user_data.password:
|
||||
current_user.hashed_password = get_password_hash(user_data.password)
|
||||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
return current_user
|
||||
@ -1,231 +0,0 @@
|
||||
import os
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from sqlalchemy.orm import Session
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
from app.core.database import get_db
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.models import User, AIEndpoint, ChatMessage, UploadedFile
|
||||
from app.schemas.schemas import (
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
ChatMessageResponse,
|
||||
UploadedFileResponse
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["Chat"])
|
||||
|
||||
|
||||
# Ensure upload directory exists
|
||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=UploadedFileResponse)
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Check file size
|
||||
contents = await file.read()
|
||||
if len(contents) > settings.MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File size exceeds maximum allowed size of {settings.MAX_FILE_SIZE} bytes"
|
||||
)
|
||||
|
||||
# Generate unique filename
|
||||
file_extension = os.path.splitext(file.filename)[1]
|
||||
unique_filename = f"{uuid.uuid4()}{file_extension}"
|
||||
file_path = os.path.join(settings.UPLOAD_DIR, unique_filename)
|
||||
|
||||
# Save file
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
# Create database record
|
||||
uploaded_file = UploadedFile(
|
||||
user_id=current_user.id,
|
||||
filename=unique_filename,
|
||||
original_filename=file.filename,
|
||||
file_path=file_path,
|
||||
file_size=len(contents),
|
||||
file_type=file.content_type
|
||||
)
|
||||
db.add(uploaded_file)
|
||||
db.commit()
|
||||
db.refresh(uploaded_file)
|
||||
|
||||
return uploaded_file
|
||||
|
||||
|
||||
@router.get("/files", response_model=List[UploadedFileResponse])
|
||||
def list_files(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
files = db.query(UploadedFile).filter(UploadedFile.user_id == current_user.id).all()
|
||||
return files
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}")
|
||||
def delete_file(
|
||||
file_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
uploaded_file = db.query(UploadedFile).filter(
|
||||
UploadedFile.id == file_id,
|
||||
UploadedFile.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not uploaded_file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Delete file from filesystem
|
||||
if os.path.exists(uploaded_file.file_path):
|
||||
os.remove(uploaded_file.file_path)
|
||||
|
||||
db.delete(uploaded_file)
|
||||
db.commit()
|
||||
|
||||
return {"message": "File deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/message", response_model=ChatResponse)
|
||||
async def send_message(
|
||||
request: ChatRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Get the AI endpoint
|
||||
endpoint = None
|
||||
if request.endpoint_id:
|
||||
endpoint = db.query(AIEndpoint).filter(
|
||||
AIEndpoint.id == request.endpoint_id,
|
||||
AIEndpoint.is_active == True
|
||||
).first()
|
||||
else:
|
||||
# Get default endpoint
|
||||
endpoint = db.query(AIEndpoint).filter(
|
||||
AIEndpoint.is_default == True,
|
||||
AIEndpoint.is_active == True
|
||||
).first()
|
||||
|
||||
if not endpoint:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No active AI endpoint available"
|
||||
)
|
||||
|
||||
# Save user message
|
||||
user_message = ChatMessage(
|
||||
user_id=current_user.id,
|
||||
role="user",
|
||||
content=request.message,
|
||||
endpoint_id=endpoint.id
|
||||
)
|
||||
db.add(user_message)
|
||||
db.commit()
|
||||
|
||||
# Build messages for API call
|
||||
messages = []
|
||||
if request.conversation_history:
|
||||
for msg in request.conversation_history:
|
||||
messages.append({"role": msg.role, "content": msg.content})
|
||||
messages.append({"role": "user", "content": request.message})
|
||||
|
||||
# Call AI endpoint
|
||||
try:
|
||||
response_content = await call_ai_endpoint(endpoint, messages)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error calling AI endpoint: {str(e)}"
|
||||
)
|
||||
|
||||
# Save assistant message
|
||||
assistant_message = ChatMessage(
|
||||
user_id=current_user.id,
|
||||
role="assistant",
|
||||
content=response_content,
|
||||
endpoint_id=endpoint.id
|
||||
)
|
||||
db.add(assistant_message)
|
||||
db.commit()
|
||||
|
||||
return ChatResponse(
|
||||
response=response_content,
|
||||
endpoint_id=endpoint.id,
|
||||
model=endpoint.model_name
|
||||
)
|
||||
|
||||
|
||||
async def call_ai_endpoint(endpoint: AIEndpoint, messages: List[dict]) -> str:
|
||||
if endpoint.endpoint_type == "openai":
|
||||
return await call_openai_compatible(endpoint, messages)
|
||||
elif endpoint.endpoint_type == "ollama":
|
||||
return await call_ollama(endpoint, messages)
|
||||
else:
|
||||
raise ValueError(f"Unknown endpoint type: {endpoint.endpoint_type}")
|
||||
|
||||
|
||||
async def call_openai_compatible(endpoint: AIEndpoint, messages: List[dict]) -> str:
|
||||
"""Call OpenAI-compatible API (works with OpenAI, local AI servers, etc.)"""
|
||||
client = AsyncOpenAI(
|
||||
api_key=endpoint.api_key or "not-needed",
|
||||
base_url=endpoint.base_url
|
||||
)
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
model=endpoint.model_name,
|
||||
messages=messages
|
||||
)
|
||||
|
||||
return response.choices[0].message.content
|
||||
|
||||
|
||||
async def call_ollama(endpoint: AIEndpoint, messages: List[dict]) -> str:
|
||||
"""Call Ollama API"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{endpoint.base_url.rstrip('/')}/api/chat",
|
||||
json={
|
||||
"model": endpoint.model_name,
|
||||
"messages": messages,
|
||||
"stream": False
|
||||
},
|
||||
timeout=60.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["message"]["content"]
|
||||
|
||||
|
||||
@router.get("/history", response_model=List[ChatMessageResponse])
|
||||
def get_history(
|
||||
limit: int = 50,
|
||||
endpoint_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(ChatMessage).filter(ChatMessage.user_id == current_user.id)
|
||||
|
||||
if endpoint_id:
|
||||
query = query.filter(ChatMessage.endpoint_id == endpoint_id)
|
||||
|
||||
messages = query.order_by(ChatMessage.created_at.desc()).limit(limit).all()
|
||||
return list(reversed(messages))
|
||||
|
||||
|
||||
@router.delete("/history")
|
||||
def clear_history(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
db.query(ChatMessage).filter(ChatMessage.user_id == current_user.id).delete()
|
||||
db.commit()
|
||||
return {"message": "Chat history cleared successfully"}
|
||||
@ -1 +0,0 @@
|
||||
# Core module
|
||||
@ -1,79 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
from app.models.models import User
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
payload = decode_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exception
|
||||
|
||||
user_id: int = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
return current_user
|
||||
@ -1,19 +0,0 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
DATABASE_URL: str = "sqlite:///./moxiegen.db"
|
||||
UPLOAD_DIR: str = "./uploads"
|
||||
MAX_FILE_SIZE: int = 10 * 1024 * 1024 # 10MB
|
||||
DEFAULT_ADMIN_EMAIL: str = "admin@moxiegen.com"
|
||||
DEFAULT_ADMIN_PASSWORD: str = "admin123"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@ -1,21 +0,0 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@ -1,98 +0,0 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.core.database import engine, Base, SessionLocal
|
||||
from app.core.auth import get_password_hash
|
||||
from app.models.models import User, AIEndpoint
|
||||
from app.api import auth, admin, chat
|
||||
|
||||
|
||||
# Create database tables
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
# Create default admin user and default AI endpoint
|
||||
def create_defaults():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check if admin exists
|
||||
admin = db.query(User).filter(User.email == settings.DEFAULT_ADMIN_EMAIL).first()
|
||||
if not admin:
|
||||
admin = User(
|
||||
email=settings.DEFAULT_ADMIN_EMAIL,
|
||||
username="admin",
|
||||
hashed_password=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD),
|
||||
role="admin",
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin)
|
||||
print(f"Created default admin user: {settings.DEFAULT_ADMIN_EMAIL}")
|
||||
|
||||
# Check if default endpoint exists
|
||||
default_endpoint = db.query(AIEndpoint).filter(AIEndpoint.is_default == True).first()
|
||||
if not default_endpoint:
|
||||
# Create a default Ollama endpoint
|
||||
default_endpoint = AIEndpoint(
|
||||
name="Default Ollama",
|
||||
endpoint_type="ollama",
|
||||
base_url="http://localhost:11434",
|
||||
model_name="llama2",
|
||||
is_active=True,
|
||||
is_default=True
|
||||
)
|
||||
db.add(default_endpoint)
|
||||
print("Created default Ollama endpoint")
|
||||
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
init_db()
|
||||
create_defaults()
|
||||
|
||||
# Ensure upload directory exists
|
||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown (if needed)
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Moxiegen API",
|
||||
description="Backend API for Moxiegen application",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Configure this properly in production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(chat.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "Welcome to Moxiegen API", "version": "1.0.0"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "healthy"}
|
||||
@ -1 +0,0 @@
|
||||
# Models module
|
||||
@ -1,64 +0,0 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
role = Column(String, default="user") # "user" or "admin"
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
chat_messages = relationship("ChatMessage", back_populates="user")
|
||||
uploaded_files = relationship("UploadedFile", back_populates="user")
|
||||
|
||||
|
||||
class AIEndpoint(Base):
|
||||
__tablename__ = "ai_endpoints"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
endpoint_type = Column(String, nullable=False) # "openai" or "ollama"
|
||||
base_url = Column(String, nullable=False)
|
||||
api_key = Column(String, nullable=True) # Optional for Ollama
|
||||
model_name = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
role = Column(String, nullable=False) # "user" or "assistant"
|
||||
content = Column(Text, nullable=False)
|
||||
endpoint_id = Column(Integer, ForeignKey("ai_endpoints.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="chat_messages")
|
||||
endpoint = relationship("AIEndpoint")
|
||||
|
||||
|
||||
class UploadedFile(Base):
|
||||
__tablename__ = "uploaded_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
filename = Column(String, nullable=False)
|
||||
original_filename = Column(String, nullable=False)
|
||||
file_path = Column(String, nullable=False)
|
||||
file_size = Column(Integer, nullable=False)
|
||||
file_type = Column(String, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="uploaded_files")
|
||||
@ -1 +0,0 @@
|
||||
# Schemas module
|
||||
@ -1,139 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
# User schemas
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
username: str
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Token schemas
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
|
||||
|
||||
# AIEndpoint schemas
|
||||
class AIEndpointBase(BaseModel):
|
||||
name: str
|
||||
endpoint_type: str # "openai" or "ollama"
|
||||
base_url: str
|
||||
api_key: Optional[str] = None
|
||||
model_name: str
|
||||
is_active: bool = True
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class AIEndpointCreate(AIEndpointBase):
|
||||
pass
|
||||
|
||||
|
||||
class AIEndpointUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
endpoint_type: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_default: Optional[bool] = None
|
||||
|
||||
|
||||
class AIEndpointResponse(AIEndpointBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ChatMessage schemas
|
||||
class ChatMessageBase(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class ChatMessageCreate(ChatMessageBase):
|
||||
endpoint_id: Optional[int] = None
|
||||
|
||||
|
||||
class ChatMessageResponse(ChatMessageBase):
|
||||
id: int
|
||||
user_id: int
|
||||
endpoint_id: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# UploadedFile schemas
|
||||
class UploadedFileBase(BaseModel):
|
||||
original_filename: str
|
||||
file_type: Optional[str] = None
|
||||
|
||||
|
||||
class UploadedFileResponse(UploadedFileBase):
|
||||
id: int
|
||||
user_id: int
|
||||
filename: str
|
||||
file_path: str
|
||||
file_size: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Chat schemas
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
endpoint_id: Optional[int] = None
|
||||
file_ids: Optional[List[int]] = None
|
||||
conversation_history: Optional[List[dict]] = None
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
response: str
|
||||
endpoint_used: Optional[str] = None
|
||||
model_used: Optional[str] = None
|
||||
|
||||
|
||||
# AdminStats schema
|
||||
class AdminStats(BaseModel):
|
||||
total_users: int
|
||||
total_endpoints: int
|
||||
total_messages: int
|
||||
active_endpoints: int
|
||||
@ -1,10 +0,0 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlalchemy
|
||||
pydantic
|
||||
python-jose
|
||||
passlib
|
||||
python-multipart
|
||||
aiofiles
|
||||
httpx
|
||||
openai
|
||||
@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "moxiegen-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^15.0.0",
|
||||
"highlight.js": "^11.11.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
@ -1,463 +0,0 @@
|
||||
:root {
|
||||
/* Vibrant Color Palette - Hot Air Balloon Theme */
|
||||
--primary: #FF6B6B; /* Coral Red */
|
||||
--primary-hover: #FF5252;
|
||||
--secondary: #4ECDC4; /* Teal */
|
||||
--accent-1: #FFE66D; /* Sunny Yellow */
|
||||
--accent-2: #95E1D3; /* Mint */
|
||||
--accent-3: #F38181; /* Salmon */
|
||||
--accent-4: #AA96DA; /* Lavender */
|
||||
--accent-5: #FCBAD3; /* Pink */
|
||||
--accent-6: #A8D8EA; /* Sky Blue */
|
||||
--accent-7: #FF9F43; /* Orange */
|
||||
--accent-8: #5F27CD; /* Purple */
|
||||
|
||||
/* Gradients */
|
||||
--gradient-sunset: linear-gradient(135deg, #FF6B6B 0%, #FFE66D 50%, #95E1D3 100%);
|
||||
--gradient-balloon: linear-gradient(135deg, #FF6B6B 0%, #FCBAD3 50%, #AA96DA 100%);
|
||||
--gradient-sky: linear-gradient(135deg, #A8D8EA 0%, #95E1D3 50%, #FFE66D 100%);
|
||||
--gradient-aurora: linear-gradient(135deg, #5F27CD 0%, #4ECDC4 50%, #FFE66D 100%);
|
||||
|
||||
/* Background */
|
||||
--background: #1A1A2E; /* Deep Space */
|
||||
--background-light: #16213E;
|
||||
--surface: #0F3460; /* Ocean Blue */
|
||||
--surface-hover: #1A4B8C;
|
||||
--surface-card: rgba(15, 52, 96, 0.8);
|
||||
--border: rgba(78, 205, 196, 0.3);
|
||||
--border-bright: #4ECDC4;
|
||||
|
||||
/* Text */
|
||||
--text: #F7F7F7;
|
||||
--text-muted: #B8B8D1;
|
||||
--text-bright: #FFFFFF;
|
||||
|
||||
/* Status Colors */
|
||||
--success: #95E1D3;
|
||||
--danger: #FF6B6B;
|
||||
--warning: #FFE66D;
|
||||
--info: #A8D8EA;
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 280px;
|
||||
--header-height: 70px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-glow: 0 0 30px rgba(78, 205, 196, 0.3);
|
||||
--shadow-card: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
--shadow-button: 0 4px 15px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif;
|
||||
background: var(--background);
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 80%, rgba(170, 150, 218, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(78, 205, 196, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(255, 107, 107, 0.1) 0%, transparent 40%);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-1);
|
||||
text-shadow: 0 0 10px rgba(255, 230, 109, 0.5);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--background);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--gradient-sunset);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--gradient-balloon);
|
||||
}
|
||||
|
||||
/* ============ BUTTONS ============ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
border-radius: 50px;
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-sunset);
|
||||
color: var(--background);
|
||||
box-shadow: var(--shadow-button);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 107, 0.5);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--secondary);
|
||||
border: 2px solid var(--secondary);
|
||||
box-shadow: 0 0 15px rgba(78, 205, 196, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--secondary);
|
||||
color: var(--background);
|
||||
box-shadow: 0 0 25px rgba(78, 205, 196, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #FF6B6B, #F38181);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 107, 0.5);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.75rem;
|
||||
min-width: 48px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* ============ INPUTS ============ */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(22, 33, 62, 0.8);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 15px;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--secondary);
|
||||
box-shadow: 0 0 20px rgba(78, 205, 196, 0.3);
|
||||
background: rgba(22, 33, 62, 1);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ============ CARDS ============ */
|
||||
.card {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-bright);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* ============ TABS ============ */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--surface);
|
||||
border-radius: 50px;
|
||||
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: rgba(78, 205, 196, 0.1);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--gradient-sunset);
|
||||
color: var(--background);
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
|
||||
/* ============ BADGES ============ */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge.admin {
|
||||
background: var(--gradient-balloon);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.active {
|
||||
background: linear-gradient(135deg, #95E1D3, #4ECDC4);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.badge.inactive {
|
||||
background: linear-gradient(135deg, #FF6B6B, #F38181);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.default {
|
||||
background: linear-gradient(135deg, #FFE66D, #FF9F43);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
/* ============ TABLES ============ */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 1rem 1.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: var(--background-light);
|
||||
font-weight: 700;
|
||||
color: var(--secondary);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.data-table tr {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: rgba(78, 205, 196, 0.05);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ============ ANIMATIONS ============ */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(78, 205, 196, 0.3); }
|
||||
50% { box-shadow: 0 0 40px rgba(78, 205, 196, 0.6); }
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ============ MARKDOWN CONTENT ============ */
|
||||
.markdown-content {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background: var(--background);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
color: var(--accent-1);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background: var(--background);
|
||||
padding: 1.25rem;
|
||||
border-radius: 15px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.markdown-content ul, .markdown-content ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content h1, .markdown-content h2, .markdown-content h3 {
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
background: var(--gradient-sunset);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.markdown-content h1 { font-size: 1.5rem; }
|
||||
.markdown-content h2 { font-size: 1.25rem; }
|
||||
.markdown-content h3 { font-size: 1.1rem; }
|
||||
|
||||
/* ============ MODAL ============ */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(26, 26, 46, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: linear-gradient(135deg, var(--surface) 0%, var(--background-light) 100%);
|
||||
border: 2px solid var(--border-bright);
|
||||
border-radius: 25px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow:
|
||||
0 0 50px rgba(78, 205, 196, 0.3),
|
||||
0 25px 50px rgba(0, 0, 0, 0.5);
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
background: var(--gradient-sunset);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* ============ NEURAL NETWORK BACKGROUND DECORATION ============ */
|
||||
.neural-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 20%, rgba(255, 107, 107, 0.1) 0%, transparent 20%),
|
||||
radial-gradient(circle at 30% 80%, rgba(78, 205, 196, 0.1) 0%, transparent 20%),
|
||||
radial-gradient(circle at 70% 30%, rgba(255, 230, 109, 0.1) 0%, transparent 20%),
|
||||
radial-gradient(circle at 90% 70%, rgba(170, 150, 218, 0.1) 0%, transparent 20%);
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/logo.jpg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,202 +0,0 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.token = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
this.token = localStorage.getItem('token');
|
||||
}
|
||||
}
|
||||
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
if (typeof window !== 'undefined') {
|
||||
if (token) {
|
||||
localStorage.setItem('token', token);
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
this.setToken(null);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||
// Handle FastAPI validation errors
|
||||
if (errorData.detail && typeof errorData.detail === 'object') {
|
||||
throw new Error(JSON.stringify(errorData.detail));
|
||||
}
|
||||
throw new Error(errorData.detail || 'Request failed');
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async login(email, password) {
|
||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
if (data.detail) {
|
||||
if (typeof data.detail === 'string') {
|
||||
throw new Error(data.detail);
|
||||
}
|
||||
// Validation errors
|
||||
if (Array.isArray(data.detail)) {
|
||||
const messages = data.detail.map(e => e.msg).join(', ');
|
||||
throw new Error(messages);
|
||||
}
|
||||
throw new Error(JSON.stringify(data.detail));
|
||||
}
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
this.setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
|
||||
async register(email, username, password) {
|
||||
return this.request('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
async getMe() {
|
||||
return this.request('/auth/me');
|
||||
}
|
||||
|
||||
async updateMe(data) {
|
||||
return this.request('/auth/me', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getEndpoints() {
|
||||
return this.request('/chat/endpoints');
|
||||
}
|
||||
|
||||
async sendMessage(message, endpointId = null, fileIds = [], conversationHistory = []) {
|
||||
return this.request('/chat/message', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
endpoint_id: endpointId,
|
||||
file_ids: fileIds,
|
||||
conversation_history: conversationHistory,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getChatHistory(limit = 50) {
|
||||
return this.request(`/chat/history?limit=${limit}`);
|
||||
}
|
||||
|
||||
async uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const headers = {};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/chat/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Upload failed' }));
|
||||
throw new Error(error.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getFiles() {
|
||||
return this.request('/chat/files');
|
||||
}
|
||||
|
||||
async deleteFile(fileId) {
|
||||
return this.request(`/chat/files/${fileId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async getAdminStats() {
|
||||
return this.request('/admin/stats');
|
||||
}
|
||||
|
||||
async getUsers() {
|
||||
return this.request('/admin/users');
|
||||
}
|
||||
|
||||
async updateUser(userId, data) {
|
||||
return this.request(`/admin/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(userId) {
|
||||
return this.request(`/admin/users/${userId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async getAdminEndpoints() {
|
||||
return this.request('/admin/endpoints');
|
||||
}
|
||||
|
||||
async createEndpoint(data) {
|
||||
return this.request('/admin/endpoints', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateEndpoint(endpointId, data) {
|
||||
return this.request(`/admin/endpoints/${endpointId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEndpoint(endpointId) {
|
||||
return this.request(`/admin/endpoints/${endpointId}`, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
@ -1,39 +0,0 @@
|
||||
import { api } from './api.js';
|
||||
|
||||
// Svelte 5 runes-based stores
|
||||
let user = $state(null);
|
||||
let isLoading = $state(true);
|
||||
|
||||
export function getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
export function getIsLoading() {
|
||||
return isLoading;
|
||||
}
|
||||
|
||||
export async function initializeAuth() {
|
||||
try {
|
||||
if (api.token) {
|
||||
const userData = await api.getMe();
|
||||
user = userData;
|
||||
}
|
||||
} catch (e) {
|
||||
api.setToken(null);
|
||||
user = null;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setUser(userData) {
|
||||
user = userData;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
api.setToken(null);
|
||||
user = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@ -1,436 +0,0 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getUser, getIsLoading, initializeAuth, logout, setUser } from '$lib/stores.svelte';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
|
||||
let user = $derived(getUser());
|
||||
let isLoading = $derived(getIsLoading());
|
||||
|
||||
onMount(() => {
|
||||
initializeAuth();
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-screen">
|
||||
<div class="loading-content">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="loading-logo" />
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading Moxiegen...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if user}
|
||||
<div class="app-layout">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<button class="menu-toggle" onclick={() => sidebarOpen = !sidebarOpen}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="logo">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="logo-img" />
|
||||
<span class="logo-text">Moxiegen</span>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="user-badge">
|
||||
<span class="user-icon">👤</span>
|
||||
<span class="user-name">{user.username}</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick={handleLogout}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" class:open={sidebarOpen}>
|
||||
<div class="sidebar-header">
|
||||
<h3>Navigation</h3>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/chat" class="nav-link" class:active={$page.url.pathname === '/chat'}>
|
||||
<span class="nav-icon">💬</span>
|
||||
<span class="nav-text">Chat</span>
|
||||
<span class="nav-glow"></span>
|
||||
</a>
|
||||
{#if user.role === 'admin'}
|
||||
<a href="/admin" class="nav-link" class:active={$page.url.pathname.startsWith('/admin')}>
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span class="nav-text">Admin Panel</span>
|
||||
<span class="nav-glow"></span>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="neural-decoration">
|
||||
<div class="node n1"></div>
|
||||
<div class="node n2"></div>
|
||||
<div class="node n3"></div>
|
||||
<div class="connection c1"></div>
|
||||
<div class="connection c2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<!-- Overlay for mobile -->
|
||||
{#if sidebarOpen}
|
||||
<div class="sidebar-overlay" onclick={() => sidebarOpen = false}></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="neural-bg"></div>
|
||||
{:else}
|
||||
<slot />
|
||||
<div class="neural-bg"></div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 1.5rem;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto 1rem;
|
||||
border: 4px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-right-color: var(--accent-1);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-content p {
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"sidebar main";
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
grid-template-rows: var(--header-height) 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 2rem;
|
||||
background: linear-gradient(135deg, var(--surface) 0%, var(--background-light) 100%);
|
||||
border-bottom: 2px solid transparent;
|
||||
border-image: var(--gradient-sunset) 1;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
background: transparent;
|
||||
border: 2px solid var(--secondary);
|
||||
color: var(--secondary);
|
||||
padding: 0.5rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-toggle:hover {
|
||||
background: var(--secondary);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 15px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.logo-img:hover {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
box-shadow: 0 6px 25px rgba(255, 107, 107, 0.5);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
background: var(--gradient-sunset);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(78, 205, 196, 0.1);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.user-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: var(--secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
background: linear-gradient(180deg, var(--surface) 0%, var(--background-light) 100%);
|
||||
border-right: 2px solid transparent;
|
||||
border-image: var(--gradient-sky) 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-header h3 {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 15px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text);
|
||||
background: rgba(78, 205, 196, 0.1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-link:hover .nav-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--gradient-sunset);
|
||||
color: var(--background);
|
||||
box-shadow: 0 4px 20px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
|
||||
.nav-link.active .nav-icon {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.5rem;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.nav-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at center, rgba(78, 205, 196, 0.2), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* Neural Network Decoration */
|
||||
.sidebar-footer {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.neural-decoration {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.n1 {
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
background: var(--primary);
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.n2 {
|
||||
left: 44px;
|
||||
top: 35px;
|
||||
background: var(--secondary);
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.n3 {
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
background: var(--accent-1);
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.connection {
|
||||
position: absolute;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--secondary));
|
||||
opacity: 0.5;
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
left: 16px;
|
||||
top: 16px;
|
||||
width: 35px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.c2 {
|
||||
left: 50px;
|
||||
top: 40px;
|
||||
width: 35px;
|
||||
transform: rotate(-45deg);
|
||||
background: linear-gradient(90deg, var(--secondary), var(--accent-1));
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
grid-area: main;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: var(--header-height);
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: var(--header-height);
|
||||
background: rgba(26, 26, 46, 0.9);
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,48 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getUser, getIsLoading, initializeAuth } from '$lib/stores.svelte';
|
||||
|
||||
let user = $derived(getUser());
|
||||
let isLoading = $derived(getIsLoading());
|
||||
|
||||
onMount(async () => {
|
||||
await initializeAuth();
|
||||
if (user) {
|
||||
goto('/chat');
|
||||
} else {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="loading-container">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading Moxiegen...</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
gap: 1rem;
|
||||
background: var(--background);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@ -1,669 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { getUser, initializeAuth } from '$lib/stores.svelte';
|
||||
|
||||
let stats = $state({ total_users: 0, total_endpoints: 0, total_messages: 0, active_endpoints: 0 });
|
||||
let users = $state([]);
|
||||
let endpoints = $state([]);
|
||||
let showUserModal = $state(false);
|
||||
let editingUser = $state(null);
|
||||
let userForm = $state({ email: '', username: '', role: 'user', is_active: true });
|
||||
let showEndpointModal = $state(false);
|
||||
let editingEndpoint = $state(null);
|
||||
let endpointForm = $state({
|
||||
name: '',
|
||||
endpoint_type: 'openai',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model_name: '',
|
||||
is_active: true,
|
||||
is_default: false
|
||||
});
|
||||
let activeTab = $state('stats');
|
||||
let loading = $state(true);
|
||||
|
||||
let user = $derived(getUser());
|
||||
|
||||
onMount(async () => {
|
||||
await initializeAuth();
|
||||
if (!user || user.role !== 'admin') {
|
||||
goto('/chat');
|
||||
return;
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [statsData, usersData, endpointsData] = await Promise.all([
|
||||
api.getAdminStats(),
|
||||
api.getUsers(),
|
||||
api.getAdminEndpoints()
|
||||
]);
|
||||
stats = statsData;
|
||||
users = usersData;
|
||||
endpoints = endpointsData;
|
||||
} catch (e) {
|
||||
console.error('Failed to load admin data:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openUserModal(user = null) {
|
||||
editingUser = user;
|
||||
if (user) {
|
||||
userForm = {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
is_active: user.is_active
|
||||
};
|
||||
} else {
|
||||
userForm = { email: '', username: '', role: 'user', is_active: true };
|
||||
}
|
||||
showUserModal = true;
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
try {
|
||||
if (editingUser) {
|
||||
await api.updateUser(editingUser.id, userForm);
|
||||
}
|
||||
showUserModal = false;
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
alert('Failed to save user: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(user) {
|
||||
if (!confirm(`Delete user "${user.username}"?`)) return;
|
||||
try {
|
||||
await api.deleteUser(user.id);
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
alert('Failed to delete user: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openEndpointModal(endpoint = null) {
|
||||
editingEndpoint = endpoint;
|
||||
if (endpoint) {
|
||||
endpointForm = {
|
||||
name: endpoint.name,
|
||||
endpoint_type: endpoint.endpoint_type,
|
||||
base_url: endpoint.base_url,
|
||||
api_key: endpoint.api_key || '',
|
||||
model_name: endpoint.model_name,
|
||||
is_active: endpoint.is_active,
|
||||
is_default: endpoint.is_default
|
||||
};
|
||||
} else {
|
||||
endpointForm = {
|
||||
name: '',
|
||||
endpoint_type: 'openai',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model_name: '',
|
||||
is_active: true,
|
||||
is_default: false
|
||||
};
|
||||
}
|
||||
showEndpointModal = true;
|
||||
}
|
||||
|
||||
async function saveEndpoint() {
|
||||
try {
|
||||
if (editingEndpoint) {
|
||||
await api.updateEndpoint(editingEndpoint.id, endpointForm);
|
||||
} else {
|
||||
await api.createEndpoint(endpointForm);
|
||||
}
|
||||
showEndpointModal = false;
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
alert('Failed to save endpoint: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEndpoint(endpoint) {
|
||||
if (!confirm(`Delete endpoint "${endpoint.name}"?`)) return;
|
||||
try {
|
||||
await api.deleteEndpoint(endpoint.id);
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
alert('Failed to delete endpoint: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin-page">
|
||||
<h1>Admin Panel</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'stats'}
|
||||
onclick={() => activeTab = 'stats'}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'users'}
|
||||
onclick={() => activeTab = 'users'}
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'endpoints'}
|
||||
onclick={() => activeTab = 'endpoints'}
|
||||
>
|
||||
AI Endpoints
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if activeTab === 'stats'}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{stats.total_users}</div>
|
||||
<div class="stat-label">Total Users</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{stats.total_endpoints}</div>
|
||||
<div class="stat-label">AI Endpoints</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{stats.active_endpoints}</div>
|
||||
<div class="stat-label">Active Endpoints</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{stats.total_messages}</div>
|
||||
<div class="stat-label">Total Messages</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeTab === 'users'}
|
||||
<div class="section-header">
|
||||
<h2>Users</h2>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as userRow}
|
||||
<tr>
|
||||
<td>{userRow.id}</td>
|
||||
<td>{userRow.username}</td>
|
||||
<td>{userRow.email}</td>
|
||||
<td>
|
||||
<span class="badge" class:admin={userRow.role === 'admin'}>
|
||||
{userRow.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status" class:active={userRow.is_active}>
|
||||
{userRow.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{new Date(userRow.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick={() => openUserModal(userRow)}>
|
||||
Edit
|
||||
</button>
|
||||
{#if userRow.role !== 'admin'}
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(userRow)}>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeTab === 'endpoints'}
|
||||
<div class="section-header">
|
||||
<h2>AI Endpoints</h2>
|
||||
<button class="btn btn-primary" onclick={() => openEndpointModal()}>
|
||||
+ Add Endpoint
|
||||
</button>
|
||||
</div>
|
||||
<div class="endpoints-grid">
|
||||
{#each endpoints as endpoint}
|
||||
<div class="endpoint-card">
|
||||
<div class="endpoint-header">
|
||||
<h3>{endpoint.name}</h3>
|
||||
<div class="endpoint-badges">
|
||||
{#if endpoint.is_default}
|
||||
<span class="badge default">Default</span>
|
||||
{/if}
|
||||
{#if endpoint.is_active}
|
||||
<span class="badge active">Active</span>
|
||||
{:else}
|
||||
<span class="badge inactive">Inactive</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="endpoint-details">
|
||||
<p><strong>Type:</strong> {endpoint.endpoint_type}</p>
|
||||
<p><strong>Model:</strong> {endpoint.model_name}</p>
|
||||
<p><strong>URL:</strong> {endpoint.base_url}</p>
|
||||
</div>
|
||||
<div class="endpoint-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick={() => openEndpointModal(endpoint)}>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteEndpoint(endpoint)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="no-data">No endpoints configured. Add one to get started.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showUserModal}
|
||||
<div class="modal-overlay" onclick={() => showUserModal = false}>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<h3>{editingUser ? 'Edit User' : 'New User'}</h3>
|
||||
<form onsubmit={(e) => { e.preventDefault(); saveUser(); }}>
|
||||
<div class="form-group">
|
||||
<label class="label">Email</label>
|
||||
<input type="email" class="input" bind:value={userForm.email} required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label">Username</label>
|
||||
<input type="text" class="input" bind:value={userForm.username} required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label">Role</label>
|
||||
<select class="input" bind:value={userForm.role}>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={userForm.is_active} />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick={() => showUserModal = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showEndpointModal}
|
||||
<div class="modal-overlay" onclick={() => showEndpointModal = false}>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<h3>{editingEndpoint ? 'Edit Endpoint' : 'Add AI Endpoint'}</h3>
|
||||
<form onsubmit={(e) => { e.preventDefault(); saveEndpoint(); }}>
|
||||
<div class="form-group">
|
||||
<label class="label">Name</label>
|
||||
<input type="text" class="input" bind:value={endpointForm.name} placeholder="My OpenAI Endpoint" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label">Type</label>
|
||||
<select class="input" bind:value={endpointForm.endpoint_type}>
|
||||
<option value="openai">OpenAI Compatible</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={endpointForm.base_url}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label">API Key (optional for Ollama)</label>
|
||||
<input
|
||||
type="password"
|
||||
class="input"
|
||||
bind:value={endpointForm.api_key}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label">Model Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={endpointForm.model_name}
|
||||
placeholder="gpt-3.5-turbo or llama2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={endpointForm.is_active} />
|
||||
Active
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={endpointForm.is_default} />
|
||||
Default
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick={() => showEndpointModal = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.admin-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: var(--background);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge.admin {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.active {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.inactive {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.default {
|
||||
background: var(--warning);
|
||||
color: black;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status.active {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.endpoints-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.endpoint-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.endpoint-header h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.endpoint-badges {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.endpoint-details {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.endpoint-details p {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.endpoint-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@ -1,812 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { getUser, getIsLoading, initializeAuth } from '$lib/stores.svelte';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
let messages = $state([]);
|
||||
let inputMessage = $state('');
|
||||
let endpoints = $state([]);
|
||||
let selectedEndpoint = $state(null);
|
||||
let loading = $state(true);
|
||||
let sending = $state(false);
|
||||
let uploadedFiles = $state([]);
|
||||
let selectedFiles = $state([]);
|
||||
let fileInput;
|
||||
|
||||
let user = $derived(getUser());
|
||||
let isLoading = $derived(getIsLoading());
|
||||
|
||||
marked.setOptions({
|
||||
highlight: function(code, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await initializeAuth();
|
||||
if (!user) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [endpointsData, historyData, filesData] = await Promise.all([
|
||||
api.getEndpoints(),
|
||||
api.getChatHistory(50),
|
||||
api.getFiles()
|
||||
]);
|
||||
endpoints = endpointsData;
|
||||
uploadedFiles = filesData;
|
||||
|
||||
const defaultEp = endpoints.find(e => e.is_default);
|
||||
if (defaultEp) {
|
||||
selectedEndpoint = defaultEp.id;
|
||||
} else if (endpoints.length > 0) {
|
||||
selectedEndpoint = endpoints[0].id;
|
||||
}
|
||||
|
||||
messages = historyData.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: new Date(m.created_at)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!inputMessage.trim() || sending) return;
|
||||
|
||||
const userMessage = inputMessage;
|
||||
inputMessage = '';
|
||||
|
||||
messages = [...messages, {
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
|
||||
const conversationHistory = messages.slice(-10).map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}));
|
||||
|
||||
sending = true;
|
||||
|
||||
try {
|
||||
const response = await api.sendMessage(
|
||||
userMessage,
|
||||
selectedEndpoint,
|
||||
selectedFiles.map(f => f.id),
|
||||
conversationHistory
|
||||
);
|
||||
|
||||
messages = [...messages, {
|
||||
role: 'assistant',
|
||||
content: response.response,
|
||||
model: response.model_used,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
|
||||
selectedFiles = [];
|
||||
} catch (e) {
|
||||
messages = [...messages, {
|
||||
role: 'assistant',
|
||||
content: `Error: ${e.message}`,
|
||||
isError: true,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
} finally {
|
||||
sending = false;
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const uploaded = await api.uploadFile(file);
|
||||
uploadedFiles = [...uploadedFiles, uploaded];
|
||||
selectedFiles = [...selectedFiles, uploaded];
|
||||
} catch (e) {
|
||||
alert('Failed to upload file: ' + e.message);
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
function removeSelectedFile(file) {
|
||||
selectedFiles = selectedFiles.filter(f => f.id !== file.id);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
const container = document.querySelector('.messages-container');
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function renderMarkdown(content) {
|
||||
return marked.parse(content);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-page">
|
||||
<!-- Sidebar -->
|
||||
<aside class="chat-sidebar">
|
||||
<div class="sidebar-section">
|
||||
<h3>🎈 AI Endpoints</h3>
|
||||
{#if endpoints.length === 0}
|
||||
<p class="no-data">No endpoints configured. <a href="/admin">Add one</a></p>
|
||||
{:else}
|
||||
<div class="endpoint-list">
|
||||
{#each endpoints as endpoint}
|
||||
<label class="endpoint-option" class:selected={selectedEndpoint === endpoint.id}>
|
||||
<input
|
||||
type="radio"
|
||||
name="endpoint"
|
||||
value={endpoint.id}
|
||||
bind:group={selectedEndpoint}
|
||||
/>
|
||||
<div class="endpoint-info">
|
||||
<span class="endpoint-name">{endpoint.name}</span>
|
||||
<span class="endpoint-model">{endpoint.model}</span>
|
||||
</div>
|
||||
{#if endpoint.is_default}
|
||||
<span class="default-badge">⭐</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h4>📎 Recent Files</h4>
|
||||
{#if uploadedFiles.length === 0}
|
||||
<p class="no-data">No files uploaded yet</p>
|
||||
{:else}
|
||||
<ul class="files-list">
|
||||
{#each uploadedFiles.slice(0, 5) as file}
|
||||
<li>
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">{file.original_filename}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<div class="chat-main">
|
||||
<!-- Messages -->
|
||||
<div class="messages-container">
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<div class="loading-balloon"></div>
|
||||
<p>Loading your conversations...</p>
|
||||
</div>
|
||||
{:else if messages.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-illustration">
|
||||
<div class="balloon-small"></div>
|
||||
<div class="balloon-small b2"></div>
|
||||
<div class="balloon-small b3"></div>
|
||||
</div>
|
||||
<h2>Start a Conversation</h2>
|
||||
<p>Send a message to begin chatting with AI</p>
|
||||
<div class="suggestions">
|
||||
<button onclick={() => inputMessage = 'Explain quantum computing'} class="suggestion-chip">
|
||||
🔬 Explain quantum computing
|
||||
</button>
|
||||
<button onclick={() => inputMessage = 'Write a poem about technology'} class="suggestion-chip">
|
||||
✍️ Write a poem
|
||||
</button>
|
||||
<button onclick={() => inputMessage = 'Help me brainstorm ideas'} class="suggestion-chip">
|
||||
💡 Brainstorm ideas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#each messages as message}
|
||||
<div class="message {message.role}" class:error={message.isError}>
|
||||
<div class="message-avatar">
|
||||
{#if message.role === 'user'}
|
||||
👤
|
||||
{:else}
|
||||
🤖
|
||||
{/if}
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<div class="message-header">
|
||||
<span class="message-role">
|
||||
{message.role === 'user' ? 'You' : 'Assistant'}
|
||||
</span>
|
||||
{#if message.model}
|
||||
<span class="message-model">{message.model}</span>
|
||||
{/if}
|
||||
<span class="message-time">
|
||||
{message.timestamp.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</span>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
{#if message.role === 'user'}
|
||||
{message.content}
|
||||
{:else}
|
||||
<div class="markdown-content">
|
||||
{@html renderMarkdown(message.content)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="input-area">
|
||||
{#if selectedFiles.length > 0}
|
||||
<div class="selected-files">
|
||||
{#each selectedFiles as file}
|
||||
<div class="file-chip">
|
||||
<span>📎 {file.original_filename}</span>
|
||||
<button onclick={() => removeSelectedFile(file)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); sendMessage(); }} class="input-form">
|
||||
<div class="input-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="attach-btn"
|
||||
onclick={() => fileInput.click()}
|
||||
title="Attach file"
|
||||
>
|
||||
📎
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
bind:this={fileInput}
|
||||
onchange={handleFileUpload}
|
||||
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.doc,.docx,.xls,.xlsx,.csv,.json,.md"
|
||||
style="display: none"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
class="message-input"
|
||||
placeholder="Type your message..."
|
||||
bind:value={inputMessage}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
rows="1"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="send-btn"
|
||||
disabled={!inputMessage.trim() || sending || endpoints.length === 0}
|
||||
>
|
||||
{#if sending}
|
||||
<div class="sending-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
{:else}
|
||||
🚀
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
.chat-page {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
height: calc(100vh - var(--header-height) - 4rem);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.chat-sidebar {
|
||||
width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 1.25rem;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.sidebar-section h3 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-section h4 {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.endpoint-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.endpoint-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.endpoint-option:hover {
|
||||
background: rgba(78, 205, 196, 0.1);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.endpoint-option.selected {
|
||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.2), rgba(255, 230, 109, 0.2));
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.endpoint-option input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.endpoint-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.endpoint-name {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.endpoint-model {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.default-badge {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.files-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.files-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.files-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Main Chat */
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 25px;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.loading-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading-balloon {
|
||||
width: 60px;
|
||||
height: 75px;
|
||||
background: var(--gradient-sunset);
|
||||
border-radius: 50% 50% 50% 50%;
|
||||
margin-bottom: 1rem;
|
||||
animation: float 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.balloon-small {
|
||||
width: 40px;
|
||||
height: 50px;
|
||||
background: var(--gradient-sunset);
|
||||
border-radius: 50% 50% 50% 50%;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.balloon-small.b2 {
|
||||
background: var(--gradient-sky);
|
||||
animation-delay: 0.5s;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.balloon-small.b3 {
|
||||
background: var(--gradient-balloon);
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
background: var(--gradient-sunset);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.suggestion-chip {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 50px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.suggestion-chip:hover {
|
||||
background: var(--gradient-sunset);
|
||||
color: var(--background);
|
||||
border-color: transparent;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
margin-left: auto;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message.user .message-avatar {
|
||||
background: var(--gradient-sunset);
|
||||
}
|
||||
|
||||
.message.assistant .message-avatar {
|
||||
background: var(--gradient-sky);
|
||||
}
|
||||
|
||||
.message-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.message-role {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.message-model {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: var(--secondary);
|
||||
color: var(--background);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background: var(--gradient-sunset);
|
||||
color: var(--background);
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
|
||||
.message.error .message-content {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
.input-area {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1.25rem;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.selected-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.file-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: var(--gradient-sky);
|
||||
color: var(--background);
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-chip button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--background);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.file-chip button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 25px;
|
||||
padding: 0.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within {
|
||||
border-color: var(--secondary);
|
||||
box-shadow: 0 0 20px rgba(78, 205, 196, 0.2);
|
||||
}
|
||||
|
||||
.attach-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.attach-btn:hover {
|
||||
background: rgba(78, 205, 196, 0.2);
|
||||
}
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
max-height: 150px;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.message-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.message-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--gradient-sunset);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 107, 0.5);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.sending-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sending-dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sending-dots span:nth-child(1) { animation-delay: 0s; }
|
||||
.sending-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.sending-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: translateY(0); }
|
||||
40% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-page {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: calc(100vh - var(--header-height) - 2rem);
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 95%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,357 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { getUser, initializeAuth } from '$lib/stores.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
let user = $derived(getUser());
|
||||
|
||||
onMount(async () => {
|
||||
await initializeAuth();
|
||||
if (user) {
|
||||
goto('/chat');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await api.login(email, password);
|
||||
goto('/chat');
|
||||
} catch (e) {
|
||||
error = e.message || 'Login failed. Please try again.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-page">
|
||||
<!-- Floating decorations -->
|
||||
<div class="balloon b1"></div>
|
||||
<div class="balloon b2"></div>
|
||||
<div class="balloon b3"></div>
|
||||
<div class="node n1"></div>
|
||||
<div class="node n2"></div>
|
||||
<div class="node n3"></div>
|
||||
<div class="node n4"></div>
|
||||
<div class="node n5"></div>
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<div class="logo-wrapper">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="auth-logo" />
|
||||
<div class="logo-glow"></div>
|
||||
</div>
|
||||
<h1>Welcome Back</h1>
|
||||
<p>Sign in to your account to continue</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleLogin} class="auth-form">
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<span class="error-icon">⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="input"
|
||||
placeholder="Enter your email"
|
||||
bind:value={email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="input"
|
||||
placeholder="Enter your password"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-full" disabled={loading}>
|
||||
{#if loading}
|
||||
<span class="loading-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
{:else}
|
||||
🚀 Sign In
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Don't have an account? <a href="/register">Create one</a></p>
|
||||
</div>
|
||||
|
||||
<div class="demo-credentials">
|
||||
<div class="demo-header">🎯 Demo Admin Credentials</div>
|
||||
<div class="demo-row">
|
||||
<span class="demo-label">Email:</span>
|
||||
<span class="demo-value">admin@moxiegen.com</span>
|
||||
</div>
|
||||
<div class="demo-row">
|
||||
<span class="demo-label">Password:</span>
|
||||
<span class="demo-value">admin123</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Floating Balloons */
|
||||
.balloon {
|
||||
position: absolute;
|
||||
border-radius: 50% 50% 50% 50%;
|
||||
opacity: 0.15;
|
||||
animation: float 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.b1 {
|
||||
width: 200px;
|
||||
height: 250px;
|
||||
top: 10%;
|
||||
left: 5%;
|
||||
background: linear-gradient(135deg, #FF6B6B, #FCBAD3);
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.b2 {
|
||||
width: 150px;
|
||||
height: 180px;
|
||||
top: 60%;
|
||||
right: 10%;
|
||||
background: linear-gradient(135deg, #4ECDC4, #95E1D3);
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.b3 {
|
||||
width: 100px;
|
||||
height: 120px;
|
||||
bottom: 10%;
|
||||
left: 15%;
|
||||
background: linear-gradient(135deg, #FFE66D, #FF9F43);
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
/* Neural Network Nodes */
|
||||
.node {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.3;
|
||||
animation: pulse-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.n1 { top: 20%; left: 20%; background: #FF6B6B; animation-delay: 0s; }
|
||||
.n2 { top: 40%; right: 15%; background: #4ECDC4; animation-delay: 0.5s; }
|
||||
.n3 { top: 70%; left: 10%; background: #FFE66D; animation-delay: 1s; }
|
||||
.n4 { bottom: 20%; right: 25%; background: #AA96DA; animation-delay: 1.5s; }
|
||||
.n5 { top: 30%; right: 30%; background: #FCBAD3; animation-delay: 2s; }
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: linear-gradient(135deg, rgba(15, 52, 96, 0.9), rgba(22, 33, 62, 0.95));
|
||||
border: 2px solid transparent;
|
||||
border-image: var(--gradient-sunset) 1;
|
||||
border-radius: 30px;
|
||||
padding: 2.5rem;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow:
|
||||
0 25px 50px rgba(0, 0, 0, 0.4),
|
||||
0 0 100px rgba(78, 205, 196, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
object-fit: cover;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 10px 30px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
|
||||
.auth-logo:hover {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.logo-glow {
|
||||
position: absolute;
|
||||
inset: -10px;
|
||||
background: var(--gradient-sunset);
|
||||
border-radius: 30px;
|
||||
opacity: 0.3;
|
||||
filter: blur(20px);
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
background: var(--gradient-sunset);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
padding: 1rem;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.loading-dots span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(1) { animation-delay: 0s; }
|
||||
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: translateY(0); }
|
||||
40% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-footer p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-footer a:hover {
|
||||
color: var(--accent-1);
|
||||
}
|
||||
|
||||
.demo-credentials {
|
||||
background: linear-gradient(135deg, rgba(78, 205, 196, 0.1), rgba(149, 225, 211, 0.1));
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 15px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
font-weight: 700;
|
||||
color: var(--secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.demo-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.demo-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.demo-value {
|
||||
color: var(--accent-1);
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.auth-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,188 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { getUser, initializeAuth } from '$lib/stores.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
let user = $derived(getUser());
|
||||
|
||||
onMount(async () => {
|
||||
await initializeAuth();
|
||||
if (user) {
|
||||
goto('/chat');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleRegister(e) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
error = 'Password must be at least 6 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await api.register(email, username, password);
|
||||
await api.login(email, password);
|
||||
goto('/chat');
|
||||
} catch (e) {
|
||||
error = e.message || 'Registration failed. Please try again.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="auth-logo" />
|
||||
<h1>Create Account</h1>
|
||||
<p>Sign up to start using Moxiegen</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleRegister} class="auth-form">
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="input"
|
||||
placeholder="Enter your email"
|
||||
bind:value={email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
class="input"
|
||||
placeholder="Choose a username"
|
||||
bind:value={username}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="input"
|
||||
placeholder="Create a password"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
class="input"
|
||||
placeholder="Confirm your password"
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Already have an account? <a href="/login">Sign in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB |
@ -1,10 +0,0 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@ -1,6 +0,0 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user