visionA/local-tool/.autoflow/04-architecture/architect-analysis-round2-refactor.md
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

50 KiB
Raw Permalink Blame History

Architect 第一輪分析 — visionA-local 方向變更2026-04-14

作者Architect Agent 狀態Round-2 refactor 的第一輪筆記(非正式 Design Doc / TDD 目的:針對使用者提出的方向重大變更做技術可行性評估 + 方案選項 + 風險標記 前置閱讀:design-doc.md 索引 + architecture-overview.md + tray-and-lifecycle.md + dependency-bundling.md + risks-and-mitigations.md + progress.md + visiona-local/app.go + server/main.go


摘要3 行)

  1. 好消息:技術上非常可行,而且大部分搬遷成本已經被 M7-B 提前付掉。前端現在已經是純 HTTP從 Wails WebView 搬到 Browser tab 只差「不把同一個網址 redirect 進 Wails」這一步。
  2. 砍量顯著yt-dlp 整包可丟vendor 87MB + resolver 程式碼 + frontend URL tab安裝檔從 220MB 降到 ~135MBWails 內嵌的控制台 UI 改寫成 3 頁獨立 shelllog / server 控制 / 開瀏覽器)。
  3. 但 lifecycle 決策必須復議Q-A砍 tray與 Q7關閉視窗=結束 app都站不住腳了——新方向的核心是「Wails 視窗只是控制台server 是主角」,控制台關掉不能殺 server否則瀏覽器 tab 瞬間斷線。這點若不先跟使用者談清楚,後面做出來會有嚴重體驗落差。

A. 技術影響範圍

A1. 新架構圖ASCII

┌─────────────────────────────────────────────────────────────────────┐
│  visionA-local.app (Wails Control Console — 桌面殼)                 │
│                                                                     │
│   ┌─────────────────────────────────────────────────────────────┐   │
│   │  控制台 UIHTML/CSS/JSgo:embed 進 Wails binary          │   │
│   │  ┌──────────────┐  ┌───────────────┐  ┌─────────────────┐  │   │
│   │  │ Server 狀態  │  │ Log panel     │  │ 動作列          │  │   │
│   │  │ ● Running    │  │ [ring buffer] │  │ [Start] [Stop]  │  │   │
│   │  │ Port: 3721   │  │ scrollable    │  │ [Restart]       │  │   │
│   │  │ PID: 42131   │  │ filter/search │  │ [Open Browser]  │  │   │
│   │  │ Python: sys  │  │ clear / save  │  │ [Reveal Logs]   │  │   │
│   │  └──────────────┘  └───────────────┘  └─────────────────┘  │   │
│   └─────────────────────────────────────────────────────────────┘   │
│                     ↑ Wails runtime (IPC)                          │
│                     │ - Bindings: StartServer/StopServer/...       │
│                     │ - Events:   server:log, server:status,        │
│                     │             server:dead, server:recovered    │
│   ┌─────────────────┴───────────────────────────────────────────┐   │
│   │  App (Go, app.go)                                            │   │
│   │  - Lifecycle / single-instance / data-dir migration         │   │
│   │  - Server process manager (spawn/kill/watch)                │   │
│   │  - Log pump: stdout/stderr → ring buffer → EventsEmit       │   │
│   │  - Python runtime 雙策略、driver installer                   │   │
│   └──────────────────────┬──────────────────────────────────────┘   │
└───────────────────────────│──────────────────────────────────────────┘
                            │ exec.Command(...) pipe stdout/stderr
                            ▼
┌─────────────────────────────────────────────────────────────────────┐
│  visiona-local-server  (Gin on 127.0.0.1:random_port)               │
│                                                                     │
│   ┌──────────────────────────────────────────────────────────────┐  │
│   │  REST API (/api/*)  + WebSocket (/ws/*)                       │  │
│   │   - /api/devices/*   /api/models/*   /api/inference/*         │  │
│   │   - /ws/devices/events  /ws/inference/frames  /ws/server-logs │  │
│   │  Next.js UI (go:embed)  —— 使用者業務介面                     │  │
│   │   - Dashboard / Devices / Models / Workspace / Settings      │  │
│   └──────────────────────────────────────────────────────────────┘  │
│                         │            ▲                              │
│                         │ spawn      │ HTTP/WS                      │
│                         ▼            │                              │
│   ┌──────────────────────────────┐   │                              │
│   │ python3 kneron_bridge.py     │   │                              │
│   │  (KneronPLUS SDK)            │   │                              │
│   └──────────────────────────────┘   │                              │
│                                      │                              │
│   ┌──────────────────────────────┐   │                              │
│   │ ffmpeg (on demand)           │   │                              │
│   │  (webcam / 上傳影片解碼)      │   │                              │
│   └──────────────────────────────┘   │                              │
└──────────────────────────────────────┼──────────────────────────────┘
                                       │
                                       │ HTTP/WS over loopback
                                       ▼
                  ┌────────────────────────────────────────┐
                  │  Browser tab (Chrome / Safari / Edge)  │
                  │  http://127.0.0.1:<random_port>/       │
                  │                                        │
                  │  Next.js SPA (fetch + WebSocket)       │
                  │  - 所有業務操作在這裡                   │
                  └────────────────────────────────────────┘

資料流關鍵差異vs. 現況)

項目 現況M7-B 新方向
Wails WebView 載入 splash → window.location.replace(http://127.0.0.1:port/) 跳 Next.js 主 UI splash 邏輯移除Wails 永遠停在「控制台 UI」不跳轉
使用者業務操作 Wails WebView 內(但已經是純 HTTP 另開 Browser tabWails 不碰業務 UI
Server stdout/stderr 寫進 logs/server.stdout.log + server.stderr.logfile only 同時管到 ring buffer → Wails events 給控制台 log panel
關閉 Wails 視窗 Q7=B關 = 殺 server + 殺 app 必須改:關 = 隱藏到 tray 或 minimize真正結束需透過「Quit」按鈕
yt-dlp pipeline vendor 87MB + ResolveWithYTDLP + /api/media/url + 前端 URL tab 整條砍
瀏覽器端情境 使用者若自己連 http://127.0.0.1:port/ 也可以用(未文件化) 一級公民Wails 的「Open in Browser」會主動導引

Lifecycle 狀態機

                ┌──────────────┐
                │ app launched │
                └──────┬───────┘
                       ▼
          ┌────────────────────────┐
          │ acquire single-instance│
          └──────┬─────────────────┘
                 ▼
        ┌──────────────────┐
        │ seed user-data   │
        └──────┬───────────┘
               ▼
      ┌─────────────────────┐
      │ ensure python       │
      └────┬────────────────┘
           ▼
   ┌──────────────────────┐       ┌───────────────┐
   │  STATE: Starting     │──OK──▶│ STATE: Running │◀──┐
   │  spawn server        │       └──┬─────────────┘   │
   │  wait /health        │          │                  │
   └──┬───────────────────┘          │ user: Stop       │
      │ error                        ▼                  │
      ▼                       ┌───────────────┐          │
   ┌──────────────────┐       │ STATE: Stopping│         │
   │ STATE: Error     │       │ SIGTERM+wait   │         │
   │ show error in    │       └──┬─────────────┘         │
   │ console, wait    │          ▼                        │
   │ for user action  │       ┌───────────────┐           │
   └──┬───────────────┘       │ STATE: Stopped │          │
      │                       └──┬─────────────┘          │
      │ user: Retry              │ user: Start            │
      ▼                          └───────────────────────▶│
   (back to Starting)                                     │
                                                          │
   user: Restart ─────────▶ Stop then Start ──────────────┘

   watchServer() 失敗 3 次 ──▶ STATE: Error非 reportFatal + os.Exit

跟現在最大的不同:watchServer 觸發 3 次失敗時,不能再直接 reportFatal + os.Exit(1)——那只是 server 死掉,使用者的控制台還有用,應該切到 Error state 等使用者手動 Restart。


A2. Wails 視窗載入什麼(方案比較)

方案 描述 成本 風險 推薦?
A1繼續 go:embed 極簡 HTML/JS/CSS(沿用現有 splash 路徑) 直接改寫 visiona-local/frontend/ 的 splash 成真正的控制台 UI保留 ES module + Wails bindings + events 最低(~200 行 JS、一張 index.html、一份 style.css 控制台要長成什麼樣要自己刻,少 shadcn 之類 lib。但這是 3 個 widget 的頁面,手刻 1-2 天完成 推薦
A2Go server 內的 /control 路由(和主 UI 同 server瀏覽器看不到 把控制台 UI 也塞進 server/web/out/,用 Gin 中介層鎖「只有 X-Wails-Control: 1 header 才能看」 高(要兩個 SPA 共用 Next.js project + middleware 阻擋瀏覽器存取) 根本性問題server 還沒起來時Wails 視窗要載什麼Chicken-and-egg。而且 server 死掉時控制台也跟著死,等於沒控制台可用 否決
A3獨立的 Vite/Next.js mini project(專為 Wails 控制台) 新開一個 visiona-local/console-ui/ 有自己的 build pipeline 中(新增 node build 依賴、Tailwind/shadcn 可復用、更好看) 對一個只有 3 個 widget 的頁面工具鏈太重。build 時間變長(~30s ⚠️ 不推薦(除非要做很多控制台 UI
A4純原生 Wails UI不 embed 任何網頁) 用 Wails 的 native 模式(但 Wails v2 不支援真原生) Wails v2 一律要有 embedded WebView這方案根本不存在 技術上不可行

推薦 A1。理由:

  • 控制台複雜度低status text + log scrollview + 4 個按鈕),手刻 vanilla 最直接
  • 已經有現成 splash 範本app.js + style.css + index.html改寫比從零快
  • 不引入 Next.js / Vite build 依賴Wails binary 體積不受影響
  • Log panel 不需要 shadcn用原生 <pre> + CSS 就夠
  • 未來若真的要加很多功能再重構成 A3

必要改動

visiona-local/frontend/
├── index.html        ← 改寫成控制台 layout
├── app.js            ← 改寫:訂閱 server:log/status events呼叫 Start/Stop/Restart bindings
├── style.css         ← 新增 log panel 樣式
├── components/       ← 視需要拆出來log-panel.js / action-bar.js
└── wailsjs/          ← 自動生成,不動

A3. Log 顯示機制

核心技術決策:伺服器 stdout/stderr 同時寫檔 + 寫 ring buffer + 推送 Wails event。

現況app.go 481-516 行)

目前 startServer() 是把 server 的 stdout/stderr 直接 assign 給 os.File

cmd.Stdout = stdoutLog  // os.File, 寫到 logs/server.stdout.log
cmd.Stderr = stderrLog

限制

  • 沒有任何地方能「讀」這些 log 回來
  • Wails 前端拿不到 server log
  • 使用者看 log 必須自己去 ~/Library/Application Support/visiona-local/logs/

新方案:多重 writer + 環形緩衝

type LogBuffer struct {
    mu      sync.Mutex
    lines   []LogLine         // ring buffercap=2000
    head    int
    size    int
    subs    map[uint64]chan LogLine
    nextSub uint64
}

type LogLine struct {
    Ts     time.Time `json:"ts"`
    Stream string    `json:"stream"`  // "stdout" / "stderr"
    Line   string    `json:"line"`
    Level  string    `json:"level,omitempty"`  // 解析過的 levelinfo/warn/error
}

// 每條新 log
//   1. 寫入 logs/server.{stdout,stderr}.log持久化
//   2. append 進 ring buffer供初次載入 + "過去 2000 行"
//   3. broadcast 給訂閱者Wails events
func (b *LogBuffer) Write(stream, line string) { ... }

startServer() 改成:

stdoutPipe, _ := cmd.StdoutPipe()
stderrPipe, _ := cmd.StderrPipe()
go a.logPump(stdoutPipe, "stdout", stdoutLog)
go a.logPump(stderrPipe, "stderr", stderrLog)

logPump 同時寫檔 + 寫 ring buffer + EventsEmit

func (a *App) logPump(pipe io.ReadCloser, stream string, fileWriter io.Writer) {
    scanner := bufio.NewScanner(pipe)
    scanner.Buffer(make([]byte, 64*1024), 1024*1024)  // 避免長 log line 爆
    for scanner.Scan() {
        line := scanner.Text()
        fmt.Fprintln(fileWriter, line)
        a.logBuf.Append(stream, line)
        wailsRuntime.EventsEmit(a.ctx, "server:log", map[string]any{
            "stream": stream,
            "line":   line,
            "ts":     time.Now().UnixMilli(),
        })
    }
}

前端訂閱app.js in Wails frontend

import { EventsOn } from '../wailsjs/runtime/runtime.js';
import { GetRecentLogs } from './wailsjs/go/main/App.js';

// 1. 初次載入:抓 ring buffer 裡已累積的行
const initial = await GetRecentLogs(2000);
logPanel.append(initial);

// 2. 持續訂閱
EventsOn('server:log', (line) => logPanel.append([line]));

關鍵參數與風險

項目 理由
Ring buffer 大小 2000 行 ~200KB 記憶體,足以涵蓋「開啟控制台時看到最近 10 分鐘」
單行最大長度 1MB 防止超長 log堆疊追蹤爆 scanner
Events 批次 每行 emit 一次 初版先簡單,若壓力大再改 micro-batch10ms window
持久化 仍寫 logs/server.{stdout,stderr}.log 不改現況;控制台 log panel 只是即時視圖
Log 檔 rotation 暫不做M1 就沒做) 已在 tray-and-lifecycle.md 風險清單。建議跟這次重構一起做:按大小 rotate10MB × 5 份)

高頻 log 壓力評估

  • 推論一次 frame 若列一行 log → 30 fps × 即時 stream = 30 events/sec
  • Wails IPC 在 macOS 上實測可輕鬆吞 >1000 events/sec
  • 不是瓶頸。但推論的 frame 狀態不應該用 log 傳——server 應該用 WebSocket /ws/inference/frames
  • 控制台只看 app 級別 log不看 per-frame log

⚠️ 待確認:目前 server/pkg/logger 是否有把 per-frame 塞進 stdout需要翻一下。若有必須先降級成 debug-only。


A4. Server 生命週期控制

目前狀態(app.go

  • startServer()spawn → health check → 寫 ipc-port → 啟動 watchServer
  • stopServer()cancel watch goroutine → proc.stop()SIGTERM → 5s → SIGKILL
  • 沒有 Restart。要重啟只能重開整個 Wails app
  • watchServer() 3 次失敗 → reportFatalos.Exit(1) → 連 Wails app 一起死

新方案:補齊 Start/Stop/Restart彼此互斥

新增 bindings

// Wails bindings
func (a *App) StartServer() error           // 若 Runningno-op
func (a *App) StopServer() error            // 若 Stoppedno-op
func (a *App) RestartServer() error         // stop then start
func (a *App) GetServerState() ServerState  // Running | Stopping | Starting | Stopped | Error

實作約束:

  1. 互斥:用 sync.Mutex 保護 state 欄位,任何時候只能有一個操作進行中
  2. Restart 期間 port 可重用pickPort(preferredPort=<old_port>),但若被別的程序搶了就用新 portipc-port 檔必須更新
  3. Python runtime 不重跑a.pythonBin / a.pythonModeR 已 resolvedStop 後留著Start 時直接重用(省 5-10s
  4. Log buffer 不清空Stop 時只 reset server:status eventlog panel 保留既往 log加個分隔線

Watch 行為變更(重大修改

現在:

if failures >= 3 {
    a.reportFatal("server died", ...)  // os.Exit(1) — 連 Wails app 一起死
    return
}

新版:

if failures >= 3 {
    a.setServerState(ServerStateError, "health check failed 3 times")
    wailsRuntime.EventsEmit(a.ctx, "server:dead", ...)
    // 不 os.Exit讓控制台 UI 顯示錯誤狀態,使用者可手動 Restart / 查看 log
    return
}

這個是 Q7 決策復議 的關鍵理由之一(見 C2

  • 現況的邏輯假設「沒有 server 就沒有 app」但新方向的 Wails app 就是要提供「server 死掉後給人看 log + 決定下一步」的地方
  • 保留 reportFatal 只留給「完全無法啟動」的致命錯誤(例如 data-dir 建不起來、lock 取不到)

Restart 期間的瀏覽器 tab

這是體驗層的核心問題。Restart 過程(最快 ~3s含 SIGTERM grace + 新 server spawn + health check

t=0.0s  使用者按 Restart
t=0.0s  SIGTERM → 舊 server
t=0.5s  舊 server 優雅結束
t=0.5s  spawn 新 server可能是同 port
t=2.0s  health check 通過
t=2.0s  寫新 ipc-port若 port 變更)

瀏覽器 tab 在 t=0~2s 期間的請求會 connection refused / ECONNREFUSED。方案

方案 描述 推薦?
R1前端 retry + toast Next.js 全域 fetch interceptor 偵測 ECONNREFUSED重試 5 次 × 500ms同時顯示 toast「伺服器重啟中」 推薦,且這不是大工
R2Wails 代理 proxy 讓 Wails app 自己起一個固定 port 做 reverse proxybackend 切換時透明處理 成本太高,且 Wails 關了瀏覽器更慘
R3不處理 使用者看到 ERR_CONNECTION_REFUSED 錯誤頁,自己按 reload 體驗爛

R1 可搭配「port 若變更時 Wails 控制台 emit event瀏覽器透過 WS 接收後 location.reload()」,但要注意瀏覽器 tab 不可能透過 Wails bindings 收事件——它就是個 HTTP client。解法server 啟動後暴露 /api/system/boot-id endpoint前端 poll 比對,不一致就 reload。


A5. Open in Browser 機制

現況(platform_*.go

openBrowser(url string) 已經實作,app.go:329OpenBrowser binding。實際定義在 platform_darwin.go / platform_linux.go / platform_windows.go

  • macOSexec.Command("open", url).Start()
  • Linuxexec.Command("xdg-open", url).Start()
  • Windowsrundll32 url.dll,FileProtocolHandler urlcmd /c start url

可直接沿用,不用重寫

要加的東西

  1. URL 組合http://127.0.0.1:<actual_port>/,從 a.server.port
  2. 控制台按鈕:呼叫 OpenBrowser(url) binding
  3. 自動開啟(選配):使用者決定要不要在 server 就緒後自動開瀏覽器
    • 預設「就緒後自動開一次」(符合 Ollama / Docker Desktop 行為)
    • Settings 可關閉
    • 避免每次 Restart 都多開一個 tab要記錄「這個 session 已自動開過」)
  4. 多次點擊 :瀏覽器會自動 reuse 同一個 tab若 URL 相同),我們不用處理

Wails runtime 有沒有 BrowserOpenURL

Wails v2 runtime 有 BrowserOpenURL(ctx, url) 可用,但它底層也是 shell out。我們現有的 openBrowser 已經封裝得跟 Wails 一樣,不必換。


A6. 依賴瘦身

可以砍的

項目 大小 來源 原因
vendor/yt-dlp/ 87MB M6-2 新方向完全不用 URL 推論
server/internal/camera/video_source.goResolveWithYTDLP <1KB yt-dlp 呼叫點
server/internal/api/handlers/camera_handler.goytdlpHosts / classifyVideoURL / StartFromURL ~100 行 URL classify + resolve
server/internal/api/router.goapi.POST("/media/url", ...) 1 行 endpoint
server/internal/deps/checker.gocheck("yt-dlp", ...) ~3 行 deps 檢查
server/main.go 的 yt-dlp PATH 注入註解 註解
frontend/src/components/camera/source-selector.tsxvideoMode === 'url' 分支 ~50 行 UI
frontend/src/stores/camera-store.tsstartFromUrl ~25 行 store action
frontend/src/lib/i18n/{zh-TW,en,types}.tscamera.pasteUrl / urlPlaceholder / urlHelpText 等 key ~10 keys × 2 lang i18n
payload-*.sh 中的 vendor-ytdlp 相關 stage 步驟 M6-3 打包
Makefilevendor-ytdlp target M6-2 build
server/internal/depsVISIONA_BUNDLE_BIN_DIR 對 yt-dlp 的偵測 ~10 行 M6-4 env 注入

總砍量

  • Binary size~87MBdmg 從 220MB 降到 ~135MB
  • Source~200 行 Go + ~100 行 TS/TSX + 10 幾個 i18n keys

必須保留的

ffmpeg 必須留。新需求:「上傳影片 avi/mpeg/mp4」。這些格式的解碼靠 ffmpegKneron pipeline 吃的是 ffmpeg 產出的 MJPEG frame stream。現狀 UploadVideo handler 已支援 .mp4 / .avi / .mov,再加個 .mpeg / .mpg 即可。

但 ffmpeg GPL release blocker 還在。狀況比之前更尷尬:

  • 之前 URL 推論是賣點之一GPL 對使用情境比較 justify
  • 現在 ffmpeg 的唯一用途是「解碼一個使用者自己選的本地檔」,使用情境非常窄
  • 機會使用情境變窄後LGPL build 的 feature set 更容易覆蓋(不需要 x264 encoder只需要 decoder
  • 建議:這次 refactor 是重新談 ffmpeg GPL 的好時機。可以考慮切到 LGPL build 或甚至改用 Go 純粹解碼(例如 github.com/zergon321/reisen 包 libav-via-Go但還是需要 ffmpeg 的 lib...

Camerawebcampipeline:也需要 ffmpegmacOS 用 AVFoundation device、Windows 用 dshow、Linux 用 v4l2。這條保留。

決策點

  • yt-dlp — 砍,無爭議
  • ffmpeg — 保留,但 LGPL 切換評估

A7. 程式碼變更清單(砍哪些 / 改哪些)

砍(刪整段 / 整檔)

檔案 改動 行數
vendor/yt-dlp/ 整個目錄 87MB
Makefile vendor-ytdlp target + payload-* 的 yt-dlp stage 段 ~20 行
server/internal/camera/video_source.go ResolveWithYTDLP + friendlyYTDLPError L91-L160 ~70 行
server/internal/api/handlers/camera_handler.go ytdlpHosts map + urlKind enum + classifyVideoURL + StartFromURL + stopActivePipeline 裡的 URL cleanup若有 L341-L500 ~160 行
server/internal/api/router.go api.POST("/media/url", ...) L83 1 行
server/internal/deps/checker.go 刪 yt-dlp check entry L30-L32 3 行
server/main.go 刪 yt-dlp PATH 相關註解(若 binDir 已放其他東西則 binDir 注入保留) ~5 行
frontend/src/components/camera/source-selector.tsx videoMode'url' 分支、pasteUrl 按鈕、URL input、helper text L51, L190-L260 ~70 行
frontend/src/stores/camera-store.ts startFromUrl action + 相關 state L32, L167-L195 ~30 行
frontend/src/lib/i18n/zh-TW.ts / en.ts / types.ts camera.pasteUrl / urlPlaceholder / urlHelpText / 相關 errors ~12 行 × 3

砍(新方向要求)

檔案 改動
frontend/src/stores/model-store.ts(視 UX 決策) 確認「上傳模型」對應的只是使用者自訂 .nef 路徑。使用者說「模型除了預設的幾種只能用上傳的」= 保留現況(預置 + 上傳),不砍

檔案 改動 預估行數
visiona-local/frontend/index.html 改寫成控制台 layoutstatus / log panel / actions ~80 行
visiona-local/frontend/app.js 改寫:訂閱 server:log events、呼叫 Start/Stop/Restart、OpenBrowser移除 自動跳轉邏輯 ~150 行
visiona-local/frontend/style.css log panel + buttons + status badge 樣式 ~150 行
visiona-local/app.go 新增 LogBuffer + logPump + StartServer/StopServer/RestartServer bindings + GetRecentLogs + ServerStatewatchServer 不再 os.Exit ~300 行新增 / 20 行刪改
visiona-local/main.go 加 EventsEmit 支援(已有);若需要 tray 則補 options.App SystemTray ~10 行
server/internal/api/handlers/camera_handler.go UploadVideo 擴充支援的副檔名:.mp4, .avi, .mov, .mpeg, .mpg(「瀏覽器能吃的」集合與 Kneron pipeline 吃得到的集合的交集) L251 1 行改
frontend/src/components/camera/source-selector.tsx accept=".mp4,.avi,.mov,.mpeg,.mpg" 1 行

新寫

檔案 用途
visiona-local/log_buffer.go Ring buffer + pubsub
visiona-local/server_control.go StartServer / StopServer / RestartServer + state machine
visiona-local/frontend/components/log-panel.js Log 顯示元件(可選,可與 app.js 合併)

A8. 控制台 UI 技術選型(詳細)

承 A2 推薦 A1vanilla JS/HTML/CSS這裡補細節。

UI 結構建議

┌─────────────────────────────────────────────────────────────────┐
│  visionA-local                                                  │
├─────────────────────────────────────────────────────────────────┤
│  Server Status                                                  │
│  ● Running    •   http://127.0.0.1:3721     •   PID 42131       │
│  Python: system (/usr/bin/python3.12)                           │
├─────────────────────────────────────────────────────────────────┤
│  [▶ Start]   [■ Stop]   [⟲ Restart]   [🌐 Open in Browser]     │
│  [📁 Reveal Logs]                                              │
├─────────────────────────────────────────────────────────────────┤
│  Server Log                            [Pause] [Clear] [Save] │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ 14:23:01 [INFO]  visiona-local-server starting on :3721     ││
│ │ 14:23:01 [INFO]  python bridge path: /usr/bin/python3.12    ││
│ │ 14:23:02 [INFO]  loaded 8 preset models                     ││
│ │ 14:23:02 [INFO]  HTTP server listening on 127.0.0.1:3721    ││
│ │ 14:23:15 [INFO]  GET /api/system/info 200 2.1ms              ││
│ │ ...                                                          ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘

元件清單vanilla

  • Status card一個 <div> + CSSdata 從 GetServerStatus() 輪詢(或更好:EventsOn('server:status', ...)
  • Action bar5 顆 button對應 5 個 bindings
  • Log panel<pre class="log"> with auto-scroll + virtual scrolling不需要第三方~100 行 JS 手刻)
  • Pause button停止 auto-scroll方便使用者檢查過去的 log
  • Filtertext inputclient-side substring filterM2 再加)
  • Save把 ring buffer dump 成 txt

Dark mode跟隨系統M1 已有 prefers-color-scheme: dark)。

i18n:中英雙語,走 navigator.language 判斷 + 硬 code 兩份文字(不用引 i18next。或直接用現有 server/web/out 的 i18n 資料檔(不推薦,兩邊耦合)。

預估工作量1.5-2 人天。


A9. 資料安全 / 綁定 interface

現況

server/main.go 的預設 --host 是什麼?從 config.go 看應是可 flag 控制。visiona-local/app.go:468 明寫:

args := []string{
    "--host", "127.0.0.1",
    ...
}

目前是強制 127.0.0.1,瀏覽器只能從同一台機器連。

新方向的安全情境

新方向把「可在瀏覽器使用」變成 first-class會不會有人希望能從區網另一台電腦連例如PM 在 Mac 上跑 visionA-local工程師想從筆電瀏覽器連進來看推論結果

方案比較

方案 描述 風險 推薦
N1維持 127.0.0.1,不提供選項 只能本機連 無新風險 預設
N2Settings 新增「允許區網存取」toggle 0.0.0.0 + firewall 提示 必須加 auth token否則區網任何人都能控制裝置 / 上傳模型macOS/Windows 會跳 firewall 警告 ⚠️ 有需求再做
N3LAN mode with bearer token N2 + 自動產生 token + Open in Browser 時帶 token query param + Cookie 成本:全套 auth middleware + session破壞「純 fetch 沒 auth」的簡潔現況 M1-M7 沒這需求

建議 N1。若使用者有需求M8+ 再加 N2/N3。

127.0.0.1 的隱性安全假設

  • 本機其他 process 可以連上 visiona-local-server例如惡意的 Electron app。這是現況就有的風險。
  • 沒有 CORS 限制(預設 gin 允許 localhost 跨來源)。在瀏覽器情境下 要確認:使用者如果在 Chrome 開了其他網站,那些網站能不能 fetch('http://127.0.0.1:3721/api/models/upload')
    • 答案:預設 可以送請求,但 CORS 預檢會擋(若 server 沒回 Access-Control-Allow-Origin),讀不到回應
    • 但 POST 是會被送出去的CSRF risk
    • ⚠️ 建議:這次 refactor 時把 CORS 政策明確定義:只允許 Origin: http://127.0.0.1:*Origin: http://localhost:*。不允許其他 origin。
    • 這個議題在「Wails WebView 模式」下沒什麼威脅(因為 WebView 的 origin 是 wails://,沒人能偽造),但移到瀏覽器後要當真

B. 方案選項彙總表

議題 選項 推薦 工時 備註
Wails 視窗載入 A1 vanilla / A2 server route / A3 mini Vite / A4 native A1 1.5-2 天
Log 顯示 stdout→file only / + ring buffer + events / + WebSocket 分支 ring buffer + events 0.5 天
Server 控制 現有only start/ 加 Stop/Restart 加 Stop/Restart + state machine 1 天
Open in Browser 沿用 openBrowser / Wails BrowserOpenURL 沿用 <1h
yt-dlp 保留 / 砍 0.5 天
ffmpeg 保留 GPL / 切 LGPL / 純 Go 解碼 保留,但安排 LGPL 評估 跟使用者討論
影片格式 .mp4,.avi,.mov / + .mpeg,.mpg / 所有 ffmpeg 能吃 .mp4,.avi,.mov,.mpeg,.mpg <1h 對齊「瀏覽器能吃」
綁定 interface 127.0.0.1 / 0.0.0.0 toggle / LAN + token 127.0.0.1(維持)
CORS 政策 不設(寬鬆)/ 只允許 localhost 只允許 127.0.0.1/localhost 0.5 天 瀏覽器模式下必做
Lifecycle Q7 復議 維持關閉=結束 / 改為關閉=隱藏 / 加 tray 復議,見 C2 需要使用者決策
watchServer 失敗行為 os.Exit / 進 Error state 等使用者 Error state 0.5 天

C. 和既有決策的衝突

C1. 砍 tray 決策Q-A=A3是否復議

原本的理由(第三輪決策)

Q-A Tray 角色衝突Q7 選關閉=結束後 tray 價值變低)→ A3 砍掉 tray省跨平台圖資產與 Wails tray 踩坑。

原本邏輯是「Q7 選關閉=結束程式,所以 tray 的核心價值(關視窗後還能找回 app消失乾脆砍」。

新方向下的衝突

新方向的本質是:server 是主角Wails app 只是控制台。這產生幾個矛盾:

  1. 使用者主要時間在瀏覽器。若 Wails 控制台關了,使用者還想停 server 或看 log就沒有入口
  2. 使用者不關 Wails 控制台(因為不想斷 server桌面就一直有個不太用的視窗。體驗像強迫你開著 Docker Desktop 的主視窗
  3. 瀏覽器 tab 關了之後,使用者怎麼再開?如果 Wails 控制台也關了,就得重開 app

Tray 在新方向下的價值反而提高了

方案比較

方案 描述 優點 缺點 推薦
T1維持砍 trayWails 視窗必須開著 現況 不用跨平台 tray 資產、不用處理 Wails systray 踩坑 使用者被迫開著一個沒人看的視窗;體驗倒退
T2復活 traymacOS menu bar / Win systray / Linux indicator tray icon 有 Show / Hide Console / Open in Browser / Start / Stop / Quit 選單;關 Wails 視窗 = minimize to tray 主場:使用者可關視窗但保留 server菜單直接提供核心動作 Wails v2 的 tray 支援有限macOS OK、Windows OK、Linux 看 DE要準備 icon 資產(~10 KB可能會有個別 Linux DE 不支援KDE/GNOME 沒 StatusNotifierItem 的)
T3不做 tray改「關視窗 = 隱藏視窗」app 仍在前景) Wails 不支援 hide to dock onlymacOS 本來就有 runtime.WindowHide 但 Dock 圖示仍在 不用 tray 資產 Linux 下完全找不到那個視窗(沒有 DockWindows 下工作列會殘留空白按鈕 ⚠️ 只做 macOS 可接受,跨平台不佳
T4Wails app 當 daemonOpen in Browser 是唯一入口 無視窗,只有 tray 最極致的 server-as-main Wails v2 不支援「完全無主視窗」模式;實作困難

推薦 T2復活 tray,但要給使用者決策:

  • 使用者是否接受跨平台 tray 資產icon × 3 平台 + light/dark 版)
  • 是否接受 Linux 下 tray 可能在某些 DE 不 work 的風險fallback視窗仍可關/最小化)

tray 菜單建議

visionA-local
━━━━━━━━━━━━━━━━━
● Server: Running (3721)
─────────
Show Console           ⌘0
Open in Browser        ⌘B
─────────
▶ Start Server
■ Stop Server
⟲ Restart Server
─────────
Reveal Logs
About
Quit                   ⌘Q

估工時

  • Wails tray 綁定(參考 wails tray examples~0.5 天
  • Icon 資產(原 edge-ai-platform 的 tray-*.png 可復用或新做):~0.5 天
  • 菜單行為 + 跨平台測試:~0.5 天
  • 合計 1.5 天

C2. Q7 關閉視窗=結束 app 決策是否復議

原本的理由(第二輪決策)

Q7 視窗關閉行為 → B 傳統式(關閉 = 結束程式)

原本是因為 tray 砍了Q-A又要簡化體驗所以「關視窗 = 結束 app」最直觀。

新方向下的衝突

非常嚴重。若維持 Q7=B

使用者打開 Wails 控制台 → 按 Open in Browser → 瀏覽器 tab 跑得好好
使用者覺得控制台占地方 → 關掉 Wails 視窗 → Wails shutdown → server 被 SIGTERM → 瀏覽器 tab 所有請求 ECONNREFUSED → 使用者傻眼

這不只是體驗問題,是功能失效。

方案

必須改。三個選項:

方案 關閉視窗行為 真正結束 app 的方式 推薦
Q7-B1關視窗 = minimize to tray(配合 T2 hide windowtray 還在 tray > Quit / 視窗內 Quit 按鈕 / ⌘Q 配套 T2 最順
Q7-B2關視窗 = hide無 tray(配合 T3 hide windowDock/taskbar 圖示留著 macOS右鍵 Dock > Quit其他重開後 Quit ⚠️ Linux 困難
Q7-B3關視窗 = confirm dialog 問「也要結束 server 嗎?」 使用者選擇 太囉嗦

推薦 Q7-B1(綁 T2 復活 tray

額外細節

  • macOS 慣例本來就是「紅色關閉鈕 = hide」+「⌘Q = quit」。Q7 原本選 B 是反慣例。現在回歸 macOS 慣例反而更好
  • Windows 慣例× = quit。如果這邊改成 hide要明確顯示 tray tooltip「visionA-local 仍在執行」,避免使用者以為沒關
  • Linux:看 DE通常 × = quit但實務上 GNOME 有些 app 有 tray fallback

D. 遷移策略與沿用率

沿用率評估

模組 現況 新方向需要 沿用率 備註
server/ 整個 Go 後端 M1-M7 完成 幾乎全部保留 ~95% 砍 yt-dlp 相關 + 加 UploadVideo 格式CORS 政策調整;預置模型系統不動
server/web/out/ Next.js embedded SPA 完整業務 UI 保留,幾乎不變 ~90% 砍 URL tabupload 支援 extension 擴充;可能加 server restart 時的 reconnect 邏輯
visiona-local/frontend/ splash 78 行 splash 重寫成控制台80+150+150=~400 行) 結構可沿用,程式砍掉寫新的 保留 ES module 載入、wailsjs 串接模式
visiona-local/app.go startup/shutdown/lifecycle/IPC 1584 行 新增 300 行 server control + log buffer改 ~30 行 watchServer 行為,其餘不動 ~85% 核心 startup 順序、Python 雙策略、single-instance、driver install、data migration 全保留
visiona-local/payload/ 完整 砍 yt-dlp stage其餘不變 ~90%
安裝器Inno Setup / dmgbuild / AppImage M4/M5/M7-A 不變 100%
Build pipeline / Makefile 完整 砍 vendor-ytdlp target其餘不變 ~95%

總體:這次 refactor 不是丟掉重做,是擴充 + 砍功能。M1-M7 的打包功夫 100% 保留價值,唯一被動搖的是:

  1. Wails 視窗長什麼樣splash → 控制台)
  2. server 生死邏輯connected to app lifetime → independent lifecycle
  3. 關視窗行為quit → hide

工時預估

任務 工時 前置條件
A. 砍 yt-dlp 全套vendor + server + frontend + i18n + Makefile + payload 0.5-1 天 使用者確認砍
B. log buffer + log pump + GetRecentLogs binding 0.5 天
C. StartServer/StopServer/RestartServer bindings + state machine 1 天 B 完成
D. Wails 控制台 UI 改寫vanilla 1.5-2 天 B, C 完成
E. watchServer 改為 Error state不 os.Exit 0.5 天 C 完成
F. Tray復活 Q-A 1.5 天 使用者確認復活 tray
G. Q7 復議為 hide-to-tray 0.5 天 使用者確認 + F 完成
H. CORS 限制為 localhost 0.5 天
I. 瀏覽器 tab restart 重連邏輯(前端 retry + boot-id 比對) 1 天 C 完成
J. UploadVideo 擴充副檔名 0.5h
K. ffmpeg LGPL 切換評估與可能的重 build pipeline ? 需另估
L. 端到端重 build + smoke testmacOS + Windows 1 天 全部完成
M. PRD / Design spec / TDD 補文件M 級 refactor 1 天 使用者確認範圍

總計~10 人天K 不估)。比「從零做 M1」便宜得多。

風險的遷移成本

風險 影響
Wails v2 tray 在 Linux 某些 DE 壞掉 T2 可能在 Ubuntu+GNOME 沒 icon需要 fallback 邏輯(指向「請使用主視窗」)
Wails events 在 Windows 下壓力測試未知 若 server 產生大量 logWails IPC 可能掉 event。需要 log pump 加 rate limiting
瀏覽器 tab 的 security context 問題 現有前端程式碼假設 origin 固定,移到瀏覽器後要確認 localStorage / sessionStorage 行為
ffmpeg LGPL 重新評估需時間 若使用者要趁這次切build pipeline 要重構,會延長 2-3 天

E. 待使用者決策的問題(技術選型類)

# 問題 選項 Architect 推薦
E-1 Wails 控制台技術選型 A1 vanilla / A2 同 server 路由 / A3 Vite mini / A4 native A1 vanilla
E-2 是否復活 trayQ-A 復議) 維持砍T1/ 復活T2/ 只做 hideT3 T2 復活
E-3 視窗關閉行為Q7 復議) 維持關=quitB/ 改為 hide-to-trayB1/ 確認對話框B3 B1 hide-to-tray綁 E-2
E-4 yt-dlp 處理 砍 / 保留 (無爭議)
E-5 ffmpeg 授權處理 維持 GPL 繼續開發、發佈前決定 / 現在就切 LGPL / 改純 Go 解碼 維持 GPL但把 LGPL 評估列為 M8 必做(因為使用情境變窄,切起來成本變低)
E-6 支援的上傳影片副檔名 現有 mp4/avi/mov / + mpeg/mpg / 所有 ffmpeg 能吃的 mp4/avi/mov/mpeg/mpg(使用者明示「瀏覽器能吃的」)
E-7 綁定 interface 只 127.0.0.1 / 加區網 toggle 只 127.0.0.1M1-M7 延續)
E-8 CORS 政策 現況(可能寬鬆)/ 明確限制 127.0.0.1 + localhost 限制(瀏覽器模式下必做)
E-9 Restart 時瀏覽器 tab 重連 不處理 / 前端 retry + boot-id 比對 / Wails reverse proxy 前端 retry + boot-id
E-10 watchServer 失敗後行為 維持 os.Exit / 改為 Error state 改為 Error state(無爭議)
E-11 自動開瀏覽器 server 就緒後自動開一次 / 永不自動 / 使用者設定 預設自動開Settings 可關(對齊 Ollama/Docker Desktop
E-12 Log panel 大小 500/1000/2000/5000 行 2000 行~200KB平衡記憶體與可用性

F. 風險觀察

# 風險 等級 緩解
F-1 Wails tray 在 Linux GNOME 下可能完全看不到 iconStatusNotifierItem 協議在某些版本沒支援) 🔴 加 fallback視窗仍可 minimize 到 dock提供「一律顯示視窗」的 config
F-2 瀏覽器 tab 與 Wails 控制台同時存在,兩邊 state 會分歧(例如 Wails 控制台顯示「Running」瀏覽器 fetch 失敗) 🟠 控制台的 status 來自 Wails 本地狀態(準確),瀏覽器的失敗要優雅處理(全域 error boundary
F-3 使用者習慣以「× 關閉」結束程式,改成 hide 後回頭找 app 困難 🟠 第一次 hide 時彈 toast 告訴使用者「visionA-local 正在背景執行,可從 tray/menu bar 找到」
F-4 Wails EventsEmit 在極高頻率下(例如每秒 >100 events可能丟事件或延遲 🟡 log pump 加 rate limiting + batch 合併;推論 frame 不走 log
F-5 ffmpeg GPL 發佈前 review 仍未解決 🔴 新方向下使用情境變窄,反而是切 LGPL 的好時機;建議 M8 專門處理
F-6 瀏覽器的 CSRF / CORS 攻擊面(任何網站都能嘗試 fetch 127.0.0.1:3721 🟠 CORS 限制 + 必要時加 state-changing 操作的 CSRF tokenPOST /api/models/upload 等)
F-7 使用者同時開多個 visionA-local 視窗single-instance 被喚起目前「raise existing window」會失效因為 Wails 視窗可能被 hide 到 tray 🟡 /ipc/raise 除了 WindowShow 還要呼叫 WindowUnminimise + 從 tray 彈出
F-8 Restart 期間若使用者 close 視窗state machine 亂掉 🟡 state 轉換要用 mutex 保護;關視窗也透過 command queue
F-9 Wails app 關閉時若 server 還活著,下次啟動 port 可能佔用 🟡 已有 isOurStaleServer 偵測;新方向若 server 要獨立生命週期要重新評估「Wails 關閉是否真的能殺 server」或「要不要保留 daemon」
F-10 瀏覽器快取問題:使用者更新 visionA-local 後,瀏覽器 tab 仍用舊版前端 🟠 server 啟動時在每個 response 加 ETagCache-Control: no-store;或升級時改用新 boot-id前端偵測到強制 reload
F-11 macOS Gatekeeper原本 Wails app 開瀏覽器 = shell out open,正常。但若加 trayicon 檔可能觸發 code signing 問題 🟡 icon 打在 binary 內,不走 shell out
F-12 使用者可能期待 Wails app 關掉 = server 也停(避免 USB 裝置被占用) 🟠 Quit 動作tray > Quit 或 ⌘Q要明確停 serverhide 不停;需要清楚的 UX 差異

G. 給 PM 和 Design 的議題

Architect 不作決定,只標記需要 PM / Design 處理的事)

給 PM

  • G-P1任務等級 — 這次 refactor 涉及 UX 模式變更Wails 窗 = 控制台、server 獨立生命週期)+ 新 user story「從瀏覽器使用 visionA-local」+ 決策復議Q-A、Q7我的判斷是 L 級(新 user story + 原決策復議),需要 PM 更新 PRD 的「功能願景」「使用情境」「非功能需求」段落,並重新跑 RICE
  • G-P2定位重寫 — PRD 開頭的定位(「單機桌面應用,像 Docker Desktop」需要改為「本機 AI inference 服務,提供 server + 網頁介面,像 Ollamaollama serve + 網頁 / CLI client」。這不只是包裝是產品定位的實質變更
  • G-P3使用者旅程重寫 — 從「打開 app → 在視窗內操作」變成「打開 app → 控制台確認 server 在跑 → 在瀏覽器操作」
  • G-P4成功指標 — 現有 AC「首次啟動 < 5 秒」「關視窗 = 結束」)有一半要改
  • G-P5yt-dlp 砍功能的 user-facing 影響 — 有沒有 PM 認為「URL 推論」是需要保留的功能若無YouTube / Vimeo / RTSP 這些 URL 都不支援了,需不需要在 release note 明說
  • G-P6ffmpeg LGPL 評估要不要排 M8 milestone
  • G-P7「預設模型只能用預設的幾種其他只能上傳」 — 現況已如此(預置 + 使用者上傳),是確認還是有新要求?要不要 PM 確認
  • G-P8法律 — 新方向下使用情境更窄GPL 評估可能更好談TOS/Privacy 要看是否有新的資料流動(實際上沒有)

給 Design

  • G-D1Wails 控制台 UI 設計 — 雖然我推薦 vanilla JS 實作,但 layout、視覺語言、用字、深色淺色規範還是需要 Design 出稿
  • G-D2tray 菜單 i18n + icon 設計 — 若 E-2 確認復活tray icon 需要新做(至少 light/dark × 3 平台 × 2 狀態 = 12 張 icon
  • G-D3Wails 視窗關閉時的第一次提示 toast — 「visionA-local 正在背景執行,可從 menu bar 找到」要怎麼寫、什麼時機
  • G-D4Settings 新增「自動開瀏覽器」選項的文案與位置
  • G-D5Restart 期間瀏覽器 tab 的 skeleton state / loading overlay 設計
  • G-D6Workspace 的 URL tab 砍掉後的 UI 調整 — source-selector.tsx 目前「上傳檔案 / 貼 URL」雙 mode砍掉 URL 後要不要簡化成單 tab還是加入 camera 的 device 選擇?

H. 下一步建議

  1. 給使用者看 E 段的 12 個決策點 → 等使用者裁決
  2. 決策後更新 progress.md 的「未解決問題」 → 清掉已經解決的ffmpeg GPL 在新方向下的 posture、Wails 主視窗用途新增新出現的tray 復活、Q7 復議)
  3. 若使用者確認走這條路
    • PM更新 PRDL 級文件更新)
    • Design出控制台 + tray 設計稿
    • Architect把這份筆記升級為正式的 Design Doc 更新 + TDD 補丁
    • 一路走完三方交叉審閱後再進 M8 開發

簽名Architect Agent2026-04-14