1009 lines
27 KiB
TypeScript
1009 lines
27 KiB
TypeScript
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<string, string> = {}): HeadersInit {
|
|
const headers: Record<string, string> = { ...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<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(() => "");
|
|
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<string, unknown> };
|
|
}
|
|
}
|
|
};
|
|
|
|
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<string, string>;
|
|
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<string, FitnessChartSeries>;
|
|
daily_series: Record<string, FitnessChartSeries> | 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<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const response = await fetch(`${API_BASE}${path}`, {
|
|
...options,
|
|
headers: authHeaders(
|
|
options?.headers instanceof Headers
|
|
? Object.fromEntries(options.headers.entries())
|
|
: (options?.headers as Record<string, string> | undefined) ?? {},
|
|
),
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(text || response.statusText);
|
|
}
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export const api = {
|
|
health: () => request<{ status: string }>("/api/v1/health"),
|
|
|
|
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<ChatSession[]>("/api/v1/chat/sessions"),
|
|
|
|
createSession: (title = "Новый чат") =>
|
|
request<ChatSession>("/api/v1/chat/sessions", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title }),
|
|
}),
|
|
|
|
getSession: (id: number) => request<SessionDetail>(`/api/v1/chat/sessions/${id}`),
|
|
|
|
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<MessagesPage>(
|
|
`/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<GenerationStatus>(`/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<PomodoroStatus>("/api/v1/pomodoro/status"),
|
|
|
|
pomodoroStart: (duration_min: number, task_note: string) =>
|
|
request<PomodoroStatus>("/api/v1/pomodoro/start", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ duration_min, task_note }),
|
|
}),
|
|
|
|
pomodoroPause: () =>
|
|
request<PomodoroStatus>("/api/v1/pomodoro/pause", { method: "POST" }),
|
|
|
|
pomodoroResume: () =>
|
|
request<PomodoroStatus>("/api/v1/pomodoro/resume", { method: "POST" }),
|
|
|
|
pomodoroStop: (result: string, completed: boolean) =>
|
|
request<PomodoroStatus>("/api/v1/pomodoro/stop", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ result, completed }),
|
|
}),
|
|
|
|
pomodoroHistory: () => request<PomodoroHistoryItem[]>("/api/v1/pomodoro/history"),
|
|
|
|
pomodoroResetCycle: (clear_task = false) =>
|
|
request<PomodoroStatus>(`/api/v1/pomodoro/cycle/reset?clear_task=${clear_task}`, {
|
|
method: "POST",
|
|
}),
|
|
|
|
pomodoroSkip: () =>
|
|
request<PomodoroStatus>("/api/v1/pomodoro/skip", { method: "POST" }),
|
|
|
|
pomodoroStartShortBreak: (duration_min?: number) =>
|
|
request<PomodoroStatus>(
|
|
`/api/v1/pomodoro/break/short/start${duration_min ? `?duration_min=${duration_min}` : ""}`,
|
|
{ method: "POST" }
|
|
),
|
|
|
|
pomodoroStartLongBreak: (duration_min?: number) =>
|
|
request<PomodoroStatus>(
|
|
`/api/v1/pomodoro/break/long/start${duration_min ? `?duration_min=${duration_min}` : ""}`,
|
|
{ method: "POST" }
|
|
),
|
|
|
|
weatherDashboard: (hoursAhead = 12, daysAhead = 7) =>
|
|
request<WeatherDashboard>(
|
|
`/api/v1/homelab/weather?hours_ahead=${hoursAhead}&days_ahead=${daysAhead}`,
|
|
),
|
|
|
|
getCharacter: () => request<CharacterCardV2>("/api/v1/character"),
|
|
|
|
saveCharacter: (card: CharacterCardV2) =>
|
|
request<CharacterCardV2>("/api/v1/character", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(card),
|
|
}),
|
|
|
|
getMemorySnapshot: (sessionId?: number) =>
|
|
request<MemorySnapshot>(
|
|
`/api/v1/memory${sessionId ? `?session_id=${sessionId}` : ""}`
|
|
),
|
|
|
|
updateProfile: (updates: UserProfile) =>
|
|
request<{ ok: boolean; profile: UserProfile }>("/api/v1/profile", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ updates }),
|
|
}),
|
|
|
|
createMemoryFact: (payload: {
|
|
content: string;
|
|
category?: string;
|
|
importance?: number;
|
|
session_id?: number;
|
|
}) =>
|
|
request<{ ok: boolean; memory_id: number }>("/api/v1/memory/facts", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
forgetMemoryFact: (id: number) =>
|
|
request<{ ok: boolean }>(`/api/v1/memory/facts/${id}`, { method: "DELETE" }),
|
|
|
|
getFitnessSnapshot: () => request<FitnessSnapshot>("/api/v1/fitness"),
|
|
|
|
getFitnessSummary: (day?: string) =>
|
|
request<FitnessDailySummary>(
|
|
`/api/v1/fitness/summary${day ? `?day=${encodeURIComponent(day)}` : ""}`
|
|
),
|
|
|
|
|
|
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<FitnessWorkoutStats>(`/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<FitnessHistory>(`/api/v1/fitness/history?${params}`);
|
|
},
|
|
|
|
getFitnessCharts: (weeks = 52, trend = true, end?: string) => {
|
|
const params = new URLSearchParams({
|
|
weeks: String(weeks),
|
|
trend: String(trend),
|
|
});
|
|
if (end) params.set("end", end);
|
|
return request<FitnessChartsResponse>(`/api/v1/fitness/charts?${params}`);
|
|
},
|
|
|
|
updateFitnessProfile: (updates: Partial<FitnessProfile>) =>
|
|
request<{ ok: boolean; profile: FitnessProfile }>("/api/v1/fitness/profile", {
|
|
method: "PUT",
|
|
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<BodyCompositionComputed>("/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<ShoppingSnapshot>("/api/v1/shopping"),
|
|
|
|
createShoppingList: (name: string) =>
|
|
request<{ ok: boolean; list: ShoppingList }>("/api/v1/shopping/lists", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name }),
|
|
}),
|
|
|
|
renameShoppingList: (listId: number, name: string) =>
|
|
request<{ ok: boolean; list: ShoppingList }>(`/api/v1/shopping/lists/${listId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name }),
|
|
}),
|
|
|
|
deleteShoppingList: (listId: number) =>
|
|
request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}`, { method: "DELETE" }),
|
|
|
|
addShoppingItems: (payload: {
|
|
list_id?: number;
|
|
list_name?: string;
|
|
items: { text: string; quantity?: number; unit?: string }[];
|
|
}) =>
|
|
request<{ ok: boolean }>("/api/v1/shopping/items", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
setShoppingItemChecked: (itemId: number, checked: boolean) =>
|
|
request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ checked }),
|
|
}),
|
|
|
|
removeShoppingItem: (itemId: number) =>
|
|
request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, { method: "DELETE" }),
|
|
|
|
clearShoppingChecked: (listId: number) =>
|
|
request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}/clear-checked`, {
|
|
method: "POST",
|
|
}),
|
|
|
|
getRemindersSnapshot: () => request<RemindersSnapshot>("/api/v1/reminders"),
|
|
|
|
getRemindersCalendar: (year: number, month: number) =>
|
|
request<RemindersCalendar>(`/api/v1/reminders/calendar?year=${year}&month=${month}`),
|
|
|
|
createReminder: (payload: ReminderCreatePayload) =>
|
|
request<{ ok: boolean; reminder: Reminder }>("/api/v1/reminders", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
updateReminder: (id: number, payload: Partial<ReminderCreatePayload> & { enabled?: boolean }) =>
|
|
request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteReminder: (id: number) =>
|
|
request<{ ok: boolean }>(`/api/v1/reminders/${id}`, { method: "DELETE" }),
|
|
|
|
|
|
getSettings: () => request<AssistantSettings>("/api/v1/settings"),
|
|
|
|
patchSettings: (updates: Partial<AssistantSettings>) =>
|
|
request<AssistantSettings>("/api/v1/settings", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(updates),
|
|
}),
|
|
|
|
listDocuments: () => request<DocumentItem[]>("/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;
|
|
}
|