Initial MVP skeleton with auth, chat persistence, UI and text LLM integration
This commit is contained in:
98
backend/app/api/auth.py
Normal file
98
backend/app/api/auth.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import verify_password
|
||||
from app.db.session import get_db
|
||||
from app.db.models import User, Session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "ai_chat_session")
|
||||
SESSION_TTL_HOURS = int(os.getenv("SESSION_TTL_HOURS", "168"))
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
login: str
|
||||
password: str
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
login: str
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(login_data: LoginRequest, response: Response, db: DBSession = Depends(get_db)):
|
||||
user = db.scalar(select(User).where(User.login == login_data.login))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials or inactive user")
|
||||
|
||||
if not verify_password(login_data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials or inactive user")
|
||||
|
||||
# Create session
|
||||
expires = datetime.now(timezone.utc) + timedelta(hours=SESSION_TTL_HOURS)
|
||||
# Strip timezone for naive datetime storage if DB expects it, depending on pg setup. Let's use naive UTC
|
||||
expires_naive = expires.replace(tzinfo=None)
|
||||
|
||||
db_session = Session(
|
||||
user_id=user.id,
|
||||
expires_at=expires_naive
|
||||
)
|
||||
db.add(db_session)
|
||||
db.commit()
|
||||
db.refresh(db_session)
|
||||
|
||||
# Set cookie
|
||||
is_secure = os.getenv("SESSION_COOKIE_SECURE", "false").lower() == "true"
|
||||
samesite = os.getenv("SESSION_COOKIE_SAMESITE", "lax").lower()
|
||||
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=db_session.id,
|
||||
httponly=True,
|
||||
secure=is_secure,
|
||||
samesite=samesite,
|
||||
max_age=SESSION_TTL_HOURS * 3600
|
||||
)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post("/logout", response_model=LoginResponse)
|
||||
def logout(request: Request, response: Response, db: DBSession = Depends(get_db)):
|
||||
session_id = request.cookies.get(COOKIE_NAME)
|
||||
if session_id:
|
||||
db_session = db.get(Session, session_id)
|
||||
if db_session:
|
||||
db.delete(db_session)
|
||||
db.commit()
|
||||
|
||||
response.delete_cookie(key=COOKIE_NAME)
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
def me(request: Request, db: DBSession = Depends(get_db)):
|
||||
session_id = request.cookies.get(COOKIE_NAME)
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
db_session = db.get(Session, session_id)
|
||||
if not db_session:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session")
|
||||
|
||||
if db_session.expires_at < datetime.now(timezone.utc).replace(tzinfo=None):
|
||||
db.delete(db_session)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
|
||||
|
||||
user = db_session.user
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User inactive")
|
||||
|
||||
return {"login": user.login}
|
||||
283
backend/app/api/chats.py
Normal file
283
backend/app/api/chats.py
Normal file
@@ -0,0 +1,283 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.db.models import User, Chat, Message
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.models_catalog import AVAILABLE_MODELS, ModelInfo
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
|
||||
logger.addHandler(handler)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class ChatCreateRequest(BaseModel):
|
||||
title: str = "New Chat"
|
||||
model_alias: str
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
model_alias: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class MessageCreateRequest(BaseModel):
|
||||
content: str
|
||||
role: str = "user"
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: str
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
@router.get("/models", response_model=List[ModelInfo])
|
||||
def get_models(user: User = Depends(get_current_user)):
|
||||
return AVAILABLE_MODELS
|
||||
|
||||
@router.post("/chats", response_model=ChatResponse)
|
||||
def create_chat(
|
||||
req: ChatCreateRequest,
|
||||
db: DBSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user)
|
||||
):
|
||||
valid_aliases = {m.alias for m in AVAILABLE_MODELS}
|
||||
if req.model_alias not in valid_aliases:
|
||||
raise HTTPException(status_code=400, detail="Invalid model alias")
|
||||
|
||||
chat = Chat(user_id=user.id, title=req.title, model_alias=req.model_alias)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
db.refresh(chat)
|
||||
|
||||
return chat
|
||||
|
||||
@router.get("/chats", response_model=List[ChatResponse])
|
||||
def list_chats(
|
||||
db: DBSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user)
|
||||
):
|
||||
stmt = select(Chat).where(Chat.user_id == user.id).order_by(desc(Chat.updated_at))
|
||||
chats = db.scalars(stmt).all()
|
||||
return chats
|
||||
|
||||
@router.get("/chats/{chat_id}", response_model=ChatResponse)
|
||||
def get_chat(
|
||||
chat_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user)
|
||||
):
|
||||
chat = db.get(Chat, chat_id)
|
||||
if not chat or chat.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
return chat
|
||||
|
||||
@router.delete("/chats/{chat_id}")
|
||||
def delete_chat(
|
||||
chat_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user)
|
||||
):
|
||||
chat = db.get(Chat, chat_id)
|
||||
if not chat or chat.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
db.delete(chat)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/chats/{chat_id}/messages", response_model=List[MessageResponse])
|
||||
def list_messages(
|
||||
chat_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user)
|
||||
):
|
||||
chat = db.get(Chat, chat_id)
|
||||
if not chat or chat.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
stmt = select(Message).where(Message.chat_id == chat_id).order_by(Message.created_at)
|
||||
messages = db.scalars(stmt).all()
|
||||
return messages
|
||||
|
||||
from app.core.llm_client import llm_client, inference_lock
|
||||
|
||||
def sanitize_llm_text(raw_text: Optional[str]) -> Optional[str]:
|
||||
if not raw_text:
|
||||
return None
|
||||
text = raw_text.strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
cleaned = text.replace("<reasoning>", "").replace("</reasoning>", "").strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
|
||||
return cleaned
|
||||
|
||||
def normalize_llm_response(content: str, reasoning: str) -> Optional[str]:
|
||||
c_sanitized = sanitize_llm_text(content)
|
||||
if c_sanitized:
|
||||
return c_sanitized
|
||||
|
||||
r_sanitized = sanitize_llm_text(reasoning)
|
||||
if r_sanitized:
|
||||
return r_sanitized
|
||||
|
||||
return None
|
||||
|
||||
@router.post("/chats/{chat_id}/messages", response_model=List[MessageResponse])
|
||||
async def add_message(
|
||||
chat_id: str,
|
||||
req: MessageCreateRequest,
|
||||
db: DBSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user)
|
||||
):
|
||||
chat = db.get(Chat, chat_id)
|
||||
if not chat or chat.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
# 1. Save user message
|
||||
user_msg = Message(chat_id=chat.id, role=req.role, content=req.content)
|
||||
db.add(user_msg)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
chat.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
db.refresh(user_msg)
|
||||
|
||||
logger.info(f"User message saved for chat {chat.id}. Selected model: {chat.model_alias}")
|
||||
|
||||
# 2. Fetch recent chat history to assemble prompt
|
||||
# Get last 20 messages
|
||||
stmt = select(Message).where(Message.chat_id == chat_id).order_by(desc(Message.created_at)).limit(20)
|
||||
recent_msgs = db.scalars(stmt).all()
|
||||
recent_msgs.reverse()
|
||||
|
||||
llm_history = []
|
||||
for m in recent_msgs:
|
||||
llm_history.append({"role": m.role, "content": m.content})
|
||||
|
||||
# 3. Enter Critical Section for LLM Switch and Inference
|
||||
ai_response = None
|
||||
final_content = None
|
||||
async with inference_lock:
|
||||
try:
|
||||
status_data = await llm_client.get_status()
|
||||
current_model = status_data.get("active_model")
|
||||
logger.info(f"Current active llm-manager model: {current_model}")
|
||||
|
||||
# Switch if needed
|
||||
switched = (current_model != chat.model_alias)
|
||||
if switched:
|
||||
logger.info(f"Switching model to {chat.model_alias}... (switch requested)")
|
||||
await llm_client.switch_model(chat.model_alias)
|
||||
logger.info(f"Successfully requested switch to {chat.model_alias}. Waiting for readiness...")
|
||||
|
||||
# Wait for readiness
|
||||
is_ready, iterations, final_status = await llm_client.wait_for_model_ready(
|
||||
model_name=chat.model_alias,
|
||||
timeout=60.0,
|
||||
poll_interval=2.0
|
||||
)
|
||||
|
||||
if not is_ready:
|
||||
logger.error(f"Readiness timeout for {chat.model_alias} after {iterations} iterations. Final status: {final_status}")
|
||||
raise HTTPException(status_code=504, detail=f"LLM Manager readiness timeout for {chat.model_alias}")
|
||||
|
||||
logger.info(f"Model {chat.model_alias} is ready after {iterations} iterations. Final status before completion: {final_status}")
|
||||
|
||||
async def do_completion(msgs, max_tok=None, temp=None):
|
||||
try:
|
||||
return await llm_client.chat_completion(messages=msgs, max_tokens=max_tok, temperature=temp)
|
||||
except HTTPException as e:
|
||||
if e.status_code == 502 or "503" in str(e.detail):
|
||||
logger.warning("Generation failed (possibly 503 unloading). Retrying switch and completion...")
|
||||
await llm_client.switch_model(chat.model_alias)
|
||||
return await llm_client.chat_completion(messages=msgs, max_tokens=max_tok, temperature=temp)
|
||||
raise e
|
||||
|
||||
# Call inference (Attempt 1)
|
||||
logger.info("Starting chat completion (Attempt 1)...")
|
||||
ai_response = await do_completion(llm_history)
|
||||
|
||||
# Parse Attempt 1
|
||||
ai_choice = ai_response.get("choices", [{}])[0].get("message", {})
|
||||
ai_content_raw = ai_choice.get("content", "") or ""
|
||||
ai_reasoning_raw = ai_choice.get("reasoning_content", "") or ""
|
||||
|
||||
c_san = sanitize_llm_text(ai_content_raw)
|
||||
r_san = sanitize_llm_text(ai_reasoning_raw)
|
||||
final_content = normalize_llm_response(ai_content_raw, ai_reasoning_raw)
|
||||
|
||||
logger.info(
|
||||
f"LLM Stats (Attempt 1) | model: {chat.model_alias} | "
|
||||
f"switched: {switched} | "
|
||||
f"content_raw_len: {len(ai_content_raw)} | reasoning_raw_len: {len(ai_reasoning_raw)} | "
|
||||
f"content_san_len: {len(c_san) if c_san else 0} | reasoning_san_len: {len(r_san) if r_san else 0}"
|
||||
)
|
||||
|
||||
if not final_content:
|
||||
logger.warning("Attempt 1 rejected: invalid response (both sanitized texts are empty). Triggering controlled retry.")
|
||||
|
||||
retry_history = list(llm_history)
|
||||
retry_history.append({
|
||||
"role": "user",
|
||||
"content": "Ответь сразу финальным текстом. Не выводи reasoning, chain-of-thought, XML-теги или служебную разметку."
|
||||
})
|
||||
|
||||
logger.info("Starting chat completion (Attempt 2 - Retry) with max_tokens=2048 and temperature=0.1...")
|
||||
ai_response_retry = await do_completion(retry_history, max_tok=2048, temp=0.1)
|
||||
|
||||
ai_choice_r = ai_response_retry.get("choices", [{}])[0].get("message", {})
|
||||
ai_content_r_raw = ai_choice_r.get("content", "") or ""
|
||||
ai_reasoning_r_raw = ai_choice_r.get("reasoning_content", "") or ""
|
||||
|
||||
c_san_r = sanitize_llm_text(ai_content_r_raw)
|
||||
r_san_r = sanitize_llm_text(ai_reasoning_r_raw)
|
||||
final_content = normalize_llm_response(ai_content_r_raw, ai_reasoning_r_raw)
|
||||
|
||||
logger.info(
|
||||
f"LLM Stats (Attempt 2 - Retry) | model: {chat.model_alias} | "
|
||||
f"content_raw_len: {len(ai_content_r_raw)} | reasoning_raw_len: {len(ai_reasoning_r_raw)} | "
|
||||
f"content_san_len: {len(c_san_r) if c_san_r else 0} | reasoning_san_len: {len(r_san_r) if r_san_r else 0}"
|
||||
)
|
||||
|
||||
if not final_content:
|
||||
logger.error("Attempt 2 also failed to produce valid output. Aborting.")
|
||||
raise HTTPException(status_code=500, detail="LLM failed to produce valid output after retry.")
|
||||
else:
|
||||
logger.info("Attempt 2 succeeded in producing valid output.")
|
||||
else:
|
||||
if not ai_content_raw.strip() and final_content:
|
||||
logger.info("Fallback to reasoning_content was chosen because 'content' was empty (Attempt 1).")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Inference pipeline failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# 5. Save AI message
|
||||
assistant_msg = Message(chat_id=chat.id, role="assistant", content=final_content)
|
||||
db.add(assistant_msg)
|
||||
|
||||
chat.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
db.refresh(assistant_msg)
|
||||
logger.info("Assistant message saved successfully.")
|
||||
|
||||
return [user_msg, assistant_msg]
|
||||
30
backend/app/api/deps.py
Normal file
30
backend/app/api/deps.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.db.models import Session, User
|
||||
|
||||
COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "ai_chat_session")
|
||||
|
||||
def get_current_user(request: Request, db: DBSession = Depends(get_db)) -> User:
|
||||
session_id = request.cookies.get(COOKIE_NAME)
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
db_session = db.get(Session, session_id)
|
||||
if not db_session:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session")
|
||||
|
||||
if db_session.expires_at < datetime.now(timezone.utc).replace(tzinfo=None):
|
||||
db.delete(db_session)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
|
||||
|
||||
user = db_session.user
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User inactive")
|
||||
|
||||
return user
|
||||
20
backend/app/api/health.py
Normal file
20
backend/app/api/health.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "service": "backend"}
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
def ready() -> dict:
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return {"status": "ready", "database": "ok"}
|
||||
39
backend/app/core/config.py
Normal file
39
backend/app/core/config.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(extra="ignore")
|
||||
|
||||
app_env: str = "dev"
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
|
||||
database_url: str
|
||||
|
||||
admin_bootstrap_login: str = "admin"
|
||||
admin_bootstrap_password: str = "change_me_later"
|
||||
|
||||
llm_manager_base_url: str
|
||||
llm_manager_api_key: str
|
||||
searxng_base_url: str
|
||||
|
||||
upload_root: str = "/data/uploads"
|
||||
temp_root: str = "/data/temp"
|
||||
log_root: str = "/data/logs"
|
||||
|
||||
max_image_mb: int = 10
|
||||
max_audio_mb: int = 25
|
||||
max_audio_duration_sec: int = 300
|
||||
max_message_chars: int = 16000
|
||||
|
||||
tts_ttl_hours: int = 4
|
||||
temp_audio_ttl_hours: int = 24
|
||||
orphan_file_grace_hours: int = 24
|
||||
|
||||
summary_trigger_message_count: int = 30
|
||||
summary_keep_recent_messages: int = 16
|
||||
summary_max_chars: int = 8000
|
||||
summary_model_alias: str = "qwen3.5-4b"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
98
backend/app/core/llm_client.py
Normal file
98
backend/app/core/llm_client.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import httpx
|
||||
import asyncio
|
||||
from fastapi import HTTPException
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global lock to prevent concurrent switches and generation requests
|
||||
# This is safe for a single-worker MVP (uvicorn without --workers)
|
||||
inference_lock = asyncio.Lock()
|
||||
|
||||
class LLMClient:
|
||||
def __init__(self):
|
||||
self.base_url = settings.llm_manager_base_url.rstrip("/")
|
||||
self.api_key = settings.llm_manager_api_key
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
async def get_status(self):
|
||||
"""Fetch the current global state of llm-manager."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/status",
|
||||
headers=self.headers,
|
||||
timeout=10.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Failed to fetch llm-manager status: {e}")
|
||||
raise HTTPException(status_code=502, detail="llm-manager status check failed")
|
||||
|
||||
async def switch_model(self, model_name: str):
|
||||
"""Request llm-manager to switch its active model."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
logger.info(f"Requesting llm-manager switch to model: {model_name}")
|
||||
response = await client.post(
|
||||
f"{self.base_url}/switch/{model_name}",
|
||||
headers=self.headers,
|
||||
timeout=60.0 # Switching can take a while via LLM manager
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Failed to switch model to {model_name}: {e}")
|
||||
raise HTTPException(status_code=502, detail=f"Failed to switch model to {model_name}")
|
||||
|
||||
async def wait_for_model_ready(self, model_name: str, timeout: float = 60.0, poll_interval: float = 2.0):
|
||||
"""Wait for the model to be active and not loading/unloading."""
|
||||
import time
|
||||
start_time = time.time()
|
||||
iterations = 0
|
||||
while time.time() - start_time < timeout:
|
||||
iterations += 1
|
||||
status = await self.get_status()
|
||||
current_model = status.get("active_model")
|
||||
vram_state = status.get("vram_state", "")
|
||||
|
||||
logger.info(f"Readiness poll #{iterations}: model={current_model}, vram_state={vram_state}")
|
||||
|
||||
if current_model == model_name and vram_state not in ("loading", "unloading"):
|
||||
return True, iterations, status
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
return False, iterations, None
|
||||
|
||||
async def chat_completion(self, messages: list, max_tokens: int = None, temperature: float = None):
|
||||
"""Generate response via llm-manager."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
payload = {
|
||||
"messages": messages,
|
||||
"stream": False
|
||||
}
|
||||
if max_tokens is not None:
|
||||
payload["max_tokens"] = max_tokens
|
||||
if temperature is not None:
|
||||
payload["temperature"] = temperature
|
||||
|
||||
response = await client.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=120.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Failed to generate chat completion: {e}")
|
||||
raise HTTPException(status_code=502, detail="Chat completion generation failed")
|
||||
|
||||
llm_client = LLMClient()
|
||||
15
backend/app/core/models_catalog.py
Normal file
15
backend/app/core/models_catalog.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ModelInfo(BaseModel):
|
||||
alias: str
|
||||
name: str
|
||||
vision_alias: Optional[str] = None
|
||||
|
||||
# Defined curated list avoiding direct LLM integration dynamically
|
||||
AVAILABLE_MODELS = [
|
||||
ModelInfo(alias="qwen3.5-4b", name="Qwen 3.5 4B", vision_alias="qwen3.5-4b-vl"),
|
||||
ModelInfo(alias="qwen3.5-9b", name="Qwen 3.5 9B", vision_alias="qwen3.5-9b-vl"),
|
||||
ModelInfo(alias="qwen2.5-coder-14b", name="Qwen 2.5 Coder 14B"),
|
||||
ModelInfo(alias="a-vibe", name="A-Vibe"),
|
||||
]
|
||||
13
backend/app/core/security.py
Normal file
13
backend/app/core/security.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
ph = PasswordHasher()
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
try:
|
||||
return ph.verify(hashed_password, plain_password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return ph.hash(password)
|
||||
3
backend/app/db/base_class.py
Normal file
3
backend/app/db/base_class.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
37
backend/app/db/init_db.py
Normal file
37
backend/app/db/init_db.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.db.base_class import Base
|
||||
from app.db.session import engine
|
||||
from app.db.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def init_db(db: Session) -> None:
|
||||
# MVP: create tables if they don't exist
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
bootstrap_admin(db)
|
||||
|
||||
def bootstrap_admin(db: Session) -> None:
|
||||
admin_login = settings.admin_bootstrap_login
|
||||
admin_pass = settings.admin_bootstrap_password
|
||||
|
||||
user = db.scalar(select(User).where(User.login == admin_login))
|
||||
if not user:
|
||||
logger.info(f"Creating bootstrap admin user: {admin_login}")
|
||||
hashed_password = get_password_hash(admin_pass)
|
||||
admin_user = User(
|
||||
login=admin_login,
|
||||
hashed_password=hashed_password,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(admin_user)
|
||||
db.commit()
|
||||
db.refresh(admin_user)
|
||||
else:
|
||||
logger.info(f"Admin user {admin_login} already exists")
|
||||
70
backend/app/db/models.py
Normal file
70
backend/app/db/models.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
def generate_uuid() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
||||
login = Column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
||||
chats = relationship("Chat", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
||||
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="sessions")
|
||||
|
||||
class Chat(Base):
|
||||
__tablename__ = "chats"
|
||||
|
||||
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
||||
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
|
||||
title = Column(String, nullable=False, default="New Chat")
|
||||
model_alias = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=utc_now)
|
||||
updated_at = Column(DateTime, nullable=False, default=utc_now, onupdate=utc_now)
|
||||
|
||||
user = relationship("User", back_populates="chats")
|
||||
messages = relationship("Message", back_populates="chat", cascade="all, delete-orphan", order_by="Message.created_at")
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
||||
chat_id = Column(String, ForeignKey("chats.id"), nullable=False, index=True)
|
||||
role = Column(String, nullable=False) # system, user, assistant
|
||||
content = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=utc_now)
|
||||
|
||||
chat = relationship("Chat", back_populates="messages")
|
||||
attachments = relationship("Attachment", back_populates="message", cascade="all, delete-orphan")
|
||||
|
||||
class Attachment(Base):
|
||||
__tablename__ = "attachments"
|
||||
|
||||
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
||||
message_id = Column(String, ForeignKey("messages.id"), nullable=False, index=True)
|
||||
filename = Column(String, nullable=False)
|
||||
content_type = Column(String, nullable=False)
|
||||
file_path = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=utc_now)
|
||||
|
||||
message = relationship("Message", back_populates="attachments")
|
||||
14
backend/app/db/session.py
Normal file
14
backend/app/db/session.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
59
backend/app/main.py
Normal file
59
backend/app/main.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.health import router as health_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.chats import router as chats_router
|
||||
from app.core.config import settings
|
||||
from app.db.init_db import init_db
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
for path in [settings.upload_root, settings.temp_root, settings.log_root]:
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
app = FastAPI(
|
||||
title="AI Chat MVP Backend",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
init_db(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://127.0.0.1:13000",
|
||||
"http://localhost:13000",
|
||||
"http://192.168.149.194:13000",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health_router, prefix="/api")
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(chats_router, prefix="/api", tags=["chats"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict:
|
||||
return {
|
||||
"service": "ai-chat-backend",
|
||||
"env": settings.app_env,
|
||||
"docs": "/docs",
|
||||
}
|
||||
Reference in New Issue
Block a user