make PWA chat resume idempotent
This commit is contained in:
@@ -8,5 +8,5 @@ self.addEventListener("activate", (event) => {
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.mode !== "navigate") return;
|
||||
event.respondWith(fetch(event.request));
|
||||
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
|
||||
});
|
||||
|
||||
270
web/src/App.tsx
270
web/src/App.tsx
@@ -206,6 +206,13 @@ function getActiveRunsAfterResume() {
|
||||
return retryAfterAppResume(getActiveRuns);
|
||||
}
|
||||
|
||||
function createClientRequestId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function isRecoverableStreamDisconnect(error: unknown) {
|
||||
if (error instanceof TypeError) return true;
|
||||
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
@@ -1085,7 +1092,7 @@ export default function App() {
|
||||
resetWorkspaceState();
|
||||
};
|
||||
|
||||
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
||||
const refreshCollections = async (preferredSelection?: SidebarSelection, reportTransientError = true) => {
|
||||
setIsLoadingCollections(true);
|
||||
try {
|
||||
const nextWorkspaceItems = await listWorkspaceItems();
|
||||
@@ -1113,7 +1120,7 @@ export default function App() {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
} else if (reportTransientError || !isRecoverableStreamDisconnect(err)) {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
@@ -2204,141 +2211,166 @@ export default function App() {
|
||||
});
|
||||
}
|
||||
|
||||
let streamErrorMessage: string | null = null;
|
||||
const abortController = new AbortController();
|
||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||
const target: SidebarSelection = { kind: "chat", id: chatId };
|
||||
const clientRequestId = createClientRequestId();
|
||||
|
||||
try {
|
||||
await runCompletionStream(
|
||||
{
|
||||
chatId,
|
||||
provider,
|
||||
model: selectedModel,
|
||||
messages: requestMessages,
|
||||
},
|
||||
{
|
||||
onMeta: (payload) => {
|
||||
if (payload.chatId !== chatId) return;
|
||||
while (true) {
|
||||
let streamErrorMessage: string | null = null;
|
||||
const abortController = new AbortController();
|
||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||
|
||||
try {
|
||||
await runCompletionStream(
|
||||
{
|
||||
chatId,
|
||||
clientRequestId,
|
||||
provider,
|
||||
model: selectedModel,
|
||||
messages: requestMessages,
|
||||
},
|
||||
onToolCall: (payload) => {
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
return {
|
||||
...current,
|
||||
[chatId]: {
|
||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
if (!payload.text) return;
|
||||
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 };
|
||||
{
|
||||
onMeta: (payload) => {
|
||||
if (payload.chatId !== chatId) return;
|
||||
},
|
||||
onToolCall: (payload) => {
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
return {
|
||||
...current,
|
||||
[chatId]: {
|
||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
||||
},
|
||||
};
|
||||
});
|
||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||
});
|
||||
},
|
||||
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 };
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
if (!payload.text) return;
|
||||
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;
|
||||
});
|
||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||
});
|
||||
},
|
||||
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;
|
||||
});
|
||||
},
|
||||
onError: (payload) => {
|
||||
streamErrorMessage = payload.message;
|
||||
},
|
||||
},
|
||||
onError: (payload) => {
|
||||
streamErrorMessage = payload.message;
|
||||
},
|
||||
},
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
|
||||
if (streamErrorMessage) {
|
||||
throw new Error(streamErrorMessage);
|
||||
}
|
||||
if (streamErrorMessage) {
|
||||
throw new Error(streamErrorMessage);
|
||||
}
|
||||
|
||||
backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||
await refreshCollections();
|
||||
const currentSelection = selectedItemRef.current;
|
||||
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
||||
await refreshChat(chatId);
|
||||
}
|
||||
removePendingChatState(chatId);
|
||||
removeActiveRun("chat", chatId);
|
||||
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
||||
requestSettleTranscriptTailSpacer();
|
||||
}
|
||||
return target;
|
||||
} catch (err) {
|
||||
const wasBackgroundSuspended =
|
||||
abortController.signal.aborted && backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||
const shouldResumeStream =
|
||||
wasBackgroundSuspended ||
|
||||
(!abortController.signal.aborted && streamErrorMessage === null && isRecoverableStreamDisconnect(err));
|
||||
backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||
await refreshCollections(target, false);
|
||||
if (isCurrentSelection(target)) {
|
||||
setSelectedChat(persistedChat);
|
||||
setSelectedSearch(null);
|
||||
}
|
||||
removePendingChatState(chatId);
|
||||
removeActiveRun("chat", chatId);
|
||||
if (isCurrentSelection(target)) {
|
||||
requestSettleTranscriptTailSpacer();
|
||||
}
|
||||
return target;
|
||||
} catch (caughtError) {
|
||||
let err: unknown = caughtError;
|
||||
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 (shouldResumeStream) {
|
||||
try {
|
||||
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||
const userMessageWasAccepted = hasNewPersistedUserMessage(
|
||||
persistedChat,
|
||||
previousMessageIds,
|
||||
content,
|
||||
attachments
|
||||
);
|
||||
setError(null);
|
||||
if (isCurrentSelection(target)) {
|
||||
setSelectedChat(persistedChat);
|
||||
setSelectedSearch(null);
|
||||
}
|
||||
setPendingChatStates((current) => ({
|
||||
...current,
|
||||
[chatId]: {
|
||||
messages: persistedChat.messages.concat(
|
||||
...(userMessageWasAccepted ? [] : [optimisticUserMessage]),
|
||||
{
|
||||
...optimisticAssistantMessage,
|
||||
id: `temp-assistant-resume-${Date.now()}`,
|
||||
content: "",
|
||||
}
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
if (resumedRuns.chats.includes(chatId)) {
|
||||
removePendingChatState(chatId);
|
||||
await attachToActiveChatStream(chatId, true);
|
||||
return target;
|
||||
// Retrying the same idempotent request either starts an unaccepted
|
||||
// submission or replays the existing backend-owned stream.
|
||||
continue;
|
||||
} catch (resumeError) {
|
||||
err = resumeError;
|
||||
}
|
||||
|
||||
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||
await refreshCollections(target);
|
||||
if (isCurrentSelection(target)) {
|
||||
setSelectedChat(persistedChat);
|
||||
setSelectedSearch(null);
|
||||
} else if (streamErrorMessage) {
|
||||
try {
|
||||
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||
if (hasNewPersistedUserMessage(persistedChat, previousMessageIds, content, attachments)) {
|
||||
if (isCurrentSelection(target)) {
|
||||
setSelectedChat(persistedChat);
|
||||
setSelectedSearch(null);
|
||||
setError(streamErrorMessage);
|
||||
}
|
||||
removePendingChatState(chatId);
|
||||
removeActiveRun("chat", chatId);
|
||||
requestSettleTranscriptTailSpacer();
|
||||
return target;
|
||||
}
|
||||
} catch (reconcileError) {
|
||||
err = reconcileError;
|
||||
}
|
||||
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);
|
||||
removeActiveRun("chat", chatId);
|
||||
pendingReplyScrollRef.current = false;
|
||||
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||
throw err;
|
||||
} finally {
|
||||
if (chatStreamAbortRefs.current.get(chatId) === abortController) {
|
||||
chatStreamAbortRefs.current.delete(chatId);
|
||||
removePendingChatState(chatId);
|
||||
removeActiveRun("chat", chatId);
|
||||
pendingReplyScrollRef.current = false;
|
||||
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||
throw err;
|
||||
} finally {
|
||||
if (chatStreamAbortRefs.current.get(chatId) === abortController) {
|
||||
chatStreamAbortRefs.current.delete(chatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -589,6 +589,7 @@ export async function runCompletionStream(
|
||||
body: {
|
||||
chatId?: string | null;
|
||||
persist?: boolean;
|
||||
clientRequestId?: string;
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
|
||||
@@ -2,8 +2,19 @@ export function registerServiceWorker() {
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => {
|
||||
console.warn("Sybil service worker registration failed", error);
|
||||
const hadController = Boolean(navigator.serviceWorker.controller);
|
||||
let isReloadingForUpdate = false;
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
if (!hadController || isReloadingForUpdate) return;
|
||||
isReloadingForUpdate = true;
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
void navigator.serviceWorker
|
||||
.register("/sw.js", { updateViaCache: "none" })
|
||||
.then((registration) => registration.update())
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Sybil service worker registration failed", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user