Commit f86dd1a6 authored by xuchentao's avatar xuchentao

feat: add authenticated workspace drafts

parent 10c7988e
PORT=3100 PORT=3100
HOST=127.0.0.1 HOST=127.0.0.1
FRONTEND_ORIGIN=http://localhost FRONTEND_ORIGIN=http://localhost
# 单用户登录账号固定为 user;生产环境请务必修改默认密码。
WEBAGENT_USER_PASSWORD=user
# 相对路径以 WebAgent 项目根目录为基准,也支持 /data/WebAgent-sites 等绝对路径。 # 相对路径以 WebAgent 项目根目录为基准,也支持 /data/WebAgent-sites 等绝对路径。
WEBAGENT_SITES_DIR=../WebAgent-sites WEBAGENT_SITES_DIR=../WebAgent-sites
# 已上线的静态产物独立保存,不会被测试环境构建覆盖。 # 已上线的静态产物独立保存,不会被测试环境构建覆盖。
......
...@@ -20,7 +20,7 @@ export class BuildManager { ...@@ -20,7 +20,7 @@ export class BuildManager {
output += install.stdout + "\n" + install.stderr + "\n"; output += install.stdout + "\n" + install.stderr + "\n";
const build = await execa("pnpm", ["run", "build"], { const build = await execa("pnpm", ["run", "build"], {
cwd: projectPath, cwd: projectPath,
env: { ...process.env, SITE_BASE_PATH: basePath.replace(/\/$/, "") || "/" }, env: { ...process.env, CI: "true", SITE_BASE_PATH: basePath.replace(/\/$/, "") || "/" },
}); });
output += build.stdout + "\n" + build.stderr; output += build.stdout + "\n" + build.stderr;
await writeFile(path.join(runtimePaths.logs, taskId + ".log"), output, "utf8"); await writeFile(path.join(runtimePaths.logs, taskId + ".log"), output, "utf8");
......
...@@ -22,6 +22,10 @@ export const config = { ...@@ -22,6 +22,10 @@ export const config = {
host: process.env.HOST || "127.0.0.1", host: process.env.HOST || "127.0.0.1",
port: Number(process.env.PORT || 3100), port: Number(process.env.PORT || 3100),
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173", frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
auth: {
username: "user",
password: process.env.WEBAGENT_USER_PASSWORD || "user",
},
openai: { openai: {
apiKey: process.env.OPENAI_API_KEY || "", apiKey: process.env.OPENAI_API_KEY || "",
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.deepseek.com/v1").replace(/\/$/, ""), baseUrl: (process.env.OPENAI_BASE_URL || "https://api.deepseek.com/v1").replace(/\/$/, ""),
......
...@@ -45,6 +45,23 @@ export class GitManager { ...@@ -45,6 +45,23 @@ export class GitManager {
await execa("git", ["worktree", "add", "--detach", workspacePath, reference], { cwd: projectPath }); await execa("git", ["worktree", "add", "--detach", workspacePath, reference], { cwd: projectPath });
} }
async createPersistentWorktree(projectPath: string, workspacePath: string, reference = "HEAD"): Promise<void> {
await this.removePersistentWorktree(projectPath, workspacePath);
await mkdir(path.dirname(workspacePath), { recursive: true });
await execa("git", ["worktree", "add", "--detach", workspacePath, reference], { cwd: projectPath });
}
async removePersistentWorktree(projectPath: string, workspacePath: string): Promise<void> {
await execa("git", ["worktree", "remove", "--force", workspacePath], { cwd: projectPath, reject: false });
await execa("git", ["worktree", "prune"], { cwd: projectPath, reject: false });
await rm(workspacePath, { recursive: true, force: true });
}
async hasChanges(workspacePath: string): Promise<boolean> {
const result = await execa("git", ["status", "--porcelain"], { cwd: workspacePath });
return result.stdout.trim().length > 0;
}
async removeWorktree(projectPath: string, workspacePath: string): Promise<void> { async removeWorktree(projectPath: string, workspacePath: string): Promise<void> {
await execa("git", ["worktree", "remove", "--force", workspacePath], { cwd: projectPath, reject: false }); await execa("git", ["worktree", "remove", "--force", workspacePath], { cwd: projectPath, reject: false });
await execa("git", ["worktree", "prune"], { cwd: projectPath, reject: false }); await execa("git", ["worktree", "prune"], { cwd: projectPath, reject: false });
...@@ -69,6 +86,14 @@ export class GitManager { ...@@ -69,6 +86,14 @@ export class GitManager {
return this.currentCommit(projectPath); return this.currentCommit(projectPath);
} }
async fastForward(projectPath: string, commit: string, expectedBase: string): Promise<string> {
const current = await this.currentCommit(projectPath);
if (current === commit) return current;
if (current !== expectedBase) throw new Error("最近保存版本已变化,请刷新后重新预览草稿");
await execa("git", ["merge", "--ff-only", commit], { cwd: projectPath });
return this.currentCommit(projectPath);
}
async restoreAsCommit(projectPath: string, sourceCommit: string): Promise<string> { async restoreAsCommit(projectPath: string, sourceCommit: string): Promise<string> {
await this.assertCommit(projectPath, sourceCommit); await this.assertCommit(projectPath, sourceCommit);
await execa("git", ["restore", "--source", sourceCommit, "--staged", "--worktree", "--", ".", ":(exclude)astro.config.mjs"], { cwd: projectPath }); await execa("git", ["restore", "--source", sourceCommit, "--staged", "--worktree", "--", ".", ":(exclude)astro.config.mjs"], { cwd: projectPath });
......
...@@ -10,6 +10,11 @@ export const createSiteSchema = z.object({ ...@@ -10,6 +10,11 @@ export const createSiteSchema = z.object({
brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "品牌色必须是 6 位十六进制颜色"), brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "品牌色必须是 6 位十六进制颜色"),
}); });
export const loginSchema = z.object({
username: z.string().trim().min(1),
password: z.string().min(1),
});
export const chatSchema = z.object({ export const chatSchema = z.object({
message: z.string().trim().min(2).max(2000), message: z.string().trim().min(2).max(2000),
}); });
......
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto";
import { access, mkdir, rm } from "node:fs/promises"; import { access, mkdir, rm } from "node:fs/promises";
import Fastify from "fastify"; import Fastify from "fastify";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
...@@ -8,7 +9,7 @@ import { BuildManager } from "./build/build-manager.js"; ...@@ -8,7 +9,7 @@ import { BuildManager } from "./build/build-manager.js";
import { config, runtimePaths } from "./config.js"; import { config, runtimePaths } from "./config.js";
import { GitManager } from "./git/git-manager.js"; import { GitManager } from "./git/git-manager.js";
import { PreviewProcessManager } from "./preview/preview-process-manager.js"; import { PreviewProcessManager } from "./preview/preview-process-manager.js";
import { chatSchema, createSiteSchema, versionCommitSchema } from "./schemas.js"; import { chatSchema, createSiteSchema, loginSchema, versionCommitSchema } from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js"; import { CreateSiteService } from "./sites/create-site.js";
import { SiteAgentService } from "./sites/site-agent-service.js"; import { SiteAgentService } from "./sites/site-agent-service.js";
import { SiteRepository } from "./sites/site-repository.js"; import { SiteRepository } from "./sites/site-repository.js";
...@@ -22,10 +23,33 @@ const git = new GitManager(); ...@@ -22,10 +23,33 @@ const git = new GitManager();
const builds = new BuildManager(); const builds = new BuildManager();
const previews = new PreviewProcessManager(); const previews = new PreviewProcessManager();
const createSite = new CreateSiteService(sites, git, builds, previews); const createSite = new CreateSiteService(sites, git, builds, previews);
const siteAgent = new SiteAgentService(sites, git, builds, previews, new AgentLoop()); const siteAgent = new SiteAgentService(sites, git, new AgentLoop());
const versions = new SiteVersionService(sites, git, builds, previews); const versions = new SiteVersionService(sites, git, builds, previews);
const sessions = new Set<string>();
app.addHook("onRequest", async (request, reply) => {
if (!request.url.startsWith("/api/") || request.url === "/api/health" || request.url === "/api/login") return;
const token = request.headers.authorization?.replace(/^Bearer\s+/i, "");
if (!token || !sessions.has(token)) return reply.code(401).send({ error: "登录已失效,请重新登录" });
});
app.get("/api/health", async () => ({ status: "ok", service: "WebAgent", agentMode: config.openai.apiKey ? "model" : "local", timestamp: new Date().toISOString() })); app.get("/api/health", async () => ({ status: "ok", service: "WebAgent", agentMode: config.openai.apiKey ? "model" : "local", timestamp: new Date().toISOString() }));
app.post("/api/login", async (request, reply) => {
const body = loginSchema.parse(request.body);
const digest = (value: string) => crypto.createHash("sha256").update(value).digest();
const validUsername = crypto.timingSafeEqual(digest(body.username), digest(config.auth.username));
const validPassword = crypto.timingSafeEqual(digest(body.password), digest(config.auth.password));
if (!validUsername || !validPassword) return reply.code(401).send({ error: "账号或密码错误" });
const token = crypto.randomBytes(32).toString("hex");
sessions.add(token);
return { token, username: config.auth.username };
});
app.post("/api/logout", async (request) => {
const token = request.headers.authorization?.replace(/^Bearer\s+/i, "");
if (token) sessions.delete(token);
return { success: true };
});
app.get("/api/session", async () => ({ username: config.auth.username }));
app.get("/api/sites", async () => sites.list()); app.get("/api/sites", async () => sites.list());
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId", async (request) => sites.get(request.params.siteId)); app.get<{ Params: { siteId: string } }>("/api/sites/:siteId", async (request) => sites.get(request.params.siteId));
app.post("/api/sites", async (request, reply) => reply.code(201).send(await createSite.execute(createSiteSchema.parse(request.body)))); app.post("/api/sites", async (request, reply) => reply.code(201).send(await createSite.execute(createSiteSchema.parse(request.body))));
...@@ -36,6 +60,7 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/build", async (requ ...@@ -36,6 +60,7 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/build", async (requ
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", async (request) => { app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", async (request) => {
const body = versionCommitSchema.parse(request.body); return versions.previewVersion(request.params.siteId, body.commit); const body = versionCommitSchema.parse(request.body); return versions.previewVersion(request.params.siteId, body.commit);
}); });
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-draft", async (request) => versions.previewDraft(request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => versions.publish(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.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/undo", async (request) => versions.undo(request.params.siteId)); app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/undo", async (request) => versions.undo(request.params.siteId));
......
import crypto from "node:crypto";
import path from "node:path";
import type { ChatResult, SitePatch } from "@webagent/shared"; import type { ChatResult, SitePatch } from "@webagent/shared";
import { getPublicPreviewUrl, runtimePaths } from "../config.js";
import { AgentLoop } from "../agent/agent-loop.js"; import { AgentLoop } from "../agent/agent-loop.js";
import { BuildError, BuildManager } from "../build/build-manager.js";
import { GitManager } from "../git/git-manager.js"; import { GitManager } from "../git/git-manager.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { validatePatch } from "../security/path-policy.js"; import { validatePatch } from "../security/path-policy.js";
import { applyPatch } from "./apply-patch.js"; import { applyPatch } from "./apply-patch.js";
import { SiteRepository } from "./site-repository.js"; import { SiteRepository } from "./site-repository.js";
...@@ -13,56 +8,39 @@ import { SiteRepository } from "./site-repository.js"; ...@@ -13,56 +8,39 @@ import { SiteRepository } from "./site-repository.js";
export class SiteAgentService { export class SiteAgentService {
constructor( constructor(
private readonly sites: SiteRepository, private readonly git: GitManager, private readonly sites: SiteRepository, private readonly git: GitManager,
private readonly builds: BuildManager, private readonly previews: PreviewProcessManager,
private readonly agent: AgentLoop, private readonly agent: AgentLoop,
) {} ) {}
async execute(siteId: string, message: string): Promise<ChatResult> { async execute(siteId: string, message: string): Promise<ChatResult> {
const site = await this.sites.get(siteId); const site = await this.sites.get(siteId);
if (site.previewCommit && site.previewCommit !== site.currentCommit) {
throw new Error("当前测试环境正在预览历史版本,请先切换到最近保存版本再继续修改");
}
const projectPath = this.sites.getProjectPath(siteId); const projectPath = this.sites.getProjectPath(siteId);
const taskId = "task_" + crypto.randomBytes(4).toString("hex"); const workspace = this.sites.getDraftPath(siteId);
const workspace = path.join(runtimePaths.builds, taskId, "workspace"); const baseCommit = site.draftBaseCommit || site.currentCommit;
const creatingDraft = !site.draftBaseCommit;
await this.sites.update(siteId, { status: "building", lastError: undefined }); await this.sites.update(siteId, { status: "building", lastError: undefined });
let generated: { patch: SitePatch; mode: "model" | "local" } | undefined; let generated: { patch: SitePatch; mode: "model" | "local" } | undefined;
try { try {
await this.git.createWorktree(projectPath, workspace); if (creatingDraft) await this.git.createPersistentWorktree(projectPath, workspace, baseCommit);
generated = await this.agent.generate(workspace, message); generated = await this.agent.generate(workspace, message);
await validatePatch(generated.patch, workspace); await validatePatch(generated.patch, workspace);
await applyPatch(workspace, generated.patch); await applyPatch(workspace, generated.patch);
let dist: string;
try {
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);
await validatePatch(repair, workspace);
await applyPatch(workspace, repair);
const mergedOperations = new Map(
[...generated.patch.operations, ...repair.operations].map((operation) => [operation.path, operation]),
);
if (mergedOperations.size > 5) throw new Error("自动修复后的修改文件总数超过 5 个,任务已安全取消");
generated.patch = { summary: repair.summary, operations: [...mergedOperations.values()] };
dist = await this.builds.build(workspace, taskId + "_repair", getPublicPreviewUrl(siteId));
}
const changedFiles = generated.patch.operations.map((item) => item.path); const changedFiles = generated.patch.operations.map((item) => item.path);
const worktreeCommit = await this.git.commitWorktree(workspace, "Agent: " + generated.patch.summary, changedFiles); const updatedAt = new Date().toISOString();
const commit = await this.git.cherryPick(projectPath, worktreeCommit);
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { await this.sites.update(siteId, {
status: "ready", currentCommit: commit, previewCommit: commit, status: "ready", draftBaseCommit: baseCommit, draftUpdatedAt: updatedAt,
previousCommit: site.currentCommit, environmentVersion: 3, lastError: undefined, draftSummary: generated.patch.summary, lastError: undefined,
}); });
return { summary: generated.patch.summary, commit, previewUrl: site.previewUrl, changedFiles, mode: generated.mode }; return { summary: generated.patch.summary, draft: true, baseCommit, updatedAt, changedFiles, mode: generated.mode };
} catch (error) { } catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error); const details = error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details }); const keepDraft = !creatingDraft || await this.git.hasChanges(workspace).catch(() => false);
if (!keepDraft) await this.git.removePersistentWorktree(projectPath, workspace);
await this.sites.update(siteId, {
status: site.previewCommit ? "ready" : "failed", lastError: details,
draftBaseCommit: keepDraft ? baseCommit : undefined,
draftUpdatedAt: keepDraft ? new Date().toISOString() : undefined,
});
throw error; throw error;
} finally {
await this.git.removeWorktree(projectPath, workspace);
} }
} }
} }
...@@ -20,6 +20,10 @@ export class SiteRepository { ...@@ -20,6 +20,10 @@ export class SiteRepository {
return path.join(this.getSiteRoot(siteId), "project"); return path.join(this.getSiteRoot(siteId), "project");
} }
getDraftPath(siteId: string): string {
return path.join(this.getSiteRoot(siteId), "draft");
}
getMetadataPath(siteId: string): string { getMetadataPath(siteId: string): string {
return path.join(this.getSiteRoot(siteId), "metadata", "site.json"); return path.join(this.getSiteRoot(siteId), "metadata", "site.json");
} }
......
...@@ -35,8 +35,40 @@ export class SiteVersionService { ...@@ -35,8 +35,40 @@ export class SiteVersionService {
return { commit: targetCommit, previewUrl: site.previewUrl }; return { commit: targetCommit, previewUrl: site.previewUrl };
} }
async previewDraft(siteId: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(siteId);
if (!site.draftBaseCommit) throw new Error("当前没有可预览的工作草稿");
const project = this.sites.getProjectPath(siteId);
const workspace = this.sites.getDraftPath(siteId);
const taskId = "draft_" + crypto.randomBytes(4).toString("hex");
const draftHead = await this.git.currentCommit(workspace);
if (draftHead === site.draftBaseCommit && !await this.git.hasChanges(workspace)) throw new Error("工作草稿没有实际修改");
await this.sites.update(siteId, { status: "building", lastError: undefined });
try {
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(siteId));
const draftCommit = await this.git.commit(workspace, "Agent: " + (site.draftSummary || "保存工作草稿"));
const commit = await this.git.fastForward(project, draftCommit, site.draftBaseCommit);
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, previewCommit: commit,
previousCommit: site.currentCommit, draftBaseCommit: undefined,
draftUpdatedAt: undefined, draftSummary: undefined,
environmentVersion: 3, lastError: undefined,
});
await this.git.removePersistentWorktree(project, workspace);
return { commit, previewUrl: site.previewUrl };
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details });
throw error;
}
}
async undo(siteId: string): Promise<PreviewVersionResult> { async undo(siteId: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(siteId); const site = await this.sites.get(siteId);
if (site.draftBaseCommit) throw new Error("工作草稿尚未预览,请先预览草稿或继续编辑");
const project = this.sites.getProjectPath(siteId); const project = this.sites.getProjectPath(siteId);
if (site.previewCommit && site.previewCommit !== site.currentCommit) { if (site.previewCommit && site.previewCommit !== site.currentCommit) {
throw new Error("当前正在查看历史版本,请先切换回最近保存版本再撤销修改"); throw new Error("当前正在查看历史版本,请先切换回最近保存版本再撤销修改");
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
WebAgent-sites/ WebAgent-sites/
└── site_xxxxxx/ └── site_xxxxxx/
├── project/ # 独立 Astro 项目,也是独立 Git 仓库 ├── project/ # 独立 Astro 项目,也是独立 Git 仓库
├── draft/ # 持久化工作草稿 Git Worktree,不进入正式版本历史
└── metadata/ └── metadata/
└── site.json # WebAgent 管理信息,不交给 Agent 修改 └── site.json # WebAgent 管理信息,不交给 Agent 修改
...@@ -27,9 +28,9 @@ WebAgent/.runtime/ ...@@ -27,9 +28,9 @@ WebAgent/.runtime/
## 不变量 ## 不变量
1. Agent 只可读取和修改当前站点 `project/` 内的白名单文件。 1. Agent 只可读取和修改当前站点草稿 Worktree 内的白名单文件。
2. Agent 永远不直接修改正式项目,先在 `builds/`验证。 2. Agent 永远不直接修改正式项目;修改自动保存到 `draft/`,用户选择“预览草稿”后再构建验证。
3. 只有构建成功的版本才允许进入站点 Git 历史并成为预览版本 3. 只有草稿构建成功才允许提交到站点 Git 历史并成为预览版本;失败草稿继续保留
4. 测试预览更新不能覆盖生产产物;生产环境只能通过显式发布操作更新。 4. 测试预览更新不能覆盖生产产物;生产环境只能通过显式发布操作更新。
5. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。 5. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。
6. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。 6. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
......
...@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type FormEvent } from "react"; ...@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowLeft, ArrowRight, Bot, Check, ChevronDown, Clock3, Code2, ExternalLink, ArrowLeft, ArrowRight, Bot, Check, ChevronDown, Clock3, Code2, ExternalLink,
Globe2, History, Laptop, LoaderCircle, MessageSquareText, Monitor, Palette, Globe2, History, Laptop, LoaderCircle, MessageSquareText, Monitor,
Plus, RefreshCw, Rocket, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Undo2, Plus, RefreshCw, Rocket, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Undo2,
WandSparkles, X, WandSparkles, X,
} from "lucide-react"; } from "lucide-react";
...@@ -13,7 +13,9 @@ type ChatMessage = { role: "user" | "agent"; text: string; meta?: string }; ...@@ -13,7 +13,9 @@ type ChatMessage = { role: "user" | "agent"; text: string; meta?: string };
export default function App() { export default function App() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sitesQuery = useQuery({ queryKey: ["sites"], queryFn: api.sites }); const [authenticated, setAuthenticated] = useState(() => Boolean(sessionStorage.getItem("webagent-session")));
const sitesQuery = useQuery({ queryKey: ["sites"], queryFn: api.sites, enabled: authenticated });
const sessionQuery = useQuery({ queryKey: ["session"], queryFn: api.session, enabled: authenticated, retry: false });
const healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health }); const healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health });
const [currentSiteId, setCurrentSiteId] = useState(() => localStorage.getItem("webagent-current-site") || ""); const [currentSiteId, setCurrentSiteId] = useState(() => localStorage.getItem("webagent-current-site") || "");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
...@@ -24,15 +26,28 @@ export default function App() { ...@@ -24,15 +26,28 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (currentSiteId) localStorage.setItem("webagent-current-site", currentSiteId); if (currentSiteId) localStorage.setItem("webagent-current-site", currentSiteId);
}, [currentSiteId]); }, [currentSiteId]);
useEffect(() => {
if (!sessionQuery.isError) return;
sessionStorage.removeItem("webagent-session");
setAuthenticated(false);
}, [sessionQuery.isError]);
const openSite = (site: SiteInfo) => { const openSite = (site: SiteInfo) => {
setCurrentSiteId(site.siteId); setCreating(false); setCurrentSiteId(site.siteId); setCreating(false);
queryClient.invalidateQueries({ queryKey: ["sites"] }); queryClient.invalidateQueries({ queryKey: ["sites"] });
}; };
const logout = async () => {
await api.logout().catch(() => undefined);
sessionStorage.removeItem("webagent-session");
setAuthenticated(false); setCreating(false);
queryClient.clear();
};
if (!authenticated) return <LoginPage onAuthenticated={() => setAuthenticated(true)} />;
if (sitesQuery.isLoading) return <Splash />; if (sitesQuery.isLoading) return <Splash />;
if (creating || !currentSiteId || !sitesQuery.data?.length) { if (creating || !sitesQuery.data?.length) {
return <CreateSitePage onCreated={openSite} onBack={sitesQuery.data?.length ? () => setCreating(false) : undefined} />; return <CreateSiteWorkspace sites={sitesQuery.data || []} currentSiteId={currentSiteId} onCreated={openSite} onCancel={sitesQuery.data?.length ? () => setCreating(false) : undefined} onSelectSite={(id) => { setCurrentSiteId(id); setCreating(false); }} onLogout={logout} />;
} }
return <Workspace return <Workspace
siteId={currentSiteId} siteId={currentSiteId}
...@@ -40,6 +55,7 @@ export default function App() { ...@@ -40,6 +55,7 @@ export default function App() {
agentMode={healthQuery.data?.agentMode || "local"} agentMode={healthQuery.data?.agentMode || "local"}
onSelectSite={setCurrentSiteId} onSelectSite={setCurrentSiteId}
onCreate={() => setCreating(true)} onCreate={() => setCreating(true)}
onLogout={logout}
/>; />;
} }
...@@ -51,7 +67,46 @@ function Logo({ compact = false }: { compact?: boolean }) { ...@@ -51,7 +67,46 @@ function Logo({ compact = false }: { compact?: boolean }) {
return <div className="logo"><span className="logo-mark"><WandSparkles size={compact ? 15 : 18} /></span>{!compact && <span>WebAgent</span>}</div>; return <div className="logo"><span className="logo-mark"><WandSparkles size={compact ? 15 : 18} /></span>{!compact && <span>WebAgent</span>}</div>;
} }
function CreateSitePage({ onCreated, onBack }: { onCreated: (site: SiteInfo) => void; onBack?: () => void }) { function LoginPage({ onAuthenticated }: { onAuthenticated: () => void }) {
const [username, setUsername] = useState("user");
const [password, setPassword] = useState("");
const mutation = useMutation({
mutationFn: () => api.login(username, password),
onSuccess: (result) => { sessionStorage.setItem("webagent-session", result.token); onAuthenticated(); },
});
return <main className="create-page login-page">
<div className="create-orb orb-a" /><div className="create-orb orb-b" />
<header className="landing-header"><Logo /><div className="landing-status"><span /><span>单用户工作台</span></div></header>
<div className="create-layout login-layout">
<section className="create-intro">
<div className="intro-tag"><Sparkles size={14} /> AI WEBSITE WORKSPACE</div>
<h1>一句话描述,<br />让官网<span>自然生长。</span></h1>
<p>登录你的官网工作台,在一个空间内完成创建、编辑、版本预览与上线。</p>
<div className="feature-row">
<div><span><Bot size={18} /></span><strong>Agent 自由创作</strong><small>从文案到布局,持续迭代</small></div>
<div><span><ShieldCheck size={18} /></span><strong>草稿自动保存</strong><small>未完成修改随时继续</small></div>
<div><span><History size={18} /></span><strong>版本随时回看</strong><small>测试与线上相互隔离</small></div>
</div>
</section>
<section className="create-card-wrap login-card-wrap">
<form className="create-card login-card" onSubmit={(event) => { event.preventDefault(); mutation.mutate(); }}>
<div className="login-card-icon"><ShieldCheck size={20} /></div>
<div className="card-heading"><div><span>WELCOME BACK</span><h2>登录工作台</h2></div></div>
<p className="login-copy">使用管理员账号继续管理你的官网。</p>
<div className="login-fields">
<Field label="账号"><input required autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} /></Field>
<Field label="密码"><input required type="password" autoComplete="current-password" placeholder="请输入密码" value={password} onChange={(event) => setPassword(event.target.value)} /></Field>
</div>
{mutation.error && <div className="form-error"><X size={15} />{mutation.error.message}</div>}
<button className="create-submit login-submit" disabled={mutation.isPending}>{mutation.isPending ? <><LoaderCircle className="spin" size={18} /> 正在登录…</> : <>进入工作台 <ArrowRight size={18} /></>}</button>
<p className="local-note"><ShieldCheck size={13} /> 本地单用户安全登录</p>
</form>
</section>
</div>
</main>;
}
function CreateSiteForm({ onCreated, onCancel }: { onCreated: (site: SiteInfo) => void; onCancel?: () => void }) {
const [form, setForm] = useState<CreateSiteInput>({ const [form, setForm] = useState<CreateSiteInput>({
name: "云启科技", industry: "企业数字化服务", description: "专注于为成长型企业提供智能化、可持续的数字解决方案,让技术真正服务于业务增长。", name: "云启科技", industry: "企业数字化服务", description: "专注于为成长型企业提供智能化、可持续的数字解决方案,让技术真正服务于业务增长。",
services: ["数字化咨询", "智能产品开发", "品牌体验设计"], email: "hello@yunqi.example", phone: "400-800-2026", brandColor: "#7028FF", services: ["数字化咨询", "智能产品开发", "品牌体验设计"], email: "hello@yunqi.example", phone: "400-800-2026", brandColor: "#7028FF",
...@@ -63,24 +118,9 @@ function CreateSitePage({ onCreated, onBack }: { onCreated: (site: SiteInfo) => ...@@ -63,24 +118,9 @@ function CreateSitePage({ onCreated, onBack }: { onCreated: (site: SiteInfo) =>
mutation.mutate({ ...form, services: serviceText.split(/[、,,\n]/).map((item) => item.trim()).filter(Boolean) }); mutation.mutate({ ...form, services: serviceText.split(/[、,,\n]/).map((item) => item.trim()).filter(Boolean) });
}; };
return <main className="create-page"> return <section className="workspace-create-content">
<div className="create-orb orb-a" /><div className="create-orb orb-b" /> <form className="create-card workspace-create-card" onSubmit={submit}>
<header className="landing-header"><Logo /><div className="landing-status"><span /><span>本地安全运行</span></div></header> <div className="card-heading"><div><span>创建新网站</span><h2>告诉我你的企业信息</h2><p>填写基础信息,Agent 将生成首个可编辑版本。</p></div>{onCancel && <button className="workspace-create-cancel" type="button" onClick={onCancel}><X size={15} />取消</button>}</div>
<div className="create-layout">
<section className="create-intro">
{onBack && <button className="back-button" onClick={onBack}><ArrowLeft size={15} /> 返回工作台</button>}
<div className="intro-tag"><Sparkles size={14} /> AI WEBSITE WORKSPACE</div>
<h1>一句话描述,<br />让官网<span>自然生长。</span></h1>
<p>创建一个真实、完整、可持续维护的 Astro 项目。每次修改都经过构建验证,并由本地 Git 安全记录。</p>
<div className="feature-row">
<div><span><Bot size={18} /></span><strong>Agent 自由创作</strong><small>从文案到布局,持续迭代</small></div>
<div><span><ShieldCheck size={18} /></span><strong>构建后再生效</strong><small>失败修改不会污染项目</small></div>
<div><span><History size={18} /></span><strong>版本随时回滚</strong><small>每次有效修改都有记录</small></div>
</div>
</section>
<section className="create-card-wrap">
<form className="create-card" onSubmit={submit}>
<div className="card-heading"><div><span>创建新网站</span><h2>告诉我你的企业信息</h2></div><div className="step-pill">01 / 01</div></div>
<div className="form-grid"> <div className="form-grid">
<Field label="企业名称"><input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></Field> <Field label="企业名称"><input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></Field>
<Field label="所属行业"><input required value={form.industry} onChange={(e) => setForm({ ...form, industry: e.target.value })} /></Field> <Field label="所属行业"><input required value={form.industry} onChange={(e) => setForm({ ...form, industry: e.target.value })} /></Field>
...@@ -94,17 +134,52 @@ function CreateSitePage({ onCreated, onBack }: { onCreated: (site: SiteInfo) => ...@@ -94,17 +134,52 @@ function CreateSitePage({ onCreated, onBack }: { onCreated: (site: SiteInfo) =>
</div> </div>
{mutation.error && <div className="form-error"><X size={15} />{mutation.error.message}</div>} {mutation.error && <div className="form-error"><X size={15} />{mutation.error.message}</div>}
<button className="create-submit" disabled={mutation.isPending}>{mutation.isPending ? <><LoaderCircle className="spin" size={18} /> 正在创建、构建并启动预览…</> : <>生成我的企业官网 <ArrowRight size={18} /></>}</button> <button className="create-submit" disabled={mutation.isPending}>{mutation.isPending ? <><LoaderCircle className="spin" size={18} /> 正在创建、构建并启动预览…</> : <>生成我的企业官网 <ArrowRight size={18} /></>}</button>
<p className="local-note"><ShieldCheck size={13} /> 项目与版本记录仅保存在当前设备</p> <p className="local-note"><ShieldCheck size={13} /> 创建完成后将直接进入工作台编辑</p>
</form> </form>
</section> </section>;
</div>
</main>;
} }
function Field({ label, hint, wide, children }: { label: string; hint?: string; wide?: boolean; children: React.ReactNode }) { function Field({ label, hint, wide, children }: { label: string; hint?: string; wide?: boolean; children: React.ReactNode }) {
return <label className={wide ? "field wide" : "field"}><span>{label}{hint && <small>{hint}</small>}</span>{children}</label>; return <label className={wide ? "field wide" : "field"}><span>{label}{hint && <small>{hint}</small>}</span>{children}</label>;
} }
function RailSettings({ onLogout }: { onLogout: () => void }) {
const [open, setOpen] = useState(false);
const settingsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (event: PointerEvent) => {
if (event.target instanceof Node && !settingsRef.current?.contains(event.target)) setOpen(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, [open]);
return <div className={`rail-settings ${open ? "open" : ""}`} ref={settingsRef}>
<button className="rail-settings-trigger" type="button" title="设置" data-tooltip="设置" aria-label="设置" aria-expanded={open} onClick={() => setOpen((value) => !value)}><Settings2 size={18} /></button>
{open && <div className="rail-settings-menu">
<div><span className="settings-avatar">U</span><span><strong>user</strong><small>管理员账号</small></span></div>
<button type="button" onClick={onLogout}><ArrowLeft size={14} />退出登录</button>
</div>}
</div>;
}
function CreateSiteWorkspace({ sites, currentSiteId, onCreated, onCancel, onSelectSite, onLogout }: { sites: SiteInfo[]; currentSiteId: string; onCreated: (site: SiteInfo) => void; onCancel?: () => void; onSelectSite: (id: string) => void; onLogout: () => void }) {
const selectedSite = sites.find((site) => site.siteId === currentSiteId) || sites[0];
return <main className="workspace create-workspace">
<aside className="rail">
<Logo compact />
<button className="rail-create-top active" title="创建官网" data-tooltip="创建官网" aria-label="创建官网"><Plus size={18} /></button>
<div className="rail-nav"><button title="Agent 工作台" data-tooltip="Agent 工作台" aria-label="Agent 工作台" onClick={onCancel}><Monitor size={18} /></button></div>
<RailSettings onLogout={onLogout} />
</aside>
<section className="control-panel create-control-panel">
{selectedSite ? <SiteSwitcher site={selectedSite} sites={sites} onSelect={onSelectSite} /> : <div className="empty-site-heading"><Logo /><small>创建你的第一个官网</small></div>}
<div className="create-side-copy"><span><Sparkles size={14} /></span><h2>创建新官网</h2><p>填写企业的基础资料,系统会生成独立项目、测试环境与版本记录。</p><ul><li>独立 Astro 项目</li><li>自动创建 Git 版本</li><li>生成后直接进入工作台</li></ul></div>
</section>
<CreateSiteForm onCreated={onCreated} onCancel={onCancel} />
</main>;
}
function SiteSwitcher({ site, sites, onSelect }: { site: SiteInfo; sites: SiteInfo[]; onSelect: (id: string) => void }) { function SiteSwitcher({ site, sites, onSelect }: { site: SiteInfo; sites: SiteInfo[]; onSelect: (id: string) => void }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const switcherRef = useRef<HTMLDivElement>(null); const switcherRef = useRef<HTMLDivElement>(null);
...@@ -149,7 +224,7 @@ function SiteSwitcher({ site, sites, onSelect }: { site: SiteInfo; sites: SiteIn ...@@ -149,7 +224,7 @@ function SiteSwitcher({ site, sites, onSelect }: { site: SiteInfo; sites: SiteIn
</div>; </div>;
} }
function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteId: string; sites: SiteInfo[]; agentMode: "model" | "local"; onSelectSite: (id: string) => void; onCreate: () => void }) { function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout }: { siteId: string; sites: SiteInfo[]; agentMode: "model" | "local"; onSelectSite: (id: string) => void; onCreate: () => void; onLogout: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const siteQuery = useQuery({ queryKey: ["site", siteId], queryFn: () => api.site(siteId) }); const siteQuery = useQuery({ queryKey: ["site", siteId], queryFn: () => api.site(siteId) });
const historyQuery = useQuery({ queryKey: ["history", siteId], queryFn: () => api.history(siteId) }); const historyQuery = useQuery({ queryKey: ["history", siteId], queryFn: () => api.history(siteId) });
...@@ -160,7 +235,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -160,7 +235,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [iframeVersion, setIframeVersion] = useState(0); const [iframeVersion, setIframeVersion] = useState(0);
const [messages, setMessages] = useState<ChatMessage[]>([ const [messages, setMessages] = useState<ChatMessage[]>([
{ role: "agent", text: "网站已准备好。告诉我你想调整的内容、配色或页面结构,我会先在隔离环境中验证,再更新预览。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }, { role: "agent", text: "网站已准备好。告诉我你想调整的内容、配色或页面结构,修改会自动保存为工作草稿,由你确认后再更新预览。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" },
]); ]);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const site = siteQuery.data; const site = siteQuery.data;
...@@ -184,7 +259,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -184,7 +259,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
const chatMutation = useMutation({ const chatMutation = useMutation({
mutationFn: (message: string) => api.chat(siteId, message), mutationFn: (message: string) => api.chat(siteId, message),
onSuccess: async (result) => { onSuccess: async (result) => {
setMessages((items) => [...items, { role: "agent", text: result.summary, meta: (result.mode === "model" ? "AI 生成" : "本地规则") + " · 已构建 · " + result.commit.slice(0, 7) }]); setMessages((items) => [...items, { role: "agent", text: result.summary, meta: (result.mode === "model" ? "AI 生成" : "本地规则") + " · 草稿已自动保存" }]);
await refreshData(); await refreshData();
}, },
onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "修改未生效,原版本保持可用" }]), onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "修改未生效,原版本保持可用" }]),
...@@ -209,6 +284,15 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -209,6 +284,15 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
}, },
onError: (error) => { setPendingPreviewCommit(""); setMessages((items) => [...items, { role: "agent", text: error.message, meta: "版本预览失败,原测试版本保持可用" }]); }, onError: (error) => { setPendingPreviewCommit(""); setMessages((items) => [...items, { role: "agent", text: error.message, meta: "版本预览失败,原测试版本保持可用" }]); },
}); });
const previewDraftMutation = useMutation({
mutationFn: () => api.previewDraft(siteId),
onSuccess: async (result) => {
setEnvironment("preview");
await refreshData();
setMessages((items) => [...items, { role: "agent", text: "工作草稿已构建并保存为新版本。", meta: "测试版本 · " + result.commit.slice(0, 7) }]);
},
onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "草稿仍已保留,可以继续编辑" }]),
});
const undoMutation = useMutation({ const undoMutation = useMutation({
mutationFn: () => api.undo(siteId), mutationFn: () => api.undo(siteId),
onSuccess: async (result) => { onSuccess: async (result) => {
...@@ -228,10 +312,10 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -228,10 +312,10 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "发布失败,原生产版本保持可用" }]), onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "发布失败,原生产版本保持可用" }]),
}); });
const send = (message = input) => { const send = (message = input) => {
const value = message.trim(); if (!value || chatMutation.isPending || historicalPreview) return; const value = message.trim(); if (!value || chatMutation.isPending || (historicalPreview && !site?.draftBaseCommit)) return;
setMessages((items) => [...items, { role: "user", text: value }]); setInput(""); chatMutation.mutate(value); setMessages((items) => [...items, { role: "user", text: value }]); setInput(""); chatMutation.mutate(value);
}; };
const busy = chatMutation.isPending || rebuildMutation.isPending || previewVersionMutation.isPending || undoMutation.isPending || publishMutation.isPending; const busy = chatMutation.isPending || rebuildMutation.isPending || previewVersionMutation.isPending || previewDraftMutation.isPending || undoMutation.isPending || publishMutation.isPending;
if (!site) return <Splash />; if (!site) return <Splash />;
const previewVersion = historyQuery.data?.find((item) => item.hash === site.previewCommit); const previewVersion = historyQuery.data?.find((item) => item.hash === site.previewCommit);
...@@ -245,15 +329,12 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -245,15 +329,12 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
const environmentStatus = showingProduction const environmentStatus = showingProduction
? site.lastPublishError ? "上线失败 · 线上未变" : !site.publishedCommit ? "尚未上线" : productionMatchesPreview ? "生产已同步" : "生产版本较旧" ? site.lastPublishError ? "上线失败 · 线上未变" : !site.publishedCommit ? "尚未上线" : productionMatchesPreview ? "生产已同步" : "生产版本较旧"
: site.lastError ? "构建失败 · 预览未变" : site.status === "ready" ? "测试已同步" : "需要检查"; : site.lastError ? "构建失败 · 预览未变" : site.status === "ready" ? "测试已同步" : "需要检查";
const viewingLabel = showingProduction
? site.publishedCommit ? `线上版本 ${site.publishedCommit.slice(0, 7)}` : "尚未上线"
: historicalPreview ? `历史版本 ${site.previewCommit?.slice(0, 7)}` : `测试版本 ${site.previewCommit?.slice(0, 7) || "-------"}`;
const savedState = chatMutation.isPending ? "正在修改" : site.lastError ? "构建失败" : "已保存";
return <main className="workspace"> return <main className="workspace">
<aside className="rail"> <aside className="rail">
<Logo compact /> <Logo compact />
<div className="rail-nav"><button className="active" title="网站工作台"><Monitor size={18} /></button><button title="项目设置"><Settings2 size={18} /></button></div> <button className="rail-create-top" onClick={onCreate} title="创建官网" data-tooltip="创建官网" aria-label="创建官网"><Plus size={18} /></button>
<button className="rail-create" onClick={onCreate} title="创建新网站"><Plus size={18} /></button> <div className="rail-nav"><button className="active" title="Agent 工作台" data-tooltip="Agent 工作台" aria-label="Agent 工作台"><Monitor size={18} /></button></div>
<RailSettings onLogout={onLogout} />
</aside> </aside>
<section className="control-panel"> <section className="control-panel">
<SiteSwitcher site={site} sites={sites} onSelect={onSelectSite} /> <SiteSwitcher site={site} sites={sites} onSelect={onSelectSite} />
...@@ -265,17 +346,21 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -265,17 +346,21 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
{message.role === "agent" && <span className="agent-avatar"><Sparkles size={14} /></span>} {message.role === "agent" && <span className="agent-avatar"><Sparkles size={14} /></span>}
<div><p>{message.text}</p>{message.meta && <small><Check size={11} />{message.meta}</small>}</div> <div><p>{message.text}</p>{message.meta && <small><Check size={11} />{message.meta}</small>}</div>
</div>)} </div>)}
{chatMutation.isPending && <div className="agent-progress"><span className="agent-avatar"><Sparkles size={14} /></span><div><p><LoaderCircle className="spin" size={14} /> Agent 正在设计和验证</p><div className="progress-track"><i /></div><small>读取文件 → 生成 Patch → 构建检查</small></div></div>} {chatMutation.isPending && <div className="agent-progress"><span className="agent-avatar"><Sparkles size={14} /></span><div><p><LoaderCircle className="spin" size={14} /> Agent 正在修改工作草稿</p><div className="progress-track"><i /></div><small>读取文件 → 生成修改 → 自动保存草稿</small></div></div>}
{messages.length <= 1 && <div className="suggestions"><span>试试这些修改</span>{["把网站主色改成深蓝色", "增加三个服务卡片", "增加一个企业优势"].map((text) => <button key={text} onClick={() => send(text)}><Sparkles size={12} />{text}</button>)}</div>} {messages.length <= 1 && <div className="suggestions"><span>试试这些修改</span>{["把网站主色改成深蓝色", "增加三个服务卡片", "增加一个企业优势"].map((text) => <button key={text} onClick={() => send(text)}><Sparkles size={12} />{text}</button>)}</div>}
</div> </div>
{historicalPreview && <div className="history-edit-lock"><div><History size={14} /><span>当前是只读历史预览;切换到“最近保存”版本后才能继续修改</span></div></div>} {historicalPreview && <div className="history-edit-lock"><div><History size={14} /><span>{site.draftBaseCommit ? "正在查看旧版本;工作草稿已保留,可以继续编辑或预览草稿" : "当前是只读历史预览;切换到“最近保存”版本后才能继续修改"}</span></div></div>}
<div className="composer"> <div className="composer">
<textarea rows={3} value={input} disabled={busy || historicalPreview} placeholder={historicalPreview ? "切换到最近保存版本后才能继续修改…" : "描述你想要的修改,例如:让首屏更有科技感…"} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }} /> <textarea rows={3} value={input} disabled={busy || (historicalPreview && !site.draftBaseCommit)} placeholder={historicalPreview && !site.draftBaseCommit ? "切换到最近保存版本后才能继续修改…" : site.draftBaseCommit ? "继续描述对工作草稿的修改…" : "描述你想要的修改,例如:让首屏更有科技感…"} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }} />
<div><span>{agentMode === "model" ? <><span className="online-dot" />模型 Agent</> : <><Code2 size={12} />本地演示模式</>}</span><button onClick={() => send()} disabled={!input.trim() || busy || historicalPreview}><Send size={15} /></button></div> <div><span>{site.draftBaseCommit ? <><span className="draft-dot" />工作草稿自动保存</> : agentMode === "model" ? <><span className="online-dot" />模型 Agent</> : <><Code2 size={12} />本地演示模式</>}</span><button onClick={() => send()} disabled={!input.trim() || busy || (historicalPreview && !site.draftBaseCommit)}><Send size={15} /></button></div>
</div> </div>
</> : <div className="history-list"> </> : <div className="history-list">
<div className="history-heading"><div><h3>测试版本</h3><p>点击右侧“预览”切换测试版本,生产环境不会改变</p></div><span>{historyQuery.data?.length || 0}</span></div> <div className="history-heading"><div><h3>测试版本</h3><p>点击右侧“预览”切换测试版本,生产环境不会改变</p></div><span>{historyQuery.data?.length || 0}</span></div>
{historicalPreview && <div className="history-edit-lock history-panel-lock"><div><History size={14} /><span>历史版本只能预览或上线;切换到“最近保存”版本后才能继续修改</span></div></div>} {site.draftBaseCommit && <section className="draft-card">
<div className="draft-card-heading"><span><Sparkles size={13} /></span><div><strong>工作草稿</strong><small>自动保存于 {site.draftUpdatedAt ? formatTime(site.draftUpdatedAt) : "刚刚"} · 基于 {site.draftBaseCommit.slice(0, 7)}</small></div></div>
<div className="draft-card-actions"><button type="button" onClick={() => setTab("chat")}><MessageSquareText size={11} />继续编辑</button><button type="button" disabled={busy} onClick={() => previewDraftMutation.mutate()}>{previewDraftMutation.isPending ? <LoaderCircle className="spin" size={11} /> : <Monitor size={11} />}{previewDraftMutation.isPending ? "构建中" : "预览草稿"}</button></div>
</section>}
{historicalPreview && <div className="history-edit-lock history-panel-lock"><div><History size={14} /><span>{site.draftBaseCommit ? "正在查看旧版本,工作草稿已保留" : "历史版本只能预览或上线;切换到“最近保存”版本后才能继续修改"}</span></div></div>}
{historyQuery.data?.map((item, index) => { {historyQuery.data?.map((item, index) => {
const isPreview = item.hash === site.previewCommit; const isPreview = item.hash === site.previewCommit;
const isProduction = item.hash === site.publishedCommit; const isProduction = item.hash === site.publishedCommit;
...@@ -295,7 +380,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -295,7 +380,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
</section> </section>
<section className="preview-shell"> <section className="preview-shell">
<header className="preview-toolbar"> <header className="preview-toolbar">
<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.lastPublishError ? "failed" : site.publishStatus : site.lastError ? "failed" : site.status}`}>{busy ? <LoaderCircle className="spin" size={12} /> : showingProduction ? site.lastPublishError ? <X size={12} /> : <Check size={12} /> : site.lastError ? <X size={12} /> : <Check size={12} />}{publishMutation.isPending ? "正在发布" : chatMutation.isPending || rebuildMutation.isPending || previewVersionMutation.isPending || undoMutation.isPending ? "正在构建" : environmentStatus}</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.lastPublishError ? "failed" : site.publishStatus : site.lastError ? "failed" : site.status}`}>{busy ? <LoaderCircle className="spin" size={12} /> : showingProduction ? site.lastPublishError ? <X size={12} /> : <Check size={12} /> : site.lastError ? <X size={12} /> : <Check size={12} />}{publishMutation.isPending ? "正在发布" : previewDraftMutation.isPending ? "正在构建草稿" : chatMutation.isPending ? "正在修改草稿" : rebuildMutation.isPending || previewVersionMutation.isPending || undoMutation.isPending ? "正在构建" : !showingProduction && site.draftBaseCommit ? "草稿已保存" : environmentStatus}</span></div>
<div className="preview-modes"> <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="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="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>
...@@ -310,19 +395,17 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -310,19 +395,17 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
</button>} </button>}
</div> </div>
</header> </header>
<section className="version-state-strip">
<article><span>当前正在看</span><strong>{viewingLabel}</strong><small>{showingProduction ? "生产链接" : historicalPreview ? "只读测试预览" : "测试链接"}</small></article>
<article className={site.lastError ? "error" : ""}><span>最近保存 <em>{savedState}</em></span><strong>{site.currentCommit.slice(0, 7)}</strong><small>{site.lastError ? "修改失败,已保留原测试版本" : "最近一次成功保存的可编辑版本"}</small></article>
<article className="production"><span>线上运行</span><strong>{site.publishedCommit?.slice(0, 7) || "尚未上线"}</strong><small>{site.lastPublishError ? "上线失败,原线上版本保持不变" : site.publishedAt ? formatDate(site.publishedAt) : "生产链接已保留"}</small></article>
</section>
<div className="browser-stage"> <div className="browser-stage">
<div className={`browser-frame ${device}`}> <div className={`browser-frame ${device}`}>
<div className="browser-chrome"><div><i /><i /><i /></div><span>{activeUrl || site.productionUrl || "/sites/" + site.siteId + "/"}</span><RefreshCw size={11} /></div> <div className="browser-chrome"><div><i /><i /><i /></div><span>{activeUrl || site.productionUrl || "/sites/" + site.siteId + "/"}</span><RefreshCw size={11} /></div>
<iframe key={environment + iframeVersion} title={site.name + (showingProduction ? " 生产站点" : " 测试预览")} src={activeUrl + "?v=" + iframeVersion} /> <iframe key={environment + iframeVersion} title={site.name + (showingProduction ? " 生产站点" : " 测试预览")} src={activeUrl + "?v=" + iframeVersion} />
{busy && <div className="preview-busy"><LoaderCircle className="spin" size={25} /><strong>{publishMutation.isPending ? "正在发布生产版本" : "正在生成测试版本"}</strong><span>{publishMutation.isPending ? "测试环境和原生产版本保持可用" : "当前测试预览保持可用"}</span></div>} {(publishMutation.isPending || rebuildMutation.isPending || previewVersionMutation.isPending || previewDraftMutation.isPending || undoMutation.isPending) && <div className="preview-busy"><LoaderCircle className="spin" size={25} /><strong>{publishMutation.isPending ? "正在发布生产版本" : previewDraftMutation.isPending ? "正在构建工作草稿" : "正在生成测试版本"}</strong><span>{publishMutation.isPending ? "测试环境和原生产版本保持可用" : "当前测试预览保持可用"}</span></div>}
</div> </div>
</div> </div>
<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> <footer className="preview-footer">
<span>{showingProduction ? <Globe2 size={12} /> : <Monitor size={12} />}当前查看 · {showingProduction ? `生产版本 ${productionVersion?.shortHash || site.publishedCommit?.slice(0, 7) || "尚未上线"}` : `${historicalPreview ? "历史版本" : "测试版本"} ${previewVersion?.shortHash || site.currentCommit.slice(0, 7)}`} · {showingProduction ? site.publishedAt ? formatDate(site.publishedAt) : "尚未发布" : previewVersion ? formatDate(previewVersion.date) : formatDate(site.updatedAt)}{historicalPreview && !showingProduction ? " · 只读" : ""}</span>
<span><Clock3 size={12} />{site.publishedAt ? `线上版本 ${productionVersion?.shortHash || site.publishedCommit?.slice(0, 7)} · ${formatDate(site.publishedAt)}` : "生产环境尚未发布"}</span>
</footer>
</section> </section>
</main>; </main>;
} }
...@@ -330,3 +413,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI ...@@ -330,3 +413,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
function formatDate(value: string) { function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); return new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
} }
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
...@@ -2,6 +2,8 @@ import type { ChatResult, CreateSiteInput, GitHistoryItem, PreviewVersionResult, ...@@ -2,6 +2,8 @@ import type { ChatResult, CreateSiteInput, GitHistoryItem, PreviewVersionResult,
async function request<T>(url: string, options?: RequestInit): Promise<T> { async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers); const headers = new Headers(options?.headers);
const token = sessionStorage.getItem("webagent-session");
if (token) headers.set("authorization", "Bearer " + token);
if (options?.body != null && !headers.has("content-type")) headers.set("content-type", "application/json"); if (options?.body != null && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
...@@ -13,6 +15,9 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> { ...@@ -13,6 +15,9 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
} }
export const api = { export const api = {
login: (username: string, password: string) => request<{ token: string; username: string }>("/api/login", { method: "POST", body: JSON.stringify({ username, password }) }),
logout: () => request<{ success: true }>("/api/logout", { method: "POST" }),
session: () => request<{ username: string }>("/api/session"),
health: () => request<{ status: string; agentMode: "model" | "local" }>("/api/health"), health: () => request<{ status: string; agentMode: "model" | "local" }>("/api/health"),
sites: () => request<SiteInfo[]>("/api/sites"), sites: () => request<SiteInfo[]>("/api/sites"),
site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId), site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId),
...@@ -20,6 +25,7 @@ export const api = { ...@@ -20,6 +25,7 @@ export const api = {
chat: (siteId: string, message: string) => request<ChatResult>("/api/sites/" + siteId + "/chat", { method: "POST", body: JSON.stringify({ message }) }), 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"), history: (siteId: string) => request<GitHistoryItem[]>("/api/sites/" + siteId + "/history"),
previewVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-version", { method: "POST", body: JSON.stringify({ commit }) }), previewVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-version", { method: "POST", body: JSON.stringify({ commit }) }),
previewDraft: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-draft", { method: "POST" }),
rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }), rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }),
publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }), publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }),
undo: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/undo", { method: "POST" }), undo: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/undo", { method: "POST" }),
......
...@@ -13,15 +13,19 @@ ...@@ -13,15 +13,19 @@
.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)} .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-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} .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;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)} .login-layout{grid-template-columns:1.05fr .95fr}.login-card-wrap{align-items:center}.login-card{width:min(100%,480px);padding:42px 42px 28px}.login-card-icon{display:grid;place-items:center;width:45px;height:45px;margin-bottom:22px;color:var(--primary);background:var(--soft);border:1px solid #ded6ff;border-radius:14px}.login-card .card-heading{margin-bottom:10px}.login-copy{margin:0 0 26px;color:var(--muted);font-size:12px;line-height:1.7}.login-fields{display:flex;flex-direction:column;gap:18px}.login-submit{margin-top:26px}.create-workspace{grid-template-columns:64px 320px 1fr}.rail-bottom{margin-top:auto;display:flex;flex-direction:column;gap:8px}.rail .rail-bottom .rail-create{margin-top:0!important}.rail .rail-logout{color:#9a9da8}.rail .rail-logout:hover{color:#b42318;background:#fff0ee}.create-workspace>.rail>.rail-logout{margin-top:auto}.create-control-panel{background:linear-gradient(180deg,#fff,#faf9ff)}.empty-site-heading{height:86px;display:flex;align-items:flex-start;justify-content:center;flex-direction:column;gap:5px;padding:16px 20px;border-bottom:1px solid var(--divider)}.empty-site-heading .logo{font-size:15px}.empty-site-heading .logo-mark{width:31px;height:31px}.empty-site-heading small{padding-left:42px;color:var(--muted);font-size:8px}.create-side-copy{padding:42px 28px}.create-side-copy>span{display:grid;place-items:center;width:38px;height:38px;color:var(--primary);background:var(--soft);border-radius:12px}.create-side-copy h2{margin:18px 0 9px;font-size:20px}.create-side-copy p{margin:0;color:var(--muted);font-size:11px;line-height:1.8}.create-side-copy ul{margin:25px 0 0;padding:0;list-style:none;color:var(--text-2);font-size:10px}.create-side-copy li{display:flex;align-items:center;gap:8px;margin-top:12px}.create-side-copy li::before{content:"";width:6px;height:6px;background:var(--primary);border-radius:50%;box-shadow:0 0 0 4px rgba(112,40,255,.08)}.workspace-create-content{min-width:0;min-height:0;overflow:auto;display:flex;align-items:flex-start;justify-content:center;padding:42px;background:linear-gradient(145deg,#f4f1ff,#f8f8fc)}.workspace-create-card{width:min(780px,100%);border:1px solid #fff;border-radius:20px}.workspace-create-card .card-heading p{margin:7px 0 0;color:var(--muted);font-size:10px}.workspace-create-cancel{height:31px;display:flex;align-items:center;gap:5px;padding:0 10px;color:var(--muted);background:#fff;border:1px solid var(--border);border-radius:8px;font-size:9px;font-weight:700}.workspace-create-cancel:hover{color:#b42318;background:#fff5f3}
.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:16px}.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 .rail-create{color:white!important;background:var(--gradient)!important;box-shadow:0 8px 20px rgba(112,40,255,.2)}.rail-create-top{flex:0 0 auto;margin-top:13px!important}.rail-create-top.active{box-shadow:0 0 0 3px rgba(112,40,255,.13),0 8px 20px rgba(112,40,255,.2)!important}.rail-settings{position:relative;margin-top:auto}.rail-settings.open .rail-settings-trigger{color:var(--primary);background:var(--soft)}.rail-settings-menu{position:absolute;left:47px;bottom:0;width:178px;padding:8px;background:rgba(255,255,255,.98);border:1px solid #dfd9ef;border-radius:13px;box-shadow:0 18px 48px rgba(36,16,95,.18);backdrop-filter:blur(16px)}.rail-settings-menu>div{display:flex;align-items:center;gap:9px;padding:7px 7px 10px;border-bottom:1px solid var(--divider)}.settings-avatar{display:grid;place-items:center;width:30px;height:30px;flex:0 0 auto;color:white;background:var(--gradient);border-radius:9px;font-size:11px;font-weight:800}.rail-settings-menu>div>span:last-child{min-width:0;display:flex;flex-direction:column;gap:3px}.rail-settings-menu strong{font-size:10px}.rail-settings-menu small{color:var(--muted);font-size:8px}.rail .rail-settings-menu>button{width:100%;height:33px;margin-top:5px;display:flex;align-items:center;justify-content:flex-start;gap:7px;padding:0 8px;color:#b42318;border-radius:8px;font-size:9px;font-weight:700}.rail .rail-settings-menu>button:hover{background:#fff0ee}
.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)} .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} .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} .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,.draft-dot{width:6px;height:6px;border-radius:50%}.online-dot{background:#20b486}.draft-dot{background:#d79614;box-shadow:0 0 0 3px rgba(215,150,20,.1)}.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)} .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 .undo-button{color:#9a6100;border-color:#ecd8ad;background:#fffaf0}.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} .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 .undo-button{color:#9a6100;border-color:#ecd8ad;background:#fffaf0}.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}
.history-edit-lock{flex:0 0 auto;margin:0 14px 10px;padding:9px 11px;color:#6f4b00;background:#fff8e7;border:1px solid #f0dfb8;border-radius:10px;font-size:9px;line-height:1.5}.history-edit-lock>div{display:flex;align-items:flex-start;gap:7px}.history-edit-lock svg{flex:0 0 auto;margin-top:1px}.history-panel-lock{margin:0 0 14px}.history-list article{display:block;min-height:0;margin-bottom:5px}.history-list article.selected{background:#faf8ff;border-radius:11px}.history-row{width:100%;min-width:0;display:grid;grid-template-columns:24px minmax(0,1fr) auto;gap:8px;padding:9px 8px 5px;color:var(--text);text-align:left;background:transparent;border:1px solid transparent;border-radius:11px}.history-list article.selected .history-row{border-color:#ded4ff}.history-row .timeline{min-height:49px}.history-list article.selected .timeline i{background:var(--primary);box-shadow:0 0 0 1px var(--primary)}.history-row .commit-info{display:block;min-width:0;padding:0 0 10px}.commit-info>span{display:flex;align-items:center;gap:5px;min-width:0}.commit-info>span>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 .current-badge{color:#775400;background:#fff2c7}.commit-info>small{display:flex;align-items:center;gap:5px;margin-top:6px;color:var(--muted);font-size:8px}.commit-info>small code{font-size:8px}.history-row-state,.history-preview-button{align-self:start;min-height:24px;padding:4px 7px;border-radius:6px;font-size:7px;font-weight:750}.history-row-state{color:var(--primary);background:var(--soft)}.history-preview-button{display:flex;align-items:center;gap:4px;color:var(--text-2);background:#fff;border:1px solid var(--border)}.history-preview-button:hover:not(:disabled){color:var(--primary);background:var(--soft);border-color:#d8ccff} .history-edit-lock{flex:0 0 auto;margin:0 14px 10px;padding:9px 11px;color:#6f4b00;background:#fff8e7;border:1px solid #f0dfb8;border-radius:10px;font-size:9px;line-height:1.5}.history-edit-lock>div{display:flex;align-items:flex-start;gap:7px}.history-edit-lock svg{flex:0 0 auto;margin-top:1px}.history-panel-lock{margin:0 0 14px}.history-list article{display:block;min-height:0;margin-bottom:5px}.history-list article.selected{background:#faf8ff;border-radius:11px}.history-row{width:100%;min-width:0;display:grid;grid-template-columns:24px minmax(0,1fr) auto;gap:8px;padding:9px 8px 5px;color:var(--text);text-align:left;background:transparent;border:1px solid transparent;border-radius:11px}.history-list article.selected .history-row{border-color:#ded4ff}.history-row .timeline{min-height:49px}.history-list article.selected .timeline i{background:var(--primary);box-shadow:0 0 0 1px var(--primary)}.history-row .commit-info{display:block;min-width:0;padding:0 0 10px}.commit-info>span{display:flex;align-items:center;gap:5px;min-width:0}.commit-info>span>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 .current-badge{color:#775400;background:#fff2c7}.commit-info>small{display:flex;align-items:center;gap:5px;margin-top:6px;color:var(--muted);font-size:8px}.commit-info>small code{font-size:8px}.history-row-state,.history-preview-button{align-self:start;min-height:24px;padding:4px 7px;border-radius:6px;font-size:7px;font-weight:750}.history-row-state{color:var(--primary);background:var(--soft)}.history-preview-button{display:flex;align-items:center;gap:4px;color:var(--text-2);background:#fff;border:1px solid var(--border)}.history-preview-button:hover:not(:disabled){color:var(--primary);background:var(--soft);border-color:#d8ccff}
.version-state-strip{flex:0 0 70px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:#fff;border-bottom:1px solid var(--border)}.version-state-strip article{min-width:0;padding:10px 16px;border-right:1px solid var(--divider)}.version-state-strip article:last-child{border-right:0}.version-state-strip article>span,.version-state-strip strong,.version-state-strip small{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.version-state-strip article>span{color:var(--muted);font-size:8px;font-weight:750}.version-state-strip article>span em{margin-left:4px;padding:2px 5px;color:#6a4bb8;background:#f2edff;border-radius:5px;font-size:7px;font-style:normal}.version-state-strip strong{margin-top:5px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px}.version-state-strip small{margin-top:3px;color:var(--muted);font-size:7px}.version-state-strip article.error{background:#fffafa}.version-state-strip article.error>span em{color:#b42318;background:#fff0ee}.version-state-strip article.production{background:#fbfffd}.version-state-strip article.production strong{color:#13765b} .history-edit-lock{flex:0 0 auto;margin:0 14px 10px;padding:9px 11px;color:#6f4b00;background:#fff8e7;border:1px solid #f0dfb8;border-radius:10px;font-size:9px;line-height:1.5}.history-edit-lock>div{display:flex;align-items:flex-start;gap:7px}.history-edit-lock svg{flex:0 0 auto;margin-top:1px}.history-panel-lock{margin:0 0 14px}.draft-card{margin:0 0 14px;padding:12px;background:#fffaf0;border:1px solid #ecd8ad;border-radius:12px}.draft-card-heading{display:flex;align-items:center;gap:9px}.draft-card-heading>span{display:grid;place-items:center;width:28px;height:28px;flex:0 0 auto;color:#9a6100;background:#fff1cf;border-radius:8px}.draft-card-heading>div{min-width:0}.draft-card-heading strong,.draft-card-heading small{display:block}.draft-card-heading strong{font-size:10px}.draft-card-heading small{margin-top:4px;overflow:hidden;color:#8a6a30;font-size:8px;white-space:nowrap;text-overflow:ellipsis}.draft-card-actions{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:11px}.draft-card-actions button{height:29px;display:flex;align-items:center;justify-content:center;gap:5px;color:#765421;background:#fff;border:1px solid #ecd8ad;border-radius:7px;font-size:8px;font-weight:750}.draft-card-actions button:last-child{color:white;background:#b87808;border-color:#b87808}.draft-card-actions button:hover:not(:disabled){box-shadow:0 4px 12px rgba(154,97,0,.12)}.history-list article{display:block;min-height:0;margin-bottom:5px}.history-list article.selected{background:#faf8ff;border-radius:11px}.history-row{width:100%;min-width:0;display:grid;grid-template-columns:24px minmax(0,1fr) auto;gap:8px;padding:9px 8px 5px;color:var(--text);text-align:left;background:transparent;border:1px solid transparent;border-radius:11px}.history-list article.selected .history-row{border-color:#ded4ff}.history-row .timeline{min-height:49px}.history-list article.selected .timeline i{background:var(--primary);box-shadow:0 0 0 1px var(--primary)}.history-row .commit-info{display:block;min-width:0;padding:0 0 10px}.commit-info>span{display:flex;align-items:center;gap:5px;min-width:0}.commit-info>span>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 .current-badge{color:#775400;background:#fff2c7}.commit-info>small{display:flex;align-items:center;gap:5px;margin-top:6px;color:var(--muted);font-size:8px}.commit-info>small code{font-size:8px}.history-row-state,.history-preview-button{align-self:start;min-height:24px;padding:4px 7px;border-radius:6px;font-size:7px;font-weight:750}.history-row-state{color:var(--primary);background:var(--soft)}.history-preview-button{display:flex;align-items:center;gap:4px;color:var(--text-2);background:#fff;border:1px solid var(--border)}.history-preview-button:hover:not(:disabled){color:var(--primary);background:var(--soft);border-color:#d8ccff}
.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} .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;gap:18px;color:var(--muted);background:#fff;border-top:1px solid var(--divider);font-size:8px}.preview-footer span{min-width:0;display:flex;align-items:center;gap:5px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.preview-footer span:last-child{flex:0 1 auto}
@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}} .history-row-state{display:flex;align-items:center;justify-content:center}
.rail-bottom,.rail-logout{display:none}
.rail button{position:relative}.rail button[data-tooltip]::after{content:attr(data-tooltip);position:absolute;z-index:50;left:48px;top:50%;width:max-content;max-width:150px;padding:6px 9px;color:white;background:#282435;border-radius:7px;box-shadow:0 7px 18px rgba(25,20,42,.2);font-size:9px;font-weight:700;line-height:1;white-space:nowrap;opacity:0;pointer-events:none;transform:translate(4px,-50%);transition:opacity .14s ease,transform .14s ease}.rail button[data-tooltip]:hover::after,.rail button[data-tooltip]:focus-visible::after{opacity:1;transform:translate(0,-50%)}.rail .rail-create-top{color:var(--muted)!important;background:transparent!important;box-shadow:none!important}.rail .rail-create-top:hover,.rail .rail-create-top.active{color:var(--primary)!important;background:var(--soft)!important}.rail .rail-create-top.active{box-shadow:inset 0 0 0 1px #e5ddff!important}
@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}.create-workspace{grid-template-columns:58px 280px 1fr}.workspace-create-content{padding:28px}}
@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: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}} @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}.create-workspace{grid-template-columns:52px 1fr}.create-workspace .create-control-panel{display:none}.workspace-create-content{padding:18px}.login-card{padding:30px 24px}}
...@@ -24,6 +24,9 @@ export interface SiteInfo { ...@@ -24,6 +24,9 @@ export interface SiteInfo {
currentCommit: string; currentCommit: string;
previewCommit?: string; previewCommit?: string;
previousCommit?: string; previousCommit?: string;
draftBaseCommit?: string;
draftUpdatedAt?: string;
draftSummary?: string;
publishedCommit?: string; publishedCommit?: string;
publishedAt?: string; publishedAt?: string;
publishStatus: PublishStatus; publishStatus: PublishStatus;
...@@ -52,8 +55,9 @@ export interface GitHistoryItem { ...@@ -52,8 +55,9 @@ export interface GitHistoryItem {
export interface ChatResult { export interface ChatResult {
summary: string; summary: string;
commit: string; draft: true;
previewUrl: string; baseCommit: string;
updatedAt: string;
changedFiles: string[]; changedFiles: string[];
mode: "model" | "local"; mode: "model" | "local";
} }
......
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