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:
@@ -5,6 +5,8 @@ import Framework7 from 'framework7/lite-bundle';
|
||||
import Framework7React from 'framework7-react';
|
||||
|
||||
import routes from './routes';
|
||||
import Copilot from './components/Copilot';
|
||||
import { FEATURES } from './features';
|
||||
import { apiClient, AUTH_EVENT } from './services/api';
|
||||
|
||||
import 'framework7/css/bundle';
|
||||
@@ -314,6 +316,11 @@ function App() {
|
||||
</Views>
|
||||
)}
|
||||
</F7App>
|
||||
|
||||
{/* Outside <F7App> for the same reason NavProgress is: a stray child of
|
||||
the Framework7 root breaks its initialisation. Only rendered with a
|
||||
session — there is nothing to ask about on the login screen. */}
|
||||
{authed && FEATURES.ai && <Copilot />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
260
client/src/components/AiBriefing.css
Normal file
260
client/src/components/AiBriefing.css
Normal 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); }
|
||||
227
client/src/components/AiBriefing.tsx
Normal file
227
client/src/components/AiBriefing.tsx
Normal 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;
|
||||
196
client/src/components/Copilot.css
Normal file
196
client/src/components/Copilot.css
Normal 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;
|
||||
}
|
||||
185
client/src/components/Copilot.tsx
Normal file
185
client/src/components/Copilot.tsx
Normal 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;
|
||||
125
client/src/components/TrendInsight.css
Normal file
125
client/src/components/TrendInsight.css
Normal 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);
|
||||
}
|
||||
148
client/src/components/TrendInsight.tsx
Normal file
148
client/src/components/TrendInsight.tsx
Normal 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" />
|
||||
正在分析,通常需要 2–5 分钟
|
||||
</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;
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Feature switches.
|
||||
*
|
||||
* `ai` is off while the recommendation module is being reworked: the pages and
|
||||
* the backend endpoints still exist, so turning it back on is a one-line
|
||||
* change rather than a rebuild. Nothing links to the route while it is off,
|
||||
* and the route itself is not registered — a hidden nav entry with a live URL
|
||||
* would still be reachable by typing it.
|
||||
* `ai` covers the whole coach surface: the 晨间简报 card on 今日, the Copilot
|
||||
* dock, and the trend attribution panel. It is one switch rather than three
|
||||
* because they share a backend that depends on the ai-gateway being reachable
|
||||
* — when that box is down, all three degrade together and turning the set off
|
||||
* is one edit.
|
||||
*/
|
||||
export const FEATURES = {
|
||||
ai: false,
|
||||
ai: true,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,9 @@ import { daysAgo, today as todayIso } from '../lib/day';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import BandBar from '../components/charts/BandBar';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import TrendInsight from '../components/TrendInsight';
|
||||
import { useCountUp } from '../lib/motion';
|
||||
import { FEATURES } from '../features';
|
||||
import './MetricDetail.css';
|
||||
|
||||
const WINDOWS = [7, 30, 90, 365];
|
||||
@@ -183,6 +185,16 @@ function MetricDetailPage({ id, f7route }: Props) {
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Directly under the chart it explains, and scoped to the same
|
||||
window the range selector is showing. */}
|
||||
{FEATURES.ai && (
|
||||
<TrendInsight
|
||||
metricId={key}
|
||||
startDate={daysAgo(window - 1)}
|
||||
endDate={todayIso()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="md-about">
|
||||
<h3 className="sec-title">这个指标是什么</h3>
|
||||
<p>{def.about}</p>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, f7 } from 'framework7-react';
|
||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import AiBriefing from '../components/AiBriefing';
|
||||
import Ring from '../components/charts/Ring';
|
||||
import MetricCard from '../components/charts/MetricCard';
|
||||
import MetricStrip from '../components/charts/MetricStrip';
|
||||
@@ -10,6 +11,7 @@ import { useCountUp } from '../lib/motion';
|
||||
import { RANGES } from '../lib/ranges';
|
||||
import { METRICS, metricHref } from '../lib/metrics';
|
||||
import { daysAgo, iso, shiftDay, today as todayIso } from '../lib/day';
|
||||
import { FEATURES } from '../features';
|
||||
import './Today.css';
|
||||
|
||||
/* Enough history for the cards' sparklines and a few weeks of stepping back
|
||||
@@ -283,6 +285,13 @@ function TodayPage() {
|
||||
<>
|
||||
<RingRow today={today} history={history} />
|
||||
|
||||
{/* Under the rings, not above them: the rings are the day's
|
||||
facts and load instantly, while the briefing is an
|
||||
interpretation of those facts that may still be generating.
|
||||
Putting a card that can say "生成中" at the very top would
|
||||
make the whole screen look unready. */}
|
||||
{FEATURES.ai && <AiBriefing date={date} />}
|
||||
|
||||
{SECTIONS.map((section) => (
|
||||
<section className="sec" key={section.title}>
|
||||
<h3 className="sec-title">{section.title}</h3>
|
||||
|
||||
@@ -332,6 +332,106 @@ export interface TrendPoint {
|
||||
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,
|
||||
@@ -675,6 +775,139 @@ class ApiClient {
|
||||
);
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user