fix(local-tool): Stage 順序亂跳修復 + 移除秒數顯示

兩個問題一次修:

1. Stage 順序亂跳 — 「Stage 1 等待中、Stage 2 完成、Stage 3 進行中」
   根因:Wails Webview JS load 需 1-3 秒(Windows 乾淨環境更慢),這段
   期間 Go 的 Pipeline.Start 已經 emit Stage 1 running event 甚至跑完
   Stage 1 / Stage 2,但前端 EventsOn 還沒掛上去,events 全被丟掉。前端
   接到的第一個 event 可能是 Stage 2 completed 或 Stage 3 running,
   stages[1].status 仍是初始 pending 值,UI 顯示亂序。

   修法:
   - 新增 Go binding GetStartupSnapshot() 回傳 pipeline 當前所有 stages
     狀態(含 current / startedAt / status)。
   - 前端 init 流程在 subscribeEvents 後立即拉一次 snapshot,補上漏掉
     的 stage 狀態。
   - updateStage 加 monotonic 模式:snapshot 補漏時不會用較舊狀態覆蓋
     已收到的較新狀態(避免 race condition 倒退)。
   - status 優先級 STAGE_STATUS_RANK = pending<running<{skipped,failed}<completed

2. 進度條已等待秒數顯示錯誤 — 「進度 3 / 6 · 已等待 20 秒」
   根因:pause 機制讓 elapsed 計算失準(pause 期間 wall clock 仍走但
   stages[i].startedAt 沒重設,會顯示明顯比真實還久的數字)。使用者
   覺得不需要顯示秒數。
   修法:
   - paintProgressBar 移除 elapsedText 邏輯,永遠顯示 progressLabel
   - i18n 文案移除 {elapsed} placeholder(zh-TW + en):
     stage.1.detail.seedSlow / stage.3.detail.waitHealth /
     stage.3.detail.waitHealthSlow 都改為固定文案
   - Go 端 emit 仍會傳 elapsed(waitProgress callback 不變),但前端
     i18n template 不再用該變數,自然就不顯示

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK
- Wails bindings 自動 regen 含 GetStartupSnapshot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-16 01:14:21 +08:00
parent ff5cab6b0e
commit d946561362
8 changed files with 173 additions and 18 deletions

View File

@ -466,6 +466,21 @@ func (a *App) GetServerStatus() ServerStatus {
return st return st
} }
// GetStartupSnapshot 回傳 pipeline 當前所有 stages 狀態。
// 前端 init 完成後呼叫一次,補上 Wails Webview JS load 完成前 Go 端已 emit
// 但被丟掉的 progress events避免畫面顯示「Stage 1 等待中、Stage 2 完成」
// 這種亂序。pipeline 還沒建立時回傳空 snapshotcurrent=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 載入用)。 // GetServerURL 回傳 server base URL給前端 WebView 載入用)。
func (a *App) GetServerURL() string { func (a *App) GetServerURL() string {
a.mu.Lock() a.mu.Lock()

View File

@ -8,6 +8,7 @@ import {
RestartServer, RestartServer,
ForceKillServer, ForceKillServer,
GetServerStatusV2, GetServerStatusV2,
GetStartupSnapshot,
GetRecentLogs, GetRecentLogs,
ClearLogs, ClearLogs,
GetSystemInfo, GetSystemInfo,
@ -106,6 +107,35 @@ async function init() {
// 7. 訂閱 Wails events // 7. 訂閱 Wails events
subscribeEvents(); 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 // 8. 初始 state query
try { try {
const status = await GetServerStatusV2(); const status = await GetServerStatusV2();

View File

@ -68,7 +68,7 @@ const dict = {
'startup.stage.1.detail.lock': '建立 single-instance lock...', 'startup.stage.1.detail.lock': '建立 single-instance lock...',
'startup.stage.1.detail.ipc': '啟動 Wails IPC server...', 'startup.stage.1.detail.ipc': '啟動 Wails IPC server...',
'startup.stage.1.detail.seed': '正在準備內建模型資料(首次啟動會花幾秒鐘)...', 'startup.stage.1.detail.seed': '正在準備內建模型資料(首次啟動會花幾秒鐘)...',
'startup.stage.1.detail.seedSlow': '正在準備內建模型資料Windows Defender 掃描檔案中,已 {elapsed} 秒', 'startup.stage.1.detail.seedSlow': '正在準備內建模型資料Windows Defender 掃描檔案中...',
// Stage 2 - 檢查 Python 執行環境 // Stage 2 - 檢查 Python 執行環境
'startup.stage.2.detail.detect': '偵測系統 Python 執行環境...', 'startup.stage.2.detail.detect': '偵測系統 Python 執行環境...',
'startup.stage.2.detail.bootstrap': '正在解壓內建 Python runtime首次啟動需 1-2 分鐘)...', 'startup.stage.2.detail.bootstrap': '正在解壓內建 Python runtime首次啟動需 1-2 分鐘)...',
@ -77,8 +77,8 @@ const dict = {
'startup.stage.2.detail.driver': '正在安裝 Kneron USB 驅動程式(請點選 UAC 允許)...', 'startup.stage.2.detail.driver': '正在安裝 Kneron USB 驅動程式(請點選 UAC 允許)...',
// Stage 3 - 啟動本機伺服器 // Stage 3 - 啟動本機伺服器
'startup.stage.3.detail.spawn': '正在啟動伺服器子程序...', 'startup.stage.3.detail.spawn': '正在啟動伺服器子程序...',
'startup.stage.3.detail.waitHealth': '正在等待伺服器健康檢查通過(已等 {elapsed} 秒)', 'startup.stage.3.detail.waitHealth': '正在等待伺服器健康檢查通過...',
'startup.stage.3.detail.waitHealthSlow': '首次啟動較久屬正常Windows Defender 掃描可能需 1-2 分鐘(已等 {elapsed} 秒)', 'startup.stage.3.detail.waitHealthSlow': '首次啟動較久屬正常Windows Defender 掃描可能需 1-2 分鐘...',
// Stage 4 - 偵測 Kneron 裝置 // Stage 4 - 偵測 Kneron 裝置
'startup.stage.4.detail.probe': '正在掃描 USB 裝置...', 'startup.stage.4.detail.probe': '正在掃描 USB 裝置...',
// Stage 5 - 開啟瀏覽器 // Stage 5 - 開啟瀏覽器
@ -174,7 +174,7 @@ const dict = {
'startup.stage.1.detail.lock': 'Acquiring single-instance lock...', 'startup.stage.1.detail.lock': 'Acquiring single-instance lock...',
'startup.stage.1.detail.ipc': 'Starting Wails IPC server...', '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.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 // Stage 2
'startup.stage.2.detail.detect': 'Detecting system Python runtime...', '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)...', '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)...', 'startup.stage.2.detail.driver': 'Installing Kneron USB driver (please allow UAC)...',
// Stage 3 // Stage 3
'startup.stage.3.detail.spawn': 'Launching server subprocess...', '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.waitHealth': 'Waiting for server health check...',
'startup.stage.3.detail.waitHealthSlow': 'First launch is slow — Windows Defender scan may take 1-2 minutes ({elapsed}s elapsed)', 'startup.stage.3.detail.waitHealthSlow': 'First launch is slow — Windows Defender scan may take 1-2 minutes...',
// Stage 4 // Stage 4
'startup.stage.4.detail.probe': 'Scanning USB devices...', 'startup.stage.4.detail.probe': 'Scanning USB devices...',
// Stage 5 // Stage 5

View File

@ -131,16 +131,11 @@ function paintProgressBar() {
if (stages[i].status === 'running') current = i; if (stages[i].status === 'running') current = i;
} }
const progressNum = current || completed; const progressNum = current || completed;
// 若有 slow 狀態,顯示 elapsed // 不顯示已等待秒數 — pause 機制讓 elapsed 計算失準pause 期間 wall clock
let elapsedText = ''; // 仍走但 startedAt 沒重設,會顯示明顯比真實還久的數字),且使用者覺得
for (let i = 1; i <= TOTAL_STAGES; i++) { // 沒必要看秒數。直接顯示 progress N/M 即可slow hint 改由 stage-hint
if (stages[i].slow && stages[i].status === 'running' && stages[i].startedAt) { // 文案傳達。
const elapsed = Math.floor((Date.now() - stages[i].startedAt) / 1000); textEl.textContent = t('startup.progressLabel', { current: progressNum, max: TOTAL_STAGES });
elapsedText = t('startup.progressWithElapsed', { current: progressNum, max: TOTAL_STAGES, elapsed });
break;
}
}
textEl.textContent = elapsedText || t('startup.progressLabel', { current: progressNum, max: TOTAL_STAGES });
// aria // aria
const panel = document.getElementById('startup-panel'); const panel = document.getElementById('startup-panel');
@ -214,12 +209,32 @@ export function hideStartupPanel() {
renderStages(); 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---------- // ---------- 更新階段(收到 startup:progress----------
export function updateStage(ev) { // monotonic = true 時不會用較舊的狀態覆蓋較新的snapshot 補漏用)
export function updateStage(ev, opts = {}) {
if (!ev || !ev.stage) return; if (!ev || !ev.stage) return;
const n = ev.stage; const n = ev.stage;
if (n < 1 || n > TOTAL_STAGES) return; 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 (ev.startedAt) stages[n].startedAt = ev.startedAt;
if (stages[n].status !== 'running') { if (stages[n].status !== 'running') {
stages[n].slow = false; stages[n].slow = false;

View File

@ -20,6 +20,8 @@ export function GetServerStatusV2():Promise<main.ServerStatusV2>;
export function GetServerURL():Promise<string>; export function GetServerURL():Promise<string>;
export function GetStartupSnapshot():Promise<main.StartupSnapshot>;
export function GetSystemInfo():Promise<main.SystemInfo>; export function GetSystemInfo():Promise<main.SystemInfo>;
export function InstallKneronDriver():Promise<void>; export function InstallKneronDriver():Promise<void>;

View File

@ -38,6 +38,10 @@ export function GetServerURL() {
return window['go']['main']['App']['GetServerURL'](); return window['go']['main']['App']['GetServerURL']();
} }
export function GetStartupSnapshot() {
return window['go']['main']['App']['GetStartupSnapshot']();
}
export function GetSystemInfo() { export function GetSystemInfo() {
return window['go']['main']['App']['GetSystemInfo'](); return window['go']['main']['App']['GetSystemInfo']();
} }

View File

@ -84,6 +84,57 @@ export namespace main {
this.lastError = source["lastError"]; 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 { export class SystemInfo {
appVersion: string; appVersion: string;
buildTime: string; buildTime: string;

View File

@ -90,6 +90,22 @@ type StartupStageDetailEvent struct {
ElapsedSeconds int `json:"elapsedSeconds"` // 0 時不顯示耗時 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 — 單一階段的內部狀態 // stageState — 單一階段的內部狀態
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -322,6 +338,28 @@ func (p *StartupPipeline) emitProgress(stage int) {
}() }()
} }
// Snapshot 回傳 pipeline 當前所有 stages 狀態,供前端 init 完成後追上歷史。
// 解 raceWails 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 進行中送一條細步提示文字到前端,讓使用者看到 // EmitStageDetail 在 stage 進行中送一條細步提示文字到前端,讓使用者看到
// 「當下在做什麼」。不改 stage status只更新 UI 上的 stage-hint 欄位。 // 「當下在做什麼」。不改 stage status只更新 UI 上的 stage-hint 欄位。
// //