Smooth WebUI streaming with state-driven viewport motion (#4696)
This commit is contained in:
@@ -71,6 +71,7 @@ const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||
|
||||
const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>();
|
||||
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
const clockTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function activeLocale(locale?: string): string {
|
||||
return locale || i18n.resolvedLanguage || i18n.language || currentLocale();
|
||||
@@ -95,6 +96,25 @@ function dateTimeFormatter(locale: string): Intl.DateTimeFormat {
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function clockTimeFormatter(locale: string): Intl.DateTimeFormat {
|
||||
const existing = clockTimeFormatters.get(locale);
|
||||
if (existing) return existing;
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
clockTimeFormatters.set(locale, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function isSameLocalCalendarDay(left: Date, right: Date): boolean {
|
||||
return (
|
||||
left.getFullYear() === right.getFullYear()
|
||||
&& left.getMonth() === right.getMonth()
|
||||
&& left.getDate() === right.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export function relativeTime(
|
||||
value: string | number | null | undefined,
|
||||
locale?: string,
|
||||
@@ -120,6 +140,22 @@ export function fmtDateTime(
|
||||
return date ? dateTimeFormatter(activeLocale(locale)).format(date) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a completion timestamp in the browser's local timezone.
|
||||
* Today's messages stay compact; older messages include their date for orientation.
|
||||
*/
|
||||
export function formatMessageEndTime(
|
||||
value: number | null | undefined,
|
||||
locale?: string,
|
||||
): string {
|
||||
const date = parseDate(value);
|
||||
if (!date) return "";
|
||||
const loc = activeLocale(locale);
|
||||
return isSameLocalCalendarDay(date, new Date())
|
||||
? clockTimeFormatter(loc).format(date)
|
||||
: dateTimeFormatter(loc).format(date);
|
||||
}
|
||||
|
||||
/** Human-readable turn duration (wall-clock), locale-aware via ``Intl`` (seconds/minutes). */
|
||||
export function formatTurnLatency(ms: number, locale?: string): string {
|
||||
const loc = activeLocale(locale);
|
||||
|
||||
@@ -24,6 +24,44 @@ export function normalizeLegacyLongTaskMessages(messages: UIMessage[]): UIMessag
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay timestamps an assistant row when its first output is recorded, while
|
||||
* latency covers the whole turn. Derive the end from the matching user start
|
||||
* so the displayed time cannot double-count the pre-output interval.
|
||||
*/
|
||||
function deriveAssistantCompletionTimes(messages: UIMessage[]): UIMessage[] {
|
||||
const userStartedAtByTurn = new Map<string, number>();
|
||||
let latestUserStartedAt: number | undefined;
|
||||
|
||||
return messages.map((message) => {
|
||||
if (message.role === "user") {
|
||||
if (Number.isFinite(message.createdAt)) {
|
||||
latestUserStartedAt = message.createdAt;
|
||||
if (message.turnId) userStartedAtByTurn.set(message.turnId, message.createdAt);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
if (
|
||||
message.role !== "assistant"
|
||||
|| message.kind === "trace"
|
||||
|| message.completedAt !== undefined
|
||||
|| message.latencyMs === undefined
|
||||
|| !Number.isFinite(message.latencyMs)
|
||||
|| message.latencyMs < 0
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const startedAt = message.turnId
|
||||
? userStartedAtByTurn.get(message.turnId)
|
||||
: message.source
|
||||
? undefined
|
||||
: latestUserStartedAt;
|
||||
if (startedAt === undefined) return message;
|
||||
return { ...message, completedAt: startedAt + message.latencyMs };
|
||||
});
|
||||
}
|
||||
|
||||
export function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
||||
const normalized = scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
||||
const hiddenTurns = new Set(normalized.flatMap((message) => (
|
||||
@@ -31,10 +69,11 @@ export function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
||||
? [message.turnId]
|
||||
: []
|
||||
)));
|
||||
return normalized.filter((message) => (
|
||||
const visible = normalized.filter((message) => (
|
||||
!isSystemCommandTurnId(message.turnId)
|
||||
&& (!message.turnId || !hiddenTurns.has(message.turnId))
|
||||
&& !(message.role === "user" && isModelCommandText(message.content))
|
||||
&& !(message.role === "assistant" && isModelCommandResponseText(message.content))
|
||||
));
|
||||
return deriveAssistantCompletionTimes(visible);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface UIMessage {
|
||||
reasoningStreaming?: boolean;
|
||||
/** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */
|
||||
latencyMs?: number;
|
||||
/** Client epoch milliseconds when the definitive ``turn_end`` was received. */
|
||||
completedAt?: number;
|
||||
/** Lightweight provenance for proactive assistant messages. */
|
||||
source?: UIMessageSource;
|
||||
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
|
||||
|
||||
Reference in New Issue
Block a user