Files
makelore/electron/image-workspace/works-square-workspace.ts

961 lines
37 KiB
TypeScript

import WebSocket from 'ws';
import {
designAssetContentPath,
type DesignAsset,
type DesignAssetUploadInput,
type DesignAssistantDeltaEvent,
type DesignCapabilities,
type DesignChangeSet,
type DesignCommandInput,
type DesignCommandResult,
type DesignCompilationIssue,
type DesignCreateWorkspaceInput,
type DesignDecisionPrompt,
type DesignDeleteWorkspaceResult,
type DesignDirectionUpdatedEvent,
type DesignForm,
type DesignGenerationQuote,
type DesignGenerationTask,
type DesignInteractionOutcome,
type DesignQuoteBlockedEvent,
type DesignQuoteOutputSummary,
type DesignRenameWorkspaceInput,
type DesignSessionSnapshotEvent,
type DesignSpecification,
type DesignTurn,
type DesignUserInput,
type DesignWorkspace,
type DesignWorkspaceBootstrap,
type DesignWorkspaceEvent,
type DesignWorkspaceEventSubscription,
type DesignWorkspaceEventSubscriptionInput,
type DesignWorkspaceSummary,
type DesignWorkspaceUpdatedEvent,
} from '../../shared/image-workspace';
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
import { getValidWorksSquareAccessToken } from '../services/works-square-session';
import {
fetchWithDeadline,
proxyAwareFetch,
RequestDeadlineExceededError,
runWithDeadline,
} from '../utils/proxy-fetch';
import {
DesignWorkspaceModuleError,
type DesignWorkspaceModule,
} from './module';
type WorksSquareDesignWorkspaceOptions = {
apiBaseUrl?: string;
fetchImpl?: typeof fetch;
webSocketFactory?: AgentWebSocketFactory;
requestTimeoutMs?: number;
};
type ServerErrorDetail = { code?: unknown; message?: unknown };
type ServerAgentCommandError = { code: string; message: string; retryable: boolean };
type ServerAgentCommand = { run_id: string; status: string; error: ServerAgentCommandError | null };
type ServerAgentRun = {
run_id: string;
status: 'queued' | 'running' | 'cancel_requested' | 'succeeded' | 'failed' | 'cancelled';
error: ServerAgentCommandError | null;
};
type ServerAgentStreamTicket = { stream_url: string };
type ServerAgentEvent = {
session_id: string;
sequence: number;
runtime: string;
type: string;
schema_version: number;
payload: unknown;
};
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>;
const REQUEST_TIMEOUT_MS = 30_000;
const RUN_TIMEOUT_MS = 10 * 60_000;
const RUN_INITIAL_POLL_MS = 500;
const RUN_MAX_POLL_MS = 4_000;
const WEBSOCKET_OPEN_TIMEOUT_MS = 10_000;
const WEBSOCKET_PING_INTERVAL_MS = 20_000;
const WEBSOCKET_OPEN = 1;
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new DesignWorkspaceModuleError(
502,
'DESIGN_WORKSPACE_RESPONSE_INVALID',
'AI 设计服务返回了无效数据',
);
}
return value as Record<string, unknown>;
}
function stringValue(value: unknown, field: string): string {
if (typeof value !== 'string') {
throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', `${field} 无效`);
}
return value;
}
function numberValue(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', `${field} 无效`);
}
return value;
}
function arrayValue(value: unknown, field: string): unknown[] {
if (!Array.isArray(value)) {
throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', `${field} 无效`);
}
return value;
}
function nullableString(value: unknown): string | null {
return value === null || value === undefined ? null : stringValue(value, '可选文本');
}
function mapWorkspaceSummary(value: unknown): DesignWorkspaceSummary {
const item = record(value);
return {
workspaceId: stringValue(item.workspace_id, 'Workspace ID'),
clientWorkspaceId: stringValue(item.client_workspace_id, 'Client Workspace ID'),
title: stringValue(item.title, 'Workspace title'),
directionId: stringValue(item.direction_id, 'Direction ID'),
sessionId: stringValue(item.session_id, 'Session ID'),
directionRevision: numberValue(item.direction_revision, 'Direction revision'),
specificationRevision: numberValue(item.specification_revision, 'Specification revision'),
workspaceViewRevision: numberValue(item.workspace_view_revision, 'Workspace revision'),
updatedAt: stringValue(item.updated_at, 'Workspace updated time'),
};
}
function mapIssue(value: unknown): DesignCompilationIssue {
const issue = record(value);
const severity = issue.severity === 'blocker' ? 'blocker' : 'warning';
const sourcePaths = Array.isArray(issue.source_paths)
? issue.source_paths.filter((path): path is string => typeof path === 'string')
: [];
return {
code: stringValue(issue.code, 'Issue code'),
message: stringValue(issue.message, 'Issue message'),
severity,
path: sourcePaths[0] ?? null,
};
}
function mapOutputSummary(value: unknown): DesignQuoteOutputSummary {
const summary = record(value);
const medium = summary.medium;
if (medium !== 'image' && medium !== 'video') {
throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '设计媒介无效');
}
return {
medium,
strategy: summary.strategy === 'multi_step' ? 'multi_step' : 'direct',
aspectRatio: stringValue(summary.aspect_ratio, 'Aspect ratio'),
outputCount: numberValue(summary.output_count, 'Output count'),
deliveryFormat: nullableString(summary.delivery_format),
durationSeconds: summary.duration_seconds === null
? null
: numberValue(summary.duration_seconds, 'Duration'),
requiredOutputRoles: arrayValue(summary.required_output_roles, 'Output roles')
.map((role) => stringValue(role, 'Output role')),
};
}
function mapQuote(value: unknown): DesignGenerationQuote {
const quote = record(value);
const medium = quote.medium;
if (medium !== 'image' && medium !== 'video') {
throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '报价媒介无效');
}
const status = quote.status;
if (!['offered', 'consumed', 'expired', 'superseded'].includes(String(status))) {
throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '报价状态无效');
}
return {
quoteId: stringValue(quote.quote_id, 'Quote ID'),
status: status as DesignGenerationQuote['status'],
specificationRevision: numberValue(quote.specification_revision, 'Quote revision'),
specificationRevisionId: stringValue(quote.specification_revision_id, 'Quote spec ID'),
specificationDigest: stringValue(quote.specification_digest, 'Quote digest'),
medium,
compilerVersion: stringValue(quote.compiler_version, 'Compiler version'),
outputSummary: mapOutputSummary(quote.output_summary),
warnings: arrayValue(quote.warnings, 'Quote warnings').map(mapIssue),
quotedDesignPoints: numberValue(quote.quoted_design_points, 'Quoted points'),
maximumCustomerChargeAtoms: numberValue(
quote.maximum_customer_charge_atoms,
'Maximum charge',
),
expiresAt: stringValue(quote.expires_at, 'Quote expiry'),
};
}
function mapChangeSet(value: unknown): DesignChangeSet {
const changeSet = record(value);
return {
interaction_id: stringValue(changeSet.interaction_id, 'Interaction ID'),
changes: arrayValue(changeSet.changes, 'Changes') as DesignChangeSet['changes'],
};
}
function mapDecisionPrompt(value: unknown): DesignDecisionPrompt {
const prompt = record(value);
const content = record(prompt.content);
return {
id: stringValue(prompt.id, 'Prompt ID'),
kind: prompt.kind === 'question' ? 'question' : 'suggestion',
basedOnSpecificationRevisionId: stringValue(
prompt.based_on_specification_revision_id,
'Prompt revision',
),
targetPaths: arrayValue(prompt.target_paths, 'Prompt paths')
.map((path) => stringValue(path, 'Prompt path')),
title: stringValue(content.title, 'Prompt title'),
body: stringValue(content.body, 'Prompt body'),
options: arrayValue(content.options, 'Prompt options').map((rawOption) => {
const option = record(rawOption);
return {
id: stringValue(option.id, 'Option ID'),
label: stringValue(option.label, 'Option label'),
description: nullableString(option.description),
};
}),
};
}
function mapForm(value: unknown): DesignForm {
const form = record(value);
const specification = record(form.specification);
return {
schemaVersion: 1,
workspaceId: stringValue(form.workspace_id, 'Workspace ID'),
directionId: stringValue(form.direction_id, 'Direction ID'),
directionRevision: numberValue(form.direction_revision, 'Direction revision'),
rawTurnSequence: numberValue(form.raw_turn_sequence, 'Turn sequence'),
specificationRevision: numberValue(form.specification_revision, 'Specification revision'),
workspaceViewRevision: numberValue(form.workspace_view_revision, 'Workspace revision'),
specificationRevisionId: stringValue(form.specification_revision_id, 'Specification ID'),
specificationDigest: stringValue(form.specification_digest, 'Specification digest'),
specification: specification as unknown as DesignSpecification,
decisionPrompts: arrayValue(form.decision_prompts, 'Decision prompts').map(mapDecisionPrompt),
activeQuotes: arrayValue(form.active_quotes, 'Active quotes').map(mapQuote),
recentChangeSet: mapChangeSet(form.recent_change_set),
};
}
function mapTurn(value: unknown): DesignTurn {
const turn = record(value);
return {
turnId: stringValue(turn.turn_id, 'Turn ID'),
rawTurnSequence: numberValue(turn.raw_turn_sequence, 'Turn sequence'),
userMessage: stringValue(turn.user_message, 'User message'),
assistantMessage: stringValue(turn.assistant_message, 'Assistant message'),
};
}
function mapAsset(workspaceId: string, value: unknown): DesignAsset {
const asset = record(value);
const mediaType = asset.media_type;
if (mediaType !== 'image' && mediaType !== 'video') {
throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '素材类型无效');
}
const role = asset.role === 'generated' ? 'generated' : 'uploaded';
return {
assetId: stringValue(asset.asset_id, 'Asset ID'),
workspaceId,
role,
mediaType,
mimeType: stringValue(asset.mime_type, 'Asset MIME'),
width: numberValue(asset.width, 'Asset width'),
height: numberValue(asset.height, 'Asset height'),
durationMilliseconds: asset.duration_milliseconds === null
? null
: numberValue(asset.duration_milliseconds, 'Asset duration'),
generationTaskId: nullableString(asset.generation_task_id),
createdAt: stringValue(asset.created_at, 'Asset creation time'),
contentPath: designAssetContentPath(workspaceId, stringValue(asset.asset_id, 'Asset ID')),
};
}
function mapTask(value: unknown): DesignGenerationTask {
const task = record(value);
const medium = task.medium;
if (medium !== 'image' && medium !== 'video') {
throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '任务媒介无效');
}
const billing = record(task.customer_billing);
const progress = record(task.progress);
const cancellation = record(task.cancellation);
return {
taskId: stringValue(task.task_id, 'Task ID'),
quoteId: stringValue(task.quote_id, 'Quote ID'),
status: task.status as DesignGenerationTask['status'],
taskRevision: numberValue(task.task_revision, 'Task revision'),
specificationRevision: numberValue(task.specification_revision, 'Task spec revision'),
specificationRevisionId: stringValue(task.specification_revision_id, 'Task spec ID'),
specificationDigest: stringValue(task.specification_digest, 'Task digest'),
medium,
compilerVersion: stringValue(task.compiler_version, 'Task compiler'),
outputSummary: mapOutputSummary(task.output_summary),
maximumCustomerChargeAtoms: numberValue(task.maximum_customer_charge_atoms, 'Task charge'),
customerBilling: {
quotedAtoms: numberValue(billing.quoted_atoms, 'Quoted atoms'),
heldAtoms: numberValue(billing.held_atoms, 'Held atoms'),
chargedAtoms: numberValue(billing.charged_atoms, 'Charged atoms'),
refundedAtoms: numberValue(billing.refunded_atoms, 'Refunded atoms'),
pendingResolutionAtoms: numberValue(
billing.pending_resolution_atoms,
'Pending resolution atoms',
),
},
customerHoldExpiresAt: stringValue(task.customer_hold_expires_at, 'Hold expiry'),
resolutionDeadlineAt: nullableString(task.resolution_deadline_at),
progress: {
stage: progress.stage as DesignGenerationTask['progress']['stage'],
completedRequiredSteps: numberValue(progress.completed_required_steps, 'Completed steps'),
totalRequiredSteps: numberValue(progress.total_required_steps, 'Required steps'),
},
executionHealth: task.execution_health as DesignGenerationTask['executionHealth'],
cancellation: {
state: cancellation.state as DesignGenerationTask['cancellation']['state'],
outcome: cancellation.outcome as DesignGenerationTask['cancellation']['outcome'],
},
issues: arrayValue(task.issues, 'Task issues').map((rawIssue) => {
const issue = record(rawIssue);
return {
code: stringValue(issue.code, 'Task issue code'),
message: stringValue(issue.message, 'Task issue message'),
recoveryOwner: issue.recovery_owner as 'platform' | 'operator',
};
}),
resultAssetIds: arrayValue(task.result_asset_ids, 'Task assets')
.map((id) => stringValue(id, 'Task asset ID')),
nextActions: arrayValue(task.next_actions, 'Task actions') as DesignGenerationTask['nextActions'],
createdAt: stringValue(task.created_at, 'Task creation time'),
};
}
function mapWorkspace(value: unknown): DesignWorkspace {
const detail = record(value);
const summary = mapWorkspaceSummary(detail.workspace);
return {
workspace: summary,
form: mapForm(detail.form),
turns: arrayValue(detail.turns, 'Turns').map(mapTurn),
tasks: arrayValue(detail.tasks, 'Tasks').map(mapTask),
assets: arrayValue(detail.assets, 'Assets').map((asset) => mapAsset(summary.workspaceId, asset)),
};
}
function mapInteraction(value: unknown): DesignInteractionOutcome {
const operation = record(value);
return {
operationKind: operation.operation_kind as DesignInteractionOutcome['operationKind'],
interactionId: stringValue(operation.interaction_id, 'Interaction ID'),
baseDirectionRevision: numberValue(operation.base_direction_revision, 'Base revision'),
newDirectionRevision: numberValue(operation.new_direction_revision, 'New revision'),
rawTurnSequence: numberValue(operation.raw_turn_sequence, 'Turn sequence'),
specificationRevision: numberValue(operation.specification_revision, 'Spec revision'),
specificationRevisionId: stringValue(operation.specification_revision_id, 'Spec ID'),
workspaceViewRevision: numberValue(operation.workspace_view_revision, 'Workspace revision'),
specificationRevisionCreated: Boolean(operation.specification_revision_created),
meaningChanged: Boolean(operation.meaning_changed),
changeSet: mapChangeSet(operation.change_set),
turnId: nullableString(operation.turn_id),
assistantMessage: nullableString(operation.assistant_message),
createdDecisionPromptIds: arrayValue(
operation.created_decision_prompt_ids ?? [],
'Created prompt IDs',
).map((id) => stringValue(id, 'Prompt ID')),
resolvedDecisionPromptId: nullableString(operation.resolved_decision_prompt_id),
supersededDecisionPromptIds: arrayValue(
operation.superseded_decision_prompt_ids ?? [],
'Superseded prompt IDs',
).map((id) => stringValue(id, 'Prompt ID')),
supersededQuoteCount: numberValue(operation.superseded_quote_count, 'Superseded quotes'),
quoteId: nullableString(operation.quote_id),
generationTaskId: nullableString(operation.generation_task_id),
};
}
function agentEventFromFrame(data: unknown): unknown | null {
if (typeof data !== 'string') return null;
try {
const frame = JSON.parse(data) as Record<string, unknown>;
return frame?.type === 'event' ? frame.event ?? null : null;
} catch {
return null;
}
}
function normalizeWorkspaceEvent(
value: unknown,
sessionId: string,
workspaceId: string,
): DesignWorkspaceEvent | null {
try {
const event = record(value) as unknown as ServerAgentEvent;
if (event.session_id !== sessionId
|| event.runtime !== 'design'
|| event.schema_version !== 1
|| !Number.isInteger(event.sequence)
|| event.sequence < 1) return null;
const payload = record(event.payload);
const id = `${sessionId}:${event.sequence}`;
if (event.type === 'design.session.snapshot') {
return { id, type: event.type, form: mapForm(payload.form) } satisfies DesignSessionSnapshotEvent;
}
if (event.type === 'design.assistant.delta') {
if (payload.workspace_id !== workspaceId) return null;
return {
id,
type: event.type,
workspaceId,
directionId: stringValue(payload.direction_id, 'Direction ID'),
clientOperationId: stringValue(payload.client_operation_id, 'Operation ID'),
directionRevision: numberValue(payload.direction_revision, 'Direction revision'),
chunkIndex: numberValue(payload.chunk_index, 'Chunk index'),
delta: stringValue(payload.delta, 'Assistant delta'),
} satisfies DesignAssistantDeltaEvent;
}
if (event.type === 'design.direction.updated') {
const form = mapForm(payload.form);
if (form.workspaceId !== workspaceId) return null;
return {
id,
type: event.type,
replayed: Boolean(payload.replayed),
operation: mapInteraction(payload.operation),
form,
} satisfies DesignDirectionUpdatedEvent;
}
if (event.type === 'design.quote.blocked') {
const form = mapForm(payload.form);
if (form.workspaceId !== workspaceId) return null;
return {
id,
type: event.type,
clientOperationId: stringValue(payload.client_operation_id, 'Operation ID'),
blockers: arrayValue(payload.blockers, 'Quote blockers').map(mapIssue),
warnings: arrayValue(payload.warnings, 'Quote warnings').map(mapIssue),
form,
} satisfies DesignQuoteBlockedEvent;
}
if (event.type === 'design.workspace.updated') {
if (payload.workspace_id !== workspaceId) return null;
const changedDirection = payload.changed_direction
? record(payload.changed_direction)
: null;
return {
id,
type: event.type,
workspaceId,
workspaceViewRevision: numberValue(payload.workspace_view_revision, 'Workspace revision'),
changedDirection: changedDirection
? {
directionId: stringValue(changedDirection.direction_id, 'Direction ID'),
directionRevision: numberValue(
changedDirection.direction_revision,
'Direction revision',
),
specificationRevision: numberValue(
changedDirection.specification_revision,
'Specification revision',
),
}
: null,
changedTask: payload.changed_task ? mapTask(payload.changed_task) : null,
} satisfies DesignWorkspaceUpdatedEvent;
}
return null;
} catch {
return null;
}
}
function mapUserInput(input: DesignUserInput): Record<string, unknown> {
if (input.kind === 'direct_edit') return input;
if (input.kind === 'chat') return input;
if (input.kind === 'restore') {
return { kind: input.kind, specification_revision_id: input.specificationRevisionId };
}
return {
kind: input.kind,
prompt_id: input.promptId,
action: input.action,
option_id: input.optionId ?? null,
};
}
function agentCommand(input: DesignCommandInput): Record<string, unknown> {
if (input.kind === 'apply_input') {
return {
client_command_id: input.clientOperationId,
name: 'design.input.apply',
input: {
expected_direction_revision: input.expectedDirectionRevision,
client_operation_id: input.clientOperationId,
input: mapUserInput(input.input),
},
};
}
if (input.kind === 'request_quote') {
return {
client_command_id: input.clientOperationId,
name: 'design.quote.request',
input: {
expected_direction_revision: input.expectedDirectionRevision,
specification_revision: input.specificationRevision,
client_operation_id: input.clientOperationId,
},
};
}
return {
client_command_id: input.clientOperationId,
name: 'design.generation.confirm',
input: {
quote_id: input.quoteId,
expected_direction_revision: input.expectedDirectionRevision,
client_operation_id: input.clientOperationId,
},
};
}
function createEventQueue(): {
events: AsyncIterable<DesignWorkspaceEvent>;
push(event: DesignWorkspaceEvent): void;
finish(): void;
fail(error: unknown): void;
} {
const queued: DesignWorkspaceEvent[] = [];
const waiters: Array<() => void> = [];
let ended = false;
let failure: unknown;
const wake = () => waiters.splice(0).forEach((resolve) => resolve());
return {
events: {
async *[Symbol.asyncIterator]() {
while (true) {
const event = queued.shift();
if (event) {
yield event;
continue;
}
if (failure) throw failure;
if (ended) return;
await new Promise<void>((resolve) => waiters.push(resolve));
}
},
},
push(event) {
if (ended || failure) return;
queued.push(event);
wake();
},
finish() {
ended = true;
wake();
},
fail(error) {
failure = error;
wake();
},
};
}
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;
}
function defaultAgentWebSocketFactory(url: string): AgentWebSocketConnection {
return { socket: new WebSocket(url) as unknown as AgentWebSocket };
}
function webSocketCloseError(code: number): DesignWorkspaceModuleError | null {
if (code === 1000 || code === 1001) return null;
if (code === 4409) {
return new DesignWorkspaceModuleError(410, 'DESIGN_EVENT_CURSOR_EXPIRED', '设计状态断点已过期');
}
if (code === 4404) {
return new DesignWorkspaceModuleError(404, 'DESIGN_EVENT_SESSION_NOT_FOUND', '设计会话不存在');
}
return new DesignWorkspaceModuleError(502, 'DESIGN_EVENT_STREAM_UNAVAILABLE', '设计状态连接已断开');
}
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;
return detail && typeof detail === 'object' && !Array.isArray(detail)
? detail as ServerErrorDetail
: payload as ServerErrorDetail;
}
function userFacingErrorMessage(code: string): string {
const messages: Record<string, string> = {
design_direction_revision_conflict: '设计表单已更新,正在重新同步',
design_idempotency_conflict: '这次操作与已经受理的请求冲突',
design_quote_blocked: '当前设计信息还不足以生成,请先补全表单',
design_quote_expired: '当前报价已过期,请重新获取报价',
design_quote_consumed: '当前报价已经生成过',
budget_denied: '当前设计点不足,无法开始生成',
policy_blocked: '当前内容不符合创作安全规则',
design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试',
design_runtime_unavailable: 'AI 设计服务暂时不可用',
design_production_unavailable: '当前生成能力暂时不可用',
agent_runtime_unavailable: 'AI 设计服务暂时不可用,请稍后重试',
agent_command_invalid: '设计请求内容无效,请检查后重试',
};
return messages[code] ?? 'AI 设计请求失败,请稍后重试';
}
function agentRunErrorStatus(code: string): number {
if (code.includes('not_found')) return 404;
if (code === 'budget_denied') return 402;
if (code.includes('conflict') || code.includes('expired') || code.includes('consumed')) return 409;
if (code.includes('invalid') || code === 'policy_blocked' || code === 'design_quote_blocked') {
return 422;
}
if (code.includes('unavailable')) return 503;
return 502;
}
function designRequestTimeoutError(): DesignWorkspaceModuleError {
return new DesignWorkspaceModuleError(
504,
'DESIGN_WORKSPACE_REQUEST_TIMEOUT',
'AI 设计服务响应超时,请重试',
);
}
export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
private readonly apiBaseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly webSocketFactory: AgentWebSocketFactory;
private readonly requestTimeoutMs: number;
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 as typeof fetch);
this.webSocketFactory = options.webSocketFactory ?? defaultAgentWebSocketFactory;
this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
}
async bootstrap(): Promise<DesignWorkspaceBootstrap> {
const [capabilities, workspaces] = await Promise.all([
this.requestJson<DesignCapabilities>('/api/design/capabilities'),
this.requestJson<unknown[]>('/api/design/workspaces?limit=100&offset=0'),
]);
this.eventSessionsEnabled = true;
return { capabilities, workspaces: workspaces.map(mapWorkspaceSummary) };
}
async createWorkspace(input: DesignCreateWorkspaceInput): Promise<DesignWorkspace> {
const created = record(await this.requestJson<unknown>('/api/design/workspaces', {
method: 'POST',
body: JSON.stringify({
client_workspace_id: input.clientWorkspaceId,
title: input.title,
}),
}));
const snapshot = record(created.snapshot);
const form = mapForm(snapshot.form);
return this.getWorkspace(form.workspaceId);
}
async deleteWorkspace(workspaceId: string): Promise<DesignDeleteWorkspaceResult> {
await this.requestJson<unknown>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}`,
{ method: 'DELETE' },
);
this.closeWorkspaceSubscriptions(workspaceId);
return { workspaceId, deleted: true };
}
async renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace> {
await this.requestJson<unknown>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}`,
{ method: 'PATCH', body: JSON.stringify({ title: input.title }) },
);
return this.getWorkspace(input.workspaceId);
}
async getWorkspace(workspaceId: string): Promise<DesignWorkspace> {
return mapWorkspace(await this.requestJson<unknown>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}`,
));
}
async submitCommand(input: DesignCommandInput): Promise<DesignCommandResult> {
const command = await this.requestJson<ServerAgentCommand>(
`/api/agents/sessions/${encodeURIComponent(input.sessionId)}/commands`,
{ method: 'POST', body: JSON.stringify(agentCommand(input)) },
);
const run = await this.waitForRun(input.sessionId, command.run_id);
if (run.status !== 'succeeded') {
const code = run.error?.code ?? (
run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed'
);
throw new DesignWorkspaceModuleError(
agentRunErrorStatus(code),
code,
userFacingErrorMessage(code),
);
}
return {
clientOperationId: input.clientOperationId,
runId: run.run_id,
workspace: await this.getWorkspace(input.workspaceId),
};
}
async uploadAsset(input: DesignAssetUploadInput): Promise<DesignAsset> {
const form = new FormData();
form.append('file', new Blob([input.bytes], { type: input.mimeType }), input.fileName);
const asset = await this.requestJson<unknown>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/assets`,
{ method: 'POST', body: form },
);
return mapAsset(input.workspaceId, { ...record(asset), role: 'uploaded' });
}
async openWorkspaceEvents(
input: DesignWorkspaceEventSubscriptionInput,
): Promise<DesignWorkspaceEventSubscription> {
if (!this.eventSessionsEnabled) {
throw new DesignWorkspaceModuleError(503, 'DESIGN_EVENT_STREAM_PAUSED', '设计状态流已暂停');
}
const ticket = await this.requestJson<ServerAgentStreamTicket>(
`/api/agents/sessions/${encodeURIComponent(input.sessionId)}/stream-tickets`,
{ method: 'POST', body: JSON.stringify({ transport: 'websocket' }) },
);
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', '设计状态流地址无效');
}
streamUrl.searchParams.set(
'after_sequence',
String(eventSequence(input.afterEventId, input.sessionId)),
);
streamUrl.protocol = streamUrl.protocol === 'https:' ? 'wss:' : 'ws:';
const connection = await this.webSocketFactory(streamUrl.toString());
const queue = createEventQueue();
const { socket } = connection;
let opened = false;
let settled = false;
let heartbeat: ReturnType<typeof setInterval> | null = null;
let openTimeout: ReturnType<typeof setTimeout> | null = null;
let resolveOpen!: () => void;
let rejectOpen!: (error: unknown) => void;
const openPromise = new Promise<void>((resolve, reject) => {
resolveOpen = resolve;
rejectOpen = reject;
});
const unregister = this.registerSubscription(input.workspaceId, () => close());
const settle = (error: unknown | null) => {
if (settled) return;
settled = true;
unregister();
if (heartbeat) clearInterval(heartbeat);
if (openTimeout) clearTimeout(openTimeout);
void connection.dispose?.();
if (!opened) rejectOpen(error ?? new Error('Design stream closed'));
else if (error) queue.fail(error);
else queue.finish();
};
const close = () => {
if (settled) return;
try {
socket.close(1000, 'Client closed design event stream');
} finally {
settle(null);
}
};
socket.onopen = () => {
opened = true;
heartbeat = setInterval(() => {
if (socket.readyState !== WEBSOCKET_OPEN) return;
socket.send(JSON.stringify({ type: 'ping', request_id: `design-ping-${Date.now()}` }));
}, WEBSOCKET_PING_INTERVAL_MS);
resolveOpen();
};
socket.onmessage = ({ data }) => {
const event = normalizeWorkspaceEvent(
agentEventFromFrame(data),
input.sessionId,
input.workspaceId,
);
if (event) queue.push(event);
};
socket.onerror = () => settle(
new DesignWorkspaceModuleError(502, 'DESIGN_EVENT_STREAM_UNAVAILABLE', '设计状态连接失败'),
);
socket.onclose = ({ code }) => settle(webSocketCloseError(code));
openTimeout = setTimeout(() => {
settle(new DesignWorkspaceModuleError(504, 'DESIGN_EVENT_STREAM_TIMEOUT', '设计状态连接超时'));
try {
socket.close(1000, 'Timed out opening design event stream');
} catch {
// settle above is authoritative.
}
}, WEBSOCKET_OPEN_TIMEOUT_MS);
await openPromise;
return { events: queue.events, close };
}
async closeEventSessions(): Promise<void> {
this.eventSessionsEnabled = false;
const closers = [...this.eventSubscriptionClosers.values()].flatMap((set) => [...set]);
this.eventSubscriptionClosers.clear();
closers.forEach((close) => close());
}
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 waitForRun(sessionId: string, runId: string): Promise<ServerAgentRun> {
const deadline = Date.now() + RUN_TIMEOUT_MS;
let interval = RUN_INITIAL_POLL_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, interval));
interval = Math.min(interval * 2, RUN_MAX_POLL_MS);
}
}
private registerSubscription(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) this.eventSubscriptionClosers.delete(workspaceId);
};
}
private closeWorkspaceSubscriptions(workspaceId: string): void {
const closers = [...(this.eventSubscriptionClosers.get(workspaceId) ?? [])];
this.eventSubscriptionClosers.delete(workspaceId);
closers.forEach((close) => close());
}
private async requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
try {
return await runWithDeadline(async (signal) => {
const response = await this.authorizedFetch(path, {
...init,
signal,
headers: {
Accept: 'application/json',
...(init.body && !(init.body instanceof FormData)
? { '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';
throw new DesignWorkspaceModuleError(
response.status,
code,
typeof detail.message === 'string'
? detail.message
: userFacingErrorMessage(code),
);
}
return payload as T;
}, this.requestTimeoutMs, init.signal);
} catch (error) {
if (error instanceof RequestDeadlineExceededError) throw designRequestTimeoutError();
throw error;
}
}
private async authorizedFetch(path: string, init: RequestInit = {}): Promise<Response> {
try {
let token = await getValidWorksSquareAccessToken({
fetchImpl: this.fetchImpl,
requestTimeoutMs: this.requestTimeoutMs,
});
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,
requestTimeoutMs: this.requestTimeoutMs,
});
if (!token) {
throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录');
}
response = await this.fetchWithToken(path, token, init);
return response;
} catch (error) {
if (error instanceof RequestDeadlineExceededError) throw designRequestTimeoutError();
throw error;
}
}
private fetchWithToken(path: string, token: string, init: RequestInit): Promise<Response> {
const requestInit: RequestInit = {
...init,
headers: { ...init.headers, Authorization: `Bearer ${token}` },
};
return init.signal
? this.fetchImpl(`${this.apiBaseUrl}${path}`, requestInit)
: fetchWithDeadline(
this.fetchImpl,
`${this.apiBaseUrl}${path}`,
requestInit,
this.requestTimeoutMs,
);
}
}