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:
2026-08-02 20:15:37 +08:00
parent 83e21563dc
commit 518d7ab4cd
17 changed files with 1816 additions and 50 deletions

View File

@@ -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) {

View File

@@ -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'

View File

@@ -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>;
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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
*/