需求:设计 Agent 对话与确认生成的回复需要实时展示。 实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
1125 lines
36 KiB
TypeScript
1125 lines
36 KiB
TypeScript
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;
|
||
}
|
||
}
|