added RAG, Multiuser, TG bot
This commit is contained in:
+134
-81
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+612
-417
File diff suppressed because it is too large
Load Diff
+373
-262
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
test
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user