Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/desktop/src/features/chat/transcript/AssistantTurn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ import {
shouldGroupTurnProcess,
} from "../../../lib/turn-process";
import { useAppStore } from "../../../stores/app-store";
import { latestGenerationMessage } from "../../../lib/live-throughput";
import { Markdown } from "../../../components/Markdown";
import { IconBranch, IconReview } from "../../../components/icons";
import { TooltipButton } from "../../../components/ui";
import {
AssistantErrorMessage,
CopyButton,
MessageMeta,
LiveMessageMeta,
} from "./shared";
import { activityItemsEqual, ActivityGroup } from "./ActivityGroup";
import { GeneratedImages } from "./GeneratedImages";
Expand Down Expand Up @@ -251,6 +253,9 @@ export const AssistantTurn = memo(function AssistantTurn({
const responseDurationMs = assistantTurnResponseDuration(entry);
const responseOutputTokens = assistantTurnResponseOutputTokens(entry);
const modelId = metaMessage?.modelId ?? latestUsageMessage?.modelId;
// The tail message is the one still growing; the live rate is estimated from
// it because the provider only reports usage at message_end.
const latestMessage = latestGenerationMessage(entry);
const hasError = messages.some((message) => Boolean(message.error));
const complete =
!isActive && !hasError && Boolean(content) && Boolean(actionMessage);
Expand Down Expand Up @@ -382,12 +387,23 @@ export const AssistantTurn = memo(function AssistantTurn({
{turnAllActivityItems.filter((item) => item.kind === "tool" && item.message.toolName === "GenerateImages").map((item) => (
<GeneratedImages key={item.message.id} message={item.message} />
))}
{!isActive && metaMessage ? (
{!isActive && (metaMessage || latestMessage?.timeToFirstTokenMs !== undefined) ? (
<MessageMeta
modelId={modelId}
usage={usage}
responseDurationMs={responseDurationMs}
responseOutputTokens={responseOutputTokens}
timeToFirstTokenMs={latestMessage?.timeToFirstTokenMs}
/>
) : null}
{isActive ? (
<LiveMessageMeta
key={entry.id}
modelId={modelId}
message={latestMessage}
toolRunning={turnAllActivityItems.some(
(item) => item.kind === "tool" && item.message.toolStatus === "running",
)}
/>
) : null}
{complete && actionMessage ? (
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/features/chat/transcript/FirstOutputLatency.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useTranslation } from "react-i18next";

/** A measured request latency; absent on old records and before the first output. */
export function FirstOutputLatency({ milliseconds }: { milliseconds?: number }) {
const { t } = useTranslation();
if (milliseconds === undefined || !Number.isFinite(milliseconds) || milliseconds < 0) {
return null;
}
return (
<span className="message-meta-chip first-output-latency" title={t("chat.firstOutputHint")}>
{t("chat.firstOutputLatency", { seconds: (milliseconds / 1000).toFixed(1) })}
</span>
);
}
64 changes: 63 additions & 1 deletion apps/desktop/src/features/chat/transcript/shared.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FirstOutputLatency } from "./FirstOutputLatency";
import {
memo,
useCallback,
Expand All @@ -19,6 +20,9 @@ import {
type ThinkingLevel,
} from "@pi-desktop/shared";
import { useOpenChatFileRef, useOpenPreviewTarget } from "../../../hooks/use-preview-target";
import { generationPhase, type ThroughputMessage } from "../../../lib/live-throughput";
import { useLiveThroughput } from "./use-live-throughput";

import { useDisclosureAnchorNotifier } from "../../../lib/disclosure-anchor-context";
import { isThinkingActive, resolveThinkingDisplayMode } from "../../../lib/turn-process";
import { TranscriptSearchContext } from "../../../lib/transcript-search-context";
Expand Down Expand Up @@ -110,19 +114,21 @@ export function MessageMeta({
usage,
responseDurationMs,
responseOutputTokens,
timeToFirstTokenMs,
}: {
modelId?: string;
usage?: MessageUsage;
responseDurationMs?: number;
responseOutputTokens?: number;
timeToFirstTokenMs?: number;
}) {
const { t } = useTranslation();
const throughput = calculateTokenRate(
responseOutputTokens ?? usage?.outputTokens ?? 0,
responseDurationMs,
);
const showThroughput = !usage && throughput !== undefined;
if (!modelId && !showThroughput) {
if (!modelId && !showThroughput && timeToFirstTokenMs === undefined) {
return null;
}
return (
Expand All @@ -132,6 +138,7 @@ export function MessageMeta({
{modelId}
</span>
) : null}
<FirstOutputLatency milliseconds={timeToFirstTokenMs} />
{showThroughput ? (
<span className="message-meta-chip throughput">
{t("chat.usageThroughputEstimated", {
Expand All @@ -143,6 +150,61 @@ export function MessageMeta({
);
}

/**
* Meta row for the turn that is still streaming.
*
* Mounted only for the active tail turn, which keeps the sampler and its
* interval off every history row and keeps per-token work out of the store.
* The figure is always an estimate — the provider reports usage once, at
* `message_end` — so it carries the "≈" copy (ADR 0073 §4), and `MessageMeta`
* takes over with the exact value once the turn completes.
*/
export function LiveMessageMeta({
modelId,
message,
toolRunning = false,
}: {
modelId?: string;
message?: ThroughputMessage;
toolRunning?: boolean;
}) {
const { t } = useTranslation();
const phase = generationPhase(message, toolRunning);
const generating = phase === "thinking" || phase === "generating";
const { rate, stale } = useLiveThroughput(message, generating);
const phaseLabel = t({
waiting: "chat.liveWaiting",
thinking: "chat.thinking",
generating: "chat.liveGenerating",
tool: "chat.liveToolRunning",
}[phase]);
const rateLabel = rate === undefined ? undefined : t("chat.usageThroughputEstimated", {
count: Math.round(rate),
});
return (
<div className="message-meta">
{modelId ? (
<span className="message-meta-chip model" title={modelId}>
{modelId}
</span>
) : null}
<FirstOutputLatency milliseconds={generating ? message?.timeToFirstTokenMs : undefined} />
<span className="message-meta-chip generation-phase" data-generation-phase={phase}>
{phaseLabel}
</span>
{rate === undefined ? null : (
<span
className="message-meta-chip throughput"
data-stale={stale ? "true" : undefined}
title={t("chat.usageThroughputLabel")}
>
{stale ? t("chat.liveLastRate", { rate: rateLabel }) : rateLabel}
</span>
)}
</div>
);
}

export function AssistantErrorMessage({ message }: { message: UiMessage }) {
const { t } = useTranslation();
const [open, setOpen] = useState(true);
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/features/chat/transcript/use-live-throughput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useEffect, useRef, useState } from "react";
import {
LIVE_THROUGHPUT_SAMPLE_MS,
advanceThroughput,
type LiveTokenRate,
type ThroughputMessage,
type ThroughputTracker,
} from "../../../lib/live-throughput";

/** Only the active turn mounts this sampler. Committed props feed a fixed cadence. */
export function useLiveThroughput(
message: ThroughputMessage | undefined,
generating: boolean,
): LiveTokenRate {
const inputRef = useRef({ message, generating });
const trackerRef = useRef<ThroughputTracker>({ samples: [] });
const [view, setView] = useState<LiveTokenRate & { messageId?: string }>({ stale: false });

useEffect(() => {
inputRef.current = { message, generating };
}, [message, generating]);

useEffect(() => {
const sample = () => {
const input = inputRef.current;
const next = advanceThroughput(
trackerRef.current, input.message, input.generating, performance.now(),
);
trackerRef.current = next.tracker;
setView((previous) =>
previous.rate === next.view.rate && previous.stale === next.view.stale &&
previous.messageId === input.message?.id
? previous
: { ...next.view, messageId: input.message?.id },
);
};
sample();
const timer = window.setInterval(sample, LIVE_THROUGHPUT_SAMPLE_MS);
return () => window.clearInterval(timer);
}, []);

return {
rate: view.rate,
stale: view.stale || !generating || view.messageId !== message?.id,
};
}
166 changes: 166 additions & 0 deletions apps/desktop/src/lib/live-throughput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { calculateTokenRate, estimateResponseOutputTokens } from "./context-usage";
import type { AssistantTurnEntry } from "./assistant-turns";
import type { UiMessage } from "@pi-desktop/shared";

/** Renderer estimate of visible output; provider usage arrives at message_end. */
/** Span the rate is measured over. */
export const LIVE_THROUGHPUT_WINDOW_MS = 3_000;
/** Minimum spacing between samples; the estimate walks the whole message. */
export const LIVE_THROUGHPUT_SAMPLE_MS = 250;
/** Silence past this point dims the figure instead of replacing it. */
export const LIVE_THROUGHPUT_STALE_MS = 1_500;
/** Below this span the sample set is too thin to put a number on screen. */
export const LIVE_THROUGHPUT_MIN_SPAN_MS = 600;
/** Backstop on the ring; the window normally bounds it well below this. */
export const LIVE_THROUGHPUT_MAX_SAMPLES = 120;

export type ThroughputSample = {
ts: number;
/** Cumulative estimated output tokens for the message, not a delta. */
tokens: number;
};

export type LiveTokenRate = {
/** Absent until the samples span `LIVE_THROUGHPUT_MIN_SPAN_MS` and grow. */
rate?: number;
/** True during tool/waiting phases or after output stops for the stale interval. */
stale: boolean;
};

/** Uses ADR 0073’s visible thinking/text estimate, shared with stopped turns. */
export function sampleTokensForMessage(
message: Pick<UiMessage, "content" | "thinking"> | undefined,
): number {
if (!message) return 0;
return estimateResponseOutputTokens(message) ?? 0;
}

/** Prunes the window, retaining one baseline when every prior sample is older. */
export function pushThroughputSample(
samples: readonly ThroughputSample[],
sample: ThroughputSample,
): ThroughputSample[] {
const cutoff = sample.ts - LIVE_THROUGHPUT_WINDOW_MS;
const firstInWindow = samples.findIndex((entry) => entry.ts >= cutoff);
// Keep the newest pre-window sample only when the window holds nothing else.
const start = firstInWindow === -1 ? Math.max(0, samples.length - 1) : firstInWindow;
const next = [...samples.slice(start), sample];
return next.length > LIVE_THROUGHPUT_MAX_SAMPLES
? next.slice(next.length - LIVE_THROUGHPUT_MAX_SAMPLES)
: next;
}

/** Only growth updates the rate; idle ticks must not dilute the retained value. */
export function sampleDidGrow(samples: readonly ThroughputSample[]): boolean {
if (samples.length < 2) return false;
const newest = samples[samples.length - 1];
const previous = samples[samples.length - 2];
return newest.tokens > previous.tokens;
}

/** Endpoint deltas keep irregular sample spacing from biasing the window rate. */
export function windowedTokenRate(
samples: readonly ThroughputSample[],
): number | undefined {
const newest = samples.at(-1);
if (!newest) return undefined;
const cutoff = newest.ts - LIVE_THROUGHPUT_WINDOW_MS;
const oldest = samples.find((entry) => entry.ts >= cutoff) ?? samples[0];
const spanMs = newest.ts - oldest.ts;
if (spanMs < LIVE_THROUGHPUT_MIN_SPAN_MS) return undefined;
return calculateTokenRate(newest.tokens - oldest.tokens, spanMs);
}

/** Retains the last measured rate through silence and dims it after the threshold. */
export function retainLiveRate(
fresh: number | undefined,
remembered: { rate: number; at: number } | undefined,
now: number,
): LiveTokenRate {
if (fresh !== undefined) return { rate: fresh, stale: false };
if (!remembered) return { stale: false };
return {
rate: remembered.rate,
stale: now - remembered.at > LIVE_THROUGHPUT_STALE_MS,
};
}

export type GenerationPhase = "waiting" | "thinking" | "generating" | "tool";
export type ThroughputMessage = Pick<UiMessage, "id" | "content" | "thinking" | "status" | "timeToFirstTokenMs">;

export function generationPhase(
message: ThroughputMessage | undefined,
toolRunning: boolean,
): GenerationPhase {
if (toolRunning) return "tool";
if (message?.status !== "streaming") return "waiting";
if (message.content) return "generating";
if (message.thinking) return "thinking";
return "waiting";
}

/** Time-based smoothing gives the same response at different sampling cadences. */
export function smoothTokenRate(
previous: number | undefined,
next: number,
elapsedMs: number,
): number {
if (previous === undefined) return next;
const weight = 1 - Math.exp(-Math.max(0, elapsedMs) / 750);
return previous + weight * (next - previous);
}

export type ThroughputTracker = {
messageId?: string;
samples: ThroughputSample[];
remembered?: { rate: number; at: number };
smoothed?: { rate: number; at: number };
};

/** A new message or resumed generation starts a new window; history is display-only. */
export function advanceThroughput(
previous: ThroughputTracker,
message: ThroughputMessage | undefined,
generating: boolean,
now: number,
): { tracker: ThroughputTracker; view: LiveTokenRate } {
let tracker = { ...previous };
if (message?.id !== tracker.messageId || !generating) {
tracker = { messageId: message?.id, samples: [], remembered: tracker.remembered };
}
let fresh: number | undefined;
if (generating) {
const tokens = sampleTokensForMessage(message);
const last = tracker.samples.at(-1);
// A replaced/truncated message cannot share a baseline with the old text.
if (last && (tokens < last.tokens || now < last.ts)) {
tracker.samples = [];
tracker.smoothed = undefined;
}
tracker.samples = pushThroughputSample(tracker.samples, { ts: now, tokens });
const raw = sampleDidGrow(tracker.samples) ? windowedTokenRate(tracker.samples) : undefined;
if (raw !== undefined) {
fresh = smoothTokenRate(
tracker.smoothed?.rate, raw, now - (tracker.smoothed?.at ?? now),
);
tracker.smoothed = { rate: fresh, at: now };
tracker.remembered = { rate: fresh, at: now };
}
}
const view = retainLiveRate(fresh, tracker.remembered, now);
// Outside generation the remembered number is explicitly historical immediately.
if ((!generating || !tracker.smoothed) && view.rate !== undefined) view.stale = true;
return { tracker, view };
}

/** Thinking-only messages live in activity parts, before an answer row exists. */
export function latestGenerationMessage(entry: AssistantTurnEntry): UiMessage | undefined {
for (let index = entry.parts.length - 1; index >= 0; index--) {
const part = entry.parts[index];
if (part.kind === "message") return part.message;
for (let item = part.items.length - 1; item >= 0; item--) {
if (part.items[item].kind === "thinking") return part.items[item].message;
}
}
return undefined;
}
Loading
Loading