feat: 对接设计 Agent Gateway WebSocket
需求:服务端统一 Agent Gateway 将设计任务状态流切换为 WebSocket,客户端需要实时展示生成任务并支持断线恢复。 实现:Electron Main 管理 Session、一次性 Ticket、WebSocket 心跳与游标续传,按关闭码回收会话;Renderer 继续通过本机 Host API 的 SSE 投影接收任务事件,并保留 REST 降级同步。 验证:typecheck、变更文件 ESLint、37 个聚焦测试及 build:vite 通过。
This commit is contained in:
@@ -365,6 +365,12 @@ async function handleSessionSync(req: IncomingMessage, res: ServerResponse): Pro
|
||||
async function clearManagedWorksSquareRuntime(ctx: HostApiContext): Promise<void> {
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
await ctx.imageWorkspace?.closeEventSessions?.();
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.opencodeManager.stop();
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
} from '../../../shared/image-workspace';
|
||||
import { DesignWorkspaceModuleError } from '../../image-workspace/module';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
import {
|
||||
flushStreamingHeaders,
|
||||
parseJsonBody,
|
||||
sendJson,
|
||||
writeStreamingChunk,
|
||||
} from '../route-utils';
|
||||
|
||||
function decodedSegments(pathname: string): string[] | null {
|
||||
const suffix = pathname.slice(IMAGE_WORKSPACE_API_PATH.length).replace(/^\/+/, '');
|
||||
@@ -105,6 +110,55 @@ async function relayAssetContent(
|
||||
await pipeline(Readable.fromWeb(content.body), res);
|
||||
}
|
||||
|
||||
async function relayWorkspaceEvents(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
if (!ctx.imageWorkspace?.openWorkspaceEvents) {
|
||||
throw new DesignWorkspaceModuleError(
|
||||
501,
|
||||
'DESIGN_EVENT_STREAM_UNAVAILABLE',
|
||||
'AI 设计任务实时状态暂时不可用',
|
||||
);
|
||||
}
|
||||
const header = req.headers['last-event-id'];
|
||||
const afterEventId = Array.isArray(header) ? header[0] : header;
|
||||
const subscription = await ctx.imageWorkspace.openWorkspaceEvents({
|
||||
workspaceId,
|
||||
...(afterEventId ? { afterEventId } : {}),
|
||||
});
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
subscription.close();
|
||||
};
|
||||
res.once('close', close);
|
||||
try {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
flushStreamingHeaders(res);
|
||||
if (!await writeStreamingChunk(res, ': connected\n\n')) return;
|
||||
for await (const event of subscription.events) {
|
||||
if (!await writeStreamingChunk(
|
||||
res,
|
||||
`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!res.writableEnded) res.end();
|
||||
} finally {
|
||||
res.off('close', close);
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleImageWorkspaceRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -199,6 +253,14 @@ export async function handleImageWorkspaceRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segments.length === 3
|
||||
&& segments[0] === 'workspaces'
|
||||
&& segments[2] === 'events'
|
||||
&& req.method === 'GET') {
|
||||
await relayWorkspaceEvents(req, res, ctx, segments[1]);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segments.length === 5
|
||||
&& segments[0] === 'workspaces'
|
||||
&& segments[2] === 'quotes'
|
||||
|
||||
@@ -7,8 +7,19 @@ import type {
|
||||
DesignSubmitMessageInput,
|
||||
DesignWorkspace,
|
||||
DesignWorkspaceBootstrap,
|
||||
DesignWorkspaceEvent,
|
||||
} from '../../shared/image-workspace';
|
||||
|
||||
export type DesignWorkspaceEventSubscription = {
|
||||
events: AsyncIterable<DesignWorkspaceEvent>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export type DesignWorkspaceEventSubscriptionInput = {
|
||||
workspaceId: string;
|
||||
afterEventId?: string;
|
||||
};
|
||||
|
||||
export class DesignWorkspaceModuleError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
@@ -30,6 +41,10 @@ export interface DesignWorkspaceModule {
|
||||
submitMessage(input: DesignSubmitMessageInput): Promise<DesignWorkspace>;
|
||||
confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignWorkspace>;
|
||||
listTasks(workspaceId: string): Promise<DesignGenerationTask[]>;
|
||||
openWorkspaceEvents?(
|
||||
input: DesignWorkspaceEventSubscriptionInput,
|
||||
): Promise<DesignWorkspaceEventSubscription>;
|
||||
closeEventSessions?(): Promise<void>;
|
||||
openAssetContent(workspaceId: string, assetId: string, range?: string): Promise<Response>;
|
||||
reset?(): Promise<DesignWorkspaceBootstrap>;
|
||||
}
|
||||
|
||||
@@ -6,18 +6,28 @@ import type {
|
||||
DesignCreateWorkspaceInput,
|
||||
DesignGenerationQuote,
|
||||
DesignGenerationTask,
|
||||
DesignGenerationTasksSnapshotEvent,
|
||||
DesignGenerationTaskUpdatedEvent,
|
||||
DesignMessage,
|
||||
DesignRenameWorkspaceInput,
|
||||
DesignSubmitMessageInput,
|
||||
DesignWorkspace,
|
||||
DesignWorkspaceBootstrap,
|
||||
DesignWorkspaceEvent,
|
||||
DesignWorkspaceSummary,
|
||||
} from '../../shared/image-workspace';
|
||||
import { createHash } from 'node:crypto';
|
||||
import WebSocket from 'ws';
|
||||
import { designAssetContentPath } from '../../shared/image-workspace';
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getValidWorksSquareAccessToken } from '../services/works-square-session';
|
||||
import { DesignWorkspaceModuleError, type DesignWorkspaceModule } from './module';
|
||||
import {
|
||||
DesignWorkspaceModuleError,
|
||||
type DesignWorkspaceEventSubscription,
|
||||
type DesignWorkspaceEventSubscriptionInput,
|
||||
type DesignWorkspaceModule,
|
||||
} from './module';
|
||||
|
||||
type ServerBrief = {
|
||||
version: number;
|
||||
@@ -95,8 +105,61 @@ type ServerErrorDetail = {
|
||||
type WorksSquareDesignWorkspaceOptions = {
|
||||
apiBaseUrl?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
clientInstanceId?: string;
|
||||
webSocketFactory?: AgentWebSocketFactory;
|
||||
eventSessionClientIdStore?: {
|
||||
getOrCreate(workspaceId: string): Promise<string>;
|
||||
rotate(workspaceId: string): Promise<string>;
|
||||
};
|
||||
};
|
||||
|
||||
type ServerAgentSession = {
|
||||
session_id: string;
|
||||
status: 'active' | 'closed';
|
||||
};
|
||||
|
||||
type ServerAgentStreamTicket = {
|
||||
stream_url: string;
|
||||
};
|
||||
|
||||
type ServerAgentEvent = {
|
||||
session_id: string;
|
||||
sequence: number;
|
||||
runtime: string;
|
||||
type: string;
|
||||
schema_version: number;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
type AgentWebSocket = {
|
||||
readyState: number;
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((event: { data: unknown }) => void) | null;
|
||||
onerror: ((event: unknown) => void) | null;
|
||||
onclose: ((event: { code: number; reason: string }) => void) | null;
|
||||
send(data: string): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
};
|
||||
|
||||
type AgentWebSocketConnection = {
|
||||
socket: AgentWebSocket;
|
||||
dispose?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
type AgentWebSocketFactory = (
|
||||
url: string,
|
||||
) => AgentWebSocketConnection | Promise<AgentWebSocketConnection>;
|
||||
|
||||
type TaskEventQueue = {
|
||||
events: AsyncIterable<DesignWorkspaceEvent>;
|
||||
push(event: DesignWorkspaceEvent): void;
|
||||
finish(): void;
|
||||
fail(error: unknown): void;
|
||||
};
|
||||
|
||||
const AGENT_WEBSOCKET_OPEN = 1;
|
||||
const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000;
|
||||
|
||||
function mapBrief(brief: ServerBrief): DesignBrief {
|
||||
return {
|
||||
version: brief.version,
|
||||
@@ -176,6 +239,198 @@ function mapTask(task: ServerTask): DesignGenerationTask {
|
||||
};
|
||||
}
|
||||
|
||||
function createClientId(prefix: string): string {
|
||||
const id = globalThis.crypto?.randomUUID?.()
|
||||
?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${prefix}-${id}`;
|
||||
}
|
||||
|
||||
function stableWorkspaceSessionClientId(clientInstanceId: string, workspaceId: string): string {
|
||||
const digest = createHash('sha256')
|
||||
.update(`${clientInstanceId}\0${workspaceId}`)
|
||||
.digest('hex');
|
||||
return `design-stream-${digest}`;
|
||||
}
|
||||
|
||||
function isServerTask(value: unknown): value is ServerTask {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const task = value as Record<string, unknown>;
|
||||
return typeof task.task_id === 'string'
|
||||
&& typeof task.workspace_id === 'string'
|
||||
&& (task.medium === 'image' || task.medium === 'video')
|
||||
&& ['queued', 'running', 'succeeded', 'failed', 'cancelled'].includes(String(task.status))
|
||||
&& typeof task.brief_version === 'number'
|
||||
&& typeof task.brief_summary === 'string'
|
||||
&& Array.isArray(task.result_assets)
|
||||
&& typeof task.created_at === 'string'
|
||||
&& typeof task.updated_at === 'string';
|
||||
}
|
||||
|
||||
function normalizeWorkspaceEvent(
|
||||
value: unknown,
|
||||
sessionId: string,
|
||||
workspaceId: string,
|
||||
): DesignWorkspaceEvent | null {
|
||||
try {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const event = value as ServerAgentEvent;
|
||||
if (event.session_id !== sessionId
|
||||
|| !Number.isInteger(event.sequence)
|
||||
|| event.sequence < 1
|
||||
|| event.runtime !== 'design'
|
||||
|| event.schema_version !== 1
|
||||
|| !event.payload
|
||||
|| typeof event.payload !== 'object'
|
||||
|| Array.isArray(event.payload)) {
|
||||
return null;
|
||||
}
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
if (event.type === 'design.generation_task.updated') {
|
||||
if (payload.workspace_id !== workspaceId
|
||||
|| !Number.isInteger(payload.workspace_view_revision)
|
||||
|| Number(payload.workspace_view_revision) < 1
|
||||
|| !isServerTask(payload.generation_task)
|
||||
|| payload.generation_task.workspace_id !== workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: `${sessionId}:${event.sequence}`,
|
||||
type: 'design.generation_task.updated',
|
||||
workspaceId,
|
||||
workspaceViewRevision: Number(payload.workspace_view_revision),
|
||||
generationTask: mapTask(payload.generation_task),
|
||||
} satisfies DesignGenerationTaskUpdatedEvent;
|
||||
}
|
||||
|
||||
if (event.type !== 'design.workspace.updated'
|
||||
|| !payload.workspace
|
||||
|| typeof payload.workspace !== 'object'
|
||||
|| Array.isArray(payload.workspace)
|
||||
|| !Array.isArray(payload.generation_tasks)) {
|
||||
return null;
|
||||
}
|
||||
const workspace = payload.workspace as Record<string, unknown>;
|
||||
if (workspace.workspace_id !== workspaceId
|
||||
|| !Number.isInteger(workspace.view_revision)
|
||||
|| Number(workspace.view_revision) < 0
|
||||
|| !payload.generation_tasks.every(
|
||||
(task) => isServerTask(task) && task.workspace_id === workspaceId,
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: `${sessionId}:${event.sequence}`,
|
||||
type: 'design.generation_tasks.snapshot',
|
||||
workspaceId,
|
||||
workspaceViewRevision: Number(workspace.view_revision),
|
||||
generationTasks: payload.generation_tasks.map((task) => mapTask(task as ServerTask)),
|
||||
} satisfies DesignGenerationTasksSnapshotEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function agentEventFromWebSocketFrame(data: unknown): unknown | null {
|
||||
if (typeof data !== 'string') return null;
|
||||
try {
|
||||
const frame = JSON.parse(data) as Record<string, unknown>;
|
||||
return frame
|
||||
&& typeof frame === 'object'
|
||||
&& !Array.isArray(frame)
|
||||
&& frame.type === 'event'
|
||||
? frame.event ?? null
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createTaskEventQueue(): TaskEventQueue {
|
||||
const queued: DesignWorkspaceEvent[] = [];
|
||||
const waiters: Array<() => void> = [];
|
||||
let finished = false;
|
||||
let failed = false;
|
||||
let failure: unknown;
|
||||
const wake = () => {
|
||||
for (const resolve of waiters.splice(0)) resolve();
|
||||
};
|
||||
|
||||
return {
|
||||
events: {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
while (true) {
|
||||
const event = queued.shift();
|
||||
if (event) {
|
||||
yield event;
|
||||
continue;
|
||||
}
|
||||
if (failed) throw failure;
|
||||
if (finished) return;
|
||||
await new Promise<void>((resolve) => waiters.push(resolve));
|
||||
}
|
||||
},
|
||||
},
|
||||
push(event) {
|
||||
if (finished || failed) return;
|
||||
queued.push(event);
|
||||
wake();
|
||||
},
|
||||
finish() {
|
||||
if (finished || failed) return;
|
||||
finished = true;
|
||||
wake();
|
||||
},
|
||||
fail(error) {
|
||||
if (finished || failed) return;
|
||||
failed = true;
|
||||
failure = error;
|
||||
wake();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function webSocketCloseError(code: number): DesignWorkspaceModuleError | null {
|
||||
if (code === 1000 || code === 1001) return null;
|
||||
if (code === 4401) {
|
||||
return new DesignWorkspaceModuleError(
|
||||
401,
|
||||
'DESIGN_EVENT_TICKET_INVALID',
|
||||
'AI 设计任务连接凭证已失效',
|
||||
);
|
||||
}
|
||||
if (code === 4404) {
|
||||
return new DesignWorkspaceModuleError(
|
||||
404,
|
||||
'DESIGN_EVENT_SESSION_NOT_FOUND',
|
||||
'AI 设计任务状态会话不存在',
|
||||
);
|
||||
}
|
||||
if (code === 4409) {
|
||||
return new DesignWorkspaceModuleError(
|
||||
410,
|
||||
'DESIGN_EVENT_CURSOR_EXPIRED',
|
||||
'AI 设计任务状态断点已过期',
|
||||
);
|
||||
}
|
||||
return new DesignWorkspaceModuleError(
|
||||
502,
|
||||
'DESIGN_EVENT_STREAM_UNAVAILABLE',
|
||||
'AI 设计任务状态连接已断开',
|
||||
);
|
||||
}
|
||||
|
||||
function defaultAgentWebSocketFactory(url: string): AgentWebSocketConnection {
|
||||
return {
|
||||
socket: new WebSocket(url) as unknown as AgentWebSocket,
|
||||
};
|
||||
}
|
||||
|
||||
function eventSequence(afterEventId: string | undefined, sessionId: string): number {
|
||||
if (!afterEventId?.startsWith(`${sessionId}:`)) return 0;
|
||||
const sequence = Number(afterEventId.slice(sessionId.length + 1));
|
||||
return Number.isSafeInteger(sequence) && sequence >= 0 ? sequence : 0;
|
||||
}
|
||||
|
||||
async function readPayload(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return null;
|
||||
@@ -216,10 +471,26 @@ function userFacingErrorMessage(code: string, fallback: string): string {
|
||||
export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
private readonly apiBaseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly webSocketFactory: AgentWebSocketFactory;
|
||||
private readonly eventSessionClientIdStore: NonNullable<
|
||||
WorksSquareDesignWorkspaceOptions['eventSessionClientIdStore']
|
||||
>;
|
||||
private readonly eventSessions = new Map<string, Promise<ServerAgentSession>>();
|
||||
private readonly eventSessionClientIds = new Map<string, string>();
|
||||
private readonly eventSubscriptionClosers = new Map<string, Set<() => void>>();
|
||||
private eventSessionsEnabled = true;
|
||||
|
||||
constructor(options: WorksSquareDesignWorkspaceOptions = {}) {
|
||||
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
|
||||
this.webSocketFactory = options.webSocketFactory ?? defaultAgentWebSocketFactory;
|
||||
const clientInstanceId = options.clientInstanceId?.trim() || createClientId('process');
|
||||
this.eventSessionClientIdStore = options.eventSessionClientIdStore ?? {
|
||||
getOrCreate: async (workspaceId) => (
|
||||
stableWorkspaceSessionClientId(clientInstanceId, workspaceId)
|
||||
),
|
||||
rotate: async () => createClientId('design-stream-rotated'),
|
||||
};
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<DesignWorkspaceBootstrap> {
|
||||
@@ -227,6 +498,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
this.getCapabilities(),
|
||||
this.requestJson<ServerWorkspaceSummary[]>('/api/design/workspaces?limit=100&offset=0'),
|
||||
]);
|
||||
this.eventSessionsEnabled = true;
|
||||
return {
|
||||
capabilities,
|
||||
workspaces: workspaces.map(mapWorkspaceSummary),
|
||||
@@ -310,6 +582,212 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
return tasks.map(mapTask);
|
||||
}
|
||||
|
||||
async openWorkspaceEvents(
|
||||
input: DesignWorkspaceEventSubscriptionInput,
|
||||
): Promise<DesignWorkspaceEventSubscription> {
|
||||
let session = await this.ensureEventSession(input.workspaceId);
|
||||
let ticket: ServerAgentStreamTicket;
|
||||
try {
|
||||
ticket = await this.createEventStreamTicket(session.session_id);
|
||||
} catch (error) {
|
||||
if (!(error instanceof DesignWorkspaceModuleError)
|
||||
|| (error.status !== 404 && error.status !== 409)) {
|
||||
throw error;
|
||||
}
|
||||
await this.invalidateEventSession(input.workspaceId);
|
||||
session = await this.ensureEventSession(input.workspaceId);
|
||||
ticket = await this.createEventStreamTicket(session.session_id);
|
||||
}
|
||||
const streamUrl = new URL(ticket.stream_url, `${this.apiBaseUrl}/`);
|
||||
if (streamUrl.origin !== new URL(this.apiBaseUrl).origin) {
|
||||
throw new DesignWorkspaceModuleError(
|
||||
502,
|
||||
'DESIGN_EVENT_STREAM_INVALID',
|
||||
'AI 设计任务状态流地址无效',
|
||||
);
|
||||
}
|
||||
if (streamUrl.protocol !== 'http:' && streamUrl.protocol !== 'https:') {
|
||||
throw new DesignWorkspaceModuleError(
|
||||
502,
|
||||
'DESIGN_EVENT_STREAM_INVALID',
|
||||
'AI 设计任务状态流协议无效',
|
||||
);
|
||||
}
|
||||
streamUrl.searchParams.set(
|
||||
'after_sequence',
|
||||
String(eventSequence(input.afterEventId, session.session_id)),
|
||||
);
|
||||
streamUrl.protocol = streamUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
let connection: AgentWebSocketConnection;
|
||||
try {
|
||||
connection = await this.webSocketFactory(streamUrl.toString());
|
||||
} catch {
|
||||
throw new DesignWorkspaceModuleError(
|
||||
502,
|
||||
'DESIGN_EVENT_STREAM_UNAVAILABLE',
|
||||
'AI 设计任务状态连接失败',
|
||||
);
|
||||
}
|
||||
|
||||
const { socket } = connection;
|
||||
const queue = createTaskEventQueue();
|
||||
let didOpen = false;
|
||||
let ending = false;
|
||||
let settled = false;
|
||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||
let unregister = () => undefined;
|
||||
let resolveOpened: () => void = () => undefined;
|
||||
let rejectOpened: (error: unknown) => void = () => undefined;
|
||||
const opened = new Promise<void>((resolve, reject) => {
|
||||
resolveOpened = resolve;
|
||||
rejectOpened = reject;
|
||||
});
|
||||
const unavailableError = () => new DesignWorkspaceModuleError(
|
||||
502,
|
||||
'DESIGN_EVENT_STREAM_UNAVAILABLE',
|
||||
'AI 设计任务状态连接已断开',
|
||||
);
|
||||
const settle = async (error: unknown | null): Promise<void> => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.onopen = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.onclose = null;
|
||||
if (heartbeat !== null) clearInterval(heartbeat);
|
||||
heartbeat = null;
|
||||
unregister();
|
||||
try {
|
||||
await connection.dispose?.();
|
||||
} catch {
|
||||
// The stream outcome is authoritative; dispatcher cleanup is best effort.
|
||||
}
|
||||
if (!didOpen) {
|
||||
rejectOpened(error ?? unavailableError());
|
||||
} else if (error) {
|
||||
queue.fail(error);
|
||||
} else {
|
||||
queue.finish();
|
||||
}
|
||||
};
|
||||
const beginEnd = (
|
||||
error: unknown | null,
|
||||
cleanup?: () => void | Promise<void>,
|
||||
): void => {
|
||||
if (ending) return;
|
||||
ending = true;
|
||||
void (async () => {
|
||||
let finalError = error;
|
||||
try {
|
||||
await cleanup?.();
|
||||
} catch (cleanupError) {
|
||||
finalError ??= cleanupError;
|
||||
}
|
||||
await settle(finalError);
|
||||
})();
|
||||
};
|
||||
const close = (): void => {
|
||||
if (ending) return;
|
||||
beginEnd(null);
|
||||
try {
|
||||
socket.close(1000, 'Client closed design event stream');
|
||||
} catch {
|
||||
// The local queue is already closed.
|
||||
}
|
||||
};
|
||||
|
||||
socket.onopen = () => {
|
||||
if (ending) return;
|
||||
didOpen = true;
|
||||
heartbeat = setInterval(() => {
|
||||
if (ending || socket.readyState !== AGENT_WEBSOCKET_OPEN) return;
|
||||
try {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'ping',
|
||||
request_id: `design-ping-${Date.now()}`,
|
||||
}));
|
||||
} catch {
|
||||
beginEnd(unavailableError());
|
||||
}
|
||||
}, AGENT_WEBSOCKET_PING_INTERVAL_MS);
|
||||
resolveOpened();
|
||||
};
|
||||
socket.onmessage = ({ data }) => {
|
||||
if (ending) return;
|
||||
const agentEvent = agentEventFromWebSocketFrame(data);
|
||||
const event = normalizeWorkspaceEvent(
|
||||
agentEvent,
|
||||
session.session_id,
|
||||
input.workspaceId,
|
||||
);
|
||||
if (event) queue.push(event);
|
||||
};
|
||||
socket.onerror = () => {
|
||||
setTimeout(() => {
|
||||
if (!ending) beginEnd(unavailableError());
|
||||
}, 0);
|
||||
};
|
||||
socket.onclose = ({ code }) => {
|
||||
const error = webSocketCloseError(code);
|
||||
if (code === 4409) {
|
||||
beginEnd(error, async () => {
|
||||
try {
|
||||
await this.closeEventSession(session.session_id);
|
||||
} finally {
|
||||
await this.invalidateEventSession(input.workspaceId);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (code === 4404) {
|
||||
beginEnd(error, () => this.invalidateEventSession(input.workspaceId));
|
||||
return;
|
||||
}
|
||||
beginEnd(error);
|
||||
};
|
||||
unregister = this.registerEventSubscription(input.workspaceId, close);
|
||||
await opened;
|
||||
return {
|
||||
events: queue.events,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
async closeEventSessions(): Promise<void> {
|
||||
this.eventSessionsEnabled = false;
|
||||
const activeSubscriptions = [...this.eventSubscriptionClosers.values()]
|
||||
.flatMap((closers) => [...closers]);
|
||||
this.eventSubscriptionClosers.clear();
|
||||
for (const close of activeSubscriptions) close();
|
||||
const pendingSessions = [...this.eventSessions.entries()];
|
||||
this.eventSessions.clear();
|
||||
const sessions = await Promise.allSettled(
|
||||
pendingSessions.map(async ([workspaceId, pending]) => ({
|
||||
workspaceId,
|
||||
session: await pending,
|
||||
})),
|
||||
);
|
||||
const closeResults = await Promise.allSettled(
|
||||
sessions.flatMap((result) => (
|
||||
result.status === 'fulfilled'
|
||||
? [this.closeEventSession(result.value.session.session_id)
|
||||
.then(() => this.rotateEventSession(result.value.workspaceId))]
|
||||
: []
|
||||
)),
|
||||
);
|
||||
const uncertainCreations = sessions.filter((result) => (
|
||||
result.status === 'rejected'
|
||||
&& !(result.reason instanceof DesignWorkspaceModuleError
|
||||
&& result.reason.code === 'DESIGN_EVENT_SESSION_CLOSED')
|
||||
)).length;
|
||||
const failedCloses = closeResults.filter((result) => result.status === 'rejected').length;
|
||||
const failed = uncertainCreations + failedCloses;
|
||||
if (failed > 0) {
|
||||
throw new Error(`Failed to close ${failed} AI design Agent Session(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
openAssetContent(workspaceId: string, assetId: string, range?: string): Promise<Response> {
|
||||
return this.authorizedFetch(
|
||||
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(assetId)}/content`,
|
||||
@@ -373,4 +851,94 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private ensureEventSession(workspaceId: string): Promise<ServerAgentSession> {
|
||||
if (!this.eventSessionsEnabled) {
|
||||
throw new DesignWorkspaceModuleError(
|
||||
503,
|
||||
'DESIGN_EVENT_STREAM_PAUSED',
|
||||
'AI 设计任务状态流已暂停',
|
||||
);
|
||||
}
|
||||
const existing = this.eventSessions.get(workspaceId);
|
||||
if (existing) return existing;
|
||||
const pending = (async () => {
|
||||
const clientSessionId = this.eventSessionClientIds.get(workspaceId)
|
||||
?? await this.eventSessionClientIdStore.getOrCreate(workspaceId);
|
||||
this.eventSessionClientIds.set(workspaceId, clientSessionId);
|
||||
const session = await this.requestJson<ServerAgentSession>('/api/agents/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_session_id: clientSessionId,
|
||||
runtime: 'design',
|
||||
runtime_version: 'v1',
|
||||
binding: {
|
||||
kind: 'design_workspace',
|
||||
key: workspaceId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (session.status !== 'active') {
|
||||
throw new DesignWorkspaceModuleError(
|
||||
409,
|
||||
'DESIGN_EVENT_SESSION_CLOSED',
|
||||
'AI 设计任务状态会话已关闭',
|
||||
);
|
||||
}
|
||||
return session;
|
||||
})().catch(async (error) => {
|
||||
this.eventSessions.delete(workspaceId);
|
||||
if (error instanceof DesignWorkspaceModuleError
|
||||
&& error.code === 'DESIGN_EVENT_SESSION_CLOSED') {
|
||||
await this.rotateEventSession(workspaceId);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
this.eventSessions.set(workspaceId, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private createEventStreamTicket(sessionId: string): Promise<ServerAgentStreamTicket> {
|
||||
return this.requestJson<ServerAgentStreamTicket>(
|
||||
`/api/agents/sessions/${encodeURIComponent(sessionId)}/stream-tickets`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ transport: 'websocket' }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private registerEventSubscription(workspaceId: string, close: () => void): () => void {
|
||||
const closers = this.eventSubscriptionClosers.get(workspaceId) ?? new Set<() => void>();
|
||||
closers.add(close);
|
||||
this.eventSubscriptionClosers.set(workspaceId, closers);
|
||||
return () => {
|
||||
closers.delete(close);
|
||||
if (closers.size === 0) this.eventSubscriptionClosers.delete(workspaceId);
|
||||
};
|
||||
}
|
||||
|
||||
private async closeEventSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await this.requestJson<ServerAgentSession>(
|
||||
`/api/agents/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DesignWorkspaceModuleError
|
||||
&& (error.status === 404 || error.status === 409)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateEventSession(workspaceId: string): Promise<void> {
|
||||
return this.rotateEventSession(workspaceId).then(() => undefined);
|
||||
}
|
||||
|
||||
private async rotateEventSession(workspaceId: string): Promise<string> {
|
||||
this.eventSessions.delete(workspaceId);
|
||||
const clientSessionId = await this.eventSessionClientIdStore.rotate(workspaceId);
|
||||
this.eventSessionClientIds.set(workspaceId, clientSessionId);
|
||||
return clientSessionId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ import { logger } from '../utils/logger';
|
||||
import { warmupNetworkOptimization } from '../utils/uv-env';
|
||||
import { resolvePythonRuntime } from '../utils/python-runtime';
|
||||
import { initTelemetry } from '../utils/telemetry';
|
||||
import {
|
||||
getOrCreateAgentGatewaySessionClientId,
|
||||
rotateAgentGatewaySessionClientId,
|
||||
} from '../utils/store';
|
||||
|
||||
import { isQuitting, setQuitting } from './app-state';
|
||||
import { applyProxySettings } from './proxy';
|
||||
@@ -73,6 +77,7 @@ import {
|
||||
LocalImageWorkspace,
|
||||
} from '../image-workspace/local-workspace';
|
||||
import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace';
|
||||
import type { DesignWorkspaceModule } from '../image-workspace/module';
|
||||
|
||||
const WINDOWS_APP_USER_MODEL_ID = 'app.niancode.desktop';
|
||||
const isE2EMode = process.env.NIANCODE_E2E === '1';
|
||||
@@ -152,6 +157,7 @@ let hostApiServer: Server | null = null;
|
||||
let projectProgressSync: ReturnType<typeof createProjectProgressSync> | null = null;
|
||||
let worksCloudDeployment: ReturnType<typeof createWorksCloudDeployment> | null = null;
|
||||
let agentBrowser: AgentBrowserModule | null = null;
|
||||
let imageWorkspaceModule: DesignWorkspaceModule | null = null;
|
||||
const mainWindowFocusState = createMainWindowFocusState();
|
||||
const quitLifecycleState = createQuitLifecycleState();
|
||||
const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv);
|
||||
@@ -419,7 +425,13 @@ async function initialize(): Promise<void> {
|
||||
});
|
||||
const imageWorkspace = localImageWorkspaceEnabled
|
||||
? new LocalImageWorkspace({ userDataDir: app.getPath('userData') })
|
||||
: new WorksSquareDesignWorkspace();
|
||||
: new WorksSquareDesignWorkspace({
|
||||
eventSessionClientIdStore: {
|
||||
getOrCreate: getOrCreateAgentGatewaySessionClientId,
|
||||
rotate: rotateAgentGatewaySessionClientId,
|
||||
},
|
||||
});
|
||||
imageWorkspaceModule = imageWorkspace;
|
||||
if (localImageWorkspaceEnabled) {
|
||||
logger.info('AI painting workspace is using local development storage');
|
||||
} else {
|
||||
@@ -647,9 +659,13 @@ if (gotTheLock) {
|
||||
const stopAgentBrowserPromise = agentBrowser?.dispose().catch((err) => {
|
||||
logger.warn('agentBrowser.dispose() error during quit:', err);
|
||||
}) ?? Promise.resolve();
|
||||
const closeImageWorkspacePromise = imageWorkspaceModule?.closeEventSessions?.().catch((err) => {
|
||||
logger.warn('imageWorkspace.closeEventSessions() error during quit:', err);
|
||||
}) ?? Promise.resolve();
|
||||
const stopPromise = Promise.allSettled([
|
||||
stopOpencodePromise,
|
||||
stopAgentBrowserPromise,
|
||||
closeImageWorkspacePromise,
|
||||
]);
|
||||
const timeoutPromise = new Promise<'timeout'>((resolve) => {
|
||||
setTimeout(() => resolve('timeout'), 5000);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { app } from 'electron';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { resolveSupportedLanguage } from '../../shared/language';
|
||||
|
||||
// Lazy-load electron-store (ESM module)
|
||||
@@ -21,6 +22,7 @@ export interface AppSettings {
|
||||
launchAtStartup: boolean;
|
||||
telemetryEnabled: boolean;
|
||||
machineId: string;
|
||||
agentGatewaySessionClientIds: Record<string, string>;
|
||||
hasReportedInstall: boolean;
|
||||
|
||||
proxyEnabled: boolean;
|
||||
@@ -68,6 +70,7 @@ function createDefaultSettings(): AppSettings {
|
||||
launchAtStartup: false,
|
||||
telemetryEnabled: true,
|
||||
machineId: '',
|
||||
agentGatewaySessionClientIds: {},
|
||||
hasReportedInstall: false,
|
||||
|
||||
proxyEnabled: false,
|
||||
@@ -127,6 +130,39 @@ export async function setSetting<K extends keyof AppSettings>(
|
||||
store.set(key, value);
|
||||
}
|
||||
|
||||
let agentGatewaySessionIdMutation: Promise<void> = Promise.resolve();
|
||||
|
||||
async function mutateAgentGatewaySessionClientIds<T>(
|
||||
mutation: (current: Record<string, string>) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const result = agentGatewaySessionIdMutation.then(async () => {
|
||||
const current = await getSetting('agentGatewaySessionClientIds');
|
||||
return await mutation({ ...current });
|
||||
});
|
||||
agentGatewaySessionIdMutation = result.then(() => undefined, () => undefined);
|
||||
return await result;
|
||||
}
|
||||
|
||||
export function getOrCreateAgentGatewaySessionClientId(workspaceId: string): Promise<string> {
|
||||
return mutateAgentGatewaySessionClientIds(async (current) => {
|
||||
const existing = current[workspaceId]?.trim();
|
||||
if (existing) return existing;
|
||||
const created = `design-stream-${randomUUID()}`;
|
||||
current[workspaceId] = created;
|
||||
await setSetting('agentGatewaySessionClientIds', current);
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
export function rotateAgentGatewaySessionClientId(workspaceId: string): Promise<string> {
|
||||
return mutateAgentGatewaySessionClientIds(async (current) => {
|
||||
const created = `design-stream-${randomUUID()}`;
|
||||
current[workspaceId] = created;
|
||||
await setSetting('agentGatewaySessionClientIds', current);
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user