需求:服务端统一 Agent Gateway 将设计任务状态流切换为 WebSocket,客户端需要实时展示生成任务并支持断线恢复。 实现:Electron Main 管理 Session、一次性 Ticket、WebSocket 心跳与游标续传,按关闭码回收会话;Renderer 继续通过本机 Host API 的 SSE 投影接收任务事件,并保留 REST 降级同步。 验证:typecheck、变更文件 ESLint、37 个聚焦测试及 build:vite 通过。
300 lines
8.8 KiB
TypeScript
300 lines
8.8 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import { Readable } from 'node:stream';
|
|
import { pipeline } from 'node:stream/promises';
|
|
import {
|
|
IMAGE_WORKSPACE_API_PATH,
|
|
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
|
|
IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
|
|
type DesignConfirmGenerationInput,
|
|
type DesignCreateWorkspaceInput,
|
|
type DesignRenameWorkspaceInput,
|
|
type DesignSubmitMessageInput,
|
|
} from '../../../shared/image-workspace';
|
|
import { DesignWorkspaceModuleError } from '../../image-workspace/module';
|
|
import type { HostApiContext } from '../context';
|
|
import {
|
|
flushStreamingHeaders,
|
|
parseJsonBody,
|
|
sendJson,
|
|
writeStreamingChunk,
|
|
} from '../route-utils';
|
|
|
|
function decodedSegments(pathname: string): string[] | null {
|
|
const suffix = pathname.slice(IMAGE_WORKSPACE_API_PATH.length).replace(/^\/+/, '');
|
|
if (!suffix) return [];
|
|
try {
|
|
return suffix.split('/').map((segment) => decodeURIComponent(segment));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function asString(value: unknown): string {
|
|
return typeof value === 'string' ? value : '';
|
|
}
|
|
|
|
function asInteger(value: unknown): number {
|
|
return typeof value === 'number' && Number.isInteger(value) ? value : -1;
|
|
}
|
|
|
|
function asStringArray(value: unknown): string[] {
|
|
return Array.isArray(value) && value.every((item) => typeof item === 'string')
|
|
? value
|
|
: [];
|
|
}
|
|
|
|
function sendData(res: ServerResponse, data: unknown): void {
|
|
sendJson(res, 200, { success: true, status: 200, data });
|
|
}
|
|
|
|
function sendRouteError(res: ServerResponse, error: unknown): void {
|
|
if (res.headersSent) {
|
|
res.destroy(error instanceof Error ? error : undefined);
|
|
return;
|
|
}
|
|
if (error instanceof DesignWorkspaceModuleError) {
|
|
sendJson(res, error.status, {
|
|
success: false,
|
|
status: error.status,
|
|
code: error.code,
|
|
error: error.message,
|
|
});
|
|
return;
|
|
}
|
|
if (error instanceof SyntaxError) {
|
|
sendJson(res, 400, {
|
|
success: false,
|
|
status: 400,
|
|
code: 'IMAGE_WORKSPACE_INVALID_JSON',
|
|
error: '请求内容不是有效 JSON',
|
|
});
|
|
return;
|
|
}
|
|
sendJson(res, 500, {
|
|
success: false,
|
|
status: 500,
|
|
code: 'IMAGE_WORKSPACE_REQUEST_FAILED',
|
|
error: error instanceof Error ? error.message : 'AI 设计请求失败',
|
|
});
|
|
}
|
|
|
|
async function relayAssetContent(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
ctx: HostApiContext,
|
|
workspaceId: string,
|
|
assetId: string,
|
|
): Promise<void> {
|
|
const content = await ctx.imageWorkspace!.openAssetContent(
|
|
workspaceId,
|
|
assetId,
|
|
typeof req.headers.range === 'string' ? req.headers.range : undefined,
|
|
);
|
|
res.statusCode = content.status;
|
|
for (const header of [
|
|
'accept-ranges',
|
|
'cache-control',
|
|
'content-length',
|
|
'content-range',
|
|
'content-type',
|
|
'etag',
|
|
'last-modified',
|
|
]) {
|
|
const value = content.headers.get(header);
|
|
if (value) res.setHeader(header, value);
|
|
}
|
|
if (!content.body || req.method === 'HEAD') {
|
|
res.end();
|
|
return;
|
|
}
|
|
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,
|
|
url: URL,
|
|
ctx: HostApiContext,
|
|
): Promise<boolean> {
|
|
if (url.pathname !== IMAGE_WORKSPACE_API_PATH
|
|
&& !url.pathname.startsWith(`${IMAGE_WORKSPACE_API_PATH}/`)) {
|
|
return false;
|
|
}
|
|
|
|
if (!ctx.imageWorkspace) {
|
|
sendJson(res, 501, {
|
|
success: false,
|
|
status: 501,
|
|
code: IMAGE_WORKSPACE_UNAVAILABLE_CODE,
|
|
error: IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
const segments = decodedSegments(url.pathname);
|
|
if (!segments) {
|
|
sendJson(res, 400, { success: false, status: 400, error: '无效的 AI 设计路径' });
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
if (segments.length === 0 && req.method === 'GET') {
|
|
sendData(res, await ctx.imageWorkspace.bootstrap());
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 1 && segments[0] === 'workspaces' && req.method === 'POST') {
|
|
const body = await parseJsonBody<Record<string, unknown>>(req);
|
|
const input: DesignCreateWorkspaceInput = {
|
|
clientWorkspaceId: asString(body.clientWorkspaceId),
|
|
title: asString(body.title),
|
|
};
|
|
sendData(res, await ctx.imageWorkspace.createWorkspace(input));
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 1 && segments[0] === 'local-data' && req.method === 'DELETE') {
|
|
if (!ctx.imageWorkspace.reset) {
|
|
throw new DesignWorkspaceModuleError(
|
|
405,
|
|
'IMAGE_WORKSPACE_RESET_NOT_ALLOWED',
|
|
'云端 AI 设计不支持清空本地数据',
|
|
);
|
|
}
|
|
sendData(res, await ctx.imageWorkspace.reset());
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'GET') {
|
|
sendData(res, await ctx.imageWorkspace.getWorkspace(segments[1]));
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'PATCH') {
|
|
const body = await parseJsonBody<Record<string, unknown>>(req);
|
|
const input: DesignRenameWorkspaceInput = {
|
|
workspaceId: segments[1],
|
|
title: asString(body.title),
|
|
};
|
|
sendData(res, await ctx.imageWorkspace.renameWorkspace(input));
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 3
|
|
&& segments[0] === 'workspaces'
|
|
&& segments[2] === 'messages'
|
|
&& req.method === 'POST') {
|
|
const body = await parseJsonBody<Record<string, unknown>>(req);
|
|
const input: DesignSubmitMessageInput = {
|
|
workspaceId: segments[1],
|
|
clientTurnId: asString(body.clientTurnId),
|
|
expectedTurnRevision: asInteger(body.expectedTurnRevision),
|
|
message: asString(body.message),
|
|
attachmentAssetIds: asStringArray(body.attachmentAssetIds),
|
|
};
|
|
sendData(res, await ctx.imageWorkspace.submitMessage(input));
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 3
|
|
&& segments[0] === 'workspaces'
|
|
&& segments[2] === 'tasks'
|
|
&& req.method === 'GET') {
|
|
sendData(res, await ctx.imageWorkspace.listTasks(segments[1]));
|
|
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'
|
|
&& segments[4] === 'confirm'
|
|
&& req.method === 'POST') {
|
|
const body = await parseJsonBody<Record<string, unknown>>(req);
|
|
const input: DesignConfirmGenerationInput = {
|
|
workspaceId: segments[1],
|
|
quoteId: segments[3],
|
|
clientTurnId: asString(body.clientTurnId),
|
|
expectedTurnRevision: asInteger(body.expectedTurnRevision),
|
|
};
|
|
sendData(res, await ctx.imageWorkspace.confirmGeneration(input));
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 5
|
|
&& segments[0] === 'workspaces'
|
|
&& segments[2] === 'assets'
|
|
&& segments[4] === 'content'
|
|
&& (req.method === 'GET' || req.method === 'HEAD')) {
|
|
await relayAssetContent(req, res, ctx, segments[1], segments[3]);
|
|
return true;
|
|
}
|
|
|
|
sendJson(res, 404, {
|
|
success: false,
|
|
status: 404,
|
|
code: 'IMAGE_WORKSPACE_ROUTE_NOT_FOUND',
|
|
error: `没有对应的 AI 设计接口:${req.method ?? 'GET'} ${url.pathname}`,
|
|
});
|
|
} catch (error) {
|
|
sendRouteError(res, error);
|
|
}
|
|
return true;
|
|
}
|