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 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.
|
- 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.
|
- 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`
|
### `DELETE /v1/chats/:chatId`
|
||||||
- Response: `{ "deleted": true }`
|
- Response: `{ "deleted": true }`
|
||||||
|
|||||||
@@ -1007,7 +1007,18 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
||||||
|
|
||||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
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);
|
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||||
|
|
||||||
await prisma.chat.updateMany({
|
await prisma.chat.updateMany({
|
||||||
|
|||||||
@@ -3,7 +3,21 @@ self.addEventListener("install", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
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) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
|
|||||||
@@ -162,20 +162,18 @@ const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
|||||||
|
|
||||||
function waitForAppForeground() {
|
function waitForAppForeground() {
|
||||||
if (typeof document === "undefined" || typeof window === "undefined") return Promise.resolve();
|
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) => {
|
return new Promise<void>((resolve) => {
|
||||||
const finishIfReady = () => {
|
const finishIfReady = () => {
|
||||||
if (document.visibilityState === "hidden" || navigator.onLine === false) return;
|
if (document.visibilityState === "hidden") return;
|
||||||
document.removeEventListener("visibilitychange", finishIfReady);
|
document.removeEventListener("visibilitychange", finishIfReady);
|
||||||
window.removeEventListener("pageshow", finishIfReady);
|
window.removeEventListener("pageshow", finishIfReady);
|
||||||
window.removeEventListener("online", finishIfReady);
|
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", finishIfReady);
|
document.addEventListener("visibilitychange", finishIfReady);
|
||||||
window.addEventListener("pageshow", 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);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
handleAuthFailure(message);
|
handleAuthFailure(message);
|
||||||
} else {
|
} else if (!isRecoverableStreamDisconnect(err)) {
|
||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1197,7 +1195,7 @@ export default function App() {
|
|||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
handleAuthFailure(message);
|
handleAuthFailure(message);
|
||||||
} else {
|
} else if (!isRecoverableStreamDisconnect(err)) {
|
||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1234,7 +1232,15 @@ export default function App() {
|
|||||||
suspendChatStreams();
|
suspendChatStreams();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setError((current) => (current && isRecoverableStreamDisconnect(new Error(current)) ? null : current));
|
||||||
void refreshActiveRuns();
|
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 = () => {
|
const handlePageShow = () => {
|
||||||
void refreshActiveRuns();
|
void refreshActiveRuns();
|
||||||
@@ -2216,6 +2222,7 @@ export default function App() {
|
|||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
|
let replayedAssistantText = "";
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||||
|
|
||||||
@@ -2246,6 +2253,7 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
onDelta: (payload) => {
|
onDelta: (payload) => {
|
||||||
if (!payload.text) return;
|
if (!payload.text) return;
|
||||||
|
replayedAssistantText += payload.text;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
@@ -2254,7 +2262,7 @@ export default function App() {
|
|||||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
||||||
if (!isTarget) return message;
|
if (!isTarget) return message;
|
||||||
updated = true;
|
updated = true;
|
||||||
return { ...message, content: message.content + payload.text };
|
return { ...message, content: replayedAssistantText };
|
||||||
});
|
});
|
||||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||||
});
|
});
|
||||||
@@ -2312,35 +2320,23 @@ export default function App() {
|
|||||||
if (shouldResumeStream) {
|
if (shouldResumeStream) {
|
||||||
try {
|
try {
|
||||||
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||||
const userMessageWasAccepted = hasNewPersistedUserMessage(
|
|
||||||
persistedChat,
|
|
||||||
previousMessageIds,
|
|
||||||
content,
|
|
||||||
attachments
|
|
||||||
);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
if (isCurrentSelection(target)) {
|
if (isCurrentSelection(target)) {
|
||||||
setSelectedChat(persistedChat);
|
setSelectedChat(persistedChat);
|
||||||
setSelectedSearch(null);
|
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
|
// 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;
|
continue;
|
||||||
} catch (resumeError) {
|
} catch (resumeError) {
|
||||||
|
if (isRecoverableStreamDisconnect(resumeError)) {
|
||||||
|
setError(null);
|
||||||
|
await waitForAppForeground();
|
||||||
|
await waitForRetry(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
err = resumeError;
|
err = resumeError;
|
||||||
}
|
}
|
||||||
} else if (streamErrorMessage) {
|
} else if (streamErrorMessage) {
|
||||||
|
|||||||
@@ -2,14 +2,6 @@ export function registerServiceWorker() {
|
|||||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
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
|
void navigator.serviceWorker
|
||||||
.register("/sw.js", { updateViaCache: "none" })
|
.register("/sw.js", { updateViaCache: "none" })
|
||||||
.then((registration) => registration.update())
|
.then((registration) => registration.update())
|
||||||
|
|||||||
Reference in New Issue
Block a user