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

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