fix: stabilize ai design history switching

This commit is contained in:
2026-08-19 10:31:36 +08:00
parent abece81fdb
commit bf0b805d9f
14 changed files with 573 additions and 48 deletions

View File

@@ -65,6 +65,8 @@ type ImageWorkspaceState = {
deletingWorkspaceId: string | null;
tasks: DesignGenerationTask[];
pendingTurn: PendingDesignTurn | null;
loadingOlderMessages: boolean;
olderMessagesError: string | null;
taskStreamState: ImageWorkspaceTaskStreamState;
error: string | null;
load: () => Promise<DesignWorkspaceBootstrap | null>;
@@ -76,6 +78,7 @@ type ImageWorkspaceState = {
selectConversation: (conversationId: string) => Promise<void>;
refreshWorkspace: () => Promise<DesignWorkspace | null>;
refreshConversation: () => Promise<DesignConversation | null>;
loadOlderMessages: () => Promise<DesignConversation | null>;
refreshTasks: () => Promise<DesignGenerationTask[]>;
connectTaskStream: () => void;
markTaskStreamNeedsReconciliation: () => void;
@@ -101,7 +104,7 @@ const CONFIRMED_TASK_RECONCILE_INTERVAL_MS = 250;
let activeTaskEventSource: EventSource | null = null;
let activeTaskEventWorkspaceId: string | null = null;
let activeTaskEventConversationId: string | null = null;
let taskStreamGeneration = 0;
const pendingTaskEventConnections = new Map<string, symbol>();
let taskFallbackTimer: ReturnType<typeof setInterval> | null = null;
const taskEventRevisions = new Map<string, number>();
let workspaceLoadGeneration = 0;
@@ -126,13 +129,16 @@ function taskRevisionKey(workspaceId: string, taskId: string): string {
return `${workspaceId}:${taskId}`;
}
function taskStreamKey(workspaceId: string, conversationId: string): string {
return `${workspaceId}:${conversationId}`;
}
function stopFallbackPolling(): void {
if (taskFallbackTimer !== null) clearInterval(taskFallbackTimer);
taskFallbackTimer = null;
}
function closeTaskEventSource(): void {
taskStreamGeneration += 1;
stopFallbackPolling();
if (activeTaskEventSource) {
activeTaskEventSource.onopen = null;
@@ -345,33 +351,35 @@ function mergePendingUserMessage(
return { ...conversation, messages };
}
function mergeUserMessagesFromCurrentConversation(
function mergeConversationMessagePages(
current: DesignConversation | null,
incoming: DesignConversation,
source: 'latest' | 'older',
): DesignConversation {
if (!current
|| current.conversationId !== incoming.conversationId
|| current.workspaceId !== incoming.workspaceId) {
return incoming;
}
const missingUserMessages = current.messages.filter((message) => (
message.role === 'user'
&& !incoming.messages.some((candidate) => (
candidate.role === 'user'
&& candidate.turnRevision === message.turnRevision
&& candidate.text === message.text
))
));
if (missingUserMessages.length === 0) return incoming;
const messages = [...incoming.messages];
for (const userMessage of missingUserMessages) {
const insertionIndex = messages.findIndex(
(message) => message.turnRevision >= userMessage.turnRevision,
);
messages.splice(insertionIndex < 0 ? messages.length : insertionIndex, 0, userMessage);
const messagesById = new Map<string, DesignMessage>();
const messageSources = source === 'older'
? [incoming.messages, current.messages]
: [current.messages, incoming.messages];
for (const messages of messageSources) {
for (const message of messages) messagesById.set(message.id, message);
}
return { ...incoming, messages };
const messages = [...messagesById.values()].sort((left, right) => (
left.turnRevision - right.turnRevision
|| (left.role === right.role ? left.id.localeCompare(right.id) : left.role === 'user' ? -1 : 1)
|| left.createdAt.localeCompare(right.createdAt)
));
return {
...incoming,
messages,
messagePage: source === 'older'
? incoming.messagePage
: current.messagePage ?? incoming.messagePage,
};
}
function replaceGenerationQuote(
@@ -473,13 +481,20 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
&& activeTaskEventConversationId === conversationId
&& get().taskStreamState !== 'idle') return;
closeTaskEventSource();
const generation = taskStreamGeneration;
activeTaskEventWorkspaceId = workspaceId;
activeTaskEventConversationId = conversationId;
set({ taskStreamState: 'connecting' });
const connectionKey = taskStreamKey(workspaceId, conversationId);
if (pendingTaskEventConnections.has(connectionKey)) return;
const connectionAttempt = Symbol(connectionKey);
pendingTaskEventConnections.set(connectionKey, connectionAttempt);
void openImageWorkspaceTaskEvents(workspaceId, conversationId).then((source) => {
if (generation !== taskStreamGeneration
|| get().activeWorkspaceId !== workspaceId
if (pendingTaskEventConnections.get(connectionKey) !== connectionAttempt) {
source.close();
return;
}
pendingTaskEventConnections.delete(connectionKey);
if (get().activeWorkspaceId !== workspaceId
|| get().activeConversationId !== conversationId) {
source.close();
return;
@@ -518,9 +533,10 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|| snapshot.conversation.turnRevision >= state.conversation.turnRevision
? snapshot.conversation
: state.conversation;
const resolvedConversation = mergeUserMessagesFromCurrentConversation(
const resolvedConversation = mergeConversationMessagePages(
state.conversation,
mergePendingUserMessage(conversation, state.pendingTurn),
'latest',
);
const workspace = upsertConversation(
state.workspace
@@ -607,8 +623,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
startFallbackPolling();
};
}).catch(() => {
if (generation !== taskStreamGeneration
|| get().activeWorkspaceId !== workspaceId
if (pendingTaskEventConnections.get(connectionKey) !== connectionAttempt) return;
pendingTaskEventConnections.delete(connectionKey);
if (get().activeWorkspaceId !== workspaceId
|| get().activeConversationId !== conversationId) return;
set({ taskStreamState: 'degraded' });
startFallbackPolling();
@@ -668,7 +685,10 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
return workspace;
};
const applyConversation = (conversation: DesignConversation): DesignConversation => {
const applyConversation = (
conversation: DesignConversation,
source: 'latest' | 'older' = 'latest',
): DesignConversation => {
let applied = conversation;
set((state) => {
if (state.conversation?.conversationId === conversation.conversationId
@@ -678,9 +698,10 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
applied = state.conversation;
return state;
}
const resolvedConversation = mergeUserMessagesFromCurrentConversation(
const resolvedConversation = mergeConversationMessagePages(
state.conversation,
mergePendingUserMessage(conversation, state.pendingTurn),
source,
);
applied = resolvedConversation;
const pendingTurn = state.pendingTurn?.workspaceId === resolvedConversation.workspaceId
@@ -693,6 +714,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
activeConversationId: resolvedConversation.conversationId,
conversation: resolvedConversation,
pendingTurn,
loadingOlderMessages: false,
olderMessagesError: null,
error: null,
};
});
@@ -711,9 +734,6 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
)
? currentConversationId
: workspace.conversations[0]?.conversationId ?? null;
const tasks = await fetchImageWorkspaceTasks(workspaceId);
if (loadGeneration !== workspaceLoadGeneration
|| get().activeWorkspaceId !== workspaceId) return;
set((state) => ({
status: 'ready',
bootstrap: upsertSummary(state.bootstrap, workspace),
@@ -721,9 +741,17 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
activeConversationId: conversationId,
workspace,
conversation: null,
tasks: sortTasks(tasks),
tasks: [],
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
error: null,
}));
void fetchImageWorkspaceTasks(workspaceId).then((tasks) => {
if (loadGeneration !== workspaceLoadGeneration
|| get().activeWorkspaceId !== workspaceId) return;
set({ tasks: sortTasks(tasks) });
}).catch(() => undefined);
if (!conversationId) return;
const selectionGeneration = ++conversationSelectionGeneration;
const conversation = await fetchImageWorkspaceConversation(workspaceId, conversationId);
@@ -760,6 +788,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
deletingWorkspaceId: null,
tasks: [],
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
taskStreamState: 'idle',
error: null,
@@ -861,6 +891,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
activeConversationId: conversation.conversationId,
conversation: null,
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
taskStreamState: 'idle',
error: null,
});
@@ -962,6 +994,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
creatingConversation: false,
tasks: [],
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
taskStreamState: 'idle',
error: null,
});
@@ -990,6 +1024,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
activeConversationId: conversationId,
conversation: null,
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
taskStreamState: 'idle',
error: null,
});
@@ -1047,6 +1083,35 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
}
},
loadOlderMessages: async () => {
const workspaceId = get().activeWorkspaceId;
const conversation = get().conversation;
const before = conversation?.messagePage?.nextBefore;
if (!workspaceId || !conversation || !conversation.messagePage?.hasOlder || !before) {
return conversation ?? null;
}
const conversationId = conversation.conversationId;
const selectionGeneration = conversationSelectionGeneration;
set({ loadingOlderMessages: true, olderMessagesError: null });
try {
const older = await fetchImageWorkspaceConversation(workspaceId, conversationId, before);
if (selectionGeneration !== conversationSelectionGeneration
|| get().activeWorkspaceId !== workspaceId
|| get().activeConversationId !== conversationId) return null;
return applyConversation(older, 'older');
} catch (error) {
if (selectionGeneration === conversationSelectionGeneration
&& get().activeWorkspaceId === workspaceId
&& get().activeConversationId === conversationId) {
set({
loadingOlderMessages: false,
olderMessagesError: handleRequestError(error),
});
}
return null;
}
},
refreshTasks: async () => {
const workspaceId = get().activeWorkspaceId;
if (!workspaceId) {
@@ -1306,6 +1371,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
generationQuoteUpdateSequence += 1;
reconcileTaskStateOnNextConnect = false;
stopTaskStream();
pendingTaskEventConnections.clear();
set({
status: 'idle',
bootstrap: null,
@@ -1317,6 +1383,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
deletingWorkspaceId: null,
tasks: [],
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
taskStreamState: 'idle',
error: null,
});