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"
}
}
This diff is collapsed.
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>,
);
This diff is collapsed.
{
"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 diff is collapsed.
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