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

@@ -18,7 +18,10 @@ import {
type DesignRenameWorkspaceInput,
type DesignSubmitMessageInput,
} from '../../../shared/image-workspace';
import { DesignWorkspaceModuleError } from '../../image-workspace/module';
import {
DesignWorkspaceModuleError,
type DesignWorkspaceEventSubscription,
} from '../../image-workspace/module';
import type { HostApiContext } from '../context';
import {
flushStreamingHeaders,
@@ -328,19 +331,25 @@ async function relayWorkspaceEvents(
}
const header = req.headers['last-event-id'];
const afterEventId = Array.isArray(header) ? header[0] : header;
const subscription = await ctx.imageWorkspace.openWorkspaceEvents({
workspaceId,
conversationId,
...(afterEventId ? { afterEventId } : {}),
});
let subscription: DesignWorkspaceEventSubscription | null = null;
let closed = false;
const close = () => {
if (closed) return;
closed = true;
subscription.close();
subscription?.close();
};
res.once('close', close);
try {
const opened = await ctx.imageWorkspace.openWorkspaceEvents({
workspaceId,
conversationId,
...(afterEventId ? { afterEventId } : {}),
});
if (closed) {
opened.close();
return;
}
subscription = opened;
res.statusCode = 200;
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
@@ -456,7 +465,13 @@ export async function handleImageWorkspaceRoutes(
&& segments[0] === 'workspaces'
&& segments[2] === 'conversations'
&& req.method === 'GET') {
sendData(res, await ctx.imageWorkspace.getConversation(segments[1], segments[3]));
const before = url.searchParams.get('before');
sendData(
res,
before === null
? await ctx.imageWorkspace.getConversation(segments[1], segments[3])
: await ctx.imageWorkspace.getConversation(segments[1], segments[3], before),
);
return true;
}

View File

@@ -257,6 +257,7 @@ const LOCAL_CAPABILITIES: DesignCapabilities = {
image: true,
video: true,
};
const LOCAL_CONVERSATION_MESSAGE_PAGE_SIZE = 10;
type PersistedWorkspace = DesignWorkspaceSummary & {
clientWorkspaceId: string;
@@ -542,9 +543,13 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
async getConversation(
workspaceId: string,
conversationId: string,
before?: string,
): Promise<DesignConversation> {
const state = await this.load();
return clone(this.requireConversation(state, workspaceId, conversationId));
return this.conversationView(
this.requireConversation(state, workspaceId, conversationId),
before,
);
}
renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace> {
@@ -1081,14 +1086,34 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
});
}
private conversationView(conversation: PersistedConversation): DesignConversation {
private conversationView(
conversation: PersistedConversation,
before?: string,
): DesignConversation {
const {
clientConversationId: _clientId,
requestHashes: _requestHashes,
...view
} = conversation;
const endIndex = before === undefined
? conversation.messages.length
: conversation.messages.findIndex((message) => message.id === before);
if (endIndex < 0) {
throw new LocalImageWorkspaceError(
422,
'conversation_history_cursor_invalid',
'历史会话分页位置已失效,请重新打开会话',
);
}
const startIndex = Math.max(0, endIndex - LOCAL_CONVERSATION_MESSAGE_PAGE_SIZE);
const messages = conversation.messages.slice(startIndex, endIndex);
return clone({
...view,
messages,
messagePage: {
hasOlder: startIndex > 0,
nextBefore: startIndex > 0 ? messages[0]?.id ?? null : null,
},
latestMessagePreview: latestMessagePreview(conversation.messages),
});
}

View File

@@ -53,7 +53,11 @@ export interface DesignWorkspaceModule {
renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace>;
getWorkspace(workspaceId: string): Promise<DesignWorkspace>;
createConversation(input: DesignCreateConversationInput): Promise<DesignConversation>;
getConversation(workspaceId: string, conversationId: string): Promise<DesignConversation>;
getConversation(
workspaceId: string,
conversationId: string,
before?: string,
): Promise<DesignConversation>;
submitMessage(input: DesignSubmitMessageInput): Promise<DesignConversation>;
updateGenerationQuote(input: DesignGenerationQuoteUpdateInput): Promise<DesignGenerationQuote>;
confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignConversation>;

View File

@@ -86,6 +86,7 @@ type ServerQuote = {
};
type ServerMessage = {
message_id?: string;
role: DesignMessage['role'];
kind: DesignMessage['kind'];
text: string;
@@ -119,6 +120,10 @@ type ServerConversationSummary = {
type ServerConversation = ServerConversationSummary & {
messages: ServerMessage[];
message_page?: {
has_older: boolean;
next_before: string | null;
};
};
type ServerWorkspace = ServerWorkspaceSummary & {
@@ -264,6 +269,7 @@ type AgentCommandChannel = {
};
const AGENT_WEBSOCKET_OPEN = 1;
const AGENT_WEBSOCKET_OPEN_TIMEOUT_MS = 10_000;
const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000;
const AGENT_COMMAND_ACK_TIMEOUT_MS = 5_000;
const AGENT_RUN_INITIAL_POLL_INTERVAL_MS = 1_000;
@@ -393,7 +399,8 @@ function mapConversationSummary(
function mapConversation(conversation: ServerConversation): DesignConversation {
const messages = conversation.messages.map((message, index) => ({
id: `${conversation.conversation_id}:${message.turn_revision}:${message.role}:${index}`,
id: message.message_id
?? `${conversation.conversation_id}:${message.turn_revision}:${message.role}:${index}`,
role: message.role,
kind: message.kind,
text: message.text,
@@ -408,6 +415,12 @@ function mapConversation(conversation: ServerConversation): DesignConversation {
?? normalizeMessagePreview(conversation.latest_message_preview)
?? normalizeMessagePreview(conversation.brief.summary),
messages,
messagePage: conversation.message_page
? {
hasOlder: conversation.message_page.has_older,
nextBefore: conversation.message_page.next_before,
}
: undefined,
};
}
@@ -935,9 +948,11 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
async getConversation(
workspaceId: string,
conversationId: string,
before?: string,
): Promise<DesignConversation> {
const query = before ? `?before=${encodeURIComponent(before)}` : '';
const conversation = await this.requestJson<ServerConversation>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`,
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}${query}`,
);
this.rememberConversation(conversation);
return mapConversation(conversation);
@@ -1328,6 +1343,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
let ending = false;
let settled = false;
let heartbeat: ReturnType<typeof setInterval> | null = null;
let openedTimeout: ReturnType<typeof setTimeout> | null = null;
let unregister = () => undefined;
let resolveOpened: () => void = () => undefined;
let rejectOpened: (error: unknown) => void = () => undefined;
@@ -1349,6 +1365,8 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
socket.onclose = null;
if (heartbeat !== null) clearInterval(heartbeat);
heartbeat = null;
if (openedTimeout !== null) clearTimeout(openedTimeout);
openedTimeout = null;
unregister();
if (didOpen) {
this.unregisterAgentCommandChannel(sessionId, commandChannel);
@@ -1450,6 +1468,19 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
input.conversationId,
close,
);
openedTimeout = setTimeout(() => {
if (ending || didOpen) return;
beginEnd(new DesignWorkspaceModuleError(
504,
'DESIGN_EVENT_STREAM_TIMEOUT',
'AI 设计任务状态连接超时,请稍后重试',
));
try {
socket.close(1000, 'Timed out opening design event stream');
} catch {
// settle below remains authoritative when the transport cannot close.
}
}, AGENT_WEBSOCKET_OPEN_TIMEOUT_MS);
await opened;
return {
events: queue.events,