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