added RAG, Multiuser, TG bot
This commit is contained in:
@@ -352,6 +352,45 @@ export interface FitnessHistory {
|
||||
summaries: FitnessDayOverview[];
|
||||
}
|
||||
|
||||
export interface FitnessChartPoint {
|
||||
index: number;
|
||||
value: number | null;
|
||||
has_data: boolean;
|
||||
days_with_data?: number;
|
||||
week_start?: string;
|
||||
week_end?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface FitnessChartTrend {
|
||||
slope_per_week?: number;
|
||||
slope_per_day?: number;
|
||||
intercept: number;
|
||||
points_with_data: number;
|
||||
line: Array<{ index: number; value: number; week_start?: string; date?: string }>;
|
||||
}
|
||||
|
||||
export interface FitnessChartSeries {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
points: FitnessChartPoint[];
|
||||
trend: FitnessChartTrend | null;
|
||||
data_points: number;
|
||||
}
|
||||
|
||||
export interface FitnessChartsResponse {
|
||||
end_date: string;
|
||||
weeks: number;
|
||||
granularity: "week" | "day";
|
||||
first_week_start: string;
|
||||
last_week_start: string;
|
||||
days_with_data: number;
|
||||
weeks_with_data: number;
|
||||
series: Record<string, FitnessChartSeries>;
|
||||
daily_series: Record<string, FitnessChartSeries> | null;
|
||||
}
|
||||
|
||||
export interface FitnessSnapshot {
|
||||
profile: FitnessProfile | null;
|
||||
today: FitnessDailySummary;
|
||||
@@ -598,6 +637,15 @@ export const api = {
|
||||
return request<FitnessHistory>(`/api/v1/fitness/history?${params}`);
|
||||
},
|
||||
|
||||
getFitnessCharts: (weeks = 52, trend = true, end?: string) => {
|
||||
const params = new URLSearchParams({
|
||||
weeks: String(weeks),
|
||||
trend: String(trend),
|
||||
});
|
||||
if (end) params.set("end", end);
|
||||
return request<FitnessChartsResponse>(`/api/v1/fitness/charts?${params}`);
|
||||
},
|
||||
|
||||
updateFitnessProfile: (updates: Partial<FitnessProfile>) =>
|
||||
request<{ ok: boolean; profile: FitnessProfile }>("/api/v1/fitness/profile", {
|
||||
method: "PUT",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
.fitness-charts-section {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fitness-charts-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.fitness-charts-subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #8b95a8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.fitness-chart-status {
|
||||
margin: 0;
|
||||
color: #8b95a8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.fitness-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.fitness-chart-card {
|
||||
background: #10141c;
|
||||
border: 1px solid #242b38;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fitness-chart-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fitness-chart-card-header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.fitness-chart-unit {
|
||||
color: #8b95a8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.fitness-chart-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #7d8799;
|
||||
}
|
||||
|
||||
.fitness-chart-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fitness-chart-grid {
|
||||
stroke: #232a36;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.fitness-chart-axis {
|
||||
fill: #7d8799;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.fitness-chart-dot {
|
||||
fill: #5b9bd5;
|
||||
stroke: #151922;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.fitness-chart-trend {
|
||||
fill: none;
|
||||
stroke: #d4a15a;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fitness-charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useMemo } from "react";
|
||||
import { FitnessChartPoint, FitnessChartSeries, FitnessChartsResponse } from "../api/client";
|
||||
import "./FitnessCharts.css";
|
||||
|
||||
const CHART_KEYS = ["weight_kg", "calories", "protein_g", "water_l", "steps", "body_fat_pct"] as const;
|
||||
|
||||
interface MetricChartProps {
|
||||
series: FitnessChartSeries;
|
||||
showTrend: boolean;
|
||||
granularity: "week" | "day";
|
||||
}
|
||||
|
||||
function formatTick(point: FitnessChartPoint, granularity: "week" | "day") {
|
||||
if (granularity === "day") {
|
||||
const d = new Date(`${point.date}T12:00:00`);
|
||||
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
||||
}
|
||||
const d = new Date(`${point.week_start}T12:00:00`);
|
||||
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
||||
}
|
||||
|
||||
function MetricChart({ series, showTrend, granularity }: MetricChartProps) {
|
||||
const layout = useMemo(() => {
|
||||
const width = 640;
|
||||
const height = 168;
|
||||
const pad = { top: 12, right: 12, bottom: 28, left: 44 };
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
|
||||
const active = series.points.filter((p) => p.has_data && p.value != null);
|
||||
if (active.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const xMax = Math.max(1, series.points.length - 1);
|
||||
const values = active.map((p) => p.value as number);
|
||||
let yMin = Math.min(...values);
|
||||
let yMax = Math.max(...values);
|
||||
if (yMin === yMax) {
|
||||
yMin -= 1;
|
||||
yMax += 1;
|
||||
} else {
|
||||
const padY = (yMax - yMin) * 0.12;
|
||||
yMin -= padY;
|
||||
yMax += padY;
|
||||
}
|
||||
|
||||
const xScale = (index: number) => pad.left + (index / xMax) * plotW;
|
||||
const yScale = (value: number) => pad.top + plotH - ((value - yMin) / (yMax - yMin)) * plotH;
|
||||
|
||||
const dots = active.map((p) => ({
|
||||
x: xScale(p.index),
|
||||
y: yScale(p.value as number),
|
||||
label: formatTick(p, granularity),
|
||||
value: p.value as number,
|
||||
}));
|
||||
|
||||
let trendPath = "";
|
||||
if (showTrend && series.trend?.line) {
|
||||
const trendPoints = series.trend.line
|
||||
.map((p) => `${xScale(p.index)},${yScale(p.value)}`)
|
||||
.join(" ");
|
||||
trendPath = trendPoints;
|
||||
}
|
||||
|
||||
const yTicks = [yMin, (yMin + yMax) / 2, yMax];
|
||||
const labelIndexes = active.length <= 4
|
||||
? active.map((p) => p.index)
|
||||
: [active[0].index, active[active.length - 1].index];
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
pad,
|
||||
plotW,
|
||||
plotH,
|
||||
yMin,
|
||||
yMax,
|
||||
yScale,
|
||||
xScale,
|
||||
dots,
|
||||
trendPath,
|
||||
yTicks,
|
||||
labelIndexes,
|
||||
};
|
||||
}, [granularity, series, showTrend]);
|
||||
|
||||
if (!layout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slopeLabel =
|
||||
granularity === "day" && series.trend && "slope_per_day" in series.trend
|
||||
? `${(series.trend as { slope_per_day: number }).slope_per_day > 0 ? "+" : ""}${(series.trend as { slope_per_day: number }).slope_per_day}/день`
|
||||
: series.trend && "slope_per_week" in series.trend
|
||||
? `${(series.trend as { slope_per_week: number }).slope_per_week > 0 ? "+" : ""}${(series.trend as { slope_per_week: number }).slope_per_week}/нед`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<article className="fitness-chart-card">
|
||||
<div className="fitness-chart-card-header">
|
||||
<h4>
|
||||
{series.label} <span className="fitness-chart-unit">({series.unit})</span>
|
||||
</h4>
|
||||
<span className="fitness-chart-meta">
|
||||
{series.data_points} {granularity === "day" ? "дн." : "нед."}
|
||||
{showTrend && slopeLabel ? ` · тренд ${slopeLabel}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className="fitness-chart-svg"
|
||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||
role="img"
|
||||
aria-label={`График ${series.label}`}
|
||||
>
|
||||
{layout.yTicks.map((tick) => (
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={layout.pad.left}
|
||||
x2={layout.pad.left + layout.plotW}
|
||||
y1={layout.yScale(tick)}
|
||||
y2={layout.yScale(tick)}
|
||||
className="fitness-chart-grid"
|
||||
/>
|
||||
<text x={layout.pad.left - 6} y={layout.yScale(tick) + 4} className="fitness-chart-axis">
|
||||
{tick.toFixed(tick >= 100 ? 0 : 1)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{showTrend && layout.trendPath ? (
|
||||
<polyline points={layout.trendPath} className="fitness-chart-trend" />
|
||||
) : null}
|
||||
|
||||
{layout.dots.map((dot) => (
|
||||
<g key={`${dot.label}-${dot.value}`}>
|
||||
<circle cx={dot.x} cy={dot.y} r={4.5} className="fitness-chart-dot" />
|
||||
<title>
|
||||
{dot.label}: {dot.value.toFixed(1)} {series.unit}
|
||||
</title>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{layout.labelIndexes.map((idx) => {
|
||||
const point = series.points[idx];
|
||||
if (!point?.has_data) return null;
|
||||
return (
|
||||
<text
|
||||
key={idx}
|
||||
x={layout.xScale(idx)}
|
||||
y={layout.height - 8}
|
||||
textAnchor="middle"
|
||||
className="fitness-chart-axis"
|
||||
>
|
||||
{formatTick(point, granularity)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface FitnessChartsProps {
|
||||
data: FitnessChartsResponse | null;
|
||||
showTrend: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function FitnessCharts({ data, showTrend, loading }: FitnessChartsProps) {
|
||||
if (loading) {
|
||||
return <p className="fitness-chart-status">Загрузка графиков…</p>;
|
||||
}
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const granularity = data.granularity;
|
||||
const source =
|
||||
granularity === "day" && data.daily_series ? data.daily_series : data.series;
|
||||
|
||||
const charts = CHART_KEYS.map((key) => source[key]).filter(
|
||||
(series) => series && series.data_points > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="fitness-section fitness-charts-section">
|
||||
<div className="fitness-charts-head">
|
||||
<div>
|
||||
<h3>Динамика за год</h3>
|
||||
<p className="fitness-charts-subtitle">
|
||||
{granularity === "day"
|
||||
? `Мало данных (${data.days_with_data} дн.) — показаны дневные точки`
|
||||
: `Недельные точки · заполнено ${data.weeks_with_data} из ${data.weeks} нед.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{charts.length === 0 ? (
|
||||
<p className="fitness-empty">
|
||||
Пока нет данных для графиков. Логируй еду, воду, шаги или вес — точки появятся автоматически.
|
||||
</p>
|
||||
) : (
|
||||
<div className="fitness-charts-grid">
|
||||
{charts.map((series) => (
|
||||
<MetricChart
|
||||
key={series.key}
|
||||
series={series}
|
||||
showTrend={showTrend}
|
||||
granularity={granularity}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
.fitness-page {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.fitness-header {
|
||||
@@ -62,11 +66,16 @@
|
||||
|
||||
.fitness-day-title {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fitness-day-title h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
min-height: 1.35em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fitness-day-title input[type="date"] {
|
||||
@@ -84,6 +93,8 @@
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.25rem;
|
||||
max-width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.fitness-week-day {
|
||||
@@ -125,6 +136,17 @@
|
||||
border: 1px solid #2a2f3a;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fitness-day-panel {
|
||||
min-height: 11rem;
|
||||
}
|
||||
|
||||
.fitness-progress {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fitness-section h3 {
|
||||
@@ -146,10 +168,20 @@
|
||||
.fitness-progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.fitness-progress-header span:last-child {
|
||||
text-align: right;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fitness-progress-track {
|
||||
height: 8px;
|
||||
background: #0f1218;
|
||||
@@ -195,6 +227,7 @@
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.9rem;
|
||||
color: #8b95a8;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fitness-body-calc {
|
||||
@@ -237,6 +270,13 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.fitness-table-wrap {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.fitness-table-wide th,
|
||||
.fitness-table-wide td {
|
||||
white-space: nowrap;
|
||||
@@ -251,10 +291,13 @@
|
||||
.fitness-log-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid #1e2430;
|
||||
font-size: 0.9rem;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fitness-log-list button {
|
||||
@@ -349,6 +392,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fitness-activity-block p {
|
||||
@@ -359,6 +403,7 @@
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #c5d0e0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fitness-workout-stats h4 {
|
||||
@@ -371,3 +416,53 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fitness-charts-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.fitness-charts-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
color: #a8b0bd;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fitness-charts-toggle input {
|
||||
accent-color: #4a7cff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fitness-page {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.fitness-section {
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.fitness-header {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.fitness-header-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.fitness-progress-header {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.fitness-day-panel {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.fitness-profile-form {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ import {
|
||||
api,
|
||||
BodyCompositionComputed,
|
||||
BodyMetric,
|
||||
FitnessChartsResponse,
|
||||
FitnessDailySummary,
|
||||
FitnessHistory,
|
||||
FitnessProfile,
|
||||
FitnessReminder,
|
||||
FitnessSnapshot,
|
||||
} from "../api/client";
|
||||
import FitnessCharts from "../components/FitnessCharts";
|
||||
import "./Fitness.css";
|
||||
|
||||
function todayIso() {
|
||||
@@ -89,26 +91,33 @@ export default function Fitness() {
|
||||
const [calcResult, setCalcResult] = useState<BodyCompositionComputed | null>(null);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [charts, setCharts] = useState<FitnessChartsResponse | null>(null);
|
||||
const [chartsLoading, setChartsLoading] = useState(false);
|
||||
const [showTrend, setShowTrend] = useState(true);
|
||||
|
||||
const load = useCallback(async (day: string = selectedDate) => {
|
||||
setLoading(true);
|
||||
setChartsLoading(true);
|
||||
try {
|
||||
const [data, summary, hist] = await Promise.all([
|
||||
const [data, summary, hist, chartData] = await Promise.all([
|
||||
api.getFitnessSnapshot(),
|
||||
api.getFitnessSummary(day),
|
||||
api.getFitnessHistory(7, day),
|
||||
api.getFitnessCharts(52, showTrend, day),
|
||||
]);
|
||||
setSnapshot(data);
|
||||
setDaySummary(summary);
|
||||
setHistory(hist);
|
||||
setCharts(chartData);
|
||||
if (data.profile) setProfile(data.profile);
|
||||
setMessage("");
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Ошибка загрузки");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setChartsLoading(false);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
}, [selectedDate, showTrend]);
|
||||
|
||||
useEffect(() => {
|
||||
load(selectedDate).catch(console.error);
|
||||
@@ -245,7 +254,7 @@ export default function Fitness() {
|
||||
)}
|
||||
|
||||
{totals && targets ? (
|
||||
<>
|
||||
<div className="fitness-day-panel">
|
||||
{activity ? (
|
||||
<div className="fitness-activity-block">
|
||||
<p>
|
||||
@@ -304,12 +313,24 @@ export default function Fitness() {
|
||||
unit="л"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<p className="fitness-empty">Нет записей за этот день</p>
|
||||
<p className="fitness-empty fitness-day-panel">Нет записей за этот день</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="fitness-charts-controls">
|
||||
<label className="fitness-charts-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTrend}
|
||||
onChange={(e) => setShowTrend(e.target.checked)}
|
||||
/>
|
||||
Линия тренда (МНК)
|
||||
</label>
|
||||
</div>
|
||||
<FitnessCharts data={charts} showTrend={showTrend} loading={chartsLoading} />
|
||||
|
||||
<section className="fitness-section">
|
||||
<h3>Профиль и цели</h3>
|
||||
<form className="fitness-profile-form" onSubmit={handleProfileSave}>
|
||||
@@ -543,6 +564,7 @@ export default function Fitness() {
|
||||
|
||||
<section className="fitness-section">
|
||||
<h3>Антропометрия</h3>
|
||||
<div className="fitness-table-wrap">
|
||||
<table className="fitness-table fitness-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -571,6 +593,7 @@ export default function Fitness() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="fitness-section">
|
||||
|
||||
Reference in New Issue
Block a user