完成 Editor Agent Mock Agent P1 收尾
接入 Web Project 契约、SpacetimeDB 表与 api-server 控制面 新增 Mock Agent、静态构建 runner 与独立预览网关 补齐 /editor/agent 前端页面、服务客户端和 SSE 订阅 修复 sandbox 预览资源跨域加载并补充并发保护 接入本地 dev 预览端口漂移与服务身份初始化 更新 P1 技术方案、验收清单和 Hermes 共享记忆
This commit is contained in:
@@ -133,8 +133,9 @@ export default function App() {
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
const isImageEditorStage = selectionStage === 'image-editor';
|
||||
const platformShellSurfaceClass = isImageEditorStage
|
||||
const isEditorToolStage =
|
||||
selectionStage === 'image-editor' || selectionStage === 'editor-agent';
|
||||
const platformShellSurfaceClass = isEditorToolStage
|
||||
? 'bg-white p-0'
|
||||
: 'bg-[image:var(--platform-body-fill)] p-2 sm:p-4';
|
||||
|
||||
|
||||
363
src/components/editor/agent/WebProjectAgentEditorPage.test.tsx
Normal file
363
src/components/editor/agent/WebProjectAgentEditorPage.test.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
WebProject,
|
||||
WebProjectFile,
|
||||
WebProjectPreviewBuild,
|
||||
WebProjectSnapshot,
|
||||
} from '../../../../packages/shared/src/contracts/webProject';
|
||||
import { WebProjectAgentEditorPage } from './WebProjectAgentEditorPage';
|
||||
|
||||
const createWebProjectWithSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const loadWebProjectMock = vi.hoisted(() => vi.fn());
|
||||
const loadActiveWebProjectSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const loadWebProjectPreviewBuildMock = vi.hoisted(() => vi.fn());
|
||||
const saveWebProjectFilesMock = vi.hoisted(() => vi.fn());
|
||||
const submitMockAgentTurnMock = vi.hoisted(() => vi.fn());
|
||||
const createWebProjectPreviewBuildMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeWebProjectPreviewBuildEventsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../../services/web-project/webProjectClient', () => ({
|
||||
createWebProjectWithSnapshot: createWebProjectWithSnapshotMock,
|
||||
loadWebProject: loadWebProjectMock,
|
||||
loadActiveWebProjectSnapshot: loadActiveWebProjectSnapshotMock,
|
||||
loadWebProjectPreviewBuild: loadWebProjectPreviewBuildMock,
|
||||
saveWebProjectFiles: saveWebProjectFilesMock,
|
||||
submitMockAgentTurn: submitMockAgentTurnMock,
|
||||
createWebProjectPreviewBuild: createWebProjectPreviewBuildMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/web-project/webProjectSse', () => ({
|
||||
subscribeWebProjectPreviewBuildEvents: subscribeWebProjectPreviewBuildEventsMock,
|
||||
}));
|
||||
|
||||
const baseFiles: WebProjectFile[] = [
|
||||
{
|
||||
path: 'src/App.css',
|
||||
content: 'body { margin: 0; }',
|
||||
mediaType: 'text/css',
|
||||
encoding: 'utf-8',
|
||||
sizeBytes: 18,
|
||||
},
|
||||
{
|
||||
path: 'src/App.tsx',
|
||||
content: 'export default function App() { return <h1>初始</h1>; }',
|
||||
mediaType: 'text/typescript',
|
||||
encoding: 'utf-8',
|
||||
sizeBytes: 55,
|
||||
},
|
||||
];
|
||||
|
||||
function createProject(input: Partial<WebProject> = {}): WebProject {
|
||||
return {
|
||||
projectId: 'web-project-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '未命名 Web 工程',
|
||||
templateKey: 'react-vite-ts-static',
|
||||
activeSnapshotId: 'web-snapshot-1',
|
||||
activePreviewBuildId: null,
|
||||
createdAt: '2026-06-15T00:00:00.000Z',
|
||||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function createSnapshot(input: Partial<WebProjectSnapshot> = {}): WebProjectSnapshot {
|
||||
return {
|
||||
snapshotId: 'web-snapshot-1',
|
||||
projectId: 'web-project-1',
|
||||
ownerUserId: 'user-1',
|
||||
parentSnapshotId: null,
|
||||
templateKey: 'react-vite-ts-static',
|
||||
files: baseFiles,
|
||||
patchSummary: '初始化固定模板',
|
||||
createdBy: 'system',
|
||||
createdAt: '2026-06-15T00:00:00.000Z',
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function createBuild(
|
||||
input: Partial<WebProjectPreviewBuild> = {},
|
||||
): WebProjectPreviewBuild {
|
||||
return {
|
||||
jobId: 'web-build-1',
|
||||
projectId: 'web-project-1',
|
||||
snapshotId: 'web-snapshot-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'queued',
|
||||
logs: ['构建任务已进入队列'],
|
||||
artifactId: null,
|
||||
previewTokenId: null,
|
||||
previewUrl: null,
|
||||
errorSummary: null,
|
||||
createdAt: '2026-06-15T00:00:00.000Z',
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
describe('WebProjectAgentEditorPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.history.replaceState(null, '', '/editor/agent');
|
||||
window.localStorage.clear();
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes('1024px'),
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
createWebProjectWithSnapshotMock.mockResolvedValue({
|
||||
project: createProject(),
|
||||
snapshot: createSnapshot(),
|
||||
});
|
||||
saveWebProjectFilesMock.mockResolvedValue(createSnapshot());
|
||||
createWebProjectPreviewBuildMock.mockResolvedValue(createBuild());
|
||||
subscribeWebProjectPreviewBuildEventsMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('creates a project when no project id is present and writes the query id', async () => {
|
||||
render(<WebProjectAgentEditorPage />);
|
||||
|
||||
expect(await screen.findByText('未命名 Web 工程')).toBeTruthy();
|
||||
expect(createWebProjectWithSnapshotMock).toHaveBeenCalledWith({
|
||||
title: '未命名 Web 工程',
|
||||
});
|
||||
expect(window.location.search).toBe('?projectid=web-project-1');
|
||||
expect(screen.getByLabelText<HTMLTextAreaElement>('代码编辑区').value).toBe(
|
||||
'export default function App() { return <h1>初始</h1>; }',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads an explicit project id from query before rendering files', async () => {
|
||||
window.history.replaceState(null, '', '/editor/agent?projectid=web-project-query');
|
||||
loadWebProjectMock.mockResolvedValue(createProject({ projectId: 'web-project-query' }));
|
||||
loadActiveWebProjectSnapshotMock.mockResolvedValue(
|
||||
createSnapshot({ projectId: 'web-project-query' }),
|
||||
);
|
||||
|
||||
render(<WebProjectAgentEditorPage />);
|
||||
|
||||
expect(await screen.findByText('未命名 Web 工程')).toBeTruthy();
|
||||
expect(loadWebProjectMock).toHaveBeenCalledWith('web-project-query');
|
||||
expect(loadActiveWebProjectSnapshotMock).toHaveBeenCalledWith(
|
||||
'web-project-query',
|
||||
);
|
||||
expect(createWebProjectWithSnapshotMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves current file as an updateFile patch', async () => {
|
||||
const user = userEvent.setup();
|
||||
const savedContent =
|
||||
'export default function App() { return <h1>已保存</h1>; }';
|
||||
const nextSnapshot = createSnapshot({
|
||||
snapshotId: 'web-snapshot-2',
|
||||
parentSnapshotId: 'web-snapshot-1',
|
||||
files: [
|
||||
baseFiles[0]!,
|
||||
{
|
||||
...baseFiles[1]!,
|
||||
content: savedContent,
|
||||
},
|
||||
],
|
||||
});
|
||||
saveWebProjectFilesMock.mockResolvedValue(nextSnapshot);
|
||||
|
||||
render(<WebProjectAgentEditorPage />);
|
||||
const editor = await screen.findByLabelText('代码编辑区');
|
||||
fireEvent.change(editor, { target: { value: savedContent } });
|
||||
await user.click(screen.getByRole('button', { name: /保存/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveWebProjectFilesMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(saveWebProjectFilesMock).toHaveBeenCalledWith(
|
||||
'web-project-1',
|
||||
expect.objectContaining({
|
||||
baseSnapshotId: 'web-snapshot-1',
|
||||
summary: '更新 src/App.tsx',
|
||||
patch: {
|
||||
operations: [
|
||||
{
|
||||
type: 'updateFile',
|
||||
path: 'src/App.tsx',
|
||||
content: savedContent,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits mock turn, creates build, subscribes events, and uses backend preview url', async () => {
|
||||
const user = userEvent.setup();
|
||||
const agentSnapshot = createSnapshot({
|
||||
snapshotId: 'web-snapshot-agent',
|
||||
parentSnapshotId: 'web-snapshot-1',
|
||||
files: [
|
||||
baseFiles[0]!,
|
||||
{
|
||||
...baseFiles[1]!,
|
||||
content: 'export default function App() { return <button>1</button>; }',
|
||||
},
|
||||
],
|
||||
patchSummary: '更新首页计数按钮示例',
|
||||
createdBy: 'mock-agent',
|
||||
});
|
||||
submitMockAgentTurnMock.mockResolvedValue({
|
||||
snapshot: agentSnapshot,
|
||||
patch: {
|
||||
operations: [
|
||||
{
|
||||
type: 'updateFile',
|
||||
path: 'src/App.tsx',
|
||||
content:
|
||||
'export default function App() { return <button>1</button>; }',
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: '更新首页计数按钮示例',
|
||||
});
|
||||
createWebProjectPreviewBuildMock.mockResolvedValue(
|
||||
createBuild({
|
||||
jobId: 'web-build-success',
|
||||
snapshotId: 'web-snapshot-agent',
|
||||
}),
|
||||
);
|
||||
subscribeWebProjectPreviewBuildEventsMock.mockImplementation(
|
||||
async (_jobId: string, options: { onEvent: (event: unknown) => void }) => {
|
||||
options.onEvent({
|
||||
jobId: 'web-build-success',
|
||||
status: 'succeeded',
|
||||
message: '构建完成',
|
||||
build: createBuild({
|
||||
jobId: 'web-build-success',
|
||||
snapshotId: 'web-snapshot-agent',
|
||||
status: 'succeeded',
|
||||
logs: ['构建完成'],
|
||||
previewUrl: 'http://127.0.0.1:3999/p/backend-token/',
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
render(<WebProjectAgentEditorPage />);
|
||||
|
||||
await user.type(
|
||||
await screen.findByLabelText('Mock Agent 输入'),
|
||||
'做一个蓝色计数按钮页面',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /发送/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitMockAgentTurnMock).toHaveBeenCalledWith('web-project-1', {
|
||||
prompt: '做一个蓝色计数按钮页面',
|
||||
baseSnapshotId: 'web-snapshot-1',
|
||||
});
|
||||
});
|
||||
expect(createWebProjectPreviewBuildMock).toHaveBeenCalledWith(
|
||||
'web-project-1',
|
||||
{ snapshotId: 'web-snapshot-agent' },
|
||||
);
|
||||
expect(subscribeWebProjectPreviewBuildEventsMock).toHaveBeenCalledWith(
|
||||
'web-build-success',
|
||||
expect.objectContaining({ onEvent: expect.any(Function) }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const iframe = screen.getByTitle('Web 工程预览');
|
||||
expect(iframe.getAttribute('src')).toBe(
|
||||
'http://127.0.0.1:3999/p/backend-token/',
|
||||
);
|
||||
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps previous preview iframe when a later build fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
createWebProjectWithSnapshotMock.mockResolvedValueOnce({
|
||||
project: createProject({ activePreviewBuildId: 'web-build-active' }),
|
||||
snapshot: createSnapshot(),
|
||||
});
|
||||
loadWebProjectPreviewBuildMock.mockResolvedValueOnce(
|
||||
createBuild({
|
||||
jobId: 'web-build-active',
|
||||
status: 'succeeded',
|
||||
previewUrl: 'http://127.0.0.1:3999/p/old-token/',
|
||||
}),
|
||||
);
|
||||
createWebProjectPreviewBuildMock.mockResolvedValue(
|
||||
createBuild({
|
||||
jobId: 'web-build-failed',
|
||||
}),
|
||||
);
|
||||
subscribeWebProjectPreviewBuildEventsMock.mockImplementation(
|
||||
async (_jobId: string, options: { onEvent: (event: unknown) => void }) => {
|
||||
options.onEvent({
|
||||
jobId: 'web-build-failed',
|
||||
status: 'failed',
|
||||
message: '构建失败',
|
||||
build: createBuild({
|
||||
jobId: 'web-build-failed',
|
||||
status: 'failed',
|
||||
previewUrl: null,
|
||||
errorSummary: 'TypeScript 编译失败',
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
render(<WebProjectAgentEditorPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Web 工程预览').getAttribute('src')).toBe(
|
||||
'http://127.0.0.1:3999/p/old-token/',
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /构建/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('TypeScript 编译失败').length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
expect(screen.getByTitle('Web 工程预览').getAttribute('src')).toBe(
|
||||
'http://127.0.0.1:3999/p/old-token/',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses mobile tabs for the compact layout', async () => {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: !query.includes('1024px'),
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<WebProjectAgentEditorPage />);
|
||||
|
||||
const tablist = await screen.findByRole('tablist', {
|
||||
name: 'Editor Agent 面板',
|
||||
});
|
||||
expect(within(tablist).getByRole('tab', { name: '文件' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('代码编辑区')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
949
src/components/editor/agent/WebProjectAgentEditorPage.tsx
Normal file
949
src/components/editor/agent/WebProjectAgentEditorPage.tsx
Normal file
@@ -0,0 +1,949 @@
|
||||
import {
|
||||
Code2,
|
||||
FileText,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
MonitorPlay,
|
||||
Play,
|
||||
Save,
|
||||
Send,
|
||||
TerminalSquare,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
WebProject,
|
||||
WebProjectFile,
|
||||
WebProjectPreviewBuild,
|
||||
WebProjectSnapshot,
|
||||
} from '../../../../packages/shared/src/contracts/webProject';
|
||||
import { PlatformActionButton } from '../../common/PlatformActionButton';
|
||||
import { PlatformSegmentedTabs } from '../../common/PlatformSegmentedTabs';
|
||||
import { PlatformStatusMessage } from '../../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../../common/PlatformSubpanel';
|
||||
import {
|
||||
createWebProjectPreviewBuild,
|
||||
createWebProjectWithSnapshot,
|
||||
loadActiveWebProjectSnapshot,
|
||||
loadWebProject,
|
||||
loadWebProjectPreviewBuild,
|
||||
saveWebProjectFiles,
|
||||
submitMockAgentTurn,
|
||||
} from '../../../services/web-project/webProjectClient';
|
||||
import { subscribeWebProjectPreviewBuildEvents } from '../../../services/web-project/webProjectSse';
|
||||
import {
|
||||
buildLogEntriesFromPreviewBuild,
|
||||
createBuildLogEntry,
|
||||
createUpdateFilePatch,
|
||||
findWebProjectFile,
|
||||
hasFileContentChanged,
|
||||
isTerminalWebProjectBuild,
|
||||
mergeWebProjectBuildEvent,
|
||||
resolveInitialSelectedFilePath,
|
||||
sortWebProjectFiles,
|
||||
type WebProjectAgentLogEntry,
|
||||
} from './webProjectAgentViewModel';
|
||||
|
||||
type WebProjectAgentMobileTab = 'files' | 'code' | 'preview' | 'logs';
|
||||
|
||||
const WEB_PROJECT_QUERY_KEYS = ['projectid', 'projectId'] as const;
|
||||
const WEB_PROJECT_PENDING_BUILD_STORAGE_PREFIX =
|
||||
'genarrative.web-project.pending-build.';
|
||||
|
||||
const MOBILE_TABS = [
|
||||
{ id: 'files', label: '文件' },
|
||||
{ id: 'code', label: '代码' },
|
||||
{ id: 'preview', label: '预览' },
|
||||
{ id: 'logs', label: '日志' },
|
||||
] as const;
|
||||
|
||||
function readInitialProjectIdFromLocation() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
for (const key of WEB_PROJECT_QUERY_KEYS) {
|
||||
const value = params.get(key)?.trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeProjectIdToLocation(projectId: string) {
|
||||
const nextUrl = new URL(window.location.href);
|
||||
nextUrl.searchParams.set('projectid', projectId);
|
||||
nextUrl.searchParams.delete('projectId');
|
||||
window.history.replaceState(null, '', `${nextUrl.pathname}${nextUrl.search}`);
|
||||
}
|
||||
|
||||
function pendingBuildStorageKey(projectId: string) {
|
||||
return `${WEB_PROJECT_PENDING_BUILD_STORAGE_PREFIX}${projectId}`;
|
||||
}
|
||||
|
||||
function readPendingBuildJobId(projectId: string) {
|
||||
try {
|
||||
return window.localStorage
|
||||
.getItem(pendingBuildStorageKey(projectId))
|
||||
?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePendingBuildJobId(projectId: string, jobId: string) {
|
||||
try {
|
||||
window.localStorage.setItem(pendingBuildStorageKey(projectId), jobId);
|
||||
} catch {
|
||||
// localStorage 只用于刷新后恢复订阅;不可用时不影响主流程。
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingBuildJobId(projectId: string, jobId?: string) {
|
||||
try {
|
||||
const key = pendingBuildStorageKey(projectId);
|
||||
if (jobId && window.localStorage.getItem(key) !== jobId) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// localStorage 只用于刷新后恢复订阅;不可用时不影响主流程。
|
||||
}
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function buildStatusLabel(build: WebProjectPreviewBuild | null) {
|
||||
switch (build?.status) {
|
||||
case 'queued':
|
||||
return '排队中';
|
||||
case 'running':
|
||||
return '构建中';
|
||||
case 'succeeded':
|
||||
return '已完成';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
case 'cancelled':
|
||||
return '已取消';
|
||||
case 'expired':
|
||||
return '已过期';
|
||||
case 'stale':
|
||||
return '已过时';
|
||||
default:
|
||||
return '未构建';
|
||||
}
|
||||
}
|
||||
|
||||
function useAgentDesktopLayout() {
|
||||
const [isDesktop, setIsDesktop] = useState(() =>
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(min-width: 1024px)').matches
|
||||
: false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||
const handleChange = () => setIsDesktop(mediaQuery.matches);
|
||||
handleChange();
|
||||
mediaQuery.addEventListener?.('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener?.('change', handleChange);
|
||||
}, []);
|
||||
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
function useWebProjectAgentController() {
|
||||
const [project, setProject] = useState<WebProject | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<WebProjectSnapshot | null>(null);
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
|
||||
const [editorContent, setEditorContent] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [activePreviewUrl, setActivePreviewUrl] = useState<string | null>(null);
|
||||
const [currentBuild, setCurrentBuild] =
|
||||
useState<WebProjectPreviewBuild | null>(null);
|
||||
const [logs, setLogs] = useState<WebProjectAgentLogEntry[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isMockAgentBusy, setIsMockAgentBusy] = useState(false);
|
||||
const [isCreatingBuild, setIsCreatingBuild] = useState(false);
|
||||
const [mobileTab, setMobileTab] =
|
||||
useState<WebProjectAgentMobileTab>('code');
|
||||
const buildAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const selectedFilePathRef = useRef<string | null>(null);
|
||||
|
||||
const files = useMemo(
|
||||
() => sortWebProjectFiles(snapshot?.files ?? []),
|
||||
[snapshot?.files],
|
||||
);
|
||||
const selectedFile = useMemo(
|
||||
() => findWebProjectFile(files, selectedFilePath),
|
||||
[files, selectedFilePath],
|
||||
);
|
||||
const hasUnsavedChange = hasFileContentChanged(selectedFile, editorContent);
|
||||
const isBuildBusy =
|
||||
isCreatingBuild ||
|
||||
currentBuild?.status === 'queued' ||
|
||||
currentBuild?.status === 'running';
|
||||
|
||||
const appendLog = useCallback((entry: Omit<WebProjectAgentLogEntry, 'id'>) => {
|
||||
setLogs((currentLogs) => [
|
||||
...currentLogs,
|
||||
{
|
||||
...entry,
|
||||
id: `${Date.now()}:${currentLogs.length}:${entry.text}`,
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const applySnapshot = useCallback((nextSnapshot: WebProjectSnapshot) => {
|
||||
if (!nextSnapshot || !Array.isArray(nextSnapshot.files)) {
|
||||
throw new Error('Web 工程快照文件列表无效');
|
||||
}
|
||||
|
||||
setSnapshot(nextSnapshot);
|
||||
const nextPath = resolveInitialSelectedFilePath(
|
||||
nextSnapshot.files,
|
||||
selectedFilePathRef.current,
|
||||
);
|
||||
selectedFilePathRef.current = nextPath;
|
||||
setSelectedFilePath(nextPath);
|
||||
setEditorContent(
|
||||
findWebProjectFile(nextSnapshot.files, nextPath)?.content ?? '',
|
||||
);
|
||||
}, []);
|
||||
|
||||
const selectFile = useCallback((file: WebProjectFile) => {
|
||||
selectedFilePathRef.current = file.path;
|
||||
setSelectedFilePath(file.path);
|
||||
setEditorContent(file.content);
|
||||
setMobileTab('code');
|
||||
}, []);
|
||||
|
||||
const handleTerminalBuild = useCallback(
|
||||
async (build: WebProjectPreviewBuild) => {
|
||||
if (!isTerminalWebProjectBuild(build)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingBuildJobId(build.projectId, build.jobId);
|
||||
|
||||
if (build.status === 'succeeded' && build.previewUrl) {
|
||||
setActivePreviewUrl(build.previewUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (build.status === 'failed') {
|
||||
setErrorMessage(build.errorSummary || '预览构建失败');
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshBuild = useCallback(
|
||||
async (jobId: string) => {
|
||||
try {
|
||||
const build = await loadWebProjectPreviewBuild(jobId);
|
||||
setCurrentBuild(build);
|
||||
setLogs(buildLogEntriesFromPreviewBuild(build));
|
||||
await handleTerminalBuild(build);
|
||||
return build;
|
||||
} catch (error) {
|
||||
setErrorMessage(resolveErrorMessage(error, '读取预览构建失败'));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[handleTerminalBuild],
|
||||
);
|
||||
|
||||
const subscribeToBuild = useCallback(
|
||||
(jobId: string) => {
|
||||
buildAbortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
buildAbortControllerRef.current = controller;
|
||||
|
||||
void subscribeWebProjectPreviewBuildEvents(jobId, {
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => {
|
||||
const logEntry = createBuildLogEntry(event);
|
||||
if (logEntry) {
|
||||
setLogs((currentLogs) => [...currentLogs, logEntry]);
|
||||
}
|
||||
|
||||
setCurrentBuild((build) => {
|
||||
if (build?.jobId !== event.jobId) {
|
||||
return build;
|
||||
}
|
||||
return mergeWebProjectBuildEvent(build, event);
|
||||
});
|
||||
|
||||
if (
|
||||
event.status === 'succeeded' ||
|
||||
event.status === 'failed' ||
|
||||
event.status === 'cancelled' ||
|
||||
event.status === 'expired' ||
|
||||
event.status === 'stale'
|
||||
) {
|
||||
if (event.build) {
|
||||
void handleTerminalBuild(event.build);
|
||||
} else {
|
||||
void refreshBuild(event.jobId);
|
||||
}
|
||||
}
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(resolveErrorMessage(error, '预览构建订阅中断'));
|
||||
void refreshBuild(jobId);
|
||||
});
|
||||
},
|
||||
[handleTerminalBuild, refreshBuild],
|
||||
);
|
||||
|
||||
const loadInitialState = useCallback(async () => {
|
||||
setIsBootstrapping(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const projectId = readInitialProjectIdFromLocation();
|
||||
const bundle = projectId
|
||||
? {
|
||||
project: await loadWebProject(projectId),
|
||||
snapshot: await loadActiveWebProjectSnapshot(projectId),
|
||||
}
|
||||
: await createWebProjectWithSnapshot({ title: '未命名 Web 工程' });
|
||||
|
||||
if (!projectId) {
|
||||
writeProjectIdToLocation(bundle.project.projectId);
|
||||
}
|
||||
|
||||
setProject(bundle.project);
|
||||
applySnapshot(bundle.snapshot);
|
||||
appendLog({ tone: 'info', text: '工程已打开' });
|
||||
|
||||
const activeBuildId = bundle.project.activePreviewBuildId?.trim();
|
||||
if (activeBuildId) {
|
||||
const activeBuild = await loadWebProjectPreviewBuild(activeBuildId);
|
||||
if (activeBuild.status === 'succeeded' && activeBuild.previewUrl) {
|
||||
setActivePreviewUrl(activeBuild.previewUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const pendingJobId = readPendingBuildJobId(bundle.project.projectId);
|
||||
if (pendingJobId && pendingJobId !== activeBuildId) {
|
||||
const pendingBuild = await refreshBuild(pendingJobId);
|
||||
if (pendingBuild && !isTerminalWebProjectBuild(pendingBuild)) {
|
||||
subscribeToBuild(pendingBuild.jobId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(resolveErrorMessage(error, '打开 Web 工程失败'));
|
||||
} finally {
|
||||
setIsBootstrapping(false);
|
||||
}
|
||||
}, [appendLog, applySnapshot, refreshBuild, subscribeToBuild]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitialState();
|
||||
return () => buildAbortControllerRef.current?.abort();
|
||||
}, [loadInitialState]);
|
||||
|
||||
const saveCurrentFile = useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
if (!project || !snapshot || !selectedFile) {
|
||||
throw new Error('当前文件不可用');
|
||||
}
|
||||
|
||||
if (!hasFileContentChanged(selectedFile, editorContent)) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const nextSnapshot = await saveWebProjectFiles(project.projectId, {
|
||||
baseSnapshotId: snapshot.snapshotId,
|
||||
patch: createUpdateFilePatch(selectedFile, editorContent),
|
||||
summary: `更新 ${selectedFile.path}`,
|
||||
});
|
||||
applySnapshot(nextSnapshot);
|
||||
if (!options.silent) {
|
||||
appendLog({ tone: 'success', text: '文件已保存' });
|
||||
}
|
||||
return nextSnapshot;
|
||||
} catch (error) {
|
||||
const message = resolveErrorMessage(error, '保存 Web 工程文件失败');
|
||||
setErrorMessage(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
appendLog,
|
||||
applySnapshot,
|
||||
editorContent,
|
||||
project,
|
||||
selectedFile,
|
||||
snapshot,
|
||||
],
|
||||
);
|
||||
|
||||
const createPreviewBuild = useCallback(
|
||||
async (targetSnapshot: WebProjectSnapshot) => {
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsCreatingBuild(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const build = await createWebProjectPreviewBuild(project.projectId, {
|
||||
snapshotId: targetSnapshot.snapshotId,
|
||||
});
|
||||
setCurrentBuild(build);
|
||||
setLogs(buildLogEntriesFromPreviewBuild(build));
|
||||
writePendingBuildJobId(project.projectId, build.jobId);
|
||||
|
||||
if (isTerminalWebProjectBuild(build)) {
|
||||
await handleTerminalBuild(build);
|
||||
} else {
|
||||
subscribeToBuild(build.jobId);
|
||||
}
|
||||
|
||||
return build;
|
||||
} catch (error) {
|
||||
setErrorMessage(resolveErrorMessage(error, '创建预览构建失败'));
|
||||
return null;
|
||||
} finally {
|
||||
setIsCreatingBuild(false);
|
||||
}
|
||||
},
|
||||
[handleTerminalBuild, project, subscribeToBuild],
|
||||
);
|
||||
|
||||
const handleSaveCurrentFile = useCallback(async () => {
|
||||
await saveCurrentFile();
|
||||
}, [saveCurrentFile]);
|
||||
|
||||
const handleCreatePreviewBuild = useCallback(async () => {
|
||||
const targetSnapshot = await saveCurrentFile({ silent: true });
|
||||
await createPreviewBuild(targetSnapshot);
|
||||
setMobileTab('preview');
|
||||
}, [createPreviewBuild, saveCurrentFile]);
|
||||
|
||||
const handleSubmitMockAgent = useCallback(
|
||||
async (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (!project || !snapshot || !trimmedPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMockAgentBusy(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const baseSnapshot = await saveCurrentFile({ silent: true });
|
||||
const response = await submitMockAgentTurn(project.projectId, {
|
||||
prompt: trimmedPrompt,
|
||||
baseSnapshotId: baseSnapshot.snapshotId,
|
||||
});
|
||||
applySnapshot(response.snapshot);
|
||||
appendLog({ tone: 'success', text: response.summary });
|
||||
setPrompt('');
|
||||
await createPreviewBuild(response.snapshot);
|
||||
setMobileTab('preview');
|
||||
} catch (error) {
|
||||
setErrorMessage(resolveErrorMessage(error, 'Mock Agent 执行失败'));
|
||||
} finally {
|
||||
setIsMockAgentBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
appendLog,
|
||||
applySnapshot,
|
||||
createPreviewBuild,
|
||||
project,
|
||||
prompt,
|
||||
saveCurrentFile,
|
||||
snapshot,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
activePreviewUrl,
|
||||
buildStatusLabel: buildStatusLabel(currentBuild),
|
||||
currentBuild,
|
||||
editorContent,
|
||||
errorMessage,
|
||||
files,
|
||||
hasUnsavedChange,
|
||||
isBootstrapping,
|
||||
isBuildBusy,
|
||||
isMockAgentBusy,
|
||||
isSaving,
|
||||
logs,
|
||||
mobileTab,
|
||||
project,
|
||||
prompt,
|
||||
selectedFile,
|
||||
selectedFilePath,
|
||||
snapshot,
|
||||
setEditorContent,
|
||||
setMobileTab,
|
||||
setPrompt,
|
||||
handleCreatePreviewBuild,
|
||||
handleSaveCurrentFile,
|
||||
handleSubmitMockAgent,
|
||||
loadInitialState,
|
||||
selectFile,
|
||||
};
|
||||
}
|
||||
|
||||
function FileTreePanel({
|
||||
files,
|
||||
selectedFilePath,
|
||||
onSelectFile,
|
||||
}: {
|
||||
files: readonly WebProjectFile[];
|
||||
selectedFilePath: string | null;
|
||||
onSelectFile: (file: WebProjectFile) => void;
|
||||
}) {
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FolderTree className="h-4 w-4" aria-hidden />
|
||||
文件
|
||||
</span>
|
||||
}
|
||||
surface="soft"
|
||||
padding="sm"
|
||||
radius="sm"
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
bodyClassName="mt-3 min-h-0 flex-1 overflow-y-auto pr-1"
|
||||
>
|
||||
<div className="space-y-1" role="list" aria-label="工程文件">
|
||||
{files.map((file) => {
|
||||
const selected = file.path === selectedFilePath;
|
||||
return (
|
||||
<button
|
||||
key={file.path}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelectFile(file)}
|
||||
className={[
|
||||
'flex w-full min-w-0 items-center gap-2 rounded-lg border px-2.5 py-2 text-left text-xs transition',
|
||||
selected
|
||||
? 'border-[var(--platform-accent)] bg-white text-[var(--platform-text-strong)] shadow-sm'
|
||||
: 'border-transparent bg-white/45 text-[var(--platform-text-base)] hover:bg-white/72',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">{file.path}</span>
|
||||
<span className="shrink-0 text-[10px] text-[var(--platform-text-muted)]">
|
||||
{Math.max(1, Math.ceil(file.sizeBytes / 1024))}K
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
function CodePanel({
|
||||
editorContent,
|
||||
hasUnsavedChange,
|
||||
isSaving,
|
||||
selectedFilePath,
|
||||
onContentChange,
|
||||
onSave,
|
||||
}: {
|
||||
editorContent: string;
|
||||
hasUnsavedChange: boolean;
|
||||
isSaving: boolean;
|
||||
selectedFilePath: string | null;
|
||||
onContentChange: (content: string) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
title={
|
||||
<span className="inline-flex min-w-0 items-center gap-2">
|
||||
<Code2 className="h-4 w-4 shrink-0" aria-hidden />
|
||||
<span className="truncate">{selectedFilePath ?? '代码'}</span>
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<PlatformActionButton
|
||||
surface="platform"
|
||||
tone={hasUnsavedChange ? 'primary' : 'secondary'}
|
||||
size="xs"
|
||||
disabled={!hasUnsavedChange || isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
保存
|
||||
</span>
|
||||
</PlatformActionButton>
|
||||
}
|
||||
surface="soft"
|
||||
padding="sm"
|
||||
radius="sm"
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
bodyClassName="mt-3 min-h-0 flex-1"
|
||||
>
|
||||
<textarea
|
||||
aria-label="代码编辑区"
|
||||
value={editorContent}
|
||||
onChange={(event) => onContentChange(event.target.value)}
|
||||
spellCheck={false}
|
||||
className="h-full min-h-[360px] w-full resize-none rounded-xl border border-[var(--platform-subpanel-border)] bg-zinc-950 px-3 py-3 font-mono text-[13px] leading-5 text-zinc-50 outline-none transition focus:border-[var(--platform-accent)]"
|
||||
/>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
function MockAgentPanel({
|
||||
disabled,
|
||||
isBusy,
|
||||
prompt,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
isBusy: boolean;
|
||||
prompt: string;
|
||||
onPromptChange: (prompt: string) => void;
|
||||
onSubmit: (event?: FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Send className="h-4 w-4" aria-hidden />
|
||||
Mock Agent
|
||||
</span>
|
||||
}
|
||||
surface="soft"
|
||||
padding="sm"
|
||||
radius="sm"
|
||||
bodyClassName="mt-3"
|
||||
>
|
||||
<form className="flex flex-col gap-3" onSubmit={onSubmit}>
|
||||
<textarea
|
||||
aria-label="Mock Agent 输入"
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
className="min-h-24 resize-none rounded-xl border border-[var(--platform-subpanel-border)] bg-white/82 px-3 py-2.5 text-sm text-[var(--platform-text-strong)] outline-none transition placeholder:text-[var(--platform-text-muted)] focus:border-[var(--platform-accent)]"
|
||||
placeholder="做一个蓝色计数按钮页面"
|
||||
/>
|
||||
<PlatformActionButton
|
||||
surface="platform"
|
||||
tone="primary"
|
||||
size="sm"
|
||||
disabled={disabled || isBusy || !prompt.trim()}
|
||||
type="submit"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Send className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
发送
|
||||
</span>
|
||||
</PlatformActionButton>
|
||||
</form>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewPanel({
|
||||
activePreviewUrl,
|
||||
buildStatusLabel,
|
||||
disabled,
|
||||
isBuildBusy,
|
||||
onCreatePreviewBuild,
|
||||
}: {
|
||||
activePreviewUrl: string | null;
|
||||
buildStatusLabel: string;
|
||||
disabled: boolean;
|
||||
isBuildBusy: boolean;
|
||||
onCreatePreviewBuild: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<MonitorPlay className="h-4 w-4" aria-hidden />
|
||||
预览
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="rounded-full bg-white/68 px-2.5 py-1 text-xs font-semibold text-[var(--platform-text-base)]">
|
||||
{buildStatusLabel}
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
surface="platform"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
disabled={disabled || isBuildBusy}
|
||||
onClick={onCreatePreviewBuild}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{isBuildBusy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
构建
|
||||
</span>
|
||||
</PlatformActionButton>
|
||||
</span>
|
||||
}
|
||||
surface="soft"
|
||||
padding="sm"
|
||||
radius="sm"
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
bodyClassName="mt-3 min-h-0 flex-1"
|
||||
>
|
||||
{activePreviewUrl ? (
|
||||
<iframe
|
||||
title="Web 工程预览"
|
||||
src={activePreviewUrl}
|
||||
sandbox="allow-scripts"
|
||||
className="h-full min-h-[360px] w-full rounded-xl border border-[var(--platform-subpanel-border)] bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full min-h-[360px] place-items-center rounded-xl border border-dashed border-[var(--platform-subpanel-border)] bg-white/55 text-sm text-[var(--platform-text-muted)]">
|
||||
暂无预览
|
||||
</div>
|
||||
)}
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsPanel({
|
||||
currentBuild,
|
||||
logs,
|
||||
}: {
|
||||
currentBuild: WebProjectPreviewBuild | null;
|
||||
logs: readonly WebProjectAgentLogEntry[];
|
||||
}) {
|
||||
const errorSummary = currentBuild?.errorSummary?.trim();
|
||||
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<TerminalSquare className="h-4 w-4" aria-hidden />
|
||||
日志
|
||||
</span>
|
||||
}
|
||||
surface="soft"
|
||||
padding="sm"
|
||||
radius="sm"
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
bodyClassName="mt-3 min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{errorSummary ? (
|
||||
<PlatformStatusMessage tone="error" surface="platform" size="sm">
|
||||
{errorSummary}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{logs.length ? (
|
||||
<ul className="space-y-1.5" aria-label="构建日志">
|
||||
{logs.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className={[
|
||||
'rounded-lg border px-3 py-2 font-mono text-xs leading-5',
|
||||
entry.tone === 'error'
|
||||
? 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
: entry.tone === 'success'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/62 text-[var(--platform-text-base)]',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{entry.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-[var(--platform-subpanel-border)] px-3 py-8 text-center text-sm text-[var(--platform-text-muted)]">
|
||||
暂无日志
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebProjectAgentEditorPage() {
|
||||
const controller = useWebProjectAgentController();
|
||||
const isDesktop = useAgentDesktopLayout();
|
||||
const headerTitle = controller.project?.title ?? 'Editor Agent';
|
||||
|
||||
if (controller.isBootstrapping) {
|
||||
return (
|
||||
<div className="grid h-full min-h-0 place-items-center">
|
||||
<PlatformSubpanel
|
||||
surface="soft"
|
||||
radius="sm"
|
||||
padding="sm"
|
||||
className="inline-flex items-center gap-3 px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
正在打开
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileTreePanel = (
|
||||
<FileTreePanel
|
||||
files={controller.files}
|
||||
selectedFilePath={controller.selectedFilePath}
|
||||
onSelectFile={controller.selectFile}
|
||||
/>
|
||||
);
|
||||
const codePanel = (
|
||||
<CodePanel
|
||||
editorContent={controller.editorContent}
|
||||
hasUnsavedChange={controller.hasUnsavedChange}
|
||||
isSaving={controller.isSaving}
|
||||
selectedFilePath={controller.selectedFilePath}
|
||||
onContentChange={controller.setEditorContent}
|
||||
onSave={() => {
|
||||
void controller.handleSaveCurrentFile();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const agentPanel = (
|
||||
<MockAgentPanel
|
||||
disabled={!controller.project || !controller.snapshot}
|
||||
isBusy={controller.isMockAgentBusy}
|
||||
prompt={controller.prompt}
|
||||
onPromptChange={controller.setPrompt}
|
||||
onSubmit={(event) => {
|
||||
void controller.handleSubmitMockAgent(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const previewPanel = (
|
||||
<PreviewPanel
|
||||
activePreviewUrl={controller.activePreviewUrl}
|
||||
buildStatusLabel={controller.buildStatusLabel}
|
||||
disabled={!controller.project || !controller.snapshot}
|
||||
isBuildBusy={controller.isBuildBusy}
|
||||
onCreatePreviewBuild={() => {
|
||||
void controller.handleCreatePreviewBuild();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const logsPanel = (
|
||||
<LogsPanel currentBuild={controller.currentBuild} logs={controller.logs} />
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 min-w-0 flex-col gap-3 overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef5f1_100%)] p-2 text-[var(--platform-text-strong)] sm:p-3">
|
||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-xl border border-[var(--platform-subpanel-border)] bg-white/76 px-3 py-2.5 shadow-sm backdrop-blur">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-base font-black sm:text-lg">
|
||||
{headerTitle}
|
||||
</h1>
|
||||
<div className="mt-0.5 truncate text-xs text-[var(--platform-text-muted)]">
|
||||
{controller.selectedFilePath ?? controller.project?.projectId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-[var(--platform-text-base)]">
|
||||
{controller.buildStatusLabel}
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
surface="platform"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
onClick={() => void controller.loadInitialState()}
|
||||
>
|
||||
刷新
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{controller.errorMessage ? (
|
||||
<PlatformStatusMessage tone="error" surface="platform" size="sm">
|
||||
{controller.errorMessage}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
{!isDesktop ? (
|
||||
<PlatformSegmentedTabs
|
||||
items={MOBILE_TABS}
|
||||
activeId={controller.mobileTab}
|
||||
onChange={controller.setMobileTab}
|
||||
columns="four"
|
||||
size="compact"
|
||||
surface="soft"
|
||||
semantics="tabs"
|
||||
ariaLabel="Editor Agent 面板"
|
||||
truncateLabels
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isDesktop ? (
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[220px_minmax(0,1.05fr)_minmax(360px,0.95fr)] gap-3 overflow-hidden">
|
||||
<div className="min-h-0">{fileTreePanel}</div>
|
||||
<div className="flex min-h-0 flex-col gap-3 overflow-hidden">
|
||||
<div className="min-h-0 flex-1">{codePanel}</div>
|
||||
<div className="shrink-0">{agentPanel}</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col gap-3 overflow-hidden">
|
||||
<div className="min-h-0 flex-1">{previewPanel}</div>
|
||||
<div className="h-[220px] min-h-0 shrink-0">{logsPanel}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{controller.mobileTab === 'files' ? fileTreePanel : null}
|
||||
{controller.mobileTab === 'code' ? (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
<div className="min-h-0 flex-1">{codePanel}</div>
|
||||
<div className="shrink-0">{agentPanel}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{controller.mobileTab === 'preview' ? previewPanel : null}
|
||||
{controller.mobileTab === 'logs' ? logsPanel : null}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
128
src/components/editor/agent/webProjectAgentViewModel.test.ts
Normal file
128
src/components/editor/agent/webProjectAgentViewModel.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
WebProjectFile,
|
||||
WebProjectPreviewBuild,
|
||||
} from '../../../../packages/shared/src/contracts/webProject';
|
||||
import {
|
||||
createBuildLogEntry,
|
||||
createUpdateFilePatch,
|
||||
findWebProjectFile,
|
||||
hasFileContentChanged,
|
||||
isTerminalWebProjectBuild,
|
||||
mergeWebProjectBuildEvent,
|
||||
resolveInitialSelectedFilePath,
|
||||
sortWebProjectFiles,
|
||||
} from './webProjectAgentViewModel';
|
||||
|
||||
const files: WebProjectFile[] = [
|
||||
{
|
||||
path: 'src/App.css',
|
||||
content: 'body{}',
|
||||
mediaType: 'text/css',
|
||||
encoding: 'utf-8',
|
||||
sizeBytes: 6,
|
||||
},
|
||||
{
|
||||
path: 'src/App.tsx',
|
||||
content: 'export default function App() { return null; }',
|
||||
mediaType: 'text/typescript',
|
||||
encoding: 'utf-8',
|
||||
sizeBytes: 45,
|
||||
},
|
||||
];
|
||||
|
||||
const appFile = files[1]!;
|
||||
|
||||
const build: WebProjectPreviewBuild = {
|
||||
jobId: 'web-build-1',
|
||||
projectId: 'web-project-1',
|
||||
snapshotId: 'web-snapshot-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'running',
|
||||
logs: ['开始构建'],
|
||||
artifactId: null,
|
||||
previewTokenId: null,
|
||||
previewUrl: null,
|
||||
errorSummary: null,
|
||||
createdAt: '2026-06-15T00:00:00.000Z',
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('webProjectAgentViewModel', () => {
|
||||
it('sorts files and selects App.tsx by default', () => {
|
||||
expect(sortWebProjectFiles([...files].reverse()).map((file) => file.path)).toEqual([
|
||||
'src/App.css',
|
||||
'src/App.tsx',
|
||||
]);
|
||||
expect(resolveInitialSelectedFilePath(files, null)).toBe('src/App.tsx');
|
||||
expect(findWebProjectFile(files, 'src/App.css')?.content).toBe('body{}');
|
||||
});
|
||||
|
||||
it('builds a single updateFile patch from textarea content', () => {
|
||||
const patch = createUpdateFilePatch(appFile, 'export default null;');
|
||||
|
||||
expect(patch).toEqual({
|
||||
operations: [
|
||||
{
|
||||
type: 'updateFile',
|
||||
path: 'src/App.tsx',
|
||||
content: 'export default null;',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(hasFileContentChanged(appFile, 'export default null;')).toBe(true);
|
||||
expect(hasFileContentChanged(appFile, appFile.content)).toBe(false);
|
||||
});
|
||||
|
||||
it('merges build events without replacing preview URL on failed events', () => {
|
||||
const merged = mergeWebProjectBuildEvent(build, {
|
||||
jobId: 'web-build-1',
|
||||
status: 'failed',
|
||||
message: '构建失败',
|
||||
build: null,
|
||||
});
|
||||
|
||||
expect(merged?.status).toBe('failed');
|
||||
expect(merged?.previewUrl).toBeNull();
|
||||
expect(merged?.logs).toContain('构建失败');
|
||||
expect(isTerminalWebProjectBuild(merged)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses backend build payload as the authoritative preview result', () => {
|
||||
const succeededBuild = {
|
||||
...build,
|
||||
status: 'succeeded' as const,
|
||||
previewUrl: 'http://127.0.0.1:3999/p/token/',
|
||||
};
|
||||
const merged = mergeWebProjectBuildEvent(build, {
|
||||
jobId: 'web-build-1',
|
||||
status: 'succeeded',
|
||||
message: '构建完成',
|
||||
build: succeededBuild,
|
||||
});
|
||||
|
||||
expect(merged?.previewUrl).toBe('http://127.0.0.1:3999/p/token/');
|
||||
});
|
||||
|
||||
it('creates log entries from build events', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-06-15T00:00:00.000Z'));
|
||||
|
||||
expect(
|
||||
createBuildLogEntry({
|
||||
jobId: 'web-build-1',
|
||||
status: 'succeeded',
|
||||
message: '构建完成',
|
||||
build: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
tone: 'success',
|
||||
text: '构建完成',
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
134
src/components/editor/agent/webProjectAgentViewModel.ts
Normal file
134
src/components/editor/agent/webProjectAgentViewModel.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
WebProjectFile,
|
||||
WebProjectPatch,
|
||||
WebProjectPreviewBuild,
|
||||
WebProjectPreviewBuildEvent,
|
||||
} from '../../../../packages/shared/src/contracts/webProject';
|
||||
|
||||
const TERMINAL_BUILD_STATUSES = new Set([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'expired',
|
||||
'stale',
|
||||
]);
|
||||
|
||||
export type WebProjectAgentLogEntry = {
|
||||
id: string;
|
||||
tone: 'info' | 'success' | 'error' | 'warning';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function sortWebProjectFiles(files: readonly WebProjectFile[]) {
|
||||
return [...files].sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
export function findWebProjectFile(
|
||||
files: readonly WebProjectFile[],
|
||||
path: string | null,
|
||||
) {
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return files.find((file) => file.path === path) ?? null;
|
||||
}
|
||||
|
||||
export function resolveInitialSelectedFilePath(
|
||||
files: readonly WebProjectFile[],
|
||||
currentPath: string | null,
|
||||
) {
|
||||
if (currentPath && files.some((file) => file.path === currentPath)) {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
return (
|
||||
files.find((file) => file.path === 'src/App.tsx')?.path ??
|
||||
sortWebProjectFiles(files)[0]?.path ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function createUpdateFilePatch(
|
||||
file: WebProjectFile,
|
||||
nextContent: string,
|
||||
): WebProjectPatch {
|
||||
return {
|
||||
operations: [
|
||||
{
|
||||
type: 'updateFile',
|
||||
path: file.path,
|
||||
content: nextContent,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function hasFileContentChanged(
|
||||
file: WebProjectFile | null,
|
||||
nextContent: string,
|
||||
) {
|
||||
return file !== null && file.content !== nextContent;
|
||||
}
|
||||
|
||||
export function isTerminalWebProjectBuild(build: WebProjectPreviewBuild | null) {
|
||||
return Boolean(build && TERMINAL_BUILD_STATUSES.has(build.status));
|
||||
}
|
||||
|
||||
export function mergeWebProjectBuildEvent(
|
||||
currentBuild: WebProjectPreviewBuild | null,
|
||||
event: WebProjectPreviewBuildEvent,
|
||||
) {
|
||||
if (event.build) {
|
||||
return event.build;
|
||||
}
|
||||
|
||||
if (!currentBuild || currentBuild.jobId !== event.jobId) {
|
||||
return currentBuild;
|
||||
}
|
||||
|
||||
const logs = event.message
|
||||
? [...currentBuild.logs, event.message]
|
||||
: currentBuild.logs;
|
||||
|
||||
return {
|
||||
...currentBuild,
|
||||
status: event.status,
|
||||
logs,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBuildLogEntry(
|
||||
event: WebProjectPreviewBuildEvent,
|
||||
): WebProjectAgentLogEntry | null {
|
||||
const text = event.message?.trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${event.jobId}:${event.status}:${Date.now()}:${text}`,
|
||||
tone:
|
||||
event.status === 'succeeded'
|
||||
? 'success'
|
||||
: event.status === 'failed'
|
||||
? 'error'
|
||||
: 'info',
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLogEntriesFromPreviewBuild(
|
||||
build: WebProjectPreviewBuild,
|
||||
) {
|
||||
return build.logs.map((text, index) => ({
|
||||
id: `${build.jobId}:log:${index}`,
|
||||
tone:
|
||||
build.status === 'failed'
|
||||
? 'error'
|
||||
: build.status === 'succeeded'
|
||||
? 'success'
|
||||
: 'info',
|
||||
text,
|
||||
})) satisfies WebProjectAgentLogEntry[];
|
||||
}
|
||||
@@ -1278,6 +1278,13 @@ const ImageCanvasEditorView = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const WebProjectAgentEditorPage = lazy(async () => {
|
||||
const module = await import('../editor/agent/WebProjectAgentEditorPage');
|
||||
return {
|
||||
default: module.WebProjectAgentEditorPage,
|
||||
};
|
||||
});
|
||||
|
||||
const ProjectGalleryView = lazy(async () => {
|
||||
const module = await import('../project/ProjectGalleryView');
|
||||
return {
|
||||
@@ -5105,8 +5112,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
() => pendingPlatformTaskCompletionDialog,
|
||||
[pendingPlatformTaskCompletionDialog],
|
||||
);
|
||||
const isEditorToolStage =
|
||||
selectionStage === 'image-editor' || selectionStage === 'editor-agent';
|
||||
const activePlatformTaskCompletionDialog =
|
||||
selectionStage === 'image-editor'
|
||||
isEditorToolStage
|
||||
? null
|
||||
: resolveActivePlatformDialog(
|
||||
currentPlatformTaskCompletionDialog,
|
||||
@@ -5114,7 +5123,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
buildPlatformTaskCompletionDialogDismissKey,
|
||||
);
|
||||
const activePlatformErrorDialog =
|
||||
selectionStage === 'image-editor'
|
||||
isEditorToolStage
|
||||
? null
|
||||
: resolveActivePlatformDialog(
|
||||
currentPlatformErrorDialog,
|
||||
@@ -15169,6 +15178,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
{selectionStage === 'editor-agent' && (
|
||||
<motion.div
|
||||
key="editor-agent"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="editor-agent-stage-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载 Web 工程编辑器..." />}
|
||||
>
|
||||
<WebProjectAgentEditorPage />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
{selectionStage === 'project' && (
|
||||
<motion.div
|
||||
key="project-gallery"
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'project'
|
||||
| 'image-editor'
|
||||
| 'editor-agent'
|
||||
| 'profile-feedback'
|
||||
| 'work-detail'
|
||||
| 'detail'
|
||||
|
||||
@@ -4,6 +4,7 @@ const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
|
||||
platform: true,
|
||||
project: true,
|
||||
'image-editor': true,
|
||||
'editor-agent': true,
|
||||
'profile-feedback': false,
|
||||
'work-detail': true,
|
||||
detail: true,
|
||||
|
||||
@@ -13,6 +13,7 @@ const STAGE_ROUTE_ENTRIES = [
|
||||
['platform', '/'],
|
||||
['project', '/project'],
|
||||
['image-editor', '/editor/canvas'],
|
||||
['editor-agent', '/editor/agent'],
|
||||
['profile-feedback', '/profile/feedback'],
|
||||
['work-detail', '/works/detail'],
|
||||
['detail', '/worlds/detail'],
|
||||
|
||||
221
src/services/web-project/webProjectClient.test.ts
Normal file
221
src/services/web-project/webProjectClient.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createWebProject,
|
||||
createWebProjectPreviewBuild,
|
||||
createWebProjectWithSnapshot,
|
||||
loadActiveWebProjectSnapshot,
|
||||
loadWebProject,
|
||||
loadWebProjectPreviewBuild,
|
||||
saveWebProjectFiles,
|
||||
submitMockAgentTurn,
|
||||
} from './webProjectClient';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('webProjectClient', () => {
|
||||
afterEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('creates and loads web projects through the P1 API namespace', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'web-project-1',
|
||||
title: '未命名 Web 工程',
|
||||
activeSnapshotId: 'web-snapshot-1',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'web-project-1',
|
||||
title: '未命名 Web 工程',
|
||||
activeSnapshotId: 'web-snapshot-1',
|
||||
},
|
||||
});
|
||||
|
||||
await createWebProject({ title: '未命名 Web 工程' });
|
||||
await loadWebProject('web-project-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/creation/web-project/projects',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: '未命名 Web 工程' }),
|
||||
}),
|
||||
'创建 Web 工程失败',
|
||||
expect.objectContaining({
|
||||
clearAuthOnUnauthorized: false,
|
||||
notifyAuthStateChange: false,
|
||||
}),
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/creation/web-project/projects/web-project-1',
|
||||
{ method: 'GET' },
|
||||
'读取 Web 工程失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads active snapshot and saves a single-file patch', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: {
|
||||
snapshotId: 'web-snapshot-1',
|
||||
files: [],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: {
|
||||
snapshotId: 'web-snapshot-2',
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
|
||||
await loadActiveWebProjectSnapshot('web-project-1');
|
||||
await saveWebProjectFiles('web-project-1', {
|
||||
baseSnapshotId: 'web-snapshot-1',
|
||||
summary: '更新 src/App.tsx',
|
||||
patch: {
|
||||
operations: [
|
||||
{
|
||||
type: 'updateFile',
|
||||
path: 'src/App.tsx',
|
||||
content: 'export default function App() { return null; }',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/creation/web-project/projects/web-project-1/snapshot',
|
||||
{ method: 'GET' },
|
||||
'读取 Web 工程快照失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
const saveRequest = requestJsonMock.mock.calls[1];
|
||||
const saveRequestInit = saveRequest?.[1] as RequestInit | undefined;
|
||||
expect(JSON.parse(String(saveRequestInit?.body))).toEqual({
|
||||
baseSnapshotId: 'web-snapshot-1',
|
||||
summary: '更新 src/App.tsx',
|
||||
patch: {
|
||||
operations: [
|
||||
{
|
||||
type: 'updateFile',
|
||||
path: 'src/App.tsx',
|
||||
content: 'export default function App() { return null; }',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/creation/web-project/projects/web-project-1/files',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
}),
|
||||
'保存 Web 工程文件失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits mock agent turns and creates preview builds without composing preview URLs', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: { snapshotId: 'web-snapshot-2', files: [] },
|
||||
patch: { operations: [] },
|
||||
summary: '更新首页计数按钮示例',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
build: {
|
||||
jobId: 'web-build-1',
|
||||
projectId: 'web-project-1',
|
||||
snapshotId: 'web-snapshot-2',
|
||||
status: 'queued',
|
||||
previewUrl: null,
|
||||
logs: [],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
build: {
|
||||
jobId: 'web-build-1',
|
||||
projectId: 'web-project-1',
|
||||
snapshotId: 'web-snapshot-2',
|
||||
status: 'succeeded',
|
||||
previewUrl: 'http://127.0.0.1:3999/p/token/',
|
||||
logs: [],
|
||||
},
|
||||
});
|
||||
|
||||
await submitMockAgentTurn('web-project-1', {
|
||||
prompt: '做一个蓝色计数按钮页面',
|
||||
baseSnapshotId: 'web-snapshot-1',
|
||||
});
|
||||
await createWebProjectPreviewBuild('web-project-1', {
|
||||
snapshotId: 'web-snapshot-2',
|
||||
});
|
||||
const build = await loadWebProjectPreviewBuild('web-build-1');
|
||||
|
||||
expect(build.previewUrl).toBe('http://127.0.0.1:3999/p/token/');
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/creation/web-project/projects/web-project-1/mock-agent-turns',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
prompt: '做一个蓝色计数按钮页面',
|
||||
baseSnapshotId: 'web-snapshot-1',
|
||||
}),
|
||||
}),
|
||||
'提交 Mock Agent 指令失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/runtime/web-project/projects/web-project-1/preview-builds',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ snapshotId: 'web-snapshot-2' }),
|
||||
}),
|
||||
'创建 Web 工程预览构建失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/runtime/web-project/preview-builds/web-build-1',
|
||||
{ method: 'GET' },
|
||||
'读取 Web 工程预览构建失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a project and immediately reads its active snapshot', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'web-project-created',
|
||||
activeSnapshotId: 'web-snapshot-created',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: {
|
||||
snapshotId: 'web-snapshot-created',
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
|
||||
const bundle = await createWebProjectWithSnapshot();
|
||||
|
||||
expect(bundle.project.projectId).toBe('web-project-created');
|
||||
expect(bundle.snapshot.snapshotId).toBe('web-snapshot-created');
|
||||
expect(requestJsonMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
139
src/services/web-project/webProjectClient.ts
Normal file
139
src/services/web-project/webProjectClient.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
MockAgentTurnResponse,
|
||||
WebProject,
|
||||
WebProjectPatch,
|
||||
WebProjectPreviewBuild,
|
||||
WebProjectResponse,
|
||||
WebProjectSnapshot,
|
||||
WebProjectSnapshotResponse,
|
||||
} from '../../../packages/shared/src/contracts/webProject';
|
||||
import { requestJson, type ApiRequestOptions } from '../apiClient';
|
||||
|
||||
const WEB_PROJECT_CREATION_API_BASE = '/api/creation/web-project/projects';
|
||||
const WEB_PROJECT_RUNTIME_API_BASE = '/api/runtime/web-project';
|
||||
|
||||
const WEB_PROJECT_REQUEST_OPTIONS = {
|
||||
clearAuthOnUnauthorized: false,
|
||||
notifyAuthStateChange: false,
|
||||
} satisfies ApiRequestOptions;
|
||||
|
||||
type WebProjectPreviewBuildResponse = {
|
||||
build: WebProjectPreviewBuild;
|
||||
};
|
||||
|
||||
function jsonRequest(method: 'POST' | 'PATCH', body: Record<string, unknown>) {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
function projectUrl(projectId: string) {
|
||||
return `${WEB_PROJECT_CREATION_API_BASE}/${encodeURIComponent(projectId)}`;
|
||||
}
|
||||
|
||||
export async function createWebProject(input: { title?: string } = {}) {
|
||||
const response = await requestJson<WebProjectResponse>(
|
||||
WEB_PROJECT_CREATION_API_BASE,
|
||||
jsonRequest('POST', { title: input.title }),
|
||||
'创建 Web 工程失败',
|
||||
WEB_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
export async function loadWebProject(projectId: string) {
|
||||
const response = await requestJson<WebProjectResponse>(
|
||||
projectUrl(projectId),
|
||||
{ method: 'GET' },
|
||||
'读取 Web 工程失败',
|
||||
WEB_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
export async function loadActiveWebProjectSnapshot(projectId: string) {
|
||||
const response = await requestJson<WebProjectSnapshotResponse>(
|
||||
`${projectUrl(projectId)}/snapshot`,
|
||||
{ method: 'GET' },
|
||||
'读取 Web 工程快照失败',
|
||||
WEB_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.snapshot;
|
||||
}
|
||||
|
||||
export async function saveWebProjectFiles(
|
||||
projectId: string,
|
||||
input: {
|
||||
patch: WebProjectPatch;
|
||||
baseSnapshotId: string;
|
||||
summary?: string;
|
||||
},
|
||||
) {
|
||||
const response = await requestJson<WebProjectSnapshotResponse>(
|
||||
`${projectUrl(projectId)}/files`,
|
||||
jsonRequest('PATCH', {
|
||||
patch: input.patch,
|
||||
baseSnapshotId: input.baseSnapshotId,
|
||||
summary: input.summary,
|
||||
}),
|
||||
'保存 Web 工程文件失败',
|
||||
WEB_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.snapshot;
|
||||
}
|
||||
|
||||
export async function submitMockAgentTurn(
|
||||
projectId: string,
|
||||
input: {
|
||||
prompt: string;
|
||||
baseSnapshotId: string;
|
||||
},
|
||||
) {
|
||||
return requestJson<MockAgentTurnResponse>(
|
||||
`${projectUrl(projectId)}/mock-agent-turns`,
|
||||
jsonRequest('POST', {
|
||||
prompt: input.prompt,
|
||||
baseSnapshotId: input.baseSnapshotId,
|
||||
}),
|
||||
'提交 Mock Agent 指令失败',
|
||||
WEB_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createWebProjectPreviewBuild(
|
||||
projectId: string,
|
||||
input: { snapshotId?: string } = {},
|
||||
) {
|
||||
const response = await requestJson<WebProjectPreviewBuildResponse>(
|
||||
`${WEB_PROJECT_RUNTIME_API_BASE}/projects/${encodeURIComponent(projectId)}/preview-builds`,
|
||||
jsonRequest('POST', { snapshotId: input.snapshotId }),
|
||||
'创建 Web 工程预览构建失败',
|
||||
WEB_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.build;
|
||||
}
|
||||
|
||||
export async function loadWebProjectPreviewBuild(jobId: string) {
|
||||
const response = await requestJson<WebProjectPreviewBuildResponse>(
|
||||
`${WEB_PROJECT_RUNTIME_API_BASE}/preview-builds/${encodeURIComponent(jobId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取 Web 工程预览构建失败',
|
||||
WEB_PROJECT_REQUEST_OPTIONS,
|
||||
);
|
||||
return response.build;
|
||||
}
|
||||
|
||||
export type WebProjectClientSnapshotBundle = {
|
||||
project: WebProject;
|
||||
snapshot: WebProjectSnapshot;
|
||||
};
|
||||
|
||||
export async function createWebProjectWithSnapshot(
|
||||
input: { title?: string } = {},
|
||||
): Promise<WebProjectClientSnapshotBundle> {
|
||||
const project = await createWebProject(input);
|
||||
const snapshot = await loadActiveWebProjectSnapshot(project.projectId);
|
||||
return { project, snapshot };
|
||||
}
|
||||
105
src/services/web-project/webProjectSse.test.ts
Normal file
105
src/services/web-project/webProjectSse.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeWebProjectBuildSseEvent,
|
||||
subscribeWebProjectPreviewBuildEvents,
|
||||
} from './webProjectSse';
|
||||
|
||||
const fetchWithApiAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
fetchWithApiAuth: fetchWithApiAuthMock,
|
||||
}));
|
||||
|
||||
function createSseResponse(payload: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(payload));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('webProjectSse', () => {
|
||||
afterEach(() => {
|
||||
fetchWithApiAuthMock.mockReset();
|
||||
});
|
||||
|
||||
it('normalizes message events carrying a build event payload', () => {
|
||||
const event = normalizeWebProjectBuildSseEvent('message', {
|
||||
jobId: 'web-build-1',
|
||||
status: 'succeeded',
|
||||
message: '构建完成',
|
||||
build: {
|
||||
jobId: 'web-build-1',
|
||||
projectId: 'web-project-1',
|
||||
snapshotId: 'web-snapshot-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'succeeded',
|
||||
logs: ['构建完成'],
|
||||
previewUrl: 'http://127.0.0.1:3999/p/token/',
|
||||
createdAt: '2026-06-15T00:00:00.000Z',
|
||||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
expect(event?.status).toBe('succeeded');
|
||||
expect(event?.build?.previewUrl).toBe('http://127.0.0.1:3999/p/token/');
|
||||
});
|
||||
|
||||
it('normalizes typed SSE event names when status is omitted in data', () => {
|
||||
const event = normalizeWebProjectBuildSseEvent('running', {
|
||||
jobId: 'web-build-1',
|
||||
message: '开始构建',
|
||||
});
|
||||
|
||||
expect(event).toEqual({
|
||||
jobId: 'web-build-1',
|
||||
status: 'running',
|
||||
message: '开始构建',
|
||||
build: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('subscribes build events with auth and stops after terminal status', async () => {
|
||||
fetchWithApiAuthMock.mockResolvedValueOnce(
|
||||
createSseResponse(
|
||||
[
|
||||
'event: message',
|
||||
'data: {"jobId":"web-build-1","status":"queued","message":"排队中"}',
|
||||
'',
|
||||
'event: message',
|
||||
'data: {"jobId":"web-build-1","status":"succeeded","message":"完成"}',
|
||||
'',
|
||||
'event: message',
|
||||
'data: {"jobId":"web-build-1","status":"running","message":"不应继续消费"}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
|
||||
const events: string[] = [];
|
||||
await subscribeWebProjectPreviewBuildEvents('web-build-1', {
|
||||
onEvent: (event) => events.push(`${event.status}:${event.message}`),
|
||||
});
|
||||
|
||||
expect(events).toEqual(['queued:排队中', 'succeeded:完成']);
|
||||
expect(fetchWithApiAuthMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/web-project/preview-builds/web-build-1/events',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
clearAuthOnUnauthorized: false,
|
||||
notifyAuthStateChange: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
126
src/services/web-project/webProjectSse.ts
Normal file
126
src/services/web-project/webProjectSse.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
WebProjectPreviewBuild,
|
||||
WebProjectPreviewBuildEvent,
|
||||
WebProjectPreviewBuildStatus,
|
||||
} from '../../../packages/shared/src/contracts/webProject';
|
||||
import { fetchWithApiAuth, type ApiRequestOptions } from '../apiClient';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
const WEB_PROJECT_RUNTIME_API_BASE = '/api/runtime/web-project';
|
||||
|
||||
const WEB_PROJECT_SSE_REQUEST_OPTIONS = {
|
||||
clearAuthOnUnauthorized: false,
|
||||
notifyAuthStateChange: false,
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
},
|
||||
} satisfies ApiRequestOptions;
|
||||
|
||||
const WEB_PROJECT_BUILD_STATUSES = new Set<WebProjectPreviewBuildStatus>([
|
||||
'queued',
|
||||
'running',
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'expired',
|
||||
'stale',
|
||||
]);
|
||||
|
||||
export type WebProjectBuildEventHandler = (
|
||||
event: WebProjectPreviewBuildEvent,
|
||||
) => void;
|
||||
|
||||
function isWebProjectBuildStatus(
|
||||
value: unknown,
|
||||
): value is WebProjectPreviewBuildStatus {
|
||||
return typeof value === 'string' && WEB_PROJECT_BUILD_STATUSES.has(value as WebProjectPreviewBuildStatus);
|
||||
}
|
||||
|
||||
function normalizeBuild(rawBuild: unknown): WebProjectPreviewBuild | null {
|
||||
if (!rawBuild || typeof rawBuild !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const build = rawBuild as Partial<WebProjectPreviewBuild>;
|
||||
if (
|
||||
typeof build.jobId !== 'string' ||
|
||||
typeof build.projectId !== 'string' ||
|
||||
typeof build.snapshotId !== 'string' ||
|
||||
!isWebProjectBuildStatus(build.status)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return build as WebProjectPreviewBuild;
|
||||
}
|
||||
|
||||
export function normalizeWebProjectBuildSseEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
): WebProjectPreviewBuildEvent | null {
|
||||
const eventStatus = isWebProjectBuildStatus(eventName) ? eventName : null;
|
||||
const status = isWebProjectBuildStatus(parsed.status)
|
||||
? parsed.status
|
||||
: eventStatus;
|
||||
const jobId = typeof parsed.jobId === 'string' ? parsed.jobId : '';
|
||||
|
||||
if (!jobId || !status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const build = normalizeBuild(parsed.build);
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message
|
||||
: null;
|
||||
|
||||
return {
|
||||
jobId,
|
||||
status,
|
||||
message,
|
||||
build,
|
||||
};
|
||||
}
|
||||
|
||||
export async function subscribeWebProjectPreviewBuildEvents(
|
||||
jobId: string,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
onEvent: WebProjectBuildEventHandler;
|
||||
},
|
||||
) {
|
||||
const response = await fetchWithApiAuth(
|
||||
`${WEB_PROJECT_RUNTIME_API_BASE}/preview-builds/${encodeURIComponent(jobId)}/events`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: options.signal,
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
},
|
||||
WEB_PROJECT_SSE_REQUEST_OPTIONS,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('订阅 Web 工程预览构建失败');
|
||||
}
|
||||
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const event = normalizeWebProjectBuildSseEvent(eventName, parsed);
|
||||
if (!event || event.jobId !== jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
options.onEvent(event);
|
||||
|
||||
if (
|
||||
event.status === 'succeeded' ||
|
||||
event.status === 'failed' ||
|
||||
event.status === 'cancelled' ||
|
||||
event.status === 'expired' ||
|
||||
event.status === 'stale'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user