This commit is contained in:
+51
-56
@@ -81,6 +81,16 @@ import {
|
||||
type SidebarSelection,
|
||||
} from "@/lib/sidebar-selection";
|
||||
import { buildQuickQuestionRequest } from "@/lib/quick-question";
|
||||
import {
|
||||
appendStreamingDelta,
|
||||
clearStreamingAssistantTrace,
|
||||
createStreamingAssistantMetadata,
|
||||
createStreamingAttemptState,
|
||||
finalizeStreamingAssistant,
|
||||
promoteStreamingAssistantTrace,
|
||||
setStreamingAssistantSegment,
|
||||
transitionStreamingToolCall,
|
||||
} from "@/lib/chat-stream-presentation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DraftSelectionKind = "chat" | "search";
|
||||
@@ -2486,7 +2496,7 @@ export default function App() {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
name: null,
|
||||
metadata: null,
|
||||
metadata: createStreamingAssistantMetadata(),
|
||||
};
|
||||
|
||||
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||
@@ -2591,7 +2601,7 @@ export default function App() {
|
||||
|
||||
while (true) {
|
||||
let streamErrorMessage: string | null = null;
|
||||
let replayedAssistantText = "";
|
||||
let streamingAttempt = createStreamingAttemptState();
|
||||
const abortController = new AbortController();
|
||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||
|
||||
@@ -2609,45 +2619,39 @@ export default function App() {
|
||||
if (payload.chatId !== chatId) return;
|
||||
},
|
||||
onToolCall: (payload) => {
|
||||
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||
streamingAttempt = transition.attempt;
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
const messages = transition.startedNewToolCall
|
||||
? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "")
|
||||
: pendingState.messages;
|
||||
return {
|
||||
...current,
|
||||
[chatId]: {
|
||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
||||
messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
if (!payload.text) return;
|
||||
replayedAssistantText += payload.text;
|
||||
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||
const segmentText = streamingAttempt.segmentText;
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
let updated = false;
|
||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: replayedAssistantText };
|
||||
});
|
||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||
const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText);
|
||||
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||
});
|
||||
},
|
||||
onDone: (payload) => {
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
let updated = false;
|
||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: payload.text };
|
||||
});
|
||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||
const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text);
|
||||
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||
});
|
||||
},
|
||||
onError: (payload) => {
|
||||
@@ -2896,6 +2900,7 @@ export default function App() {
|
||||
const abortController = new AbortController();
|
||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||
let streamErrorMessage: string | null = null;
|
||||
let streamingAttempt = createStreamingAttemptState();
|
||||
|
||||
try {
|
||||
const baseChat = await getChat(chatId);
|
||||
@@ -2912,7 +2917,7 @@ export default function App() {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
name: null,
|
||||
metadata: null,
|
||||
metadata: createStreamingAssistantMetadata(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -2922,44 +2927,39 @@ export default function App() {
|
||||
chatId,
|
||||
{
|
||||
onToolCall: (payload) => {
|
||||
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||
streamingAttempt = transition.attempt;
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
const messages = transition.startedNewToolCall
|
||||
? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "")
|
||||
: pendingState.messages;
|
||||
return {
|
||||
...current,
|
||||
[chatId]: {
|
||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
||||
messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
if (!payload.text) return;
|
||||
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||
const segmentText = streamingAttempt.segmentText;
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
let updated = false;
|
||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: message.content + payload.text };
|
||||
});
|
||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||
const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText);
|
||||
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||
});
|
||||
},
|
||||
onDone: (payload) => {
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
let updated = false;
|
||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: payload.text };
|
||||
});
|
||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||
const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text);
|
||||
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||
});
|
||||
},
|
||||
onError: (payload) => {
|
||||
@@ -3211,7 +3211,7 @@ export default function App() {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
name: null,
|
||||
metadata: null,
|
||||
metadata: createStreamingAssistantMetadata(),
|
||||
};
|
||||
|
||||
quickQuestionAbortRef.current?.abort();
|
||||
@@ -3225,6 +3225,7 @@ export default function App() {
|
||||
setIsQuickQuestionSending(true);
|
||||
|
||||
let streamErrorMessage: string | null = null;
|
||||
let streamingAttempt = createStreamingAttemptState();
|
||||
|
||||
try {
|
||||
await runQuickQuestionStream(
|
||||
@@ -3235,33 +3236,26 @@ export default function App() {
|
||||
}),
|
||||
{
|
||||
onToolCall: (payload) => {
|
||||
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||
streamingAttempt = transition.attempt;
|
||||
setQuickQuestionMessages((current) => {
|
||||
return upsertOptimisticToolMessage(current, payload, "temp-assistant-quick-");
|
||||
const messages = transition.startedNewToolCall
|
||||
? promoteStreamingAssistantTrace(current, "temp-assistant-quick-", transition.traceText ?? "")
|
||||
: current;
|
||||
return upsertOptimisticToolMessage(messages, payload, "temp-assistant-quick-");
|
||||
});
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
if (!payload.text) return;
|
||||
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||
const segmentText = streamingAttempt.segmentText;
|
||||
setQuickQuestionMessages((current) => {
|
||||
let updated = false;
|
||||
const nextMessages = current.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: message.content + payload.text };
|
||||
});
|
||||
return updated ? nextMessages : current;
|
||||
return setStreamingAssistantSegment(current, "temp-assistant-quick-", segmentText);
|
||||
});
|
||||
},
|
||||
onDone: (payload) => {
|
||||
setQuickQuestionMessages((current) => {
|
||||
let updated = false;
|
||||
const nextMessages = current.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: payload.text };
|
||||
});
|
||||
return updated ? nextMessages : current;
|
||||
return finalizeStreamingAssistant(current, "temp-assistant-quick-", payload.text);
|
||||
});
|
||||
},
|
||||
onError: (payload) => {
|
||||
@@ -3276,6 +3270,7 @@ export default function App() {
|
||||
}
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return;
|
||||
setQuickQuestionMessages((current) => clearStreamingAssistantTrace(current, "temp-assistant-quick-"));
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren, JSX } from "preact";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||
|
||||
@@ -396,8 +397,144 @@ function ToolCallStack({
|
||||
);
|
||||
}
|
||||
|
||||
type RenderedThinkingTrace = {
|
||||
text: string;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
const THINKING_TRACE_EXIT_MS = 220;
|
||||
|
||||
export function StreamingAssistantPresentation({ message, isSending }: { message: Message; isSending: boolean }) {
|
||||
const metadata = asStreamingAssistantMetadata(message.metadata);
|
||||
const traceText = metadata?.ephemeralTrace?.trim() || null;
|
||||
const traceRevision = metadata?.traceRevision ?? 0;
|
||||
const [activeTrace, setActiveTrace] = useState<RenderedThinkingTrace | null>(() =>
|
||||
traceText ? { text: traceText, revision: traceRevision } : null
|
||||
);
|
||||
const [outgoingTrace, setOutgoingTrace] = useState<RenderedThinkingTrace | null>(null);
|
||||
const [reservedTraceHeight, setReservedTraceHeight] = useState(0);
|
||||
const activeTraceRef = useRef<HTMLDivElement | null>(null);
|
||||
const outgoingTraceRef = useRef<HTMLDivElement | null>(null);
|
||||
const exitTimerRef = useRef<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const isSameTrace =
|
||||
(!activeTrace && !traceText) ||
|
||||
(activeTrace?.text === traceText && activeTrace?.revision === traceRevision && Boolean(activeTrace) === Boolean(traceText));
|
||||
if (isSameTrace) return;
|
||||
|
||||
if (exitTimerRef.current !== null) {
|
||||
window.clearTimeout(exitTimerRef.current);
|
||||
exitTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (activeTrace) {
|
||||
setOutgoingTrace(activeTrace);
|
||||
exitTimerRef.current = window.setTimeout(() => {
|
||||
setOutgoingTrace(null);
|
||||
exitTimerRef.current = null;
|
||||
}, THINKING_TRACE_EXIT_MS);
|
||||
} else {
|
||||
setOutgoingTrace(null);
|
||||
}
|
||||
|
||||
setActiveTrace(traceText ? { text: traceText, revision: traceRevision } : null);
|
||||
}, [traceRevision, traceText]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (exitTimerRef.current !== null) window.clearTimeout(exitTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const traceElements = [activeTraceRef.current, outgoingTraceRef.current].filter(
|
||||
(element): element is HTMLDivElement => element !== null
|
||||
);
|
||||
if (!traceElements.length) return;
|
||||
|
||||
const reserveMeasuredHeight = () => {
|
||||
const measuredHeight = Math.max(...traceElements.map((element) => element.getBoundingClientRect().height));
|
||||
if (measuredHeight > 0) {
|
||||
setReservedTraceHeight((current) => Math.max(current, Math.ceil(measuredHeight)));
|
||||
}
|
||||
};
|
||||
|
||||
reserveMeasuredHeight();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
|
||||
const observer = new ResizeObserver(reserveMeasuredHeight);
|
||||
for (const element of traceElements) observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [activeTrace, outgoingTrace]);
|
||||
|
||||
const hasAnswer = message.content.trim().length > 0;
|
||||
const showTyping = isSending && !hasAnswer && !traceText && !activeTrace && !outgoingTrace;
|
||||
const phase = hasAnswer ? "answer" : traceText || activeTrace || outgoingTrace ? "trace" : "pending";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="streaming-assistant-slot"
|
||||
data-streaming-assistant="true"
|
||||
data-streaming-phase={phase}
|
||||
style={reservedTraceHeight ? { minHeight: `${reservedTraceHeight}px` } : undefined}
|
||||
>
|
||||
{outgoingTrace ? (
|
||||
<div
|
||||
ref={outgoingTraceRef}
|
||||
key={`outgoing-trace-${outgoingTrace.revision}`}
|
||||
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-exit"
|
||||
data-thinking-trace="true"
|
||||
data-thinking-trace-state="outgoing"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{outgoingTrace.text}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{activeTrace ? (
|
||||
<div
|
||||
ref={activeTraceRef}
|
||||
key={`active-trace-${activeTrace.revision}`}
|
||||
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-enter"
|
||||
data-thinking-trace="true"
|
||||
data-thinking-trace-state="active"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{activeTrace.text}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{activeTrace && !hasAnswer ? (
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
Assistant is working
|
||||
</span>
|
||||
) : null}
|
||||
{hasAnswer ? (
|
||||
<div className="streaming-assistant-layer streaming-assistant-answer" data-streaming-answer="true">
|
||||
<MarkdownContent
|
||||
markdown={message.content}
|
||||
openLinksInNewTab
|
||||
className="leading-[1.82] text-violet-50 [&_a]:text-inherit [&_a]:underline"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showTyping ? (
|
||||
<span
|
||||
className="streaming-assistant-layer inline-flex items-center gap-1"
|
||||
data-streaming-pending="true"
|
||||
aria-label="Assistant is typing"
|
||||
role="status"
|
||||
>
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
|
||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
|
||||
@@ -457,6 +594,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
}
|
||||
|
||||
const isUser = message.role === "user";
|
||||
const streamingAssistantMetadata = asStreamingAssistantMetadata(message.metadata);
|
||||
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
||||
const attachments = getMessageAttachments(message.metadata);
|
||||
return (
|
||||
@@ -475,7 +613,9 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
}
|
||||
>
|
||||
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
||||
{isPendingAssistant ? (
|
||||
{streamingAssistantMetadata ? (
|
||||
<StreamingAssistantPresentation message={message} isSending={isSending} />
|
||||
) : isPendingAssistant ? (
|
||||
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||
|
||||
@@ -321,6 +321,97 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
.streaming-assistant-slot {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 1.82em;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.streaming-assistant-layer {
|
||||
grid-area: 1 / 1;
|
||||
min-width: 0;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.ephemeral-thinking-trace {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: hsl(252 35% 88% / 0.8);
|
||||
font-style: italic;
|
||||
opacity: 0.78;
|
||||
filter: saturate(0.72);
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
.ephemeral-thinking-trace-enter {
|
||||
animation: ephemeral-thinking-trace-enter 200ms cubic-bezier(0.2, 0.78, 0.24, 1) both;
|
||||
}
|
||||
|
||||
.ephemeral-thinking-trace-exit {
|
||||
pointer-events: none;
|
||||
animation: ephemeral-thinking-trace-exit 220ms cubic-bezier(0.4, 0, 0.8, 0.28) both;
|
||||
}
|
||||
|
||||
.streaming-assistant-answer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
animation: streaming-assistant-answer-enter 180ms cubic-bezier(0.2, 0.78, 0.24, 1) both;
|
||||
}
|
||||
|
||||
@keyframes ephemeral-thinking-trace-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: saturate(0.72) blur(1px);
|
||||
transform: translate3d(0, 0.32rem, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0.78;
|
||||
filter: saturate(0.72) blur(0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ephemeral-thinking-trace-exit {
|
||||
from {
|
||||
opacity: 0.78;
|
||||
filter: saturate(0.72) blur(0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
filter: saturate(0.72) blur(1px);
|
||||
transform: translate3d(0, -0.32rem, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes streaming-assistant-answer-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0.18rem, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ephemeral-thinking-trace-enter,
|
||||
.ephemeral-thinking-trace-exit,
|
||||
.streaming-assistant-answer {
|
||||
animation: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ephemeral-thinking-trace-exit {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.md-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Message, ToolCallEvent } from "@/lib/api";
|
||||
|
||||
export const STREAMING_ASSISTANT_METADATA_KIND = "streaming_assistant";
|
||||
|
||||
export type StreamingAssistantMetadata = {
|
||||
kind: typeof STREAMING_ASSISTANT_METADATA_KIND;
|
||||
ephemeralTrace: string | null;
|
||||
traceRevision: number;
|
||||
};
|
||||
|
||||
export type StreamingAttemptState = {
|
||||
segmentText: string;
|
||||
initiatedToolCallIds: string[];
|
||||
};
|
||||
|
||||
export type StreamingToolCallTransition = {
|
||||
attempt: StreamingAttemptState;
|
||||
startedNewToolCall: boolean;
|
||||
traceText: string | null;
|
||||
};
|
||||
|
||||
export function createStreamingAssistantMetadata(): StreamingAssistantMetadata {
|
||||
return {
|
||||
kind: STREAMING_ASSISTANT_METADATA_KIND,
|
||||
ephemeralTrace: null,
|
||||
traceRevision: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function asStreamingAssistantMetadata(value: unknown): StreamingAssistantMetadata | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.kind !== STREAMING_ASSISTANT_METADATA_KIND) return null;
|
||||
|
||||
return {
|
||||
kind: STREAMING_ASSISTANT_METADATA_KIND,
|
||||
ephemeralTrace: typeof record.ephemeralTrace === "string" ? record.ephemeralTrace : null,
|
||||
traceRevision:
|
||||
typeof record.traceRevision === "number" && Number.isFinite(record.traceRevision)
|
||||
? Math.max(0, Math.floor(record.traceRevision))
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStreamingAttemptState(): StreamingAttemptState {
|
||||
return {
|
||||
segmentText: "",
|
||||
initiatedToolCallIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function appendStreamingDelta(attempt: StreamingAttemptState, delta: string): StreamingAttemptState {
|
||||
if (!delta) return attempt;
|
||||
return {
|
||||
...attempt,
|
||||
segmentText: attempt.segmentText + delta,
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionStreamingToolCall(
|
||||
attempt: StreamingAttemptState,
|
||||
event: Pick<ToolCallEvent, "toolCallId" | "status">
|
||||
): StreamingToolCallTransition {
|
||||
if (event.status !== "initiated" || attempt.initiatedToolCallIds.includes(event.toolCallId)) {
|
||||
return { attempt, startedNewToolCall: false, traceText: null };
|
||||
}
|
||||
|
||||
const traceText = attempt.segmentText.trim();
|
||||
return {
|
||||
attempt: {
|
||||
segmentText: "",
|
||||
initiatedToolCallIds: attempt.initiatedToolCallIds.concat(event.toolCallId),
|
||||
},
|
||||
startedNewToolCall: true,
|
||||
traceText: traceText || null,
|
||||
};
|
||||
}
|
||||
|
||||
function updateStreamingAssistant(
|
||||
messages: Message[],
|
||||
assistantMessagePrefix: string,
|
||||
update: (message: Message, metadata: StreamingAssistantMetadata) => Message
|
||||
) {
|
||||
let didUpdate = false;
|
||||
const nextMessages = messages.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith(assistantMessagePrefix);
|
||||
if (!isTarget) return message;
|
||||
didUpdate = true;
|
||||
return update(message, asStreamingAssistantMetadata(message.metadata) ?? createStreamingAssistantMetadata());
|
||||
});
|
||||
return didUpdate ? nextMessages : messages;
|
||||
}
|
||||
|
||||
export function setStreamingAssistantSegment(messages: Message[], assistantMessagePrefix: string, segmentText: string) {
|
||||
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||
...message,
|
||||
content: segmentText,
|
||||
metadata: {
|
||||
...metadata,
|
||||
ephemeralTrace: null,
|
||||
} satisfies StreamingAssistantMetadata,
|
||||
}));
|
||||
}
|
||||
|
||||
export function promoteStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string, traceText: string) {
|
||||
const normalizedTrace = traceText.trim();
|
||||
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||
...message,
|
||||
content: "",
|
||||
metadata: normalizedTrace
|
||||
? ({
|
||||
...metadata,
|
||||
ephemeralTrace: normalizedTrace,
|
||||
traceRevision: metadata.traceRevision + 1,
|
||||
} satisfies StreamingAssistantMetadata)
|
||||
: metadata,
|
||||
}));
|
||||
}
|
||||
|
||||
export function finalizeStreamingAssistant(messages: Message[], assistantMessagePrefix: string, finalText: string) {
|
||||
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||
...message,
|
||||
content: finalText,
|
||||
metadata: {
|
||||
...metadata,
|
||||
ephemeralTrace: null,
|
||||
} satisfies StreamingAssistantMetadata,
|
||||
}));
|
||||
}
|
||||
|
||||
export function clearStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string) {
|
||||
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||
...message,
|
||||
metadata: {
|
||||
...metadata,
|
||||
ephemeralTrace: null,
|
||||
} satisfies StreamingAssistantMetadata,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
appendStreamingDelta,
|
||||
asStreamingAssistantMetadata,
|
||||
clearStreamingAssistantTrace,
|
||||
createStreamingAssistantMetadata,
|
||||
createStreamingAttemptState,
|
||||
finalizeStreamingAssistant,
|
||||
promoteStreamingAssistantTrace,
|
||||
setStreamingAssistantSegment,
|
||||
transitionStreamingToolCall,
|
||||
} from "../src/lib/chat-stream-presentation.ts";
|
||||
|
||||
function assistantMessage(content = "") {
|
||||
return {
|
||||
id: "temp-assistant-test",
|
||||
createdAt: "2026-08-30T00:00:00.000Z",
|
||||
role: "assistant",
|
||||
content,
|
||||
name: null,
|
||||
metadata: createStreamingAssistantMetadata(),
|
||||
};
|
||||
}
|
||||
|
||||
function metadata(messages) {
|
||||
return asStreamingAssistantMetadata(messages.at(-1)?.metadata);
|
||||
}
|
||||
|
||||
test("a tool boundary promotes the current segment and the next delta starts the answer in its place", () => {
|
||||
let messages = [assistantMessage()];
|
||||
let attempt = appendStreamingDelta(createStreamingAttemptState(), "I'll search for that.");
|
||||
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
|
||||
|
||||
const toolTransition = transitionStreamingToolCall(attempt, {
|
||||
toolCallId: "search-1",
|
||||
status: "initiated",
|
||||
});
|
||||
attempt = toolTransition.attempt;
|
||||
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", toolTransition.traceText ?? "");
|
||||
|
||||
assert.equal(messages.at(-1).content, "");
|
||||
assert.deepEqual(metadata(messages), {
|
||||
kind: "streaming_assistant",
|
||||
ephemeralTrace: "I'll search for that.",
|
||||
traceRevision: 1,
|
||||
});
|
||||
|
||||
attempt = appendStreamingDelta(attempt, "Here is the answer");
|
||||
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
|
||||
|
||||
assert.equal(messages.at(-1).content, "Here is the answer");
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, null);
|
||||
|
||||
messages = finalizeStreamingAssistant(messages, "temp-assistant-", "Here is the answer.");
|
||||
assert.equal(messages.at(-1).content, "Here is the answer.");
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, null);
|
||||
});
|
||||
|
||||
test("a later tool round replaces the previous trace and parallel initiated calls do not erase it", () => {
|
||||
let messages = [assistantMessage("First trace")];
|
||||
let attempt = appendStreamingDelta(createStreamingAttemptState(), "First trace");
|
||||
let transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-1", status: "initiated" });
|
||||
attempt = transition.attempt;
|
||||
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
|
||||
|
||||
attempt = appendStreamingDelta(attempt, "I need one more lookup.");
|
||||
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
|
||||
transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-2", status: "initiated" });
|
||||
attempt = transition.attempt;
|
||||
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
|
||||
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, "I need one more lookup.");
|
||||
assert.equal(metadata(messages)?.traceRevision, 2);
|
||||
|
||||
transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-3", status: "initiated" });
|
||||
assert.equal(transition.startedNewToolCall, true);
|
||||
assert.equal(transition.traceText, null);
|
||||
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
|
||||
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, "I need one more lookup.");
|
||||
assert.equal(metadata(messages)?.traceRevision, 2);
|
||||
});
|
||||
|
||||
test("terminal and duplicate tool events never promote or reset the in-progress segment", () => {
|
||||
const initial = appendStreamingDelta(createStreamingAttemptState(), "candidate");
|
||||
const completed = transitionStreamingToolCall(initial, { toolCallId: "tool-1", status: "completed" });
|
||||
|
||||
assert.equal(completed.startedNewToolCall, false);
|
||||
assert.strictEqual(completed.attempt, initial);
|
||||
|
||||
const initiated = transitionStreamingToolCall(initial, { toolCallId: "tool-1", status: "initiated" });
|
||||
const withNextSegment = appendStreamingDelta(initiated.attempt, "next segment");
|
||||
const duplicate = transitionStreamingToolCall(withNextSegment, { toolCallId: "tool-1", status: "initiated" });
|
||||
|
||||
assert.equal(duplicate.startedNewToolCall, false);
|
||||
assert.strictEqual(duplicate.attempt, withNextSegment);
|
||||
assert.equal(duplicate.attempt.segmentText, "next segment");
|
||||
});
|
||||
|
||||
test("a fresh per-attempt accumulator can replay and reclassify an already-seen tool round", () => {
|
||||
let messages = [assistantMessage("stale partial answer")];
|
||||
let replayAttempt = appendStreamingDelta(createStreamingAttemptState(), "Replayed trace");
|
||||
messages = setStreamingAssistantSegment(messages, "temp-assistant-", replayAttempt.segmentText);
|
||||
|
||||
const replayedTool = transitionStreamingToolCall(replayAttempt, { toolCallId: "same-tool-id", status: "initiated" });
|
||||
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", replayedTool.traceText ?? "");
|
||||
|
||||
assert.equal(replayedTool.startedNewToolCall, true);
|
||||
assert.equal(messages.at(-1).content, "");
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, "Replayed trace");
|
||||
});
|
||||
|
||||
test("message helpers preserve identity when no matching temporary assistant exists", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "persisted-assistant",
|
||||
createdAt: "2026-08-30T00:00:00.000Z",
|
||||
role: "assistant",
|
||||
content: "Persisted",
|
||||
name: null,
|
||||
metadata: null,
|
||||
},
|
||||
];
|
||||
|
||||
assert.strictEqual(setStreamingAssistantSegment(messages, "temp-assistant-", "new"), messages);
|
||||
assert.strictEqual(promoteStreamingAssistantTrace(messages, "temp-assistant-", "trace"), messages);
|
||||
assert.strictEqual(finalizeStreamingAssistant(messages, "temp-assistant-", "done"), messages);
|
||||
assert.strictEqual(clearStreamingAssistantTrace(messages, "temp-assistant-"), messages);
|
||||
});
|
||||
|
||||
test("a terminal error clears the ephemeral trace without discarding a partial answer", () => {
|
||||
let messages = [
|
||||
{
|
||||
...assistantMessage("Partial answer"),
|
||||
metadata: {
|
||||
...createStreamingAssistantMetadata(),
|
||||
ephemeralTrace: "Still checking one source.",
|
||||
traceRevision: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
messages = clearStreamingAssistantTrace(messages, "temp-assistant-");
|
||||
|
||||
assert.equal(messages.at(-1).content, "Partial answer");
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, null);
|
||||
});
|
||||
Reference in New Issue
Block a user