291 lines
8.4 KiB
TypeScript
291 lines
8.4 KiB
TypeScript
import type { UIMessage } from "@/lib/types";
|
|
|
|
export type TurnUnit =
|
|
| {
|
|
type: "activity";
|
|
messages: UIMessage[];
|
|
turnLatencyMs?: number;
|
|
startedAtMs?: number;
|
|
}
|
|
| { type: "message"; message: UIMessage };
|
|
|
|
interface NormalizeActivityTimelineOptions {
|
|
preserveTrailingActivity?: boolean;
|
|
}
|
|
|
|
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
|
|
if (message.role !== "assistant" || message.kind === "trace") return false;
|
|
if (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";
|
|
}
|
|
|
|
export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
|
|
if (messages.length === 0) return false;
|
|
const last = messages[messages.length - 1];
|
|
if (!isAgentActivityMember(last)) return false;
|
|
|
|
let trailingStart = messages.length - 1;
|
|
while (
|
|
trailingStart > 0
|
|
&& isAgentActivityMember(messages[trailingStart - 1])
|
|
) {
|
|
trailingStart -= 1;
|
|
}
|
|
|
|
const trailing = messages.slice(trailingStart);
|
|
if (trailing.some((message) => message.isStreaming || message.reasoningStreaming)) {
|
|
return true;
|
|
}
|
|
|
|
const previous = messages[trailingStart - 1];
|
|
if (!previous || previous.role !== "assistant" || isAgentActivityMember(previous)) {
|
|
return true;
|
|
}
|
|
|
|
const trailingTurnIds = new Set(
|
|
trailing
|
|
.map((message) => message.turnId)
|
|
.filter((turnId): turnId is string => typeof turnId === "string" && turnId.length > 0),
|
|
);
|
|
if (!previous.turnId) return trailingTurnIds.size > 0;
|
|
return trailingTurnIds.size > 0 && !trailingTurnIds.has(previous.turnId);
|
|
}
|
|
|
|
export function normalizeActivityTimeline(
|
|
messages: UIMessage[],
|
|
options: NormalizeActivityTimelineOptions = {},
|
|
): TurnUnit[] {
|
|
const units: TurnUnit[] = [];
|
|
let turnMessages: UIMessage[] = [];
|
|
let activeTurnId: string | undefined;
|
|
let activeTurnStartedAtMs: number | undefined;
|
|
|
|
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => {
|
|
if (turnMessages.length === 0) {
|
|
activeTurnId = undefined;
|
|
return;
|
|
}
|
|
|
|
const turnUnits: TurnUnit[] = [];
|
|
const turnStartedAtMs = activeTurnStartedAtMs;
|
|
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages);
|
|
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages);
|
|
let visibleIndex = 0;
|
|
let activityMessages: UIMessage[] = [];
|
|
|
|
const flushActivityMessages = () => {
|
|
if (!activityMessages.length) return;
|
|
pushActivityUnits(
|
|
turnUnits,
|
|
activityMessages,
|
|
visibleMessages.slice(visibleIndex),
|
|
turnStartedAtMs,
|
|
);
|
|
activityMessages = [];
|
|
};
|
|
|
|
for (const message of orderedTurnMessages) {
|
|
if (isAgentActivityMember(message)) {
|
|
activityMessages.push(message);
|
|
continue;
|
|
}
|
|
|
|
if (assistantHasInlineReasoning(message)) {
|
|
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
|
|
flushActivityMessages();
|
|
turnUnits.push({ type: "message", message: stripInlineReasoning(message) });
|
|
visibleIndex += 1;
|
|
continue;
|
|
}
|
|
|
|
flushActivityMessages();
|
|
turnUnits.push({ type: "message", message });
|
|
visibleIndex += 1;
|
|
}
|
|
|
|
flushActivityMessages();
|
|
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
|
|
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(options);
|
|
return units;
|
|
}
|
|
|
|
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) => {
|
|
const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0);
|
|
return bySeq || left.index - right.index;
|
|
})
|
|
.map(({ message }) => message);
|
|
}
|
|
|
|
function normalizeCompletedTurnUnits(
|
|
turnUnits: TurnUnit[],
|
|
options: NormalizeActivityTimelineOptions,
|
|
): TurnUnit[] {
|
|
if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits;
|
|
if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits;
|
|
|
|
let trailingStart = turnUnits.length - 1;
|
|
while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") {
|
|
trailingStart -= 1;
|
|
}
|
|
|
|
const previous = turnUnits[trailingStart - 1];
|
|
if (
|
|
!previous
|
|
|| previous.type !== "message"
|
|
|| previous.message.role !== "assistant"
|
|
) {
|
|
return turnUnits;
|
|
}
|
|
|
|
return [
|
|
...turnUnits.slice(0, trailingStart - 1),
|
|
...turnUnits.slice(trailingStart),
|
|
previous,
|
|
];
|
|
}
|
|
|
|
function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] {
|
|
const visibleMessages: UIMessage[] = [];
|
|
for (const message of messages) {
|
|
if (isAgentActivityMember(message)) continue;
|
|
visibleMessages.push(assistantHasInlineReasoning(message) ? stripInlineReasoning(message) : message);
|
|
}
|
|
return visibleMessages;
|
|
}
|
|
|
|
function validCreatedAtMs(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
}
|
|
|
|
function pushActivityUnits(
|
|
units: TurnUnit[],
|
|
activityMessages: UIMessage[],
|
|
visibleMessages: UIMessage[],
|
|
startedAtMs?: number,
|
|
) {
|
|
let runMessages: UIMessage[] = [];
|
|
let runBucket: "file" | "other" | undefined;
|
|
let runSegmentId: string | undefined;
|
|
|
|
const flushRun = () => {
|
|
if (!runMessages.length) return;
|
|
units.push({
|
|
type: "activity",
|
|
messages: runMessages,
|
|
turnLatencyMs: activityTurnLatencyMs(runMessages, visibleMessages),
|
|
startedAtMs,
|
|
});
|
|
runMessages = [];
|
|
runBucket = undefined;
|
|
runSegmentId = undefined;
|
|
};
|
|
|
|
for (const message of activityMessages) {
|
|
const bucket = isFileEditActivityMessage(message) ? "file" : "other";
|
|
const segmentId = message.activitySegmentId;
|
|
const segmentChanged =
|
|
bucket === "file"
|
|
&& runBucket === "file"
|
|
&& !!runSegmentId
|
|
&& !!segmentId
|
|
&& runSegmentId !== segmentId;
|
|
if ((runBucket && bucket !== runBucket) || segmentChanged) {
|
|
flushRun();
|
|
}
|
|
runBucket = bucket;
|
|
if (segmentId) runSegmentId = segmentId;
|
|
runMessages.push(message);
|
|
}
|
|
|
|
flushRun();
|
|
}
|
|
|
|
function isFileEditActivityMessage(message: UIMessage): boolean {
|
|
return message.kind === "trace" && !!message.fileEdits?.length;
|
|
}
|
|
|
|
function assistantHasInlineReasoning(message: UIMessage): boolean {
|
|
return (
|
|
message.role === "assistant"
|
|
&& message.kind !== "trace"
|
|
&& message.content.trim().length > 0
|
|
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming)
|
|
);
|
|
}
|
|
|
|
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 activityTurnLatencyMs(activityMessages: UIMessage[], visibleMessages: UIMessage[]): number | undefined {
|
|
for (let i = visibleMessages.length - 1; i >= 0; i -= 1) {
|
|
const latency = visibleMessages[i].latencyMs;
|
|
if (isValidLatency(latency)) return latency;
|
|
}
|
|
for (let i = activityMessages.length - 1; i >= 0; i -= 1) {
|
|
const latency = activityMessages[i].latencyMs;
|
|
if (isValidLatency(latency)) return latency;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function isValidLatency(value: unknown): value is number {
|
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
}
|