feat(ai): 每个数据页面都有 AI 解读,靠一条带优先级的生产者/消费者队列
原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外 的 10 个页面都有:健康、睡眠、运动、趋势、每日、身体成分、成绩预测、 身体年龄、挑战赛、运动详情。 不是给每个页面写一套,而是一个通用管线: - services/scopes.py:一个页面一个 context builder,返回同一个信封。 context["highlights"] 是已经算好的白话事实——模型负责解读它们,模型不 可用时规则引擎原样渲染。两者引用同一批数字,所以降级读起来不像换了个 App。 没数据的页面返回 None,宁可不出卡片,也不让模型对着空表格发挥。 - coach.scope_messages / parse_scope_insight:一套提示词吃所有页面,页面 的差异全在 context 里,加页面 = 加一个 builder。 - 前端 <AiPanel scope="…">:一个组件渲染所有页面,轮询逻辑抽成 lib/insight.ts 的 usePolledInsight,晨报卡也改用它。 ## 队列 一次生成 40 秒到 4.5 分钟,所以什么都不能在请求里生成。页面只负责入队, worker 负责消费(services/jobs.py)。 优先级才是用队列而不是后台线程的理由:同步完成后 prefetch 把所有页面按 背景优先级排进去,可能要跑半小时;而用户一打开某个页面,那个页面的任务 立刻提到队首、下一个就跑。你在看什么,队列就在算什么。 队列放在数据库而不是内存里,因为 gunicorn 有两个 worker:任务带 holder 声明后回读确认,和 scheduler.py 抢 tick 是同一套做法。id 由 user+kind+subject 推导,所以每几秒一次的轮询是幂等的入队,不会每几秒堆一 个任务。 ## 网关中断时踩到的两个坑(当场修了) 写完正好赶上 oracle 那台机器不通,于是看到: - 三次失败后任务被永久标 failed,网关恢复了也不会重试——一次瞬时中断就把 那个页面的解读判了死刑,直到它的数据碰巧变化。加了冷却期,过期后重置 尝试次数再排一次。 - 队列已经放弃了,页面还在 pending 转圈,要转满 8 分钟才停。meta.pending 现在跟着队列状态走,并把失败原因带给卡片。 顺带把 BAND_SOURCES 从 routes/settings.py 下沉到 services/insights.py: 教练要拿它做参照,而 services 不该反向依赖 routes。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
apiClient, errorMessage, Briefing, BriefingContext, InsightMeta,
|
||||
apiClient, Briefing, BriefingContext, InsightMeta,
|
||||
} from '../services/api';
|
||||
import { usePolledInsight } from '../lib/insight';
|
||||
import './AiBriefing.css';
|
||||
|
||||
/* A generation runs for minutes on the gateway's reasoning upstream, so the
|
||||
card shows the rule-based briefing immediately and polls for the model's
|
||||
version. The interval is a compromise: often enough that the swap feels
|
||||
like it belongs to this visit, rare enough that a five-minute generation
|
||||
costs a few dozen requests rather than a few hundred. */
|
||||
const POLL_MS = 8000;
|
||||
const POLL_LIMIT_MS = 6 * 60 * 1000;
|
||||
|
||||
interface Props {
|
||||
/** Day to brief on. Omit for the newest day on record. */
|
||||
date?: string;
|
||||
@@ -79,65 +72,17 @@ function Deviations({ context }: { context: BriefingContext }) {
|
||||
}
|
||||
|
||||
function AiBriefing({ date }: Props) {
|
||||
const [briefing, setBriefing] = useState<Briefing | null>(null);
|
||||
const [context, setContext] = useState<BriefingContext | null>(null);
|
||||
const [meta, setMeta] = useState<InsightMeta | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
/* The poll is cleared on unmount and whenever the day changes, so stepping
|
||||
back through dates cannot leave a timer writing into a stale card. */
|
||||
const timer = useRef<number>();
|
||||
const startedAt = useRef(0);
|
||||
|
||||
const load = useCallback(async (refresh?: boolean) => {
|
||||
try {
|
||||
const data = await apiClient.getBriefing({ date, refresh });
|
||||
setBriefing(data.briefing);
|
||||
setContext(data.context);
|
||||
setMeta(data.meta);
|
||||
setError('');
|
||||
return data.meta;
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '获取简报失败'));
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const resp = await apiClient.getBriefing({ date, refresh });
|
||||
return { data: resp.briefing, meta: resp.meta, extra: resp.context };
|
||||
}, [date]);
|
||||
|
||||
const cancelled = useRef(false);
|
||||
|
||||
/* One poll loop, shared by the first load and by 重新生成. Each tick asks
|
||||
without `refresh` — only the first request may bypass the cache, or every
|
||||
tick would restart the generation it is waiting for. */
|
||||
const poll = useCallback(async (refresh?: boolean) => {
|
||||
window.clearTimeout(timer.current);
|
||||
startedAt.current = Date.now();
|
||||
setLoading(true);
|
||||
|
||||
const tick = async (first: boolean) => {
|
||||
const result = await load(first && refresh);
|
||||
if (cancelled.current) return;
|
||||
// Stop as soon as a model answer lands, and give up after the window a
|
||||
// generation realistically needs — an upstream that has gone quiet
|
||||
// should not leave the tab polling for the rest of the session.
|
||||
if (result?.pending && Date.now() - startedAt.current < POLL_LIMIT_MS) {
|
||||
timer.current = window.setTimeout(() => tick(false), POLL_MS);
|
||||
}
|
||||
};
|
||||
await tick(true);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
cancelled.current = false;
|
||||
poll();
|
||||
return () => {
|
||||
cancelled.current = true;
|
||||
window.clearTimeout(timer.current);
|
||||
};
|
||||
}, [poll]);
|
||||
const {
|
||||
data: briefing, meta, extra, loading, error, refresh,
|
||||
} = usePolledInsight<Briefing>(load, '获取简报失败');
|
||||
const context = extra as BriefingContext | null;
|
||||
|
||||
if (loading && !briefing) {
|
||||
return <div className="brief-skeleton" aria-label="正在生成简报" />;
|
||||
@@ -197,11 +142,7 @@ function AiBriefing({ date }: Props) {
|
||||
{context && <Deviations context={context} />}
|
||||
|
||||
<div className="brief-foot">
|
||||
<button
|
||||
className="brief-link"
|
||||
onClick={() => poll(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
<button className="brief-link" onClick={refresh} disabled={loading}>
|
||||
{loading ? '重新生成中…' : '重新生成'}
|
||||
</button>
|
||||
{meta.generatedAt && (
|
||||
|
||||
150
client/src/components/AiPanel.css
Normal file
150
client/src/components/AiPanel.css
Normal file
@@ -0,0 +1,150 @@
|
||||
/* The AI reading shown on every data screen. Deliberately quieter than the
|
||||
今日 briefing card: that one is the hero of its screen, these sit among the
|
||||
charts they comment on. */
|
||||
.aip {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 0.9rem 0.6rem;
|
||||
margin-bottom: 1.25rem;
|
||||
animation: aip-in 0.32s var(--ease) both;
|
||||
}
|
||||
|
||||
@keyframes aip-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.aip-skeleton {
|
||||
height: 104px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 1.25rem;
|
||||
background: linear-gradient(
|
||||
100deg, var(--surface-1) 30%, var(--surface-2) 50%, var(--surface-1) 70%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: aip-shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes aip-shimmer {
|
||||
from { background-position: 180% 0; }
|
||||
to { background-position: -80% 0; }
|
||||
}
|
||||
|
||||
.aip-error { color: var(--status-critical); font-size: 0.82rem; }
|
||||
|
||||
.aip-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.aip-head .sec-title { margin: 0; }
|
||||
|
||||
/* Always visible: a model reading and a plain calculation look alike on the
|
||||
page, and which one it is changes how much weight it deserves. */
|
||||
.aip-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 600;
|
||||
padding: 0.14rem 0.42rem;
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-0);
|
||||
border: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aip-badge-ai {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.aip-badge-pending { color: var(--text-secondary); }
|
||||
|
||||
.aip-spinner {
|
||||
width: 0.52rem;
|
||||
height: 0.52rem;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-top-color: var(--accent);
|
||||
animation: aip-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes aip-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.aip-headline {
|
||||
margin: 0.4rem 0 0.7rem;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.aip-point { margin-bottom: 0.55rem; }
|
||||
|
||||
.aip-tag {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.14rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.aip-point p {
|
||||
margin: 0;
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.aip-actions {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-left: 1.05rem;
|
||||
}
|
||||
|
||||
.aip-actions li {
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.aip-caution {
|
||||
margin: 0.55rem 0 0;
|
||||
padding-left: 0.55rem;
|
||||
border-left: 2px solid var(--status-warning);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.aip-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.65rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--grid);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.aip-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.aip-link:disabled { color: var(--text-muted); cursor: default; }
|
||||
99
client/src/components/AiPanel.tsx
Normal file
99
client/src/components/AiPanel.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
apiClient, InsightScope, InsightMeta, ScopeInsight,
|
||||
} from '../services/api';
|
||||
import { usePolledInsight } from '../lib/insight';
|
||||
import './AiPanel.css';
|
||||
|
||||
const CONFIDENCE_LABEL: Record<string, string> = {
|
||||
high: '证据充分', medium: '证据一般', low: '证据薄弱',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
scope: InsightScope;
|
||||
/** Identifies the item for per-item screens: an activity id, a date. */
|
||||
subject?: string;
|
||||
/** Heading. Defaults to the generic one. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function Badge({ meta }: { meta: InsightMeta }) {
|
||||
if (meta.source === 'ai') {
|
||||
return (
|
||||
<span className="aip-badge aip-badge-ai" title={meta.model ?? undefined}>
|
||||
AI 生成{meta.upstream ? ` · ${meta.upstream}` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (meta.pending) {
|
||||
return (
|
||||
<span className="aip-badge aip-badge-pending">
|
||||
<span className="aip-spinner" aria-hidden="true" />
|
||||
排队生成中
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="aip-badge" title={meta.reason}>直接计算</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One screen's AI reading.
|
||||
*
|
||||
* The same component on every screen: what differs between 睡眠 and 运动 is
|
||||
* entirely in the context the backend builds, so a new screen is one more
|
||||
* `<AiPanel scope="…" />` and a builder — not another card.
|
||||
*
|
||||
* It always renders something. The computed facts appear immediately, and the
|
||||
* model's interpretation replaces them when the coach's queue reaches this
|
||||
* screen — which opening the screen moves to the front of.
|
||||
*/
|
||||
function AiPanel({ scope, subject, title = 'AI 解读' }: Props) {
|
||||
const load = useCallback(async () => {
|
||||
const resp = await apiClient.getInsight(scope, { subject });
|
||||
return { data: resp.insight, meta: resp.meta };
|
||||
}, [scope, subject]);
|
||||
|
||||
const { data, meta, loading, error, refresh } =
|
||||
usePolledInsight<ScopeInsight>(load);
|
||||
|
||||
if (loading && !data) return <div className="aip-skeleton" aria-label="正在读取解读" />;
|
||||
if (error) return <section className="aip aip-error">{error}</section>;
|
||||
if (!data || !meta) return null;
|
||||
// Nothing to read yet on this screen — a card saying so is worse than none.
|
||||
if (meta.source === 'none') return null;
|
||||
|
||||
return (
|
||||
<section className="aip">
|
||||
<div className="aip-head">
|
||||
<h3 className="sec-title">{title}</h3>
|
||||
<Badge meta={meta} />
|
||||
</div>
|
||||
|
||||
{data.headline && <p className="aip-headline">{data.headline}</p>}
|
||||
|
||||
{data.points.map((p) => (
|
||||
<div className="aip-point" key={p.title + p.detail}>
|
||||
<span className="aip-tag">{p.title}</span>
|
||||
<p>{p.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!!data.actions.length && (
|
||||
<ul className="aip-actions">
|
||||
{data.actions.map((a) => <li key={a}>{a}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data.caution && <p className="aip-caution">{data.caution}</p>}
|
||||
|
||||
<div className="aip-foot">
|
||||
<button className="aip-link" onClick={refresh} disabled={loading}>
|
||||
{loading ? '重新生成中…' : '重新生成'}
|
||||
</button>
|
||||
<span>{CONFIDENCE_LABEL[data.confidence]}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default AiPanel;
|
||||
99
client/src/lib/insight.ts
Normal file
99
client/src/lib/insight.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { errorMessage, InsightMeta } from '../services/api';
|
||||
|
||||
/* A generation runs for minutes on the gateway, so every AI card shows the
|
||||
rule-based version first and polls for the model's. The interval is a
|
||||
compromise: often enough that the swap belongs to this visit, rare enough
|
||||
that a five-minute wait costs a few dozen requests rather than a few
|
||||
hundred. */
|
||||
export const POLL_MS = 8000;
|
||||
export const POLL_LIMIT_MS = 8 * 60 * 1000;
|
||||
|
||||
export interface Loaded<T> {
|
||||
data: T | null;
|
||||
meta: InsightMeta;
|
||||
}
|
||||
|
||||
export interface Polled<T> {
|
||||
data: T | null;
|
||||
meta: InsightMeta | null;
|
||||
/** Extra payload the loader returned alongside the insight (the context). */
|
||||
extra: unknown;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
/** Discard the stored answer and generate again. */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an AI insight, then keep polling while the model is still working.
|
||||
*
|
||||
* Shared by every AI card. `load` must be stable — wrap it in `useCallback`
|
||||
* keyed on whatever identifies the thing being read — because a new identity
|
||||
* restarts the poll, which is exactly right when the screen changes what it
|
||||
* is about and exactly wrong on an unrelated re-render.
|
||||
*/
|
||||
export function usePolledInsight<T>(
|
||||
load: (refresh?: boolean) => Promise<Loaded<T> & { extra?: unknown }>,
|
||||
fallbackMessage = '获取 AI 解读失败'
|
||||
): Polled<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [meta, setMeta] = useState<InsightMeta | null>(null);
|
||||
const [extra, setExtra] = useState<unknown>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const timer = useRef<number>();
|
||||
const startedAt = useRef(0);
|
||||
const cancelled = useRef(false);
|
||||
|
||||
const fetchOnce = useCallback(async (refresh?: boolean) => {
|
||||
try {
|
||||
const result = await load(refresh);
|
||||
if (cancelled.current) return null;
|
||||
setData(result.data);
|
||||
setMeta(result.meta);
|
||||
setExtra(result.extra ?? null);
|
||||
setError('');
|
||||
return result.meta;
|
||||
} catch (err: any) {
|
||||
if (!cancelled.current) setError(errorMessage(err, fallbackMessage));
|
||||
return null;
|
||||
} finally {
|
||||
if (!cancelled.current) setLoading(false);
|
||||
}
|
||||
}, [load, fallbackMessage]);
|
||||
|
||||
const poll = useCallback(async (refresh?: boolean) => {
|
||||
window.clearTimeout(timer.current);
|
||||
startedAt.current = Date.now();
|
||||
setLoading(true);
|
||||
|
||||
// Only the first request may bypass the cache; a `refresh` on every tick
|
||||
// would restart the generation the poll is waiting for.
|
||||
const tick = async (first: boolean) => {
|
||||
const result = await fetchOnce(first && refresh);
|
||||
if (cancelled.current) return;
|
||||
// Give up after the window a generation realistically needs — an
|
||||
// upstream that has gone quiet must not leave the tab polling forever.
|
||||
if (result?.pending && Date.now() - startedAt.current < POLL_LIMIT_MS) {
|
||||
timer.current = window.setTimeout(() => tick(false), POLL_MS);
|
||||
}
|
||||
};
|
||||
await tick(true);
|
||||
}, [fetchOnce]);
|
||||
|
||||
useEffect(() => {
|
||||
cancelled.current = false;
|
||||
poll();
|
||||
return () => {
|
||||
cancelled.current = true;
|
||||
window.clearTimeout(timer.current);
|
||||
};
|
||||
}, [poll]);
|
||||
|
||||
return {
|
||||
data, meta, extra, loading, error,
|
||||
refresh: () => { poll(true); },
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import { apiClient, ActivityDetail, errorMessage } from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import AiPanel from '../components/AiPanel';
|
||||
import { FEATURES } from '../features';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './ActivityDetail.css';
|
||||
@@ -289,6 +291,7 @@ function ActivityDetailPage({ id, f7route }: Props) {
|
||||
|
||||
return (
|
||||
<Screen title={title} subtitle={s.startTimeLocal?.slice(0, 16).replace('T', ' ')} backLink>
|
||||
{FEATURES.ai && <AiPanel scope="activity" subject={activityId} title="AI 本次运动解读" />}
|
||||
<div className="metric-tabs">
|
||||
{TABS.map(([tabId, text]) => (
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import { apiClient, errorMessage, FitnessAge } from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import AiPanel from '../components/AiPanel';
|
||||
import { FEATURES } from '../features';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './BodyAge.css';
|
||||
|
||||
@@ -42,6 +44,8 @@ function BodyAgePage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{FEATURES.ai && <AiPanel scope="bodyAge" title="AI 身体年龄解读" />}
|
||||
|
||||
<section className="ba-hero">
|
||||
<div className="ba-value">
|
||||
{data.value}<span className="ba-unit">岁</span>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
apiClient, BloodPressureReading, BodyCompositionDay, errorMessage,
|
||||
} from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import AiPanel from '../components/AiPanel';
|
||||
import { FEATURES } from '../features';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
@@ -71,6 +73,8 @@ function BodyPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{FEATURES.ai && <AiPanel scope="body" title="AI 身体成分解读" />}
|
||||
|
||||
<section className="md-hero">
|
||||
<div className="md-value">
|
||||
{latest?.weightKg != null ? latest.weightKg.toFixed(1) : '—'}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import { apiClient, Challenge, errorMessage } from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import AiPanel from '../components/AiPanel';
|
||||
import { FEATURES } from '../features';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Challenges.css';
|
||||
|
||||
@@ -54,6 +56,8 @@ function ChallengesPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{FEATURES.ai && <AiPanel scope="challenges" title="AI 挑战赛解读" />}
|
||||
|
||||
{kinds.length > 2 && (
|
||||
<div className="metric-tabs">
|
||||
{kinds.map((k) => (
|
||||
|
||||
@@ -6,6 +6,8 @@ import Chart from '../components/charts/Chart';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Daily.css';
|
||||
import Screen from '../components/Screen';
|
||||
import AiPanel from '../components/AiPanel';
|
||||
import { FEATURES } from '../features';
|
||||
import { iso, today as todayIso } from '../lib/day';
|
||||
|
||||
/** Every stored metric, grouped the way the device groups them. */
|
||||
@@ -189,6 +191,7 @@ function DailyPage() {
|
||||
|
||||
return (
|
||||
<Screen title="每日数据" backLink>
|
||||
{FEATURES.ai && <AiPanel scope="daily" subject={date} title="AI 当日解读" />}
|
||||
|
||||
<div className="day-nav">
|
||||
<button className="day-btn" onClick={() => shift(-1)} aria-label="前一天">‹</button>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord,
|
||||
} from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import AiPanel from '../components/AiPanel';
|
||||
import { FEATURES } from '../features';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
import MetricCard from '../components/charts/MetricCard';
|
||||
import Chart from '../components/charts/Chart';
|
||||
@@ -126,6 +128,8 @@ function ExercisePage() {
|
||||
<Screen title="运动" subtitle={`近 ${WINDOW_DAYS} 天`}>
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
|
||||
{FEATURES.ai && <AiPanel scope="exercise" title="AI 训练解读" />}
|
||||
|
||||
<section className="sec">
|
||||
<div className="mcard-grid">
|
||||
<MetricCard label="运动时长" value={totalMinutes} unit="分钟"
|
||||
|
||||
@@ -5,6 +5,8 @@ import { METRICS, metricHref } from '../lib/metrics';
|
||||
import MetricCard from '../components/charts/MetricCard';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import Screen from '../components/Screen';
|
||||
import AiPanel from '../components/AiPanel';
|
||||
import { FEATURES } from '../features';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
import './Health.css';
|
||||
import './Settings.css';
|
||||
@@ -143,6 +145,8 @@ function HealthPage() {
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||
<BodyAge data={bodyAge} />
|
||||
|
||||
{FEATURES.ai && <AiPanel scope="health" title="AI 健康解读" />}
|
||||
|
||||
{SECTIONS.map((section) => (
|
||||
<section className="sec" key={section.title}>
|
||||
<h3 className="sec-title">{section.title}</h3>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import { apiClient, errorMessage, RacePrediction } from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import AiPanel from '../components/AiPanel';
|
||||
import { FEATURES } from '../features';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './MetricDetail.css';
|
||||
@@ -75,6 +77,8 @@ function RacePage() {
|
||||
<Screen title="成绩预测" backLink subtitle={latest.date}>
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
|
||||
{FEATURES.ai && <AiPanel scope="race" title="AI 成绩解读" />}
|
||||
|
||||
<div className="race-list">
|
||||
{DISTANCES.map(([key, label]) => (
|
||||
<div className="race-row" key={key}>
|
||||
|
||||
@@ -5,6 +5,8 @@ import Chart from '../components/charts/Chart';
|
||||
import StatTile from '../components/charts/StatTile';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import Screen from '../components/Screen';
|
||||
import AiPanel from '../components/AiPanel';
|
||||
import { FEATURES } from '../features';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
|
||||
const RANGES = [7, 14, 30, 90];
|
||||
@@ -75,6 +77,7 @@ function SleepPage() {
|
||||
|
||||
return (
|
||||
<Screen title="睡眠" backLink subtitle="分期、评分与夜间生理指标">
|
||||
{FEATURES.ai && <AiPanel scope="sleep" title="AI 睡眠解读" />}
|
||||
|
||||
<div className="segmented-row">
|
||||
<span className="segmented-label">范围</span>
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
|
||||
} from '../lib/aggregate';
|
||||
import Screen from '../components/Screen';
|
||||
import AiPanel from '../components/AiPanel';
|
||||
import { FEATURES } from '../features';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
|
||||
const RANGES = [
|
||||
@@ -221,6 +223,7 @@ function TrendsPage() {
|
||||
|
||||
return (
|
||||
<Screen title="趋势">
|
||||
{FEATURES.ai && <AiPanel scope="trends" title="AI 长期趋势归因" />}
|
||||
|
||||
<div className="segmented-row">
|
||||
<span className="segmented-label">范围</span>
|
||||
|
||||
@@ -427,6 +427,27 @@ export interface TrendInsightResponse {
|
||||
meta: InsightMeta;
|
||||
}
|
||||
|
||||
/** One screen's AI reading. The same shape for every screen, so one component
|
||||
* renders all of them. */
|
||||
export interface ScopeInsight {
|
||||
headline: string | null;
|
||||
points: Array<{ title: string; detail: string }>;
|
||||
actions: string[];
|
||||
caution: string | null;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface ScopeInsightResponse {
|
||||
insight: ScopeInsight | null;
|
||||
context: Record<string, any> | null;
|
||||
meta: InsightMeta;
|
||||
}
|
||||
|
||||
/** Screens the coach can read. Mirrors SCOPES in backend/services/scopes.py. */
|
||||
export type InsightScope =
|
||||
| 'health' | 'sleep' | 'exercise' | 'trends' | 'daily'
|
||||
| 'body' | 'race' | 'bodyAge' | 'challenges' | 'activity';
|
||||
|
||||
export interface CopilotTurn {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
@@ -777,6 +798,34 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// --- AI coach ---
|
||||
/**
|
||||
* One screen's AI reading.
|
||||
*
|
||||
* Answers immediately: while the model's version is being generated the
|
||||
* computed highlights come back with `meta.pending`, and opening the screen
|
||||
* puts its job at the front of the coach's queue. Poll to pick up the
|
||||
* finished version.
|
||||
*/
|
||||
async getInsight(
|
||||
scope: InsightScope,
|
||||
opts: { subject?: string; refresh?: boolean } = {}
|
||||
) {
|
||||
const { data } = await this.client.get<ScopeInsightResponse>('/analysis/insight', {
|
||||
params: {
|
||||
scope, subject: opts.subject, ...(opts.refresh ? { refresh: 1 } : {}),
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/** How much the coach still has to generate — for a progress hint. */
|
||||
async getInsightQueue() {
|
||||
const { data } = await this.client.get<{ pending: number; enabled: boolean }>(
|
||||
'/analysis/insight/queue'
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 晨间简报 for one day, plus the computed context behind it.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user