diff --git a/messages/de.json b/messages/de.json index fc469ffe7..c0247a8cf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2099,6 +2099,9 @@ "composerPlaceholder": "Frag mich etwas zu deinen Daten…", "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", "fabLabel": "Coach öffnen", + "readAloud": "Vorlesen", + "readAloudStop": "Vorlesen beenden", + "messageTimeLabel": "Nachricht gesendet {time}", "selfContextAdopt": { "offer": "Antwort in deine Selbstauskunft übernehmen?", "confirm": "Übernehmen", diff --git a/messages/en.json b/messages/en.json index c02b890d4..e62b0ffd7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2099,6 +2099,9 @@ "composerPlaceholder": "Ask anything about your data…", "nudgeBubbleLabel": "New message from the Coach — open chat", "fabLabel": "Open the Coach", + "readAloud": "Read aloud", + "readAloudStop": "Stop reading", + "messageTimeLabel": "Message sent {time}", "selfContextAdopt": { "offer": "Add this answer to your self-context?", "confirm": "Add", diff --git a/src/components/insights/coach-panel/__tests__/coach-charts.test.tsx b/src/components/insights/coach-panel/__tests__/coach-charts.test.tsx new file mode 100644 index 000000000..6de0a035e --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-charts.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +// Mirror the message-thread test harness: SSR render with no providers, so +// stub the hooks the bubble reaches for. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "tester", role: "USER", avatarUrl: null }, + isAuthenticated: true, + isLoading: false, + }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined }), + useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }), +})); + +import { MessageThread, selectCoachChartTokens } from "../message-thread"; +import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("selectCoachChartTokens", () => { + it("returns an allowlisted token grounded in the turn's provenance", () => { + expect( + selectCoachChartTokens("The trend metric:WEIGHT is up", ["weight"]), + ).toEqual(["metric:WEIGHT"]); + }); + + it("drops a token whose metric is not in the grounded provenance", () => { + expect(selectCoachChartTokens("metric:WEIGHT", [])).toEqual([]); + expect(selectCoachChartTokens("metric:WEIGHT", ["pulse"])).toEqual([]); + }); + + it("drops a hallucinated / non-allowlisted token", () => { + expect(selectCoachChartTokens("metric:NUKE", ["weight"])).toEqual([]); + }); + + it("omits synthetic tokens without a self-fetching chart (MOOD)", () => { + expect(selectCoachChartTokens("metric:MOOD", ["mood"])).toEqual([]); + }); + + it("de-duplicates by metric and caps the number rendered", () => { + const tokens = selectCoachChartTokens( + "metric:WEIGHT metric:WEIGHT metric:PULSE metric:BLOOD_GLUCOSE", + ["weight", "pulse", "glucose"], + ); + expect(tokens).toEqual(["metric:WEIGHT", "metric:PULSE"]); + }); +}); + +describe("Coach chart rendering", () => { + const conversation: CoachConversationDetailDTO = { + id: "c1", + title: "Test", + createdAt: "2026-06-01T10:00:00.000Z", + updatedAt: "2026-06-01T10:00:00.000Z", + messageCount: 1, + messages: [ + { + id: "m1", + role: "assistant", + content: "Your weight is trending down. metric:WEIGHT", + createdAt: "2026-06-01T10:00:00.000Z", + metricSource: { windows: ["last30days"], metrics: ["weight"] }, + providerType: "openai", + promptVersion: null, + tokensUsed: null, + model: null, + }, + ], + }; + + it("mounts a chart for a grounded token and strips it from the prose", () => { + const html = render(); + expect(html).toContain('data-slot="coach-charts"'); + expect(html).toContain('data-slot="coach-chart"'); + // The literal token must never surface in the rendered prose. + expect(html).not.toContain("metric:WEIGHT"); + expect(html).toContain("Your weight is trending down"); + }); + + it("renders no chart when the token is not grounded in provenance", () => { + const ungrounded: CoachConversationDetailDTO = { + ...conversation, + messages: [ + { + ...conversation.messages[0], + metricSource: { windows: [], metrics: ["pulse"] }, + }, + ], + }; + const html = render(); + expect(html).not.toContain('data-slot="coach-chart"'); + }); +}); diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 088b83235..2082b8562 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -1,6 +1,15 @@ "use client"; -import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; +import { + Fragment, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import Link from "next/link"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -9,19 +18,30 @@ import { Bot, Check, ChevronRight, + Clock, Loader2, Sparkles, ThumbsDown, ThumbsUp, User, + Volume2, + VolumeX, } from "lucide-react"; import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; +import { + parseChartTokens, + stripChartTokens, + tokenToMetric, + type ChartToken, +} from "@/lib/insights/chart-tokens"; +import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; +import { ProseBlocks } from "@/components/insights/prose-blocks"; import { SourceChips } from "./source-chips"; import { ReminderSuggestionCard } from "./reminder-suggestion-card"; @@ -32,7 +52,10 @@ import type { CoachOptimisticUserMessage, CoachStreamingMessage, } from "./use-coach"; -import type { CoachMessageDTO } from "@/lib/ai/coach/types"; +import type { + CoachMessageDTO, + CoachProvenanceMetric, +} from "@/lib/ai/coach/types"; /** * v1.4.20 phase B2b — message-thread renderer. @@ -209,6 +232,282 @@ export function errorCodeToI18nKey(code: string): string { } } +/** + * v1.22 (W5) — Coach accompanying charts, Phase 1: the renderer half. + * + * The chart-token mechanism (`chart-tokens.ts`) was prepared for the + * Insights prose but its render path was never wired for the Coach — + * `stripChartTokens` cleaned tokens out of the prose, but `parseChartTokens` + * had no caller, so a `metric:` token the model emitted rendered no + * chart. This map activates the render half for the Coach. + * + * Each entry pairs an allowlisted chart token with the Coach provenance + * TOPIC that the snapshot stamps when it actually drew on that metric. A + * chart only renders when its topic is present in `metricSource.metrics`, so + * the series is grounded twice over: the closed allowlist drops a + * hallucinated token, and the provenance intersect drops a metric the turn + * never saw (and which therefore has no data). The chart itself self-fetches + * the user's real series from `/api/measurements` — the model never emits a + * data point. + * + * Only MeasurementType-backed tokens that render through the generic, + * self-fetching `` are listed. The synthetic `metric:MOOD` + * token (served by a separate ``) and the allowlist's reserved + * score classes are intentionally omitted from Phase 1. + * + * The prompt clause that tells the model it MAY emit one such token is a + * separate concern (the narrative/prompt workstream); this is render-only, + * provider-agnostic (it reads the plain inline token, so it works for every + * provider including the codex inline-text path), and a graceful no-op when + * no grounded token is present. + */ +const CHART_TOKEN_PROVENANCE: Partial< + Record +> = { + "metric:WEIGHT": "weight", + "metric:BLOOD_PRESSURE_SYS": "bp", + "metric:BLOOD_PRESSURE_DIA": "bp", + "metric:PULSE": "pulse", + "metric:BODY_FAT": "body_fat", + "metric:SLEEP_DURATION": "sleep", + "metric:ACTIVITY_STEPS": "steps", + "metric:BLOOD_GLUCOSE": "glucose", + "metric:TOTAL_BODY_WATER": "total_body_water", + "metric:BONE_MASS": "bone_mass", + "metric:OXYGEN_SATURATION": "spo2", + "metric:HEART_RATE_VARIABILITY": "hrv", + "metric:RESTING_HEART_RATE": "resting_hr", + "metric:ACTIVE_ENERGY_BURNED": "active_energy", + "metric:FLIGHTS_CLIMBED": "flights", + "metric:WALKING_RUNNING_DISTANCE": "distance", + "metric:VO2_MAX": "vo2_max", + "metric:BODY_TEMPERATURE": "body_temp", + "metric:FAT_FREE_MASS": "fat_free_mass", + "metric:FAT_MASS": "fat_mass", + "metric:MUSCLE_MASS": "muscle_mass", + "metric:LEAN_BODY_MASS": "lean_body_mass", + "metric:BODY_MASS_INDEX": "bmi", + "metric:VISCERAL_FAT": "visceral_fat", + "metric:SKIN_TEMPERATURE": "skin_temp", + "metric:RESPIRATORY_RATE": "respiratory_rate", + "metric:PULSE_WAVE_VELOCITY": "pulse_wave_velocity", + "metric:VASCULAR_AGE": "vascular_age", + "metric:WALKING_HEART_RATE_AVERAGE": "walking_hr", + "metric:WALKING_ASYMMETRY": "walking_asymmetry", + "metric:WALKING_DOUBLE_SUPPORT": "walking_double_support", + "metric:WALKING_STEP_LENGTH": "walking_step_length", + "metric:WALKING_SPEED": "walking_speed", + "metric:AUDIO_EXPOSURE_ENV": "audio_env", + "metric:AUDIO_EXPOSURE_HEADPHONE": "audio_headphone", + "metric:AUDIO_EXPOSURE_EVENT": "audio_event", + "metric:TIME_IN_DAYLIGHT": "daylight", +}; + +/** Max charts rendered under a single Coach turn (keeps the reply scannable). */ +const MAX_COACH_CHARTS = 2; + +/** + * Pure selection of the chart tokens to render under an assistant turn: + * allowlist-parsed, intersected with the turn's grounded provenance topics, + * de-duplicated by metric, and capped. Exported for unit tests so the + * grounding contract is pinned without standing up the chart component. + */ +export function selectCoachChartTokens( + content: string, + metrics: readonly CoachProvenanceMetric[] | undefined, +): ChartToken[] { + const grounded = new Set(metrics ?? []); + const out: ChartToken[] = []; + const seen = new Set(); + for (const token of parseChartTokens(content)) { + const topic = CHART_TOKEN_PROVENANCE[token]; + if (!topic || !grounded.has(topic)) continue; + const metric = tokenToMetric(token); + if (seen.has(metric)) continue; + seen.add(metric); + out.push(token); + if (out.length >= MAX_COACH_CHARTS) break; + } + return out; +} + +/** + * Chart tokens whose canonical measurement-type label has no dedicated + * `measurements.type*` key — route them to the closest existing key so the + * chart header reads cleanly rather than echoing the raw enum. + */ +const CHART_TITLE_KEY_OVERRIDE: Record = { + BLOOD_PRESSURE_SYS: "measurements.typeBloodPressure", + BLOOD_PRESSURE_DIA: "measurements.typeBloodPressure", +}; + +/** Localised chart header for a MeasurementType, mirroring the snapshot's + * `measurements.type` convention with a readable fallback. */ +function coachChartTitle( + metric: string, + t: (key: string, vars?: Record) => string, +): string { + const override = CHART_TITLE_KEY_OVERRIDE[metric]; + if (override) { + const resolved = t(override); + if (resolved !== override) return resolved; + } + const camel = metric + .toLowerCase() + .split("_") + .map((part, i) => + i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), + ) + .join(""); + const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; + const resolved = t(key); + return resolved === key ? metric.replace(/_/g, " ").toLowerCase() : resolved; +} + +/** + * v1.22 (W5) — feature-detect the browser Speech Synthesis API SSR-safe. + * `useSyncExternalStore` returns false on the server + first client paint + * (so the read-aloud button is absent in markup) and resolves to the real + * capability after hydration, mirroring the mic button's detection pattern + * — no hydration mismatch warning, no `connect-src` (it runs in-browser). + */ +function useSpeechSynthesisSupported(): boolean { + return useSyncExternalStore( + () => () => {}, + () => + typeof window !== "undefined" && + "speechSynthesis" in window && + "SpeechSynthesisUtterance" in window, + () => false, + ); +} + +/** + * v1.22 (W5) — read the assistant prose aloud via the browser's + * `SpeechSynthesis`. Client-only, no dependency, no egress, CSP-clean. A + * single in-flight utterance: a new `speak` cancels any prior one; `toggle` + * stops while speaking. The utterance language follows the active UI locale + * so the browser picks the closest installed voice (best-effort by OS). + */ +function useReadAloud(): { + supported: boolean; + speaking: boolean; + toggle: (text: string, lang: string) => void; +} { + const supported = useSpeechSynthesisSupported(); + const [speaking, setSpeaking] = useState(false); + + // Stop any in-flight utterance if the bubble unmounts (thread reset, nav). + useEffect(() => { + return () => { + if (typeof window !== "undefined" && "speechSynthesis" in window) { + window.speechSynthesis.cancel(); + } + }; + }, []); + + const toggle = useCallback( + (text: string, lang: string) => { + if (!supported) return; + const synth = window.speechSynthesis; + if (speaking) { + synth.cancel(); + setSpeaking(false); + return; + } + const trimmed = text.trim(); + if (!trimmed) return; + synth.cancel(); + const utterance = new SpeechSynthesisUtterance(trimmed); + utterance.lang = lang; + utterance.onend = () => setSpeaking(false); + utterance.onerror = () => setSpeaking(false); + setSpeaking(true); + synth.speak(utterance); + }, + [supported, speaking], + ); + + return { supported, speaking, toggle }; +} + +/** + * v1.22 (W5) — read-aloud toggle for a settled assistant turn. Hidden when + * the browser has no Speech Synthesis support. Speaks `stripChartTokens` + * (the same text the prose shows), so stray tokens are never voiced. + */ +function ReadAloudButton({ content }: { content: string }) { + const { t, locale } = useTranslations(); + const { supported, speaking, toggle } = useReadAloud(); + if (!supported) return null; + const label = speaking + ? t("insights.coach.readAloudStop") + : t("insights.coach.readAloud"); + return ( + + ); +} + +/** + * v1.22 (W5) — hover/tap timestamp for a persisted message bubble. A small, + * calm clock affordance; the locale-aware date + time surfaces in a tooltip + * on hover or keyboard focus (desktop) and on tap (mobile, toggled state). + * Pure CSS + one boolean — no portal, no Radix dependency, SSR-safe. + */ +function BubbleTimestamp({ + iso, + align = "start", +}: { + iso: string; + align?: "start" | "end"; +}) { + const { t } = useTranslations(); + const formatters = useFormatters(); + const [open, setOpen] = useState(false); + const label = formatters.dateTime(iso); + return ( + + + + {label} + + + ); +} + export function MessageThread({ conversation, streaming, @@ -425,6 +724,7 @@ export function MessageThread({ messageId={m.id} tokensUsed={m.tokensUsed} model={m.model} + createdAt={m.createdAt} /> ); @@ -521,6 +821,12 @@ interface ChatBubbleProps { */ tokensUsed?: number | null; model?: string | null; + /** + * v1.22 (W5) — ISO creation timestamp for the persisted bubble's + * hover/tap timestamp tooltip. Absent on optimistic + streaming bubbles + * (no persisted time yet), so those render no timestamp. + */ + createdAt?: string; } function ChatBubble({ @@ -536,6 +842,7 @@ function ChatBubble({ usage, tokensUsed, model, + createdAt, }: ChatBubbleProps) { const { t } = useTranslations(); const { user } = useAuth(); @@ -580,8 +887,12 @@ function ChatBubble({ "text-sm leading-relaxed", )} > - {content} + {/* v1.22 (W5) — render real paragraph blocks so a multi-line + message reads as paragraphs, not one run-on block. User text + is verbatim: no chart-token strip, no Learn linkify. */} + + {createdAt && } {/* v1.16.8 — explicit remember control. Stating an allergy in chat used to leave no durable trace unless a narrow pattern pass happened to match the phrasing; this stores @@ -645,6 +956,15 @@ function ChatBubble({ (metricSource.windows?.length ?? 0) > 0); const hasProvenance = hasChips || keyValues.length > 0; + // v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance- + // grounded `metric:` chart under a SETTLED assistant turn. Skipped + // while streaming / in-flight / errored / on a refusal; a no-op when no + // grounded token is present (provider-agnostic — reads the inline token). + const chartTokens = + !streaming && !inProgress && !errorCode && providerType !== "refusal" + ? selectCoachChartTokens(content, metricSource?.metrics) + : []; + return (
{safeError}

)} + {/* v1.22 (W5) — accompanying chart(s). The token was already + stripped from the prose by ; here it mounts the + real, self-fetching Recharts chart for the user's own series. + The model never emits data — only a metric identifier from a + closed allowlist, intersected with the turn's grounded metrics. */} + {chartTokens.length > 0 && ( +
+ {chartTokens.map((token) => { + const metric = tokenToMetric(token); + return ( +
+ +
+ ); + })} +
+ )} {/* v1.18.6 — a "no provider configured anywhere" turn is a setup gap, not a transient failure: surface a direct link to Settings → AI so the Coach guides the user into BYOK / local @@ -829,14 +1173,19 @@ function ChatBubble({ )} {/* v1.4.23 H7 — per-message thumbs feedback. Only persisted assistant messages get the row (skipped for refusals, - errors, in-flight stream bubbles). The aggregator buckets - the rating by (promptVersion, tone, verbosity). */} - {messageId && - !inProgress && - !errorCode && - providerType !== "refusal" && ( - - )} + errors, in-flight stream bubbles). v1.22 (W5) — the + read-aloud toggle joins the same action row on a settled turn; + the bubble timestamp trails it. */} + {!inProgress && !errorCode && providerType !== "refusal" && content && ( +
+ {!streaming && } + {messageId && } + {createdAt && } +
+ )}
);