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

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type {
DesignGenerationTask,
DesignGenerationTaskUpdatedEvent,
DesignWorkspace,
DesignWorkspaceBootstrap,
} from '../../shared/image-workspace';
@@ -16,8 +17,29 @@ const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
const resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn());
type EventListener = (event: MessageEvent<string>) => void;
class MockEventSource {
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
readonly close = vi.fn();
private readonly listeners = new Map<string, Set<EventListener>>();
addEventListener(type: string, listener: EventListener): void {
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
emit(type: string, payload: unknown): void {
const event = { data: JSON.stringify(payload) } as MessageEvent<string>;
for (const listener of this.listeners.get(type) ?? []) listener(event);
}
}
vi.mock('@/lib/image-workspace', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
return {
@@ -29,6 +51,9 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
confirmImageWorkspaceGenerationMock(...args)
),
openImageWorkspaceTaskEvents: (...args: unknown[]) => (
openImageWorkspaceTaskEventsMock(...args)
),
resolveImageWorkspaceAssetUrl: (...args: unknown[]) => (
resolveImageWorkspaceAssetUrlMock(...args)
),
@@ -122,6 +147,8 @@ const taskFixture: DesignGenerationTask = {
};
describe('ImageCanvas Workspace-first design experience', () => {
let taskEventSource: MockEventSource;
beforeEach(() => {
vi.clearAllMocks();
useImageWorkspaceStore.getState().reset();
@@ -134,6 +161,10 @@ describe('ImageCanvas Workspace-first design experience', () => {
turnRevision: 2,
phase: 'shaping',
});
taskEventSource = new MockEventSource();
openImageWorkspaceTaskEventsMock.mockResolvedValue(
taskEventSource as unknown as EventSource,
);
resolveImageWorkspaceAssetUrlMock.mockResolvedValue(
'http://127.0.0.1:13210/content?token=host',
);
@@ -330,27 +361,33 @@ describe('ImageCanvas Workspace-first design experience', () => {
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
});
it('keeps checking after Quote confirmation until the new task becomes visible', async () => {
it('renders a new task pushed by the design event stream without repeated polling', async () => {
const queuedTask = {
...taskFixture,
taskId: 'task-delayed',
status: 'queued' as const,
resultAssets: [],
};
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([queuedTask]);
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('design-quote-quote-one');
await waitFor(() => expect(openImageWorkspaceTaskEventsMock)
.toHaveBeenCalledWith('workspace-cloud'));
await waitFor(() => expect(taskEventSource.onopen).not.toBeNull());
act(() => {
taskEventSource.onopen?.(new Event('open'));
taskEventSource.emit('design.generation_task.updated', {
id: 'session-one:2',
type: 'design.generation_task.updated',
workspaceId: 'workspace-cloud',
workspaceViewRevision: 2,
generationTask: queuedTask,
} satisfies DesignGenerationTaskUpdatedEvent);
});
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
.toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one'));
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(3));
expect(await screen.findByTestId('design-task-task-delayed'))
.toBeInTheDocument();
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledOnce();
});
});