init
This commit is contained in:
9
.env
Normal file
9
.env
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
TELEGRAM_TOKEN=8039533737:AAF0DKayjO21LRNCwdh8UffltXLg1HameGQ
|
||||||
|
OPENROUTER_API_KEY=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
|
||||||
|
DATABASE_URL=postgresql+asyncpg://drholy:SportForever1999@db:5432/chatdb
|
||||||
|
WEBAPP_URL=https://llm.byte-mate.xyz/webapp
|
||||||
|
DOMAIN=llm.byte-mate.xyz
|
||||||
|
EMAIL=your@email.com
|
||||||
|
POSTGRES_USER=drholy
|
||||||
|
POSTGRES_PASSWORD=SportForever1999
|
||||||
|
POSTGRES_DB=chatdb
|
||||||
10
Dockerfile
Normal file
10
Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.11
|
||||||
|
|
||||||
|
WORKDIR /code
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
BIN
app/__pycache__/api.cpython-311.pyc
Normal file
BIN
app/__pycache__/api.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/crud.cpython-311.pyc
Normal file
BIN
app/__pycache__/crud.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/database.cpython-311.pyc
Normal file
BIN
app/__pycache__/database.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-311.pyc
Normal file
BIN
app/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/models.cpython-311.pyc
Normal file
BIN
app/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/openrouter_client.cpython-311.pyc
Normal file
BIN
app/__pycache__/openrouter_client.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/schemas.cpython-311.pyc
Normal file
BIN
app/__pycache__/schemas.cpython-311.pyc
Normal file
Binary file not shown.
101
app/api.py
Normal file
101
app/api.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# from fastapi import APIRouter, Depends
|
||||||
|
# from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
# from .database import async_session
|
||||||
|
# from . import crud, openrouter_client
|
||||||
|
|
||||||
|
# router = APIRouter()
|
||||||
|
|
||||||
|
# async def get_db():
|
||||||
|
# async with async_session() as db:
|
||||||
|
# yield db
|
||||||
|
|
||||||
|
# @router.get("/history/{telegram_id}")
|
||||||
|
# async def get_history(telegram_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
# user = await crud.get_or_create_user(db, telegram_id)
|
||||||
|
# history = await crud.get_conversation(db, user, limit=20)
|
||||||
|
# return [{"role": m.role, "content": m.content} for m in history]
|
||||||
|
|
||||||
|
# @router.post("/chat/{telegram_id}")
|
||||||
|
# async def chat(telegram_id: str, payload: dict, db: AsyncSession = Depends(get_db)):
|
||||||
|
# user = await crud.get_or_create_user(db, telegram_id)
|
||||||
|
# user_msg = payload.get("message", "")
|
||||||
|
# if not user_msg:
|
||||||
|
# return {"error": "Empty message"}
|
||||||
|
|
||||||
|
# await crud.add_message(db, user, "user", user_msg)
|
||||||
|
|
||||||
|
# history = await crud.get_conversation(db, user, limit=10)
|
||||||
|
# messages_payload = [{"role": m.role, "content": m.content} for m in history]
|
||||||
|
|
||||||
|
# response = await openrouter_client.generate_response(messages_payload)
|
||||||
|
# await crud.add_message(db, user, "assistant", response)
|
||||||
|
|
||||||
|
# return {"response": response}
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from app import models, schemas
|
||||||
|
from .database import async_session
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# --- Dependency ---
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with async_session() as db:
|
||||||
|
yield db
|
||||||
|
|
||||||
|
|
||||||
|
# 📌 Получить профиль пользователя (берём первого для примера)
|
||||||
|
@router.get("/profile", response_model=schemas.UserOut)
|
||||||
|
async def get_profile(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(models.User).options(
|
||||||
|
selectinload(models.User.chats).selectinload(models.Chat.messages)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user = result.scalars().first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# 📌 Получить список чатов
|
||||||
|
@router.get("/chats", response_model=list[schemas.ChatOut])
|
||||||
|
async def get_chats(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(models.Chat).options(selectinload(models.Chat.messages))
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
# 📌 Сообщения конкретного чата
|
||||||
|
@router.get("/chats/{chat_id}/messages", response_model=list[schemas.MessageOut])
|
||||||
|
async def get_chat_messages(chat_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(models.Chat)
|
||||||
|
.options(selectinload(models.Chat.messages))
|
||||||
|
.filter_by(id=chat_id)
|
||||||
|
)
|
||||||
|
chat = result.scalars().first()
|
||||||
|
if not chat:
|
||||||
|
raise HTTPException(status_code=404, detail="Chat not found")
|
||||||
|
return chat.messages
|
||||||
|
|
||||||
|
|
||||||
|
# 📌 Добавить новое сообщение
|
||||||
|
@router.post("/chats/{chat_id}/messages", response_model=schemas.MessageOut)
|
||||||
|
async def add_message(chat_id: int, msg: schemas.MessageBase, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(models.Chat).filter_by(id=chat_id)
|
||||||
|
)
|
||||||
|
chat = result.scalars().first()
|
||||||
|
if not chat:
|
||||||
|
raise HTTPException(status_code=404, detail="Chat not found")
|
||||||
|
|
||||||
|
new_msg = models.Message(chat_id=chat_id, role=msg.role, content=msg.content)
|
||||||
|
db.add(new_msg)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_msg)
|
||||||
|
return new_msg
|
||||||
29
app/crud.py
Normal file
29
app/crud.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from sqlalchemy.future import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
async def get_or_create_user(db: AsyncSession, telegram_id: str):
|
||||||
|
result = await db.execute(select(models.User).where(models.User.telegram_id == telegram_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
user = models.User(telegram_id=telegram_id)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def add_message(db: AsyncSession, user, role, content):
|
||||||
|
msg = models.Message(user_id=user.id, role=role, content=content)
|
||||||
|
db.add(msg)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(msg)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
async def get_conversation(db: AsyncSession, user, limit=10):
|
||||||
|
result = await db.execute(
|
||||||
|
select(models.Message)
|
||||||
|
.where(models.Message.user_id == user.id)
|
||||||
|
.order_by(models.Message.timestamp.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(reversed(result.scalars().all()))
|
||||||
9
app/database.py
Normal file
9
app/database.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
import os
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=True, future=True)
|
||||||
|
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
Base = declarative_base()
|
||||||
18
app/main.py
Normal file
18
app/main.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from . import database, models, api
|
||||||
|
|
||||||
|
from app.api import router as api_router
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
app.include_router(api_router, prefix="/api")
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
async with database.engine.begin() as conn:
|
||||||
|
await conn.run_sync(models.Base.metadata.create_all)
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def serve_spa(full_path: str):
|
||||||
|
index_path = os.path.join(dist_path, "index.html")
|
||||||
|
return FileResponse(index_path)
|
||||||
33
app/models.py
Normal file
33
app/models.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
telegram_id = Column(String, unique=True, index=True)
|
||||||
|
username = Column(String, nullable=True)
|
||||||
|
|
||||||
|
chats = relationship("Chat", back_populates="owner")
|
||||||
|
|
||||||
|
|
||||||
|
class Chat(Base):
|
||||||
|
__tablename__ = "chats"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
title = Column(String, default="Новый чат")
|
||||||
|
|
||||||
|
owner_id = Column(Integer, ForeignKey("users.id"))
|
||||||
|
owner = relationship("User", back_populates="chats")
|
||||||
|
|
||||||
|
messages = relationship("Message", back_populates="chat", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
role = Column(String) # 'user' | 'assistant' | 'system'
|
||||||
|
content = Column(Text)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
chat_id = Column(Integer, ForeignKey("chats.id"))
|
||||||
|
chat = relationship("Chat", back_populates="messages")
|
||||||
33
app/openrouter_client.py
Normal file
33
app/openrouter_client.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import os
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
|
||||||
|
SITE_URL = os.getenv("WEBAPP_URL", "https://llm.byte-mate.xyz")
|
||||||
|
SITE_NAME = "Telegram LLM Chat"
|
||||||
|
|
||||||
|
# инициализируем клиента
|
||||||
|
client = OpenAI(
|
||||||
|
base_url="https://openrouter.ai/api/v1",
|
||||||
|
api_key=OPENROUTER_API_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate_response(messages):
|
||||||
|
"""
|
||||||
|
messages: список сообщений [{'role': 'user'|'assistant'|'system', 'content': str}]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
completion = client.chat.completions.create(
|
||||||
|
# дополнительные заголовки для OpenRouter (не обязательно, но полезно)
|
||||||
|
extra_headers={
|
||||||
|
"HTTP-Referer": SITE_URL,
|
||||||
|
"X-Title": SITE_NAME,
|
||||||
|
},
|
||||||
|
model="qwen/qwen3-235b-a22b:free", # ✅ используй модель, которая точно доступна твоему API‑ключу
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
return completion.choices[0].message.content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Логи и возврат ошибки
|
||||||
|
print(f"❌ Ошибка вызова OpenRouter API: {e}")
|
||||||
|
return "Извините, LLM сейчас недоступен. Попробуйте позже."
|
||||||
35
app/schemas.py
Normal file
35
app/schemas.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class MessageBase(BaseModel):
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
class MessageOut(MessageBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
class ChatBase(BaseModel):
|
||||||
|
title: str
|
||||||
|
|
||||||
|
class ChatOut(ChatBase):
|
||||||
|
id: int
|
||||||
|
messages: List[MessageOut] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
telegram_id: str
|
||||||
|
username: Optional[str]
|
||||||
|
chats: List[ChatOut] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
32
bot.py
Normal file
32
bot.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||||
|
from telegram.ext import Application, CommandHandler, ContextTypes
|
||||||
|
|
||||||
|
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN")
|
||||||
|
WEBAPP_URL = os.getenv("WEBAPP_URL", "https://yourdomain.com/webapp") # из .env или дефолт
|
||||||
|
|
||||||
|
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Обработка команды /start"""
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("🚀 Открыть чат", web_app=WebAppInfo(url=WEBAPP_URL))]
|
||||||
|
]
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Привет! Нажми кнопку ниже, чтобы открыть WebApp 👇",
|
||||||
|
reply_markup=reply_markup
|
||||||
|
)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Создаём приложение
|
||||||
|
app = Application.builder().token(TELEGRAM_TOKEN).build()
|
||||||
|
|
||||||
|
# Добавляем обработчик команды /start
|
||||||
|
app.add_handler(CommandHandler("start", start))
|
||||||
|
|
||||||
|
# Запускаем (асинхронно работает внутри run_polling)
|
||||||
|
print("🚀 Бот запущен. Нажми /start в чате с ним.")
|
||||||
|
app.run_polling()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
71
docker-compose.yml
Normal file
71
docker-compose.yml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
container_name: telegram-llm-chat
|
||||||
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
volumes:
|
||||||
|
- .:/code
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
container_name: telegram-bot
|
||||||
|
command: python bot.py
|
||||||
|
volumes:
|
||||||
|
- .:/code
|
||||||
|
depends_on:
|
||||||
|
- web # бот будет запускаться после backend API
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: always # если упадёт - перезапустится
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
container_name: chatdb
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
container_name: nginx_proxy
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- certbot-etc:/etc/letsencrypt
|
||||||
|
- certbot-var:/var/lib/letsencrypt
|
||||||
|
- ./nginx/certbot:/var/www/certbot
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot
|
||||||
|
container_name: certbot
|
||||||
|
volumes:
|
||||||
|
- certbot-etc:/etc/letsencrypt
|
||||||
|
- certbot-var:/var/lib/letsencrypt
|
||||||
|
- ./nginx/certbot:/var/www/certbot
|
||||||
|
depends_on:
|
||||||
|
- nginx
|
||||||
|
# Закоментируй entrypoint для первого выпуска сертификата!!!
|
||||||
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h; done'"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
certbot-etc:
|
||||||
|
certbot-var:
|
||||||
|
frontend-html:
|
||||||
29
frontend/Dockerfile
Normal file
29
frontend/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# ---------- STAGE 1: Build React App ----------
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# копируем package.json и package-lock.json из frontend/
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# копируем весь src проекта фронта
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---------- STAGE 2: Nginx ----------
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# чистим дефолтку
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
|
||||||
|
# копируем билд фронта
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# создаём каталог под challenge заранее (на случай, если volume не примонтировался)
|
||||||
|
RUN mkdir -p /var/www/certbot/.well-known/acme-challenge
|
||||||
|
|
||||||
|
# копируем nginx.conf (он лежит в ./nginx/nginx.conf)
|
||||||
|
COPY nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
EXPOSE 80 443
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Telegram LLM Chat</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "telegram-llm-chat-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.22.2",
|
||||||
|
"react-markdown": "^9.0.0",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"axios": "^1.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/src/App.jsx
Normal file
26
frontend/src/App.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Link } from "react-router-dom";
|
||||||
|
import Home from "./pages/Home";
|
||||||
|
import UserProfile from "./pages/UserProfile";
|
||||||
|
import ChatPage from "./pages/ChatPage";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<div className="flex flex-col h-screen">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/profile" element={<UserProfile />} />
|
||||||
|
<Route path="/chat/:chatId" element={<ChatPage />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
{/* Навигация снизу */}
|
||||||
|
<nav className="flex justify-around p-2 bg-gray-100 border-t">
|
||||||
|
<Link to="/">🏠 Домой</Link>
|
||||||
|
<Link to="/profile">👤 Профиль</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/src/api.js
Normal file
21
frontend/src/api.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || "https://llm.byte-mate.xyz";
|
||||||
|
|
||||||
|
export async function getProfile() {
|
||||||
|
const resp = await axios.get(`${API_BASE}/api/profile`);
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChats() {
|
||||||
|
const resp = await axios.get(`${API_BASE}/api/chats`);
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(chatId, message) {
|
||||||
|
const resp = await axios.post(`${API_BASE}/api/chats/${chatId}/messages`, {
|
||||||
|
role: "user",
|
||||||
|
content: message,
|
||||||
|
});
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
24
frontend/src/components/ChatList.jsx
Normal file
24
frontend/src/components/ChatList.jsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function ChatList({ chats }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{chats.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c}
|
||||||
|
to={`/chat/${c}`}
|
||||||
|
className="block p-3 bg-gray-200 rounded hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
💬 Чат {c}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<Link
|
||||||
|
to="/chat/new"
|
||||||
|
className="block p-3 bg-green-500 text-white rounded hover:bg-green-600"
|
||||||
|
>
|
||||||
|
➕ Новый чат
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/components/ChatWindow.jsx
Normal file
42
frontend/src/components/ChatWindow.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import MessageBubble from "./MessageBubble";
|
||||||
|
import { sendMessage } from "../api";
|
||||||
|
|
||||||
|
export default function ChatWindow({ chatId }) {
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
const newMsg = { role: "user", content: input };
|
||||||
|
setMessages([...messages, newMsg]);
|
||||||
|
|
||||||
|
const resp = await sendMessage(chatId, input);
|
||||||
|
setMessages([...messages, newMsg, { role: "assistant", content: resp }]);
|
||||||
|
setInput("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-grow overflow-y-auto p-4">
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<MessageBubble key={i} role={m.role} content={m.content} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex p-2 border-t">
|
||||||
|
<input
|
||||||
|
className="flex-grow border rounded-lg px-3 py-2 mr-2"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Напишите сообщение..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||||
|
>
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/components/MarkdownRenderer.jsx
Normal file
14
frontend/src/components/MarkdownRenderer.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
export default function MarkdownRenderer({ children }) {
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
className="prose prose-sm max-w-none"
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/components/MessageBubble.jsx
Normal file
14
frontend/src/components/MessageBubble.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import MarkdownRenderer from "./MarkdownRenderer";
|
||||||
|
|
||||||
|
export default function MessageBubble({ role, content }) {
|
||||||
|
const isUser = role === "user";
|
||||||
|
return (
|
||||||
|
<div className={`flex ${isUser ? "justify-end" : "justify-start"} mb-2`}>
|
||||||
|
<div className={`max-w-[75%] px-4 py-2 rounded-lg text-sm
|
||||||
|
${isUser ? "bg-blue-500 text-white" : "bg-gray-200 text-black"}`}>
|
||||||
|
<MarkdownRenderer>{content}</MarkdownRenderer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/src/main.jsx
Normal file
9
frontend/src/main.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App.jsx';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
12
frontend/src/pages/ChatPage.jsx
Normal file
12
frontend/src/pages/ChatPage.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import ChatWindow from "../components/ChatWindow";
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const { chatId } = useParams();
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<ChatWindow chatId={chatId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/src/pages/Home.jsx
Normal file
20
frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import ChatList from "../components/ChatList";
|
||||||
|
import { getProfile } from "../api";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [profile, setProfile] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getProfile().then(setProfile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!profile) return <p>Загрузка...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h1 className="text-xl font-bold mb-4">Привет, {profile.username}!</h1>
|
||||||
|
<ChatList chats={profile.chats} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/pages/UserProfile.jsx
Normal file
11
frontend/src/pages/UserProfile.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function UserProfile() {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h1 className="text-xl font-bold mb-2">Профиль</h1>
|
||||||
|
<p>Имя: demo_user</p>
|
||||||
|
<p>Всего чатов: 3</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
frontend/src/styles/index.css
Normal file
3
frontend/src/styles/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://web:8000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
3
getcert.sh
Normal file
3
getcert.sh
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
docker compose up -d nginx
|
||||||
|
docker compose run --rm certbot certonly --webroot -w /var/www/certbot -d llm.byte-mate.xyz --email dr.holyblack@gmail.com --agree-tos --no-eff-email
|
||||||
12
install.sh
Normal file
12
install.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
apt-get update
|
||||||
|
apt-get install ca-certificates curl
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
echo \
|
||||||
|
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||||
|
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
|
||||||
|
tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
|
||||||
|
apt-get -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
1
nginx/certbot/.well-known/acme-challenge/test.txt
Normal file
1
nginx/certbot/.well-known/acme-challenge/test.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hello
|
||||||
52
nginx/nginx.conf
Normal file
52
nginx/nginx.conf
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name llm.byte-mate.xyz;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name llm.byte-mate.xyz;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/llm.byte-mate.xyz/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/llm.byte-mate.xyz/privkey.pem;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://web:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
sqlalchemy[asyncio]
|
||||||
|
asyncpg
|
||||||
|
python-telegram-bot
|
||||||
|
openai
|
||||||
Reference in New Issue
Block a user