feat(webui): polish agent output and app discovery
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user