diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go
index 35434ee..c186525 100644
--- a/local-tool/visiona-local/app.go
+++ b/local-tool/visiona-local/app.go
@@ -188,7 +188,7 @@ func (a *App) startup(ctx context.Context) {
a.appLog("==================================================")
a.appLog("visionA-local startup build=%s buildTime=%s", appVersionString(), appBuildTimeString())
a.appLog("platform=%s arch=%s dataDir=%s", runtime.GOOS, runtime.GOARCH, dataDir)
- a.appLog("fix marker: 9c9e005+ (180s hard timeout + all-stage sub-step detail + Stage1 seed pause)")
+ a.appLog("fix marker: d946561+ (5min hard timeout + 5-stage UI + fullscreen splash)")
a.appLog("==================================================")
// M8-4:載入 preferences.json(讀取失敗 → 用 DefaultPreferences 預設)
diff --git a/local-tool/visiona-local/frontend/app.js b/local-tool/visiona-local/frontend/app.js
index df2776f..0a71513 100644
--- a/local-tool/visiona-local/frontend/app.js
+++ b/local-tool/visiona-local/frontend/app.js
@@ -31,6 +31,7 @@ import {
showToast,
initHeaderClock,
setPrimaryCTAPulse,
+ setWebUIStatus,
STATE_ERROR,
} from './control-panel.js';
import {
@@ -45,6 +46,7 @@ import {
showStartupError,
renderStages,
onManualModeChange,
+ onConnectionStatusChange,
} from './startup-panel.js';
import {
initLogPanel,
@@ -63,6 +65,14 @@ const state = {
starting: false, // 啟動進度面板是否顯示
};
+// ---------- Boot splash 控制 ----------
+// 全屏 spinner overlay。DOM ready 即顯示,收到第一個 startup:progress event
+// 或 init 完成後拉 snapshot 發現 pipeline 已啟動時 hide。
+function hideBootSplash() {
+ const splash = document.getElementById('boot-splash');
+ if (splash) splash.classList.add('hidden');
+}
+
// ---------- 初始化 ----------
async function init() {
// 1. 讀 preferences → 決定 locale
@@ -119,6 +129,7 @@ async function init() {
// pipeline 還在進行中或已完成 → 顯示 panel + 用 monotonic 模式
// 補上各 stage 狀態(已收到較新狀態的 stage 不會被倒退)
state.starting = true;
+ hideBootSplash();
showStartupPanel();
for (const s of snapshot.stages || []) {
updateStage(
@@ -168,6 +179,13 @@ async function init() {
onManualModeChange((enabled) => {
setPrimaryCTAPulse(enabled);
});
+
+ // 12. Web UI 連線指示燈:startup-panel 偵測到 stage 6 status 變化時通知
+ onConnectionStatusChange((status) => {
+ setWebUIStatus(status);
+ });
+ // 初始一次(pending)
+ setWebUIStatus('pending');
}
// ---------- 處理 server status ----------
@@ -178,6 +196,11 @@ function handleServerStatus(status) {
updateServerMeta(status);
updatePrimaryControls(status);
+ // 任何非 idle 狀態都代表 server 已經被 ServerController 接管 → hide splash
+ if (status.state && status.state !== 'idle') {
+ hideBootSplash();
+ }
+
// Error state runtime banner(非 startup error)
if (status.state === STATE_ERROR && status.lastError && !state.starting) {
showErrorBanner(status.lastError);
@@ -367,6 +390,8 @@ function subscribeEvents() {
state.starting = true;
showStartupPanel();
}
+ // 收到第一個 progress event 即 hide 全屏 splash
+ hideBootSplash();
updateStage(ev);
});
EventsOn('startup:stage-timeout', (ev) => {
@@ -377,9 +402,11 @@ function subscribeEvents() {
updateStageDetail(ev);
});
EventsOn('startup:error', (ev) => {
+ hideBootSplash();
showStartupError(ev);
});
EventsOn('startup:ready', () => {
+ hideBootSplash();
state.starting = false;
collapseStartupPanel();
});
diff --git a/local-tool/visiona-local/frontend/control-panel.js b/local-tool/visiona-local/frontend/control-panel.js
index b2a0a53..3a19bc4 100644
--- a/local-tool/visiona-local/frontend/control-panel.js
+++ b/local-tool/visiona-local/frontend/control-panel.js
@@ -47,6 +47,31 @@ export function updateServerMeta(status) {
// uptime 由 initHeaderClock 定時刷新
}
+// ---------- Web UI 連線指示燈(取代 stage 6 的 UI 顯示)----------
+// 由 startup-panel 的 connection listener 觸發,stage 6 status 變化時呼叫。
+// status 取值:'pending' | 'running' | 'completed' | 'done' | 'failed' | 'skipped'
+export function setWebUIStatus(status) {
+ const el = document.getElementById('meta-webui');
+ if (!el) return;
+ el.setAttribute('data-state', status || 'pending');
+ let textKey;
+ switch (status) {
+ case 'completed':
+ case 'done':
+ textKey = 'control.webui.connected';
+ break;
+ case 'running':
+ textKey = 'control.webui.waiting';
+ break;
+ case 'failed':
+ textKey = 'control.webui.disconnected';
+ break;
+ default:
+ textKey = 'control.webui.waiting';
+ }
+ el.textContent = t(textKey);
+}
+
export function initHeaderClock(getServer) {
if (headerClockTimer) clearInterval(headerClockTimer);
const uptimeEl = document.getElementById('meta-uptime');
diff --git a/local-tool/visiona-local/frontend/i18n.js b/local-tool/visiona-local/frontend/i18n.js
index 6d92620..9be9268 100644
--- a/local-tool/visiona-local/frontend/i18n.js
+++ b/local-tool/visiona-local/frontend/i18n.js
@@ -16,6 +16,10 @@ const dict = {
'control.meta.uptime': '執行時間',
'control.meta.pid': '程序 ID',
'control.meta.version': '版本',
+ 'control.meta.webui': 'Web UI',
+ 'control.webui.connected': '已連線',
+ 'control.webui.waiting': '等待連線',
+ 'control.webui.disconnected': '未連線',
'control.action.openBrowser': '在瀏覽器開啟',
'control.action.start': '啟動',
'control.action.stop': '停止',
@@ -96,7 +100,7 @@ const dict = {
'startup.status.skipped': '跳過(依偏好設定)',
'startup.timeout.message': '這個步驟花的時間比預期久,正在重試...',
'startup.error.title': '啟動失敗',
- 'startup.error.description.timeout': '啟動時間超過 180 秒,可能是系統環境異常或網路中斷。',
+ 'startup.error.description.timeout': '啟動時間超過 5 分鐘,可能是系統環境異常或網路中斷。',
'startup.error.description.stageFailed': '階段「{stageLabel}」執行失敗。',
'startup.error.failedStage': '失敗階段:{n} · {label}',
'startup.error.retry': '重試',
@@ -122,6 +126,10 @@ const dict = {
'control.meta.uptime': 'Uptime',
'control.meta.pid': 'PID',
'control.meta.version': 'Version',
+ 'control.meta.webui': 'Web UI',
+ 'control.webui.connected': 'Connected',
+ 'control.webui.waiting': 'Waiting',
+ 'control.webui.disconnected': 'Disconnected',
'control.action.openBrowser': 'Open in Browser',
'control.action.start': 'Start',
'control.action.stop': 'Stop',
@@ -202,7 +210,7 @@ const dict = {
'startup.status.skipped': 'Skipped (per preference)',
'startup.timeout.message': 'This step is taking longer than expected, retrying...',
'startup.error.title': 'Startup failed',
- 'startup.error.description.timeout': 'Startup exceeded 180 seconds. Your environment may have issues or the network is interrupted.',
+ 'startup.error.description.timeout': 'Startup exceeded 5 minutes. Your environment may have issues or the network is interrupted.',
'startup.error.description.stageFailed': 'Stage "{stageLabel}" failed.',
'startup.error.failedStage': 'Failed stage: {n} · {label}',
'startup.error.retry': 'Retry',
diff --git a/local-tool/visiona-local/frontend/index.html b/local-tool/visiona-local/frontend/index.html
index 291153a..1f2c4a4 100644
--- a/local-tool/visiona-local/frontend/index.html
+++ b/local-tool/visiona-local/frontend/index.html
@@ -8,6 +8,13 @@
+
+
+

+
+
啟動中...
+
diff --git a/local-tool/visiona-local/frontend/startup-panel.js b/local-tool/visiona-local/frontend/startup-panel.js
index 147fb96..75902ef 100644
--- a/local-tool/visiona-local/frontend/startup-panel.js
+++ b/local-tool/visiona-local/frontend/startup-panel.js
@@ -1,14 +1,23 @@
-// startup-panel.js — 6 階段啟動進度面板
+// startup-panel.js — 啟動進度面板
// 對齊 Design Spec v2.1 startup-progress.md
+//
+// Go 端 pipeline 是 6 階段,但前端 UI 只顯示 5 階段(Stage 1-5)。
+// Stage 6「等待 Web UI 連線」對使用者是技術細節,不該佔一個 step;
+// 它的狀態移到 header 連線指示燈(control-panel.js 那邊),此處只用
+// stage 6 status 控制 collapse 時機(stage 6 = completed 才整面板收合)。
import { t } from './i18n.js';
-const TOTAL_STAGES = 6;
+// UI 顯示的階段數(Stage 1-5)
+const TOTAL_STAGES = 5;
+// Go 端 pipeline 的真實階段數(包含 stage 6 隱藏 stage)
+const PIPELINE_STAGES = 6;
// 本地狀態:stages[1..6] = {status, startedAt, slow, manualHint, detail}
// detail: 從 startup:stage-detail event 來的 sub-step 提示,顯示在 stage-hint 欄位
+// stages[6] 仍存在但不顯示在 panel,只用來判斷整體 ready 時機
const stages = {};
-for (let i = 1; i <= TOTAL_STAGES; i++) {
+for (let i = 1; i <= PIPELINE_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
}
@@ -27,7 +36,21 @@ function emitManualMode(enabled) {
});
}
+// 連線狀態(stage 6 隱藏後新增的觀察者機制):stage 6 status 變化時
+// 通知 control-panel 的連線指示燈。
+const connectionListeners = new Set();
+export function onConnectionStatusChange(fn) {
+ connectionListeners.add(fn);
+ return () => connectionListeners.delete(fn);
+}
+function emitConnectionStatus(status) {
+ connectionListeners.forEach((fn) => {
+ try { fn(status); } catch (e) { console.warn('connection listener error:', e); }
+ });
+}
+
// ---------- 渲染 stage 列 skeleton ----------
+// 只 render TOTAL_STAGES (= 5) 個 stage,stage 6 在背景追蹤但不顯示
export function renderStages() {
const container = document.getElementById('stages');
if (!container) return;
@@ -123,6 +146,7 @@ function paintProgressBar() {
bar.innerHTML = '';
let completed = 0;
let current = 0;
+ // 只計算 UI 顯示的 5 個 stage,stage 6 在背景但不算進進度條
for (let i = 1; i <= TOTAL_STAGES; i++) {
const cell = document.createElement('span');
cell.className = 'progress-cell state-' + stages[i].status;
@@ -182,7 +206,7 @@ export function expandStartupPanel() {
// resetStartupPanel:重置內部狀態 + DOM render(給 Restart 用)
// 注意:不要呼叫 hide,因為使用者按 Restart 是想看新一輪啟動進度。
export function resetStartupPanel() {
- for (let i = 1; i <= TOTAL_STAGES; i++) {
+ for (let i = 1; i <= PIPELINE_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
}
if (manualMode) {
@@ -199,7 +223,7 @@ export function hideStartupPanel() {
if (!panel) return;
panel.setAttribute('hidden', '');
panel.removeAttribute('data-collapsed');
- for (let i = 1; i <= TOTAL_STAGES; i++) {
+ for (let i = 1; i <= PIPELINE_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
}
if (manualMode) {
@@ -225,10 +249,11 @@ function statusRank(s) {
// ---------- 更新階段(收到 startup:progress)----------
// monotonic = true 時不會用較舊的狀態覆蓋較新的(snapshot 補漏用)
+// stage 6 仍會被收到並更新內部 state(控制 collapse 時機),但不會 paint UI
export function updateStage(ev, opts = {}) {
if (!ev || !ev.stage) return;
const n = ev.stage;
- if (n < 1 || n > TOTAL_STAGES) return;
+ if (n < 1 || n > PIPELINE_STAGES) return;
const newStatus = ev.status || 'pending';
if (opts.monotonic && statusRank(newStatus) < statusRank(stages[n].status)) {
// snapshot 帶來的狀態比 event 流的狀態還舊,忽略
@@ -257,6 +282,14 @@ export function updateStage(ev, opts = {}) {
}
}
+ // 通知連線狀態指示燈聽眾(stage 6 status 變化時)
+ if (n === 6) {
+ emitConnectionStatus(stages[6].status);
+ }
+
+ // stage 6 不在 UI 上,跳過 paint
+ if (n > TOTAL_STAGES) return;
+
// running 狀態下,把其他仍 pending 的顯示維持
paintStageRow(n);
paintProgressBar();
diff --git a/local-tool/visiona-local/frontend/style.css b/local-tool/visiona-local/frontend/style.css
index 84f253c..852aa19 100644
--- a/local-tool/visiona-local/frontend/style.css
+++ b/local-tool/visiona-local/frontend/style.css
@@ -143,6 +143,31 @@ html, body {
.server-meta dt::after { content: ':'; margin-right: 2px; }
.server-meta dd { display: inline; margin: 0; font-variant-numeric: tabular-nums; }
+/* Web UI 連線指示燈:圓點 + 文字 */
+.webui-status {
+ display: inline-flex !important;
+ align-items: center;
+ gap: 4px;
+}
+.webui-status::before {
+ content: '';
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--fg-muted);
+}
+.webui-status[data-state="pending"]::before { background: var(--fg-muted); }
+.webui-status[data-state="running"]::before { background: var(--warning); animation: pulse 1.5s ease-in-out infinite; }
+.webui-status[data-state="connected"]::before,
+.webui-status[data-state="completed"]::before,
+.webui-status[data-state="done"]::before { background: var(--success); }
+.webui-status[data-state="failed"]::before { background: var(--destructive); }
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
.brand-version {
display: flex;
flex-direction: column;
@@ -629,6 +654,40 @@ html, body {
z-index: 100;
animation: fadeIn 150ms ease-out;
}
+/* Boot splash:app 啟動最一開始的全屏 spinner overlay。
+ * 預設 visible(DOM ready 即顯示),app.js init 完成 + 收到第一個
+ * startup:progress event 後加 hidden 隱藏。
+ * 不用 [hidden] 屬性,用 class .hidden 控制(避免被 [hidden]!important
+ * 蓋掉時的重複邏輯)。
+ */
+.boot-splash {
+ position: fixed;
+ inset: 0;
+ background: var(--bg);
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 24px;
+ animation: fadeIn 200ms ease-out;
+}
+.boot-splash.hidden {
+ display: none;
+}
+.boot-splash-logo {
+ width: 80px;
+ height: 80px;
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-md);
+}
+.boot-splash-text {
+ margin: 0;
+ font-size: 14px;
+ color: var(--fg-muted);
+ font-weight: 500;
+}
+
/* CSS specificity 修補:`.modal-backdrop` 的 `display: flex` 規則
* specificity = (0,1,0),和 user agent stylesheet 的 `[hidden] { display: none }`
* 相同,但因為寫在後面 cascade 勝出 → 結果是即使 div 有 `hidden` 屬性,
diff --git a/local-tool/visiona-local/startup_pipeline.go b/local-tool/visiona-local/startup_pipeline.go
index 49ffb95..2133cc3 100644
--- a/local-tool/visiona-local/startup_pipeline.go
+++ b/local-tool/visiona-local/startup_pipeline.go
@@ -38,15 +38,17 @@ import (
const (
startupTotalStages = 6
+ // 每階段 soft timeout 20 秒(用 wall clock 計時,不受 pause 影響)
+ // 觸發後 emit "startup:stage-timeout" 提示「正在重試」但不中斷流程。
+ // 這是「單一階段卡太久」的保護,搭配下方 hard timeout 兩層防線。
startupSoftTimeout = 20 * time.Second
- // startupHardTimeout 從 R5-E1 原定 60 秒放寬到 180 秒。理由:即使有
- // Stage 1 (seedUserDataDir) / Stage 2 (Python bootstrap) / Stage 3
- // (waitHealthy) 三段 pause 機制豁免,Windows 乾淨環境首次啟動仍可能在
- // 段落間(Defender 掃多個檔/EDR cloud lookup/段落間小工作)累積延遲,
- // 使用者體感「應該還在啟動但被當失敗」非常挫折。180 秒給意料之外的
- // 延遲足夠 buffer,搭配 pause 機制 + 細步進度 emit 涵蓋 99% 情境。
- // 日常啟動只要幾秒,放寬不影響正常情境(second launch 通常 < 5 秒)。
- startupHardTimeout = 180 * time.Second
+ // startupHardTimeout 從 R5-E1 原定 60 秒一路放寬到 300 秒(5 分鐘)。
+ // 理由:每階段已經有 soft timeout 提示機制(20 秒),整體 budget 不需
+ // 緊湊也能擋住真的卡死的情境。300 秒是「使用者點完一杯咖啡都還沒好」
+ // 的心理上限,這時候再 fail 才合理。
+ // pause 機制(Stage 1 seed / Stage 2 Python bootstrap / Stage 3 waitHealthy)
+ // 仍維持,作為「一次性 bootstrap 完全不算 budget」的快速通道。
+ startupHardTimeout = 300 * time.Second
startupWatcherTick = 1 * time.Second
)
diff --git a/local-tool/visiona-local/startup_pipeline_test.go b/local-tool/visiona-local/startup_pipeline_test.go
index 48993d7..25ef072 100644
--- a/local-tool/visiona-local/startup_pipeline_test.go
+++ b/local-tool/visiona-local/startup_pipeline_test.go
@@ -180,9 +180,9 @@ func TestStartupPipeline_Watcher_SoftTimeout(t *testing.T) {
func TestStartupPipeline_Watcher_HardTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
- // 模擬「總時已經 185 秒(超過 180 秒 hard timeout),當前在階段 3」
+ // 模擬「總時已經 305 秒(超過 300 秒 hard timeout),當前在階段 3」
now := time.Now()
- p.startedAt = now.Add(-185 * time.Second)
+ p.startedAt = now.Add(-305 * time.Second)
p.current = 3
p.stages[3].status = "running"
p.stages[3].startedAt = now.Add(-30 * time.Second)
@@ -263,7 +263,7 @@ func TestStartupPipeline_PauseHardTimeout_PreventsHardTimeout(t *testing.T) {
p := NewStartupPipeline(a)
// 模擬 wall clock 已過 300 秒,但其中 250 秒是「首次 bootstrap」暫停
- // effective = 50s < 180s hard timeout,pipeline 不該 fail
+ // effective = 50s < 300s hard timeout,pipeline 不該 fail
now := time.Now()
p.startedAt = now.Add(-300 * time.Second)
p.pausedDuration = 250 * time.Second
@@ -284,7 +284,7 @@ func TestStartupPipeline_PauseHardTimeout_PreventsHardTimeout(t *testing.T) {
p.mu.Unlock()
if cur == -1 {
- t.Fatal("pipeline failed due to hard timeout, but effective=50s should be under the 180s limit")
+ t.Fatal("pipeline failed due to hard timeout, but effective=50s should be under the 300s limit")
}
if status == "failed" {
t.Fatalf("stage 2 failed, want still running (effective time under limit)")
@@ -316,12 +316,12 @@ func TestStartupPipeline_Watcher_SkippedStageNoTimeout(t *testing.T) {
a.prefs.AutoOpenBrowser = false
p := NewStartupPipeline(a)
- // 階段 6 + AutoOpenBrowser=false:總時 200s(已超 180s hard timeout)也不該觸發
+ // 階段 6 + AutoOpenBrowser=false:總時 320s(已超 300s hard timeout)也不該觸發
now := time.Now()
- p.startedAt = now.Add(-200 * time.Second)
+ p.startedAt = now.Add(-320 * time.Second)
p.current = 6
p.stages[6].status = "running"
- p.stages[6].startedAt = now.Add(-200 * time.Second)
+ p.stages[6].startedAt = now.Add(-320 * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -349,10 +349,10 @@ func TestStartupPipeline_Watcher_SkippedStatusBypassesTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
- // 階段 5 已 skipped,總時 200s 不該觸發 hard timeout(skipped 跳過所有檢查)
+ // 階段 5 已 skipped,總時 320s 不該觸發 hard timeout(skipped 跳過所有檢查)
// 注意:skip 之後實際上 current 會是 6,但這裡測試的是 skip 狀態本身的 bypass 行為
now := time.Now()
- p.startedAt = now.Add(-200 * time.Second)
+ p.startedAt = now.Add(-320 * time.Second)
p.current = 5
p.stages[5].status = "skipped"
p.stages[5].startedAt = now.Add(-30 * time.Second)