依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。
程式碼變動
- M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
Makefile vendor / installer / bootstrap / CI workflow,-555 行)
- M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
VISIONA_MOCK 環境變數,-528 行)
- M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
LGPL binary,macOS 自 build minimal decoder-only 進 git
(vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
- M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
- M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
- M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
state 視覺、log panel、startup progress panel、Stage 6 manual CTA
pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
- M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
- M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
wsEverConnected 容錯 + Page Visibility)
- M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
- MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
(/ws/system endpoint + notifyShutdownImminent helper)
- M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)
品質
- ~105+ 新 unit test + race detector (-count=2) 全綠
- 10 個 milestone 全部通過 Reviewer 審查
- 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
收錄在 .autoflow/
交付前待處理(M8-10)
- 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
- 三平台 end-to-end build 驗證
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
850 lines
38 KiB
Markdown
850 lines
38 KiB
Markdown
# v2/control-panel.md — Wails 控制台實作規格
|
||
|
||
> 所屬:TDD v2 §2.1
|
||
> 版本:v2.1(2026-04-14 吸收 PM 審閱 + R5-D + R5-E)
|
||
> 決策依據:R5-1(Wails 視窗 = 控制台)、R5-5(Mock 切換不放控制台)、R5-D1(OS 崩潰通知並存)、R5-D2(Linux 預設 auto-open OFF)、R5-D3(每次 Start 成功都開瀏覽器)、R5-E(階段化啟動進度)、三方共識 #7(vanilla HTML/JS/CSS)
|
||
> 對應 milestone:M8-4(lifecycle + bindings + LogBuffer)、M8-4b(階段化啟動管線)、M8-5(vanilla UI 改寫)
|
||
> 關聯子檔:`v2/startup-pipeline.md`(R5-E 6 階段啟動管線細節)
|
||
|
||
---
|
||
|
||
## 1. 目的與範圍
|
||
|
||
把現有的 `visiona-local/frontend/` splash(M7-B 寫的 78 行 splash + redirect)**整組改寫**成一個靜態的控制台 UI,長駐在 Wails 視窗內,不再跳轉。控制台提供:
|
||
|
||
1. **Server 狀態卡片**:即時 state、port、PID、Python runtime 資訊
|
||
2. **Log panel**:ring buffer 2000 行、auto-scroll、pause、clear
|
||
3. **動作列**:Start / Stop / Restart / Open in Browser / Reveal Logs Folder / Clear Logs
|
||
4. **Preferences 區塊**:`AutoOpenBrowser` toggle(R5-4 / R5-D2 / R5-D3)
|
||
5. **系統資訊**:data dir / app version / build time(對齊 `GET /api/system/info`,但取自 Wails local state 更穩)
|
||
|
||
控制台**不做**:業務資料(裝置清單、模型清單、推論畫面、Settings > 語言、Mock 切換)— 這些全在瀏覽器 tab 的 Next.js Web UI。
|
||
|
||
---
|
||
|
||
## 2. 技術選型(R5-1 + 三方共識 #7)
|
||
|
||
| 面向 | 決定 | 原因 |
|
||
|------|------|------|
|
||
| UI stack | **vanilla HTML + CSS + ES module JS** | 控制台元件 < 10 個,引入 React/Vue/Svelte 反而重;現有 `visiona-local/frontend/` 已經是 ES module,不需重建 build chain |
|
||
| Build | **無** — `go:embed all:frontend` 直接塞入 Wails binary | 維持現有 `visiona-local/main.go:11-12` 的 embed 機制,完全不動 |
|
||
| CSS 方法 | BEM 命名 + CSS variables(light/dark mode token) | 不需 Tailwind;dark mode 靠 `@media (prefers-color-scheme: dark)` 換 CSS var |
|
||
| 圖示 | Inline SVG(action bar 的播放 / 停止 / 刷新 / 瀏覽器 / 資料夾 / 垃圾桶) | 不引外部 icon library,避免多一份字型檔 |
|
||
| i18n | 沿用 `frontend/src/lib/i18n/{zh-TW,en}.ts` 的字串鍵,但以**獨立的 JSON 子集**提供給控制台(避免控制台與 Next.js 前端耦合 build pipeline) | 詳見 §6 |
|
||
| 自動偵測語系 | Q5 強化:`navigator.languages[0] \|\| navigator.language`,以 `zh` 開頭 → `zh-TW`;`C` / `POSIX` / 空字串 → `en-US`;其他 → `en-US`。fetch 失敗 → hardcoded 英文 fallback | Wails v2 在 Windows/Linux 下偶爾回 `en` 或 `C`,需 fallback;細節見 §6.2 |
|
||
|
||
### 2.1 檔案結構
|
||
|
||
```
|
||
visiona-local/frontend/
|
||
├── index.html ← 改寫:控制台 layout
|
||
├── app.js ← 改寫:初始化 + binding 呼叫 + event 訂閱
|
||
├── style.css ← 改寫:控制台樣式(status / log / action bar / prefs)
|
||
├── components/
|
||
│ ├── status-card.js ← 新增:狀態卡片 render + update
|
||
│ ├── log-panel.js ← 新增:ring buffer 顯示 + auto-scroll + pause
|
||
│ ├── action-bar.js ← 新增:6 顆按鈕 + disable 邏輯(依 state machine)
|
||
│ ├── preferences.js ← 新增:AutoOpenBrowser toggle(R5-D2/D3)
|
||
│ └── startup-panel.js ← 新增:R5-E 6 階段啟動進度面板 render/update
|
||
├── i18n/
|
||
│ ├── en-US.json ← 新增:控制台 i18n 字串子集
|
||
│ └── zh-TW.json ← 新增
|
||
├── icons/
|
||
│ ├── play.svg ← 新增
|
||
│ ├── stop.svg
|
||
│ ├── restart.svg
|
||
│ ├── browser.svg
|
||
│ ├── folder.svg
|
||
│ └── trash.svg
|
||
└── wailsjs/ ← 自動生成(Wails build),不動
|
||
```
|
||
|
||
---
|
||
|
||
## 3. UI 元件清單 + 視覺結構
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────────┐
|
||
│ visionA Local [■ Dark/Light]│ ← titlebar(Wails frameless 或 system,v2 保持 system)
|
||
├────────────────────────────────────────────────────────────────┤
|
||
│ ┌─────────────── Server Status ─────────────────────────────┐ │
|
||
│ │ ● Running │ │ ← state badge(顏色:Running 綠 / Starting 黃 / Error 紅 / Stopped 灰)
|
||
│ │ http://127.0.0.1:3721 • PID 42131 │ │
|
||
│ │ Python: system (/usr/bin/python3.12) │ │
|
||
│ │ Data dir: ~/Library/Application Support/visiona-local │ │
|
||
│ └────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────── Server Log ────────────────────────────────┐ │
|
||
│ │ 14:23:01 [INFO] visiona-local-server starting on :3721 │ │
|
||
│ │ 14:23:01 [INFO] Python bridge: /usr/bin/python3.12 │ │
|
||
│ │ 14:23:02 [INFO] Loaded 8 built-in models │ │
|
||
│ │ 14:23:02 [INFO] HTTP server listening on 127.0.0.1:3721 │ │
|
||
│ │ 14:23:15 [GIN] 200 | 2.1ms | GET /api/system/info │ │ ← scrollable <pre class="log__body">
|
||
│ │ ... │ │
|
||
│ └────────────────────────────────────────────────────────────┘ │
|
||
│ [Pause auto-scroll] [Clear] │ ← log panel 底下的子動作
|
||
│ │
|
||
│ ┌─────────────── Actions ───────────────────────────────────┐ │
|
||
│ │ [▶ Start] [■ Stop] [⟲ Restart] [🌐 Open in Browser] │ │
|
||
│ │ [📁 Reveal Logs] │ │
|
||
│ └────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────── Preferences ───────────────────────────────┐ │
|
||
│ │ ☑ Open browser automatically when server starts │ │
|
||
│ │ (Linux 預設為 OFF — R5-D2;macOS/Windows 預設為 ON) │ │
|
||
│ └────────────────────────────────────────────────────────────┘ │
|
||
└────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
Button disable 矩陣(依 ServerState):
|
||
|
||
| 按鈕 | Stopped | Starting | Running | Stopping | Error |
|
||
|------|---------|----------|---------|----------|-------|
|
||
| Start | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||
| Stop | ❌ | ❌ | ✅ | ❌ | ❌ |
|
||
| Restart | ❌ | ❌ | ✅ | ❌ | ❌ |
|
||
| Open in Browser | ❌ | ❌ | ✅ | ❌ | ❌ |
|
||
| Reveal Logs | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||
| Clear Logs | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||
|
||
`Starting` 與 `Stopping` 期間顯示進度 spinner 取代 state icon。
|
||
|
||
### 3.1 Starting state 下的「啟動進度面板」(R5-E)
|
||
|
||
當 ServerState 處於 `Starting` 時,主控台覆蓋一層啟動進度面板,**取代**(而非疊加於)Status/Log/Actions 區塊:
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────────┐
|
||
│ visionA Local │
|
||
├────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 正在啟動 visionA Local │
|
||
│ │
|
||
│ [■■■■□□] 4 / 6 │
|
||
│ │
|
||
│ ✅ 1. 初始化控制台 (0.1 s) │
|
||
│ ✅ 2. 檢查 Python runtime (1.8 s) │
|
||
│ ✅ 3. 啟動本機伺服器 (2.4 s) │
|
||
│ 🔄 4. 偵測 Kneron 裝置 (進行中…) │
|
||
│ ⏳ 5. 開啟瀏覽器 │
|
||
│ ⏳ 6. 等待 Web UI 連線 │
|
||
│ │
|
||
│ 已耗時 4.3 s · 上限 60 s │
|
||
│ │
|
||
│ (第 N 階段正在重試 …) │
|
||
│ │
|
||
└────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- 6 階段逐步 render,每個階段 status:`pending` / `running` / `completed` / `failed`,對應 ⏳ / 🔄 / ✅ / ❌
|
||
- 底部總耗時 counter(每秒更新一次)
|
||
- 當某階段收到 `startup:stage-timeout` event(soft timeout 20 s),在該階段行下方顯示 subtle 灰字「第 N 階段正在重試 …」
|
||
- 當收到 `startup:error` event(任一階段失敗 或 總時 60 s)→ 切 Error state,淡出進度面板顯示主控台 Error banner
|
||
- 當收到 `startup:ready` event → 淡出 300 ms → 主控台 UI 顯現
|
||
- i18n key:`startup.stage.1.label` ~ `startup.stage.6.label` / `startup.retrying` / `startup.elapsed` / `startup.error`。文案由 Design Spec v2.1 敲定(R5-E5)
|
||
|
||
完整 event schema、超時機制、watcher goroutine 見 `v2/startup-pipeline.md`。
|
||
|
||
### 3.2 Error state 的 UI 呈現 + R5-D1 OS 通知並存
|
||
|
||
當 ServerState 切到 `Error`(watchServer 連續 3 次失敗、StartServer 失敗、或階段化啟動總超時 60 s):
|
||
|
||
1. **控制台內**:Status card 的 badge 變紅、顯示 `lastError` 訊息、log panel 頂端插入一條 error banner(可關閉),action bar 的 [Start] 重新 enable 讓使用者手動 Restart
|
||
2. **同時**:透過 `visiona-local/notify.go` 發一則 OS 原生通知(macOS `osascript display notification` / Linux `notify-send` / Windows `powershell BurntToast` 或 `msg *`),標題「visionA Local — Server 崩潰」,副文「點回應用程式查看錯誤詳情」
|
||
3. **非此即彼**:OS 通知與控制台 banner **並存**,並非二選一。OS 通知的用途是「使用者把 Wails 視窗縮小/最小化時也能被知會」;控制台 banner 是「使用者打開 Wails 時一眼可見」。
|
||
|
||
詳細 `notify.go` 三平台實作見 `server-lifecycle.md` §10。
|
||
|
||
---
|
||
|
||
## 4. Go 側:Wails bindings + state machine + LogBuffer
|
||
|
||
### 4.1 新增/修改的 Go 檔案
|
||
|
||
| 檔案 | 狀態 | 內容 |
|
||
|------|------|------|
|
||
| `visiona-local/app.go` | 修改(~1584 行 → +250 / -30) | 新增 bindings;`watchServer` 改為 Error state;`shutdown` 不動;`reportFatal` 保留給「完全無法啟動」的致命錯誤 |
|
||
| `visiona-local/server_control.go` | **新增** | `ServerController` struct + 狀態機 + Start/Stop/Restart 方法 |
|
||
| `visiona-local/log_buffer.go` | **新增** | `LogBuffer` struct + ring buffer + subscribe / unsubscribe / append + logPump |
|
||
| `visiona-local/preferences.go` | **新增** | `Preferences` struct + `DefaultPreferences()`(依 `runtime.GOOS` 分平台 default,R5-D2)+ load/save JSON(atomic write-rename)+ 路徑 `<dataDir>/preferences.json` |
|
||
| `visiona-local/notify.go` | **新增** | `sendCrashNotification(title, body string)` 三平台實作(macOS `osascript` / Linux `notify-send` / Windows `powershell BurntToast` 或 `msg *`)。非阻塞、最佳努力送達。詳見 `server-lifecycle.md` §10 |
|
||
| `visiona-local/startup_pipeline.go` | **新增** | R5-E 6 階段啟動管線:`StartupPipeline` struct + watcher goroutine(20 s soft / 60 s hard timeout)+ event emit。詳見 `v2/startup-pipeline.md` |
|
||
|
||
### 4.2 新增 Bindings(Wails 自動暴露為前端 JS 函式)
|
||
|
||
```go
|
||
// server_control.go 中新增的 App method(Wails binding 會暴露成前端可呼叫)
|
||
|
||
// StartServer 啟動 server 子程序。若目前已是 Running / Starting / Stopping → 回錯誤(前端 UI 用 button disable 防呆)。
|
||
func (a *App) StartServer() error
|
||
|
||
// StopServer 優雅停止 server 子程序:SIGTERM → 等 5 s → SIGKILL。若目前 Stopped / Error → no-op。
|
||
func (a *App) StopServer() error
|
||
|
||
// RestartServer = Stop 同步完成後 Start。中間經過 Stopped 狀態。
|
||
func (a *App) RestartServer() error
|
||
|
||
// GetServerStatus 取代 v1 版本,回傳更完整的結構(見 §4.3)。
|
||
func (a *App) GetServerStatus() ServerStatusV2
|
||
|
||
// GetRecentLogs 回傳 LogBuffer 最後 n 行;n <= 0 或 > 2000 則回全部。
|
||
// 前端初次載入時呼叫一次,之後靠 log:append event 增量更新。
|
||
func (a *App) GetRecentLogs(n int) []LogLine
|
||
|
||
// ClearLogs 只清畫面(不動 server.stdout.log / server.stderr.log 磁碟檔)。
|
||
// 實作:LogBuffer.Reset() + emit event 'log:clear'。
|
||
func (a *App) ClearLogs()
|
||
|
||
// GetSystemInfo 回傳靜態系統資訊(給 Status card 顯示)。
|
||
// 不是 HTTP API 調用,直接從 Wails local state / config 讀,避免 server 未 Running 時撈不到。
|
||
func (a *App) GetSystemInfo() SystemInfo
|
||
|
||
// OpenInBrowser 用系統預設瀏覽器開啟 url;空字串則用當前 server URL。
|
||
// 實作沿用現有 openBrowser()(platform_darwin.go / _linux.go / _windows.go),完全不動。
|
||
func (a *App) OpenInBrowser(url string) error
|
||
|
||
// RevealLogsFolder 在檔案管理器中開啟 <dataDir>/logs/ 目錄。
|
||
// macOS: `open <path>` / Windows: `explorer <path>` / Linux: `xdg-open <path>`
|
||
func (a *App) RevealLogsFolder() error
|
||
|
||
// ExportLog 將 LogBuffer ring buffer 當前內容(最多 2000 行)寫入單一檔案並回傳路徑。
|
||
// 路徑:<dataDir>/exports/log-<timestamp>.txt (timestamp = time.Now().Format("20060102-150405"))
|
||
// 內容格式:逐行 dump,每行格式 "[level] timestamp message"(level 為空時省略 bracket)
|
||
// 實作要點:
|
||
// 1. 確保 <dataDir>/exports/ 目錄存在(os.MkdirAll 0755)
|
||
// 2. LogBuffer.Snapshot() 取得當前 ring buffer 副本(不干擾後續 append)
|
||
// 3. os.WriteFile(atomic-ish;檔案小、不需 atomic rename)
|
||
// 4. 回傳絕對路徑(前端可顯示 toast「已匯出至 <path>」並提供「在檔案管理器中顯示」按鈕)
|
||
// 用途:Wails 控制台「Export log」按鈕(Design Spec v2.1 §3.4);使用者回報問題時一鍵拿到 snapshot
|
||
func (a *App) ExportLog() (string, error)
|
||
|
||
// GetPreferences / SetPreferences 控制台 Preferences 區塊。
|
||
// 讀取失敗或檔案不存在時回傳 DefaultPreferences()(依平台預設)。
|
||
// SetPreferences 使用 atomic write-rename 避免 crash 時檔案損毀。
|
||
func (a *App) GetPreferences() Preferences
|
||
func (a *App) SetPreferences(p Preferences) error
|
||
```
|
||
|
||
**保留不動(v1 已有)**:`GetServerURL()`(前端控制台不會用,但為了相容性不刪)
|
||
|
||
**刪掉(v1 splash 殘留)**:`GetBootstrapStatus()`(splash 專用,控制台不需要)
|
||
|
||
### 4.3 型別定義
|
||
|
||
```go
|
||
type ServerState string
|
||
|
||
const (
|
||
ServerStateStopped ServerState = "stopped"
|
||
ServerStateStarting ServerState = "starting"
|
||
ServerStateRunning ServerState = "running"
|
||
ServerStateStopping ServerState = "stopping"
|
||
ServerStateError ServerState = "error"
|
||
)
|
||
|
||
type ServerStatusV2 struct {
|
||
State ServerState `json:"state"`
|
||
Port int `json:"port,omitempty"`
|
||
URL string `json:"url,omitempty"`
|
||
PID int `json:"pid,omitempty"`
|
||
PythonBin string `json:"pythonBin,omitempty"`
|
||
PythonMode string `json:"pythonMode,omitempty"`
|
||
StartedAt int64 `json:"startedAt,omitempty"` // Unix ms
|
||
LastError string `json:"lastError,omitempty"`
|
||
}
|
||
|
||
type SystemInfo struct {
|
||
AppVersion string `json:"appVersion"` // Wails build 時塞的 version
|
||
BuildTime string `json:"buildTime"`
|
||
DataDir string `json:"dataDir"`
|
||
LogsDir string `json:"logsDir"`
|
||
Platform string `json:"platform"` // runtime.GOOS + "/" + runtime.GOARCH
|
||
}
|
||
|
||
// Preferences 控制台偏好設定。對應檔案:<dataDir>/preferences.json。
|
||
// 詳細持久化策略見 server-lifecycle.md §11「Preferences 持久化」。
|
||
type Preferences struct {
|
||
// AutoOpenBrowser — StartServer 成功後是否自動開瀏覽器。
|
||
// 預設值由 DefaultPreferences() 依 runtime.GOOS 決定:
|
||
// macOS / Windows → true
|
||
// Linux → false (R5-D2:Linux 桌面環境差異大,預設關)
|
||
// 使用者可在 Preferences 區塊自行切換。
|
||
AutoOpenBrowser bool `json:"autoOpenBrowser"`
|
||
|
||
// Locale — 控制台 UI 的語系覆寫;空字串 → 自動偵測(navigator.language)。
|
||
Locale string `json:"locale,omitempty"`
|
||
|
||
// LogRingSize — log panel ring buffer 行數上限。0 → 使用預設 2000。
|
||
LogRingSize int `json:"logRingSize,omitempty"`
|
||
}
|
||
|
||
type LogLine struct {
|
||
Ts int64 `json:"ts"` // Unix ms
|
||
Stream string `json:"stream"` // "stdout" / "stderr" / "wails"(控制台自己的 log)
|
||
Line string `json:"line"`
|
||
Level string `json:"level,omitempty"` // 解析出 INFO/WARN/ERROR 時才填
|
||
}
|
||
```
|
||
|
||
### 4.4 LogBuffer 實作(`visiona-local/log_buffer.go`)
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"io"
|
||
"sync"
|
||
"time"
|
||
|
||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||
)
|
||
|
||
const (
|
||
logBufferCap = 2000
|
||
logScannerMaxBytes = 1 * 1024 * 1024 // 1 MB 單行上限
|
||
logBatchWindowMs = 10 // micro-batch window(見 R-v2-3)
|
||
)
|
||
|
||
type LogBuffer struct {
|
||
mu sync.Mutex
|
||
lines [logBufferCap]LogLine
|
||
head int
|
||
size int
|
||
// stats:給未來觀察用
|
||
dropped uint64
|
||
}
|
||
|
||
func NewLogBuffer() *LogBuffer { return &LogBuffer{} }
|
||
|
||
func (b *LogBuffer) Append(l LogLine) {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.lines[b.head] = l
|
||
b.head = (b.head + 1) % logBufferCap
|
||
if b.size < logBufferCap {
|
||
b.size++
|
||
} else {
|
||
b.dropped++
|
||
}
|
||
}
|
||
|
||
// Snapshot 依插入順序回傳 (最舊 → 最新) 的行,最多 n 行。
|
||
func (b *LogBuffer) Snapshot(n int) []LogLine {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
if n <= 0 || n > b.size {
|
||
n = b.size
|
||
}
|
||
out := make([]LogLine, 0, n)
|
||
start := (b.head - b.size + logBufferCap) % logBufferCap
|
||
// 跳過 b.size - n 行
|
||
skip := b.size - n
|
||
for i := 0; i < b.size; i++ {
|
||
idx := (start + i) % logBufferCap
|
||
if i < skip {
|
||
continue
|
||
}
|
||
out = append(out, b.lines[idx])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (b *LogBuffer) Reset() {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.head = 0
|
||
b.size = 0
|
||
}
|
||
```
|
||
|
||
### 4.5 logPump goroutine(`visiona-local/server_control.go`)
|
||
|
||
```go
|
||
// logPump 讀取 server 子程序的 stdout 或 stderr pipe,同時寫檔 + append 到 LogBuffer +
|
||
// 以 micro-batch 方式 emit log:append event 給前端。
|
||
//
|
||
// 參數:
|
||
// pipe — server cmd.StdoutPipe() 或 cmd.StderrPipe() 回傳的 ReadCloser
|
||
// stream — "stdout" / "stderr"
|
||
// fileWriter — logs/server.<stream>.log 的 os.File(append 模式)
|
||
// done — 當 pump 結束時關閉(例如 process exit / pipe EOF)
|
||
func (a *App) logPump(pipe io.ReadCloser, stream string, fileWriter io.Writer, done chan<- struct{}) {
|
||
defer close(done)
|
||
defer pipe.Close()
|
||
|
||
scanner := bufio.NewScanner(pipe)
|
||
scanner.Buffer(make([]byte, 64*1024), logScannerMaxBytes)
|
||
|
||
// micro-batch:累積 logBatchWindowMs 內的行,一次 emit 一個 log:append event
|
||
batch := make([]LogLine, 0, 16)
|
||
ticker := time.NewTicker(time.Duration(logBatchWindowMs) * time.Millisecond)
|
||
defer ticker.Stop()
|
||
|
||
flush := func() {
|
||
if len(batch) == 0 {
|
||
return
|
||
}
|
||
if a.ctx != nil {
|
||
wailsRuntime.EventsEmit(a.ctx, "log:append", batch)
|
||
}
|
||
batch = batch[:0]
|
||
}
|
||
|
||
scanDone := make(chan struct{})
|
||
lineCh := make(chan string, 128)
|
||
|
||
go func() {
|
||
defer close(scanDone)
|
||
for scanner.Scan() {
|
||
select {
|
||
case lineCh <- scanner.Text():
|
||
default:
|
||
// 丟行(極高頻下的安全閥),同時寫檔仍保留
|
||
// 避免阻塞 scanner goroutine 讓 server stdout 背壓
|
||
}
|
||
}
|
||
}()
|
||
|
||
for {
|
||
select {
|
||
case line, ok := <-lineCh:
|
||
if !ok {
|
||
flush()
|
||
return
|
||
}
|
||
// 1. 寫檔(持久化)
|
||
_, _ = fileWriter.Write([]byte(line + "\n"))
|
||
// 2. ring buffer
|
||
l := LogLine{
|
||
Ts: time.Now().UnixMilli(),
|
||
Stream: stream,
|
||
Line: line,
|
||
Level: parseLogLevel(line),
|
||
}
|
||
a.logBuf.Append(l)
|
||
// 3. 加進 batch
|
||
batch = append(batch, l)
|
||
case <-ticker.C:
|
||
flush()
|
||
case <-scanDone:
|
||
flush()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// parseLogLevel 嘗試從 Go logger 的典型格式抽 level(best-effort,只為 UI 著色,不影響邏輯)。
|
||
// 例:`2026/04/14 14:23:01 [INFO] ...` → "info"
|
||
// `[GIN] 500 | ... | POST /...` → "error"(status >= 500)
|
||
func parseLogLevel(line string) string {
|
||
// 實作細節省略;用簡單 substring match。
|
||
return ""
|
||
}
|
||
```
|
||
|
||
### 4.6 ServerController state machine(`visiona-local/server_control.go`)
|
||
|
||
```go
|
||
type ServerController struct {
|
||
app *App
|
||
|
||
mu sync.Mutex
|
||
state ServerState
|
||
proc *ServerProcess
|
||
startedAt time.Time
|
||
lastError string
|
||
}
|
||
|
||
func (c *ServerController) setState(s ServerState, err string) {
|
||
c.mu.Lock()
|
||
c.state = s
|
||
c.lastError = err
|
||
c.mu.Unlock()
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", c.app.GetServerStatus())
|
||
}
|
||
}
|
||
|
||
func (c *ServerController) Start() error {
|
||
c.mu.Lock()
|
||
if c.state == ServerStateRunning || c.state == ServerStateStarting || c.state == ServerStateStopping {
|
||
c.mu.Unlock()
|
||
return fmt.Errorf("cannot start: current state=%s", c.state)
|
||
}
|
||
c.state = ServerStateStarting
|
||
c.lastError = ""
|
||
c.mu.Unlock()
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", c.app.GetServerStatus())
|
||
}
|
||
|
||
// 呼叫 a.startServer()(沿用 v1 邏輯,見 app.go:425-564),差別:
|
||
// 1. 用 StdoutPipe/StderrPipe 取代 cmd.Stdout=file
|
||
// 2. 啟動兩個 logPump goroutine
|
||
// 3. 健康檢查 OK 後 state → Running
|
||
// 4. 失敗後 state → Error(不 os.Exit)
|
||
if err := c.app.startServerV2(c); err != nil {
|
||
c.setState(ServerStateError, err.Error())
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:error", err.Error())
|
||
}
|
||
// R5-D1:server 啟動徹底失敗時,除了 Error state 以外也發 OS 原生通知
|
||
// 實作細節見 visiona-local/notify.go 與 server-lifecycle.md §10
|
||
go sendCrashNotification(
|
||
"visionA Local — Server 啟動失敗",
|
||
"點回應用程式查看錯誤詳情或按 Restart 重試。",
|
||
)
|
||
return err
|
||
}
|
||
c.setState(ServerStateRunning, "")
|
||
|
||
// R5-D3:只要 Preferences 允許,每次 StartServer 成功都呼叫 OpenInBrowser。
|
||
// 取消 v2.0 的 autoOpenedThisSession flag(砍掉 per-session-once 概念)。
|
||
//
|
||
// 為什麼不怕 Restart 時開多個 tab:OS 瀏覽器的 open/start/xdg-open 對於
|
||
// 「已經載入同一 URL 的 tab」通常是聚焦既有 tab 而不是開新的;即使在
|
||
// 少數平台實際開了新 tab,這也是 R5-D3 明示可接受的結果。
|
||
//
|
||
// Preferences 預設值由 DefaultPreferences() 依 runtime.GOOS 決定:
|
||
// macOS / Windows → true
|
||
// Linux → false (R5-D2)
|
||
if c.app.prefs.AutoOpenBrowser {
|
||
_ = c.app.OpenInBrowser("")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c *ServerController) Stop() error {
|
||
c.mu.Lock()
|
||
if c.state == ServerStateStopped || c.state == ServerStateError {
|
||
c.mu.Unlock()
|
||
return nil
|
||
}
|
||
if c.state != ServerStateRunning {
|
||
c.mu.Unlock()
|
||
return fmt.Errorf("cannot stop: current state=%s", c.state)
|
||
}
|
||
c.state = ServerStateStopping
|
||
proc := c.proc
|
||
c.mu.Unlock()
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", c.app.GetServerStatus())
|
||
}
|
||
|
||
if proc != nil {
|
||
proc.stop() // 沿用 v1:SIGTERM → 5s grace → SIGKILL
|
||
}
|
||
|
||
c.mu.Lock()
|
||
c.proc = nil
|
||
c.mu.Unlock()
|
||
c.setState(ServerStateStopped, "")
|
||
return nil
|
||
}
|
||
|
||
func (c *ServerController) Restart() error {
|
||
if err := c.Stop(); err != nil && err.Error() != "" {
|
||
return err
|
||
}
|
||
return c.Start()
|
||
}
|
||
```
|
||
|
||
### 4.7 watchServer 改為 Error state(修改 `app.go:571-610`)
|
||
|
||
原本:
|
||
```go
|
||
if failures >= 3 {
|
||
a.reportFatal("server died", ...)
|
||
return
|
||
}
|
||
```
|
||
|
||
改為:
|
||
```go
|
||
if failures >= 3 {
|
||
a.ctrl.setState(ServerStateError, "health check failed 3 times")
|
||
if a.ctx != nil {
|
||
wailsRuntime.EventsEmit(a.ctx, "server:error", map[string]any{
|
||
"reason": "health check failed 3 times",
|
||
"port": sp.port,
|
||
})
|
||
}
|
||
// 不呼叫 reportFatal、不 os.Exit — 讓使用者在控制台手動 Restart 或查 log
|
||
return
|
||
}
|
||
```
|
||
|
||
`reportFatal()` 本身保留(app.go:215-237),只留給 `startup()` 階段致命錯誤:data dir 建不起來、single-instance lock 無法取得(非「別人在跑」的情況)、首次啟動 startServer 失敗(此時無控制台可看)。後續 server 崩潰一律走 Error state。
|
||
|
||
---
|
||
|
||
## 5. 前端 JS 實作(`visiona-local/frontend/app.js` 重寫)
|
||
|
||
```javascript
|
||
// visiona-local/frontend/app.js
|
||
import {
|
||
StartServer, StopServer, RestartServer,
|
||
GetServerStatus, GetRecentLogs, ClearLogs, GetSystemInfo,
|
||
OpenInBrowser, RevealLogsFolder, ExportLog,
|
||
GetPreferences, SetPreferences,
|
||
} from './wailsjs/go/main/App.js';
|
||
import { EventsOn } from './wailsjs/runtime/runtime.js';
|
||
|
||
import { renderStatusCard, updateStatusCard } from './components/status-card.js';
|
||
import { initLogPanel, appendLogLines, clearLogPanel } from './components/log-panel.js';
|
||
import { renderActionBar, updateActionBarState } from './components/action-bar.js';
|
||
import { renderPreferences } from './components/preferences.js';
|
||
import { loadLocale, t } from './i18n/loader.js';
|
||
|
||
async function main() {
|
||
// 1. 載入 i18n(依 navigator.language)
|
||
await loadLocale();
|
||
|
||
// 2. 靜態元件一次 render
|
||
const sys = await GetSystemInfo();
|
||
renderStatusCard(sys);
|
||
initLogPanel();
|
||
renderActionBar({
|
||
onStart: () => StartServer().catch(showError),
|
||
onStop: () => StopServer().catch(showError),
|
||
onRestart: () => RestartServer().catch(showError),
|
||
onOpenBrowser: () => OpenInBrowser('').catch(showError),
|
||
onReveal: () => RevealLogsFolder().catch(showError),
|
||
onClear: () => { ClearLogs(); clearLogPanel(); },
|
||
onExportLog: async () => {
|
||
try {
|
||
const path = await ExportLog();
|
||
showToast(t('log.exported', { path }));
|
||
} catch (e) {
|
||
showError(e);
|
||
}
|
||
},
|
||
});
|
||
const prefs = await GetPreferences();
|
||
renderPreferences(prefs, async (p) => { await SetPreferences(p); });
|
||
|
||
// 3. 初始 server status + log 快照
|
||
const st = await GetServerStatus();
|
||
updateStatusCard(st);
|
||
updateActionBarState(st.state);
|
||
const initialLogs = await GetRecentLogs(2000);
|
||
appendLogLines(initialLogs);
|
||
|
||
// 4. 訂閱 Wails events
|
||
EventsOn('log:append', (batch) => appendLogLines(batch));
|
||
EventsOn('log:clear', () => clearLogPanel());
|
||
EventsOn('server:state-change', (newStatus) => {
|
||
updateStatusCard(newStatus);
|
||
updateActionBarState(newStatus.state);
|
||
});
|
||
EventsOn('server:error', (info) => {
|
||
// Status card 的 last error 段會自動透過 state-change 更新,
|
||
// 這裡額外做一個 toast(若有需要的話)。
|
||
});
|
||
|
||
// 5. R5-E 階段化啟動進度 — 四個 event 訂閱(細節見 v2/startup-pipeline.md)
|
||
EventsOn('startup:progress', (e) => {
|
||
// e = { stage, totalStages, labelKey, status, startedAt }
|
||
updateStartupPanel(e);
|
||
});
|
||
EventsOn('startup:stage-timeout', (e) => {
|
||
// e = { stage, softTimeoutSeconds }
|
||
// 顯示「階段 N 正在重試 ...」副文字(不中斷流程)
|
||
updateStartupPanel({ ...e, status: 'retrying' });
|
||
});
|
||
EventsOn('startup:error', (e) => {
|
||
// e = { stage, error }
|
||
// 切到 Error state + 彈 toast。實際 server state 會由 server:state-change 同步。
|
||
showStartupError(e);
|
||
});
|
||
EventsOn('startup:ready', () => {
|
||
// 總時 < 60 s、6 階段都 completed → 淡出啟動進度面板,顯示主控台
|
||
hideStartupPanel();
|
||
});
|
||
}
|
||
|
||
function showError(e) {
|
||
// 顯示 inline toast;詳見 style.css .toast
|
||
const el = document.getElementById('toast');
|
||
el.textContent = String(e);
|
||
el.hidden = false;
|
||
setTimeout(() => (el.hidden = true), 5000);
|
||
}
|
||
|
||
main().catch((e) => console.error('[visiona-local console] init failed:', e));
|
||
```
|
||
|
||
### 5.1 Log panel 細節(`components/log-panel.js`)
|
||
|
||
- 容器:`<pre class="log__body">`,最多保留 2000 行(超過則從頂部截)
|
||
- Auto-scroll:若使用者滾輪已滾到底(`scrollTop + clientHeight >= scrollHeight - 2`),自動 scroll 到新行;若使用者手動往上捲,停止 auto-scroll(即 Pause 狀態)
|
||
- Pause 按鈕:明確切 auto-scroll on/off,即使在底部也不跟
|
||
- Level 顏色:`.log-line--info` / `--warn` / `--error`(由 Go `parseLogLevel` 塞到 LogLine.Level)
|
||
- Batch render:每收到一批 log:append,一次 DOM 更新(createDocumentFragment)避免 layout thrash
|
||
- 行長度截斷:單行 > 2 KB 的 log 在 UI 只顯示前 2 KB + `…(點擊展開)`
|
||
|
||
### 5.2 Action bar disable 邏輯
|
||
|
||
見 §3 的 disable 矩陣,`updateActionBarState(state)` 依 state 呼叫 `btn.disabled = true|false`。
|
||
|
||
---
|
||
|
||
## 6. i18n 機制
|
||
|
||
### 6.1 控制台自己的字串清單(不動 Next.js Web UI i18n)
|
||
|
||
`visiona-local/frontend/i18n/zh-TW.json`:
|
||
|
||
```json
|
||
{
|
||
"statusCard": {
|
||
"running": "執行中",
|
||
"stopped": "已停止",
|
||
"starting": "啟動中…",
|
||
"stopping": "停止中…",
|
||
"error": "錯誤",
|
||
"port": "埠",
|
||
"pid": "PID",
|
||
"python": "Python",
|
||
"dataDir": "資料目錄"
|
||
},
|
||
"actions": {
|
||
"start": "啟動",
|
||
"stop": "停止",
|
||
"restart": "重啟",
|
||
"openBrowser": "在瀏覽器中開啟",
|
||
"revealLogs": "開啟 log 資料夾",
|
||
"clearLogs": "清空 log"
|
||
},
|
||
"logPanel": {
|
||
"title": "Server Log",
|
||
"pauseAutoScroll": "暫停捲動",
|
||
"resumeAutoScroll": "繼續捲動",
|
||
"clear": "清空"
|
||
},
|
||
"preferences": {
|
||
"title": "偏好設定",
|
||
"openBrowserOnStart": "Server 就緒時自動開啟瀏覽器"
|
||
},
|
||
"errors": {
|
||
"startFailed": "啟動失敗:{msg}",
|
||
"stopFailed": "停止失敗:{msg}"
|
||
}
|
||
}
|
||
```
|
||
|
||
`en-US.json` 內容對應,英文版。
|
||
|
||
### 6.2 Loader
|
||
|
||
`visiona-local/frontend/i18n/loader.js`:
|
||
|
||
```javascript
|
||
let strings = {};
|
||
|
||
// Q5:navigator.language fallback 強化。
|
||
//
|
||
// Wails v2 在 Windows 下某些情況會回 'en';在 Linux 下有時回 'C';在 macOS 下偶爾
|
||
// 會回空字串。我們明確處理這些邊緣情況:
|
||
//
|
||
// 1. 若 Preferences.Locale 有值(使用者手動覆寫)→ 直接用
|
||
// 2. 否則讀 navigator.languages[0] || navigator.language
|
||
// 3. 空字串 / 'C' / 'POSIX' → 視為 'en'
|
||
// 4. 以 'zh' 開頭(含 zh-TW / zh-CN / zh-HK / zh-SG)→ 一律載 zh-TW
|
||
// (我們目前只有一份中文,不做繁簡切換)
|
||
// 5. 其他 → 載 en-US
|
||
// 6. 若第一次 fetch 失敗 → 最終 fallback 到 hardcoded 英文字串集(~30 key)
|
||
//
|
||
// 第 6 點的 hardcoded fallback 直接定義在 loader.js 最底下,避免「i18n 完全壞掉
|
||
// 時控制台 UI 變成一片 placeholder key」。
|
||
export async function loadLocale(override) {
|
||
const raw = override
|
||
|| (Array.isArray(navigator.languages) && navigator.languages[0])
|
||
|| navigator.language
|
||
|| '';
|
||
const cleaned = String(raw).trim();
|
||
let lang;
|
||
if (cleaned === '' || cleaned === 'C' || cleaned === 'POSIX') {
|
||
lang = 'en-US';
|
||
} else if (cleaned.toLowerCase().startsWith('zh')) {
|
||
lang = 'zh-TW';
|
||
} else {
|
||
lang = 'en-US';
|
||
}
|
||
try {
|
||
const res = await fetch(`./i18n/${lang}.json`);
|
||
if (!res.ok) throw new Error(`i18n load failed: ${res.status}`);
|
||
strings = await res.json();
|
||
} catch (e) {
|
||
console.warn('[visiona-local console] i18n load failed, using hardcoded fallback:', e);
|
||
strings = HARDCODED_EN_FALLBACK;
|
||
}
|
||
}
|
||
|
||
export function t(key, params = {}) {
|
||
const parts = key.split('.');
|
||
let v = strings;
|
||
for (const p of parts) {
|
||
v = v?.[p];
|
||
if (v === undefined) return key;
|
||
}
|
||
if (typeof v !== 'string') return key;
|
||
return v.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ''));
|
||
}
|
||
```
|
||
|
||
### 6.3 為何不共用 Next.js Web UI 的 i18n 檔
|
||
|
||
Next.js 的 `frontend/src/lib/i18n/{zh-TW,en}.ts` 是 TypeScript 源碼,需要編譯,控制台 UI 不引 TS build chain;若直接複製一份 JS 也可以,但控制台需要的 string 集合(~30 個 key)和 Web UI(~400+ key)差異太大,獨立 JSON 更清晰。
|
||
|
||
---
|
||
|
||
## 7. 檔案系統變化(relative to `visiona-local/`)
|
||
|
||
**刪檔**:無(現有 `frontend/` 下的 3 個檔都是要改寫而非刪)
|
||
|
||
**改寫**:
|
||
- `frontend/index.html`(M7-B 的 splash → 控制台 layout,~80 行 HTML)
|
||
- `frontend/app.js`(78 行 splash → ~130 行 init + 事件處理)
|
||
- `frontend/style.css`(112 行 splash 樣式 → ~300 行控制台 + dark/light mode tokens)
|
||
|
||
**新增**:
|
||
- `frontend/components/status-card.js`(~80 行)
|
||
- `frontend/components/log-panel.js`(~180 行,含 virtual-scroll-lite + batch render)
|
||
- `frontend/components/action-bar.js`(~80 行)
|
||
- `frontend/components/preferences.js`(~60 行)
|
||
- `frontend/components/startup-panel.js`(~100 行,R5-E 階段化進度面板)
|
||
- `frontend/i18n/zh-TW.json`、`frontend/i18n/en-US.json`(~60 行 each;含 startup.stage.1-6 / startup.retrying / startup.error)
|
||
- `frontend/i18n/loader.js`(~35 行,含 Q5 navigator.language fallback 強化)
|
||
- `frontend/icons/*.svg` × 6
|
||
- `server_control.go`(~250 行)
|
||
- `log_buffer.go`(~120 行)
|
||
- `preferences.go`(~80 行,含 `DefaultPreferences()` 依 `runtime.GOOS` 分平台 default + atomic write-rename)
|
||
- `notify.go`(~100 行,R5-D1 三平台 OS 通知)
|
||
- `startup_pipeline.go`(~180 行,R5-E 6 階段 watcher + event emit)
|
||
|
||
**修改**:
|
||
- `app.go`:
|
||
- 新增 `ctrl *ServerController` / `logBuf *LogBuffer` / `prefs Preferences` / `pipeline *StartupPipeline` 欄位(**不再**有 `autoOpenedThisSession` — R5-D3 砍掉 per-session-once 概念)
|
||
- 砍 `GetBootstrapStatus()` / `setBootstrapStatus()` / `bootstrapStatus` 欄位(splash 專用,~15 行)
|
||
- 砍 `mockMode` 欄位 + `VISIONA_MOCK` 環境變數讀取(R5-5a,見 `v2/deletions.md`)
|
||
- 改 `watchServer()` 失敗後行為(不 os.Exit,進 Error state + 發 OS 通知,R5-D1)
|
||
- 新增 bindings(§4.2 的 13 個 method:Start/Stop/Restart Server、GetServerStatus、GetRecentLogs、ClearLogs、GetSystemInfo、OpenInBrowser、RevealLogsFolder、ExportLog、GetPreferences、SetPreferences、RestartStartupSequence)
|
||
- `startup()` 流程:seed → lock → 階段化 `ctrl.Start`(透過 StartupPipeline,R5-E)
|
||
|
||
---
|
||
|
||
## 8. 待確認 / 風險
|
||
|
||
1. **Wails v2 EventsEmit 的 payload 大小上限** — micro-batch window 10 ms × 100 行 × 2 KB = 200 KB。Wails v2 文件沒明確標 payload limit,實作完要壓測:用一個 bash 腳本噴 1000 行/秒的 stdout 看 events 有沒有丟 / 延遲。若有問題,改成「每 batch 最多 50 行,超過就拆兩個 event」。
|
||
2. **Dark mode 與 system 切換時的閃爍** — CSS var 切換應用 `transition: background-color 0.2s`,但 `prefers-color-scheme` media query 切換瞬間 Wails WebView 可能有瞬閃。實測,必要時改用 JS 主動套 `data-theme` 屬性。
|
||
3. **啟動進度面板 vs 主控台的轉場** — R5-E 定義「6 階段 completed 後淡出」,但若啟動很快(< 2 s)閃太快不好看;若啟動慢(20 s+)使用者看得很久。實務上建議淡出用 300 ms ease,進度面板本身做 min-display-time 1 s 避免閃爍。文案與 min-display-time 等交 Design Spec v2.1 敲定。
|
||
4. **R5-D1 OS 通知在使用者關閉系統通知時的 fallback** — 若使用者在 macOS 系統偏好「通知」關閉 visionA Local,`osascript display notification` 會靜默失敗。不額外處理(最佳努力送達的設計),控制台 Error banner 仍會顯示。
|
||
5. **N-R4 CI/E2E 測試分層** — PM 審閱的 Minor 1,交 Testing Agent 階段解決。狀態:blocked-on-testing-agent。
|
||
|
||
**已移至 `server-lifecycle.md` §11 的項目**(不再重複):
|
||
- Preferences JSON 的 atomic write-rename(PM §11-1 已定案)
|
||
- `navigator.language` 在 Wails v2 的邊緣情況(Q5 已在 §6.2 強化)
|