fix for PWA background/foreground
This commit is contained in:
214
web/src/App.tsx
214
web/src/App.tsx
@@ -139,6 +139,7 @@ const ALL_PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
|||||||
|
|
||||||
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
||||||
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
||||||
|
const STREAM_RESUME_RETRY_DELAYS_MS = [0, 250, 750, 1500];
|
||||||
|
|
||||||
type ProviderModelPreferences = Record<Provider, string | null>;
|
type ProviderModelPreferences = Record<Provider, string | null>;
|
||||||
|
|
||||||
@@ -159,6 +160,73 @@ const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
|||||||
searches: {},
|
searches: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function waitForAppForeground() {
|
||||||
|
if (typeof document === "undefined" || typeof window === "undefined") return Promise.resolve();
|
||||||
|
if (document.visibilityState !== "hidden" && navigator.onLine !== false) return Promise.resolve();
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const finishIfReady = () => {
|
||||||
|
if (document.visibilityState === "hidden" || navigator.onLine === false) return;
|
||||||
|
document.removeEventListener("visibilitychange", finishIfReady);
|
||||||
|
window.removeEventListener("pageshow", finishIfReady);
|
||||||
|
window.removeEventListener("online", finishIfReady);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", finishIfReady);
|
||||||
|
window.addEventListener("pageshow", finishIfReady);
|
||||||
|
window.addEventListener("online", finishIfReady);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForRetry(delayMs: number) {
|
||||||
|
if (!delayMs) return Promise.resolve();
|
||||||
|
return new Promise<void>((resolve) => window.setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryAfterAppResume<T>(operation: () => Promise<T>) {
|
||||||
|
await waitForAppForeground();
|
||||||
|
let lastError: unknown = new Error("Unable to reconnect");
|
||||||
|
|
||||||
|
for (const delayMs of STREAM_RESUME_RETRY_DELAYS_MS) {
|
||||||
|
await waitForRetry(delayMs);
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes("bearer token")) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveRunsAfterResume() {
|
||||||
|
return retryAfterAppResume(getActiveRuns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecoverableStreamDisconnect(error: unknown) {
|
||||||
|
if (error instanceof TypeError) return true;
|
||||||
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||||
|
return (
|
||||||
|
message.includes("failed to fetch") ||
|
||||||
|
message.includes("load failed") ||
|
||||||
|
message.includes("network error") ||
|
||||||
|
message.includes("networkerror") ||
|
||||||
|
message.includes("no response stream") ||
|
||||||
|
message.includes("stream disconnected before completion")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNewPersistedUserMessage(chat: ChatDetail, previousMessageIds: Set<string>, content: string, attachments: ChatAttachment[]) {
|
||||||
|
return chat.messages.some((message) => {
|
||||||
|
if (previousMessageIds.has(message.id) || message.role !== "user" || message.content !== content) return false;
|
||||||
|
const persistedAttachmentIds = new Set(getMessageAttachments(message.metadata).map((attachment) => attachment.id));
|
||||||
|
return attachments.length === persistedAttachmentIds.size && attachments.every((attachment) => persistedAttachmentIds.has(attachment.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const TRANSCRIPT_BOTTOM_GAP = 20;
|
const TRANSCRIPT_BOTTOM_GAP = 20;
|
||||||
const REPLY_SCROLL_BUFFER_MIN = 288;
|
const REPLY_SCROLL_BUFFER_MIN = 288;
|
||||||
const REPLY_SCROLL_BUFFER_MAX = 576;
|
const REPLY_SCROLL_BUFFER_MAX = 576;
|
||||||
@@ -870,6 +938,7 @@ export default function App() {
|
|||||||
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
||||||
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
||||||
const chatStreamAbortRefs = useRef<Map<string, AbortController>>(new Map());
|
const chatStreamAbortRefs = useRef<Map<string, AbortController>>(new Map());
|
||||||
|
const backgroundSuspendedChatStreamsRef = useRef<Set<string>>(new Set());
|
||||||
const searchRunAbortRefs = useRef<Map<string, AbortController>>(new Map());
|
const searchRunAbortRefs = useRef<Map<string, AbortController>>(new Map());
|
||||||
const quickQuestionAbortRef = useRef<AbortController | null>(null);
|
const quickQuestionAbortRef = useRef<AbortController | null>(null);
|
||||||
const searchRunCountersRef = useRef<Map<string, number>>(new Map());
|
const searchRunCountersRef = useRef<Map<string, number>>(new Map());
|
||||||
@@ -977,6 +1046,7 @@ export default function App() {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
}
|
}
|
||||||
chatStreamAbortRefs.current.clear();
|
chatStreamAbortRefs.current.clear();
|
||||||
|
backgroundSuspendedChatStreamsRef.current.clear();
|
||||||
for (const controller of searchRunAbortRefs.current.values()) {
|
for (const controller of searchRunAbortRefs.current.values()) {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}
|
}
|
||||||
@@ -1143,6 +1213,36 @@ export default function App() {
|
|||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
const suspendChatStreams = () => {
|
||||||
|
for (const [chatId, controller] of chatStreamAbortRefs.current) {
|
||||||
|
backgroundSuspendedChatStreamsRef.current.add(chatId);
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
suspendChatStreams();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refreshActiveRuns();
|
||||||
|
};
|
||||||
|
const handlePageShow = () => {
|
||||||
|
void refreshActiveRuns();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.addEventListener("pagehide", suspendChatStreams);
|
||||||
|
window.addEventListener("pageshow", handlePageShow);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.removeEventListener("pagehide", suspendChatStreams);
|
||||||
|
window.removeEventListener("pageshow", handlePageShow);
|
||||||
|
};
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPopState = () => {
|
const onPopState = () => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -2051,6 +2151,7 @@ export default function App() {
|
|||||||
if (!baseChat || baseChat.id !== chatId) {
|
if (!baseChat || baseChat.id !== chatId) {
|
||||||
baseChat = await getChat(chatId);
|
baseChat = await getChat(chatId);
|
||||||
}
|
}
|
||||||
|
const previousMessageIds = new Set(baseChat.messages.map((message) => message.id));
|
||||||
|
|
||||||
setPendingChatStates((current) => ({
|
setPendingChatStates((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -2104,6 +2205,9 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||||
|
const target: SidebarSelection = { kind: "chat", id: chatId };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runCompletionStream(
|
await runCompletionStream(
|
||||||
@@ -2161,13 +2265,15 @@ export default function App() {
|
|||||||
onError: (payload) => {
|
onError: (payload) => {
|
||||||
streamErrorMessage = payload.message;
|
streamErrorMessage = payload.message;
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
{ signal: abortController.signal }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (streamErrorMessage) {
|
if (streamErrorMessage) {
|
||||||
throw new Error(streamErrorMessage);
|
throw new Error(streamErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||||
await refreshCollections();
|
await refreshCollections();
|
||||||
const currentSelection = selectedItemRef.current;
|
const currentSelection = selectedItemRef.current;
|
||||||
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
||||||
@@ -2178,13 +2284,62 @@ export default function App() {
|
|||||||
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
||||||
requestSettleTranscriptTailSpacer();
|
requestSettleTranscriptTailSpacer();
|
||||||
}
|
}
|
||||||
return { kind: "chat", id: chatId };
|
return target;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const wasBackgroundSuspended =
|
||||||
|
abortController.signal.aborted && backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||||
|
const shouldResumeStream =
|
||||||
|
wasBackgroundSuspended ||
|
||||||
|
(!abortController.signal.aborted && streamErrorMessage === null && isRecoverableStreamDisconnect(err));
|
||||||
|
|
||||||
|
if (shouldResumeStream) {
|
||||||
|
if (chatStreamAbortRefs.current.get(chatId) === abortController) {
|
||||||
|
chatStreamAbortRefs.current.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resumedRuns = await getActiveRunsAfterResume();
|
||||||
|
setActiveRuns(buildActiveRunsState(resumedRuns));
|
||||||
|
|
||||||
|
if (resumedRuns.chats.includes(chatId)) {
|
||||||
|
removePendingChatState(chatId);
|
||||||
|
await attachToActiveChatStream(chatId, true);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||||
|
await refreshCollections(target);
|
||||||
|
if (isCurrentSelection(target)) {
|
||||||
|
setSelectedChat(persistedChat);
|
||||||
|
setSelectedSearch(null);
|
||||||
|
}
|
||||||
|
removePendingChatState(chatId);
|
||||||
|
removeActiveRun("chat", chatId);
|
||||||
|
|
||||||
|
if (hasNewPersistedUserMessage(persistedChat, previousMessageIds, content, attachments)) {
|
||||||
|
requestSettleTranscriptTailSpacer();
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
err = new Error(
|
||||||
|
wasBackgroundSuspended
|
||||||
|
? "The app was backgrounded before the message reached the server. Please send it again."
|
||||||
|
: "The connection dropped before the message reached the server. Please send it again."
|
||||||
|
);
|
||||||
|
} catch (resumeError) {
|
||||||
|
err = resumeError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
removePendingChatState(chatId);
|
removePendingChatState(chatId);
|
||||||
removeActiveRun("chat", chatId);
|
removeActiveRun("chat", chatId);
|
||||||
pendingReplyScrollRef.current = false;
|
pendingReplyScrollRef.current = false;
|
||||||
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||||
throw err;
|
throw err;
|
||||||
|
} finally {
|
||||||
|
if (chatStreamAbortRefs.current.get(chatId) === abortController) {
|
||||||
|
chatStreamAbortRefs.current.delete(chatId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2329,19 +2484,24 @@ export default function App() {
|
|||||||
return target;
|
return target;
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachToActiveChatStream = async (chatId: string) => {
|
async function attachToActiveChatStream(chatId: string, resetFromServer = false) {
|
||||||
if (chatStreamAbortRefs.current.has(chatId)) return;
|
if (chatStreamAbortRefs.current.has(chatId)) return;
|
||||||
const target: SidebarSelection = { kind: "chat", id: chatId };
|
const target: SidebarSelection = { kind: "chat", id: chatId };
|
||||||
|
addActiveRun("chat", chatId);
|
||||||
|
let shouldResetFromServer = resetFromServer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||||
addActiveRun("chat", chatId);
|
|
||||||
|
|
||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseChat = selectedChat?.id === chatId ? selectedChat : await getChat(chatId);
|
const baseChat = await getChat(chatId);
|
||||||
|
const resetPendingState = shouldResetFromServer;
|
||||||
|
shouldResetFromServer = false;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
if (current[chatId]) return current;
|
if (!resetPendingState && current[chatId]) return current;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[chatId]: {
|
[chatId]: {
|
||||||
@@ -2412,12 +2572,41 @@ export default function App() {
|
|||||||
throw new Error(streamErrorMessage);
|
throw new Error(streamErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||||
await refreshCollections();
|
await refreshCollections();
|
||||||
if (isCurrentSelection(target)) {
|
if (isCurrentSelection(target)) {
|
||||||
await refreshChat(chatId);
|
await refreshChat(chatId);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (abortController.signal.aborted) return;
|
const wasBackgroundSuspended =
|
||||||
|
abortController.signal.aborted && backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||||
|
const shouldResumeStream =
|
||||||
|
wasBackgroundSuspended ||
|
||||||
|
(!abortController.signal.aborted && streamErrorMessage === null && isRecoverableStreamDisconnect(err));
|
||||||
|
|
||||||
|
if (shouldResumeStream) {
|
||||||
|
shouldResetFromServer = true;
|
||||||
|
try {
|
||||||
|
const resumedRuns = await getActiveRunsAfterResume();
|
||||||
|
setActiveRuns(buildActiveRunsState(resumedRuns));
|
||||||
|
if (resumedRuns.chats.includes(chatId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||||
|
if (isCurrentSelection(target)) {
|
||||||
|
setSelectedChat(persistedChat);
|
||||||
|
setSelectedSearch(null);
|
||||||
|
}
|
||||||
|
void refreshCollections(target);
|
||||||
|
return;
|
||||||
|
} catch (resumeError) {
|
||||||
|
err = resumeError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (abortController.signal.aborted && !shouldResumeStream) return;
|
||||||
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("active chat stream not found")) {
|
if (message.includes("active chat stream not found")) {
|
||||||
await refreshActiveRuns();
|
await refreshActiveRuns();
|
||||||
@@ -2427,15 +2616,22 @@ export default function App() {
|
|||||||
} else if (isCurrentSelection(target)) {
|
} else if (isCurrentSelection(target)) {
|
||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (chatStreamAbortRefs.current.get(chatId) === abortController) {
|
||||||
chatStreamAbortRefs.current.delete(chatId);
|
chatStreamAbortRefs.current.delete(chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||||
removePendingChatState(chatId);
|
removePendingChatState(chatId);
|
||||||
removeActiveRun("chat", chatId);
|
removeActiveRun("chat", chatId);
|
||||||
if (isCurrentSelection(target)) {
|
if (isCurrentSelection(target)) {
|
||||||
requestSettleTranscriptTailSpacer();
|
requestSettleTranscriptTailSpacer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const attachToActiveSearchStream = async (searchId: string) => {
|
const attachToActiveSearchStream = async (searchId: string) => {
|
||||||
if (searchRunAbortRefs.current.has(searchId)) return;
|
if (searchRunAbortRefs.current.has(searchId)) return;
|
||||||
|
|||||||
@@ -450,6 +450,7 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
|
|||||||
let buffer = "";
|
let buffer = "";
|
||||||
let eventName = "message";
|
let eventName = "message";
|
||||||
let dataLines: string[] = [];
|
let dataLines: string[] = [];
|
||||||
|
let sawTerminalEvent = false;
|
||||||
|
|
||||||
const flushEvent = () => {
|
const flushEvent = () => {
|
||||||
if (!dataLines.length) {
|
if (!dataLines.length) {
|
||||||
@@ -466,6 +467,9 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispatch(eventName, payload);
|
dispatch(eventName, payload);
|
||||||
|
if (eventName === "done" || eventName === "error") {
|
||||||
|
sawTerminalEvent = true;
|
||||||
|
}
|
||||||
|
|
||||||
dataLines = [];
|
dataLines = [];
|
||||||
eventName = "message";
|
eventName = "message";
|
||||||
@@ -505,6 +509,10 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
flushEvent();
|
flushEvent();
|
||||||
|
|
||||||
|
if (!sawTerminalEvent) {
|
||||||
|
throw new Error("Stream disconnected before completion");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSearchStream(
|
export async function runSearchStream(
|
||||||
@@ -528,87 +536,14 @@ export async function runSearchStream(
|
|||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
await readSseStream(response, (eventName, payload) => {
|
||||||
const fallback = `${response.status} ${response.statusText}`;
|
|
||||||
let message = fallback;
|
|
||||||
try {
|
|
||||||
const body = (await response.json()) as { message?: string };
|
|
||||||
if (body.message) message = body.message;
|
|
||||||
} catch {
|
|
||||||
// keep fallback message
|
|
||||||
}
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error("No response stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
let eventName = "message";
|
|
||||||
let dataLines: string[] = [];
|
|
||||||
|
|
||||||
const flushEvent = () => {
|
|
||||||
if (!dataLines.length) {
|
|
||||||
eventName = "message";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataText = dataLines.join("\n");
|
|
||||||
let payload: any = null;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(dataText);
|
|
||||||
} catch {
|
|
||||||
payload = { message: dataText };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === "search_results") handlers.onSearchResults?.(payload);
|
if (eventName === "search_results") handlers.onSearchResults?.(payload);
|
||||||
else if (eventName === "search_error") handlers.onSearchError?.(payload);
|
else if (eventName === "search_error") handlers.onSearchError?.(payload);
|
||||||
else if (eventName === "answer") handlers.onAnswer?.(payload);
|
else if (eventName === "answer") handlers.onAnswer?.(payload);
|
||||||
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
|
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
|
||||||
else if (eventName === "done") handlers.onDone?.(payload);
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
else if (eventName === "error") handlers.onError?.(payload);
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
});
|
||||||
dataLines = [];
|
|
||||||
eventName = "message";
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
let newlineIndex = buffer.indexOf("\n");
|
|
||||||
|
|
||||||
while (newlineIndex >= 0) {
|
|
||||||
const rawLine = buffer.slice(0, newlineIndex);
|
|
||||||
buffer = buffer.slice(newlineIndex + 1);
|
|
||||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
||||||
|
|
||||||
if (!line) {
|
|
||||||
flushEvent();
|
|
||||||
} else if (line.startsWith("event:")) {
|
|
||||||
eventName = line.slice("event:".length).trim();
|
|
||||||
} else if (line.startsWith("data:")) {
|
|
||||||
dataLines.push(line.slice("data:".length).trimStart());
|
|
||||||
}
|
|
||||||
|
|
||||||
newlineIndex = buffer.indexOf("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode();
|
|
||||||
if (buffer.length) {
|
|
||||||
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
|
||||||
if (line.startsWith("event:")) {
|
|
||||||
eventName = line.slice("event:".length).trim();
|
|
||||||
} else if (line.startsWith("data:")) {
|
|
||||||
dataLines.push(line.slice("data:".length).trimStart());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushEvent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
|
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||||
@@ -679,86 +614,13 @@ export async function runCompletionStream(
|
|||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
await readSseStream(response, (eventName, payload) => {
|
||||||
const fallback = `${response.status} ${response.statusText}`;
|
|
||||||
let message = fallback;
|
|
||||||
try {
|
|
||||||
const body = (await response.json()) as { message?: string };
|
|
||||||
if (body.message) message = body.message;
|
|
||||||
} catch {
|
|
||||||
// keep fallback message
|
|
||||||
}
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error("No response stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
let eventName = "message";
|
|
||||||
let dataLines: string[] = [];
|
|
||||||
|
|
||||||
const flushEvent = () => {
|
|
||||||
if (!dataLines.length) {
|
|
||||||
eventName = "message";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataText = dataLines.join("\n");
|
|
||||||
let payload: any = null;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(dataText);
|
|
||||||
} catch {
|
|
||||||
payload = { message: dataText };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === "meta") handlers.onMeta?.(payload);
|
if (eventName === "meta") handlers.onMeta?.(payload);
|
||||||
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
||||||
else if (eventName === "delta") handlers.onDelta?.(payload);
|
else if (eventName === "delta") handlers.onDelta?.(payload);
|
||||||
else if (eventName === "done") handlers.onDone?.(payload);
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
else if (eventName === "error") handlers.onError?.(payload);
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
});
|
||||||
dataLines = [];
|
|
||||||
eventName = "message";
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
let newlineIndex = buffer.indexOf("\n");
|
|
||||||
|
|
||||||
while (newlineIndex >= 0) {
|
|
||||||
const rawLine = buffer.slice(0, newlineIndex);
|
|
||||||
buffer = buffer.slice(newlineIndex + 1);
|
|
||||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
||||||
|
|
||||||
if (!line) {
|
|
||||||
flushEvent();
|
|
||||||
} else if (line.startsWith("event:")) {
|
|
||||||
eventName = line.slice("event:".length).trim();
|
|
||||||
} else if (line.startsWith("data:")) {
|
|
||||||
dataLines.push(line.slice("data:".length).trimStart());
|
|
||||||
}
|
|
||||||
|
|
||||||
newlineIndex = buffer.indexOf("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode();
|
|
||||||
if (buffer.length) {
|
|
||||||
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
|
||||||
if (line.startsWith("event:")) {
|
|
||||||
eventName = line.slice("event:".length).trim();
|
|
||||||
} else if (line.startsWith("data:")) {
|
|
||||||
dataLines.push(line.slice("data:".length).trimStart());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushEvent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user