const API_BASE = import.meta.env.VITE_API_URL ?? ""; const TOKEN_STORAGE_KEY = "ha_api_token"; const ENV_TOKEN = import.meta.env.VITE_API_TOKEN ?? ""; export function getAuthToken(): string { return localStorage.getItem(TOKEN_STORAGE_KEY)?.trim() || ENV_TOKEN; } export function setAuthToken(token: string): void { localStorage.setItem(TOKEN_STORAGE_KEY, token.trim()); } export function clearAuthToken(): void { localStorage.removeItem(TOKEN_STORAGE_KEY); } function authHeaders(extra: Record = {}): HeadersInit { const headers: Record = { ...extra }; const token = getAuthToken(); if (token) { headers.Authorization = `Bearer ${token}`; } return headers; } export interface AuthUser { id: number; username: string; display_name: string; } export interface ChatSession { id: number; title: string; created_at: string; updated_at: string; } export interface ChatMessage { id: number; role: string; content: string; tool_calls_json?: string | null; created_at: string; } export interface SessionDetail extends ChatSession { messages: ChatMessage[]; } export interface MessagesPage { messages: ChatMessage[]; has_more: boolean; } export interface GenerationStatus { active: boolean; } export interface ChatStreamChunk { event: string; data: Record; } export interface VisionDebugPayload { model?: string | string[]; count?: number; parsed?: Record; raw_content?: string; image_meta?: Record; usage?: Record; parse_error?: string | null; images?: VisionDebugPayload[]; } async function* readChatSse(response: Response): AsyncGenerator { if (!response.ok || !response.body) { const detail = await response.text().catch(() => ""); throw new Error(detail || `Ошибка запроса (${response.status})`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; const flushParts = function* (parts: string[]) { for (const part of parts) { if (!part.trim()) continue; const lines = part.split("\n"); let event = "message"; let data = ""; for (const line of lines) { if (line.startsWith("event: ")) event = line.slice(7); if (line.startsWith("data: ")) data = line.slice(6); } if (data) { yield { event, data: JSON.parse(data) as Record }; } } }; try { while (true) { let done = false; let value: Uint8Array | undefined; try { ({ done, value } = await reader.read()); } catch { throw new Error( "Соединение прервалось. Генерация продолжается на сервере — обновите страницу.", ); } if (value) { buffer += decoder.decode(value, { stream: !done }); } const parts = buffer.split("\n\n"); buffer = parts.pop() ?? ""; yield* flushParts(parts); if (done) { if (buffer.trim()) { yield* flushParts([buffer]); } break; } } } finally { reader.releaseLock(); } } export interface PomodoroCycle { completed_work_sessions: number; sessions_until_long_break: number; task_note: string; work_duration_min: number; short_break_min: number; long_break_min: number; auto_advance: boolean; chat_notify_seq: number; } export interface PomodoroStatus { status: string; phase: string; duration_min: number; task_note: string; elapsed_seconds: number; remaining_seconds: number; session_id: number | null; started_at?: string | null; finished_at?: string | null; cycle: PomodoroCycle; } export interface WeatherCurrent { time?: string | null; temperature_c?: number | null; apparent_temperature_c?: number | null; humidity_pct?: number | null; precipitation_mm?: number | null; wind_speed_kmh?: number | null; weather_code?: number | null; conditions?: string; } export interface WeatherHourly { time?: string; temperature_c?: number | null; precipitation_mm?: number | null; precipitation_probability?: number | null; weather_code?: number | null; 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[]; 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; cached: boolean; fetched_at: number | null; age_sec: number | null; ttl_sec: number; expires_in_sec: number | null; source?: string; merged_fields?: string[]; }; config: { location: string; latitude: number; longitude: number; openmeteo_base_url: string; cache_ttl_sec: number; forecast_days: number; timezone: string; }; available_fields: { current: string[]; hourly: string[]; daily: 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; system_prompt: string; } export interface CharacterCardData { name: string; description: string; personality: string; scenario: string; first_mes: string; mes_example: string; system_prompt: string; post_history_instructions: string; tags: string[]; creator: string; creator_notes: string; alternate_greetings: string[]; character_version: string; } export interface CharacterCardV2 { spec: string; spec_version: string; data: CharacterCardData; } export interface UserProfile { name?: string; age?: string; timezone?: string; language?: string; notes?: string; } export interface MemoryFact { id: number; category: string; content: string; importance: number; source?: string; updated_at?: string | null; } export interface FitnessTdeeBreakdown { bmr: number; neat_kcal: number; steps_kcal: number; workout_kcal: number; tdee: number; calorie_target: number; steps: number; } export interface FitnessTdeeExpected extends FitnessTdeeBreakdown { source: "weekly_avg" | "baseline" | "defaults"; lookback_days: number; days_with_data: number; } export interface FitnessTargets { calories: number; protein_g: number; fat_g: number; carbs_g: number; water_ml: number; } export interface StepLogItem { id: number; steps: number; active_calories?: number | null; source?: string; notes?: string; logged_at?: string; } export interface FitnessWorkoutStats { days: number; start_date: string; end_date: string; count: number; duration_min: number; active_kcal: number; weekly_target: number; streak: number; } export interface FitnessComputed { bmr: number; tdee: number; bmi: number; neat_kcal?: number; steps_kcal?: number; workout_kcal?: number; } export interface FitnessProfile { sex?: string; age?: number; height_cm?: number; weight_kg?: number; goal?: string; target_weight_kg?: number | null; neat_base_kcal?: number; activity_level?: string; weekly_workouts?: number; baseline_steps?: number | null; baseline_workout_kcal?: number | null; calorie_target?: number; protein_g?: number; fat_g?: number; carbs_g?: number; water_l?: number; computed?: FitnessComputed; } export interface FoodLogItem { id: number; meal_type: string; description: string; calories: number; protein_g: number; fat_g: number; carbs_g: number; estimated: boolean; logged_at?: string; } export interface WaterLogItem { id: number; amount_ml: number; logged_at?: string; } export interface WorkoutLogItem { id: number; title: string; notes?: string; duration_min?: number | null; active_calories?: number | null; total_calories?: number | null; steps?: number | null; exercises?: unknown[]; logged_at?: string; } export interface FitnessDailySummary { date: string; totals: { calories: number; protein_g: number; fat_g: number; carbs_g: number; water_ml: number; steps?: number; }; targets: FitnessTargets; targets_expected?: FitnessTargets; tdee_breakdown?: FitnessTdeeBreakdown; tdee_expected?: FitnessTdeeExpected; steps?: StepLogItem[]; steps_total?: number; meals: FoodLogItem[]; water: WaterLogItem[]; workouts: WorkoutLogItem[]; } export interface BodyMetric { id: number; weight_kg: number; body_fat_pct?: number | null; body_fat_method?: string | null; chest_cm?: number | null; waist_cm?: number | null; neck_cm?: number | null; hip_cm?: number | null; whr?: number | null; lbm_kg?: number | null; ffmi?: number | null; notes?: string; recorded_at?: string; } export interface BodyCompositionComputed { body_fat_pct?: number | null; body_fat_method?: string | null; whr?: number | null; lbm_kg?: number | null; ffmi?: number | null; warnings?: string[]; } export interface FitnessReminder { id: number; kind: string; hour: number; minute: number; interval_hours?: number | null; enabled: boolean; } export interface FitnessDayOverview { date: string; has_data: boolean; totals: FitnessDailySummary["totals"]; targets: FitnessDailySummary["targets"]; targets_expected?: FitnessTargets; tdee_breakdown?: FitnessTdeeBreakdown; tdee_expected?: FitnessTdeeExpected; meal_count: number; workout_count: number; } export interface FitnessHistory { start_date: string; end_date: string; days: number; 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; daily_series: Record | null; } export interface FitnessSnapshot { profile: FitnessProfile | null; today: FitnessDailySummary; history?: FitnessHistory; workout_stats?: FitnessWorkoutStats; body_metrics: BodyMetric[]; reminders: FitnessReminder[]; } export interface MemorySnapshot { profile: UserProfile; facts: MemoryFact[]; session_summary?: string; total_facts: number; } export interface PomodoroHistoryItem { id: number; status: string; phase: string; duration_min: number; task_note: string; result: string | null; completed: boolean; elapsed_seconds: number; finished_at: string | null; } async function request(path: string, options?: RequestInit): Promise { const response = await fetch(`${API_BASE}${path}`, { ...options, headers: authHeaders( options?.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : (options?.headers as Record | undefined) ?? {}, ), }); if (!response.ok) { const text = await response.text(); throw new Error(text || response.statusText); } return response.json() as Promise; } export const api = { health: () => request<{ status: string }>("/api/v1/health"), login: async (token: string) => { const response = await fetch(`${API_BASE}/api/v1/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }); if (!response.ok) { const text = await response.text(); throw new Error(text || "Неверный токен"); } return response.json() as Promise<{ ok: boolean; user: AuthUser; token: string }>; }, me: () => request<{ ok: boolean; user: AuthUser }>("/api/v1/auth/me"), listUsers: () => request<{ ok: boolean; users: AuthUser[]; current_user_id: number }>("/api/v1/auth/users"), createUser: (payload: { username: string; display_name?: string; token?: string }) => request<{ ok: boolean; user: AuthUser; token: string; created_by: string }>( "/api/v1/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }, ), listSessions: () => request("/api/v1/chat/sessions"), createSession: (title = "Новый чат") => request("/api/v1/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }), getSession: (id: number) => request(`/api/v1/chat/sessions/${id}`), getSessionMessages: ( id: number, params?: { limit?: number; before_id?: number; after_id?: number }, ) => { const query = new URLSearchParams(); if (params?.limit) query.set("limit", String(params.limit)); if (params?.before_id) query.set("before_id", String(params.before_id)); if (params?.after_id) query.set("after_id", String(params.after_id)); const suffix = query.toString(); return request( `/api/v1/chat/sessions/${id}/messages${suffix ? `?${suffix}` : ""}`, ); }, deleteSession: (id: number) => request<{ ok: boolean }>(`/api/v1/chat/sessions/${id}`, { method: "DELETE" }), getGenerationStatus: (id: number) => request(`/api/v1/chat/sessions/${id}/generation`), sendMessage: async function* (sessionId: number, content: string) { const response = await fetch(`${API_BASE}/api/v1/chat/sessions/${sessionId}/messages`, { method: "POST", headers: authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ content }), }); if (!response.ok) { const detail = await response.text().catch(() => ""); throw new Error(detail || `Ошибка отправки (${response.status})`); } 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`, { headers: authHeaders() }, ); if (response.status === 404) { return; } yield* readChatSse(response); }, pomodoroStatus: () => request("/api/v1/pomodoro/status"), pomodoroStart: (duration_min: number, task_note: string) => request("/api/v1/pomodoro/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ duration_min, task_note }), }), pomodoroPause: () => request("/api/v1/pomodoro/pause", { method: "POST" }), pomodoroResume: () => request("/api/v1/pomodoro/resume", { method: "POST" }), pomodoroStop: (result: string, completed: boolean) => request("/api/v1/pomodoro/stop", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ result, completed }), }), pomodoroHistory: () => request("/api/v1/pomodoro/history"), pomodoroResetCycle: (clear_task = false) => request(`/api/v1/pomodoro/cycle/reset?clear_task=${clear_task}`, { method: "POST", }), pomodoroSkip: () => request("/api/v1/pomodoro/skip", { method: "POST" }), pomodoroStartShortBreak: (duration_min?: number) => request( `/api/v1/pomodoro/break/short/start${duration_min ? `?duration_min=${duration_min}` : ""}`, { method: "POST" } ), pomodoroStartLongBreak: (duration_min?: number) => request( `/api/v1/pomodoro/break/long/start${duration_min ? `?duration_min=${duration_min}` : ""}`, { method: "POST" } ), weatherDashboard: (hoursAhead = 12, daysAhead = 7) => request( `/api/v1/homelab/weather?hours_ahead=${hoursAhead}&days_ahead=${daysAhead}`, ), getCharacter: () => request("/api/v1/character"), saveCharacter: (card: CharacterCardV2) => request("/api/v1/character", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(card), }), getMemorySnapshot: (sessionId?: number) => request( `/api/v1/memory${sessionId ? `?session_id=${sessionId}` : ""}` ), updateProfile: (updates: UserProfile) => request<{ ok: boolean; profile: UserProfile }>("/api/v1/profile", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ updates }), }), createMemoryFact: (payload: { content: string; category?: string; importance?: number; session_id?: number; }) => request<{ ok: boolean; memory_id: number }>("/api/v1/memory/facts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }), forgetMemoryFact: (id: number) => request<{ ok: boolean }>(`/api/v1/memory/facts/${id}`, { method: "DELETE" }), getFitnessSnapshot: () => request("/api/v1/fitness"), getFitnessSummary: (day?: string) => request( `/api/v1/fitness/summary${day ? `?day=${encodeURIComponent(day)}` : ""}` ), logFitnessSteps: (payload: { steps: number; active_calories?: number; notes?: string; day?: string; days_ago?: number; logged_at?: string; }) => request<{ ok: boolean; step_log: StepLogItem }>("/api/v1/fitness/steps", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }), getFitnessWorkoutStats: (days = 7, end?: string) => { const params = new URLSearchParams({ days: String(days) }); if (end) params.set("end", end); return request(`/api/v1/fitness/workout-stats?${params}`); }, getFitnessHistory: (days = 7, end?: string) => { const params = new URLSearchParams({ days: String(days) }); if (end) params.set("end", end); return request(`/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(`/api/v1/fitness/charts?${params}`); }, updateFitnessProfile: (updates: Partial) => request<{ ok: boolean; profile: FitnessProfile }>("/api/v1/fitness/profile", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updates), }), calcBodyComposition: (payload: { weight_kg?: number; height_cm?: number; sex?: string; neck_cm?: number; waist_cm?: number; hip_cm?: number; body_fat_pct?: number; }) => request("/api/v1/fitness/body-composition/calc", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }), deleteFitnessMeal: (id: number) => request<{ ok: boolean }>(`/api/v1/fitness/meals/${id}`, { method: "DELETE" }), deleteFitnessWater: (id: number) => request<{ ok: boolean }>(`/api/v1/fitness/water/${id}`, { method: "DELETE" }), updateFitnessReminder: ( kind: string, updates: { enabled?: boolean; hour?: number; minute?: number; interval_hours?: number } ) => request<{ ok: boolean }>(`/api/v1/fitness/reminders/${kind}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updates), }), getShoppingSnapshot: () => request("/api/v1/shopping"), createShoppingList: (name: string) => request<{ ok: boolean; list: ShoppingList }>("/api/v1/shopping/lists", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }), renameShoppingList: (listId: number, name: string) => request<{ ok: boolean; list: ShoppingList }>(`/api/v1/shopping/lists/${listId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }), deleteShoppingList: (listId: number) => request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}`, { method: "DELETE" }), addShoppingItems: (payload: { list_id?: number; list_name?: string; items: { text: string; quantity?: number; unit?: string }[]; }) => request<{ ok: boolean }>("/api/v1/shopping/items", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }), setShoppingItemChecked: (itemId: number, checked: boolean) => request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ checked }), }), removeShoppingItem: (itemId: number) => request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, { method: "DELETE" }), clearShoppingChecked: (listId: number) => request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}/clear-checked`, { method: "POST", }), getRemindersSnapshot: () => request("/api/v1/reminders"), getRemindersCalendar: (year: number, month: number) => request(`/api/v1/reminders/calendar?year=${year}&month=${month}`), createReminder: (payload: ReminderCreatePayload) => request<{ ok: boolean; reminder: Reminder }>("/api/v1/reminders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }), updateReminder: (id: number, payload: Partial & { enabled?: boolean }) => request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }), deleteReminder: (id: number) => request<{ ok: boolean }>(`/api/v1/reminders/${id}`, { method: "DELETE" }), getSettings: () => request("/api/v1/settings"), patchSettings: (updates: Partial) => request("/api/v1/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updates), }), listDocuments: () => request("/api/v1/documents"), uploadDocument: async (file: File, title = "") => { const form = new FormData(); form.append("file", file); form.append("title", title); const response = await fetch(`${API_BASE}/api/v1/documents/upload`, { method: "POST", headers: authHeaders(), body: form, }); if (!response.ok) { const detail = await response.text().catch(() => ""); throw new Error(detail || response.statusText); } return response.json() as Promise<{ ok: boolean; document: DocumentItem }>; }, completeReminder: (id: number) => request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}/complete`, { method: "POST", }), }; export interface ShoppingListItem { id: number; list_id: number; text: string; quantity: number | null; unit: string; checked: boolean; sort_order: number; } export interface ShoppingList { id: number; name: string; sort_order: number; item_count: number; unchecked_count: number; items?: ShoppingListItem[]; } export interface ShoppingSnapshot { lists: ShoppingList[]; list_count: number; total_items: number; unchecked_items: number; } export interface Reminder { id: number; title: string; notes: string; due_at: string; due_at_local: string; all_day: boolean; recurrence: string; enabled: boolean; completed_at: string | null; timezone: string; created_at: string | null; } export interface RemindersSnapshot { notify_seq: number; upcoming: Reminder[]; upcoming_count: number; timezone: string; } export interface RemindersCalendar { year: number; month: number; timezone: string; reminders: Reminder[]; } export interface AssistantSettings { openrouter_model: string; memory_extract_model: string; openrouter_vision_model: string; openrouter_reasoning_effort: string; rag_enabled: boolean; rag_top_k: number; embedding_model: string; memory_facts_in_context: number; qdrant_url: string; } export interface DocumentItem { id: number; title: string; filename: string; size_bytes: number; created_at: string | null; } export interface ReminderCreatePayload { title: string; due_at: string; notes?: string; all_day?: boolean; recurrence?: string; }