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

850 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.md`R5-E 6 階段啟動管線細節)
---
## 1. 目的與範圍
把現有的 `visiona-local/frontend/` splashM7-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` 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-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 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 | | | | | |
`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` eventsoft 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` 分平台 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 BurntToast` `msg *`)。非阻塞最佳努力送達詳見 `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 函式)
```go
// 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 型別定義
```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-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`
```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.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 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-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`
原本
```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 = {};
// 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.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 個 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 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-renamePM §11-1 已定案
- `navigator.language` Wails v2 的邊緣情況Q5 已在 §6.2 強化