jim800121chen 3f0175f1a9 feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI
從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑:
tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。
Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local),
雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。

Backend / Wails Go(AB1-AB13):
- internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped)
  + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event
- internal/auth:encrypted file token store(AES-GCM + scrypt + machineID
  fallback salt + 13 tests)
- internal/config:YAML validation + atomic write + 11 tests
- internal/log:ring buffer + ExportLog 升級 zip
- visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests
- 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage)
- end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護
  → tunnel drop failover)

Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎):
- AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab)
- ConnectionStatusBadge 5 種狀態
- TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁
- 設定頁 4 區塊(含重新配對 AlertDialog)
- agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests

Phase 0.7 review-driven fix(Round 2):
- A1 Session fixation 防護(RotateSessionID)
- A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log
- A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態)
- A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test
- F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL
  / F4 Settings draft 持久 + 未儲存 badge

驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 /
agent frontend pnpm test 119 tests 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:22:01 +08:00

236 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"visiona-agent/server/internal/api/handlers"
"visiona-agent/server/internal/api/ws"
"visiona-agent/server/internal/camera"
"visiona-agent/server/internal/device"
"visiona-agent/server/internal/flash"
"visiona-agent/server/internal/inference"
"visiona-agent/server/internal/model"
"visiona-agent/server/pkg/logger"
"github.com/gin-gonic/gin"
)
func NewRouter(
modelRepo *model.Repository,
modelStore *model.ModelStore,
deviceMgr *device.Manager,
cameraMgr *camera.Manager,
flashSvc *flash.Service,
inferenceSvc *inference.Service,
wsHub *ws.Hub,
staticFS http.FileSystem,
logBroadcaster *logger.Broadcaster,
systemHandler *handlers.SystemHandler,
) *gin.Engine {
// Use gin.New() instead of gin.Default() to replace the default logger
// with one that also pushes to the WebSocket broadcaster.
r := gin.New()
r.Use(gin.Recovery())
// M8-4跳過高頻輪詢 endpoint 的 access logTDD v2/server-lifecycle.md §9.1a)。
// 瀏覽器每 10s poll 一次 boot-idbusiness code 也會定期輪詢 health
// 若每次都寫 access log 會把 business log 淹沒。
//
// 注意broadcasterLogger 是我們自製的 middleware不會直接套用 gin.LoggerConfig
// 因此 skip 邏輯在 broadcasterLogger 內部手動實作(見下方)。
r.Use(broadcasterLogger(logBroadcaster))
r.Use(CORSMiddleware())
modelHandler := handlers.NewModelHandler(modelRepo)
modelUploadHandler := handlers.NewModelUploadHandler(modelRepo, modelStore)
deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, inferenceSvc, wsHub)
cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub)
api := r.Group("/api")
{
// System
api.GET("/system/health", systemHandler.HealthCheck)
api.GET("/system/info", systemHandler.Info)
api.GET("/system/metrics", systemHandler.Metrics)
api.GET("/system/deps", systemHandler.Deps)
api.GET("/system/boot-id", systemHandler.BootID) // M8-4瀏覽器 tab 用於偵測 server 重啟
api.POST("/system/restart", systemHandler.Restart)
api.POST("/system/install-driver", systemHandler.InstallDriver)
api.POST("/system/install-udev", systemHandler.InstallUdevRule) // Linux udev rule 安裝
// MAJ-4 補丁Wails shutdown / Restart 前廣播 server:shutdown-imminent
// 到 /ws/system讓瀏覽器 tab 立即顯示 Offline Overlay。
api.POST("/system/shutdown-notify", systemHandler.ShutdownNotify)
// Models
api.GET("/models", modelHandler.ListModels)
api.GET("/models/:id", modelHandler.GetModel)
api.POST("/models/upload", modelUploadHandler.UploadModel)
api.DELETE("/models/:id", modelUploadHandler.DeleteModel)
// Devices
api.GET("/devices", deviceHandler.ListDevices)
api.POST("/devices/scan", deviceHandler.ScanDevices)
api.GET("/devices/:id", deviceHandler.GetDevice)
api.POST("/devices/:id/connect", deviceHandler.ConnectDevice)
api.POST("/devices/:id/disconnect", deviceHandler.DisconnectDevice)
api.POST("/devices/:id/flash", deviceHandler.FlashDevice)
api.POST("/devices/:id/inference/start", deviceHandler.StartInference)
api.POST("/devices/:id/inference/stop", deviceHandler.StopInference)
// Camera
api.GET("/camera/list", cameraHandler.ListCameras)
api.POST("/camera/start", cameraHandler.StartPipeline)
api.POST("/camera/stop", cameraHandler.StopPipeline)
api.GET("/camera/stream", cameraHandler.StreamMJPEG)
// Media
api.POST("/media/upload/image", cameraHandler.UploadImage)
api.POST("/media/upload/video", cameraHandler.UploadVideo)
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages)
api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
api.POST("/media/seek", cameraHandler.SeekVideo)
}
// WebSocket
r.GET("/ws/devices/events", ws.DeviceEventsHandler(wsHub, deviceMgr))
r.GET("/ws/devices/:id/flash-progress", ws.FlashProgressHandler(wsHub))
r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc))
r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster))
// MAJ-4 補丁:/ws/system — server:shutdown-imminent 事件訂閱
r.GET("/ws/system", ws.SystemEventsHandler(wsHub))
// Embedded frontend static file serving (production mode)
if staticFS != nil {
fileServer := http.FileServer(staticFS)
// Serve Next.js-style static assets
r.GET("/_next/*filepath", func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
r.GET("/favicon.ico", func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
// SPA fallback for all other routes (client-side routing)
r.NoRoute(spaFallback(staticFS))
}
return r
}
// broadcasterLoggerSkipPaths 列出不寫 access log 的 endpointM8-4 TDD §9.1a)。
// 這些 endpoint 被瀏覽器或業務 code 高頻輪詢,每次都寫 log 會把 log 噴滿。
var broadcasterLoggerSkipPaths = map[string]struct{}{
"/api/system/boot-id": {},
"/api/system/health": {},
}
// broadcasterLogger is a Gin middleware that logs HTTP requests to both
// stdout (like gin.Logger) and the WebSocket log broadcaster so that
// request logs are visible in the frontend Settings page.
//
// M8-4對 broadcasterLoggerSkipPaths 裡列出的 endpoint 不寫 log
// 避免把 access log 淹沒(瀏覽器每 10s poll boot-idhealth 被業務 code 高頻輪詢)。
func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
c.Next()
// M8-4跳過高頻輪詢 endpoint比對只看 path不含 query
if _, skip := broadcasterLoggerSkipPaths[path]; skip {
return
}
latency := time.Since(start)
status := c.Writer.Status()
method := c.Request.Method
if raw != "" {
path = path + "?" + raw
}
msg := fmt.Sprintf("%3d | %13v | %-7s %s",
status, latency, method, path)
// Write to stdout (original Gin behaviour)
fmt.Printf("[GIN] %s\n", msg)
// Push to broadcaster for WebSocket streaming
if b != nil {
level := "INFO"
if status >= 500 {
level = "ERROR"
} else if status >= 400 {
level = "WARN"
}
b.Push(level, fmt.Sprintf("[GIN] %s", msg))
}
}
}
// spaFallback tries to serve the exact file from the embedded FS.
// If the file doesn't exist, it finds the best matching route shell HTML
// for Next.js static export client-side routing.
//
// Next.js static export with generateStaticParams creates:
// /models/index.html — static page
// /models/_/index.html — dynamic route shell (placeholder param '_')
//
// For a request like /models/yolov5-face-detection:
// 1. Try exact file → not found
// 2. Try /models/_/index.html → found → serve it (Next.js CSR picks up real param from URL)
// 3. Fall back to /index.html (root)
func spaFallback(staticFS http.FileSystem) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
// Don't serve index.html for API or WebSocket routes
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") {
c.Status(http.StatusNotFound)
return
}
// Try to serve the exact file
if f, err := staticFS.Open(path); err == nil {
f.Close()
http.FileServer(staticFS).ServeHTTP(c.Writer, c.Request)
return
}
// Try Next.js dynamic route shell: replace last path segment with '_'
// e.g. /models/yolov5 → /models/_/index.html
// /devices/kl520-0 → /devices/_/index.html
// /workspace/kl520-0 → /workspace/_/index.html
segments := strings.Split(strings.TrimRight(path, "/"), "/")
if len(segments) >= 2 {
segments[len(segments)-1] = "_"
shellPath := strings.Join(segments, "/") + "/index.html"
if f, err := staticFS.Open(shellPath); err == nil {
defer f.Close()
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, f)
return
}
}
// Final fallback: root index.html
index, err := staticFS.Open("/index.html")
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
defer index.Close()
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, index)
}
}