依 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>
38 KiB
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 視窗內,不再跳轉。控制台提供:
- Server 狀態卡片:即時 state、port、PID、Python runtime 資訊
- Log panel:ring buffer 2000 行、auto-scroll、pause、clear
- 動作列:Start / Stop / Restart / Open in Browser / Reveal Logs Folder / Clear Logs
- Preferences 區塊:
AutoOpenBrowsertoggle(R5-4 / R5-D2 / R5-D3) - 系統資訊: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-timeoutevent(soft timeout 20 s),在該階段行下方顯示 subtle 灰字「第 N 階段正在重試 …」 - 當收到
startup:errorevent(任一階段失敗 或 總時 60 s)→ 切 Error state,淡出進度面板顯示主控台 Error banner - 當收到
startup:readyevent → 淡出 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):
- 控制台內:Status card 的 badge 變紅、顯示
lastError訊息、log panel 頂端插入一條 error banner(可關閉),action bar 的 [Start] 重新 enable 讓使用者手動 Restart - 同時:透過
visiona-local/notify.go發一則 OS 原生通知(macOSosascript display notification/ Linuxnotify-send/ Windowspowershell BurntToast或msg *),標題「visionA Local — Server 崩潰」,副文「點回應用程式查看錯誤詳情」 - 非此即彼: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 函式)
// 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 型別定義
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)
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)
// 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)
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)
原本:
if failures >= 3 {
a.reportFatal("server died", ...)
return
}
改為:
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 重寫)
// 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(由 GoparseLogLevel塞到 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:
{
"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:
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× 6server_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. 待確認 / 風險
- 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」。
- Dark mode 與 system 切換時的閃爍 — CSS var 切換應用
transition: background-color 0.2s,但prefers-color-schememedia query 切換瞬間 Wails WebView 可能有瞬閃。實測,必要時改用 JS 主動套data-theme屬性。 - 啟動進度面板 vs 主控台的轉場 — R5-E 定義「6 階段 completed 後淡出」,但若啟動很快(< 2 s)閃太快不好看;若啟動慢(20 s+)使用者看得很久。實務上建議淡出用 300 ms ease,進度面板本身做 min-display-time 1 s 避免閃爍。文案與 min-display-time 等交 Design Spec v2.1 敲定。
- R5-D1 OS 通知在使用者關閉系統通知時的 fallback — 若使用者在 macOS 系統偏好「通知」關閉 visionA Local,
osascript display notification會靜默失敗。不額外處理(最佳努力送達的設計),控制台 Error banner 仍會顯示。 - 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 強化)