harden PWA resume and title fallback
This commit is contained in:
@@ -186,6 +186,7 @@ Behavior notes:
|
||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
||||
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.
|
||||
|
||||
### `DELETE /v1/chats/:chatId`
|
||||
- Response: `{ "deleted": true }`
|
||||
|
||||
@@ -1007,7 +1007,18 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
||||
|
||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||
const suggestedRaw = await generateChatTitle(body.content);
|
||||
let suggestedRaw = "";
|
||||
try {
|
||||
suggestedRaw = await generateChatTitle(body.content);
|
||||
} catch (err) {
|
||||
req.log.warn(
|
||||
{
|
||||
chatId: body.chatId,
|
||||
err: getErrorMessage(err),
|
||||
},
|
||||
"chat title generation failed; using fallback"
|
||||
);
|
||||
}
|
||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||
|
||||
await prisma.chat.updateMany({
|
||||
|
||||
@@ -3,7 +3,21 @@ self.addEventListener("install", () => {
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
await self.clients.claim();
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
await Promise.all(
|
||||
windows.map(async (client) => {
|
||||
try {
|
||||
await client.navigate(client.url);
|
||||
} catch {
|
||||
// The client may have closed while the new worker was activating.
|
||||
}
|
||||
})
|
||||
);
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
|
||||
@@ -162,20 +162,18 @@ const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
||||
|
||||
function waitForAppForeground() {
|
||||
if (typeof document === "undefined" || typeof window === "undefined") return Promise.resolve();
|
||||
if (document.visibilityState !== "hidden" && navigator.onLine !== false) return Promise.resolve();
|
||||
if (document.visibilityState !== "hidden") return Promise.resolve();
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const finishIfReady = () => {
|
||||
if (document.visibilityState === "hidden" || navigator.onLine === false) return;
|
||||
if (document.visibilityState === "hidden") 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1179,7 +1177,7 @@ export default function App() {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
} else if (!isRecoverableStreamDisconnect(err)) {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
@@ -1197,7 +1195,7 @@ export default function App() {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
} else if (!isRecoverableStreamDisconnect(err)) {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
@@ -1234,7 +1232,15 @@ export default function App() {
|
||||
suspendChatStreams();
|
||||
return;
|
||||
}
|
||||
setError((current) => (current && isRecoverableStreamDisconnect(new Error(current)) ? null : current));
|
||||
void refreshActiveRuns();
|
||||
void refreshCollections(selectedItemRef.current ?? undefined, false);
|
||||
const currentSelection = selectedItemRef.current;
|
||||
if (currentSelection?.kind === "chat") {
|
||||
void refreshChat(currentSelection.id);
|
||||
} else if (currentSelection?.kind === "search") {
|
||||
void refreshSearch(currentSelection.id);
|
||||
}
|
||||
};
|
||||
const handlePageShow = () => {
|
||||
void refreshActiveRuns();
|
||||
@@ -2216,6 +2222,7 @@ export default function App() {
|
||||
|
||||
while (true) {
|
||||
let streamErrorMessage: string | null = null;
|
||||
let replayedAssistantText = "";
|
||||
const abortController = new AbortController();
|
||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||
|
||||
@@ -2246,6 +2253,7 @@ export default function App() {
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
if (!payload.text) return;
|
||||
replayedAssistantText += payload.text;
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
@@ -2254,7 +2262,7 @@ export default function App() {
|
||||
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 { ...message, content: replayedAssistantText };
|
||||
});
|
||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||
});
|
||||
@@ -2312,35 +2320,23 @@ export default function App() {
|
||||
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: "",
|
||||
}
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
// Retrying the same idempotent request either starts an unaccepted
|
||||
// submission or replays the existing backend-owned stream.
|
||||
// submission or replays the existing backend-owned stream. Keep
|
||||
// the current optimistic transcript visible until replay begins.
|
||||
continue;
|
||||
} catch (resumeError) {
|
||||
if (isRecoverableStreamDisconnect(resumeError)) {
|
||||
setError(null);
|
||||
await waitForAppForeground();
|
||||
await waitForRetry(1000);
|
||||
continue;
|
||||
}
|
||||
err = resumeError;
|
||||
}
|
||||
} else if (streamErrorMessage) {
|
||||
|
||||
@@ -2,14 +2,6 @@ export function registerServiceWorker() {
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user