diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index 1f386df..35434ee 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -466,6 +466,21 @@ func (a *App) GetServerStatus() ServerStatus { return st } +// GetStartupSnapshot 回傳 pipeline 當前所有 stages 狀態。 +// 前端 init 完成後呼叫一次,補上 Wails Webview JS load 完成前 Go 端已 emit +// 但被丟掉的 progress events,避免畫面顯示「Stage 1 等待中、Stage 2 完成」 +// 這種亂序。pipeline 還沒建立時回傳空 snapshot(current=0 stages=[])。 +func (a *App) GetStartupSnapshot() StartupSnapshot { + if a.startupPipeline == nil { + return StartupSnapshot{ + Current: 0, + TotalStages: 6, + Stages: []StartupStageSnapshot{}, + } + } + return a.startupPipeline.Snapshot() +} + // GetServerURL 回傳 server base URL(給前端 WebView 載入用)。 func (a *App) GetServerURL() string { a.mu.Lock() diff --git a/local-tool/visiona-local/frontend/app.js b/local-tool/visiona-local/frontend/app.js index 5429468..df2776f 100644 --- a/local-tool/visiona-local/frontend/app.js +++ b/local-tool/visiona-local/frontend/app.js @@ -8,6 +8,7 @@ import { RestartServer, ForceKillServer, GetServerStatusV2, + GetStartupSnapshot, GetRecentLogs, ClearLogs, GetSystemInfo, @@ -106,6 +107,35 @@ async function init() { // 7. 訂閱 Wails events subscribeEvents(); + // 7.5. 補上前端 init 完成前 Go 端已 emit 但被丟掉的 startup events。 + // Wails Webview JS load 完成需 1-3 秒(Windows 乾淨環境更慢),這段 + // 期間 Go 的 Pipeline.Start 已經跑完 emit Stage 1 running、可能也 + // 跑完 Stage 1 complete + Stage 2 running 等多個 events,前端 EventsOn + // 還沒掛上去就被丟掉。拉一次 snapshot 把這些 stage 狀態補回來, + // 避免 UI 顯示「Stage 1 等待中、Stage 2 完成」這種亂序畫面。 + try { + const snapshot = await GetStartupSnapshot(); + if (snapshot && snapshot.current > 0) { + // pipeline 還在進行中或已完成 → 顯示 panel + 用 monotonic 模式 + // 補上各 stage 狀態(已收到較新狀態的 stage 不會被倒退) + state.starting = true; + showStartupPanel(); + for (const s of snapshot.stages || []) { + updateStage( + { stage: s.stage, status: s.status, startedAt: s.startedAt }, + { monotonic: true }, + ); + } + // 已 ready (current=7) → 直接 collapse 面板 + if (snapshot.current > snapshot.totalStages) { + state.starting = false; + collapseStartupPanel(); + } + } + } catch (e) { + console.warn('GetStartupSnapshot failed:', e); + } + // 8. 初始 state query try { const status = await GetServerStatusV2(); diff --git a/local-tool/visiona-local/frontend/i18n.js b/local-tool/visiona-local/frontend/i18n.js index 80fa9ac..6d92620 100644 --- a/local-tool/visiona-local/frontend/i18n.js +++ b/local-tool/visiona-local/frontend/i18n.js @@ -68,7 +68,7 @@ const dict = { 'startup.stage.1.detail.lock': '建立 single-instance lock...', 'startup.stage.1.detail.ipc': '啟動 Wails IPC server...', 'startup.stage.1.detail.seed': '正在準備內建模型資料(首次啟動會花幾秒鐘)...', - 'startup.stage.1.detail.seedSlow': '正在準備內建模型資料(Windows Defender 掃描檔案中,已 {elapsed} 秒)', + 'startup.stage.1.detail.seedSlow': '正在準備內建模型資料(Windows Defender 掃描檔案中)...', // Stage 2 - 檢查 Python 執行環境 'startup.stage.2.detail.detect': '偵測系統 Python 執行環境...', 'startup.stage.2.detail.bootstrap': '正在解壓內建 Python runtime(首次啟動需 1-2 分鐘)...', @@ -77,8 +77,8 @@ const dict = { 'startup.stage.2.detail.driver': '正在安裝 Kneron USB 驅動程式(請點選 UAC 允許)...', // Stage 3 - 啟動本機伺服器 'startup.stage.3.detail.spawn': '正在啟動伺服器子程序...', - 'startup.stage.3.detail.waitHealth': '正在等待伺服器健康檢查通過(已等 {elapsed} 秒)', - 'startup.stage.3.detail.waitHealthSlow': '首次啟動較久屬正常,Windows Defender 掃描可能需 1-2 分鐘(已等 {elapsed} 秒)', + 'startup.stage.3.detail.waitHealth': '正在等待伺服器健康檢查通過...', + 'startup.stage.3.detail.waitHealthSlow': '首次啟動較久屬正常,Windows Defender 掃描可能需 1-2 分鐘...', // Stage 4 - 偵測 Kneron 裝置 'startup.stage.4.detail.probe': '正在掃描 USB 裝置...', // Stage 5 - 開啟瀏覽器 @@ -174,7 +174,7 @@ const dict = { 'startup.stage.1.detail.lock': 'Acquiring single-instance lock...', 'startup.stage.1.detail.ipc': 'Starting Wails IPC server...', 'startup.stage.1.detail.seed': 'Preparing built-in model data (takes a few seconds on first launch)...', - 'startup.stage.1.detail.seedSlow': 'Preparing built-in model data (Defender scanning files, {elapsed}s elapsed)', + 'startup.stage.1.detail.seedSlow': 'Preparing built-in model data (Defender scanning files)...', // Stage 2 'startup.stage.2.detail.detect': 'Detecting system Python runtime...', 'startup.stage.2.detail.bootstrap': 'Extracting bundled Python runtime (takes 1-2 min on first launch)...', @@ -183,8 +183,8 @@ const dict = { 'startup.stage.2.detail.driver': 'Installing Kneron USB driver (please allow UAC)...', // Stage 3 'startup.stage.3.detail.spawn': 'Launching server subprocess...', - 'startup.stage.3.detail.waitHealth': 'Waiting for server health check ({elapsed}s elapsed)', - 'startup.stage.3.detail.waitHealthSlow': 'First launch is slow — Windows Defender scan may take 1-2 minutes ({elapsed}s elapsed)', + 'startup.stage.3.detail.waitHealth': 'Waiting for server health check...', + 'startup.stage.3.detail.waitHealthSlow': 'First launch is slow — Windows Defender scan may take 1-2 minutes...', // Stage 4 'startup.stage.4.detail.probe': 'Scanning USB devices...', // Stage 5 diff --git a/local-tool/visiona-local/frontend/startup-panel.js b/local-tool/visiona-local/frontend/startup-panel.js index 0f4efa2..147fb96 100644 --- a/local-tool/visiona-local/frontend/startup-panel.js +++ b/local-tool/visiona-local/frontend/startup-panel.js @@ -131,16 +131,11 @@ function paintProgressBar() { if (stages[i].status === 'running') current = i; } const progressNum = current || completed; - // 若有 slow 狀態,顯示 elapsed - let elapsedText = ''; - for (let i = 1; i <= TOTAL_STAGES; i++) { - if (stages[i].slow && stages[i].status === 'running' && stages[i].startedAt) { - const elapsed = Math.floor((Date.now() - stages[i].startedAt) / 1000); - elapsedText = t('startup.progressWithElapsed', { current: progressNum, max: TOTAL_STAGES, elapsed }); - break; - } - } - textEl.textContent = elapsedText || t('startup.progressLabel', { current: progressNum, max: TOTAL_STAGES }); + // 不顯示已等待秒數 — pause 機制讓 elapsed 計算失準(pause 期間 wall clock + // 仍走但 startedAt 沒重設,會顯示明顯比真實還久的數字),且使用者覺得 + // 沒必要看秒數。直接顯示 progress N/M 即可,slow hint 改由 stage-hint + // 文案傳達。 + textEl.textContent = t('startup.progressLabel', { current: progressNum, max: TOTAL_STAGES }); // aria const panel = document.getElementById('startup-panel'); @@ -214,12 +209,32 @@ export function hideStartupPanel() { renderStages(); } +// 階段狀態優先級:愈大愈「前進」。回填 snapshot 時用來避免倒退覆蓋 +// 已收到的事件(race window 解法的一部分)。 +const STAGE_STATUS_RANK = { + pending: 0, + running: 1, + skipped: 2, + failed: 2, + completed: 3, + done: 3, +}; +function statusRank(s) { + return STAGE_STATUS_RANK[s] ?? 0; +} + // ---------- 更新階段(收到 startup:progress)---------- -export function updateStage(ev) { +// monotonic = true 時不會用較舊的狀態覆蓋較新的(snapshot 補漏用) +export function updateStage(ev, opts = {}) { if (!ev || !ev.stage) return; const n = ev.stage; if (n < 1 || n > TOTAL_STAGES) return; - stages[n].status = ev.status || 'pending'; + const newStatus = ev.status || 'pending'; + if (opts.monotonic && statusRank(newStatus) < statusRank(stages[n].status)) { + // snapshot 帶來的狀態比 event 流的狀態還舊,忽略 + return; + } + stages[n].status = newStatus; if (ev.startedAt) stages[n].startedAt = ev.startedAt; if (stages[n].status !== 'running') { stages[n].slow = false; diff --git a/local-tool/visiona-local/frontend/wailsjs/go/main/App.d.ts b/local-tool/visiona-local/frontend/wailsjs/go/main/App.d.ts index e0f7c84..c9e04bf 100755 --- a/local-tool/visiona-local/frontend/wailsjs/go/main/App.d.ts +++ b/local-tool/visiona-local/frontend/wailsjs/go/main/App.d.ts @@ -20,6 +20,8 @@ export function GetServerStatusV2():Promise; export function GetServerURL():Promise; +export function GetStartupSnapshot():Promise; + export function GetSystemInfo():Promise; export function InstallKneronDriver():Promise; 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 3983803..c4ab7d3 100755 --- a/local-tool/visiona-local/frontend/wailsjs/go/main/App.js +++ b/local-tool/visiona-local/frontend/wailsjs/go/main/App.js @@ -38,6 +38,10 @@ export function GetServerURL() { return window['go']['main']['App']['GetServerURL'](); } +export function GetStartupSnapshot() { + return window['go']['main']['App']['GetStartupSnapshot'](); +} + export function GetSystemInfo() { return window['go']['main']['App']['GetSystemInfo'](); } diff --git a/local-tool/visiona-local/frontend/wailsjs/go/models.ts b/local-tool/visiona-local/frontend/wailsjs/go/models.ts index 6a48c88..1642ab0 100755 --- a/local-tool/visiona-local/frontend/wailsjs/go/models.ts +++ b/local-tool/visiona-local/frontend/wailsjs/go/models.ts @@ -84,6 +84,57 @@ export namespace main { this.lastError = source["lastError"]; } } + export class StartupStageSnapshot { + stage: number; + status: string; + startedAt: number; + + static createFrom(source: any = {}) { + return new StartupStageSnapshot(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.stage = source["stage"]; + this.status = source["status"]; + this.startedAt = source["startedAt"]; + } + } + export class StartupSnapshot { + current: number; + totalStages: number; + stages: StartupStageSnapshot[]; + + static createFrom(source: any = {}) { + return new StartupSnapshot(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.current = source["current"]; + this.totalStages = source["totalStages"]; + this.stages = this.convertValues(source["stages"], StartupStageSnapshot); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class SystemInfo { appVersion: string; buildTime: string; diff --git a/local-tool/visiona-local/startup_pipeline.go b/local-tool/visiona-local/startup_pipeline.go index bd72e93..49ffb95 100644 --- a/local-tool/visiona-local/startup_pipeline.go +++ b/local-tool/visiona-local/startup_pipeline.go @@ -90,6 +90,22 @@ type StartupStageDetailEvent struct { ElapsedSeconds int `json:"elapsedSeconds"` // 0 時不顯示耗時 } +// StartupStageSnapshot 是單一 stage 的 snapshot(給前端追上歷史狀態用)。 +type StartupStageSnapshot struct { + Stage int `json:"stage"` + Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped" + StartedAt int64 `json:"startedAt"` +} + +// StartupSnapshot 是整個 pipeline 當前狀態的 snapshot。 +// 前端 init 完成後呼叫 GetStartupSnapshot() 拉一次,補上 race window 中漏掉的 +// progress events,避免畫面顯示「Stage 1 等待中、Stage 2 完成」這種亂序。 +type StartupSnapshot struct { + Current int `json:"current"` // -1 / 0 / 1-6 / 7 (ready) + TotalStages int `json:"totalStages"` // 固定 6 + Stages []StartupStageSnapshot `json:"stages"` // 1-indexed but slice 0-based (len=6) +} + // ----------------------------------------------------------------------- // stageState — 單一階段的內部狀態 // ----------------------------------------------------------------------- @@ -322,6 +338,28 @@ func (p *StartupPipeline) emitProgress(stage int) { }() } +// Snapshot 回傳 pipeline 當前所有 stages 狀態,供前端 init 完成後追上歷史。 +// 解 race:Wails app 啟動到前端 EventsOn 掛上去之間,Go 端可能已經 emit +// 多個 progress events 被丟掉。前端應該在 init 完成後呼叫一次此函式,把 +// 已經發生但前端漏掉的 stage 狀態補上。 +func (p *StartupPipeline) Snapshot() StartupSnapshot { + p.mu.Lock() + defer p.mu.Unlock() + stages := make([]StartupStageSnapshot, 0, startupTotalStages) + for i := 1; i <= startupTotalStages; i++ { + stages = append(stages, StartupStageSnapshot{ + Stage: i, + Status: p.stages[i].status, + StartedAt: p.stages[i].startedAt.UnixMilli(), + }) + } + return StartupSnapshot{ + Current: p.current, + TotalStages: startupTotalStages, + Stages: stages, + } +} + // EmitStageDetail 在 stage 進行中送一條細步提示文字到前端,讓使用者看到 // 「當下在做什麼」。不改 stage status,只更新 UI 上的 stage-hint 欄位。 //