refactor: update web UI asset serving and embedding mechanism.

This commit is contained in:
CJACK
2026-02-16 19:58:33 +08:00
parent 190881f13a
commit 888a0e6bff
17 changed files with 150 additions and 749 deletions

View File

@@ -54,6 +54,10 @@ DS2API_ADMIN_KEY=admin
# Built admin static assets directory
# DS2API_STATIC_ADMIN_DIR=static/admin
# Auto-build WebUI on startup when static/admin is missing.
# Default: enabled on local/Docker, disabled on Vercel.
# DS2API_AUTO_BUILD_WEBUI=true
# ---------------------------------------------------------------
# Vercel sync integration (optional)
# ---------------------------------------------------------------

3
.gitignore vendored
View File

@@ -64,7 +64,8 @@ pnpm-lock.yaml
*.tsbuildinfo
.cache/
.parcel-cache/
# static/admin build output is versioned for serverless deployments
static/admin/
internal/webui/assets/admin/
# Environment
.env.local

View File

@@ -35,6 +35,9 @@ Build WebUI if `/admin` reports missing assets:
```bash
./scripts/build-webui.sh
# Or rely on startup auto-build (enabled locally by default)
# DS2API_AUTO_BUILD_WEBUI=true go run ./cmd/ds2api
```
## 2. Docker Deployment
@@ -62,7 +65,7 @@ Notes:
- Serverless entry: `api/index.go`
- Rewrites and cache headers: `vercel.json`
- Legacy `builds` has been removed to avoid the `unused-build-settings` warning
- Build stage runs `npm ci --prefix webui && npm run build --prefix webui` automatically
Minimum environment variables:
@@ -79,6 +82,10 @@ Optional:
Recommended concurrency is computed dynamically as `account_count * per_account_inflight_limit` (default is `account_count * 2`).
Notes:
- `static/admin` build output is not committed
- Vercel/Docker generate WebUI assets during build
After deploy, verify:
- `/healthz`

View File

@@ -35,6 +35,9 @@ go run ./cmd/ds2api
```bash
./scripts/build-webui.sh
# 或依赖自动构建(默认本地开启)
# DS2API_AUTO_BUILD_WEBUI=true go run ./cmd/ds2api
```
## 2. Docker 部署
@@ -62,7 +65,7 @@ docker-compose up -d --build
- serverless 入口:`api/index.go`
- 路由与缓存头:`vercel.json`
- 已移除 legacy `builds` 字段,避免 `unused-build-settings` 警告
- 构建阶段会自动执行 `npm ci --prefix webui && npm run build --prefix webui`
至少配置环境变量:
@@ -79,6 +82,10 @@ docker-compose up -d --build
并发建议值会动态按 `账号数量 × 每账号并发上限` 计算(默认即 `账号数量 × 2`)。
说明:
- 仓库不提交 `static/admin` 构建产物
- Vercel / Docker 构建阶段自动生成 WebUI 静态文件
部署后建议先访问:
- `/healthz`

View File

@@ -8,7 +8,7 @@
语言 / Language: [中文](README.MD) | [English](README.en.md)
将 DeepSeek Web 对话能力转换为 OpenAI 与 Claude 兼容 API。当前仓库后端为 **Go 全量实现**,前端保留 React WebUI构建产物托管于 `static/admin`)。
将 DeepSeek Web 对话能力转换为 OpenAI 与 Claude 兼容 API。当前仓库后端为 **Go 全量实现**,前端保留 React WebUI源码在 `webui/`,部署时自动构建到 `static/admin`)。
## 当前实现边界
@@ -65,7 +65,8 @@ go run ./cmd/ds2api
默认地址:`http://localhost:5001`
如果访问 `/admin` 提示未构建 WebUI请执行
本地默认会在启动时自动尝试构建 WebUI需要本机有 Node.js/npm
若你想手动构建,也可执行:
```bash
./scripts/build-webui.sh
@@ -85,12 +86,12 @@ docker-compose logs -f
- 入口:`api/index.go`
- 路由重写:`vercel.json`
- `vercel.json` 会在构建阶段自动执行 `npm ci --prefix webui && npm run build --prefix webui`
- 至少配置:
- `DS2API_ADMIN_KEY`
- `DS2API_CONFIG_JSON`JSON 字符串或 Base64
说明:`vercel.json` 已移除 legacy `builds` 配置,避免部署时出现
`unused-build-settings` 警告,并使用当前推荐的函数路由模式。
说明:仓库不提交 `static/admin` 构建产物Vercel 构建时自动生成并打包。
## Release 自动构建产物GitHub Actions
@@ -162,6 +163,7 @@ cp config.example.json config.json
| `DS2API_CONFIG_JSON` | 直接注入配置JSON 或 Base64 |
| `DS2API_WASM_PATH` | PoW wasm 文件路径 |
| `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 |
| `DS2API_AUTO_BUILD_WEBUI` | 启动时缺失 WebUI 时是否自动执行 npm build默认本地开启Vercel 关闭) |
| `VERCEL_TOKEN` | Vercel 同步 token可选 |
| `VERCEL_PROJECT_ID` | Vercel 项目 ID可选 |
| `VERCEL_TEAM_ID` | Vercel 团队 ID可选 |

View File

@@ -8,7 +8,7 @@
Language: [中文](README.MD) | [English](README.en.md)
DS2API converts DeepSeek Web chat capability into OpenAI-compatible and Claude-compatible APIs. The current repository is **Go backend only** with the existing React WebUI kept as static assets under `static/admin`.
DS2API converts DeepSeek Web chat capability into OpenAI-compatible and Claude-compatible APIs. The current repository is **Go backend only** with the existing React WebUI source in `webui/` and build output generated to `static/admin` during deployment.
## Implementation Boundary
@@ -65,7 +65,8 @@ go run ./cmd/ds2api
Default URL: `http://localhost:5001`
If `/admin` says WebUI not built:
By default, local startup will auto-build WebUI when `static/admin` is missing (Node.js/npm required).
If you prefer manual build:
```bash
./scripts/build-webui.sh
@@ -85,12 +86,12 @@ docker-compose logs -f
- Entrypoint: `api/index.go`
- Rewrites: `vercel.json`
- `vercel.json` runs `npm ci --prefix webui && npm run build --prefix webui` during build
- Minimum env vars:
- `DS2API_ADMIN_KEY`
- `DS2API_CONFIG_JSON` (raw JSON or Base64)
Note: legacy `builds` has been removed from `vercel.json` to avoid
the `unused-build-settings` warning and to follow the current function routing model.
Note: build artifacts under `static/admin` are not committed; Vercel generates them during build.
## Release Artifact Automation (GitHub Actions)
@@ -162,6 +163,7 @@ cp config.example.json config.json
| `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) |
| `DS2API_WASM_PATH` | PoW wasm path |
| `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir |
| `DS2API_AUTO_BUILD_WEBUI` | Auto run npm build on startup when WebUI assets are missing (default: enabled locally, disabled on Vercel) |
| `VERCEL_TOKEN` | Vercel sync token (optional) |
| `VERCEL_PROJECT_ID` | Vercel project ID (optional) |
| `VERCEL_TEAM_ID` | Vercel team ID (optional) |

View File

@@ -7,9 +7,11 @@ import (
"ds2api/internal/config"
"ds2api/internal/server"
"ds2api/internal/webui"
)
func main() {
webui.EnsureBuiltOnStartup()
app := server.NewApp()
port := strings.TrimSpace(os.Getenv("PORT"))
if port == "" {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- SEO Meta Tags -->
<title>DS2API - 管理面板 / Admin Console</title>
<meta name="description" content="DS2API 管理面板:管理 DeepSeek 账号、测试 API、同步 Vercel 配置 / Admin console for accounts, API tests, and Vercel sync." />
<meta name="keywords" content="DeepSeek, API, OpenAI, 管理面板, admin console, DS2API" />
<meta name="author" content="CJackHwang" />
<meta name="robots" content="noindex, nofollow" />
<!-- Open Graph / Social -->
<meta property="og:type" content="website" />
<meta property="og:title" content="DS2API - 管理面板 / Admin Console" />
<meta property="og:description" content="Manage DeepSeek accounts, test the API, and sync Vercel configuration." />
<meta property="og:site_name" content="DS2API" />
<!-- PWA / Mobile -->
<meta name="theme-color" content="#f59e0b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="DS2API" />
<!-- Favicon - using data URI for orange-yellow gradient icon -->
<link rel="icon" type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%23f59e0b'/%3E%3Cstop offset='100%25' stop-color='%23ef4444'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect rx='20' width='100' height='100' fill='url(%23g)'/%3E%3Ctext x='50' y='68' font-family='Arial,sans-serif' font-size='48' font-weight='bold' fill='white' text-anchor='middle'%3EDS%3C/text%3E%3C/svg%3E" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/admin/assets/index-BZnWnZUg.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-Dd4LAu0y.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

103
internal/webui/build.go Normal file
View File

@@ -0,0 +1,103 @@
package webui
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"ds2api/internal/config"
)
const (
defaultBuildTimeout = 5 * time.Minute
)
func EnsureBuiltOnStartup() {
if !shouldAutoBuild() {
return
}
staticDir := resolveStaticAdminDir(config.StaticAdminDir())
if hasBuiltUI(staticDir) {
return
}
if err := buildWebUI(staticDir); err != nil {
config.Logger.Warn("[webui] auto build failed", "error", err)
return
}
if hasBuiltUI(staticDir) {
config.Logger.Info("[webui] auto build completed", "dir", staticDir)
return
}
config.Logger.Warn("[webui] auto build finished but output missing", "dir", staticDir)
}
func shouldAutoBuild() bool {
raw := strings.TrimSpace(os.Getenv("DS2API_AUTO_BUILD_WEBUI"))
if raw == "" {
return !config.IsVercel()
}
switch strings.ToLower(raw) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return !config.IsVercel()
}
}
func hasBuiltUI(staticDir string) bool {
if strings.TrimSpace(staticDir) == "" {
return false
}
indexPath := filepath.Join(staticDir, "index.html")
st, err := os.Stat(indexPath)
return err == nil && !st.IsDir()
}
func buildWebUI(staticDir string) error {
if _, err := exec.LookPath("npm"); err != nil {
return fmt.Errorf("npm not found in PATH: %w", err)
}
if strings.TrimSpace(staticDir) == "" {
return errors.New("static admin dir is empty")
}
config.Logger.Info("[webui] static files missing, running npm build")
ctx, cancel := context.WithTimeout(context.Background(), defaultBuildTimeout)
defer cancel()
if _, err := os.Stat(filepath.Join("webui", "node_modules")); err != nil {
if !os.IsNotExist(err) {
return err
}
installCmd := exec.CommandContext(ctx, "npm", "ci", "--prefix", "webui")
installCmd.Stdout = os.Stdout
installCmd.Stderr = os.Stderr
if err := installCmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("webui npm ci timed out after %s", defaultBuildTimeout)
}
return err
}
}
if err := os.MkdirAll(staticDir, 0o755); err != nil {
return err
}
cmd := exec.CommandContext(ctx, "npm", "run", "build", "--prefix", "webui", "--", "--outDir", staticDir, "--emptyOutDir")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("webui build timed out after %s", defaultBuildTimeout)
}
return err
}
return nil
}

View File

@@ -1,9 +0,0 @@
package webui
import "embed"
// embeddedAdminFS bundles the built admin webui so serverless runtime does not
// depend on function includeFiles behavior.
//
//go:embed assets/admin
var embeddedAdminFS embed.FS

View File

@@ -1,10 +1,8 @@
package webui
import (
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strings"
@@ -19,18 +17,11 @@ const welcomeHTML = `<!DOCTYPE html>
</head><body><main><h1>DS2API</h1><p>DeepSeek to OpenAI & Claude Compatible API</p><div class="links"><a href="/admin">管理面板</a><a href="/v1/models">API 状态</a><a href="https://github.com/CJackHwang/ds2api" target="_blank">GitHub</a></div></main></body></html>`
type Handler struct {
StaticDir string
embeddedAdmin fs.FS
hasEmbeddedUI bool
StaticDir string
}
func NewHandler() *Handler {
h := &Handler{StaticDir: resolveStaticAdminDir(config.StaticAdminDir())}
if sub, err := fs.Sub(embeddedAdminFS, "assets/admin"); err == nil {
h.embeddedAdmin = sub
h.hasEmbeddedUI = true
}
return h
return &Handler{StaticDir: resolveStaticAdminDir(config.StaticAdminDir())}
}
func RegisterRoutes(r chi.Router, h *Handler) {
@@ -61,11 +52,6 @@ func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
h.serveFromDisk(w, r, staticDir)
return
}
if h.hasEmbeddedUI {
if h.serveFromFS(w, r, h.embeddedAdmin) {
return
}
}
http.Error(w, "WebUI not built. Run `cd webui && npm run build` first.", http.StatusNotFound)
}
@@ -100,6 +86,9 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi
}
func resolveStaticAdminDir(preferred string) string {
if strings.TrimSpace(os.Getenv("DS2API_STATIC_ADMIN_DIR")) != "" {
return filepath.Clean(preferred)
}
candidates := []string{preferred}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(wd, "static/admin"))
@@ -130,34 +119,3 @@ func resolveStaticAdminDir(preferred string) string {
}
return filepath.Clean(preferred)
}
func (h *Handler) serveFromFS(w http.ResponseWriter, r *http.Request, rootFS fs.FS) bool {
rel := strings.TrimPrefix(r.URL.Path, "/admin")
rel = strings.TrimPrefix(rel, "/")
safe := strings.TrimPrefix(path.Clean("/"+rel), "/")
if strings.HasPrefix(safe, "../") {
http.NotFound(w, r)
return true
}
if safe != "" && strings.Contains(safe, ".") {
if _, err := fs.Stat(rootFS, safe); err != nil {
http.NotFound(w, r)
return true
}
if strings.HasPrefix(safe, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-store, must-revalidate")
}
http.ServeFileFS(w, r, rootFS, safe)
return true
}
if _, err := fs.Stat(rootFS, "index.html"); err != nil {
return false
}
w.Header().Set("Cache-Control", "no-store, must-revalidate")
http.ServeFileFS(w, r, rootFS, "index.html")
return true
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- SEO Meta Tags -->
<title>DS2API - 管理面板 / Admin Console</title>
<meta name="description" content="DS2API 管理面板:管理 DeepSeek 账号、测试 API、同步 Vercel 配置 / Admin console for accounts, API tests, and Vercel sync." />
<meta name="keywords" content="DeepSeek, API, OpenAI, 管理面板, admin console, DS2API" />
<meta name="author" content="CJackHwang" />
<meta name="robots" content="noindex, nofollow" />
<!-- Open Graph / Social -->
<meta property="og:type" content="website" />
<meta property="og:title" content="DS2API - 管理面板 / Admin Console" />
<meta property="og:description" content="Manage DeepSeek accounts, test the API, and sync Vercel configuration." />
<meta property="og:site_name" content="DS2API" />
<!-- PWA / Mobile -->
<meta name="theme-color" content="#f59e0b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="DS2API" />
<!-- Favicon - using data URI for orange-yellow gradient icon -->
<link rel="icon" type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%23f59e0b'/%3E%3Cstop offset='100%25' stop-color='%23ef4444'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect rx='20' width='100' height='100' fill='url(%23g)'/%3E%3Ctext x='50' y='68' font-family='Arial,sans-serif' font-size='48' font-weight='bold' fill='white' text-anchor='middle'%3EDS%3C/text%3E%3C/svg%3E" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/admin/assets/index-BZnWnZUg.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-Dd4LAu0y.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,5 +1,11 @@
{
"version": 2,
"buildCommand": "npm ci --prefix webui && npm run build --prefix webui",
"functions": {
"api/index.go": {
"includeFiles": "static/admin/**"
}
},
"rewrites": [
{
"source": "/(.*)",