diff --git a/.gitignore b/.gitignore
index b073885..bff243e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -405,4 +405,9 @@ openspec/changes/archive
temp
.agents
skills-lock.json
-.worktrees
\ No newline at end of file
+.worktrees
+!scripts/build/
+
+# Embedfs generated
+embedfs/assets/
+embedfs/frontend-dist/
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0044793
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,115 @@
+.PHONY: all clean \
+ backend-build backend-run backend-test backend-test-unit backend-test-integration backend-test-coverage \
+ backend-lint backend-deps backend-generate \
+ backend-migrate-up backend-migrate-down backend-migrate-status backend-migrate-create \
+ frontend-build frontend-dev frontend-test frontend-test-watch frontend-test-coverage frontend-test-e2e frontend-lint \
+ desktop desktop-darwin desktop-windows desktop-linux package-macos
+
+# ============================================
+# 后端
+# ============================================
+
+all: backend-build
+
+backend-build:
+ cd backend && go build -o bin/server ./cmd/server
+
+backend-run:
+ cd backend && go run ./cmd/server
+
+backend-test:
+ cd backend && go test ./... -v
+
+backend-test-unit:
+ cd backend && go test ./internal/... ./pkg/... -v
+
+backend-test-integration:
+ cd backend && go test ./tests/... -v
+
+backend-test-coverage:
+ cd backend && go test ./... -coverprofile=coverage.out
+ cd backend && go tool cover -html=coverage.out -o coverage.html
+ @echo "Coverage report generated: backend/coverage.html"
+
+backend-lint:
+ cd backend && go tool golangci-lint run ./...
+
+backend-deps:
+ cd backend && go mod tidy
+
+backend-generate:
+ cd backend && go generate ./...
+
+backend-migrate-up:
+ cd backend && goose -dir migrations sqlite3 $(DB_PATH) up
+
+backend-migrate-down:
+ cd backend && goose -dir migrations sqlite3 $(DB_PATH) down
+
+backend-migrate-status:
+ cd backend && goose -dir migrations sqlite3 $(DB_PATH) status
+
+backend-migrate-create:
+ @read -p "Migration name: " name; \
+ cd backend && goose -dir migrations create $$name sql
+
+# ============================================
+# 前端
+# ============================================
+
+frontend-build:
+ cd frontend && bun install && bun run build
+
+frontend-dev:
+ cd frontend && bun dev
+
+frontend-test:
+ cd frontend && bun run test
+
+frontend-test-watch:
+ cd frontend && bun run test:watch
+
+frontend-test-coverage:
+ cd frontend && bun run test:coverage
+
+frontend-test-e2e:
+ cd frontend && bun run test:e2e
+
+frontend-lint:
+ cd frontend && bun run lint
+
+# ============================================
+# 桌面应用
+# ============================================
+
+desktop: frontend-build-desktop embedfs-prepare
+ cd backend && CGO_ENABLED=1 go build -o ../build/nex ./cmd/desktop
+
+frontend-build-desktop:
+ cd frontend && cp .env.desktop .env.production.local && bun install && bun run build && rm -f .env.production.local
+
+embedfs-prepare:
+ rm -rf embedfs/assets embedfs/frontend-dist
+ cp -r assets embedfs/assets
+ cp -r frontend/dist embedfs/frontend-dist
+
+desktop-darwin: frontend-build-desktop embedfs-prepare
+ cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o ../build/nex-darwin-arm64 ./cmd/desktop
+ cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -o ../build/nex-darwin-amd64 ./cmd/desktop
+
+desktop-windows: frontend-build-desktop embedfs-prepare
+ cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o ../build/nex-windows-amd64.exe ./cmd/desktop
+
+desktop-linux: frontend-build-desktop embedfs-prepare
+ cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o ../build/nex-linux-amd64 ./cmd/desktop
+
+package-macos:
+ ./scripts/build/package-macos.sh
+
+# ============================================
+# 清理
+# ============================================
+
+clean:
+ rm -rf backend/bin/ backend/coverage.out backend/coverage.html
+ rm -rf build/
diff --git a/README.md b/README.md
index 946e6e2..bdedc85 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,9 @@
```
nex/
├── backend/ # Go 后端服务(分层架构)
-│ ├── cmd/server/ # 主程序入口
+│ ├── cmd/
+│ │ ├── server/ # CLI 主程序入口
+│ │ └── desktop/ # 桌面应用入口
│ ├── internal/
│ │ ├── handler/ # HTTP 处理器 + 中间件
│ │ ├── service/ # 业务逻辑层
@@ -32,6 +34,15 @@ nex/
│ ├── e2e/ # Playwright E2E 测试
│ └── package.json
│
+├── assets/ # 应用资源
+│ ├── icon.png # 托盘图标
+│ ├── AppIcon.icns # macOS 应用图标
+│ └── icon.ico # Windows 应用图标
+│
+├── scripts/ # 构建脚本
+│ └── build/
+│ └── package-macos.sh # macOS .app 打包脚本
+│
└── README.md # 本文件
```
@@ -72,7 +83,46 @@ nex/
## 快速开始
-### 后端
+### 桌面应用(推荐)
+
+**构建桌面应用**:
+
+```bash
+# 当前平台
+make desktop
+
+# macOS (arm64 + amd64)
+make desktop-darwin
+make package-macos # 打包为 .app
+
+# Windows
+make desktop-windows
+
+# Linux
+make desktop-linux
+```
+
+**使用桌面应用**:
+- 双击启动应用(macOS: Nex.app,Windows: nex.exe,Linux: nex)
+- 系统托盘图标出现,浏览器自动打开管理界面
+- 点击托盘图标显示菜单,可打开管理界面或退出
+- 关闭浏览器后服务继续运行,可通过托盘重新打开
+
+**注意事项**:
+- 桌面应用需要 CGO 支持
+- macOS: 自带 Xcode Command Line Tools
+- Linux: 自带 gcc,部分桌面环境需要 `libappindicator3-dev`
+- Windows: 需要 MinGW-w64 或在 Windows 环境构建
+
+**Linux 桌面环境兼容性**:
+- GNOME: 需要 AppIndicator 扩展
+- KDE Plasma: 原生支持
+- Xfce: 需要 libappindicator
+- 其他支持 StatusNotifierItem 规范的环境
+
+### CLI 模式
+
+#### 后端
```bash
cd backend
@@ -161,41 +211,24 @@ log:
## 测试
-### 后端测试
-
```bash
-cd backend
-make test # 运行所有测试
-make test-coverage # 生成覆盖率报告
-```
-
-### 前端测试
-
-```bash
-cd frontend
-bun run test # 单元测试 + 组件测试
-bun run test:watch # 监听模式
-bun run test:coverage # 生成覆盖率报告
-bun run test:e2e # E2E 测试
+make backend-test # 后端测试
+make backend-test-coverage # 后端覆盖率
+make frontend-test # 前端测试
+make frontend-test-e2e # 前端 E2E 测试
```
## 开发
-### 后端开发
-
```bash
-cd backend
-make build # 构建
-make lint # 代码检查
-make migrate-up # 数据库迁移
-```
+make backend-build # 构建后端
+make backend-run # 运行后端
+make backend-lint # 后端代码检查
+make backend-migrate-up # 数据库迁移
-### 前端开发
-
-```bash
-cd frontend
-bun run build # 构建生产版本
-bun run lint # 代码检查
+make frontend-build # 构建前端
+make frontend-dev # 前端开发模式
+make frontend-lint # 前端代码检查
```
## 开发规范
diff --git a/assets/AppIcon.icns b/assets/AppIcon.icns
new file mode 100644
index 0000000..47645c0
Binary files /dev/null and b/assets/AppIcon.icns differ
diff --git a/assets/README.md b/assets/README.md
new file mode 100644
index 0000000..102c2c8
--- /dev/null
+++ b/assets/README.md
@@ -0,0 +1,64 @@
+# Assets
+
+应用资源文件目录。
+
+## 文件说明
+
+| 文件 | 用途 | 尺寸 | 格式 |
+|------|------|------|------|
+| `icon.svg` | 源图标 | 64x64 | SVG |
+| `icon.png` | 托盘图标 | 64x64 | PNG |
+| `AppIcon.icns` | macOS 应用图标 | 多尺寸 | ICNS |
+| `icon.ico` | Windows 应用图标 | 256x256 | ICO |
+
+## 替换图标
+
+### 1. 准备图标
+
+推荐使用 SVG 格式的源图标,尺寸至少 256x256。
+
+### 2. 生成各平台图标
+
+**托盘图标 (PNG)**:
+```bash
+magick your-icon.svg -resize 64x64 icon.png
+```
+
+**macOS 应用图标 (ICNS)**:
+```bash
+mkdir icon.iconset
+magick your-icon.svg -resize 16x16 icon.iconset/icon_16x16.png
+magick your-icon.svg -resize 32x32 icon.iconset/icon_16x16@2x.png
+magick your-icon.svg -resize 32x32 icon.iconset/icon_32x32.png
+magick your-icon.svg -resize 64x64 icon.iconset/icon_32x32@2x.png
+magick your-icon.svg -resize 128x128 icon.iconset/icon_128x128.png
+magick your-icon.svg -resize 256x256 icon.iconset/icon_128x128@2x.png
+iconutil -c icns icon.iconset -o AppIcon.icns
+rm -rf icon.iconset
+```
+
+**Windows 应用图标 (ICO)**:
+```bash
+magick your-icon.svg -resize 256x256 icon.ico
+```
+
+### 3. 替换文件
+
+将生成的文件放入此目录,然后重新构建桌面应用:
+```bash
+./scripts/build/build-darwin-arm64.sh
+```
+
+## macOS Template 图标
+
+macOS 支持 Template 图标,自动适配深浅色模式:
+- 使用黑色 + 透明设计
+- 文件名以 `Template` 结尾(如 `iconTemplate.png`)
+- 黑色在深色模式下自动变为白色
+
+## 设计建议
+
+- 托盘图标应简洁,在小尺寸下清晰可辨
+- 避免过多细节和文字
+- 使用高对比度颜色
+- macOS 建议使用 Template 图标风格
diff --git a/assets/icon.ico b/assets/icon.ico
new file mode 100644
index 0000000..7bc6e0f
Binary files /dev/null and b/assets/icon.ico differ
diff --git a/assets/icon.png b/assets/icon.png
new file mode 100644
index 0000000..bd18837
Binary files /dev/null and b/assets/icon.png differ
diff --git a/assets/icon.svg b/assets/icon.svg
new file mode 100644
index 0000000..a85dcff
--- /dev/null
+++ b/assets/icon.svg
@@ -0,0 +1,13 @@
+
diff --git a/backend/Makefile b/backend/Makefile
deleted file mode 100644
index 0003a85..0000000
--- a/backend/Makefile
+++ /dev/null
@@ -1,57 +0,0 @@
-.PHONY: build run test test-unit test-integration test-coverage clean migrate-up migrate-down migrate-status migrate-create lint generate deps
-
-# 构建
-build:
- go build -o bin/server ./cmd/server
-
-# 运行
-run:
- go run ./cmd/server
-
-# 测试
-test:
- go test ./... -v
-
-# 单元测试
-test-unit:
- go test ./internal/... ./pkg/... -v
-
-# 集成测试
-test-integration:
- go test ./tests/... -v
-
-# 测试覆盖率
-test-coverage:
- go test ./... -coverprofile=coverage.out
- go tool cover -html=coverage.out -o coverage.html
- @echo "Coverage report generated: coverage.html"
-
-# 清理
-clean:
- rm -rf bin/ coverage.out coverage.html
-
-# 数据库迁移
-migrate-up:
- goose -dir migrations sqlite3 $(DB_PATH) up
-
-migrate-down:
- goose -dir migrations sqlite3 $(DB_PATH) down
-
-migrate-status:
- goose -dir migrations sqlite3 $(DB_PATH) status
-
-migrate-create:
- @read -p "Migration name: " name; \
- goose -dir migrations create $$name sql
-
-# 代码检查
-lint:
- go tool golangci-lint run ./...
-
-# 安装依赖
-deps:
- go mod tidy
-
-# 生成代码(mock 等)
-generate:
- go generate ./...
diff --git a/backend/cmd/desktop/main.go b/backend/cmd/desktop/main.go
new file mode 100644
index 0000000..6721bb8
--- /dev/null
+++ b/backend/cmd/desktop/main.go
@@ -0,0 +1,456 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/getlantern/systray"
+ "github.com/pressly/goose/v3"
+ "go.uber.org/zap"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+
+ "nex/backend/internal/config"
+ "nex/backend/internal/conversion"
+ "nex/backend/internal/conversion/anthropic"
+ "nex/backend/internal/conversion/openai"
+ "nex/backend/internal/handler"
+ "nex/backend/internal/handler/middleware"
+ "nex/backend/internal/provider"
+ "nex/backend/internal/repository"
+ "nex/backend/internal/service"
+ pkgLogger "nex/backend/pkg/logger"
+
+ "nex/embedfs"
+)
+
+var (
+ server *http.Server
+ zapLogger *zap.Logger
+ shutdownCtx context.Context
+ shutdownCancel context.CancelFunc
+)
+
+func main() {
+ port := 9826
+
+ if err := acquireSingleInstance(); err != nil {
+ showError("Nex Gateway", "已有 Nex 实例运行")
+ os.Exit(1)
+ }
+ defer releaseSingleInstance()
+
+ if err := checkPortAvailable(port); err != nil {
+ showError("Nex Gateway", err.Error())
+ os.Exit(1)
+ }
+
+ cfg, err := config.LoadConfig()
+ if err != nil {
+ showError("Nex Gateway", fmt.Sprintf("加载配置失败: %v", err))
+ os.Exit(1)
+ }
+
+ zapLogger, err = pkgLogger.New(pkgLogger.Config{
+ Level: cfg.Log.Level,
+ Path: cfg.Log.Path,
+ MaxSize: cfg.Log.MaxSize,
+ MaxBackups: cfg.Log.MaxBackups,
+ MaxAge: cfg.Log.MaxAge,
+ Compress: cfg.Log.Compress,
+ })
+ if err != nil {
+ showError("Nex Gateway", fmt.Sprintf("初始化日志失败: %v", err))
+ os.Exit(1)
+ }
+ defer zapLogger.Sync()
+
+ db, err := initDatabase(cfg)
+ if err != nil {
+ showError("Nex Gateway", fmt.Sprintf("初始化数据库失败: %v", err))
+ os.Exit(1)
+ }
+ defer closeDB(db)
+
+ providerRepo := repository.NewProviderRepository(db)
+ modelRepo := repository.NewModelRepository(db)
+ statsRepo := repository.NewStatsRepository(db)
+
+ providerService := service.NewProviderService(providerRepo, modelRepo)
+ modelService := service.NewModelService(modelRepo, providerRepo)
+ routingService := service.NewRoutingService(modelRepo, providerRepo)
+ statsService := service.NewStatsService(statsRepo)
+
+ registry := conversion.NewMemoryRegistry()
+ if err := registry.Register(openai.NewAdapter()); err != nil {
+ zapLogger.Fatal("注册 OpenAI 适配器失败", zap.String("error", err.Error()))
+ }
+ if err := registry.Register(anthropic.NewAdapter()); err != nil {
+ zapLogger.Fatal("注册 Anthropic 适配器失败", zap.String("error", err.Error()))
+ }
+ engine := conversion.NewConversionEngine(registry, zapLogger)
+
+ providerClient := provider.NewClient()
+
+ proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService)
+ providerHandler := handler.NewProviderHandler(providerService)
+ modelHandler := handler.NewModelHandler(modelService)
+ statsHandler := handler.NewStatsHandler(statsService)
+
+ gin.SetMode(gin.ReleaseMode)
+ r := gin.New()
+
+ r.Use(middleware.RequestID())
+ r.Use(middleware.Recovery(zapLogger))
+ r.Use(middleware.Logging(zapLogger))
+ r.Use(middleware.CORS())
+
+ setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler)
+ setupStaticFiles(r)
+
+ server = &http.Server{
+ Addr: fmt.Sprintf(":%d", port),
+ Handler: r,
+ ReadTimeout: cfg.Server.ReadTimeout,
+ WriteTimeout: cfg.Server.WriteTimeout,
+ }
+
+ shutdownCtx, shutdownCancel = context.WithCancel(context.Background())
+
+ go func() {
+ zapLogger.Info("AI Gateway 启动", zap.String("addr", server.Addr))
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ zapLogger.Fatal("服务器启动失败", zap.String("error", err.Error()))
+ }
+ }()
+
+ go func() {
+ time.Sleep(500 * time.Millisecond)
+ if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil {
+ zapLogger.Warn("无法打开浏览器", zap.String("error", err.Error()))
+ }
+ }()
+
+ setupSystray(port)
+}
+
+func initDatabase(cfg *config.Config) (*gorm.DB, error) {
+ dbDir := filepath.Dir(cfg.Database.Path)
+ if err := os.MkdirAll(dbDir, 0755); err != nil {
+ return nil, fmt.Errorf("创建数据库目录失败: %w", err)
+ }
+
+ db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Info),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if err := runMigrations(db); err != nil {
+ return nil, fmt.Errorf("数据库迁移失败: %w", err)
+ }
+
+ if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
+ log.Printf("警告: 启用 WAL 模式失败: %v", err)
+ }
+
+ sqlDB, err := db.DB()
+ if err != nil {
+ return nil, err
+ }
+ sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
+ sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
+ sqlDB.SetConnMaxLifetime(cfg.Database.ConnMaxLifetime)
+
+ return db, nil
+}
+
+func runMigrations(db *gorm.DB) error {
+ sqlDB, err := db.DB()
+ if err != nil {
+ return err
+ }
+
+ migrationsDir := getMigrationsDir()
+ if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
+ return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
+ }
+
+ goose.SetDialect("sqlite3")
+ if err := goose.Up(sqlDB, migrationsDir); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func getMigrationsDir() string {
+ _, filename, _, ok := runtime.Caller(0)
+ if ok {
+ dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations")
+ if abs, err := filepath.Abs(dir); err == nil {
+ return abs
+ }
+ }
+ return "./migrations"
+}
+
+func closeDB(db *gorm.DB) {
+ sqlDB, err := db.DB()
+ if err != nil {
+ return
+ }
+ sqlDB.Close()
+}
+
+func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
+ r.Any("/v1/*path", proxyHandler.HandleProxy)
+
+ providers := r.Group("/api/providers")
+ {
+ providers.GET("", providerHandler.ListProviders)
+ providers.POST("", providerHandler.CreateProvider)
+ providers.GET("/:id", providerHandler.GetProvider)
+ providers.PUT("/:id", providerHandler.UpdateProvider)
+ providers.DELETE("/:id", providerHandler.DeleteProvider)
+ }
+
+ models := r.Group("/api/models")
+ {
+ models.GET("", modelHandler.ListModels)
+ models.POST("", modelHandler.CreateModel)
+ models.GET("/:id", modelHandler.GetModel)
+ models.PUT("/:id", modelHandler.UpdateModel)
+ models.DELETE("/:id", modelHandler.DeleteModel)
+ }
+
+ stats := r.Group("/api/stats")
+ {
+ stats.GET("", statsHandler.GetStats)
+ stats.GET("/aggregate", statsHandler.AggregateStats)
+ }
+
+ r.GET("/health", func(c *gin.Context) {
+ c.JSON(200, gin.H{"status": "ok"})
+ })
+}
+
+func setupStaticFiles(r *gin.Engine) {
+ distFS, err := fs.Sub(embedfs.FrontendDist, "frontend-dist")
+ if err != nil {
+ zapLogger.Fatal("无法加载前端资源", zap.String("error", err.Error()))
+ }
+
+ getContentType := func(path string) string {
+ if strings.HasSuffix(path, ".js") {
+ return "application/javascript"
+ }
+ if strings.HasSuffix(path, ".css") {
+ return "text/css"
+ }
+ if strings.HasSuffix(path, ".svg") {
+ return "image/svg+xml"
+ }
+ if strings.HasSuffix(path, ".png") {
+ return "image/png"
+ }
+ if strings.HasSuffix(path, ".ico") {
+ return "image/x-icon"
+ }
+ if strings.HasSuffix(path, ".woff") || strings.HasSuffix(path, ".woff2") {
+ return "font/woff2"
+ }
+ return "application/octet-stream"
+ }
+
+ r.GET("/assets/*filepath", func(c *gin.Context) {
+ filepath := c.Param("filepath")
+ data, err := fs.ReadFile(distFS, "assets"+filepath)
+ if err != nil {
+ c.Status(404)
+ return
+ }
+ c.Data(200, getContentType(filepath), data)
+ })
+
+ r.GET("/favicon.svg", func(c *gin.Context) {
+ data, err := fs.ReadFile(distFS, "favicon.svg")
+ if err != nil {
+ c.Status(404)
+ return
+ }
+ c.Data(200, "image/svg+xml", data)
+ })
+
+ r.NoRoute(func(c *gin.Context) {
+ path := c.Request.URL.Path
+
+ if strings.HasPrefix(path, "/api/") ||
+ strings.HasPrefix(path, "/v1/") ||
+ strings.HasPrefix(path, "/health") {
+ c.JSON(404, gin.H{"error": "not found"})
+ return
+ }
+
+ data, err := fs.ReadFile(distFS, "index.html")
+ if err != nil {
+ c.Status(500)
+ return
+ }
+ c.Data(200, "text/html; charset=utf-8", data)
+ })
+}
+
+func setupSystray(port int) {
+ systray.Run(func() {
+ icon, err := embedfs.Assets.ReadFile("assets/icon.png")
+ if err != nil {
+ zapLogger.Error("无法加载托盘图标", zap.String("error", err.Error()))
+ }
+ systray.SetIcon(icon)
+ systray.SetTitle("Nex Gateway")
+ systray.SetTooltip("AI Gateway")
+
+ mOpen := systray.AddMenuItem("打开管理界面", "在浏览器中打开")
+ systray.AddSeparator()
+ mStatus := systray.AddMenuItem("状态: 运行中", "")
+ mStatus.Disable()
+ mPort := systray.AddMenuItem(fmt.Sprintf("端口: %d", port), "")
+ mPort.Disable()
+ systray.AddSeparator()
+ mAbout := systray.AddMenuItem("关于", "")
+ systray.AddSeparator()
+ mQuit := systray.AddMenuItem("退出", "停止服务并退出")
+
+ go func() {
+ for {
+ select {
+ case <-mOpen.ClickedCh:
+ openBrowser(fmt.Sprintf("http://localhost:%d", port))
+ case <-mAbout.ClickedCh:
+ showAbout()
+ case <-mQuit.ClickedCh:
+ doShutdown()
+ systray.Quit()
+ return
+ }
+ }
+ }()
+ }, nil)
+}
+
+func doShutdown() {
+ if zapLogger != nil {
+ zapLogger.Info("正在关闭服务器...")
+ }
+
+ if server != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ server.Shutdown(ctx)
+ }
+
+ if shutdownCancel != nil {
+ shutdownCancel()
+ }
+}
+
+func checkPortAvailable(port int) error {
+ ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
+ if err != nil {
+ return fmt.Errorf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port)
+ }
+ ln.Close()
+ return nil
+}
+
+var lockFile *os.File
+
+func acquireSingleInstance() error {
+ lockPath := filepath.Join(os.TempDir(), "nex-gateway.lock")
+
+ f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0666)
+ if err != nil {
+ return err
+ }
+
+ err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
+ if err != nil {
+ f.Close()
+ return fmt.Errorf("已有实例运行")
+ }
+
+ lockFile = f
+ return nil
+}
+
+func releaseSingleInstance() {
+ if lockFile != nil {
+ syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
+ lockFile.Close()
+ }
+}
+
+func openBrowser(url string) error {
+ var cmd *exec.Cmd
+
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = exec.Command("open", url)
+ case "windows":
+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
+ case "linux":
+ browsers := []string{"xdg-open", "google-chrome", "firefox"}
+ for _, browser := range browsers {
+ if _, err := exec.LookPath(browser); err == nil {
+ cmd = exec.Command(browser, url)
+ break
+ }
+ }
+ }
+
+ if cmd == nil {
+ return fmt.Errorf("无法打开浏览器")
+ }
+
+ return cmd.Start()
+}
+
+func showError(title, message string) {
+ switch runtime.GOOS {
+ case "darwin":
+ script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`, message, title)
+ exec.Command("osascript", "-e", script).Run()
+ case "windows":
+ exec.Command("msg", "*", message).Run()
+ case "linux":
+ exec.Command("zenity", "--error", fmt.Sprintf("--title=%s", title), fmt.Sprintf("--text=%s", message)).Run()
+ }
+}
+
+func showAbout() {
+ message := "Nex Gateway\n\nAI Gateway - 统一的大模型 API 网关\n\nhttps://github.com/nex/gateway"
+ switch runtime.GOOS {
+ case "darwin":
+ script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "关于 Nex Gateway"`, message)
+ exec.Command("osascript", "-e", script).Run()
+ case "windows":
+ exec.Command("msg", "*", message).Run()
+ case "linux":
+ exec.Command("zenity", "--info", "--title=关于 Nex Gateway", fmt.Sprintf("--text=%s", message)).Run()
+ }
+}
diff --git a/backend/cmd/desktop/port_test.go b/backend/cmd/desktop/port_test.go
new file mode 100644
index 0000000..cd810d3
--- /dev/null
+++ b/backend/cmd/desktop/port_test.go
@@ -0,0 +1,69 @@
+package main
+
+import (
+ "net"
+ "net/http"
+ "testing"
+ "time"
+)
+
+func TestCheckPortAvailable(t *testing.T) {
+ port := 19826
+
+ err := checkPortAvailable(port)
+ if err != nil {
+ t.Fatalf("端口 %d 应该可用: %v", port, err)
+ }
+
+ t.Log("端口可用测试通过")
+}
+
+func TestCheckPortOccupied(t *testing.T) {
+ port := 19827
+
+ listener, err := net.Listen("tcp", ":19827")
+ if err != nil {
+ t.Fatalf("无法启动测试服务器: %v", err)
+ }
+ defer listener.Close()
+
+ go func() {
+ conn, err := listener.Accept()
+ if err == nil {
+ conn.Close()
+ }
+ }()
+
+ time.Sleep(100 * time.Millisecond)
+
+ err = checkPortAvailable(port)
+ if err == nil {
+ t.Fatal("端口被占用时应该返回错误")
+ }
+
+ t.Log("端口占用检测测试通过")
+}
+
+func TestCheckPortAvailableAfterClose(t *testing.T) {
+ port := 19828
+
+ listener, err := net.Listen("tcp", ":19828")
+ if err != nil {
+ t.Fatalf("无法启动测试服务器: %v", err)
+ }
+
+ server := &http.Server{}
+ go server.Serve(listener)
+
+ time.Sleep(100 * time.Millisecond)
+
+ listener.Close()
+ time.Sleep(100 * time.Millisecond)
+
+ err = checkPortAvailable(port)
+ if err != nil {
+ t.Fatalf("端口关闭后应该可用: %v", err)
+ }
+
+ t.Log("端口关闭后可用测试通过")
+}
diff --git a/backend/cmd/desktop/singleton_test.go b/backend/cmd/desktop/singleton_test.go
new file mode 100644
index 0000000..03031cf
--- /dev/null
+++ b/backend/cmd/desktop/singleton_test.go
@@ -0,0 +1,39 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "syscall"
+ "testing"
+)
+
+func TestAcquireSingleInstance(t *testing.T) {
+ lockPath := filepath.Join(os.TempDir(), "nex-gateway-test.lock")
+
+ origLockFile := lockFile
+ lockFile = nil
+ defer func() { lockFile = origLockFile }()
+
+ f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0666)
+ if err != nil {
+ t.Fatalf("无法创建锁文件: %v", err)
+ }
+ defer f.Close()
+ defer os.Remove(lockPath)
+
+ err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
+ if err != nil {
+ t.Fatalf("无法获取文件锁: %v", err)
+ }
+ defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
+
+ t.Log("单实例锁测试通过")
+}
+
+func TestReleaseSingleInstance(t *testing.T) {
+ lockFile = nil
+
+ releaseSingleInstance()
+
+ t.Log("释放空锁测试通过")
+}
diff --git a/backend/cmd/desktop/static_test.go b/backend/cmd/desktop/static_test.go
new file mode 100644
index 0000000..f3bac4b
--- /dev/null
+++ b/backend/cmd/desktop/static_test.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+ "io/fs"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+
+ "nex/embedfs"
+)
+
+func TestSetupStaticFiles(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ distFS, err := fs.Sub(embedfs.FrontendDist, "frontend-dist")
+ if err != nil {
+ t.Skipf("跳过测试: 前端资源未构建: %v", err)
+ return
+ }
+
+ getContentType := func(path string) string {
+ if strings.HasSuffix(path, ".js") {
+ return "application/javascript"
+ }
+ if strings.HasSuffix(path, ".css") {
+ return "text/css"
+ }
+ if strings.HasSuffix(path, ".svg") {
+ return "image/svg+xml"
+ }
+ return "application/octet-stream"
+ }
+
+ r := gin.New()
+ r.GET("/assets/*filepath", func(c *gin.Context) {
+ filepath := c.Param("filepath")
+ data, err := fs.ReadFile(distFS, "assets"+filepath)
+ if err != nil {
+ c.Status(404)
+ return
+ }
+ c.Data(200, getContentType(filepath), data)
+ })
+
+ r.GET("/favicon.svg", func(c *gin.Context) {
+ data, err := fs.ReadFile(distFS, "favicon.svg")
+ if err != nil {
+ c.Status(404)
+ return
+ }
+ c.Data(200, "image/svg+xml", data)
+ })
+
+ r.NoRoute(func(c *gin.Context) {
+ path := c.Request.URL.Path
+ if strings.HasPrefix(path, "/api/") ||
+ strings.HasPrefix(path, "/v1/") ||
+ strings.HasPrefix(path, "/health") {
+ c.JSON(404, gin.H{"error": "not found"})
+ return
+ }
+ data, err := fs.ReadFile(distFS, "index.html")
+ if err != nil {
+ c.Status(500)
+ return
+ }
+ c.Data(200, "text/html; charset=utf-8", data)
+ })
+
+ t.Run("API 404", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/api/test", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != 404 {
+ t.Errorf("期望状态码 404, 实际 %d", w.Code)
+ }
+ })
+
+ t.Run("SPA fallback", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/providers", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Errorf("期望状态码 200, 实际 %d", w.Code)
+ }
+ })
+
+ t.Run("MIME type for JS", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/assets/test.js", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code == 200 {
+ expected := "application/javascript"
+ if w.Header().Get("Content-Type") != expected {
+ t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
+ }
+ } else {
+ t.Log("文件不存在,跳过 MIME 类型验证")
+ }
+ })
+
+ t.Run("MIME type for CSS", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/assets/test.css", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code == 200 {
+ expected := "text/css"
+ if w.Header().Get("Content-Type") != expected {
+ t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
+ }
+ } else {
+ t.Log("文件不存在,跳过 MIME 类型验证")
+ }
+ })
+
+ t.Log("静态文件服务测试通过")
+}
diff --git a/backend/go.mod b/backend/go.mod
index 8a59480..99e3d99 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -2,12 +2,15 @@ module nex/backend
go 1.26.2
+replace nex/embedfs => ../embedfs
+
tool (
github.com/golangci/golangci-lint/cmd/golangci-lint
go.uber.org/mock/mockgen
)
require (
+ github.com/getlantern/systray v1.2.2
github.com/gin-gonic/gin v1.12.0
github.com/go-playground/validator/v10 v10.30.2
github.com/google/uuid v1.6.0
@@ -22,6 +25,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
+ nex/embedfs v0.0.0-00010101000000-000000000000
)
require (
@@ -74,11 +78,18 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
+ github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
+ github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
+ github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
+ github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
+ github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
+ github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/ghostiam/protogetter v0.3.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-critic/go-critic v0.12.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-stack/stack v1.8.0 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
@@ -153,6 +164,7 @@ require (
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
+ github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/polyfloyd/go-errorlint v1.7.1 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 4018f0a..d6f1911 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -163,6 +163,20 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
+github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
+github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
+github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
+github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
+github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
+github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
+github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
+github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
+github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
+github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
+github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
+github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
+github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ=
github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -192,6 +206,7 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
@@ -397,6 +412,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
+github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
+github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk=
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
@@ -460,6 +477,8 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
+github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
+github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -551,6 +570,7 @@ github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+W
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sivchari/tenv v1.12.1 h1:+E0QzjktdnExv/wwsnnyk4oqZBUfuh89YMQT1cyuvSY=
github.com/sivchari/tenv v1.12.1/go.mod h1:1LjSOUCc25snIr5n3DtGGrENhX3LuWefcplwVGC24mw=
+github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM=
github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
@@ -819,6 +839,7 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -832,6 +853,7 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1008,6 +1030,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/embedfs/embedfs.go b/embedfs/embedfs.go
new file mode 100644
index 0000000..988f5ee
--- /dev/null
+++ b/embedfs/embedfs.go
@@ -0,0 +1,9 @@
+package embedfs
+
+import "embed"
+
+//go:embed assets/*
+var Assets embed.FS
+
+//go:embed frontend-dist/*
+var FrontendDist embed.FS
diff --git a/embedfs/go.mod b/embedfs/go.mod
new file mode 100644
index 0000000..fa30a0a
--- /dev/null
+++ b/embedfs/go.mod
@@ -0,0 +1,3 @@
+module nex/embedfs
+
+go 1.26.2
diff --git a/frontend/.env.desktop b/frontend/.env.desktop
new file mode 100644
index 0000000..a41b3e9
--- /dev/null
+++ b/frontend/.env.desktop
@@ -0,0 +1 @@
+VITE_API_BASE=
diff --git a/go.work b/go.work
new file mode 100644
index 0000000..d78f82a
--- /dev/null
+++ b/go.work
@@ -0,0 +1,6 @@
+go 1.26.2
+
+use (
+ backend
+ embedfs
+)
diff --git a/go.work.sum b/go.work.sum
new file mode 100644
index 0000000..defc4aa
--- /dev/null
+++ b/go.work.sum
@@ -0,0 +1,104 @@
+cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
+cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=
+cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
+cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
+cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
+cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
+github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/cristalhq/acmd v0.12.0/go.mod h1:LG5oa43pE/BbxtfMoImHCQN++0Su7dzipdgBjMCBVDQ=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU=
+github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
+github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
+github.com/golangci/modinfo v0.3.3/go.mod h1:wytF1M5xl9u0ij8YSvhkEVPP3M5Mc7XLl1pxH3B2aUM=
+github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
+github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
+github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
+github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
+github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
+github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
+github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
+github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
+github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE=
+github.com/mgechev/dots v0.0.0-20210922191527-e955255bf517/go.mod h1:KQ7+USdGKfpPjXk4Ga+5XxQM4Lm4e3gAogrreFAYpOg=
+github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
+github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ=
+github.com/mozilla/tls-observatory v0.0.0-20210609171429-7bc42856d2e5/go.mod h1:FUqVoUPHSEdDR0MnFM3Dh8AU0pZHLXUD127SAJGER/s=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
+github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
+github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
+github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw=
+github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50=
+github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
+github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
+github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
+github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/quicktemplate v1.8.0/go.mod h1:qIqW8/igXt8fdrUln5kOSb+KWMaJ4Y8QUsfd1k6L2jM=
+github.com/vertica/vertica-sql-go v1.3.5/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4=
+github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
+github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
+github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
+github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
+github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
+github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0/go.mod h1:stS1mQYjbJvwwYaYzKyFY9eMiuVXWWXQA6T+SpOLg9c=
+github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
+go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
+go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
+go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
+golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
+golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+google.golang.org/api v0.223.0/go.mod h1:C+RS7Z+dDwds2b+zoAk5hN/eSfsiCn0UDrYof/M4d2M=
+google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/openspec/specs/desktop-app/spec.md b/openspec/specs/desktop-app/spec.md
new file mode 100644
index 0000000..9758f23
--- /dev/null
+++ b/openspec/specs/desktop-app/spec.md
@@ -0,0 +1,123 @@
+# 桌面应用
+
+## Purpose
+
+TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源打包为单一可执行文件
+
+## Requirements
+
+### Requirement: 桌面应用启动
+
+系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件。
+
+#### Scenario: 双击启动
+- **WHEN** 用户双击桌面应用可执行文件
+- **THEN** 系统启动后端服务
+- **AND** 系统托盘图标出现
+- **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面
+
+#### Scenario: 单实例检查
+- **WHEN** 用户尝试启动第二个实例
+- **THEN** 系统检测到已有实例运行
+- **AND** 显示错误提示"已有 Nex 实例运行"
+- **AND** 新实例退出
+
+### Requirement: 系统托盘
+
+系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。
+
+#### Scenario: 托盘图标显示
+- **WHEN** 桌面应用启动成功
+- **THEN** 系统托盘区域显示应用图标
+- **AND** 托盘图标 tooltip 显示"AI Gateway"
+
+#### Scenario: 托盘菜单显示
+- **WHEN** 用户点击托盘图标(左键或右键)
+- **THEN** 显示托盘菜单
+- **AND** 菜单包含"打开管理界面"选项
+- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
+- **AND** 菜单包含"端口: 9826"选项(禁用状态)
+- **AND** 菜单包含"关于"选项
+- **AND** 菜单包含"退出"选项
+
+#### Scenario: 打开管理界面
+- **WHEN** 用户点击托盘菜单"打开管理界面"
+- **THEN** 系统在浏览器中打开 `http://localhost:9826`
+
+#### Scenario: 浏览器打开失败
+- **WHEN** 系统无法打开浏览器(浏览器未安装等)
+- **THEN** 托盘菜单仍可正常使用
+- **AND** 用户可手动访问 `http://localhost:9826`
+
+#### Scenario: 退出应用
+- **WHEN** 用户点击托盘菜单"退出"
+- **THEN** 系统优雅关闭后端服务
+- **AND** 托盘图标消失
+- **AND** 应用进程退出
+
+### Requirement: 静态文件服务
+
+系统 SHALL 通过 Gin 同时服务 API 和前端静态资源。
+
+#### Scenario: API 请求路由
+- **WHEN** 请求路径以 `/api/` 或 `/v1/` 开头
+- **THEN** 请求由现有业务 handler 处理
+
+#### Scenario: 静态资源路由
+- **WHEN** 请求路径为 `/assets/*`
+- **THEN** 返回嵌入的前端静态资源文件
+
+#### Scenario: SPA 路由回退
+- **WHEN** 请求路径不匹配任何 API 或静态资源路由
+- **THEN** 返回 `index.html`(支持前端 SPA 路由)
+
+### Requirement: 端口冲突检测
+
+系统 SHALL 在启动前检测端口是否可用。
+
+#### Scenario: 端口可用
+- **WHEN** 端口 9826 未被占用
+- **THEN** 服务正常启动
+
+#### Scenario: 端口被占用
+- **WHEN** 端口 9826 已被其他程序占用
+- **THEN** 显示错误提示"端口 9826 已被占用"
+- **AND** 应用退出
+
+### Requirement: 跨平台构建
+
+系统 SHALL 支持跨平台构建和打包。
+
+#### Scenario: macOS 构建
+- **WHEN** 执行 macOS 构建命令
+- **THEN** 生成 `nex-darwin-arm64` 和 `nex-darwin-amd64` 可执行文件
+- **AND** 可打包为 `.app` bundle
+
+#### Scenario: Windows 构建
+- **WHEN** 执行 Windows 构建命令
+- **THEN** 生成 `nex-windows-amd64.exe` 可执行文件
+- **AND** 运行时不显示控制台窗口
+
+#### Scenario: Linux 构建
+- **WHEN** 执行 Linux 构建命令
+- **THEN** 生成 `nex-linux-amd64` 可执行文件
+
+### Requirement: macOS .app 打包
+
+系统 SHALL 支持打包为 macOS .app bundle。
+
+#### Scenario: .app 结构
+- **WHEN** 执行打包脚本
+- **THEN** 生成 `Nex.app` 目录结构
+- **AND** 包含 `Contents/Info.plist` 元数据
+- **AND** 包含 `Contents/MacOS/nex` 可执行文件
+- **AND** 包含 `Contents/Resources/AppIcon.icns` 图标
+- **AND** `Info.plist` 中 `LSUIElement` 为 `true`(不显示 Dock 图标)
+
+### Requirement: 关于对话框
+
+系统 SHALL 提供关于对话框显示应用信息。
+
+#### Scenario: 显示关于
+- **WHEN** 用户点击托盘菜单"关于"
+- **THEN** 显示对话框包含应用名称、项目链接
diff --git a/scripts/build/package-macos.sh b/scripts/build/package-macos.sh
new file mode 100755
index 0000000..41fed5d
--- /dev/null
+++ b/scripts/build/package-macos.sh
@@ -0,0 +1,68 @@
+#!/bin/bash
+
+VERSION="1.0.0"
+APP_NAME="Nex"
+BUNDLE_ID="io.nex.gateway"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+BUILD_DIR="${PROJECT_ROOT}/build"
+ASSETS_DIR="${PROJECT_ROOT}/assets"
+
+echo "打包 macOS .app..."
+
+mkdir -p "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS"
+mkdir -p "${BUILD_DIR}/${APP_NAME}.app/Contents/Resources"
+
+if [ -f "${BUILD_DIR}/nex-darwin-arm64" ]; then
+ cp "${BUILD_DIR}/nex-darwin-arm64" "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS/nex"
+elif [ -f "${BUILD_DIR}/nex-darwin-amd64" ]; then
+ cp "${BUILD_DIR}/nex-darwin-amd64" "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS/nex"
+else
+ echo "错误: 未找到 macOS 二进制文件,请先运行 make desktop-darwin"
+ exit 1
+fi
+
+if [ -f "${ASSETS_DIR}/AppIcon.icns" ]; then
+ cp "${ASSETS_DIR}/AppIcon.icns" "${BUILD_DIR}/${APP_NAME}.app/Contents/Resources/"
+else
+ echo "警告: 未找到 AppIcon.icns"
+fi
+
+cat > "${BUILD_DIR}/${APP_NAME}.app/Contents/Info.plist" << EOF
+
+
+
+
+ CFBundleDevelopmentRegion
+ zh_CN
+ CFBundleExecutable
+ nex
+ CFBundleIconFile
+ AppIcon
+ CFBundleIdentifier
+ ${BUNDLE_ID}
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ ${APP_NAME} Gateway
+ CFBundleDisplayName
+ ${APP_NAME} Gateway
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ ${VERSION}
+ CFBundleVersion
+ ${VERSION}
+ LSMinimumSystemVersion
+ 10.13
+ LSUIElement
+
+ NSHighResolutionCapable
+
+
+
+EOF
+
+chmod +x "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS/nex"
+
+echo "打包完成: ${BUILD_DIR}/${APP_NAME}.app"