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` 和预览端口;站点名称不参与路径计算。
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft, ArrowRight, Bot, Check, ChevronDown, Clock3, Code2, ExternalLink,
History, Laptop, LoaderCircle, MessageSquareText, Monitor, MoreHorizontal, Palette,
Plus, RefreshCw, RotateCcw, Send, Settings2, ShieldCheck, Smartphone, Sparkles,
Globe2, History, Laptop, LoaderCircle, MessageSquareText, Monitor, Palette,
Plus, RefreshCw, Rocket, RotateCcw, Send, Settings2, ShieldCheck, Smartphone, Sparkles,
WandSparkles, X,
} from "lucide-react";
import type { CreateSiteInput, SiteInfo } from "@webagent/shared";
......@@ -105,12 +105,57 @@ function Field({ label, hint, wide, children }: { label: string; hint?: string;
return <label className={wide ? "field wide" : "field"}><span>{label}{hint && <small>{hint}</small>}</span>{children}</label>;
}
function SiteSwitcher({ site, sites, onSelect }: { site: SiteInfo; sites: SiteInfo[]; onSelect: (id: string) => void }) {
const [open, setOpen] = useState(false);
const switcherRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const closeOutside = (event: PointerEvent) => {
if (event.target instanceof Node && !switcherRef.current?.contains(event.target)) setOpen(false);
};
const closeWithEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", closeOutside);
document.addEventListener("keydown", closeWithEscape);
return () => {
document.removeEventListener("pointerdown", closeOutside);
document.removeEventListener("keydown", closeWithEscape);
};
}, [open]);
return <div className={`site-switcher ${open ? "open" : ""}`} ref={switcherRef}>
<button className="site-switcher-trigger" type="button" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen((value) => !value)}>
<span className="site-avatar">{site.name.slice(0, 1)}</span>
<span className="site-switcher-copy"><strong>{site.name}</strong><small>{site.industry}</small></span>
<span className="site-switcher-chevron"><ChevronDown size={14} /></span>
</button>
{open && <div className="site-switcher-menu" role="listbox" aria-label="选择要管理的官网">
<div className="site-menu-heading"><span>我的官网</span><em>{sites.length}</em></div>
<div className="site-menu-list">
{sites.map((item) => {
const selected = item.siteId === site.siteId;
return <button type="button" role="option" aria-selected={selected} className={selected ? "selected" : ""} key={item.siteId} onClick={() => { onSelect(item.siteId); setOpen(false); }}>
<span className="site-menu-avatar">{item.name.slice(0, 1)}</span>
<span className="site-menu-copy"><strong>{item.name}</strong><small>{item.industry}</small></span>
<span className={`site-menu-status ${item.publishedCommit ? "published" : item.status}`} title={item.publishedCommit ? "生产环境已上线" : item.status === "ready" ? "测试环境已就绪" : "站点需要检查"} />
{selected && <Check size={14} />}
</button>;
})}
</div>
<p><ShieldCheck size={11} /> 每个官网拥有独立代码、版本与发布环境</p>
</div>}
</div>;
}
function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteId: string; sites: SiteInfo[]; agentMode: "model" | "local"; onSelectSite: (id: string) => void; onCreate: () => void }) {
const queryClient = useQueryClient();
const siteQuery = useQuery({ queryKey: ["site", siteId], queryFn: () => api.site(siteId) });
const historyQuery = useQuery({ queryKey: ["history", siteId], queryFn: () => api.history(siteId) });
const [tab, setTab] = useState<"chat" | "history">("chat");
const [device, setDevice] = useState<"desktop" | "mobile">("desktop");
const [environment, setEnvironment] = useState<"preview" | "production">("preview");
const [input, setInput] = useState("");
const [iframeVersion, setIframeVersion] = useState(0);
const [messages, setMessages] = useState<ChatMessage[]>([
......@@ -119,7 +164,10 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
const scrollRef = useRef<HTMLDivElement>(null);
const site = siteQuery.data;
useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [messages]);
useEffect(() => { setMessages([{ role: "agent", text: "已切换到这个网站。你可以继续描述修改要求。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }]); }, [siteId, agentMode]);
useEffect(() => {
setEnvironment("preview");
setMessages([{ role: "agent", text: "已切换到这个网站。你可以继续描述修改要求。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }]);
}, [siteId, agentMode]);
const refreshData = async () => {
await Promise.all([
......@@ -138,18 +186,44 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
},
onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "修改未生效,原版本保持可用" }]),
});
const rebuildMutation = useMutation({ mutationFn: () => api.rebuild(siteId), onSuccess: refreshData });
const rebuildMutation = useMutation({
mutationFn: () => api.rebuild(siteId),
onSuccess: async () => {
setEnvironment("preview");
setMessages((items) => [...items, { role: "agent", text: "测试环境已重新构建。", meta: "测试预览已刷新" }]);
await refreshData();
},
onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "重新构建失败,原测试预览保持可用" }]),
});
const rollbackMutation = useMutation({
mutationFn: (commit: string) => api.rollback(siteId, commit),
onSuccess: async (result) => { setMessages((items) => [...items, { role: "agent", text: "已恢复所选版本,并创建新的回滚记录。", meta: result.commit.slice(0, 7) }]); await refreshData(); },
});
const publishMutation = useMutation({
mutationFn: () => api.publish(siteId),
onSuccess: async (result) => {
setEnvironment("production");
setMessages((items) => [...items, { role: "agent", text: "当前版本已发布到生产环境。", meta: "生产版本 · " + result.commit.slice(0, 7) }]);
await refreshData();
},
onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "发布失败,原生产版本保持可用" }]),
});
const send = (message = input) => {
const value = message.trim(); if (!value || chatMutation.isPending) return;
setMessages((items) => [...items, { role: "user", text: value }]); setInput(""); chatMutation.mutate(value);
};
const busy = chatMutation.isPending || rebuildMutation.isPending || rollbackMutation.isPending;
const busy = chatMutation.isPending || rebuildMutation.isPending || rollbackMutation.isPending || publishMutation.isPending;
const productionCurrent = Boolean(site?.publishedCommit && site.publishedCommit === site.currentCommit);
const canPublish = site?.status === "ready" && !productionCurrent;
if (!site) return <Splash />;
const previewVersion = historyQuery.data?.find((item) => item.hash === site.currentCommit);
const productionVersion = historyQuery.data?.find((item) => item.hash === site.publishedCommit);
const showingProduction = environment === "production";
const activeUrl = showingProduction ? site.productionUrl : site.previewUrl;
const environmentStatus = showingProduction
? !site.productionUrl ? "尚未上线" : productionCurrent ? "生产已同步" : "生产版本较旧"
: site.status === "ready" ? "测试已同步" : "需要检查";
return <main className="workspace">
<aside className="rail">
<Logo compact />
......@@ -157,12 +231,8 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
<button className="rail-create" onClick={onCreate} title="创建新网站"><Plus size={18} /></button>
</aside>
<section className="control-panel">
<div className="site-switcher">
<div className="site-avatar">{site.name.slice(0, 1)}</div>
<select value={siteId} onChange={(e) => onSelectSite(e.target.value)}>{sites.map((item) => <option value={item.siteId} key={item.siteId}>{item.name}</option>)}</select>
<ChevronDown size={15} />
</div>
<div className="panel-tabs"><button className={tab === "chat" ? "active" : ""} onClick={() => setTab("chat")}><MessageSquareText size={15} /> Agent</button><button className={tab === "history" ? "active" : ""} onClick={() => setTab("history")}><History size={15} /> 版本历史</button></div>
<SiteSwitcher site={site} sites={sites} onSelect={onSelectSite} />
<div className="panel-tabs"><button className={tab === "chat" ? "active" : ""} onClick={() => setTab("chat")}><MessageSquareText size={15} /> Agent</button><button className={tab === "history" ? "active" : ""} onClick={() => { setTab("history"); void siteQuery.refetch(); void historyQuery.refetch(); }}><History size={15} /> 版本历史</button></div>
{tab === "chat" ? <>
<div className="chat-scroll" ref={scrollRef}>
<div className="chat-date">今天</div>
......@@ -178,28 +248,58 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
<div><span>{agentMode === "model" ? <><span className="online-dot" />模型 Agent</> : <><Code2 size={12} />本地演示模式</>}</span><button onClick={() => send()} disabled={!input.trim() || busy}><Send size={15} /></button></div>
</div>
</> : <div className="history-list">
<div className="history-heading"><div><h3>版本记录</h3><p>仅显示构建成功的修改</p></div><span>{historyQuery.data?.length || 0}</span></div>
{historyQuery.data?.map((item, index) => <article className={item.current ? "current" : ""} key={item.hash}>
<div className="environment-versions">
<button className={environment === "preview" ? "active preview" : "preview"} onClick={() => setEnvironment("preview")}>
<span><Monitor size={14} />测试环境<em>当前预览</em></span>
<strong>{site.currentCommit.slice(0, 7)}</strong>
<small>{previewVersion ? formatDate(previewVersion.date) : formatDate(site.updatedAt)}</small>
</button>
<button className={environment === "production" ? "active production" : "production"} onClick={() => setEnvironment("production")}>
<span><Globe2 size={14} />生产环境<em>{site.productionUrl ? productionCurrent ? "已同步" : "待更新" : "未上线"}</em></span>
<strong>{site.publishedCommit?.slice(0, 7) || "-------"}</strong>
<small>{site.publishedAt ? formatDate(site.publishedAt) : "发布后生成生产链接"}</small>
</button>
</div>
<div className="history-heading"><div><h3>历史版本</h3><p>选择旧版本可恢复到测试环境</p></div><span>{historyQuery.data?.length || 0}</span></div>
{historyQuery.data?.map((item, index) => {
const isPreview = item.hash === site.currentCommit;
const isProduction = item.hash === site.publishedCommit;
return <article className={isPreview || isProduction ? "environment-version" : ""} key={item.hash}>
<div className="timeline"><i>{item.current ? <Check size={10} /> : ""}</i>{index < (historyQuery.data?.length || 0) - 1 && <span />}</div>
<div className="commit-info"><div><strong>{item.message}</strong>{item.current && <em>当前版本</em>}</div><p><code>{item.shortHash}</code><span>·</span>{formatDate(item.date)}</p></div>
{!item.current && <button title="恢复这个版本" disabled={busy} onClick={() => { if (window.confirm("确定恢复到版本 " + item.shortHash + " 吗?当前状态会作为历史保留。")) rollbackMutation.mutate(item.hash); }}><RotateCcw size={14} /></button>}
</article>)}
<div className="commit-info"><div><strong>{item.message}</strong>{isPreview && <em className="preview-badge">测试</em>}{isProduction && <em className="production-badge">生产</em>}</div><p><code>{item.shortHash}</code><span>·</span>{formatDate(item.date)}</p></div>
<div className="history-actions">
{isPreview && <button title="查看测试环境" onClick={() => setEnvironment("preview")}><Monitor size={13} /></button>}
{isProduction && <button title="查看生产环境" onClick={() => setEnvironment("production")}><Globe2 size={13} /></button>}
{!isPreview && <button title="恢复到测试环境" disabled={busy} onClick={() => { if (window.confirm("确定将测试环境恢复到版本 " + item.shortHash + " 吗?生产环境不会受到影响。")) rollbackMutation.mutate(item.hash); }}><RotateCcw size={13} /></button>}
</div>
</article>;
})}
</div>}
</section>
<section className="preview-shell">
<header className="preview-toolbar">
<div className="preview-title"><div><span className="status-dot" /><strong>{site.name}</strong></div><span className={`build-status ${site.status}`}>{busy ? <LoaderCircle className="spin" size={12} /> : <Check size={12} />}{busy ? "正在构建" : site.status === "ready" ? "已同步" : "需要检查"}</span></div>
<div className="preview-title"><div><span className={`status-dot ${showingProduction ? "production" : ""}`} /><strong>{site.name}</strong><em className={showingProduction ? "production" : ""}>{showingProduction ? "生产环境" : "测试环境"}</em></div><span className={`build-status ${showingProduction ? site.publishStatus : site.status}`}>{busy ? <LoaderCircle className="spin" size={12} /> : <Check size={12} />}{publishMutation.isPending ? "正在发布" : rebuildMutation.isPending ? "正在构建" : environmentStatus}</span></div>
<div className="preview-modes">
<div className="environment-toggle"><button className={!showingProduction ? "active" : ""} onClick={() => setEnvironment("preview")}><Monitor size={13} />测试预览</button><button className={showingProduction ? "active" : ""} onClick={() => setEnvironment("production")}><Globe2 size={13} />生产站点</button></div>
<div className="device-toggle"><button className={device === "desktop" ? "active" : ""} onClick={() => setDevice("desktop")}><Laptop size={15} /></button><button className={device === "mobile" ? "active" : ""} onClick={() => setDevice("mobile")}><Smartphone size={15} /></button></div>
<div className="toolbar-actions"><button disabled={busy} onClick={() => rebuildMutation.mutate()} title="重新构建"><RefreshCw className={rebuildMutation.isPending ? "spin" : ""} size={15} /></button><a href={site.previewUrl} target="_blank" rel="noreferrer" title="新窗口打开"><ExternalLink size={15} /></a><button><MoreHorizontal size={16} /></button></div>
</div>
<div className="toolbar-actions">
{!showingProduction && <button className="rebuild-button" disabled={busy} onClick={() => rebuildMutation.mutate()} title="重新构建测试环境"><RefreshCw className={rebuildMutation.isPending ? "spin" : ""} size={14} /><span>重新构建</span></button>}
<a className="environment-link preview-link" href={site.previewUrl} target="_blank" rel="noreferrer" title="打开测试环境"><Monitor size={13} /><span>测试链接</span></a>
{site.productionUrl ? <a className="environment-link production-link" href={site.productionUrl} target="_blank" rel="noreferrer" title="打开生产环境"><Globe2 size={13} /><span>生产链接</span></a> : <button className="environment-link production-link" disabled title="发布上线后生成生产链接"><Globe2 size={13} /><span>生产链接</span></button>}
<button className="publish-button" disabled={busy || !canPublish} onClick={() => { if (window.confirm("确定将当前测试版本发布到生产环境吗?")) publishMutation.mutate(); }} title={productionCurrent ? "当前版本已经上线" : site.status !== "ready" ? "测试环境构建成功后才能发布" : "发布当前测试版本到生产环境"}>
{publishMutation.isPending ? <LoaderCircle className="spin" size={14} /> : <Rocket size={14} />}<span>{productionCurrent ? "已上线" : site.productionUrl ? "发布更新" : "发布上线"}</span>
</button>
</div>
</header>
<div className="browser-stage">
<div className={`browser-frame ${device}`}>
<div className="browser-chrome"><div><i /><i /><i /></div><span>{site.previewUrl}</span><RefreshCw size={11} /></div>
<iframe key={iframeVersion} title={site.name + " 网站预览"} src={site.previewUrl + "?v=" + iframeVersion} />
{busy && <div className="preview-busy"><LoaderCircle className="spin" size={25} /><strong>正在生成新版本</strong><span>当前预览保持可用</span></div>}
<div className="browser-chrome"><div><i /><i /><i /></div><span>{activeUrl || site.productionUrl || "/sites/" + site.siteId + "/"}</span><RefreshCw size={11} /></div>
{activeUrl ? <iframe key={environment + iframeVersion} title={site.name + (showingProduction ? " 生产站点" : " 测试预览")} src={activeUrl + "?v=" + iframeVersion} /> : <div className="production-empty"><span><Globe2 size={24} /></span><strong>生产环境尚未发布</strong><p>确认测试效果后,点击“发布上线”生成独立生产版本和链接。</p><button disabled={!canPublish || busy} onClick={() => { if (window.confirm("确定将当前测试版本发布到生产环境吗?")) publishMutation.mutate(); }}><Rocket size={14} />发布当前测试版本</button></div>}
{busy && <div className="preview-busy"><LoaderCircle className="spin" size={25} /><strong>{publishMutation.isPending ? "正在发布生产版本" : "正在生成测试版本"}</strong><span>{publishMutation.isPending ? "测试环境和原生产版本保持可用" : "当前测试预览保持可用"}</span></div>}
</div>
</div>
<footer className="preview-footer"><span><Clock3 size={12} /> 最近更新 {formatDate(site.updatedAt)}</span><span><Palette size={12} /> Astro Static</span></footer>
<footer className="preview-footer"><span><Clock3 size={12} /> 测试版本 {previewVersion?.shortHash || site.currentCommit.slice(0, 7)} · {previewVersion ? formatDate(previewVersion.date) : formatDate(site.updatedAt)}</span><span><Palette size={12} />{site.publishedAt ? `生产版本 ${productionVersion?.shortHash || site.publishedCommit?.slice(0, 7)} · ${formatDate(site.publishedAt)}` : "生产环境尚未发布"}</span></footer>
</section>
</main>;
}
......
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 }) }),
};
......@@ -13,12 +13,13 @@
.landing-header{position:relative;z-index:2;width:min(1380px,calc(100% - 64px));height:88px;margin:auto;display:flex;align-items:center;justify-content:space-between}.landing-status{display:flex;align-items:center;gap:8px;padding:8px 12px;color:var(--text-2);background:rgba(255,255,255,.72);border:1px solid rgba(231,227,243,.9);border-radius:999px;font-size:11px;font-weight:650;backdrop-filter:blur(14px)}.landing-status>span:first-child{width:7px;height:7px;border-radius:50%;background:#20b486;box-shadow:0 0 0 4px rgba(32,180,134,.12)}
.create-layout{position:relative;z-index:1;width:min(1320px,calc(100% - 64px));min-height:calc(100vh - 88px);margin:auto;display:grid;grid-template-columns:.92fr 1.08fr;gap:85px;align-items:center;padding:42px 0 72px}.create-intro{padding-left:8px}.back-button{display:flex;align-items:center;gap:7px;margin-bottom:38px;padding:0;color:var(--text-2);border:0;background:none;font-size:12px}.intro-tag{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;color:var(--primary);background:rgba(255,255,255,.8);border:1px solid #ded6ff;border-radius:999px;font-size:10px;font-weight:850;letter-spacing:.13em}.create-intro h1{max-width:620px;margin:25px 0 24px;font-size:clamp(51px,5.3vw,78px);line-height:1.07;letter-spacing:-.065em}.create-intro h1 span{display:inline-block;color:transparent;background:var(--gradient);background-clip:text}.create-intro>p{max-width:570px;margin:0;color:var(--text-2);font-size:17px;line-height:1.85}.feature-row{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:52px}.feature-row>div{padding:17px 13px;background:rgba(255,255,255,.62);border:1px solid rgba(231,227,243,.9);border-radius:16px}.feature-row div>span{display:grid;place-items:center;width:35px;height:35px;margin-bottom:13px;color:var(--primary);background:var(--soft);border-radius:10px}.feature-row strong,.feature-row small{display:block}.feature-row strong{font-size:12px;margin-bottom:6px}.feature-row small{color:var(--muted);font-size:10px;line-height:1.5}
.create-card-wrap{display:flex;justify-content:flex-end}.create-card{width:min(100%,650px);padding:35px 38px 25px;background:rgba(255,255,255,.91);border:1px solid rgba(255,255,255,.95);border-radius:26px;box-shadow:0 32px 90px rgba(36,16,95,.13),0 2px 6px rgba(36,16,95,.04);backdrop-filter:blur(24px)}.card-heading{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:27px}.card-heading span{color:var(--primary);font-size:10px;font-weight:800;letter-spacing:.12em}.card-heading h2{margin:7px 0 0;font-size:24px;letter-spacing:-.035em}.step-pill{padding:7px 10px;color:var(--muted);background:#f8f8fc;border-radius:9px;font-size:10px;font-weight:700}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:17px}.field{display:flex;flex-direction:column;gap:8px}.field.wide{grid-column:1/-1}.field>span{display:flex;justify-content:space-between;color:var(--text-2);font-size:11px;font-weight:700}.field small{color:var(--muted);font-weight:500}.field input,.field textarea{width:100%;padding:11px 13px;color:var(--text);background:#fbfbfd;border:1px solid var(--border);border-radius:10px;outline:none;font-size:13px;transition:.2s}.field textarea{resize:vertical;line-height:1.6}.field input:focus,.field textarea:focus{background:#fff;border-color:#ad8cff;box-shadow:0 0 0 3px rgba(112,40,255,.08)}.color-field{height:44px;display:flex;align-items:center;gap:11px;padding:5px 8px;background:#fbfbfd;border:1px solid var(--border);border-radius:10px}.color-field>input{width:31px;height:31px;padding:2px;border:0;background:none}.color-field>span{font-family:ui-monospace,SFMono-Regular,monospace;color:var(--text-2);font-size:11px}.color-swatches{display:flex;gap:7px;margin-left:auto}.color-swatches button{width:21px;height:21px;padding:0;border:2px solid white;border-radius:7px;box-shadow:0 0 0 1px var(--border)}.color-swatches button.active{box-shadow:0 0 0 2px var(--primary)}.create-submit{width:100%;height:51px;margin-top:24px;display:flex;align-items:center;justify-content:center;gap:11px;color:white;background:var(--gradient);border:0;border-radius:12px;box-shadow:0 13px 28px rgba(112,40,255,.24);font-size:13px;font-weight:800;transition:.2s}.create-submit:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 16px 34px rgba(112,40,255,.32)}.local-note{display:flex;align-items:center;justify-content:center;gap:6px;margin:14px 0 0;color:var(--muted);font-size:9px}.form-error{display:flex;align-items:center;gap:7px;margin-top:16px;padding:10px 12px;color:#b42318;background:#fff2f0;border-radius:9px;font-size:11px}
.workspace{height:100vh;display:grid;grid-template-columns:64px 372px 1fr;overflow:hidden;background:#eff0f5}.rail{display:flex;flex-direction:column;align-items:center;padding:15px 0;background:#fff;border-right:1px solid var(--divider);z-index:3}.rail .logo-mark{width:35px;height:35px}.rail-nav{display:flex;flex-direction:column;gap:9px;margin-top:42px}.rail button{display:grid;place-items:center;width:38px;height:38px;color:var(--muted);background:transparent;border:0;border-radius:11px}.rail button:hover,.rail button.active{color:var(--primary);background:var(--soft)}.rail-create{margin-top:auto!important;color:white!important;background:var(--gradient)!important;box-shadow:0 8px 20px rgba(112,40,255,.2)}
.control-panel{display:flex;flex-direction:column;min-width:0;background:#fff;border-right:1px solid var(--border);z-index:2}.site-switcher{position:relative;height:66px;padding:0 18px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--divider)}.site-avatar{display:grid;place-items:center;width:32px;height:32px;color:white;background:var(--gradient);border-radius:9px;font-size:12px;font-weight:800}.site-switcher select{flex:1;min-width:0;padding:0 20px 0 0;color:var(--text);background:transparent;border:0;appearance:none;outline:none;font-size:13px;font-weight:750}.site-switcher>svg{position:absolute;right:18px;color:var(--muted);pointer-events:none}.panel-tabs{height:49px;padding:0 14px;display:grid;grid-template-columns:1fr 1fr;gap:5px;align-items:center;border-bottom:1px solid var(--divider)}.panel-tabs button{height:35px;display:flex;align-items:center;justify-content:center;gap:7px;color:var(--muted);background:transparent;border:0;border-radius:9px;font-size:11px;font-weight:700}.panel-tabs button.active{color:var(--primary);background:var(--soft)}
.chat-scroll{flex:1;min-height:0;overflow:auto;padding:20px 17px 28px}.chat-date{text-align:center;color:#aaaeba;font-size:9px;margin-bottom:22px}.message{display:flex;gap:9px;margin-bottom:17px}.message.user{justify-content:flex-end}.message>div{max-width:86%}.message p{margin:0;padding:12px 14px;color:var(--text-2);background:#f6f6f9;border-radius:4px 13px 13px 13px;font-size:12px;line-height:1.65}.message.user p{color:white;background:var(--gradient);border-radius:13px 4px 13px 13px;box-shadow:0 8px 18px rgba(112,40,255,.15)}.message small{display:flex;align-items:center;gap:4px;margin:6px 2px 0;color:#9a9da8;font-size:9px}.agent-avatar{flex:0 0 auto;display:grid;place-items:center;width:28px;height:28px;color:var(--primary);background:var(--soft);border:1px solid #e2dbff;border-radius:9px}.agent-progress{display:flex;gap:9px;margin:8px 0 18px}.agent-progress>div{flex:1;padding:12px 13px;background:#faf9ff;border:1px solid #ebe6ff;border-radius:4px 13px 13px 13px}.agent-progress p{display:flex;align-items:center;gap:7px;margin:0 0 10px;color:var(--text-2);font-size:11px;font-weight:700}.agent-progress small{color:var(--muted);font-size:9px}.progress-track{height:3px;margin-bottom:8px;overflow:hidden;background:#ebe7f5;border-radius:9px}.progress-track i{display:block;width:45%;height:100%;background:var(--gradient);border-radius:9px;animation:progress 1.5s ease-in-out infinite}@keyframes progress{50%{transform:translateX(120%)}}.suggestions{display:flex;flex-direction:column;gap:7px;margin:25px 37px 0}.suggestions>span{margin-bottom:3px;color:var(--muted);font-size:9px}.suggestions button{display:flex;align-items:center;gap:7px;padding:9px 11px;text-align:left;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:9px;font-size:10px}.suggestions button:hover{color:var(--primary);border-color:#cdbdff;background:#faf9ff}
.composer{margin:0 14px 14px;padding:10px;background:#fff;border:1px solid #dcd8e8;border-radius:13px;box-shadow:0 5px 22px rgba(36,16,95,.07)}.composer textarea{width:100%;padding:2px 4px;resize:none;color:var(--text);background:transparent;border:0;outline:none;font-size:11px;line-height:1.55}.composer textarea::placeholder{color:#a5a7b0}.composer>div{display:flex;align-items:center;justify-content:space-between}.composer>div>span{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:9px}.online-dot{width:6px;height:6px;border-radius:50%;background:#20b486}.composer button{display:grid;place-items:center;width:29px;height:29px;color:white;background:var(--gradient);border:0;border-radius:8px}
.history-list{flex:1;overflow:auto;padding:22px 18px}.history-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:25px}.history-heading h3{margin:0 0 4px;font-size:14px}.history-heading p{margin:0;color:var(--muted);font-size:9px}.history-heading>span{display:grid;place-items:center;width:25px;height:25px;color:var(--primary);background:var(--soft);border-radius:8px;font-size:10px;font-weight:800}.history-list article{display:grid;grid-template-columns:24px 1fr 28px;gap:7px;min-height:69px}.timeline{display:flex;align-items:center;flex-direction:column}.timeline i{display:grid;place-items:center;width:17px;height:17px;color:white;background:#d6d7df;border:3px solid white;box-shadow:0 0 0 1px #d6d7df;border-radius:50%;font-style:normal}.history-list article.current .timeline i{background:var(--primary);box-shadow:0 0 0 1px var(--primary)}.timeline span{width:1px;flex:1;background:var(--divider)}.commit-info{padding-bottom:18px}.commit-info>div{display:flex;align-items:center;gap:7px}.commit-info strong{font-size:11px;line-height:1.4}.commit-info em{padding:2px 5px;color:var(--primary);background:var(--soft);border-radius:5px;font-size:8px;font-style:normal}.commit-info p{display:flex;align-items:center;gap:5px;margin:5px 0 0;color:var(--muted);font-size:9px}.commit-info code{font-size:9px}.history-list article>button{display:grid;place-items:center;width:26px;height:26px;color:var(--muted);background:white;border:1px solid var(--border);border-radius:8px}.history-list article>button:hover{color:var(--primary);background:var(--soft)}
.preview-shell{min-width:0;display:flex;flex-direction:column}.preview-toolbar{height:66px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:0 20px;background:#fff;border-bottom:1px solid var(--border)}.preview-title{display:flex;align-items:center;gap:13px}.preview-title>div{display:flex;align-items:center;gap:8px}.preview-title strong{font-size:12px}.status-dot{width:7px;height:7px;border-radius:50%;background:#20b486;box-shadow:0 0 0 4px rgba(32,180,134,.1)}.build-status{display:flex;align-items:center;gap:4px;padding:5px 7px;color:#188866;background:#ebfbf5;border-radius:7px;font-size:8px;font-weight:750}.build-status.failed{color:#b42318;background:#fff0ee}.device-toggle{display:flex;padding:3px;background:#f2f2f6;border-radius:8px}.device-toggle button{display:grid;place-items:center;width:31px;height:27px;color:var(--muted);background:transparent;border:0;border-radius:6px}.device-toggle button.active{color:var(--text);background:#fff;box-shadow:0 2px 7px rgba(36,16,95,.1)}.toolbar-actions{display:flex;justify-content:flex-end;gap:7px}.toolbar-actions button,.toolbar-actions a{display:grid;place-items:center;width:31px;height:31px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:8px}.toolbar-actions button:hover,.toolbar-actions a:hover{color:var(--primary);background:var(--soft)}
.browser-stage{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:25px;background:#eff0f5;overflow:auto}.browser-frame{position:relative;width:100%;height:100%;overflow:hidden;background:#fff;border:1px solid #dfe0e7;border-radius:11px;box-shadow:0 15px 45px rgba(30,24,53,.1);transition:.35s ease}.browser-frame.mobile{width:390px;max-height:760px}.browser-chrome{height:31px;display:grid;grid-template-columns:70px 1fr 70px;align-items:center;padding:0 10px;background:#fafafd;border-bottom:1px solid #ebeaf0}.browser-chrome>div{display:flex;gap:5px}.browser-chrome i{width:7px;height:7px;border-radius:50%;background:#e1e1e7}.browser-chrome i:first-child{background:#ff8b86}.browser-chrome i:nth-child(2){background:#ffd36b}.browser-chrome i:nth-child(3){background:#71d19a}.browser-chrome span{justify-self:center;width:min(390px,80%);padding:4px 10px;text-align:center;color:#9a9ca6;background:#f0f0f4;border-radius:5px;font-size:8px}.browser-chrome>svg{justify-self:end;color:#a7a9b3}.browser-frame iframe{display:block;width:100%;height:calc(100% - 31px);border:0;background:#fff}.preview-busy{position:absolute;inset:31px 0 0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;color:var(--primary);background:rgba(248,248,252,.72);backdrop-filter:blur(4px)}.preview-busy strong{color:var(--text);font-size:13px}.preview-busy span{color:var(--muted);font-size:10px}.preview-footer{height:29px;padding:0 22px;display:flex;align-items:center;justify-content:space-between;color:var(--muted);background:#fff;border-top:1px solid var(--divider);font-size:8px}.preview-footer span{display:flex;align-items:center;gap:5px}
.workspace{height:100vh;height:100dvh;max-height:100dvh;min-height:0;display:grid;grid-template-columns:64px 372px 1fr;overflow:hidden;background:#eff0f5}.rail{min-height:0;display:flex;flex-direction:column;align-items:center;padding:15px 0;background:#fff;border-right:1px solid var(--divider);z-index:3}.rail .logo-mark{width:35px;height:35px}.rail-nav{display:flex;flex-direction:column;gap:9px;margin-top:42px}.rail button{display:grid;place-items:center;width:38px;height:38px;color:var(--muted);background:transparent;border:0;border-radius:11px}.rail button:hover,.rail button.active{color:var(--primary);background:var(--soft)}.rail-create{margin-top:auto!important;color:white!important;background:var(--gradient)!important;box-shadow:0 8px 20px rgba(112,40,255,.2)}
.control-panel{height:100%;min-height:0;min-width:0;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-right:1px solid var(--border);z-index:2}.site-switcher{position:relative;z-index:20;flex:0 0 66px;height:66px;padding:8px 12px;border-bottom:1px solid var(--divider)}.site-switcher-trigger{width:100%;height:49px;display:flex;align-items:center;gap:10px;padding:5px 7px;color:var(--text);text-align:left;background:#fff;border:1px solid transparent;border-radius:12px;transition:.18s ease}.site-switcher-trigger:hover,.site-switcher.open .site-switcher-trigger{background:#faf9ff;border-color:#ded6ff;box-shadow:0 5px 18px rgba(36,16,95,.06)}.site-avatar,.site-menu-avatar{display:grid;place-items:center;flex:0 0 auto;color:white;background:var(--gradient);font-weight:800}.site-avatar{width:35px;height:35px;border-radius:10px;font-size:12px;box-shadow:0 7px 16px rgba(112,40,255,.18)}.site-switcher-copy,.site-menu-copy{min-width:0;display:flex;flex-direction:column}.site-switcher-copy{flex:1;gap:3px}.site-switcher-copy strong{overflow:hidden;font-size:12px;white-space:nowrap;text-overflow:ellipsis}.site-switcher-copy small{overflow:hidden;color:var(--muted);font-size:8px;white-space:nowrap;text-overflow:ellipsis}.site-switcher-chevron{display:grid;place-items:center;width:26px;height:26px;color:var(--muted);background:#f4f4f7;border-radius:8px;transition:.2s ease}.site-switcher.open .site-switcher-chevron{color:var(--primary);background:var(--soft);transform:rotate(180deg)}.site-switcher-menu{position:absolute;top:61px;left:12px;right:12px;padding:8px;background:rgba(255,255,255,.98);border:1px solid #dfd9ef;border-radius:14px;box-shadow:0 20px 52px rgba(36,16,95,.18),0 4px 12px rgba(36,16,95,.07);backdrop-filter:blur(18px);animation:site-menu-in .16s ease-out}@keyframes site-menu-in{from{opacity:0;transform:translateY(-5px) scale(.985)}to{opacity:1;transform:none}}.site-menu-heading{height:28px;display:flex;align-items:center;justify-content:space-between;padding:0 7px;color:var(--muted);font-size:8px;font-weight:750;letter-spacing:.04em}.site-menu-heading em{display:grid;place-items:center;width:19px;height:19px;color:var(--primary);background:var(--soft);border-radius:6px;font-size:8px;font-style:normal}.site-menu-list{display:flex;flex-direction:column;gap:3px}.site-menu-list>button{width:100%;min-width:0;height:52px;display:flex;align-items:center;gap:9px;padding:6px 8px;color:var(--text);text-align:left;background:transparent;border:0;border-radius:10px}.site-menu-list>button:hover{background:#f7f5ff}.site-menu-list>button.selected{background:#f4f0ff}.site-menu-avatar{width:32px;height:32px;border-radius:9px;font-size:11px}.site-menu-copy{flex:1;gap:4px}.site-menu-copy strong{overflow:hidden;font-size:10px;white-space:nowrap;text-overflow:ellipsis}.site-menu-copy small{overflow:hidden;color:var(--muted);font-size:8px;white-space:nowrap;text-overflow:ellipsis}.site-menu-status{width:7px;height:7px;border-radius:50%;background:#d0d1d8;box-shadow:0 0 0 3px rgba(208,209,216,.15)}.site-menu-status.ready{background:#7957d5;box-shadow:0 0 0 3px rgba(121,87,213,.1)}.site-menu-status.published{background:#20b486;box-shadow:0 0 0 3px rgba(32,180,134,.1)}.site-menu-status.failed{background:#d92d20;box-shadow:0 0 0 3px rgba(217,45,32,.1)}.site-menu-list>button>svg{flex:0 0 auto;color:var(--primary)}.site-switcher-menu>p{height:27px;display:flex;align-items:center;gap:5px;margin:6px 3px 0;padding:6px 5px 0;color:var(--muted);border-top:1px solid var(--divider);font-size:7px}.panel-tabs{flex:0 0 49px;height:49px;padding:0 14px;display:grid;grid-template-columns:1fr 1fr;gap:5px;align-items:center;border-bottom:1px solid var(--divider)}.panel-tabs button{height:35px;display:flex;align-items:center;justify-content:center;gap:7px;color:var(--muted);background:transparent;border:0;border-radius:9px;font-size:11px;font-weight:700}.panel-tabs button.active{color:var(--primary);background:var(--soft)}
.chat-scroll{flex:1 1 0;min-height:0;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;padding:20px 17px 28px}.chat-date{text-align:center;color:#aaaeba;font-size:9px;margin-bottom:22px}.message{display:flex;gap:9px;margin-bottom:17px}.message.user{justify-content:flex-end}.message>div{max-width:86%}.message p{margin:0;padding:12px 14px;color:var(--text-2);background:#f6f6f9;border-radius:4px 13px 13px 13px;font-size:12px;line-height:1.65}.message.user p{color:white;background:var(--gradient);border-radius:13px 4px 13px 13px;box-shadow:0 8px 18px rgba(112,40,255,.15)}.message small{display:flex;align-items:center;gap:4px;margin:6px 2px 0;color:#9a9da8;font-size:9px}.agent-avatar{flex:0 0 auto;display:grid;place-items:center;width:28px;height:28px;color:var(--primary);background:var(--soft);border:1px solid #e2dbff;border-radius:9px}.agent-progress{display:flex;gap:9px;margin:8px 0 18px}.agent-progress>div{flex:1;padding:12px 13px;background:#faf9ff;border:1px solid #ebe6ff;border-radius:4px 13px 13px 13px}.agent-progress p{display:flex;align-items:center;gap:7px;margin:0 0 10px;color:var(--text-2);font-size:11px;font-weight:700}.agent-progress small{color:var(--muted);font-size:9px}.progress-track{height:3px;margin-bottom:8px;overflow:hidden;background:#ebe7f5;border-radius:9px}.progress-track i{display:block;width:45%;height:100%;background:var(--gradient);border-radius:9px;animation:progress 1.5s ease-in-out infinite}@keyframes progress{50%{transform:translateX(120%)}}.suggestions{display:flex;flex-direction:column;gap:7px;margin:25px 37px 0}.suggestions>span{margin-bottom:3px;color:var(--muted);font-size:9px}.suggestions button{display:flex;align-items:center;gap:7px;padding:9px 11px;text-align:left;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:9px;font-size:10px}.suggestions button:hover{color:var(--primary);border-color:#cdbdff;background:#faf9ff}
.composer{flex:0 0 auto;margin:0 14px 14px;padding:10px;background:#fff;border:1px solid #dcd8e8;border-radius:13px;box-shadow:0 5px 22px rgba(36,16,95,.07)}.composer textarea{width:100%;padding:2px 4px;resize:none;color:var(--text);background:transparent;border:0;outline:none;font-size:11px;line-height:1.55}.composer textarea::placeholder{color:#a5a7b0}.composer>div{display:flex;align-items:center;justify-content:space-between}.composer>div>span{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:9px}.online-dot{width:6px;height:6px;border-radius:50%;background:#20b486}.composer button{display:grid;place-items:center;width:29px;height:29px;color:white;background:var(--gradient);border:0;border-radius:8px}
.history-list{flex:1 1 0;min-height:0;overflow:auto;padding:16px 16px 22px}.environment-versions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:22px}.environment-versions>button{min-width:0;padding:12px;text-align:left;background:#fafafd;border:1px solid var(--border);border-radius:12px}.environment-versions>button:hover,.environment-versions>button.active{border-color:#bca8ff;background:#faf8ff;box-shadow:0 5px 18px rgba(112,40,255,.08)}.environment-versions>button.production:hover,.environment-versions>button.production.active{border-color:#9bd8c6;background:#f4fcf9;box-shadow:0 5px 18px rgba(32,180,134,.08)}.environment-versions span{display:flex;align-items:center;gap:5px;color:var(--text-2);font-size:9px;font-weight:750}.environment-versions span>em{margin-left:auto;padding:2px 4px;color:var(--primary);background:var(--soft);border-radius:4px;font-size:7px;font-style:normal}.environment-versions .production span>em{color:#13765b;background:#e7f8f2}.environment-versions strong,.environment-versions small{display:block}.environment-versions strong{margin-top:13px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;letter-spacing:.02em}.environment-versions small{margin-top:5px;overflow:hidden;color:var(--muted);font-size:8px;white-space:nowrap;text-overflow:ellipsis}.history-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px}.history-heading h3{margin:0 0 4px;font-size:14px}.history-heading p{margin:0;color:var(--muted);font-size:9px}.history-heading>span{display:grid;place-items:center;width:25px;height:25px;color:var(--primary);background:var(--soft);border-radius:8px;font-size:10px;font-weight:800}.history-list article{display:grid;grid-template-columns:24px minmax(0,1fr) auto;gap:7px;min-height:69px}.history-list article.environment-version{margin:0 -5px;padding:0 5px;background:#fbfaff;border-radius:10px}.timeline{display:flex;align-items:center;flex-direction:column}.timeline i{display:grid;place-items:center;width:17px;height:17px;color:white;background:#d6d7df;border:3px solid white;box-shadow:0 0 0 1px #d6d7df;border-radius:50%;font-style:normal}.history-list article.environment-version .timeline i{background:var(--primary);box-shadow:0 0 0 1px var(--primary)}.timeline span{width:1px;flex:1;background:var(--divider)}.commit-info{min-width:0;padding-bottom:18px}.commit-info>div{display:flex;align-items:center;gap:5px;min-width:0}.commit-info strong{min-width:0;overflow:hidden;font-size:10px;line-height:1.4;white-space:nowrap;text-overflow:ellipsis}.commit-info em{flex:0 0 auto;padding:2px 5px;border-radius:5px;font-size:7px;font-style:normal}.commit-info .preview-badge{color:var(--primary);background:var(--soft)}.commit-info .production-badge{color:#13765b;background:#e7f8f2}.commit-info p{display:flex;align-items:center;gap:5px;margin:5px 0 0;color:var(--muted);font-size:9px}.commit-info code{font-size:9px}.history-actions{display:flex;align-items:flex-start;gap:4px}.history-actions button{display:grid;place-items:center;width:25px;height:25px;padding:0;color:var(--muted);background:white;border:1px solid var(--border);border-radius:7px}.history-actions button:hover{color:var(--primary);background:var(--soft)}
.preview-shell{min-height:0;min-width:0;overflow:hidden;display:flex;flex-direction:column}.preview-toolbar{min-height:66px;display:grid;grid-template-columns:minmax(190px,1fr) auto minmax(310px,1fr);grid-template-areas:"title modes actions";align-items:center;gap:12px;padding:0 18px;background:#fff;border-bottom:1px solid var(--border)}.preview-title{grid-area:title;display:flex;align-items:center;gap:10px;min-width:0}.preview-title>div{display:flex;align-items:center;gap:8px;min-width:0}.preview-title strong{overflow:hidden;font-size:12px;white-space:nowrap;text-overflow:ellipsis}.preview-title em{flex:0 0 auto;padding:3px 6px;color:#7957d5;background:#f2edff;border-radius:5px;font-size:8px;font-style:normal;font-weight:750}.preview-title em.production{color:#13765b;background:#e7f8f2}.status-dot{flex:0 0 auto;width:7px;height:7px;border-radius:50%;background:#7957d5;box-shadow:0 0 0 4px rgba(112,40,255,.1)}.status-dot.production{background:#20b486;box-shadow:0 0 0 4px rgba(32,180,134,.1)}.build-status{flex:0 0 auto;display:flex;align-items:center;gap:4px;padding:5px 7px;color:#188866;background:#ebfbf5;border-radius:7px;font-size:8px;font-weight:750}.build-status.failed{color:#b42318;background:#fff0ee}.build-status.unpublished{color:#986600;background:#fff8e5}.preview-modes{grid-area:modes;display:flex;align-items:center;gap:7px}.environment-toggle,.device-toggle{display:flex;padding:3px;background:#f2f2f6;border-radius:8px}.environment-toggle button{height:27px;display:flex;align-items:center;gap:5px;padding:0 9px;color:var(--muted);background:transparent;border:0;border-radius:6px;font-size:8px;font-weight:750}.environment-toggle button.active,.device-toggle button.active{color:var(--text);background:#fff;box-shadow:0 2px 7px rgba(36,16,95,.1)}.device-toggle button{display:grid;place-items:center;width:31px;height:27px;color:var(--muted);background:transparent;border:0;border-radius:6px}.toolbar-actions{grid-area:actions;display:flex;justify-content:flex-end;gap:6px;min-width:0}.toolbar-actions button,.toolbar-actions a{height:31px;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 9px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:8px;font-size:8px;font-weight:750;white-space:nowrap}.toolbar-actions button:hover:not(:disabled),.toolbar-actions a:hover{color:var(--primary);background:var(--soft)}.toolbar-actions .environment-link.production-link{color:#13765b}.toolbar-actions .environment-link:disabled{color:#a8a9b0;background:#f5f5f7}.toolbar-actions .rebuild-button{color:var(--primary);border-color:#d8ccff;background:#faf8ff}.toolbar-actions .publish-button{color:white;background:var(--gradient);border:0}.toolbar-actions .publish-button:hover:not(:disabled){color:white;background:var(--gradient);box-shadow:0 7px 17px rgba(112,40,255,.2)}.toolbar-actions .publish-button:disabled{color:#9a9da8;background:#efeff4;border:1px solid #e1e1e7;box-shadow:none}
.browser-stage{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:25px;background:#eff0f5;overflow:auto}.browser-frame{position:relative;width:100%;height:100%;overflow:hidden;background:#fff;border:1px solid #dfe0e7;border-radius:11px;box-shadow:0 15px 45px rgba(30,24,53,.1);transition:.35s ease}.browser-frame.mobile{width:390px;max-height:760px}.browser-chrome{height:31px;display:grid;grid-template-columns:70px 1fr 70px;align-items:center;padding:0 10px;background:#fafafd;border-bottom:1px solid #ebeaf0}.browser-chrome>div{display:flex;gap:5px}.browser-chrome i{width:7px;height:7px;border-radius:50%;background:#e1e1e7}.browser-chrome i:first-child{background:#ff8b86}.browser-chrome i:nth-child(2){background:#ffd36b}.browser-chrome i:nth-child(3){background:#71d19a}.browser-chrome span{justify-self:center;width:min(390px,80%);padding:4px 10px;text-align:center;color:#9a9ca6;background:#f0f0f4;border-radius:5px;font-size:8px}.browser-chrome>svg{justify-self:end;color:#a7a9b3}.browser-frame iframe{display:block;width:100%;height:calc(100% - 31px);border:0;background:#fff}.production-empty{height:calc(100% - 31px);display:flex;align-items:center;justify-content:center;flex-direction:column;padding:30px;text-align:center;background:linear-gradient(180deg,#fbfffd,#f7faf9)}.production-empty>span{display:grid;place-items:center;width:52px;height:52px;color:#168064;background:#e8f8f2;border-radius:16px}.production-empty strong{margin-top:17px;font-size:15px}.production-empty p{max-width:360px;margin:8px 0 20px;color:var(--muted);font-size:10px;line-height:1.7}.production-empty button{height:36px;display:flex;align-items:center;gap:7px;padding:0 14px;color:white;background:linear-gradient(135deg,#15936e,#20b486);border:0;border-radius:9px;font-size:10px;font-weight:800}.preview-busy{position:absolute;inset:31px 0 0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;color:var(--primary);background:rgba(248,248,252,.72);backdrop-filter:blur(4px)}.preview-busy strong{color:var(--text);font-size:13px}.preview-busy span{color:var(--muted);font-size:10px}.preview-footer{height:29px;padding:0 22px;display:flex;align-items:center;justify-content:space-between;color:var(--muted);background:#fff;border-top:1px solid var(--divider);font-size:8px}.preview-footer span{display:flex;align-items:center;gap:5px}
@media(max-width:1100px){.create-layout{grid-template-columns:1fr;gap:48px;padding-top:40px}.create-intro{text-align:center}.create-intro>p,.create-intro h1{margin-left:auto;margin-right:auto}.intro-tag{margin:auto}.feature-row{max-width:620px;margin-left:auto;margin-right:auto}.create-card-wrap{justify-content:center}.workspace{grid-template-columns:58px 340px 1fr}}
@media(max-width:1250px) and (min-width:801px){.preview-toolbar{height:96px;grid-template-columns:minmax(160px,1fr) auto;grid-template-rows:42px 42px;grid-template-areas:"title actions" "modes modes";gap:0 10px;padding:6px 12px}.preview-modes{justify-content:center}.toolbar-actions .environment-link{width:31px;padding:0}.toolbar-actions .environment-link>span{display:none}}
@media(max-width:800px){.landing-header,.create-layout{width:min(100% - 30px,700px)}.landing-status{display:none}.feature-row{grid-template-columns:1fr}.create-card{padding:26px 20px}.form-grid{grid-template-columns:1fr}.field.wide{grid-column:auto}.workspace{grid-template-columns:52px 1fr}.control-panel{border-right:0}.preview-shell{display:none}.create-intro h1{font-size:48px}.create-layout{padding-bottom:35px}}
......@@ -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