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'
|
||||
|
||||
Reference in New Issue
Block a user