数值全部在服务端算好再交给模型,模型只做解读。让模型从 CSV 里自己推 z 分数,它算错的次数足以让简报引用图表反驳它的数字。 - services/insights.py:z 分数(28 天个人基线,且**排除当天**——用一个 值参与算出来的均值去衡量它自己,会把真实离群点摊平)、13 个月趋势斜率 (按序数日期最小二乘,手表放充电器上一周不会压缩 x 轴)、近 7 天活动量 对比。 - services/coach.py:三套提示词 + 回复解析,每套都配一个规则引擎版本。 网关一次生成要几分钟,上游被限流时给一个朴素的答案,好过给一张空卡片。 - services/ai.py:多轮 chat()、SSE stream()、complete()/stream_chat(), 以及 extract_json()——上游是推理模型,可见输出以思维链开头,所以从末尾 倒着找最后一个配平的 JSON(字符串感知,扛得住引号里的 } 和转义引号)。 - 接口 briefing / trend-insight / copilot(SSE),缓存表 ai_insights。 - 前端:今日页晨报卡(后台生成 + 轮询升级)、全局 Copilot 浮窗、指标详情 页归因面板。features.ai 打开。 实测(对着自建 ai-gateway):晨报一次 273 秒,缓存命中 18 毫秒——所以简报 绝不能同步阻塞首屏。网关的流式通道比阻塞通道更不可靠:同一条提示词流式 139 秒后返回「所有模型均不可用」,阻塞则成功,因此 stream_chat() 在流式零 输出时对同一模型退回非流式重试。Copilot 实测 TTFB 9ms、全程 40 秒。 顺带修两处:refresh 原来只跳过缓存读、不删行,导致「重新生成」后的轮询读 到旧行、看到 cached 就停了,用户一直盯着他刚要求替换掉的那段字;基线零方差 时原来返回 z=0.0,把「和每一条观测都不同」标成「完全正常」,改为 z=null。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
914 lines
25 KiB
TypeScript
914 lines
25 KiB
TypeScript
import axios, { AxiosInstance } from 'axios';
|
|
|
|
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
|
const TOKEN_KEY = 'ghl_token';
|
|
/** Fired on this tab whenever the session is created or cleared. */
|
|
export const AUTH_EVENT = 'ghl:auth';
|
|
|
|
// The Flask backend returns bare JSON (an array, or the object itself) and
|
|
// signals failure with `{ error: "..." }` plus a non-2xx status. There is no
|
|
// {success, data} envelope, so responses are read as `response.data` directly.
|
|
export interface AuthResponse {
|
|
id: string;
|
|
username: string;
|
|
token: string;
|
|
}
|
|
|
|
export interface SleepDetail {
|
|
duration: number;
|
|
quality: number | null;
|
|
deepSeconds: number | null;
|
|
lightSeconds: number | null;
|
|
remSeconds: number | null;
|
|
awakeSeconds: number | null;
|
|
}
|
|
|
|
/** One day, with every metric the sync stores. All are nullable: a device
|
|
* that does not record a metric leaves it null rather than zero. */
|
|
export interface HealthDay {
|
|
date: string;
|
|
steps: number | null;
|
|
stepGoal: number | null;
|
|
distanceMeters: number | null;
|
|
caloriesBurned: number | null;
|
|
activeCalories: number | null;
|
|
bmrCalories: number | null;
|
|
floorsAscended: number | null;
|
|
floorsDescended: number | null;
|
|
intensityMinutes: number | null;
|
|
sedentarySeconds: number | null;
|
|
activeSeconds: number | null;
|
|
heartRate: number | null;
|
|
heartRateMax: number | null;
|
|
heartRateMin: number | null;
|
|
heartRateVariability: number | null;
|
|
stress: number | null;
|
|
stressMax: number | null;
|
|
bodyBatteryHigh: number | null;
|
|
bodyBatteryLow: number | null;
|
|
bodyBatteryCharged: number | null;
|
|
bodyBatteryDrained: number | null;
|
|
spo2Avg: number | null;
|
|
spo2Min: number | null;
|
|
respirationAvg: number | null;
|
|
respirationMin: number | null;
|
|
respirationMax: number | null;
|
|
sleepDuration: number | null;
|
|
sleepQuality: number | null;
|
|
sleepSpo2Avg: number | null;
|
|
sleepRespirationAvg: number | null;
|
|
sleepStressAvg: number | null;
|
|
trainingReadiness: number | null;
|
|
vo2max: number | null;
|
|
hillScore: number | null;
|
|
hydrationMl: number | null;
|
|
hydrationGoalMl: number | null;
|
|
sweatLossMl: number | null;
|
|
weightKg: number | null;
|
|
bodyFatPct: number | null;
|
|
bmi: number | null;
|
|
enduranceScore: number | null;
|
|
sleep: SleepDetail | null;
|
|
}
|
|
|
|
export interface Badge {
|
|
id: string;
|
|
badge_key: string | null;
|
|
name: string | null;
|
|
category_id: number | null;
|
|
difficulty_id: number | null;
|
|
earned_date: string | null;
|
|
earned_count: number | null;
|
|
points: number | null;
|
|
}
|
|
|
|
export interface PersonalRecord {
|
|
id: string;
|
|
type_id: number | null;
|
|
activity_id: string | null;
|
|
activity_name: string | null;
|
|
activity_type: string | null;
|
|
value: number | null;
|
|
achieved_at: string | null;
|
|
}
|
|
|
|
export interface Activity {
|
|
id: string;
|
|
activity_type: string;
|
|
start_time: string;
|
|
end_time: string;
|
|
duration: number | null;
|
|
distance: number | null;
|
|
calories: number | null;
|
|
heart_rate_average: number | null;
|
|
heart_rate_max: number | null;
|
|
}
|
|
|
|
export interface SyncStatus {
|
|
status: 'idle' | 'syncing' | 'error' | 'rate_limited';
|
|
lastSyncTime: string | null;
|
|
recordsSynced: number;
|
|
totalDays: number;
|
|
lastError: string | null;
|
|
/** Days completed / requested. A backfill runs for many minutes. */
|
|
progressCurrent: number | null;
|
|
progressTotal: number | null;
|
|
startedAt: string | null;
|
|
/** Which part of the sync is running, e.g. 每日数据 2026-08-01. A bare
|
|
* "0 / 730 天" says nothing about what is happening for several minutes. */
|
|
stage?: string | null;
|
|
}
|
|
|
|
export interface SyncResult {
|
|
status: 'success' | 'error' | 'rate_limited';
|
|
recordsSynced: number;
|
|
activitiesSynced?: number;
|
|
message: string;
|
|
/** Set when the account has two-factor auth and no token is stored yet. */
|
|
mfaRequired?: boolean;
|
|
lastSyncTime: string;
|
|
}
|
|
|
|
export interface Recommendation {
|
|
id: string;
|
|
category: string;
|
|
recommendation: string;
|
|
priority: 'high' | 'medium' | 'low';
|
|
basedOn: string[];
|
|
source?: 'ai';
|
|
}
|
|
|
|
export interface AiRecommendations {
|
|
recommendations: Recommendation[];
|
|
meta: {
|
|
source: 'ai' | 'rules';
|
|
model: string | null;
|
|
provider?: string;
|
|
upstream?: string | null;
|
|
days?: number;
|
|
fallbackFrom?: string[];
|
|
reason?: string;
|
|
/** True when served from the stored answer rather than freshly generated. */
|
|
cached?: boolean;
|
|
generatedAt?: string;
|
|
};
|
|
}
|
|
|
|
export type GarminLoginStatus =
|
|
| 'starting'
|
|
| 'awaiting_code'
|
|
| 'finishing'
|
|
| 'done'
|
|
| 'failed';
|
|
|
|
export interface ModelInfo {
|
|
id: string;
|
|
model: string;
|
|
provider: string;
|
|
contextWindow: number;
|
|
configured: boolean;
|
|
default: boolean;
|
|
}
|
|
|
|
export interface UserSettings {
|
|
heightCm: number | null;
|
|
weightKg: number | null;
|
|
birthDate: string | null;
|
|
sex: 'male' | 'female' | 'other' | null;
|
|
units: 'metric' | 'imperial';
|
|
autoSync: boolean;
|
|
autoSyncMinutes: number;
|
|
/** 0 means "everything Garmin has". */
|
|
historyDays: number;
|
|
age: number | null;
|
|
bmi: number | null;
|
|
}
|
|
|
|
export interface SettingsOptions {
|
|
sexes: string[];
|
|
units: string[];
|
|
autoSyncMinutes: number[];
|
|
historyDays: number[];
|
|
}
|
|
|
|
export interface BasisStep {
|
|
name: string;
|
|
detail: string;
|
|
source: string;
|
|
}
|
|
|
|
export interface RatingBasis {
|
|
fitnessAge: {
|
|
title: string;
|
|
summary: string;
|
|
steps: BasisStep[];
|
|
caveat: string;
|
|
};
|
|
bands: Array<{ metric: string; bands: string; source: string }>;
|
|
note: string;
|
|
}
|
|
|
|
export interface FitnessAge {
|
|
value: number | null;
|
|
chronologicalAge?: number;
|
|
delta?: number;
|
|
clamped?: boolean;
|
|
steps?: Array<{
|
|
label: string; input: string; years: number; kind: string;
|
|
/** Base step only: the undamped figure, and the factor applied to it. */
|
|
raw?: number; damping?: number;
|
|
}>;
|
|
missing: string[];
|
|
basis: RatingBasis['fitnessAge'];
|
|
}
|
|
|
|
export interface DetailSyncStatus {
|
|
running: boolean;
|
|
/** Which part of the backfill is running: 运动详情 / 每日曲线. */
|
|
stage?: string | null;
|
|
done: number;
|
|
total: number;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface BodyCompositionDay {
|
|
date: string;
|
|
weightKg: number | null;
|
|
bmi: number | null;
|
|
bodyFatPct: number | null;
|
|
bodyWaterPct: number | null;
|
|
boneMassKg: number | null;
|
|
muscleMassKg: number | null;
|
|
physiqueRating: number | null;
|
|
visceralFat: number | null;
|
|
metabolicAge: number | null;
|
|
}
|
|
|
|
export interface BloodPressureReading {
|
|
measuredAt: string;
|
|
systolic: number | null;
|
|
diastolic: number | null;
|
|
pulse: number | null;
|
|
note: string | null;
|
|
}
|
|
|
|
/** Predicted finishing times, in seconds. */
|
|
export interface RacePrediction {
|
|
date: string;
|
|
time5k: number | null;
|
|
time10k: number | null;
|
|
timeHalf: number | null;
|
|
timeMarathon: number | null;
|
|
}
|
|
|
|
/** [timestamp, value] pairs, thinned to at most 240 points per day. */
|
|
export type DailySeries = Record<string, Array<[string, number]>>;
|
|
|
|
export interface Challenge {
|
|
uuid: string;
|
|
kind: string;
|
|
name: string | null;
|
|
status: string | null;
|
|
startDate: string | null;
|
|
endDate: string | null;
|
|
payload: Record<string, any>;
|
|
}
|
|
|
|
export interface Device {
|
|
deviceId: string;
|
|
name: string | null;
|
|
model: string | null;
|
|
serial: string | null;
|
|
softwareVersion: string | null;
|
|
lastUsedAt: string | null;
|
|
}
|
|
|
|
export interface AutoSyncStatus {
|
|
enabled: boolean;
|
|
intervalSeconds: number;
|
|
tickSeconds: number;
|
|
days: number;
|
|
lastRunAt: string | null;
|
|
nextRunAt: string | null;
|
|
running: boolean;
|
|
account?: {
|
|
autoSync: boolean;
|
|
intervalMinutes: number;
|
|
dueAt: string | null;
|
|
};
|
|
}
|
|
|
|
/** One activity in full. Shapes mirror Garmin's own payload, which is why the
|
|
* summary is left loosely typed — it carries dozens of optional fields that
|
|
* differ by sport. */
|
|
export interface ActivityDetail {
|
|
activityId: string;
|
|
activityName: string | null;
|
|
activityType: string | null;
|
|
summary: Record<string, number | string | null>;
|
|
laps: Array<{
|
|
index: number;
|
|
duration: number | null;
|
|
movingDuration: number | null;
|
|
distance: number | null;
|
|
averageSpeed: number | null;
|
|
maxSpeed: number | null;
|
|
calories: number | null;
|
|
averageHR: number | null;
|
|
maxHR: number | null;
|
|
elevationGain: number | null;
|
|
elevationLoss: number | null;
|
|
}>;
|
|
hrZones: Array<{ zone: number; seconds: number; lowBoundary: number | null }>;
|
|
weather: Record<string, any>;
|
|
gear: Array<Record<string, any>>;
|
|
exerciseSets: Array<Record<string, any>>;
|
|
series: Record<string, Array<number | null>>;
|
|
cached: boolean;
|
|
}
|
|
|
|
export interface TrendPoint {
|
|
date: string;
|
|
value: number;
|
|
}
|
|
|
|
// --- AI coach -------------------------------------------------------------
|
|
/** How far one of today's metrics sits from the user's own recent baseline. */
|
|
export interface Deviation {
|
|
metric: string;
|
|
label: string;
|
|
unit: string;
|
|
value: number;
|
|
baselineMean: number | null;
|
|
sd: number | null;
|
|
baselineDays?: number;
|
|
z: number | null;
|
|
verdict: string;
|
|
}
|
|
|
|
export interface TrendSummary {
|
|
metric: string;
|
|
label: string;
|
|
unit: string;
|
|
days: number;
|
|
samples: number;
|
|
firstMean: number;
|
|
lastMean: number;
|
|
delta: number;
|
|
slopePer30d: number | null;
|
|
direction?: string;
|
|
}
|
|
|
|
/** The computed features a briefing was derived from — the same numbers the
|
|
* card quotes, so the UI can show them without a second request. */
|
|
export interface BriefingContext {
|
|
snapshotDate: string;
|
|
userProfile: Record<string, number | string | null>;
|
|
todayMetrics: {
|
|
sleep: Record<string, number | number[] | null> | null;
|
|
autonomicNervous: Record<string, number | null>;
|
|
recovery: Record<string, number | null>;
|
|
activityToday: Record<string, number | null>;
|
|
};
|
|
deviations: Deviation[];
|
|
trends: TrendSummary[];
|
|
activityShift: Record<string, {
|
|
label: string; recentMean: number; priorMean: number; changePct: number | null;
|
|
}>;
|
|
recentActivities: Array<Record<string, string | number | null>>;
|
|
dataQuality: { totalDays: number; firstDate: string; lastDate: string; staleDays: number };
|
|
}
|
|
|
|
export interface Briefing {
|
|
status: string;
|
|
headline: string | null;
|
|
diagnosis: Array<{ title: string; detail: string }>;
|
|
shortfall: string | null;
|
|
prescription: {
|
|
intensity: string | null;
|
|
hrZone: string | null;
|
|
suggestion: string | null;
|
|
durationMin: number | null;
|
|
avoid: string | null;
|
|
};
|
|
actions: string[];
|
|
}
|
|
|
|
/** `source` says who answered: the model, or the rule engine standing in for
|
|
* it. `pending` means the card on screen is the placeholder and the model's
|
|
* version is still being generated — poll again. */
|
|
export interface InsightMeta {
|
|
source: 'ai' | 'rules' | 'none';
|
|
model?: string | null;
|
|
upstream?: string | null;
|
|
cached?: boolean;
|
|
pending?: boolean;
|
|
generating?: boolean;
|
|
generatedAt?: string | null;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface BriefingResponse {
|
|
briefing: Briefing | null;
|
|
context: BriefingContext | null;
|
|
meta: InsightMeta;
|
|
}
|
|
|
|
export interface TrendInsight {
|
|
summary: string | null;
|
|
drivers: Array<{ factor: string; detail: string }>;
|
|
caution: string | null;
|
|
confidence: 'high' | 'medium' | 'low';
|
|
}
|
|
|
|
export interface TrendInsightResponse {
|
|
insight: TrendInsight | null;
|
|
window: Record<string, any> | null;
|
|
meta: InsightMeta;
|
|
}
|
|
|
|
export interface CopilotTurn {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
/**
|
|
* Parse a timestamp the backend wrote with `datetime.utcnow()` — i.e. UTC but
|
|
* with no offset in the string. JavaScript reads such a value as *local* time,
|
|
* which showed sync times eight hours in the past here.
|
|
*/
|
|
export function parseUtc(value: string | null | undefined): Date | null {
|
|
if (!value) return null;
|
|
const normalised = value.replace(' ', 'T');
|
|
const withZone = /[Zz]|[+-]\d{2}:?\d{2}$/.test(normalised)
|
|
? normalised
|
|
: `${normalised}Z`;
|
|
const date = new Date(withZone);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
/** Pull a human-readable message out of an axios error. */
|
|
export function errorMessage(err: any, fallback = '请求失败'): string {
|
|
return err?.response?.data?.error || err?.message || fallback;
|
|
}
|
|
|
|
class ApiClient {
|
|
private client: AxiosInstance;
|
|
|
|
constructor() {
|
|
this.client = axios.create({
|
|
baseURL: API_BASE_URL,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
// Attach the saved JWT to every request.
|
|
this.client.interceptors.request.use((config) => {
|
|
const token = localStorage.getItem(TOKEN_KEY);
|
|
if (token) {
|
|
config.headers = config.headers || {};
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// On 401 drop the session and bounce to login, so an expired token does
|
|
// not leave the user staring at empty pages.
|
|
this.client.interceptors.response.use(
|
|
(resp) => resp,
|
|
(error) => {
|
|
if (error.response?.status === 401) {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
if (window.location.pathname !== '/login') {
|
|
window.location.href = '/login';
|
|
}
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
}
|
|
|
|
// --- session ---
|
|
/* `storage` only fires in *other* tabs, so a same-tab login or logout needs
|
|
its own signal for the shell to notice. */
|
|
private announce() {
|
|
window.dispatchEvent(new Event(AUTH_EVENT));
|
|
}
|
|
|
|
setSession(token: string) {
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
this.announce();
|
|
}
|
|
|
|
clearSession() {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
this.announce();
|
|
}
|
|
|
|
isAuthenticated(): boolean {
|
|
return Boolean(localStorage.getItem(TOKEN_KEY));
|
|
}
|
|
|
|
// --- auth ---
|
|
async logout() {
|
|
try {
|
|
await this.client.post('/auth/logout');
|
|
} finally {
|
|
this.clearSession();
|
|
}
|
|
}
|
|
|
|
// --- auth-hub OAuth2 ---
|
|
async authHubStart() {
|
|
const { data } = await this.client.post<{
|
|
auth_url: string;
|
|
code_verifier: string;
|
|
state: string;
|
|
}>('/auth/auth-hub/start', {});
|
|
return data;
|
|
}
|
|
|
|
async authHubCallback(code: string, codeVerifier: string) {
|
|
const { data } = await this.client.get<AuthResponse>('/auth/callback', {
|
|
params: { code, code_verifier: codeVerifier }
|
|
});
|
|
return data;
|
|
}
|
|
|
|
// --- garmin ---
|
|
/**
|
|
* With a stored OAuth token no password is needed. Without one, the
|
|
* plaintext password must be supplied because only a hash is kept — and an
|
|
* MFA-protected account cannot log in this way at all (see garmin_login.py).
|
|
*/
|
|
/** Starts a sync in the background; poll getGarminSyncStatus for progress. */
|
|
async syncGarminData(days?: number, garminPassword?: string) {
|
|
const { data } = await this.client.post<{
|
|
status: string; days?: number; message?: string; retryAfterSeconds?: number;
|
|
}>(
|
|
'/garmin/sync',
|
|
{
|
|
// `days` must survive being 0 — that is "全部历史", not "unset".
|
|
// `days ? …` dropped it, so the backend fell back to its 7-day
|
|
// default and a full backfill silently pulled a week.
|
|
...(days === undefined || days === null ? {} : { days }),
|
|
...(garminPassword ? { garminPassword } : {}),
|
|
}
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/** Whether a stored Garmin token exists (then sync needs no password). */
|
|
async getGarminAuthStatus() {
|
|
const { data } = await this.client.get<{ hasToken: boolean }>(
|
|
'/garmin/auth-status'
|
|
);
|
|
return data.hasToken;
|
|
}
|
|
|
|
/** Drop the stored Garmin OAuth token; the next sync must re-authenticate. */
|
|
async disconnectGarmin() {
|
|
const { data } = await this.client.post<{ ok: boolean; message?: string }>(
|
|
'/garmin/disconnect'
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Start an interactive Garmin login. Returns a session id; the login runs
|
|
* in the background and parks if Garmin asks for a two-factor code.
|
|
*/
|
|
async startGarminLogin(garminPassword: string, garminEmail?: string) {
|
|
const { data } = await this.client.post<{ session: string }>('/garmin/login', {
|
|
garminPassword,
|
|
...(garminEmail ? { garminEmail } : {}),
|
|
});
|
|
return data.session;
|
|
}
|
|
|
|
async getGarminLoginStatus(session: string) {
|
|
const { data } = await this.client.get<{
|
|
session: string;
|
|
status: GarminLoginStatus;
|
|
error: string | null;
|
|
}>('/garmin/login-status', { params: { session } });
|
|
return data;
|
|
}
|
|
|
|
async submitGarminMfa(session: string, code: string) {
|
|
const { data } = await this.client.post<{ ok: boolean; message: string }>(
|
|
'/garmin/mfa',
|
|
{ session, code }
|
|
);
|
|
return data;
|
|
}
|
|
|
|
async cancelGarminLogin(session: string) {
|
|
await this.client.delete('/garmin/login', { params: { session } });
|
|
}
|
|
|
|
async getGarminSyncStatus() {
|
|
const { data } = await this.client.get<SyncStatus>('/garmin/status');
|
|
return data;
|
|
}
|
|
|
|
// --- health ---
|
|
private range(startDate?: string, endDate?: string) {
|
|
return { params: { startDate, endDate } };
|
|
}
|
|
|
|
async getHealthSummary(startDate?: string, endDate?: string) {
|
|
const { data } = await this.client.get<HealthDay[]>(
|
|
'/health/summary',
|
|
this.range(startDate, endDate)
|
|
);
|
|
return data;
|
|
}
|
|
|
|
async getActivities(startDate?: string, endDate?: string) {
|
|
const { data } = await this.client.get<Activity[]>(
|
|
'/health/activities',
|
|
this.range(startDate, endDate)
|
|
);
|
|
return data;
|
|
}
|
|
|
|
// --- settings ---
|
|
async getSettings() {
|
|
const { data } = await this.client.get<UserSettings>('/settings');
|
|
return data;
|
|
}
|
|
|
|
async saveSettings(patch: Partial<UserSettings>) {
|
|
const { data } = await this.client.put<UserSettings>('/settings', patch);
|
|
return data;
|
|
}
|
|
|
|
async getSettingsOptions() {
|
|
const { data } = await this.client.get<SettingsOptions>('/settings/options');
|
|
return data;
|
|
}
|
|
|
|
async getRatingBasis() {
|
|
const { data } = await this.client.get<RatingBasis>('/settings/rating-basis');
|
|
return data;
|
|
}
|
|
|
|
async getFitnessAge() {
|
|
const { data } = await this.client.get<FitnessAge>('/health/fitness-age');
|
|
return data;
|
|
}
|
|
|
|
async getAutoSyncStatus() {
|
|
const { data } = await this.client.get<AutoSyncStatus>('/garmin/auto-sync');
|
|
return data;
|
|
}
|
|
|
|
/** Pull the last few days inline — fast enough to await, unlike a backfill. */
|
|
async syncLatest(days = 2) {
|
|
const { data } = await this.client.post<SyncResult>(
|
|
'/garmin/sync-latest', { days }
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/** A local read: details are stored during the sync, not fetched on tap. */
|
|
async getActivityDetail(activityId: string) {
|
|
const { data } = await this.client.get<ActivityDetail>(
|
|
`/garmin/activities/${activityId}/detail`
|
|
);
|
|
return data;
|
|
}
|
|
|
|
async startDetailSync(limit?: number) {
|
|
const { data } = await this.client.post<DetailSyncStatus>(
|
|
'/garmin/sync-details', limit ? { limit } : {}
|
|
);
|
|
return data;
|
|
}
|
|
|
|
async getDetailSyncStatus() {
|
|
const { data } = await this.client.get<DetailSyncStatus>('/garmin/sync-details');
|
|
return data;
|
|
}
|
|
|
|
async getBodyComposition(startDate?: string, endDate?: string) {
|
|
const { data } = await this.client.get<BodyCompositionDay[]>(
|
|
'/health/body-composition', this.range(startDate, endDate)
|
|
);
|
|
return data;
|
|
}
|
|
|
|
async getBloodPressure() {
|
|
const { data } = await this.client.get<BloodPressureReading[]>(
|
|
'/health/blood-pressure'
|
|
);
|
|
return data;
|
|
}
|
|
|
|
async getRacePredictions() {
|
|
const { data } = await this.client.get<RacePrediction[]>(
|
|
'/health/race-predictions'
|
|
);
|
|
return data;
|
|
}
|
|
|
|
async getDailySeries(date: string) {
|
|
const { data } = await this.client.get<DailySeries>(
|
|
'/health/series', { params: { date } }
|
|
);
|
|
return data;
|
|
}
|
|
|
|
async getChallenges() {
|
|
const { data } = await this.client.get<Challenge[]>('/health/challenges');
|
|
return data;
|
|
}
|
|
|
|
async getDevices() {
|
|
const { data } = await this.client.get<Device[]>('/health/devices');
|
|
return data;
|
|
}
|
|
|
|
async getBadges() {
|
|
const { data } = await this.client.get<Badge[]>('/health/badges');
|
|
return data;
|
|
}
|
|
|
|
async getPersonalRecords() {
|
|
const { data } = await this.client.get<PersonalRecord[]>(
|
|
'/health/personal-records'
|
|
);
|
|
return data;
|
|
}
|
|
|
|
// --- analysis ---
|
|
async getTrends(metricType: string, startDate?: string, endDate?: string) {
|
|
const { data } = await this.client.get<TrendPoint[]>('/analysis/trends', {
|
|
params: { metricType, startDate, endDate },
|
|
});
|
|
return data;
|
|
}
|
|
|
|
async getRecommendations() {
|
|
const { data } = await this.client.get<Recommendation[]>('/analysis/recommendations');
|
|
return data;
|
|
}
|
|
|
|
async getModels() {
|
|
const { data } = await this.client.get<ModelInfo[]>('/analysis/models');
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Served from the stored answer unless `refresh` is set or a `model` is
|
|
* named. A fresh generation can take minutes, so the caller should show a
|
|
* long-running state for those two cases.
|
|
*/
|
|
async getAiRecommendations(model?: string, refresh?: boolean, days?: number) {
|
|
const { data } = await this.client.get<AiRecommendations>(
|
|
'/analysis/ai-recommendations',
|
|
{
|
|
params: { model, days, ...(refresh ? { refresh: 1 } : {}) },
|
|
// A cold generation runs well past axios's default timeout.
|
|
timeout: 240_000,
|
|
}
|
|
);
|
|
return data;
|
|
}
|
|
|
|
// --- AI coach ---
|
|
/**
|
|
* 晨间简报 for one day, plus the computed context behind it.
|
|
*
|
|
* Returns immediately. When no stored model answer matches the current data
|
|
* the reply is the rule-based briefing with `meta.pending`, and the model's
|
|
* version is generated in the background — call again to pick it up.
|
|
*/
|
|
async getBriefing(opts: { date?: string; refresh?: boolean; model?: string } = {}) {
|
|
const { data } = await this.client.get<BriefingResponse>('/analysis/briefing', {
|
|
params: {
|
|
date: opts.date,
|
|
model: opts.model,
|
|
...(opts.refresh ? { refresh: 1 } : {}),
|
|
},
|
|
});
|
|
return data;
|
|
}
|
|
|
|
/** Attribution for one metric over a selected span. Blocking: a cold
|
|
* generation runs well past axios's default timeout. */
|
|
async getTrendInsight(
|
|
metric: string, startDate: string, endDate: string, refresh?: boolean
|
|
) {
|
|
const { data } = await this.client.get<TrendInsightResponse>(
|
|
'/analysis/trend-insight',
|
|
{
|
|
params: { metric, startDate, endDate, ...(refresh ? { refresh: 1 } : {}) },
|
|
timeout: 300_000,
|
|
}
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Ask the Copilot, streamed.
|
|
*
|
|
* `fetch` rather than axios or EventSource: axios buffers the whole body
|
|
* before resolving, and EventSource cannot send an Authorization header —
|
|
* which would mean moving the JWT into the query string, where it would be
|
|
* logged by every proxy in the path.
|
|
*
|
|
* `onDelta` is called with each fragment as it arrives. Pass `signal` to
|
|
* abort; the promise resolves with the full text.
|
|
*/
|
|
async streamCopilot(
|
|
question: string,
|
|
opts: {
|
|
history?: CopilotTurn[];
|
|
date?: string;
|
|
model?: string;
|
|
signal?: AbortSignal;
|
|
onDelta?: (text: string) => void;
|
|
} = {}
|
|
): Promise<{ text: string; upstream: string | null }> {
|
|
const token = localStorage.getItem(TOKEN_KEY);
|
|
const resp = await fetch(`${API_BASE_URL}/analysis/copilot`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
question,
|
|
history: opts.history ?? [],
|
|
date: opts.date,
|
|
model: opts.model,
|
|
}),
|
|
signal: opts.signal,
|
|
});
|
|
|
|
if (!resp.ok || !resp.body) {
|
|
let message = `请求失败 (${resp.status})`;
|
|
try {
|
|
message = (await resp.json()).error || message;
|
|
} catch {
|
|
// A non-JSON error body (a proxy's HTML 502) leaves the status text.
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
|
|
const reader = resp.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let text = '';
|
|
let upstream: string | null = null;
|
|
let failure: string | null = null;
|
|
|
|
// SSE frames are separated by a blank line and can split across chunks,
|
|
// so the tail of the buffer is kept until its terminator arrives.
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
let split = buffer.indexOf('\n\n');
|
|
while (split !== -1) {
|
|
const frame = buffer.slice(0, split);
|
|
buffer = buffer.slice(split + 2);
|
|
split = buffer.indexOf('\n\n');
|
|
|
|
let event = 'message';
|
|
const dataLines: string[] = [];
|
|
for (const line of frame.split('\n')) {
|
|
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
|
}
|
|
if (!dataLines.length) continue;
|
|
|
|
let payload: any;
|
|
try {
|
|
payload = JSON.parse(dataLines.join('\n'));
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
if (event === 'delta' && payload.text) {
|
|
text += payload.text;
|
|
opts.onDelta?.(payload.text);
|
|
} else if (event === 'done') {
|
|
upstream = payload.upstream ?? null;
|
|
} else if (event === 'error') {
|
|
// Recorded rather than thrown here: the stream still has to be
|
|
// drained, and the server closes it right after this frame.
|
|
failure = payload.message || '生成失败';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (failure) throw new Error(failure);
|
|
return { text, upstream };
|
|
}
|
|
}
|
|
|
|
export const apiClient = new ApiClient();
|