mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge v189/coachui into release/v1.18.9
Rebuild the /coach surface as a centred-hero chat with a word-by-word fade, a single thinking disclosure, and a per-message token footer; emit token usage on the done frame and persist it via migration 0182; plumb an additive reasoning frame with an elapsed-time fallback for non-reasoning providers.
This commit is contained in:
commit
3505ffde24
22 files changed
+1013
-159
No files matched your search
+24
-5
@@ -4148,11 +4148,14 @@ paths:
|
||||
summary: Send a Coach turn (streaming reply)
|
||||
description: "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is
|
||||
`text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk
|
||||
of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{
|
||||
type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a
|
||||
provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame
|
||||
types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and
|
||||
rate-limited. Auth via cookie or Bearer."
|
||||
of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion`
|
||||
(a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{
|
||||
type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId,
|
||||
messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?,
|
||||
completionTokens?, model }`, server-authoritative; clients display it, never recompute), or `error` (`{ type,
|
||||
code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error`
|
||||
frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts
|
||||
a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -15709,6 +15712,20 @@ components:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Coach prompt version that produced the reply; null for user turns.
|
||||
tokensUsed:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
description: v1.18.9 — total tokens this assistant turn cost, persisted so the per-message token footer survives a
|
||||
reload. Null on user turns, refusals, and pre-feature rows.
|
||||
model:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: v1.18.9 — the provider model that produced the reply (e.g. gpt-4o). Null when unknown (user turns,
|
||||
refusals, older rows).
|
||||
required:
|
||||
- id
|
||||
- role
|
||||
@@ -15717,6 +15734,8 @@ components:
|
||||
- metricSource
|
||||
- providerType
|
||||
- promptVersion
|
||||
- tokensUsed
|
||||
- model
|
||||
additionalProperties: false
|
||||
description: One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation.
|
||||
CoachProvenance:
|
||||
|
||||
+9
-1
@@ -2269,7 +2269,15 @@
|
||||
"dictate": "Diktieren",
|
||||
"dictateStop": "Diktat beenden",
|
||||
"showConversations": "Unterhaltungen anzeigen",
|
||||
"hideConversations": "Unterhaltungen ausblenden"
|
||||
"hideConversations": "Unterhaltungen ausblenden",
|
||||
"heroGreeting": "Wie kann ich dir helfen?",
|
||||
"heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "Nachgedacht für {seconds}s",
|
||||
"thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat",
|
||||
"thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.",
|
||||
"tokensUsed": "{count} Tokens",
|
||||
"tokensUsedWithModel": "{count} Tokens · {model}"
|
||||
},
|
||||
"relativeJustNow": "gerade eben",
|
||||
"relativeMinutesAgoOne": "vor {count} Minute",
|
||||
|
||||
+9
-1
@@ -2269,7 +2269,15 @@
|
||||
"dictate": "Dictate",
|
||||
"dictateStop": "Stop dictation",
|
||||
"showConversations": "Show conversations",
|
||||
"hideConversations": "Hide conversations"
|
||||
"hideConversations": "Hide conversations",
|
||||
"heroGreeting": "How can I help you?",
|
||||
"heroSubline": "Ask about your trends, medications, or readings.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "Thought for {seconds}s",
|
||||
"thinkingExpandAria": "Show what the Coach considered",
|
||||
"thinkingDetail": "Answered from your health snapshot.",
|
||||
"tokensUsed": "{count} tokens",
|
||||
"tokensUsedWithModel": "{count} tokens · {model}"
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgoOne": "{count} minute ago",
|
||||
|
||||
+9
-1
@@ -2269,7 +2269,15 @@
|
||||
"dictate": "Dictar",
|
||||
"dictateStop": "Detener el dictado",
|
||||
"showConversations": "Mostrar conversaciones",
|
||||
"hideConversations": "Ocultar conversaciones"
|
||||
"hideConversations": "Ocultar conversaciones",
|
||||
"heroGreeting": "¿En qué puedo ayudarte?",
|
||||
"heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "Pensó durante {seconds}s",
|
||||
"thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta",
|
||||
"thinkingDetail": "Respondido a partir de tu resumen de salud.",
|
||||
"tokensUsed": "{count} tokens",
|
||||
"tokensUsedWithModel": "{count} tokens · {model}"
|
||||
},
|
||||
"relativeJustNow": "ahora mismo",
|
||||
"relativeMinutesAgoOne": "hace {count} minuto",
|
||||
|
||||
+9
-1
@@ -2269,7 +2269,15 @@
|
||||
"dictate": "Dicter",
|
||||
"dictateStop": "Arrêter la dictée",
|
||||
"showConversations": "Afficher les conversations",
|
||||
"hideConversations": "Masquer les conversations"
|
||||
"hideConversations": "Masquer les conversations",
|
||||
"heroGreeting": "Comment puis-je t'aider ?",
|
||||
"heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "A réfléchi pendant {seconds}s",
|
||||
"thinkingExpandAria": "Montrer ce que le Coach a pris en compte",
|
||||
"thinkingDetail": "Réponse établie à partir de ton aperçu santé.",
|
||||
"tokensUsed": "{count} jetons",
|
||||
"tokensUsedWithModel": "{count} jetons · {model}"
|
||||
},
|
||||
"relativeJustNow": "à l’instant",
|
||||
"relativeMinutesAgoOne": "il y a {count} minute",
|
||||
|
||||
+9
-1
@@ -2269,7 +2269,15 @@
|
||||
"dictate": "Detta",
|
||||
"dictateStop": "Interrompi dettatura",
|
||||
"showConversations": "Mostra conversazioni",
|
||||
"hideConversations": "Nascondi conversazioni"
|
||||
"hideConversations": "Nascondi conversazioni",
|
||||
"heroGreeting": "Come posso aiutarti?",
|
||||
"heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "Ha riflettuto per {seconds}s",
|
||||
"thinkingExpandAria": "Mostra cosa ha considerato il Coach",
|
||||
"thinkingDetail": "Risposta basata sul tuo riepilogo di salute.",
|
||||
"tokensUsed": "{count} token",
|
||||
"tokensUsedWithModel": "{count} token · {model}"
|
||||
},
|
||||
"relativeJustNow": "proprio ora",
|
||||
"relativeMinutesAgoOne": "{count} minuto fa",
|
||||
|
||||
+9
-1
@@ -2269,7 +2269,15 @@
|
||||
"dictate": "Dyktuj",
|
||||
"dictateStop": "Zatrzymaj dyktowanie",
|
||||
"showConversations": "Pokaż rozmowy",
|
||||
"hideConversations": "Ukryj rozmowy"
|
||||
"hideConversations": "Ukryj rozmowy",
|
||||
"heroGreeting": "W czym mogę pomóc?",
|
||||
"heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "Myślał przez {seconds}s",
|
||||
"thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę",
|
||||
"thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.",
|
||||
"tokensUsed": "{count} tokenów",
|
||||
"tokensUsedWithModel": "{count} tokenów · {model}"
|
||||
},
|
||||
"relativeJustNow": "przed chwilą",
|
||||
"relativeMinutesAgoOne": "{count} minutę temu",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- v1.18.9 — per-message Coach token usage + model.
|
||||
--
|
||||
-- The streaming chat endpoint already computes the per-turn token count
|
||||
-- (`result.tokensUsed`) and the model that produced the reply, but until
|
||||
-- now neither reached the client. Two additive nullable columns let the
|
||||
-- quiet per-message token footer survive a conversation reload: the live
|
||||
-- turn paints from the `done.usage` SSE frame, persisted history paints
|
||||
-- from these columns.
|
||||
--
|
||||
-- Both NULL by default. No backfill: existing rows (and every user turn
|
||||
-- or refusal, which never carries a token count) simply render no footer.
|
||||
ALTER TABLE "coach_messages"
|
||||
ADD COLUMN "tokens_used" INTEGER,
|
||||
ADD COLUMN "model" TEXT;
|
||||
@@ -3643,6 +3643,11 @@ model CoachMessage {
|
||||
metricSourceJson String? @map("metric_source_json")
|
||||
providerType String? @map("provider_type")
|
||||
promptVersion String? @map("prompt_version")
|
||||
// v1.18.9 — per-turn token usage + model, persisted so the quiet
|
||||
// per-message token footer survives a conversation reload. Both
|
||||
// nullable: user turns, refusals, and pre-feature rows carry NULL.
|
||||
tokensUsed Int? @map("tokens_used")
|
||||
model String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -652,6 +652,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
metricSource: enrichedProvenance,
|
||||
providerType: workingProviderType,
|
||||
promptVersion: PROMPT_VERSION,
|
||||
// v1.18.9 — persist the per-turn token count + model so the quiet
|
||||
// token footer survives a conversation reload. The live turn paints
|
||||
// from the `done.usage` SSE frame below; reloads read these columns.
|
||||
tokensUsed: result.tokensUsed ?? null,
|
||||
model: result.model ?? null,
|
||||
});
|
||||
|
||||
// v1.18.7 — the day's spend was already reconciled against the
|
||||
@@ -703,11 +708,19 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }),
|
||||
);
|
||||
}
|
||||
// v1.18.9 — additive `usage` envelope on the `done` frame so the client
|
||||
// can paint the quiet per-message token footer the instant the stream
|
||||
// closes. Server-authoritative: the client renders these numbers, never
|
||||
// recomputes them. Older clients drop the unknown key.
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "done",
|
||||
conversationId: workingConversationId,
|
||||
messageId: assistantMessage.id,
|
||||
usage: {
|
||||
totalTokens: result.tokensUsed ?? null,
|
||||
model: result.model ?? null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { CoachHero } from "../coach-hero";
|
||||
import { splitProseSegments } from "../streamed-prose";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<CoachHero>", () => {
|
||||
it("renders the centred greeting, the composer slot, and the chips", () => {
|
||||
const html = render(
|
||||
<CoachHero
|
||||
composer={<div data-slot="test-composer">composer</div>}
|
||||
prompts={["What should I tell my doctor?", "Is my medication working?"]}
|
||||
onPickPrompt={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-hero"');
|
||||
// Greeting copy from insights.coach.heroGreeting.
|
||||
expect(html).toContain("How can I help you?");
|
||||
// The composer is re-parented into the hero, not forked.
|
||||
expect(html).toContain('data-slot="coach-hero-composer"');
|
||||
expect(html).toContain('data-slot="test-composer"');
|
||||
// Both starter-question chips render.
|
||||
const chips = (html.match(/data-slot="coach-hero-chip"/g) ?? []).length;
|
||||
expect(chips).toBe(2);
|
||||
expect(html).toContain("What should I tell my doctor?");
|
||||
expect(html).toContain("Is my medication working?");
|
||||
});
|
||||
|
||||
it("renders the German greeting under the de locale", () => {
|
||||
const html = render(
|
||||
<CoachHero composer={null} prompts={[]} onPickPrompt={() => {}} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Wie kann ich dir helfen?");
|
||||
});
|
||||
|
||||
it("omits the chip row when no prompts are supplied", () => {
|
||||
const html = render(
|
||||
<CoachHero composer={null} prompts={[]} onPickPrompt={() => {}} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-hero-chips"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitProseSegments", () => {
|
||||
it("splits prose into word+trailing-space segments", () => {
|
||||
expect(splitProseSegments("Looking at your data")).toEqual([
|
||||
"Looking ",
|
||||
"at ",
|
||||
"your ",
|
||||
"data",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array for empty input", () => {
|
||||
expect(splitProseSegments("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a single whitespace-free token intact", () => {
|
||||
expect(splitProseSegments("Drafting…")).toEqual(["Drafting…"]);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,28 @@ import {
|
||||
type InterleavedThreadItem,
|
||||
} from "../message-thread";
|
||||
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
|
||||
import type { CoachStreamingMessage } from "../use-coach";
|
||||
|
||||
// v1.18.9 — `CoachStreamingMessage` gained `usage` / `startedAt` /
|
||||
// `reasoning` (per-message token footer + thinking disclosure). The
|
||||
// fixtures below predate those fields; this builder fills the defaults so
|
||||
// each test only spells out the fields it cares about.
|
||||
function streaming(
|
||||
partial: Partial<CoachStreamingMessage>,
|
||||
): CoachStreamingMessage {
|
||||
return {
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
usage: null,
|
||||
startedAt: null,
|
||||
reasoning: "",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
@@ -82,6 +104,8 @@ const baseConversation: CoachConversationDetailDTO = {
|
||||
metricSource: null,
|
||||
providerType: null,
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
@@ -96,6 +120,8 @@ const baseConversation: CoachConversationDetailDTO = {
|
||||
},
|
||||
providerType: "admin-openai",
|
||||
promptVersion: "4.20.0",
|
||||
tokensUsed: 312,
|
||||
model: "gpt-4o",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -143,14 +169,14 @@ describe("<MessageThread>", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={baseConversation}
|
||||
streaming={{
|
||||
streaming={streaming({
|
||||
content: "Looking at your data ",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
inProgress: true,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// 1 persisted user + 1 persisted assistant + 1 streaming assistant.
|
||||
@@ -161,21 +187,26 @@ describe("<MessageThread>", () => {
|
||||
).length;
|
||||
expect(userBubbles).toBe(1);
|
||||
expect(assistantBubbles).toBe(2);
|
||||
expect(html).toContain("Looking at your data");
|
||||
// v1.18.9 — the live streaming turn renders each word in its own
|
||||
// fade-in <span> (`<StreamedProse>`), so the prose is no longer one
|
||||
// contiguous text node; assert each word is present span-wrapped.
|
||||
for (const word of ["Looking ", "at ", "your ", "data"]) {
|
||||
expect(html).toContain(`>${word}</span>`);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the streaming-only bubble when no conversation is loaded yet", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={null}
|
||||
streaming={{
|
||||
streaming={streaming({
|
||||
content: "Drafting…",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
inProgress: true,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Drafting…");
|
||||
@@ -186,14 +217,14 @@ describe("<MessageThread>", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={null}
|
||||
streaming={{
|
||||
streaming={streaming({
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
inProgress: true,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Thinking");
|
||||
@@ -203,14 +234,14 @@ describe("<MessageThread>", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={null}
|
||||
streaming={{
|
||||
streaming={streaming({
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: "errorProvider",
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// v1.4.33 IW7 — copy rewritten from "AI provider" to "Insights
|
||||
@@ -237,6 +268,8 @@ describe("<MessageThread>", () => {
|
||||
metricSource: null,
|
||||
providerType: "admin-openai",
|
||||
promptVersion: "4.20.0",
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -244,14 +277,14 @@ describe("<MessageThread>", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={conv}
|
||||
streaming={{
|
||||
streaming={streaming({
|
||||
content: "Looking at your data ",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
inProgress: false,
|
||||
messageId: "m3-streaming",
|
||||
errorCode: null,
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// 1 persisted user + 2 persisted assistant + 0 streaming → 2.
|
||||
@@ -271,14 +304,14 @@ describe("<MessageThread>", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={baseConversation}
|
||||
streaming={{
|
||||
streaming={streaming({
|
||||
content: "Looking at your data ",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
inProgress: true,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const assistantBubbles = (
|
||||
@@ -470,14 +503,14 @@ describe("<MessageThread>", () => {
|
||||
content: "Wie ist mein Blutdruck letzte Woche?",
|
||||
conversationId: null,
|
||||
}}
|
||||
streaming={{
|
||||
streaming={streaming({
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
inProgress: true,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Wie ist mein Blutdruck letzte Woche?");
|
||||
@@ -577,14 +610,14 @@ describe("<MessageThread>", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={null}
|
||||
streaming={{
|
||||
streaming={streaming({
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: "coach.budget.exceeded",
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Daily limit reached; resets at 00:00 UTC.");
|
||||
@@ -598,14 +631,14 @@ describe("<MessageThread>", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={null}
|
||||
streaming={{
|
||||
streaming={streaming({
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: "coach.provider.rate_limited",
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(
|
||||
@@ -627,14 +660,14 @@ describe("<MessageThread>", () => {
|
||||
content: "Why was BP higher on Monday?",
|
||||
conversationId: "conv-1",
|
||||
}}
|
||||
streaming={{
|
||||
streaming={streaming({
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? [])
|
||||
|
||||
@@ -13,6 +13,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
import { CoachDrawerBody } from "./coach-drawer-body";
|
||||
import { CoachHero } from "./coach-hero";
|
||||
import { CoachInput } from "./coach-input";
|
||||
import {
|
||||
GuidedQuestionBubble,
|
||||
@@ -283,6 +284,53 @@ export function CoachConversation({
|
||||
}),
|
||||
);
|
||||
|
||||
// v1.18.9 — the live composer, lifted so it can mount in the new-chat
|
||||
// hero (centred) OR docked at the bottom of the conversation without
|
||||
// forking any composer logic (dictation, auto-grow, send/stop, the
|
||||
// guided-question placeholder all behave identically in both spots).
|
||||
const composerNode = (
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={() => handleSubmit(inputValue)}
|
||||
onCancel={send.cancel}
|
||||
disabled={send.isStreaming}
|
||||
isStreaming={send.isStreaming}
|
||||
autoFocusOnOpen={autoFocusComposer}
|
||||
placeholder={
|
||||
guided.phase === "asking"
|
||||
? t("insights.coach.guided.answerPlaceholder")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// v1.18.9 — the centred new-chat hero replaces the cramped empty thread
|
||||
// on the PAGE surface only. It shows exactly when the surface is a fresh,
|
||||
// untouched new chat: no selected conversation, nothing streaming or
|
||||
// optimistic, no error, and none of the guided / adopt / pending-question
|
||||
// affordances in play (those belong in the docked conversation flow). The
|
||||
// first send flips `send.isStreaming` / `send.optimisticUser`, so the hero
|
||||
// unmounts and the docked thread + bottom composer take over.
|
||||
const heroActive =
|
||||
surface === "page" &&
|
||||
currentConversationId === null &&
|
||||
!send.isStreaming &&
|
||||
!send.streaming.content &&
|
||||
!send.streaming.errorCode &&
|
||||
!send.optimisticUser &&
|
||||
guided.phase === "idle" &&
|
||||
pendingQuestions.length === 0 &&
|
||||
!pendingAdopt;
|
||||
|
||||
// The Coach's own starter questions seed the hero chips (NOT generic
|
||||
// "create an image" prompts). Same two openers the suggested-prompt strip
|
||||
// uses elsewhere, resolved here so the hero stays locale-agnostic.
|
||||
const heroPrompts = [
|
||||
t("insights.suggestedPrompts.tellMyDoctor"),
|
||||
t("insights.suggestedPrompts.medicationWorking"),
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-conversation"
|
||||
@@ -358,100 +406,98 @@ export function CoachConversation({
|
||||
{trailingHeaderActions}
|
||||
</header>
|
||||
|
||||
<CoachDrawerBody
|
||||
historyOpen={historyRailOpen}
|
||||
onToggleHistory={
|
||||
surface === "page"
|
||||
? () => setHistoryRailOpen((open) => !open)
|
||||
: undefined
|
||||
}
|
||||
historyRail={
|
||||
surface === "page" ? (
|
||||
<HistoryRail
|
||||
activeId={currentConversationId}
|
||||
// v1.18.1 — the `<aside>` band already renders the
|
||||
// "Conversations" <h2>; suppress the rail's own <h3> so the
|
||||
// heading is not stacked twice on the page surface.
|
||||
hideHeading
|
||||
onSelect={(id) => {
|
||||
// v1.16.5 — switching conversations drops the guided
|
||||
// session; unanswered questions stay pending.
|
||||
setCurrentConversationId(id);
|
||||
setPendingAdopt(null);
|
||||
dispatchGuided({ type: "RESET" });
|
||||
}}
|
||||
{heroActive ? (
|
||||
<CoachHero
|
||||
composer={composerNode}
|
||||
prompts={heroPrompts}
|
||||
onPickPrompt={(prompt) => handleSubmit(prompt)}
|
||||
/>
|
||||
) : (
|
||||
<CoachDrawerBody
|
||||
historyOpen={historyRailOpen}
|
||||
onToggleHistory={
|
||||
surface === "page"
|
||||
? () => setHistoryRailOpen((open) => !open)
|
||||
: undefined
|
||||
}
|
||||
historyRail={
|
||||
surface === "page" ? (
|
||||
<HistoryRail
|
||||
activeId={currentConversationId}
|
||||
// v1.18.1 — the `<aside>` band already renders the
|
||||
// "Conversations" <h2>; suppress the rail's own <h3> so the
|
||||
// heading is not stacked twice on the page surface.
|
||||
hideHeading
|
||||
onSelect={(id) => {
|
||||
// v1.16.5 — switching conversations drops the guided
|
||||
// session; unanswered questions stay pending.
|
||||
setCurrentConversationId(id);
|
||||
setPendingAdopt(null);
|
||||
dispatchGuided({ type: "RESET" });
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
thread={
|
||||
<MessageThread
|
||||
conversation={conversation ?? null}
|
||||
streaming={send.streaming}
|
||||
optimisticUser={send.optimisticUser}
|
||||
interleaved={interleaved}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
thread={
|
||||
<MessageThread
|
||||
conversation={conversation ?? null}
|
||||
streaming={send.streaming}
|
||||
optimisticUser={send.optimisticUser}
|
||||
interleaved={interleaved}
|
||||
/>
|
||||
}
|
||||
composer={
|
||||
<div>
|
||||
{/* v1.16.4 — quiet adopt-into-self-context offer once a
|
||||
}
|
||||
composer={
|
||||
<div>
|
||||
{/* v1.16.4 — quiet adopt-into-self-context offer once a
|
||||
clarifying question has been answered. Self-removes
|
||||
after settle; v1.16.5 reports the outcome back into the
|
||||
guided machine for the closing summary. */}
|
||||
{pendingAdopt && !send.isStreaming ? (
|
||||
<SelfContextAdoptOffer
|
||||
question={pendingAdopt.question}
|
||||
answer={pendingAdopt.answer}
|
||||
onDismiss={() => setPendingAdopt(null)}
|
||||
onSettled={(adoption) =>
|
||||
dispatchGuided({
|
||||
type: "ADOPTION_SETTLED",
|
||||
index: pendingAdopt.guidedIndex,
|
||||
adoption,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{/* v1.16.5 — guided clarifying-questions entry card (V2 of
|
||||
{pendingAdopt && !send.isStreaming ? (
|
||||
<SelfContextAdoptOffer
|
||||
question={pendingAdopt.question}
|
||||
answer={pendingAdopt.answer}
|
||||
onDismiss={() => setPendingAdopt(null)}
|
||||
onSettled={(adoption) =>
|
||||
dispatchGuided({
|
||||
type: "ADOPTION_SETTLED",
|
||||
index: pendingAdopt.guidedIndex,
|
||||
adoption,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{/* v1.16.5 — guided clarifying-questions entry card (V2 of
|
||||
the v1.16.0 chips). Offers the in-chat sequence while
|
||||
questions pend and the flow hasn't started. */}
|
||||
{guided.phase === "idle" && pendingQuestions.length > 0 ? (
|
||||
<GuidedQuestionsCard
|
||||
count={pendingQuestions.length}
|
||||
disabled={send.isStreaming || dismissQuestions.isPending}
|
||||
onStart={() =>
|
||||
dispatchGuided({ type: "START", questions: pendingQuestions })
|
||||
}
|
||||
onLater={() => dispatchGuided({ type: "LATER" })}
|
||||
onDismissAll={() =>
|
||||
dismissQuestions.mutate(undefined, {
|
||||
onSuccess: () => dispatchGuided({ type: "LATER" }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={() => handleSubmit(inputValue)}
|
||||
onCancel={send.cancel}
|
||||
disabled={send.isStreaming}
|
||||
isStreaming={send.isStreaming}
|
||||
autoFocusOnOpen={autoFocusComposer}
|
||||
placeholder={
|
||||
guided.phase === "asking"
|
||||
? t("insights.coach.guided.answerPlaceholder")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onHistoryClick={
|
||||
surface === "drawer" && onRequestFullView
|
||||
? onRequestFullView
|
||||
: () => setHistoryTrayOpen(true)
|
||||
}
|
||||
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
|
||||
/>
|
||||
{guided.phase === "idle" && pendingQuestions.length > 0 ? (
|
||||
<GuidedQuestionsCard
|
||||
count={pendingQuestions.length}
|
||||
disabled={send.isStreaming || dismissQuestions.isPending}
|
||||
onStart={() =>
|
||||
dispatchGuided({
|
||||
type: "START",
|
||||
questions: pendingQuestions,
|
||||
})
|
||||
}
|
||||
onLater={() => dispatchGuided({ type: "LATER" })}
|
||||
onDismissAll={() =>
|
||||
dismissQuestions.mutate(undefined, {
|
||||
onSuccess: () => dispatchGuided({ type: "LATER" }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{composerNode}
|
||||
</div>
|
||||
}
|
||||
onHistoryClick={
|
||||
surface === "drawer" && onRequestFullView
|
||||
? onRequestFullView
|
||||
: () => setHistoryTrayOpen(true)
|
||||
}
|
||||
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MobileRailTray
|
||||
historyOpen={historyTrayOpen}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { Quote, Sparkles } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.18.9 — the Coach new-chat hero (page surface only).
|
||||
*
|
||||
* A centred greeting with the composer directly beneath it and a row of
|
||||
* HealthLog's own starter questions, floated in generous negative space
|
||||
* over a faint purple atmospheric backdrop. This replaces the cramped
|
||||
* bottom-pinned empty thread with a calm, ChatGPT/Claude-grade prompt
|
||||
* surface. On first send the conversation branch takes over and the
|
||||
* composer docks to the bottom (handled by `<CoachConversation>`).
|
||||
*
|
||||
* The hero does NOT fork composer logic: it RECEIVES the live
|
||||
* `<CoachInput>` as the `composer` slot, so dictation, auto-grow, the
|
||||
* send/stop control, and the guided-question placeholder all behave
|
||||
* exactly as in the docked composer. The suggestion chips seed the same
|
||||
* composer via `onPickPrompt`.
|
||||
*
|
||||
* Motion: one orchestrated reveal — sparkle → greeting → composer → chips
|
||||
* stagger in via `motion-safe` `animate-in` with capped delays;
|
||||
* reduced-motion users get the layout instantly.
|
||||
*/
|
||||
export interface CoachHeroProps {
|
||||
/** The live `<CoachInput>` composer, re-parented into the hero. */
|
||||
composer: React.ReactNode;
|
||||
/** Localised starter prompts shown as chips below the composer. */
|
||||
prompts: string[];
|
||||
/** Seeds the composer with a chip's text (the parent submits on pick). */
|
||||
onPickPrompt: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function CoachHero({ composer, prompts, onPickPrompt }: CoachHeroProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-hero"
|
||||
className="relative flex min-h-0 flex-1 flex-col items-center justify-center overflow-hidden px-4 py-8 sm:px-6"
|
||||
>
|
||||
{/* Atmospheric backdrop — a faint purple radial glow behind the
|
||||
hero, never busy. Pointer-events-none + aria-hidden so it is
|
||||
purely decorative; it sits behind the content via -z-10. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 -z-10",
|
||||
"bg-[radial-gradient(60%_50%_at_50%_38%,color-mix(in_srgb,var(--dracula-purple)_14%,transparent),transparent_70%)]",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full max-w-2xl flex-col items-center gap-6 text-center">
|
||||
{/* Sparkle mark */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"from-dracula-purple to-dracula-pink flex size-12 items-center justify-center rounded-2xl bg-gradient-to-br shadow-sm",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:duration-500",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="text-background size-6" />
|
||||
</div>
|
||||
|
||||
{/* Greeting — a slightly larger, tighter display treatment of the
|
||||
existing font (weight/tracking/size only, no new font). */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
|
||||
)}
|
||||
style={{ animationDelay: "80ms", animationFillMode: "both" }}
|
||||
>
|
||||
<h1 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{t("insights.coach.heroGreeting")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.coach.heroSubline")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Composer — the live <CoachInput>, centred. */}
|
||||
<div
|
||||
data-slot="coach-hero-composer"
|
||||
className={cn(
|
||||
"w-full",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
|
||||
)}
|
||||
style={{ animationDelay: "160ms", animationFillMode: "both" }}
|
||||
>
|
||||
{composer}
|
||||
</div>
|
||||
|
||||
{/* Starter-question chips, seeded with the Coach's own prompts. */}
|
||||
{prompts.length > 0 && (
|
||||
<div
|
||||
data-slot="coach-hero-chips"
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-center gap-2",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-500",
|
||||
)}
|
||||
style={{ animationDelay: "240ms", animationFillMode: "both" }}
|
||||
>
|
||||
{prompts.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
type="button"
|
||||
onClick={() => onPickPrompt(prompt)}
|
||||
data-slot="coach-hero-chip"
|
||||
className={cn(
|
||||
"border-dracula-purple/20 hover:border-dracula-purple/45 hover:text-foreground",
|
||||
"text-muted-foreground inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
|
||||
"rounded-full border bg-transparent px-3.5 py-1.5 text-xs sm:text-sm",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
"focus-visible:ring-dracula-purple/50",
|
||||
)}
|
||||
>
|
||||
<Quote className="size-3 shrink-0" aria-hidden="true" />
|
||||
<span>{prompt}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,12 +20,14 @@ import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
|
||||
|
||||
import { SourceChips } from "./source-chips";
|
||||
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
|
||||
import { StreamedProse } from "./streamed-prose";
|
||||
import { ThinkingDisclosure } from "./thinking-disclosure";
|
||||
import { MessageTokenFooter } from "./message-token-footer";
|
||||
import type {
|
||||
CoachConversationDetailDTO,
|
||||
CoachOptimisticUserMessage,
|
||||
@@ -422,6 +424,8 @@ export function MessageThread({
|
||||
metricSource={m.metricSource}
|
||||
providerType={m.providerType}
|
||||
messageId={m.id}
|
||||
tokensUsed={m.tokensUsed}
|
||||
model={m.model}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
@@ -452,8 +456,10 @@ export function MessageThread({
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
// v1.18.7 — a soft fade/slide-in on the assistant turn so the
|
||||
// hand-off from the "Thinking…" beat to the first streamed
|
||||
// tokens reads as one continuous motion, not a hard swap.
|
||||
// hand-off from the thinking beat to the first streamed tokens
|
||||
// reads as one continuous motion, not a hard swap. v1.18.9 — the
|
||||
// per-word fade now lives in <StreamedProse>; this stays a light
|
||||
// container fade for the disclosure → prose transition.
|
||||
className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300"
|
||||
>
|
||||
<ChatBubble
|
||||
@@ -464,6 +470,12 @@ export function MessageThread({
|
||||
providerType={streaming.inProgress ? "streaming" : null}
|
||||
inProgress={streaming.inProgress}
|
||||
errorCode={streaming.errorCode}
|
||||
// v1.18.9 — live word-fade + the elapsed-time thinking
|
||||
// disclosure + the just-landed token footer.
|
||||
streaming
|
||||
startedAt={streaming.startedAt}
|
||||
reasoning={streaming.reasoning}
|
||||
usage={streaming.usage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -495,6 +507,35 @@ interface ChatBubbleProps {
|
||||
* user can't rate before the message lands on disk.
|
||||
*/
|
||||
messageId?: string;
|
||||
/**
|
||||
* v1.18.9 — true for the live streaming assistant turn. Drives the
|
||||
* word-by-word prose fade (`<StreamedProse>`); persisted bubbles render
|
||||
* settled plain text.
|
||||
*/
|
||||
streaming?: boolean;
|
||||
/**
|
||||
* v1.18.9 — send-anchored timestamp (epoch ms) for the streaming turn,
|
||||
* feeding the thinking disclosure's elapsed timer. Null on persisted
|
||||
* bubbles.
|
||||
*/
|
||||
startedAt?: number | null;
|
||||
/**
|
||||
* v1.18.9 — reasoning-summary text from the additive `reasoning` SSE
|
||||
* frame, shown inside the thinking disclosure when present.
|
||||
*/
|
||||
reasoning?: string;
|
||||
/**
|
||||
* v1.18.9 — per-turn token usage for the just-finished streaming bubble
|
||||
* (from `done.usage`). Persisted bubbles read `tokensUsed` / `model`
|
||||
* instead.
|
||||
*/
|
||||
usage?: import("./use-coach").CoachUsage | null;
|
||||
/**
|
||||
* v1.18.9 — persisted per-message token count + model, for the quiet
|
||||
* token footer on reload. Null on user turns, refusals, and older rows.
|
||||
*/
|
||||
tokensUsed?: number | null;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
function ChatBubble({
|
||||
@@ -506,6 +547,12 @@ function ChatBubble({
|
||||
inProgress,
|
||||
errorCode,
|
||||
messageId,
|
||||
streaming,
|
||||
startedAt,
|
||||
reasoning,
|
||||
usage,
|
||||
tokensUsed,
|
||||
model,
|
||||
}: ChatBubbleProps) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -631,38 +678,53 @@ function ChatBubble({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex max-w-[calc(80%-2.625rem)] flex-col gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-foreground",
|
||||
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
|
||||
"text-sm leading-relaxed whitespace-pre-wrap",
|
||||
)}
|
||||
>
|
||||
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from
|
||||
the assistant prose before it lands in the bubble. The
|
||||
raw `content` is the streamed Coach reply (or the
|
||||
persisted twin after `done` fires); both paths are AI-
|
||||
authored so they share the same leak surface as the
|
||||
insight prose elsewhere.
|
||||
|
||||
v1.16.1 — before the first token lands, the bubble shows
|
||||
a classic typing indicator (three pulsing dots) instead
|
||||
of the old whole-bubble pulse + "Thinking…" prose. The
|
||||
prose stays as the screen-reader announcement. */}
|
||||
{/* v1.16.4 — a failed turn with no streamed prose renders the
|
||||
error copy INSIDE the bubble instead of an empty bubble
|
||||
with a caption below it. With partial prose the prose
|
||||
keeps the bubble and the error stays a caption. */}
|
||||
{content ? (
|
||||
stripChartTokens(content)
|
||||
) : inProgress ? (
|
||||
<TypingDots label={t("insights.coach.thinking")} />
|
||||
) : safeError ? (
|
||||
<span className="text-warning/90">{safeError}</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
{/* v1.18.9 — the single thinking indicator (live elapsed-time line,
|
||||
then an auto-collapsed past-tense disclosure). Renders only for
|
||||
the live streaming turn — persisted bubbles pass no `startedAt`,
|
||||
so the disclosure returns null and history stays settled. This
|
||||
replaces the old <TypingDots> dots-plus-word combo: there is
|
||||
never a three-dots indicator AND a "Thinking" word at once. */}
|
||||
{streaming && startedAt != null && (
|
||||
<ThinkingDisclosure
|
||||
startedAt={startedAt}
|
||||
inProgress={!!inProgress}
|
||||
hasContent={!!content}
|
||||
reasoning={reasoning}
|
||||
/>
|
||||
)}
|
||||
{/* The prose bubble. Skipped while the live turn is still thinking
|
||||
with no prose (the disclosure carries the live state above), so
|
||||
the user never sees an empty bubble under the indicator. The
|
||||
`<TypingDots>` fallback below only fires when there is no live
|
||||
disclosure (e.g. a defensive null `startedAt`). */}
|
||||
{(content ||
|
||||
safeError ||
|
||||
(inProgress && !(streaming && startedAt != null))) && (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-foreground",
|
||||
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
|
||||
"text-sm leading-relaxed whitespace-pre-wrap",
|
||||
)}
|
||||
>
|
||||
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the
|
||||
assistant prose. v1.18.9 — the live turn streams in
|
||||
word-by-word with a soft fade (<StreamedProse>); a settled
|
||||
/ persisted turn renders as plain text. */}
|
||||
{/* v1.16.4 — a failed turn with no streamed prose renders the
|
||||
error copy INSIDE the bubble; with partial prose the prose
|
||||
keeps the bubble and the error stays a caption. */}
|
||||
{content ? (
|
||||
<StreamedProse content={content} streaming={!!streaming} />
|
||||
) : inProgress ? (
|
||||
<TypingDots label={t("insights.coach.thinking")} />
|
||||
) : safeError ? (
|
||||
<span className="text-warning/90">{safeError}</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{safeError && content && (
|
||||
<p className="text-warning/90 text-xs">{safeError}</p>
|
||||
)}
|
||||
@@ -784,6 +846,16 @@ function ChatBubble({
|
||||
const sug = suggestion ?? metricSource?.suggestion ?? null;
|
||||
return sug ? <ReminderSuggestionCard suggestion={sug} /> : null;
|
||||
})()}
|
||||
{/* v1.18.9 — quiet per-message token footer. The just-finished
|
||||
streaming turn reads the `done.usage` envelope; a persisted /
|
||||
reloaded turn reads the message's own `tokensUsed` + `model`.
|
||||
Skipped on in-flight, errored, and refusal turns. */}
|
||||
{!inProgress && !errorCode && providerType !== "refusal" && (
|
||||
<MessageTokenFooter
|
||||
tokens={usage?.totalTokens ?? tokensUsed}
|
||||
model={usage?.model ?? model}
|
||||
/>
|
||||
)}
|
||||
{/* 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
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.18.9 — quiet per-message token-usage caption under an assistant
|
||||
* bubble. Server-authoritative: it renders the count the server reported
|
||||
* (`done.usage.totalTokens` for the live turn, `CoachMessageDTO.tokensUsed`
|
||||
* on reload), never a client-side recomputation. No cost — BYOK users pay
|
||||
* the provider directly and a price table would only drift.
|
||||
*
|
||||
* Renders nothing when there is no count (user turns, refusals, in-flight
|
||||
* bubbles, and pre-feature persisted rows all carry null).
|
||||
*/
|
||||
export interface MessageTokenFooterProps {
|
||||
tokens: number | null | undefined;
|
||||
/** Provider model name (e.g. `gpt-4o`); appended when present. */
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
export function MessageTokenFooter({ tokens, model }: MessageTokenFooterProps) {
|
||||
const { t } = useTranslations();
|
||||
if (tokens == null) return null;
|
||||
|
||||
const label = model
|
||||
? t("insights.coach.tokensUsedWithModel", { count: tokens, model })
|
||||
: t("insights.coach.tokensUsed", { count: tokens });
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="coach-token-footer"
|
||||
className="text-muted-foreground/80 text-[11px] tabular-nums"
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
|
||||
/**
|
||||
* v1.18.9 — Claude-style word-by-word fade-in for the LIVE Coach turn.
|
||||
*
|
||||
* The provider clients return the whole reply in one shot; the chat route
|
||||
* re-chunks it into per-word `token` SSE frames (`tokeniseForStreaming`),
|
||||
* so `content` grows as frames land. This component renders each
|
||||
* whitespace segment as its own `<span>` that fades + drifts up on mount,
|
||||
* giving the reply a "thinking out loud" cadence rather than a hard dump
|
||||
* of word groups. Once the turn settles (`streaming === false`) the spans
|
||||
* collapse to a single plain-text node — zero residual animation DOM, and
|
||||
* the feedback / evidence layout underneath stays stable.
|
||||
*
|
||||
* Why no "only animate the new words" bookkeeping: each segment is keyed
|
||||
* by its index, so when a fresh SSE frame appends words, React keeps the
|
||||
* already-mounted spans (their CSS entrance already ran and does not
|
||||
* replay on reconcile) and only MOUNTS the new trailing spans — which is
|
||||
* exactly when the `animate-in` entrance fires. The browser does the
|
||||
* "new words only" gating for us; no render-time ref is needed.
|
||||
*
|
||||
* CSP-safe: the entrance is Tailwind's `animate-in fade-in-0
|
||||
* slide-in-from-bottom-1` (compiled utilities served from `'self'`) plus a
|
||||
* per-span inline `animationDelay` / `animationDuration`. Inline `style`
|
||||
* attributes are already permitted under the strict CSP
|
||||
* (`style-src 'self' 'unsafe-inline'`); there is no runtime-injected
|
||||
* `<style>` tag, no `dangerouslySetInnerHTML`, and no markdown library —
|
||||
* the text renders as plain React children, the XSS-safe path the rest of
|
||||
* the Coach uses.
|
||||
*
|
||||
* `prefers-reduced-motion`: the animation classes are `motion-safe:`
|
||||
* gated, so reduced-motion users get the words instantly with no fade and
|
||||
* no translate.
|
||||
*/
|
||||
export interface StreamedProseProps {
|
||||
/** Raw assistant prose (may contain stray chart-token leaks). */
|
||||
content: string;
|
||||
/**
|
||||
* True while the turn is still streaming. When false the prose renders
|
||||
* settled as one plain-text node (persisted history + finished turns).
|
||||
*/
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split prose into word+trailing-whitespace segments. Mirrors the route's
|
||||
* server-side `tokeniseForStreaming` split (`\S+\s*`) so the client-side
|
||||
* re-split lines up with how the SSE frames arrived. Exported for tests.
|
||||
*/
|
||||
export function splitProseSegments(text: string): string[] {
|
||||
return text.match(/\S+\s*/g) ?? (text ? [text] : []);
|
||||
}
|
||||
|
||||
export function StreamedProse({ content, streaming }: StreamedProseProps) {
|
||||
const clean = stripChartTokens(content);
|
||||
const segments = useMemo(
|
||||
() => (streaming ? splitProseSegments(clean) : []),
|
||||
[streaming, clean],
|
||||
);
|
||||
|
||||
if (!streaming) {
|
||||
// Settled turn: one plain text node, no spans, no animation.
|
||||
return <>{clean}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => (
|
||||
// New trailing spans mount + animate; already-mounted spans keep
|
||||
// their stable index key and do not replay the entrance.
|
||||
<span
|
||||
key={index}
|
||||
className={cn(
|
||||
"motion-safe:animate-in motion-safe:fade-in-0",
|
||||
"motion-safe:slide-in-from-bottom-1",
|
||||
)}
|
||||
style={{
|
||||
animationDuration: "340ms",
|
||||
// A gentle, capped stagger keyed off the in-batch position so a
|
||||
// burst of words reads as a soft cascade rather than a single
|
||||
// flash, without lagging on a long frame.
|
||||
animationDelay: `${(index % 6) * 26}ms`,
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
{segment}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.18.9 — the ONE Coach thinking indicator on the page surface.
|
||||
*
|
||||
* Replaces the old dots-plus-word combo (`<TypingDots>`), which showed
|
||||
* three bouncing dots AND the word "Thinking…" at the same time — the
|
||||
* exact pairing the maintainer disliked. This is a single, calm control:
|
||||
*
|
||||
* - While the turn is thinking (streaming, no prose yet): a live
|
||||
* "Thinking… N s" line with a small spinner. `role="status"` so a
|
||||
* screen reader announces "thinking" once, not on every tick.
|
||||
* - Once the first token lands OR the turn settles: it relabels to the
|
||||
* past tense ("Thought for N s") and auto-collapses into a quiet
|
||||
* `<details>` disclosure. Expanding reveals the reasoning summary when
|
||||
* a reasoning-capable provider supplied one (the additive `reasoning`
|
||||
* SSE frame), otherwise a one-line factual fallback — never fabricated
|
||||
* chain-of-thought.
|
||||
*
|
||||
* The elapsed time is computed from the send-anchored `startedAt`
|
||||
* timestamp: it ticks live while thinking and freezes the instant the
|
||||
* first token (or the settled state) arrives.
|
||||
*/
|
||||
export interface ThinkingDisclosureProps {
|
||||
/**
|
||||
* Epoch-ms timestamp the turn started (the moment the send fired). Null
|
||||
* disables the elapsed timer (the component renders nothing).
|
||||
*/
|
||||
startedAt: number | null;
|
||||
/** True until the turn settles (the `done`/`error` frame closed it). */
|
||||
inProgress: boolean;
|
||||
/**
|
||||
* Whether any assistant prose has streamed in yet. Drives the live →
|
||||
* past-tense relabel + auto-collapse: the moment the answer starts, the
|
||||
* "Thinking…" line freezes and folds away.
|
||||
*/
|
||||
hasContent: boolean;
|
||||
/**
|
||||
* Optional reasoning-summary text from the additive `reasoning` SSE
|
||||
* frame, rendered inside the expanded disclosure when present.
|
||||
*/
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
export function ThinkingDisclosure({
|
||||
startedAt,
|
||||
inProgress,
|
||||
hasContent,
|
||||
reasoning,
|
||||
}: ThinkingDisclosureProps) {
|
||||
const { t } = useTranslations();
|
||||
const panelId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// The turn is "thinking" only while it is in progress with no prose yet.
|
||||
const thinking = inProgress && hasContent === false && startedAt !== null;
|
||||
|
||||
// `elapsed` ticks once a second while thinking; the interval stops the
|
||||
// moment `thinking` flips false, so the last value it set is the frozen
|
||||
// "thought for N s" figure — no render-time ref, no sync setState in the
|
||||
// effect body (the first tick lands on the interval, not inline).
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!thinking || startedAt === null) return;
|
||||
const tick = () =>
|
||||
setElapsed(Math.max(0, Math.round((Date.now() - startedAt) / 1000)));
|
||||
tick();
|
||||
const handle = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(handle);
|
||||
}, [thinking, startedAt]);
|
||||
|
||||
if (startedAt === null) return null;
|
||||
|
||||
// ── Live "thinking" line (single indicator, no dots-plus-word) ──
|
||||
if (thinking) {
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-thinking-live"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-muted-foreground inline-flex items-center gap-2 py-0.5 text-xs"
|
||||
>
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="text-dracula-purple/70 size-3.5 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
<span>{t("insights.coach.thinking")}</span>
|
||||
<span aria-hidden="true" className="tabular-nums opacity-70">
|
||||
{t("insights.coach.thinkingElapsed", { seconds: elapsed })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Settled: past-tense collapsible disclosure (auto-collapsed) ──
|
||||
const reasoningText = reasoning?.trim() ?? "";
|
||||
return (
|
||||
<details
|
||||
data-slot="coach-thinking-disclosure"
|
||||
open={open}
|
||||
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
|
||||
className="group inline-block"
|
||||
>
|
||||
<summary
|
||||
data-slot="coach-thinking-summary"
|
||||
aria-controls={panelId}
|
||||
aria-expanded={open}
|
||||
aria-label={t("insights.coach.thinkingExpandAria")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground inline-flex cursor-pointer items-center gap-1 text-xs",
|
||||
"marker:hidden [&::-webkit-details-marker]:hidden",
|
||||
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="size-3 transition-transform group-open:rotate-90"
|
||||
/>
|
||||
<span>
|
||||
{t("insights.coach.thinkingDuration", { seconds: elapsed })}
|
||||
</span>
|
||||
</summary>
|
||||
<p
|
||||
id={panelId}
|
||||
data-slot="coach-thinking-detail"
|
||||
className="text-muted-foreground mt-1.5 pl-4 text-xs leading-relaxed"
|
||||
>
|
||||
{reasoningText || t("insights.coach.thinkingDetail")}
|
||||
</p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
CoachScope,
|
||||
CoachStreamEvent,
|
||||
CoachSuggestion,
|
||||
CoachUsage,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -191,6 +192,26 @@ export interface CoachStreamingMessage {
|
||||
messageId: string | null;
|
||||
/** Server-side error code from an `error` frame, if any. */
|
||||
errorCode: string | null;
|
||||
/**
|
||||
* v1.18.9 — per-turn token usage from the additive `done.usage` frame.
|
||||
* Null until `done` lands (or when the provider returned no count). The
|
||||
* thread's token footer reads this for the just-finished streaming
|
||||
* bubble; persisted bubbles read `CoachMessageDTO.tokensUsed` instead.
|
||||
*/
|
||||
usage: CoachUsage | null;
|
||||
/**
|
||||
* v1.18.9 — epoch-ms timestamp the send fired, so the thinking
|
||||
* disclosure can show a live "thinking for N s" counter that freezes
|
||||
* to a past-tense summary once the first token lands. Null before a
|
||||
* turn starts.
|
||||
*/
|
||||
startedAt: number | null;
|
||||
/**
|
||||
* v1.18.9 — optional reasoning-summary text from the additive
|
||||
* `reasoning` frame, rendered inside the thinking disclosure when a
|
||||
* reasoning-capable provider emits it. Empty string when none.
|
||||
*/
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,6 +252,9 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
usage: null,
|
||||
startedAt: null,
|
||||
reasoning: "",
|
||||
};
|
||||
|
||||
export interface SendCoachMessageParams {
|
||||
@@ -324,6 +348,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
inProgress: true,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
usage: null,
|
||||
// v1.18.9 — anchor the thinking-disclosure elapsed timer to the
|
||||
// moment the send fired.
|
||||
startedAt: Date.now(),
|
||||
reasoning: "",
|
||||
});
|
||||
|
||||
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
|
||||
@@ -337,6 +366,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: "coach.network",
|
||||
usage: null,
|
||||
startedAt: null,
|
||||
reasoning: "",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -370,6 +402,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: "coach.network",
|
||||
usage: null,
|
||||
startedAt: null,
|
||||
reasoning: "",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -382,6 +417,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: `coach.http.${response.status}`,
|
||||
usage: null,
|
||||
startedAt: null,
|
||||
reasoning: "",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -419,6 +457,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: structured ?? `coach.http.${response.status}`,
|
||||
usage: null,
|
||||
startedAt: null,
|
||||
reasoning: "",
|
||||
});
|
||||
// v1.16.4 — KEEP the optimistic user bubble: a rejected turn
|
||||
// (budget gate, 4xx) has no persisted twin coming, and dropping
|
||||
@@ -437,6 +478,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
let collectedSuggestion: CoachSuggestion | null = null;
|
||||
let lastError: string | null = null;
|
||||
let messageId: string | null = null;
|
||||
let collectedUsage: CoachUsage | null = null;
|
||||
let collectedReasoning = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -468,9 +511,21 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
suggestion: evt.suggestion,
|
||||
}));
|
||||
break;
|
||||
case "reasoning":
|
||||
// v1.18.9 — additive reasoning-summary frame. Append so
|
||||
// multiple chunks accumulate; the disclosure renders it
|
||||
// when present, else falls back to the elapsed-time label.
|
||||
collectedReasoning += evt.text;
|
||||
setStreaming((prev) => ({
|
||||
...prev,
|
||||
reasoning: prev.reasoning + evt.text,
|
||||
}));
|
||||
break;
|
||||
case "done":
|
||||
resolvedConversationId = evt.conversationId;
|
||||
messageId = evt.messageId;
|
||||
// v1.18.9 — additive per-turn usage envelope.
|
||||
collectedUsage = evt.usage ?? null;
|
||||
break;
|
||||
case "error":
|
||||
lastError = evt.code;
|
||||
@@ -484,8 +539,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
if (evt.type === "done") {
|
||||
resolvedConversationId = evt.conversationId;
|
||||
messageId = evt.messageId;
|
||||
collectedUsage = evt.usage ?? null;
|
||||
} else if (evt.type === "error") {
|
||||
lastError = evt.code;
|
||||
} else if (evt.type === "reasoning") {
|
||||
collectedReasoning += evt.text;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -494,14 +552,20 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
setStreaming({
|
||||
setStreaming((prev) => ({
|
||||
content: collectedContent,
|
||||
metricSource: collectedProvenance,
|
||||
suggestion: collectedSuggestion,
|
||||
inProgress: false,
|
||||
messageId,
|
||||
errorCode: lastError,
|
||||
});
|
||||
usage: collectedUsage,
|
||||
// v1.18.9 — keep the send-anchored timestamp so the disclosure can
|
||||
// freeze the elapsed time to a past-tense "thought for N s" once
|
||||
// the turn settles.
|
||||
startedAt: prev.startedAt,
|
||||
reasoning: collectedReasoning,
|
||||
}));
|
||||
|
||||
if (resolvedConversationId) {
|
||||
// Invalidate the freshly-persisted conversation + the rail so
|
||||
@@ -550,4 +614,5 @@ export type {
|
||||
CoachConversationDetailDTO,
|
||||
CoachProvenance,
|
||||
CoachSuggestion,
|
||||
CoachUsage,
|
||||
};
|
||||
@@ -128,6 +128,13 @@ export interface AppendMessageParams {
|
||||
metricSource?: CoachProvenance | null;
|
||||
providerType?: string | null;
|
||||
promptVersion?: string | null;
|
||||
/**
|
||||
* v1.18.9 — per-turn token count + model the reply was produced with,
|
||||
* persisted so the quiet token footer survives a reload. Omitted on
|
||||
* user turns and refusals (no token count to record).
|
||||
*/
|
||||
tokensUsed?: number | null;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,6 +175,8 @@ export async function appendMessage(
|
||||
metricSourceJson: provenanceToJson(params.metricSource ?? null),
|
||||
providerType: params.providerType ?? null,
|
||||
promptVersion: params.promptVersion ?? null,
|
||||
tokensUsed: params.tokensUsed ?? null,
|
||||
model: params.model ?? null,
|
||||
},
|
||||
});
|
||||
await tx.coachConversation.update({
|
||||
@@ -185,6 +194,8 @@ export async function appendMessage(
|
||||
metricSource: provenanceFromJson(result.metricSourceJson),
|
||||
providerType: result.providerType,
|
||||
promptVersion: result.promptVersion,
|
||||
tokensUsed: result.tokensUsed,
|
||||
model: result.model,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,6 +281,8 @@ export async function fetchConversationWithMessages(
|
||||
metricSource: provenanceFromJson(m.metricSourceJson),
|
||||
providerType: m.providerType,
|
||||
promptVersion: m.promptVersion,
|
||||
tokensUsed: m.tokensUsed,
|
||||
model: m.model,
|
||||
}));
|
||||
|
||||
// v1.11.1 — decrypt the rolling conversation summary (fail-closed: an
|
||||
|
||||
@@ -165,6 +165,21 @@ export interface CoachSuggestion {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.9 — per-turn token-usage envelope carried on the `done` frame
|
||||
* (and persisted onto the assistant message). Server-authoritative: the
|
||||
* client renders these numbers, never recomputes them. `totalTokens` is
|
||||
* the headline count surfaced in the quiet per-message footer; the
|
||||
* prompt / completion split is optional (not every provider returns it)
|
||||
* and `model` names the provider model that produced the reply.
|
||||
*/
|
||||
export interface CoachUsage {
|
||||
totalTokens: number | null;
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE event shapes emitted by the streaming endpoint.
|
||||
*
|
||||
@@ -173,6 +188,15 @@ export interface CoachSuggestion {
|
||||
* The `suggestion` frame (v1.18.1) is additive: older web + iOS clients
|
||||
* that don't know it drop it on the floor (the parser keeps only frames
|
||||
* whose `type` it handles), so the chat stays backwards-compatible.
|
||||
*
|
||||
* v1.18.9 — two additive shapes:
|
||||
* - `done.usage` carries the per-turn `CoachUsage` (tokens + model) so
|
||||
* the client can paint the quiet per-message footer the instant the
|
||||
* stream closes; older clients ignore the extra key.
|
||||
* - `reasoning` is an optional frame whose `text` the client renders
|
||||
* inside the thinking disclosure when a reasoning-capable provider
|
||||
* emits a cheap reasoning summary. Providers without reasoning emit
|
||||
* none, and the disclosure falls back to its elapsed-time label.
|
||||
*/
|
||||
export type CoachStreamEvent =
|
||||
| { type: "token"; token: string }
|
||||
@@ -181,7 +205,13 @@ export type CoachStreamEvent =
|
||||
metricSource: CoachProvenance;
|
||||
}
|
||||
| { type: "suggestion"; suggestion: CoachSuggestion }
|
||||
| { type: "done"; conversationId: string; messageId: string }
|
||||
| { type: "reasoning"; text: string }
|
||||
| {
|
||||
type: "done";
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
usage?: CoachUsage;
|
||||
}
|
||||
| { type: "error"; code: string; message: string };
|
||||
|
||||
/**
|
||||
@@ -335,6 +365,19 @@ export interface CoachMessageDTO {
|
||||
metricSource: CoachProvenance | null;
|
||||
providerType: string | null;
|
||||
promptVersion: string | null;
|
||||
/**
|
||||
* v1.18.9 — total tokens the assistant turn cost, persisted so the
|
||||
* quiet per-message footer survives a conversation reload. Null on
|
||||
* older messages (pre-feature) and on user turns / refusals where no
|
||||
* token count was recorded.
|
||||
*/
|
||||
tokensUsed: number | null;
|
||||
/**
|
||||
* v1.18.9 — the provider model that produced the reply (e.g.
|
||||
* `gpt-4o`), persisted alongside `tokensUsed` for the footer. Null
|
||||
* when unknown (user turns, refusals, older rows).
|
||||
*/
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface CoachConversationDetailDTO extends CoachConversationDTO {
|
||||
|
||||
@@ -307,6 +307,19 @@ const coachMessageSchema = z
|
||||
.describe(
|
||||
"Coach prompt version that produced the reply; null for user turns.",
|
||||
),
|
||||
tokensUsed: z
|
||||
.number()
|
||||
.int()
|
||||
.nullable()
|
||||
.describe(
|
||||
"v1.18.9 — total tokens this assistant turn cost, persisted so the per-message token footer survives a reload. Null on user turns, refusals, and pre-feature rows.",
|
||||
),
|
||||
model: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"v1.18.9 — the provider model that produced the reply (e.g. gpt-4o). Null when unknown (user turns, refusals, older rows).",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachMessage",
|
||||
@@ -425,7 +438,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Send a Coach turn (streaming reply)",
|
||||
description:
|
||||
"v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{ type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.",
|
||||
"v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
|
||||
Reference in new issue
Block a user