mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(ui): keep live stream below prompt
This commit is contained in:
@@ -413,6 +413,31 @@ describe("buildChatItems", () => {
|
||||
expect(messageRecord(requireGroup(items[1])).content).toBe("Missing timestamp.");
|
||||
});
|
||||
|
||||
it("renders an active stream after the persisted user turn it answers", () => {
|
||||
const items = buildChatItems(
|
||||
createProps({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Persisted prompt." }],
|
||||
timestamp: 2_000,
|
||||
},
|
||||
],
|
||||
stream: "Visible partial answer.",
|
||||
streamStartedAt: 1_000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(requireGroup(items[0]).role).toBe("user");
|
||||
expect(items[1]).toMatchObject({
|
||||
kind: "stream",
|
||||
text: "Visible partial answer.",
|
||||
startedAt: 2_001,
|
||||
isStreaming: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders submitted queued sends as user turns before chat.send ACK", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [{ role: "assistant", content: "Ready.", timestamp: 1 }],
|
||||
|
||||
@@ -348,6 +348,19 @@ function chatItemTimestamp(item: ChatItem): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function timestampAfterVisibleItems(items: ChatItem[], desiredTimestamp: number): number {
|
||||
const latestTimestamp = items.reduce<number | null>((latest, item) => {
|
||||
const timestamp = chatItemTimestamp(item);
|
||||
if (timestamp == null) {
|
||||
return latest;
|
||||
}
|
||||
return latest == null || timestamp > latest ? timestamp : latest;
|
||||
}, null);
|
||||
return latestTimestamp != null && desiredTimestamp <= latestTimestamp
|
||||
? latestTimestamp + 1
|
||||
: desiredTimestamp;
|
||||
}
|
||||
|
||||
function sortChatItemsByVisibleTime(items: ChatItem[]): ChatItem[] {
|
||||
return items
|
||||
.map((item, index) => ({ item, index, timestamp: chatItemTimestamp(item) }))
|
||||
@@ -647,13 +660,14 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
|
||||
const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`;
|
||||
const text = sanitizeStreamText(props.stream);
|
||||
const visibleText = trimAccumulatedStreamPrefix(text, previousAccumulatedStreamText);
|
||||
const startedAt = timestampAfterVisibleItems(items, props.streamStartedAt ?? Date.now());
|
||||
if (visibleText.length > 0) {
|
||||
if (!stripHeartbeatTokenForDisplay(visibleText).shouldSkip) {
|
||||
items.push({
|
||||
kind: "stream",
|
||||
key,
|
||||
text: visibleText,
|
||||
startedAt: props.streamStartedAt ?? Date.now(),
|
||||
startedAt,
|
||||
isStreaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2703,6 +2703,40 @@ describe("loadChatHistory retry handling", () => {
|
||||
expect(state.chatStreamStartedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("timestamps materialized streamed text after the persisted user prompt", async () => {
|
||||
const persistedUser = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "first" }],
|
||||
timestamp: 200,
|
||||
__openclaw: { seq: 1 },
|
||||
};
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
messages: [persistedUser],
|
||||
thinkingLevel: "low",
|
||||
});
|
||||
const state = createState({
|
||||
connected: true,
|
||||
client: { request } as unknown as ChatState["client"],
|
||||
chatMessages: [persistedUser],
|
||||
chatRunId: null,
|
||||
chatStream: "Partial answer before history catch-up.",
|
||||
chatStreamStartedAt: 100,
|
||||
});
|
||||
|
||||
await loadChatHistory(state);
|
||||
|
||||
expect(state.chatMessages).toHaveLength(2);
|
||||
expect(state.chatMessages[0]).toEqual(persistedUser);
|
||||
expectTextChatMessage(
|
||||
state.chatMessages[1],
|
||||
"assistant",
|
||||
"Partial answer before history catch-up.",
|
||||
);
|
||||
expect(requireRecord(state.chatMessages[1]).timestamp).toBe(201);
|
||||
expect(state.chatStream).toBeNull();
|
||||
expect(state.chatStreamStartedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("materializes orphaned segment-only assistant text before clearing caught-up tools", async () => {
|
||||
const persistedUser = {
|
||||
role: "user",
|
||||
|
||||
@@ -513,6 +513,35 @@ function insertMessageAtIndex(messages: unknown[], message: unknown, index: numb
|
||||
return [...messages.slice(0, index), message, ...messages.slice(index)];
|
||||
}
|
||||
|
||||
function timestampForInsertedVisibleStream(
|
||||
messages: unknown[],
|
||||
index: number,
|
||||
desiredTimestamp: number,
|
||||
): number {
|
||||
const previousTimestamp = messages
|
||||
.slice(0, index)
|
||||
.toReversed()
|
||||
.map(messageTimestampMs)
|
||||
.find((timestamp): timestamp is number => timestamp != null);
|
||||
const nextTimestamp = messages
|
||||
.slice(index)
|
||||
.map(messageTimestampMs)
|
||||
.find((timestamp): timestamp is number => timestamp != null);
|
||||
if (previousTimestamp != null && desiredTimestamp <= previousTimestamp) {
|
||||
const afterPrevious = previousTimestamp + 1;
|
||||
return nextTimestamp != null && afterPrevious >= nextTimestamp
|
||||
? previousTimestamp + (nextTimestamp - previousTimestamp) / 2
|
||||
: afterPrevious;
|
||||
}
|
||||
if (nextTimestamp != null && desiredTimestamp >= nextTimestamp) {
|
||||
const beforeNext = nextTimestamp - 1;
|
||||
return previousTimestamp != null && beforeNext <= previousTimestamp
|
||||
? previousTimestamp + (nextTimestamp - previousTimestamp) / 2
|
||||
: beforeNext;
|
||||
}
|
||||
return desiredTimestamp;
|
||||
}
|
||||
|
||||
function appendVisibleStreamStateMessages(
|
||||
messages: unknown[],
|
||||
state: ChatState,
|
||||
@@ -524,15 +553,16 @@ function appendVisibleStreamStateMessages(
|
||||
if (hasAssistantStreamPartReplacement([...nextMessages, ...replacementMessages], part)) {
|
||||
continue;
|
||||
}
|
||||
const streamMessage = buildAssistantStreamMessage(
|
||||
part.text,
|
||||
part.replacementText,
|
||||
part.timestamp,
|
||||
);
|
||||
const toolIndex =
|
||||
part.source === "segment"
|
||||
? currentToolStreamMessageIndex(nextMessages, state, part.toolCallId)
|
||||
: -1;
|
||||
const insertIndex = toolIndex >= 0 ? toolIndex : nextMessages.length;
|
||||
const streamMessage = buildAssistantStreamMessage(
|
||||
part.text,
|
||||
part.replacementText,
|
||||
timestampForInsertedVisibleStream(nextMessages, insertIndex, part.timestamp),
|
||||
);
|
||||
nextMessages =
|
||||
toolIndex >= 0
|
||||
? insertMessageAtIndex(nextMessages, streamMessage, toolIndex)
|
||||
|
||||
Reference in New Issue
Block a user