added RAG, Multiuser, TG bot

This commit is contained in:
2026-06-13 20:20:56 +00:00
parent 66e1b0e29e
commit c8a9429bed
142 changed files with 19901 additions and 8790 deletions
+134 -81
View File
@@ -1,81 +1,134 @@
.app {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #2a2f3a;
background: #151922;
}
.app-header h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.app-header nav {
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header nav a {
padding: 0.45rem 0.9rem;
border-radius: 8px;
color: #a8b0bd;
}
.app-header nav a.active {
background: #2b3445;
color: #fff;
}
.app-main {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
@media (max-width: 768px) {
.app-header {
padding: 0.55rem 0.75rem;
gap: 0.5rem;
flex-shrink: 0;
}
.app-header h1 {
display: none;
}
.app-header nav {
flex: 1;
overflow-x: auto;
flex-wrap: nowrap;
gap: 0.35rem;
padding-bottom: 0.1rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.app-header nav::-webkit-scrollbar {
display: none;
}
.app-header nav a {
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
white-space: nowrap;
flex-shrink: 0;
}
}
.app {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #2a2f3a;
background: #151922;
}
.app-header h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.app-header nav {
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header nav a {
padding: 0.45rem 0.9rem;
border-radius: 8px;
color: #a8b0bd;
}
.app-header nav a.active {
background: #2b3445;
color: #fff;
}
.app-user {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-left: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid #2a2f3a;
color: #8b939f;
font-size: 0.8rem;
white-space: nowrap;
}
.app-logout {
padding: 0.35rem 0.6rem;
border: 1px solid #3a4254;
border-radius: 6px;
background: transparent;
color: #c5ccd6;
cursor: pointer;
font-size: 0.75rem;
}
.app-logout:hover {
background: #2b3445;
}
.app-main {
position: relative;
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
@media (max-width: 768px) {
.app-header {
padding: 0.55rem 0.75rem;
gap: 0.5rem;
flex-shrink: 0;
}
.app-header h1 {
display: none;
}
.app-header nav {
flex: 1;
overflow-x: auto;
flex-wrap: nowrap;
gap: 0.35rem;
padding-bottom: 0.1rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.app-header nav::-webkit-scrollbar {
display: none;
}
.app-header nav a {
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
white-space: nowrap;
flex-shrink: 0;
}
}
.app-main-chat {
overflow: hidden;
}
.route-panel {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
.route-panel-active {
visibility: visible;
pointer-events: auto;
}
.app-main:not(.app-main-chat) > .route-panel-active {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
+81
View File
@@ -0,0 +1,81 @@
.app {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #2a2f3a;
background: #151922;
}
.app-header h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.app-header nav {
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header nav a {
padding: 0.45rem 0.9rem;
border-radius: 8px;
color: #a8b0bd;
}
.app-header nav a.active {
background: #2b3445;
color: #fff;
}
.app-main {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
@media (max-width: 768px) {
.app-header {
padding: 0.55rem 0.75rem;
gap: 0.5rem;
flex-shrink: 0;
}
.app-header h1 {
display: none;
}
.app-header nav {
flex: 1;
overflow-x: auto;
flex-wrap: nowrap;
gap: 0.35rem;
padding-bottom: 0.1rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.app-header nav::-webkit-scrollbar {
display: none;
}
.app-header nav a {
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
white-space: nowrap;
flex-shrink: 0;
}
}
+192 -49
View File
@@ -1,49 +1,192 @@
import { NavLink, Route, Routes } from "react-router-dom";
import PomodoroWidget from "./components/PomodoroWidget";
import { PomodoroProvider } from "./context/PomodoroContext";
import { useVisualViewportHeight } from "./hooks/useVisualViewport";
import Character from "./pages/Character";
import Chat from "./pages/Chat";
import Fitness from "./pages/Fitness";
import Reminders from "./pages/Reminders";
import Shopping from "./pages/Shopping";
import Memory from "./pages/Memory";
import Pomodoro from "./pages/Pomodoro";
import "./App.css";
export default function App() {
useVisualViewportHeight();
return (
<PomodoroProvider>
<div className="app">
<header className="app-header">
<h1>Home AI Assistant</h1>
<nav>
<NavLink to="/" end>
Чат
</NavLink>
<NavLink to="/pomodoro">Помидоро</NavLink>
<NavLink to="/character">Персонаж</NavLink>
<NavLink to="/memory">Память</NavLink>
<NavLink to="/fitness">Фитнес</NavLink>
<NavLink to="/shopping">Покупки</NavLink>
<NavLink to="/reminders">Календарь</NavLink>
<PomodoroWidget compact />
</nav>
</header>
<main className="app-main">
<Routes>
<Route path="/" element={<Chat />} />
<Route path="/pomodoro" element={<Pomodoro />} />
<Route path="/character" element={<Character />} />
<Route path="/memory" element={<Memory />} />
<Route path="/fitness" element={<Fitness />} />
<Route path="/shopping" element={<Shopping />} />
<Route path="/reminders" element={<Reminders />} />
</Routes>
</main>
</div>
</PomodoroProvider>
);
}
import { NavLink, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import PomodoroWidget from "./components/PomodoroWidget";
import RequireAuth from "./components/RequireAuth";
import { AuthProvider, useAuth } from "./context/AuthContext";
import { PomodoroProvider } from "./context/PomodoroContext";
import { useVisualViewportHeight } from "./hooks/useVisualViewport";
import Character from "./pages/Character";
import Chat from "./pages/Chat";
import Fitness from "./pages/Fitness";
import Login from "./pages/Login";
import Reminders from "./pages/Reminders";
import Shopping from "./pages/Shopping";
import Memory from "./pages/Memory";
import Pomodoro from "./pages/Pomodoro";
import Settings from "./pages/Settings";
import "./App.css";
function AppShell() {
const location = useLocation();
const navigate = useNavigate();
const { user, logout } = useAuth();
const isChat = location.pathname === "/";
const mainClass = isChat ? "app-main app-main-chat" : "app-main";
return (
<div className="app">
<header className="app-header">
<h1>Home AI Assistant</h1>
<nav>
<NavLink to="/" end>
Чат
</NavLink>
<NavLink to="/pomodoro">Помидоро</NavLink>
<NavLink to="/character">Персонаж</NavLink>
<NavLink to="/memory">Память</NavLink>
<NavLink to="/fitness">Фитнес</NavLink>
<NavLink to="/shopping">Покупки</NavLink>
<NavLink to="/settings">Настройки</NavLink>
<NavLink to="/reminders">Календарь</NavLink>
<PomodoroWidget compact />
{user && (
<span className="app-user">
{user.display_name || user.username}
<button
type="button"
className="app-logout"
onClick={() => {
logout();
navigate("/login");
}}
>
Выйти
</button>
</span>
)}
</nav>
</header>
<main className={mainClass}>
<div className={`route-panel ${isChat ? "route-panel-active" : ""}`}>
<Chat />
</div>
<div className={`route-panel ${!isChat ? "route-panel-active" : ""}`}>
<Routes>
<Route path="/pomodoro" element={<Pomodoro />} />
<Route path="/character" element={<Character />} />
<Route path="/memory" element={<Memory />} />
<Route path="/fitness" element={<Fitness />} />
<Route path="/shopping" element={<Shopping />} />
<Route path="/reminders" element={<Reminders />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</div>
</main>
</div>
);
}
function ProtectedApp() {
return (
<RequireAuth>
<PomodoroProvider>
<AppShell />
</PomodoroProvider>
</RequireAuth>
);
}
export default function App() {
useVisualViewportHeight();
return (
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/*" element={<ProtectedApp />} />
</Routes>
</AuthProvider>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { NavLink, Route, Routes } from "react-router-dom";
import PomodoroWidget from "./components/PomodoroWidget";
import { PomodoroProvider } from "./context/PomodoroContext";
import { useVisualViewportHeight } from "./hooks/useVisualViewport";
import Character from "./pages/Character";
import Chat from "./pages/Chat";
import Fitness from "./pages/Fitness";
import Reminders from "./pages/Reminders";
import Shopping from "./pages/Shopping";
import Memory from "./pages/Memory";
import Pomodoro from "./pages/Pomodoro";
import "./App.css";
export default function App() {
useVisualViewportHeight();
return (
<PomodoroProvider>
<div className="app">
<header className="app-header">
<h1>Home AI Assistant</h1>
<nav>
<NavLink to="/" end>
Чат
</NavLink>
<NavLink to="/pomodoro">Помидоро</NavLink>
<NavLink to="/character">Персонаж</NavLink>
<NavLink to="/memory">Память</NavLink>
<NavLink to="/fitness">Фитнес</NavLink>
<NavLink to="/shopping">Покупки</NavLink>
<NavLink to="/reminders">Календарь</NavLink>
<PomodoroWidget compact />
</nav>
</header>
<main className="app-main">
<Routes>
<Route path="/" element={<Chat />} />
<Route path="/pomodoro" element={<Pomodoro />} />
<Route path="/character" element={<Character />} />
<Route path="/memory" element={<Memory />} />
<Route path="/fitness" element={<Fitness />} />
<Route path="/shopping" element={<Shopping />} />
<Route path="/reminders" element={<Reminders />} />
</Routes>
</main>
</div>
</PomodoroProvider>
);
}
+821 -555
View File
File diff suppressed because it is too large Load Diff
+555
View File
@@ -0,0 +1,555 @@
const API_BASE = import.meta.env.VITE_API_URL ?? "";
export interface ChatSession {
id: number;
title: string;
created_at: string;
updated_at: string;
}
export interface ChatMessage {
id: number;
role: string;
content: string;
tool_calls_json?: string | null;
created_at: string;
}
export interface SessionDetail extends ChatSession {
messages: ChatMessage[];
}
export interface PomodoroCycle {
completed_work_sessions: number;
sessions_until_long_break: number;
task_note: string;
work_duration_min: number;
short_break_min: number;
long_break_min: number;
auto_advance: boolean;
chat_notify_seq: number;
}
export interface PomodoroStatus {
status: string;
phase: string;
duration_min: number;
task_note: string;
elapsed_seconds: number;
remaining_seconds: number;
session_id: number | null;
started_at?: string | null;
finished_at?: string | null;
cycle: PomodoroCycle;
}
export interface CharacterCardData {
name: string;
description: string;
personality: string;
scenario: string;
first_mes: string;
mes_example: string;
system_prompt: string;
post_history_instructions: string;
tags: string[];
creator: string;
creator_notes: string;
alternate_greetings: string[];
character_version: string;
}
export interface CharacterCardV2 {
spec: string;
spec_version: string;
data: CharacterCardData;
}
export interface UserProfile {
name?: string;
age?: string;
timezone?: string;
language?: string;
notes?: string;
}
export interface MemoryFact {
id: number;
category: string;
content: string;
importance: number;
source?: string;
updated_at?: string | null;
}
export interface FitnessComputed {
bmr: number;
tdee: number;
bmi: number;
}
export interface FitnessProfile {
sex?: string;
age?: number;
height_cm?: number;
weight_kg?: number;
activity_level?: string;
goal?: string;
target_weight_kg?: number | null;
weekly_workouts?: number;
calorie_target?: number;
protein_g?: number;
fat_g?: number;
carbs_g?: number;
water_l?: number;
computed?: FitnessComputed;
}
export interface FoodLogItem {
id: number;
meal_type: string;
description: string;
calories: number;
protein_g: number;
fat_g: number;
carbs_g: number;
estimated: boolean;
logged_at?: string;
}
export interface WaterLogItem {
id: number;
amount_ml: number;
logged_at?: string;
}
export interface WorkoutLogItem {
id: number;
title: string;
notes?: string;
duration_min?: number | null;
exercises?: unknown[];
logged_at?: string;
}
export interface FitnessDailySummary {
date: string;
totals: {
calories: number;
protein_g: number;
fat_g: number;
carbs_g: number;
water_ml: number;
};
targets: {
calories: number;
protein_g: number;
fat_g: number;
carbs_g: number;
water_ml: number;
};
meals: FoodLogItem[];
water: WaterLogItem[];
workouts: WorkoutLogItem[];
}
export interface BodyMetric {
id: number;
weight_kg: number;
recorded_at?: string;
}
export interface FitnessReminder {
id: number;
kind: string;
hour: number;
minute: number;
interval_hours?: number | null;
enabled: boolean;
}
export interface FitnessDayOverview {
date: string;
has_data: boolean;
totals: FitnessDailySummary["totals"];
targets: FitnessDailySummary["targets"];
meal_count: number;
workout_count: number;
}
export interface FitnessHistory {
start_date: string;
end_date: string;
days: number;
summaries: FitnessDayOverview[];
}
export interface FitnessSnapshot {
profile: FitnessProfile | null;
today: FitnessDailySummary;
history?: FitnessHistory;
body_metrics: BodyMetric[];
reminders: FitnessReminder[];
}
export interface MemorySnapshot {
profile: UserProfile;
facts: MemoryFact[];
session_summary?: string;
total_facts: number;
}
export interface PomodoroHistoryItem {
id: number;
status: string;
phase: string;
duration_min: number;
task_note: string;
result: string | null;
completed: boolean;
elapsed_seconds: number;
finished_at: string | null;
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, options);
if (!response.ok) {
const text = await response.text();
throw new Error(text || response.statusText);
}
return response.json() as Promise<T>;
}
export const api = {
health: () => request<{ status: string }>("/api/v1/health"),
listSessions: () => request<ChatSession[]>("/api/v1/chat/sessions"),
createSession: (title = "Новый чат") =>
request<ChatSession>("/api/v1/chat/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
}),
getSession: (id: number) => request<SessionDetail>(`/api/v1/chat/sessions/${id}`),
deleteSession: (id: number) =>
request<{ ok: boolean }>(`/api/v1/chat/sessions/${id}`, { method: "DELETE" }),
sendMessage: async function* (sessionId: number, content: string) {
const response = await fetch(`${API_BASE}/api/v1/chat/sessions/${sessionId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!response.ok || !response.body) {
const detail = await response.text().catch(() => "");
throw new Error(detail || `Ошибка отправки (${response.status})`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const flushParts = function* (parts: string[]) {
for (const part of parts) {
if (!part.trim()) continue;
const lines = part.split("\n");
let event = "message";
let data = "";
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
if (line.startsWith("data: ")) data = line.slice(6);
}
if (data) {
yield { event, data: JSON.parse(data) };
}
}
};
try {
while (true) {
let done = false;
let value: Uint8Array | undefined;
try {
({ done, value } = await reader.read());
} catch {
throw new Error(
"Соединение прервалось (таймаут прокси). Обновите чат — ответ мог уже сохраниться.",
);
}
if (value) {
buffer += decoder.decode(value, { stream: !done });
}
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? "";
yield* flushParts(parts);
if (done) {
if (buffer.trim()) {
yield* flushParts([buffer]);
}
break;
}
}
} finally {
reader.releaseLock();
}
},
pomodoroStatus: () => request<PomodoroStatus>("/api/v1/pomodoro/status"),
pomodoroStart: (duration_min: number, task_note: string) =>
request<PomodoroStatus>("/api/v1/pomodoro/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ duration_min, task_note }),
}),
pomodoroPause: () =>
request<PomodoroStatus>("/api/v1/pomodoro/pause", { method: "POST" }),
pomodoroResume: () =>
request<PomodoroStatus>("/api/v1/pomodoro/resume", { method: "POST" }),
pomodoroStop: (result: string, completed: boolean) =>
request<PomodoroStatus>("/api/v1/pomodoro/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result, completed }),
}),
pomodoroHistory: () => request<PomodoroHistoryItem[]>("/api/v1/pomodoro/history"),
pomodoroResetCycle: (clear_task = false) =>
request<PomodoroStatus>(`/api/v1/pomodoro/cycle/reset?clear_task=${clear_task}`, {
method: "POST",
}),
pomodoroSkip: () =>
request<PomodoroStatus>("/api/v1/pomodoro/skip", { method: "POST" }),
pomodoroStartShortBreak: (duration_min?: number) =>
request<PomodoroStatus>(
`/api/v1/pomodoro/break/short/start${duration_min ? `?duration_min=${duration_min}` : ""}`,
{ method: "POST" }
),
pomodoroStartLongBreak: (duration_min?: number) =>
request<PomodoroStatus>(
`/api/v1/pomodoro/break/long/start${duration_min ? `?duration_min=${duration_min}` : ""}`,
{ method: "POST" }
),
getCharacter: () => request<CharacterCardV2>("/api/v1/character"),
saveCharacter: (card: CharacterCardV2) =>
request<CharacterCardV2>("/api/v1/character", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(card),
}),
getMemorySnapshot: (sessionId?: number) =>
request<MemorySnapshot>(
`/api/v1/memory${sessionId ? `?session_id=${sessionId}` : ""}`
),
updateProfile: (updates: UserProfile) =>
request<{ ok: boolean; profile: UserProfile }>("/api/v1/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }),
}),
createMemoryFact: (payload: {
content: string;
category?: string;
importance?: number;
session_id?: number;
}) =>
request<{ ok: boolean; memory_id: number }>("/api/v1/memory/facts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
forgetMemoryFact: (id: number) =>
request<{ ok: boolean }>(`/api/v1/memory/facts/${id}`, { method: "DELETE" }),
getFitnessSnapshot: () => request<FitnessSnapshot>("/api/v1/fitness"),
getFitnessSummary: (day?: string) =>
request<FitnessDailySummary>(
`/api/v1/fitness/summary${day ? `?day=${encodeURIComponent(day)}` : ""}`
),
getFitnessHistory: (days = 7, end?: string) => {
const params = new URLSearchParams({ days: String(days) });
if (end) params.set("end", end);
return request<FitnessHistory>(`/api/v1/fitness/history?${params}`);
},
updateFitnessProfile: (updates: Partial<FitnessProfile>) =>
request<{ ok: boolean; profile: FitnessProfile }>("/api/v1/fitness/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
}),
deleteFitnessMeal: (id: number) =>
request<{ ok: boolean }>(`/api/v1/fitness/meals/${id}`, { method: "DELETE" }),
deleteFitnessWater: (id: number) =>
request<{ ok: boolean }>(`/api/v1/fitness/water/${id}`, { method: "DELETE" }),
updateFitnessReminder: (
kind: string,
updates: { enabled?: boolean; hour?: number; minute?: number; interval_hours?: number }
) =>
request<{ ok: boolean }>(`/api/v1/fitness/reminders/${kind}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
}),
getShoppingSnapshot: () => request<ShoppingSnapshot>("/api/v1/shopping"),
createShoppingList: (name: string) =>
request<{ ok: boolean; list: ShoppingList }>("/api/v1/shopping/lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}),
renameShoppingList: (listId: number, name: string) =>
request<{ ok: boolean; list: ShoppingList }>(`/api/v1/shopping/lists/${listId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}),
deleteShoppingList: (listId: number) =>
request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}`, { method: "DELETE" }),
addShoppingItems: (payload: {
list_id?: number;
list_name?: string;
items: { text: string; quantity?: number; unit?: string }[];
}) =>
request<{ ok: boolean }>("/api/v1/shopping/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
setShoppingItemChecked: (itemId: number, checked: boolean) =>
request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ checked }),
}),
removeShoppingItem: (itemId: number) =>
request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, { method: "DELETE" }),
clearShoppingChecked: (listId: number) =>
request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}/clear-checked`, {
method: "POST",
}),
getRemindersSnapshot: () => request<RemindersSnapshot>("/api/v1/reminders"),
getRemindersCalendar: (year: number, month: number) =>
request<RemindersCalendar>(`/api/v1/reminders/calendar?year=${year}&month=${month}`),
createReminder: (payload: ReminderCreatePayload) =>
request<{ ok: boolean; reminder: Reminder }>("/api/v1/reminders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
updateReminder: (id: number, payload: Partial<ReminderCreatePayload> & { enabled?: boolean }) =>
request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
deleteReminder: (id: number) =>
request<{ ok: boolean }>(`/api/v1/reminders/${id}`, { method: "DELETE" }),
completeReminder: (id: number) =>
request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}/complete`, {
method: "POST",
}),
};
export interface ShoppingListItem {
id: number;
list_id: number;
text: string;
quantity: number | null;
unit: string;
checked: boolean;
sort_order: number;
}
export interface ShoppingList {
id: number;
name: string;
sort_order: number;
item_count: number;
unchecked_count: number;
items?: ShoppingListItem[];
}
export interface ShoppingSnapshot {
lists: ShoppingList[];
list_count: number;
total_items: number;
unchecked_items: number;
}
export interface Reminder {
id: number;
title: string;
notes: string;
due_at: string;
due_at_local: string;
all_day: boolean;
recurrence: string;
enabled: boolean;
completed_at: string | null;
timezone: string;
created_at: string | null;
}
export interface RemindersSnapshot {
notify_seq: number;
upcoming: Reminder[];
upcoming_count: number;
timezone: string;
}
export interface RemindersCalendar {
year: number;
month: number;
timezone: string;
reminders: Reminder[];
}
export interface ReminderCreatePayload {
title: string;
due_at: string;
notes?: string;
all_day?: boolean;
recurrence?: string;
}
+101
View File
@@ -0,0 +1,101 @@
import { memo, useMemo } from "react";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import { ChatMessage } from "../api/client";
const API_BASE = import.meta.env.VITE_API_URL ?? "";
function resolveMediaUrl(src: string | undefined): string | undefined {
if (!src) return src;
if (/^https?:\/\//i.test(src) || src.startsWith("data:")) {
return src;
}
if (src.startsWith("/")) {
return `${API_BASE}${src}`;
}
return src;
}
function createMarkdownComponents(onContentResize?: () => void): Components {
return {
img: ({ src, alt }) => (
<img
src={resolveMediaUrl(src)}
alt={alt ?? ""}
loading="lazy"
decoding="async"
onLoad={onContentResize}
onError={onContentResize}
/>
),
};
}
function noticeLabel(content: string): string {
if (content.startsWith("⏱")) return "таймер";
if (content.startsWith("📋")) return "задачи";
if (content.startsWith("🔀")) return "git";
if (content.startsWith("🧠")) return "память";
if (content.startsWith("💪")) return "фитнес";
if (content.startsWith("🌤")) return "погода";
if (content.startsWith("🎨")) return "картинка";
if (content.startsWith("⚠️")) return "сервер";
if (content.startsWith("🛒")) return "покупки";
if (content.startsWith("📅")) return "напоминание";
return "система";
}
function roleLabel(role: string, content = ""): string {
if (role === "notice") return noticeLabel(content);
if (role === "character") return "assistant";
if (role === "user") return "вы";
return role;
}
function messageClassName(role: string): string {
if (role === "character") return "assistant";
return role;
}
function usesMarkdown(role: string): boolean {
return role === "assistant" || role === "notice" || role === "character";
}
interface MessageBubbleProps {
msg: ChatMessage;
onContentResize?: () => void;
}
function MessageBubbleInner({ msg, onContentResize }: MessageBubbleProps) {
const markdownComponents = useMemo(
() => createMarkdownComponents(onContentResize),
[onContentResize],
);
const markdown = useMemo(
() =>
usesMarkdown(msg.role) ? (
<ReactMarkdown components={markdownComponents}>{msg.content}</ReactMarkdown>
) : (
msg.content
),
[msg.role, msg.content, markdownComponents],
);
return (
<div className={`message message-${messageClassName(msg.role)}`}>
<div className="message-role">{roleLabel(msg.role, msg.content)}</div>
<div className="message-content">{markdown}</div>
</div>
);
}
const MessageBubble = memo(MessageBubbleInner, (prev, next) => {
return (
prev.msg.id === next.msg.id &&
prev.msg.content === next.msg.content &&
prev.onContentResize === next.onContentResize
);
});
export default MessageBubble;
+123
View File
@@ -0,0 +1,123 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import { forwardRef, RefObject, useCallback, useImperativeHandle, useLayoutEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { ChatMessage } from "../api/client";
import MessageBubble from "./MessageBubble";
const VIRTUALIZE_THRESHOLD = 80;
const MESSAGE_GAP_PX = 16;
const IMAGE_MESSAGE_ESTIMATE_PX = 420;
function estimateMessageHeight(msg: ChatMessage): number {
if (/!\[[^\]]*\]\([^)]+\)/.test(msg.content)) {
return IMAGE_MESSAGE_ESTIMATE_PX;
}
const lines = Math.max(1, Math.ceil(msg.content.length / 48));
const base = msg.role === "notice" ? 72 : 96;
return Math.min(480, base + lines * 22);
}
export interface MessageListHandle {
scrollToBottom: () => void;
}
interface MessageListProps {
messages: ChatMessage[];
containerRef: RefObject<HTMLDivElement>;
}
const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
{ messages, containerRef },
ref,
) {
const location = useLocation();
const isChatVisible = location.pathname === "/";
const wasChatVisibleRef = useRef(isChatVisible);
const useVirtual = messages.length > VIRTUALIZE_THRESHOLD;
const virtualizer = useVirtualizer({
count: useVirtual ? messages.length : 0,
getScrollElement: () => containerRef.current,
estimateSize: (index) => estimateMessageHeight(messages[index]),
overscan: 8,
enabled: useVirtual,
gap: MESSAGE_GAP_PX,
});
const remeasure = useCallback(() => {
if (!useVirtual) return;
virtualizer.measure();
requestAnimationFrame(() => virtualizer.measure());
}, [useVirtual, virtualizer]);
useImperativeHandle(
ref,
() => ({
scrollToBottom: () => {
if (useVirtual && messages.length > 0) {
virtualizer.scrollToIndex(messages.length - 1, { align: "end" });
return;
}
containerRef.current?.scrollTo({
top: containerRef.current.scrollHeight,
behavior: "auto",
});
},
}),
[containerRef, messages.length, useVirtual, virtualizer],
);
useLayoutEffect(() => {
const becameVisible = isChatVisible && !wasChatVisibleRef.current;
wasChatVisibleRef.current = isChatVisible;
if (!useVirtual || !isChatVisible) return;
if (becameVisible) {
remeasure();
}
}, [isChatVisible, remeasure, useVirtual, messages.length]);
if (!useVirtual) {
return (
<>
{messages.map((msg) => (
<MessageBubble key={msg.id} msg={msg} onContentResize={undefined} />
))}
</>
);
}
const virtualItems = virtualizer.getVirtualItems();
return (
<div
className="messages-virtual-spacer"
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
>
{virtualItems.map((virtual) => {
const msg = messages[virtual.index];
return (
<div
key={msg.id}
data-index={virtual.index}
ref={virtualizer.measureElement}
className="messages-virtual-item"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtual.start}px)`,
}}
>
<MessageBubble msg={msg} onContentResize={remeasure} />
</div>
);
})}
</div>
);
});
export default MessageList;
+23
View File
@@ -0,0 +1,23 @@
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
export default function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="login-page">
<div className="login-card">
<p>Загрузка</p>
</div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return children;
}
+75
View File
@@ -0,0 +1,75 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api, AuthUser, clearAuthToken, getAuthToken, setAuthToken } from "../api/client";
interface AuthContextValue {
user: AuthUser | null;
loading: boolean;
login: (token: string) => Promise<void>;
logout: () => void;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
const token = getAuthToken();
if (!token) {
setUser(null);
return;
}
const res = await api.me();
setUser(res.user);
}, []);
useEffect(() => {
const token = getAuthToken();
if (!token) {
setLoading(false);
return;
}
refresh()
.catch(() => {
clearAuthToken();
setUser(null);
})
.finally(() => setLoading(false));
}, [refresh]);
const login = useCallback(async (token: string) => {
const res = await api.login(token.trim());
setAuthToken(res.token);
setUser(res.user);
}, []);
const logout = useCallback(() => {
clearAuthToken();
setUser(null);
}, []);
const value = useMemo(
() => ({ user, loading, login, logout, refresh }),
[user, loading, login, logout, refresh],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within AuthProvider");
}
return ctx;
}
+47
View File
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useRef } from "react";
import { api } from "../api/client";
const NOTIFY_POLL_MS = 30000;
export function usePomodoroRefresh() {
return useCallback(async () => {
await api.pomodoroStatus();
}, []);
}
export function usePomodoroNotify(onNotify: (seq: number) => void) {
const handlerRef = useRef(onNotify);
handlerRef.current = onNotify;
const lastSeqRef = useRef(0);
const readyRef = useRef(false);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const data = await api.pomodoroStatus();
if (cancelled) return;
const seq = data.cycle?.chat_notify_seq ?? 0;
if (!readyRef.current) {
readyRef.current = true;
lastSeqRef.current = seq;
return;
}
if (seq > lastSeqRef.current) {
lastSeqRef.current = seq;
handlerRef.current(seq);
}
} catch {
// ignore polling errors
}
};
poll().catch(console.error);
const id = setInterval(() => poll().catch(console.error), NOTIFY_POLL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
}
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useRef, useState } from "react";
const STREAM_THROTTLE_MS = 80;
export function useThrottledStreaming() {
const [streaming, setStreamingState] = useState("");
const pendingRef = useRef("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const flush = useCallback(() => {
timerRef.current = null;
setStreamingState(pendingRef.current);
}, []);
const setStreaming = useCallback(
(text: string) => {
pendingRef.current = text;
if (timerRef.current === null) {
timerRef.current = setTimeout(flush, STREAM_THROTTLE_MS);
}
},
[flush],
);
const resetStreaming = useCallback(() => {
pendingRef.current = "";
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setStreamingState("");
}, []);
const flushStreaming = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setStreamingState(pendingRef.current);
}, []);
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
};
}, []);
return { streaming, setStreaming, resetStreaming, flushStreaming };
}
+417
View File
@@ -0,0 +1,417 @@
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { api, ChatMessage, ChatSession } from "../api/client";
import PomodoroWidget from "../components/PomodoroWidget";
import { usePomodoro } from "../context/PomodoroContext";
import "./Chat.css";
function shouldShowMessage(msg: ChatMessage): boolean {
if (msg.role === "tool") return false;
if (msg.role === "assistant" && msg.tool_calls_json) return false;
if (msg.role === "assistant" && !msg.content.trim()) return false;
return true;
}
function noticeLabel(content: string): string {
if (content.startsWith("⏱")) return "таймер";
if (content.startsWith("📋")) return "задачи";
if (content.startsWith("🔀")) return "git";
if (content.startsWith("🧠")) return "память";
if (content.startsWith("💪")) return "фитнес";
if (content.startsWith("🌤")) return "погода";
if (content.startsWith("🎨")) return "картинка";
if (content.startsWith("⚠️")) return "сервер";
if (content.startsWith("🛒")) return "покупки";
if (content.startsWith("📅")) return "напоминание";
return "система";
}
function roleLabel(role: string, content = ""): string {
if (role === "notice") return noticeLabel(content);
if (role === "character") return "assistant";
if (role === "user") return "вы";
return role;
}
function messageClassName(role: string): string {
if (role === "character") return "assistant";
return role;
}
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 [pendingPhase, setPendingPhase] = useState<
"thinking" | "preparing" | "generating" | "tools"
>("thinking");
const [chatError, setChatError] = useState<string | null>(null);
const tempMessageId = useRef(0);
const messagesRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollRafRef = useRef<number | null>(null);
const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro();
const [lastNotifySeq, setLastNotifySeq] = useState(0);
const lastReminderNotifySeq = useRef(0);
const remindersNotifyReady = useRef(false);
const pendingHistoryReload = useRef(false);
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]);
const scrollToBottom = useCallback((smooth = false) => {
const container = messagesRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
}, []);
useEffect(() => {
if (scrollRafRef.current !== null) {
cancelAnimationFrame(scrollRafRef.current);
}
scrollRafRef.current = requestAnimationFrame(() => {
scrollToBottom(!streaming);
scrollRafRef.current = null;
});
return () => {
if (scrollRafRef.current !== null) {
cancelAnimationFrame(scrollRafRef.current);
}
};
}, [messages, streaming, loading, chatError, scrollToBottom]);
const dismissKeyboard = useCallback(() => {
inputRef.current?.blur();
}, []);
const waitingForStream = loading && !streaming;
const nextTempId = () => {
tempMessageId.current -= 1;
return tempMessageId.current;
};
const appendNotice = useCallback((content: string) => {
setMessages((prev) => [
...prev,
{
id: nextTempId(),
role: "notice",
content,
created_at: new Date().toISOString(),
},
]);
}, []);
const pendingLabel =
pendingPhase === "tools"
? "Выполняю команды…"
: pendingPhase === "preparing"
? "Собираю контекст…"
: pendingPhase === "generating"
? "Генерирую ответ…"
: "Думаю…";
useEffect(() => {
const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0;
if (seq > lastNotifySeq) {
setLastNotifySeq(seq);
refreshPomodoro().catch(console.error);
if (activeId) {
if (loading) {
pendingHistoryReload.current = true;
} else {
loadMessages(activeId).catch(console.error);
}
}
}
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro, loading]);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const data = await api.getRemindersSnapshot();
if (cancelled) return;
if (!remindersNotifyReady.current) {
remindersNotifyReady.current = true;
lastReminderNotifySeq.current = data.notify_seq;
return;
}
if (data.notify_seq > lastReminderNotifySeq.current) {
lastReminderNotifySeq.current = data.notify_seq;
if (activeId) {
if (loading) {
pendingHistoryReload.current = true;
} else {
loadMessages(activeId).catch(console.error);
}
}
}
} catch {
// ignore polling errors
}
};
poll().catch(console.error);
const id = setInterval(() => poll().catch(console.error), 60000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [activeId, loading]);
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("");
dismissKeyboard();
setLoading(true);
setStreaming("");
setPendingPhase("thinking");
setChatError(null);
const tempUser: ChatMessage = {
id: nextTempId(),
role: "user",
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, tempUser]);
try {
let assistantText = "";
for await (const chunk of api.sendMessage(activeId, text)) {
if (chunk.event === "status") {
if (chunk.data.phase === "preparing") {
setPendingPhase("preparing");
}
if (chunk.data.phase === "generating") {
setPendingPhase("generating");
}
if (chunk.data.phase === "tools") {
setPendingPhase("tools");
assistantText = "";
setStreaming("");
}
}
if (chunk.event === "token") {
assistantText += chunk.data.content;
setPendingPhase("generating");
setStreaming(assistantText);
}
if (chunk.event === "notice") {
appendNotice(chunk.data.content);
if (String(chunk.data.content).startsWith("⏱")) {
refreshPomodoro();
}
}
if (chunk.event === "pomodoro") {
refreshPomodoro();
}
if (chunk.event === "done") {
const tail = assistantText.trim();
if (tail) {
setMessages((prev) => [
...prev,
{
id: nextTempId(),
role: "assistant",
content: tail,
created_at: new Date().toISOString(),
},
]);
}
setStreaming("");
setChatError(null);
await loadMessages(activeId);
await loadSessions();
}
if (chunk.event === "error") {
throw new Error(chunk.data.message);
}
}
} catch (err) {
console.error(err);
const message = err instanceof Error ? err.message : "Ошибка чата";
setChatError(message);
setStreaming("");
if (activeId) {
await loadMessages(activeId);
}
} finally {
setLoading(false);
if (pendingHistoryReload.current && activeId) {
pendingHistoryReload.current = false;
loadMessages(activeId).catch(console.error);
}
}
};
const visibleMessages = messages.filter(shouldShowMessage);
return (
<div className="chat-layout">
<aside className="chat-sidebar">
<button className="primary-btn" onClick={handleNewChat}>
+ Новый чат
</button>
<PomodoroWidget />
<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="chat-mobile-bar">
<select
className="chat-session-select"
value={activeId}
onChange={(e) => setActiveId(Number(e.target.value))}
aria-label="Выбор чата"
>
{sessions.map((session) => (
<option key={session.id} value={session.id}>
{session.title}
</option>
))}
</select>
<button type="button" className="chat-mobile-new" onClick={handleNewChat}>
+ Новый
</button>
</div>
<div
className="messages"
ref={messagesRef}
onClick={dismissKeyboard}
>
{visibleMessages.map((msg) => (
<div key={msg.id} className={`message message-${messageClassName(msg.role)}`}>
<div className="message-role">{roleLabel(msg.role, msg.content)}</div>
<div className="message-content">
{msg.role === "assistant" || msg.role === "notice" || msg.role === "character" ? (
<ReactMarkdown>{msg.content}</ReactMarkdown>
) : (
msg.content
)}
</div>
</div>
))}
{waitingForStream && (
<div className="message message-assistant message-pending" aria-live="polite">
<div className="message-role">assistant</div>
<div className="message-content message-pending-content">
<span className="typing-indicator" aria-hidden="true">
<span />
<span />
<span />
</span>
<span className="typing-label">{pendingLabel}</span>
</div>
</div>
)}
{streaming && (
<div className="message message-assistant">
<div className="message-role">assistant</div>
<div className="message-content">
<ReactMarkdown>{streaming}</ReactMarkdown>
</div>
</div>
)}
{chatError && (
<div className="message message-error" role="alert">
<div className="message-role">ошибка</div>
<div className="message-content">{chatError}</div>
</div>
)}
<div className="messages-bottom-anchor" aria-hidden="true" />
</div>
<form className="chat-input" onSubmit={handleSubmit}>
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение..."
rows={2}
enterKeyHint="send"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
/>
<button type="submit" disabled={loading || !input.trim()}>
{loading ? "..." : "Отправить"}
</button>
</form>
</>
)}
</section>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
/* Chat performance & mobile image fixes — imported after Chat.css */
.message {
min-width: 0;
}
.message-content {
min-width: 0;
overflow-wrap: anywhere;
}
.message-content img {
max-width: 100%;
width: 100%;
height: auto;
display: block;
border-radius: 8px;
object-fit: contain;
}
.message-notice .message-content img {
max-height: min(70vh, 520px);
margin: 0.35rem auto 0;
}
.message-notice .message-content p:empty {
display: none;
}
.streaming-text {
white-space: pre-wrap;
word-break: break-word;
}
.messages-virtual-spacer {
width: 100%;
flex-shrink: 0;
}
.messages-virtual-item {
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
}
.messages {
overflow-anchor: none;
}
.messages-bottom-anchor {
overflow-anchor: auto;
}
.messages-load-older-hint {
text-align: center;
font-size: 0.8rem;
color: var(--text-muted, #888);
padding: 0.5rem 0 0.75rem;
flex-shrink: 0;
}
File diff suppressed because it is too large Load Diff
+373 -262
View File
@@ -1,262 +1,373 @@
.fitness-page {
max-width: 900px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.fitness-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 1rem;
}
.fitness-header h2 {
margin: 0 0 0.25rem;
}
.fitness-header p {
margin: 0;
color: #8b95a8;
font-size: 0.9rem;
}
.fitness-header-actions {
display: flex;
gap: 0.5rem;
}
.fitness-message {
padding: 0.6rem 0.9rem;
background: #1a2433;
border: 1px solid #2a3f5a;
border-radius: 8px;
}
.fitness-day-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.fitness-day-nav button {
width: 2rem;
height: 2rem;
border-radius: 8px;
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
cursor: pointer;
}
.fitness-day-nav button:disabled {
opacity: 0.4;
cursor: default;
}
.fitness-day-title {
text-align: center;
flex: 1;
}
.fitness-day-title h3 {
margin: 0 0 0.25rem;
}
.fitness-day-title input[type="date"] {
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
border-radius: 6px;
padding: 0.2rem 0.4rem;
font-size: 0.85rem;
}
.fitness-week-strip {
display: flex;
gap: 0.35rem;
overflow-x: auto;
margin-bottom: 1rem;
padding-bottom: 0.25rem;
}
.fitness-week-day {
flex: 0 0 auto;
min-width: 3.2rem;
border: 1px solid #2f3748;
border-radius: 8px;
background: #1a1f2b;
color: inherit;
padding: 0.35rem 0.4rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.fitness-week-day.active {
border-color: #4a7cff;
background: #1c2740;
}
.fitness-week-day.empty .fitness-week-kcal {
color: #6b7280;
}
.fitness-week-date {
font-size: 0.75rem;
color: #8b95a8;
}
.fitness-week-kcal {
font-size: 0.8rem;
font-weight: 600;
}
.fitness-section {
background: #151922;
border: 1px solid #2a2f3a;
border-radius: 10px;
padding: 1rem 1.25rem;
}
.fitness-section h3 {
margin: 0 0 0.75rem;
}
.fitness-section h4 {
margin: 0.75rem 0 0.35rem;
font-size: 0.85rem;
color: #8b95a8;
}
.fitness-progress-grid {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.fitness-progress-header {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
margin-bottom: 0.2rem;
}
.fitness-progress-track {
height: 8px;
background: #0f1218;
border-radius: 4px;
overflow: hidden;
}
.fitness-progress-fill {
height: 100%;
background: #3d7a5a;
border-radius: 4px;
}
.fitness-profile-form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.fitness-profile-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #8b95a8;
}
.fitness-profile-form input,
.fitness-profile-form select {
padding: 0.45rem 0.6rem;
border-radius: 6px;
border: 1px solid #2a2f3a;
background: #0f1218;
color: #e8ecf1;
}
.fitness-profile-form button {
grid-column: 1 / -1;
justify-self: start;
}
.fitness-computed {
margin: 0.75rem 0 0;
font-size: 0.9rem;
color: #8b95a8;
}
.fitness-log-list {
list-style: none;
margin: 0;
padding: 0;
}
.fitness-log-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.35rem 0;
border-bottom: 1px solid #1e2430;
font-size: 0.9rem;
}
.fitness-log-list button {
padding: 0 0.4rem;
font-size: 1rem;
line-height: 1;
}
.fitness-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.fitness-table th,
.fitness-table td {
text-align: left;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid #1e2430;
}
.fitness-reminders {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fitness-reminders li {
display: flex;
gap: 1rem;
align-items: center;
}
.fitness-empty {
color: #8b95a8;
font-style: italic;
}
.fitness-raw {
background: #0f1218;
border: 1px solid #2a2f3a;
border-radius: 10px;
padding: 1rem;
overflow: auto;
font-size: 0.82rem;
max-height: 70vh;
}
.fitness-page {
max-width: 900px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.fitness-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 1rem;
}
.fitness-header h2 {
margin: 0 0 0.25rem;
}
.fitness-header p {
margin: 0;
color: #8b95a8;
font-size: 0.9rem;
}
.fitness-header-actions {
display: flex;
gap: 0.5rem;
}
.fitness-message {
padding: 0.6rem 0.9rem;
background: #1a2433;
border: 1px solid #2a3f5a;
border-radius: 8px;
}
.fitness-day-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.fitness-day-nav button {
width: 2rem;
height: 2rem;
border-radius: 8px;
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
cursor: pointer;
}
.fitness-day-nav button:disabled {
opacity: 0.4;
cursor: default;
}
.fitness-day-title {
text-align: center;
flex: 1;
}
.fitness-day-title h3 {
margin: 0 0 0.25rem;
}
.fitness-day-title input[type="date"] {
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
border-radius: 6px;
padding: 0.2rem 0.4rem;
font-size: 0.85rem;
}
.fitness-week-strip {
display: flex;
gap: 0.35rem;
overflow-x: auto;
margin-bottom: 1rem;
padding-bottom: 0.25rem;
}
.fitness-week-day {
flex: 0 0 auto;
min-width: 3.2rem;
border: 1px solid #2f3748;
border-radius: 8px;
background: #1a1f2b;
color: inherit;
padding: 0.35rem 0.4rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.fitness-week-day.active {
border-color: #4a7cff;
background: #1c2740;
}
.fitness-week-day.empty .fitness-week-kcal {
color: #6b7280;
}
.fitness-week-date {
font-size: 0.75rem;
color: #8b95a8;
}
.fitness-week-kcal {
font-size: 0.8rem;
font-weight: 600;
}
.fitness-section {
background: #151922;
border: 1px solid #2a2f3a;
border-radius: 10px;
padding: 1rem 1.25rem;
}
.fitness-section h3 {
margin: 0 0 0.75rem;
}
.fitness-section h4 {
margin: 0.75rem 0 0.35rem;
font-size: 0.85rem;
color: #8b95a8;
}
.fitness-progress-grid {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.fitness-progress-header {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
margin-bottom: 0.2rem;
}
.fitness-progress-track {
height: 8px;
background: #0f1218;
border-radius: 4px;
overflow: hidden;
}
.fitness-progress-fill {
height: 100%;
background: #3d7a5a;
border-radius: 4px;
}
.fitness-profile-form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.fitness-profile-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #8b95a8;
}
.fitness-profile-form input,
.fitness-profile-form select {
padding: 0.45rem 0.6rem;
border-radius: 6px;
border: 1px solid #2a2f3a;
background: #0f1218;
color: #e8ecf1;
}
.fitness-profile-form button {
grid-column: 1 / -1;
justify-self: start;
}
.fitness-computed {
margin: 0.75rem 0 0;
font-size: 0.9rem;
color: #8b95a8;
}
.fitness-body-calc {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #2a2f3a;
}
.fitness-body-calc h4 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
}
.fitness-body-card {
background: #141820;
border: 1px solid #2a2f3a;
border-radius: 10px;
}
.fitness-body-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
}
.fitness-body-label {
display: block;
font-size: 0.75rem;
color: #8b95a8;
margin-bottom: 0.15rem;
}
.fitness-body-measures {
margin: 0.75rem 0 0;
color: #9aa3b5;
font-size: 0.88rem;
}
.fitness-table-wide {
font-size: 0.85rem;
}
.fitness-table-wide th,
.fitness-table-wide td {
white-space: nowrap;
}
.fitness-log-list {
list-style: none;
margin: 0;
padding: 0;
}
.fitness-log-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.35rem 0;
border-bottom: 1px solid #1e2430;
font-size: 0.9rem;
}
.fitness-log-list button {
padding: 0 0.4rem;
font-size: 1rem;
line-height: 1;
}
.fitness-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.fitness-table th,
.fitness-table td {
text-align: left;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid #1e2430;
}
.fitness-reminders {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fitness-reminders li {
display: flex;
gap: 1rem;
align-items: center;
}
.fitness-empty {
color: #8b95a8;
font-style: italic;
}
.fitness-raw {
background: #0f1218;
border: 1px solid #2a2f3a;
border-radius: 10px;
padding: 1rem;
overflow: auto;
font-size: 0.82rem;
max-height: 70vh;
}
.fitness-progress-track-v2 {
position: relative;
}
.fitness-progress-base,
.fitness-progress-bonus,
.fitness-progress-fill,
.fitness-progress-overflow {
position: absolute;
left: 0;
top: 0;
height: 100%;
}
.fitness-progress-base {
background: #2a3548;
border-radius: 4px 0 0 4px;
}
.fitness-progress-bonus {
background: #3a4a62;
}
.fitness-progress-fill {
background: #3d7a5a;
opacity: 0.95;
border-radius: 4px;
z-index: 1;
}
.fitness-progress-overflow {
background: #8b5a4a;
z-index: 2;
border-radius: 0 4px 4px 0;
}
.fitness-activity-block {
margin-bottom: 0.75rem;
font-size: 0.85rem;
color: #a8b4c8;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.fitness-activity-block p {
margin: 0;
}
.fitness-workout-stats {
margin-bottom: 0.75rem;
font-size: 0.85rem;
color: #c5d0e0;
}
.fitness-workout-stats h4 {
margin: 0 0 0.25rem;
font-size: 0.85rem;
color: #8b95a8;
}
.fitness-workout-stats p {
margin: 0;
}
+598 -393
View File
@@ -1,393 +1,598 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import {
api,
FitnessDailySummary,
FitnessHistory,
FitnessProfile,
FitnessReminder,
FitnessSnapshot,
} from "../api/client";
import "./Fitness.css";
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
function shiftDate(iso: string, deltaDays: number) {
const d = new Date(`${iso}T12:00:00`);
d.setDate(d.getDate() + deltaDays);
return d.toISOString().slice(0, 10);
}
function formatDayLabel(iso: string) {
const today = todayIso();
if (iso === today) return "Сегодня";
if (iso === shiftDate(today, -1)) return "Вчера";
return new Date(`${iso}T12:00:00`).toLocaleDateString("ru-RU", {
weekday: "short",
day: "numeric",
month: "short",
});
}
function ProgressBar({ label, current, target, unit }: {
label: string;
current: number;
target: number;
unit: string;
}) {
const pct = target > 0 ? Math.min(100, (current / target) * 100) : 0;
return (
<div className="fitness-progress">
<div className="fitness-progress-header">
<span>{label}</span>
<span>
{current.toFixed(0)}/{target.toFixed(0)} {unit}
</span>
</div>
<div className="fitness-progress-track">
<div className="fitness-progress-fill" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
export default function Fitness() {
const [snapshot, setSnapshot] = useState<FitnessSnapshot | null>(null);
const [selectedDate, setSelectedDate] = useState(todayIso);
const [daySummary, setDaySummary] = useState<FitnessDailySummary | null>(null);
const [history, setHistory] = useState<FitnessHistory | null>(null);
const [profile, setProfile] = useState<Partial<FitnessProfile>>({});
const [message, setMessage] = useState("");
const [showRaw, setShowRaw] = useState(false);
const [loading, setLoading] = useState(false);
const load = useCallback(async (day: string = selectedDate) => {
setLoading(true);
try {
const [data, summary, hist] = await Promise.all([
api.getFitnessSnapshot(),
api.getFitnessSummary(day),
api.getFitnessHistory(7, day),
]);
setSnapshot(data);
setDaySummary(summary);
setHistory(hist);
if (data.profile) setProfile(data.profile);
setMessage("");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setLoading(false);
}
}, [selectedDate]);
useEffect(() => {
load(selectedDate).catch(console.error);
}, [selectedDate, load]);
const pickDay = (iso: string) => {
setSelectedDate(iso);
};
const handleProfileSave = async (e: FormEvent) => {
e.preventDefault();
try {
await api.updateFitnessProfile(profile);
setMessage("Профиль сохранён");
await load();
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка");
}
};
const handleToggleReminder = async (rem: FitnessReminder) => {
try {
await api.updateFitnessReminder(rem.kind, { enabled: !rem.enabled });
await load();
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка");
}
};
const handleDeleteMeal = async (id: number) => {
await api.deleteFitnessMeal(id);
await load();
};
const handleDeleteWater = async (id: number) => {
await api.deleteFitnessWater(id);
await load();
};
const totals = daySummary?.totals;
const targets = daySummary?.targets;
const isToday = selectedDate === todayIso();
return (
<div className="fitness-page">
<header className="fitness-header">
<div>
<h2>Фитнес</h2>
<p>Дневник, цели, напоминания</p>
</div>
<div className="fitness-header-actions">
<button type="button" onClick={() => load(selectedDate)} disabled={loading}>
{loading ? "…" : "Обновить"}
</button>
<button type="button" onClick={() => setShowRaw((v) => !v)}>
{showRaw ? "UI" : "JSON"}
</button>
</div>
</header>
{message && <div className="fitness-message">{message}</div>}
{showRaw ? (
<pre className="fitness-raw">{JSON.stringify(snapshot, null, 2)}</pre>
) : (
<>
<section className="fitness-section">
<div className="fitness-day-nav">
<button type="button" onClick={() => pickDay(shiftDate(selectedDate, -1))}>
</button>
<div className="fitness-day-title">
<h3>{formatDayLabel(selectedDate)}</h3>
<input
type="date"
value={selectedDate}
onChange={(e) => pickDay(e.target.value)}
aria-label="Выбор дня"
/>
</div>
<button
type="button"
onClick={() => pickDay(shiftDate(selectedDate, 1))}
disabled={selectedDate >= todayIso()}
>
</button>
</div>
{history && history.summaries.length > 0 && (
<div className="fitness-week-strip">
{history.summaries.map((row) => (
<button
key={row.date}
type="button"
className={`fitness-week-day${row.date === selectedDate ? " active" : ""}${row.has_data ? "" : " empty"}`}
onClick={() => pickDay(row.date)}
title={`${row.date}: ${row.totals.calories.toFixed(0)} ккал`}
>
<span className="fitness-week-date">
{new Date(`${row.date}T12:00:00`).toLocaleDateString("ru-RU", {
day: "numeric",
month: "numeric",
})}
</span>
<span className="fitness-week-kcal">
{row.has_data ? `${row.totals.calories.toFixed(0)}` : "—"}
</span>
</button>
))}
</div>
)}
{totals && targets ? (
<div className="fitness-progress-grid">
<ProgressBar
label="Калории"
current={totals.calories}
target={targets.calories}
unit="ккал"
/>
<ProgressBar
label="Белок"
current={totals.protein_g}
target={targets.protein_g}
unit="г"
/>
<ProgressBar
label="Жиры"
current={totals.fat_g}
target={targets.fat_g}
unit="г"
/>
<ProgressBar
label="Углеводы"
current={totals.carbs_g}
target={targets.carbs_g}
unit="г"
/>
<ProgressBar
label="Вода"
current={totals.water_ml / 1000}
target={targets.water_ml / 1000}
unit="л"
/>
</div>
) : (
<p className="fitness-empty">Нет записей за этот день</p>
)}
</section>
<section className="fitness-section">
<h3>Профиль и цели</h3>
<form className="fitness-profile-form" onSubmit={handleProfileSave}>
<label>
<span>пол</span>
<select
value={profile.sex ?? "male"}
onChange={(e) => setProfile((p) => ({ ...p, sex: e.target.value }))}
>
<option value="male">male</option>
<option value="female">female</option>
</select>
</label>
<label>
<span>возраст</span>
<input
type="number"
value={profile.age ?? ""}
onChange={(e) =>
setProfile((p) => ({ ...p, age: Number(e.target.value) }))
}
/>
</label>
<label>
<span>рост см</span>
<input
type="number"
value={profile.height_cm ?? ""}
onChange={(e) =>
setProfile((p) => ({ ...p, height_cm: Number(e.target.value) }))
}
/>
</label>
<label>
<span>вес кг</span>
<input
type="number"
value={profile.weight_kg ?? ""}
onChange={(e) =>
setProfile((p) => ({ ...p, weight_kg: Number(e.target.value) }))
}
/>
</label>
<label>
<span>активность</span>
<select
value={profile.activity_level ?? "moderate"}
onChange={(e) =>
setProfile((p) => ({ ...p, activity_level: e.target.value }))
}
>
<option value="sedentary">sedentary</option>
<option value="light">light</option>
<option value="moderate">moderate</option>
<option value="active">active</option>
<option value="very_active">very_active</option>
</select>
</label>
<label>
<span>цель</span>
<select
value={profile.goal ?? "maintain"}
onChange={(e) => setProfile((p) => ({ ...p, goal: e.target.value }))}
>
<option value="lose">lose</option>
<option value="maintain">maintain</option>
<option value="gain">gain</option>
</select>
</label>
<button type="submit">Сохранить и пересчитать TDEE</button>
</form>
{profile.computed && (
<p className="fitness-computed">
BMR {profile.computed.bmr} · TDEE {profile.computed.tdee} · BMI{" "}
{profile.computed.bmi}
</p>
)}
</section>
<section className="fitness-section">
<h3>Логи {isToday ? "за сегодня" : `за ${selectedDate}`}</h3>
<h4>Еда</h4>
<ul className="fitness-log-list">
{(daySummary?.meals ?? []).map((m) => (
<li key={m.id}>
{m.estimated ? "≈" : ""}
{m.description} {m.calories} ккал
{isToday && (
<button type="button" onClick={() => handleDeleteMeal(m.id)}>
×
</button>
)}
</li>
))}
</ul>
<h4>Вода</h4>
<ul className="fitness-log-list">
{(daySummary?.water ?? []).map((w) => (
<li key={w.id}>
+{w.amount_ml} мл
{isToday && (
<button type="button" onClick={() => handleDeleteWater(w.id)}>
×
</button>
)}
</li>
))}
</ul>
<h4>Тренировки</h4>
<ul className="fitness-log-list">
{(daySummary?.workouts ?? []).map((w) => (
<li key={w.id}>{w.title}</li>
))}
</ul>
</section>
<section className="fitness-section">
<h3>История веса</h3>
<table className="fitness-table">
<thead>
<tr>
<th>Дата</th>
<th>кг</th>
</tr>
</thead>
<tbody>
{(snapshot?.body_metrics ?? []).map((m) => (
<tr key={m.id}>
<td>{m.recorded_at?.slice(0, 10)}</td>
<td>{m.weight_kg}</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="fitness-section">
<h3>Напоминания</h3>
<ul className="fitness-reminders">
{(snapshot?.reminders ?? []).map((r) => (
<li key={r.id}>
<span>{r.kind}</span>
<span>
{r.interval_hours
? `каждые ${r.interval_hours}ч`
: `${String(r.hour).padStart(2, "0")}:${String(r.minute).padStart(2, "0")}`}
</span>
<button type="button" onClick={() => handleToggleReminder(r)}>
{r.enabled ? "вкл" : "выкл"}
</button>
</li>
))}
</ul>
</section>
</>
)}
</div>
);
}
import { FormEvent, useCallback, useEffect, useState } from "react";
import {
api,
BodyCompositionComputed,
BodyMetric,
FitnessDailySummary,
FitnessHistory,
FitnessProfile,
FitnessReminder,
FitnessSnapshot,
} from "../api/client";
import "./Fitness.css";
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
function shiftDate(iso: string, deltaDays: number) {
const d = new Date(`${iso}T12:00:00`);
d.setDate(d.getDate() + deltaDays);
return d.toISOString().slice(0, 10);
}
function formatDayLabel(iso: string) {
const today = todayIso();
if (iso === today) return "Сегодня";
if (iso === shiftDate(today, -1)) return "Вчера";
return new Date(`${iso}T12:00:00`).toLocaleDateString("ru-RU", {
weekday: "short",
day: "numeric",
month: "short",
});
}
function ProgressBar({
label,
current,
target,
baseTarget,
unit,
}: {
label: string;
current: number;
target: number;
baseTarget?: number;
unit: string;
}) {
const base = baseTarget && baseTarget > 0 ? baseTarget : target;
const bonus = Math.max(0, target - base);
const scaleMax = Math.max(target, current, 1);
const basePct = (base / scaleMax) * 100;
const bonusPct = (bonus / scaleMax) * 100;
const fillPct = (Math.min(current, scaleMax) / scaleMax) * 100;
const overflowPct = current > target ? ((current - target) / scaleMax) * 100 : 0;
return (
<div className="fitness-progress">
<div className="fitness-progress-header">
<span>{label}</span>
<span>
{current.toFixed(0)}/{target.toFixed(0)} {unit}
{bonus > 0 ? ` (+${bonus.toFixed(0)} бонус)` : ""}
</span>
</div>
<div className="fitness-progress-track fitness-progress-track-v2">
<div className="fitness-progress-base" style={{ width: `${basePct}%` }} />
<div className="fitness-progress-bonus" style={{ width: `${bonusPct}%` }} />
<div className="fitness-progress-fill" style={{ width: `${fillPct}%` }} />
{overflowPct > 0 ? (
<div className="fitness-progress-overflow" style={{ width: `${overflowPct}%` }} />
) : null}
</div>
</div>
);
}
export default function Fitness() {
const [snapshot, setSnapshot] = useState<FitnessSnapshot | null>(null);
const [selectedDate, setSelectedDate] = useState(todayIso);
const [daySummary, setDaySummary] = useState<FitnessDailySummary | null>(null);
const [history, setHistory] = useState<FitnessHistory | null>(null);
const [profile, setProfile] = useState<Partial<FitnessProfile>>({});
const [message, setMessage] = useState("");
const [calcInput, setCalcInput] = useState({
neck_cm: "",
waist_cm: "",
hip_cm: "",
weight_kg: "",
});
const [calcResult, setCalcResult] = useState<BodyCompositionComputed | null>(null);
const [showRaw, setShowRaw] = useState(false);
const [loading, setLoading] = useState(false);
const load = useCallback(async (day: string = selectedDate) => {
setLoading(true);
try {
const [data, summary, hist] = await Promise.all([
api.getFitnessSnapshot(),
api.getFitnessSummary(day),
api.getFitnessHistory(7, day),
]);
setSnapshot(data);
setDaySummary(summary);
setHistory(hist);
if (data.profile) setProfile(data.profile);
setMessage("");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setLoading(false);
}
}, [selectedDate]);
useEffect(() => {
load(selectedDate).catch(console.error);
}, [selectedDate, load]);
const pickDay = (iso: string) => {
setSelectedDate(iso);
};
const handleProfileSave = async (e: FormEvent) => {
e.preventDefault();
try {
await api.updateFitnessProfile(profile);
setMessage("Профиль сохранён");
await load();
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка");
}
};
const handleToggleReminder = async (rem: FitnessReminder) => {
try {
await api.updateFitnessReminder(rem.kind, { enabled: !rem.enabled });
await load();
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка");
}
};
const handleDeleteMeal = async (id: number) => {
await api.deleteFitnessMeal(id);
await load();
};
const handleDeleteWater = async (id: number) => {
await api.deleteFitnessWater(id);
await load();
};
const handleCalcBodyComposition = async (e: FormEvent) => {
e.preventDefault();
try {
const result = await api.calcBodyComposition({
weight_kg: calcInput.weight_kg ? Number(calcInput.weight_kg) : profile.weight_kg,
height_cm: profile.height_cm,
sex: profile.sex,
neck_cm: calcInput.neck_cm ? Number(calcInput.neck_cm) : undefined,
waist_cm: calcInput.waist_cm ? Number(calcInput.waist_cm) : undefined,
hip_cm: calcInput.hip_cm ? Number(calcInput.hip_cm) : undefined,
});
setCalcResult(result);
setMessage("");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка расчёта");
}
};
const totals = daySummary?.totals;
const targets = daySummary?.targets;
const targetsBase = daySummary?.targets_base;
const activity = daySummary?.activity;
const workoutStats = snapshot?.workout_stats;
const latestMetric: BodyMetric | undefined = snapshot?.body_metrics?.[0];
const isToday = selectedDate === todayIso();
return (
<div className="fitness-page">
<header className="fitness-header">
<div>
<h2>Фитнес</h2>
<p>Дневник, цели, напоминания</p>
</div>
<div className="fitness-header-actions">
<button type="button" onClick={() => load(selectedDate)} disabled={loading}>
{loading ? "…" : "Обновить"}
</button>
<button type="button" onClick={() => setShowRaw((v) => !v)}>
{showRaw ? "UI" : "JSON"}
</button>
</div>
</header>
{message && <div className="fitness-message">{message}</div>}
{showRaw ? (
<pre className="fitness-raw">{JSON.stringify(snapshot, null, 2)}</pre>
) : (
<>
<section className="fitness-section">
<div className="fitness-day-nav">
<button type="button" onClick={() => pickDay(shiftDate(selectedDate, -1))}>
</button>
<div className="fitness-day-title">
<h3>{formatDayLabel(selectedDate)}</h3>
<input
type="date"
value={selectedDate}
onChange={(e) => pickDay(e.target.value)}
aria-label="Выбор дня"
/>
</div>
<button
type="button"
onClick={() => pickDay(shiftDate(selectedDate, 1))}
disabled={selectedDate >= todayIso()}
>
</button>
</div>
{history && history.summaries.length > 0 && (
<div className="fitness-week-strip">
{history.summaries.map((row) => (
<button
key={row.date}
type="button"
className={`fitness-week-day${row.date === selectedDate ? " active" : ""}${row.has_data ? "" : " empty"}`}
onClick={() => pickDay(row.date)}
title={`${row.date}: ${row.totals.calories.toFixed(0)} ккал`}
>
<span className="fitness-week-date">
{new Date(`${row.date}T12:00:00`).toLocaleDateString("ru-RU", {
day: "numeric",
month: "numeric",
})}
</span>
<span className="fitness-week-kcal">
{row.has_data ? `${row.totals.calories.toFixed(0)}` : "—"}
</span>
</button>
))}
</div>
)}
{totals && targets ? (
<>
{activity ? (
<div className="fitness-activity-block">
<p>
Шаги: {daySummary?.steps_total ?? activity.steps} / база {activity.steps_baseline}
</p>
<p>
Бонус активности: +{activity.total_bonus_kcal} ккал (шаги +{activity.steps_bonus_kcal},
тренировки +{activity.workout_bonus_kcal})
</p>
</div>
) : null}
{workoutStats ? (
<div className="fitness-workout-stats">
<h4>Тренировки ({workoutStats.days} дн.)</h4>
<p>
{workoutStats.count} сессий · {workoutStats.duration_min} мин · {workoutStats.active_kcal} ккал
активных · цель/нед {workoutStats.weekly_target} · серия {workoutStats.streak} дн.
</p>
</div>
) : null}
<div className="fitness-progress-grid">
<ProgressBar
label="Калории"
current={totals.calories}
target={targets.calories}
baseTarget={targetsBase?.calories}
unit="ккал"
/>
<ProgressBar
label="Белок"
current={totals.protein_g}
target={targets.protein_g}
baseTarget={targetsBase?.protein_g}
unit="г"
/>
<ProgressBar
label="Жиры"
current={totals.fat_g}
target={targets.fat_g}
baseTarget={targetsBase?.fat_g}
unit="г"
/>
<ProgressBar
label="Углеводы"
current={totals.carbs_g}
target={targets.carbs_g}
baseTarget={targetsBase?.carbs_g}
unit="г"
/>
<ProgressBar
label="Вода"
current={totals.water_ml / 1000}
target={targets.water_ml / 1000}
unit="л"
/>
</div>
</>
) : (
<p className="fitness-empty">Нет записей за этот день</p>
)}
</section>
<section className="fitness-section">
<h3>Профиль и цели</h3>
<form className="fitness-profile-form" onSubmit={handleProfileSave}>
<label>
<span>пол</span>
<select
value={profile.sex ?? "male"}
onChange={(e) => setProfile((p) => ({ ...p, sex: e.target.value }))}
>
<option value="male">male</option>
<option value="female">female</option>
</select>
</label>
<label>
<span>возраст</span>
<input
type="number"
value={profile.age ?? ""}
onChange={(e) =>
setProfile((p) => ({ ...p, age: Number(e.target.value) }))
}
/>
</label>
<label>
<span>рост см</span>
<input
type="number"
value={profile.height_cm ?? ""}
onChange={(e) =>
setProfile((p) => ({ ...p, height_cm: Number(e.target.value) }))
}
/>
</label>
<label>
<span>вес кг</span>
<input
type="number"
value={profile.weight_kg ?? ""}
onChange={(e) =>
setProfile((p) => ({ ...p, weight_kg: Number(e.target.value) }))
}
/>
</label>
<label>
<span>активность</span>
<select
value={profile.activity_level ?? "moderate"}
onChange={(e) =>
setProfile((p) => ({ ...p, activity_level: e.target.value }))
}
>
<option value="sedentary">sedentary</option>
<option value="light">light</option>
<option value="moderate">moderate</option>
<option value="active">active</option>
<option value="very_active">very_active</option>
</select>
</label>
<label>
<span>цель</span>
<select
value={profile.goal ?? "maintain"}
onChange={(e) => setProfile((p) => ({ ...p, goal: e.target.value }))}
>
<option value="lose">lose</option>
<option value="maintain">maintain</option>
<option value="gain">gain</option>
</select>
</label>
<button type="submit">Сохранить и пересчитать TDEE</button>
</form>
{profile.computed && (
<p className="fitness-computed">
BMR {profile.computed.bmr} · TDEE {profile.computed.tdee} · BMI{" "}
{profile.computed.bmi}
</p>
)}
<div className="fitness-body-calc">
<h4>Navy-калькулятор (без сохранения)</h4>
<form className="fitness-profile-form" onSubmit={handleCalcBodyComposition}>
<label>
<span>вес кг</span>
<input
type="number"
placeholder={String(profile.weight_kg ?? "")}
value={calcInput.weight_kg}
onChange={(e) =>
setCalcInput((p) => ({ ...p, weight_kg: e.target.value }))
}
/>
</label>
<label>
<span>шея см</span>
<input
type="number"
value={calcInput.neck_cm}
onChange={(e) =>
setCalcInput((p) => ({ ...p, neck_cm: e.target.value }))
}
/>
</label>
<label>
<span>талия см</span>
<input
type="number"
value={calcInput.waist_cm}
onChange={(e) =>
setCalcInput((p) => ({ ...p, waist_cm: e.target.value }))
}
/>
</label>
<label>
<span>бёдра см</span>
<input
type="number"
value={calcInput.hip_cm}
onChange={(e) =>
setCalcInput((p) => ({ ...p, hip_cm: e.target.value }))
}
/>
</label>
<button type="submit">Рассчитать</button>
</form>
{calcResult && (
<p className="fitness-computed">
{calcResult.body_fat_pct != null
? `Жир ≈${calcResult.body_fat_pct}% (${calcResult.body_fat_method ?? "?"})`
: "Жир: недостаточно данных"}
{calcResult.whr != null ? ` · WHR ${calcResult.whr}` : ""}
{calcResult.lbm_kg != null ? ` · LBM ${calcResult.lbm_kg} кг` : ""}
{calcResult.ffmi != null ? ` · FFMI ${calcResult.ffmi}` : ""}
</p>
)}
</div>
</section>
{latestMetric && (
<section className="fitness-section fitness-body-card">
<h3>Состав тела (последняя запись)</h3>
<div className="fitness-body-grid">
<div>
<span className="fitness-body-label">Вес</span>
<strong>{latestMetric.weight_kg} кг</strong>
</div>
{latestMetric.body_fat_pct != null && (
<div>
<span className="fitness-body-label">Жир</span>
<strong>
{latestMetric.body_fat_pct}% ({latestMetric.body_fat_method ?? "?"})
</strong>
</div>
)}
{latestMetric.whr != null && (
<div>
<span className="fitness-body-label">WHR</span>
<strong>{latestMetric.whr}</strong>
</div>
)}
{latestMetric.lbm_kg != null && (
<div>
<span className="fitness-body-label">LBM</span>
<strong>{latestMetric.lbm_kg} кг</strong>
</div>
)}
{latestMetric.ffmi != null && (
<div>
<span className="fitness-body-label">FFMI</span>
<strong>{latestMetric.ffmi}</strong>
</div>
)}
</div>
<p className="fitness-body-measures">
{[
latestMetric.neck_cm != null ? `шея ${latestMetric.neck_cm}` : null,
latestMetric.waist_cm != null ? `талия ${latestMetric.waist_cm}` : null,
latestMetric.hip_cm != null ? `бёдра ${latestMetric.hip_cm}` : null,
latestMetric.chest_cm != null ? `грудь ${latestMetric.chest_cm}` : null,
]
.filter(Boolean)
.join(" · ")}
</p>
</section>
)}
<section className="fitness-section">
<h3>Логи {isToday ? "за сегодня" : `за ${selectedDate}`}</h3>
<h4>Еда</h4>
<ul className="fitness-log-list">
{(daySummary?.meals ?? []).map((m) => (
<li key={m.id}>
{m.estimated ? "≈" : ""}
{m.description} {m.calories} ккал
{isToday && (
<button type="button" onClick={() => handleDeleteMeal(m.id)}>
×
</button>
)}
</li>
))}
</ul>
<h4>Вода</h4>
<ul className="fitness-log-list">
{(daySummary?.water ?? []).map((w) => (
<li key={w.id}>
+{w.amount_ml} мл
{isToday && (
<button type="button" onClick={() => handleDeleteWater(w.id)}>
×
</button>
)}
</li>
))}
</ul>
<h4>Шаги</h4>
<ul className="fitness-log-list">
{(daySummary?.steps ?? []).map((s) => (
<li key={s.id}>
{s.steps.toLocaleString("ru-RU")} шаг.
{s.active_calories ? ` · ${s.active_calories} ккал` : ""}
</li>
))}
</ul>
<h4>Тренировки</h4>
<ul className="fitness-log-list">
{(daySummary?.workouts ?? []).map((w) => (
<li key={w.id}>{w.title}</li>
))}
</ul>
</section>
<section className="fitness-section">
<h3>Антропометрия</h3>
<table className="fitness-table fitness-table-wide">
<thead>
<tr>
<th>Дата</th>
<th>кг</th>
<th>%</th>
<th>шея</th>
<th>талия</th>
<th>бёдра</th>
<th>WHR</th>
<th>метод</th>
</tr>
</thead>
<tbody>
{(snapshot?.body_metrics ?? []).map((m) => (
<tr key={m.id}>
<td>{m.recorded_at?.slice(0, 10)}</td>
<td>{m.weight_kg}</td>
<td>{m.body_fat_pct != null ? `${m.body_fat_pct}` : "—"}</td>
<td>{m.neck_cm ?? "—"}</td>
<td>{m.waist_cm ?? "—"}</td>
<td>{m.hip_cm ?? "—"}</td>
<td>{m.whr ?? "—"}</td>
<td>{m.body_fat_method ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="fitness-section">
<h3>Напоминания</h3>
<ul className="fitness-reminders">
{(snapshot?.reminders ?? []).map((r) => (
<li key={r.id}>
<span>{r.kind}</span>
<span>
{r.interval_hours
? `каждые ${r.interval_hours}ч`
: `${String(r.hour).padStart(2, "0")}:${String(r.minute).padStart(2, "0")}`}
</span>
<button type="button" onClick={() => handleToggleReminder(r)}>
{r.enabled ? "вкл" : "выкл"}
</button>
</li>
))}
</ul>
</section>
</>
)}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: #0f1218;
}
.login-card {
width: 100%;
max-width: 420px;
padding: 2rem;
border-radius: 12px;
background: #151922;
border: 1px solid #2a2f3a;
}
.login-card h1 {
margin: 0 0 0.75rem;
font-size: 1.25rem;
}
.login-hint {
margin: 0 0 1.25rem;
color: #a8b0bd;
font-size: 0.9rem;
line-height: 1.45;
}
.login-card label {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
font-size: 0.85rem;
color: #c5ccd6;
}
.login-card input {
padding: 0.65rem 0.75rem;
border-radius: 8px;
border: 1px solid #3a4254;
background: #0f1218;
color: #fff;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.login-card button {
width: 100%;
padding: 0.7rem 1rem;
border: none;
border-radius: 8px;
background: #4a7cff;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.login-card button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-error {
margin: 0 0 0.75rem;
color: #ff8a8a;
font-size: 0.85rem;
}
+66
View File
@@ -0,0 +1,66 @@
import { FormEvent, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import "./Login.css";
export default function Login() {
const { user, loading, login } = useAuth();
const location = useLocation();
const [token, setToken] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const from = (location.state as { from?: string } | null)?.from ?? "/";
if (!loading && user) {
return <Navigate to={from} replace />;
}
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!token.trim()) {
setError("Введите API-токен");
return;
}
setSubmitting(true);
setError(null);
try {
await login(token.trim());
} catch (err) {
setError(err instanceof Error ? err.message : "Не удалось войти");
} finally {
setSubmitting(false);
}
};
return (
<div className="login-page">
<div className="login-card">
<h1>Home AI Assistant</h1>
<p className="login-hint">
Введите персональный API-токен. Без него чат и данные недоступны из сети.
</p>
<form onSubmit={onSubmit}>
<label>
API-токен
<input
type="password"
autoComplete="off"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Ваш секретный токен"
/>
</label>
{error && (
<p className="login-error" role="alert">
{error}
</p>
)}
<button type="submit" disabled={submitting || loading}>
{submitting ? "Вход…" : "Войти"}
</button>
</form>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
.settings-page {
max-width: 720px;
margin: 0 auto;
padding: 1.25rem;
}
.settings-page h2 {
margin-top: 0;
}
.settings-form label {
display: block;
margin: 0.75rem 0 0.25rem;
}
.settings-form input,
.settings-form select {
width: 100%;
max-width: 480px;
padding: 0.5rem 0.65rem;
border-radius: 8px;
border: 1px solid #2a2f3a;
background: #151922;
color: #e8ecf1;
}
.settings-actions {
margin-top: 1rem;
display: flex;
gap: 0.75rem;
}
.settings-docs {
margin-top: 2rem;
}
.settings-docs ul {
padding-left: 1.2rem;
}
.settings-hint {
color: #a8b0bd;
font-size: 0.9rem;
}
.settings-users-form {
margin-top: 1rem;
}
.settings-token-box {
margin-top: 1rem;
padding: 1rem;
border-radius: 8px;
background: #0f1218;
border: 1px solid #3a4254;
}
.settings-token-box code {
display: block;
margin-top: 0.5rem;
word-break: break-all;
font-size: 0.85rem;
color: #9fd4ff;
}
+215
View File
@@ -0,0 +1,215 @@
import { FormEvent, useEffect, useState } from "react";
import { api, AssistantSettings, AuthUser, DocumentItem } from "../api/client";
import { useAuth } from "../context/AuthContext";
import "./Settings.css";
export default function Settings() {
const { user } = useAuth();
const [settings, setSettings] = useState<AssistantSettings | null>(null);
const [documents, setDocuments] = useState<DocumentItem[]>([]);
const [users, setUsers] = useState<AuthUser[]>([]);
const [newUsername, setNewUsername] = useState("");
const [newDisplayName, setNewDisplayName] = useState("");
const [createdToken, setCreatedToken] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [creatingUser, setCreatingUser] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = async () => {
const [s, docs, u] = await Promise.all([
api.getSettings(),
api.listDocuments(),
api.listUsers(),
]);
setSettings(s);
setDocuments(docs);
setUsers(u.users);
};
useEffect(() => {
load().catch((e) => setError(String(e)));
}, []);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!settings) return;
setSaving(true);
setError(null);
try {
const updated = await api.patchSettings({
openrouter_model: settings.openrouter_model,
memory_extract_model: settings.memory_extract_model,
openrouter_reasoning_effort: settings.openrouter_reasoning_effort,
rag_enabled: settings.rag_enabled,
rag_top_k: settings.rag_top_k,
});
setSettings(updated);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
};
const onUpload = async (file: File | null) => {
if (!file) return;
setError(null);
try {
await api.uploadDocument(file);
const docs = await api.listDocuments();
setDocuments(docs);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onCreateUser = async (e: FormEvent) => {
e.preventDefault();
if (!newUsername.trim()) return;
setCreatingUser(true);
setError(null);
setCreatedToken(null);
try {
const res = await api.createUser({
username: newUsername.trim(),
display_name: newDisplayName.trim() || newUsername.trim(),
});
setCreatedToken(res.token);
setNewUsername("");
setNewDisplayName("");
const u = await api.listUsers();
setUsers(u.users);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCreatingUser(false);
}
};
if (!settings) {
return <div className="settings-page">Загрузка</div>;
}
return (
<div className="settings-page">
<h2>Настройки ассистента</h2>
{error && <p role="alert">{error}</p>}
<form className="settings-form" onSubmit={onSubmit}>
<label>
Модель OpenRouter
<input
value={settings.openrouter_model}
onChange={(e) => setSettings({ ...settings, openrouter_model: e.target.value })}
/>
</label>
<label>
Модель извлечения памяти
<input
value={settings.memory_extract_model}
onChange={(e) => setSettings({ ...settings, memory_extract_model: e.target.value })}
/>
</label>
<label>
Reasoning effort
<select
value={settings.openrouter_reasoning_effort}
onChange={(e) =>
setSettings({ ...settings, openrouter_reasoning_effort: e.target.value })
}
>
<option value="none">none</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={settings.rag_enabled}
onChange={(e) => setSettings({ ...settings, rag_enabled: e.target.checked })}
/>{" "}
RAG включён
</label>
<label>
RAG top K
<input
type="number"
min={1}
max={50}
value={settings.rag_top_k}
onChange={(e) =>
setSettings({ ...settings, rag_top_k: Number(e.target.value) || 8 })
}
/>
</label>
<div className="settings-actions">
<button type="submit" disabled={saving}>
{saving ? "Сохранение…" : "Сохранить"}
</button>
</div>
</form>
<section className="settings-docs">
<h3>Документы</h3>
<input
type="file"
accept=".txt,.md,.markdown,.json,.csv"
onChange={(e) => onUpload(e.target.files?.[0] ?? null)}
/>
<ul>
{documents.map((d) => (
<li key={d.id}>
{d.title} ({d.filename}) {Math.round(d.size_bytes / 1024)} KB
</li>
))}
</ul>
</section>
<section className="settings-docs">
<h3>Пользователи</h3>
<p className="settings-hint">
Вы вошли как <strong>{user?.display_name || user?.username}</strong>. Новый пользователь
получит отдельные чаты, память и персонажа.
</p>
<ul>
{users.map((u) => (
<li key={u.id}>
{u.display_name} (@{u.username}){u.id === user?.id ? " — вы" : ""}
</li>
))}
</ul>
<form className="settings-form settings-users-form" onSubmit={onCreateUser}>
<label>
Логин
<input
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
placeholder="testuser"
/>
</label>
<label>
Имя (необязательно)
<input
value={newDisplayName}
onChange={(e) => setNewDisplayName(e.target.value)}
placeholder="Тестовый пользователь"
/>
</label>
<div className="settings-actions">
<button type="submit" disabled={creatingUser}>
{creatingUser ? "Создание…" : "Создать пользователя"}
</button>
</div>
</form>
{createdToken && (
<div className="settings-token-box" role="status">
<p>Токен нового пользователя (скопируйте сейчас, больше не покажем):</p>
<code>{createdToken}</code>
</div>
)}
</section>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
test
+81
View File
@@ -0,0 +1,81 @@
import { ChatMessage } from "../api/client";
function noticeKey(content: string): string {
const imageMatch = content.match(/!\[[^\]]*\]\(([^)]+)\)/);
if (imageMatch) {
return `img:${imageMatch[1]}`;
}
return content.trim();
}
function messagesMatch(a: ChatMessage, b: ChatMessage): boolean {
if (a.role === "notice" && b.role === "notice") {
return noticeKey(a.content) === noticeKey(b.content);
}
return (
a.role === b.role &&
a.content.trim() === b.content.trim() &&
Math.abs(new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) < 120_000
);
}
export function stripOptimisticMessages(messages: ChatMessage[]): ChatMessage[] {
return messages.filter((m) => m.id >= 0);
}
/** @deprecated use stripOptimisticMessages */
export function stripOptimisticNotices(messages: ChatMessage[]): ChatMessage[] {
return stripOptimisticMessages(messages);
}
export function mergeMessages(local: ChatMessage[], server: ChatMessage[]): ChatMessage[] {
if (server.length === 0) {
return dedupeMessages(local);
}
const serverById = new Map(server.map((m) => [m.id, m]));
const optimistic = local.filter((m) => m.id < 0);
const localServer = local.filter((m) => m.id > 0);
const localIds = new Set(localServer.map((m) => m.id));
const unmatchedOptimistic = optimistic.filter(
(temp) => !server.some((s) => messagesMatch(s, temp)),
);
const updatedLocal = localServer.map((m) => serverById.get(m.id) ?? m);
const appended = server.filter((m) => !localIds.has(m.id));
return dedupeMessages(
[...updatedLocal, ...appended, ...unmatchedOptimistic].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
),
);
}
export function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
const seenNoticeKeys = new Set<string>();
const result: ChatMessage[] = [];
for (const msg of messages) {
if (msg.role === "notice") {
const key = noticeKey(msg.content);
if (seenNoticeKeys.has(key)) {
continue;
}
seenNoticeKeys.add(key);
}
const prev = result[result.length - 1];
if (prev && messagesMatch(prev, msg) && (prev.id <= 0 || msg.id <= 0)) {
if (msg.id > 0 && prev.id <= 0) {
result[result.length - 1] = msg;
}
continue;
}
result.push(msg);
}
return result;
}