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