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:
parent
3355a096b8
commit
9b0d946acd
@ -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>
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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: '內嵌(推薦)',
|
||||
|
||||
@ -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=<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) {
|
||||
}
|
||||
|
||||
// 解壓 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
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user