init
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user