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 { describe, expect, it } from "vitest";
|
||||||
import { mergeChatHistory, type RawMessagePage } from "./chatHistoryCache";
|
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 };
|
return { start, total, messages };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,4 +37,17 @@ describe("mergeChatHistory", () => {
|
|||||||
|
|
||||||
expect(mergeChatHistory(page(0, 10, ["a", "b"]), incoming)).toEqual(incoming);
|
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 {
|
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;
|
if (isCompleteReplacement(existing, incoming)) return incoming;
|
||||||
|
|
||||||
const start = Math.min(existing.start, incoming.start);
|
const start = Math.min(existing.start, incoming.start);
|
||||||
@@ -71,14 +72,31 @@ function cacheKey(sessionId: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isCachedHistory(value: unknown): value is CachedChatHistory {
|
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"
|
return typeof value === "object"
|
||||||
&& value !== null
|
&& value !== null
|
||||||
&& "messages" in value
|
&& "role" in value
|
||||||
&& "start" in value
|
&& "parts" in value
|
||||||
&& "total" in value
|
&& !("content" in value)
|
||||||
&& "savedAt" in value
|
&& typeof value.role === "string"
|
||||||
&& Array.isArray(value.messages)
|
&& Array.isArray(value.parts);
|
||||||
&& typeof value.start === "number"
|
|
||||||
&& typeof value.total === "number"
|
|
||||||
&& typeof value.savedAt === "number";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export class SessionController {
|
|||||||
private catchupStreamSessionId: string | undefined;
|
private catchupStreamSessionId: string | undefined;
|
||||||
private pendingTranscriptEvents: SessionUiEvent[] = [];
|
private pendingTranscriptEvents: SessionUiEvent[] = [];
|
||||||
private pendingTranscriptFrame: number | undefined;
|
private pendingTranscriptFrame: number | undefined;
|
||||||
|
private readonly rawHistoryPages = new Map<string, RawMessagePage>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly getState: GetState,
|
private readonly getState: GetState,
|
||||||
@@ -63,7 +64,7 @@ export class SessionController {
|
|||||||
this.socket.close();
|
this.socket.close();
|
||||||
this.catchupStreamSessionId = undefined;
|
this.catchupStreamSessionId = undefined;
|
||||||
this.clearPendingTranscriptEvents();
|
this.clearPendingTranscriptEvents();
|
||||||
const cached = readChatHistoryCache(session.id);
|
const cached = this.rawHistoryPage(session.id);
|
||||||
this.setState({
|
this.setState({
|
||||||
selectedSession: session,
|
selectedSession: session,
|
||||||
messages: normalizeMessages(cached?.messages ?? []),
|
messages: normalizeMessages(cached?.messages ?? []),
|
||||||
@@ -78,7 +79,7 @@ export class SessionController {
|
|||||||
if (session.archived === true) {
|
if (session.archived === true) {
|
||||||
const page = await api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
|
const page = await api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
|
||||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
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 });
|
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();
|
if (options?.updateUrl !== false) this.updateUrl();
|
||||||
return;
|
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)]);
|
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;
|
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;
|
const isReceivingPartialStream = status.isStreaming;
|
||||||
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined;
|
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] });
|
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;
|
if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
|
||||||
this.setState({ isLoadingEarlierMessages: true });
|
this.setState({ isLoadingEarlierMessages: true });
|
||||||
try {
|
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 });
|
const page = await api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE });
|
||||||
if (this.getState().selectedSession?.id !== session.id) return;
|
if (this.getState().selectedSession?.id !== session.id) return;
|
||||||
const history = this.mergeAndCacheHistory(session.id, page, base);
|
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;
|
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
this.flushPendingTranscriptEvents();
|
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)]);
|
const [page, status] = await Promise.all([api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }), api.status(sessionId)]);
|
||||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
if (this.getState().selectedSession?.id !== sessionId) return;
|
||||||
const history = this.mergeAndCacheHistory(sessionId, page, base);
|
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);
|
const history = mergeChatHistory(base, page);
|
||||||
|
this.rawHistoryPages.set(sessionId, history);
|
||||||
writeChatHistoryCache(sessionId, history);
|
writeChatHistoryCache(sessionId, history);
|
||||||
return history;
|
return history;
|
||||||
}
|
}
|
||||||
|
|
||||||
private currentHistoryPage(): RawMessagePage | undefined {
|
private rawHistoryPage(sessionId: string): RawMessagePage | undefined {
|
||||||
const state = this.getState();
|
const cached = this.rawHistoryPages.get(sessionId) ?? readChatHistoryCache(sessionId);
|
||||||
if (state.messages.length === 0 && state.messagePageTotal === 0) return undefined;
|
if (cached !== undefined) this.rawHistoryPages.set(sessionId, cached);
|
||||||
return { messages: state.messages, start: state.messagePageStart, total: state.messagePageTotal };
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyCommandResult(result: CommandResult) {
|
private applyCommandResult(result: CommandResult) {
|
||||||
@@ -443,7 +445,7 @@ export class SessionController {
|
|||||||
|
|
||||||
private async refreshMessages(sessionId: string) {
|
private async refreshMessages(sessionId: string) {
|
||||||
try {
|
try {
|
||||||
const base = this.currentHistoryPage();
|
const base = this.rawHistoryPage(sessionId);
|
||||||
const page = await api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE });
|
const page = await api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE });
|
||||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
if (this.getState().selectedSession?.id !== sessionId) return;
|
||||||
const history = this.mergeAndCacheHistory(sessionId, page, base);
|
const history = this.mergeAndCacheHistory(sessionId, page, base);
|
||||||
|
|||||||
Reference in New Issue
Block a user