Commit 6631f8f5 authored by xuchentao's avatar xuchentao

Initialize WebAgent MVP

parents
PORT=3100
HOST=127.0.0.1
# 可选:任意兼容 OpenAI Chat Completions 的模型服务。
# 默认示例使用国内可访问的 DeepSeek;也可以替换为企业内部网关,
# 或阿里云百炼兼容地址:https://dashscope.aliyuncs.com/compatible-mode/v1
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.deepseek.com/v1
OPENAI_MODEL=deepseek-chat
node_modules/
.pnpm-store/
dist/
.astro/
.env
.env.local
.runtime/
*.log
.DS_Store
registry=https://registry.npmmirror.com
store-dir=.runtime/pnpm-store
prefer-offline=true
strict-peer-dependencies=false
# WebAgent
WebAgent 是一个本地 AI 官网工作台。它把每个用户网站保存为独立 Astro 项目,通过 Git 记录每次有效修改,并在隔离 Workspace 中完成构建验证后再更新正式项目。
## 快速开始
```bash
pnpm install
cp .env.example .env
pnpm dev
```
- 控制台:<http://localhost:5173>
- API:<http://localhost:3100>
- 站点预览端口:`4300-4399`
未配置模型密钥时,内置演示指令支持修改主题颜色、增加服务卡片、增加企业优势等常见场景。配置 `OPENAI_API_KEY` 后,会使用兼容 OpenAI Chat Completions 的接口生成受约束的 `SitePatch`。示例默认使用国内可访问的 DeepSeek API,也可换成阿里云百炼或企业内部兼容网关。
项目的 `.npmrc` 只对当前仓库生效,依赖从 `registry.npmmirror.com` 安装,pnpm store 写入已忽略的 `.runtime/pnpm-store/`,不会污染全局 npm 配置。
## MVP 能力
- 从 Astro 模板创建独立企业官网
- 自动初始化网站 Git 仓库并提交初始版本
- 在隔离 Git Worktree 中应用 Agent 修改
- 构建通过后才写入正式项目并创建提交
- 本地 iframe 预览与自动刷新
- 浏览 Git 历史并安全回滚到任意版本
- 所有运行数据隔离在 `.runtime/`,不提交到 WebAgent 仓库
详细目录约束见 [docs/storage-convention.md](docs/storage-convention.md),Agent 写入规则见 [docs/site-patch-schema.md](docs/site-patch-schema.md)
node_modules/
dist/
.astro/
.env
.env.*
.DS_Store
import { defineConfig } from "astro/config";
export default defineConfig({
output: "static",
server: { host: "127.0.0.1" },
});
{
"name": "@webagent/astro-template",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"astro": "^5.7.0",
"typescript": "^5.8.3"
}
}
packages:
- "."
allowBuilds:
esbuild: true
sharp: true
---
import company from "../data/company.json";
---
<section class="section about" id="about"><div class="container about-grid">
<div><span class="eyebrow">ABOUT US</span><h2>理解变化,<br />也创造变化。</h2></div>
<div class="about-copy"><p>{company.description}</p><p>我们相信,真正有效的创新来自对业务的理解、对体验的尊重,以及对每一个交付细节的坚持。</p><div class="signature"><span>专注行业</span><strong>{company.industry}</strong></div></div>
</div></section>
<style>
.about{background:var(--color-surface)}.about-grid{display:grid;grid-template-columns:1fr 1fr;gap:100px}.about h2{margin:18px 0 0;font-size:clamp(40px,5vw,64px);line-height:1.12;letter-spacing:-.05em}.about-copy{padding-top:28px}.about-copy p{margin:0 0 20px;color:var(--color-text-secondary);font-size:18px;line-height:1.9}.signature{display:flex;align-items:center;justify-content:space-between;margin-top:36px;padding-top:24px;border-top:1px solid var(--color-divider);font-size:13px}.signature span{color:var(--color-text-muted)}.signature strong{color:var(--color-primary)}@media(max-width:760px){.about-grid{grid-template-columns:1fr;gap:28px}}
</style>
---
import home from "../data/home.json";
---
<section class="section advantages" id="advantages"><div class="container advantage-grid">
<div class="advantage-intro"><span class="eyebrow">WHY CHOOSE US</span><h2>不止完成项目,<br />更在意结果发生。</h2><p>清晰、克制、可靠,是我们对每一次合作的共同承诺。</p></div>
<div class="advantage-list">{home.advantages.map((item,index) => <article><span>0{index+1}</span><div><h3>{item.title}</h3><p>{item.description}</p></div></article>)}</div>
</div></section>
<style>
.advantages{overflow:hidden;background:var(--color-primary-dark);color:white;position:relative}.advantages::after{content:"";position:absolute;width:620px;height:620px;right:-260px;top:-250px;border:1px solid rgba(255,255,255,.12);border-radius:50%;box-shadow:0 0 0 80px rgba(255,255,255,.025),0 0 0 160px rgba(255,255,255,.018)}.advantage-grid{position:relative;z-index:1;display:grid;grid-template-columns:.9fr 1.1fr;gap:110px}.eyebrow{color:#bda7ff}.advantage-intro h2{font-size:clamp(40px,4.6vw,62px);line-height:1.12;letter-spacing:-.05em;margin:18px 0 22px}.advantage-intro p{max-width:440px;color:rgba(255,255,255,.58);font-size:16px;line-height:1.8}.advantage-list article{display:grid;grid-template-columns:54px 1fr;gap:24px;padding:28px 0;border-bottom:1px solid rgba(255,255,255,.12)}.advantage-list article:first-child{padding-top:4px}.advantage-list span{color:#a98bff;font-size:12px;font-weight:700}.advantage-list h3{margin:0 0 10px;font-size:23px}.advantage-list p{margin:0;color:rgba(255,255,255,.56);font-size:14px;line-height:1.7}@media(max-width:800px){.advantage-grid{grid-template-columns:1fr;gap:55px}}
</style>
---
import company from "../data/company.json";
---
<section class="section contact" id="contact"><div class="container contact-card">
<div><span class="eyebrow">LET'S TALK</span><h2>准备好开启<br />下一段增长了吗?</h2></div>
<div class="contact-side"><p>告诉我们你的目标,让一个清晰可行的方案从这次对话开始。</p><a href={`mailto:${company.email}`}>联系 {company.name}<span>→</span></a><div class="contact-meta"><span>{company.email}</span><span>{company.phone}</span></div></div>
</div></section>
<style>
.contact{background:var(--color-background-soft)}.contact-card{display:grid;grid-template-columns:1.1fr .9fr;gap:80px;padding:68px;border-radius:30px;background:white;border:1px solid var(--color-border);box-shadow:0 24px 70px rgba(36,16,95,.07)}h2{margin:17px 0 0;font-size:clamp(40px,5vw,64px);line-height:1.1;letter-spacing:-.05em}.contact-side{padding-top:20px}.contact-side p{margin:0 0 28px;color:var(--color-text-secondary);font-size:16px;line-height:1.8}.contact-side>a{display:flex;align-items:center;justify-content:space-between;height:58px;padding:0 22px;color:white;background:var(--gradient-primary);border-radius:14px;font-weight:750;box-shadow:0 14px 30px rgba(112,40,255,.22)}.contact-meta{display:flex;justify-content:space-between;margin-top:24px;color:var(--color-text-muted);font-size:12px}@media(max-width:760px){.contact-card{grid-template-columns:1fr;padding:32px;gap:30px}.contact-meta{flex-direction:column;gap:8px}}
</style>
---
import company from "../data/company.json";
---
<footer><div class="container footer-inner"><div class="brand"><span>W</span><strong>{company.name}</strong></div><p>© {new Date().getFullYear()} {company.name}. Crafted for progress.</p><a href="#top">返回顶部 ↑</a></div></footer>
<style>
footer{padding:32px 0;background:white;border-top:1px solid var(--color-divider)}.footer-inner{display:flex;align-items:center;justify-content:space-between}.brand{display:flex;align-items:center;gap:9px}.brand span{display:grid;place-items:center;width:28px;height:28px;color:white;background:var(--gradient-primary);border-radius:8px;font-size:12px;font-weight:800}.brand strong{font-size:14px}p,a{color:var(--color-text-muted);font-size:11px}a:hover{color:var(--color-primary)}@media(max-width:650px){.footer-inner{flex-direction:column;gap:16px}}
</style>
---
import company from "../data/company.json";
---
<header class="header">
<div class="container nav">
<a class="brand" href="#top"><span class="brand-mark">W</span><span>{company.name}</span></a>
<nav><a href="#about">关于我们</a><a href="#services">服务能力</a><a href="#advantages">企业优势</a></nav>
<a class="nav-action" href="#contact">联系合作 <span>↗</span></a>
</div>
</header>
<style>
.header { position: absolute; z-index: 20; width: 100%; top: 0; }
.nav { height: 88px; display: flex; align-items: center; justify-content: space-between; }
.brand { display: flex; align-items: center; gap: 11px; font-size: 18px; font-weight: 800; letter-spacing: -.02em; }
.brand-mark { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 11px; color: white; background: var(--gradient-primary); box-shadow: 0 8px 22px rgba(112,40,255,.28); }
nav { display: flex; gap: 34px; color: var(--color-text-secondary); font-size: 14px; font-weight: 600; }
nav a:hover { color: var(--color-primary); }
.nav-action { padding: 11px 17px; border: 1px solid var(--color-border); background: rgba(255,255,255,.72); border-radius: 11px; font-size: 13px; font-weight: 700; backdrop-filter: blur(14px); }
.nav-action span { color: var(--color-primary); margin-left: 5px; }
@media (max-width: 760px) { nav { display: none; } .nav-action { display: none; } }
</style>
---
import company from "../data/company.json";
import home from "../data/home.json";
const titleLines = home.heroTitle.split("\n");
---
<section class="hero" id="top">
<div class="orb orb-one"></div><div class="orb orb-two"></div>
<div class="container hero-grid">
<div class="hero-copy">
<span class="eyebrow">{home.eyebrow}</span>
<h1>{titleLines.map((line, index) => <>{line}{index < titleLines.length - 1 && <br />}</>)}</h1>
<p>{home.heroDescription}</p>
<div class="actions">
<a class="primary" href="#contact">{home.primaryAction}<span>→</span></a>
<a class="secondary" href="#services">{home.secondaryAction}<span>↓</span></a>
</div>
<div class="metrics">{home.metrics.map((item) => <div><strong>{item.value}</strong><span>{item.label}</span></div>)}</div>
</div>
<div class="visual" aria-label={`${company.name} 数字化能力展示`}>
<div class="visual-top"><span>BUSINESS INTELLIGENCE</span><span class="pulse">● LIVE</span></div>
<div class="visual-title"><span>增长动能</span><strong>+38.6%</strong></div>
<div class="chart"><i style="height:32%"></i><i style="height:44%"></i><i style="height:39%"></i><i style="height:62%"></i><i style="height:58%"></i><i style="height:76%"></i><i style="height:92%"></i></div>
<div class="visual-footer"><div><span>智能决策</span><strong>实时洞察</strong></div><div><span>业务协同</span><strong>全链连接</strong></div></div>
<div class="float-card"><span class="float-icon">✦</span><div><small>AI 能力引擎</small><strong>持续进化中</strong></div></div>
</div>
</div>
</section>
<style>
.hero { position: relative; overflow: hidden; min-height: 820px; padding: 178px 0 90px; background: var(--gradient-soft); }
.hero::before { content:""; position:absolute; inset:0; opacity:.32; background-image: linear-gradient(var(--color-border) 1px, transparent 1px), linear-gradient(90deg, var(--color-border) 1px, transparent 1px); background-size: 64px 64px; mask-image: linear-gradient(to bottom, black, transparent 82%); }
.orb { position:absolute; border-radius:50%; filter:blur(2px); pointer-events:none; }
.orb-one { width:550px; height:550px; right:-150px; top:-210px; background:radial-gradient(circle,rgba(112,40,255,.18),transparent 68%); }
.orb-two { width:480px; height:480px; left:-280px; bottom:-210px; background:radial-gradient(circle,rgba(83,108,255,.12),transparent 68%); }
.hero-grid { position:relative; display:grid; grid-template-columns: 1.05fr .95fr; gap:84px; align-items:center; }
h1 { max-width:720px; margin:22px 0 26px; font-size:clamp(48px,5.8vw,76px); letter-spacing:-.06em; line-height:1.07; }
.hero-copy > p { max-width:600px; margin:0; color:var(--color-text-secondary); font-size:18px; line-height:1.8; }
.actions { display:flex; gap:14px; margin-top:38px; }
.actions a { display:inline-flex; align-items:center; justify-content:center; gap:28px; height:54px; padding:0 23px; border-radius:13px; font-weight:750; font-size:14px; transition:.25s ease; }
.primary { color:white; background:var(--gradient-primary); box-shadow:0 14px 34px rgba(112,40,255,.24); }
.primary:hover { transform:translateY(-2px); box-shadow:0 18px 38px rgba(112,40,255,.32); }
.secondary { color:var(--color-primary); background:white; border:1px solid var(--color-border); }
.metrics { display:flex; gap:40px; margin-top:58px; padding-top:26px; border-top:1px solid var(--color-divider); }
.metrics div { display:flex; flex-direction:column; gap:5px; }
.metrics strong { font-size:24px; letter-spacing:-.04em; }
.metrics span { color:var(--color-text-muted); font-size:12px; }
.visual { position:relative; min-height:470px; padding:28px; color:white; border-radius:30px; background:var(--gradient-dark); box-shadow:0 34px 80px rgba(36,16,95,.25); transform:perspective(1200px) rotateY(-4deg) rotateX(2deg); }
.visual::after { content:""; position:absolute; inset:1px; border:1px solid rgba(255,255,255,.14); border-radius:29px; pointer-events:none; }
.visual-top { display:flex; justify-content:space-between; font-size:10px; letter-spacing:.15em; opacity:.72; }
.pulse { color:#a5ffd9; }
.visual-title { margin-top:48px; display:flex; flex-direction:column; gap:10px; }
.visual-title span { font-size:14px; opacity:.65; }.visual-title strong { font-size:52px; letter-spacing:-.05em; }
.chart { height:170px; display:flex; align-items:end; gap:12px; margin:30px 0 26px; border-bottom:1px solid rgba(255,255,255,.15); }
.chart i { flex:1; min-height:20px; border-radius:7px 7px 0 0; background:linear-gradient(to top,rgba(255,255,255,.18),rgba(255,255,255,.82)); }
.visual-footer { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.visual-footer div { padding:17px; border:1px solid rgba(255,255,255,.12); border-radius:14px; background:rgba(255,255,255,.07); }
.visual-footer span,.visual-footer strong { display:block; }.visual-footer span { opacity:.58;font-size:11px;margin-bottom:7px;}.visual-footer strong{font-size:14px;}
.float-card { position:absolute; display:flex; align-items:center; gap:12px; right:-38px; bottom:58px; padding:14px 17px; color:var(--color-text-primary); background:rgba(255,255,255,.94); border:1px solid white; border-radius:16px; box-shadow:0 18px 44px rgba(36,16,95,.22); backdrop-filter:blur(16px); }
.float-icon { display:grid; place-items:center; width:36px;height:36px;border-radius:10px;color:white;background:var(--gradient-primary); }
.float-card small,.float-card strong{display:block}.float-card small{color:var(--color-text-muted);font-size:10px;margin-bottom:4px}.float-card strong{font-size:13px}
@media(max-width:980px){.hero-grid{grid-template-columns:1fr}.visual{max-width:620px;transform:none}.hero{padding-top:150px}.float-card{right:-8px}}
@media(max-width:620px){.hero{min-height:auto}.actions{flex-direction:column}.actions a{width:100%}.metrics{gap:20px;justify-content:space-between}.visual{min-height:400px;padding:22px}.float-card{display:none}.visual-title strong{font-size:42px}}
</style>
---
import home from "../data/home.json";
---
<section class="section services" id="services"><div class="container">
<div class="section-heading"><span class="eyebrow">WHAT WE DO</span><h2>聚焦关键问题,<br />提供完整解法。</h2><p>从洞察、设计到工程交付,让创意与技术最终转化为真实的业务价值。</p></div>
<div class="service-grid">{home.services.map((service) => <article><span class="number">{service.number}</span><div class="icon">↗</div><h3>{service.title}</h3><p>{service.description}</p><a href="#contact">探索服务 <span>→</span></a></article>)}</div>
</div></section>
<style>
.services{background:var(--color-background)}.service-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}article{position:relative;min-height:330px;padding:30px;background:white;border:1px solid var(--color-border);border-radius:20px;box-shadow:0 12px 36px rgba(36,16,95,.045);transition:.3s ease}article:hover{transform:translateY(-7px);border-color:#d8ccff;box-shadow:0 22px 48px rgba(36,16,95,.09)}.number{color:var(--color-text-muted);font-size:12px;font-weight:700}.icon{display:grid;place-items:center;width:47px;height:47px;margin:48px 0 28px;border-radius:14px;color:white;background:var(--gradient-primary);box-shadow:0 10px 24px rgba(112,40,255,.2)}h3{margin:0 0 14px;font-size:22px;letter-spacing:-.03em}p{margin:0;color:var(--color-text-secondary);line-height:1.75;font-size:14px}a{position:absolute;left:30px;bottom:27px;color:var(--color-primary);font-size:13px;font-weight:750}a span{margin-left:8px}@media(max-width:820px){.service-grid{grid-template-columns:1fr}article{min-height:290px}.icon{margin-top:32px}}
</style>
{
"name": "星澜智能",
"industry": "企业智能服务",
"description": "以可信赖的数字化能力,帮助成长型企业连接技术、业务与长期价值。",
"email": "hello@example.com",
"phone": "400-888-2026",
"location": "上海 · 中国"
}
{
"eyebrow": "AI-POWERED BUSINESS",
"heroTitle": "让每一次数字化升级,\n都成为增长的起点",
"heroDescription": "从策略洞察到产品落地,我们用清晰的方法与先进技术,为企业构建可持续的数字竞争力。",
"primaryAction": "开始合作",
"secondaryAction": "了解我们的能力",
"metrics": [
{ "value": "120+", "label": "服务企业" },
{ "value": "98%", "label": "项目交付率" },
{ "value": "7×24", "label": "专业支持" }
],
"services": [
{ "number": "01", "title": "数字化咨询", "description": "从业务目标出发,识别真正值得投入的数字化机会。" },
{ "number": "02", "title": "智能产品开发", "description": "融合 AI 与现代工程能力,打造稳定、易用的产品体验。" },
{ "number": "03", "title": "品牌体验升级", "description": "以一致的视觉与内容系统,建立更具辨识度的品牌表达。" }
],
"advantages": [
{ "title": "业务优先", "description": "不堆砌技术概念,每一项方案都回应真实业务目标。" },
{ "title": "敏捷共创", "description": "用短周期验证关键假设,让过程透明、结果可控。" },
{ "title": "长期主义", "description": "交付可维护、可演进的系统,而不是一次性成果。" }
]
}
---
import "../styles/global.css";
interface Props { title: string; description: string; }
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content={description} />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect width=%2232%22 height=%2232%22 rx=%229%22 fill=%22%237028ff%22/><path d=%22M9 20L16 8l7 12-4 4-3-6-3 6z%22 fill=%22white%22/></svg>" />
<title>{title}</title>
</head>
<body><slot /></body>
</html>
---
import BaseLayout from "../layouts/BaseLayout.astro";
import Header from "../components/Header.astro";
import Hero from "../components/Hero.astro";
import About from "../components/About.astro";
import Services from "../components/Services.astro";
import Advantages from "../components/Advantages.astro";
import Contact from "../components/Contact.astro";
import Footer from "../components/Footer.astro";
import company from "../data/company.json";
---
<BaseLayout title={`${company.name}|${company.industry}`} description={company.description}>
<Header /><main><Hero /><About /><Services /><Advantages /><Contact /></main><Footer />
</BaseLayout>
@import "./theme.css";
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
color: var(--color-text-primary);
background: var(--color-surface);
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
button, input { font: inherit; }
.container { width: min(1180px, calc(100% - 48px)); margin: 0 auto; }
.eyebrow {
display: inline-flex; align-items: center; gap: 9px; color: var(--color-primary);
font-size: 12px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase;
}
.eyebrow::before { content: ""; width: 22px; height: 2px; background: var(--gradient-primary); }
.section { padding: 112px 0; }
.section-heading { max-width: 680px; margin-bottom: 56px; }
.section-heading h2 { margin: 14px 0 18px; font-size: clamp(34px, 4.6vw, 58px); letter-spacing: -.045em; line-height: 1.08; }
.section-heading p { margin: 0; color: var(--color-text-secondary); font-size: 17px; line-height: 1.8; }
@media (max-width: 720px) {
.container { width: min(100% - 30px, 1180px); }
.section { padding: 78px 0; }
}
:root {
--color-primary: #7028ff;
--color-primary-hover: #5c1fe0;
--color-primary-light: #8b5cff;
--color-secondary: #536cff;
--color-primary-dark: #24105f;
--color-background: #f8f8fc;
--color-background-soft: #f3f0ff;
--color-surface: #ffffff;
--color-text-primary: #191927;
--color-text-secondary: #4f5260;
--color-text-muted: #8b8e99;
--color-border: #e7e3f3;
--color-divider: #eeeff4;
--gradient-primary: linear-gradient(135deg, #7028ff 0%, #536cff 100%);
--gradient-dark: linear-gradient(135deg, #24105f 0%, #4a1ca8 55%, #7028ff 100%);
--gradient-soft: linear-gradient(180deg, #f3f0ff 0%, #ffffff 100%);
}
{
"extends": "astro/tsconfigs/strict"
}
{
"name": "@webagent/backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fastify/cors": "^11.0.1",
"@webagent/shared": "workspace:*",
"dotenv": "^16.5.0",
"execa": "^9.5.2",
"fastify": "^5.3.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.15.3",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
}
}
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { SitePatch } from "@webagent/shared";
import { config } from "../config.js";
import { sitePatchSchema } from "../schemas.js";
import { selectContext } from "./context-selector.js";
import { agentSystemPrompt } from "./prompts.js";
export class AgentLoop {
async generate(projectPath: string, message: string): Promise<{ patch: SitePatch; mode: "model" | "local" }> {
if (config.openai.apiKey) return { patch: await this.generateWithModel(projectPath, message), mode: "model" };
return { patch: await this.generateLocally(projectPath, message), mode: "local" };
}
async repair(projectPath: string, request: string, error: string): Promise<SitePatch> {
if (!config.openai.apiKey) throw new Error("本地演示模式无法自动修复构建错误");
return this.generateWithModel(projectPath, request + "\n\n上一次构建失败,请修复当前文件。错误摘要:\n" + error.slice(-6000));
}
private async generateWithModel(projectPath: string, message: string): Promise<SitePatch> {
const context = await selectContext(projectPath);
const response = await fetch(config.openai.baseUrl + "/chat/completions", {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer " + config.openai.apiKey },
body: JSON.stringify({
model: config.openai.model,
temperature: 0.75,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: agentSystemPrompt },
{ role: "user", content: "用户要求:\n" + message + "\n\n当前项目文件:\n" + context },
],
}),
signal: AbortSignal.timeout(90000),
});
if (!response.ok) throw new Error("模型请求失败: " + response.status + " " + (await response.text()).slice(0, 500));
const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("模型没有返回 SitePatch");
const cleaned = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
return sitePatchSchema.parse(JSON.parse(cleaned)) as SitePatch;
}
private async generateLocally(projectPath: string, message: string): Promise<SitePatch> {
const normalized = message.toLowerCase();
if (/颜色|配色|主题|主色|色调|#[0-9a-f]{6}/i.test(message)) return this.localColorPatch(projectPath, message);
if (/服务|业务/.test(message) && /增加|添加|新增|三个|3个/.test(message)) return this.localServicePatch(projectPath, message);
if (/优势|亮点|为什么选择/.test(message) && /增加|添加|新增|区块|板块/.test(message)) return this.localAdvantagePatch(projectPath, message);
if (/企业名称|公司名称/.test(message) && /改成|修改为/.test(message)) return this.localCompanyPatch(projectPath, message);
if (/标题|首屏|hero/.test(normalized) && /改成|修改为/.test(message)) return this.localHeroPatch(projectPath, message);
throw new Error("当前为本地演示模式,可尝试“把主色改成深蓝色”“增加三个服务卡片”或“增加一个企业优势”。配置 OPENAI_API_KEY 后可处理开放式设计需求。");
}
private async localColorPatch(projectPath: string, message: string): Promise<SitePatch> {
const explicit = message.match(/#[0-9a-fA-F]{6}/)?.[0];
const color = explicit || (message.includes("深蓝") ? "#1d4ed8" : message.includes("蓝") ? "#2563eb" : message.includes("绿") ? "#16a34a" : message.includes("橙") ? "#ea580c" : message.includes("红") ? "#dc2626" : "#7028ff");
const themePath = path.join(projectPath, "src/styles/theme.css");
let content = await readFile(themePath, "utf8");
const dark = shade(color, -28), light = shade(color, 25), secondary = shade(color, 13);
content = content
.replace(/--color-primary: #[0-9a-f]{6};/i, "--color-primary: " + color + ";")
.replace(/--color-primary-hover: #[0-9a-f]{6};/i, "--color-primary-hover: " + dark + ";")
.replace(/--color-primary-light: #[0-9a-f]{6};/i, "--color-primary-light: " + light + ";")
.replace(/--color-secondary: #[0-9a-f]{6};/i, "--color-secondary: " + secondary + ";")
.replace(/--gradient-primary: [^;]+;/i, "--gradient-primary: linear-gradient(135deg, " + color + " 0%, " + secondary + " 100%);");
return { summary: "将网站品牌主色更新为 " + color, operations: [{ type: "write", path: "src/styles/theme.css", content }] };
}
private async localServicePatch(projectPath: string, message: string): Promise<SitePatch> {
const file = path.join(projectPath, "src/data/home.json");
const data = JSON.parse(await readFile(file, "utf8")) as { services: Array<{ number: string; title: string; description: string }> };
const count = /三|3/.test(message) ? 3 : 1;
const ideas = [
["增长策略设计", "围绕客户旅程与业务目标,设计可衡量、可持续的增长路径。"],
["数据洞察服务", "将分散的数据转化为清晰洞察,为关键决策提供可靠依据。"],
["智能运营支持", "以自动化流程提升协作效率,让团队聚焦更高价值的工作。"],
];
for (let index = 0; index < count; index += 1) data.services.push({ number: String(data.services.length + 1).padStart(2, "0"), title: ideas[index][0], description: ideas[index][1] });
return { summary: "新增 " + count + " 个服务卡片", operations: [{ type: "write", path: "src/data/home.json", content: JSON.stringify(data, null, 2) + "\n" }] };
}
private async localAdvantagePatch(projectPath: string, _message: string): Promise<SitePatch> {
const file = path.join(projectPath, "src/data/home.json");
const data = JSON.parse(await readFile(file, "utf8")) as { advantages: Array<{ title: string; description: string }> };
data.advantages.push({ title: "持续陪伴", description: "从方案上线到持续优化,用稳定响应陪伴每一个增长阶段。" });
return { summary: "新增“持续陪伴”企业优势", operations: [{ type: "write", path: "src/data/home.json", content: JSON.stringify(data, null, 2) + "\n" }] };
}
private async localCompanyPatch(projectPath: string, message: string): Promise<SitePatch> {
const name = message.match(/(?:改成|修改为)[\"']?([^“”\"',。]{2,30})/)?.[1]?.trim();
if (!name) throw new Error("请使用“把企业名称改成某某公司”的格式");
const file = path.join(projectPath, "src/data/company.json");
const data = JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
data.name = name;
return { summary: "将企业名称修改为“" + name + "”", operations: [{ type: "write", path: "src/data/company.json", content: JSON.stringify(data, null, 2) + "\n" }] };
}
private async localHeroPatch(projectPath: string, message: string): Promise<SitePatch> {
const title = message.match(/(?:改成|修改为)[\"']?([^“”\"']{4,80})[\"']?$/)?.[1]?.trim();
if (!title) throw new Error("请明确给出新的首屏标题");
const file = path.join(projectPath, "src/data/home.json");
const data = JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
data.heroTitle = title;
return { summary: "更新网站首屏标题", operations: [{ type: "write", path: "src/data/home.json", content: JSON.stringify(data, null, 2) + "\n" }] };
}
}
function shade(hex: string, amount: number): string {
const value = Number.parseInt(hex.slice(1), 16);
const adjust = (channel: number) => Math.max(0, Math.min(255, channel + Math.round(255 * amount / 100)));
const r = adjust(value >> 16), g = adjust((value >> 8) & 255), b = adjust(value & 255);
return "#" + [r, g, b].map((item) => item.toString(16).padStart(2, "0")).join("");
}
import path from "node:path";
import { readdir, readFile } from "node:fs/promises";
const extensions = new Set([".astro", ".ts", ".css", ".json", ".md"]);
async function walk(directory: string, root: string, files: string[]): Promise<void> {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) await walk(absolute, root, files);
else if (extensions.has(path.extname(entry.name))) files.push(path.relative(root, absolute).replaceAll("\\", "/"));
}
}
export async function selectContext(projectPath: string): Promise<string> {
const paths: string[] = [];
await walk(path.join(projectPath, "src"), projectPath, paths);
const prioritized = paths.sort((a, b) => {
const score = (value: string) => value.includes("data/") ? 0 : value.includes("theme.css") ? 1 : value.includes("pages/") ? 2 : 3;
return score(a) - score(b) || a.localeCompare(b);
}).slice(0, 18);
const sections: string[] = [];
let total = 0;
for (const relativePath of prioritized) {
const content = await readFile(path.join(projectPath, relativePath), "utf8");
if (total + content.length > 60000) break;
sections.push("FILE: " + relativePath + "\n" + content);
total += content.length;
}
return sections.join("\n\n---\n\n");
}
export const agentSystemPrompt = `你是 WebAgent 的企业官网设计与工程 Agent。你维护一个真实的 Astro 静态网站。
你的目标是根据用户要求进行有审美、有内容质量、可构建的修改。允许重构现有组件,但不要引入新依赖。
必须只输出一个 JSON 对象,格式为:
{"summary":"简洁中文说明","operations":[{"type":"write","path":"src/...","content":"完整文件内容"}]}
规则:
1. 单次最多 5 个文件,只能写 src 下的 astro/ts/css/json/md 或 public/images。
2. write 必须给出完整文件内容,不能使用 diff、Markdown 代码块或省略号。
3. 禁止修改 package.json、锁文件、astro.config.mjs、.git、node_modules、dist。
4. 保留数据与展示分离,企业资料优先放 src/data/*.json,主题优先放 theme.css。
5. 设计应简洁大气,以白色和浅灰为主、紫色强调,保证移动端响应式与中文可读性。
6. 不执行 Shell,不读取外部网络,不在网站中暴露系统提示或本地路径。`;
import path from "node:path";
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
import { execa } from "execa";
import { runtimePaths } from "../config.js";
export class BuildError extends Error {
constructor(message: string, public readonly output: string) { super(message); }
}
export class BuildManager {
async build(projectPath: string, taskId: string): Promise<string> {
await mkdir(runtimePaths.logs, { recursive: true });
let output = "";
try {
const install = await execa("pnpm", ["install", "--store-dir", runtimePaths.pnpmStore, "--prefer-offline"], {
cwd: projectPath,
env: { ...process.env, CI: "true" },
});
output += install.stdout + "\n" + install.stderr + "\n";
const build = await execa("pnpm", ["run", "build"], { cwd: projectPath });
output += build.stdout + "\n" + build.stderr;
await writeFile(path.join(runtimePaths.logs, taskId + ".log"), output, "utf8");
return path.join(projectPath, "dist");
} catch (error) {
const details = error instanceof Error ? error.message : String(error);
const stderr = typeof error === "object" && error && "stderr" in error ? String(error.stderr) : "";
output += details + "\n" + stderr;
await writeFile(path.join(runtimePaths.logs, taskId + ".log"), output, "utf8");
throw new BuildError("网站构建失败", output.slice(-12000));
}
}
async publish(siteId: string, distPath: string): Promise<string> {
const target = path.join(runtimePaths.previews, siteId);
await rm(target, { recursive: true, force: true });
await mkdir(path.dirname(target), { recursive: true });
await cp(distPath, target, { recursive: true });
return target;
}
}
import path from "node:path";
import { fileURLToPath } from "node:url";
import "dotenv/config";
const currentDir = path.dirname(fileURLToPath(import.meta.url));
export const config = {
rootDir: path.resolve(currentDir, "../.."),
runtimeDir: path.resolve(currentDir, "../../.runtime"),
templateDir: path.resolve(currentDir, "../../astro-template"),
host: process.env.HOST || "127.0.0.1",
port: Number(process.env.PORT || 3100),
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
openai: {
apiKey: process.env.OPENAI_API_KEY || "",
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.deepseek.com/v1").replace(/\/$/, ""),
model: process.env.OPENAI_MODEL || "deepseek-chat",
},
};
export const runtimePaths = {
sites: path.join(config.runtimeDir, "sites"),
builds: path.join(config.runtimeDir, "builds"),
previews: path.join(config.runtimeDir, "previews"),
uploads: path.join(config.runtimeDir, "uploads"),
pnpmStore: path.join(config.runtimeDir, "pnpm-store"),
logs: path.join(config.runtimeDir, "logs"),
};
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { execa } from "execa";
import type { GitHistoryItem } from "@webagent/shared";
export class GitManager {
async init(projectPath: string): Promise<string> {
await execa("git", ["init", "-b", "main"], { cwd: projectPath });
await execa("git", ["config", "user.name", "WebAgent"], { cwd: projectPath });
await execa("git", ["config", "user.email", "webagent@localhost"], { cwd: projectPath });
return this.commit(projectPath, "Initial website");
}
async commit(projectPath: string, message: string): Promise<string> {
await execa("git", ["add", "-A"], { cwd: projectPath });
const status = await execa("git", ["diff", "--cached", "--quiet"], { cwd: projectPath, reject: false });
if (status.exitCode === 0) return this.currentCommit(projectPath);
await execa("git", ["commit", "-m", message], { cwd: projectPath });
return this.currentCommit(projectPath);
}
async currentCommit(projectPath: string): Promise<string> {
const result = await execa("git", ["rev-parse", "HEAD"], { cwd: projectPath });
return result.stdout.trim();
}
async history(projectPath: string): Promise<GitHistoryItem[]> {
const current = await this.currentCommit(projectPath);
const result = await execa("git", ["log", "--date=iso-strict", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI%x1e", "-30"], { cwd: projectPath });
return result.stdout.split("\x1e").map((row) => row.trim()).filter(Boolean).map((row) => {
const [hash, shortHash, message, author, date] = row.split("\x1f");
return { hash, shortHash, message, author, date, current: hash === current };
});
}
async createWorktree(projectPath: string, workspacePath: string, reference = "HEAD"): Promise<void> {
await rm(path.dirname(workspacePath), { recursive: true, force: true });
await mkdir(path.dirname(workspacePath), { recursive: true });
await execa("git", ["worktree", "add", "--detach", workspacePath, reference], { cwd: projectPath });
}
async removeWorktree(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(path.dirname(workspacePath), { recursive: true, force: true });
}
async pruneWorktrees(projectPath: string): Promise<void> {
await execa("git", ["worktree", "prune"], { cwd: projectPath, reject: false });
}
async commitWorktree(workspacePath: string, message: string, paths: string[]): Promise<string> {
const uniquePaths = [...new Set(paths)];
await execa("git", ["add", "-A", "--", ...uniquePaths], { cwd: workspacePath });
const status = await execa("git", ["diff", "--cached", "--quiet"], { cwd: workspacePath, reject: false });
if (status.exitCode === 0) throw new Error("修改没有产生实际文件变化");
await execa("git", ["commit", "-m", message], { cwd: workspacePath });
return this.currentCommit(workspacePath);
}
async cherryPick(projectPath: string, commit: string): Promise<string> {
await execa("git", ["cherry-pick", commit], { cwd: projectPath });
return this.currentCommit(projectPath);
}
async restoreAsCommit(projectPath: string, sourceCommit: string): Promise<string> {
await this.assertCommit(projectPath, sourceCommit);
await execa("git", ["restore", "--source", sourceCommit, "--staged", "--worktree", "--", "."], { cwd: projectPath });
return this.commit(projectPath, "Rollback to " + sourceCommit.slice(0, 7));
}
async assertCommit(projectPath: string, commit: string): Promise<void> {
const result = await execa("git", ["cat-file", "-e", commit + "^{commit}"], { cwd: projectPath, reject: false });
if (result.exitCode !== 0) throw new Error("目标版本不存在");
}
}
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import http, { type Server } from "node:http";
import path from "node:path";
const mimeTypes: Record<string, string> = {
".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8",
".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
".jpeg": "image/jpeg", ".webp": "image/webp", ".ico": "image/x-icon",
};
export class PreviewProcessManager {
private servers = new Map<string, Server>();
async start(siteId: string, distPath: string, port: number): Promise<string> {
await this.stop(siteId);
const root = path.resolve(distPath);
const server = http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url || "/", "http://localhost").pathname);
let filePath = path.resolve(root, "." + pathname);
if (!filePath.startsWith(root + path.sep) && filePath !== root) {
response.writeHead(403).end("Forbidden"); return;
}
let info = await stat(filePath).catch(() => null);
if (info?.isDirectory()) { filePath = path.join(filePath, "index.html"); info = await stat(filePath).catch(() => null); }
if (!info?.isFile()) {
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found"); return;
}
response.writeHead(200, { "content-type": mimeTypes[path.extname(filePath)] || "application/octet-stream", "cache-control": "no-store" });
createReadStream(filePath).pipe(response);
} catch { response.writeHead(500).end("Preview error"); }
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
this.servers.set(siteId, server);
return this.getPreviewUrl(port);
}
async stop(siteId: string): Promise<void> {
const server = this.servers.get(siteId);
if (!server) return;
await new Promise<void>((resolve) => {
server.close(() => resolve());
server.closeAllConnections();
});
this.servers.delete(siteId);
}
getPreviewUrl(port: number): string { return "http://localhost:" + port; }
}
import { z } from "zod";
export const createSiteSchema = z.object({
name: z.string().trim().min(2, "企业名称至少 2 个字符").max(60),
industry: z.string().trim().min(2).max(60),
description: z.string().trim().min(10).max(500),
services: z.array(z.string().trim().min(2).max(80)).min(1).max(8),
email: z.string().email(),
phone: z.string().trim().max(30).optional(),
brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "品牌色必须是 6 位十六进制颜色"),
});
export const chatSchema = z.object({
message: z.string().trim().min(2).max(2000),
});
export const rollbackSchema = z.object({
commit: z.string().regex(/^[0-9a-f]{7,40}$/),
});
export const sitePatchSchema = z.object({
summary: z.string().trim().min(2).max(200),
operations: z.array(z.discriminatedUnion("type", [
z.object({ type: z.literal("write"), path: z.string(), content: z.string() }),
z.object({ type: z.literal("delete"), path: z.string() }),
])).min(1).max(5),
});
import path from "node:path";
import { lstat } from "node:fs/promises";
import type { SitePatch } from "@webagent/shared";
const allowedPatterns = [
/^src\/.+\.astro$/, /^src\/.+\.ts$/, /^src\/.+\.css$/,
/^src\/.+\.json$/, /^src\/.+\.md$/, /^public\/images\/.+$/,
];
const forbiddenParts = new Set([".git", "node_modules", "dist", ".astro"]);
export async function validatePatch(patch: SitePatch, workspacePath: string): Promise<void> {
if (patch.operations.length > 5) throw new Error("单次最多修改 5 个文件");
const seen = new Set<string>();
for (const operation of patch.operations) {
const normalized = operation.path.replaceAll("\\", "/");
if (path.isAbsolute(normalized) || normalized.includes("..") || normalized.startsWith("/")) throw new Error("Patch 包含不安全路径");
if (normalized.split("/").some((part) => forbiddenParts.has(part))) throw new Error("Patch 尝试修改禁止目录");
if (!allowedPatterns.some((pattern) => pattern.test(normalized))) throw new Error("Patch 路径不在白名单中: " + normalized);
if (seen.has(normalized)) throw new Error("Patch 中存在重复路径: " + normalized);
seen.add(normalized);
if (operation.type === "write" && Buffer.byteLength(operation.content, "utf8") > 256 * 1024) throw new Error("单文件不能超过 256 KiB");
const destination = path.resolve(workspacePath, normalized);
if (!destination.startsWith(path.resolve(workspacePath) + path.sep)) throw new Error("Patch 路径越界");
const existing = await lstat(destination).catch(() => null);
if (existing?.isSymbolicLink()) throw new Error("不允许写入符号链接");
}
}
import path from "node:path";
import { access, mkdir, rm } from "node:fs/promises";
import Fastify from "fastify";
import cors from "@fastify/cors";
import { ZodError } from "zod";
import { AgentLoop } from "./agent/agent-loop.js";
import { BuildManager } from "./build/build-manager.js";
import { config, runtimePaths } from "./config.js";
import { GitManager } from "./git/git-manager.js";
import { PreviewProcessManager } from "./preview/preview-process-manager.js";
import { chatSchema, createSiteSchema, rollbackSchema } from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js";
import { SiteAgentService } from "./sites/site-agent-service.js";
import { SiteRepository } from "./sites/site-repository.js";
import { SiteVersionService } from "./sites/site-version-service.js";
const app = Fastify({ logger: { level: process.env.LOG_LEVEL || "info" }, bodyLimit: 1024 * 1024 });
await app.register(cors, { origin: [config.frontendOrigin, "http://127.0.0.1:5173"] });
const sites = new SiteRepository();
const git = new GitManager();
const builds = new BuildManager();
const previews = new PreviewProcessManager();
const createSite = new CreateSiteService(sites, git, builds, previews);
const siteAgent = new SiteAgentService(sites, git, builds, previews, new AgentLoop());
const versions = new SiteVersionService(sites, git, builds, previews);
app.get("/api/health", async () => ({ status: "ok", service: "WebAgent", agentMode: config.openai.apiKey ? "model" : "local", timestamp: new Date().toISOString() }));
app.get("/api/sites", async () => sites.list());
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<{ Params: { siteId: string } }>("/api/sites/:siteId/chat", async (request) => {
const body = chatSchema.parse(request.body); return siteAgent.execute(request.params.siteId, body.message);
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/build", async (request) => versions.rebuild(request.params.siteId));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => git.history(sites.getProjectPath(request.params.siteId)));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/rollback", async (request) => {
const body = rollbackSchema.parse(request.body); return versions.rollback(request.params.siteId, body.commit);
});
app.setErrorHandler((error, _request, reply) => {
const isMissingFile = error instanceof Error && "code" in error && error.code === "ENOENT";
const status = error instanceof ZodError ? 400 : isMissingFile ? 404 : 500;
const message = error instanceof ZodError
? error.issues.map((item) => item.message).join(";")
: error instanceof Error ? error.message : "请求处理失败";
app.log.error(error);
reply.code(status).send({ error: message || "请求处理失败" });
});
await sites.ensureRuntime();
await rm(runtimePaths.builds, { recursive: true, force: true });
await mkdir(runtimePaths.builds, { recursive: true });
for (const site of await sites.list()) {
await git.pruneWorktrees(sites.getProjectPath(site.siteId));
const previewPath = path.join(runtimePaths.previews, site.siteId);
if (await access(previewPath).then(() => true).catch(() => false)) {
await previews.start(site.siteId, previewPath, site.previewPort)
.then(async () => sites.update(site.siteId, {
status: "ready",
currentCommit: await git.currentCommit(sites.getProjectPath(site.siteId)),
lastError: undefined,
}))
.catch((error) => app.log.warn(error));
}
}
await app.listen({ host: config.host, port: config.port });
import path from "node:path";
import { mkdir, rm, writeFile } from "node:fs/promises";
import type { SitePatch } from "@webagent/shared";
export async function applyPatch(workspacePath: string, patch: SitePatch): Promise<void> {
for (const operation of patch.operations) {
const destination = path.resolve(workspacePath, operation.path);
if (operation.type === "delete") await rm(destination, { force: true });
else {
await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, operation.content, "utf8");
}
}
}
import crypto from "node:crypto";
import path from "node:path";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import type { CreateSiteInput, SiteInfo } from "@webagent/shared";
import { config } from "../config.js";
import { GitManager } from "../git/git-manager.js";
import { BuildManager } from "../build/build-manager.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { SiteRepository } from "./site-repository.js";
export class CreateSiteService {
constructor(
private readonly sites: SiteRepository,
private readonly git: GitManager,
private readonly builds: BuildManager,
private readonly previews: PreviewProcessManager,
) {}
async execute(input: CreateSiteInput): Promise<SiteInfo> {
await this.sites.ensureRuntime();
const siteId = "site_" + crypto.randomBytes(4).toString("hex");
const projectPath = this.sites.getProjectPath(siteId);
const previewPort = await this.sites.allocatePreviewPort();
const now = new Date().toISOString();
const site: SiteInfo = {
siteId, name: input.name, industry: input.industry, status: "creating", templateVersion: "0.1.0",
previewPort, previewUrl: "http://localhost:" + previewPort, currentCommit: "", createdAt: now, updatedAt: now,
};
await mkdir(projectPath, { recursive: true });
await this.sites.save(site);
try {
await cp(config.templateDir, projectPath, { recursive: true, filter: (source) => !["node_modules", "dist", ".astro", ".git", "pnpm-lock.yaml"].includes(path.basename(source)) });
await rm(path.join(projectPath, "src/data/company.json"), { force: true });
await writeFile(path.join(projectPath, "src/data/company.json"), JSON.stringify({
name: input.name, industry: input.industry, description: input.description,
email: input.email, phone: input.phone || "", location: "中国",
}, null, 2) + "\n", "utf8");
const homePath = path.join(projectPath, "src/data/home.json");
const home = JSON.parse(await readFile(homePath, "utf8")) as { services: Array<{ number: string; title: string; description: string }> };
home.services = input.services.map((service, index) => ({ number: String(index + 1).padStart(2, "0"), title: service, description: "以专业方法和可靠交付,为客户提供高质量的" + service + "服务。" }));
await writeFile(homePath, JSON.stringify(home, null, 2) + "\n", "utf8");
const themePath = path.join(projectPath, "src/styles/theme.css");
const theme = (await readFile(themePath, "utf8")).replaceAll("#7028ff", input.brandColor.toLowerCase());
await writeFile(themePath, theme, "utf8");
const dist = await this.builds.build(projectPath, "create_" + siteId);
const commit = await this.git.init(projectPath);
await this.sites.update(siteId, { currentCommit: commit });
const published = await this.builds.publish(siteId, dist);
await this.previews.start(siteId, published, previewPort);
return await this.sites.update(siteId, { status: "ready", currentCommit: commit, lastError: undefined });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: "failed", lastError: message });
throw error;
}
}
}
import crypto from "node:crypto";
import path from "node:path";
import type { ChatResult, SitePatch } from "@webagent/shared";
import { runtimePaths } from "../config.js";
import { AgentLoop } from "../agent/agent-loop.js";
import { BuildError, BuildManager } from "../build/build-manager.js";
import { GitManager } from "../git/git-manager.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { validatePatch } from "../security/path-policy.js";
import { applyPatch } from "./apply-patch.js";
import { SiteRepository } from "./site-repository.js";
export class SiteAgentService {
constructor(
private readonly sites: SiteRepository, private readonly git: GitManager,
private readonly builds: BuildManager, private readonly previews: PreviewProcessManager,
private readonly agent: AgentLoop,
) {}
async execute(siteId: string, message: string): Promise<ChatResult> {
const site = await this.sites.get(siteId);
const projectPath = this.sites.getProjectPath(siteId);
const taskId = "task_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(siteId, { status: "building", lastError: undefined });
let generated: { patch: SitePatch; mode: "model" | "local" } | undefined;
try {
await this.git.createWorktree(projectPath, workspace);
generated = await this.agent.generate(workspace, message);
await validatePatch(generated.patch, workspace);
await applyPatch(workspace, generated.patch);
let dist: string;
try {
dist = await this.builds.build(workspace, taskId);
} 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");
}
const changedFiles = generated.patch.operations.map((item) => item.path);
const worktreeCommit = await this.git.commitWorktree(workspace, "Agent: " + generated.patch.summary, changedFiles);
const commit = await this.git.cherryPick(projectPath, worktreeCommit);
const published = await this.builds.publish(siteId, dist);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready", currentCommit: commit, lastError: undefined });
return { summary: generated.patch.summary, commit, previewUrl: site.previewUrl, changedFiles, mode: generated.mode };
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: "failed", lastError: details });
throw error;
} finally {
await this.git.removeWorktree(projectPath, workspace);
}
}
}
import path from "node:path";
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
import type { SiteInfo } from "@webagent/shared";
import { runtimePaths } from "../config.js";
export class SiteRepository {
async ensureRuntime(): Promise<void> {
await Promise.all(Object.values(runtimePaths).map((directory) => mkdir(directory, { recursive: true })));
}
getSiteRoot(siteId: string): string {
this.assertSiteId(siteId);
return path.join(runtimePaths.sites, siteId);
}
getProjectPath(siteId: string): string {
return path.join(this.getSiteRoot(siteId), "project");
}
getMetadataPath(siteId: string): string {
return path.join(this.getSiteRoot(siteId), "metadata", "site.json");
}
async get(siteId: string): Promise<SiteInfo> {
const raw = await readFile(this.getMetadataPath(siteId), "utf8");
return JSON.parse(raw) as SiteInfo;
}
async list(): Promise<SiteInfo[]> {
await this.ensureRuntime();
const entries = await readdir(runtimePaths.sites, { withFileTypes: true });
const results = await Promise.all(entries.filter((entry) => entry.isDirectory() && /^site_[a-z0-9]+$/.test(entry.name)).map(async (entry) => {
try { return await this.get(entry.name); } catch { return null; }
}));
return results.filter((site): site is SiteInfo => site !== null).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
async save(site: SiteInfo): Promise<void> {
const metadataPath = this.getMetadataPath(site.siteId);
await mkdir(path.dirname(metadataPath), { recursive: true });
const temporaryPath = metadataPath + ".tmp";
await writeFile(temporaryPath, JSON.stringify(site, null, 2) + "\n", "utf8");
await rename(temporaryPath, metadataPath);
}
async update(siteId: string, values: Partial<SiteInfo>): Promise<SiteInfo> {
const current = await this.get(siteId);
const next = { ...current, ...values, siteId, updatedAt: new Date().toISOString() };
await this.save(next);
return next;
}
async allocatePreviewPort(): Promise<number> {
const used = new Set((await this.list()).map((site) => site.previewPort));
for (let port = 4300; port <= 4399; port += 1) if (!used.has(port)) return port;
throw new Error("本地预览端口 4300-4399 已全部占用");
}
private assertSiteId(siteId: string): void {
if (!/^site_[a-z0-9]+$/.test(siteId)) throw new Error("无效的 siteId");
}
}
import crypto from "node:crypto";
import path from "node:path";
import { runtimePaths } from "../config.js";
import { BuildManager } from "../build/build-manager.js";
import { GitManager } from "../git/git-manager.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { SiteRepository } from "./site-repository.js";
export class SiteVersionService {
constructor(
private readonly sites: SiteRepository, private readonly git: GitManager,
private readonly builds: BuildManager, private readonly previews: PreviewProcessManager,
) {}
async rebuild(siteId: string): Promise<{ previewUrl: string }> {
const site = await this.sites.get(siteId);
const project = this.sites.getProjectPath(siteId);
await this.sites.update(siteId, { status: "building", lastError: undefined });
try {
const dist = await this.builds.build(project, "rebuild_" + siteId);
const published = await this.builds.publish(siteId, dist);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready" });
return { previewUrl: site.previewUrl };
} catch (error) {
await this.sites.update(siteId, { status: "failed", lastError: error instanceof Error ? error.message : String(error) });
throw error;
}
}
async rollback(siteId: string, targetCommit: string): Promise<{ commit: string; previewUrl: string }> {
const site = await this.sites.get(siteId);
const project = this.sites.getProjectPath(siteId);
await this.git.assertCommit(project, targetCommit);
const taskId = "rollback_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(siteId, { status: "building", lastError: undefined });
try {
await this.git.createWorktree(project, workspace, targetCommit);
const dist = await this.builds.build(workspace, taskId);
const published = await this.builds.publish(siteId, dist);
const commit = await this.git.restoreAsCommit(project, targetCommit);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready", currentCommit: commit, lastError: undefined });
return { commit, previewUrl: site.previewUrl };
} catch (error) {
await this.sites.update(siteId, { status: "failed", lastError: error instanceof Error ? error.message : String(error) });
throw error;
} finally { await this.git.removeWorktree(project, workspace); }
}
}
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"]
},
"include": ["src"]
}
# SitePatch 约束
模型输出必须是纯 JSON:
```json
{
"summary": "修改说明",
"operations": [
{ "type": "write", "path": "src/data/home.json", "content": "..." },
{ "type": "delete", "path": "public/images/old.webp" }
]
}
```
允许路径:
- `src/**/*.astro`
- `src/**/*.ts`
- `src/**/*.css`
- `src/**/*.json`
- `src/**/*.md`
- `public/images/**`
禁止路径包括 `.git``node_modules``dist``.astro`、依赖清单和 Astro 配置。单次最多 5 个操作,单文件不超过 256 KiB;不允许路径穿越、绝对路径、符号链接写入或模型生成 Shell。
# 本地存储规范
所有运行期数据必须写入项目根目录的 `.runtime/`,该目录不进入 WebAgent Git。
```text
.runtime/
├── sites/
│ └── site_xxxxxx/
│ ├── project/ # 独立 Astro 项目,也是独立 Git 仓库
│ └── metadata/
│ └── site.json # WebAgent 管理信息,不交给 Agent 修改
├── builds/
│ └── task_xxxxxx/
│ └── workspace/ # 临时 Git Worktree,任务结束必须删除
├── previews/
│ └── site_xxxxxx/ # 最近一次构建成功的静态产物
├── uploads/ # 原始上传文件的临时落点
├── pnpm-store/ # 所有生成站点共享的 pnpm store
└── logs/ # Agent 与构建日志
```
## 不变量
1. Agent 只可读取和修改当前站点 `project/` 内的白名单文件。
2. Agent 永远不直接修改正式项目,先在 `builds/` 中验证。
3. 只有构建成功的版本才允许进入站点 Git 历史并成为预览版本。
4. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。
5. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
6. 每个站点拥有稳定的 `siteId` 和预览端口;站点名称不参与路径计算。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f8f8fc" />
<meta name="description" content="WebAgent 本地 AI 官网工作台" />
<title>WebAgent · AI 官网工作台</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
{
"name": "@webagent/frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@tanstack/react-query": "^5.74.4",
"@webagent/shared": "workspace:*",
"lucide-react": "^0.503.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.8.3",
"vite": "^6.3.3"
}
}
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft, ArrowRight, Bot, Check, ChevronDown, Clock3, Code2, ExternalLink,
History, Laptop, LoaderCircle, MessageSquareText, Monitor, MoreHorizontal, Palette,
Plus, RefreshCw, RotateCcw, Send, Settings2, ShieldCheck, Smartphone, Sparkles,
WandSparkles, X,
} from "lucide-react";
import type { CreateSiteInput, SiteInfo } from "@webagent/shared";
import { api } from "./api";
type ChatMessage = { role: "user" | "agent"; text: string; meta?: string };
export default function App() {
const queryClient = useQueryClient();
const sitesQuery = useQuery({ queryKey: ["sites"], queryFn: api.sites });
const healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health });
const [currentSiteId, setCurrentSiteId] = useState(() => localStorage.getItem("webagent-current-site") || "");
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!currentSiteId && sitesQuery.data?.[0]) setCurrentSiteId(sitesQuery.data[0].siteId);
}, [currentSiteId, sitesQuery.data]);
useEffect(() => {
if (currentSiteId) localStorage.setItem("webagent-current-site", currentSiteId);
}, [currentSiteId]);
const openSite = (site: SiteInfo) => {
setCurrentSiteId(site.siteId); setCreating(false);
queryClient.invalidateQueries({ queryKey: ["sites"] });
};
if (sitesQuery.isLoading) return <Splash />;
if (creating || !currentSiteId || !sitesQuery.data?.length) {
return <CreateSitePage onCreated={openSite} onBack={sitesQuery.data?.length ? () => setCreating(false) : undefined} />;
}
return <Workspace
siteId={currentSiteId}
sites={sitesQuery.data}
agentMode={healthQuery.data?.agentMode || "local"}
onSelectSite={setCurrentSiteId}
onCreate={() => setCreating(true)}
/>;
}
function Splash() {
return <div className="splash"><Logo /><LoaderCircle className="spin" size={22} /></div>;
}
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>;
}
function CreateSitePage({ onCreated, onBack }: { onCreated: (site: SiteInfo) => void; onBack?: () => void }) {
const [form, setForm] = useState<CreateSiteInput>({
name: "云启科技", industry: "企业数字化服务", description: "专注于为成长型企业提供智能化、可持续的数字解决方案,让技术真正服务于业务增长。",
services: ["数字化咨询", "智能产品开发", "品牌体验设计"], email: "hello@yunqi.example", phone: "400-800-2026", brandColor: "#7028FF",
});
const [serviceText, setServiceText] = useState(form.services.join("、"));
const mutation = useMutation({ mutationFn: api.createSite, onSuccess: onCreated });
const submit = (event: FormEvent) => {
event.preventDefault();
mutation.mutate({ ...form, services: serviceText.split(/[、,,\n]/).map((item) => item.trim()).filter(Boolean) });
};
return <main className="create-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">
<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">
<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="企业简介" wide><textarea required rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></Field>
<Field label="核心服务" hint="使用逗号或顿号分隔" wide><input required value={serviceText} onChange={(e) => setServiceText(e.target.value)} /></Field>
<Field label="联系邮箱"><input required type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
<Field label="联系电话"><input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></Field>
<Field label="品牌主色" wide>
<div className="color-field"><input type="color" value={form.brandColor} onChange={(e) => setForm({ ...form, brandColor: e.target.value.toUpperCase() })} /><span>{form.brandColor}</span><div className="color-swatches">{["#7028FF", "#536CFF", "#0F766E", "#1D4ED8", "#C2410C"].map((color) => <button type="button" aria-label={color} key={color} style={{ background: color }} onClick={() => setForm({ ...form, brandColor: color })} className={form.brandColor === color ? "active" : ""} />)}</div></div>
</Field>
</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>
<p className="local-note"><ShieldCheck size={13} /> 项目与版本记录仅保存在当前设备</p>
</form>
</section>
</div>
</main>;
}
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>;
}
function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteId: string; sites: SiteInfo[]; agentMode: "model" | "local"; onSelectSite: (id: string) => void; onCreate: () => void }) {
const queryClient = useQueryClient();
const siteQuery = useQuery({ queryKey: ["site", siteId], queryFn: () => api.site(siteId) });
const historyQuery = useQuery({ queryKey: ["history", siteId], queryFn: () => api.history(siteId) });
const [tab, setTab] = useState<"chat" | "history">("chat");
const [device, setDevice] = useState<"desktop" | "mobile">("desktop");
const [input, setInput] = useState("");
const [iframeVersion, setIframeVersion] = useState(0);
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: "agent", text: "网站已准备好。告诉我你想调整的内容、配色或页面结构,我会先在隔离环境中验证,再更新预览。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" },
]);
const scrollRef = useRef<HTMLDivElement>(null);
const site = siteQuery.data;
useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [messages]);
useEffect(() => { setMessages([{ role: "agent", text: "已切换到这个网站。你可以继续描述修改要求。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }]); }, [siteId, agentMode]);
const refreshData = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["site", siteId] }),
queryClient.invalidateQueries({ queryKey: ["history", siteId] }),
queryClient.invalidateQueries({ queryKey: ["sites"] }),
]);
setIframeVersion((value) => value + 1);
};
const chatMutation = useMutation({
mutationFn: (message: string) => api.chat(siteId, message),
onSuccess: async (result) => {
setMessages((items) => [...items, { role: "agent", text: result.summary, meta: (result.mode === "model" ? "AI 生成" : "本地规则") + " · 已构建 · " + result.commit.slice(0, 7) }]);
await refreshData();
},
onError: (error) => setMessages((items) => [...items, { role: "agent", text: error.message, meta: "修改未生效,原版本保持可用" }]),
});
const rebuildMutation = useMutation({ mutationFn: () => api.rebuild(siteId), onSuccess: refreshData });
const rollbackMutation = useMutation({
mutationFn: (commit: string) => api.rollback(siteId, commit),
onSuccess: async (result) => { setMessages((items) => [...items, { role: "agent", text: "已恢复所选版本,并创建新的回滚记录。", meta: result.commit.slice(0, 7) }]); await refreshData(); },
});
const send = (message = input) => {
const value = message.trim(); if (!value || chatMutation.isPending) return;
setMessages((items) => [...items, { role: "user", text: value }]); setInput(""); chatMutation.mutate(value);
};
const busy = chatMutation.isPending || rebuildMutation.isPending || rollbackMutation.isPending;
if (!site) return <Splash />;
return <main className="workspace">
<aside className="rail">
<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" onClick={onCreate} title="创建新网站"><Plus size={18} /></button>
</aside>
<section className="control-panel">
<div className="site-switcher">
<div className="site-avatar">{site.name.slice(0, 1)}</div>
<select value={siteId} onChange={(e) => onSelectSite(e.target.value)}>{sites.map((item) => <option value={item.siteId} key={item.siteId}>{item.name}</option>)}</select>
<ChevronDown size={15} />
</div>
<div className="panel-tabs"><button className={tab === "chat" ? "active" : ""} onClick={() => setTab("chat")}><MessageSquareText size={15} /> Agent</button><button className={tab === "history" ? "active" : ""} onClick={() => setTab("history")}><History size={15} /> 版本历史</button></div>
{tab === "chat" ? <>
<div className="chat-scroll" ref={scrollRef}>
<div className="chat-date">今天</div>
{messages.map((message, index) => <div className={`message ${message.role}`} key={index}>
{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>)}
{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>}
{messages.length <= 1 && <div className="suggestions"><span>试试这些修改</span>{["把网站主色改成深蓝色", "增加三个服务卡片", "增加一个企业优势"].map((text) => <button key={text} onClick={() => send(text)}><Sparkles size={12} />{text}</button>)}</div>}
</div>
<div className="composer">
<textarea rows={3} value={input} disabled={busy} placeholder="描述你想要的修改,例如:让首屏更有科技感…" 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}><Send size={15} /></button></div>
</div>
</> : <div className="history-list">
<div className="history-heading"><div><h3>版本记录</h3><p>仅显示构建成功的修改</p></div><span>{historyQuery.data?.length || 0}</span></div>
{historyQuery.data?.map((item, index) => <article className={item.current ? "current" : ""} key={item.hash}>
<div className="timeline"><i>{item.current ? <Check size={10} /> : ""}</i>{index < (historyQuery.data?.length || 0) - 1 && <span />}</div>
<div className="commit-info"><div><strong>{item.message}</strong>{item.current && <em>当前版本</em>}</div><p><code>{item.shortHash}</code><span>·</span>{formatDate(item.date)}</p></div>
{!item.current && <button title="恢复这个版本" disabled={busy} onClick={() => { if (window.confirm("确定恢复到版本 " + item.shortHash + " 吗?当前状态会作为历史保留。")) rollbackMutation.mutate(item.hash); }}><RotateCcw size={14} /></button>}
</article>)}
</div>}
</section>
<section className="preview-shell">
<header className="preview-toolbar">
<div className="preview-title"><div><span className="status-dot" /><strong>{site.name}</strong></div><span className={`build-status ${site.status}`}>{busy ? <LoaderCircle className="spin" size={12} /> : <Check size={12} />}{busy ? "正在构建" : site.status === "ready" ? "已同步" : "需要检查"}</span></div>
<div className="device-toggle"><button className={device === "desktop" ? "active" : ""} onClick={() => setDevice("desktop")}><Laptop size={15} /></button><button className={device === "mobile" ? "active" : ""} onClick={() => setDevice("mobile")}><Smartphone size={15} /></button></div>
<div className="toolbar-actions"><button disabled={busy} onClick={() => rebuildMutation.mutate()} title="重新构建"><RefreshCw className={rebuildMutation.isPending ? "spin" : ""} size={15} /></button><a href={site.previewUrl} target="_blank" rel="noreferrer" title="新窗口打开"><ExternalLink size={15} /></a><button><MoreHorizontal size={16} /></button></div>
</header>
<div className="browser-stage">
<div className={`browser-frame ${device}`}>
<div className="browser-chrome"><div><i /><i /><i /></div><span>{site.previewUrl}</span><RefreshCw size={11} /></div>
<iframe key={iframeVersion} title={site.name + " 网站预览"} src={site.previewUrl + "?v=" + iframeVersion} />
{busy && <div className="preview-busy"><LoaderCircle className="spin" size={25} /><strong>正在生成新版本</strong><span>当前预览保持可用</span></div>}
</div>
</div>
<footer className="preview-footer"><span><Clock3 size={12} /> 最近更新 {formatDate(site.updatedAt)}</span><span><Palette size={12} /> Astro Static</span></footer>
</section>
</main>;
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
import type { ChatResult, CreateSiteInput, GitHistoryItem, SiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
headers: { "content-type": "application/json", ...options?.headers },
});
const data = await response.json().catch(() => ({})) as { error?: string };
if (!response.ok) throw new Error(data.error || "请求失败,请稍后重试");
return data as T;
}
export const api = {
health: () => request<{ status: string; agentMode: "model" | "local" }>("/api/health"),
sites: () => request<SiteInfo[]>("/api/sites"),
site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId),
createSite: (input: CreateSiteInput) => request<SiteInfo>("/api/sites", { method: "POST", body: JSON.stringify(input) }),
chat: (siteId: string, message: string) => request<ChatResult>("/api/sites/" + siteId + "/chat", { method: "POST", body: JSON.stringify({ message }) }),
history: (siteId: string) => request<GitHistoryItem[]>("/api/sites/" + siteId + "/history"),
rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }),
rollback: (siteId: string, commit: string) => request<{ commit: string; previewUrl: string }>("/api/sites/" + siteId + "/rollback", { method: "POST", body: JSON.stringify({ commit }) }),
};
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./styles.css";
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
createRoot(document.getElementById("root")!).render(
<StrictMode><QueryClientProvider client={queryClient}><App /></QueryClientProvider></StrictMode>,
);
:root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #191927; background: #f8f8fc; font-synthesis: none;
--primary:#7028ff; --primary-hover:#5c1fe0; --secondary:#536cff; --deep:#24105f;
--soft:#f3f0ff; --surface:#fff; --text:#191927; --text-2:#4f5260; --muted:#8b8e99;
--border:#e7e3f3; --divider:#eeeff4; --gradient:linear-gradient(135deg,#7028ff,#536cff);
}
*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh}button,input,textarea,select{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.58}.spin{animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
.splash{height:100vh;display:flex;align-items:center;justify-content:center;gap:22px;background:linear-gradient(180deg,#f3f0ff,#fff)}
.logo{display:flex;align-items:center;gap:11px;font-size:19px;font-weight:850;letter-spacing:-.035em}.logo-mark{display:grid;place-items:center;width:37px;height:37px;color:#fff;background:var(--gradient);border-radius:12px;box-shadow:0 8px 22px rgba(112,40,255,.25)}
.create-page{position:relative;overflow:hidden;min-height:100vh;background:linear-gradient(145deg,#fbfaff 0%,#f4f1ff 48%,#f8f8fc 100%)}.create-page::before{content:"";position:absolute;inset:0;background-image:linear-gradient(rgba(112,40,255,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(112,40,255,.035) 1px,transparent 1px);background-size:58px 58px;mask-image:linear-gradient(90deg,#000,transparent 72%)}
.create-orb{position:absolute;border-radius:50%;pointer-events:none}.orb-a{width:680px;height:680px;right:-330px;top:-280px;background:radial-gradient(circle,rgba(112,40,255,.18),transparent 67%)}.orb-b{width:540px;height:540px;left:-300px;bottom:-310px;background:radial-gradient(circle,rgba(83,108,255,.11),transparent 67%)}
.landing-header{position:relative;z-index:2;width:min(1380px,calc(100% - 64px));height:88px;margin:auto;display:flex;align-items:center;justify-content:space-between}.landing-status{display:flex;align-items:center;gap:8px;padding:8px 12px;color:var(--text-2);background:rgba(255,255,255,.72);border:1px solid rgba(231,227,243,.9);border-radius:999px;font-size:11px;font-weight:650;backdrop-filter:blur(14px)}.landing-status>span:first-child{width:7px;height:7px;border-radius:50%;background:#20b486;box-shadow:0 0 0 4px rgba(32,180,134,.12)}
.create-layout{position:relative;z-index:1;width:min(1320px,calc(100% - 64px));min-height:calc(100vh - 88px);margin:auto;display:grid;grid-template-columns:.92fr 1.08fr;gap:85px;align-items:center;padding:42px 0 72px}.create-intro{padding-left:8px}.back-button{display:flex;align-items:center;gap:7px;margin-bottom:38px;padding:0;color:var(--text-2);border:0;background:none;font-size:12px}.intro-tag{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;color:var(--primary);background:rgba(255,255,255,.8);border:1px solid #ded6ff;border-radius:999px;font-size:10px;font-weight:850;letter-spacing:.13em}.create-intro h1{max-width:620px;margin:25px 0 24px;font-size:clamp(51px,5.3vw,78px);line-height:1.07;letter-spacing:-.065em}.create-intro h1 span{display:inline-block;color:transparent;background:var(--gradient);background-clip:text}.create-intro>p{max-width:570px;margin:0;color:var(--text-2);font-size:17px;line-height:1.85}.feature-row{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:52px}.feature-row>div{padding:17px 13px;background:rgba(255,255,255,.62);border:1px solid rgba(231,227,243,.9);border-radius:16px}.feature-row div>span{display:grid;place-items:center;width:35px;height:35px;margin-bottom:13px;color:var(--primary);background:var(--soft);border-radius:10px}.feature-row strong,.feature-row small{display:block}.feature-row strong{font-size:12px;margin-bottom:6px}.feature-row small{color:var(--muted);font-size:10px;line-height:1.5}
.create-card-wrap{display:flex;justify-content:flex-end}.create-card{width:min(100%,650px);padding:35px 38px 25px;background:rgba(255,255,255,.91);border:1px solid rgba(255,255,255,.95);border-radius:26px;box-shadow:0 32px 90px rgba(36,16,95,.13),0 2px 6px rgba(36,16,95,.04);backdrop-filter:blur(24px)}.card-heading{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:27px}.card-heading span{color:var(--primary);font-size:10px;font-weight:800;letter-spacing:.12em}.card-heading h2{margin:7px 0 0;font-size:24px;letter-spacing:-.035em}.step-pill{padding:7px 10px;color:var(--muted);background:#f8f8fc;border-radius:9px;font-size:10px;font-weight:700}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:17px}.field{display:flex;flex-direction:column;gap:8px}.field.wide{grid-column:1/-1}.field>span{display:flex;justify-content:space-between;color:var(--text-2);font-size:11px;font-weight:700}.field small{color:var(--muted);font-weight:500}.field input,.field textarea{width:100%;padding:11px 13px;color:var(--text);background:#fbfbfd;border:1px solid var(--border);border-radius:10px;outline:none;font-size:13px;transition:.2s}.field textarea{resize:vertical;line-height:1.6}.field input:focus,.field textarea:focus{background:#fff;border-color:#ad8cff;box-shadow:0 0 0 3px rgba(112,40,255,.08)}.color-field{height:44px;display:flex;align-items:center;gap:11px;padding:5px 8px;background:#fbfbfd;border:1px solid var(--border);border-radius:10px}.color-field>input{width:31px;height:31px;padding:2px;border:0;background:none}.color-field>span{font-family:ui-monospace,SFMono-Regular,monospace;color:var(--text-2);font-size:11px}.color-swatches{display:flex;gap:7px;margin-left:auto}.color-swatches button{width:21px;height:21px;padding:0;border:2px solid white;border-radius:7px;box-shadow:0 0 0 1px var(--border)}.color-swatches button.active{box-shadow:0 0 0 2px var(--primary)}.create-submit{width:100%;height:51px;margin-top:24px;display:flex;align-items:center;justify-content:center;gap:11px;color:white;background:var(--gradient);border:0;border-radius:12px;box-shadow:0 13px 28px rgba(112,40,255,.24);font-size:13px;font-weight:800;transition:.2s}.create-submit:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 16px 34px rgba(112,40,255,.32)}.local-note{display:flex;align-items:center;justify-content:center;gap:6px;margin:14px 0 0;color:var(--muted);font-size:9px}.form-error{display:flex;align-items:center;gap:7px;margin-top:16px;padding:10px 12px;color:#b42318;background:#fff2f0;border-radius:9px;font-size:11px}
.workspace{height:100vh;display:grid;grid-template-columns:64px 372px 1fr;overflow:hidden;background:#eff0f5}.rail{display:flex;flex-direction:column;align-items:center;padding:15px 0;background:#fff;border-right:1px solid var(--divider);z-index:3}.rail .logo-mark{width:35px;height:35px}.rail-nav{display:flex;flex-direction:column;gap:9px;margin-top:42px}.rail button{display:grid;place-items:center;width:38px;height:38px;color:var(--muted);background:transparent;border:0;border-radius:11px}.rail button:hover,.rail button.active{color:var(--primary);background:var(--soft)}.rail-create{margin-top:auto!important;color:white!important;background:var(--gradient)!important;box-shadow:0 8px 20px rgba(112,40,255,.2)}
.control-panel{display:flex;flex-direction:column;min-width:0;background:#fff;border-right:1px solid var(--border);z-index:2}.site-switcher{position:relative;height:66px;padding:0 18px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--divider)}.site-avatar{display:grid;place-items:center;width:32px;height:32px;color:white;background:var(--gradient);border-radius:9px;font-size:12px;font-weight:800}.site-switcher select{flex:1;min-width:0;padding:0 20px 0 0;color:var(--text);background:transparent;border:0;appearance:none;outline:none;font-size:13px;font-weight:750}.site-switcher>svg{position:absolute;right:18px;color:var(--muted);pointer-events:none}.panel-tabs{height:49px;padding:0 14px;display:grid;grid-template-columns:1fr 1fr;gap:5px;align-items:center;border-bottom:1px solid var(--divider)}.panel-tabs button{height:35px;display:flex;align-items:center;justify-content:center;gap:7px;color:var(--muted);background:transparent;border:0;border-radius:9px;font-size:11px;font-weight:700}.panel-tabs button.active{color:var(--primary);background:var(--soft)}
.chat-scroll{flex:1;min-height:0;overflow:auto;padding:20px 17px 28px}.chat-date{text-align:center;color:#aaaeba;font-size:9px;margin-bottom:22px}.message{display:flex;gap:9px;margin-bottom:17px}.message.user{justify-content:flex-end}.message>div{max-width:86%}.message p{margin:0;padding:12px 14px;color:var(--text-2);background:#f6f6f9;border-radius:4px 13px 13px 13px;font-size:12px;line-height:1.65}.message.user p{color:white;background:var(--gradient);border-radius:13px 4px 13px 13px;box-shadow:0 8px 18px rgba(112,40,255,.15)}.message small{display:flex;align-items:center;gap:4px;margin:6px 2px 0;color:#9a9da8;font-size:9px}.agent-avatar{flex:0 0 auto;display:grid;place-items:center;width:28px;height:28px;color:var(--primary);background:var(--soft);border:1px solid #e2dbff;border-radius:9px}.agent-progress{display:flex;gap:9px;margin:8px 0 18px}.agent-progress>div{flex:1;padding:12px 13px;background:#faf9ff;border:1px solid #ebe6ff;border-radius:4px 13px 13px 13px}.agent-progress p{display:flex;align-items:center;gap:7px;margin:0 0 10px;color:var(--text-2);font-size:11px;font-weight:700}.agent-progress small{color:var(--muted);font-size:9px}.progress-track{height:3px;margin-bottom:8px;overflow:hidden;background:#ebe7f5;border-radius:9px}.progress-track i{display:block;width:45%;height:100%;background:var(--gradient);border-radius:9px;animation:progress 1.5s ease-in-out infinite}@keyframes progress{50%{transform:translateX(120%)}}.suggestions{display:flex;flex-direction:column;gap:7px;margin:25px 37px 0}.suggestions>span{margin-bottom:3px;color:var(--muted);font-size:9px}.suggestions button{display:flex;align-items:center;gap:7px;padding:9px 11px;text-align:left;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:9px;font-size:10px}.suggestions button:hover{color:var(--primary);border-color:#cdbdff;background:#faf9ff}
.composer{margin:0 14px 14px;padding:10px;background:#fff;border:1px solid #dcd8e8;border-radius:13px;box-shadow:0 5px 22px rgba(36,16,95,.07)}.composer textarea{width:100%;padding:2px 4px;resize:none;color:var(--text);background:transparent;border:0;outline:none;font-size:11px;line-height:1.55}.composer textarea::placeholder{color:#a5a7b0}.composer>div{display:flex;align-items:center;justify-content:space-between}.composer>div>span{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:9px}.online-dot{width:6px;height:6px;border-radius:50%;background:#20b486}.composer button{display:grid;place-items:center;width:29px;height:29px;color:white;background:var(--gradient);border:0;border-radius:8px}
.history-list{flex:1;overflow:auto;padding:22px 18px}.history-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:25px}.history-heading h3{margin:0 0 4px;font-size:14px}.history-heading p{margin:0;color:var(--muted);font-size:9px}.history-heading>span{display:grid;place-items:center;width:25px;height:25px;color:var(--primary);background:var(--soft);border-radius:8px;font-size:10px;font-weight:800}.history-list article{display:grid;grid-template-columns:24px 1fr 28px;gap:7px;min-height:69px}.timeline{display:flex;align-items:center;flex-direction:column}.timeline i{display:grid;place-items:center;width:17px;height:17px;color:white;background:#d6d7df;border:3px solid white;box-shadow:0 0 0 1px #d6d7df;border-radius:50%;font-style:normal}.history-list article.current .timeline i{background:var(--primary);box-shadow:0 0 0 1px var(--primary)}.timeline span{width:1px;flex:1;background:var(--divider)}.commit-info{padding-bottom:18px}.commit-info>div{display:flex;align-items:center;gap:7px}.commit-info strong{font-size:11px;line-height:1.4}.commit-info em{padding:2px 5px;color:var(--primary);background:var(--soft);border-radius:5px;font-size:8px;font-style:normal}.commit-info p{display:flex;align-items:center;gap:5px;margin:5px 0 0;color:var(--muted);font-size:9px}.commit-info code{font-size:9px}.history-list article>button{display:grid;place-items:center;width:26px;height:26px;color:var(--muted);background:white;border:1px solid var(--border);border-radius:8px}.history-list article>button:hover{color:var(--primary);background:var(--soft)}
.preview-shell{min-width:0;display:flex;flex-direction:column}.preview-toolbar{height:66px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:0 20px;background:#fff;border-bottom:1px solid var(--border)}.preview-title{display:flex;align-items:center;gap:13px}.preview-title>div{display:flex;align-items:center;gap:8px}.preview-title strong{font-size:12px}.status-dot{width:7px;height:7px;border-radius:50%;background:#20b486;box-shadow:0 0 0 4px rgba(32,180,134,.1)}.build-status{display:flex;align-items:center;gap:4px;padding:5px 7px;color:#188866;background:#ebfbf5;border-radius:7px;font-size:8px;font-weight:750}.build-status.failed{color:#b42318;background:#fff0ee}.device-toggle{display:flex;padding:3px;background:#f2f2f6;border-radius:8px}.device-toggle button{display:grid;place-items:center;width:31px;height:27px;color:var(--muted);background:transparent;border:0;border-radius:6px}.device-toggle button.active{color:var(--text);background:#fff;box-shadow:0 2px 7px rgba(36,16,95,.1)}.toolbar-actions{display:flex;justify-content:flex-end;gap:7px}.toolbar-actions button,.toolbar-actions a{display:grid;place-items:center;width:31px;height:31px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:8px}.toolbar-actions button:hover,.toolbar-actions a:hover{color:var(--primary);background:var(--soft)}
.browser-stage{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:25px;background:#eff0f5;overflow:auto}.browser-frame{position:relative;width:100%;height:100%;overflow:hidden;background:#fff;border:1px solid #dfe0e7;border-radius:11px;box-shadow:0 15px 45px rgba(30,24,53,.1);transition:.35s ease}.browser-frame.mobile{width:390px;max-height:760px}.browser-chrome{height:31px;display:grid;grid-template-columns:70px 1fr 70px;align-items:center;padding:0 10px;background:#fafafd;border-bottom:1px solid #ebeaf0}.browser-chrome>div{display:flex;gap:5px}.browser-chrome i{width:7px;height:7px;border-radius:50%;background:#e1e1e7}.browser-chrome i:first-child{background:#ff8b86}.browser-chrome i:nth-child(2){background:#ffd36b}.browser-chrome i:nth-child(3){background:#71d19a}.browser-chrome span{justify-self:center;width:min(390px,80%);padding:4px 10px;text-align:center;color:#9a9ca6;background:#f0f0f4;border-radius:5px;font-size:8px}.browser-chrome>svg{justify-self:end;color:#a7a9b3}.browser-frame iframe{display:block;width:100%;height:calc(100% - 31px);border:0;background:#fff}.preview-busy{position:absolute;inset:31px 0 0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;color:var(--primary);background:rgba(248,248,252,.72);backdrop-filter:blur(4px)}.preview-busy strong{color:var(--text);font-size:13px}.preview-busy span{color:var(--muted);font-size:10px}.preview-footer{height:29px;padding:0 22px;display:flex;align-items:center;justify-content:space-between;color:var(--muted);background:#fff;border-top:1px solid var(--divider);font-size:8px}.preview-footer span{display:flex;align-items:center;gap:5px}
@media(max-width:1100px){.create-layout{grid-template-columns:1fr;gap:48px;padding-top:40px}.create-intro{text-align:center}.create-intro>p,.create-intro h1{margin-left:auto;margin-right:auto}.intro-tag{margin:auto}.feature-row{max-width:620px;margin-left:auto;margin-right:auto}.create-card-wrap{justify-content:center}.workspace{grid-template-columns:58px 340px 1fr}}
@media(max-width: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}}
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"noEmit": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: { "/api": "http://127.0.0.1:3100" },
},
});
{
"name": "webagent",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@11.7.0",
"scripts": {
"dev": "pnpm --parallel --stream --filter @webagent/backend --filter @webagent/frontend dev",
"build": "pnpm --filter @webagent/shared build && pnpm --filter @webagent/astro-template build && pnpm --filter @webagent/backend build && pnpm --filter @webagent/frontend build",
"typecheck": "pnpm -r typecheck",
"start": "pnpm --filter @webagent/backend start"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
packages:
- frontend
- backend
- shared
- astro-template
allowBuilds:
esbuild: true
sharp: true
{
"name": "@webagent/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}
export type SiteStatus = "creating" | "ready" | "building" | "failed";
export interface CreateSiteInput {
name: string;
industry: string;
description: string;
services: string[];
email: string;
phone?: string;
brandColor: string;
}
export interface SiteInfo {
siteId: string;
name: string;
industry: string;
status: SiteStatus;
templateVersion: string;
previewPort: number;
previewUrl: string;
currentCommit: string;
createdAt: string;
updatedAt: string;
lastError?: string;
}
export interface SitePatch {
summary: string;
operations: Array<
| { type: "write"; path: string; content: string }
| { type: "delete"; path: string }
>;
}
export interface GitHistoryItem {
hash: string;
shortHash: string;
message: string;
author: string;
date: string;
current: boolean;
}
export interface ChatResult {
summary: string;
commit: string;
previewUrl: string;
changedFiles: string[];
mode: "model" | "local";
}
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
}
}
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