From 9b0d946acd307b18fdfcc557ca70d613c83bcb5f Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Sun, 12 Apr 2026 08:42:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(local-tool):=20splash=20=E9=A1=AF=E7=A4=BA?= =?UTF-8?q?=E5=AF=A6=E6=99=82=E5=95=9F=E5=8B=95=E9=80=B2=E5=BA=A6=20+=20?= =?UTF-8?q?=E6=8B=89=E9=95=B7=20timeout=20+=20=E4=BF=AE=E6=AD=A3=E5=9F=B7?= =?UTF-8?q?=E8=A1=8C=E6=A8=A1=E5=BC=8F=E9=A1=AF=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splash 進度: - app.go 新增 bootstrapStatus field + GetBootstrapStatus() binding - 各 startup step 呼叫 setBootstrapStatus 更新文字: "正在初始化 Python 環境..." "正在解壓 Python runtime (~10 秒)..." "正在建立 Python 虛擬環境 (~5 秒)..." "正在安裝 N 個 Python 套件 (numpy / opencv / KneronPLUS ...) (~30-60 秒)..." "正在安裝 Kneron USB 驅動程式 (請在 UAC 視窗點「是」)..." "正在準備應用程式資料..." "正在啟動伺服器..." "等待伺服器就緒..." "載入主介面..." - visiona-local/frontend/app.js 每 400ms 呼叫 GetBootstrapStatus 更新畫面 - wailsjs/go/main/App.js 手動補上新 binding export(避免等 wails generate) Timeout: - splash MAX_WAIT_MS 60s → 240s(涵蓋 UAC 被拖延 + 慢速硬碟) - healthCheckTimeout 15s → 30s(server 首次啟動內部解析 + embed fs 載入) 設定 > 硬體 > 執行模式: - 顯示預設值從 mock 改為 real(跟 app.go 實際預設對齊 - Q8 決策) - 下拉選單寬度 240 → 420px 避免文字被截斷 - i18n 說明文字改為「預設為真實硬體模式,強制 Mock 請設 VISIONA_MOCK=1」 - 仍標 disabled — 未來 M8+ 會連 backend GET /api/system/config Co-Authored-By: Claude Opus 4.6 (1M context) --- local-tool/frontend/src/app/settings/page.tsx | 6 +-- local-tool/frontend/src/lib/i18n/en.ts | 6 +-- local-tool/frontend/src/lib/i18n/zh-TW.ts | 6 +-- local-tool/visiona-local/app.go | 37 ++++++++++++++++--- local-tool/visiona-local/frontend/app.js | 37 ++++++++++++++----- .../frontend/wailsjs/go/main/App.js | 8 ++++ 6 files changed, 77 insertions(+), 23 deletions(-) diff --git a/local-tool/frontend/src/app/settings/page.tsx b/local-tool/frontend/src/app/settings/page.tsx index dee6d95..6bc655a 100644 --- a/local-tool/frontend/src/app/settings/page.tsx +++ b/local-tool/frontend/src/app/settings/page.tsx @@ -135,9 +135,9 @@ export default function SettingsPage() {
- {/* TODO(M2+): wire up to backend mock/real toggle endpoint */} - + diff --git a/local-tool/frontend/src/lib/i18n/en.ts b/local-tool/frontend/src/lib/i18n/en.ts index 0c0be65..0932601 100644 --- a/local-tool/frontend/src/lib/i18n/en.ts +++ b/local-tool/frontend/src/lib/i18n/en.ts @@ -271,9 +271,9 @@ export const en: TranslationDict = { hardware: { title: 'Hardware', runtimeMode: 'Runtime Mode', - runtimeModeMock: 'Mock (no hardware required)', - runtimeModeReal: 'Real Hardware', - runtimeModeHint: 'Switching runtime mode requires restarting the server.', + runtimeModeMock: 'Mock (simulated devices, no hardware required — for development/testing)', + runtimeModeReal: 'Real Hardware (default — connects to actual Kneron devices)', + runtimeModeHint: 'Default is Real Hardware mode. To force Mock mode for development, set environment variable VISIONA_MOCK=1 before launching. Runtime switching will be available in a future release.', pythonMode: 'Python Runtime', pythonModeAuto: 'Auto (prefer bundled)', pythonModeBundled: 'Bundled (recommended)', diff --git a/local-tool/frontend/src/lib/i18n/zh-TW.ts b/local-tool/frontend/src/lib/i18n/zh-TW.ts index 621630d..cf19659 100644 --- a/local-tool/frontend/src/lib/i18n/zh-TW.ts +++ b/local-tool/frontend/src/lib/i18n/zh-TW.ts @@ -271,9 +271,9 @@ export const zhTW: TranslationDict = { hardware: { title: '硬體', runtimeMode: '執行模式', - runtimeModeMock: 'Mock(不需硬體)', - runtimeModeReal: '真實硬體', - runtimeModeHint: '切換執行模式需重啟伺服器才會生效。', + runtimeModeMock: 'Mock(模擬裝置,不需真實硬體 — 開發 / 測試用)', + runtimeModeReal: '真實硬體(預設 — 連接實體 Kneron 裝置)', + runtimeModeHint: '預設為真實硬體模式。若要強制使用 Mock 模式進行開發,啟動前設定環境變數 VISIONA_MOCK=1。未來版本會加入 UI 切換功能。', pythonMode: 'Python 執行模式', pythonModeAuto: '自動(優先內嵌)', pythonModeBundled: '內嵌(推薦)', diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index 97c10e3..a1bb812 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -42,7 +42,7 @@ import ( const ( defaultPreferredPort = 3721 portSearchRange = 20 - healthCheckTimeout = 15 * time.Second + healthCheckTimeout = 30 * time.Second shutdownGracePeriod = 5 * time.Second appName = "visiona-local" ) @@ -89,6 +89,9 @@ type App struct { lastError string releaseLock func() + // 啟動進度訊息 — 供 splash page 透過 GetBootstrapStatus() binding 輪詢顯示 + bootstrapStatus string + // L-1:server 健康偵測 goroutine 控制 watchCancel context.CancelFunc @@ -175,6 +178,7 @@ func (a *App) startup(ctx context.Context) { // 3.5. 首次啟動 seed:把 installer 內建的 models.json / nef 預置模型 / scripts // 複製到 user data-dir,讓 server 能在 --data-dir= 情境下讀到內建模型。 // 失敗不擋啟動,只是 server 啟動後模型庫會是空的。 + a.setBootstrapStatus("正在準備應用程式資料...") if err := a.seedUserDataDir(); err != nil { fmt.Fprintln(os.Stderr, "[visiona-local] seed user data dir failed:", err) } @@ -305,6 +309,22 @@ func (a *App) GetServerURL() string { return fmt.Sprintf("http://127.0.0.1:%d", a.server.port) } +// GetBootstrapStatus 回傳目前啟動階段的人類可讀文字(給 splash page 顯示進度)。 +// 空字串代表還沒設定或已完成。 +func (a *App) GetBootstrapStatus() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.bootstrapStatus +} + +// setBootstrapStatus 更新啟動階段文字。各 startup step 呼叫此函式。 +func (a *App) setBootstrapStatus(msg string) { + a.mu.Lock() + a.bootstrapStatus = msg + a.mu.Unlock() + a.appLog("bootstrap: %s", msg) +} + // OpenBrowser 用系統預設瀏覽器開啟 URL。 func (a *App) OpenBrowser(url string) error { return openBrowser(url) @@ -379,6 +399,7 @@ func (a *App) ensureDriverInstalled(pyBin string) error { return nil } + a.setBootstrapStatus("正在安裝 Kneron USB 驅動程式 (請在 UAC 視窗點「是」)...") a.appLog("首次啟動:自動安裝 Kneron WinUSB driver(會彈出 UAC 提權視窗,請點「是」)") if err := installKneronWinUSBDriver(pyBin); err != nil { a.appLog("driver 自動安裝失敗(非致命):%v", err) @@ -386,6 +407,7 @@ func (a *App) ensureDriverInstalled(pyBin string) error { } a.markDriverInstalled() a.appLog("driver 自動安裝完成,記號檔已建立:%s", marker) + a.setBootstrapStatus("Kneron USB 驅動程式安裝完成") return nil } @@ -402,6 +424,7 @@ func (a *App) markDriverInstalled() { func (a *App) startServer() error { // 1. 決定 python runtime + a.setBootstrapStatus("正在初始化 Python 環境...") pyBin, pyMode, err := a.ensurePythonRuntime(a.pythonMode) if err != nil && !a.mockMode { // Mock 模式下沒有 python 仍可啟動(server 不 spawn sidecar) @@ -421,6 +444,8 @@ func (a *App) startServer() error { } } + a.setBootstrapStatus("正在啟動伺服器...") + // 2. 找 port port, err := pickPort(defaultPreferredPort) if err != nil { @@ -508,6 +533,7 @@ func (a *App) startServer() error { } // 7. 等 health check(成功後才寫 ipc-port,避免把「預期 port」寫進檔案誤導) + a.setBootstrapStatus("等待伺服器就緒...") if err := waitHealthy(port, healthCheckTimeout); err != nil { proc.kill() removeIPCPort(a.dataDir) @@ -517,6 +543,7 @@ func (a *App) startServer() error { // 8. 寫 ipc-port 檔(給 single-instance raise 用)— // 此時 server 已確認在 listen,寫下去的就是「實際可連線」的 port。 writeIPCPort(a.dataDir, port) + a.setBootstrapStatus("就緒,載入主介面...") a.mu.Lock() a.server = proc @@ -743,7 +770,7 @@ func (a *App) ensureBundledPython() (string, error) { } // 解壓 tarball(strip-components=1 剝掉 "python/" 前綴) - fmt.Fprintln(os.Stderr, "[visiona-local] extracting bundled python runtime...") + a.setBootstrapStatus("正在解壓 Python runtime (~10 秒)...") extract := exec.Command("tar", "-xzf", pyTarball, "-C", pyHome, "--strip-components=1") configureSysProcAttr(extract) if out, err := extract.CombinedOutput(); err != nil { @@ -758,7 +785,7 @@ func (a *App) ensureBundledPython() (string, error) { return "", fmt.Errorf("embedded python not found after extract: %w", err) } - fmt.Fprintln(os.Stderr, "[visiona-local] creating venv at", venvPath) + a.setBootstrapStatus("正在建立 Python 虛擬環境 (~5 秒)...") venvCmd := exec.Command(embeddedPython, "-m", "venv", venvPath) configureSysProcAttr(venvCmd) if out, err := venvCmd.CombinedOutput(); err != nil { @@ -780,7 +807,7 @@ func (a *App) ensureBundledPython() (string, error) { return pythonBin, nil } - fmt.Fprintf(os.Stderr, "[visiona-local] offline pip install %d wheels...\n", len(wheels)) + a.setBootstrapStatus(fmt.Sprintf("正在安裝 %d 個 Python 套件 (numpy / opencv / KneronPLUS ...) (~30-60 秒)...", len(wheels))) args := []string{"-m", "pip", "install", "--no-index", "--find-links", wheelsDir, "--prefer-binary"} args = append(args, wheels...) pipCmd := exec.Command(pythonBin, args...) @@ -789,7 +816,7 @@ func (a *App) ensureBundledPython() (string, error) { return "", fmt.Errorf("pip install wheels: %w\n%s", err, string(out)) } - fmt.Fprintln(os.Stderr, "[visiona-local] bundled python runtime ready:", pythonBin) + a.setBootstrapStatus("Python 環境就緒") return pythonBin, nil } diff --git a/local-tool/visiona-local/frontend/app.js b/local-tool/visiona-local/frontend/app.js index 04043d9..e4d1cf6 100644 --- a/local-tool/visiona-local/frontend/app.js +++ b/local-tool/visiona-local/frontend/app.js @@ -1,41 +1,60 @@ // visionA Local — splash / bootstrap -// 職責:等 Go server 起來,拿到 URL 後跳轉到 Next.js 主 UI +// 職責:顯示 app 啟動進度 → server 就緒後跳轉到 Next.js 主 UI -import { GetServerStatus, GetServerURL } from './wailsjs/go/main/App.js'; +import { GetServerStatus, GetServerURL, GetBootstrapStatus } from './wailsjs/go/main/App.js'; const statusEl = document.getElementById('status'); const errorEl = document.getElementById('error'); -const POLL_INTERVAL_MS = 500; -const MAX_WAIT_MS = 60_000; +const POLL_INTERVAL_MS = 400; +// 首次啟動最長容忍時間:venv 解壓(10s) + 建 venv(5s) + pip install wheels(30-60s) + +// libwdi driver install with UAC(15-30s) + server spawn(3s) + health check(2s) ≈ 60-110s +// 給到 240 秒以涵蓋慢速硬碟 / UAC 被使用者拖延的情況 +const MAX_WAIT_MS = 240_000; const startTime = Date.now(); +let lastStatus = ''; + async function poll() { - if (Date.now() - startTime > MAX_WAIT_MS) { - showError('伺服器啟動逾時(60 秒),請檢查 log 或重新啟動應用程式。'); + const elapsed = Date.now() - startTime; + if (elapsed > MAX_WAIT_MS) { + showError( + `啟動逾時(${Math.round(MAX_WAIT_MS / 1000)} 秒)。\n` + + '請關閉此視窗並重新啟動應用程式,或查看 log:\n' + + '%APPDATA%\\visiona-local\\logs\\wails.log' + ); return; } try { + // 先更新 bootstrap 進度文字(venv / pip / driver / server...) + const bootstrapMsg = await GetBootstrapStatus(); + if (bootstrapMsg && bootstrapMsg !== lastStatus) { + statusEl.textContent = bootstrapMsg; + lastStatus = bootstrapMsg; + } + + // 檢查 server 是否已就緒 const status = await GetServerStatus(); if (status && status.lastError) { showError('伺服器啟動失敗:' + status.lastError); return; } if (status && status.running && status.url) { - statusEl.textContent = '啟動完成,載入主介面...'; + statusEl.textContent = '載入主介面...'; window.location.replace(status.url + '/'); return; } + // 備用:直接問 URL const url = await GetServerURL(); if (url) { - statusEl.textContent = '啟動完成,載入主介面...'; + statusEl.textContent = '載入主介面...'; window.location.replace(url + '/'); return; } } catch (e) { - // binding 尚未就緒時會 throw,忽略繼續輪詢 + // binding 尚未就緒時會 throw,繼續輪詢 } setTimeout(poll, POLL_INTERVAL_MS); diff --git a/local-tool/visiona-local/frontend/wailsjs/go/main/App.js b/local-tool/visiona-local/frontend/wailsjs/go/main/App.js index 3a2254a..c6f35b6 100755 --- a/local-tool/visiona-local/frontend/wailsjs/go/main/App.js +++ b/local-tool/visiona-local/frontend/wailsjs/go/main/App.js @@ -10,6 +10,14 @@ export function GetServerURL() { return window['go']['main']['App']['GetServerURL'](); } +export function GetBootstrapStatus() { + return window['go']['main']['App']['GetBootstrapStatus'](); +} + +export function InstallKneronDriver() { + return window['go']['main']['App']['InstallKneronDriver'](); +} + export function OpenBrowser(arg1) { return window['go']['main']['App']['OpenBrowser'](arg1); }