jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 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>
2026-04-15 17:57:54 +08:00

38 KiB
Raw Permalink Blame History

v2/control-panel.md — Wails 控制台實作規格

所屬TDD v2 §2.1 版本v2.12026-04-14 吸收 PM 審閱 + R5-D + R5-E 決策依據R5-1Wails 視窗 = 控制台、R5-5Mock 切換不放控制台、R5-D1OS 崩潰通知並存、R5-D2Linux 預設 auto-open OFF、R5-D3每次 Start 成功都開瀏覽器、R5-E階段化啟動進度、三方共識 #7vanilla HTML/JS/CSS 對應 milestoneM8-4lifecycle + bindings + LogBuffer、M8-4b階段化啟動管線、M8-5vanilla UI 改寫) 關聯子檔:v2/startup-pipeline.mdR5-E 6 階段啟動管線細節)


1. 目的與範圍

把現有的 visiona-local/frontend/ splashM7-B 寫的 78 行 splash + redirect整組改寫成一個靜態的控制台 UI長駐在 Wails 視窗內,不再跳轉。控制台提供:

  1. Server 狀態卡片:即時 state、port、PID、Python runtime 資訊
  2. Log panelring buffer 2000 行、auto-scroll、pause、clear
  3. 動作列Start / Stop / Restart / Open in Browser / Reveal Logs Folder / Clear Logs
  4. Preferences 區塊AutoOpenBrowser toggleR5-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 variableslight/dark mode token 不需 Tailwinddark mode 靠 @media (prefers-color-scheme: dark) 換 CSS var
圖示 Inline SVGaction 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-TWC / POSIX / 空字串 → en-US;其他 → en-US。fetch 失敗 → hardcoded 英文 fallback Wails v2 在 Windows/Linux 下偶爾回 enC,需 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 toggleR5-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]│ ← titlebarWails frameless 或 systemv2 保持 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-D2macOS/Windows 預設為 ON     │ │
│  └────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘

Button disable 矩陣(依 ServerState

按鈕 Stopped Starting Running Stopping Error
Start
Stop
Restart
Open in Browser
Reveal Logs
Clear Logs

StartingStopping 期間顯示進度 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每個階段 statuspending / running / completed / failed,對應 / 🔄 / /
  • 底部總耗時 counter每秒更新一次
  • 當某階段收到 startup:stage-timeout eventsoft timeout 20 s在該階段行下方顯示 subtle 灰字「第 N 階段正在重試 …」
  • 當收到 startup:error event任一階段失敗 或 總時 60 s→ 切 Error state淡出進度面板顯示主控台 Error banner
  • 當收到 startup:ready event → 淡出 300 ms → 主控台 UI 顯現
  • i18n keystartup.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 切到 ErrorwatchServer 連續 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 BurntToastmsg *標題「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 新增 bindingswatchServer 改為 Error stateshutdown 不動;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 分平台 defaultR5-D2+ load/save JSONatomic write-rename+ 路徑 <dataDir>/preferences.json
visiona-local/notify.go 新增 sendCrashNotification(title, body string) 三平台實作macOS osascript / Linux notify-send / Windows powershell BurntToastmsg *)。非阻塞、最佳努力送達。詳見 server-lifecycle.md §10
visiona-local/startup_pipeline.go 新增 R5-E 6 階段啟動管線:StartupPipeline struct + watcher goroutine20 s soft / 60 s hard timeout+ event emit。詳見 v2/startup-pipeline.md

4.2 新增 BindingsWails 自動暴露為前端 JS 函式)

// server_control.go 中新增的 App methodWails 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.WriteFileatomic-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-D2Linux 桌面環境差異大,預設關)
    //   使用者可在 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 goroutinevisiona-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.Fileappend 模式)
//   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 的典型格式抽 levelbest-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 machinevisiona-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-D1server 啟動徹底失敗時,除了 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 時開多個 tabOS 瀏覽器的 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() // 沿用 v1SIGTERM → 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(由 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

{
  "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 = {};

// Q5navigator.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.htmlM7-B 的 splash → 控制台 layout~80 行 HTML
  • frontend/app.js78 行 splash → ~130 行 init + 事件處理)
  • frontend/style.css112 行 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.jsonfrontend/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-5av2/deletions.md
    • watchServer() 失敗後行為(不 os.Exit進 Error state + 發 OS 通知R5-D1
    • 新增 bindings§4.2 的 13 個 methodStart/Stop/Restart Server、GetServerStatus、GetRecentLogs、ClearLogs、GetSystemInfo、OpenInBrowser、RevealLogsFolder、ExportLog、GetPreferences、SetPreferences、RestartStartupSequence
    • startup() 流程seed → lock → 階段化 ctrl.Start(透過 StartupPipelineR5-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 Localosascript 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-renamePM §11-1 已定案)
  • navigator.language 在 Wails v2 的邊緣情況Q5 已在 §6.2 強化)