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

1520 lines
50 KiB
TypeScript

import type {
DesignAsset,
DesignAssetUploadInput,
DesignAssistantDeltaEvent,
DesignBrief,
DesignCapabilities,
DesignConversation,
DesignConversationSnapshotEvent,
DesignConversationSummary,
DesignConfirmGenerationInput,
DesignCreateConversationInput,
DesignCreateWorkspaceInput,
DesignGenerationQuote,
DesignGenerationTask,
DesignGenerationTaskUpdatedEvent,
DesignMessage,
DesignRenameWorkspaceInput,
DesignSubmitMessageInput,
DesignWorkspace,
DesignWorkspaceBootstrap,
DesignWorkspaceEvent,
DesignWorkspaceSummary,
} from '../../shared/image-workspace';
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;
view_revision: number;
conversation_count?: number;
phase: DesignWorkspaceSummary['phase'];
updated_at: string;
};
type ServerConversationSummary = {
conversation_id: string;
workspace_id: string;
agent_session_id: string | null;
title: string;
latest_message_preview?: string | null;
turn_revision: number;
phase: DesignConversationSummary['phase'];
brief: ServerBrief;
created_at: string;
updated_at: string;
};
type ServerConversation = ServerConversationSummary & {
messages: ServerMessage[];
};
type ServerWorkspace = ServerWorkspaceSummary & {
conversations?: ServerConversationSummary[];
};
type ServerWorkspaceCreation = ServerWorkspace & {
initial_conversation?: ServerConversation;
};
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;
conversation_id?: string | null;
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;
webSocketFactory?: AgentWebSocketFactory;
};
type ServerAgentStreamTicket = {
stream_url: string;
};
type ServerAgentEvent = {
session_id: string;
sequence: number;
runtime: string;
type: string;
run_id?: unknown;
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;
conversationId: string;
agentSessionId: 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): Promise<void>;
finish(): void;
fail(error: unknown): void;
};
type QueuedTaskEvent = {
event: DesignWorkspaceEvent;
acknowledge(): void;
};
type AgentRunEventWaiter = (run: ServerAgentRun) => void;
type AgentCommandWaiter = {
resolve(command: ServerAgentCommand): void;
reject(error: unknown): void;
timeout: ReturnType<typeof setTimeout>;
};
type AgentCommandChannel = {
socket: AgentWebSocket;
waiters: Map<string, AgentCommandWaiter>;
};
const AGENT_WEBSOCKET_OPEN = 1;
const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000;
const AGENT_COMMAND_ACK_TIMEOUT_MS = 5_000;
const AGENT_RUN_INITIAL_POLL_INTERVAL_MS = 1_000;
const AGENT_RUN_MAX_POLL_INTERVAL_MS = 5_000;
const AGENT_RUN_TIMEOUT_MS = 10 * 60_000;
const DESIGN_EVENT_DELIVERY_BARRIER_TIMEOUT_MS = 1_000;
class AgentCommandTransportError extends Error {}
function mapBrief(brief: ServerBrief): DesignBrief {
const medium = brief.medium ?? null;
if (medium !== null && medium !== 'image' && medium !== 'video') {
throw new DesignWorkspaceModuleError(
502,
'DESIGN_WORKSPACE_RESPONSE_INVALID',
'AI 设计服务返回了无效的媒介类型',
);
}
return {
version: brief.version,
status: brief.status,
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,
viewRevision: workspace.view_revision,
conversationCount: workspace.conversation_count ?? 0,
phase: workspace.phase,
updatedAt: workspace.updated_at,
};
}
function mapWorkspace(workspace: ServerWorkspace): DesignWorkspace {
const conversations = workspace.conversations ?? [];
return {
...mapWorkspaceSummary(workspace),
conversationCount: workspace.conversation_count ?? conversations.length,
conversations: conversations.map(mapConversationSummary),
};
}
function normalizeMessagePreview(value: string | null | undefined): string | null {
const preview = value?.replace(/\s+/gu, ' ').trim();
return preview || null;
}
function latestServerMessagePreview(messages: ServerMessage[]): string | null {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const preview = normalizeMessagePreview(messages[index]?.text);
if (preview) return preview;
}
return null;
}
function mapConversationSummary(
conversation: ServerConversationSummary,
): DesignConversationSummary {
return {
conversationId: conversation.conversation_id,
workspaceId: conversation.workspace_id,
title: conversation.title,
latestMessagePreview: normalizeMessagePreview(
conversation.latest_message_preview,
) ?? normalizeMessagePreview(conversation.brief.summary),
turnRevision: conversation.turn_revision,
phase: conversation.phase,
brief: mapBrief(conversation.brief),
createdAt: conversation.created_at,
updatedAt: conversation.updated_at,
};
}
function mapConversation(conversation: ServerConversation): DesignConversation {
const messages = conversation.messages.map((message, index) => ({
id: `${conversation.conversation_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,
}));
return {
...mapConversationSummary(conversation),
latestMessagePreview: latestServerMessagePreview(messages)
?? normalizeMessagePreview(conversation.latest_message_preview)
?? normalizeMessagePreview(conversation.brief.summary),
messages,
};
}
function mapTask(task: ServerTask): DesignGenerationTask {
return {
taskId: task.task_id,
workspaceId: task.workspace_id,
conversationId: task.conversation_id ?? null,
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) => mapAsset(task.workspace_id, asset)),
createdAt: task.created_at,
updatedAt: task.updated_at,
};
}
function mapAsset(workspaceId: string, asset: ServerAsset): DesignAsset {
return {
assetId: asset.asset_id,
workspaceId,
mediaType: asset.media_type,
mimeType: asset.mime_type,
width: asset.width,
height: asset.height,
durationMilliseconds: asset.duration_milliseconds,
createdAt: asset.created_at,
contentPath: designAssetContentPath(workspaceId, asset.asset_id),
};
}
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.conversation_id === undefined
|| task.conversation_id === null
|| typeof task.conversation_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 isServerConversation(value: unknown): value is ServerConversation {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const conversation = value as Record<string, unknown>;
const brief = conversation.brief as Record<string, unknown> | null;
return typeof conversation.conversation_id === 'string'
&& typeof conversation.workspace_id === 'string'
&& (conversation.agent_session_id === null
|| typeof conversation.agent_session_id === 'string')
&& typeof conversation.title === 'string'
&& Number.isInteger(conversation.turn_revision)
&& Number(conversation.turn_revision) >= 0
&& ['shaping', 'awaiting_confirmation', 'blocked'].includes(String(conversation.phase))
&& Boolean(brief)
&& Number.isInteger(brief?.version)
&& ['draft', 'ready', 'confirmed'].includes(String(brief?.status))
&& (brief?.medium === undefined
|| 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(conversation.messages)
&& conversation.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 conversation.created_at === 'string'
&& typeof conversation.updated_at === 'string';
}
function normalizeWorkspaceEvent(
value: unknown,
sessionId: string,
workspaceId: string,
conversationId: 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
|| payload.conversation_id !== conversationId
|| 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,
conversationId,
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.conversation.updated'
&& event.type !== 'design.workspace.updated')
|| payload.workspace_id !== workspaceId
|| payload.conversation_id !== conversationId
|| !isServerConversation(payload.conversation)
|| !Array.isArray(payload.generation_tasks)) {
return null;
}
const conversation = payload.conversation;
if (conversation.workspace_id !== workspaceId
|| conversation.conversation_id !== conversationId
|| !Number.isInteger(payload.workspace_view_revision)
|| Number(payload.workspace_view_revision) < 0
|| !payload.generation_tasks.every(
(task) => isServerTask(task) && task.workspace_id === workspaceId,
)) {
return null;
}
return {
id: `${sessionId}:${event.sequence}`,
type: 'design.conversation.snapshot',
workspaceId,
conversationId,
workspaceViewRevision: Number(payload.workspace_view_revision),
conversation: mapConversation(conversation),
generationTasks: payload.generation_tasks.map((task) => mapTask(task as ServerTask)),
} satisfies DesignConversationSnapshotEvent;
} 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 normalizeAgentRunEvent(value: unknown, sessionId: string): ServerAgentRun | null {
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
|| typeof event.run_id !== 'string'
|| event.run_id.length === 0) {
return null;
}
const status = event.type === 'run.completed'
? 'succeeded'
: event.type === 'run.failed'
? 'failed'
: event.type === 'run.cancelled'
? 'cancelled'
: null;
if (!status) return null;
const payload = event.payload && typeof event.payload === 'object' && !Array.isArray(event.payload)
? event.payload as Record<string, unknown>
: null;
const rawError = payload?.error;
const error = rawError && typeof rawError === 'object' && !Array.isArray(rawError)
&& typeof (rawError as Record<string, unknown>).code === 'string'
&& typeof (rawError as Record<string, unknown>).message === 'string'
&& typeof (rawError as Record<string, unknown>).retryable === 'boolean'
? rawError as ServerAgentCommandError
: null;
return { run_id: event.run_id, status, error };
}
function createTaskEventQueue(): TaskEventQueue {
const queued: QueuedTaskEvent[] = [];
const pendingDeliveries = new Set<QueuedTaskEvent>();
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 entry = queued.shift();
if (entry) {
try {
yield entry.event;
} finally {
entry.acknowledge();
}
continue;
}
if (failed) throw failure;
if (finished) return;
await new Promise<void>((resolve) => waiters.push(resolve));
}
},
},
push(event) {
if (finished || failed) return Promise.resolve();
let resolveDelivery!: () => void;
const delivered = new Promise<void>((resolve) => {
resolveDelivery = resolve;
});
let acknowledged = false;
const entry: QueuedTaskEvent = {
event,
acknowledge() {
if (acknowledged) return;
acknowledged = true;
pendingDeliveries.delete(entry);
resolveDelivery();
},
};
pendingDeliveries.add(entry);
queued.push(entry);
wake();
return delivered;
},
finish() {
if (finished || failed) return;
finished = true;
for (const entry of [...pendingDeliveries]) entry.acknowledge();
wake();
},
fail(error) {
if (finished || failed) return;
failed = true;
failure = error;
for (const entry of [...pendingDeliveries]) entry.acknowledge();
wake();
},
};
}
function boundedTaskEventDelivery(delivered: Promise<void>): Promise<void> {
return new Promise((resolve) => {
const timeout = setTimeout(resolve, DESIGN_EVENT_DELIVERY_BARRIER_TIMEOUT_MS);
void delivered.then(() => {
clearTimeout(timeout);
resolve();
});
});
}
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): string {
if (code === 'generation_task_not_created') {
return '生成方案已确认,但任务创建失败,请刷新后重试';
}
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_runtime_unavailable: 'AI 设计服务暂时不可用,请稍后重试',
agent_command_invalid: '设计请求内容无效,请检查后重试',
design_agent_run_cancelled: '设计 Agent 请求已取消',
design_agent_run_failed: '设计 Agent 暂时不可用,请稍后重试',
};
return messages[code] ?? 'AI 设计请求失败,请稍后重试';
}
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'
|| code === 'agent_runtime_unavailable') {
return 503;
}
return 502;
}
function isStaleAgentSessionError(error: unknown): error is DesignWorkspaceModuleError {
return error instanceof DesignWorkspaceModuleError
&& (error.code === 'agent_session_not_found'
|| error.code === 'agent_session_closed');
}
export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
private readonly apiBaseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly webSocketFactory: AgentWebSocketFactory;
private readonly conversationSessionIds = new Map<string, string>();
private readonly eventSubscriptionClosers = new Map<string, Set<() => void>>();
private readonly activeRunEventStreams = new Map<string, number>();
private readonly terminalAgentRuns = new Map<string, ServerAgentRun>();
private readonly agentRunEventWaiters = new Map<string, Set<AgentRunEventWaiter>>();
private readonly runStreamEndWaiters = new Map<string, Set<() => void>>();
private readonly agentCommandChannels = new Map<string, Set<AgentCommandChannel>>();
private nextAgentCommandRequestId = 0;
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;
}
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<ServerWorkspaceCreation>('/api/design/workspaces', {
method: 'POST',
body: JSON.stringify({
client_workspace_id: input.clientWorkspaceId,
title: input.title,
}),
});
if (workspace.initial_conversation) this.rememberConversation(workspace.initial_conversation);
return this.getWorkspace(workspace.workspace_id);
}
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 this.getWorkspace(workspace.workspace_id);
}
async getWorkspace(workspaceId: string): Promise<DesignWorkspace> {
const [workspace, conversations] = await Promise.all([
this.requestJson<ServerWorkspace>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}`,
),
this.requestJson<ServerConversationSummary[]>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations?limit=100&offset=0`,
),
]);
return mapWorkspace({ ...workspace, conversations });
}
async createConversation(input: DesignCreateConversationInput): Promise<DesignConversation> {
const conversation = await this.requestJson<ServerConversation>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/conversations`,
{
method: 'POST',
body: JSON.stringify({
client_conversation_id: input.clientConversationId,
title: input.title,
}),
},
);
this.rememberConversation(conversation);
return mapConversation(conversation);
}
async getConversation(
workspaceId: string,
conversationId: string,
): Promise<DesignConversation> {
const conversation = await this.requestJson<ServerConversation>(
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`,
);
this.rememberConversation(conversation);
return mapConversation(conversation);
}
async submitMessage(input: DesignSubmitMessageInput): Promise<DesignConversation> {
const agentSessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId);
return this.executeAgentTurn({
workspaceId: input.workspaceId,
conversationId: input.conversationId,
agentSessionId,
clientTurnId: input.clientTurnId,
expectedTurnRevision: input.expectedTurnRevision,
message: input.message,
attachmentAssetIds: input.attachmentAssetIds ?? [],
action: null,
});
}
async confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignConversation> {
const agentSessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId);
return this.executeAgentTurn({
workspaceId: input.workspaceId,
conversationId: input.conversationId,
agentSessionId,
clientTurnId: input.clientTurnId,
expectedTurnRevision: input.expectedTurnRevision,
message: '确认生成',
attachmentAssetIds: [],
action: {
type: 'confirm_generation',
quote_id: input.quoteId,
},
});
}
private async executeAgentTurn(input: AgentDesignTurnSubmission): Promise<DesignConversation> {
try {
return await this.executeAgentTurnOnce(input);
} catch (error) {
if (!isStaleAgentSessionError(error)) throw error;
this.forgetConversation(input.workspaceId, input.conversationId);
const agentSessionId = await this.getAgentSessionId(
input.workspaceId,
input.conversationId,
);
return this.executeAgentTurnOnce({ ...input, agentSessionId });
}
}
private async executeAgentTurnOnce(input: AgentDesignTurnSubmission): Promise<DesignConversation> {
const command = await this.submitTurnCommand(input.agentSessionId, input);
const run = await this.waitForAgentRun(input.agentSessionId, 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 this.getConversation(input.workspaceId, input.conversationId);
}
private async submitTurnCommand(
sessionId: string,
input: AgentDesignTurnSubmission,
): Promise<ServerAgentCommand> {
const command = {
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,
},
};
const channel = this.findAgentCommandChannel(sessionId);
if (channel) {
try {
return await this.submitTurnCommandOverWebSocket(channel, command);
} catch (error) {
if (!(error instanceof AgentCommandTransportError)) throw error;
}
}
return this.requestJson<ServerAgentCommand>(
`/api/agents/sessions/${encodeURIComponent(sessionId)}/commands`,
{
method: 'POST',
body: JSON.stringify(command),
},
);
}
private findAgentCommandChannel(sessionId: string): AgentCommandChannel | null {
const channels = this.agentCommandChannels.get(sessionId);
if (!channels) return null;
for (const channel of channels) {
if (channel.socket.readyState === AGENT_WEBSOCKET_OPEN) return channel;
}
return null;
}
private submitTurnCommandOverWebSocket(
channel: AgentCommandChannel,
command: Record<string, unknown>,
): Promise<ServerAgentCommand> {
const requestId = `design-command-${Date.now()}-${this.nextAgentCommandRequestId += 1}`;
return new Promise<ServerAgentCommand>((resolve, reject) => {
const timeout = setTimeout(() => {
channel.waiters.delete(requestId);
reject(new AgentCommandTransportError('Agent WebSocket command acknowledgement timed out'));
}, AGENT_COMMAND_ACK_TIMEOUT_MS);
channel.waiters.set(requestId, { resolve, reject, timeout });
try {
channel.socket.send(JSON.stringify({
type: 'command.submit',
request_id: requestId,
command,
}));
} catch {
clearTimeout(timeout);
channel.waiters.delete(requestId);
reject(new AgentCommandTransportError('Agent WebSocket command send failed'));
}
});
}
private handleAgentCommandFrame(channel: AgentCommandChannel, data: unknown): void {
if (typeof data !== 'string') return;
let frame: Record<string, unknown>;
try {
const parsed = JSON.parse(data) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
frame = parsed as Record<string, unknown>;
} catch {
return;
}
const requestId = frame.request_id;
if (typeof requestId !== 'string') return;
const waiter = channel.waiters.get(requestId);
if (!waiter) return;
if (frame.type === 'command.accepted') {
const command = frame.command;
if (!command || typeof command !== 'object' || Array.isArray(command)
|| typeof (command as Record<string, unknown>).run_id !== 'string') {
return;
}
clearTimeout(waiter.timeout);
channel.waiters.delete(requestId);
waiter.resolve(command as ServerAgentCommand);
return;
}
if (frame.type !== 'error') return;
const error = frame.error;
if (!error || typeof error !== 'object' || Array.isArray(error)) return;
const code = (error as Record<string, unknown>).code;
const message = (error as Record<string, unknown>).message;
if (typeof code !== 'string' || typeof message !== 'string') return;
clearTimeout(waiter.timeout);
channel.waiters.delete(requestId);
waiter.reject(new DesignWorkspaceModuleError(
agentRunErrorStatus(code),
code,
userFacingErrorMessage(code),
));
}
private registerAgentCommandChannel(
sessionId: string,
channel: AgentCommandChannel,
): void {
const channels = this.agentCommandChannels.get(sessionId) ?? new Set<AgentCommandChannel>();
channels.add(channel);
this.agentCommandChannels.set(sessionId, channels);
}
private unregisterAgentCommandChannel(
sessionId: string,
channel: AgentCommandChannel,
): void {
const channels = this.agentCommandChannels.get(sessionId);
channels?.delete(channel);
if (channels?.size === 0) this.agentCommandChannels.delete(sessionId);
for (const waiter of channel.waiters.values()) {
clearTimeout(waiter.timeout);
waiter.reject(new AgentCommandTransportError('Agent WebSocket connection closed'));
}
channel.waiters.clear();
}
private async waitForAgentRun(sessionId: string, runId: string): Promise<ServerAgentRun> {
const deadline = Date.now() + AGENT_RUN_TIMEOUT_MS;
const streamedRun = await this.waitForAgentRunEvent(sessionId, runId, deadline);
if (streamedRun) return streamedRun;
return this.pollAgentRun(sessionId, runId, deadline);
}
private async pollAgentRun(
sessionId: string,
runId: string,
deadline: number,
): Promise<ServerAgentRun> {
let pollInterval = AGENT_RUN_INITIAL_POLL_INTERVAL_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, pollInterval));
pollInterval = Math.min(pollInterval * 2, AGENT_RUN_MAX_POLL_INTERVAL_MS);
}
}
private waitForAgentRunEvent(
sessionId: string,
runId: string,
deadline: number,
): Promise<ServerAgentRun | null> {
const key = `${sessionId}:${runId}`;
const cached = this.terminalAgentRuns.get(key);
if (cached) {
this.terminalAgentRuns.delete(key);
return Promise.resolve(cached);
}
if (!this.activeRunEventStreams.has(sessionId)) return Promise.resolve(null);
return new Promise<ServerAgentRun | null>((resolve, reject) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeout !== null) clearTimeout(timeout);
const runWaiters = this.agentRunEventWaiters.get(key);
runWaiters?.delete(onRun);
if (runWaiters?.size === 0) this.agentRunEventWaiters.delete(key);
const streamWaiters = this.runStreamEndWaiters.get(sessionId);
streamWaiters?.delete(onStreamEnd);
if (streamWaiters?.size === 0) this.runStreamEndWaiters.delete(sessionId);
};
const finish = (run: ServerAgentRun | null, error?: unknown) => {
if (settled) return;
settled = true;
cleanup();
if (error) reject(error);
else resolve(run);
};
const onRun: AgentRunEventWaiter = (run) => finish(run);
const onStreamEnd = () => finish(null);
const runWaiters = this.agentRunEventWaiters.get(key) ?? new Set<AgentRunEventWaiter>();
runWaiters.add(onRun);
this.agentRunEventWaiters.set(key, runWaiters);
const streamWaiters = this.runStreamEndWaiters.get(sessionId) ?? new Set<() => void>();
streamWaiters.add(onStreamEnd);
this.runStreamEndWaiters.set(sessionId, streamWaiters);
timeout = setTimeout(() => finish(null, new DesignWorkspaceModuleError(
504,
'design_agent_run_timeout',
'设计 Agent 响应超时,请稍后重试',
)), Math.max(0, deadline - Date.now()));
const racedRun = this.terminalAgentRuns.get(key);
if (racedRun) {
this.terminalAgentRuns.delete(key);
finish(racedRun);
} else if (!this.activeRunEventStreams.has(sessionId)) {
finish(null);
}
});
}
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 uploadAsset(input: DesignAssetUploadInput): Promise<DesignAsset> {
const form = new FormData();
form.set(
'file',
new Blob([Uint8Array.from(input.bytes)], { type: input.mimeType }),
input.fileName,
);
const asset = await this.requestJson<ServerAsset>(
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/assets`,
{ method: 'POST', body: form },
);
return mapAsset(input.workspaceId, asset);
}
async openWorkspaceEvents(
input: DesignWorkspaceEventSubscriptionInput,
): Promise<DesignWorkspaceEventSubscription> {
if (!this.eventSessionsEnabled) {
throw new DesignWorkspaceModuleError(
503,
'DESIGN_EVENT_STREAM_PAUSED',
'AI 设计任务状态流已暂停',
);
}
let sessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId);
let ticket: ServerAgentStreamTicket;
try {
ticket = await this.createEventStreamTicket(sessionId);
} catch (error) {
if (!isStaleAgentSessionError(error)) {
throw error;
}
this.forgetConversation(input.workspaceId, input.conversationId);
sessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId);
ticket = await this.createEventStreamTicket(sessionId);
}
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, sessionId)),
);
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 commandChannel: AgentCommandChannel = { socket, waiters: new Map() };
const queue = createTaskEventQueue();
let latestWorkspaceEventDelivery = Promise.resolve();
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();
if (didOpen) {
this.unregisterAgentCommandChannel(sessionId, commandChannel);
this.unregisterRunEventStream(sessionId);
}
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;
this.registerRunEventStream(sessionId);
this.registerAgentCommandChannel(sessionId, commandChannel);
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;
this.handleAgentCommandFrame(commandChannel, data);
const agentEvent = agentEventFromWebSocketFrame(data);
const event = normalizeWorkspaceEvent(
agentEvent,
sessionId,
input.workspaceId,
input.conversationId,
);
if (event) {
latestWorkspaceEventDelivery = boundedTaskEventDelivery(queue.push(event));
}
const run = normalizeAgentRunEvent(agentEvent, sessionId);
if (run) {
const deliveryBarrier = latestWorkspaceEventDelivery;
void deliveryBarrier.then(() => this.publishAgentRun(sessionId, run));
}
};
socket.onerror = () => {
setTimeout(() => {
if (!ending) beginEnd(unavailableError());
}, 0);
};
socket.onclose = ({ code }) => {
const error = webSocketCloseError(code);
if (code === 4409 || code === 4404) {
beginEnd(error, () => {
this.forgetConversation(input.workspaceId, input.conversationId);
});
return;
}
beginEnd(error);
};
unregister = this.registerEventSubscription(
input.workspaceId,
input.conversationId,
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();
this.conversationSessionIds.clear();
}
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 && !(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,
userFacingErrorMessage(code),
);
}
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 conversationKey(workspaceId: string, conversationId: string): string {
return `${workspaceId}:${conversationId}`;
}
private rememberConversation(conversation: ServerConversationSummary): void {
if (typeof conversation.agent_session_id !== 'string'
|| !conversation.agent_session_id.trim()) return;
this.conversationSessionIds.set(
this.conversationKey(conversation.workspace_id, conversation.conversation_id),
conversation.agent_session_id,
);
}
private forgetConversation(workspaceId: string, conversationId: string): void {
this.conversationSessionIds.delete(this.conversationKey(workspaceId, conversationId));
}
private async getAgentSessionId(
workspaceId: string,
conversationId: string,
): Promise<string> {
const key = this.conversationKey(workspaceId, conversationId);
const existing = this.conversationSessionIds.get(key);
if (existing) return existing;
await this.getConversation(workspaceId, conversationId);
const refreshed = this.conversationSessionIds.get(key);
if (!refreshed) {
throw new DesignWorkspaceModuleError(
503,
'DESIGN_CONVERSATION_SESSION_UNAVAILABLE',
'设计会话暂时无法连接,请稍后重试',
);
}
return refreshed;
}
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,
conversationId: string,
close: () => void,
): () => void {
const key = this.conversationKey(workspaceId, conversationId);
const closers = this.eventSubscriptionClosers.get(key) ?? new Set<() => void>();
closers.add(close);
this.eventSubscriptionClosers.set(key, closers);
return () => {
closers.delete(close);
if (closers.size === 0) this.eventSubscriptionClosers.delete(key);
};
}
private registerRunEventStream(sessionId: string): void {
this.activeRunEventStreams.set(
sessionId,
(this.activeRunEventStreams.get(sessionId) ?? 0) + 1,
);
}
private unregisterRunEventStream(sessionId: string): void {
const remaining = (this.activeRunEventStreams.get(sessionId) ?? 1) - 1;
if (remaining > 0) {
this.activeRunEventStreams.set(sessionId, remaining);
return;
}
this.activeRunEventStreams.delete(sessionId);
const waiters = this.runStreamEndWaiters.get(sessionId);
this.runStreamEndWaiters.delete(sessionId);
for (const resolve of waiters ?? []) resolve();
}
private publishAgentRun(sessionId: string, run: ServerAgentRun): void {
const key = `${sessionId}:${run.run_id}`;
const waiters = this.agentRunEventWaiters.get(key);
if (!waiters?.size) {
this.terminalAgentRuns.set(key, run);
if (this.terminalAgentRuns.size > 100) {
const oldest = this.terminalAgentRuns.keys().next().value;
if (oldest) this.terminalAgentRuns.delete(oldest);
}
return;
}
this.agentRunEventWaiters.delete(key);
for (const resolve of waiters) resolve(run);
}
}