# 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
│  │ ...                                                        │ │
│  └────────────────────────────────────────────────────────────┘ │
│  [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)+ 路徑 `/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 在檔案管理器中開啟 /logs/ 目錄。
// macOS: `open ` / Windows: `explorer ` / Linux: `xdg-open `
func (a *App) RevealLogsFolder() error

// ExportLog 將 LogBuffer ring buffer 當前內容(最多 2000 行)寫入單一檔案並回傳路徑。
//   路徑:/exports/log-.txt  (timestamp = time.Now().Format("20060102-150405"))
//   內容格式:逐行 dump,每行格式 "[level] timestamp message"(level 為空時省略 bracket)
//   實作要點:
//     1. 確保 /exports/ 目錄存在(os.MkdirAll 0755)
//     2. LogBuffer.Snapshot() 取得當前 ring buffer 副本(不干擾後續 append)
//     3. os.WriteFile(atomic-ish;檔案小、不需 atomic rename)
//     4. 回傳絕對路徑(前端可顯示 toast「已匯出至 」並提供「在檔案管理器中顯示」按鈕)
//   用途: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 控制台偏好設定。對應檔案:/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..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`)

- 容器:`
`,最多保留 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 強化)