Files
makelore/electron/image-workspace/works-square-workspace.ts
brother7 90a249db31 feat: 流式展示设计 Agent 对话
需求:设计 Agent 对话与确认生成的回复需要实时展示。

实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
2026-08-02 21:21:50 +08:00

1125 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>;
rotate(workspaceId: string): Promise<string>;
};
};
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<void>;
};
type AgentWebSocketFactory = (
url: string,
) => AgentWebSocketConnection | Promise<AgentWebSocketConnection>;
type TaskEventQueue = {
events: AsyncIterable<DesignWorkspaceEvent>;
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<string, unknown>;
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<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))
&& 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<void>((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<unknown> {
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<string, unknown>).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<string, string> = {
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<string, Promise<ServerAgentSession>>();
private readonly eventSessionClientIds = new Map<string, string>();
private readonly eventSubscriptionClosers = new Map<string, Set<() => 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<DesignWorkspaceBootstrap> {
const [capabilities, workspaces] = await Promise.all([
this.getCapabilities(),
this.requestJson<ServerWorkspaceSummary[]>('/api/design/workspaces?limit=100&offset=0'),
]);
this.eventSessionsEnabled = true;
return {
capabilities,
workspaces: workspaces.map(mapWorkspaceSummary),
};
}
getCapabilities(): Promise<DesignCapabilities> {
return this.requestJson<DesignCapabilities>('/api/design/capabilities');
}
async createWorkspace(input: DesignCreateWorkspaceInput): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>('/api/design/workspaces', {
method: 'POST',
body: JSON.stringify({
client_workspace_id: input.clientWorkspaceId,
title: input.title,
}),
});
return mapWorkspace(workspace);
}
async renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}`,
{
method: 'PATCH',
body: JSON.stringify({ title: input.title }),
},
);
return mapWorkspace(workspace);
}
async getWorkspace(workspaceId: string): Promise<DesignWorkspace> {
const workspace = await this.requestJson<ServerWorkspace>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}`,
);
return mapWorkspace(workspace);
}
async submitMessage(input: DesignSubmitMessageInput): Promise<DesignWorkspace> {
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<DesignWorkspace> {
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<DesignWorkspace> {
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<ServerAgentCommand> {
return this.requestJson<ServerAgentCommand>(
`/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<ServerAgentRun> {
const deadline = Date.now() + AGENT_RUN_TIMEOUT_MS;
while (true) {
const run = await this.requestJson<ServerAgentRun>(
`/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<void>((resolve) => setTimeout(resolve, AGENT_RUN_POLL_INTERVAL_MS));
}
}
async listTasks(workspaceId: string): Promise<DesignGenerationTask[]> {
const tasks = await this.requestJson<ServerTask[]>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/generation-tasks?limit=100&offset=0`,
);
return tasks.map(mapTask);
}
async openWorkspaceEvents(
input: DesignWorkspaceEventSubscriptionInput,
): Promise<DesignWorkspaceEventSubscription> {
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<typeof setInterval> | null = null;
let unregister = () => undefined;
let resolveOpened: () => void = () => undefined;
let rejectOpened: (error: unknown) => void = () => undefined;
const opened = new Promise<void>((resolve, reject) => {
resolveOpened = resolve;
rejectOpened = reject;
});
const unavailableError = () => new DesignWorkspaceModuleError(
502,
'DESIGN_EVENT_STREAM_UNAVAILABLE',
'AI 设计任务状态连接已断开',
);
const settle = async (error: unknown | null): Promise<void> => {
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>,
): 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<void> {
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<Response> {
return this.authorizedFetch(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(assetId)}/content`,
range ? { headers: { Range: range } } : {},
);
}
private async requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
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<Response> {
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<Response> {
return this.fetchImpl(`${this.apiBaseUrl}${path}`, {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${token}`,
},
});
}
private ensureEventSession(workspaceId: string): Promise<ServerAgentSession> {
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<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;
}
private createEventStreamTicket(sessionId: string): Promise<ServerAgentStreamTicket> {
return this.requestJson<ServerAgentStreamTicket>(
`/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<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;
}
}