smart tdee

This commit is contained in:
2026-06-16 04:38:23 +00:00
parent f2e98942ff
commit a3f01cd850
56 changed files with 2519 additions and 591 deletions
+76 -21
View File
@@ -63,6 +63,17 @@ export interface ChatStreamChunk {
data: Record<string, unknown>;
}
export interface VisionDebugPayload {
model?: string | string[];
count?: number;
parsed?: Record<string, unknown>;
raw_content?: string;
image_meta?: Record<string, unknown>;
usage?: Record<string, unknown>;
parse_error?: string | null;
images?: VisionDebugPayload[];
}
async function* readChatSse(response: Response): AsyncGenerator<ChatStreamChunk> {
if (!response.ok || !response.body) {
const detail = await response.text().catch(() => "");
@@ -167,21 +178,36 @@ export interface WeatherHourly {
conditions?: string;
}
export interface WeatherDaily {
date?: string;
label?: string;
temperature_max_c?: number | null;
temperature_min_c?: number | null;
precipitation_sum_mm?: number | null;
precipitation_probability_max?: number | null;
wind_speed_max_kmh?: number | null;
weather_code?: number | null;
conditions?: string;
}
export interface WeatherSnapshot {
ok: boolean;
location?: string;
error?: string;
field_coverage?: { current: string[]; hourly: string[] };
local_field_coverage?: { current: string[]; hourly: string[] };
field_coverage?: { current: string[]; hourly: string[]; daily: string[] };
local_field_coverage?: { current: string[]; hourly: string[]; daily: string[] };
data_source?: string;
merged_fields?: string[];
sync_hint?: string;
current?: WeatherCurrent;
hourly?: WeatherHourly[];
daily?: WeatherDaily[];
}
export interface WeatherDashboard {
weather: WeatherSnapshot;
rain_summary: string;
daily_summary: string;
assistant_context: string;
cache: {
has_data: boolean;
@@ -191,6 +217,7 @@ export interface WeatherDashboard {
ttl_sec: number;
expires_in_sec: number | null;
source?: string;
merged_fields?: string[];
};
config: {
location: string;
@@ -204,10 +231,12 @@ export interface WeatherDashboard {
available_fields: {
current: string[];
hourly: string[];
daily: string[];
};
field_coverage: { current: string[]; hourly: string[] };
local_field_coverage: { current: string[]; hourly: string[] };
field_coverage: { current: string[]; hourly: string[]; daily: string[] };
local_field_coverage: { current: string[]; hourly: string[]; daily: string[] };
data_source: string;
merged_fields: string[];
sync_hint: string;
recommended_sync: { domains: string; variables: string };
assistant_tools: Record<string, string>;
@@ -254,15 +283,14 @@ export interface MemoryFact {
}
export interface FitnessActivityBonus {
export interface FitnessTdeeBreakdown {
bmr: number;
neat_kcal: number;
steps_kcal: number;
workout_kcal: number;
tdee: number;
calorie_target: number;
steps: number;
steps_baseline: number;
steps_bonus_kcal: number;
workout_active_kcal: number;
workout_baseline_kcal: number;
workout_bonus_kcal: number;
total_bonus_kcal: number;
scale_factor: number;
}
export interface FitnessTargets {
@@ -297,6 +325,9 @@ export interface FitnessComputed {
bmr: number;
tdee: number;
bmi: number;
neat_kcal?: number;
steps_kcal?: number;
workout_kcal?: number;
}
export interface FitnessProfile {
@@ -304,12 +335,9 @@ export interface FitnessProfile {
age?: number;
height_cm?: number;
weight_kg?: number;
activity_level?: string;
goal?: string;
target_weight_kg?: number | null;
weekly_workouts?: number;
baseline_steps?: number | null;
baseline_workout_kcal?: number | null;
neat_base_kcal?: number;
calorie_target?: number;
protein_g?: number;
fat_g?: number;
@@ -359,8 +387,7 @@ export interface FitnessDailySummary {
steps?: number;
};
targets: FitnessTargets;
targets_base?: FitnessTargets;
activity?: FitnessActivityBonus;
tdee_breakdown?: FitnessTdeeBreakdown;
steps?: StepLogItem[];
steps_total?: number;
meals: FoodLogItem[];
@@ -407,7 +434,7 @@ export interface FitnessDayOverview {
has_data: boolean;
totals: FitnessDailySummary["totals"];
targets: FitnessDailySummary["targets"];
targets_base?: FitnessTargets;
tdee_breakdown?: FitnessTdeeBreakdown;
meal_count: number;
workout_count: number;
}
@@ -579,6 +606,31 @@ export const api = {
yield* readChatSse(response);
},
sendMessageWithImage: async function* (sessionId: number, content: string, file: File) {
yield* api.sendMessageWithImages(sessionId, content, [file]);
},
sendMessageWithImages: async function* (sessionId: number, content: string, files: File[]) {
const form = new FormData();
form.append("content", content);
for (const file of files) {
form.append("images", file);
}
const response = await fetch(`${API_BASE}/api/v1/chat/sessions/${sessionId}/messages`, {
method: "POST",
headers: authHeaders(),
body: form,
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(detail || `Ошибка отправки (${response.status})`);
}
yield* readChatSse(response);
},
streamGeneration: async function* (sessionId: number) {
const response = await fetch(
`${API_BASE}/api/v1/chat/sessions/${sessionId}/generation/stream`,
@@ -634,8 +686,10 @@ export const api = {
{ method: "POST" }
),
weatherDashboard: (hoursAhead = 12) =>
request<WeatherDashboard>(`/api/v1/homelab/weather?hours_ahead=${hoursAhead}`),
weatherDashboard: (hoursAhead = 12, daysAhead = 7) =>
request<WeatherDashboard>(
`/api/v1/homelab/weather?hours_ahead=${hoursAhead}&days_ahead=${daysAhead}`,
),
getCharacter: () => request<CharacterCardV2>("/api/v1/character"),
@@ -914,6 +968,7 @@ export interface RemindersCalendar {
export interface AssistantSettings {
openrouter_model: string;
memory_extract_model: string;
openrouter_vision_model: string;
openrouter_reasoning_effort: string;
rag_enabled: boolean;
rag_top_k: number;
+44 -18
View File
@@ -1,33 +1,56 @@
import { memo, useMemo } from "react";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import { ChatMessage } from "../api/client";
import { ChatMessage, getAuthToken } 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:")) {
if (/^https?:\/\//i.test(src) || src.startsWith("data:") || src.startsWith("blob:")) {
return src;
}
if (src.startsWith("/")) {
return `${API_BASE}${src}`;
let path = src;
if (path.startsWith("/")) {
path = `${API_BASE}${path}`;
}
return src;
if (path.includes("/media/uploads/")) {
const token = getAuthToken();
if (token) {
const url = new URL(path, window.location.origin);
url.searchParams.set("token", token);
return url.toString();
}
}
return path;
}
function createMarkdownComponents(onContentResize?: () => void): Components {
return {
img: ({ src, alt }) => (
<img
src={resolveMediaUrl(src)}
alt={alt ?? ""}
loading="lazy"
decoding="async"
onLoad={onContentResize}
onError={onContentResize}
/>
),
img: ({ src, alt }) => {
const resolved = resolveMediaUrl(src);
if (!resolved) return null;
return (
<a
className="message-image-link"
href={resolved}
target="_blank"
rel="noopener noreferrer"
>
<img
src={resolved}
alt={alt ?? ""}
loading="lazy"
decoding="async"
onLoad={onContentResize}
onError={onContentResize}
/>
</a>
);
},
};
}
@@ -57,7 +80,10 @@ function messageClassName(role: string): string {
return role;
}
function usesMarkdown(role: string): boolean {
function usesMarkdown(role: string, content: string): boolean {
if (role === "user") {
return /!\[[^\]]*\]\([^)]+\)/.test(content);
}
return role === "assistant" || role === "notice" || role === "character";
}
@@ -74,10 +100,10 @@ function MessageBubbleInner({ msg, onContentResize }: MessageBubbleProps) {
const markdown = useMemo(
() =>
usesMarkdown(msg.role) ? (
usesMarkdown(msg.role, msg.content) ? (
<ReactMarkdown components={markdownComponents}>{msg.content}</ReactMarkdown>
) : (
msg.content
<span className="message-plain-text">{msg.content}</span>
),
[msg.role, msg.content, markdownComponents],
);
+39
View File
@@ -201,6 +201,45 @@
line-height: 1.45;
}
.weather-widget-tomorrow .weather-widget-note {
font-weight: 600;
color: #dce3ee;
}
.weather-widget-tabs {
display: flex;
align-items: center;
gap: 0.35rem;
margin: 0.5rem 0 0.75rem;
flex-wrap: wrap;
}
.weather-widget-tabs button {
padding: 0.35rem 0.65rem;
border: 1px solid #3a4254;
border-radius: 6px;
background: #1b2130;
color: #a8b0bd;
cursor: pointer;
font-size: 0.78rem;
}
.weather-widget-tabs button.active {
background: #2b3445;
color: #fff;
border-color: #4f7cff;
}
.weather-widget-hours {
margin-left: auto;
padding: 0.3rem 0.45rem;
border: 1px solid #3a4254;
border-radius: 6px;
background: #1b2130;
color: #c5ccd6;
font-size: 0.78rem;
}
@media (max-width: 768px) {
.weather-widget-panel {
position: fixed;
+99 -60
View File
@@ -29,6 +29,13 @@ function cacheLabel(cache: WeatherDashboard["cache"]): string {
return "только что загружено";
}
function sourceLabel(data: WeatherDashboard | null): string {
if (!data) return "";
if (data.data_source === "fallback") return " · api.open-meteo.com";
if (data.data_source === "merged") return " · local + fallback";
return "";
}
interface WeatherWidgetProps {
compact?: boolean;
}
@@ -38,12 +45,14 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hoursAhead, setHoursAhead] = useState(12);
const [view, setView] = useState<"hourly" | "daily">("hourly");
const rootRef = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const dash = await api.weatherDashboard(12);
const dash = await api.weatherDashboard(hoursAhead, 7);
setData(dash);
setError(dash.weather.ok ? null : dash.weather.error ?? "OpenMeteo недоступен");
} catch (err) {
@@ -51,7 +60,7 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
} finally {
setLoading(false);
}
}, []);
}, [hoursAhead]);
useEffect(() => {
load().catch(() => undefined);
@@ -78,6 +87,7 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
}, [open]);
const cur = data?.weather.current;
const tomorrow = data?.weather.daily?.[1];
const compactLabel =
cur?.temperature_c != null
? `${Math.round(cur.temperature_c)}° · ${cur.conditions ?? "—"}`
@@ -111,7 +121,7 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
<strong>OpenMeteo</strong>
<span className="weather-widget-sub">
{data?.config.location ?? "—"} · {cacheLabel(data?.cache ?? { has_data: false, cached: false, fetched_at: null, age_sec: null, ttl_sec: 300, expires_in_sec: null })}
{data?.data_source === "fallback" && " · данные с api.open-meteo.com"}
{sourceLabel(data)}
</span>
</div>
<button type="button" className="weather-widget-refresh" onClick={() => load()} disabled={loading}>
@@ -121,42 +131,7 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
{error && !data?.weather.ok && <p className="weather-widget-error">{error}</p>}
{data?.sync_hint && (
<p className="weather-widget-warn">
{data.sync_hint}
{data.recommended_sync && (
<>
<br />
<code>SYNC_DOMAINS={data.recommended_sync.domains}</code>
<br />
<code>SYNC_VARIABLES={data.recommended_sync.variables}</code>
</>
)}
</p>
)}
{data?.field_coverage &&
data.data_source !== "fallback" &&
(data.field_coverage.current.length < data.available_fields.current.length ||
data.field_coverage.hourly.length < data.available_fields.hourly.length) && (
<p className="weather-widget-warn">
OpenMeteo вернул не все поля. Пришло: current {" "}
{data.field_coverage.current.join(", ") || "ничего"}; hourly {" "}
{data.field_coverage.hourly.join(", ") || "ничего"}. Проверь sync на{" "}
{data.config.openmeteo_base_url}.
</p>
)}
{data?.local_field_coverage &&
data.data_source === "fallback" &&
(data.local_field_coverage.current.length < data.available_fields.current.length ||
data.local_field_coverage.hourly.length < data.available_fields.hourly.length) && (
<p className="weather-widget-warn">
Локальный OpenMeteo ({data.config.openmeteo_base_url}) отдаёт только: current {" "}
{data.local_field_coverage.current.join(", ") || "ничего"}; hourly {" "}
{data.local_field_coverage.hourly.join(", ") || "ничего"}. Показаны данные fallback.
</p>
)}
{data?.sync_hint && <p className="weather-widget-warn">{data.sync_hint}</p>}
{data?.weather.ok && cur && (
<section className="weather-widget-section">
@@ -194,16 +169,56 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
</section>
)}
{tomorrow && (
<section className="weather-widget-section weather-widget-tomorrow">
<h4>Завтра</h4>
<p className="weather-widget-note">
{tomorrow.label}: {tomorrow.temperature_min_c ?? "—"}{tomorrow.temperature_max_c ?? "—"}°C,{" "}
{tomorrow.conditions}
{tomorrow.precipitation_probability_max != null && tomorrow.precipitation_probability_max >= 30
? `, дождь до ${tomorrow.precipitation_probability_max}%`
: ""}
</p>
</section>
)}
{data?.rain_summary && (
<section className="weather-widget-section">
<h4>Осадки (12 ч)</h4>
<h4>Осадки</h4>
<p className="weather-widget-note">{data.rain_summary}</p>
</section>
)}
{(data?.weather.hourly?.length ?? 0) > 0 && (
<div className="weather-widget-tabs">
<button
type="button"
className={view === "hourly" ? "active" : ""}
onClick={() => setView("hourly")}
>
По часам
</button>
<button
type="button"
className={view === "daily" ? "active" : ""}
onClick={() => setView("daily")}
>
7 дней
</button>
{view === "hourly" && (
<select
className="weather-widget-hours"
value={hoursAhead}
onChange={(e) => setHoursAhead(Number(e.target.value))}
>
<option value={12}>12 ч</option>
<option value={24}>24 ч</option>
<option value={48}>48 ч</option>
</select>
)}
</div>
{view === "hourly" && (data?.weather.hourly?.length ?? 0) > 0 && (
<section className="weather-widget-section">
<h4>По часам</h4>
<div className="weather-widget-table-wrap">
<table className="weather-widget-table">
<thead>
@@ -231,6 +246,40 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
</section>
)}
{view === "daily" && (data?.weather.daily?.length ?? 0) > 0 && (
<section className="weather-widget-section">
{data?.daily_summary && <p className="weather-widget-note">{data.daily_summary}</p>}
<div className="weather-widget-table-wrap">
<table className="weather-widget-table">
<thead>
<tr>
<th>День</th>
<th>°C</th>
<th>Осадки</th>
<th>Дождь</th>
<th>Ветер</th>
<th>Условия</th>
</tr>
</thead>
<tbody>
{data!.weather.daily!.map((row) => (
<tr key={row.date}>
<td>{row.label}</td>
<td>
{row.temperature_min_c ?? "—"}{row.temperature_max_c ?? "—"}
</td>
<td>{row.precipitation_sum_mm ?? 0} мм</td>
<td>{row.precipitation_probability_max ?? "—"}%</td>
<td>{row.wind_speed_max_kmh ?? "—"}</td>
<td>{row.conditions}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{data?.assistant_context && (
<section className="weather-widget-section">
<h4>Контекст ассистента</h4>
@@ -254,25 +303,15 @@ export default function WeatherWidget({ compact = false }: WeatherWidgetProps) {
<dd>{data.config.openmeteo_base_url}/v1/forecast</dd>
</div>
<div>
<dt>TTL кэша</dt>
<dd>{data.config.cache_ttl_sec} с</dd>
</div>
<div>
<dt>Current fields</dt>
<dd>
запрошено: {data.available_fields.current.join(", ")}
<br />
получено: {data.field_coverage.current.join(", ") || "—"}
</dd>
</div>
<div>
<dt>Hourly fields</dt>
<dd>
запрошено: {data.available_fields.hourly.join(", ")}
<br />
получено: {data.field_coverage.hourly.join(", ") || "—"}
</dd>
<dt>Прогноз</dt>
<dd>{data.config.forecast_days} дней</dd>
</div>
{data.merged_fields.length > 0 && (
<div>
<dt>Доп. с fallback</dt>
<dd>{data.merged_fields.join(", ")}</dd>
</div>
)}
</dl>
<ul className="weather-widget-tools">
{Object.entries(data.assistant_tools).map(([name, desc]) => (
+128 -4
View File
@@ -204,7 +204,8 @@
.chat-input {
display: flex;
gap: 0.75rem;
flex-direction: column;
gap: 0.5rem;
flex-shrink: 0;
padding: 0.75rem 1rem;
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
@@ -212,6 +213,120 @@
background: #0f1115;
}
.chat-input-row {
display: flex;
gap: 0.75rem;
align-items: stretch;
}
.chat-input-dragover {
outline: 2px dashed #4f7cff;
outline-offset: -2px;
background: #141a28;
}
.chat-file-input {
display: none;
}
.chat-attach-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
background: #2a3142;
color: inherit;
border: 1px solid #3a4558;
border-radius: 8px;
padding: 0 0.75rem;
cursor: pointer;
}
.chat-image-previews {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.chat-image-previews .chat-image-clear-all {
align-self: center;
background: transparent;
color: #9aa5b5;
border: 1px solid #3a4558;
border-radius: 8px;
padding: 0.35rem 0.6rem;
cursor: pointer;
font-size: 0.85rem;
}
.chat-image-previews .chat-image-clear-all:hover {
color: #e8edf4;
}
.chat-vision-debug-item {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #3a4558;
}
.chat-vision-debug-item:first-of-type {
margin-top: 0.5rem;
padding-top: 0;
border-top: none;
}
.chat-image-preview {
position: relative;
display: inline-block;
max-width: 160px;
}
.chat-image-preview img {
display: block;
max-width: 160px;
max-height: 120px;
border-radius: 8px;
border: 1px solid #3a4558;
}
.chat-image-preview button {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.65);
color: white;
border: none;
border-radius: 999px;
width: 24px;
height: 24px;
cursor: pointer;
}
.chat-vision-debug {
margin: 0 1rem 0.5rem;
padding: 0.75rem;
border: 1px solid #3a4558;
border-radius: 8px;
background: #12151c;
font-size: 0.85rem;
}
.chat-vision-debug pre {
white-space: pre-wrap;
word-break: break-word;
margin: 0.5rem 0 0;
max-height: 240px;
overflow: auto;
}
.chat-vision-error {
color: #ff8a80;
margin: 0.25rem 0;
}
.chat-input textarea {
flex: 1;
min-width: 0;
@@ -222,16 +337,25 @@
color: inherit;
padding: 0.75rem 1rem;
font-size: 16px;
line-height: 1.35;
}
.chat-input button {
align-self: flex-end;
.chat-input-row button[type="submit"] {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 6.5rem;
background: #4f7cff;
color: white;
border: none;
border-radius: 8px;
padding: 0.65rem 1rem;
padding: 0 1rem;
}
.chat-input-row button[type="submit"]:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.chat-input button:disabled {
+23
View File
@@ -8,6 +8,29 @@
overflow-wrap: anywhere;
}
.message-user .message-content,
.message-plain-text {
white-space: pre-wrap;
word-break: break-word;
}
.message-user .message-content img,
.message-image-link img {
max-width: min(100%, 280px);
width: auto;
max-height: 220px;
margin: 0 0 0.5rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
object-fit: cover;
cursor: zoom-in;
}
.message-image-link {
display: inline-block;
line-height: 0;
}
.message-content img {
max-width: 100%;
width: 100%;
+178 -25
View File
@@ -1,4 +1,4 @@
import { FormEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { DragEvent, FormEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { api, ChatMessage, ChatSession, ChatStreamChunk } from "../api/client";
import MessageList, { MessageListHandle } from "../components/MessageList";
import PomodoroWidget from "../components/PomodoroWidget";
@@ -12,11 +12,31 @@ const INITIAL_MESSAGE_LIMIT = 30;
const LOAD_OLDER_LIMIT = 30;
const SYNC_TAIL_LIMIT = 15;
const GENERATION_POLL_MS = 2000;
const MAX_PENDING_IMAGES = 8;
type PendingImageItem = {
file: File;
previewUrl: string;
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildImageMarkdown(items: PendingImageItem[]): string {
if (!items.length) return "";
return items
.map((item, index) => {
const label = items.length > 1 ? `скриншот ${index + 1}/${items.length}` : "скриншот";
return `![${label}](${item.previewUrl})`;
})
.join("\n");
}
function buildUserMessagePreview(items: PendingImageItem[], text: string): string {
return [buildImageMarkdown(items), text.trim()].filter(Boolean).join("\n\n");
}
function shouldShowMessage(msg: ChatMessage): boolean {
if (msg.role === "tool") return false;
if (msg.role === "assistant" && msg.tool_calls_json) return false;
@@ -36,7 +56,10 @@ export default function Chat() {
"thinking" | "preparing" | "generating" | "tools"
>("thinking");
const [chatError, setChatError] = useState<string | null>(null);
const [pendingImages, setPendingImages] = useState<PendingImageItem[]>([]);
const [inputDragOver, setInputDragOver] = useState(false);
const tempMessageId = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesRef = useRef<HTMLDivElement>(null);
const messageListRef = useRef<MessageListHandle>(null);
const bottomAnchorRef = useRef<HTMLDivElement>(null);
@@ -133,6 +156,9 @@ export default function Chat() {
const processStreamChunk = useCallback(
(chunk: ChatStreamChunk, assistantTextRef: { current: string }) => {
if (chunk.event === "vision") {
setPendingPhase("preparing");
}
if (chunk.event === "status") {
const phase = chunk.data.phase;
if (phase === "preparing") {
@@ -368,6 +394,17 @@ export default function Chat() {
usePomodoroNotify(handlePomodoroNotify);
useEffect(() => {
return () => {
setPendingImages((prev) => {
for (const item of prev) {
URL.revokeObjectURL(item.previewUrl);
}
return prev;
});
};
}, []);
useEffect(() => {
let cancelled = false;
@@ -422,30 +459,103 @@ export default function Chat() {
}
};
const clearPendingImages = useCallback(() => {
setPendingImages((prev) => {
for (const item of prev) {
URL.revokeObjectURL(item.previewUrl);
}
return [];
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, []);
const removePendingImage = useCallback((index: number) => {
setPendingImages((prev) => {
const next = [...prev];
const [removed] = next.splice(index, 1);
if (removed) {
URL.revokeObjectURL(removed.previewUrl);
}
return next;
});
}, []);
const handleImagePick = (fileList: FileList | null) => {
if (!fileList?.length) return;
const picked = Array.from(fileList).filter((file) => file.type.startsWith("image/"));
if (!picked.length) return;
setPendingImages((prev) => {
const room = MAX_PENDING_IMAGES - prev.length;
if (room <= 0) return prev;
const nextItems = picked.slice(0, room).map((file) => ({
file,
previewUrl: URL.createObjectURL(file),
}));
return [...prev, ...nextItems];
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleInputDragOver = (event: DragEvent<HTMLFormElement>) => {
event.preventDefault();
if (loading) return;
setInputDragOver(true);
};
const handleInputDragLeave = (event: DragEvent<HTMLFormElement>) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setInputDragOver(false);
};
const handleInputDrop = (event: DragEvent<HTMLFormElement>) => {
event.preventDefault();
setInputDragOver(false);
if (loading) return;
handleImagePick(event.dataTransfer.files);
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!input.trim() || !activeId || loading) return;
if (!activeId || loading) return;
const text = input.trim();
const submittingImages = [...pendingImages];
if (!text && submittingImages.length === 0) return;
setInput("");
dismissKeyboard();
stickToBottomRef.current = true;
setLoading(true);
resetStreaming();
setPendingPhase("thinking");
setPendingPhase(submittingImages.length > 0 ? "preparing" : "thinking");
setChatError(null);
const displayContent = buildUserMessagePreview(submittingImages, text);
const tempUser: ChatMessage = {
id: nextTempId(),
role: "user",
content: text,
content: displayContent,
created_at: new Date().toISOString(),
};
applyMessages((prev) => [...prev, tempUser], { scrollToBottom: true });
const imageFiles = submittingImages.map((item) => item.file);
setPendingImages([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
try {
const assistantTextRef = { current: "" };
for await (const chunk of api.sendMessage(activeId, text)) {
const stream = imageFiles.length
? api.sendMessageWithImages(activeId, text, imageFiles)
: api.sendMessage(activeId, text);
for await (const chunk of stream) {
processStreamChunk(chunk, assistantTextRef);
if (chunk.event === "done") {
flushStreaming();
@@ -478,6 +588,9 @@ export default function Chat() {
await syncRecentMessages(activeId);
}
} finally {
for (const item of submittingImages) {
URL.revokeObjectURL(item.previewUrl);
}
setLoading(false);
if (pendingHistoryReload.current && activeId) {
pendingHistoryReload.current = false;
@@ -584,25 +697,65 @@ export default function Chat() {
/>
</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
className={`chat-input${inputDragOver ? " chat-input-dragover" : ""}`}
onSubmit={handleSubmit}
onDragOver={handleInputDragOver}
onDragLeave={handleInputDragLeave}
onDrop={handleInputDrop}
>
{pendingImages.length ? (
<div className="chat-image-previews">
{pendingImages.map((item, index) => (
<div key={`${item.file.name}-${index}`} className="chat-image-preview">
<img src={item.previewUrl} alt={`Превью ${index + 1}`} />
<button type="button" onClick={() => removePendingImage(index)} aria-label="Убрать">
×
</button>
</div>
))}
<button type="button" className="chat-image-clear-all" onClick={clearPendingImages}>
Очистить все
</button>
</div>
) : null}
<div className="chat-input-row">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="chat-file-input"
onChange={(e) => handleImagePick(e.target.files)}
/>
<button
type="button"
className="chat-attach-btn"
title="Прикрепить скриншоты"
onClick={() => fileInputRef.current?.click()}
disabled={loading || pendingImages.length >= MAX_PENDING_IMAGES}
>
📎
</button>
<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() && pendingImages.length === 0)}>
{loading ? "..." : "Отправить"}
</button>
</div>
</form>
</>
)}
+24 -34
View File
@@ -38,20 +38,14 @@ 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 (
@@ -60,12 +54,9 @@ function ProgressBar({
<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}%` }} />
@@ -177,8 +168,7 @@ export default function Fitness() {
const totals = daySummary?.totals;
const targets = daySummary?.targets;
const targetsBase = daySummary?.targets_base;
const activity = daySummary?.activity;
const tdeeBreakdown = daySummary?.tdee_breakdown;
const workoutStats = snapshot?.workout_stats;
const latestMetric: BodyMetric | undefined = snapshot?.body_metrics?.[0];
const isToday = selectedDate === todayIso();
@@ -255,15 +245,19 @@ export default function Fitness() {
{totals && targets ? (
<div className="fitness-day-panel">
{activity ? (
{tdeeBreakdown ? (
<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})
TDEE: BMR {tdeeBreakdown.bmr} + NEAT {tdeeBreakdown.neat_kcal} + шаги{" "}
{tdeeBreakdown.steps_kcal} ({daySummary?.steps_total ?? tdeeBreakdown.steps}) + тренировки{" "}
{tdeeBreakdown.workout_kcal} = {tdeeBreakdown.tdee} ккал
</p>
<p>Цель ккал: {tdeeBreakdown.calorie_target}</p>
{!daySummary?.steps_total && !daySummary?.workouts?.length ? (
<p className="fitness-hint">
Шаги и тренировки не внесены TDEE = BMR + NEAT. Внесите данные через чат для точной цели.
</p>
) : null}
</div>
) : null}
@@ -282,28 +276,24 @@ export default function Fitness() {
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
@@ -375,19 +365,16 @@ export default function Fitness() {
/>
</label>
<label>
<span>активность</span>
<select
value={profile.activity_level ?? "moderate"}
<span>NEAT ккал</span>
<input
type="number"
min={200}
max={300}
value={profile.neat_base_kcal ?? 200}
onChange={(e) =>
setProfile((p) => ({ ...p, activity_level: e.target.value }))
setProfile((p) => ({ ...p, neat_base_kcal: Number(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>
@@ -404,10 +391,13 @@ export default function Fitness() {
</form>
{profile.computed && (
<p className="fitness-computed">
BMR {profile.computed.bmr} · TDEE {profile.computed.tdee} · BMI{" "}
{profile.computed.bmi}
BMR {profile.computed.bmr} + NEAT {profile.computed.neat_kcal ?? 200} = TDEE база{" "}
{profile.computed.tdee} · BMI {profile.computed.bmi}
</p>
)}
<p className="fitness-hint">
Дневная цель пересчитывается от фактических шагов и тренировок (TDEE ± дефицит/профицит).
</p>
<div className="fitness-body-calc">
<h4>Navy-калькулятор (без сохранения)</h4>
+10
View File
@@ -39,6 +39,7 @@ export default function Settings() {
const updated = await api.patchSettings({
openrouter_model: settings.openrouter_model,
memory_extract_model: settings.memory_extract_model,
openrouter_vision_model: settings.openrouter_vision_model,
openrouter_reasoning_effort: settings.openrouter_reasoning_effort,
rag_enabled: settings.rag_enabled,
rag_top_k: settings.rag_top_k,
@@ -109,6 +110,15 @@ export default function Settings() {
onChange={(e) => setSettings({ ...settings, memory_extract_model: e.target.value })}
/>
</label>
<label>
Vision-модель (скриншоты)
<input
value={settings.openrouter_vision_model}
onChange={(e) =>
setSettings({ ...settings, openrouter_vision_model: e.target.value })
}
/>
</label>
<label>
Reasoning effort
<select