fix: stabilize ai design history switching
This commit is contained in:
@@ -160,9 +160,11 @@ export function createImageWorkspaceConversation(
|
||||
export function fetchImageWorkspaceConversation(
|
||||
workspaceId: string,
|
||||
conversationId: string,
|
||||
before?: string,
|
||||
): Promise<DesignConversation> {
|
||||
const query = before ? `?before=${encodeURIComponent(before)}` : '';
|
||||
return requestData(
|
||||
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`,
|
||||
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -930,6 +930,8 @@ export function ImageCanvas() {
|
||||
const conversation = useImageWorkspaceStore((state) => state.conversation);
|
||||
const tasks = useImageWorkspaceStore((state) => state.tasks);
|
||||
const pendingTurn = useImageWorkspaceStore((state) => state.pendingTurn);
|
||||
const loadingOlderMessages = useImageWorkspaceStore((state) => state.loadingOlderMessages);
|
||||
const olderMessagesError = useImageWorkspaceStore((state) => state.olderMessagesError);
|
||||
const workspaceError = useImageWorkspaceStore((state) => state.error);
|
||||
const load = useImageWorkspaceStore((state) => state.load);
|
||||
const connectTaskStream = useImageWorkspaceStore((state) => state.connectTaskStream);
|
||||
@@ -937,6 +939,7 @@ export function ImageCanvas() {
|
||||
const markTaskStreamNeedsReconciliation = useImageWorkspaceStore(
|
||||
(state) => state.markTaskStreamNeedsReconciliation,
|
||||
);
|
||||
const loadOlderMessages = useImageWorkspaceStore((state) => state.loadOlderMessages);
|
||||
const sendMessage = useImageWorkspaceStore((state) => state.sendMessage);
|
||||
const updateGenerationQuote = useImageWorkspaceStore(
|
||||
(state) => state.updateGenerationQuote,
|
||||
@@ -966,6 +969,7 @@ export function ImageCanvas() {
|
||||
const conversationEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const conversationWindowStartRef = useRef<number | null>(null);
|
||||
const conversationShouldStickRef = useRef(true);
|
||||
const olderMessagesRequestRef = useRef(false);
|
||||
const promptRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const referenceUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const promptSelectionRef = useRef({ start: 0, end: 0 });
|
||||
@@ -1111,6 +1115,7 @@ export function ImageCanvas() {
|
||||
useEffect(() => {
|
||||
conversationWindowStartRef.current = null;
|
||||
conversationShouldStickRef.current = true;
|
||||
olderMessagesRequestRef.current = false;
|
||||
setConversationWindowStart(null);
|
||||
setPrompt('');
|
||||
setReferenceAssets([]);
|
||||
@@ -1162,6 +1167,22 @@ export function ImageCanvas() {
|
||||
setConversationWindowStart(null);
|
||||
}, [conversationMessages.length]);
|
||||
|
||||
const requestOlderMessages = useCallback(() => {
|
||||
const element = conversationScrollRef.current;
|
||||
if (!element || olderMessagesRequestRef.current || !conversation?.messagePage?.hasOlder) return;
|
||||
const previousHeight = element.scrollHeight;
|
||||
olderMessagesRequestRef.current = true;
|
||||
conversationShouldStickRef.current = false;
|
||||
void loadOlderMessages().finally(() => {
|
||||
olderMessagesRequestRef.current = false;
|
||||
window.requestAnimationFrame(() => {
|
||||
const nextElement = conversationScrollRef.current;
|
||||
if (!nextElement) return;
|
||||
nextElement.scrollTop += nextElement.scrollHeight - previousHeight;
|
||||
});
|
||||
});
|
||||
}, [conversation?.messagePage?.hasOlder, loadOlderMessages]);
|
||||
|
||||
const handleConversationScroll = useCallback(() => {
|
||||
const element = conversationScrollRef.current;
|
||||
if (!element) return;
|
||||
@@ -1177,11 +1198,13 @@ export function ImageCanvas() {
|
||||
if (!nextElement) return;
|
||||
nextElement.scrollTop += nextElement.scrollHeight - previousHeight;
|
||||
});
|
||||
} else if (element.scrollTop < 96 && currentStart === 0) {
|
||||
requestOlderMessages();
|
||||
}
|
||||
conversationShouldStickRef.current = element.scrollHeight
|
||||
- element.scrollTop
|
||||
- element.clientHeight <= CANVAS_BOTTOM_THRESHOLD_PX;
|
||||
}, [conversationMessages.length]);
|
||||
}, [conversationMessages.length, requestOlderMessages]);
|
||||
|
||||
const repriceQuote = async (
|
||||
requestedQuoteId: string,
|
||||
@@ -1787,9 +1810,30 @@ export function ImageCanvas() {
|
||||
setConversationWindowStart(0);
|
||||
}}
|
||||
>
|
||||
加载更早消息({effectiveConversationWindowStart})
|
||||
显示已加载的更早消息({effectiveConversationWindowStart})
|
||||
</button>
|
||||
) : null}
|
||||
{effectiveConversationWindowStart === 0 && loadingOlderMessages ? (
|
||||
<div
|
||||
data-testid="image-workspace-loading-older-messages"
|
||||
className="mx-auto flex items-center gap-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
正在读取更早消息…
|
||||
</div>
|
||||
) : null}
|
||||
{effectiveConversationWindowStart === 0
|
||||
&& !loadingOlderMessages
|
||||
&& olderMessagesError
|
||||
&& conversation?.messagePage?.hasOlder ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mx-auto block rounded-md border border-border/70 px-3 py-1.5 text-xs text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
|
||||
onClick={requestOlderMessages}
|
||||
>
|
||||
重新加载更早消息
|
||||
</button>
|
||||
) : null}
|
||||
{windowedConversationMessages.map((message) => {
|
||||
const assistant = message.role === 'assistant';
|
||||
return (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user