import type { DesignAsset, DesignAssistantDeltaEvent, DesignBrief, DesignCapabilities, DesignConfirmGenerationInput, DesignCreateWorkspaceInput, DesignGenerationQuote, DesignGenerationTask, DesignGenerationTasksSnapshotEvent, DesignGenerationTaskUpdatedEvent, DesignMessage, DesignRenameWorkspaceInput, DesignSubmitMessageInput, DesignWorkspace, DesignWorkspaceBootstrap, 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'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getValidWorksSquareAccessToken } from '../services/works-square-session'; import { DesignWorkspaceModuleError, type DesignWorkspaceEventSubscription, type DesignWorkspaceEventSubscriptionInput, type DesignWorkspaceModule, } from './module'; type ServerBrief = { version: number; status: DesignBrief['status']; medium: DesignBrief['medium']; summary: string; ready: boolean; missing_decision: string | null; }; type ServerQuote = { quote_id: string; status: DesignGenerationQuote['status']; medium: DesignGenerationQuote['medium']; brief_version: number; brief_summary: string; quoted_design_points: number; expires_at: string; }; type ServerMessage = { role: DesignMessage['role']; kind: DesignMessage['kind']; text: string; quick_replies: string[]; generation_quote: ServerQuote | null; turn_revision: number; created_at: string; }; type ServerWorkspaceSummary = { workspace_id: string; title: string; turn_revision: number; view_revision: number; phase: DesignWorkspaceSummary['phase']; brief: ServerBrief; updated_at: string; }; type ServerWorkspace = ServerWorkspaceSummary & { messages: ServerMessage[]; }; type ServerAsset = { asset_id: string; media_type: DesignAsset['mediaType']; mime_type: string; width: number; height: number; duration_milliseconds: number | null; created_at: string; }; type ServerTask = { task_id: string; workspace_id: string; medium: DesignGenerationTask['medium']; status: DesignGenerationTask['status']; brief_version: number; brief_summary: string; quote_id: string | null; quoted_design_points: number | null; failure_code: string | null; result_assets: ServerAsset[]; created_at: string; updated_at: string; }; type ServerErrorDetail = { code?: unknown; message?: unknown; }; type WorksSquareDesignWorkspaceOptions = { apiBaseUrl?: string; fetchImpl?: typeof fetch; clientInstanceId?: string; webSocketFactory?: AgentWebSocketFactory; eventSessionClientIdStore?: { getOrCreate(workspaceId: string): Promise; rotate(workspaceId: string): Promise; }; }; type ServerAgentSession = { session_id: string; status: 'active' | 'closed'; }; type ServerAgentStreamTicket = { stream_url: string; }; type ServerAgentEvent = { session_id: string; sequence: number; runtime: string; type: string; schema_version: number; payload: unknown; }; type ServerAgentCommand = { run_id: string; status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; error: ServerAgentCommandError | null; }; type ServerAgentCommandError = { code: string; message: string; retryable: boolean; }; type ServerAgentRun = { run_id: string; status: 'queued' | 'running' | 'cancel_requested' | 'succeeded' | 'failed' | 'cancelled'; error: ServerAgentCommandError | null; }; type AgentDesignTurnSubmission = { workspaceId: string; clientTurnId: string; expectedTurnRevision: number; message: string; attachmentAssetIds: string[]; action: null | { type: 'confirm_generation'; quote_id: string; }; }; type AgentWebSocket = { readyState: number; onopen: (() => void) | null; onmessage: ((event: { data: unknown }) => void) | null; onerror: ((event: unknown) => void) | null; onclose: ((event: { code: number; reason: string }) => void) | null; send(data: string): void; close(code?: number, reason?: string): void; }; type AgentWebSocketConnection = { socket: AgentWebSocket; dispose?: () => void | Promise; }; type AgentWebSocketFactory = ( url: string, ) => AgentWebSocketConnection | Promise; type TaskEventQueue = { events: AsyncIterable; push(event: DesignWorkspaceEvent): void; finish(): void; fail(error: unknown): void; }; const AGENT_WEBSOCKET_OPEN = 1; const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000; const AGENT_RUN_POLL_INTERVAL_MS = 250; const AGENT_RUN_TIMEOUT_MS = 120_000; function mapBrief(brief: ServerBrief): DesignBrief { return { version: brief.version, status: brief.status, medium: brief.medium, summary: brief.summary, ready: brief.ready, missingDecision: brief.missing_decision, }; } function mapQuote(quote: ServerQuote | null): DesignGenerationQuote | null { if (!quote) return null; return { quoteId: quote.quote_id, status: quote.status, medium: quote.medium, briefVersion: quote.brief_version, briefSummary: quote.brief_summary, quotedDesignPoints: quote.quoted_design_points, expiresAt: quote.expires_at, }; } function mapWorkspaceSummary(workspace: ServerWorkspaceSummary): DesignWorkspaceSummary { return { workspaceId: workspace.workspace_id, title: workspace.title, turnRevision: workspace.turn_revision, viewRevision: workspace.view_revision, phase: workspace.phase, brief: mapBrief(workspace.brief), updatedAt: workspace.updated_at, }; } function mapWorkspace(workspace: ServerWorkspace): DesignWorkspace { return { ...mapWorkspaceSummary(workspace), messages: workspace.messages.map((message, index) => ({ id: `${workspace.workspace_id}:${message.turn_revision}:${message.role}:${index}`, role: message.role, kind: message.kind, text: message.text, quickReplies: message.quick_replies, generationQuote: mapQuote(message.generation_quote), turnRevision: message.turn_revision, createdAt: message.created_at, })), }; } function mapTask(task: ServerTask): DesignGenerationTask { return { taskId: task.task_id, workspaceId: task.workspace_id, medium: task.medium, status: task.status, briefVersion: task.brief_version, briefSummary: task.brief_summary, quoteId: task.quote_id, quotedDesignPoints: task.quoted_design_points, failureCode: task.failure_code, resultAssets: task.result_assets.map((asset) => ({ assetId: asset.asset_id, workspaceId: task.workspace_id, mediaType: asset.media_type, mimeType: asset.mime_type, width: asset.width, height: asset.height, durationMilliseconds: asset.duration_milliseconds, createdAt: asset.created_at, contentPath: designAssetContentPath(task.workspace_id, asset.asset_id), })), createdAt: task.created_at, updatedAt: task.updated_at, }; } 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; return typeof task.task_id === 'string' && typeof task.workspace_id === 'string' && (task.medium === 'image' || task.medium === 'video') && ['queued', 'running', 'succeeded', 'failed', 'cancelled'].includes(String(task.status)) && typeof task.brief_version === 'number' && typeof task.brief_summary === 'string' && Array.isArray(task.result_assets) && typeof task.created_at === 'string' && typeof task.updated_at === 'string'; } function isServerWorkspace(value: unknown): value is ServerWorkspace { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const workspace = value as Record; const brief = workspace.brief as Record | 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)) && Boolean(brief) && Number.isInteger(brief?.version) && ['draft', 'ready', 'confirmed'].includes(String(brief?.status)) && (brief?.medium === null || brief?.medium === 'image' || brief?.medium === 'video') && 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) => { if (!message || typeof message !== 'object' || Array.isArray(message)) return false; const item = message as Record; return (item.role === 'user' || item.role === 'assistant') && ['user', 'reply', 'choice', 'confirmation', 'safety_redirect', 'failure'] .includes(String(item.kind)) && typeof item.text === 'string' && Array.isArray(item.quick_replies) && item.quick_replies.every((reply) => typeof reply === 'string') && Number.isInteger(item.turn_revision) && Number(item.turn_revision) >= 0 && typeof item.created_at === 'string'; }) && typeof workspace.updated_at === 'string'; } function normalizeWorkspaceEvent( value: unknown, sessionId: string, workspaceId: string, ): DesignWorkspaceEvent | null { try { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const event = value as ServerAgentEvent; if (event.session_id !== sessionId || !Number.isInteger(event.sequence) || event.sequence < 1 || event.runtime !== 'design' || event.schema_version !== 1 || !event.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)) { return null; } const payload = event.payload as Record; if (event.type === 'design.assistant.delta') { if (payload.workspace_id !== workspaceId || typeof payload.client_turn_id !== 'string' || payload.client_turn_id.length < 1 || payload.client_turn_id.length > 128 || !Number.isInteger(payload.turn_revision) || Number(payload.turn_revision) < 1 || !Number.isInteger(payload.chunk_index) || Number(payload.chunk_index) < 0 || typeof payload.delta !== 'string' || payload.delta.length < 1 || payload.delta.length > 128) { return null; } return { id: `${sessionId}:${event.sequence}`, type: 'design.assistant.delta', workspaceId, clientTurnId: payload.client_turn_id, turnRevision: Number(payload.turn_revision), chunkIndex: Number(payload.chunk_index), delta: payload.delta, } satisfies DesignAssistantDeltaEvent; } if (event.type === 'design.generation_task.updated') { if (payload.workspace_id !== workspaceId || !Number.isInteger(payload.workspace_view_revision) || Number(payload.workspace_view_revision) < 1 || !isServerTask(payload.generation_task) || payload.generation_task.workspace_id !== workspaceId) { return null; } return { id: `${sessionId}:${event.sequence}`, type: 'design.generation_task.updated', workspaceId, workspaceViewRevision: Number(payload.workspace_view_revision), generationTask: mapTask(payload.generation_task), } satisfies DesignGenerationTaskUpdatedEvent; } if (event.type !== 'design.workspace.updated' || !isServerWorkspace(payload.workspace) || !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 || !payload.generation_tasks.every( (task) => isServerTask(task) && task.workspace_id === workspaceId, )) { return null; } return { id: `${sessionId}:${event.sequence}`, type: 'design.generation_tasks.snapshot', workspaceId, workspaceViewRevision: Number(workspace.view_revision), workspace: mapWorkspace(workspace), generationTasks: payload.generation_tasks.map((task) => mapTask(task as ServerTask)), } satisfies DesignGenerationTasksSnapshotEvent; } catch { return null; } } function agentEventFromWebSocketFrame(data: unknown): unknown | null { if (typeof data !== 'string') return null; try { const frame = JSON.parse(data) as Record; return frame && typeof frame === 'object' && !Array.isArray(frame) && frame.type === 'event' ? frame.event ?? null : null; } catch { return null; } } function createTaskEventQueue(): TaskEventQueue { const queued: DesignWorkspaceEvent[] = []; const waiters: Array<() => void> = []; let finished = false; let failed = false; let failure: unknown; const wake = () => { for (const resolve of waiters.splice(0)) resolve(); }; return { events: { async *[Symbol.asyncIterator]() { while (true) { const event = queued.shift(); if (event) { yield event; continue; } if (failed) throw failure; if (finished) return; await new Promise((resolve) => waiters.push(resolve)); } }, }, push(event) { if (finished || failed) return; queued.push(event); wake(); }, finish() { if (finished || failed) return; finished = true; wake(); }, fail(error) { if (finished || failed) return; failed = true; failure = error; wake(); }, }; } function webSocketCloseError(code: number): DesignWorkspaceModuleError | null { if (code === 1000 || code === 1001) return null; if (code === 4401) { return new DesignWorkspaceModuleError( 401, 'DESIGN_EVENT_TICKET_INVALID', 'AI 设计任务连接凭证已失效', ); } if (code === 4404) { return new DesignWorkspaceModuleError( 404, 'DESIGN_EVENT_SESSION_NOT_FOUND', 'AI 设计任务状态会话不存在', ); } if (code === 4409) { return new DesignWorkspaceModuleError( 410, 'DESIGN_EVENT_CURSOR_EXPIRED', 'AI 设计任务状态断点已过期', ); } return new DesignWorkspaceModuleError( 502, 'DESIGN_EVENT_STREAM_UNAVAILABLE', 'AI 设计任务状态连接已断开', ); } function defaultAgentWebSocketFactory(url: string): AgentWebSocketConnection { return { socket: new WebSocket(url) as unknown as AgentWebSocket, }; } function eventSequence(afterEventId: string | undefined, sessionId: string): number { if (!afterEventId?.startsWith(`${sessionId}:`)) return 0; const sequence = Number(afterEventId.slice(sessionId.length + 1)); return Number.isSafeInteger(sequence) && sequence >= 0 ? sequence : 0; } async function readPayload(response: Response): Promise { const text = await response.text(); if (!text.trim()) return null; try { return JSON.parse(text) as unknown; } catch { return text; } } function asErrorDetail(payload: unknown): ServerErrorDetail { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return {}; const detail = (payload as Record).detail; if (detail && typeof detail === 'object' && !Array.isArray(detail)) { return detail as ServerErrorDetail; } return payload as ServerErrorDetail; } function userFacingErrorMessage(code: string, fallback: string): string { const messages: Record = { workspace_not_found: '设计项目不存在或无权访问', workspace_revision_conflict: '设计项目已更新,请刷新后重试', idempotency_conflict: '这次操作与已经提交的请求冲突', reference_asset_invalid: '所选参考资产不可用', generation_quote_expired: '当前生成方案已过期,请让 Agent 重新确认', generation_quote_consumed: '当前生成方案已经确认过', generation_quote_invalid: '当前生成方案已失效,请让 Agent 重新确认', budget_denied: '当前设计点不足,无法开始生成', policy_blocked: '当前内容不符合创作安全规则', design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试', design_runtime_unavailable: 'AI 设计服务暂时不可用', design_production_unavailable: '当前生成能力暂时不可用', agent_command_invalid: '设计请求内容无效,请检查后重试', }; return messages[code] ?? fallback; } function agentRunErrorStatus(code: string): number { if (code === 'workspace_not_found') return 404; if (code === 'budget_denied') return 402; if (code === 'workspace_revision_conflict' || code === 'idempotency_conflict' || code === 'generation_quote_expired' || code === 'generation_quote_consumed' || code === 'generation_quote_invalid') { return 409; } if (code === 'agent_command_invalid' || code === 'reference_asset_invalid' || code === 'policy_blocked') { return 422; } if (code === 'design_reasoner_unavailable' || code === 'design_runtime_unavailable' || code === 'design_production_unavailable') { return 503; } return 502; } 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>(); private readonly eventSessionClientIds = new Map(); private readonly eventSubscriptionClosers = new Map void>>(); private eventSessionsEnabled = true; constructor(options: WorksSquareDesignWorkspaceOptions = {}) { 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 { const [capabilities, workspaces] = await Promise.all([ this.getCapabilities(), this.requestJson('/api/design/workspaces?limit=100&offset=0'), ]); this.eventSessionsEnabled = true; return { capabilities, workspaces: workspaces.map(mapWorkspaceSummary), }; } getCapabilities(): Promise { return this.requestJson('/api/design/capabilities'); } async createWorkspace(input: DesignCreateWorkspaceInput): Promise { const workspace = await this.requestJson('/api/design/workspaces', { method: 'POST', body: JSON.stringify({ client_workspace_id: input.clientWorkspaceId, title: input.title, }), }); return mapWorkspace(workspace); } async renameWorkspace(input: DesignRenameWorkspaceInput): Promise { const workspace = await this.requestJson( `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}`, { method: 'PATCH', body: JSON.stringify({ title: input.title }), }, ); return mapWorkspace(workspace); } async getWorkspace(workspaceId: string): Promise { const workspace = await this.requestJson( `/api/design/workspaces/${encodeURIComponent(workspaceId)}`, ); return mapWorkspace(workspace); } async submitMessage(input: DesignSubmitMessageInput): Promise { return this.executeAgentTurn({ workspaceId: input.workspaceId, clientTurnId: input.clientTurnId, expectedTurnRevision: input.expectedTurnRevision, message: input.message, attachmentAssetIds: input.attachmentAssetIds ?? [], action: null, }); } async confirmGeneration(input: DesignConfirmGenerationInput): Promise { return this.executeAgentTurn({ workspaceId: input.workspaceId, clientTurnId: input.clientTurnId, expectedTurnRevision: input.expectedTurnRevision, message: '确认生成', attachmentAssetIds: [], action: { type: 'confirm_generation', quote_id: input.quoteId, }, }); } private async executeAgentTurn(input: AgentDesignTurnSubmission): Promise { let session = await this.ensureEventSession(input.workspaceId); let command: ServerAgentCommand; try { command = await this.submitTurnCommand(session.session_id, 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); } const run = await this.waitForAgentRun(session.session_id, command.run_id); if (run.status !== 'succeeded') { const code = run.error?.code ?? ( run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed' ); const fallback = run.error?.message ?? ( run.status === 'cancelled' ? '设计 Agent 请求已取消' : '设计 Agent 暂时不可用,请稍后重试' ); throw new DesignWorkspaceModuleError( agentRunErrorStatus(code), code, userFacingErrorMessage(code, fallback), ); } return this.getWorkspace(input.workspaceId); } private submitTurnCommand( sessionId: string, input: AgentDesignTurnSubmission, ): Promise { return this.requestJson( `/api/agents/sessions/${encodeURIComponent(sessionId)}/commands`, { method: 'POST', body: JSON.stringify({ client_command_id: input.clientTurnId, name: 'turn.submit', input: { expected_turn_revision: input.expectedTurnRevision, message: input.message, attachment_asset_ids: input.attachmentAssetIds, action: input.action, }, }), }, ); } private async waitForAgentRun(sessionId: string, runId: string): Promise { const deadline = Date.now() + AGENT_RUN_TIMEOUT_MS; while (true) { const run = await this.requestJson( `/api/agents/sessions/${encodeURIComponent(sessionId)}/runs/${encodeURIComponent(runId)}`, ); if (run.status === 'succeeded' || run.status === 'failed' || run.status === 'cancelled') { return run; } if (Date.now() >= deadline) { throw new DesignWorkspaceModuleError( 504, 'design_agent_run_timeout', '设计 Agent 响应超时,请稍后重试', ); } await new Promise((resolve) => setTimeout(resolve, AGENT_RUN_POLL_INTERVAL_MS)); } } async listTasks(workspaceId: string): Promise { const tasks = await this.requestJson( `/api/design/workspaces/${encodeURIComponent(workspaceId)}/generation-tasks?limit=100&offset=0`, ); return tasks.map(mapTask); } async openWorkspaceEvents( input: DesignWorkspaceEventSubscriptionInput, ): Promise { let session = await this.ensureEventSession(input.workspaceId); let ticket: ServerAgentStreamTicket; try { ticket = await this.createEventStreamTicket(session.session_id); } catch (error) { if (!(error instanceof DesignWorkspaceModuleError) || (error.status !== 404 && error.status !== 409)) { throw error; } await this.invalidateEventSession(input.workspaceId); session = await this.ensureEventSession(input.workspaceId); ticket = await this.createEventStreamTicket(session.session_id); } const streamUrl = new URL(ticket.stream_url, `${this.apiBaseUrl}/`); if (streamUrl.origin !== new URL(this.apiBaseUrl).origin) { throw new DesignWorkspaceModuleError( 502, 'DESIGN_EVENT_STREAM_INVALID', 'AI 设计任务状态流地址无效', ); } if (streamUrl.protocol !== 'http:' && streamUrl.protocol !== 'https:') { throw new DesignWorkspaceModuleError( 502, 'DESIGN_EVENT_STREAM_INVALID', 'AI 设计任务状态流协议无效', ); } streamUrl.searchParams.set( 'after_sequence', String(eventSequence(input.afterEventId, session.session_id)), ); streamUrl.protocol = streamUrl.protocol === 'https:' ? 'wss:' : 'ws:'; let connection: AgentWebSocketConnection; try { connection = await this.webSocketFactory(streamUrl.toString()); } catch { throw new DesignWorkspaceModuleError( 502, 'DESIGN_EVENT_STREAM_UNAVAILABLE', 'AI 设计任务状态连接失败', ); } const { socket } = connection; const queue = createTaskEventQueue(); let didOpen = false; let ending = false; let settled = false; let heartbeat: ReturnType | null = null; let unregister = () => undefined; let resolveOpened: () => void = () => undefined; let rejectOpened: (error: unknown) => void = () => undefined; const opened = new Promise((resolve, reject) => { resolveOpened = resolve; rejectOpened = reject; }); const unavailableError = () => new DesignWorkspaceModuleError( 502, 'DESIGN_EVENT_STREAM_UNAVAILABLE', 'AI 设计任务状态连接已断开', ); const settle = async (error: unknown | null): Promise => { if (settled) return; settled = true; socket.onopen = null; socket.onmessage = null; socket.onerror = null; socket.onclose = null; if (heartbeat !== null) clearInterval(heartbeat); heartbeat = null; unregister(); try { await connection.dispose?.(); } catch { // The stream outcome is authoritative; dispatcher cleanup is best effort. } if (!didOpen) { rejectOpened(error ?? unavailableError()); } else if (error) { queue.fail(error); } else { queue.finish(); } }; const beginEnd = ( error: unknown | null, cleanup?: () => void | Promise, ): void => { if (ending) return; ending = true; void (async () => { let finalError = error; try { await cleanup?.(); } catch (cleanupError) { finalError ??= cleanupError; } await settle(finalError); })(); }; const close = (): void => { if (ending) return; beginEnd(null); try { socket.close(1000, 'Client closed design event stream'); } catch { // The local queue is already closed. } }; socket.onopen = () => { if (ending) return; didOpen = true; heartbeat = setInterval(() => { if (ending || socket.readyState !== AGENT_WEBSOCKET_OPEN) return; try { socket.send(JSON.stringify({ type: 'ping', request_id: `design-ping-${Date.now()}`, })); } catch { beginEnd(unavailableError()); } }, AGENT_WEBSOCKET_PING_INTERVAL_MS); resolveOpened(); }; socket.onmessage = ({ data }) => { if (ending) return; const agentEvent = agentEventFromWebSocketFrame(data); const event = normalizeWorkspaceEvent( agentEvent, session.session_id, input.workspaceId, ); if (event) queue.push(event); }; socket.onerror = () => { setTimeout(() => { if (!ending) beginEnd(unavailableError()); }, 0); }; 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); } }); return; } if (code === 4404) { beginEnd(error, () => this.invalidateEventSession(input.workspaceId)); return; } beginEnd(error); }; unregister = this.registerEventSubscription(input.workspaceId, close); await opened; return { events: queue.events, close, }; } async closeEventSessions(): Promise { this.eventSessionsEnabled = false; const activeSubscriptions = [...this.eventSubscriptionClosers.values()] .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 { return this.authorizedFetch( `/api/design/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(assetId)}/content`, range ? { headers: { Range: range } } : {}, ); } private async requestJson(path: string, init: RequestInit = {}): Promise { const response = await this.authorizedFetch(path, { ...init, headers: { Accept: 'application/json', ...(init.body ? { 'Content-Type': 'application/json' } : {}), ...(init.headers ?? {}), }, }); const payload = await readPayload(response); if (!response.ok) { const detail = asErrorDetail(payload); const code = typeof detail.code === 'string' ? detail.code : 'DESIGN_WORKSPACE_REQUEST_FAILED'; const fallback = typeof detail.message === 'string' ? detail.message : `AI 设计请求失败(${response.status})`; throw new DesignWorkspaceModuleError( response.status, code, userFacingErrorMessage(code, fallback), ); } return payload as T; } private async authorizedFetch(path: string, init: RequestInit = {}): Promise { let token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl }); if (!token) { throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计'); } let response = await this.fetchWithToken(path, token, init); if (response.status !== 401) return response; token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl, forceRefresh: true, }); if (!token) { throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录'); } response = await this.fetchWithToken(path, token, init); return response; } private fetchWithToken(path: string, token: string, init: RequestInit): Promise { return this.fetchImpl(`${this.apiBaseUrl}${path}`, { ...init, headers: { ...init.headers, Authorization: `Bearer ${token}`, }, }); } private ensureEventSession(workspaceId: string): Promise { if (!this.eventSessionsEnabled) { throw new DesignWorkspaceModuleError( 503, 'DESIGN_EVENT_STREAM_PAUSED', 'AI 设计任务状态流已暂停', ); } 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('/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; } private createEventStreamTicket(sessionId: string): Promise { return this.requestJson( `/api/agents/sessions/${encodeURIComponent(sessionId)}/stream-tickets`, { method: 'POST', body: JSON.stringify({ transport: 'websocket' }), }, ); } private registerEventSubscription(workspaceId: string, close: () => void): () => void { const closers = this.eventSubscriptionClosers.get(workspaceId) ?? new Set<() => void>(); closers.add(close); this.eventSubscriptionClosers.set(workspaceId, closers); return () => { closers.delete(close); if (closers.size === 0) this.eventSubscriptionClosers.delete(workspaceId); }; } private async closeEventSession(sessionId: string): Promise { try { await this.requestJson( `/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 { return this.rotateEventSession(workspaceId).then(() => undefined); } private async rotateEventSession(workspaceId: string): Promise { this.eventSessions.delete(workspaceId); const clientSessionId = await this.eventSessionClientIdStore.rotate(workspaceId); this.eventSessionClientIds.set(workspaceId, clientSessionId); return clientSessionId; } }