Initial MVP skeleton with auth, chat persistence, UI and text LLM integration
38
.env.example
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
APP_ENV=dev
|
||||||
|
|
||||||
|
POSTGRES_DB=ai_chat
|
||||||
|
POSTGRES_USER=ai_chat
|
||||||
|
POSTGRES_PASSWORD=change_me_now
|
||||||
|
|
||||||
|
ADMIN_BOOTSTRAP_LOGIN=admin
|
||||||
|
ADMIN_BOOTSTRAP_PASSWORD=change_me_later
|
||||||
|
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:18000
|
||||||
|
|
||||||
|
SESSION_SECRET=change_me_long_random_secret
|
||||||
|
SESSION_COOKIE_NAME=ai_chat_session
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
SESSION_COOKIE_SAMESITE=lax
|
||||||
|
SESSION_TTL_HOURS=168
|
||||||
|
|
||||||
|
LLM_MANAGER_BASE_URL=http://192.168.149.194:8001
|
||||||
|
LLM_MANAGER_API_KEY=change_me
|
||||||
|
SEARXNG_BASE_URL=http://192.168.149.22:8888
|
||||||
|
|
||||||
|
UPLOAD_ROOT=/data/uploads
|
||||||
|
TEMP_ROOT=/data/temp
|
||||||
|
LOG_ROOT=/data/logs
|
||||||
|
|
||||||
|
MAX_IMAGE_MB=10
|
||||||
|
MAX_AUDIO_MB=25
|
||||||
|
MAX_AUDIO_DURATION_SEC=300
|
||||||
|
MAX_MESSAGE_CHARS=16000
|
||||||
|
|
||||||
|
TTS_TTL_HOURS=4
|
||||||
|
TEMP_AUDIO_TTL_HOURS=24
|
||||||
|
ORPHAN_FILE_GRACE_HOURS=24
|
||||||
|
|
||||||
|
SUMMARY_TRIGGER_MESSAGE_COUNT=30
|
||||||
|
SUMMARY_KEEP_RECENT_MESSAGES=16
|
||||||
|
SUMMARY_MAX_CHARS=8000
|
||||||
|
SUMMARY_MODEL_ALIAS=qwen3.5-4b
|
||||||
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
/tmp/
|
||||||
|
walkthrough.md
|
||||||
27
README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# AI Chat MVP
|
||||||
|
|
||||||
|
Первый шаг: infra skeleton.
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
Что должно подняться
|
||||||
|
|
||||||
|
frontend: http://127.0.0.1:13000
|
||||||
|
|
||||||
|
backend: http://127.0.0.1:18000
|
||||||
|
|
||||||
|
backend docs: http://127.0.0.1:18000/docs
|
||||||
|
|
||||||
|
Что пока реализовано
|
||||||
|
|
||||||
|
docker-compose каркас
|
||||||
|
|
||||||
|
postgres container
|
||||||
|
|
||||||
|
backend health/ready
|
||||||
|
|
||||||
|
frontend shell
|
||||||
20
backend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
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
@@ -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
@@ -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
@@ -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",
|
||||||
|
}
|
||||||
8
backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.115.8
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
pydantic==2.10.6
|
||||||
|
pydantic-settings==2.8.1
|
||||||
|
sqlalchemy==2.0.38
|
||||||
|
psycopg[binary]==3.2.6
|
||||||
|
argon2-cffi==23.1.0
|
||||||
|
httpx
|
||||||
5
cookies.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
|
#HttpOnly_127.0.0.1 FALSE / FALSE 1773687247 ai_chat_session 6a30ee77ead843569d08f2d47dda4b60
|
||||||
60
docker-compose.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: ai-chat-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
container_name: ai-chat-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
APP_HOST: 0.0.0.0
|
||||||
|
APP_PORT: 8000
|
||||||
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- app_uploads:/data/uploads
|
||||||
|
- app_temp:/data/temp
|
||||||
|
- app_logs:/data/logs
|
||||||
|
ports:
|
||||||
|
- "18000:8000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||||
|
container_name: ai-chat-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||||
|
INTERNAL_API_URL: http://backend:8000
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "13000:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
app_uploads:
|
||||||
|
app_temp:
|
||||||
|
app_logs:
|
||||||
BIN
docs/ui-mockups/01_login.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/ui-mockups/02_main-chat selection.jpg
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
docs/ui-mockups/03_new chat creation.jpg
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
docs/ui-mockups/04_chat - text only.jpg
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
docs/ui-mockups/05_chat - text and picture.jpg
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
docs/ui-mockups/06_chat - audio input.jpg
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
docs/ui-mockups/07_caht - TTS output.jpg
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/ui-mockups/08_chat - empty chat new.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/ui-mockups/09_chat - error - not available.jpg
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
docs/ui-mockups/10 user admin.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
17
docs/ui-mockups/README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# UI Mockups
|
||||||
|
|
||||||
|
Референсные экраны для реализации frontend MVP.
|
||||||
|
|
||||||
|
## Список экранов
|
||||||
|
1. 01-login.jpg — экран логина
|
||||||
|
2. 02-main-chat-selection.jpg — главная / список чатов
|
||||||
|
3. 03-new-chat-creation.jpg — создание нового чата
|
||||||
|
4. 04-chat-text-only.jpg — текстовый чат
|
||||||
|
5. 05-chat-text-and-picture.jpg — чат с картинкой
|
||||||
|
6. 06-chat-audio-input.jpg — голосовой ввод / аудио
|
||||||
|
7. 07-chat-tts-output.jpg — ответ с TTS
|
||||||
|
8. 08-chat-empty-new.jpg — пустое состояние
|
||||||
|
9. 09-chat-error-not-available.jpg — ошибка сервиса/модели
|
||||||
|
10. 10-user-admin.jpg — базовая админка пользователей
|
||||||
|
|
||||||
|
Использовать как визуальный референс. Не воспринимать как pixel-perfect спецификацию.
|
||||||
17
frontend/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG NEXT_PUBLIC_API_BASE_URL
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
248
frontend/app/chat/[id]/ChatView.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chat {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
model_alias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
|
||||||
|
|
||||||
|
export default function ChatView({ chat, initialMessages }: { chat: Chat, initialMessages: Message[] }) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages, isGenerating]); // Also scroll when isGenerating changes
|
||||||
|
|
||||||
|
const handleSend = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!input.trim() || isSending || isGenerating) return;
|
||||||
|
|
||||||
|
setIsSending(true);
|
||||||
|
const content = input;
|
||||||
|
setInput("");
|
||||||
|
|
||||||
|
// Eagerly show user message map
|
||||||
|
const tempUserMsg: Message = {
|
||||||
|
id: "temp-" + Date.now(),
|
||||||
|
role: "user",
|
||||||
|
content: content,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, tempUserMsg]);
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/chats/${chat.id}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ content, role: "user" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to send message");
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!Array.isArray(data) || data.length < 2) {
|
||||||
|
throw new Error("Invalid response format from server");
|
||||||
|
}
|
||||||
|
const [userMsg, assistantMsg] = data;
|
||||||
|
|
||||||
|
// Replace the temp user msg and append the actual user and assistant messages from backend
|
||||||
|
setMessages(prev => [...prev.filter(m => m.id !== tempUserMsg.id), userMsg, assistantMsg]);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert("Failed to send message: " + err);
|
||||||
|
setInput(content); // restore input
|
||||||
|
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id)); // Remove failed message
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}>
|
||||||
|
{/* Dynamic Chat Header */}
|
||||||
|
<div style={{
|
||||||
|
height: 64,
|
||||||
|
padding: '0 24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
borderBottom: '1px solid var(--border-color)',
|
||||||
|
backgroundColor: 'var(--panel-bg)'
|
||||||
|
}}>
|
||||||
|
<h2 style={{ fontSize: 18, fontWeight: 600, margin: 0, color: 'var(--text-main)' }}>{chat.title}</h2>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '6px 12px', backgroundColor: 'var(--bg-color)',
|
||||||
|
borderRadius: 20, fontSize: 13, color: 'var(--primary)', fontWeight: 500
|
||||||
|
}}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M12 16V12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M12 8H12.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Модель: {chat.model_alias}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages Scroll Area */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{ flex: 1, overflowY: "auto", padding: '24px 10%', backgroundColor: 'var(--bg-color)' }}
|
||||||
|
>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: 'var(--text-muted)' }}>
|
||||||
|
Напишите первое сообщение, чтобы начать диалог.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, paddingBottom: 24 }}>
|
||||||
|
{messages.map(msg => {
|
||||||
|
const isUser = msg.role === 'user';
|
||||||
|
return (
|
||||||
|
<div key={msg.id} style={{ display: 'flex', flexDirection: 'column', alignItems: isUser ? 'flex-end' : 'flex-start' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: isUser ? 'var(--primary)' : 'var(--text-main)' }}>
|
||||||
|
{isUser ? 'Вы' : 'AI Помощник'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: isUser ? 'var(--primary)' : 'var(--panel-bg)',
|
||||||
|
color: isUser ? '#fff' : 'var(--text-main)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: isUser ? '16px 16px 0 16px' : '16px 16px 16px 0',
|
||||||
|
maxWidth: '85%',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
fontSize: 15,
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||||
|
border: isUser ? 'none' : '1px solid var(--border-color)',
|
||||||
|
whiteSpace: 'pre-wrap'
|
||||||
|
}}>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Loading Indicator for AI Response */}
|
||||||
|
{isGenerating && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-main)' }}>
|
||||||
|
AI Помощник
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--panel-bg)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: '16px 16px 16px 0',
|
||||||
|
maxWidth: '85%',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
fontSize: 15,
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px'
|
||||||
|
}}>
|
||||||
|
<i>Печатает...</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 10%',
|
||||||
|
backgroundColor: 'var(--panel-bg)',
|
||||||
|
borderTop: '1px solid var(--border-color)'
|
||||||
|
}}>
|
||||||
|
<form onSubmit={handleSend} style={{
|
||||||
|
display: "flex",
|
||||||
|
position: "relative",
|
||||||
|
alignItems: "center"
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
placeholder="Спросите о чем-нибудь..."
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '14px 110px 14px 20px',
|
||||||
|
borderRadius: 24,
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
backgroundColor: "var(--bg-color)",
|
||||||
|
fontSize: 15,
|
||||||
|
outline: "none",
|
||||||
|
transition: "border-color 0.2s"
|
||||||
|
}}
|
||||||
|
disabled={isSending}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--primary)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ position: 'absolute', right: 8, display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
title="Вложения пока не поддерживаются"
|
||||||
|
style={{
|
||||||
|
width: 36, height: 36, borderRadius: '50%', backgroundColor: 'transparent',
|
||||||
|
border: 'none', cursor: 'not-allowed', color: 'var(--text-muted)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21.4354 2.58198C20.9402 2.06941 20.1492 2.06941 19.654 2.58198L10.3168 12.2359C9.89423 12.6728 9.89423 13.3854 10.3168 13.8224C10.7394 14.2593 11.4284 14.2593 11.851 13.8224L20.5977 4.77443C20.893 4.469 21.3748 4.469 21.6701 4.77443C21.9654 5.07985 21.9654 5.57813 21.6701 5.88355L12.9234 14.9315C11.9079 15.9818 10.2524 15.9818 9.23692 14.9315C8.22144 13.8812 8.22144 12.1697 9.23692 11.1193L18.5741 1.46543C19.6701 0.33202 21.4616 0.33202 22.5576 1.46543C23.6535 2.59884 23.6535 4.45307 22.5576 5.58648L11.5332 17.0267C9.92358 18.6917 7.29124 18.6917 5.68161 17.0267C4.07198 15.3618 4.07198 12.6393 5.68161 10.9743L14.8812 1.45511C15.1765 1.14968 15.6583 1.14968 15.9536 1.45511C16.2489 1.76054 16.2489 2.25881 15.9536 2.56424L6.75399 12.0835C5.74836 13.1237 5.74836 14.823 6.75399 15.8633C7.75962 16.9035 9.40232 16.9035 10.4079 15.8633L21.4324 4.42316C21.944 3.89363 21.944 3.09151 21.4324 2.58198H21.4354Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSending || !input.trim()}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 36, height: 36, borderRadius: '50%',
|
||||||
|
backgroundColor: isSending || !input.trim() ? '#e5e7eb' : 'var(--primary)',
|
||||||
|
color: isSending || !input.trim() ? '#a1a1aa' : '#fff',
|
||||||
|
border: 'none', cursor: (isSending || !input.trim()) ? 'default' : 'pointer',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginLeft: 2 }}>
|
||||||
|
<path d="M22 2L11 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M22 2L15 22L11 13L2 9L22 2Z" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
frontend/app/chat/[id]/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import ChatView from "./ChatView";
|
||||||
|
import Layout from "../../../components/Layout";
|
||||||
|
|
||||||
|
async function getAuthState() {
|
||||||
|
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionCookie = cookieStore.get("ai_chat_session");
|
||||||
|
|
||||||
|
if (!sessionCookie) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `${sessionCookie.name}=${sessionCookie.value}`
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChat(id: string) {
|
||||||
|
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionCookie = cookieStore.get("ai_chat_session");
|
||||||
|
|
||||||
|
if (!sessionCookie) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/chats/${id}`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `${sessionCookie.name}=${sessionCookie.value}`
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessages(id: string) {
|
||||||
|
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionCookie = cookieStore.get("ai_chat_session");
|
||||||
|
|
||||||
|
if (!sessionCookie) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/chats/${id}/messages`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `${sessionCookie.name}=${sessionCookie.value}`
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChats() {
|
||||||
|
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionCookie = cookieStore.get("ai_chat_session");
|
||||||
|
|
||||||
|
if (!sessionCookie) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/chats`, {
|
||||||
|
headers: { Cookie: `${sessionCookie.name}=${sessionCookie.value}` },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const auth = await getAuthState();
|
||||||
|
if (!auth) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const chat = await getChat(id);
|
||||||
|
if (!chat) {
|
||||||
|
return (
|
||||||
|
<main style={{ padding: 24, fontFamily: "Arial, sans-serif" }}>
|
||||||
|
<h1>Chat Not Found</h1>
|
||||||
|
<Link href="/">Back to Home</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await getMessages(id);
|
||||||
|
const chats = await getChats();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout chats={chats} user={auth}>
|
||||||
|
<ChatView chat={chat} initialMessages={messages} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/app/globals.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-color: #f5f6f8;
|
||||||
|
--panel-bg: #ffffff;
|
||||||
|
--text-main: #333333;
|
||||||
|
--text-muted: #888888;
|
||||||
|
--border-color: #e5e7eb;
|
||||||
|
--primary: #4a76a8;
|
||||||
|
--primary-hover: #3d628f;
|
||||||
|
--sidebar-w: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
18
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "AI Chat MVP",
|
||||||
|
description: "AI Chat MVP",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
frontend/app/login/page.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [login, setLogin] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ login, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = "Неверный логин или пароль";
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.detail && typeof data.detail === "string") {
|
||||||
|
message = data.detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed/non-json body
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message || "Ошибка входа");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: "100vh",
|
||||||
|
backgroundColor: "var(--bg-color)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 24,
|
||||||
|
left: 32,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-main)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 2C6.477 2 2 6.477 2 12C2 17.523 6.477 22 12 22C17.523 22 22 17.523 22 12C22 6.477 17.523 2 12 2ZM12 4C16.418 4 20 7.582 20 12C20 16.418 16.418 20 12 20C7.582 20 4 16.418 4 12C4 7.582 7.582 4 12 4Z"
|
||||||
|
fill="var(--primary)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M15 13H9C8.448 13 8 12.552 8 12C8 11.448 8.448 11 9 11H15C15.552 11 16 11.448 16 12C16 12.552 15.552 13 15 13Z"
|
||||||
|
fill="var(--primary)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
AI Chat MVP
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
padding: "40px",
|
||||||
|
borderRadius: "16px",
|
||||||
|
boxShadow: "0 4px 20px rgba(0,0,0,0.05)",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "420px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: "32px",
|
||||||
|
color: "var(--text-main)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Вход в систему
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "20px",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: "14px",
|
||||||
|
marginBottom: "8px",
|
||||||
|
color: "var(--text-main)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Логин
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={login}
|
||||||
|
onChange={(e) => setLogin(e.target.value)}
|
||||||
|
type="text"
|
||||||
|
placeholder="admin"
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
fontSize: "15px",
|
||||||
|
outline: "none",
|
||||||
|
transition: "border-color 0.2s",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = "var(--primary)")}
|
||||||
|
onBlur={(e) =>
|
||||||
|
(e.target.style.borderColor = "var(--border-color)")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={{ fontSize: "14px", color: "var(--text-main)" }}>
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "13px",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "4px",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showPassword ? "Скрыть" : "Показать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
fontSize: "15px",
|
||||||
|
outline: "none",
|
||||||
|
transition: "border-color 0.2s",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = "var(--primary)")}
|
||||||
|
onBlur={(e) =>
|
||||||
|
(e.target.style.borderColor = "var(--border-color)")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#d32f2f",
|
||||||
|
fontSize: "14px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
padding: "14px",
|
||||||
|
marginTop: "8px",
|
||||||
|
backgroundColor: isSubmitting
|
||||||
|
? "var(--border-color)"
|
||||||
|
: "var(--primary)",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "15px",
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: "background-color 0.2s",
|
||||||
|
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--primary-hover)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--primary)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Вход..." : "Войти"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "24px",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Вход доступен только для пользователей, созданных администратором.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/app/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Layout from "../components/Layout";
|
||||||
|
|
||||||
|
async function getAuthState() {
|
||||||
|
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionCookie = cookieStore.get("ai_chat_session");
|
||||||
|
|
||||||
|
if (!sessionCookie) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `${sessionCookie.name}=${sessionCookie.value}`
|
||||||
|
},
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChats() {
|
||||||
|
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionCookie = cookieStore.get("ai_chat_session");
|
||||||
|
|
||||||
|
if (!sessionCookie) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/chats`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `${sessionCookie.name}=${sessionCookie.value}`
|
||||||
|
},
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const auth = await getAuthState();
|
||||||
|
if (!auth) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const chats = await getChats();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout chats={chats} user={auth}>
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', color: 'var(--text-muted)' }}>
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ opacity: 0.3, marginBottom: 16 }}>
|
||||||
|
<path d="M12 2C6.477 2 2 6.477 2 12C2 17.523 6.477 22 12 22C17.523 22 22 17.523 22 12C22 6.477 17.523 2 12 2ZM12 4C16.418 4 20 7.582 20 12C20 16.418 16.418 20 12 20C7.582 20 4 16.418 4 12C4 7.582 7.582 4 12 4Z" fill="currentColor"/>
|
||||||
|
<path d="M15 13H9C8.448 13 8 12.552 8 12C8 11.448 8.448 11 9 11H15C15.552 11 16 11.448 16 12C16 12.552 15.552 13 15 13Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-main)', marginBottom: 8 }}>Чем я могу помочь?</div>
|
||||||
|
<div style={{ fontSize: 14 }}>Выберите чат в меню слева или создайте новый.</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
frontend/components/Layout.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface Chat {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
model_alias: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
login: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
|
||||||
|
|
||||||
|
// Global modal state since Sidebar and Topbar both might need logic, but let's keep it simple
|
||||||
|
export default function Layout({ children, chats: initialChats, user }: { children: React.ReactNode, chats: Chat[], user: User }) {
|
||||||
|
const [chats, setChats] = useState<Chat[]>(initialChats);
|
||||||
|
const [models, setModels] = useState<{alias: string, name: string}[]>([]);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [newChatTitle, setNewChatTitle] = useState("");
|
||||||
|
const [newChatModel, setNewChatModel] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE_URL}/api/models`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setModels(data);
|
||||||
|
if (data.length > 0) setNewChatModel(data[0].alias);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await fetch(`${API_BASE_URL}/api/auth/logout`, { method: "POST" });
|
||||||
|
window.location.href = "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
const createChat = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newChatTitle.trim() || !newChatModel) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/chats`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ title: newChatTitle, model_alias: newChatModel }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const chat = await res.json();
|
||||||
|
setChats([chat, ...chats]);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setNewChatTitle("");
|
||||||
|
router.push(`/chat/${chat.id}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create chat", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', height: '100vh', flexDirection: 'column', backgroundColor: 'var(--bg-color)' }}>
|
||||||
|
{/* Top Bar */}
|
||||||
|
<header style={{
|
||||||
|
height: 56,
|
||||||
|
backgroundColor: 'var(--panel-bg)',
|
||||||
|
borderBottom: '1px solid var(--border-color)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 24px',
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 18, fontWeight: 600, color: 'var(--primary)' }}>
|
||||||
|
<Link href="/" style={{ color: 'inherit', textDecoration: 'none' }}>AI Chat MVP</Link>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--text-main)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ width: 28, height: 28, borderRadius: '50%', backgroundColor: '#e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 600, color: 'var(--text-main)' }}>
|
||||||
|
{user.login.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{user.login}
|
||||||
|
</div>
|
||||||
|
<button onClick={handleLogout} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 14, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
Выход
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Container */}
|
||||||
|
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside style={{
|
||||||
|
width: 'var(--sidebar-w)',
|
||||||
|
backgroundColor: 'var(--panel-bg)',
|
||||||
|
borderRight: '1px solid var(--border-color)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
zIndex: 10
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '16px 16px 8px 16px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 16px',
|
||||||
|
backgroundColor: 'var(--bg-color)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
onMouseOver={e => e.currentTarget.style.backgroundColor = '#e8eaed'}
|
||||||
|
onMouseOut={e => e.currentTarget.style.backgroundColor = 'var(--bg-color)'}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 18, color: 'var(--primary)', fontWeight: 300 }}>+</span> Новый чат
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '16px', fontSize: 13, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Мои чаты
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{chats.map(chat => (
|
||||||
|
<Link key={chat.id} href={`/chat/${chat.id}`} style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontSize: 14,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
backgroundColor: pathname === `/chat/${chat.id}` ? 'var(--bg-color)' : 'transparent',
|
||||||
|
borderLeft: pathname === `/chat/${chat.id}` ? '3px solid var(--primary)' : '3px solid transparent',
|
||||||
|
}}
|
||||||
|
onMouseOver={e => { if (pathname !== `/chat/${chat.id}`) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.02)' }}
|
||||||
|
onMouseOut={e => { if (pathname !== `/chat/${chat.id}`) e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<div style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: pathname === `/chat/${chat.id}` ? 500 : 400 }}>
|
||||||
|
{chat.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||||
|
{chat.model_alias}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative' }}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Chat Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)', zIndex: 100,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#fff', borderRadius: 16, width: '100%', maxWidth: 480,
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.1)', padding: 32
|
||||||
|
}}>
|
||||||
|
<h2 style={{ fontSize: 20, margin: '0 0 24px 0', fontWeight: 600 }}>Новый чат</h2>
|
||||||
|
<form onSubmit={createChat} style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 14, marginBottom: 8, color: 'var(--text-main)', fontWeight: 500 }}>Название чата</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newChatTitle}
|
||||||
|
onChange={e => setNewChatTitle(e.target.value)}
|
||||||
|
placeholder="Введите название..."
|
||||||
|
required
|
||||||
|
style={{ width: '100%', padding: '10px 14px', borderRadius: 8, border: '1px solid var(--border-color)', fontSize: 14, outline: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 14, marginBottom: 8, color: 'var(--text-main)', fontWeight: 500 }}>Модель</label>
|
||||||
|
<select
|
||||||
|
value={newChatModel}
|
||||||
|
onChange={e => setNewChatModel(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '10px 14px', borderRadius: 8, border: '1px solid var(--border-color)', fontSize: 14, outline: 'none', backgroundColor: '#fff' }}
|
||||||
|
>
|
||||||
|
{models.map(m => (
|
||||||
|
<option key={m.alias} value={m.alias}>{m.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginTop: 12, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
style={{ padding: '10px 20px', backgroundColor: 'transparent', border: '1px solid var(--border-color)', borderRadius: 8, fontSize: 14, fontWeight: 500, color: 'var(--text-main)' }}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{ padding: '10px 20px', backgroundColor: 'var(--primary)', border: 'none', borderRadius: 8, fontSize: 14, fontWeight: 500, color: '#fff' }}
|
||||||
|
>
|
||||||
|
Создать чат
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-chat-mvp-frontend",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -H 0.0.0.0 -p 3000",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3000"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "15.2.0",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||