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

1526 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import type {
DesignAsset,
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, fallback: 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: '设计请求内容无效,请检查后重试',
};
return messages[code] ?? fallback;
}
function agentRunErrorStatus(code: string): number {
if (code === 'workspace_not_found') return 404;
if (code === 'budget_denied') return 402;
if (code === 'workspace_revision_conflict'
|| code === 'idempotency_conflict'
|| code === 'generation_quote_expired'
|| code === 'generation_quote_consumed'
|| code === 'generation_quote_invalid') {
return 409;
}
if (code === 'agent_command_invalid'
|| code === 'reference_asset_invalid'
|| code === 'policy_blocked') {
return 422;
}
if (code === 'design_reasoner_unavailable'
|| code === 'design_runtime_unavailable'
|| code === 'design_production_unavailable'
|| 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'
);
const fallback = run.error?.message ?? (
run.status === 'cancelled'
? '设计 Agent 请求已取消'
: '设计 Agent 暂时不可用,请稍后重试'
);
throw new DesignWorkspaceModuleError(
agentRunErrorStatus(code),
code,
userFacingErrorMessage(code, fallback),
);
}
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, message),
));
}
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';
const fallback = typeof detail.message === 'string'
? detail.message
: `AI 设计请求失败(${response.status}`;
throw new DesignWorkspaceModuleError(
response.status,
code,
userFacingErrorMessage(code, fallback),
);
}
return payload as T;
}
private async authorizedFetch(path: string, init: RequestInit = {}): Promise<Response> {
let token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl });
if (!token) {
throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计');
}
let response = await this.fetchWithToken(path, token, init);
if (response.status !== 401) return response;
token = await getValidWorksSquareAccessToken({
fetchImpl: this.fetchImpl,
forceRefresh: true,
});
if (!token) {
throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录');
}
response = await this.fetchWithToken(path, token, init);
return response;
}
private fetchWithToken(path: string, token: string, init: RequestInit): Promise<Response> {
return this.fetchImpl(`${this.apiBaseUrl}${path}`, {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${token}`,
},
});
}
private 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);
}
}