local-tool/: visionA-local desktop app
- M1: Wails shell + Go server + Next.js UI + Mock mode (macOS dmg ready)
- M2: i18n (zh-TW/en) + Settings 4-tab refactor
- M3: Embedded Python 3.12 runtime (python-build-standalone) + KneronPLUS wheels
- M4: Windows Inno Setup script (build on Windows runner)
- M5: Linux AppImage script + udev rule (build on Linux runner)
- M6: ffmpeg (GPL, pending legal review) + yt-dlp bundled
- Lifecycle: watchServer health check, fatal native dialog,
Wails IPC raise endpoint, stale process cleanup
.autoflow/: full PRD / Design Spec / Architecture / Testing docs
(4 rounds tri-party discussion + cross review)
.github/workflows/: macOS / Windows / Linux build CI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
308 lines
12 KiB
Markdown
308 lines
12 KiB
Markdown
# API Endpoints — visionA-local
|
||
|
||
> REST + WebSocket 端點清單。來源:`edge-ai-platform/server/internal/api/router.go`
|
||
> 決策:保留所有業務 API,砍掉 cluster、relay、tunnel、update 相關。
|
||
|
||
---
|
||
|
||
## 1. REST API
|
||
|
||
### 1.1 /api/system/*(保留,部分精簡)
|
||
|
||
| Method | Path | 決定 | 備註 |
|
||
|--------|------|------|------|
|
||
| GET | `/api/system/health` | ✅ 保留 | Wails app 用來確認 server 活著 |
|
||
| GET | `/api/system/info` | ✅ 保留 + **擴充** | 顯示版本、OS、uptime、**`actual_port`**(前端依此顯示實際 listen port)、`mode`(mock/real)、`python_mode`(bundled/system) |
|
||
| GET | `/api/system/metrics` | ✅ 保留 | CPU / RAM / 裝置數 |
|
||
| GET | `/api/system/deps` | ✅ 保留 | 依賴檢查(python / ffmpeg / kneron) |
|
||
| POST | `/api/system/restart` | ✅ 保留 | Settings 使用 |
|
||
| GET | `/api/system/update-check` | ❌ **刪除** | 使用者決策 Q6 = 不做 auto-update |
|
||
| GET | `/api/system/mode` | ➕ **新增** | 回傳當前 inference mode:`{"mode":"mock"\|"real"}` |
|
||
| POST | `/api/system/mode` | ➕ **新增** | 切換 Mock ↔ Real,body `{"mode":"mock"\|"real"}`;**不重啟 server**,只切 inference backend(見 `architecture-overview §8`) |
|
||
| GET | `/api/system/python-runtime` | ➕ **新增** | 回傳當前 Python 策略:`{"mode":"bundled"\|"system","path":"/.../python3"}` |
|
||
| POST | `/api/system/python-runtime` | ➕ **新增** | 切換 Python 策略(下次重啟 server 生效),body `{"mode":"auto"\|"bundled"\|"system"}` |
|
||
| POST | `/api/system/port` | ➕ **新增** | 修改 server listen port:body `{"port":3721}`;flow 見 §6 |
|
||
|
||
### 1.2 /api/models/*(全保留)
|
||
|
||
| Method | Path | 決定 |
|
||
|--------|------|------|
|
||
| GET | `/api/models` | ✅ |
|
||
| GET | `/api/models/:id` | ✅ |
|
||
| POST | `/api/models/upload` | ✅ |
|
||
| DELETE | `/api/models/:id` | ✅ |
|
||
|
||
### 1.3 /api/devices/*(保留核心,砍掉 flash)
|
||
|
||
| Method | Path | 決定 | 備註 |
|
||
|--------|------|------|------|
|
||
| GET | `/api/devices` | ✅ | |
|
||
| POST | `/api/devices/scan` | ✅ | |
|
||
| GET | `/api/devices/:id` | ✅ | |
|
||
| POST | `/api/devices/:id/connect` | ✅ | |
|
||
| POST | `/api/devices/:id/disconnect` | ✅ | |
|
||
| POST | `/api/devices/:id/flash` | ❌ **刪除** | 使用者決策 Q9 = 砍掉韌體燒錄 |
|
||
| POST | `/api/devices/:id/inference/start` | ✅ | |
|
||
| POST | `/api/devices/:id/inference/stop` | ✅ | |
|
||
|
||
### 1.4 /api/camera/*(全保留)
|
||
|
||
| Method | Path | 決定 |
|
||
|--------|------|------|
|
||
| GET | `/api/camera/list` | ✅ |
|
||
| POST | `/api/camera/start` | ✅ |
|
||
| POST | `/api/camera/stop` | ✅ |
|
||
| GET | `/api/camera/stream` | ✅ |
|
||
|
||
### 1.5 /api/media/*(全保留,包括 url 與 yt-dlp)
|
||
|
||
| Method | Path | 決定 | 備註 |
|
||
|--------|------|------|------|
|
||
| POST | `/api/media/upload/image` | ✅ | |
|
||
| POST | `/api/media/upload/video` | ✅ | |
|
||
| POST | `/api/media/upload/batch-images` | ✅ | |
|
||
| GET | `/api/media/batch-images/:index` | ✅ | |
|
||
| POST | `/api/media/url` | ✅ | 使用者決策 Q10 = 保留 yt-dlp |
|
||
| POST | `/api/media/seek` | ✅ | |
|
||
|
||
### 1.6 /api/clusters/*(全砍)
|
||
|
||
| Method | Path | 決定 |
|
||
|--------|------|------|
|
||
| GET | `/api/clusters` | ❌ |
|
||
| POST | `/api/clusters` | ❌ |
|
||
| GET | `/api/clusters/:id` | ❌ |
|
||
| DELETE | `/api/clusters/:id` | ❌ |
|
||
| POST | `/api/clusters/:id/devices` | ❌ |
|
||
| DELETE | `/api/clusters/:id/devices/:deviceId` | ❌ |
|
||
| PUT | `/api/clusters/:id/devices/:deviceId/weight` | ❌ |
|
||
| POST | `/api/clusters/:id/flash` | ❌ |
|
||
| POST | `/api/clusters/:id/inference/start` | ❌ |
|
||
| POST | `/api/clusters/:id/inference/stop` | ❌ |
|
||
|
||
### 1.7 其他(全砍)
|
||
|
||
| Method | Path | 決定 | 備註 |
|
||
|--------|------|------|------|
|
||
| GET | `/auth/token` | ❌ | relay token endpoint,local 無需 |
|
||
| OPTIONS | `/auth/token` | ❌ | 同上 |
|
||
|
||
## 2. WebSocket
|
||
|
||
| Path | 決定 | 備註 |
|
||
|------|------|------|
|
||
| `/ws/devices/events` | ✅ 保留 | 裝置插拔事件推送 |
|
||
| `/ws/devices/:id/flash-progress` | ❌ **刪除** | flash 已砍 |
|
||
| `/ws/devices/:id/inference` | ✅ 保留 | 推論結果 streaming |
|
||
| `/ws/server-logs` | ✅ 保留 | Settings 頁的即時 log |
|
||
| `/ws/clusters/:id/inference` | ❌ **刪除** | cluster 已砍 |
|
||
| `/ws/clusters/:id/flash-progress` | ❌ **刪除** | 同上 |
|
||
|
||
## 3. IPC(visionA-local 內部)
|
||
|
||
這是 **新增**的,給 single-instance lock 用,詳見 [`tray-and-lifecycle.md`](./tray-and-lifecycle.md)(lifecycle 章節)§2.3:
|
||
|
||
| Method | Path | 說明 |
|
||
|--------|------|------|
|
||
| GET | `/ipc/raise` | 讓已存在的 instance 浮到前景 |
|
||
| GET | `/ipc/status` | 回傳 server port、版本、pid 等 |
|
||
|
||
這些 endpoint **不是**由 Go server 提供,而是 Wails app 自己起一個極小的 HTTP listener(bound 到 localhost:random),供同機器的 visionA-local 程序間通訊。Port 號寫在各平台資料目錄下的 `visiona-local.ipc-port`(macOS:`~/Library/Application Support/visiona-local/`;Windows:`%APPDATA%\visiona-local\`;Linux:`~/.local/share/visiona-local/`)。
|
||
|
||
## 3.1 順序修正提醒
|
||
|
||
上面把模式 / runtime / port 切換流程放在 §5、拖放代理放在 §6、前端 client 清理放在 §7,原本的 §4 router.go 結構保持不變。
|
||
|
||
## 4. 新版 `router.go` 簡化後結構
|
||
|
||
```go
|
||
// server/internal/api/router.go
|
||
func NewRouter(
|
||
modelRepo *model.Repository,
|
||
modelStore *model.ModelStore,
|
||
deviceMgr *device.Manager,
|
||
cameraMgr *camera.Manager,
|
||
// ❌ clusterMgr 移除
|
||
// ❌ flashSvc 移除
|
||
inferenceSvc *inference.Service,
|
||
wsHub *ws.Hub,
|
||
staticFS http.FileSystem,
|
||
logBroadcaster *logger.Broadcaster,
|
||
systemHandler *handlers.SystemHandler,
|
||
// ❌ relayToken 移除
|
||
) *gin.Engine {
|
||
r := gin.New()
|
||
r.Use(gin.Recovery())
|
||
r.Use(broadcasterLogger(logBroadcaster))
|
||
r.Use(CORSMiddleware())
|
||
|
||
modelHandler := handlers.NewModelHandler(modelRepo)
|
||
modelUploadHandler := handlers.NewModelUploadHandler(modelRepo, modelStore)
|
||
deviceHandler := handlers.NewDeviceHandler(deviceMgr, inferenceSvc, wsHub) // flashSvc 拿掉
|
||
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.POST("/system/restart", systemHandler.Restart)
|
||
// ❌ /system/update-check 移除
|
||
|
||
// 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)
|
||
// ❌ /devices/:id/flash 移除
|
||
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/url", cameraHandler.StartFromURL)
|
||
api.POST("/media/seek", cameraHandler.SeekVideo)
|
||
|
||
// ❌ /clusters/* 全部移除
|
||
}
|
||
|
||
// ❌ /auth/token 移除
|
||
|
||
// WebSocket
|
||
r.GET("/ws/devices/events", ws.DeviceEventsHandler(wsHub, deviceMgr))
|
||
// ❌ /ws/devices/:id/flash-progress 移除
|
||
r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc))
|
||
r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster))
|
||
// ❌ /ws/clusters/* 全部移除
|
||
|
||
// Embedded frontend
|
||
if staticFS != nil {
|
||
fileServer := http.FileServer(staticFS)
|
||
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) })
|
||
r.NoRoute(spaFallback(staticFS))
|
||
}
|
||
return r
|
||
}
|
||
```
|
||
|
||
## 5. 模式 / Runtime / Port 切換流程
|
||
|
||
### 5.1 Mock ↔ Real 模式切換(`POST /api/system/mode`)
|
||
|
||
不重啟 server,只切 inference backend:
|
||
|
||
```
|
||
前端 POST /api/system/mode {"mode":"real"}
|
||
↓
|
||
Go server: device.Manager.SetMode("real")
|
||
├─ 若原為 mock → spawn Python sidecar → kneron_bridge scan → 回填 registry
|
||
└─ 若原為 real → kill Python sidecar → 載入 mock devices
|
||
↓
|
||
broadcast 200 OK + WebSocket /ws/devices/events push "mode_changed"
|
||
↓
|
||
前端重新拉 /api/devices 與 /api/system/info
|
||
```
|
||
|
||
所有進行中的 inference session 強制終止,使用者收到 toast:「模式已切換,請重新啟動推論」。
|
||
|
||
### 5.2 Python Runtime 切換(`POST /api/system/python-runtime`)
|
||
|
||
切換不即時生效(需重啟 Go server 子行程):
|
||
|
||
```
|
||
前端 POST /api/system/python-runtime {"mode":"system"}
|
||
↓
|
||
Go server: 寫入 .installed 的 python.mode 欄位
|
||
↓
|
||
回應 200 + {"needs_restart": true}
|
||
↓
|
||
前端顯示「需重啟 server 才會生效,立即重啟?」
|
||
→ 使用者同意 → 呼叫 POST /api/system/restart
|
||
→ Wails app 接收 → kill 當前 Go server 子行程 → 用新 python mode 重 spawn
|
||
```
|
||
|
||
### 5.3 Port 變更(`POST /api/system/port`)
|
||
|
||
**Port 修改走「持久化 → 重啟 server 子行程 → Wails WebView 重連」流程**:
|
||
|
||
```
|
||
前端 POST /api/system/port {"port":3722}
|
||
↓
|
||
Go server 驗證 port 可用(若不可用回 409)
|
||
↓
|
||
Go server 寫入 config.json 的 port 欄位(持久化到資料目錄)
|
||
↓
|
||
Go server 回應 200 + {"new_port":3722,"needs_restart":true}
|
||
↓
|
||
前端通知 Wails app 透過 bind API: runtime.RequestServerRestart(3722)
|
||
↓
|
||
Wails app:
|
||
1. 儲存 pending_new_port=3722 到記憶體
|
||
2. kill 當前 Go server 子行程(SIGTERM → 3s timeout → SIGKILL)
|
||
3. 用新 port spawn 新的 Go server 子行程
|
||
4. waitHealthy(3722, 10s)
|
||
5. WebView.Reload("http://127.0.0.1:3722/")
|
||
↓
|
||
前端重新載入並顯示新 port
|
||
```
|
||
|
||
**失敗回退**:若新 port spawn 失敗,Wails app 用舊 port 重啟,並顯示錯誤 toast。config.json 不回滾(使用者可自行修正)。
|
||
|
||
### 5.4 Port picking 後顯示實際 port
|
||
|
||
使用者未指定時走 `pickPort(3721)`,結果可能落在 3722/3723... 前端取得方式:
|
||
|
||
```
|
||
前端啟動 → GET /api/system/info → 取 data.actual_port
|
||
前端 Sidebar/Settings → 顯示「Server listening on 127.0.0.1:{actual_port}」
|
||
```
|
||
|
||
## 6. 拖放檔案代理上傳
|
||
|
||
Wails v2 `OnFileDrop` 回傳絕對路徑,不是 File 物件。前端不能直接 FormData 上傳,必須走 Wails 代理:
|
||
|
||
```
|
||
使用者拖 .nef → Wails WebView 捕獲
|
||
↓
|
||
Wails Go 端 runtime.OnFileDrop([]string{abs_path})
|
||
↓
|
||
Wails 透過 Bind API 把路徑陣列 emit 給前端
|
||
↓
|
||
前端呼叫 window.runtime.UploadFileByPath(path, "/api/models/upload")
|
||
↓
|
||
Wails Go 端讀檔 → 包成 multipart/form-data → POST http://127.0.0.1:{port}/api/models/upload
|
||
↓
|
||
Go server 走原本的 upload handler,無需修改
|
||
```
|
||
|
||
詳見 `architecture-overview.md §9`。
|
||
|
||
## 7. 對應的前端 API client 清理
|
||
|
||
`frontend/src/lib/api/` 底下需要刪除:
|
||
- `clusters.ts`
|
||
- `relay.ts`(若存在)
|
||
- `update.ts`(若存在)
|
||
- `flash.ts`(若獨立檔案)
|
||
|
||
保留其他所有 API client。
|