Files
nanobot/webui/src/lib/activity-timeline.ts
T

306 lines
10 KiB
TypeScript

import type { UIMessage } from "@/lib/types";
/** A completed turn has two surfaces: one activity container and one final
* answer. An active turn temporarily preserves arrival order so visible
* Markdown never moves when a later tool starts. */
export type TurnUnit =
| {
type: "activity";
messages: UIMessage[];
turnLatencyMs?: number;
startedAtMs?: number;
}
| { type: "message"; message: UIMessage };
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.activityKind === "model" || message.content.trim().length > 0) return false;
return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
}
export function isAgentActivityMember(message: UIMessage): boolean {
return isReasoningOnlyAssistant(message) || message.kind === "trace" || message.activityKind === "model";
}
export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
const last = messages.at(-1);
if (!last || !isAgentActivityMember(last)) return false;
if (last.isStreaming || last.reasoningStreaming) return true;
const lastTurnId = last.turnId;
const previous = messages.at(-2);
// A trace without a visible answer is an unfinished turn on replay. Once a
// final assistant answer exists after it, the activity is simply history.
return !previous
|| previous.role !== "assistant"
|| isAgentActivityMember(previous)
|| previous.turnId !== lastTurnId;
}
/**
* Project completed or replayed gateway rows into the stable shape:
*
* user → [one live/completed activity surface] → [one final answer]
*
* Assistant text that is followed by another activity is an intermediate model
* segment. It remains in the activity timeline, where the renderer keeps its
* normal Markdown surface, but it never creates a second answer bubble. This
* is the same causal model used by Codex-style transcripts.
*/
export function normalizeActivityTimeline(
messages: UIMessage[],
): TurnUnit[] {
const units: TurnUnit[] = [];
let turnMessages: UIMessage[] = [];
let activeTurnId: string | undefined;
let activeTurnStartedAtMs: number | undefined;
const flushTurn = () => {
if (!turnMessages.length) {
activeTurnId = undefined;
activeTurnStartedAtMs = undefined;
return;
}
const ordered = orderMessagesByTurnSeq(turnMessages);
const lastActivityIndex = ordered.reduce(
(index, message, current) => isRawActivity(message) ? current : index,
-1,
);
const answerIndices = ordered
.map((message, index) => ({ message, index }))
.filter(({ message }) => isAssistantAnswer(message));
const finalAnswerIndex = answerIndices.at(-1)?.index;
// A replay can deliver a completed answer before a late trace row. Keep
// that answer visible, but place the late activity in the single activity
// surface before it. An answer followed by more activity is an
// intermediate model segment and stays in that surface in turn order.
const hasFinalAnswer = finalAnswerIndex !== undefined
&& (finalAnswerIndex > lastActivityIndex || ordered[finalAnswerIndex].isStreaming !== true);
const activity: UIMessage[] = [];
const answers: UIMessage[] = [];
ordered.forEach((message, index) => {
if (isRawActivity(message)) {
activity.push(message);
} else if (isAssistantAnswer(message)) {
if (message.reasoning?.trim() || message.reasoningStreaming) {
activity.push(reasoningOnlyMessageFromAnswer(message));
}
if (hasFinalAnswer && index === finalAnswerIndex) {
answers.push(stripInlineReasoning(message));
} else {
activity.push(modelActivitySnippet(message));
}
} else {
activity.push(message);
}
});
if (activity.length) {
units.push({
type: "activity",
messages: activity,
turnLatencyMs: activityTurnLatencyMs(activity, ordered),
startedAtMs: activeTurnStartedAtMs,
});
}
if (answers.length) {
units.push({ type: "message", message: mergeAssistantAnswers(answers) });
}
turnMessages = [];
activeTurnId = undefined;
activeTurnStartedAtMs = undefined;
};
for (const message of messages) {
if (message.role === "user") {
flushTurn();
units.push({ type: "message", message });
activeTurnId = message.turnId;
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
continue;
}
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) flushTurn();
if (message.turnId) activeTurnId = message.turnId;
turnMessages.push(message);
}
flushTurn();
return units;
}
/**
* Keep an in-flight turn in arrival order. Until ``turn_end`` there is no
* reliable way to know whether an assistant text segment is the final answer
* or commentary before another tool call. Reclassifying it when that tool
* arrives makes an already-visible Markdown tree jump between containers.
*
* Completed turns still use ``normalizeActivityTimeline`` and collapse into
* one audit surface plus the final answer. While the turn is active, text and
* contiguous activity phases stay in arrival order. A later tool therefore
* appends a Working surface after existing Markdown instead of reparenting it.
*/
export function projectActivityTimeline(
messages: UIMessage[],
liveTurnId?: string | null,
): TurnUnit[] {
if (liveTurnId === undefined) return normalizeActivityTimeline(messages);
const liveStart = findLiveTurnStart(messages, liveTurnId);
if (liveStart < 0) return normalizeActivityTimeline(messages);
const nextPrompt = messages.findIndex(
(message, index) => index > liveStart && message.role === "user",
);
const liveEnd = nextPrompt < 0 ? messages.length : nextPrompt;
return [
...normalizeActivityTimeline(messages.slice(0, liveStart)),
...projectLiveTurn(messages.slice(liveStart, liveEnd)),
...normalizeActivityTimeline(messages.slice(liveEnd)),
];
}
function findLiveTurnStart(messages: UIMessage[], liveTurnId: string | null): number {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role !== "user") continue;
if (liveTurnId === null || message.turnId === liveTurnId) return index;
}
return -1;
}
function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
const units: TurnUnit[] = [];
const prompt = messages[0];
const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined;
let activity: UIMessage[] = [];
if (prompt?.role === "user") units.push({ type: "message", message: prompt });
const flushActivity = () => {
if (!activity.length) return;
units.push({
type: "activity",
messages: activity,
turnLatencyMs: activityTurnLatencyMs(activity, activity),
startedAtMs,
});
activity = [];
};
for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) {
if (isRawActivity(message)) {
activity.push(message);
continue;
}
if (isAssistantAnswer(message)) {
if (message.reasoning?.trim() || message.reasoningStreaming) {
activity.push(reasoningOnlyMessageFromAnswer(message));
}
flushActivity();
units.push({ type: "message", message: stripInlineReasoning(message) });
continue;
}
activity.push(message);
}
flushActivity();
return units;
}
function isRawActivity(message: UIMessage): boolean {
return isAgentActivityMember(message);
}
function isAssistantAnswer(message: UIMessage): boolean {
return message.role === "assistant" && message.kind !== "trace" && message.content.trim().length > 0;
}
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
if (messages.length < 2 || !messages.every((message) => Number.isFinite(message.turnSeq))) {
return messages;
}
return messages
.map((message, index) => ({ message, index }))
.sort((left, right) => (left.message.turnSeq! - right.message.turnSeq!) || (left.index - right.index))
.map(({ message }) => message);
}
function mergeAssistantAnswers(answers: UIMessage[]): UIMessage {
const first = answers[0];
const last = answers.at(-1)!;
const media = answers.flatMap((message) => message.media ?? []);
const images = answers.flatMap((message) => message.images ?? []);
const merged: UIMessage = {
...first,
...last,
id: first.id,
content: answers.map((message) => message.content.trim()).filter(Boolean).join("\n\n"),
createdAt: first.createdAt,
isStreaming: answers.some((message) => message.isStreaming),
};
if (media.length) merged.media = media;
else delete merged.media;
if (images.length) merged.images = images;
else delete merged.images;
return merged;
}
function modelActivitySnippet(message: UIMessage): UIMessage {
return {
...stripInlineReasoning(message),
id: `${message.id}-activity`,
activityKind: "model",
turnPhase: "activity",
// Keep the source stream state so the activity surface can render this
// segment with the same Markdown streaming semantics as a normal answer.
isStreaming: message.isStreaming,
};
}
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
return {
id: `${message.id}-reasoning`,
role: "assistant",
content: "",
createdAt: message.createdAt,
reasoning: message.reasoning,
reasoningStreaming: message.reasoningStreaming,
isStreaming: message.reasoningStreaming,
activitySegmentId: message.activitySegmentId,
latencyMs: message.latencyMs,
turnId: message.turnId,
turnPhase: "reasoning",
turnSeq: message.turnSeq,
};
}
function stripInlineReasoning(message: UIMessage): UIMessage {
const next = { ...message };
delete next.reasoning;
delete next.reasoningStreaming;
return next;
}
function validCreatedAtMs(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function activityTurnLatencyMs(activityMessages: UIMessage[], allMessages: UIMessage[]): number | undefined {
for (let index = allMessages.length - 1; index >= 0; index -= 1) {
const latency = allMessages[index].latencyMs;
if (isValidLatency(latency)) return latency;
}
for (let index = activityMessages.length - 1; index >= 0; index -= 1) {
const latency = activityMessages[index].latencyMs;
if (isValidLatency(latency)) return latency;
}
return undefined;
}
function isValidLatency(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}