依 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>
799 lines
50 KiB
Markdown
799 lines
50 KiB
Markdown
# 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 降到 ~135MB;Wails 內嵌的控制台 UI 改寫成 3 頁獨立 shell(log / server 控制 / 開瀏覽器)。
|
||
3. **但 lifecycle 決策必須復議**:Q-A(砍 tray)與 Q7(關閉視窗=結束 app)都站不住腳了——新方向的核心是「Wails 視窗只是控制台,server 是主角」,控制台關掉不能殺 server,否則瀏覽器 tab 瞬間斷線。這點若不先跟使用者談清楚,後面做出來會有嚴重體驗落差。
|
||
|
||
---
|
||
|
||
## A. 技術影響範圍
|
||
|
||
### A1. 新架構圖(ASCII)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ visionA-local.app (Wails Control Console — 桌面殼) │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||
│ │ 控制台 UI(HTML/CSS/JS,go: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 tab,Wails 不碰業務 UI |
|
||
| Server stdout/stderr | 寫進 `logs/server.stdout.log` + `server.stderr.log`(file 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 天完成 | **✅ 推薦** |
|
||
| **A2:Go 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`:
|
||
|
||
```go
|
||
cmd.Stdout = stdoutLog // os.File, 寫到 logs/server.stdout.log
|
||
cmd.Stderr = stderrLog
|
||
```
|
||
|
||
**限制**:
|
||
- 沒有任何地方能「讀」這些 log 回來
|
||
- Wails 前端拿不到 server log
|
||
- 使用者看 log 必須自己去 `~/Library/Application Support/visiona-local/logs/`
|
||
|
||
#### 新方案:多重 writer + 環形緩衝
|
||
|
||
```go
|
||
type LogBuffer struct {
|
||
mu sync.Mutex
|
||
lines []LogLine // ring buffer,cap=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"` // 解析過的 level:info/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()` 改成:
|
||
|
||
```go
|
||
stdoutPipe, _ := cmd.StdoutPipe()
|
||
stderrPipe, _ := cmd.StderrPipe()
|
||
go a.logPump(stdoutPipe, "stdout", stdoutLog)
|
||
go a.logPump(stderrPipe, "stderr", stderrLog)
|
||
```
|
||
|
||
`logPump` 同時寫檔 + 寫 ring buffer + EventsEmit:
|
||
|
||
```go
|
||
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)
|
||
|
||
```javascript
|
||
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-batch(10ms window) |
|
||
| 持久化 | 仍寫 `logs/server.{stdout,stderr}.log` | 不改現況;控制台 log panel 只是即時視圖 |
|
||
| Log 檔 rotation | 暫不做(M1 就沒做)| 已在 `tray-and-lifecycle.md` 風險清單。建議跟這次重構一起做:按大小 rotate(10MB × 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 次失敗 → `reportFatal` → `os.Exit(1)` → 連 Wails app 一起死
|
||
|
||
#### 新方案:補齊 Start/Stop/Restart,彼此互斥
|
||
|
||
新增 bindings:
|
||
|
||
```go
|
||
// Wails bindings
|
||
func (a *App) StartServer() error // 若 Running,no-op
|
||
func (a *App) StopServer() error // 若 Stopped,no-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>)`,但若被別的程序搶了就用新 port;ipc-port 檔必須更新
|
||
3. **Python runtime 不重跑**:`a.pythonBin` / `a.pythonModeR` 已 resolved,Stop 後留著,Start 時直接重用(省 5-10s)
|
||
4. **Log buffer 不清空**:Stop 時只 reset `server:status` event,log panel 保留既往 log(加個分隔線)
|
||
|
||
#### Watch 行為變更(**重大修改**)
|
||
|
||
現在:
|
||
|
||
```go
|
||
if failures >= 3 {
|
||
a.reportFatal("server died", ...) // os.Exit(1) — 連 Wails app 一起死
|
||
return
|
||
}
|
||
```
|
||
|
||
新版:
|
||
|
||
```go
|
||
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「伺服器重啟中」 | ✅ 推薦,且這不是大工 |
|
||
| **R2:Wails 代理 proxy** | 讓 Wails app 自己起一個固定 port 做 reverse proxy,backend 切換時透明處理 | ❌ 成本太高,且 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:329` 有 `OpenBrowser` binding。實際定義在 `platform_darwin.go` / `platform_linux.go` / `platform_windows.go`:
|
||
|
||
- macOS:`exec.Command("open", url).Start()`
|
||
- Linux:`exec.Command("xdg-open", url).Start()`
|
||
- Windows:`rundll32 url.dll,FileProtocolHandler url` 或 `cmd /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.go` 的 `ResolveWithYTDLP` | <1KB | — | yt-dlp 呼叫點 |
|
||
| `server/internal/api/handlers/camera_handler.go` 的 `ytdlpHosts` / `classifyVideoURL` / `StartFromURL` | ~100 行 | — | URL classify + resolve |
|
||
| `server/internal/api/router.go` 的 `api.POST("/media/url", ...)` | 1 行 | — | endpoint |
|
||
| `server/internal/deps/checker.go` 的 `check("yt-dlp", ...)` | ~3 行 | — | deps 檢查 |
|
||
| `server/main.go` 的 yt-dlp PATH 注入註解 | — | — | 註解 |
|
||
| `frontend/src/components/camera/source-selector.tsx` 的 `videoMode === 'url'` 分支 | ~50 行 | — | UI |
|
||
| `frontend/src/stores/camera-store.ts` 的 `startFromUrl` | ~25 行 | — | store action |
|
||
| `frontend/src/lib/i18n/{zh-TW,en,types}.ts` 的 `camera.pasteUrl` / `urlPlaceholder` / `urlHelpText` 等 key | ~10 keys × 2 lang | — | i18n |
|
||
| payload-*.sh 中的 `vendor-ytdlp` 相關 stage 步驟 | — | M6-3 | 打包 |
|
||
| `Makefile` 的 `vendor-ytdlp` target | — | M6-2 | build |
|
||
| `server/internal/deps` 中 `VISIONA_BUNDLE_BIN_DIR` 對 yt-dlp 的偵測 | ~10 行 | M6-4 | env 注入 |
|
||
|
||
**總砍量**:
|
||
- Binary size:**~87MB**(dmg 從 220MB 降到 ~135MB)
|
||
- Source:~200 行 Go + ~100 行 TS/TSX + 10 幾個 i18n keys
|
||
|
||
#### 必須保留的
|
||
|
||
**ffmpeg 必須留**。新需求:「上傳影片 avi/mpeg/mp4」。這些格式的解碼靠 ffmpeg,Kneron 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...)
|
||
|
||
**Camera(webcam)pipeline**:也需要 ffmpeg(macOS 用 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` | 改寫成控制台 layout(status / 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 + ServerState;watchServer 不再 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 推薦 A1(vanilla 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>` + CSS;data 從 `GetServerStatus()` 輪詢(或更好:`EventsOn('server:status', ...)`)
|
||
- Action bar:5 顆 button,對應 5 個 bindings
|
||
- Log panel:`<pre class="log">` with auto-scroll + virtual scrolling(不需要第三方,~100 行 JS 手刻)
|
||
- Pause button:停止 auto-scroll(方便使用者檢查過去的 log)
|
||
- Filter:text input,client-side substring filter(M2 再加)
|
||
- 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` 明寫:
|
||
|
||
```go
|
||
args := []string{
|
||
"--host", "127.0.0.1",
|
||
...
|
||
}
|
||
```
|
||
|
||
**目前是強制 127.0.0.1**,瀏覽器只能從同一台機器連。
|
||
|
||
#### 新方向的安全情境
|
||
|
||
新方向把「可在瀏覽器使用」變成 first-class,會不會有人希望能從區網另一台電腦連?(例如:PM 在 Mac 上跑 visionA-local,工程師想從筆電瀏覽器連進來看推論結果)
|
||
|
||
**方案比較**:
|
||
|
||
| 方案 | 描述 | 風險 | 推薦 |
|
||
|------|------|------|------|
|
||
| **N1:維持 127.0.0.1,不提供選項** | 只能本機連 | 無新風險 | ✅ 預設 |
|
||
| **N2:Settings 新增「允許區網存取」toggle** | 切 `0.0.0.0` + firewall 提示 | 必須加 auth token,否則區網任何人都能控制裝置 / 上傳模型;macOS/Windows 會跳 firewall 警告 | ⚠️ 有需求再做 |
|
||
| **N3:LAN 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:維持砍 tray,Wails 視窗必須開著** | 現況 | 不用跨平台 tray 資產、不用處理 Wails systray 踩坑 | 使用者被迫開著一個沒人看的視窗;體驗倒退 | ❌ |
|
||
| **T2:復活 tray(macOS 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 only,macOS 本來就有 `runtime.WindowHide` 但 Dock 圖示仍在 | 不用 tray 資產 | Linux 下完全找不到那個視窗(沒有 Dock);Windows 下工作列會殘留空白按鈕 | ⚠️ 只做 macOS 可接受,跨平台不佳 |
|
||
| **T4:Wails app 當 daemon,Open 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](https://wails.io/docs/reference/runtime/systray)):~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 window,tray 還在 | tray > Quit / 視窗內 Quit 按鈕 / ⌘Q | **✅ 配套 T2 最順** |
|
||
| **Q7-B2:關視窗 = hide,無 tray**(配合 T3)| hide window,Dock/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 tab;upload 支援 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 test(macOS + 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 產生大量 log,Wails 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** | 是否復活 tray(Q-A 復議)| 維持砍(T1)/ 復活(T2)/ 只做 hide(T3)| **T2 復活** |
|
||
| **E-3** | 視窗關閉行為(Q7 復議)| 維持關=quit(B)/ 改為 hide-to-tray(B1)/ 確認對話框(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.1**(M1-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 下可能完全看不到 icon(StatusNotifierItem 協議在某些版本沒支援)| 🔴 高 | 加 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 token(POST /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 加 `ETag` 或 `Cache-Control: no-store`;或升級時改用新 boot-id,前端偵測到強制 reload |
|
||
| F-11 | macOS Gatekeeper:原本 Wails app 開瀏覽器 = shell out `open`,正常。但若加 tray,icon 檔可能觸發 code signing 問題 | 🟡 低 | icon 打在 binary 內,不走 shell out |
|
||
| F-12 | 使用者可能期待 Wails app 關掉 = server 也停(避免 USB 裝置被占用)| 🟠 中 | Quit 動作(tray > Quit 或 ⌘Q)要明確停 server;hide 不停;需要清楚的 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 + 網頁介面,像 Ollama(ollama serve + 網頁 / CLI client)」。這不只是包裝,是產品定位的實質變更
|
||
- **G-P3:使用者旅程重寫** — 從「打開 app → 在視窗內操作」變成「打開 app → 控制台確認 server 在跑 → 在瀏覽器操作」
|
||
- **G-P4:成功指標** — 現有 AC(「首次啟動 < 5 秒」「關視窗 = 結束」)有一半要改
|
||
- **G-P5:yt-dlp 砍功能的 user-facing 影響** — 有沒有 PM 認為「URL 推論」是需要保留的功能?若無,YouTube / Vimeo / RTSP 這些 URL 都不支援了,需不需要在 release note 明說
|
||
- **G-P6:ffmpeg LGPL 評估**要不要排 M8 milestone
|
||
- **G-P7:「預設模型只能用預設的幾種,其他只能上傳」** — 現況已如此(預置 + 使用者上傳),是確認還是有新要求?要不要 PM 確認
|
||
- **G-P8:法律** — 新方向下使用情境更窄,GPL 評估可能更好談;TOS/Privacy 要看是否有新的資料流動(實際上沒有)
|
||
|
||
### 給 Design
|
||
|
||
- **G-D1:Wails 控制台 UI 設計** — 雖然我推薦 vanilla JS 實作,但 layout、視覺語言、用字、深色淺色規範還是需要 Design 出稿
|
||
- **G-D2:tray 菜單 i18n + icon 設計** — 若 E-2 確認復活,tray icon 需要新做(至少 light/dark × 3 平台 × 2 狀態 = 12 張 icon)
|
||
- **G-D3:Wails 視窗關閉時的第一次提示 toast** — 「visionA-local 正在背景執行,可從 menu bar 找到」要怎麼寫、什麼時機
|
||
- **G-D4:Settings 新增「自動開瀏覽器」選項**的文案與位置
|
||
- **G-D5:Restart 期間瀏覽器 tab 的 skeleton state / loading overlay 設計**
|
||
- **G-D6:Workspace 的 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:更新 PRD(L 級文件更新)
|
||
- Design:出控制台 + tray 設計稿
|
||
- Architect:把這份筆記升級為正式的 Design Doc 更新 + TDD 補丁
|
||
- 一路走完三方交叉審閱後再進 M8 開發
|
||
|
||
---
|
||
|
||
**簽名**:Architect Agent,2026-04-14
|