Initial MVP skeleton with auth, chat persistence, UI and text LLM integration

This commit is contained in:
2026-03-10 16:58:02 +00:00
commit 105b8b3db4
40 changed files with 1984 additions and 0 deletions

98
backend/app/api/auth.py Normal file
View 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
View 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
View 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
View 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"}

View 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()

View 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()

View 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"),
]

View 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)

View File

@@ -0,0 +1,3 @@
from sqlalchemy.orm import declarative_base
Base = declarative_base()

37
backend/app/db/init_db.py Normal file
View 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
View 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
View 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
View 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",
}