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:
parent
ff5cab6b0e
commit
d946561362
@ -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()
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -20,6 +20,8 @@ export function GetServerStatusV2():Promise<main.ServerStatusV2>;
|
||||
|
||||
export function GetServerURL():Promise<string>;
|
||||
|
||||
export function GetStartupSnapshot():Promise<main.StartupSnapshot>;
|
||||
|
||||
export function GetSystemInfo():Promise<main.SystemInfo>;
|
||||
|
||||
export function InstallKneronDriver():Promise<void>;
|
||||
|
||||
@ -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']();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 欄位。
|
||||
//
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user