This commit is contained in:
2026-06-09 09:36:48 +03:00
parent 8247b7116f
commit f0fda693d8
49 changed files with 5503 additions and 1 deletions
+156
View File
@@ -0,0 +1,156 @@
.chat-layout {
display: grid;
grid-template-columns: 280px 1fr;
height: calc(100vh - 65px);
}
.chat-sidebar {
border-right: 1px solid #2a2f3a;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
background: #12151c;
}
.primary-btn {
background: #4f7cff;
color: white;
border: none;
border-radius: 8px;
padding: 0.65rem 1rem;
}
.session-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.session-list li {
display: flex;
align-items: center;
gap: 0.25rem;
border-radius: 8px;
}
.session-list li.active {
background: #1f2633;
}
.session-list li button:first-child {
flex: 1;
text-align: left;
background: transparent;
border: none;
color: inherit;
padding: 0.55rem 0.7rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.delete-btn {
background: transparent;
border: none;
color: #7d8796;
padding: 0.3rem 0.5rem;
}
.chat-main {
display: flex;
flex-direction: column;
min-height: 0;
}
.chat-empty {
margin: auto;
color: #7d8796;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.message {
max-width: 80%;
padding: 0.9rem 1rem;
border-radius: 12px;
}
.message-user {
align-self: flex-end;
background: #2b4acb;
}
.message-assistant,
.message-tool {
align-self: flex-start;
background: #1b2130;
border: 1px solid #2a3142;
}
.message-role {
font-size: 0.75rem;
color: #8b95a5;
margin-bottom: 0.35rem;
text-transform: uppercase;
}
.message-content p {
margin: 0 0 0.5rem;
}
.message-content p:last-child {
margin-bottom: 0;
}
.chat-input {
display: flex;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #2a2f3a;
}
.chat-input textarea {
flex: 1;
resize: none;
border-radius: 10px;
border: 1px solid #2f3748;
background: #12151c;
color: inherit;
padding: 0.75rem 1rem;
}
.chat-input button {
align-self: flex-end;
background: #4f7cff;
color: white;
border: none;
border-radius: 8px;
padding: 0.65rem 1.2rem;
}
.chat-input button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) {
.chat-layout {
grid-template-columns: 1fr;
}
.chat-sidebar {
display: none;
}
}
+167
View File
@@ -0,0 +1,167 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { api, ChatMessage, ChatSession } from "../api/client";
import "./Chat.css";
export default function Chat() {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeId, setActiveId] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
const loadSessions = async () => {
const data = await api.listSessions();
setSessions(data);
if (!activeId && data.length > 0) {
setActiveId(data[0].id);
}
};
const loadMessages = async (sessionId: number) => {
const data = await api.getSession(sessionId);
setMessages(data.messages);
};
useEffect(() => {
loadSessions().catch(console.error);
}, []);
useEffect(() => {
if (activeId) {
loadMessages(activeId).catch(console.error);
}
}, [activeId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streaming]);
const handleNewChat = async () => {
const session = await api.createSession();
await loadSessions();
setActiveId(session.id);
setMessages([]);
};
const handleDelete = async (id: number) => {
await api.deleteSession(id);
const data = await api.listSessions();
setSessions(data);
if (activeId === id) {
setActiveId(data[0]?.id ?? null);
setMessages([]);
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!input.trim() || !activeId || loading) return;
const text = input.trim();
setInput("");
setLoading(true);
setStreaming("");
const tempUser: ChatMessage = {
id: Date.now(),
role: "user",
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, tempUser]);
try {
for await (const chunk of api.sendMessage(activeId, text)) {
if (chunk.event === "token") {
setStreaming((prev) => prev + chunk.data.content);
}
if (chunk.event === "done") {
await loadMessages(activeId);
await loadSessions();
setStreaming("");
}
if (chunk.event === "error") {
throw new Error(chunk.data.message);
}
}
} catch (err) {
console.error(err);
setStreaming("");
} finally {
setLoading(false);
}
};
return (
<div className="chat-layout">
<aside className="chat-sidebar">
<button className="primary-btn" onClick={handleNewChat}>
+ Новый чат
</button>
<ul className="session-list">
{sessions.map((session) => (
<li key={session.id} className={activeId === session.id ? "active" : ""}>
<button onClick={() => setActiveId(session.id)}>{session.title}</button>
<button className="delete-btn" onClick={() => handleDelete(session.id)}>
×
</button>
</li>
))}
</ul>
</aside>
<section className="chat-main">
{!activeId ? (
<div className="chat-empty">Создайте новый чат, чтобы начать</div>
) : (
<>
<div className="messages">
{messages.map((msg) => (
<div key={msg.id} className={`message message-${msg.role}`}>
<div className="message-role">{msg.role}</div>
<div className="message-content">
{msg.role === "assistant" ? (
<ReactMarkdown>{msg.content}</ReactMarkdown>
) : (
msg.content
)}
</div>
</div>
))}
{streaming && (
<div className="message message-assistant">
<div className="message-role">assistant</div>
<div className="message-content">
<ReactMarkdown>{streaming}</ReactMarkdown>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
<form className="chat-input" onSubmit={handleSubmit}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение..."
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
/>
<button type="submit" disabled={loading || !input.trim()}>
{loading ? "..." : "Отправить"}
</button>
</form>
</>
)}
</section>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
.pomodoro-page {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
padding: 1.5rem;
max-width: 1100px;
margin: 0 auto;
}
.timer-card,
.history-card {
background: #151922;
border: 1px solid #2a2f3a;
border-radius: 16px;
padding: 1.5rem;
}
.timer-ring {
width: 220px;
height: 220px;
border-radius: 50%;
margin: 0 auto 1.5rem;
display: grid;
place-items: center;
}
.timer-inner {
width: 180px;
height: 180px;
border-radius: 50%;
background: #0f1115;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.timer-value {
font-size: 2.4rem;
font-weight: 700;
}
.timer-status {
color: #8b95a5;
text-transform: uppercase;
font-size: 0.8rem;
}
.task-note {
text-align: center;
color: #c5ccd6;
}
.timer-form,
.timer-controls,
.stop-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.timer-form label,
.stop-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
color: #a8b0bd;
}
.timer-form input,
.stop-form input {
border-radius: 8px;
border: 1px solid #2f3748;
background: #0f1115;
color: inherit;
padding: 0.6rem 0.8rem;
}
.timer-controls button,
.primary-btn {
background: #4f7cff;
color: white;
border: none;
border-radius: 8px;
padding: 0.65rem 1rem;
}
.error {
color: #ff7b7b;
margin-top: 0.75rem;
}
.history-card h2 {
margin-top: 0;
}
.history-card ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.history-card li {
padding-bottom: 0.9rem;
border-bottom: 1px solid #232936;
}
.history-title {
font-weight: 600;
}
.history-meta {
color: #8b95a5;
font-size: 0.85rem;
margin-top: 0.2rem;
}
.history-result {
margin-top: 0.35rem;
color: #c5ccd6;
}
@media (max-width: 900px) {
.pomodoro-page {
grid-template-columns: 1fr;
}
}
+167
View File
@@ -0,0 +1,167 @@
import { FormEvent, useEffect, useState } from "react";
import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client";
import "./Pomodoro.css";
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
export default function Pomodoro() {
const [status, setStatus] = useState<PomodoroStatus | null>(null);
const [history, setHistory] = useState<PomodoroHistoryItem[]>([]);
const [duration, setDuration] = useState(25);
const [taskNote, setTaskNote] = useState("");
const [result, setResult] = useState("");
const [completed, setCompleted] = useState(false);
const [error, setError] = useState("");
const refresh = async () => {
const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]);
setStatus(current);
setHistory(past);
};
useEffect(() => {
refresh().catch(console.error);
const timer = setInterval(() => {
api.pomodoroStatus().then(setStatus).catch(console.error);
}, 1000);
return () => clearInterval(timer);
}, []);
const handleStart = async (e: FormEvent) => {
e.preventDefault();
setError("");
try {
const data = await api.pomodoroStart(duration, taskNote);
setStatus(data);
setResult("");
setCompleted(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка запуска");
}
};
const handlePause = async () => {
setError("");
try {
setStatus(await api.pomodoroPause());
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
}
};
const handleResume = async () => {
setError("");
try {
setStatus(await api.pomodoroResume());
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
}
};
const handleStop = async () => {
setError("");
try {
await api.pomodoroStop(result, completed);
await refresh();
setTaskNote("");
setResult("");
setCompleted(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
}
};
const isActive = status?.status === "running" || status?.status === "paused";
const displaySeconds = isActive ? (status?.remaining_seconds ?? 0) : duration * 60;
const progress = status
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
: 0;
return (
<div className="pomodoro-page">
<section className="timer-card">
<div className="timer-ring" style={{ background: `conic-gradient(#4f7cff ${progress}%, #1f2633 0)` }}>
<div className="timer-inner">
<div className="timer-value">{formatTime(displaySeconds)}</div>
<div className="timer-status">{status?.status ?? "idle"}</div>
</div>
</div>
{status?.task_note && <p className="task-note">Задача: {status.task_note}</p>}
{!isActive ? (
<form className="timer-form" onSubmit={handleStart}>
<label>
Минут
<input
type="number"
min={1}
max={180}
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
/>
</label>
<label>
Над чем работаем
<input
value={taskNote}
onChange={(e) => setTaskNote(e.target.value)}
placeholder="Опишите задачу"
/>
</label>
<button type="submit" className="primary-btn">
Старт
</button>
</form>
) : (
<div className="timer-controls">
{status?.status === "running" ? (
<button onClick={handlePause}>Пауза</button>
) : (
<button onClick={handleResume}>Продолжить</button>
)}
<div className="stop-form">
<input
value={result}
onChange={(e) => setResult(e.target.value)}
placeholder="Что успели сделать?"
/>
<label>
<input
type="checkbox"
checked={completed}
onChange={(e) => setCompleted(e.target.checked)}
/>
Задача завершена
</label>
<button onClick={handleStop}>Стоп</button>
</div>
</div>
)}
{error && <p className="error">{error}</p>}
</section>
<section className="history-card">
<h2>История</h2>
<ul>
{history.map((item) => (
<li key={item.id}>
<div className="history-title">
{item.task_note || "Без описания"} {item.status}
</div>
<div className="history-meta">
{item.duration_min} мин · {item.finished_at ? new Date(item.finished_at).toLocaleString("ru-RU") : ""}
</div>
{item.result && <div className="history-result">{item.result}</div>}
</li>
))}
</ul>
</section>
</div>
);
}