Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in / Register
Toggle navigation
W
WebAgent
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xuchentao
WebAgent
Commits
2d6e5007
Commit
2d6e5007
authored
Jul 15, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: separate preview and production workflows
parent
6631f8f5
Changes
20
Show whitespace changes
Inline
Side-by-side
Showing
20 changed files
with
444 additions
and
77 deletions
+444
-77
.env.example
.env.example
+5
-0
README.md
README.md
+25
-1
astro.config.mjs
astro-template/astro.config.mjs
+1
-0
build-manager.ts
backend/src/build/build-manager.ts
+33
-6
config.ts
backend/src/config.ts
+21
-2
git-manager.ts
backend/src/git/git-manager.ts
+1
-1
server.ts
backend/src/server.ts
+5
-0
create-site.ts
backend/src/sites/create-site.ts
+6
-5
ensure-environment-config.ts
backend/src/sites/ensure-environment-config.ts
+15
-0
site-agent-service.ts
backend/src/sites/site-agent-service.ts
+5
-5
site-repository.ts
backend/src/sites/site-repository.ts
+44
-4
site-version-service.ts
backend/src/sites/site-version-service.ts
+42
-8
README.md
deploy/nginx/README.md
+23
-0
webagent.conf
deploy/nginx/webagent.conf
+45
-0
storage-convention.md
docs/storage-convention.md
+18
-11
App.tsx
frontend/src/App.tsx
+124
-24
api.ts
frontend/src/api.ts
+5
-2
styles.css
frontend/src/styles.css
+8
-7
vite.config.ts
frontend/vite.config.ts
+5
-1
index.ts
shared/src/index.ts
+13
-0
No files found.
.env.example
View file @
2d6e5007
PORT=3100
PORT=3100
HOST=127.0.0.1
HOST=127.0.0.1
FRONTEND_ORIGIN=http://localhost
# 相对路径以 WebAgent 项目根目录为基准,也支持 /data/WebAgent-sites 等绝对路径。
WEBAGENT_SITES_DIR=../WebAgent-sites
# 已上线的静态产物独立保存,不会被测试环境构建覆盖。
WEBAGENT_PRODUCTION_DIR=../WebAgent-production
# 可选:任意兼容 OpenAI Chat Completions 的模型服务。
# 可选:任意兼容 OpenAI Chat Completions 的模型服务。
# 默认示例使用国内可访问的 DeepSeek;也可以替换为企业内部网关,
# 默认示例使用国内可访问的 DeepSeek;也可以替换为企业内部网关,
# 或阿里云百炼兼容地址:https://dashscope.aliyuncs.com/compatible-mode/v1
# 或阿里云百炼兼容地址:https://dashscope.aliyuncs.com/compatible-mode/v1
...
...
README.md
View file @
2d6e5007
...
@@ -13,11 +13,33 @@ pnpm dev
...
@@ -13,11 +13,33 @@ pnpm dev
-
控制台:
<http://localhost:5173>
-
控制台:
<http://localhost:5173>
-
API:
<http://localhost:3100>
-
API:
<http://localhost:3100>
-
站点预览端口:
`4300-4399`
-
站点预览端口:
`4300-4399`
-
Nginx 统一入口:
<http://localhost>
-
测试预览链接:
`http://你的主机/previews/site_xxx/`
-
生产站点链接:
`http://你的主机/sites/site_xxx/`
(用户手动发布后可用)
生成的网站默认保存在 WebAgent 项目同级的
`WebAgent-sites/`
。配置支持相对路径和绝对路径;相对路径以 WebAgent 项目根目录为基准:
```
bash
WEBAGENT_SITES_DIR
=
../WebAgent-sites
WEBAGENT_PRODUCTION_DIR
=
../WebAgent-production
# 或 WEBAGENT_SITES_DIR=/data/WebAgent-sites
```
未配置模型密钥时,内置演示指令支持修改主题颜色、增加服务卡片、增加企业优势等常见场景。配置
`OPENAI_API_KEY`
后,会使用兼容 OpenAI Chat Completions 的接口生成受约束的
`SitePatch`
。示例默认使用国内可访问的 DeepSeek API,也可换成阿里云百炼或企业内部兼容网关。
未配置模型密钥时,内置演示指令支持修改主题颜色、增加服务卡片、增加企业优势等常见场景。配置
`OPENAI_API_KEY`
后,会使用兼容 OpenAI Chat Completions 的接口生成受约束的
`SitePatch`
。示例默认使用国内可访问的 DeepSeek API,也可换成阿里云百炼或企业内部兼容网关。
项目的
`.npmrc`
只对当前仓库生效,依赖从
`registry.npmmirror.com`
安装,pnpm store 写入已忽略的
`.runtime/pnpm-store/`
,不会污染全局 npm 配置。
项目的
`.npmrc`
只对当前仓库生效,依赖从
`registry.npmmirror.com`
安装,pnpm store 写入已忽略的
`.runtime/pnpm-store/`
,不会污染全局 npm 配置。
## Nginx 访问架构
生产构建后,Nginx 在 80 端口提供统一入口:
-
`/`
:React 控制台静态文件
-
`/api/`
:反向代理到
`127.0.0.1:3100`
-
`/previews/site_xxx/`
:当前代码的测试预览,每次成功构建后更新
-
`/sites/site_xxx/`
:生产站点,只有用户执行“发布上线”后更新
当前 Intel macOS Homebrew 配置位于
[
deploy/nginx/webagent.conf
](
deploy/nginx/webagent.conf
)
。服务器路径变化时,需要同步调整配置中的
`root`
和
`alias`
绝对路径。
## MVP 能力
## MVP 能力
-
从 Astro 模板创建独立企业官网
-
从 Astro 模板创建独立企业官网
...
@@ -25,7 +47,9 @@ pnpm dev
...
@@ -25,7 +47,9 @@ pnpm dev
-
在隔离 Git Worktree 中应用 Agent 修改
-
在隔离 Git Worktree 中应用 Agent 修改
-
构建通过后才写入正式项目并创建提交
-
构建通过后才写入正式项目并创建提交
-
本地 iframe 预览与自动刷新
-
本地 iframe 预览与自动刷新
-
测试环境与生产环境使用独立目录和独立链接
-
用户确认后手动发布,并记录生产版本 commit 与发布时间
-
浏览 Git 历史并安全回滚到任意版本
-
浏览 Git 历史并安全回滚到任意版本
-
所有运行数据隔离在
`.runtime/`
,不提交到 WebAgent 仓库
-
生成站点保存在独立的可配置目录,构建缓存等临时数据隔离在
`.runtime/`
详细目录约束见
[
docs/storage-convention.md
](
docs/storage-convention.md
)
,Agent 写入规则见
[
docs/site-patch-schema.md
](
docs/site-patch-schema.md
)
。
详细目录约束见
[
docs/storage-convention.md
](
docs/storage-convention.md
)
,Agent 写入规则见
[
docs/site-patch-schema.md
](
docs/site-patch-schema.md
)
。
astro-template/astro.config.mjs
View file @
2d6e5007
...
@@ -2,5 +2,6 @@ import { defineConfig } from "astro/config";
...
@@ -2,5 +2,6 @@ import { defineConfig } from "astro/config";
export
default
defineConfig
({
export
default
defineConfig
({
output
:
"static"
,
output
:
"static"
,
base
:
process
.
env
.
SITE_BASE_PATH
||
"/"
,
server
:
{
host
:
"127.0.0.1"
},
server
:
{
host
:
"127.0.0.1"
},
});
});
backend/src/build/build-manager.ts
View file @
2d6e5007
import
path
from
"node:path"
;
import
path
from
"node:path"
;
import
{
cp
,
mkdir
,
rm
,
writeFile
}
from
"node:fs/promises"
;
import
crypto
from
"node:crypto"
;
import
{
cp
,
mkdir
,
rename
,
rm
,
stat
,
writeFile
}
from
"node:fs/promises"
;
import
{
execa
}
from
"execa"
;
import
{
execa
}
from
"execa"
;
import
{
runtimePaths
}
from
"../config.js"
;
import
{
config
,
runtimePaths
}
from
"../config.js"
;
export
class
BuildError
extends
Error
{
export
class
BuildError
extends
Error
{
constructor
(
message
:
string
,
public
readonly
output
:
string
)
{
super
(
message
);
}
constructor
(
message
:
string
,
public
readonly
output
:
string
)
{
super
(
message
);
}
}
}
export
class
BuildManager
{
export
class
BuildManager
{
async
build
(
projectPath
:
string
,
taskId
:
string
):
Promise
<
string
>
{
async
build
(
projectPath
:
string
,
taskId
:
string
,
basePath
:
string
):
Promise
<
string
>
{
await
mkdir
(
runtimePaths
.
logs
,
{
recursive
:
true
});
await
mkdir
(
runtimePaths
.
logs
,
{
recursive
:
true
});
let
output
=
""
;
let
output
=
""
;
try
{
try
{
...
@@ -17,7 +18,10 @@ export class BuildManager {
...
@@ -17,7 +18,10 @@ export class BuildManager {
env
:
{
...
process
.
env
,
CI
:
"true"
},
env
:
{
...
process
.
env
,
CI
:
"true"
},
});
});
output
+=
install
.
stdout
+
"
\n
"
+
install
.
stderr
+
"
\n
"
;
output
+=
install
.
stdout
+
"
\n
"
+
install
.
stderr
+
"
\n
"
;
const
build
=
await
execa
(
"pnpm"
,
[
"run"
,
"build"
],
{
cwd
:
projectPath
});
const
build
=
await
execa
(
"pnpm"
,
[
"run"
,
"build"
],
{
cwd
:
projectPath
,
env
:
{
...
process
.
env
,
SITE_BASE_PATH
:
basePath
.
replace
(
/
\/
$/
,
""
)
||
"/"
},
});
output
+=
build
.
stdout
+
"
\n
"
+
build
.
stderr
;
output
+=
build
.
stdout
+
"
\n
"
+
build
.
stderr
;
await
writeFile
(
path
.
join
(
runtimePaths
.
logs
,
taskId
+
".log"
),
output
,
"utf8"
);
await
writeFile
(
path
.
join
(
runtimePaths
.
logs
,
taskId
+
".log"
),
output
,
"utf8"
);
return
path
.
join
(
projectPath
,
"dist"
);
return
path
.
join
(
projectPath
,
"dist"
);
...
@@ -30,8 +34,31 @@ export class BuildManager {
...
@@ -30,8 +34,31 @@ export class BuildManager {
}
}
}
}
async
publish
(
siteId
:
string
,
distPath
:
string
):
Promise
<
string
>
{
async
publishPreview
(
siteId
:
string
,
distPath
:
string
):
Promise
<
string
>
{
const
target
=
path
.
join
(
runtimePaths
.
previews
,
siteId
);
return
this
.
publishTo
(
path
.
join
(
runtimePaths
.
previews
,
siteId
),
distPath
);
}
async
publishProduction
(
siteId
:
string
,
distPath
:
string
):
Promise
<
string
>
{
const
target
=
path
.
join
(
config
.
productionDir
,
siteId
);
const
suffix
=
crypto
.
randomBytes
(
4
).
toString
(
"hex"
);
const
staging
=
target
+
".next-"
+
suffix
;
const
previous
=
target
+
".previous-"
+
suffix
;
await
mkdir
(
path
.
dirname
(
target
),
{
recursive
:
true
});
await
cp
(
distPath
,
staging
,
{
recursive
:
true
});
const
hasCurrent
=
await
stat
(
target
).
then
(()
=>
true
).
catch
(()
=>
false
);
if
(
hasCurrent
)
await
rename
(
target
,
previous
);
try
{
await
rename
(
staging
,
target
);
}
catch
(
error
)
{
if
(
hasCurrent
)
await
rename
(
previous
,
target
);
await
rm
(
staging
,
{
recursive
:
true
,
force
:
true
});
throw
error
;
}
await
rm
(
previous
,
{
recursive
:
true
,
force
:
true
});
return
target
;
}
private
async
publishTo
(
target
:
string
,
distPath
:
string
):
Promise
<
string
>
{
await
rm
(
target
,
{
recursive
:
true
,
force
:
true
});
await
rm
(
target
,
{
recursive
:
true
,
force
:
true
});
await
mkdir
(
path
.
dirname
(
target
),
{
recursive
:
true
});
await
mkdir
(
path
.
dirname
(
target
),
{
recursive
:
true
});
await
cp
(
distPath
,
target
,
{
recursive
:
true
});
await
cp
(
distPath
,
target
,
{
recursive
:
true
});
...
...
backend/src/config.ts
View file @
2d6e5007
...
@@ -3,11 +3,22 @@ import { fileURLToPath } from "node:url";
...
@@ -3,11 +3,22 @@ import { fileURLToPath } from "node:url";
import
"dotenv/config"
;
import
"dotenv/config"
;
const
currentDir
=
path
.
dirname
(
fileURLToPath
(
import
.
meta
.
url
));
const
currentDir
=
path
.
dirname
(
fileURLToPath
(
import
.
meta
.
url
));
const
rootDir
=
path
.
resolve
(
currentDir
,
"../.."
);
const
configuredSitesDir
=
process
.
env
.
WEBAGENT_SITES_DIR
?.
trim
()
||
"../WebAgent-sites"
;
const
sitesDir
=
path
.
isAbsolute
(
configuredSitesDir
)
?
configuredSitesDir
:
path
.
resolve
(
rootDir
,
configuredSitesDir
);
const
configuredProductionDir
=
process
.
env
.
WEBAGENT_PRODUCTION_DIR
?.
trim
()
||
"../WebAgent-production"
;
const
productionDir
=
path
.
isAbsolute
(
configuredProductionDir
)
?
configuredProductionDir
:
path
.
resolve
(
rootDir
,
configuredProductionDir
);
export
const
config
=
{
export
const
config
=
{
rootDir
:
path
.
resolve
(
currentDir
,
"../.."
)
,
rootDir
,
runtimeDir
:
path
.
resolve
(
currentDir
,
"../../.runtime"
),
runtimeDir
:
path
.
resolve
(
currentDir
,
"../../.runtime"
),
templateDir
:
path
.
resolve
(
currentDir
,
"../../astro-template"
),
templateDir
:
path
.
resolve
(
currentDir
,
"../../astro-template"
),
sitesDir
,
productionDir
,
host
:
process
.
env
.
HOST
||
"127.0.0.1"
,
host
:
process
.
env
.
HOST
||
"127.0.0.1"
,
port
:
Number
(
process
.
env
.
PORT
||
3100
),
port
:
Number
(
process
.
env
.
PORT
||
3100
),
frontendOrigin
:
process
.
env
.
FRONTEND_ORIGIN
||
"http://localhost:5173"
,
frontendOrigin
:
process
.
env
.
FRONTEND_ORIGIN
||
"http://localhost:5173"
,
...
@@ -19,10 +30,18 @@ export const config = {
...
@@ -19,10 +30,18 @@ export const config = {
};
};
export
const
runtimePaths
=
{
export
const
runtimePaths
=
{
sites
:
path
.
join
(
config
.
runtimeDir
,
"sites"
)
,
sites
:
config
.
sitesDir
,
builds
:
path
.
join
(
config
.
runtimeDir
,
"builds"
),
builds
:
path
.
join
(
config
.
runtimeDir
,
"builds"
),
previews
:
path
.
join
(
config
.
runtimeDir
,
"previews"
),
previews
:
path
.
join
(
config
.
runtimeDir
,
"previews"
),
uploads
:
path
.
join
(
config
.
runtimeDir
,
"uploads"
),
uploads
:
path
.
join
(
config
.
runtimeDir
,
"uploads"
),
pnpmStore
:
path
.
join
(
config
.
runtimeDir
,
"pnpm-store"
),
pnpmStore
:
path
.
join
(
config
.
runtimeDir
,
"pnpm-store"
),
logs
:
path
.
join
(
config
.
runtimeDir
,
"logs"
),
logs
:
path
.
join
(
config
.
runtimeDir
,
"logs"
),
};
};
export
function
getPublicPreviewUrl
(
siteId
:
string
):
string
{
return
`/previews/
${
siteId
}
/`
;
}
export
function
getPublicProductionUrl
(
siteId
:
string
):
string
{
return
`/sites/
${
siteId
}
/`
;
}
backend/src/git/git-manager.ts
View file @
2d6e5007
...
@@ -65,7 +65,7 @@ export class GitManager {
...
@@ -65,7 +65,7 @@ export class GitManager {
async
restoreAsCommit
(
projectPath
:
string
,
sourceCommit
:
string
):
Promise
<
string
>
{
async
restoreAsCommit
(
projectPath
:
string
,
sourceCommit
:
string
):
Promise
<
string
>
{
await
this
.
assertCommit
(
projectPath
,
sourceCommit
);
await
this
.
assertCommit
(
projectPath
,
sourceCommit
);
await
execa
(
"git"
,
[
"restore"
,
"--source"
,
sourceCommit
,
"--staged"
,
"--worktree"
,
"--"
,
"."
],
{
cwd
:
projectPath
});
await
execa
(
"git"
,
[
"restore"
,
"--source"
,
sourceCommit
,
"--staged"
,
"--worktree"
,
"--"
,
"."
,
":(exclude)astro.config.mjs"
],
{
cwd
:
projectPath
});
return
this
.
commit
(
projectPath
,
"Rollback to "
+
sourceCommit
.
slice
(
0
,
7
));
return
this
.
commit
(
projectPath
,
"Rollback to "
+
sourceCommit
.
slice
(
0
,
7
));
}
}
...
...
backend/src/server.ts
View file @
2d6e5007
...
@@ -33,6 +33,7 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/chat", async (reque
...
@@ -33,6 +33,7 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/chat", async (reque
const
body
=
chatSchema
.
parse
(
request
.
body
);
return
siteAgent
.
execute
(
request
.
params
.
siteId
,
body
.
message
);
const
body
=
chatSchema
.
parse
(
request
.
body
);
return
siteAgent
.
execute
(
request
.
params
.
siteId
,
body
.
message
);
});
});
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/build"
,
async
(
request
)
=>
versions
.
rebuild
(
request
.
params
.
siteId
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/build"
,
async
(
request
)
=>
versions
.
rebuild
(
request
.
params
.
siteId
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/publish"
,
async
(
request
)
=>
versions
.
publish
(
request
.
params
.
siteId
));
app
.
get
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/history"
,
async
(
request
)
=>
git
.
history
(
sites
.
getProjectPath
(
request
.
params
.
siteId
)));
app
.
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
)
=>
{
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
);
const
body
=
rollbackSchema
.
parse
(
request
.
body
);
return
versions
.
rollback
(
request
.
params
.
siteId
,
body
.
commit
);
...
@@ -53,6 +54,10 @@ await rm(runtimePaths.builds, { recursive: true, force: true });
...
@@ -53,6 +54,10 @@ await rm(runtimePaths.builds, { recursive: true, force: true });
await
mkdir
(
runtimePaths
.
builds
,
{
recursive
:
true
});
await
mkdir
(
runtimePaths
.
builds
,
{
recursive
:
true
});
for
(
const
site
of
await
sites
.
list
())
{
for
(
const
site
of
await
sites
.
list
())
{
await
git
.
pruneWorktrees
(
sites
.
getProjectPath
(
site
.
siteId
));
await
git
.
pruneWorktrees
(
sites
.
getProjectPath
(
site
.
siteId
));
if
(
site
.
environmentVersion
!==
3
)
{
await
versions
.
rebuild
(
site
.
siteId
).
catch
((
error
)
=>
app
.
log
.
warn
(
error
));
continue
;
}
const
previewPath
=
path
.
join
(
runtimePaths
.
previews
,
site
.
siteId
);
const
previewPath
=
path
.
join
(
runtimePaths
.
previews
,
site
.
siteId
);
if
(
await
access
(
previewPath
).
then
(()
=>
true
).
catch
(()
=>
false
))
{
if
(
await
access
(
previewPath
).
then
(()
=>
true
).
catch
(()
=>
false
))
{
await
previews
.
start
(
site
.
siteId
,
previewPath
,
site
.
previewPort
)
await
previews
.
start
(
site
.
siteId
,
previewPath
,
site
.
previewPort
)
...
...
backend/src/sites/create-site.ts
View file @
2d6e5007
...
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
...
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
import
path
from
"node:path"
;
import
path
from
"node:path"
;
import
{
cp
,
mkdir
,
readFile
,
rm
,
writeFile
}
from
"node:fs/promises"
;
import
{
cp
,
mkdir
,
readFile
,
rm
,
writeFile
}
from
"node:fs/promises"
;
import
type
{
CreateSiteInput
,
SiteInfo
}
from
"@webagent/shared"
;
import
type
{
CreateSiteInput
,
SiteInfo
}
from
"@webagent/shared"
;
import
{
config
}
from
"../config.js"
;
import
{
config
,
getPublicPreviewUrl
}
from
"../config.js"
;
import
{
GitManager
}
from
"../git/git-manager.js"
;
import
{
GitManager
}
from
"../git/git-manager.js"
;
import
{
BuildManager
}
from
"../build/build-manager.js"
;
import
{
BuildManager
}
from
"../build/build-manager.js"
;
import
{
PreviewProcessManager
}
from
"../preview/preview-process-manager.js"
;
import
{
PreviewProcessManager
}
from
"../preview/preview-process-manager.js"
;
...
@@ -23,8 +23,9 @@ export class CreateSiteService {
...
@@ -23,8 +23,9 @@ export class CreateSiteService {
const
previewPort
=
await
this
.
sites
.
allocatePreviewPort
();
const
previewPort
=
await
this
.
sites
.
allocatePreviewPort
();
const
now
=
new
Date
().
toISOString
();
const
now
=
new
Date
().
toISOString
();
const
site
:
SiteInfo
=
{
const
site
:
SiteInfo
=
{
siteId
,
name
:
input
.
name
,
industry
:
input
.
industry
,
status
:
"creating"
,
templateVersion
:
"0.1.0"
,
siteId
,
name
:
input
.
name
,
industry
:
input
.
industry
,
status
:
"creating"
,
templateVersion
:
"0.1.0"
,
environmentVersion
:
3
,
previewPort
,
previewUrl
:
"http://localhost:"
+
previewPort
,
currentCommit
:
""
,
createdAt
:
now
,
updatedAt
:
now
,
previewPort
,
previewUrl
:
getPublicPreviewUrl
(
siteId
),
currentCommit
:
""
,
publishStatus
:
"unpublished"
,
createdAt
:
now
,
updatedAt
:
now
,
};
};
await
mkdir
(
projectPath
,
{
recursive
:
true
});
await
mkdir
(
projectPath
,
{
recursive
:
true
});
await
this
.
sites
.
save
(
site
);
await
this
.
sites
.
save
(
site
);
...
@@ -42,10 +43,10 @@ export class CreateSiteService {
...
@@ -42,10 +43,10 @@ export class CreateSiteService {
const
themePath
=
path
.
join
(
projectPath
,
"src/styles/theme.css"
);
const
themePath
=
path
.
join
(
projectPath
,
"src/styles/theme.css"
);
const
theme
=
(
await
readFile
(
themePath
,
"utf8"
)).
replaceAll
(
"#7028ff"
,
input
.
brandColor
.
toLowerCase
());
const
theme
=
(
await
readFile
(
themePath
,
"utf8"
)).
replaceAll
(
"#7028ff"
,
input
.
brandColor
.
toLowerCase
());
await
writeFile
(
themePath
,
theme
,
"utf8"
);
await
writeFile
(
themePath
,
theme
,
"utf8"
);
const
dist
=
await
this
.
builds
.
build
(
projectPath
,
"create_"
+
siteId
);
const
dist
=
await
this
.
builds
.
build
(
projectPath
,
"create_"
+
siteId
,
getPublicPreviewUrl
(
siteId
)
);
const
commit
=
await
this
.
git
.
init
(
projectPath
);
const
commit
=
await
this
.
git
.
init
(
projectPath
);
await
this
.
sites
.
update
(
siteId
,
{
currentCommit
:
commit
});
await
this
.
sites
.
update
(
siteId
,
{
currentCommit
:
commit
});
const
published
=
await
this
.
builds
.
publish
(
siteId
,
dist
);
const
published
=
await
this
.
builds
.
publish
Preview
(
siteId
,
dist
);
await
this
.
previews
.
start
(
siteId
,
published
,
previewPort
);
await
this
.
previews
.
start
(
siteId
,
published
,
previewPort
);
return
await
this
.
sites
.
update
(
siteId
,
{
status
:
"ready"
,
currentCommit
:
commit
,
lastError
:
undefined
});
return
await
this
.
sites
.
update
(
siteId
,
{
status
:
"ready"
,
currentCommit
:
commit
,
lastError
:
undefined
});
}
catch
(
error
)
{
}
catch
(
error
)
{
...
...
backend/src/sites/ensure-environment-config.ts
0 → 100644
View file @
2d6e5007
import
path
from
"node:path"
;
import
{
readFile
,
writeFile
}
from
"node:fs/promises"
;
export
async
function
ensureEnvironmentConfig
(
projectPath
:
string
):
Promise
<
boolean
>
{
const
configPath
=
path
.
join
(
projectPath
,
"astro.config.mjs"
);
const
content
=
await
readFile
(
configPath
,
"utf8"
);
if
(
content
.
includes
(
"process.env.SITE_BASE_PATH"
))
return
false
;
const
next
=
content
.
replace
(
/
(
output:
\s
*
[
"'
]
static
[
"'
]
,
?)
/
,
'$1
\
n base: process.env.SITE_BASE_PATH || "/",'
,
);
if
(
next
===
content
)
throw
new
Error
(
"无法迁移站点 Astro 环境路径配置"
);
await
writeFile
(
configPath
,
next
,
"utf8"
);
return
true
;
}
backend/src/sites/site-agent-service.ts
View file @
2d6e5007
import
crypto
from
"node:crypto"
;
import
crypto
from
"node:crypto"
;
import
path
from
"node:path"
;
import
path
from
"node:path"
;
import
type
{
ChatResult
,
SitePatch
}
from
"@webagent/shared"
;
import
type
{
ChatResult
,
SitePatch
}
from
"@webagent/shared"
;
import
{
runtimePaths
}
from
"../config.js"
;
import
{
getPublicPreviewUrl
,
runtimePaths
}
from
"../config.js"
;
import
{
AgentLoop
}
from
"../agent/agent-loop.js"
;
import
{
AgentLoop
}
from
"../agent/agent-loop.js"
;
import
{
BuildError
,
BuildManager
}
from
"../build/build-manager.js"
;
import
{
BuildError
,
BuildManager
}
from
"../build/build-manager.js"
;
import
{
GitManager
}
from
"../git/git-manager.js"
;
import
{
GitManager
}
from
"../git/git-manager.js"
;
...
@@ -31,7 +31,7 @@ export class SiteAgentService {
...
@@ -31,7 +31,7 @@ export class SiteAgentService {
await
applyPatch
(
workspace
,
generated
.
patch
);
await
applyPatch
(
workspace
,
generated
.
patch
);
let
dist
:
string
;
let
dist
:
string
;
try
{
try
{
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
);
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
getPublicPreviewUrl
(
siteId
)
);
}
catch
(
error
)
{
}
catch
(
error
)
{
if
(
!
(
error
instanceof
BuildError
)
||
generated
.
mode
!==
"model"
)
throw
error
;
if
(
!
(
error
instanceof
BuildError
)
||
generated
.
mode
!==
"model"
)
throw
error
;
const
repair
=
await
this
.
agent
.
repair
(
workspace
,
message
,
error
.
output
);
const
repair
=
await
this
.
agent
.
repair
(
workspace
,
message
,
error
.
output
);
...
@@ -42,14 +42,14 @@ export class SiteAgentService {
...
@@ -42,14 +42,14 @@ export class SiteAgentService {
);
);
if
(
mergedOperations
.
size
>
5
)
throw
new
Error
(
"自动修复后的修改文件总数超过 5 个,任务已安全取消"
);
if
(
mergedOperations
.
size
>
5
)
throw
new
Error
(
"自动修复后的修改文件总数超过 5 个,任务已安全取消"
);
generated
.
patch
=
{
summary
:
repair
.
summary
,
operations
:
[...
mergedOperations
.
values
()]
};
generated
.
patch
=
{
summary
:
repair
.
summary
,
operations
:
[...
mergedOperations
.
values
()]
};
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
+
"_repair"
);
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
+
"_repair"
,
getPublicPreviewUrl
(
siteId
)
);
}
}
const
changedFiles
=
generated
.
patch
.
operations
.
map
((
item
)
=>
item
.
path
);
const
changedFiles
=
generated
.
patch
.
operations
.
map
((
item
)
=>
item
.
path
);
const
worktreeCommit
=
await
this
.
git
.
commitWorktree
(
workspace
,
"Agent: "
+
generated
.
patch
.
summary
,
changedFiles
);
const
worktreeCommit
=
await
this
.
git
.
commitWorktree
(
workspace
,
"Agent: "
+
generated
.
patch
.
summary
,
changedFiles
);
const
commit
=
await
this
.
git
.
cherryPick
(
projectPath
,
worktreeCommit
);
const
commit
=
await
this
.
git
.
cherryPick
(
projectPath
,
worktreeCommit
);
const
published
=
await
this
.
builds
.
publish
(
siteId
,
dist
);
const
published
=
await
this
.
builds
.
publish
Preview
(
siteId
,
dist
);
await
this
.
previews
.
start
(
siteId
,
published
,
site
.
previewPort
);
await
this
.
previews
.
start
(
siteId
,
published
,
site
.
previewPort
);
await
this
.
sites
.
update
(
siteId
,
{
status
:
"ready"
,
currentCommit
:
commit
,
lastError
:
undefined
});
await
this
.
sites
.
update
(
siteId
,
{
status
:
"ready"
,
currentCommit
:
commit
,
environmentVersion
:
3
,
lastError
:
undefined
});
return
{
summary
:
generated
.
patch
.
summary
,
commit
,
previewUrl
:
site
.
previewUrl
,
changedFiles
,
mode
:
generated
.
mode
};
return
{
summary
:
generated
.
patch
.
summary
,
commit
,
previewUrl
:
site
.
previewUrl
,
changedFiles
,
mode
:
generated
.
mode
};
}
catch
(
error
)
{
}
catch
(
error
)
{
const
details
=
error
instanceof
BuildError
?
error
.
output
.
slice
(
-
3000
)
:
error
instanceof
Error
?
error
.
message
:
String
(
error
);
const
details
=
error
instanceof
BuildError
?
error
.
output
.
slice
(
-
3000
)
:
error
instanceof
Error
?
error
.
message
:
String
(
error
);
...
...
backend/src/sites/site-repository.ts
View file @
2d6e5007
import
path
from
"node:path"
;
import
path
from
"node:path"
;
import
{
mkdir
,
readFile
,
readdir
,
rename
,
writeFile
}
from
"node:fs/promises"
;
import
{
cp
,
mkdir
,
readFile
,
readdir
,
rename
,
rm
,
stat
,
writeFile
}
from
"node:fs/promises"
;
import
type
{
SiteInfo
}
from
"@webagent/shared"
;
import
type
{
SiteInfo
}
from
"@webagent/shared"
;
import
{
runtimePaths
}
from
"../config.js"
;
import
{
config
,
getPublicPreviewUrl
,
getPublicProductionUrl
,
runtimePaths
}
from
"../config.js"
;
export
class
SiteRepository
{
export
class
SiteRepository
{
private
runtimeReady
?:
Promise
<
void
>
;
async
ensureRuntime
():
Promise
<
void
>
{
async
ensureRuntime
():
Promise
<
void
>
{
await
Promise
.
all
(
Object
.
values
(
runtimePaths
).
map
((
directory
)
=>
mkdir
(
directory
,
{
recursive
:
true
})));
this
.
runtimeReady
??
=
this
.
initializeRuntime
();
await
this
.
runtimeReady
;
}
}
getSiteRoot
(
siteId
:
string
):
string
{
getSiteRoot
(
siteId
:
string
):
string
{
...
@@ -23,7 +26,13 @@ export class SiteRepository {
...
@@ -23,7 +26,13 @@ export class SiteRepository {
async
get
(
siteId
:
string
):
Promise
<
SiteInfo
>
{
async
get
(
siteId
:
string
):
Promise
<
SiteInfo
>
{
const
raw
=
await
readFile
(
this
.
getMetadataPath
(
siteId
),
"utf8"
);
const
raw
=
await
readFile
(
this
.
getMetadataPath
(
siteId
),
"utf8"
);
return
JSON
.
parse
(
raw
)
as
SiteInfo
;
const
stored
=
JSON
.
parse
(
raw
)
as
SiteInfo
;
return
{
...
stored
,
previewUrl
:
getPublicPreviewUrl
(
siteId
),
productionUrl
:
stored
.
publishedCommit
?
getPublicProductionUrl
(
siteId
)
:
undefined
,
publishStatus
:
stored
.
publishStatus
||
(
stored
.
publishedCommit
?
"published"
:
"unpublished"
),
};
}
}
async
list
():
Promise
<
SiteInfo
[]
>
{
async
list
():
Promise
<
SiteInfo
[]
>
{
...
@@ -59,4 +68,35 @@ export class SiteRepository {
...
@@ -59,4 +68,35 @@ export class SiteRepository {
private
assertSiteId
(
siteId
:
string
):
void
{
private
assertSiteId
(
siteId
:
string
):
void
{
if
(
!
/^site_
[
a-z0-9
]
+$/
.
test
(
siteId
))
throw
new
Error
(
"无效的 siteId"
);
if
(
!
/^site_
[
a-z0-9
]
+$/
.
test
(
siteId
))
throw
new
Error
(
"无效的 siteId"
);
}
}
private
async
initializeRuntime
():
Promise
<
void
>
{
await
Promise
.
all
([
...
Object
.
values
(
runtimePaths
).
map
((
directory
)
=>
mkdir
(
directory
,
{
recursive
:
true
})),
mkdir
(
config
.
productionDir
,
{
recursive
:
true
}),
]);
await
this
.
migrateLegacySites
();
}
private
async
migrateLegacySites
():
Promise
<
void
>
{
const
legacySitesDir
=
path
.
join
(
config
.
runtimeDir
,
"sites"
);
if
(
path
.
resolve
(
legacySitesDir
)
===
path
.
resolve
(
runtimePaths
.
sites
))
return
;
const
legacyExists
=
await
stat
(
legacySitesDir
).
then
((
value
)
=>
value
.
isDirectory
()).
catch
(()
=>
false
);
if
(
!
legacyExists
)
return
;
const
entries
=
await
readdir
(
legacySitesDir
,
{
withFileTypes
:
true
});
for
(
const
entry
of
entries
)
{
if
(
!
entry
.
isDirectory
()
||
!
/^site_
[
a-z0-9
]
+$/
.
test
(
entry
.
name
))
continue
;
const
source
=
path
.
join
(
legacySitesDir
,
entry
.
name
);
const
destination
=
path
.
join
(
runtimePaths
.
sites
,
entry
.
name
);
const
destinationExists
=
await
stat
(
destination
).
then
(()
=>
true
).
catch
(()
=>
false
);
if
(
destinationExists
)
continue
;
try
{
await
rename
(
source
,
destination
);
}
catch
(
error
)
{
if
(
!
(
error
instanceof
Error
)
||
!
(
"code"
in
error
)
||
error
.
code
!==
"EXDEV"
)
throw
error
;
await
cp
(
source
,
destination
,
{
recursive
:
true
,
errorOnExist
:
true
});
await
rm
(
source
,
{
recursive
:
true
,
force
:
true
});
}
}
}
}
}
backend/src/sites/site-version-service.ts
View file @
2d6e5007
import
crypto
from
"node:crypto"
;
import
crypto
from
"node:crypto"
;
import
path
from
"node:path"
;
import
path
from
"node:path"
;
import
{
runtimePaths
}
from
"../config.js"
;
import
type
{
PublishResult
}
from
"@webagent/shared"
;
import
{
BuildManager
}
from
"../build/build-manager.js"
;
import
{
getPublicPreviewUrl
,
getPublicProductionUrl
,
runtimePaths
}
from
"../config.js"
;
import
{
BuildError
,
BuildManager
}
from
"../build/build-manager.js"
;
import
{
GitManager
}
from
"../git/git-manager.js"
;
import
{
GitManager
}
from
"../git/git-manager.js"
;
import
{
PreviewProcessManager
}
from
"../preview/preview-process-manager.js"
;
import
{
PreviewProcessManager
}
from
"../preview/preview-process-manager.js"
;
import
{
ensureEnvironmentConfig
}
from
"./ensure-environment-config.js"
;
import
{
SiteRepository
}
from
"./site-repository.js"
;
import
{
SiteRepository
}
from
"./site-repository.js"
;
export
class
SiteVersionService
{
export
class
SiteVersionService
{
...
@@ -15,12 +17,15 @@ export class SiteVersionService {
...
@@ -15,12 +17,15 @@ export class SiteVersionService {
async
rebuild
(
siteId
:
string
):
Promise
<
{
previewUrl
:
string
}
>
{
async
rebuild
(
siteId
:
string
):
Promise
<
{
previewUrl
:
string
}
>
{
const
site
=
await
this
.
sites
.
get
(
siteId
);
const
site
=
await
this
.
sites
.
get
(
siteId
);
const
project
=
this
.
sites
.
getProjectPath
(
siteId
);
const
project
=
this
.
sites
.
getProjectPath
(
siteId
);
if
(
await
ensureEnvironmentConfig
(
project
))
{
await
this
.
git
.
commit
(
project
,
"System: support preview and production paths"
);
}
await
this
.
sites
.
update
(
siteId
,
{
status
:
"building"
,
lastError
:
undefined
});
await
this
.
sites
.
update
(
siteId
,
{
status
:
"building"
,
lastError
:
undefined
});
try
{
try
{
const
dist
=
await
this
.
builds
.
build
(
project
,
"rebuild_"
+
siteId
);
const
dist
=
await
this
.
builds
.
build
(
project
,
"rebuild_"
+
siteId
,
getPublicPreviewUrl
(
siteId
)
);
const
published
=
await
this
.
builds
.
publish
(
siteId
,
dist
);
const
published
=
await
this
.
builds
.
publish
Preview
(
siteId
,
dist
);
await
this
.
previews
.
start
(
siteId
,
published
,
site
.
previewPort
);
await
this
.
previews
.
start
(
siteId
,
published
,
site
.
previewPort
);
await
this
.
sites
.
update
(
siteId
,
{
status
:
"ready"
});
await
this
.
sites
.
update
(
siteId
,
{
status
:
"ready"
,
currentCommit
:
await
this
.
git
.
currentCommit
(
project
),
environmentVersion
:
3
});
return
{
previewUrl
:
site
.
previewUrl
};
return
{
previewUrl
:
site
.
previewUrl
};
}
catch
(
error
)
{
}
catch
(
error
)
{
await
this
.
sites
.
update
(
siteId
,
{
status
:
"failed"
,
lastError
:
error
instanceof
Error
?
error
.
message
:
String
(
error
)
});
await
this
.
sites
.
update
(
siteId
,
{
status
:
"failed"
,
lastError
:
error
instanceof
Error
?
error
.
message
:
String
(
error
)
});
...
@@ -37,15 +42,44 @@ export class SiteVersionService {
...
@@ -37,15 +42,44 @@ export class SiteVersionService {
await
this
.
sites
.
update
(
siteId
,
{
status
:
"building"
,
lastError
:
undefined
});
await
this
.
sites
.
update
(
siteId
,
{
status
:
"building"
,
lastError
:
undefined
});
try
{
try
{
await
this
.
git
.
createWorktree
(
project
,
workspace
,
targetCommit
);
await
this
.
git
.
createWorktree
(
project
,
workspace
,
targetCommit
);
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
);
await
ensureEnvironmentConfig
(
workspace
);
const
published
=
await
this
.
builds
.
publish
(
siteId
,
dist
);
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
getPublicPreviewUrl
(
siteId
));
const
published
=
await
this
.
builds
.
publishPreview
(
siteId
,
dist
);
const
commit
=
await
this
.
git
.
restoreAsCommit
(
project
,
targetCommit
);
const
commit
=
await
this
.
git
.
restoreAsCommit
(
project
,
targetCommit
);
await
this
.
previews
.
start
(
siteId
,
published
,
site
.
previewPort
);
await
this
.
previews
.
start
(
siteId
,
published
,
site
.
previewPort
);
await
this
.
sites
.
update
(
siteId
,
{
status
:
"ready"
,
currentCommit
:
commit
,
lastError
:
undefined
});
await
this
.
sites
.
update
(
siteId
,
{
status
:
"ready"
,
currentCommit
:
commit
,
environmentVersion
:
3
,
lastError
:
undefined
});
return
{
commit
,
previewUrl
:
site
.
previewUrl
};
return
{
commit
,
previewUrl
:
site
.
previewUrl
};
}
catch
(
error
)
{
}
catch
(
error
)
{
await
this
.
sites
.
update
(
siteId
,
{
status
:
"failed"
,
lastError
:
error
instanceof
Error
?
error
.
message
:
String
(
error
)
});
await
this
.
sites
.
update
(
siteId
,
{
status
:
"failed"
,
lastError
:
error
instanceof
Error
?
error
.
message
:
String
(
error
)
});
throw
error
;
throw
error
;
}
finally
{
await
this
.
git
.
removeWorktree
(
project
,
workspace
);
}
}
finally
{
await
this
.
git
.
removeWorktree
(
project
,
workspace
);
}
}
}
async
publish
(
siteId
:
string
):
Promise
<
PublishResult
>
{
const
project
=
this
.
sites
.
getProjectPath
(
siteId
);
const
site
=
await
this
.
sites
.
get
(
siteId
);
if
(
site
.
status
!==
"ready"
)
throw
new
Error
(
"测试环境尚未构建成功,不能发布到生产环境"
);
const
taskId
=
"publish_"
+
crypto
.
randomBytes
(
4
).
toString
(
"hex"
);
const
workspace
=
path
.
join
(
runtimePaths
.
builds
,
taskId
,
"workspace"
);
await
this
.
sites
.
update
(
siteId
,
{
publishStatus
:
"publishing"
,
lastPublishError
:
undefined
});
try
{
const
commit
=
await
this
.
git
.
currentCommit
(
project
);
await
this
.
git
.
createWorktree
(
project
,
workspace
,
commit
);
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
getPublicProductionUrl
(
siteId
));
await
this
.
builds
.
publishProduction
(
siteId
,
dist
);
const
publishedAt
=
new
Date
().
toISOString
();
const
productionUrl
=
getPublicProductionUrl
(
siteId
);
await
this
.
sites
.
update
(
siteId
,
{
publishStatus
:
"published"
,
publishedCommit
:
commit
,
publishedAt
,
productionUrl
,
lastPublishError
:
undefined
,
});
return
{
productionUrl
,
commit
,
publishedAt
};
}
catch
(
error
)
{
const
details
=
error
instanceof
BuildError
?
error
.
output
.
slice
(
-
3000
)
:
error
instanceof
Error
?
error
.
message
:
String
(
error
);
await
this
.
sites
.
update
(
siteId
,
{
publishStatus
:
"failed"
,
lastPublishError
:
details
});
throw
error
;
}
finally
{
await
this
.
git
.
removeWorktree
(
project
,
workspace
);
}
}
}
}
deploy/nginx/README.md
0 → 100644
View file @
2d6e5007
# WebAgent Nginx(Intel macOS)
当前配置适用于 Homebrew Intel 路径
`/usr/local/etc/nginx`
。
```
bash
pnpm build
ln
-sfn
/Users/mac/Desktop/code/WebAgent/deploy/nginx/webagent.conf
\
/usr/local/etc/nginx/servers/webagent.conf
nginx
-t
nginx
-s
reload
pnpm
--filter
@webagent/backend start
```
访问入口:
-
控制台:
`http://主机地址/`
-
API:
`http://主机地址/api/`
-
测试预览:
`http://主机地址/previews/site_xxx/`
-
生产站点:
`http://主机地址/sites/site_xxx/`
Nginx 由 Homebrew service 管理,登录后会自动启动;Fastify 仍需使用 launchd、进程管理器或终端进程保持运行。更换项目目录时,需要修改
`webagent.conf`
中的
`root`
和
`alias`
。
当前 MVP 没有用户身份、站点权限和请求限流。可以在可信局域网演示;正式暴露到公网前必须补充 HTTPS、鉴权、限流和站点所有权校验。
deploy/nginx/webagent.conf
0 → 100644
View file @
2d6e5007
server
{
listen
80
default_server
;
listen
[::]:
80
default_server
;
server_name
_
;
charset
utf
-
8
;
client_max_body_size
20
m
;
root
/
Users
/
mac
/
Desktop
/
code
/
WebAgent
/
frontend
/
dist
;
index
index
.
html
;
gzip
on
;
gzip_types
text
/
plain
text
/
css
application
/
json
application
/
javascript
image
/
svg
+
xml
;
location
/
api
/ {
proxy_pass
http
://
127
.
0
.
0
.
1
:
3100
;
proxy_http_version
1
.
1
;
proxy_set_header
Host
$
host
;
proxy_set_header
X
-
Real
-
IP
$
remote_addr
;
proxy_set_header
X
-
Forwarded
-
For
$
proxy_add_x_forwarded_for
;
proxy_set_header
X
-
Forwarded
-
Proto
$
scheme
;
proxy_read_timeout
180
s
;
proxy_send_timeout
180
s
;
}
location
/
previews
/ {
alias
/
Users
/
mac
/
Desktop
/
code
/
WebAgent
/.
runtime
/
previews
/;
index
index
.
html
;
add_header
Cache
-
Control
"no-store"
always
;
}
location
/
sites
/ {
alias
/
Users
/
mac
/
Desktop
/
code
/
WebAgent
-
production
/;
index
index
.
html
;
add_header
Cache
-
Control
"no-cache"
always
;
}
location
/ {
try_files
$
uri
$
uri
/ /
index
.
html
;
}
location
~ /\. {
deny
all
;
}
}
docs/storage-convention.md
View file @
2d6e5007
# 本地存储规范
# 本地存储规范
所有运行期数据必须写入项目根目录的
`.runtime/`
,该目录不进入 WebAgent Git
。
生成站点源码、测试构建和生产产物分开保存。
`.env`
中默认配置为
`WEBAGENT_SITES_DIR=../WebAgent-sites`
、
`WEBAGENT_PRODUCTION_DIR=../WebAgent-production`
,相对路径以 WebAgent 项目根目录为基准,也可以指定绝对路径
。
```
text
```
text
.runtime/
WebAgent-sites/
├── sites/
└── site_xxxxxx/
│ └── site_xxxxxx/
├── project/ # 独立 Astro 项目,也是独立 Git 仓库
│ ├── project/ # 独立 Astro 项目,也是独立 Git 仓库
└── metadata/
│ └── metadata/
└── site.json # WebAgent 管理信息,不交给 Agent 修改
│ └── site.json # WebAgent 管理信息,不交给 Agent 修改
WebAgent-production/
└── site_xxxxxx/ # 用户确认发布的生产静态产物
WebAgent/.runtime/
├── builds/
├── builds/
│ └── task_xxxxxx/
│ └── task_xxxxxx/
│ └── workspace/ # 临时 Git Worktree,任务结束必须删除
│ └── workspace/ # 临时 Git Worktree,任务结束必须删除
├── previews/
├── previews/
│ └── site_xxxxxx/ #
最近一次构建成功的静态
产物
│ └── site_xxxxxx/ #
当前代码最近一次构建成功的测试
产物
├── uploads/ # 原始上传文件的临时落点
├── uploads/ # 原始上传文件的临时落点
├── pnpm-store/ # 所有生成站点共享的 pnpm store
├── pnpm-store/ # 所有生成站点共享的 pnpm store
└── logs/ # Agent 与构建日志
└── logs/ # Agent 与构建日志
```
```
`WebAgent-sites/`
和
`WebAgent-production/`
是持久数据,应纳入服务器备份;
`.runtime/`
是可重建的测试与运行缓存,不提交 Git。修改路径配置后,系统不会自动迁移外部目录之间的数据,需要由管理员手动移动。旧版本位于
`.runtime/sites/`
的站点会在首次启动时自动迁移到当前配置目录。
## 不变量
## 不变量
1.
Agent 只可读取和修改当前站点
`project/`
内的白名单文件。
1.
Agent 只可读取和修改当前站点
`project/`
内的白名单文件。
2.
Agent 永远不直接修改正式项目,先在
`builds/`
中验证。
2.
Agent 永远不直接修改正式项目,先在
`builds/`
中验证。
3.
只有构建成功的版本才允许进入站点 Git 历史并成为预览版本。
3.
只有构建成功的版本才允许进入站点 Git 历史并成为预览版本。
4.
`metadata/site.json`
由 WebAgent 独占写入,站点代码不能反向引用它。
4.
测试预览更新不能覆盖生产产物;生产环境只能通过显式发布操作更新。
5.
删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
5.
`metadata/site.json`
由 WebAgent 独占写入,站点代码不能反向引用它。
6.
每个站点拥有稳定的
`siteId`
和预览端口;站点名称不参与路径计算。
6.
删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
7.
每个站点拥有稳定的
`siteId`
和预览端口;站点名称不参与路径计算。
frontend/src/App.tsx
View file @
2d6e5007
import
{
useEffect
,
use
Memo
,
use
Ref
,
useState
,
type
FormEvent
}
from
"react"
;
import
{
useEffect
,
useRef
,
useState
,
type
FormEvent
}
from
"react"
;
import
{
useMutation
,
useQuery
,
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useMutation
,
useQuery
,
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
import
{
ArrowLeft
,
ArrowRight
,
Bot
,
Check
,
ChevronDown
,
Clock3
,
Code2
,
ExternalLink
,
ArrowLeft
,
ArrowRight
,
Bot
,
Check
,
ChevronDown
,
Clock3
,
Code2
,
ExternalLink
,
History
,
Laptop
,
LoaderCircle
,
MessageSquareText
,
Monitor
,
MoreHorizontal
,
Palette
,
Globe2
,
History
,
Laptop
,
LoaderCircle
,
MessageSquareText
,
Monitor
,
Palette
,
Plus
,
RefreshCw
,
RotateCcw
,
Send
,
Settings2
,
ShieldCheck
,
Smartphone
,
Sparkles
,
Plus
,
RefreshCw
,
Ro
cket
,
Ro
tateCcw
,
Send
,
Settings2
,
ShieldCheck
,
Smartphone
,
Sparkles
,
WandSparkles
,
X
,
WandSparkles
,
X
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
type
{
CreateSiteInput
,
SiteInfo
}
from
"@webagent/shared"
;
import
type
{
CreateSiteInput
,
SiteInfo
}
from
"@webagent/shared"
;
...
@@ -105,12 +105,57 @@ function Field({ label, hint, wide, children }: { label: string; hint?: string;
...
@@ -105,12 +105,57 @@ function Field({ label, hint, wide, children }: { label: string; hint?: string;
return
<
label
className=
{
wide
?
"field wide"
:
"field"
}
><
span
>
{
label
}{
hint
&&
<
small
>
{
hint
}
</
small
>
}
</
span
>
{
children
}
</
label
>;
return
<
label
className=
{
wide
?
"field wide"
:
"field"
}
><
span
>
{
label
}{
hint
&&
<
small
>
{
hint
}
</
small
>
}
</
span
>
{
children
}
</
label
>;
}
}
function
SiteSwitcher
({
site
,
sites
,
onSelect
}:
{
site
:
SiteInfo
;
sites
:
SiteInfo
[];
onSelect
:
(
id
:
string
)
=>
void
})
{
const
[
open
,
setOpen
]
=
useState
(
false
);
const
switcherRef
=
useRef
<
HTMLDivElement
>
(
null
);
useEffect
(()
=>
{
if
(
!
open
)
return
;
const
closeOutside
=
(
event
:
PointerEvent
)
=>
{
if
(
event
.
target
instanceof
Node
&&
!
switcherRef
.
current
?.
contains
(
event
.
target
))
setOpen
(
false
);
};
const
closeWithEscape
=
(
event
:
KeyboardEvent
)
=>
{
if
(
event
.
key
===
"Escape"
)
setOpen
(
false
);
};
document
.
addEventListener
(
"pointerdown"
,
closeOutside
);
document
.
addEventListener
(
"keydown"
,
closeWithEscape
);
return
()
=>
{
document
.
removeEventListener
(
"pointerdown"
,
closeOutside
);
document
.
removeEventListener
(
"keydown"
,
closeWithEscape
);
};
},
[
open
]);
return
<
div
className=
{
`site-switcher ${open ? "open" : ""}`
}
ref=
{
switcherRef
}
>
<
button
className=
"site-switcher-trigger"
type=
"button"
aria
-
haspopup=
"listbox"
aria
-
expanded=
{
open
}
onClick=
{
()
=>
setOpen
((
value
)
=>
!
value
)
}
>
<
span
className=
"site-avatar"
>
{
site
.
name
.
slice
(
0
,
1
)
}
</
span
>
<
span
className=
"site-switcher-copy"
><
strong
>
{
site
.
name
}
</
strong
><
small
>
{
site
.
industry
}
</
small
></
span
>
<
span
className=
"site-switcher-chevron"
><
ChevronDown
size=
{
14
}
/></
span
>
</
button
>
{
open
&&
<
div
className=
"site-switcher-menu"
role=
"listbox"
aria
-
label=
"选择要管理的官网"
>
<
div
className=
"site-menu-heading"
><
span
>
我的官网
</
span
><
em
>
{
sites
.
length
}
</
em
></
div
>
<
div
className=
"site-menu-list"
>
{
sites
.
map
((
item
)
=>
{
const
selected
=
item
.
siteId
===
site
.
siteId
;
return
<
button
type=
"button"
role=
"option"
aria
-
selected=
{
selected
}
className=
{
selected
?
"selected"
:
""
}
key=
{
item
.
siteId
}
onClick=
{
()
=>
{
onSelect
(
item
.
siteId
);
setOpen
(
false
);
}
}
>
<
span
className=
"site-menu-avatar"
>
{
item
.
name
.
slice
(
0
,
1
)
}
</
span
>
<
span
className=
"site-menu-copy"
><
strong
>
{
item
.
name
}
</
strong
><
small
>
{
item
.
industry
}
</
small
></
span
>
<
span
className=
{
`site-menu-status ${item.publishedCommit ? "published" : item.status}`
}
title=
{
item
.
publishedCommit
?
"生产环境已上线"
:
item
.
status
===
"ready"
?
"测试环境已就绪"
:
"站点需要检查"
}
/>
{
selected
&&
<
Check
size=
{
14
}
/>
}
</
button
>;
})
}
</
div
>
<
p
><
ShieldCheck
size=
{
11
}
/>
每个官网拥有独立代码、版本与发布环境
</
p
>
</
div
>
}
</
div
>;
}
function
Workspace
({
siteId
,
sites
,
agentMode
,
onSelectSite
,
onCreate
}:
{
siteId
:
string
;
sites
:
SiteInfo
[];
agentMode
:
"model"
|
"local"
;
onSelectSite
:
(
id
:
string
)
=>
void
;
onCreate
:
()
=>
void
})
{
function
Workspace
({
siteId
,
sites
,
agentMode
,
onSelectSite
,
onCreate
}:
{
siteId
:
string
;
sites
:
SiteInfo
[];
agentMode
:
"model"
|
"local"
;
onSelectSite
:
(
id
:
string
)
=>
void
;
onCreate
:
()
=>
void
})
{
const
queryClient
=
useQueryClient
();
const
queryClient
=
useQueryClient
();
const
siteQuery
=
useQuery
({
queryKey
:
[
"site"
,
siteId
],
queryFn
:
()
=>
api
.
site
(
siteId
)
});
const
siteQuery
=
useQuery
({
queryKey
:
[
"site"
,
siteId
],
queryFn
:
()
=>
api
.
site
(
siteId
)
});
const
historyQuery
=
useQuery
({
queryKey
:
[
"history"
,
siteId
],
queryFn
:
()
=>
api
.
history
(
siteId
)
});
const
historyQuery
=
useQuery
({
queryKey
:
[
"history"
,
siteId
],
queryFn
:
()
=>
api
.
history
(
siteId
)
});
const
[
tab
,
setTab
]
=
useState
<
"chat"
|
"history"
>
(
"chat"
);
const
[
tab
,
setTab
]
=
useState
<
"chat"
|
"history"
>
(
"chat"
);
const
[
device
,
setDevice
]
=
useState
<
"desktop"
|
"mobile"
>
(
"desktop"
);
const
[
device
,
setDevice
]
=
useState
<
"desktop"
|
"mobile"
>
(
"desktop"
);
const
[
environment
,
setEnvironment
]
=
useState
<
"preview"
|
"production"
>
(
"preview"
);
const
[
input
,
setInput
]
=
useState
(
""
);
const
[
input
,
setInput
]
=
useState
(
""
);
const
[
iframeVersion
,
setIframeVersion
]
=
useState
(
0
);
const
[
iframeVersion
,
setIframeVersion
]
=
useState
(
0
);
const
[
messages
,
setMessages
]
=
useState
<
ChatMessage
[]
>
([
const
[
messages
,
setMessages
]
=
useState
<
ChatMessage
[]
>
([
...
@@ -119,7 +164,10 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
...
@@ -119,7 +164,10 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
const
scrollRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
scrollRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
site
=
siteQuery
.
data
;
const
site
=
siteQuery
.
data
;
useEffect
(()
=>
{
scrollRef
.
current
?.
scrollTo
({
top
:
scrollRef
.
current
.
scrollHeight
,
behavior
:
"smooth"
});
},
[
messages
]);
useEffect
(()
=>
{
scrollRef
.
current
?.
scrollTo
({
top
:
scrollRef
.
current
.
scrollHeight
,
behavior
:
"smooth"
});
},
[
messages
]);
useEffect
(()
=>
{
setMessages
([{
role
:
"agent"
,
text
:
"已切换到这个网站。你可以继续描述修改要求。"
,
meta
:
agentMode
===
"model"
?
"模型 Agent 已连接"
:
"本地演示 Agent"
}]);
},
[
siteId
,
agentMode
]);
useEffect
(()
=>
{
setEnvironment
(
"preview"
);
setMessages
([{
role
:
"agent"
,
text
:
"已切换到这个网站。你可以继续描述修改要求。"
,
meta
:
agentMode
===
"model"
?
"模型 Agent 已连接"
:
"本地演示 Agent"
}]);
},
[
siteId
,
agentMode
]);
const
refreshData
=
async
()
=>
{
const
refreshData
=
async
()
=>
{
await
Promise
.
all
([
await
Promise
.
all
([
...
@@ -138,18 +186,44 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
...
@@ -138,18 +186,44 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
},
},
onError
:
(
error
)
=>
setMessages
((
items
)
=>
[...
items
,
{
role
:
"agent"
,
text
:
error
.
message
,
meta
:
"修改未生效,原版本保持可用"
}]),
onError
:
(
error
)
=>
setMessages
((
items
)
=>
[...
items
,
{
role
:
"agent"
,
text
:
error
.
message
,
meta
:
"修改未生效,原版本保持可用"
}]),
});
});
const
rebuildMutation
=
useMutation
({
mutationFn
:
()
=>
api
.
rebuild
(
siteId
),
onSuccess
:
refreshData
});
const
rebuildMutation
=
useMutation
({
mutationFn
:
()
=>
api
.
rebuild
(
siteId
),
onSuccess
:
async
()
=>
{
setEnvironment
(
"preview"
);
setMessages
((
items
)
=>
[...
items
,
{
role
:
"agent"
,
text
:
"测试环境已重新构建。"
,
meta
:
"测试预览已刷新"
}]);
await
refreshData
();
},
onError
:
(
error
)
=>
setMessages
((
items
)
=>
[...
items
,
{
role
:
"agent"
,
text
:
error
.
message
,
meta
:
"重新构建失败,原测试预览保持可用"
}]),
});
const
rollbackMutation
=
useMutation
({
const
rollbackMutation
=
useMutation
({
mutationFn
:
(
commit
:
string
)
=>
api
.
rollback
(
siteId
,
commit
),
mutationFn
:
(
commit
:
string
)
=>
api
.
rollback
(
siteId
,
commit
),
onSuccess
:
async
(
result
)
=>
{
setMessages
((
items
)
=>
[...
items
,
{
role
:
"agent"
,
text
:
"已恢复所选版本,并创建新的回滚记录。"
,
meta
:
result
.
commit
.
slice
(
0
,
7
)
}]);
await
refreshData
();
},
onSuccess
:
async
(
result
)
=>
{
setMessages
((
items
)
=>
[...
items
,
{
role
:
"agent"
,
text
:
"已恢复所选版本,并创建新的回滚记录。"
,
meta
:
result
.
commit
.
slice
(
0
,
7
)
}]);
await
refreshData
();
},
});
});
const
publishMutation
=
useMutation
({
mutationFn
:
()
=>
api
.
publish
(
siteId
),
onSuccess
:
async
(
result
)
=>
{
setEnvironment
(
"production"
);
setMessages
((
items
)
=>
[...
items
,
{
role
:
"agent"
,
text
:
"当前版本已发布到生产环境。"
,
meta
:
"生产版本 · "
+
result
.
commit
.
slice
(
0
,
7
)
}]);
await
refreshData
();
},
onError
:
(
error
)
=>
setMessages
((
items
)
=>
[...
items
,
{
role
:
"agent"
,
text
:
error
.
message
,
meta
:
"发布失败,原生产版本保持可用"
}]),
});
const
send
=
(
message
=
input
)
=>
{
const
send
=
(
message
=
input
)
=>
{
const
value
=
message
.
trim
();
if
(
!
value
||
chatMutation
.
isPending
)
return
;
const
value
=
message
.
trim
();
if
(
!
value
||
chatMutation
.
isPending
)
return
;
setMessages
((
items
)
=>
[...
items
,
{
role
:
"user"
,
text
:
value
}]);
setInput
(
""
);
chatMutation
.
mutate
(
value
);
setMessages
((
items
)
=>
[...
items
,
{
role
:
"user"
,
text
:
value
}]);
setInput
(
""
);
chatMutation
.
mutate
(
value
);
};
};
const
busy
=
chatMutation
.
isPending
||
rebuildMutation
.
isPending
||
rollbackMutation
.
isPending
;
const
busy
=
chatMutation
.
isPending
||
rebuildMutation
.
isPending
||
rollbackMutation
.
isPending
||
publishMutation
.
isPending
;
const
productionCurrent
=
Boolean
(
site
?.
publishedCommit
&&
site
.
publishedCommit
===
site
.
currentCommit
);
const
canPublish
=
site
?.
status
===
"ready"
&&
!
productionCurrent
;
if
(
!
site
)
return
<
Splash
/>;
if
(
!
site
)
return
<
Splash
/>;
const
previewVersion
=
historyQuery
.
data
?.
find
((
item
)
=>
item
.
hash
===
site
.
currentCommit
);
const
productionVersion
=
historyQuery
.
data
?.
find
((
item
)
=>
item
.
hash
===
site
.
publishedCommit
);
const
showingProduction
=
environment
===
"production"
;
const
activeUrl
=
showingProduction
?
site
.
productionUrl
:
site
.
previewUrl
;
const
environmentStatus
=
showingProduction
?
!
site
.
productionUrl
?
"尚未上线"
:
productionCurrent
?
"生产已同步"
:
"生产版本较旧"
:
site
.
status
===
"ready"
?
"测试已同步"
:
"需要检查"
;
return
<
main
className=
"workspace"
>
return
<
main
className=
"workspace"
>
<
aside
className=
"rail"
>
<
aside
className=
"rail"
>
<
Logo
compact
/>
<
Logo
compact
/>
...
@@ -157,12 +231,8 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
...
@@ -157,12 +231,8 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
<
button
className=
"rail-create"
onClick=
{
onCreate
}
title=
"创建新网站"
><
Plus
size=
{
18
}
/></
button
>
<
button
className=
"rail-create"
onClick=
{
onCreate
}
title=
"创建新网站"
><
Plus
size=
{
18
}
/></
button
>
</
aside
>
</
aside
>
<
section
className=
"control-panel"
>
<
section
className=
"control-panel"
>
<
div
className=
"site-switcher"
>
<
SiteSwitcher
site=
{
site
}
sites=
{
sites
}
onSelect=
{
onSelectSite
}
/>
<
div
className=
"site-avatar"
>
{
site
.
name
.
slice
(
0
,
1
)
}
</
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"
);
void
siteQuery
.
refetch
();
void
historyQuery
.
refetch
();
}
}
><
History
size=
{
15
}
/>
版本历史
</
button
></
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"
?
<>
{
tab
===
"chat"
?
<>
<
div
className=
"chat-scroll"
ref=
{
scrollRef
}
>
<
div
className=
"chat-scroll"
ref=
{
scrollRef
}
>
<
div
className=
"chat-date"
>
今天
</
div
>
<
div
className=
"chat-date"
>
今天
</
div
>
...
@@ -178,28 +248,58 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
...
@@ -178,28 +248,58 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate }: { siteI
<
div
><
span
>
{
agentMode
===
"model"
?
<><
span
className=
"online-dot"
/>
模型 Agent
</>
:
<><
Code2
size=
{
12
}
/>
本地演示模式
</>
}
</
span
><
button
onClick=
{
()
=>
send
()
}
disabled=
{
!
input
.
trim
()
||
busy
}
><
Send
size=
{
15
}
/></
button
></
div
>
<
div
><
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
>
</>
:
<
div
className=
"history-list"
>
</>
:
<
div
className=
"history-list"
>
<
div
className=
"history-heading"
><
div
><
h3
>
版本记录
</
h3
><
p
>
仅显示构建成功的修改
</
p
></
div
><
span
>
{
historyQuery
.
data
?.
length
||
0
}
</
span
></
div
>
<
div
className=
"environment-versions"
>
{
historyQuery
.
data
?.
map
((
item
,
index
)
=>
<
article
className=
{
item
.
current
?
"current"
:
""
}
key=
{
item
.
hash
}
>
<
button
className=
{
environment
===
"preview"
?
"active preview"
:
"preview"
}
onClick=
{
()
=>
setEnvironment
(
"preview"
)
}
>
<
span
><
Monitor
size=
{
14
}
/>
测试环境
<
em
>
当前预览
</
em
></
span
>
<
strong
>
{
site
.
currentCommit
.
slice
(
0
,
7
)
}
</
strong
>
<
small
>
{
previewVersion
?
formatDate
(
previewVersion
.
date
)
:
formatDate
(
site
.
updatedAt
)
}
</
small
>
</
button
>
<
button
className=
{
environment
===
"production"
?
"active production"
:
"production"
}
onClick=
{
()
=>
setEnvironment
(
"production"
)
}
>
<
span
><
Globe2
size=
{
14
}
/>
生产环境
<
em
>
{
site
.
productionUrl
?
productionCurrent
?
"已同步"
:
"待更新"
:
"未上线"
}
</
em
></
span
>
<
strong
>
{
site
.
publishedCommit
?.
slice
(
0
,
7
)
||
"-------"
}
</
strong
>
<
small
>
{
site
.
publishedAt
?
formatDate
(
site
.
publishedAt
)
:
"发布后生成生产链接"
}
</
small
>
</
button
>
</
div
>
<
div
className=
"history-heading"
><
div
><
h3
>
历史版本
</
h3
><
p
>
选择旧版本可恢复到测试环境
</
p
></
div
><
span
>
{
historyQuery
.
data
?.
length
||
0
}
</
span
></
div
>
{
historyQuery
.
data
?.
map
((
item
,
index
)
=>
{
const
isPreview
=
item
.
hash
===
site
.
currentCommit
;
const
isProduction
=
item
.
hash
===
site
.
publishedCommit
;
return
<
article
className=
{
isPreview
||
isProduction
?
"environment-version"
:
""
}
key=
{
item
.
hash
}
>
<
div
className=
"timeline"
><
i
>
{
item
.
current
?
<
Check
size=
{
10
}
/>
:
""
}
</
i
>
{
index
<
(
historyQuery
.
data
?.
length
||
0
)
-
1
&&
<
span
/>
}
</
div
>
<
div
className=
"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
>
<
div
className=
"commit-info"
><
div
><
strong
>
{
item
.
message
}
</
strong
>
{
isPreview
&&
<
em
className=
"preview-badge"
>
测试
</
em
>
}{
isProduction
&&
<
em
className=
"production-badge"
>
生产
</
em
>
}
</
div
><
p
><
code
>
{
item
.
shortHash
}
</
code
><
span
>
·
</
span
>
{
formatDate
(
item
.
date
)
}
</
p
></
div
>
{
!
item
.
current
&&
<
button
title=
"恢复这个版本"
disabled=
{
busy
}
onClick=
{
()
=>
{
if
(
window
.
confirm
(
"确定恢复到版本 "
+
item
.
shortHash
+
" 吗?当前状态会作为历史保留。"
))
rollbackMutation
.
mutate
(
item
.
hash
);
}
}
><
RotateCcw
size=
{
14
}
/></
button
>
}
<
div
className=
"history-actions"
>
</
article
>)
}
{
isPreview
&&
<
button
title=
"查看测试环境"
onClick=
{
()
=>
setEnvironment
(
"preview"
)
}
><
Monitor
size=
{
13
}
/></
button
>
}
{
isProduction
&&
<
button
title=
"查看生产环境"
onClick=
{
()
=>
setEnvironment
(
"production"
)
}
><
Globe2
size=
{
13
}
/></
button
>
}
{
!
isPreview
&&
<
button
title=
"恢复到测试环境"
disabled=
{
busy
}
onClick=
{
()
=>
{
if
(
window
.
confirm
(
"确定将测试环境恢复到版本 "
+
item
.
shortHash
+
" 吗?生产环境不会受到影响。"
))
rollbackMutation
.
mutate
(
item
.
hash
);
}
}
><
RotateCcw
size=
{
13
}
/></
button
>
}
</
div
>
</
article
>;
})
}
</
div
>
}
</
div
>
}
</
section
>
</
section
>
<
section
className=
"preview-shell"
>
<
section
className=
"preview-shell"
>
<
header
className=
"preview-toolbar"
>
<
header
className=
"preview-toolbar"
>
<
div
className=
"preview-title"
><
div
><
span
className=
"status-dot"
/><
strong
>
{
site
.
name
}
</
strong
></
div
><
span
className=
{
`build-status ${site.status}`
}
>
{
busy
?
<
LoaderCircle
className=
"spin"
size=
{
12
}
/>
:
<
Check
size=
{
12
}
/>
}{
busy
?
"正在构建"
:
site
.
status
===
"ready"
?
"已同步"
:
"需要检查"
}
</
span
></
div
>
<
div
className=
"preview-title"
><
div
><
span
className=
{
`status-dot ${showingProduction ? "production" : ""}`
}
/><
strong
>
{
site
.
name
}
</
strong
><
em
className=
{
showingProduction
?
"production"
:
""
}
>
{
showingProduction
?
"生产环境"
:
"测试环境"
}
</
em
></
div
><
span
className=
{
`build-status ${showingProduction ? site.publishStatus : site.status}`
}
>
{
busy
?
<
LoaderCircle
className=
"spin"
size=
{
12
}
/>
:
<
Check
size=
{
12
}
/>
}{
publishMutation
.
isPending
?
"正在发布"
:
rebuildMutation
.
isPending
?
"正在构建"
:
environmentStatus
}
</
span
></
div
>
<
div
className=
"preview-modes"
>
<
div
className=
"environment-toggle"
><
button
className=
{
!
showingProduction
?
"active"
:
""
}
onClick=
{
()
=>
setEnvironment
(
"preview"
)
}
><
Monitor
size=
{
13
}
/>
测试预览
</
button
><
button
className=
{
showingProduction
?
"active"
:
""
}
onClick=
{
()
=>
setEnvironment
(
"production"
)
}
><
Globe2
size=
{
13
}
/>
生产站点
</
button
></
div
>
<
div
className=
"device-toggle"
><
button
className=
{
device
===
"desktop"
?
"active"
:
""
}
onClick=
{
()
=>
setDevice
(
"desktop"
)
}
><
Laptop
size=
{
15
}
/></
button
><
button
className=
{
device
===
"mobile"
?
"active"
:
""
}
onClick=
{
()
=>
setDevice
(
"mobile"
)
}
><
Smartphone
size=
{
15
}
/></
button
></
div
>
<
div
className=
"device-toggle"
><
button
className=
{
device
===
"desktop"
?
"active"
:
""
}
onClick=
{
()
=>
setDevice
(
"desktop"
)
}
><
Laptop
size=
{
15
}
/></
button
><
button
className=
{
device
===
"mobile"
?
"active"
:
""
}
onClick=
{
()
=>
setDevice
(
"mobile"
)
}
><
Smartphone
size=
{
15
}
/></
button
></
div
>
<
div
className=
"toolbar-actions"
><
button
disabled=
{
busy
}
onClick=
{
()
=>
rebuildMutation
.
mutate
()
}
title=
"重新构建"
><
RefreshCw
className=
{
rebuildMutation
.
isPending
?
"spin"
:
""
}
size=
{
15
}
/></
button
><
a
href=
{
site
.
previewUrl
}
target=
"_blank"
rel=
"noreferrer"
title=
"新窗口打开"
><
ExternalLink
size=
{
15
}
/></
a
><
button
><
MoreHorizontal
size=
{
16
}
/></
button
></
div
>
</
div
>
<
div
className=
"toolbar-actions"
>
{
!
showingProduction
&&
<
button
className=
"rebuild-button"
disabled=
{
busy
}
onClick=
{
()
=>
rebuildMutation
.
mutate
()
}
title=
"重新构建测试环境"
><
RefreshCw
className=
{
rebuildMutation
.
isPending
?
"spin"
:
""
}
size=
{
14
}
/><
span
>
重新构建
</
span
></
button
>
}
<
a
className=
"environment-link preview-link"
href=
{
site
.
previewUrl
}
target=
"_blank"
rel=
"noreferrer"
title=
"打开测试环境"
><
Monitor
size=
{
13
}
/><
span
>
测试链接
</
span
></
a
>
{
site
.
productionUrl
?
<
a
className=
"environment-link production-link"
href=
{
site
.
productionUrl
}
target=
"_blank"
rel=
"noreferrer"
title=
"打开生产环境"
><
Globe2
size=
{
13
}
/><
span
>
生产链接
</
span
></
a
>
:
<
button
className=
"environment-link production-link"
disabled
title=
"发布上线后生成生产链接"
><
Globe2
size=
{
13
}
/><
span
>
生产链接
</
span
></
button
>
}
<
button
className=
"publish-button"
disabled=
{
busy
||
!
canPublish
}
onClick=
{
()
=>
{
if
(
window
.
confirm
(
"确定将当前测试版本发布到生产环境吗?"
))
publishMutation
.
mutate
();
}
}
title=
{
productionCurrent
?
"当前版本已经上线"
:
site
.
status
!==
"ready"
?
"测试环境构建成功后才能发布"
:
"发布当前测试版本到生产环境"
}
>
{
publishMutation
.
isPending
?
<
LoaderCircle
className=
"spin"
size=
{
14
}
/>
:
<
Rocket
size=
{
14
}
/>
}
<
span
>
{
productionCurrent
?
"已上线"
:
site
.
productionUrl
?
"发布更新"
:
"发布上线"
}
</
span
>
</
button
>
</
div
>
</
header
>
</
header
>
<
div
className=
"browser-stage"
>
<
div
className=
"browser-stage"
>
<
div
className=
{
`browser-frame ${device}`
}
>
<
div
className=
{
`browser-frame ${device}`
}
>
<
div
className=
"browser-chrome"
><
div
><
i
/><
i
/><
i
/></
div
><
span
>
{
site
.
previewUrl
}
</
span
><
RefreshCw
size=
{
11
}
/></
div
>
<
div
className=
"browser-chrome"
><
div
><
i
/><
i
/><
i
/></
div
><
span
>
{
activeUrl
||
site
.
productionUrl
||
"/sites/"
+
site
.
siteId
+
"/"
}
</
span
><
RefreshCw
size=
{
11
}
/></
div
>
<
iframe
key=
{
iframeVersion
}
title=
{
site
.
name
+
" 网站预览"
}
src=
{
site
.
previewUrl
+
"?v="
+
iframeVersion
}
/>
{
activeUrl
?
<
iframe
key=
{
environment
+
iframeVersion
}
title=
{
site
.
name
+
(
showingProduction
?
" 生产站点"
:
" 测试预览"
)
}
src=
{
activeUrl
+
"?v="
+
iframeVersion
}
/>
:
<
div
className=
"production-empty"
><
span
><
Globe2
size=
{
24
}
/></
span
><
strong
>
生产环境尚未发布
</
strong
><
p
>
确认测试效果后,点击“发布上线”生成独立生产版本和链接。
</
p
><
button
disabled=
{
!
canPublish
||
busy
}
onClick=
{
()
=>
{
if
(
window
.
confirm
(
"确定将当前测试版本发布到生产环境吗?"
))
publishMutation
.
mutate
();
}
}
><
Rocket
size=
{
14
}
/>
发布当前测试版本
</
button
></
div
>
}
{
busy
&&
<
div
className=
"preview-busy"
><
LoaderCircle
className=
"spin"
size=
{
25
}
/><
strong
>
正在生成新版本
</
strong
><
span
>
当前预览保持可用
</
span
></
div
>
}
{
busy
&&
<
div
className=
"preview-busy"
><
LoaderCircle
className=
"spin"
size=
{
25
}
/><
strong
>
{
publishMutation
.
isPending
?
"正在发布生产版本"
:
"正在生成测试版本"
}
</
strong
><
span
>
{
publishMutation
.
isPending
?
"测试环境和原生产版本保持可用"
:
"当前测试预览保持可用"
}
</
span
></
div
>
}
</
div
>
</
div
>
</
div
>
</
div
>
<
footer
className=
"preview-footer"
><
span
><
Clock3
size=
{
12
}
/>
最近更新
{
formatDate
(
site
.
updatedAt
)
}
</
span
><
span
><
Palette
size=
{
12
}
/>
Astro Static
</
span
></
footer
>
<
footer
className=
"preview-footer"
><
span
><
Clock3
size=
{
12
}
/>
测试版本
{
previewVersion
?.
shortHash
||
site
.
currentCommit
.
slice
(
0
,
7
)
}
·
{
previewVersion
?
formatDate
(
previewVersion
.
date
)
:
formatDate
(
site
.
updatedAt
)
}
</
span
><
span
><
Palette
size=
{
12
}
/>
{
site
.
publishedAt
?
`生产版本 ${productionVersion?.shortHash || site.publishedCommit?.slice(0, 7)} · ${formatDate(site.publishedAt)}`
:
"生产环境尚未发布"
}
</
span
></
footer
>
</
section
>
</
section
>
</
main
>;
</
main
>;
}
}
...
...
frontend/src/api.ts
View file @
2d6e5007
import
type
{
ChatResult
,
CreateSiteInput
,
GitHistoryItem
,
SiteInfo
}
from
"@webagent/shared"
;
import
type
{
ChatResult
,
CreateSiteInput
,
GitHistoryItem
,
PublishResult
,
SiteInfo
}
from
"@webagent/shared"
;
async
function
request
<
T
>
(
url
:
string
,
options
?:
RequestInit
):
Promise
<
T
>
{
async
function
request
<
T
>
(
url
:
string
,
options
?:
RequestInit
):
Promise
<
T
>
{
const
headers
=
new
Headers
(
options
?.
headers
);
if
(
options
?.
body
!=
null
&&
!
headers
.
has
(
"content-type"
))
headers
.
set
(
"content-type"
,
"application/json"
);
const
response
=
await
fetch
(
url
,
{
const
response
=
await
fetch
(
url
,
{
...
options
,
...
options
,
headers
:
{
"content-type"
:
"application/json"
,
...
options
?.
headers
}
,
headers
,
});
});
const
data
=
await
response
.
json
().
catch
(()
=>
({}))
as
{
error
?:
string
};
const
data
=
await
response
.
json
().
catch
(()
=>
({}))
as
{
error
?:
string
};
if
(
!
response
.
ok
)
throw
new
Error
(
data
.
error
||
"请求失败,请稍后重试"
);
if
(
!
response
.
ok
)
throw
new
Error
(
data
.
error
||
"请求失败,请稍后重试"
);
...
@@ -18,5 +20,6 @@ export const api = {
...
@@ -18,5 +20,6 @@ export const api = {
chat
:
(
siteId
:
string
,
message
:
string
)
=>
request
<
ChatResult
>
(
"/api/sites/"
+
siteId
+
"/chat"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
message
})
}),
chat
:
(
siteId
:
string
,
message
:
string
)
=>
request
<
ChatResult
>
(
"/api/sites/"
+
siteId
+
"/chat"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
message
})
}),
history
:
(
siteId
:
string
)
=>
request
<
GitHistoryItem
[]
>
(
"/api/sites/"
+
siteId
+
"/history"
),
history
:
(
siteId
:
string
)
=>
request
<
GitHistoryItem
[]
>
(
"/api/sites/"
+
siteId
+
"/history"
),
rebuild
:
(
siteId
:
string
)
=>
request
<
{
previewUrl
:
string
}
>
(
"/api/sites/"
+
siteId
+
"/build"
,
{
method
:
"POST"
}),
rebuild
:
(
siteId
:
string
)
=>
request
<
{
previewUrl
:
string
}
>
(
"/api/sites/"
+
siteId
+
"/build"
,
{
method
:
"POST"
}),
publish
:
(
siteId
:
string
)
=>
request
<
PublishResult
>
(
"/api/sites/"
+
siteId
+
"/publish"
,
{
method
:
"POST"
}),
rollback
:
(
siteId
:
string
,
commit
:
string
)
=>
request
<
{
commit
:
string
;
previewUrl
:
string
}
>
(
"/api/sites/"
+
siteId
+
"/rollback"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
commit
})
}),
rollback
:
(
siteId
:
string
,
commit
:
string
)
=>
request
<
{
commit
:
string
;
previewUrl
:
string
}
>
(
"/api/sites/"
+
siteId
+
"/rollback"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
commit
})
}),
};
};
frontend/src/styles.css
View file @
2d6e5007
...
@@ -13,12 +13,13 @@
...
@@ -13,12 +13,13 @@
.landing-header
{
position
:
relative
;
z-index
:
2
;
width
:
min
(
1380px
,
calc
(
100%
-
64px
));
height
:
88px
;
margin
:
auto
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
}
.landing-status
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
padding
:
8px
12px
;
color
:
var
(
--text-2
);
background
:
rgba
(
255
,
255
,
255
,
.72
);
border
:
1px
solid
rgba
(
231
,
227
,
243
,
.9
);
border-radius
:
999px
;
font-size
:
11px
;
font-weight
:
650
;
backdrop-filter
:
blur
(
14px
)}
.landing-status
>
span
:first-child
{
width
:
7px
;
height
:
7px
;
border-radius
:
50%
;
background
:
#20b486
;
box-shadow
:
0
0
0
4px
rgba
(
32
,
180
,
134
,
.12
)}
.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
:
.92
fr
1.08
fr
;
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
,
1
fr
);
gap
:
12px
;
margin-top
:
52px
}
.feature-row
>
div
{
padding
:
17px
13px
;
background
:
rgba
(
255
,
255
,
255
,
.62
);
border
:
1px
solid
rgba
(
231
,
227
,
243
,
.9
);
border-radius
:
16px
}
.feature-row
div
>
span
{
display
:
grid
;
place-items
:
center
;
width
:
35px
;
height
:
35px
;
margin-bottom
:
13px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border-radius
:
10px
}
.feature-row
strong
,
.feature-row
small
{
display
:
block
}
.feature-row
strong
{
font-size
:
12px
;
margin-bottom
:
6px
}
.feature-row
small
{
color
:
var
(
--muted
);
font-size
:
10px
;
line-height
:
1.5
}
.create-layout
{
position
:
relative
;
z-index
:
1
;
width
:
min
(
1320px
,
calc
(
100%
-
64px
));
min-height
:
calc
(
100vh
-
88px
);
margin
:
auto
;
display
:
grid
;
grid-template-columns
:
.92
fr
1.08
fr
;
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
,
1
fr
);
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
:
1
fr
1
fr
;
gap
:
17px
}
.field
{
display
:
flex
;
flex-direction
:
column
;
gap
:
8px
}
.field.wide
{
grid-column
:
1
/
-1
}
.field
>
span
{
display
:
flex
;
justify-content
:
space-between
;
color
:
var
(
--text-2
);
font-size
:
11px
;
font-weight
:
700
}
.field
small
{
color
:
var
(
--muted
);
font-weight
:
500
}
.field
input
,
.field
textarea
{
width
:
100%
;
padding
:
11px
13px
;
color
:
var
(
--text
);
background
:
#fbfbfd
;
border
:
1px
solid
var
(
--border
);
border-radius
:
10px
;
outline
:
none
;
font-size
:
13px
;
transition
:
.2s
}
.field
textarea
{
resize
:
vertical
;
line-height
:
1.6
}
.field
input
:focus
,
.field
textarea
:focus
{
background
:
#fff
;
border-color
:
#ad8cff
;
box-shadow
:
0
0
0
3px
rgba
(
112
,
40
,
255
,
.08
)}
.color-field
{
height
:
44px
;
display
:
flex
;
align-items
:
center
;
gap
:
11px
;
padding
:
5px
8px
;
background
:
#fbfbfd
;
border
:
1px
solid
var
(
--border
);
border-radius
:
10px
}
.color-field
>
input
{
width
:
31px
;
height
:
31px
;
padding
:
2px
;
border
:
0
;
background
:
none
}
.color-field
>
span
{
font-family
:
ui-monospace
,
SFMono-Regular
,
monospace
;
color
:
var
(
--text-2
);
font-size
:
11px
}
.color-swatches
{
display
:
flex
;
gap
:
7px
;
margin-left
:
auto
}
.color-swatches
button
{
width
:
21px
;
height
:
21px
;
padding
:
0
;
border
:
2px
solid
white
;
border-radius
:
7px
;
box-shadow
:
0
0
0
1px
var
(
--border
)}
.color-swatches
button
.active
{
box-shadow
:
0
0
0
2px
var
(
--primary
)}
.create-submit
{
width
:
100%
;
height
:
51px
;
margin-top
:
24px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
11px
;
color
:
white
;
background
:
var
(
--gradient
);
border
:
0
;
border-radius
:
12px
;
box-shadow
:
0
13px
28px
rgba
(
112
,
40
,
255
,
.24
);
font-size
:
13px
;
font-weight
:
800
;
transition
:
.2s
}
.create-submit
:hover:not
(
:disabled
)
{
transform
:
translateY
(
-1px
);
box-shadow
:
0
16px
34px
rgba
(
112
,
40
,
255
,
.32
)}
.local-note
{
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
6px
;
margin
:
14px
0
0
;
color
:
var
(
--muted
);
font-size
:
9px
}
.form-error
{
display
:
flex
;
align-items
:
center
;
gap
:
7px
;
margin-top
:
16px
;
padding
:
10px
12px
;
color
:
#b42318
;
background
:
#fff2f0
;
border-radius
:
9px
;
font-size
:
11px
}
.create-card-wrap
{
display
:
flex
;
justify-content
:
flex-end
}
.create-card
{
width
:
min
(
100%
,
650px
);
padding
:
35px
38px
25px
;
background
:
rgba
(
255
,
255
,
255
,
.91
);
border
:
1px
solid
rgba
(
255
,
255
,
255
,
.95
);
border-radius
:
26px
;
box-shadow
:
0
32px
90px
rgba
(
36
,
16
,
95
,
.13
),
0
2px
6px
rgba
(
36
,
16
,
95
,
.04
);
backdrop-filter
:
blur
(
24px
)}
.card-heading
{
display
:
flex
;
align-items
:
flex-start
;
justify-content
:
space-between
;
margin-bottom
:
27px
}
.card-heading
span
{
color
:
var
(
--primary
);
font-size
:
10px
;
font-weight
:
800
;
letter-spacing
:
.12em
}
.card-heading
h2
{
margin
:
7px
0
0
;
font-size
:
24px
;
letter-spacing
:
-.035em
}
.step-pill
{
padding
:
7px
10px
;
color
:
var
(
--muted
);
background
:
#f8f8fc
;
border-radius
:
9px
;
font-size
:
10px
;
font-weight
:
700
}
.form-grid
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
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
1
fr
;
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
)}
.workspace
{
height
:
100vh
;
height
:
100
dvh
;
max-height
:
100
dvh
;
min-height
:
0
;
display
:
grid
;
grid-template-columns
:
64px
372px
1
fr
;
overflow
:
hidden
;
background
:
#eff0f5
}
.rail
{
min-height
:
0
;
display
:
flex
;
flex-direction
:
column
;
align-items
:
center
;
padding
:
15px
0
;
background
:
#fff
;
border-right
:
1px
solid
var
(
--divider
);
z-index
:
3
}
.rail
.logo-mark
{
width
:
35px
;
height
:
35px
}
.rail-nav
{
display
:
flex
;
flex-direction
:
column
;
gap
:
9px
;
margin-top
:
42px
}
.rail
button
{
display
:
grid
;
place-items
:
center
;
width
:
38px
;
height
:
38px
;
color
:
var
(
--muted
);
background
:
transparent
;
border
:
0
;
border-radius
:
11px
}
.rail
button
:hover
,
.rail
button
.active
{
color
:
var
(
--primary
);
background
:
var
(
--soft
)}
.rail-create
{
margin-top
:
auto
!important
;
color
:
white
!important
;
background
:
var
(
--gradient
)
!important
;
box-shadow
:
0
8px
20px
rgba
(
112
,
40
,
255
,
.2
)}
.control-panel
{
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
:
1
fr
1
fr
;
gap
:
5px
;
align-items
:
center
;
border-bottom
:
1px
solid
var
(
--divider
)}
.panel-tabs
button
{
height
:
35px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
7px
;
color
:
var
(
--muted
);
background
:
transparent
;
border
:
0
;
border-radius
:
9px
;
font-size
:
11px
;
font-weight
:
700
}
.panel-tabs
button
.active
{
color
:
var
(
--primary
);
background
:
var
(
--soft
)}
.control-panel
{
height
:
100%
;
min-height
:
0
;
min-width
:
0
;
overflow
:
hidden
;
display
:
flex
;
flex-direction
:
column
;
background
:
#fff
;
border-right
:
1px
solid
var
(
--border
);
z-index
:
2
}
.site-switcher
{
position
:
relative
;
z-index
:
20
;
flex
:
0
0
66px
;
height
:
66px
;
padding
:
8px
12px
;
border-bottom
:
1px
solid
var
(
--divider
)}
.site-switcher-trigger
{
width
:
100%
;
height
:
49px
;
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
padding
:
5px
7px
;
color
:
var
(
--text
);
text-align
:
left
;
background
:
#fff
;
border
:
1px
solid
transparent
;
border-radius
:
12px
;
transition
:
.18s
ease
}
.site-switcher-trigger
:hover
,
.site-switcher.open
.site-switcher-trigger
{
background
:
#faf9ff
;
border-color
:
#ded6ff
;
box-shadow
:
0
5px
18px
rgba
(
36
,
16
,
95
,
.06
)}
.site-avatar
,
.site-menu-avatar
{
display
:
grid
;
place-items
:
center
;
flex
:
0
0
auto
;
color
:
white
;
background
:
var
(
--gradient
);
font-weight
:
800
}
.site-avatar
{
width
:
35px
;
height
:
35px
;
border-radius
:
10px
;
font-size
:
12px
;
box-shadow
:
0
7px
16px
rgba
(
112
,
40
,
255
,
.18
)}
.site-switcher-copy
,
.site-menu-copy
{
min-width
:
0
;
display
:
flex
;
flex-direction
:
column
}
.site-switcher-copy
{
flex
:
1
;
gap
:
3px
}
.site-switcher-copy
strong
{
overflow
:
hidden
;
font-size
:
12px
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
}
.site-switcher-copy
small
{
overflow
:
hidden
;
color
:
var
(
--muted
);
font-size
:
8px
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
}
.site-switcher-chevron
{
display
:
grid
;
place-items
:
center
;
width
:
26px
;
height
:
26px
;
color
:
var
(
--muted
);
background
:
#f4f4f7
;
border-radius
:
8px
;
transition
:
.2s
ease
}
.site-switcher.open
.site-switcher-chevron
{
color
:
var
(
--primary
);
background
:
var
(
--soft
);
transform
:
rotate
(
180deg
)}
.site-switcher-menu
{
position
:
absolute
;
top
:
61px
;
left
:
12px
;
right
:
12px
;
padding
:
8px
;
background
:
rgba
(
255
,
255
,
255
,
.98
);
border
:
1px
solid
#dfd9ef
;
border-radius
:
14px
;
box-shadow
:
0
20px
52px
rgba
(
36
,
16
,
95
,
.18
),
0
4px
12px
rgba
(
36
,
16
,
95
,
.07
);
backdrop-filter
:
blur
(
18px
);
animation
:
site-menu-in
.16s
ease-out
}
@keyframes
site-menu-in
{
from
{
opacity
:
0
;
transform
:
translateY
(
-5px
)
scale
(
.985
)}
to
{
opacity
:
1
;
transform
:
none
}}
.site-menu-heading
{
height
:
28px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
0
7px
;
color
:
var
(
--muted
);
font-size
:
8px
;
font-weight
:
750
;
letter-spacing
:
.04em
}
.site-menu-heading
em
{
display
:
grid
;
place-items
:
center
;
width
:
19px
;
height
:
19px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border-radius
:
6px
;
font-size
:
8px
;
font-style
:
normal
}
.site-menu-list
{
display
:
flex
;
flex-direction
:
column
;
gap
:
3px
}
.site-menu-list
>
button
{
width
:
100%
;
min-width
:
0
;
height
:
52px
;
display
:
flex
;
align-items
:
center
;
gap
:
9px
;
padding
:
6px
8px
;
color
:
var
(
--text
);
text-align
:
left
;
background
:
transparent
;
border
:
0
;
border-radius
:
10px
}
.site-menu-list
>
button
:hover
{
background
:
#f7f5ff
}
.site-menu-list
>
button
.selected
{
background
:
#f4f0ff
}
.site-menu-avatar
{
width
:
32px
;
height
:
32px
;
border-radius
:
9px
;
font-size
:
11px
}
.site-menu-copy
{
flex
:
1
;
gap
:
4px
}
.site-menu-copy
strong
{
overflow
:
hidden
;
font-size
:
10px
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
}
.site-menu-copy
small
{
overflow
:
hidden
;
color
:
var
(
--muted
);
font-size
:
8px
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
}
.site-menu-status
{
width
:
7px
;
height
:
7px
;
border-radius
:
50%
;
background
:
#d0d1d8
;
box-shadow
:
0
0
0
3px
rgba
(
208
,
209
,
216
,
.15
)}
.site-menu-status.ready
{
background
:
#7957d5
;
box-shadow
:
0
0
0
3px
rgba
(
121
,
87
,
213
,
.1
)}
.site-menu-status.published
{
background
:
#20b486
;
box-shadow
:
0
0
0
3px
rgba
(
32
,
180
,
134
,
.1
)}
.site-menu-status.failed
{
background
:
#d92d20
;
box-shadow
:
0
0
0
3px
rgba
(
217
,
45
,
32
,
.1
)}
.site-menu-list
>
button
>
svg
{
flex
:
0
0
auto
;
color
:
var
(
--primary
)}
.site-switcher-menu
>
p
{
height
:
27px
;
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
margin
:
6px
3px
0
;
padding
:
6px
5px
0
;
color
:
var
(
--muted
);
border-top
:
1px
solid
var
(
--divider
);
font-size
:
7px
}
.panel-tabs
{
flex
:
0
0
49px
;
height
:
49px
;
padding
:
0
14px
;
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
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
}
.chat-scroll
{
flex
:
1
1
0
;
min-height
:
0
;
overflow-x
:
hidden
;
overflow-y
:
auto
;
overscroll-behavior
:
contain
;
padding
:
20px
17px
28px
}
.chat-date
{
text-align
:
center
;
color
:
#aaaeba
;
font-size
:
9px
;
margin-bottom
:
22px
}
.message
{
display
:
flex
;
gap
:
9px
;
margin-bottom
:
17px
}
.message.user
{
justify-content
:
flex-end
}
.message
>
div
{
max-width
:
86%
}
.message
p
{
margin
:
0
;
padding
:
12px
14px
;
color
:
var
(
--text-2
);
background
:
#f6f6f9
;
border-radius
:
4px
13px
13px
13px
;
font-size
:
12px
;
line-height
:
1.65
}
.message.user
p
{
color
:
white
;
background
:
var
(
--gradient
);
border-radius
:
13px
4px
13px
13px
;
box-shadow
:
0
8px
18px
rgba
(
112
,
40
,
255
,
.15
)}
.message
small
{
display
:
flex
;
align-items
:
center
;
gap
:
4px
;
margin
:
6px
2px
0
;
color
:
#9a9da8
;
font-size
:
9px
}
.agent-avatar
{
flex
:
0
0
auto
;
display
:
grid
;
place-items
:
center
;
width
:
28px
;
height
:
28px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border
:
1px
solid
#e2dbff
;
border-radius
:
9px
}
.agent-progress
{
display
:
flex
;
gap
:
9px
;
margin
:
8px
0
18px
}
.agent-progress
>
div
{
flex
:
1
;
padding
:
12px
13px
;
background
:
#faf9ff
;
border
:
1px
solid
#ebe6ff
;
border-radius
:
4px
13px
13px
13px
}
.agent-progress
p
{
display
:
flex
;
align-items
:
center
;
gap
:
7px
;
margin
:
0
0
10px
;
color
:
var
(
--text-2
);
font-size
:
11px
;
font-weight
:
700
}
.agent-progress
small
{
color
:
var
(
--muted
);
font-size
:
9px
}
.progress-track
{
height
:
3px
;
margin-bottom
:
8px
;
overflow
:
hidden
;
background
:
#ebe7f5
;
border-radius
:
9px
}
.progress-track
i
{
display
:
block
;
width
:
45%
;
height
:
100%
;
background
:
var
(
--gradient
);
border-radius
:
9px
;
animation
:
progress
1.5s
ease-in-out
infinite
}
@keyframes
progress
{
50
%
{
transform
:
translateX
(
120%
)}}
.suggestions
{
display
:
flex
;
flex-direction
:
column
;
gap
:
7px
;
margin
:
25px
37px
0
}
.suggestions
>
span
{
margin-bottom
:
3px
;
color
:
var
(
--muted
);
font-size
:
9px
}
.suggestions
button
{
display
:
flex
;
align-items
:
center
;
gap
:
7px
;
padding
:
9px
11px
;
text-align
:
left
;
color
:
var
(
--text-2
);
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
9px
;
font-size
:
10px
}
.suggestions
button
:hover
{
color
:
var
(
--primary
);
border-color
:
#cdbdff
;
background
:
#faf9ff
}
.composer
{
margin
:
0
14px
14px
;
padding
:
10px
;
background
:
#fff
;
border
:
1px
solid
#dcd8e8
;
border-radius
:
13px
;
box-shadow
:
0
5px
22px
rgba
(
36
,
16
,
95
,
.07
)}
.composer
textarea
{
width
:
100%
;
padding
:
2px
4px
;
resize
:
none
;
color
:
var
(
--text
);
background
:
transparent
;
border
:
0
;
outline
:
none
;
font-size
:
11px
;
line-height
:
1.55
}
.composer
textarea
::placeholder
{
color
:
#a5a7b0
}
.composer
>
div
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
}
.composer
>
div
>
span
{
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
color
:
var
(
--muted
);
font-size
:
9px
}
.online-dot
{
width
:
6px
;
height
:
6px
;
border-radius
:
50%
;
background
:
#20b486
}
.composer
button
{
display
:
grid
;
place-items
:
center
;
width
:
29px
;
height
:
29px
;
color
:
white
;
background
:
var
(
--gradient
);
border
:
0
;
border-radius
:
8px
}
.composer
{
flex
:
0
0
auto
;
margin
:
0
14px
14px
;
padding
:
10px
;
background
:
#fff
;
border
:
1px
solid
#dcd8e8
;
border-radius
:
13px
;
box-shadow
:
0
5px
22px
rgba
(
36
,
16
,
95
,
.07
)}
.composer
textarea
{
width
:
100%
;
padding
:
2px
4px
;
resize
:
none
;
color
:
var
(
--text
);
background
:
transparent
;
border
:
0
;
outline
:
none
;
font-size
:
11px
;
line-height
:
1.55
}
.composer
textarea
::placeholder
{
color
:
#a5a7b0
}
.composer
>
div
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
}
.composer
>
div
>
span
{
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
color
:
var
(
--muted
);
font-size
:
9px
}
.online-dot
{
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
1
fr
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
)}
.history-list
{
flex
:
1
1
0
;
min-height
:
0
;
overflow
:
auto
;
padding
:
16px
16px
22px
}
.environment-versions
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
8px
;
margin-bottom
:
22px
}
.environment-versions
>
button
{
min-width
:
0
;
padding
:
12px
;
text-align
:
left
;
background
:
#fafafd
;
border
:
1px
solid
var
(
--border
);
border-radius
:
12px
}
.environment-versions
>
button
:hover
,
.environment-versions
>
button
.active
{
border-color
:
#bca8ff
;
background
:
#faf8ff
;
box-shadow
:
0
5px
18px
rgba
(
112
,
40
,
255
,
.08
)}
.environment-versions
>
button
.production
:hover
,
.environment-versions
>
button
.production.active
{
border-color
:
#9bd8c6
;
background
:
#f4fcf9
;
box-shadow
:
0
5px
18px
rgba
(
32
,
180
,
134
,
.08
)}
.environment-versions
span
{
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
color
:
var
(
--text-2
);
font-size
:
9px
;
font-weight
:
750
}
.environment-versions
span
>
em
{
margin-left
:
auto
;
padding
:
2px
4px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border-radius
:
4px
;
font-size
:
7px
;
font-style
:
normal
}
.environment-versions
.production
span
>
em
{
color
:
#13765b
;
background
:
#e7f8f2
}
.environment-versions
strong
,
.environment-versions
small
{
display
:
block
}
.environment-versions
strong
{
margin-top
:
13px
;
font-family
:
ui-monospace
,
SFMono-Regular
,
monospace
;
font-size
:
13px
;
letter-spacing
:
.02em
}
.environment-versions
small
{
margin-top
:
5px
;
overflow
:
hidden
;
color
:
var
(
--muted
);
font-size
:
8px
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
}
.history-heading
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
margin-bottom
:
18px
}
.history-heading
h3
{
margin
:
0
0
4px
;
font-size
:
14px
}
.history-heading
p
{
margin
:
0
;
color
:
var
(
--muted
);
font-size
:
9px
}
.history-heading
>
span
{
display
:
grid
;
place-items
:
center
;
width
:
25px
;
height
:
25px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border-radius
:
8px
;
font-size
:
10px
;
font-weight
:
800
}
.history-list
article
{
display
:
grid
;
grid-template-columns
:
24px
minmax
(
0
,
1
fr
)
auto
;
gap
:
7px
;
min-height
:
69px
}
.history-list
article
.environment-version
{
margin
:
0
-5px
;
padding
:
0
5px
;
background
:
#fbfaff
;
border-radius
:
10px
}
.timeline
{
display
:
flex
;
align-items
:
center
;
flex-direction
:
column
}
.timeline
i
{
display
:
grid
;
place-items
:
center
;
width
:
17px
;
height
:
17px
;
color
:
white
;
background
:
#d6d7df
;
border
:
3px
solid
white
;
box-shadow
:
0
0
0
1px
#d6d7df
;
border-radius
:
50%
;
font-style
:
normal
}
.history-list
article
.environment-version
.timeline
i
{
background
:
var
(
--primary
);
box-shadow
:
0
0
0
1px
var
(
--primary
)}
.timeline
span
{
width
:
1px
;
flex
:
1
;
background
:
var
(
--divider
)}
.commit-info
{
min-width
:
0
;
padding-bottom
:
18px
}
.commit-info
>
div
{
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
min-width
:
0
}
.commit-info
strong
{
min-width
:
0
;
overflow
:
hidden
;
font-size
:
10px
;
line-height
:
1.4
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
}
.commit-info
em
{
flex
:
0
0
auto
;
padding
:
2px
5px
;
border-radius
:
5px
;
font-size
:
7px
;
font-style
:
normal
}
.commit-info
.preview-badge
{
color
:
var
(
--primary
);
background
:
var
(
--soft
)}
.commit-info
.production-badge
{
color
:
#13765b
;
background
:
#e7f8f2
}
.commit-info
p
{
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
margin
:
5px
0
0
;
color
:
var
(
--muted
);
font-size
:
9px
}
.commit-info
code
{
font-size
:
9px
}
.history-actions
{
display
:
flex
;
align-items
:
flex-start
;
gap
:
4px
}
.history-actions
button
{
display
:
grid
;
place-items
:
center
;
width
:
25px
;
height
:
25px
;
padding
:
0
;
color
:
var
(
--muted
);
background
:
white
;
border
:
1px
solid
var
(
--border
);
border-radius
:
7px
}
.history-actions
button
:hover
{
color
:
var
(
--primary
);
background
:
var
(
--soft
)}
.preview-shell
{
min-
width
:
0
;
display
:
flex
;
flex-direction
:
column
}
.preview-toolbar
{
height
:
66px
;
display
:
grid
;
grid-template-columns
:
1
fr
auto
1
fr
;
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
)
}
.preview-shell
{
min-
height
:
0
;
min-width
:
0
;
overflow
:
hidden
;
display
:
flex
;
flex-direction
:
column
}
.preview-toolbar
{
min-height
:
66px
;
display
:
grid
;
grid-template-columns
:
minmax
(
190px
,
1
fr
)
auto
minmax
(
310px
,
1
fr
);
grid-template-areas
:
"title modes actions"
;
align-items
:
center
;
gap
:
12px
;
padding
:
0
18px
;
background
:
#fff
;
border-bottom
:
1px
solid
var
(
--border
)}
.preview-title
{
grid-area
:
title
;
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
min-width
:
0
}
.preview-title
>
div
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
min-width
:
0
}
.preview-title
strong
{
overflow
:
hidden
;
font-size
:
12px
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
}
.preview-title
em
{
flex
:
0
0
auto
;
padding
:
3px
6px
;
color
:
#7957d5
;
background
:
#f2edff
;
border-radius
:
5px
;
font-size
:
8px
;
font-style
:
normal
;
font-weight
:
750
}
.preview-title
em
.production
{
color
:
#13765b
;
background
:
#e7f8f2
}
.status-dot
{
flex
:
0
0
auto
;
width
:
7px
;
height
:
7px
;
border-radius
:
50%
;
background
:
#7957d5
;
box-shadow
:
0
0
0
4px
rgba
(
112
,
40
,
255
,
.1
)}
.status-dot.production
{
background
:
#20b486
;
box-shadow
:
0
0
0
4px
rgba
(
32
,
180
,
134
,
.1
)}
.build-status
{
flex
:
0
0
auto
;
display
:
flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
5px
7px
;
color
:
#188866
;
background
:
#ebfbf5
;
border-radius
:
7px
;
font-size
:
8px
;
font-weight
:
750
}
.build-status.failed
{
color
:
#b42318
;
background
:
#fff0ee
}
.build-status.unpublished
{
color
:
#986600
;
background
:
#fff8e5
}
.preview-modes
{
grid-area
:
modes
;
display
:
flex
;
align-items
:
center
;
gap
:
7px
}
.environment-toggle
,
.device-toggle
{
display
:
flex
;
padding
:
3px
;
background
:
#f2f2f6
;
border-radius
:
8px
}
.environment-toggle
button
{
height
:
27px
;
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
padding
:
0
9px
;
color
:
var
(
--muted
);
background
:
transparent
;
border
:
0
;
border-radius
:
6px
;
font-size
:
8px
;
font-weight
:
750
}
.environment-toggle
button
.active
,
.device-toggle
button
.active
{
color
:
var
(
--text
);
background
:
#fff
;
box-shadow
:
0
2px
7px
rgba
(
36
,
16
,
95
,
.1
)}
.device-toggle
button
{
display
:
grid
;
place-items
:
center
;
width
:
31px
;
height
:
27px
;
color
:
var
(
--muted
);
background
:
transparent
;
border
:
0
;
border-radius
:
6px
}
.toolbar-actions
{
grid-area
:
actions
;
display
:
flex
;
justify-content
:
flex-end
;
gap
:
6px
;
min-width
:
0
}
.toolbar-actions
button
,
.toolbar-actions
a
{
height
:
31px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
5px
;
padding
:
0
9px
;
color
:
var
(
--text-2
);
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
8px
;
font-size
:
8px
;
font-weight
:
750
;
white-space
:
nowrap
}
.toolbar-actions
button
:hover:not
(
:disabled
),
.toolbar-actions
a
:hover
{
color
:
var
(
--primary
);
background
:
var
(
--soft
)}
.toolbar-actions
.environment-link.production-link
{
color
:
#13765b
}
.toolbar-actions
.environment-link
:disabled
{
color
:
#a8a9b0
;
background
:
#f5f5f7
}
.toolbar-actions
.rebuild-button
{
color
:
var
(
--primary
);
border-color
:
#d8ccff
;
background
:
#faf8ff
}
.toolbar-actions
.publish-button
{
color
:
white
;
background
:
var
(
--gradient
);
border
:
0
}
.toolbar-actions
.publish-button
:hover:not
(
:disabled
)
{
color
:
white
;
background
:
var
(
--gradient
);
box-shadow
:
0
7px
17px
rgba
(
112
,
40
,
255
,
.2
)}
.toolbar-actions
.publish-button
:disabled
{
color
:
#9a9da8
;
background
:
#efeff4
;
border
:
1px
solid
#e1e1e7
;
box-shadow
:
none
}
.browser-stage
{
position
:
relative
;
flex
:
1
;
min-height
:
0
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
padding
:
25px
;
background
:
#eff0f5
;
overflow
:
auto
}
.browser-frame
{
position
:
relative
;
width
:
100%
;
height
:
100%
;
overflow
:
hidden
;
background
:
#fff
;
border
:
1px
solid
#dfe0e7
;
border-radius
:
11px
;
box-shadow
:
0
15px
45px
rgba
(
30
,
24
,
53
,
.1
);
transition
:
.35s
ease
}
.browser-frame.mobile
{
width
:
390px
;
max-height
:
760px
}
.browser-chrome
{
height
:
31px
;
display
:
grid
;
grid-template-columns
:
70px
1
fr
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
}
.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
1
fr
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
}
.pr
oduction-empty
{
height
:
calc
(
100%
-
31px
);
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
flex-direction
:
column
;
padding
:
30px
;
text-align
:
center
;
background
:
linear-gradient
(
180deg
,
#fbfffd
,
#f7faf9
)}
.production-empty
>
span
{
display
:
grid
;
place-items
:
center
;
width
:
52px
;
height
:
52px
;
color
:
#168064
;
background
:
#e8f8f2
;
border-radius
:
16px
}
.production-empty
strong
{
margin-top
:
17px
;
font-size
:
15px
}
.production-empty
p
{
max-width
:
360px
;
margin
:
8px
0
20px
;
color
:
var
(
--muted
);
font-size
:
10px
;
line-height
:
1.7
}
.production-empty
button
{
height
:
36px
;
display
:
flex
;
align-items
:
center
;
gap
:
7px
;
padding
:
0
14px
;
color
:
white
;
background
:
linear-gradient
(
135deg
,
#15936e
,
#20b486
);
border
:
0
;
border-radius
:
9px
;
font-size
:
10px
;
font-weight
:
800
}
.pr
eview-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
:
1
fr
;
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
1
fr
}}
@media
(
max-width
:
1100px
){
.create-layout
{
grid-template-columns
:
1
fr
;
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
1
fr
}}
@media
(
max-width
:
1250px
)
and
(
min-width
:
801px
){
.preview-toolbar
{
height
:
96px
;
grid-template-columns
:
minmax
(
160px
,
1
fr
)
auto
;
grid-template-rows
:
42px
42px
;
grid-template-areas
:
"title actions"
"modes modes"
;
gap
:
0
10px
;
padding
:
6px
12px
}
.preview-modes
{
justify-content
:
center
}
.toolbar-actions
.environment-link
{
width
:
31px
;
padding
:
0
}
.toolbar-actions
.environment-link
>
span
{
display
:
none
}}
@media
(
max-width
:
800px
){
.landing-header
,
.create-layout
{
width
:
min
(
100%
-
30px
,
700px
)}
.landing-status
{
display
:
none
}
.feature-row
{
grid-template-columns
:
1
fr
}
.create-card
{
padding
:
26px
20px
}
.form-grid
{
grid-template-columns
:
1
fr
}
.field.wide
{
grid-column
:
auto
}
.workspace
{
grid-template-columns
:
52px
1
fr
}
.control-panel
{
border-right
:
0
}
.preview-shell
{
display
:
none
}
.create-intro
h1
{
font-size
:
48px
}
.create-layout
{
padding-bottom
:
35px
}}
@media
(
max-width
:
800px
){
.landing-header
,
.create-layout
{
width
:
min
(
100%
-
30px
,
700px
)}
.landing-status
{
display
:
none
}
.feature-row
{
grid-template-columns
:
1
fr
}
.create-card
{
padding
:
26px
20px
}
.form-grid
{
grid-template-columns
:
1
fr
}
.field.wide
{
grid-column
:
auto
}
.workspace
{
grid-template-columns
:
52px
1
fr
}
.control-panel
{
border-right
:
0
}
.preview-shell
{
display
:
none
}
.create-intro
h1
{
font-size
:
48px
}
.create-layout
{
padding-bottom
:
35px
}}
frontend/vite.config.ts
View file @
2d6e5007
...
@@ -5,6 +5,10 @@ export default defineConfig({
...
@@ -5,6 +5,10 @@ export default defineConfig({
plugins
:
[
react
()],
plugins
:
[
react
()],
server
:
{
server
:
{
port
:
5173
,
port
:
5173
,
proxy
:
{
"/api"
:
"http://127.0.0.1:3100"
},
proxy
:
{
"/api"
:
"http://127.0.0.1:3100"
,
"/previews"
:
"http://127.0.0.1:80"
,
"/sites"
:
"http://127.0.0.1:80"
,
},
},
},
});
});
shared/src/index.ts
View file @
2d6e5007
export
type
SiteStatus
=
"creating"
|
"ready"
|
"building"
|
"failed"
;
export
type
SiteStatus
=
"creating"
|
"ready"
|
"building"
|
"failed"
;
export
type
PublishStatus
=
"unpublished"
|
"publishing"
|
"published"
|
"failed"
;
export
interface
CreateSiteInput
{
export
interface
CreateSiteInput
{
name
:
string
;
name
:
string
;
...
@@ -16,12 +17,18 @@ export interface SiteInfo {
...
@@ -16,12 +17,18 @@ export interface SiteInfo {
industry
:
string
;
industry
:
string
;
status
:
SiteStatus
;
status
:
SiteStatus
;
templateVersion
:
string
;
templateVersion
:
string
;
environmentVersion
?:
number
;
previewPort
:
number
;
previewPort
:
number
;
previewUrl
:
string
;
previewUrl
:
string
;
productionUrl
?:
string
;
currentCommit
:
string
;
currentCommit
:
string
;
publishedCommit
?:
string
;
publishedAt
?:
string
;
publishStatus
:
PublishStatus
;
createdAt
:
string
;
createdAt
:
string
;
updatedAt
:
string
;
updatedAt
:
string
;
lastError
?:
string
;
lastError
?:
string
;
lastPublishError
?:
string
;
}
}
export
interface
SitePatch
{
export
interface
SitePatch
{
...
@@ -48,3 +55,9 @@ export interface ChatResult {
...
@@ -48,3 +55,9 @@ export interface ChatResult {
changedFiles
:
string
[];
changedFiles
:
string
[];
mode
:
"model"
|
"local"
;
mode
:
"model"
|
"local"
;
}
}
export
interface
PublishResult
{
productionUrl
:
string
;
commit
:
string
;
publishedAt
:
string
;
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment