Archived
fix: prevent chat reload duplication
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Fix chat history reloads so previously displayed messages are not duplicated from the browser cache.
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergeChatHistory, type RawMessagePage } from "./chatHistoryCache";
|
||||
|
||||
function page(start: number, total: number, messages: string[]): RawMessagePage {
|
||||
function page(start: number, total: number, messages: unknown[]): RawMessagePage {
|
||||
return { start, total, messages };
|
||||
}
|
||||
|
||||
@@ -37,4 +37,17 @@ describe("mergeChatHistory", () => {
|
||||
|
||||
expect(mergeChatHistory(page(0, 10, ["a", "b"]), incoming)).toEqual(incoming);
|
||||
});
|
||||
|
||||
it("uses incoming history when cached history contains normalized chat lines", () => {
|
||||
const incoming = page(0, 2, [{ role: "user", content: "fresh" }, { role: "assistant", content: "answer" }]);
|
||||
const normalizedLine = { role: "assistant", parts: [{ type: "text", text: "duplicated display line" }] };
|
||||
|
||||
expect(mergeChatHistory(page(0, 2, [incoming.messages[0], normalizedLine]), incoming)).toEqual(incoming);
|
||||
});
|
||||
|
||||
it("uses incoming history when cached history is longer than its raw range", () => {
|
||||
const incoming = page(0, 2, ["fresh-a", "fresh-b"]);
|
||||
|
||||
expect(mergeChatHistory(page(0, 2, ["stale-a", "stale-b", "stale-c"]), incoming)).toEqual(incoming);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,8 @@ export function writeChatHistoryCache(sessionId: string, page: RawMessagePage):
|
||||
}
|
||||
|
||||
export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage {
|
||||
if (existing === undefined) return incoming;
|
||||
if (existing === undefined || !isValidMessagePage(existing)) return incoming;
|
||||
if (!isValidMessagePage(incoming)) return existing;
|
||||
if (isCompleteReplacement(existing, incoming)) return incoming;
|
||||
|
||||
const start = Math.min(existing.start, incoming.start);
|
||||
@@ -71,14 +72,31 @@ function cacheKey(sessionId: string): string {
|
||||
}
|
||||
|
||||
function isCachedHistory(value: unknown): value is CachedChatHistory {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
if (!("messages" in value) || !("start" in value) || !("total" in value) || !("savedAt" in value)) return false;
|
||||
const { messages, start, total, savedAt } = value;
|
||||
return Array.isArray(messages)
|
||||
&& typeof start === "number"
|
||||
&& typeof total === "number"
|
||||
&& typeof savedAt === "number"
|
||||
&& isValidMessagePage({ messages, start, total });
|
||||
}
|
||||
|
||||
function isValidMessagePage(page: RawMessagePage): boolean {
|
||||
return Number.isInteger(page.start)
|
||||
&& Number.isInteger(page.total)
|
||||
&& page.start >= 0
|
||||
&& page.total >= page.start
|
||||
&& page.messages.length <= page.total - page.start
|
||||
&& !page.messages.some(isNormalizedChatLine);
|
||||
}
|
||||
|
||||
function isNormalizedChatLine(value: unknown): boolean {
|
||||
return typeof value === "object"
|
||||
&& value !== null
|
||||
&& "messages" in value
|
||||
&& "start" in value
|
||||
&& "total" in value
|
||||
&& "savedAt" in value
|
||||
&& Array.isArray(value.messages)
|
||||
&& typeof value.start === "number"
|
||||
&& typeof value.total === "number"
|
||||
&& typeof value.savedAt === "number";
|
||||
&& "role" in value
|
||||
&& "parts" in value
|
||||
&& !("content" in value)
|
||||
&& typeof value.role === "string"
|
||||
&& Array.isArray(value.parts);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export class SessionController {
|
||||
private catchupStreamSessionId: string | undefined;
|
||||
private pendingTranscriptEvents: SessionUiEvent[] = [];
|
||||
private pendingTranscriptFrame: number | undefined;
|
||||
private readonly rawHistoryPages = new Map<string, RawMessagePage>();
|
||||
|
||||
constructor(
|
||||
private readonly getState: GetState,
|
||||
@@ -63,7 +64,7 @@ export class SessionController {
|
||||
this.socket.close();
|
||||
this.catchupStreamSessionId = undefined;
|
||||
this.clearPendingTranscriptEvents();
|
||||
const cached = readChatHistoryCache(session.id);
|
||||
const cached = this.rawHistoryPage(session.id);
|
||||
this.setState({
|
||||
selectedSession: session,
|
||||
messages: normalizeMessages(cached?.messages ?? []),
|
||||
@@ -78,7 +79,7 @@ export class SessionController {
|
||||
if (session.archived === true) {
|
||||
const page = await api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
|
||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||
const history = this.mergeAndCacheHistory(session.id, page, this.currentHistoryPage());
|
||||
const history = this.mergeAndCacheHistory(session.id, page, cached);
|
||||
this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
|
||||
if (options?.updateUrl !== false) this.updateUrl();
|
||||
return;
|
||||
@@ -91,7 +92,7 @@ export class SessionController {
|
||||
);
|
||||
const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]);
|
||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||
const history = this.mergeAndCacheHistory(session.id, page, this.currentHistoryPage());
|
||||
const history = this.mergeAndCacheHistory(session.id, page, cached);
|
||||
const isReceivingPartialStream = status.isStreaming;
|
||||
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined;
|
||||
this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id] });
|
||||
@@ -110,7 +111,7 @@ export class SessionController {
|
||||
if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
|
||||
this.setState({ isLoadingEarlierMessages: true });
|
||||
try {
|
||||
const base = this.currentHistoryPage();
|
||||
const base = this.rawHistoryPage(session.id);
|
||||
const page = await api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE });
|
||||
if (this.getState().selectedSession?.id !== session.id) return;
|
||||
const history = this.mergeAndCacheHistory(session.id, page, base);
|
||||
@@ -298,7 +299,7 @@ export class SessionController {
|
||||
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
|
||||
try {
|
||||
this.flushPendingTranscriptEvents();
|
||||
const base = this.currentHistoryPage();
|
||||
const base = this.rawHistoryPage(sessionId);
|
||||
const [page, status] = await Promise.all([api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }), api.status(sessionId)]);
|
||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
||||
const history = this.mergeAndCacheHistory(sessionId, page, base);
|
||||
@@ -324,16 +325,17 @@ export class SessionController {
|
||||
});
|
||||
}
|
||||
|
||||
private mergeAndCacheHistory(sessionId: string, page: RawMessagePage, base = readChatHistoryCache(sessionId)): RawMessagePage {
|
||||
private mergeAndCacheHistory(sessionId: string, page: RawMessagePage, base = this.rawHistoryPage(sessionId)): RawMessagePage {
|
||||
const history = mergeChatHistory(base, page);
|
||||
this.rawHistoryPages.set(sessionId, history);
|
||||
writeChatHistoryCache(sessionId, history);
|
||||
return history;
|
||||
}
|
||||
|
||||
private currentHistoryPage(): RawMessagePage | undefined {
|
||||
const state = this.getState();
|
||||
if (state.messages.length === 0 && state.messagePageTotal === 0) return undefined;
|
||||
return { messages: state.messages, start: state.messagePageStart, total: state.messagePageTotal };
|
||||
private rawHistoryPage(sessionId: string): RawMessagePage | undefined {
|
||||
const cached = this.rawHistoryPages.get(sessionId) ?? readChatHistoryCache(sessionId);
|
||||
if (cached !== undefined) this.rawHistoryPages.set(sessionId, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
private applyCommandResult(result: CommandResult) {
|
||||
@@ -443,7 +445,7 @@ export class SessionController {
|
||||
|
||||
private async refreshMessages(sessionId: string) {
|
||||
try {
|
||||
const base = this.currentHistoryPage();
|
||||
const base = this.rawHistoryPage(sessionId);
|
||||
const page = await api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE });
|
||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
||||
const history = this.mergeAndCacheHistory(sessionId, page, base);
|
||||
|
||||
Reference in New Issue
Block a user