完成 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:
2026-06-16 17:31:25 +08:00
parent 80a382b034
commit 4b09ce3096
404 changed files with 14886 additions and 2497 deletions

View File

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

View 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();
});
});

View 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>
);
}

View 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();
});
});

View 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[];
}

View File

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

View File

@@ -15,6 +15,7 @@ export type SelectionStage =
| 'platform'
| 'project'
| 'image-editor'
| 'editor-agent'
| 'profile-feedback'
| 'work-detail'
| 'detail'

View File

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

View File

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

View 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);
});
});

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

View 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,
}),
);
});
});

View 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;
}
});
}