feat(ai): AI 教练 —— 晨间简报、运动处方、趋势归因与 Copilot

数值全部在服务端算好再交给模型,模型只做解读。让模型从 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>
This commit is contained in:
ericwyuan
2026-09-01 13:57:35 +08:00
parent a746327560
commit c57c930949
21 changed files with 3677 additions and 22 deletions

View File

@@ -0,0 +1,260 @@
/* AI 晨间简报 — the hero card above the metric grid.
Shares the surface, radius and lift of .hero in Today.css so the two read as
one stack rather than two competing headers. */
.brief-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 1.1rem 1rem 0.65rem;
margin-bottom: 1.25rem;
animation: hero-in 0.5s var(--ease) both;
}
.brief-skeleton {
height: 132px;
border-radius: 16px;
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: brief-shimmer 1.4s linear infinite;
}
@keyframes brief-shimmer {
from { background-position: 180% 0; }
to { background-position: -80% 0; }
}
.brief-error,
.brief-empty {
color: var(--text-secondary);
font-size: 0.88rem;
padding-bottom: 1.1rem;
}
.brief-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.brief-status {
font-size: 0.95rem;
font-weight: 680;
color: var(--text-primary);
letter-spacing: -0.01em;
}
/* Provenance is deliberately always visible: a model answer and a rule-engine
stand-in look alike on the page, and which one is on screen changes how much
weight the reader should give it. */
.brief-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.66rem;
font-weight: 600;
padding: 0.16rem 0.44rem;
border-radius: 999px;
color: var(--text-muted);
background: var(--surface-0);
border: 1px solid var(--border);
white-space: nowrap;
}
.brief-badge-ai {
color: var(--accent);
background: var(--accent-soft);
border-color: transparent;
}
.brief-badge-pending { color: var(--text-secondary); }
.brief-spinner {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
border: 1.5px solid var(--border-strong);
border-top-color: var(--accent);
animation: brief-spin 0.8s linear infinite;
}
@keyframes brief-spin { to { transform: rotate(360deg); } }
.brief-headline {
margin: 0 0 0.7rem;
font-size: 0.92rem;
line-height: 1.5;
color: var(--text-primary);
}
.brief-rx {
background: var(--surface-0);
border-radius: 12px;
padding: 0.6rem 0.7rem;
margin-bottom: 0.55rem;
}
.brief-rx-label {
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.brief-rx-body {
margin: 0.2rem 0 0;
font-size: 0.86rem;
line-height: 1.5;
color: var(--text-primary);
}
.brief-tag {
display: inline-block;
margin-left: 0.35rem;
font-size: 0.68rem;
font-weight: 600;
padding: 0.1rem 0.38rem;
border-radius: 6px;
color: var(--accent);
background: var(--accent-soft);
vertical-align: 0.06em;
}
.brief-avoid,
.brief-shortfall {
margin: 0.3rem 0 0;
font-size: 0.78rem;
color: var(--text-secondary);
}
.brief-shortfall { margin-bottom: 0.5rem; }
.brief-detail {
border-top: 1px solid var(--border);
padding-top: 0.75rem;
margin-top: 0.4rem;
animation: brief-open 0.28s var(--ease) both;
}
@keyframes brief-open {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: none; }
}
.brief-sub {
margin: 0 0 0.2rem;
font-size: 0.72rem;
font-weight: 700;
color: var(--text-muted);
}
.brief-diag { margin-bottom: 0.6rem; }
.brief-diag p {
margin: 0;
font-size: 0.84rem;
line-height: 1.5;
color: var(--text-secondary);
}
.brief-actions ul {
margin: 0 0 0.6rem;
padding-left: 1.05rem;
}
.brief-actions li {
font-size: 0.84rem;
line-height: 1.55;
color: var(--text-secondary);
}
.brief-dev-list {
list-style: none;
margin: 0 0 0.6rem;
padding: 0;
}
.brief-dev-list li {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.22rem 0;
border-bottom: 1px solid var(--grid);
font-size: 0.8rem;
}
.brief-dev-list li:last-child { border-bottom: none; }
.brief-dev-label {
flex: 1;
color: var(--text-secondary);
}
/* Tabular figures here, unlike the display numbers in the rings: these are a
column meant to be compared down the list. */
.brief-dev-value {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--text-primary);
}
/* Direction only — not good/bad. A high z on HRV is welcome and a high z on
resting heart rate is not, so colouring by sign would mislead; the status
tokens stay reserved for judgements the card actually makes. */
.brief-dev-z {
font-variant-numeric: tabular-nums;
font-size: 0.72rem;
font-weight: 600;
min-width: 3.1rem;
text-align: right;
color: var(--text-muted);
}
.brief-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-top: 0.2rem;
}
.brief-link {
background: none;
border: none;
padding: 0;
font-size: 0.78rem;
font-weight: 600;
color: var(--accent);
cursor: pointer;
}
.brief-link:disabled { color: var(--text-muted); cursor: default; }
.brief-time {
font-size: 0.68rem;
color: var(--text-muted);
}
.brief-toggle {
display: block;
width: 100%;
background: none;
border: none;
border-top: 1px solid var(--border);
margin-top: 0.5rem;
padding: 0.55rem 0 0.15rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
}
.brief-toggle:active { color: var(--accent); }

View File

@@ -0,0 +1,227 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
apiClient, errorMessage, Briefing, BriefingContext, InsightMeta,
} from '../services/api';
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;
}
function SourceBadge({ meta }: { meta: InsightMeta }) {
if (meta.source === 'ai') {
return (
<span className="brief-badge brief-badge-ai" title={meta.model ?? undefined}>
AI {meta.upstream ? ` · ${meta.upstream}` : ''}
</span>
);
}
if (meta.pending) {
return (
<span className="brief-badge brief-badge-pending">
<span className="brief-spinner" aria-hidden="true" />
· AI
</span>
);
}
return (
<span className="brief-badge" title={meta.reason}>
</span>
);
}
/**
* The metrics that moved furthest from the user's own baseline.
*
* Shown alongside the prose because the briefing quotes these figures: the
* card should let the reader check the claim rather than take it on trust.
* Only departures are listed — a row saying a metric is normal is noise.
*/
function Deviations({ context }: { context: BriefingContext }) {
const notable = context.deviations
.filter((d) => d.z !== null && Math.abs(d.z) >= 1)
.slice(0, 4);
if (!notable.length) return null;
return (
<div className="brief-dev">
<h4 className="brief-sub">线</h4>
<ul className="brief-dev-list">
{notable.map((d) => (
<li key={d.metric}>
<span className="brief-dev-label">{d.label}</span>
<span className="brief-dev-value">
{d.value}
{d.unit}
</span>
<span
className={`brief-dev-z ${d.z! > 0 ? 'up' : 'down'}`}
title={`${d.baselineDays} 天基线 ${d.baselineMean}${d.unit}`}
>
{d.z! > 0 ? '+' : ''}
{d.z!.toFixed(1)}σ
</span>
</li>
))}
</ul>
</div>
);
}
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);
}
}, [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]);
if (loading && !briefing) {
return <div className="brief-skeleton" aria-label="正在生成简报" />;
}
if (error) return <div className="brief-card brief-error">{error}</div>;
if (!briefing || !meta) return null;
if (meta.source === 'none') {
return <div className="brief-card brief-empty">{meta.reason ?? '暂无可分析的数据'}</div>;
}
const rx = briefing.prescription;
return (
<section className="brief-card">
<header className="brief-head">
<span className="brief-status">{briefing.status}</span>
<SourceBadge meta={meta} />
</header>
{briefing.headline && <p className="brief-headline">{briefing.headline}</p>}
{(rx.suggestion || rx.intensity) && (
<div className="brief-rx">
<span className="brief-rx-label"></span>
<p className="brief-rx-body">
{rx.suggestion}
{rx.intensity && <span className="brief-tag"> {rx.intensity}</span>}
{rx.hrZone && <span className="brief-tag">{rx.hrZone}</span>}
{rx.durationMin != null && <span className="brief-tag">{rx.durationMin} </span>}
</p>
{rx.avoid && <p className="brief-avoid">{rx.avoid}</p>}
</div>
)}
{briefing.shortfall && briefing.shortfall !== '无明显短板' && (
<p className="brief-shortfall">{briefing.shortfall}</p>
)}
{open && (
<div className="brief-detail">
{briefing.diagnosis.map((d) => (
<div className="brief-diag" key={d.title + d.detail}>
<h4 className="brief-sub">{d.title}</h4>
<p>{d.detail}</p>
</div>
))}
{!!briefing.actions.length && (
<div className="brief-actions">
<h4 className="brief-sub"></h4>
<ul>
{briefing.actions.map((a) => <li key={a}>{a}</li>)}
</ul>
</div>
)}
{context && <Deviations context={context} />}
<div className="brief-foot">
<button
className="brief-link"
onClick={() => poll(true)}
disabled={loading}
>
{loading ? '重新生成中…' : '重新生成'}
</button>
{meta.generatedAt && (
<span className="brief-time">
{meta.generatedAt.replace('T', ' ')} UTC
</span>
)}
</div>
</div>
)}
<button
className="brief-toggle"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
{open ? '收起' : '展开深度报告'}
</button>
</section>
);
}
export default AiBriefing;

View File

@@ -0,0 +1,196 @@
/* Health Copilot — a floating dock, portalled to <body>.
The z-index clears Framework7's navbar (500) and tab bar but stays under its
modals (10500-13500), so a dialog is never trapped behind the panel. */
.copilot-wrap {
position: fixed;
right: max(0.9rem, env(safe-area-inset-right));
/* Above the tab bar, which is 50px plus the home indicator. */
bottom: calc(50px + max(0.9rem, env(safe-area-inset-bottom)));
z-index: 9000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.6rem;
pointer-events: none;
}
.copilot-wrap > * { pointer-events: auto; }
.copilot-fab {
width: 3rem;
height: 3rem;
border-radius: 50%;
border: none;
background: var(--accent-solid);
color: #fff;
font-size: 0.86rem;
font-weight: 700;
letter-spacing: 0.02em;
box-shadow: var(--shadow-lift);
cursor: pointer;
transition: transform 0.18s var(--ease);
}
.copilot-fab:active { transform: scale(0.94); }
.copilot-fab.open { font-size: 1rem; font-weight: 500; }
.copilot-panel {
display: flex;
flex-direction: column;
width: min(23rem, calc(100vw - 1.8rem));
height: min(30rem, calc(100vh - 11rem));
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow-lift);
overflow: hidden;
animation: copilot-in 0.24s var(--ease) both;
}
@keyframes copilot-in {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: none; }
}
.copilot-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.copilot-title {
font-size: 0.86rem;
font-weight: 680;
color: var(--text-primary);
}
.copilot-close {
background: none;
border: none;
font-size: 0.9rem;
color: var(--text-muted);
cursor: pointer;
padding: 0 0.2rem;
}
.copilot-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0.75rem 0.8rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.copilot-intro p {
margin: 0 0 0.6rem;
font-size: 0.78rem;
line-height: 1.5;
color: var(--text-muted);
}
.copilot-starter {
display: block;
width: 100%;
text-align: left;
margin-bottom: 0.4rem;
padding: 0.5rem 0.6rem;
font-size: 0.8rem;
line-height: 1.4;
color: var(--text-secondary);
background: var(--surface-0);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
}
.copilot-starter:active { border-color: var(--accent); color: var(--accent); }
.copilot-msg {
max-width: 88%;
padding: 0.5rem 0.65rem;
border-radius: 12px;
font-size: 0.84rem;
line-height: 1.55;
/* Model replies arrive as Markdown-ish plain text; preserving the newlines
keeps its lists and paragraphs readable without a renderer. */
white-space: pre-wrap;
word-break: break-word;
}
.copilot-user {
align-self: flex-end;
background: var(--accent-solid);
color: #fff;
}
.copilot-assistant {
align-self: flex-start;
background: var(--surface-0);
color: var(--text-primary);
border: 1px solid var(--border);
}
.copilot-failed { color: var(--status-critical); }
.copilot-caret {
display: inline-block;
width: 0.42rem;
height: 0.85em;
margin-left: 0.12rem;
background: var(--accent);
vertical-align: -0.12em;
animation: copilot-blink 1s steps(2) infinite;
}
@keyframes copilot-blink { 50% { opacity: 0; } }
.copilot-note {
margin: 0.1rem 0 0;
font-size: 0.7rem;
line-height: 1.5;
color: var(--text-muted);
}
.copilot-compose {
display: flex;
gap: 0.4rem;
padding: 0.55rem 0.6rem;
border-top: 1px solid var(--border);
background: var(--surface-2);
}
.copilot-compose input {
flex: 1;
min-width: 0;
padding: 0.45rem 0.6rem;
font-size: 0.84rem;
color: var(--text-primary);
background: var(--surface-0);
border: 1px solid var(--border);
border-radius: 999px;
outline: none;
}
.copilot-compose input:focus { border-color: var(--accent); }
.copilot-compose button {
flex: none;
padding: 0.45rem 0.8rem;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
background: var(--accent-solid);
border: none;
border-radius: 999px;
cursor: pointer;
}
.copilot-compose button:disabled {
background: var(--border-strong);
cursor: default;
}

View File

@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { apiClient, CopilotTurn } from '../services/api';
import './Copilot.css';
/* Openers, phrased as the questions this data can actually answer. An empty
chat box gets asked nothing; these also teach the shape of question that
works — one about today's state, one about a specific reading, one about
the long arc. */
const STARTERS = [
'我昨晚的睡眠够支撑今天一次高强度训练吗?',
'为什么我的身体电量没有充满?',
'过去一年我的耐力变化,主要是什么驱动的?',
];
interface Message extends CopilotTurn {
/** Set while this answer is still arriving, so the bubble can show a caret
* and the composer can stay disabled. */
streaming?: boolean;
error?: boolean;
}
function Copilot({ date }: { date?: string }) {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const abort = useRef<AbortController>();
const scroller = useRef<HTMLDivElement>(null);
/* Follow the tail as text streams in, but only that: jumping the view on
every delta while the user has scrolled up to re-read would fight them. */
const pinned = useRef(true);
useEffect(() => {
const el = scroller.current;
if (el && pinned.current) el.scrollTop = el.scrollHeight;
}, [messages]);
useEffect(() => () => abort.current?.abort(), []);
const onScroll = () => {
const el = scroller.current;
if (!el) return;
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
};
const ask = async (question: string) => {
const text = question.trim();
if (!text || busy) return;
// The history sent upstream is the conversation *before* this question,
// and only completed turns: a half-streamed or failed answer would teach
// the model to continue its own broken reply.
const history = messages
.filter((m) => !m.streaming && !m.error)
.map(({ role, content }) => ({ role, content }));
setDraft('');
setBusy(true);
pinned.current = true;
setMessages((prev) => [
...prev,
{ role: 'user', content: text },
{ role: 'assistant', content: '', streaming: true },
]);
const controller = new AbortController();
abort.current = controller;
const appendToLast = (updater: (m: Message) => Message) =>
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = updater(next[next.length - 1]);
return next;
});
try {
await apiClient.streamCopilot(text, {
history,
date,
signal: controller.signal,
onDelta: (chunk) =>
appendToLast((m) => ({ ...m, content: m.content + chunk })),
});
appendToLast((m) => ({ ...m, streaming: false }));
} catch (err: any) {
const aborted = controller.signal.aborted;
appendToLast((m) => ({
...m,
streaming: false,
error: !aborted,
content: aborted
? m.content || '(已停止)'
: m.content || err?.message || '生成失败',
}));
} finally {
setBusy(false);
abort.current = undefined;
}
};
const panel = (
<div className="copilot-wrap">
{open && (
<div className="copilot-panel" role="dialog" aria-label="健康 Copilot">
<header className="copilot-head">
<span className="copilot-title"> Copilot</span>
<button
className="copilot-close"
onClick={() => setOpen(false)}
aria-label="关闭"
>
</button>
</header>
<div className="copilot-body" ref={scroller} onScroll={onScroll}>
{!messages.length && (
<div className="copilot-intro">
<p>西</p>
{STARTERS.map((s) => (
<button key={s} className="copilot-starter" onClick={() => ask(s)}>
{s}
</button>
))}
</div>
)}
{messages.map((m, i) => (
<div
key={i}
className={`copilot-msg copilot-${m.role}${m.error ? ' copilot-failed' : ''}`}
>
{m.content}
{m.streaming && <span className="copilot-caret" aria-hidden="true" />}
</div>
))}
{busy && (
<p className="copilot-note">
</p>
)}
</div>
<form
className="copilot-compose"
onSubmit={(e) => { e.preventDefault(); ask(draft); }}
>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="问点关于你自己数据的问题"
disabled={busy}
aria-label="输入问题"
/>
{busy ? (
<button type="button" onClick={() => abort.current?.abort()}>
</button>
) : (
<button type="submit" disabled={!draft.trim()}></button>
)}
</form>
</div>
)}
<button
className={`copilot-fab${open ? ' open' : ''}`}
onClick={() => setOpen((v) => !v)}
aria-label={open ? '收起 Copilot' : '打开健康 Copilot'}
>
{open ? '✕' : 'AI'}
</button>
</div>
);
/* Rendered onto document.body rather than inside the page. Framework7 pages
are transformed during navigation, and a `position: fixed` child of a
transformed ancestor is positioned against that ancestor instead of the
viewport — the button would slide away with the page. */
return createPortal(panel, document.body);
}
export default Copilot;

View File

@@ -0,0 +1,125 @@
/* AI attribution panel, shown under a metric's chart. */
.ti {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.85rem 0.9rem;
margin-bottom: 1.25rem;
}
.ti-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.ti-head .sec-title { margin: 0; }
.ti-hint {
margin: 0.35rem 0 0.6rem;
font-size: 0.78rem;
line-height: 1.5;
color: var(--text-muted);
}
.ti-run {
padding: 0.42rem 0.9rem;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
background: var(--accent-solid);
border: none;
border-radius: 999px;
cursor: pointer;
}
.ti-link {
background: none;
border: none;
padding: 0;
font-size: 0.76rem;
font-weight: 600;
color: var(--accent);
cursor: pointer;
}
.ti-loading {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.ti-spinner {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
border: 1.6px solid var(--border-strong);
border-top-color: var(--accent);
animation: ti-spin 0.8s linear infinite;
}
@keyframes ti-spin { to { transform: rotate(360deg); } }
.ti-error {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--status-critical);
}
.ti-body { animation: ti-in 0.3s var(--ease) both; }
@keyframes ti-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: none; }
}
.ti-summary {
margin: 0.4rem 0 0.7rem;
font-size: 0.86rem;
line-height: 1.55;
color: var(--text-primary);
}
.ti-driver { margin-bottom: 0.55rem; }
.ti-factor {
display: inline-block;
margin-bottom: 0.14rem;
font-size: 0.7rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 6px;
color: var(--accent);
background: var(--accent-soft);
}
.ti-driver p {
margin: 0;
font-size: 0.83rem;
line-height: 1.5;
color: var(--text-secondary);
}
.ti-caution {
margin: 0.5rem 0 0;
padding-left: 0.55rem;
border-left: 2px solid var(--status-warning);
font-size: 0.78rem;
line-height: 1.5;
color: var(--text-secondary);
}
.ti-foot {
display: flex;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.7rem;
padding-top: 0.5rem;
border-top: 1px solid var(--grid);
font-size: 0.68rem;
color: var(--text-muted);
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from 'react';
import {
apiClient, errorMessage, InsightMeta, TrendInsight as Insight,
} from '../services/api';
import './TrendInsight.css';
/**
* Metric ids the backend's feature engineering knows, keyed by the id this app
* uses. Two names differ (the sleep shares), the rest are identical.
*
* Kept as an explicit list rather than sent optimistically: the endpoint 400s
* on an unknown metric, and a button that reliably fails is worse than no
* button on the metrics this cannot explain.
*/
const BACKEND_METRIC: Record<string, string> = {
steps: 'steps',
intensityMinutes: 'intensityMinutes',
heartRate: 'heartRate',
heartRateVariability: 'heartRateVariability',
stress: 'stress',
bodyBatteryHigh: 'bodyBatteryHigh',
respirationAvg: 'respirationAvg',
sleepDuration: 'sleepDuration',
sleepQuality: 'sleepQuality',
deepShare: 'sleepDeepPct',
remShare: 'sleepRemPct',
trainingReadiness: 'trainingReadiness',
enduranceScore: 'enduranceScore',
};
export function supportsInsight(metricId: string) {
return metricId in BACKEND_METRIC;
}
const CONFIDENCE_LABEL: Record<string, string> = {
high: '证据充分', medium: '证据一般', low: '证据薄弱',
};
interface Props {
/** This app's metric id, e.g. `heartRateVariability`. */
metricId: string;
startDate: string;
endDate: string;
}
/**
* AI attribution for the span currently on the chart.
*
* The product spec asks for this on a brush selection over a desktop chart.
* On a phone the equivalent gesture is the range selector that is already
* there, so the panel explains whatever window the user has selected rather
* than adding a drag interaction that fights the page's own scrolling.
*
* On demand, never on load: one generation takes minutes on the gateway, so
* running it for every metric a user browses past would spend that on nothing.
*/
function TrendInsight({ metricId, startDate, endDate }: Props) {
const [insight, setInsight] = useState<Insight | null>(null);
const [meta, setMeta] = useState<InsightMeta | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const live = useRef(true);
useEffect(() => {
live.current = true;
return () => { live.current = false; };
}, []);
// A new window is a different question; clear the old answer rather than
// leaving it under a chart it no longer describes.
useEffect(() => {
setInsight(null);
setMeta(null);
setError('');
}, [metricId, startDate, endDate]);
const run = async (refresh?: boolean) => {
const backend = BACKEND_METRIC[metricId];
if (!backend || loading) return;
setLoading(true);
setError('');
try {
const data = await apiClient.getTrendInsight(backend, startDate, endDate, refresh);
if (!live.current) return;
setInsight(data.insight);
setMeta(data.meta);
} catch (err: any) {
if (live.current) setError(errorMessage(err, '归因分析失败'));
} finally {
if (live.current) setLoading(false);
}
};
if (!supportsInsight(metricId)) return null;
return (
<section className="ti">
<div className="ti-head">
<h3 className="sec-title">AI </h3>
{insight && !loading && (
<button className="ti-link" onClick={() => run(true)}></button>
)}
</div>
{!insight && !loading && !error && (
<>
<p className="ti-hint">
{startDate} ~ {endDate}
</p>
<button className="ti-run" onClick={() => run()}></button>
</>
)}
{loading && (
<div className="ti-loading">
<span className="ti-spinner" aria-hidden="true" />
25
</div>
)}
{error && <p className="ti-error">{error}</p>}
{insight && (
<div className="ti-body">
{insight.summary && <p className="ti-summary">{insight.summary}</p>}
{insight.drivers.map((d) => (
<div className="ti-driver" key={d.factor + d.detail}>
<span className="ti-factor">{d.factor}</span>
<p>{d.detail}</p>
</div>
))}
{insight.caution && <p className="ti-caution">{insight.caution}</p>}
<div className="ti-foot">
<span>
{meta?.source === 'ai' ? `AI 生成${meta.upstream ? ` · ${meta.upstream}` : ''}` : '规则引擎'}
</span>
<span>{CONFIDENCE_LABEL[insight.confidence]}</span>
</div>
</div>
)}
</section>
);
}
export default TrendInsight;