Files
nanobot/webui/src/components/thread/activity/activity-message-model.ts
T

115 lines
4.2 KiB
TypeScript

import {
canonicalToolTrace,
mergeToolProgressEvents,
mergeUniqueToolTraceLines,
} from "@/lib/tool-traces";
import type { UIMediaAttachment, UIMessage } from "@/lib/types";
/**
* Live tool progress is already folded into one trace message. Persisted
* transcripts can contain the same progress as adjacent start/end rows, so
* normalize both paths before rendering the activity timeline.
*/
export function coalesceActivityMessages(messages: UIMessage[]): UIMessage[] {
const normalized: UIMessage[] = [];
for (const message of messages) {
const targetIndex = findMergeTarget(normalized, message);
if (targetIndex < 0) {
normalized.push(message);
continue;
}
normalized[targetIndex] = mergeTraceMessages(normalized[targetIndex], message);
}
return normalized;
}
function findMergeTarget(messages: UIMessage[], incoming: UIMessage): number {
if (incoming.kind !== "trace") return -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const previous = messages[index];
if (previous.kind !== "trace") continue;
if (hasSharedToolCall(previous, incoming) && sameTurn(previous, incoming)) return index;
}
const adjacentIndex = messages.length - 1;
const adjacent = messages[adjacentIndex];
return canMergeAdjacentProgress(adjacent, incoming) ? adjacentIndex : -1;
}
function canMergeAdjacentProgress(
previous: UIMessage | undefined,
incoming: UIMessage,
): previous is UIMessage {
if (!previous || previous.kind !== "trace") return false;
if (!sameTurn(previous, incoming)) return false;
if (
previous.activitySegmentId
&& incoming.activitySegmentId
&& previous.activitySegmentId === incoming.activitySegmentId
) {
return true;
}
return hasSharedTrace(previous, incoming) && completesPreviousProgress(previous, incoming);
}
function mergeTraceMessages(previous: UIMessage, incoming: UIMessage): UIMessage {
const traces = mergeUniqueToolTraceLines(messageTraces(previous), messageTraces(incoming)).traces;
const toolEvents = mergeToolProgressEvents(previous.toolEvents, incoming.toolEvents ?? []);
const fileEdits = [...(previous.fileEdits ?? []), ...(incoming.fileEdits ?? [])];
const media = uniqueMedia([...(previous.media ?? []), ...(incoming.media ?? [])]);
return {
...previous,
content: traces[traces.length - 1] ?? incoming.content ?? previous.content,
traces,
...(toolEvents.length ? { toolEvents } : { toolEvents: undefined }),
...(fileEdits.length ? { fileEdits } : { fileEdits: undefined }),
...(media.length ? { media } : { media: undefined }),
isStreaming: incoming.isStreaming,
turnPhase: incoming.turnPhase ?? previous.turnPhase,
turnSeq: incoming.turnSeq ?? previous.turnSeq,
};
}
function messageTraces(message: UIMessage): string[] {
if (message.traces?.length) return message.traces;
return message.content.trim() ? [message.content] : [];
}
function hasSharedToolCall(previous: UIMessage, incoming: UIMessage): boolean {
const previousCallIds = new Set(
(previous.toolEvents ?? []).map((event) => event.call_id).filter(Boolean),
);
return (incoming.toolEvents ?? []).some((event) => (
!!event.call_id && previousCallIds.has(event.call_id)
));
}
function hasSharedTrace(previous: UIMessage, incoming: UIMessage): boolean {
const previousTraces = new Set(messageTraces(previous).map(canonicalToolTrace));
return messageTraces(incoming).some((trace) => previousTraces.has(canonicalToolTrace(trace)));
}
function completesPreviousProgress(previous: UIMessage, incoming: UIMessage): boolean {
const previousPhases = new Set((previous.toolEvents ?? []).map((event) => event.phase));
const incomingPhases = new Set((incoming.toolEvents ?? []).map((event) => event.phase));
return previousPhases.has("start") && (incomingPhases.has("end") || incomingPhases.has("error"));
}
function sameTurn(previous: UIMessage, incoming: UIMessage): boolean {
return !previous.turnId || !incoming.turnId || previous.turnId === incoming.turnId;
}
function uniqueMedia(media: UIMediaAttachment[]): UIMediaAttachment[] {
const seen = new Set<string>();
return media.filter((item) => {
const key = `${item.kind}:${item.url ?? ""}:${item.name ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}