feat: 支持 AI 设计项目多会话交互

需求:用户可在同一设计项目中新建和切换会话,任务列表保持项目级共享。

实现:
- 增加会话选择、新建入口及本地数据迁移
- Conversation 独立消息、Brief、Quote 与流式状态
- 保留项目任务并修复 Task revision 与 ABA 异步竞态
- 保持 Enter 发送、Shift+Enter 换行及 IME 保护
This commit is contained in:
2026-08-06 18:22:15 +08:00
parent 4980894017
commit 03dae62cf3
18 changed files with 1810 additions and 702 deletions

View File

@@ -4,11 +4,14 @@ import type {
DesignAssistantDeltaEvent,
DesignBrief,
DesignCapabilities,
DesignConversation,
DesignConversationSnapshotEvent,
DesignConversationSummary,
DesignConfirmGenerationInput,
DesignCreateConversationInput,
DesignCreateWorkspaceInput,
DesignGenerationQuote,
DesignGenerationTask,
DesignGenerationTasksSnapshotEvent,
DesignGenerationTaskUpdatedEvent,
DesignMessage,
DesignRenameWorkspaceInput,
@@ -18,7 +21,6 @@ import type {
DesignWorkspaceEvent,
DesignWorkspaceSummary,
} from '../../shared/image-workspace';
import { createHash } from 'node:crypto';
import WebSocket from 'ws';
import { designAssetContentPath } from '../../shared/image-workspace';
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
@@ -63,17 +65,36 @@ type ServerMessage = {
type ServerWorkspaceSummary = {
workspace_id: string;
title: string;
turn_revision: number;
view_revision: number;
conversation_count?: number;
phase: DesignWorkspaceSummary['phase'];
brief: ServerBrief;
updated_at: string;
};
type ServerWorkspace = ServerWorkspaceSummary & {
type ServerConversationSummary = {
conversation_id: string;
workspace_id: string;
agent_session_id: string | null;
title: string;
turn_revision: number;
phase: DesignConversationSummary['phase'];
brief: ServerBrief;
created_at: string;
updated_at: string;
};
type ServerConversation = ServerConversationSummary & {
messages: ServerMessage[];
};
type ServerWorkspace = ServerWorkspaceSummary & {
conversations?: ServerConversationSummary[];
};
type ServerWorkspaceCreation = ServerWorkspace & {
initial_conversation?: ServerConversation;
};
type ServerAsset = {
asset_id: string;
media_type: DesignAsset['mediaType'];
@@ -87,6 +108,7 @@ type ServerAsset = {
type ServerTask = {
task_id: string;
workspace_id: string;
conversation_id?: string | null;
medium: DesignGenerationTask['medium'];
status: DesignGenerationTask['status'];
brief_version: number;
@@ -107,17 +129,7 @@ type ServerErrorDetail = {
type WorksSquareDesignWorkspaceOptions = {
apiBaseUrl?: string;
fetchImpl?: typeof fetch;
clientInstanceId?: string;
webSocketFactory?: AgentWebSocketFactory;
eventSessionClientIdStore?: {
getOrCreate(workspaceId: string): Promise<string>;
rotate(workspaceId: string): Promise<string>;
};
};
type ServerAgentSession = {
session_id: string;
status: 'active' | 'closed';
};
type ServerAgentStreamTicket = {
@@ -154,6 +166,8 @@ type ServerAgentRun = {
type AgentDesignTurnSubmission = {
workspaceId: string;
conversationId: string;
agentSessionId: string;
clientTurnId: string;
expectedTurnRevision: number;
message: string;
@@ -232,19 +246,42 @@ function mapWorkspaceSummary(workspace: ServerWorkspaceSummary): DesignWorkspace
return {
workspaceId: workspace.workspace_id,
title: workspace.title,
turnRevision: workspace.turn_revision,
viewRevision: workspace.view_revision,
conversationCount: workspace.conversation_count ?? 0,
phase: workspace.phase,
brief: mapBrief(workspace.brief),
updatedAt: workspace.updated_at,
};
}
function mapWorkspace(workspace: ServerWorkspace): DesignWorkspace {
const conversations = workspace.conversations ?? [];
return {
...mapWorkspaceSummary(workspace),
messages: workspace.messages.map((message, index) => ({
id: `${workspace.workspace_id}:${message.turn_revision}:${message.role}:${index}`,
conversationCount: workspace.conversation_count ?? conversations.length,
conversations: conversations.map(mapConversationSummary),
};
}
function mapConversationSummary(
conversation: ServerConversationSummary,
): DesignConversationSummary {
return {
conversationId: conversation.conversation_id,
workspaceId: conversation.workspace_id,
title: conversation.title,
turnRevision: conversation.turn_revision,
phase: conversation.phase,
brief: mapBrief(conversation.brief),
createdAt: conversation.created_at,
updatedAt: conversation.updated_at,
};
}
function mapConversation(conversation: ServerConversation): DesignConversation {
return {
...mapConversationSummary(conversation),
messages: conversation.messages.map((message, index) => ({
id: `${conversation.conversation_id}:${message.turn_revision}:${message.role}:${index}`,
role: message.role,
kind: message.kind,
text: message.text,
@@ -260,6 +297,7 @@ function mapTask(task: ServerTask): DesignGenerationTask {
return {
taskId: task.task_id,
workspaceId: task.workspace_id,
conversationId: task.conversation_id ?? null,
medium: task.medium,
status: task.status,
briefVersion: task.brief_version,
@@ -287,24 +325,14 @@ function mapAsset(workspaceId: string, asset: ServerAsset): DesignAsset {
};
}
function createClientId(prefix: string): string {
const id = globalThis.crypto?.randomUUID?.()
?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}-${id}`;
}
function stableWorkspaceSessionClientId(clientInstanceId: string, workspaceId: string): string {
const digest = createHash('sha256')
.update(`${clientInstanceId}\0${workspaceId}`)
.digest('hex');
return `design-stream-${digest}`;
}
function isServerTask(value: unknown): value is ServerTask {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const task = value as Record<string, unknown>;
return typeof task.task_id === 'string'
&& typeof task.workspace_id === 'string'
&& (task.conversation_id === undefined
|| task.conversation_id === null
|| typeof task.conversation_id === 'string')
&& (task.medium === 'image' || task.medium === 'video')
&& ['queued', 'running', 'succeeded', 'failed', 'cancelled'].includes(String(task.status))
&& typeof task.brief_version === 'number'
@@ -314,17 +342,18 @@ function isServerTask(value: unknown): value is ServerTask {
&& typeof task.updated_at === 'string';
}
function isServerWorkspace(value: unknown): value is ServerWorkspace {
function isServerConversation(value: unknown): value is ServerConversation {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const workspace = value as Record<string, unknown>;
const brief = workspace.brief as Record<string, unknown> | null;
return typeof workspace.workspace_id === 'string'
&& typeof workspace.title === 'string'
&& Number.isInteger(workspace.turn_revision)
&& Number(workspace.turn_revision) >= 0
&& Number.isInteger(workspace.view_revision)
&& Number(workspace.view_revision) >= 0
&& ['shaping', 'awaiting_confirmation', 'blocked'].includes(String(workspace.phase))
const conversation = value as Record<string, unknown>;
const brief = conversation.brief as Record<string, unknown> | null;
return typeof conversation.conversation_id === 'string'
&& typeof conversation.workspace_id === 'string'
&& (conversation.agent_session_id === null
|| typeof conversation.agent_session_id === 'string')
&& typeof conversation.title === 'string'
&& Number.isInteger(conversation.turn_revision)
&& Number(conversation.turn_revision) >= 0
&& ['shaping', 'awaiting_confirmation', 'blocked'].includes(String(conversation.phase))
&& Boolean(brief)
&& Number.isInteger(brief?.version)
&& ['draft', 'ready', 'confirmed'].includes(String(brief?.status))
@@ -332,8 +361,8 @@ function isServerWorkspace(value: unknown): value is ServerWorkspace {
&& typeof brief?.summary === 'string'
&& typeof brief?.ready === 'boolean'
&& (brief?.missing_decision === null || typeof brief?.missing_decision === 'string')
&& Array.isArray(workspace.messages)
&& workspace.messages.every((message) => {
&& Array.isArray(conversation.messages)
&& conversation.messages.every((message) => {
if (!message || typeof message !== 'object' || Array.isArray(message)) return false;
const item = message as Record<string, unknown>;
return (item.role === 'user' || item.role === 'assistant')
@@ -346,13 +375,15 @@ function isServerWorkspace(value: unknown): value is ServerWorkspace {
&& Number(item.turn_revision) >= 0
&& typeof item.created_at === 'string';
})
&& typeof workspace.updated_at === 'string';
&& typeof conversation.created_at === 'string'
&& typeof conversation.updated_at === 'string';
}
function normalizeWorkspaceEvent(
value: unknown,
sessionId: string,
workspaceId: string,
conversationId: string,
): DesignWorkspaceEvent | null {
try {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
@@ -370,6 +401,7 @@ function normalizeWorkspaceEvent(
const payload = event.payload as Record<string, unknown>;
if (event.type === 'design.assistant.delta') {
if (payload.workspace_id !== workspaceId
|| payload.conversation_id !== conversationId
|| typeof payload.client_turn_id !== 'string'
|| payload.client_turn_id.length < 1
|| payload.client_turn_id.length > 128
@@ -386,6 +418,7 @@ function normalizeWorkspaceEvent(
id: `${sessionId}:${event.sequence}`,
type: 'design.assistant.delta',
workspaceId,
conversationId,
clientTurnId: payload.client_turn_id,
turnRevision: Number(payload.turn_revision),
chunkIndex: Number(payload.chunk_index),
@@ -410,15 +443,19 @@ function normalizeWorkspaceEvent(
} satisfies DesignGenerationTaskUpdatedEvent;
}
if (event.type !== 'design.workspace.updated'
|| !isServerWorkspace(payload.workspace)
if ((event.type !== 'design.conversation.updated'
&& event.type !== 'design.workspace.updated')
|| payload.workspace_id !== workspaceId
|| payload.conversation_id !== conversationId
|| !isServerConversation(payload.conversation)
|| !Array.isArray(payload.generation_tasks)) {
return null;
}
const workspace = payload.workspace;
if (workspace.workspace_id !== workspaceId
|| !Number.isInteger(workspace.view_revision)
|| Number(workspace.view_revision) < 0
const conversation = payload.conversation;
if (conversation.workspace_id !== workspaceId
|| conversation.conversation_id !== conversationId
|| !Number.isInteger(payload.workspace_view_revision)
|| Number(payload.workspace_view_revision) < 0
|| !payload.generation_tasks.every(
(task) => isServerTask(task) && task.workspace_id === workspaceId,
)) {
@@ -426,12 +463,13 @@ function normalizeWorkspaceEvent(
}
return {
id: `${sessionId}:${event.sequence}`,
type: 'design.generation_tasks.snapshot',
type: 'design.conversation.snapshot',
workspaceId,
workspaceViewRevision: Number(workspace.view_revision),
workspace: mapWorkspace(workspace),
conversationId,
workspaceViewRevision: Number(payload.workspace_view_revision),
conversation: mapConversation(conversation),
generationTasks: payload.generation_tasks.map((task) => mapTask(task as ServerTask)),
} satisfies DesignGenerationTasksSnapshotEvent;
} satisfies DesignConversationSnapshotEvent;
} catch {
return null;
}
@@ -668,15 +706,17 @@ function agentRunErrorStatus(code: string): number {
return 502;
}
function isStaleAgentSessionError(error: unknown): error is DesignWorkspaceModuleError {
return error instanceof DesignWorkspaceModuleError
&& (error.code === 'agent_session_not_found'
|| error.code === 'agent_session_closed');
}
export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
private readonly apiBaseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly webSocketFactory: AgentWebSocketFactory;
private readonly eventSessionClientIdStore: NonNullable<
WorksSquareDesignWorkspaceOptions['eventSessionClientIdStore']
>;
private readonly eventSessions = new Map<string, Promise<ServerAgentSession>>();
private readonly eventSessionClientIds = new Map<string, string>();
private readonly conversationSessionIds = new Map<string, string>();
private readonly eventSubscriptionClosers = new Map<string, Set<() => void>>();
private readonly activeRunEventStreams = new Map<string, number>();
private readonly terminalAgentRuns = new Map<string, ServerAgentRun>();
@@ -688,13 +728,6 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
this.webSocketFactory = options.webSocketFactory ?? defaultAgentWebSocketFactory;
const clientInstanceId = options.clientInstanceId?.trim() || createClientId('process');
this.eventSessionClientIdStore = options.eventSessionClientIdStore ?? {
getOrCreate: async (workspaceId) => (
stableWorkspaceSessionClientId(clientInstanceId, workspaceId)
),
rotate: async () => createClientId('design-stream-rotated'),
};
}
async bootstrap(): Promise<DesignWorkspaceBootstrap> {
@@ -714,14 +747,15 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
}
async createWorkspace(input: DesignCreateWorkspaceInput): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>('/api/design/workspaces', {
const workspace = await this.requestJson<ServerWorkspaceCreation>('/api/design/workspaces', {
method: 'POST',
body: JSON.stringify({
client_workspace_id: input.clientWorkspaceId,
title: input.title,
}),
});
return mapWorkspace(workspace);
if (workspace.initial_conversation) this.rememberConversation(workspace.initial_conversation);
return this.getWorkspace(workspace.workspace_id);
}
async renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace> {
@@ -732,19 +766,53 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
body: JSON.stringify({ title: input.title }),
},
);
return mapWorkspace(workspace);
return this.getWorkspace(workspace.workspace_id);
}
async getWorkspace(workspaceId: string): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}`,
);
return mapWorkspace(workspace);
const [workspace, conversations] = await Promise.all([
this.requestJson<ServerWorkspace>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}`,
),
this.requestJson<ServerConversationSummary[]>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations?limit=100&offset=0`,
),
]);
return mapWorkspace({ ...workspace, conversations });
}
async submitMessage(input: DesignSubmitMessageInput): Promise<DesignWorkspace> {
async createConversation(input: DesignCreateConversationInput): Promise<DesignConversation> {
const conversation = await this.requestJson<ServerConversation>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/conversations`,
{
method: 'POST',
body: JSON.stringify({
client_conversation_id: input.clientConversationId,
title: input.title,
}),
},
);
this.rememberConversation(conversation);
return mapConversation(conversation);
}
async getConversation(
workspaceId: string,
conversationId: string,
): Promise<DesignConversation> {
const conversation = await this.requestJson<ServerConversation>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`,
);
this.rememberConversation(conversation);
return mapConversation(conversation);
}
async submitMessage(input: DesignSubmitMessageInput): Promise<DesignConversation> {
const agentSessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId);
return this.executeAgentTurn({
workspaceId: input.workspaceId,
conversationId: input.conversationId,
agentSessionId,
clientTurnId: input.clientTurnId,
expectedTurnRevision: input.expectedTurnRevision,
message: input.message,
@@ -753,9 +821,12 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
});
}
async confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignWorkspace> {
async confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignConversation> {
const agentSessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId);
return this.executeAgentTurn({
workspaceId: input.workspaceId,
conversationId: input.conversationId,
agentSessionId,
clientTurnId: input.clientTurnId,
expectedTurnRevision: input.expectedTurnRevision,
message: '确认生成',
@@ -767,22 +838,23 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
});
}
private async executeAgentTurn(input: AgentDesignTurnSubmission): Promise<DesignWorkspace> {
let session = await this.ensureEventSession(input.workspaceId);
let command: ServerAgentCommand;
private async executeAgentTurn(input: AgentDesignTurnSubmission): Promise<DesignConversation> {
try {
command = await this.submitTurnCommand(session.session_id, input);
return await this.executeAgentTurnOnce(input);
} catch (error) {
if (!(error instanceof DesignWorkspaceModuleError)
|| (error.code !== 'agent_session_not_found'
&& error.code !== 'agent_session_closed')) {
throw error;
}
await this.invalidateEventSession(input.workspaceId);
session = await this.ensureEventSession(input.workspaceId);
command = await this.submitTurnCommand(session.session_id, input);
if (!isStaleAgentSessionError(error)) throw error;
this.forgetConversation(input.workspaceId, input.conversationId);
const agentSessionId = await this.getAgentSessionId(
input.workspaceId,
input.conversationId,
);
return this.executeAgentTurnOnce({ ...input, agentSessionId });
}
const run = await this.waitForAgentRun(session.session_id, command.run_id);
}
private async executeAgentTurnOnce(input: AgentDesignTurnSubmission): Promise<DesignConversation> {
const command = await this.submitTurnCommand(input.agentSessionId, input);
const run = await this.waitForAgentRun(input.agentSessionId, command.run_id);
if (run.status !== 'succeeded') {
const code = run.error?.code ?? (
run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed'
@@ -798,7 +870,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
userFacingErrorMessage(code, fallback),
);
}
return this.getWorkspace(input.workspaceId);
return this.getConversation(input.workspaceId, input.conversationId);
}
private submitTurnCommand(
@@ -935,18 +1007,24 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
async openWorkspaceEvents(
input: DesignWorkspaceEventSubscriptionInput,
): Promise<DesignWorkspaceEventSubscription> {
let session = await this.ensureEventSession(input.workspaceId);
if (!this.eventSessionsEnabled) {
throw new DesignWorkspaceModuleError(
503,
'DESIGN_EVENT_STREAM_PAUSED',
'AI 设计任务状态流已暂停',
);
}
let sessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId);
let ticket: ServerAgentStreamTicket;
try {
ticket = await this.createEventStreamTicket(session.session_id);
ticket = await this.createEventStreamTicket(sessionId);
} catch (error) {
if (!(error instanceof DesignWorkspaceModuleError)
|| (error.status !== 404 && error.status !== 409)) {
if (!isStaleAgentSessionError(error)) {
throw error;
}
await this.invalidateEventSession(input.workspaceId);
session = await this.ensureEventSession(input.workspaceId);
ticket = await this.createEventStreamTicket(session.session_id);
this.forgetConversation(input.workspaceId, input.conversationId);
sessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId);
ticket = await this.createEventStreamTicket(sessionId);
}
const streamUrl = new URL(ticket.stream_url, `${this.apiBaseUrl}/`);
if (streamUrl.origin !== new URL(this.apiBaseUrl).origin) {
@@ -965,7 +1043,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
}
streamUrl.searchParams.set(
'after_sequence',
String(eventSequence(input.afterEventId, session.session_id)),
String(eventSequence(input.afterEventId, sessionId)),
);
streamUrl.protocol = streamUrl.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -1009,7 +1087,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
if (heartbeat !== null) clearInterval(heartbeat);
heartbeat = null;
unregister();
if (didOpen) this.unregisterRunEventStream(session.session_id);
if (didOpen) this.unregisterRunEventStream(sessionId);
try {
await connection.dispose?.();
} catch {
@@ -1052,7 +1130,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
socket.onopen = () => {
if (ending) return;
didOpen = true;
this.registerRunEventStream(session.session_id);
this.registerRunEventStream(sessionId);
heartbeat = setInterval(() => {
if (ending || socket.readyState !== AGENT_WEBSOCKET_OPEN) return;
try {
@@ -1071,16 +1149,17 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
const agentEvent = agentEventFromWebSocketFrame(data);
const event = normalizeWorkspaceEvent(
agentEvent,
session.session_id,
sessionId,
input.workspaceId,
input.conversationId,
);
if (event) {
latestWorkspaceEventDelivery = boundedTaskEventDelivery(queue.push(event));
}
const run = normalizeAgentRunEvent(agentEvent, session.session_id);
const run = normalizeAgentRunEvent(agentEvent, sessionId);
if (run) {
const deliveryBarrier = latestWorkspaceEventDelivery;
void deliveryBarrier.then(() => this.publishAgentRun(session.session_id, run));
void deliveryBarrier.then(() => this.publishAgentRun(sessionId, run));
}
};
socket.onerror = () => {
@@ -1090,23 +1169,19 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
};
socket.onclose = ({ code }) => {
const error = webSocketCloseError(code);
if (code === 4409) {
beginEnd(error, async () => {
try {
await this.closeEventSession(session.session_id);
} finally {
await this.invalidateEventSession(input.workspaceId);
}
if (code === 4409 || code === 4404) {
beginEnd(error, () => {
this.forgetConversation(input.workspaceId, input.conversationId);
});
return;
}
if (code === 4404) {
beginEnd(error, () => this.invalidateEventSession(input.workspaceId));
return;
}
beginEnd(error);
};
unregister = this.registerEventSubscription(input.workspaceId, close);
unregister = this.registerEventSubscription(
input.workspaceId,
input.conversationId,
close,
);
await opened;
return {
events: queue.events,
@@ -1120,32 +1195,6 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
.flatMap((closers) => [...closers]);
this.eventSubscriptionClosers.clear();
for (const close of activeSubscriptions) close();
const pendingSessions = [...this.eventSessions.entries()];
this.eventSessions.clear();
const sessions = await Promise.allSettled(
pendingSessions.map(async ([workspaceId, pending]) => ({
workspaceId,
session: await pending,
})),
);
const closeResults = await Promise.allSettled(
sessions.flatMap((result) => (
result.status === 'fulfilled'
? [this.closeEventSession(result.value.session.session_id)
.then(() => this.rotateEventSession(result.value.workspaceId))]
: []
)),
);
const uncertainCreations = sessions.filter((result) => (
result.status === 'rejected'
&& !(result.reason instanceof DesignWorkspaceModuleError
&& result.reason.code === 'DESIGN_EVENT_SESSION_CLOSED')
)).length;
const failedCloses = closeResults.filter((result) => result.status === 'rejected').length;
const failed = uncertainCreations + failedCloses;
if (failed > 0) {
throw new Error(`Failed to close ${failed} AI design Agent Session(s)`);
}
}
openAssetContent(workspaceId: string, assetId: string, range?: string): Promise<Response> {
@@ -1214,50 +1263,40 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
});
}
private ensureEventSession(workspaceId: string): Promise<ServerAgentSession> {
if (!this.eventSessionsEnabled) {
private conversationKey(workspaceId: string, conversationId: string): string {
return `${workspaceId}:${conversationId}`;
}
private rememberConversation(conversation: ServerConversationSummary): void {
if (typeof conversation.agent_session_id !== 'string'
|| !conversation.agent_session_id.trim()) return;
this.conversationSessionIds.set(
this.conversationKey(conversation.workspace_id, conversation.conversation_id),
conversation.agent_session_id,
);
}
private forgetConversation(workspaceId: string, conversationId: string): void {
this.conversationSessionIds.delete(this.conversationKey(workspaceId, conversationId));
}
private async getAgentSessionId(
workspaceId: string,
conversationId: string,
): Promise<string> {
const key = this.conversationKey(workspaceId, conversationId);
const existing = this.conversationSessionIds.get(key);
if (existing) return existing;
await this.getConversation(workspaceId, conversationId);
const refreshed = this.conversationSessionIds.get(key);
if (!refreshed) {
throw new DesignWorkspaceModuleError(
503,
'DESIGN_EVENT_STREAM_PAUSED',
'AI 设计任务状态流已暂停',
'DESIGN_CONVERSATION_SESSION_UNAVAILABLE',
'设计会话暂时无法连接,请稍后重试',
);
}
const existing = this.eventSessions.get(workspaceId);
if (existing) return existing;
const pending = (async () => {
const clientSessionId = this.eventSessionClientIds.get(workspaceId)
?? await this.eventSessionClientIdStore.getOrCreate(workspaceId);
this.eventSessionClientIds.set(workspaceId, clientSessionId);
const session = await this.requestJson<ServerAgentSession>('/api/agents/sessions', {
method: 'POST',
body: JSON.stringify({
client_session_id: clientSessionId,
runtime: 'design',
runtime_version: 'v1',
binding: {
kind: 'design_workspace',
key: workspaceId,
},
}),
});
if (session.status !== 'active') {
throw new DesignWorkspaceModuleError(
409,
'DESIGN_EVENT_SESSION_CLOSED',
'AI 设计任务状态会话已关闭',
);
}
return session;
})().catch(async (error) => {
this.eventSessions.delete(workspaceId);
if (error instanceof DesignWorkspaceModuleError
&& error.code === 'DESIGN_EVENT_SESSION_CLOSED') {
await this.rotateEventSession(workspaceId);
}
throw error;
});
this.eventSessions.set(workspaceId, pending);
return pending;
return refreshed;
}
private createEventStreamTicket(sessionId: string): Promise<ServerAgentStreamTicket> {
@@ -1270,13 +1309,18 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
);
}
private registerEventSubscription(workspaceId: string, close: () => void): () => void {
const closers = this.eventSubscriptionClosers.get(workspaceId) ?? new Set<() => void>();
private registerEventSubscription(
workspaceId: string,
conversationId: string,
close: () => void,
): () => void {
const key = this.conversationKey(workspaceId, conversationId);
const closers = this.eventSubscriptionClosers.get(key) ?? new Set<() => void>();
closers.add(close);
this.eventSubscriptionClosers.set(workspaceId, closers);
this.eventSubscriptionClosers.set(key, closers);
return () => {
closers.delete(close);
if (closers.size === 0) this.eventSubscriptionClosers.delete(workspaceId);
if (closers.size === 0) this.eventSubscriptionClosers.delete(key);
};
}
@@ -1314,27 +1358,4 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
for (const resolve of waiters) resolve(run);
}
private async closeEventSession(sessionId: string): Promise<void> {
try {
await this.requestJson<ServerAgentSession>(
`/api/agents/sessions/${encodeURIComponent(sessionId)}`,
{ method: 'DELETE' },
);
} catch (error) {
if (error instanceof DesignWorkspaceModuleError
&& (error.status === 404 || error.status === 409)) return;
throw error;
}
}
private invalidateEventSession(workspaceId: string): Promise<void> {
return this.rotateEventSession(workspaceId).then(() => undefined);
}
private async rotateEventSession(workspaceId: string): Promise<string> {
this.eventSessions.delete(workspaceId);
const clientSessionId = await this.eventSessionClientIdStore.rotate(workspaceId);
this.eventSessionClientIds.set(workspaceId, clientSessionId);
return clientSessionId;
}
}