项目生命周期 深度解析
项目从一条意图起步:首条消息与 Project 一起创建;后续文件变成对象存储链接;生成应用通过 Worker 亲和端口提供预览。
导入链与契约
列表接口提供搜索、分页和更新时间排序,创建接口则要求非空 prompt 并截断生成标题。项目不是空壳:首个 PENDING 消息在嵌套 create 中同步落库 对应源码落点是 src/app/api/projects/route.ts:5、src/app/api/projects/route.ts:7、src/app/api/projects/route.ts:8、src/app/api/projects/route.ts:11、src/app/api/projects/route.ts:15、src/app/api/projects/route.ts:18、src/app/api/projects/route.ts:25。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
32 export async function POST(req: NextRequest) {
33 const body = await req.json().catch(() => null);
34 const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
35 if (!prompt) {
36 return NextResponse.json({ error: 'prompt is required' }, { status: 400 });
37 }
38
39 const title = prompt.length > 48 ? `${prompt.slice(0, 48)}…` : prompt;
40 const project = await prisma.project.create({
41 data: {
42 id: ulid(),
43 prompt,
44 title,
45 generationStatus: 'DRAFT',
46 // Seed the prompt as the first chat turn (PENDING) so the blueprint
47 // chat renders it and the orchestrator triggers the agent immediately.
48 chatMessages: {
49 create: {
50 id: ulid(),
51 role: 'USER',
52 content: prompt,
53 status: 'PENDING',
54 },
55 },
56 },
57 });
58
59 return NextResponse.json(project, { status: 201 });
60 }
核心实现,逐段拆
▸附件先上传,Agent 只看 URL
附件接口先确认项目和 multipart 数据,再限制最多五个、单个十 MB。安全文件名只保留有限字符,实际对象以 ULID 前缀存入项目专属路径 对应源码落点是 src/app/api/projects/[id]/attachments/route.ts:6、src/app/api/projects/[id]/attachments/route.ts:11、src/app/api/projects/[id]/attachments/route.ts:12、src/app/api/projects/[id]/attachments/route.ts:14、src/app/api/projects/[id]/attachments/route.ts:18、src/app/api/projects/[id]/attachments/route.ts:23、src/app/api/projects/[id]/attachments/route.ts:28。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
32 const files = form.getAll('files').filter((f): f is File => f instanceof File);
33 if (files.length === 0) {
34 return NextResponse.json({ error: 'no files provided' }, { status: 400 });
35 }
36 if (files.length > MAX_FILES) {
37 return NextResponse.json({ error: `max ${MAX_FILES} files per upload` }, { status: 400 });
38 }
39 for (const file of files) {
40 if (file.size > MAX_FILE_BYTES) {
41 return NextResponse.json(
42 { error: `${file.name} exceeds the 10 MB limit` },
43 { status: 413 }
44 );
45 }
46 }
47
48 const uploaded = [];
49 for (const file of files) {
50 const assetId = ulid();
51 const filename = `${assetId}-${safeName(file.name)}`;
52 const key = `attachments/${id}/${filename}`;
53 const mimeType = file.type || 'application/octet-stream';
54 const url = await uploadToS3(key, Buffer.from(await file.arrayBuffer()), mimeType);
55 const asset = await prisma.asset.create({
56 data: {
57 id: assetId,
58 projectId: id,
59 filename,
60 originalFilename: file.name,
61 url,
62 size: file.size,
63 mimeType,
64 },
65 });
66 uploaded.push({
67 id: asset.id,
68 url: asset.url,
69 filename: file.name,
70 mimeType,
71 size: file.size,
72 });
73 }
74
75 return NextResponse.json({ attachments: uploaded }, { status: 201 });
▸预览状态压缩成三态
预览接口读取项目亲和端口:没有端口就 hidden,有端口但访问失败是 starting,只要收到任何 HTTP 响应即 ready。编译错误页也被认为比空白更有用 对应源码落点是 src/app/api/projects/[id]/preview/route.ts:4、src/app/api/projects/[id]/preview/route.ts:7、src/app/api/projects/[id]/preview/route.ts:9、src/app/api/projects/[id]/preview/route.ts:10、src/app/api/projects/[id]/preview/route.ts:11、src/app/api/projects/[id]/preview/route.ts:12、src/app/api/projects/[id]/preview/route.ts:15。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
17 export async function GET(
18 _req: NextRequest,
19 { params }: { params: Promise<{ id: string }> }
20 ) {
21 const { id } = await params;
22 const project = await prisma.project.findUnique({
23 where: { id },
24 select: { previewPort: true },
25 });
26 if (!project) {
27 return NextResponse.json({ error: 'project not found' }, { status: 404 });
28 }
29
30 const port = project.previewPort;
31 if (port === null) {
32 return NextResponse.json({ state: 'hidden', port: null, previewUrl: null });
33 }
34
35 const previewUrl = `http://localhost:${port}/`;
36 try {
37 await fetch(`http://127.0.0.1:${port}/`, {
38 method: 'GET',
39 signal: AbortSignal.timeout(PING_TIMEOUT_MS),
40 cache: 'no-store',
41 });
42 return NextResponse.json({ state: 'ready', port, previewUrl });
43 } catch {
44 return NextResponse.json({ state: 'starting', port, previewUrl });
45 }
46 }
关键流程
assets → S3 URL → chat markdown
affinity port → health proxy → iframe
后续消息把已上传附件渲染成 Markdown 链接块,与文本合并后入库。若文本和附件都为空返回 400,项目不存在返回 404;成功时只确认消息已接受,而不等待 Agent 对应源码落点是 src/app/api/projects/[id]/chat/route.ts:21、src/app/api/projects/[id]/chat/route.ts:22、src/app/api/projects/[id]/chat/route.ts:23、src/app/api/projects/[id]/chat/route.ts:27、src/app/api/projects/[id]/chat/route.ts:28、src/app/api/projects/[id]/chat/route.ts:37、src/app/api/projects/[id]/chat/route.ts:41。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
边界 / 失败模式
- 预览目前依赖共享 localhost,跨主机部署需要服务发现或反向代理。
- 任何 HTTP 状态都算 ready,健康检查不代表应用业务可用。
- 附件大小与数量有界,但源码未显示病毒扫描和 MIME 白名单。