feat(local-tool): splash 顯示實時啟動進度 + 拉長 timeout + 修正執行模式顯示

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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-12 08:42:59 +08:00
parent 3355a096b8
commit 9b0d946acd
6 changed files with 77 additions and 23 deletions

View File

@ -135,9 +135,9 @@ export default function SettingsPage() {
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>{t('settings.hardware.runtimeMode')}</Label>
{/* TODO(M2+): wire up to backend mock/real toggle endpoint */}
<Select value="mock" disabled>
<SelectTrigger className="w-60">
{/* TODO: 連接 backend GET /api/system/config 讀實際 mode現階段只顯示預設值 real */}
<Select value="real" disabled>
<SelectTrigger className="w-[420px]">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@ -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)',

View File

@ -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: '內嵌(推薦)',

View File

@ -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-1server 健康偵測 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=<user> 情境下讀到內建模型。
// 失敗不擋啟動,只是 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) {
}
// 解壓 tarballstrip-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
}

View File

@ -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);

View File

@ -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);
}