feat: Initial Moxiegen webapp implementation
- AI chat interface with file uploads - Admin panel for managing OpenAI/Ollama endpoints - User authentication with JWT - SQLite database backend - SvelteKit frontend with dark theme
This commit is contained in:
parent
0c5e10b714
commit
061e230b18
106
README.md
Normal file
106
README.md
Normal file
@ -0,0 +1,106 @@
|
||||
# Moxiegen
|
||||
|
||||
AI Chat Interface with OpenAI and Ollama support.
|
||||
|
||||
## Features
|
||||
|
||||
- **AI Chat Interface**: Chat with AI models from OpenAI or Ollama
|
||||
- **File Uploads**: Attach files to your chat messages
|
||||
- **Admin Panel**: Manage AI endpoints and users
|
||||
- **Multi-Endpoint Support**: Configure multiple AI backends
|
||||
- **User Authentication**: Secure login system with SQLite database
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Python (FastAPI)
|
||||
- **Frontend**: SvelteKit
|
||||
- **Database**: SQLite
|
||||
- **Auth**: JWT tokens
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Backend Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run the server
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Access the Application
|
||||
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend API: http://localhost:8000
|
||||
- API Docs: http://localhost:8000/docs
|
||||
|
||||
### Default Admin Login
|
||||
|
||||
- Email: `admin@moxiegen.local`
|
||||
- Password: `admin123`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Adding AI Endpoints
|
||||
|
||||
1. Login as admin
|
||||
2. Go to Admin Panel → AI Endpoints
|
||||
3. Click "Add Endpoint"
|
||||
4. Configure:
|
||||
|
||||
**For OpenAI:**
|
||||
- Type: OpenAI Compatible
|
||||
- Base URL: `https://api.openai.com/v1`
|
||||
- API Key: Your OpenAI API key
|
||||
- Model: `gpt-3.5-turbo` or `gpt-4`
|
||||
|
||||
**For Ollama:**
|
||||
- Type: Ollama
|
||||
- Base URL: `http://localhost:11434`
|
||||
- API Key: Leave empty
|
||||
- Model: Your Ollama model name (e.g., `llama2`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
moxiegen/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API routes
|
||||
│ │ ├── models/ # Database models
|
||||
│ │ ├── schemas/ # Pydantic schemas
|
||||
│ │ ├── core/ # Config, auth, database
|
||||
│ │ └── main.py # FastAPI app
|
||||
│ ├── uploads/ # Uploaded files
|
||||
│ └── requirements.txt
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── lib/ # Utilities, API client
|
||||
│ │ ├── routes/ # SvelteKit routes
|
||||
│ │ └── app.css # Global styles
|
||||
│ └── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Moxiegen Backend Application
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# API module
|
||||
206
backend/app/api/admin.py
Normal file
206
backend/app/api/admin.py
Normal file
@ -0,0 +1,206 @@
|
||||
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
|
||||
)
|
||||
128
backend/app/api/auth.py
Normal file
128
backend/app/api/auth.py
Normal file
@ -0,0 +1,128 @@
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
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,
|
||||
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(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
# Find user by username or email
|
||||
user = db.query(User).filter(
|
||||
(User.username == form_data.username) | (User.email == form_data.username)
|
||||
).first()
|
||||
|
||||
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username 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": user.id},
|
||||
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
|
||||
231
backend/app/api/chat.py
Normal file
231
backend/app/api/chat.py
Normal file
@ -0,0 +1,231 @@
|
||||
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
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Core module
|
||||
79
backend/app/core/auth.py
Normal file
79
backend/app/core/auth.py
Normal file
@ -0,0 +1,79 @@
|
||||
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
|
||||
19
backend/app/core/config.py
Normal file
19
backend/app/core/config.py
Normal file
@ -0,0 +1,19 @@
|
||||
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()
|
||||
21
backend/app/core/database.py
Normal file
21
backend/app/core/database.py
Normal file
@ -0,0 +1,21 @@
|
||||
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()
|
||||
98
backend/app/main.py
Normal file
98
backend/app/main.py
Normal file
@ -0,0 +1,98 @@
|
||||
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
backend/app/models/__init__.py
Normal file
1
backend/app/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Models module
|
||||
64
backend/app/models/models.py
Normal file
64
backend/app/models/models.py
Normal file
@ -0,0 +1,64 @@
|
||||
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
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Schemas module
|
||||
134
backend/app/schemas/schemas.py
Normal file
134
backend/app/schemas/schemas.py
Normal file
@ -0,0 +1,134 @@
|
||||
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 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
|
||||
conversation_history: Optional[List[ChatMessageBase]] = None
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
response: str
|
||||
endpoint_id: Optional[int] = None
|
||||
model: Optional[str] = None
|
||||
|
||||
|
||||
# AdminStats schema
|
||||
class AdminStats(BaseModel):
|
||||
total_users: int
|
||||
total_endpoints: int
|
||||
total_messages: int
|
||||
total_files: int
|
||||
active_users: int
|
||||
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
@ -0,0 +1,10 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlalchemy
|
||||
pydantic
|
||||
python-jose
|
||||
passlib
|
||||
python-multipart
|
||||
aiofiles
|
||||
httpx
|
||||
openai
|
||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "moxiegen-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"svelte": "^4.2.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^12.0.0",
|
||||
"highlight.js": "^11.9.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
233
frontend/src/app.css
Normal file
233
frontend/src/app.css
Normal file
@ -0,0 +1,233 @@
|
||||
/* CSS Variables - Dark Theme */
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--primary-hover: #818cf8;
|
||||
--background: #0f172a;
|
||||
--surface: #1e293b;
|
||||
--surface-hover: #334155;
|
||||
--text: #f1f5f9;
|
||||
--text-muted: #94a3b8;
|
||||
--border: #334155;
|
||||
--error: #ef4444;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
}
|
||||
|
||||
/* Reset & Base */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: var(--background);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: var(--success);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre {
|
||||
background-color: var(--background);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background-color: var(--background);
|
||||
}
|
||||
12
frontend/src/app.html
Normal file
12
frontend/src/app.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
189
frontend/src/lib/api.js
Normal file
189
frontend/src/lib/api.js
Normal file
@ -0,0 +1,189 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.baseUrl = API_BASE;
|
||||
}
|
||||
|
||||
getToken() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setToken(token) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
}
|
||||
|
||||
clearToken() {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const config = {
|
||||
...options,
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||
throw new Error(error.detail || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
async login(email, password) {
|
||||
const formData = new FormData();
|
||||
formData.append('username', email);
|
||||
formData.append('password', password);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Login failed' }));
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
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');
|
||||
}
|
||||
|
||||
// Endpoints
|
||||
async getEndpoints() {
|
||||
return this.request('/endpoints');
|
||||
}
|
||||
|
||||
async createEndpoint(data) {
|
||||
return this.request('/endpoints', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async updateEndpoint(id, data) {
|
||||
return this.request(`/endpoints/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEndpoint(id) {
|
||||
return this.request(`/endpoints/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// Chat
|
||||
async sendMessage(endpointId, message, conversationId = null) {
|
||||
return this.request('/chat/message', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
endpoint_id: endpointId,
|
||||
message,
|
||||
conversation_id: conversationId
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async getConversations() {
|
||||
return this.request('/chat/conversations');
|
||||
}
|
||||
|
||||
async getConversation(id) {
|
||||
return this.request(`/chat/conversations/${id}`);
|
||||
}
|
||||
|
||||
async deleteConversation(id) {
|
||||
return this.request(`/chat/conversations/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// File upload
|
||||
async uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.getToken()}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Upload failed' }));
|
||||
throw new Error(error.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
async getAdminStats() {
|
||||
return this.request('/admin/stats');
|
||||
}
|
||||
|
||||
async getUsers() {
|
||||
return this.request('/admin/users');
|
||||
}
|
||||
|
||||
async updateUser(id, data) {
|
||||
return this.request(`/admin/users/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(id) {
|
||||
return this.request(`/admin/users/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.clearToken();
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
28
frontend/src/lib/stores.js
Normal file
28
frontend/src/lib/stores.js
Normal file
@ -0,0 +1,28 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from './api';
|
||||
|
||||
export const user = writable(null);
|
||||
export const isLoading = writable(true);
|
||||
|
||||
export async function initializeAuth() {
|
||||
isLoading.set(true);
|
||||
const token = api.getToken();
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const userData = await api.getMe();
|
||||
user.set(userData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user:', error);
|
||||
api.clearToken();
|
||||
user.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.set(false);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
api.logout();
|
||||
user.set(null);
|
||||
}
|
||||
1
frontend/src/routes/+layout.js
Normal file
1
frontend/src/routes/+layout.js
Normal file
@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
233
frontend/src/routes/+layout.svelte
Normal file
233
frontend/src/routes/+layout.svelte
Normal file
@ -0,0 +1,233 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, isLoading, initializeAuth, logout } from '$lib/stores';
|
||||
import '../app.css';
|
||||
|
||||
onMount(() => {
|
||||
initializeAuth();
|
||||
});
|
||||
|
||||
$: currentPath = $page.url.pathname;
|
||||
$: isAuthPage = currentPath === '/login' || currentPath === '/register';
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $isLoading}
|
||||
<div class="loading-screen">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else if !$user && !isAuthPage}
|
||||
{#if typeof window !== 'undefined'}
|
||||
<script>
|
||||
window.location.href = '/login';
|
||||
</script>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="app-layout">
|
||||
{#if $user}
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="logo" />
|
||||
<span class="logo-text">Moxiegen</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/chat" class="nav-item" class:active={currentPath === '/chat'}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
Chat
|
||||
</a>
|
||||
|
||||
{#if $user.is_admin}
|
||||
<a href="/admin" class="nav-item" class:active={currentPath === '/admin'}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
Admin
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">{$user.username?.[0]?.toUpperCase() || 'U'}</div>
|
||||
<div class="user-details">
|
||||
<span class="user-name">{$user.username}</span>
|
||||
<span class="user-email">{$user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="logout-btn" on:click={handleLogout}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<main class="main-content">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background-color: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: var(--background);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: var(--background);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 260px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
43
frontend/src/routes/+page.svelte
Normal file
43
frontend/src/routes/+page.svelte
Normal file
@ -0,0 +1,43 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user } from '$lib/stores';
|
||||
|
||||
onMount(() => {
|
||||
if ($user) {
|
||||
goto('/chat');
|
||||
} else {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="redirect-page">
|
||||
<div class="spinner"></div>
|
||||
<p>Redirecting...</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.redirect-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
gap: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
</style>
|
||||
557
frontend/src/routes/admin/+page.svelte
Normal file
557
frontend/src/routes/admin/+page.svelte
Normal file
@ -0,0 +1,557 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let activeTab = 'dashboard';
|
||||
let stats = null;
|
||||
let users = [];
|
||||
let endpoints = [];
|
||||
let loading = true;
|
||||
let error = '';
|
||||
|
||||
// User editing
|
||||
let editingUser = null;
|
||||
let editUserData = {};
|
||||
|
||||
// Endpoint editing
|
||||
let editingEndpoint = null;
|
||||
let endpointData = {};
|
||||
let showEndpointModal = false;
|
||||
|
||||
onMount(async () => {
|
||||
if (!$user?.is_admin) {
|
||||
goto('/chat');
|
||||
return;
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
[stats, users, endpoints] = await Promise.all([
|
||||
api.getAdminStats(),
|
||||
api.getUsers(),
|
||||
api.getEndpoints()
|
||||
]);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEditUser(user) {
|
||||
editingUser = user.id;
|
||||
editUserData = { ...user };
|
||||
}
|
||||
|
||||
function cancelEditUser() {
|
||||
editingUser = null;
|
||||
editUserData = {};
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
try {
|
||||
await api.updateUser(editingUser, editUserData);
|
||||
users = users.map(u => u.id === editingUser ? { ...editUserData } : u);
|
||||
editingUser = null;
|
||||
editUserData = {};
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(user) {
|
||||
if (!confirm(`Delete user "${user.username}"?`)) return;
|
||||
|
||||
try {
|
||||
await api.deleteUser(user.id);
|
||||
users = users.filter(u => u.id !== user.id);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function openEndpointModal(endpoint = null) {
|
||||
editingEndpoint = endpoint?.id || null;
|
||||
endpointData = endpoint ? { ...endpoint } : { name: '', url: '', api_key: '' };
|
||||
showEndpointModal = true;
|
||||
}
|
||||
|
||||
function closeEndpointModal() {
|
||||
showEndpointModal = false;
|
||||
editingEndpoint = null;
|
||||
endpointData = {};
|
||||
}
|
||||
|
||||
async function saveEndpoint() {
|
||||
try {
|
||||
if (editingEndpoint) {
|
||||
await api.updateEndpoint(editingEndpoint, endpointData);
|
||||
endpoints = endpoints.map(e => e.id === editingEndpoint ? { ...endpointData } : e);
|
||||
} else {
|
||||
const newEndpoint = await api.createEndpoint(endpointData);
|
||||
endpoints = [...endpoints, newEndpoint];
|
||||
}
|
||||
closeEndpointModal();
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEndpoint(endpoint) {
|
||||
if (!confirm(`Delete endpoint "${endpoint.name}"?`)) return;
|
||||
|
||||
try {
|
||||
await api.deleteEndpoint(endpoint.id);
|
||||
endpoints = endpoints.filter(e => e.id !== endpoint.id);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin-page">
|
||||
<div class="admin-header">
|
||||
<h1>Admin Panel</h1>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'dashboard'}
|
||||
on:click={() => activeTab = 'dashboard'}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'users'}
|
||||
on:click={() => activeTab = 'users'}
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'endpoints'}
|
||||
on:click={() => activeTab = 'endpoints'}
|
||||
>
|
||||
Endpoints
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Dashboard Tab -->
|
||||
{#if activeTab === 'dashboard'}
|
||||
<div class="dashboard">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon users">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{stats?.total_users || 0}</span>
|
||||
<span class="stat-label">Total Users</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon endpoints">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{stats?.total_endpoints || 0}</span>
|
||||
<span class="stat-label">Endpoints</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon messages">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{stats?.total_messages || 0}</span>
|
||||
<span class="stat-label">Messages</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon active">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{stats?.active_users || 0}</span>
|
||||
<span class="stat-label">Active Users</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Users Tab -->
|
||||
{#if activeTab === 'users'}
|
||||
<div class="users-tab">
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Admin</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user (user.id)}
|
||||
<tr>
|
||||
<td>{user.id}</td>
|
||||
<td>
|
||||
{#if editingUser === user.id}
|
||||
<input type="text" class="input" bind:value={editUserData.username} />
|
||||
{:else}
|
||||
{user.username}
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if editingUser === user.id}
|
||||
<input type="email" class="input" bind:value={editUserData.email} />
|
||||
{:else}
|
||||
{user.email}
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if editingUser === user.id}
|
||||
<input type="checkbox" bind:checked={editUserData.is_admin} />
|
||||
{:else}
|
||||
{#if user.is_admin}
|
||||
<span class="badge">Admin</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</td>
|
||||
<td>{new Date(user.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
{#if editingUser === user.id}
|
||||
<button class="btn btn-primary btn-sm" on:click={saveUser}>Save</button>
|
||||
<button class="btn btn-secondary btn-sm" on:click={cancelEditUser}>Cancel</button>
|
||||
{:else}
|
||||
<button class="btn btn-secondary btn-sm" on:click={() => startEditUser(user)}>Edit</button>
|
||||
<button class="btn btn-danger btn-sm" on:click={() => deleteUser(user)}>Delete</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Endpoints Tab -->
|
||||
{#if activeTab === 'endpoints'}
|
||||
<div class="endpoints-tab">
|
||||
<div class="tab-header">
|
||||
<button class="btn btn-primary" on:click={() => openEndpointModal()}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Add Endpoint
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="endpoints-grid">
|
||||
{#each endpoints as endpoint (endpoint.id)}
|
||||
<div class="endpoint-card card">
|
||||
<div class="endpoint-header">
|
||||
<h3>{endpoint.name}</h3>
|
||||
<span class="endpoint-status {endpoint.is_active ? 'active' : 'inactive'}">
|
||||
{endpoint.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="endpoint-details">
|
||||
<p><strong>URL:</strong> {endpoint.url}</p>
|
||||
<p><strong>Model:</strong> {endpoint.model || 'Default'}</p>
|
||||
</div>
|
||||
<div class="endpoint-actions">
|
||||
<button class="btn btn-secondary btn-sm" on:click={() => openEndpointModal(endpoint)}>Edit</button>
|
||||
<button class="btn btn-danger btn-sm" on:click={() => deleteEndpoint(endpoint)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showEndpointModal}
|
||||
<div class="modal-overlay" on:click={closeEndpointModal}>
|
||||
<div class="modal" on:click|stopPropagation>
|
||||
<div class="modal-header">
|
||||
<h2>{editingEndpoint ? 'Edit Endpoint' : 'Add Endpoint'}</h2>
|
||||
<button class="modal-close" on:click={closeEndpointModal}>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" class="input" bind:value={endpointData.name} placeholder="Endpoint name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="url">URL</label>
|
||||
<input type="text" id="url" class="input" bind:value={endpointData.url} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="apiKey">API Key</label>
|
||||
<input type="password" id="apiKey" class="input" bind:value={endpointData.api_key} placeholder="Enter API key" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="model">Model</label>
|
||||
<input type="text" id="model" class="input" bind:value={endpointData.model} placeholder="gpt-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" on:click={closeEndpointModal}>Cancel</button>
|
||||
<button class="btn btn-primary" on:click={saveEndpoint}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.admin-page {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-icon.users { background-color: rgba(99, 102, 241, 0.2); color: var(--primary); }
|
||||
.stat-icon.endpoints { background-color: rgba(34, 197, 94, 0.2); color: var(--success); }
|
||||
.stat-icon.messages { background-color: rgba(245, 158, 11, 0.2); color: var(--warning); }
|
||||
.stat-icon.active { background-color: rgba(99, 102, 241, 0.2); color: var(--primary); }
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Users */
|
||||
.users-tab .card {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Endpoints */
|
||||
.endpoints-tab .tab-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.endpoints-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.endpoint-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.endpoint-header h3 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.endpoint-status {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.endpoint-status.active {
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.endpoint-status.inactive {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.endpoint-details {
|
||||
flex: 1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.endpoint-details p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.endpoint-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: var(--surface);
|
||||
border-radius: 0.75rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
420
frontend/src/routes/chat/+page.svelte
Normal file
420
frontend/src/routes/chat/+page.svelte
Normal file
@ -0,0 +1,420 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
let endpoints = [];
|
||||
let selectedEndpoint = null;
|
||||
let conversations = [];
|
||||
let currentConversation = null;
|
||||
let messages = [];
|
||||
let newMessage = '';
|
||||
let loading = false;
|
||||
let sending = false;
|
||||
let error = '';
|
||||
let fileInput;
|
||||
let uploadedFile = null;
|
||||
|
||||
// Configure marked with highlight.js
|
||||
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 loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
endpoints = await api.getEndpoints();
|
||||
if (endpoints.length > 0) {
|
||||
selectedEndpoint = endpoints[0];
|
||||
}
|
||||
conversations = await api.getConversations();
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectConversation(conv) {
|
||||
currentConversation = conv;
|
||||
messages = conv.messages || [];
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
currentConversation = null;
|
||||
messages = [];
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!newMessage.trim() || !selectedEndpoint) return;
|
||||
|
||||
sending = true;
|
||||
const userMessage = newMessage;
|
||||
newMessage = '';
|
||||
|
||||
// Add user message to UI immediately
|
||||
messages = [...messages, { role: 'user', content: userMessage }];
|
||||
|
||||
try {
|
||||
const response = await api.sendMessage(
|
||||
selectedEndpoint.id,
|
||||
userMessage,
|
||||
currentConversation?.id
|
||||
);
|
||||
|
||||
// Add assistant response
|
||||
messages = [...messages, { role: 'assistant', content: response.response }];
|
||||
|
||||
// Update conversation if new
|
||||
if (!currentConversation) {
|
||||
currentConversation = { id: response.conversation_id };
|
||||
conversations = await api.getConversations();
|
||||
}
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
// Remove the user message on error
|
||||
messages = messages.slice(0, -1);
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const result = await api.uploadFile(file);
|
||||
uploadedFile = result;
|
||||
newMessage += `\n[File: ${file.name}]`;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkdown(content) {
|
||||
return marked(content);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-page">
|
||||
<!-- Conversations Sidebar -->
|
||||
<aside class="conversations-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<button class="btn btn-primary" on:click={newChat}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="conversations-list">
|
||||
{#each conversations as conv (conv.id)}
|
||||
<button
|
||||
class="conversation-item"
|
||||
class:active={currentConversation?.id === conv.id}
|
||||
on:click={() => selectConversation(conv)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<span>{conv.title || 'Untitled conversation'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<div class="chat-main">
|
||||
<!-- Endpoint Selector -->
|
||||
<div class="chat-header">
|
||||
<select class="input endpoint-select" bind:value={selectedEndpoint}>
|
||||
<option value={null} disabled selected>Select an endpoint</option>
|
||||
{#each endpoints as endpoint (endpoint.id)}
|
||||
<option value={endpoint}>{endpoint.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Messages Area -->
|
||||
<div class="messages-area">
|
||||
{#if messages.length === 0}
|
||||
<div class="empty-state">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="logo" />
|
||||
<h2>Start a conversation</h2>
|
||||
<p>Select an endpoint and type a message to begin</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each messages as message (message.id || Math.random())}
|
||||
<div class="message {message.role}">
|
||||
<div class="message-content">
|
||||
{#if message.role === 'user'}
|
||||
<p>{message.content}</p>
|
||||
{:else}
|
||||
<div class="markdown-content">
|
||||
{@html renderMarkdown(message.content)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if sending}
|
||||
<div class="message assistant">
|
||||
<div class="message-content">
|
||||
<div class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="input-area">
|
||||
<input type="file" bind:this={fileInput} on:change={handleFileUpload} accept="*/*" hidden />
|
||||
<button
|
||||
class="btn btn-secondary attachment-btn"
|
||||
on:click={() => fileInput.click()}
|
||||
title="Upload file"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="input message-input"
|
||||
bind:value={newMessage}
|
||||
placeholder="Type a message..."
|
||||
on:keydown={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()}
|
||||
disabled={!selectedEndpoint || sending}
|
||||
/>
|
||||
|
||||
<button
|
||||
class="btn btn-primary send-btn"
|
||||
on:click={sendMessage}
|
||||
disabled={!newMessage.trim() || !selectedEndpoint || sending}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-page {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.conversations-sidebar {
|
||||
width: 260px;
|
||||
background-color: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: var(--background);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--surface);
|
||||
}
|
||||
|
||||
.endpoint-select {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.messages-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 80%;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
background-color: var(--surface);
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(pre) {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--background) !important;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-content :global(code) {
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(p) {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(ul),
|
||||
.markdown-content :global(ol) {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0 1.5rem;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--error);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background-color: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.attachment-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
158
frontend/src/routes/login/+page.svelte
Normal file
158
frontend/src/routes/login/+page.svelte
Normal file
@ -0,0 +1,158 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/stores';
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let error = '';
|
||||
let loading = false;
|
||||
|
||||
onMount(() => {
|
||||
if ($user) {
|
||||
goto('/chat');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await api.login(email, password);
|
||||
const userData = await api.getMe();
|
||||
user.set(userData);
|
||||
goto('/chat');
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-card card">
|
||||
<div class="login-header">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="logo" />
|
||||
<h1>Welcome Back</h1>
|
||||
<p>Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form on:submit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="input"
|
||||
bind:value={email}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="input"
|
||||
bind:value={password}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn btn-primary full-width" disabled={loading}>
|
||||
{#if loading}
|
||||
Signing in...
|
||||
{:else}
|
||||
Sign In
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="demo-credentials">
|
||||
<p>Demo credentials:</p>
|
||||
<code>demo@moxiegen.com / demo123</code>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>Don't have an account? <a href="/register">Sign up</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.demo-credentials {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--background);
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.demo-credentials p {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.demo-credentials code {
|
||||
font-size: 0.875rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
173
frontend/src/routes/register/+page.svelte
Normal file
173
frontend/src/routes/register/+page.svelte
Normal file
@ -0,0 +1,173 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/stores';
|
||||
|
||||
let email = '';
|
||||
let username = '';
|
||||
let password = '';
|
||||
let confirmPassword = '';
|
||||
let error = '';
|
||||
let loading = false;
|
||||
|
||||
onMount(() => {
|
||||
if ($user) {
|
||||
goto('/chat');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(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);
|
||||
// Auto login after registration
|
||||
await api.login(email, password);
|
||||
const userData = await api.getMe();
|
||||
user.set(userData);
|
||||
goto('/chat');
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="register-page">
|
||||
<div class="register-card card">
|
||||
<div class="register-header">
|
||||
<img src="/logo.jpg" alt="Moxiegen" class="logo" />
|
||||
<h1>Create Account</h1>
|
||||
<p>Get started with Moxiegen</p>
|
||||
</div>
|
||||
|
||||
<form on:submit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="input"
|
||||
bind:value={email}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
class="input"
|
||||
bind:value={username}
|
||||
placeholder="Choose a username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="input"
|
||||
bind:value={password}
|
||||
placeholder="Create a password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
class="input"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn btn-primary full-width" disabled={loading}>
|
||||
{#if loading}
|
||||
Creating account...
|
||||
{:else}
|
||||
Create Account
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="register-footer">
|
||||
<p>Already have an account? <a href="/login">Sign in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.register-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.register-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.register-header p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
BIN
frontend/static/logo.jpg
Normal file
BIN
frontend/static/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
10
frontend/svelte.config.js
Normal file
10
frontend/svelte.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
6
frontend/vite.config.js
Normal file
6
frontend/vite.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user