This commit is contained in:
2025-09-14 17:15:48 +00:00
commit 4dee2bb54c
40 changed files with 739 additions and 0 deletions

9
.env Normal file
View 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
View 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"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

101
app/api.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

19
frontend/vite.config.js Normal file
View 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
View 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
View 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

View File

@@ -0,0 +1 @@
hello

52
nginx/nginx.conf Normal file
View 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
View File

@@ -0,0 +1,6 @@
fastapi
uvicorn[standard]
sqlalchemy[asyncio]
asyncpg
python-telegram-bot
openai

2
run.sh Normal file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
docker compose up --build -d

2
stop.sh Normal file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
docker compose down