1042 lines
39 KiB
TypeScript
1042 lines
39 KiB
TypeScript
import WebSocket from 'ws';
|
|
import {
|
|
designAssetContentPath,
|
|
type DesignAsset,
|
|
type DesignAssetUploadInput,
|
|
type DesignAssistantDeltaEvent,
|
|
type DesignCapabilities,
|
|
type DesignChangeSet,
|
|
type DesignCommandFailureOutcome,
|
|
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_reasoner_invalid: 'AI 没有整理好这次想法,请再试一次',
|
|
design_agent_run_failed: 'AI 这次没有完成设计整理,请再试一次',
|
|
design_agent_run_cancelled: '这次设计整理已停止',
|
|
design_runtime_unavailable: 'AI 设计服务暂时不可用',
|
|
design_production_unavailable: '当前生成能力暂时不可用',
|
|
agent_runtime_unavailable: 'AI 设计服务暂时不可用,请稍后重试',
|
|
agent_command_invalid: '设计请求内容无效,请检查后重试',
|
|
auth_required: '请先登录后再使用 AI 设计',
|
|
auth_expired: '登录状态已失效,请重新登录',
|
|
};
|
|
return messages[code.toLowerCase()] ?? 'AI 设计请求失败,请稍后重试';
|
|
}
|
|
|
|
function agentRunErrorStatus(code: string): number {
|
|
const normalizedCode = code.toLowerCase();
|
|
const exactStatuses: Record<string, number> = {
|
|
design_direction_not_found: 404,
|
|
design_direction_unavailable: 409,
|
|
design_revision_conflict: 409,
|
|
design_idempotency_conflict: 409,
|
|
design_prompt_unavailable: 409,
|
|
design_reasoner_unavailable: 503,
|
|
design_reasoner_invalid: 502,
|
|
design_field_locked: 409,
|
|
design_generation_unavailable: 503,
|
|
design_quote_expired: 409,
|
|
design_quote_consumed: 409,
|
|
design_quote_invalid: 409,
|
|
design_quote_dependency_invalid: 409,
|
|
budget_denied: 402,
|
|
design_billing_unavailable: 503,
|
|
design_state_unavailable: 409,
|
|
design_command_invalid: 400,
|
|
design_input_invalid: 422,
|
|
design_agent_run_failed: 502,
|
|
design_agent_run_cancelled: 409,
|
|
};
|
|
if (exactStatuses[normalizedCode] !== undefined) return exactStatuses[normalizedCode];
|
|
if (normalizedCode.includes('not_found')) return 404;
|
|
if (normalizedCode.includes('conflict')
|
|
|| normalizedCode.includes('expired')
|
|
|| normalizedCode.includes('consumed')) return 409;
|
|
if (normalizedCode.includes('invalid')
|
|
|| normalizedCode === 'policy_blocked'
|
|
|| normalizedCode === 'design_quote_blocked') {
|
|
return 422;
|
|
}
|
|
if (normalizedCode.includes('unavailable')) return 503;
|
|
return 502;
|
|
}
|
|
|
|
function commandFailureError(
|
|
error: unknown,
|
|
outcome: DesignCommandFailureOutcome,
|
|
): DesignWorkspaceModuleError {
|
|
if (error instanceof DesignWorkspaceModuleError) {
|
|
return new DesignWorkspaceModuleError(
|
|
error.status,
|
|
error.code,
|
|
userFacingErrorMessage(error.code),
|
|
outcome,
|
|
);
|
|
}
|
|
return new DesignWorkspaceModuleError(
|
|
502,
|
|
'DESIGN_WORKSPACE_REQUEST_FAILED',
|
|
'AI 设计服务暂时无法连接',
|
|
outcome,
|
|
);
|
|
}
|
|
|
|
function submissionFailureOutcome(error: unknown): DesignCommandFailureOutcome {
|
|
if (error instanceof DesignWorkspaceModuleError
|
|
&& error.status >= 400
|
|
&& error.status < 500
|
|
&& error.status !== 408) {
|
|
return 'definitive_failure';
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
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> {
|
|
let command: ServerAgentCommand;
|
|
try {
|
|
command = await this.requestJson<ServerAgentCommand>(
|
|
`/api/agents/sessions/${encodeURIComponent(input.sessionId)}/commands`,
|
|
{ method: 'POST', body: JSON.stringify(agentCommand(input)) },
|
|
);
|
|
} catch (error) {
|
|
throw commandFailureError(error, submissionFailureOutcome(error));
|
|
}
|
|
|
|
let run: ServerAgentRun;
|
|
try {
|
|
run = await this.waitForRun(input.sessionId, command.run_id);
|
|
} catch (error) {
|
|
throw commandFailureError(error, 'unknown');
|
|
}
|
|
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),
|
|
'definitive_failure',
|
|
);
|
|
}
|
|
let workspace: DesignWorkspace;
|
|
try {
|
|
workspace = await this.getWorkspace(input.workspaceId);
|
|
} catch (error) {
|
|
throw commandFailureError(error, 'unknown');
|
|
}
|
|
return {
|
|
clientOperationId: input.clientOperationId,
|
|
runId: run.run_id,
|
|
workspace,
|
|
};
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|