Commit 2d6e5007 authored by xuchentao's avatar xuchentao

feat: separate preview and production workflows

parent 6631f8f5
PORT=3100
HOST=127.0.0.1
FRONTEND_ORIGIN=http://localhost
# 相对路径以 WebAgent 项目根目录为基准,也支持 /data/WebAgent-sites 等绝对路径。
WEBAGENT_SITES_DIR=../WebAgent-sites
# 已上线的静态产物独立保存,不会被测试环境构建覆盖。
WEBAGENT_PRODUCTION_DIR=../WebAgent-production
# 可选:任意兼容 OpenAI Chat Completions 的模型服务。
# 默认示例使用国内可访问的 DeepSeek;也可以替换为企业内部网关,
# 或阿里云百炼兼容地址:https://dashscope.aliyuncs.com/compatible-mode/v1
......
......@@ -13,11 +13,33 @@ pnpm dev
- 控制台:<http://localhost:5173>
- API:<http://localhost:3100>
- 站点预览端口:`4300-4399`
- Nginx 统一入口:<http://localhost>
- 测试预览链接:`http://你的主机/previews/site_xxx/`
- 生产站点链接:`http://你的主机/sites/site_xxx/`(用户手动发布后可用)
生成的网站默认保存在 WebAgent 项目同级的 `WebAgent-sites/`。配置支持相对路径和绝对路径;相对路径以 WebAgent 项目根目录为基准:
```bash
WEBAGENT_SITES_DIR=../WebAgent-sites
WEBAGENT_PRODUCTION_DIR=../WebAgent-production
# 或 WEBAGENT_SITES_DIR=/data/WebAgent-sites
```
未配置模型密钥时,内置演示指令支持修改主题颜色、增加服务卡片、增加企业优势等常见场景。配置 `OPENAI_API_KEY` 后,会使用兼容 OpenAI Chat Completions 的接口生成受约束的 `SitePatch`。示例默认使用国内可访问的 DeepSeek API,也可换成阿里云百炼或企业内部兼容网关。
项目的 `.npmrc` 只对当前仓库生效,依赖从 `registry.npmmirror.com` 安装,pnpm store 写入已忽略的 `.runtime/pnpm-store/`,不会污染全局 npm 配置。
## Nginx 访问架构
生产构建后,Nginx 在 80 端口提供统一入口:
- `/`:React 控制台静态文件
- `/api/`:反向代理到 `127.0.0.1:3100`
- `/previews/site_xxx/`:当前代码的测试预览,每次成功构建后更新
- `/sites/site_xxx/`:生产站点,只有用户执行“发布上线”后更新
当前 Intel macOS Homebrew 配置位于 [deploy/nginx/webagent.conf](deploy/nginx/webagent.conf)。服务器路径变化时,需要同步调整配置中的 `root``alias` 绝对路径。
## MVP 能力
- 从 Astro 模板创建独立企业官网
......@@ -25,7 +47,9 @@ pnpm dev
- 在隔离 Git Worktree 中应用 Agent 修改
- 构建通过后才写入正式项目并创建提交
- 本地 iframe 预览与自动刷新
- 测试环境与生产环境使用独立目录和独立链接
- 用户确认后手动发布,并记录生产版本 commit 与发布时间
- 浏览 Git 历史并安全回滚到任意版本
- 所有运行数据隔离在 `.runtime/`,不提交到 WebAgent 仓库
- 生成站点保存在独立的可配置目录,构建缓存等临时数据隔离在 `.runtime/`
详细目录约束见 [docs/storage-convention.md](docs/storage-convention.md),Agent 写入规则见 [docs/site-patch-schema.md](docs/site-patch-schema.md)
......@@ -2,5 +2,6 @@ import { defineConfig } from "astro/config";
export default defineConfig({
output: "static",
base: process.env.SITE_BASE_PATH || "/",
server: { host: "127.0.0.1" },
});
import path from "node:path";
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
import crypto from "node:crypto";
import { cp, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { execa } from "execa";
import { runtimePaths } from "../config.js";
import { config, runtimePaths } from "../config.js";
export class BuildError extends Error {
constructor(message: string, public readonly output: string) { super(message); }
}
export class BuildManager {
async build(projectPath: string, taskId: string): Promise<string> {
async build(projectPath: string, taskId: string, basePath: string): Promise<string> {
await mkdir(runtimePaths.logs, { recursive: true });
let output = "";
try {
......@@ -17,7 +18,10 @@ export class BuildManager {
env: { ...process.env, CI: "true" },
});
output += install.stdout + "\n" + install.stderr + "\n";
const build = await execa("pnpm", ["run", "build"], { cwd: projectPath });
const build = await execa("pnpm", ["run", "build"], {
cwd: projectPath,
env: { ...process.env, SITE_BASE_PATH: basePath.replace(/\/$/, "") || "/" },
});
output += build.stdout + "\n" + build.stderr;
await writeFile(path.join(runtimePaths.logs, taskId + ".log"), output, "utf8");
return path.join(projectPath, "dist");
......@@ -30,8 +34,31 @@ export class BuildManager {
}
}
async publish(siteId: string, distPath: string): Promise<string> {
const target = path.join(runtimePaths.previews, siteId);
async publishPreview(siteId: string, distPath: string): Promise<string> {
return this.publishTo(path.join(runtimePaths.previews, siteId), distPath);
}
async publishProduction(siteId: string, distPath: string): Promise<string> {
const target = path.join(config.productionDir, siteId);
const suffix = crypto.randomBytes(4).toString("hex");
const staging = target + ".next-" + suffix;
const previous = target + ".previous-" + suffix;
await mkdir(path.dirname(target), { recursive: true });
await cp(distPath, staging, { recursive: true });
const hasCurrent = await stat(target).then(() => true).catch(() => false);
if (hasCurrent) await rename(target, previous);
try {
await rename(staging, target);
} catch (error) {
if (hasCurrent) await rename(previous, target);
await rm(staging, { recursive: true, force: true });
throw error;
}
await rm(previous, { recursive: true, force: true });
return target;
}
private async publishTo(target: string, distPath: string): Promise<string> {
await rm(target, { recursive: true, force: true });
await mkdir(path.dirname(target), { recursive: true });
await cp(distPath, target, { recursive: true });
......
......@@ -3,11 +3,22 @@ import { fileURLToPath } from "node:url";
import "dotenv/config";
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(currentDir, "../..");
const configuredSitesDir = process.env.WEBAGENT_SITES_DIR?.trim() || "../WebAgent-sites";
const sitesDir = path.isAbsolute(configuredSitesDir)
? configuredSitesDir
: path.resolve(rootDir, configuredSitesDir);
const configuredProductionDir = process.env.WEBAGENT_PRODUCTION_DIR?.trim() || "../WebAgent-production";
const productionDir = path.isAbsolute(configuredProductionDir)
? configuredProductionDir
: path.resolve(rootDir, configuredProductionDir);
export const config = {
rootDir: path.resolve(currentDir, "../.."),
rootDir,
runtimeDir: path.resolve(currentDir, "../../.runtime"),
templateDir: path.resolve(currentDir, "../../astro-template"),
sitesDir,
productionDir,
host: process.env.HOST || "127.0.0.1",
port: Number(process.env.PORT || 3100),
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
......@@ -19,10 +30,18 @@ export const config = {
};
export const runtimePaths = {
sites: path.join(config.runtimeDir, "sites"),
sites: config.sitesDir,
builds: path.join(config.runtimeDir, "builds"),
previews: path.join(config.runtimeDir, "previews"),
uploads: path.join(config.runtimeDir, "uploads"),
pnpmStore: path.join(config.runtimeDir, "pnpm-store"),
logs: path.join(config.runtimeDir, "logs"),
};
export function getPublicPreviewUrl(siteId: string): string {
return `/previews/${siteId}/`;
}
export function getPublicProductionUrl(siteId: string): string {
return `/sites/${siteId}/`;
}
......@@ -65,7 +65,7 @@ export class GitManager {
async restoreAsCommit(projectPath: string, sourceCommit: string): Promise<string> {
await this.assertCommit(projectPath, sourceCommit);
await execa("git", ["restore", "--source", sourceCommit, "--staged", "--worktree", "--", "."], { cwd: projectPath });
await execa("git", ["restore", "--source", sourceCommit, "--staged", "--worktree", "--", ".", ":(exclude)astro.config.mjs"], { cwd: projectPath });
return this.commit(projectPath, "Rollback to " + sourceCommit.slice(0, 7));
}
......
......@@ -33,6 +33,7 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/chat", async (reque
const body = chatSchema.parse(request.body); return siteAgent.execute(request.params.siteId, body.message);
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/build", async (request) => versions.rebuild(request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => versions.publish(request.params.siteId));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => git.history(sites.getProjectPath(request.params.siteId)));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/rollback", async (request) => {
const body = rollbackSchema.parse(request.body); return versions.rollback(request.params.siteId, body.commit);
......@@ -53,6 +54,10 @@ await rm(runtimePaths.builds, { recursive: true, force: true });
await mkdir(runtimePaths.builds, { recursive: true });
for (const site of await sites.list()) {
await git.pruneWorktrees(sites.getProjectPath(site.siteId));
if (site.environmentVersion !== 3) {
await versions.rebuild(site.siteId).catch((error) => app.log.warn(error));
continue;
}
const previewPath = path.join(runtimePaths.previews, site.siteId);
if (await access(previewPath).then(() => true).catch(() => false)) {
await previews.start(site.siteId, previewPath, site.previewPort)
......
......@@ -2,7 +2,7 @@ import crypto from "node:crypto";
import path from "node:path";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import type { CreateSiteInput, SiteInfo } from "@webagent/shared";
import { config } from "../config.js";
import { config, getPublicPreviewUrl } from "../config.js";
import { GitManager } from "../git/git-manager.js";
import { BuildManager } from "../build/build-manager.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
......@@ -23,8 +23,9 @@ export class CreateSiteService {
const previewPort = await this.sites.allocatePreviewPort();
const now = new Date().toISOString();
const site: SiteInfo = {
siteId, name: input.name, industry: input.industry, status: "creating", templateVersion: "0.1.0",
previewPort, previewUrl: "http://localhost:" + previewPort, currentCommit: "", createdAt: now, updatedAt: now,
siteId, name: input.name, industry: input.industry, status: "creating", templateVersion: "0.1.0", environmentVersion: 3,
previewPort, previewUrl: getPublicPreviewUrl(siteId), currentCommit: "", publishStatus: "unpublished",
createdAt: now, updatedAt: now,
};
await mkdir(projectPath, { recursive: true });
await this.sites.save(site);
......@@ -42,10 +43,10 @@ export class CreateSiteService {
const themePath = path.join(projectPath, "src/styles/theme.css");
const theme = (await readFile(themePath, "utf8")).replaceAll("#7028ff", input.brandColor.toLowerCase());
await writeFile(themePath, theme, "utf8");
const dist = await this.builds.build(projectPath, "create_" + siteId);
const dist = await this.builds.build(projectPath, "create_" + siteId, getPublicPreviewUrl(siteId));
const commit = await this.git.init(projectPath);
await this.sites.update(siteId, { currentCommit: commit });
const published = await this.builds.publish(siteId, dist);
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, previewPort);
return await this.sites.update(siteId, { status: "ready", currentCommit: commit, lastError: undefined });
} catch (error) {
......
import path from "node:path";
import { readFile, writeFile } from "node:fs/promises";
export async function ensureEnvironmentConfig(projectPath: string): Promise<boolean> {
const configPath = path.join(projectPath, "astro.config.mjs");
const content = await readFile(configPath, "utf8");
if (content.includes("process.env.SITE_BASE_PATH")) return false;
const next = content.replace(
/(output:\s*["']static["'],?)/,
'$1\n base: process.env.SITE_BASE_PATH || "/",',
);
if (next === content) throw new Error("无法迁移站点 Astro 环境路径配置");
await writeFile(configPath, next, "utf8");
return true;
}
import crypto from "node:crypto";
import path from "node:path";
import type { ChatResult, SitePatch } from "@webagent/shared";
import { runtimePaths } from "../config.js";
import { getPublicPreviewUrl, runtimePaths } from "../config.js";
import { AgentLoop } from "../agent/agent-loop.js";
import { BuildError, BuildManager } from "../build/build-manager.js";
import { GitManager } from "../git/git-manager.js";
......@@ -31,7 +31,7 @@ export class SiteAgentService {
await applyPatch(workspace, generated.patch);
let dist: string;
try {
dist = await this.builds.build(workspace, taskId);
dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(siteId));
} catch (error) {
if (!(error instanceof BuildError) || generated.mode !== "model") throw error;
const repair = await this.agent.repair(workspace, message, error.output);
......@@ -42,14 +42,14 @@ export class SiteAgentService {
);
if (mergedOperations.size > 5) throw new Error("自动修复后的修改文件总数超过 5 个,任务已安全取消");
generated.patch = { summary: repair.summary, operations: [...mergedOperations.values()] };
dist = await this.builds.build(workspace, taskId + "_repair");
dist = await this.builds.build(workspace, taskId + "_repair", getPublicPreviewUrl(siteId));
}
const changedFiles = generated.patch.operations.map((item) => item.path);
const worktreeCommit = await this.git.commitWorktree(workspace, "Agent: " + generated.patch.summary, changedFiles);
const commit = await this.git.cherryPick(projectPath, worktreeCommit);
const published = await this.builds.publish(siteId, dist);
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready", currentCommit: commit, lastError: undefined });
await this.sites.update(siteId, { status: "ready", currentCommit: commit, environmentVersion: 3, lastError: undefined });
return { summary: generated.patch.summary, commit, previewUrl: site.previewUrl, changedFiles, mode: generated.mode };
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
......
import path from "node:path";
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import type { SiteInfo } from "@webagent/shared";
import { runtimePaths } from "../config.js";
import { config, getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js";
export class SiteRepository {
private runtimeReady?: Promise<void>;
async ensureRuntime(): Promise<void> {
await Promise.all(Object.values(runtimePaths).map((directory) => mkdir(directory, { recursive: true })));
this.runtimeReady ??= this.initializeRuntime();
await this.runtimeReady;
}
getSiteRoot(siteId: string): string {
......@@ -23,7 +26,13 @@ export class SiteRepository {
async get(siteId: string): Promise<SiteInfo> {
const raw = await readFile(this.getMetadataPath(siteId), "utf8");
return JSON.parse(raw) as SiteInfo;
const stored = JSON.parse(raw) as SiteInfo;
return {
...stored,
previewUrl: getPublicPreviewUrl(siteId),
productionUrl: stored.publishedCommit ? getPublicProductionUrl(siteId) : undefined,
publishStatus: stored.publishStatus || (stored.publishedCommit ? "published" : "unpublished"),
};
}
async list(): Promise<SiteInfo[]> {
......@@ -59,4 +68,35 @@ export class SiteRepository {
private assertSiteId(siteId: string): void {
if (!/^site_[a-z0-9]+$/.test(siteId)) throw new Error("无效的 siteId");
}
private async initializeRuntime(): Promise<void> {
await Promise.all([
...Object.values(runtimePaths).map((directory) => mkdir(directory, { recursive: true })),
mkdir(config.productionDir, { recursive: true }),
]);
await this.migrateLegacySites();
}
private async migrateLegacySites(): Promise<void> {
const legacySitesDir = path.join(config.runtimeDir, "sites");
if (path.resolve(legacySitesDir) === path.resolve(runtimePaths.sites)) return;
const legacyExists = await stat(legacySitesDir).then((value) => value.isDirectory()).catch(() => false);
if (!legacyExists) return;
const entries = await readdir(legacySitesDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || !/^site_[a-z0-9]+$/.test(entry.name)) continue;
const source = path.join(legacySitesDir, entry.name);
const destination = path.join(runtimePaths.sites, entry.name);
const destinationExists = await stat(destination).then(() => true).catch(() => false);
if (destinationExists) continue;
try {
await rename(source, destination);
} catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "EXDEV") throw error;
await cp(source, destination, { recursive: true, errorOnExist: true });
await rm(source, { recursive: true, force: true });
}
}
}
}
import crypto from "node:crypto";
import path from "node:path";
import { runtimePaths } from "../config.js";
import { BuildManager } from "../build/build-manager.js";
import type { PublishResult } from "@webagent/shared";
import { getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js";
import { BuildError, BuildManager } from "../build/build-manager.js";
import { GitManager } from "../git/git-manager.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { ensureEnvironmentConfig } from "./ensure-environment-config.js";
import { SiteRepository } from "./site-repository.js";
export class SiteVersionService {
......@@ -15,12 +17,15 @@ export class SiteVersionService {
async rebuild(siteId: string): Promise<{ previewUrl: string }> {
const site = await this.sites.get(siteId);
const project = this.sites.getProjectPath(siteId);
if (await ensureEnvironmentConfig(project)) {
await this.git.commit(project, "System: support preview and production paths");
}
await this.sites.update(siteId, { status: "building", lastError: undefined });
try {
const dist = await this.builds.build(project, "rebuild_" + siteId);
const published = await this.builds.publish(siteId, dist);
const dist = await this.builds.build(project, "rebuild_" + siteId, getPublicPreviewUrl(siteId));
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready" });
await this.sites.update(siteId, { status: "ready", currentCommit: await this.git.currentCommit(project), environmentVersion: 3 });
return { previewUrl: site.previewUrl };
} catch (error) {
await this.sites.update(siteId, { status: "failed", lastError: error instanceof Error ? error.message : String(error) });
......@@ -37,15 +42,44 @@ export class SiteVersionService {
await this.sites.update(siteId, { status: "building", lastError: undefined });
try {
await this.git.createWorktree(project, workspace, targetCommit);
const dist = await this.builds.build(workspace, taskId);
const published = await this.builds.publish(siteId, dist);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(siteId));
const published = await this.builds.publishPreview(siteId, dist);
const commit = await this.git.restoreAsCommit(project, targetCommit);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready", currentCommit: commit, lastError: undefined });
await this.sites.update(siteId, { status: "ready", currentCommit: commit, environmentVersion: 3, lastError: undefined });
return { commit, previewUrl: site.previewUrl };
} catch (error) {
await this.sites.update(siteId, { status: "failed", lastError: error instanceof Error ? error.message : String(error) });
throw error;
} finally { await this.git.removeWorktree(project, workspace); }
}
async publish(siteId: string): Promise<PublishResult> {
const project = this.sites.getProjectPath(siteId);
const site = await this.sites.get(siteId);
if (site.status !== "ready") throw new Error("测试环境尚未构建成功,不能发布到生产环境");
const taskId = "publish_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(siteId, { publishStatus: "publishing", lastPublishError: undefined });
try {
const commit = await this.git.currentCommit(project);
await this.git.createWorktree(project, workspace, commit);
const dist = await this.builds.build(workspace, taskId, getPublicProductionUrl(siteId));
await this.builds.publishProduction(siteId, dist);
const publishedAt = new Date().toISOString();
const productionUrl = getPublicProductionUrl(siteId);
await this.sites.update(siteId, {
publishStatus: "published", publishedCommit: commit, publishedAt,
productionUrl, lastPublishError: undefined,
});
return { productionUrl, commit, publishedAt };
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { publishStatus: "failed", lastPublishError: details });
throw error;
} finally {
await this.git.removeWorktree(project, workspace);
}
}
}
# WebAgent Nginx(Intel macOS)
当前配置适用于 Homebrew Intel 路径 `/usr/local/etc/nginx`
```bash
pnpm build
ln -sfn /Users/mac/Desktop/code/WebAgent/deploy/nginx/webagent.conf \
/usr/local/etc/nginx/servers/webagent.conf
nginx -t
nginx -s reload
pnpm --filter @webagent/backend start
```
访问入口:
- 控制台:`http://主机地址/`
- API:`http://主机地址/api/`
- 测试预览:`http://主机地址/previews/site_xxx/`
- 生产站点:`http://主机地址/sites/site_xxx/`
Nginx 由 Homebrew service 管理,登录后会自动启动;Fastify 仍需使用 launchd、进程管理器或终端进程保持运行。更换项目目录时,需要修改 `webagent.conf` 中的 `root``alias`
当前 MVP 没有用户身份、站点权限和请求限流。可以在可信局域网演示;正式暴露到公网前必须补充 HTTPS、鉴权、限流和站点所有权校验。
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
charset utf-8;
client_max_body_size 20m;
root /Users/mac/Desktop/code/WebAgent/frontend/dist;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
location /api/ {
proxy_pass http://127.0.0.1:3100;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
location /previews/ {
alias /Users/mac/Desktop/code/WebAgent/.runtime/previews/;
index index.html;
add_header Cache-Control "no-store" always;
}
location /sites/ {
alias /Users/mac/Desktop/code/WebAgent-production/;
index index.html;
add_header Cache-Control "no-cache" always;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~ /\. {
deny all;
}
}
# 本地存储规范
所有运行期数据必须写入项目根目录的 `.runtime/`,该目录不进入 WebAgent Git
生成站点源码、测试构建和生产产物分开保存。`.env` 中默认配置为 `WEBAGENT_SITES_DIR=../WebAgent-sites``WEBAGENT_PRODUCTION_DIR=../WebAgent-production`,相对路径以 WebAgent 项目根目录为基准,也可以指定绝对路径
```text
.runtime/
├── sites/
│ └── site_xxxxxx/
│ ├── project/ # 独立 Astro 项目,也是独立 Git 仓库
│ └── metadata/
│ └── site.json # WebAgent 管理信息,不交给 Agent 修改
WebAgent-sites/
└── site_xxxxxx/
├── project/ # 独立 Astro 项目,也是独立 Git 仓库
└── metadata/
└── site.json # WebAgent 管理信息,不交给 Agent 修改
WebAgent-production/
└── site_xxxxxx/ # 用户确认发布的生产静态产物
WebAgent/.runtime/
├── builds/
│ └── task_xxxxxx/
│ └── workspace/ # 临时 Git Worktree,任务结束必须删除
├── previews/
│ └── site_xxxxxx/ # 最近一次构建成功的静态产物
│ └── site_xxxxxx/ # 当前代码最近一次构建成功的测试产物
├── uploads/ # 原始上传文件的临时落点
├── pnpm-store/ # 所有生成站点共享的 pnpm store
└── logs/ # Agent 与构建日志
```
`WebAgent-sites/``WebAgent-production/` 是持久数据,应纳入服务器备份;`.runtime/` 是可重建的测试与运行缓存,不提交 Git。修改路径配置后,系统不会自动迁移外部目录之间的数据,需要由管理员手动移动。旧版本位于 `.runtime/sites/` 的站点会在首次启动时自动迁移到当前配置目录。
## 不变量
1. Agent 只可读取和修改当前站点 `project/` 内的白名单文件。
2. Agent 永远不直接修改正式项目,先在 `builds/` 中验证。
3. 只有构建成功的版本才允许进入站点 Git 历史并成为预览版本。
4. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。
5. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
6. 每个站点拥有稳定的 `siteId` 和预览端口;站点名称不参与路径计算。
4. 测试预览更新不能覆盖生产产物;生产环境只能通过显式发布操作更新。
5. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。
6. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
7. 每个站点拥有稳定的 `siteId` 和预览端口;站点名称不参与路径计算。
This diff is collapsed.
import type { ChatResult, CreateSiteInput, GitHistoryItem, SiteInfo } from "@webagent/shared";
import type { ChatResult, CreateSiteInput, GitHistoryItem, PublishResult, SiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers);
if (options?.body != null && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(url, {
...options,
headers: { "content-type": "application/json", ...options?.headers },
headers,
});
const data = await response.json().catch(() => ({})) as { error?: string };
if (!response.ok) throw new Error(data.error || "请求失败,请稍后重试");
......@@ -18,5 +20,6 @@ export const api = {
chat: (siteId: string, message: string) => request<ChatResult>("/api/sites/" + siteId + "/chat", { method: "POST", body: JSON.stringify({ message }) }),
history: (siteId: string) => request<GitHistoryItem[]>("/api/sites/" + siteId + "/history"),
rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }),
publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }),
rollback: (siteId: string, commit: string) => request<{ commit: string; previewUrl: string }>("/api/sites/" + siteId + "/rollback", { method: "POST", body: JSON.stringify({ commit }) }),
};
This diff is collapsed.
......@@ -5,6 +5,10 @@ export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: { "/api": "http://127.0.0.1:3100" },
proxy: {
"/api": "http://127.0.0.1:3100",
"/previews": "http://127.0.0.1:80",
"/sites": "http://127.0.0.1:80",
},
},
});
export type SiteStatus = "creating" | "ready" | "building" | "failed";
export type PublishStatus = "unpublished" | "publishing" | "published" | "failed";
export interface CreateSiteInput {
name: string;
......@@ -16,12 +17,18 @@ export interface SiteInfo {
industry: string;
status: SiteStatus;
templateVersion: string;
environmentVersion?: number;
previewPort: number;
previewUrl: string;
productionUrl?: string;
currentCommit: string;
publishedCommit?: string;
publishedAt?: string;
publishStatus: PublishStatus;
createdAt: string;
updatedAt: string;
lastError?: string;
lastPublishError?: string;
}
export interface SitePatch {
......@@ -48,3 +55,9 @@ export interface ChatResult {
changedFiles: string[];
mode: "model" | "local";
}
export interface PublishResult {
productionUrl: string;
commit: string;
publishedAt: string;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment