smart tdee
This commit is contained in:
@@ -4,6 +4,9 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Скриншоты (до VISION_MAX_IMAGES штук) — иначе 413 от nginx в контейнере
|
||||
client_max_body_size 128m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
@@ -16,6 +19,7 @@ server {
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
client_max_body_size 128m;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
+76
-21
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 ``;
|
||||
})
|
||||
.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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user