Initial MVP skeleton with auth, chat persistence, UI and text LLM integration

This commit is contained in:
2026-03-10 16:58:02 +00:00
commit 105b8b3db4
40 changed files with 1984 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
"use client";
import { useState, useRef, useEffect } from "react";
interface Message {
id: string;
role: string;
content: string;
created_at: string;
}
interface Chat {
id: string;
title: string;
model_alias: string;
}
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
export default function ChatView({ chat, initialMessages }: { chat: Chat, initialMessages: Message[] }) {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [input, setInput] = useState("");
const [isSending, setIsSending] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isGenerating]); // Also scroll when isGenerating changes
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isSending || isGenerating) return;
setIsSending(true);
const content = input;
setInput("");
// Eagerly show user message map
const tempUserMsg: Message = {
id: "temp-" + Date.now(),
role: "user",
content: content,
created_at: new Date().toISOString()
};
setMessages(prev => [...prev, tempUserMsg]);
setIsGenerating(true);
try {
const res = await fetch(`${API_BASE_URL}/api/chats/${chat.id}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ content, role: "user" }),
});
if (!res.ok) throw new Error("Failed to send message");
const data = await res.json();
if (!Array.isArray(data) || data.length < 2) {
throw new Error("Invalid response format from server");
}
const [userMsg, assistantMsg] = data;
// Replace the temp user msg and append the actual user and assistant messages from backend
setMessages(prev => [...prev.filter(m => m.id !== tempUserMsg.id), userMsg, assistantMsg]);
} catch (err) {
console.error(err);
alert("Failed to send message: " + err);
setInput(content); // restore input
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id)); // Remove failed message
} finally {
setIsSending(false);
setIsGenerating(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}>
{/* Dynamic Chat Header */}
<div style={{
height: 64,
padding: '0 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid var(--border-color)',
backgroundColor: 'var(--panel-bg)'
}}>
<h2 style={{ fontSize: 18, fontWeight: 600, margin: 0, color: 'var(--text-main)' }}>{chat.title}</h2>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 12px', backgroundColor: 'var(--bg-color)',
borderRadius: 20, fontSize: 13, color: 'var(--primary)', fontWeight: 500
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 16V12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 8H12.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Модель: {chat.model_alias}
</div>
</div>
{/* Messages Scroll Area */}
<div
ref={scrollRef}
style={{ flex: 1, overflowY: "auto", padding: '24px 10%', backgroundColor: 'var(--bg-color)' }}
>
{messages.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: 'var(--text-muted)' }}>
Напишите первое сообщение, чтобы начать диалог.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, paddingBottom: 24 }}>
{messages.map(msg => {
const isUser = msg.role === 'user';
return (
<div key={msg.id} style={{ display: 'flex', flexDirection: 'column', alignItems: isUser ? 'flex-end' : 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: isUser ? 'var(--primary)' : 'var(--text-main)' }}>
{isUser ? 'Вы' : 'AI Помощник'}
</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div style={{
backgroundColor: isUser ? 'var(--primary)' : 'var(--panel-bg)',
color: isUser ? '#fff' : 'var(--text-main)',
padding: '12px 16px',
borderRadius: isUser ? '16px 16px 0 16px' : '16px 16px 16px 0',
maxWidth: '85%',
lineHeight: '1.5',
fontSize: 15,
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
border: isUser ? 'none' : '1px solid var(--border-color)',
whiteSpace: 'pre-wrap'
}}>
{msg.content}
</div>
</div>
);
})}
{/* Loading Indicator for AI Response */}
{isGenerating && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-main)' }}>
AI Помощник
</span>
</div>
<div style={{
backgroundColor: 'var(--panel-bg)',
color: 'var(--text-muted)',
padding: '12px 16px',
borderRadius: '16px 16px 16px 0',
maxWidth: '85%',
lineHeight: '1.5',
fontSize: 15,
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
border: '1px solid var(--border-color)',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
<i>Печатает...</i>
</div>
</div>
)}
</div>
)}
</div>
{/* Composer */}
<div style={{
padding: '16px 10%',
backgroundColor: 'var(--panel-bg)',
borderTop: '1px solid var(--border-color)'
}}>
<form onSubmit={handleSend} style={{
display: "flex",
position: "relative",
alignItems: "center"
}}>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Спросите о чем-нибудь..."
style={{
flex: 1,
padding: '14px 110px 14px 20px',
borderRadius: 24,
border: "1px solid var(--border-color)",
backgroundColor: "var(--bg-color)",
fontSize: 15,
outline: "none",
transition: "border-color 0.2s"
}}
disabled={isSending}
onFocus={e => e.target.style.borderColor = 'var(--primary)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
/>
<div style={{ position: 'absolute', right: 8, display: 'flex', gap: 8 }}>
<button
type="button"
disabled
title="Вложения пока не поддерживаются"
style={{
width: 36, height: 36, borderRadius: '50%', backgroundColor: 'transparent',
border: 'none', cursor: 'not-allowed', color: 'var(--text-muted)',
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.4354 2.58198C20.9402 2.06941 20.1492 2.06941 19.654 2.58198L10.3168 12.2359C9.89423 12.6728 9.89423 13.3854 10.3168 13.8224C10.7394 14.2593 11.4284 14.2593 11.851 13.8224L20.5977 4.77443C20.893 4.469 21.3748 4.469 21.6701 4.77443C21.9654 5.07985 21.9654 5.57813 21.6701 5.88355L12.9234 14.9315C11.9079 15.9818 10.2524 15.9818 9.23692 14.9315C8.22144 13.8812 8.22144 12.1697 9.23692 11.1193L18.5741 1.46543C19.6701 0.33202 21.4616 0.33202 22.5576 1.46543C23.6535 2.59884 23.6535 4.45307 22.5576 5.58648L11.5332 17.0267C9.92358 18.6917 7.29124 18.6917 5.68161 17.0267C4.07198 15.3618 4.07198 12.6393 5.68161 10.9743L14.8812 1.45511C15.1765 1.14968 15.6583 1.14968 15.9536 1.45511C16.2489 1.76054 16.2489 2.25881 15.9536 2.56424L6.75399 12.0835C5.74836 13.1237 5.74836 14.823 6.75399 15.8633C7.75962 16.9035 9.40232 16.9035 10.4079 15.8633L21.4324 4.42316C21.944 3.89363 21.944 3.09151 21.4324 2.58198H21.4354Z" fill="currentColor"/>
</svg>
</button>
<button
type="submit"
disabled={isSending || !input.trim()}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 36, height: 36, borderRadius: '50%',
backgroundColor: isSending || !input.trim() ? '#e5e7eb' : 'var(--primary)',
color: isSending || !input.trim() ? '#a1a1aa' : '#fff',
border: 'none', cursor: (isSending || !input.trim()) ? 'default' : 'pointer',
transition: 'background-color 0.2s'
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginLeft: 2 }}>
<path d="M22 2L11 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M22 2L15 22L11 13L2 9L22 2Z" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import Link from "next/link";
import ChatView from "./ChatView";
import Layout from "../../../components/Layout";
async function getAuthState() {
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("ai_chat_session");
if (!sessionCookie) return null;
try {
const res = await fetch(`${baseUrl}/api/auth/me`, {
headers: {
Cookie: `${sessionCookie.name}=${sessionCookie.value}`
},
cache: "no-store",
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function getChat(id: string) {
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("ai_chat_session");
if (!sessionCookie) return null;
try {
const res = await fetch(`${baseUrl}/api/chats/${id}`, {
headers: {
Cookie: `${sessionCookie.name}=${sessionCookie.value}`
},
cache: "no-store",
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function getMessages(id: string) {
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("ai_chat_session");
if (!sessionCookie) return [];
try {
const res = await fetch(`${baseUrl}/api/chats/${id}/messages`, {
headers: {
Cookie: `${sessionCookie.name}=${sessionCookie.value}`
},
cache: "no-store",
});
if (!res.ok) return [];
return await res.json();
} catch {
return [];
}
}
async function getChats() {
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("ai_chat_session");
if (!sessionCookie) return [];
try {
const res = await fetch(`${baseUrl}/api/chats`, {
headers: { Cookie: `${sessionCookie.name}=${sessionCookie.value}` },
cache: "no-store",
});
if (!res.ok) return [];
return await res.json();
} catch {
return [];
}
}
export default async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
const auth = await getAuthState();
if (!auth) {
redirect("/login");
}
const { id } = await params;
const chat = await getChat(id);
if (!chat) {
return (
<main style={{ padding: 24, fontFamily: "Arial, sans-serif" }}>
<h1>Chat Not Found</h1>
<Link href="/">Back to Home</Link>
</main>
);
}
const messages = await getMessages(id);
const chats = await getChats();
return (
<Layout chats={chats} user={auth}>
<ChatView chat={chat} initialMessages={messages} />
</Layout>
);
}

36
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,36 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
:root {
--bg-color: #f5f6f8;
--panel-bg: #ffffff;
--text-main: #333333;
--text-muted: #888888;
--border-color: #e5e7eb;
--primary: #4a76a8;
--primary-hover: #3d628f;
--sidebar-w: 260px;
}
body, html {
margin: 0;
padding: 0;
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
height: 100vh;
box-sizing: border-box;
}
* {
box-sizing: inherit;
}
a {
text-decoration: none;
color: inherit;
}
button {
cursor: pointer;
font-family: 'Inter', sans-serif;
}

18
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import "./globals.css";
export const metadata = {
title: "AI Chat MVP",
description: "AI Chat MVP",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

268
frontend/app/login/page.tsx Normal file
View File

@@ -0,0 +1,268 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
export default function LoginPage() {
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setIsSubmitting(true);
try {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ login, password }),
});
if (!res.ok) {
let message = "Неверный логин или пароль";
try {
const data = await res.json();
if (data?.detail && typeof data.detail === "string") {
message = data.detail;
}
} catch {
// ignore malformed/non-json body
}
throw new Error(message);
}
router.push("/");
router.refresh();
} catch (err) {
setError(err?.message || "Ошибка входа");
} finally {
setIsSubmitting(false);
}
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
minHeight: "100vh",
backgroundColor: "var(--bg-color)",
alignItems: "center",
justifyContent: "center",
position: "relative",
}}
>
<div
style={{
position: "absolute",
top: 24,
left: 32,
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 20,
fontWeight: 600,
color: "var(--text-main)",
}}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2C6.477 2 2 6.477 2 12C2 17.523 6.477 22 12 22C17.523 22 22 17.523 22 12C22 6.477 17.523 2 12 2ZM12 4C16.418 4 20 7.582 20 12C20 16.418 16.418 20 12 20C7.582 20 4 16.418 4 12C4 7.582 7.582 4 12 4Z"
fill="var(--primary)"
/>
<path
d="M15 13H9C8.448 13 8 12.552 8 12C8 11.448 8.448 11 9 11H15C15.552 11 16 11.448 16 12C16 12.552 15.552 13 15 13Z"
fill="var(--primary)"
/>
</svg>
AI Chat MVP
</div>
<div
style={{
background: "#fff",
padding: "40px",
borderRadius: "16px",
boxShadow: "0 4px 20px rgba(0,0,0,0.05)",
width: "100%",
maxWidth: "420px",
textAlign: "center",
}}
>
<h1
style={{
fontSize: "24px",
fontWeight: 500,
marginBottom: "32px",
color: "var(--text-main)",
}}
>
Вход в систему
</h1>
<form
onSubmit={handleSubmit}
style={{
display: "flex",
flexDirection: "column",
gap: "20px",
textAlign: "left",
}}
>
<div>
<label
style={{
display: "block",
fontSize: "14px",
marginBottom: "8px",
color: "var(--text-main)",
}}
>
Логин
</label>
<input
value={login}
onChange={(e) => setLogin(e.target.value)}
type="text"
placeholder="admin"
required
style={{
width: "100%",
padding: "12px 16px",
borderRadius: "8px",
border: "1px solid var(--border-color)",
fontSize: "15px",
outline: "none",
transition: "border-color 0.2s",
}}
onFocus={(e) => (e.target.style.borderColor = "var(--primary)")}
onBlur={(e) =>
(e.target.style.borderColor = "var(--border-color)")
}
/>
</div>
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "8px",
}}
>
<label style={{ fontSize: "14px", color: "var(--text-main)" }}>
Пароль
</label>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
style={{
background: "none",
border: "none",
fontSize: "13px",
color: "var(--text-muted)",
display: "flex",
alignItems: "center",
gap: "4px",
padding: 0,
cursor: "pointer",
}}
>
{showPassword ? "Скрыть" : "Показать"}
</button>
</div>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type={showPassword ? "text" : "password"}
placeholder="••••••••"
required
style={{
width: "100%",
padding: "12px 16px",
borderRadius: "8px",
border: "1px solid var(--border-color)",
fontSize: "15px",
outline: "none",
transition: "border-color 0.2s",
}}
onFocus={(e) => (e.target.style.borderColor = "var(--primary)")}
onBlur={(e) =>
(e.target.style.borderColor = "var(--border-color)")
}
/>
</div>
{error ? (
<div
style={{
color: "#d32f2f",
fontSize: "14px",
textAlign: "center",
}}
>
{error}
</div>
) : null}
<button
type="submit"
disabled={isSubmitting}
style={{
padding: "14px",
marginTop: "8px",
backgroundColor: isSubmitting
? "var(--border-color)"
: "var(--primary)",
color: "#fff",
border: "none",
borderRadius: "8px",
fontSize: "15px",
fontWeight: 500,
transition: "background-color 0.2s",
cursor: isSubmitting ? "not-allowed" : "pointer",
}}
onMouseOver={(e) => {
if (!isSubmitting) {
e.currentTarget.style.backgroundColor = "var(--primary-hover)";
}
}}
onMouseOut={(e) => {
if (!isSubmitting) {
e.currentTarget.style.backgroundColor = "var(--primary)";
}
}}
>
{isSubmitting ? "Вход..." : "Войти"}
</button>
</form>
<div
style={{
marginTop: "24px",
fontSize: "14px",
color: "var(--text-muted)",
}}
>
Вход доступен только для пользователей, созданных администратором.
</div>
</div>
</div>
);
}

67
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import Layout from "../components/Layout";
async function getAuthState() {
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("ai_chat_session");
if (!sessionCookie) return null;
try {
const res = await fetch(`${baseUrl}/api/auth/me`, {
headers: {
Cookie: `${sessionCookie.name}=${sessionCookie.value}`
},
cache: "no-store"
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function getChats() {
const baseUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("ai_chat_session");
if (!sessionCookie) return [];
try {
const res = await fetch(`${baseUrl}/api/chats`, {
headers: {
Cookie: `${sessionCookie.name}=${sessionCookie.value}`
},
cache: "no-store"
});
if (!res.ok) return [];
return await res.json();
} catch {
return [];
}
}
export default async function Home() {
const auth = await getAuthState();
if (!auth) {
redirect("/login");
}
const chats = await getChats();
return (
<Layout chats={chats} user={auth}>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', color: 'var(--text-muted)' }}>
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ opacity: 0.3, marginBottom: 16 }}>
<path d="M12 2C6.477 2 2 6.477 2 12C2 17.523 6.477 22 12 22C17.523 22 22 17.523 22 12C22 6.477 17.523 2 12 2ZM12 4C16.418 4 20 7.582 20 12C20 16.418 16.418 20 12 20C7.582 20 4 16.418 4 12C4 7.582 7.582 4 12 4Z" fill="currentColor"/>
<path d="M15 13H9C8.448 13 8 12.552 8 12C8 11.448 8.448 11 9 11H15C15.552 11 16 11.448 16 12C16 12.552 15.552 13 15 13Z" fill="currentColor"/>
</svg>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-main)', marginBottom: 8 }}>Чем я могу помочь?</div>
<div style={{ fontSize: 14 }}>Выберите чат в меню слева или создайте новый.</div>
</div>
</Layout>
);
}