From 4dee2bb54ca0d975954a6977ef14a7debab89a7a Mon Sep 17 00:00:00 2001 From: drholy Date: Sun, 14 Sep 2025 17:15:48 +0000 Subject: [PATCH] init --- .env | 9 ++ Dockerfile | 10 ++ app/__pycache__/api.cpython-311.pyc | Bin 0 -> 5118 bytes app/__pycache__/crud.cpython-311.pyc | Bin 0 -> 2665 bytes app/__pycache__/database.cpython-311.pyc | Bin 0 -> 644 bytes app/__pycache__/main.cpython-311.pyc | Bin 0 -> 1728 bytes app/__pycache__/models.cpython-311.pyc | Bin 0 -> 2402 bytes .../openrouter_client.cpython-311.pyc | Bin 0 -> 1559 bytes app/__pycache__/schemas.cpython-311.pyc | Bin 0 -> 2450 bytes app/api.py | 101 ++++++++++++++++++ app/crud.py | 29 +++++ app/database.py | 9 ++ app/main.py | 18 ++++ app/models.py | 33 ++++++ app/openrouter_client.py | 33 ++++++ app/schemas.py | 35 ++++++ bot.py | 32 ++++++ docker-compose.yml | 71 ++++++++++++ frontend/Dockerfile | 29 +++++ frontend/index.html | 13 +++ frontend/package.json | 24 +++++ frontend/src/App.jsx | 26 +++++ frontend/src/api.js | 21 ++++ frontend/src/components/ChatList.jsx | 24 +++++ frontend/src/components/ChatWindow.jsx | 42 ++++++++ frontend/src/components/MarkdownRenderer.jsx | 14 +++ frontend/src/components/MessageBubble.jsx | 14 +++ frontend/src/main.jsx | 9 ++ frontend/src/pages/ChatPage.jsx | 12 +++ frontend/src/pages/Home.jsx | 20 ++++ frontend/src/pages/UserProfile.jsx | 11 ++ frontend/src/styles/index.css | 3 + frontend/vite.config.js | 19 ++++ getcert.sh | 3 + install.sh | 12 +++ .../.well-known/acme-challenge/test.txt | 1 + nginx/nginx.conf | 52 +++++++++ requirements.txt | 6 ++ run.sh | 2 + stop.sh | 2 + 40 files changed, 739 insertions(+) create mode 100644 .env create mode 100644 Dockerfile create mode 100644 app/__pycache__/api.cpython-311.pyc create mode 100644 app/__pycache__/crud.cpython-311.pyc create mode 100644 app/__pycache__/database.cpython-311.pyc create mode 100644 app/__pycache__/main.cpython-311.pyc create mode 100644 app/__pycache__/models.cpython-311.pyc create mode 100644 app/__pycache__/openrouter_client.cpython-311.pyc create mode 100644 app/__pycache__/schemas.cpython-311.pyc create mode 100644 app/api.py create mode 100644 app/crud.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/openrouter_client.py create mode 100644 app/schemas.py create mode 100644 bot.py create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/components/ChatList.jsx create mode 100644 frontend/src/components/ChatWindow.jsx create mode 100644 frontend/src/components/MarkdownRenderer.jsx create mode 100644 frontend/src/components/MessageBubble.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/ChatPage.jsx create mode 100644 frontend/src/pages/Home.jsx create mode 100644 frontend/src/pages/UserProfile.jsx create mode 100644 frontend/src/styles/index.css create mode 100644 frontend/vite.config.js create mode 100644 getcert.sh create mode 100644 install.sh create mode 100644 nginx/certbot/.well-known/acme-challenge/test.txt create mode 100644 nginx/nginx.conf create mode 100644 requirements.txt create mode 100644 run.sh create mode 100644 stop.sh diff --git a/.env b/.env new file mode 100644 index 0000000..5c9d32b --- /dev/null +++ b/.env @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2239bd9 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/app/__pycache__/api.cpython-311.pyc b/app/__pycache__/api.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b6494111aa37d9cb4c42e39c553e8eb367ec4859 GIT binary patch literal 5118 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 WJWYNU4|6BB5U^F#>GoyL9{vH?z6}xp literal 0 HcmV?d00001 diff --git a/app/__pycache__/database.cpython-311.pyc b/app/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0962272c265fe7039678ac365a5d779c4697db8d GIT binary patch literal 644 zcmZuty^9k;6rbJfNA}#91@+E@V0~$l%0dt!%bnFB#6*ilSmt(~m(~48^4nA0TpX-VE=(-)|nj_deHZTS(aA!;is{g3vFS zY)oGVi%%l>hzKG|gkr^0V%1X>nX6GH);ulNJ$;#1BEvIO1jZ|K(}ZYK(f3^3m3dWx zD^Mjm(Jzc?MPTBtF-u??SfCQ~qWT}UB`|#|i02zR@@|ky4>hgdveH!ft$+f~z{hNy z1U@9kVFKLl$gBs9g=r#4Hd}=;ehrj61cC@toP{UgA7KVhxV_)$cic|zz<>7iurGdS z5DZdoyc}gC3Xes>5MLPcxdW%ekb<8kevrm-m^E#_&E7;f3I-65TX34SmRiD;$|0#H z9)9y5MoMEU*GvtGs43&dF=UXOP`N;EtY@gLCR!`!nk$5UMGHYeS9jV$N}!F0!#2Sg zmNT@5DhR4Z&u%% z*Y|S!(rOe|WAfsg_Pt+N?#y!MmYW-;(|B)Bo_{&~dB1SFGp9Rux_PZMcM5Z7X70|- M-CUE0-WHPn0s)M*?*IS* literal 0 HcmV?d00001 diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fdc0b1130971578fc2514a69d72a347af80c0195 GIT binary patch literal 1728 zcmb7EQEMAV5T3m|NhisYDyN{7o{K>uL;>3=gcMS@;651IfGG_WqKAr$Ze^diJBi&> z;y5vNW7CvUXekBfp#eWRDQSO19{PR?2Qdo;Lt1FwoVpLjPn|vM6v?!1ySFDP1QPH zZ|ps~YMhT=*}5{SaRtioCJaltc0*ZKufh!k&{aII^n+<83ss%TP(+e?i?&Ba;u^)j z&{@~3^P66S$?$~sp3J!JQs0#M7nug5rcdjp<6!EwM%(ka%-Q5&ai)RSf_KoFaZTHs z?%ZS&c@dEe#{l4W{MO*NLg}kdpB5@l3YA{rOjtOx2PE;%$>d>WCqMqR`=xt-{`T9KMkL|NQq&P7dKetR5U3oUFx*n2HDI=gz5*&t>K=q#nOT zkggPGZZ*4(Q}3Am(j`e&MS37;3_yvtvOYur_K^Ai0QLu<1FS5gU|J=98lCprZ88c* z1dXz=lCeFDE>AD{u2W42rOkyV!xdVqjo?XJHus}Lh_QLwp&wD+X?vVj74|B+6-U;Q z0+;V=a*~~r@-(OHI<51L$qbwqaPTUUK3s~YzAJ4S-x~Mrheoe>F)UtO`{0*s>0YLn zEr;21pq2Mg85XLqh*T4+2G#*4AEJN=;t`UWd}AVo@SK**@H7>^@YlP7Df33unF`Mo#0TP#iG=8=)3GJ%H)Od014udkR5nE z80|Yy4*t3C!r5T7?Qfkh0^{FY+G++c-h-(SrUIDSCE8u%j`4-v zBd0@hdQVXZJ}D$GR8U?UNOVvVcT0ClLE7k%iI7Z4#mYS{RVy!vGl{t5cEJV@_x#dU7q~(*D zibEo4w(gdTLZne*TYjbLdh?YUw53y)N6PCf^Oheat2bRj9|%$IidDX5 z)~dBS90KvCi&~_ZrfWIGG^31ZI#pb^SzR;D>vhXsSF)z*v*F?H3V5E+wMt8V)Z9x87X!pK7~2>y|Lk$9dp2k@}KH42t( zj|@Ykh)+JM(%QV`7Dc*;dzLM)Y+*7DQx8E0eUq8J%bS9-ntvjqi<_96a7_Ft5glR%o4VmxA|7fp#R+j&!vn%`>YU zk-1Kz(^xtb9vJT)7!UPQS1&cs_jVvjJbD5F>^s-Pg-NkvJ0Kvb0r$UhOK2CohX&AFkIC+J26u3TB*aFh5E8$jeMwv1tkms;z`7v-Zn{tD)= znTgbVtJFSt=j7d!&BeAkD|KaZb!(nc`n;VBQ)IF^eozzkzP>+S`v95Zo zImxbg-E6hfMLo)R*KNx#&l6{1(?2f>D&IUms!b=9d+9z}rB0lyHOR}O*n(^ipGB&& zcX?vq5#nbv%=$LY>$!3w{$`w{B@kFv^Zo|2mJkG?hdvEn|L&m=gROTDy&Y`5*QAs% vv4*yr*5Q9x@3WUuK@q+X*3izDxijV2W_?f*jMF literal 0 HcmV?d00001 diff --git a/app/__pycache__/openrouter_client.cpython-311.pyc b/app/__pycache__/openrouter_client.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d8fe3e16de365a0e2457b03d99917383960016c GIT binary patch literal 1559 zcmZuxO=ufO6rNrEtyZ>WJCUj5+Flcb5Va-z(?Uf_P>i4^j%{Rx)J}_7YbUnk)lX(u zv8~!gjssO`4mku`Xwv57P?AGp>edOw!H3>wi=bj5P+9`*O{r4|IrWWf$#v+=&bM!7 z-h2DrH*fZCW21t4K-)TeW4>qYa>w&OW0O2d7Aca%W414n`m*Qu54)F-1q@`w{ zST;C>?!nk^GuAiut%_}f2Mrhp8aBl~V+So>abW9x;f-B~;=G1t1cjSIKJhPRGVbF{ z`$QI}L!)Nv#AJ9pGI2f{j>JNfqp`E$X;ZuqJ`6rNCL_p3a0#^onyeZ7e8jj_N zYC2#owt$!8^+X{sAP=p>*!vscEaHGLC(=Q#wPam8Sx^5?ow}M9d+WmCOs^i#{#@a1 z@e03!?prp%?q1z?m0RJG_L_}iZLf(a>8Odxy3Z+e66vkAb%c5m>0IS?&*6$;$#Yl>O2!&}O1A-b=wmk7 zf&S3=f^N|#belesE`I7I85Mhjk~gPe;$88|nwHRXIjw`NEo(YXdHW=9Jd@TjtOX@a zC##=SzvAQb4If6Mll>8V2@_26j*t4I30=kc_REVn9bhojKRER6Y`;7>I2*h~F!ou@ zM!cjGIW~`F1ryD*r7{XuO`#SuVP})`1N?sbx48{^{?ts4^>6YK;GVO6S0UVgtI8>|Yif2+;73+yiT7xZup|VY3 zYBU;VrEfZ`(s<}x*mT6_Gl@9XOvgcvu(D65=?pK$ahCZoZCR2?>plx{K^mrb=-uS7 z#wrcY^>tSu4s5}d$OaBCNo+qZe|FhynZs#JAd(otS{5S1<-vK_Bl;H1C%oYHt_6u%>nACfG|Zrs$ZDm#C6@AxaFsKc z?gCft6x_Kg=wn&s36CPD!%<(xMAzq;(e8#Z598LkmYKF4Isv!stZdss*FEuZzhc`z zpE&*mQKML7b=!8rup2vQV`SUsWQ;alukWY(K58CWp!48LZ?`d~hRFaGC)Z$M_MDH#n_<`_gHd02LCzUP1}If|UkGtVdUgw9_ zITI^zUR06PfMjM@1Q=H?){rM%yoG?C%ZQKR0gsQ7I}1{J>!ZGjzg*-~09#FC{&v+1 zW3dXD(2?M&IX&V-Z;1+s%=>Z&c}Osp5k{`yOQn_^BnM}( zl7rkLJX^46T1+iKmRr;zmd15%;Pv@d1)-GnQcUotiy_a1X76ev}W#Z zC*Swo!PkG+wuWpQ<^|h+-{L`ag+NVH6#}w6Rebv1L&CH|!qi=mf>avZW2Uts-~T(klbhcmyv$f!uJ{~@Otj4BvP3_?BS;X8egU#P(QE5NhVz20r?uOI=y@ 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 \ No newline at end of file diff --git a/app/crud.py b/app/crud.py new file mode 100644 index 0000000..416e0fc --- /dev/null +++ b/app/crud.py @@ -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())) \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..fb9ed0d --- /dev/null +++ b/app/database.py @@ -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() \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f997448 --- /dev/null +++ b/app/main.py @@ -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) \ No newline at end of file diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..9b11f10 --- /dev/null +++ b/app/models.py @@ -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") \ No newline at end of file diff --git a/app/openrouter_client.py b/app/openrouter_client.py new file mode 100644 index 0000000..6c6bbd1 --- /dev/null +++ b/app/openrouter_client.py @@ -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 сейчас недоступен. Попробуйте позже." \ No newline at end of file diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..0e6d7a2 --- /dev/null +++ b/app/schemas.py @@ -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 \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..9b71944 --- /dev/null +++ b/bot.py @@ -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() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..559b414 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c9ab0a7 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..58a608a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Telegram LLM Chat + + +
+ + + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..47da453 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..dc8f9a7 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( + +
+
+ + } /> + } /> + } /> + +
+ {/* Навигация снизу */} + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..761945a --- /dev/null +++ b/frontend/src/api.js @@ -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; +} \ No newline at end of file diff --git a/frontend/src/components/ChatList.jsx b/frontend/src/components/ChatList.jsx new file mode 100644 index 0000000..324ae3e --- /dev/null +++ b/frontend/src/components/ChatList.jsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Link } from "react-router-dom"; + +export default function ChatList({ chats }) { + return ( +
+ {chats.map((c) => ( + + 💬 Чат {c} + + ))} + + ➕ Новый чат + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx new file mode 100644 index 0000000..8d31056 --- /dev/null +++ b/frontend/src/components/ChatWindow.jsx @@ -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 ( +
+
+ {messages.map((m, i) => ( + + ))} +
+
+ setInput(e.target.value)} + placeholder="Напишите сообщение..." + /> + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/MarkdownRenderer.jsx b/frontend/src/components/MarkdownRenderer.jsx new file mode 100644 index 0000000..f342d28 --- /dev/null +++ b/frontend/src/components/MarkdownRenderer.jsx @@ -0,0 +1,14 @@ +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +export default function MarkdownRenderer({ children }) { + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/MessageBubble.jsx b/frontend/src/components/MessageBubble.jsx new file mode 100644 index 0000000..4624956 --- /dev/null +++ b/frontend/src/components/MessageBubble.jsx @@ -0,0 +1,14 @@ +import React from "react"; +import MarkdownRenderer from "./MarkdownRenderer"; + +export default function MessageBubble({ role, content }) { + const isUser = role === "user"; + return ( +
+
+ {content} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..9657af5 --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + +); \ No newline at end of file diff --git a/frontend/src/pages/ChatPage.jsx b/frontend/src/pages/ChatPage.jsx new file mode 100644 index 0000000..58a25b7 --- /dev/null +++ b/frontend/src/pages/ChatPage.jsx @@ -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 ( +
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 0000000..eee9cf2 --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -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

Загрузка...

; + + return ( +
+

Привет, {profile.username}!

+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/UserProfile.jsx b/frontend/src/pages/UserProfile.jsx new file mode 100644 index 0000000..a864812 --- /dev/null +++ b/frontend/src/pages/UserProfile.jsx @@ -0,0 +1,11 @@ +import React from "react"; + +export default function UserProfile() { + return ( +
+

Профиль

+

Имя: demo_user

+

Всего чатов: 3

+
+ ); +} \ No newline at end of file diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/frontend/src/styles/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..aada98e --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } + } + } +}); \ No newline at end of file diff --git a/getcert.sh b/getcert.sh new file mode 100644 index 0000000..3af613b --- /dev/null +++ b/getcert.sh @@ -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 \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..223f96a --- /dev/null +++ b/install.sh @@ -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 \ No newline at end of file diff --git a/nginx/certbot/.well-known/acme-challenge/test.txt b/nginx/certbot/.well-known/acme-challenge/test.txt new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/nginx/certbot/.well-known/acme-challenge/test.txt @@ -0,0 +1 @@ +hello diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..5a31599 --- /dev/null +++ b/nginx/nginx.conf @@ -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; + } + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9d9c7d8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn[standard] +sqlalchemy[asyncio] +asyncpg +python-telegram-bot +openai \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..45813ee --- /dev/null +++ b/run.sh @@ -0,0 +1,2 @@ +#!/bin/bash +docker compose up --build -d \ No newline at end of file diff --git a/stop.sh b/stop.sh new file mode 100644 index 0000000..a2c900c --- /dev/null +++ b/stop.sh @@ -0,0 +1,2 @@ +#!/bin/bash +docker compose down \ No newline at end of file