Spaces:
Paused
Paused
File size: 5,304 Bytes
fae625a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | import {
MessageUpdateStatus,
MessageUpdateType,
type MessageStatusUpdate,
type MessageUpdate,
} from "$lib/types/MessageUpdate";
import type { Message } from "$lib/types/Message";
import type { StreamingMode } from "$lib/types/Settings";
import { mergeFinalAnswerContent } from "./mergeFinalAnswer";
export interface ConsumeContext {
streamingMode: StreamingMode;
/** In raw mode, how long content may buffer before a flush (ms). */
maxUpdateTime: number;
isAborted: () => boolean;
onAbort: () => void;
/** Fired on every stream token; the caller uses it to clear its `pending` flag once. */
onStreamStart: () => void;
onTitle: (title: string) => void;
onError: (update: MessageStatusUpdate) => void;
}
/**
* Apply a stream of MessageUpdates to the reactive $state `message` the UI renders.
* Shared by the two ways updates arrive — the POST that starts a run and the reattach
* stream that resumes one — so both render identically from one copy of the rules.
*/
export async function consumeMessageUpdates(
iterator: AsyncGenerator<MessageUpdate>,
message: Message,
ctx: ConsumeContext
): Promise<void> {
let buffer = "";
let lastUpdateTime = new Date();
let frameFlushScheduled = false;
// Local authoritative copy of message.updates during streaming. Assigning the
// reactive field on every chunk re-triggers the full markdown derivation; buffer
// here and flush on the same cadence as content. Readers below must use the
// buffer, not message.updates, or they see stale data between flushes.
let updatesBuffer: MessageUpdate[] = message.updates ?? [];
let updatesDirty = false;
const flushBuffer = (currentTime: Date) => {
if (buffer.length === 0 && !updatesDirty) return;
if (buffer.length > 0) {
message.content += buffer;
buffer = "";
lastUpdateTime = currentTime;
}
if (updatesDirty) {
message.updates = updatesBuffer;
updatesDirty = false;
}
};
const scheduleFrameFlush = () => {
if (frameFlushScheduled) return;
frameFlushScheduled = true;
const flush = () => {
frameFlushScheduled = false;
flushBuffer(new Date());
};
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(flush);
} else {
setTimeout(flush, 0);
}
};
for await (const update of iterator) {
if (ctx.isAborted()) {
// Commit anything still buffered: the navigation-abort path skips the
// post-stream refresh, so a dropped buffer would be lost from the UI.
flushBuffer(new Date());
ctx.onAbort();
return;
}
// Remove null padding added server-side (see the keylogging note there).
if (update.type === MessageUpdateType.Stream) {
update.token = update.token.replaceAll("\0", "");
}
const isKeepAlive =
update.type === MessageUpdateType.Status && update.status === MessageUpdateStatus.KeepAlive;
if (!isKeepAlive) {
if (update.type === MessageUpdateType.Stream) {
const lastUpdate = updatesBuffer.at(-1);
if (lastUpdate?.type === MessageUpdateType.Stream) {
// Persisted streams use empty-token length markers whose spans are
// read from message.content. Keep that representation when live text
// resumes, or replacing the marker with a token makes the renderer
// drop the marker's original span at the reattach boundary.
const merged =
lastUpdate.token === "" && lastUpdate.len !== undefined
? {
...lastUpdate,
len: lastUpdate.len + update.token.length,
}
: {
...lastUpdate,
token: lastUpdate.token + update.token,
};
// Fresh objects/arrays so the UI reacts to merged tokens or lengths.
updatesBuffer = [...updatesBuffer.slice(0, -1), merged];
} else {
updatesBuffer = [...updatesBuffer, update];
}
} else {
updatesBuffer = [...updatesBuffer, update];
}
updatesDirty = true;
}
const currentTime = new Date();
// A non-stream update flushes buffered tokens so the UI doesn't appear to cut
// mid-sentence while tools run or the final answer arrives.
if (update.type !== MessageUpdateType.Stream) {
flushBuffer(currentTime);
}
if (update.type === MessageUpdateType.Stream) {
buffer += update.token;
if (ctx.streamingMode === "smooth") {
scheduleFrameFlush();
} else if (currentTime.getTime() - lastUpdateTime.getTime() > ctx.maxUpdateTime) {
flushBuffer(currentTime);
}
ctx.onStreamStart();
} else if (update.type === MessageUpdateType.FinalAnswer) {
message.content = mergeFinalAnswerContent({
existing: message.content,
finalText: update.text ?? "",
hadTools: updatesBuffer.some((u) => u.type === MessageUpdateType.Tool),
isInterrupted: update.interrupted === true,
});
} else if (
update.type === MessageUpdateType.Status &&
update.status === MessageUpdateStatus.Error
) {
ctx.onError(update);
} else if (update.type === MessageUpdateType.Title) {
ctx.onTitle(update.title);
} else if (update.type === MessageUpdateType.File) {
message.files = [
...(message.files ?? []),
{ type: "hash", value: update.sha, mime: update.mime, name: update.name },
];
} else if (update.type === MessageUpdateType.RouterMetadata) {
message.routerMetadata = { route: update.route, model: update.model };
}
}
flushBuffer(new Date());
}
|