diff --git a/README.md b/README.md new file mode 100644 index 0000000..e140124 --- /dev/null +++ b/README.md @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..ba81ad6 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# Moxiegen Backend Application diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..9c7f58e --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API module diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..a5263dd --- /dev/null +++ b/backend/app/api/admin.py @@ -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 + ) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..e7a198a --- /dev/null +++ b/backend/app/api/auth.py @@ -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 diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py new file mode 100644 index 0000000..d64cf89 --- /dev/null +++ b/backend/app/api/chat.py @@ -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"} diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..3e83c63 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +# Core module diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py new file mode 100644 index 0000000..3d798ef --- /dev/null +++ b/backend/app/core/auth.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..6d4e55f --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..b231c75 --- /dev/null +++ b/backend/app/core/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9a012ce --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e2313c5 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +# Models module diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..e95cae6 --- /dev/null +++ b/backend/app/models/models.py @@ -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") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..fff1fac --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +# Schemas module diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py new file mode 100644 index 0000000..0e9e44b --- /dev/null +++ b/backend/app/schemas/schemas.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0028e23 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi +uvicorn +sqlalchemy +pydantic +python-jose +passlib +python-multipart +aiofiles +httpx +openai diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c7a47bf --- /dev/null +++ b/frontend/package.json @@ -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" +} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..f884de9 --- /dev/null +++ b/frontend/src/app.css @@ -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); +} diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..9ef94c0 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,12 @@ + + +
+ + + + %sveltekit.head% + + +Redirecting...
+| ID | +Username | +Admin | +Created | +Actions | +|
|---|---|---|---|---|---|
| {user.id} | ++ {#if editingUser === user.id} + + {:else} + {user.username} + {/if} + | ++ {#if editingUser === user.id} + + {:else} + {user.email} + {/if} + | ++ {#if editingUser === user.id} + + {:else} + {#if user.is_admin} + Admin + {/if} + {/if} + | +{new Date(user.created_at).toLocaleDateString()} | ++ + | +
URL: {endpoint.url}
+Model: {endpoint.model || 'Default'}
+
+ Sign in to your account
+Demo credentials:
+demo@moxiegen.com / demo123
+
+ Get started with Moxiegen
+